fix(plugin-sdk): make require('trek-plugin-sdk') actually resolve everywhere

A freshly scaffolded plugin could not load anywhere: the npm package is
ESM-only (no require condition in its exports map), so the scaffold's
require('trek-plugin-sdk') threw ERR_PACKAGE_PATH_NOT_EXPORTED under
`trek-plugin dev` - and the runtime injection the wiki promised for the
plugin child never existed, so a packed plugin (node_modules stripped)
crashed with MODULE_NOT_FOUND after a real install.

- plugin child: inject a frozen {definePlugin, PLUGIN_API_VERSION} shim
  for require('trek-plugin-sdk'); subpaths fail with a pointed error
- trek-plugin dev: inject the exact same shim, so a fresh scaffold runs
  with zero npm install and dev parity with production holds
- npm package: ship a real CommonJS build (dist/cjs + require export
  conditions) so the installed package also requires cleanly on Node 18+
- create: scaffold a package.json (type commonjs, SDK as devDependency,
  npx scripts); print resolvable `npx trek-plugin-sdk ...` hints
- wiki: package.json in the scaffold tree + publishing checklist, and
  document the zero-install dev flow
This commit is contained in:
Maurice
2026-07-04 19:57:43 +02:00
committed by Maurice
parent 8b801af94a
commit d110f8f0ce
12 changed files with 185 additions and 14 deletions
+10 -4
View File
@@ -1,6 +1,6 @@
{
"name": "trek-plugin-sdk",
"version": "1.2.0",
"version": "1.2.1",
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
"type": "module",
"license": "MIT",
@@ -13,8 +13,14 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./testing": { "types": "./dist/mock-host.d.ts", "import": "./dist/mock-host.js" }
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" }
},
"./testing": {
"import": { "types": "./dist/mock-host.d.ts", "default": "./dist/mock-host.js" },
"require": { "types": "./dist/cjs/mock-host.d.ts", "default": "./dist/cjs/mock-host.js" }
}
},
"bin": {
"trek-plugin-sdk": "./dist/cli/trek-plugin.js",
@@ -23,7 +29,7 @@
},
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finish-cjs.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"prepublishOnly": "npm run build && npm test"
+9
View File
@@ -0,0 +1,9 @@
// The package root says `"type": "module"`, so the CommonJS second build in
// dist/cjs needs its own scope marker or Node would parse those files as ESM.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'cjs');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), '{"type":"commonjs"}\n');
+23 -1
View File
@@ -7,6 +7,17 @@
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { createRequire } from 'node:module';
/** This package's own version, for the scaffold's devDependency range. */
function sdkVersionRange(): string {
try {
const pkg = createRequire(import.meta.url)('../../package.json') as { version?: string };
return pkg.version ? `^${pkg.version}` : '^1';
} catch {
return '^1';
}
}
export interface ScaffoldOptions {
author?: string;
@@ -42,6 +53,17 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS);
fs.writeFileSync(path.join(root, 'README.md'), readme(name));
// `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK);
// the SDK is a devDependency ONLY (types + mock host) — at runtime both the
// dev server and TREK inject it, so it is never vendored into the artifact.
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({
name,
version: '1.0.0',
private: true,
type: 'commonjs',
scripts: { dev: 'npx -y trek-plugin-sdk dev', pack: 'npx -y trek-plugin-sdk pack' },
devDependencies: { 'trek-plugin-sdk': sdkVersionRange() },
}, null, 2) + '\n');
if (type !== 'integration') {
fs.mkdirSync(path.join(root, 'client'), { recursive: true });
fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML);
@@ -162,7 +184,7 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) {
}
try {
scaffold(name, type, process.cwd());
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin validate ${name}\`.`);
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`);
} catch (e) {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
+30
View File
@@ -13,6 +13,7 @@ import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import { createRequire } from 'node:module';
import * as sdk from '../index.js';
interface Fixtures {
config?: Record<string, unknown>;
@@ -92,6 +93,34 @@ function createDevContext(id: string, grants: Set<string>, fx: Fixtures, db: Plu
};
}
/**
* Serve `require('trek-plugin-sdk')` from THIS package, exactly like the TREK
* child process does at runtime — so `dev` works on a fresh scaffold with no
* npm install, and what loads here is what loads in production. That parity is
* the point: the injected surface is the SAME minimal frozen shim the prod
* child serves (definePlugin + PLUGIN_API_VERSION), and subpaths fail with the
* same pointed error. validateManifest/createMockHost stay available where they
* belong — in your tests, which load the installed package without this patch.
*/
let sdkInjected = false;
export function installSdkInjection(): void {
if (sdkInjected) return;
const nodeModule = createRequire(import.meta.url)('node:module') as {
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
};
const realLoad = nodeModule._load;
if (typeof realLoad !== 'function') return;
sdkInjected = true;
const shim = Object.freeze({ definePlugin: sdk.definePlugin, PLUGIN_API_VERSION: sdk.PLUGIN_API_VERSION });
nodeModule._load = function (request: string, parent: unknown, isMain: boolean): unknown {
if (request === 'trek-plugin-sdk') return shim;
if (request.startsWith('trek-plugin-sdk/')) {
throw new Error(`${request} is a build/test-time module — only 'trek-plugin-sdk' itself is injected inside TREK`);
}
return realLoad.call(this, request, parent, isMain);
};
}
function loadFixtures(dir: string): Fixtures {
const p = path.join(dir, 'dev-fixtures.json');
if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } }
@@ -120,6 +149,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
const fx = loadFixtures(abs);
const { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite'));
const broadcasts: unknown[] = [];
installSdkInjection();
const req = createRequire(path.join(abs, 'server', 'index.js'));
let plugin: PluginLike = {};
+6 -6
View File
@@ -67,14 +67,14 @@ async function main(): Promise<void> {
const name = pos[0];
if (!name || flags.interactive) {
const created = await interactiveScaffold(process.cwd(), name);
console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`trek-plugin dev ${created}\`.`);
console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${created}\`.`);
return;
}
scaffold(name, flags.type || 'integration', process.cwd(), {
author: flags.author, description: flags.description,
permissions: flags.permissions ? flags.permissions.split(/[\s,]+/).filter(Boolean) : undefined,
});
console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`trek-plugin dev ${name}\`.`);
console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`);
} else if (cmd === 'dev') {
await runDev(pos[0] || '.', { port: flags.port ? Number(flags.port) : undefined });
} else if (cmd === 'validate') {
@@ -90,17 +90,17 @@ async function main(): Promise<void> {
console.log(`Packed ${r.files.length} files -> ${path.relative(process.cwd(), r.artifact) || r.artifact}`);
for (const f of r.files) console.log(' ' + f);
console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`);
console.log('\nUpload this plugin.zip to your release, then run `trek-plugin entry` to generate the registry entry.');
console.log('\nUpload this plugin.zip to your release, then run `npx trek-plugin-sdk entry` to generate the registry entry.');
}
} else if (cmd === 'keygen') {
const keyPath = flags.key || defaultKeyPath();
const { publicKey } = generateKeypair(keyPath);
console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`);
console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`);
console.log('\nSign releases with `trek-plugin release --sign` (or `entry --sign`).');
console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).');
} else if (cmd === 'sign') {
const zip = pos[0] || 'plugin.zip';
if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`trek-plugin pack\` first`);
if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`npx trek-plugin-sdk pack\` first`);
const key = loadPrivateKey(flags.key || defaultKeyPath());
const buf = fs.readFileSync(zip);
console.log(`signature: ${signArtifact(buf, key)}`);
@@ -162,7 +162,7 @@ async function main(): Promise<void> {
console.error(`Creating GitHub release ${flags.tag} on ${flags.repo}…`);
execFileSync('gh', ['release', 'create', flags.tag, packed.artifact, '--repo', flags.repo, '--title', flags.tag, '--notes', flags.notes || `Release ${flags.tag}`], { stdio: 'inherit' });
const entry = buildEntry({ dir, repo: flags.repo, tag: flags.tag, zipPath: packed.artifact, commit: flags.commit, mergePath: flags.merge, signKeyPath: signKey(), now: new Date().toISOString() });
console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `trek-plugin submit`):\n');
console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n');
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
} else {
console.error('usage: trek-plugin <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release|publish> [...]');
+35
View File
@@ -105,6 +105,15 @@ describe('scaffold + validate CLIs', () => {
expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template
});
it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => {
scaffold('my-widget', 'widget', tmp);
const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8'));
expect(pkg.type).toBe('commonjs');
expect(pkg.private).toBe(true);
expect(pkg.devDependencies['trek-plugin-sdk']).toMatch(/^\^\d/);
expect(pkg.dependencies).toBeUndefined(); // runtime deps are the author's call; the SDK never is one
});
it('applies author, description, and permissions from options', () => {
scaffold('opt-plug', 'integration', tmp, { author: 'Jane', description: 'Does X', permissions: ['db:own', 'db:read:trips'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'opt-plug', 'trek-plugin.json'), 'utf8'));
@@ -122,6 +131,32 @@ describe('scaffold + validate CLIs', () => {
});
});
describe('dev-server SDK injection', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'inject-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('makes require(trek-plugin-sdk) resolve from the package itself — no npm install', async () => {
const { installSdkInjection } = await import('../src/cli/dev.js');
const { createRequire } = await import('node:module');
installSdkInjection();
// A scaffold-shaped CJS entry that requires the SDK without any node_modules.
// The injected surface must MATCH the prod child shim: definePlugin +
// PLUGIN_API_VERSION only, subpaths throw the same pointed error.
fs.writeFileSync(path.join(tmp, 'entry.cjs'),
"const sdkShim = require('trek-plugin-sdk');\n" +
"let testingError = '';\n" +
"try { require('trek-plugin-sdk/testing'); } catch (e) { testingError = e.message; }\n" +
'module.exports = { def: sdkShim.definePlugin({ routes: [] }), api: sdkShim.PLUGIN_API_VERSION, keys: Object.keys(sdkShim).sort(), testingError };\n');
const req = createRequire(path.join(tmp, 'entry.cjs'));
const mod = req(path.join(tmp, 'entry.cjs')) as { def: unknown; api: number; keys: string[]; testingError: string };
expect(mod.def).toEqual({ routes: [] });
expect(mod.api).toBe(1);
expect(mod.keys).toEqual(['PLUGIN_API_VERSION', 'definePlugin']); // prod parity — no dev-only extras
expect(mod.testingError).toMatch(/build\/test-time/);
});
});
describe('reference plugin (examples/koffi)', () => {
const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist/cjs"
},
"include": ["src/index.ts", "src/manifest.ts", "src/mock-host.ts"]
}
@@ -15,7 +15,7 @@ import net from 'node:net';
import dns from 'node:dns';
import dgram from 'node:dgram';
import { createRequire } from 'node:module';
import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk';
import { createPluginContext, definePlugin, PLUGIN_API_VERSION, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk';
import { isBlockedIp, makeHostAllow, classifyConnect, dgramSendTarget, dgramConnectTarget } from './egress-policy';
import type { Envelope, RpcError } from '../protocol/envelope';
@@ -47,12 +47,37 @@ const transport: ChildTransport = {
let def: PluginDefinition | null = null;
let ctx: PluginContext | null = null;
/**
* Make `require('trek-plugin-sdk')` resolve inside the child WITHOUT the plugin
* vendoring the package: the shim below is served from memory for every require
* of that name, anywhere in the plugin's module graph. This is what lets `pack`
* strip node_modules and still keep the scaffold's
* `const { definePlugin } = require('trek-plugin-sdk')` working in production.
* Subpaths (`trek-plugin-sdk/testing`) are build/test-time tools and fail with a
* pointed message instead of a confusing MODULE_NOT_FOUND.
*/
function installSdkInjection(requirePlugin: NodeJS.Require): void {
const nodeModule = requirePlugin('node:module') as {
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
};
const realLoad = nodeModule._load;
const shim = Object.freeze({ definePlugin, PLUGIN_API_VERSION });
nodeModule._load = function (request: string, parent: unknown, isMain: boolean): unknown {
if (request === 'trek-plugin-sdk') return shim;
if (request.startsWith('trek-plugin-sdk/')) {
throw new Error(`${request} is a build/test-time module — only 'trek-plugin-sdk' itself is injected inside TREK`);
}
return realLoad.call(this, request, parent, isMain);
};
}
async function boot(config: Record<string, unknown>): Promise<void> {
try {
// createRequire works whether this bootstrap runs as CJS (prod dist) or ESM
// (tsx in tests), so `require` being undefined in ESM never bites us.
const entry = path.join(pluginDir, 'server', 'index.js');
const requirePlugin = createRequire(entry);
installSdkInjection(requirePlugin);
const mod = requirePlugin(entry);
def = mod && mod.default ? (mod.default as PluginDefinition) : (mod as PluginDefinition);
ctx = createPluginContext(pluginId, config, transport);
@@ -8,6 +8,9 @@
* child holds no db handle, no secrets, no network by default.
*/
/** Mirrors the published package's constant — bumped on any breaking API change. */
export const PLUGIN_API_VERSION = 1 as const;
export interface PluginContext {
readonly id: string;
readonly config: Readonly<Record<string, unknown>>;
@@ -92,6 +92,33 @@ describe('PluginSupervisor — isolated runtime', () => {
expect(events.some((e) => e.topic === '__log')).toBe(true);
});
it('injects require(trek-plugin-sdk) — a scaffold-style plugin loads with no node_modules', async () => {
const events: Array<{ topic: string; data: unknown }> = [];
sup = makeSupervisor(events);
writePlugin(
'scaffolded',
`const { definePlugin, PLUGIN_API_VERSION } = require('trek-plugin-sdk');
let testingError = '';
try { require('trek-plugin-sdk/testing'); } catch (e) { testingError = e.message; }
module.exports = definePlugin({
async onLoad() {
process.send({ k: 'evt', topic: 'diag', data: { api: PLUGIN_API_VERSION, fn: typeof definePlugin, testingError } });
},
routes: [{ method: 'GET', path: '/hello', auth: true, async handler() { return { status: 200 }; } }],
});`,
);
await sup.activate('scaffolded', new Set(['db:own']), {});
expect(sup.isActive('scaffolded')).toBe(true);
const diag = events.find((e) => e.topic === 'diag')?.data as { api: number; fn: string; testingError: string };
expect(diag).toBeTruthy();
expect(diag.api).toBe(1); // the injected shim, not a vendored copy
expect(diag.fn).toBe('function');
expect(diag.testingError).toMatch(/build\/test-time/); // subpaths fail with a pointed message
});
it('a plugin that throws in onLoad fails activation and is marked error — the host survives', async () => {
const events: Array<{ topic: string; data: unknown }> = [];
sup = makeSupervisor(events);
+6 -2
View File
@@ -22,6 +22,7 @@ This emits:
```
my-plugin/
trek-plugin.json # manifest
package.json # CommonJS marker + the SDK as a devDependency
server/index.js # your plugin code (built, plain JS)
client/index.html # page/widget iframe (page/widget only)
README.md # fill this in — the registry requires a screenshot
@@ -33,8 +34,11 @@ my-plugin/
npx trek-plugin-sdk dev # http://localhost:4317
```
`dev` loads your `server/index.js` through the same `definePlugin` contract the
host uses and gives you a **real request loop without a full TREK**: a dashboard
`dev` works straight after `create` — no `npm install` needed, because it
injects `require('trek-plugin-sdk')` from the CLI itself, exactly like TREK
injects it in production. It loads your `server/index.js` through the same
`definePlugin` contract the host uses and gives you a **real request loop
without a full TREK**: a dashboard
listing your routes, the routes served under `/api/<path>`, your page/widget UI
at `/ui`, and a reload on every save. The injected `ctx` **enforces exactly the
permissions your manifest grants** — an ungranted call throws `PERMISSION_DENIED`,
+1
View File
@@ -38,6 +38,7 @@ npx trek-plugin-sdk create flight-tracker --type widget # integration | page |
A publishable plugin has, at the repo root:
- `trek-plugin.json` — the manifest (see [[Plugin Development|Plugin-Development]])
- `package.json` — the CommonJS marker (`"type": "commonjs"`), with the SDK as a devDependency at most
- `server/index.js` — the built server entry (required)
- `client/` — the built frontend (only for `page`/`widget` plugins)
- `README.md` — filled in, with a real screenshot (the quality gate is strict — see below)