diff --git a/package-lock.json b/package-lock.json index adeec1a7b..f8fec7311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.8", + "version": "1.0.9", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index d3bb9201e..c43f64552 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.9 + +### Added + +- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/.js` +- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests + +### Fixed + +- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled + ## 1.0.8 ### Added diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 82586569b..9c87ac136 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.8 | Plugin API 1.2.0 +SDK 1.0.9 | Plugin API 1.2.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -334,8 +334,27 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 }); ### Shared packages These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, -`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every -Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled. +`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in +`peerDependencies`. Any other npm package is bundled. + +### Workers and WebAssembly + +Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name +each worker entry in `package.json`: + +```json +"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } } +``` + +`build` bundles it alone into `dist/workers/engine.js`; start it with +`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and +can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with +`postMessage`. + +Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships +inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module` +and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }` +to a `.d.ts` in the plugin. ## Database @@ -683,20 +702,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie ## termix-plugin CLI -| Command | What it does | -| ------------------------------ | ---------------------------------------------------------------------------------------------- | -| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` | -| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` | -| `test` | Apply patches, run Vitest | -| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing | -| `openapi` | Write `dist/openapi.json` from `@openapi` blocks | -| `pack` | Validate, then write `-.tmxplug`. `--out ` | -| `sign ` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` | -| `verify --key ` | Check a `.tmxplug` against its `.sig` | -| `keygen` | Write a new signing key pair. `--out ` | -| `changelog` | Print one version's notes. `--version x.y.z` | -| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) | -| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` | +| Command | What it does | +| ------------------------------ | ----------------------------------------------------------------------------------------------- | +| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` | +| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` | +| `test` | Apply patches, run Vitest | +| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing | +| `openapi` | Write `dist/openapi.json` from `@openapi` blocks | +| `pack` | Validate, then write `-.tmxplug`. `--out ` | +| `sign ` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` | +| `verify --key ` | Check a `.tmxplug` against its `.sig` | +| `keygen` | Write a new signing key pair. `--out ` | +| `changelog` | Print one version's notes. `--version x.y.z` | +| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) | +| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` | ## SDK entry points diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 690762389..3d84a2eff 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -52,6 +52,43 @@ export function externalRequireInterop(externals) { }; } +// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle, +// so it ships in backend.js or a worker with no separate file to find. +export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"]; +const BINARY_LOADERS = Object.fromEntries( + BINARY_EXTENSIONS.map((ext) => [ext, "binary"]), +); + +const REQUIRE_BANNER = + "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);"; + +/** + * package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } } + * bundles each entry on its own into dist/workers/.js, for + * new Worker(new URL("./workers/.js", import.meta.url)). Nothing is left + * external: core's import fallback only runs on the main thread, so a worker + * can't rely on host packages and must not import the SDK. + */ +export function readWorkerEntries(cwd, pluginId) { + const pkgPath = path.join(cwd, "package.json"); + const pkg = fs.existsSync(pkgPath) + ? JSON.parse(fs.readFileSync(pkgPath, "utf8")) + : {}; + const workers = pkg.termix?.workers ?? {}; + return Object.entries(workers).map(([name, entry]) => { + if (!/^[a-z0-9-]+$/.test(name)) { + throw new Error(`${pluginId}: worker name "${name}" must be lowercase`); + } + const file = path.resolve(cwd, String(entry)); + if (!fs.existsSync(file)) { + throw new Error( + `${pluginId}: worker "${name}" entry ${entry} is missing`, + ); + } + return { name, file }; + }); +} + const SDK_PATCHES = path.join( path.dirname(fileURLToPath(import.meta.url)), "..", @@ -216,13 +253,27 @@ export async function build({ cwd }) { // node_modules import instead, resolved at runtime like a host-provided // package. external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])], + loader: BINARY_LOADERS, // Bundled CJS deps still call require() for Node builtins at runtime. // ESM has no ambient require, so give esbuild's require shim a real one. - banner: { - js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);", - }, + banner: { js: REQUIRE_BANNER }, }); + for (const worker of readWorkerEntries(cwd, pluginId)) { + await esbuild.build({ + entryPoints: [worker.file], + outfile: path.join(outDir, "workers", `${worker.name}.js`), + bundle: true, + format: "esm", + platform: "node", + target: "node22", + sourcemap: true, + logLevel: "warning", + loader: BINARY_LOADERS, + banner: { js: REQUIRE_BANNER }, + }); + } + // Without this Node finds the host's typeless package.json, tries backend.js // as CommonJS first and warns before reparsing it as ESM. fs.writeFileSync( diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 77c7a5548..80be2f786 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.8", + "version": "1.0.9", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts index 2f3fe8ba6..6bfd03b43 100644 --- a/packages/plugin-sdk/src/vitest-preset.ts +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -30,6 +30,25 @@ function findRepoRoot(startDir: string): string | null { } } +/** + * termix-plugin build turns a .wasm or font import into its bytes. Tests get the same, + * so code that imports a module compiles it the same way under vitest. + */ +export function wasmBinaryPlugin() { + return { + name: "termix-wasm-binary", + enforce: "pre" as const, + load(id: string) { + const file = id.split("?")[0]; + if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) { + return null; + } + const base64 = fs.readFileSync(file).toString("base64"); + return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`; + }, + }; +} + export interface PluginVitestOptions { /** Extra aliases merged over the defaults. */ alias?: Record; @@ -96,7 +115,10 @@ export function pluginVitestConfig( // unexpectedly". Threads don't. const pool = "threads" as const; + const plugins = [wasmBinaryPlugin()]; + return { + plugins, resolve: { alias }, test: { globals: true, @@ -104,6 +126,7 @@ export function pluginVitestConfig( pool, projects: [ { + plugins, resolve: { alias }, test: { name: `${pluginId}:backend`, @@ -116,6 +139,7 @@ export function pluginVitestConfig( }, }, { + plugins, resolve: { alias }, test: { name: `${pluginId}:frontend`, diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index e4b1caa96..ec71c9361 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -92,6 +92,74 @@ describe("termix-plugin build", () => { }); }); +describe("termix-plugin build workers and wasm", () => { + // The smallest valid module: magic and version, no sections. + const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]); + + it("inlines a .wasm import as its bytes", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM); + fs.writeFileSync( + path.join(dir, "src", "backend", "index.ts"), + 'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n', + ); + + await build({ cwd: dir }); + + const mod = await import( + pathToFileURL(path.join(dir, "dist", "backend.js")).href + ); + expect(mod.valid).toBe(true); + }); + + it("bundles each worker entry on its own into dist/workers", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }), + ); + const dep = path.join(dir, "node_modules", "worker-dep"); + fs.mkdirSync(dep, { recursive: true }); + fs.writeFileSync( + path.join(dep, "package.json"), + JSON.stringify({ name: "worker-dep", main: "index.js" }), + ); + fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n"); + fs.writeFileSync( + path.join(dir, "src", "worker.ts"), + 'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n', + ); + + await build({ cwd: dir }); + + const file = path.join(dir, "dist", "workers", "engine.js"); + expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"'); + const { Worker } = await import("node:worker_threads"); + const answer = await new Promise((resolve, reject) => { + const worker = new Worker(file); + worker.once("message", (value) => { + void worker.terminate(); + resolve(value); + }); + worker.once("error", reject); + }); + expect(answer).toBe(42); + }); + + it("refuses a worker whose entry is missing", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }), + ); + + await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/); + }); +}); + describe("plugin CSS layers", () => { it("renames the Tailwind layers so core's utilities always win", () => { expect(