mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e809aa760 | ||
|
|
7066215bd9 | ||
|
|
214372d104 | ||
|
|
bb9bca5303 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -18,6 +18,8 @@
|
||||
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
Generated
+1
-1
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.10",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.10
|
||||
|
||||
### Changed
|
||||
|
||||
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
|
||||
|
||||
## 1.0.9
|
||||
|
||||
### Added
|
||||
|
||||
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.8 | Plugin API 1.2.0
|
||||
SDK 1.0.10 | 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.
|
||||
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
|
||||
npm run typecheck # tsc
|
||||
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
|
||||
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
|
||||
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
|
||||
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
|
||||
npm run format # Prettier
|
||||
npm run format:check
|
||||
```
|
||||
@@ -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 `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||
| `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 `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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/<name>.js, for
|
||||
* new Worker(new URL("./workers/<name>.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(
|
||||
|
||||
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"icon.svg",
|
||||
// Licence texts, so third-party notices travel with the plugin.
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
];
|
||||
|
||||
const BLOCK = 512;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.10",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -46,6 +46,8 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
|
||||
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "README.md", "# Fixture\n");
|
||||
writeFile(dir, "icon.svg", "<svg/>\n");
|
||||
writeFile(dir, "LICENSE", "Apache-2.0\n");
|
||||
writeFile(dir, "NOTICE", "Third-party notices\n");
|
||||
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
|
||||
return dir;
|
||||
}
|
||||
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
|
||||
|
||||
const names = await entries(file);
|
||||
expect(names).toEqual([
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"README.md",
|
||||
"dist/backend.js",
|
||||
"dist/frontend.js",
|
||||
|
||||
Reference in new issue
Block a user