feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

This commit is contained in:
LukeGus committed 2026-10-06 15:55:00 -05:00
1 parent 49fc241e06
commit ec80a8ef8d
24 files changed
+1848 -44

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
/**
* IPC between the backend and scripts/dev.mjs, which forks it with
* TERMIX_DEV_RELOAD=true. The runner asks for a plugin to be loaded again
* after it rebuilt and copied it into the bundled folder.
*/
import { pluginLogger } from "../utils/logger.js";
interface ReloadRequest {
type: "plugin-reload";
id: string;
}
type Send = (message: unknown) => void;
type Reload = (id: string) => Promise<{ version: string; state: string }>;
function isReloadRequest(msg: unknown): msg is ReloadRequest {
return (
!!msg &&
typeof msg === "object" &&
(msg as { type?: unknown }).type === "plugin-reload" &&
typeof (msg as { id?: unknown }).id === "string"
);
}
export function isDevReloadEnabled(): boolean {
return (
process.env.TERMIX_DEV_RELOAD === "true" &&
typeof process.send === "function"
);
}
export async function handleDevMessage(
msg: unknown,
send: Send,
reload: Reload,
): Promise<void> {
if (!isReloadRequest(msg)) return;
try {
const result = await reload(msg.id);
pluginLogger.info(`Reloaded ${msg.id}@${result.version}`, {
operation: "plugin_dev_reload",
});
send({ type: "plugin-reloaded", id: msg.id, state: result.state });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.error(`Could not reload ${msg.id}`, error, {
operation: "plugin_dev_reload",
});
send({ type: "plugin-reload-failed", id: msg.id, error: message });
}
}
export function listenForDevReloads(): void {
if (!isDevReloadEnabled()) return;
process.on("message", (msg: unknown) => {
void handleDevMessage(
msg,
(reply) => process.send?.(reply),
async (id) => {
const { reloadBundledPlugin } = await import("./manage.js");
return reloadBundledPlugin(id);
},
);
});
}
+38
View File
@@ -1290,6 +1290,44 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
});
}
/** Test seam. */
export function resetUploads(): void {
uploads.clear();
+9 -1
View File
@@ -283,7 +283,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
});
// Log output can be filtered by the configured level or split across chunks.
if (process.env.ELECTRON_EMBEDDED === "true") {
if (
process.env.ELECTRON_EMBEDDED === "true" ||
process.env.TERMIX_DEV_RELOAD === "true"
) {
process.send?.({ type: "backend-ready" });
}
@@ -328,6 +331,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
}
});
if (process.env.TERMIX_DEV_RELOAD === "true") {
const { listenForDevReloads } = await import("./plugins/dev-reload.js");
listenForDevReloads();
}
// A single bad request must not take the server down. Exit only on errors
// that leave the process genuinely unusable; log and keep serving
// otherwise, since these are almost always scoped to one connection.
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { info: vi.fn(), error: vi.fn() },
}));
const { handleDevMessage } = await import("../../plugins/dev-reload.js");
describe("dev reload messages", () => {
it("reloads the plugin and answers with its state", async () => {
const send = vi.fn();
const reload = vi.fn(async () => ({ version: "1.0.0", state: "active" }));
await handleDevMessage(
{ type: "plugin-reload", id: "snippets" },
send,
reload,
);
expect(reload).toHaveBeenCalledWith("snippets");
expect(send).toHaveBeenCalledWith({
type: "plugin-reloaded",
id: "snippets",
state: "active",
});
});
it("answers with the error when the reload fails", async () => {
const send = vi.fn();
await handleDevMessage(
{ type: "plugin-reload", id: "snippets" },
send,
async () => {
throw new Error("bad manifest");
},
);
expect(send).toHaveBeenCalledWith({
type: "plugin-reload-failed",
id: "snippets",
error: "bad manifest",
});
});
it("ignores other messages", async () => {
const send = vi.fn();
const reload = vi.fn();
await handleDevMessage({ type: "shutdown" }, send, reload);
await handleDevMessage({ type: "plugin-reload" }, send, reload);
await handleDevMessage(null, send, reload);
expect(reload).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
});
+40
View File
@@ -445,6 +445,46 @@ describe("dependencies", () => {
});
});
describe("reloading a bundled plugin for the dev runner", () => {
it("loads the rebuilt copy and restarts what depends on it", async () => {
createFixturePlugin({ id: "base", root: bundled });
createFixturePlugin({
id: "addon",
root: bundled,
manifestOverrides: { dependencies: { base: "^1.0.0" } },
});
const loader = await boot();
createFixturePlugin({
id: "base",
root: bundled,
manifestOverrides: { version: "1.1.0" },
});
const result = await manage.reloadBundledPlugin("base");
expect(result).toEqual({ id: "base", version: "1.1.0", state: "active" });
expect(loader.get("base")?.manifest.version).toBe("1.1.0");
expect(loader.get("addon")?.state).toBe("active");
expect(db.rows.get("base")?.version).toBe("1.1.0");
});
it("loads and starts a plugin added since boot", async () => {
const loader = await boot();
createFixturePlugin({ id: "fresh", root: bundled });
const result = await manage.reloadBundledPlugin("fresh");
expect(result.state).toBe("active");
expect(loader.get("fresh")?.state).toBe("active");
});
it("keeps a disabled plugin stopped", async () => {
createFixturePlugin({ id: "base", root: bundled });
const loader = await boot();
await manage.setPluginState("base", false);
const result = await manage.reloadBundledPlugin("base");
expect(result.state).toBe("stopped");
expect(loader.get("base")?.state).toBe("loaded");
});
});
describe("consent", () => {
it("refuses an install without the consented list, before any download", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];