diff --git a/cli/gonext/cmd/plugin/init.go b/cli/gonext/cmd/plugin/init.go index 5db1fb73..c52d9e01 100644 --- a/cli/gonext/cmd/plugin/init.go +++ b/cli/gonext/cmd/plugin/init.go @@ -40,7 +40,7 @@ func runInit(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, initUsage) } - template := fs.String("template", "go", "template name (go|rust)") + template := fs.String("template", "go", "template name (go|rust|typescript)") pluginName := fs.String("name", "", "plugin slug for the manifest (default: project dir basename)") force := fs.Bool("force", false, "overwrite existing files at the target") @@ -74,10 +74,10 @@ func runInit(args []string, stdout, stderr io.Writer) int { } switch *template { - case "go", "rust": + case "go", "rust", "typescript": // supported default: - fmt.Fprintf(stderr, "gonext plugin init: unknown template %q (supported: go, rust)\n", *template) + fmt.Fprintf(stderr, "gonext plugin init: unknown template %q (supported: go, rust, typescript)\n", *template) return ExitUsage } @@ -108,6 +108,16 @@ func runInit(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, " cd "+projectDir) fmt.Fprintln(stdout, " cargo build --target wasm32-wasip1 --release") fmt.Fprintln(stdout, " make bundle # packs the .gnplugin ZIP") + case "typescript": + if err := writeTemplateTypeScript(projectDir, slug, *force); err != nil { + fmt.Fprintf(stderr, "gonext plugin init: %s\n", err) + return ExitFail + } + fmt.Fprintf(stdout, "Initialized GoNext TypeScript plugin in %s\n", projectDir) + fmt.Fprintln(stdout, "Next steps:") + fmt.Fprintln(stdout, " cd "+projectDir) + fmt.Fprintln(stdout, " npm install") + fmt.Fprintln(stdout, " npx gonext-sdk-build # compiles src/index.ts to plugin.wasm via Javy") } return ExitOK } @@ -124,19 +134,21 @@ Flags: --force overwrite existing files at the target Templates: - go TinyGo-targeted Go plugin using packages/go/sdk - rust Rust crate compiled to wasm32-wasip1 using packages/rust/gonext-sdk + go TinyGo-targeted Go plugin using packages/go/sdk + rust Rust crate compiled to wasm32-wasip1 using packages/rust/gonext-sdk + typescript TypeScript plugin compiled to WASM via Javy (packages/ts/sdk-plugin) Example: gonext plugin init --template=go ./my-plugin - gonext plugin init --template=rust ./my-rust-plugin` + gonext plugin init --template=rust ./my-rust-plugin + gonext plugin init --template=typescript ./my-ts-plugin` // templatesFS embeds the templates directory tree. Each file is // rendered by trivial token substitution — {{PLUGIN_NAME}} becomes // the manifest slug. We deliberately don't pull in text/template // because the rendering is straight-line. // -//go:embed templates/go/* templates/rust/* templates/rust/src/* +//go:embed templates/go/* templates/rust/* templates/rust/src/* templates/typescript/* templates/typescript/src/* var templatesFS embed.FS // writeTemplateGo renders the Go template into dir. Returns an error @@ -236,6 +248,46 @@ func writeTemplateRust(dir, slug string, force bool) error { }) } +// writeTemplateTypeScript renders the TypeScript template into dir. +// Mirrors writeTemplateGo (same walk-and-rename-tmpl pattern); the +// duplication is deliberate — the TS template uses .npmignore via +// package.json's "files" field, so no .gitignore special-case here. +func writeTemplateTypeScript(dir, slug string, force bool) error { + root := "templates/typescript" + return fs.WalkDir(templatesFS, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("relativise template path %q: %w", path, err) + } + target := filepath.Join(dir, strings.TrimSuffix(rel, ".tmpl")) + + if !force { + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("file already exists: %s (use --force to overwrite)", target) + } + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create dir for %q: %w", target, err) + } + data, err := templatesFS.ReadFile(path) + if err != nil { + return fmt.Errorf("read template %q: %w", path, err) + } + rendered := strings.ReplaceAll(string(data), "{{PLUGIN_NAME}}", slug) + rendered = strings.ReplaceAll(rendered, "{{PLUGIN_NAME_LITERAL}}", slug) + if err := os.WriteFile(target, []byte(rendered), 0o644); err != nil { + return fmt.Errorf("write %q: %w", target, err) + } + return nil + }) +} + // sanitizeSlug converts a directory basename into a plugin-manifest- // safe slug: lowercase ASCII, hyphens for non-alphanumerics, no // leading/trailing hyphens, falling back to "my-plugin" if nothing diff --git a/cli/gonext/cmd/plugin/templates/typescript/README.md.tmpl b/cli/gonext/cmd/plugin/templates/typescript/README.md.tmpl new file mode 100644 index 00000000..764f7e25 --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/typescript/README.md.tmpl @@ -0,0 +1,36 @@ +# {{slug}} + +GoNext plugin scaffolded with `gonext plugin init --template=typescript`. + +## Build + +```bash +pnpm install +npx gonext-sdk-build +``` + +The pipeline: + +1. `tsc` compiles `src/index.ts` to `dist/index.js`. +2. Javy compiles `dist/index.js` to `dist/plugin.wasm`. +3. `manifest.json` is validated and written to `dist/manifest.json`. + +Javy must be on `$PATH` (or pass `--javy `). Install it from the +[Javy releases page](https://github.com/bytecodealliance/javy/releases). + +## Sign and ship + +```bash +gonext plugin sign dist/ +``` + +See [`docs/02-plugin-system.md`](https://github.com/Singleton-Solution/GoNext/blob/main/docs/02-plugin-system.md) +for the install + activation flow. + +## Develop + +```bash +pnpm typecheck # tsc --noEmit +gonext plugin test dist/ # contract checks +gonext plugin dev # auto-build + upload + log-tail loop +``` diff --git a/cli/gonext/cmd/plugin/templates/typescript/manifest.json.tmpl b/cli/gonext/cmd/plugin/templates/typescript/manifest.json.tmpl new file mode 100644 index 00000000..018fecce --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/typescript/manifest.json.tmpl @@ -0,0 +1,18 @@ +{ + "apiVersion": "gonext.io/v1", + "name": "{{slug}}", + "version": "0.1.0", + "entry": "plugin.wasm", + "capabilities": [ + "kv.read", + "kv.write", + "audit.emit" + ], + "hooks": { + "actions": ["save_post"], + "filters": ["the_content"] + }, + "requires": { + "host": ">=0.1.0" + } +} diff --git a/cli/gonext/cmd/plugin/templates/typescript/package.json.tmpl b/cli/gonext/cmd/plugin/templates/typescript/package.json.tmpl new file mode 100644 index 00000000..179e4ca9 --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/typescript/package.json.tmpl @@ -0,0 +1,18 @@ +{ + "name": "{{slug}}", + "version": "0.1.0", + "private": true, + "description": "GoNext plugin built with the TypeScript SDK and compiled to WASM via Javy.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "gonext-sdk-build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@gonext/sdk-plugin": "^0.0.1" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/cli/gonext/cmd/plugin/templates/typescript/src/index.ts.tmpl b/cli/gonext/cmd/plugin/templates/typescript/src/index.ts.tmpl new file mode 100644 index 00000000..32e7545a --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/typescript/src/index.ts.tmpl @@ -0,0 +1,31 @@ +/** + * {{slug}} — GoNext plugin scaffolded by `gonext plugin init`. + * + * Register your hook handlers below. The dispatcher Javy emits will + * route the host's `gn_handle_hook` calls into these handlers based + * on the hook name. See `manifest.json` for the actions/filters this + * plugin subscribes to. + */ +import { + pluginInit, + registerAction, + registerFilter, + host, +} from '@gonext/sdk-plugin'; + +// Action: fires whenever a post is saved. Actions are fire-and-forget; +// the return value is ignored. Use them for side effects like +// caching, audit, or KV writes. +registerAction('save_post', async (args) => { + host.log.info('{{slug}}: save_post fired with ' + JSON.stringify(args)); + host.kv.set('last-save-ms', String(host.nowMs())); + host.audit.emit('plugin.{{slug}}.save_post', { args }); +}); + +// Filter: transforms a value through the chain. The return value +// is JSON-encoded back to the host bus. +registerFilter('the_content', async (value) => { + return `
${value}
`; +}); + +pluginInit(); diff --git a/cli/gonext/cmd/plugin/templates/typescript/tsconfig.json b/cli/gonext/cmd/plugin/templates/typescript/tsconfig.json new file mode 100644 index 00000000..0affcc29 --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/typescript/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "noEmit": false, + "declaration": false + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/plugins/sdk-ts-hello/README.md b/examples/plugins/sdk-ts-hello/README.md new file mode 100644 index 00000000..50e3be19 --- /dev/null +++ b/examples/plugins/sdk-ts-hello/README.md @@ -0,0 +1,44 @@ +# gn-sdk-ts-hello + +Worked example of a GoNext plugin written in TypeScript via the +[`@gonext/sdk-plugin`](../../../packages/ts/sdk-plugin) SDK and compiled +to WebAssembly through [Javy](https://github.com/bytecodealliance/javy). + +## What it does + +- Subscribes to one action (`save_post`): records a KV timestamp and + emits an audit row. +- Subscribes to one filter (`the_content`): wraps the value in a marker + `
` so the plugin is visible in rendered output. + +Read [`src/index.ts`](src/index.ts) — the whole plugin is ~30 lines +including comments. + +## Build + +```bash +pnpm install +pnpm build +``` + +The pipeline runs `tsc` then `javy compile` and writes: + +``` +dist/ + plugin.wasm # WASM module loaded by the host + manifest.json # validated against gonext.io/v1 +``` + +Javy must be on `$PATH` (or pass `--javy ` to +`gonext-sdk-build`). Install it from the +[Javy releases page](https://github.com/bytecodealliance/javy/releases). + +## Sign and install + +```bash +gonext plugin sign dist/ +gonext plugin test dist/ # contract checks +``` + +See [`docs/02-plugin-system.md`](../../../docs/02-plugin-system.md) for +the full install + activation flow. diff --git a/examples/plugins/sdk-ts-hello/manifest.json b/examples/plugins/sdk-ts-hello/manifest.json new file mode 100644 index 00000000..161619c1 --- /dev/null +++ b/examples/plugins/sdk-ts-hello/manifest.json @@ -0,0 +1,18 @@ +{ + "apiVersion": "gonext.io/v1", + "name": "gn-sdk-ts-hello", + "version": "0.1.0", + "entry": "plugin.wasm", + "capabilities": [ + "kv.write", + "audit.emit", + "hooks.subscribe" + ], + "hooks": { + "actions": ["save_post"], + "filters": ["the_content"] + }, + "requires": { + "host": ">=0.1.0" + } +} diff --git a/examples/plugins/sdk-ts-hello/package.json b/examples/plugins/sdk-ts-hello/package.json new file mode 100644 index 00000000..2d717343 --- /dev/null +++ b/examples/plugins/sdk-ts-hello/package.json @@ -0,0 +1,18 @@ +{ + "name": "gn-sdk-ts-hello", + "version": "0.1.0", + "private": true, + "description": "Worked example: GoNext plugin written in TypeScript using @gonext/sdk-plugin. Builds to a Javy-compiled plugin.wasm via gonext-sdk-build.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "gonext-sdk-build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@gonext/sdk-plugin": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/examples/plugins/sdk-ts-hello/src/index.ts b/examples/plugins/sdk-ts-hello/src/index.ts new file mode 100644 index 00000000..d61f2550 --- /dev/null +++ b/examples/plugins/sdk-ts-hello/src/index.ts @@ -0,0 +1,44 @@ +/** + * gn-sdk-ts-hello — worked TypeScript plugin example. + * + * Demonstrates the minimum surface a real plugin uses: + * - one action (save_post): KV write + audit emission + * - one filter (the_content): pass-through value transform + * - explicit host imports rather than the namespaced `host` facade + * so the example shows both styles. + * + * Build: + * pnpm install + * pnpm build # tsc + javy -> dist/plugin.wasm + dist/manifest.json + * + * The plugin runs sandboxed inside the wazero host. All side effects + * go through the typed `gn_*` wrappers — nothing escapes that surface. + */ +import { + audit, + kv, + log, + nowMs, + pluginInit, + registerAction, + registerFilter, +} from '@gonext/sdk-plugin'; + +const PLUGIN_SLUG = 'gn-sdk-ts-hello'; + +// Action: record the last save time and emit an audit row. +registerAction('save_post', async (args) => { + const ts = nowMs(); + log.info(`${PLUGIN_SLUG}: save_post observed at ${ts}`); + kv.set('last-save-ms', String(ts)); + audit.emit('plugin.save_post.observed', { args, ts }); +}); + +// Filter: wrap the post content in a marker div. The host's +// `the_content` filter chain composes this with any other plugins +// subscribing to the same hook. +registerFilter('the_content', async (value) => { + return `
${value}
`; +}); + +pluginInit(); diff --git a/examples/plugins/sdk-ts-hello/tsconfig.json b/examples/plugins/sdk-ts-hello/tsconfig.json new file mode 100644 index 00000000..9da35557 --- /dev/null +++ b/examples/plugins/sdk-ts-hello/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/ts/sdk-plugin/README.md b/packages/ts/sdk-plugin/README.md new file mode 100644 index 00000000..50cc20bd --- /dev/null +++ b/packages/ts/sdk-plugin/README.md @@ -0,0 +1,127 @@ +# @gonext/sdk-plugin + +TypeScript plugin SDK for [GoNext](https://github.com/Singleton-Solution/GoNext). +Plugin authors write TypeScript against typed wrappers over the host's +`gn_*` ABIs; `gonext-sdk-build` compiles the source via `tsc` + Javy +(Shopify's JS→WASM compiler) into a `plugin.wasm` ready to bundle. + +This SDK exists alongside the Go and Rust SDKs for the same plugin +runtime: WordPress-style action / filter hooks dispatched into a +sandboxed wasm guest. Choose TypeScript when you want the npm +ecosystem, JSON-native ergonomics, and a familiar editor experience; +choose Go or Rust when you care most about cold-start time and binary +size. + +## Quick start + +```bash +pnpm add -D @gonext/sdk-plugin typescript +``` + +```ts +// src/index.ts +import { + pluginInit, + registerAction, + registerFilter, + host, +} from '@gonext/sdk-plugin'; + +registerAction('save_post', async (args) => { + host.log.info('post saved: ' + JSON.stringify(args)); + host.kv.set('last-save', String(host.nowMs())); + host.audit.emit('plugin.save_post.observed', { args }); +}); + +registerFilter('the_content', async (value) => { + return `
${value}
`; +}); + +pluginInit(); +``` + +```json +// manifest.json +{ + "name": "gn-hello", + "version": "0.1.0", + "entry": "plugin.wasm", + "capabilities": ["kv.write", "audit.emit"], + "hooks": { + "actions": ["save_post"], + "filters": ["the_content"] + }, + "requires": { "host": ">=0.1.0" } +} +``` + +```bash +npx gonext-sdk-build +# -> dist/plugin.wasm + dist/manifest.json +``` + +## What's in the package + +- `index` — `pluginInit`, `registerAction`, `registerFilter`, and the + dispatcher Javy wires onto `gn_handle_hook`. +- `host` — typed wrappers for every host ABI: + - **Data**: `host.db.read/write`, `host.kv.get/set/del/incr`, + `host.cache.invalidate` + - **Network**: `host.http.fetch`, `host.media.read`, `host.users.read` + - **Platform**: `host.secrets.get`, `host.audit.emit`, `host.cron.register` + - **Observability**: `host.log.{debug,info,warn,error}`, `host.nowMs`, + `host.i18n.translate`, `host.observe.{metric,event,spanEvent}` +- `manifest` — `buildManifest`, `manifestToJSON`, types matching + `gonext.io/v1`. +- `codec` — JSON envelope helpers (mostly internal; useful if you + bypass the dispatcher). + +## Build pipeline + +`gonext-sdk-build` runs: + +1. Manifest validation (`buildManifest`) → `dist/manifest.json`. +2. esbuild bundles `src/index.ts` (plus its `@gonext/sdk-plugin` + imports from `node_modules`) into a single ES2020 file at + `dist/plugin.js`. +3. Javy compiles `dist/plugin.js` → `dist/plugin.wasm`. + +Javy must be on `$PATH` or passed via `--javy `. Install it from +the [Javy releases page](https://github.com/bytecodealliance/javy/releases). + +The CLI skips the Javy step when `--skip-wasm` is set, which is useful +in CI lanes that only need the manifest + bundle gate (esbuild surfaces +syntax errors; `pnpm typecheck` is the type-level gate). + +## Signing + +Signing is decoupled from `gonext-sdk-build`. After producing the +bundle, run: + +```bash +gonext plugin sign dist/ +``` + +See `gonext plugin sign --help` and the +[plugin-system docs](../../docs/02-plugin-system.md) for details. + +## Why a separate package from `@gonext/sdk` + +`@gonext/sdk` (sibling package, frontend SDK) is loaded inside the host's +admin / runtime browser bundle — it ships React helpers and hook +schemas for the frontend host. `@gonext/sdk-plugin` is the *guest*-side +counterpart, intended only for the JavaScript that runs inside a +plugin's wasm sandbox. Keeping the two separate means a plugin +author's bundle doesn't pull in React, and a frontend dev doesn't +ship Javy runtime stubs. + +## Testing + +```bash +pnpm --filter @gonext/sdk-plugin test +``` + +The test suite exercises the codec and manifest builder under Node; +the Javy compile step is skipped (no Javy is installed in CI). The +dispatcher tests prove the registration API + JSON wire format +without standing up a wasm runtime. diff --git a/packages/ts/sdk-plugin/bin/gonext-sdk-build.js b/packages/ts/sdk-plugin/bin/gonext-sdk-build.js new file mode 100755 index 00000000..80928bcb --- /dev/null +++ b/packages/ts/sdk-plugin/bin/gonext-sdk-build.js @@ -0,0 +1,287 @@ +#!/usr/bin/env node +/** + * gonext-sdk-build — TypeScript plugin build pipeline for GoNext. + * + * Wraps esbuild (bundler/transpiler) + Javy (Shopify's JS→WASM + * compiler) into a single one-shot command: + * + * 1. esbuild bundles `src/index.ts` (plus its workspace imports + * from @gonext/sdk-plugin) into a single `dist/plugin.js`. + * 2. `javy compile dist/plugin.js -o dist/plugin.wasm` produces + * the WASM module. + * 3. The manifest (manifest.json or manifest.ts default-exporting + * a `Manifest`) is validated against the same shape the host's + * installer enforces. The result is written to + * `dist/manifest.json` next to the wasm. + * + * The CLI is intentionally light: it does NOT bundle the .gnplugin + * zip or invoke signing — those concerns are owned by + * `gonext plugin sign` and the in-tree marketplace publisher. This + * binary's sole job is "produce a valid plugin.wasm + manifest.json". + * + * Flags + * --src Source entry. Default: src/index.ts. + * --out Output directory. Default: dist. + * --manifest Path to manifest source (.json or .ts). Default: manifest.json. + * --javy Path to the Javy binary. Default: `javy` on PATH. + * --skip-wasm Skip the Javy step (manifest validation only). Useful + * in CI environments that don't have Javy installed; the + * build still surfaces every TypeScript/manifest issue. + * --help Print this message. + * + * Exit codes + * 0 — build succeeded. + * 1 — bundling, manifest validation, or Javy compile failed. + * 2 — usage error. + * + * Why JS, not TS: the binary runs as `npx gonext-sdk-build` in + * downstream plugin repos that don't have a TypeScript loader wired + * in. Keeping the wrapper as plain Node JS means it works against any + * Node 22+ install without an additional toolchain dependency. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import process from 'node:process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function usage() { + return [ + 'gonext-sdk-build — TypeScript plugin build pipeline (esbuild + Javy)', + '', + 'Usage:', + ' gonext-sdk-build [flags]', + '', + 'Flags:', + ' --src Source entry (default: src/index.ts)', + ' --out Output directory (default: dist)', + ' --manifest Manifest source (.json or .ts; default: manifest.json)', + ' --javy Path to the Javy binary (default: javy on PATH)', + ' --skip-wasm Skip the Javy step (manifest+bundle only)', + ' --help Show this message', + ].join('\n'); +} + +function parseArgs(argv) { + const out = { + src: 'src/index.ts', + out: 'dist', + manifest: 'manifest.json', + javy: 'javy', + skipWasm: false, + help: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--src': + out.src = argv[++i] ?? ''; + break; + case '--out': + out.out = argv[++i] ?? ''; + break; + case '--manifest': + out.manifest = argv[++i] ?? ''; + break; + case '--javy': + out.javy = argv[++i] ?? ''; + break; + case '--skip-wasm': + out.skipWasm = true; + break; + case '--help': + case '-h': + out.help = true; + break; + default: + process.stderr.write(`gonext-sdk-build: unknown flag ${arg}\n${usage()}\n`); + process.exit(2); + } + } + return out; +} + +/** + * Bundle the plugin source with esbuild. Output is a single + * ES2020-targeted JavaScript file Javy can ingest. + * + * We bundle (rather than ship raw source) because: + * 1. Javy expects exactly one input file. + * 2. The plugin imports from @gonext/sdk-plugin under node_modules; + * Javy doesn't resolve node_modules itself. + * 3. esbuild handles .ts -> .js, including the .ts-extension imports + * the SDK uses internally. + * + * We deliberately don't run a typecheck here — that's `pnpm typecheck` + * (or `tsc --noEmit`) in the user's own scripts. This build step is the + * "produce the artefact" lane; typing is the "validate the code" lane. + */ +async function runBundler(srcPath, outJsPath) { + let esbuild; + try { + esbuild = await import('esbuild'); + } catch (err) { + throw new Error( + `esbuild is required (npm install esbuild). Original error: ${err.message}`, + ); + } + await esbuild.build({ + entryPoints: [srcPath], + outfile: outJsPath, + bundle: true, + format: 'esm', + target: 'es2020', + platform: 'neutral', + // Javy's runtime exposes the host imports as globals (gn_*), so we + // don't need to inject any polyfills here. The neutral platform + // setting tells esbuild to neither emit Node nor browser shims. + logLevel: 'info', + }); +} + +function runJavy(javyBin, jsPath, wasmPath) { + // Javy CLI: `javy compile -o `. + // See https://github.com/bytecodealliance/javy for docs. + const proc = spawnSync(javyBin, ['compile', jsPath, '-o', wasmPath], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (proc.error && proc.error.code === 'ENOENT') { + throw new Error( + `javy binary not found (looked for ${javyBin}). Install Javy or pass --javy ; ` + + `see https://github.com/bytecodealliance/javy.`, + ); + } + if (proc.status !== 0) { + throw new Error('javy compile exited with non-zero status'); + } +} + +async function loadManifest(manifestPath) { + const ext = manifestPath.split('.').pop()?.toLowerCase(); + if (ext === 'json') { + const raw = await readFile(manifestPath, 'utf8'); + const parsed = JSON.parse(raw); + // Drop the apiVersion if present — buildManifest re-injects it. + delete parsed.apiVersion; + return parsed; + } + if (ext === 'ts' || ext === 'js' || ext === 'mjs') { + const url = pathToFileURL(resolve(manifestPath)); + // Dynamic import — Node's loader handles .js/.mjs natively; .ts + // requires ts-node or a similar shim that the consuming repo + // wires in. We don't take a hard dependency here. + const mod = await import(url.href); + const input = mod.default ?? mod.manifest ?? mod; + if (input && typeof input === 'object' && 'apiVersion' in input) { + delete /** @type {any} */ (input).apiVersion; + } + return input; + } + throw new Error(`unsupported manifest extension: ${manifestPath}`); +} + +async function validateAndWriteManifest(manifestSrc, outManifestPath) { + // We import the sibling builder rather than the published package + // so the CLI works against an unbuilt workspace checkout. + const builderURL = pathToFileURL(join(__dirname, '..', 'src', 'manifest.ts')).href; + let buildManifest; + let manifestToJSON; + try { + ({ buildManifest, manifestToJSON } = await import(builderURL)); + } catch (err) { + // When the manifest builder lives in a published package, callers + // resolve it via `@gonext/sdk-plugin`. Fall back to that path. + try { + ({ buildManifest, manifestToJSON } = await import('@gonext/sdk-plugin')); + } catch (innerErr) { + throw new Error( + `unable to load manifest builder (${err.message}; fallback: ${innerErr.message})`, + ); + } + } + const input = await loadManifest(manifestSrc); + const built = buildManifest(input); + writeFileSync(outManifestPath, manifestToJSON(built), 'utf8'); + return built; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + process.stdout.write(usage() + '\n'); + process.exit(0); + } + + const cwd = process.cwd(); + const srcPath = isAbsolute(args.src) ? args.src : join(cwd, args.src); + const outDir = isAbsolute(args.out) ? args.out : join(cwd, args.out); + const manifestSrc = isAbsolute(args.manifest) + ? args.manifest + : join(cwd, args.manifest); + + if (!existsSync(srcPath)) { + process.stderr.write(`gonext-sdk-build: source not found: ${srcPath}\n`); + process.exit(1); + } + if (!existsSync(manifestSrc)) { + process.stderr.write(`gonext-sdk-build: manifest not found: ${manifestSrc}\n`); + process.exit(1); + } + + mkdirSync(outDir, { recursive: true }); + + const outManifest = join(outDir, 'manifest.json'); + process.stdout.write('gonext-sdk-build: validating manifest\n'); + let built; + try { + built = await validateAndWriteManifest(manifestSrc, outManifest); + } catch (err) { + process.stderr.write(`gonext-sdk-build: manifest validation failed:\n${err.message}\n`); + if (err && err.issues) { + for (const i of err.issues) { + process.stderr.write(` - ${i.path}: ${i.message}\n`); + } + } + process.exit(1); + } + + // Bundle: src/index.ts (+ workspace deps) -> dist/plugin.js + const bundledJs = join(outDir, 'plugin.js'); + process.stdout.write(`gonext-sdk-build: bundling ${srcPath} -> ${bundledJs}\n`); + try { + await runBundler(srcPath, bundledJs); + } catch (err) { + process.stderr.write(`gonext-sdk-build: bundle failed: ${err.message}\n`); + process.exit(1); + } + + if (args.skipWasm) { + process.stdout.write( + `gonext-sdk-build: --skip-wasm set; manifest at ${outManifest}, bundle at ${bundledJs}\n`, + ); + process.exit(0); + } + + const outWasm = join(outDir, built.entry || 'plugin.wasm'); + mkdirSync(dirname(outWasm), { recursive: true }); + process.stdout.write(`gonext-sdk-build: compiling JS -> ${outWasm} via Javy\n`); + try { + runJavy(args.javy, bundledJs, outWasm); + } catch (err) { + process.stderr.write(`gonext-sdk-build: javy failed: ${err.message}\n`); + process.exit(1); + } + + process.stdout.write( + `gonext-sdk-build: built ${outWasm} + ${outManifest}\n`, + ); +} + +main().catch((err) => { + process.stderr.write(`gonext-sdk-build: unexpected error: ${err?.stack ?? err}\n`); + process.exit(1); +}); diff --git a/packages/ts/sdk-plugin/package.json b/packages/ts/sdk-plugin/package.json new file mode 100644 index 00000000..59217eca --- /dev/null +++ b/packages/ts/sdk-plugin/package.json @@ -0,0 +1,52 @@ +{ + "name": "@gonext/sdk-plugin", + "version": "0.0.1", + "description": "TypeScript plugin SDK for GoNext. Authors write TypeScript against typed wrappers over the host's `gn_*` ABIs; `gonext-sdk-build` compiles via tsc + Javy (Shopify's JS→WASM compiler) into a `plugin.wasm` ready to bundle. Licensed under Apache-2.0 so plugin authors are unencumbered.", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./host": { + "types": "./src/host.ts", + "default": "./src/host.ts" + }, + "./manifest": { + "types": "./src/manifest.ts", + "default": "./src/manifest.ts" + }, + "./codec": { + "types": "./src/codec.ts", + "default": "./src/codec.ts" + } + }, + "bin": { + "gonext-sdk-build": "./bin/gonext-sdk-build.js" + }, + "files": [ + "bin", + "src", + "README.md" + ], + "scripts": { + "build": "tsc --noEmit", + "lint": "echo '@gonext/sdk-plugin lint: not yet implemented'", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "esbuild": "^0.21.0" + }, + "devDependencies": { + "@gonext/test-config": "workspace:*", + "@vitest/coverage-v8": "^1.6.0", + "typescript": "^5.6.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/ts/sdk-plugin/src/codec.ts b/packages/ts/sdk-plugin/src/codec.ts new file mode 100644 index 00000000..9d58a783 --- /dev/null +++ b/packages/ts/sdk-plugin/src/codec.ts @@ -0,0 +1,228 @@ +/** + * JSON envelope codec for the GoNext plugin ABI. + * + * Mirrors `packages/go/plugins/abi/hooks/marshal.go`. Every payload that + * crosses the host<->guest boundary is JSON, so the codec is essentially + * a typed view onto `JSON.stringify` / `JSON.parse` with a few invariants: + * + * - Action payloads always carry an empty array for `args` (never null), + * so the guest decoder has a single shape to handle. + * - Filter payloads always carry an explicit `value` (JSON-null when + * none was supplied) and an empty `args` array when no extras were + * passed. + * - Filter results carry one field — `value` — also JSON-null when the + * handler chose not to transform. + * + * Result statuses mirror the negative-int32 sentinels the host returns + * in the low 32 bits of the packed i64. The Javy guest never returns + * these directly from JS; the runtime entry point at `src/host.ts` + * translates a typed throw into the right packed return. + * + * Why a dedicated module: the codec is the only piece of the SDK that + * needs to round-trip cleanly in a Node test environment (Vitest). + * Keeping it free of `globalThis.gn_*` references means the unit tests + * for envelope handling don't have to stand up the Javy host shims. + */ + +/** Sentinel statuses returned by the host. */ +export const ResultStatus = { + OK: 0, + Error: -1, + OutOfMemory: -2, + BadPayload: -3, + UnknownHook: -4, + Trap: -5, +} as const; + +/** Type-level enumeration of the result-status sentinels. */ +export type ResultStatusCode = + (typeof ResultStatus)[keyof typeof ResultStatus]; + +/** Tag for the two payload kinds the hook bus dispatches. */ +export const PayloadKind = { + Action: 'action', + Filter: 'filter', +} as const; + +/** Type-level enumeration of payload kinds. */ +export type PayloadKindLiteral = + (typeof PayloadKind)[keyof typeof PayloadKind]; + +/** + * Wire shape of an action payload. Arrays of `unknown` because the bus + * is variadic over `any` — the guest is responsible for narrowing on a + * per-hook basis. + */ +export interface ActionPayload { + kind: typeof PayloadKind.Action; + args: unknown[]; +} + +/** + * Wire shape of a filter payload. `value` is the transformable thing + * threaded through the chain; `args` carries the per-call extras. + */ +export interface FilterPayload { + kind: typeof PayloadKind.Filter; + value: unknown; + args: unknown[]; +} + +/** Wire shape of a filter handler's return value. */ +export interface FilterResult { + value: unknown; +} + +/** + * Marshal an action payload to its JSON wire form. + * + * A null/undefined `args` becomes `[]` so the encoded envelope is + * always `{"kind":"action","args":[]}` rather than carrying `"args": + * null`. The guest decoder relies on the field being an array. + */ +export function marshalActionPayload(args?: readonly unknown[] | null): string { + const payload: ActionPayload = { + kind: PayloadKind.Action, + args: args ? Array.from(args) : [], + }; + return JSON.stringify(payload); +} + +/** + * Marshal a filter payload to its JSON wire form. + * + * Like `marshalActionPayload`, missing extras become an empty array. + * Missing `value` becomes JSON-null so the encoded form always has the + * field present. + */ +export function marshalFilterPayload( + value: unknown, + args?: readonly unknown[] | null, +): string { + const payload: FilterPayload = { + kind: PayloadKind.Filter, + value: value === undefined ? null : value, + args: args ? Array.from(args) : [], + }; + return JSON.stringify(payload); +} + +/** + * Marshal a filter result to its JSON wire form. + * + * `value === undefined` is treated as JSON-null so the field is always + * present on the wire. + */ +export function marshalFilterResult(value: unknown): string { + const payload: FilterResult = { + value: value === undefined ? null : value, + }; + return JSON.stringify(payload); +} + +/** + * Parse an incoming action payload's JSON wire bytes. + * + * Throws a {@link CodecError} on malformed JSON, missing fields, or a + * wrong `kind` tag. The thrown error's `status` is + * `ResultStatus.BadPayload` so the runtime entry point can echo it back + * to the host as a negative-length return. + */ +export function unmarshalActionPayload(raw: string): ActionPayload { + const parsed = parseJSON(raw); + if (!isObject(parsed)) { + throw new CodecError( + 'action payload is not a JSON object', + ResultStatus.BadPayload, + ); + } + if (parsed['kind'] !== PayloadKind.Action) { + throw new CodecError( + `action payload has wrong kind ${JSON.stringify(parsed['kind'])}`, + ResultStatus.BadPayload, + ); + } + const args = parsed['args']; + if (!Array.isArray(args)) { + throw new CodecError( + 'action payload args is not an array', + ResultStatus.BadPayload, + ); + } + return { kind: PayloadKind.Action, args }; +} + +/** + * Parse an incoming filter payload's JSON wire bytes. + * + * Same error contract as {@link unmarshalActionPayload}. `value` is + * accepted as any JSON-serialisable type, including null. + */ +export function unmarshalFilterPayload(raw: string): FilterPayload { + const parsed = parseJSON(raw); + if (!isObject(parsed)) { + throw new CodecError( + 'filter payload is not a JSON object', + ResultStatus.BadPayload, + ); + } + if (parsed['kind'] !== PayloadKind.Filter) { + throw new CodecError( + `filter payload has wrong kind ${JSON.stringify(parsed['kind'])}`, + ResultStatus.BadPayload, + ); + } + if (!('value' in parsed)) { + throw new CodecError( + 'filter payload missing value field', + ResultStatus.BadPayload, + ); + } + const args = parsed['args']; + if (!Array.isArray(args)) { + throw new CodecError( + 'filter payload args is not an array', + ResultStatus.BadPayload, + ); + } + return { + kind: PayloadKind.Filter, + value: parsed['value'], + args, + }; +} + +/** + * Parse a JSON wire blob without throwing the raw `SyntaxError`. We + * normalise to a {@link CodecError} so the runtime entry point can do + * one `instanceof` check. + */ +function parseJSON(raw: string): unknown { + try { + return JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CodecError(`malformed JSON: ${msg}`, ResultStatus.BadPayload); + } +} + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Typed failure carried by the codec layer. + * + * The runtime entry point catches this and packs `status` into the + * low 32 bits of the i64 return. Plugin authors generally don't + * throw `CodecError` directly — they let the codec do it for them and + * surface their own errors via {@link PluginError} from `host.ts`. + */ +export class CodecError extends Error { + override readonly name = 'CodecError'; + readonly status: ResultStatusCode; + constructor(message: string, status: ResultStatusCode = ResultStatus.BadPayload) { + super(message); + this.status = status; + } +} diff --git a/packages/ts/sdk-plugin/src/host.ts b/packages/ts/sdk-plugin/src/host.ts new file mode 100644 index 00000000..d1ae3de3 --- /dev/null +++ b/packages/ts/sdk-plugin/src/host.ts @@ -0,0 +1,443 @@ +/** + * Typed wrappers around the host's `gn_*` ABI imports. + * + * Javy exposes the host imports as plain JavaScript globals on + * `globalThis`. From the plugin author's point of view, the host + * surface is a function table: + * + * globalThis.gn_log(level, ptr, len) + * globalThis.gn_kv_set(key, value) + * globalThis.gn_http_fetch(envelope) + * ... + * + * Each Javy WASI shim handles pointer / length translation under the + * hood — when the plugin calls a typed wrapper such as `host.kv.set("k", + * "v")`, the underlying host function receives a marshaled JSON + * envelope (or a plain string), runs the host-side capability + + * audit logic, and either succeeds or returns a typed failure status. + * + * This module mirrors the host's `gn_*` Go surface 1:1. Anything not + * exposed here would force the plugin to reach into `globalThis` + * directly, which works but loses the type-checking sweet spot Javy + * gives us. + * + * Why the indirection: Javy's binding generator does not expose typed + * stubs out of the box. Without this module each plugin would have to + * declare `gn_kv_get`'s shape inline. By centralising the declarations + * here we (a) get one TypeScript source of truth, (b) can swap the + * wire format (JSON, msgpack) without touching plugin code, and (c) + * keep the wrapper logic — for example, throwing on a negative-length + * return — in one place. + */ + +import { + ResultStatus, + type ResultStatusCode, +} from './codec.ts'; + +/** + * Shape of the host bindings as Javy exposes them. The runtime is + * responsible for filling `globalThis.gn_*` with these functions + * before invoking the guest's `gn_handle_hook` entry point. Tests can + * shim the same functions on `globalThis` to exercise the wrapper + * surface without standing up a real wazero host. + * + * Pointer / length pairs in the raw Go ABI become ordinary strings or + * bytes here — Javy's runtime translates them. Where the host returns + * a packed `i64`, Javy surfaces the high half (pointer-resolved string) + * and the low half (status) as a structured object so the wrapper can + * branch on the sentinel without bit-twiddling. + * + * NOTE: The exact bridging convention is set by Javy. The shape below + * is the one this SDK contracts against; the build CLI (see + * `bin/gonext-sdk-build.js`) installs a thin Javy host adapter that + * exposes these functions under the names below. + */ +export interface HostBindings { + // ─────────────────────────── env ─────────────────────────── + gn_log?: (level: number, message: string) => void; + gn_time_ms?: () => number; + gn_panic?: (message: string) => void; + + // ─────────────────────── observability ──────────────────── + gn_i18n_translate?: (key: string, locale: string) => string | null; + gn_metric_observe?: ( + name: string, + value: number, + tags?: Record, + ) => number; + gn_event_emit?: (name: string, data?: Record) => number; + gn_span_event?: ( + name: string, + attrs?: Record, + ) => number; + + // ─────────────────────────── data ────────────────────────── + gn_db_read?: ( + query: string, + args?: readonly unknown[], + ) => HostDataResult; + gn_db_write?: ( + query: string, + args?: readonly unknown[], + ) => HostDataResult; + gn_kv_get?: (key: string) => HostDataResult; + gn_kv_set?: (key: string, value: string) => HostDataResult; + gn_kv_del?: (key: string) => HostDataResult; + gn_kv_incr?: (key: string, delta: number) => HostDataResult; + gn_cache_invalidate?: (tags: readonly string[]) => HostDataResult; + + // ────────────────────────── network ──────────────────────── + gn_http_fetch?: (envelope: HttpFetchRequest) => HostDataResult; + gn_media_read?: (id: string) => HostDataResult; + gn_users_read?: (id: string) => HostDataResult; + + // ─────────────────────────── platform ─────────────────────── + gn_secrets_get?: (name: string) => HostDataResult; + gn_audit_emit?: ( + event: string, + metadata?: Record, + ) => number; + gn_cron_register?: ( + spec: string, + job: string, + ) => number; +} + +/** + * Tagged result the Javy adapter returns for data-shaped host calls. + * + * On success `status === 0` (`ResultStatus.OK`) and either `value` + * carries the raw response body (string) or it's empty for delete-like + * operations. On failure `status` is one of the negative-int32 + * sentinels — see `packages/go/plugins/runtime/host_data.go` for the + * canonical list of values. + */ +export interface HostDataResult { + status: number; + value?: string; +} + +/** Bridging map for HTTP log levels. Same numerics the host expects. */ +export const LogLevel = { + Debug: 0, + Info: 1, + Warn: 2, + Error: 3, +} as const; + +/** Type-level enumeration of log levels. */ +export type LogLevelCode = (typeof LogLevel)[keyof typeof LogLevel]; + +/** Outbound HTTP-fetch envelope — matches `httpFetchRequest` in Go. */ +export interface HttpFetchRequest { + method?: string; + url: string; + headers?: Record; + body?: string; +} + +/** Decoded HTTP-fetch response. Mirrors the Go side's envelope. */ +export interface HttpFetchResponse { + status: number; + headers?: Record; + body?: string; + error?: string; +} + +/** + * Typed failure thrown from any host wrapper that maps a host sentinel + * to an error. Plugin code can `try/catch` to recover, or let it bubble + * — the runtime entry point in `index.ts` catches it and translates + * into the right packed return for the host. + */ +export class HostError extends Error { + override readonly name = 'HostError'; + readonly status: ResultStatusCode | number; + readonly call: string; + constructor(call: string, status: number, message?: string) { + super(message ?? `host call ${call} failed with status ${status}`); + this.call = call; + this.status = status; + } +} + +/** Resolve the live host bindings from globalThis. */ +function bindings(): HostBindings { + return globalThis as unknown as HostBindings; +} + +function requireBinding( + name: K, +): NonNullable { + const fn = bindings()[name]; + if (!fn) { + throw new HostError( + String(name), + ResultStatus.Error, + `host binding ${String(name)} is not available`, + ); + } + return fn as NonNullable; +} + +function expectOk(call: string, result: HostDataResult): HostDataResult { + if (result.status !== ResultStatus.OK) { + throw new HostError(call, result.status); + } + return result; +} + +// ──────────────────────────────────────────────────────────────────── +// Public host surface, namespaced by capability domain. +// ──────────────────────────────────────────────────────────────────── + +/** + * Structured logger wrapping `gn_log`. Each level produces a + * host-side `slog` line attributed to the plugin's slug. + * + * Calls are best-effort: a misbehaving log line cannot fail a hook. + * If the host binding is missing (test environment), the call is a + * no-op so plugins can be unit-tested without standing up a Javy + * shim. + */ +export const log = { + debug(message: string): void { + bindings().gn_log?.(LogLevel.Debug, message); + }, + info(message: string): void { + bindings().gn_log?.(LogLevel.Info, message); + }, + warn(message: string): void { + bindings().gn_log?.(LogLevel.Warn, message); + }, + error(message: string): void { + bindings().gn_log?.(LogLevel.Error, message); + }, +} as const; + +/** + * Wall-clock time in milliseconds. Wraps `gn_time_ms`. The host's + * source is `time.Now().UnixMilli()` — not strictly monotonic. + * + * Returns `Date.now()` as a fallback when the binding is missing, so + * unit tests don't have to mock it. + */ +export function nowMs(): number { + return bindings().gn_time_ms?.() ?? Date.now(); +} + +/** + * KV namespace bound to the plugin's slug. Wraps `gn_kv_*`. + * + * All methods may throw {@link HostError} with one of the data + * sentinels (denied, internal, bad_args, not_found, quota). Plugins + * that want fall-through reads should `try/catch` on + * `kv.get` — a missing key surfaces as `status === -4` (NotFound). + */ +export const kv = { + /** + * Read a key. Returns the stored string or `null` when the key is + * missing. Throws {@link HostError} for any other failure. + */ + get(key: string): string | null { + const fn = requireBinding('gn_kv_get'); + const res = fn(key); + if (res.status === ResultStatus.OK) return res.value ?? ''; + if (res.status === -4 /* dataResultNotFound */) return null; + throw new HostError('gn_kv_get', res.status); + }, + /** + * Write a key. `value` is taken as a string — callers wanting to + * persist structured data must JSON-encode it first. Audited. + */ + set(key: string, value: string): void { + const fn = requireBinding('gn_kv_set'); + expectOk('gn_kv_set', fn(key, value)); + }, + /** Delete a key. Idempotent (deleting a missing key is a no-op). */ + del(key: string): void { + const fn = requireBinding('gn_kv_del'); + expectOk('gn_kv_del', fn(key)); + }, + /** + * Atomically increment a counter by `delta`. Returns the new value. + * Counters are subject to the per-plugin key quota but not the byte + * quota. + */ + incr(key: string, delta = 1): number { + const fn = requireBinding('gn_kv_incr'); + const res = fn(key, delta); + if (res.status === ResultStatus.OK) { + return res.value ? Number(res.value) : 0; + } + // The host packs the new value into the low 32 bits when it's + // non-negative; Javy surfaces it as the `status` field. Anything + // negative is a sentinel. + if (res.status >= 0) return res.status; + throw new HostError('gn_kv_incr', res.status); + }, +} as const; + +/** + * DB-ABI wrappers. The host runs every query under the plugin's + * SET LOCAL ROLE and the manifest's per-plugin allowlist, so plugins + * see exactly the GRANTs the operator configured. + */ +export const db = { + /** + * Execute a parameterised SELECT/WITH. Returns the decoded JSON + * rowset. Throws on denied / bad_args / internal. + */ + read(query: string, args?: readonly unknown[]): T[] { + const fn = requireBinding('gn_db_read'); + const res = expectOk('gn_db_read', fn(query, args)); + if (!res.value) return []; + return JSON.parse(res.value) as T[]; + }, + /** + * Execute a parameterised INSERT/UPDATE/DELETE. Returns the number + * of affected rows. Audited. + */ + write(query: string, args?: readonly unknown[]): number { + const fn = requireBinding('gn_db_write'); + const res = fn(query, args); + if (res.status >= 0) return res.status; + throw new HostError('gn_db_write', res.status); + }, +} as const; + +/** Cache invalidation. Tags are persisted to the outbox table. */ +export const cache = { + invalidate(tags: readonly string[]): void { + const fn = requireBinding('gn_cache_invalidate'); + expectOk('gn_cache_invalidate', fn(tags)); + }, +} as const; + +/** + * HTTP / media / users — read-side network host bindings. The host + * enforces the per-plugin allowlist + SSRF guard + rate limiter + * before any call leaves the box. + */ +export const http = { + /** + * Issue an outbound HTTP request via `gn_http_fetch`. The host + * caps redirects (3), body size (10 MiB), and timeout (30s); any + * value the plugin sets in the envelope is a request, not a + * guarantee. + */ + fetch(envelope: HttpFetchRequest): HttpFetchResponse { + const fn = requireBinding('gn_http_fetch'); + const res = fn(envelope); + if (res.status === ResultStatus.OK && res.value) { + return JSON.parse(res.value) as HttpFetchResponse; + } + throw new HostError('gn_http_fetch', res.status); + }, +} as const; + +/** Media read-only lookup, gated by `media.read`. */ +export const media = { + read(id: string): T { + const fn = requireBinding('gn_media_read'); + const res = fn(id); + if (res.status === ResultStatus.OK && res.value) { + return JSON.parse(res.value) as T; + } + throw new HostError('gn_media_read', res.status); + }, +} as const; + +/** Users read-only lookup, gated by `users.read`. */ +export const users = { + read(id: string): T { + const fn = requireBinding('gn_users_read'); + const res = fn(id); + if (res.status === ResultStatus.OK && res.value) { + return JSON.parse(res.value) as T; + } + throw new HostError('gn_users_read', res.status); + }, +} as const; + +/** + * Audit-log emission. The host writes the row attributed to the + * plugin's slug. Use this for plugin-defined administrative + * actions; data-ABI writes are audited automatically. + */ +export const audit = { + emit(event: string, metadata?: Record): void { + const fn = bindings().gn_audit_emit; + if (!fn) return; // best-effort — missing binding is a no-op + fn(event, metadata); + }, +} as const; + +/** + * Secrets read. The host materialises the secret from its configured + * provider (env, file, KMS) and returns the value as a string. Returns + * `null` if the secret is not configured. + */ +export const secrets = { + get(name: string): string | null { + const fn = bindings().gn_secrets_get; + if (!fn) return null; + const res = fn(name); + if (res.status === ResultStatus.OK) return res.value ?? ''; + if (res.status === -4) return null; + throw new HostError('gn_secrets_get', res.status); + }, +} as const; + +/** Cron registration. Plugins declare jobs in the manifest; this is the runtime hook. */ +export const cron = { + register(spec: string, job: string): void { + const fn = bindings().gn_cron_register; + if (!fn) return; + fn(spec, job); + }, +} as const; + +/** Localised string lookup wrapping `gn_i18n_translate`. */ +export const i18n = { + translate(key: string, locale: string): string { + const translated = bindings().gn_i18n_translate?.(key, locale); + return translated ?? key; + }, +} as const; + +/** Metric observation and span events. */ +export const observe = { + metric(name: string, value: number, tags?: Record): void { + bindings().gn_metric_observe?.(name, value, tags); + }, + event(name: string, data?: Record): void { + bindings().gn_event_emit?.(name, data); + }, + spanEvent(name: string, attrs?: Record): void { + bindings().gn_span_event?.(name, attrs); + }, +} as const; + +/** + * Default host facade. Plugin authors import this single symbol to + * reach every capability, e.g. `host.kv.set("foo", "bar")`. The + * sub-namespaces are also exported individually so tree-shakers can + * drop the unused ones at build time. + */ +export const host = { + log, + nowMs, + kv, + db, + cache, + http, + media, + users, + audit, + secrets, + cron, + i18n, + observe, +} as const; diff --git a/packages/ts/sdk-plugin/src/index.ts b/packages/ts/sdk-plugin/src/index.ts new file mode 100644 index 00000000..cf246a40 --- /dev/null +++ b/packages/ts/sdk-plugin/src/index.ts @@ -0,0 +1,229 @@ +/** + * @gonext/sdk-plugin — public entry point. + * + * Plugin authors write code like this: + * + * import { pluginInit, registerAction, registerFilter, host } from '@gonext/sdk-plugin'; + * + * registerAction('save_post', async (args) => { + * host.log.info('post saved: ' + JSON.stringify(args)); + * host.kv.set('last-save', String(host.nowMs())); + * }); + * + * registerFilter('the_content', async (value, _args) => { + * return `
${value}
`; + * }); + * + * pluginInit(); + * + * `pluginInit()` wires the dispatcher onto `globalThis` under the + * well-known `gn_handle_hook` name. The Javy host adapter calls that + * function for every hook invocation, passing the marshaled JSON + * envelope. The dispatcher routes to the registered handler, marshals + * the result back out, and translates any thrown error into the right + * `ResultStatus` sentinel. + * + * The codec lives in `codec.ts`; the typed host wrappers live in + * `host.ts`; the manifest builder lives in `manifest.ts`. This module + * is intentionally tiny — its only job is the dispatcher plus the + * registration API. + */ + +import { + CodecError, + ResultStatus, + type ResultStatusCode, + marshalFilterResult, + unmarshalActionPayload, + unmarshalFilterPayload, +} from './codec.ts'; +import { HostError } from './host.ts'; + +export { + CodecError, + PayloadKind, + ResultStatus, + marshalActionPayload, + marshalFilterPayload, + marshalFilterResult, + unmarshalActionPayload, + unmarshalFilterPayload, +} from './codec.ts'; +export type { + ActionPayload, + FilterPayload, + FilterResult, + PayloadKindLiteral, + ResultStatusCode, +} from './codec.ts'; + +export { + HostError, + LogLevel, + audit, + cache, + cron, + db, + host, + http, + i18n, + kv, + log, + media, + nowMs, + observe, + secrets, + users, +} from './host.ts'; +export type { + HostBindings, + HostDataResult, + HttpFetchRequest, + HttpFetchResponse, + LogLevelCode, +} from './host.ts'; + +export { + MANIFEST_API_VERSION, + ManifestError, + buildManifest, + manifestToJSON, +} from './manifest.ts'; +export type { + DependencyManifest, + HooksManifest, + Manifest, + ManifestInput, + ManifestIssue, + RequiresManifest, + StorageManifest, +} from './manifest.ts'; + +/** + * Action handler. Receives the args list the host bus passed to + * `hooks.Bus.Do`. Return value is ignored — actions are fire-and-forget. + * Returning a Promise is fine; the dispatcher awaits it. + */ +export type ActionHandler = ( + args: unknown[], +) => void | Promise; + +/** + * Filter handler. Receives the value to transform plus any extras the + * host passed. The return value (or resolved value of the returned + * Promise) is JSON-encoded and sent back to the host. + */ +export type FilterHandler = ( + value: unknown, + args: unknown[], +) => unknown | Promise; + +/** Internal registry of handlers keyed by hook name. */ +const actionHandlers = new Map(); +const filterHandlers = new Map(); + +/** + * Register an action handler. Calling twice for the same hook + * replaces the previous handler — same semantics as the Go SDK's + * `AddAction` so authors switching languages aren't surprised. + */ +export function registerAction(name: string, handler: ActionHandler): void { + if (!name) throw new Error('registerAction: hook name is required'); + actionHandlers.set(name, handler); +} + +/** Register a filter handler. Replaces on conflict; see {@link registerAction}. */ +export function registerFilter(name: string, handler: FilterHandler): void { + if (!name) throw new Error('registerFilter: hook name is required'); + filterHandlers.set(name, handler); +} + +/** + * Dispatcher used by the Javy adapter. The adapter exposes the + * raw `(name, payloadJSON)` invocation; this function does the + * registry lookup, JSON parsing, and result marshalling. + * + * Exported so tests can call it directly without going through the + * Javy bridge. + * + * Return value carries the JSON-encoded result body on success + * (`status === 0`) or a `ResultStatus` sentinel with no body on + * failure. The Javy adapter packs `(ptr, len)` from this shape on + * the way back to the host. + */ +export interface DispatchResult { + status: ResultStatusCode | number; + body?: string; +} + +export async function dispatch( + name: string, + payloadJSON: string, +): Promise { + if (!name) { + return { status: ResultStatus.BadPayload }; + } + // Filters first — most hot-path calls are filter dispatch. + const filter = filterHandlers.get(name); + if (filter) { + try { + const { value, args } = unmarshalFilterPayload(payloadJSON); + const transformed = await Promise.resolve(filter(value, args)); + return { status: ResultStatus.OK, body: marshalFilterResult(transformed) }; + } catch (err) { + return { status: classifyError(err) }; + } + } + const action = actionHandlers.get(name); + if (action) { + try { + const { args } = unmarshalActionPayload(payloadJSON); + await Promise.resolve(action(args)); + return { status: ResultStatus.OK }; + } catch (err) { + return { status: classifyError(err) }; + } + } + return { status: ResultStatus.UnknownHook }; +} + +/** + * Wire the dispatcher onto `globalThis` so the Javy host adapter can + * find it. + * + * The adapter exposes one function — `globalThis.gn_handle_hook` — + * with the signature `(name, payloadJSON) => Promise`. + * The compiled wasm body that Javy emits handles the pointer / length + * dance; the JS layer just sees strings. + * + * Plugin authors call this once at module-top-level after registering + * their handlers. The function is idempotent — calling it twice + * simply rewires the same dispatcher, useful in dev when hot-reload + * re-evaluates the bundle. + */ +export function pluginInit(): void { + const slot = globalThis as Record; + slot['gn_handle_hook'] = dispatch; +} + +function classifyError(err: unknown): ResultStatusCode | number { + if (err instanceof CodecError) return err.status; + if (err instanceof HostError) { + // Host failures inside a handler propagate as a generic guest error; + // the host has already audited the underlying ABI denial / quota / + // etc., so we don't echo the data sentinel back through the hook + // surface. + return ResultStatus.Error; + } + return ResultStatus.Error; +} + +/** + * Test-only helper: clear every registered handler. Used by the test + * suite to keep cases independent without re-importing the module. + * Plugin authors should never need this. + */ +export function _resetForTests(): void { + actionHandlers.clear(); + filterHandlers.clear(); +} diff --git a/packages/ts/sdk-plugin/src/manifest.ts b/packages/ts/sdk-plugin/src/manifest.ts new file mode 100644 index 00000000..1c1e9003 --- /dev/null +++ b/packages/ts/sdk-plugin/src/manifest.ts @@ -0,0 +1,377 @@ +/** + * Typed manifest schema for GoNext plugins. + * + * Mirrors `packages/go/plugins/manifest/schema.json` (gonext.io/v1). The + * Go-side validator is the canonical gate at install time; this module + * is the author-side ergonomic counterpart so a TypeScript plugin can + * declare its manifest with autocomplete and catch the common mistakes + * (bad slug, malformed semver, dotted-token regex misses) BEFORE the + * bundle even leaves the dev machine. + * + * The {@link buildManifest} entry point both validates and renders the + * manifest to its canonical JSON form. The `gonext-sdk-build` CLI calls + * it before zipping the bundle so the on-disk `manifest.json` is + * guaranteed to satisfy the Go validator's schema. + */ + +/** The literal apiVersion the v1 schema requires. */ +export const MANIFEST_API_VERSION = 'gonext.io/v1' as const; + +/** Plugin-slug regex — matches `manifest/schema.json` `name.pattern`. */ +const SLUG_RE = /^[a-z][a-z0-9-]{2,40}$/; + +/** SemVer 2.0.0 — same regex used by the Go schema. */ +const SEMVER_RE = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** Dotted token used for capabilities and jobs. */ +const CAP_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/; + +/** Hook name — same dotted token but underscores are allowed. */ +const HOOK_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/; + +/** Entry path is a POSIX path ending in `.wasm`. */ +const ENTRY_RE = /^[A-Za-z0-9_.\-/]+\.wasm$/; + +/** Job id — same as capability but hyphens allowed in each segment. */ +const JOB_RE = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/; + +/** Semver range — accepts npm-style operators. */ +const SEMVER_RANGE_RE = /^[~^>=<*0-9A-Za-z.\-+ ,|]+$/; + +/** ed25519 signature — 128 lowercase hex chars. */ +const SIGNATURE_RE = /^[0-9a-f]{128}$/; + +/** Hooks block — at least one of `actions` / `filters` should be populated. */ +export interface HooksManifest { + actions?: string[]; + filters?: string[]; +} + +/** Required compatibility constraints. Today only `host` is defined. */ +export interface RequiresManifest { + host: string; +} + +/** Inter-plugin dependency. */ +export interface DependencyManifest { + name: string; + version: string; +} + +/** Persistent-storage budgets. Only the KV namespace is described today. */ +export interface StorageManifest { + kv?: { + max_bytes?: number; + max_keys?: number; + }; +} + +/** + * Author-facing manifest input. Mirrors the Go schema 1:1, with all + * optional fields surfacing as TypeScript optionals. + * + * `apiVersion` is omitted because {@link buildManifest} fills it in. + */ +export interface ManifestInput { + name: string; + version: string; + entry: string; + capabilities?: string[]; + hooks?: HooksManifest; + jobs?: string[]; + requires?: RequiresManifest; + depends?: DependencyManifest[]; + signature?: string; + storage?: StorageManifest; +} + +/** + * Canonical manifest as serialized to `manifest.json`. Differs from + * {@link ManifestInput} in two ways: `apiVersion` is always present, and + * unset optional fields are omitted (matching the schema's + * `additionalProperties: false` posture). + */ +export interface Manifest extends ManifestInput { + apiVersion: typeof MANIFEST_API_VERSION; +} + +/** Validation problem surfaced by {@link buildManifest}. */ +export interface ManifestIssue { + path: string; + message: string; +} + +/** + * Error thrown by {@link buildManifest} when one or more fields fail + * validation. Carries the full list so the CLI can render every problem + * in one round trip. + */ +export class ManifestError extends Error { + override readonly name = 'ManifestError'; + readonly issues: readonly ManifestIssue[]; + constructor(issues: readonly ManifestIssue[]) { + super( + issues.length === 1 && issues[0] + ? `manifest: ${issues[0].path}: ${issues[0].message}` + : `manifest: ${issues.length} validation errors`, + ); + this.issues = issues; + } +} + +/** + * Validate `input` and return the canonical manifest. Throws + * {@link ManifestError} on any failure. + * + * Validation rules mirror `packages/go/plugins/manifest/schema.json` — + * the host re-validates with the canonical schema at install time, so + * this function is a courteous early gate, not a security boundary. + */ +export function buildManifest(input: ManifestInput): Manifest { + const issues: ManifestIssue[] = []; + + if (typeof input.name !== 'string' || !SLUG_RE.test(input.name)) { + issues.push({ + path: '/name', + message: + 'must be 3..41 chars: lowercase ASCII start, [a-z0-9-] thereafter', + }); + } + if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) { + issues.push({ + path: '/version', + message: 'must be a SemVer 2.0.0 string (e.g. 1.2.3 or 1.2.3-beta.1)', + }); + } + if (typeof input.entry !== 'string' || !ENTRY_RE.test(input.entry)) { + issues.push({ + path: '/entry', + message: 'must be a POSIX path ending in .wasm (e.g. plugin.wasm)', + }); + } else if (/(^|\/)\.\.(\/|$)/.test(input.entry)) { + issues.push({ path: '/entry', message: 'parent-traversal not allowed' }); + } + + if (input.capabilities) { + if (!Array.isArray(input.capabilities)) { + issues.push({ + path: '/capabilities', + message: 'must be an array of dotted-token strings', + }); + } else { + input.capabilities.forEach((c, i) => { + if (typeof c !== 'string' || !CAP_RE.test(c)) { + issues.push({ + path: `/capabilities/${i}`, + message: 'must match ^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$', + }); + } + }); + if ( + new Set(input.capabilities).size !== input.capabilities.length + ) { + issues.push({ + path: '/capabilities', + message: 'entries must be unique', + }); + } + } + } + + if (input.hooks) { + validateHookList(input.hooks.actions, '/hooks/actions', issues); + validateHookList(input.hooks.filters, '/hooks/filters', issues); + } + + if (input.jobs) { + if (!Array.isArray(input.jobs)) { + issues.push({ path: '/jobs', message: 'must be an array of strings' }); + } else { + input.jobs.forEach((j, i) => { + if (typeof j !== 'string' || !JOB_RE.test(j)) { + issues.push({ + path: `/jobs/${i}`, + message: 'must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*$', + }); + } + }); + if (new Set(input.jobs).size !== input.jobs.length) { + issues.push({ path: '/jobs', message: 'entries must be unique' }); + } + } + } + + if (input.requires) { + if (typeof input.requires.host !== 'string') { + issues.push({ + path: '/requires/host', + message: 'must be a semver range string', + }); + } else if (!SEMVER_RANGE_RE.test(input.requires.host)) { + issues.push({ + path: '/requires/host', + message: 'contains characters outside the semver-range vocabulary', + }); + } + } + + if (input.depends) { + if (!Array.isArray(input.depends)) { + issues.push({ + path: '/depends', + message: 'must be an array of {name, version} entries', + }); + } else { + input.depends.forEach((d, i) => { + if (!d || typeof d !== 'object') { + issues.push({ + path: `/depends/${i}`, + message: 'each entry must be an object', + }); + return; + } + if (typeof d.name !== 'string' || !SLUG_RE.test(d.name)) { + issues.push({ + path: `/depends/${i}/name`, + message: 'must be a plugin slug (same regex as /name)', + }); + } + if ( + typeof d.version !== 'string' || + !SEMVER_RANGE_RE.test(d.version) + ) { + issues.push({ + path: `/depends/${i}/version`, + message: 'must be a semver range string', + }); + } + }); + } + } + + if (input.signature !== undefined) { + if ( + typeof input.signature !== 'string' || + !SIGNATURE_RE.test(input.signature) + ) { + issues.push({ + path: '/signature', + message: 'must be 128 lowercase hex chars (ed25519)', + }); + } + } + + if (input.storage?.kv) { + const { max_bytes, max_keys } = input.storage.kv; + if (max_bytes !== undefined) { + if (!Number.isInteger(max_bytes) || max_bytes < 0) { + issues.push({ + path: '/storage/kv/max_bytes', + message: 'must be a non-negative integer', + }); + } + } + if (max_keys !== undefined) { + if (!Number.isInteger(max_keys) || max_keys < 0) { + issues.push({ + path: '/storage/kv/max_keys', + message: 'must be a non-negative integer', + }); + } + } + } + + if (issues.length > 0) { + throw new ManifestError(issues); + } + + return canonicalize(input); +} + +/** + * Render a validated manifest to its canonical JSON string. Field + * order matches the Go schema's declared order so signed bundles can + * round-trip without re-canonicalisation surprises. + * + * Indent is two spaces — same as the existing `examples/plugins/seo` + * manifest committed to the repo. + */ +export function manifestToJSON(manifest: Manifest): string { + return JSON.stringify(manifest, null, 2) + '\n'; +} + +function validateHookList( + list: string[] | undefined, + path: string, + issues: ManifestIssue[], +): void { + if (list === undefined) return; + if (!Array.isArray(list)) { + issues.push({ path, message: 'must be an array of dotted hook names' }); + return; + } + list.forEach((name, i) => { + if (typeof name !== 'string' || !HOOK_RE.test(name)) { + issues.push({ + path: `${path}/${i}`, + message: 'must match ^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$', + }); + } + }); + if (new Set(list).size !== list.length) { + issues.push({ path, message: 'entries must be unique' }); + } +} + +function canonicalize(input: ManifestInput): Manifest { + const out: Manifest = { + apiVersion: MANIFEST_API_VERSION, + name: input.name, + version: input.version, + entry: input.entry, + }; + if (input.capabilities && input.capabilities.length > 0) { + out.capabilities = [...input.capabilities]; + } + if (input.hooks) { + const hooks: HooksManifest = {}; + if (input.hooks.actions && input.hooks.actions.length > 0) { + hooks.actions = [...input.hooks.actions]; + } + if (input.hooks.filters && input.hooks.filters.length > 0) { + hooks.filters = [...input.hooks.filters]; + } + if (hooks.actions || hooks.filters) { + out.hooks = hooks; + } + } + if (input.jobs && input.jobs.length > 0) { + out.jobs = [...input.jobs]; + } + if (input.requires) { + out.requires = { host: input.requires.host }; + } + if (input.depends && input.depends.length > 0) { + out.depends = input.depends.map((d) => ({ + name: d.name, + version: d.version, + })); + } + if (input.signature) { + out.signature = input.signature; + } + if (input.storage?.kv) { + const kv: StorageManifest['kv'] = {}; + if (input.storage.kv.max_bytes !== undefined) { + kv.max_bytes = input.storage.kv.max_bytes; + } + if (input.storage.kv.max_keys !== undefined) { + kv.max_keys = input.storage.kv.max_keys; + } + if (kv.max_bytes !== undefined || kv.max_keys !== undefined) { + out.storage = { kv }; + } + } + return out; +} diff --git a/packages/ts/sdk-plugin/test/codec.test.ts b/packages/ts/sdk-plugin/test/codec.test.ts new file mode 100644 index 00000000..0429a102 --- /dev/null +++ b/packages/ts/sdk-plugin/test/codec.test.ts @@ -0,0 +1,148 @@ +/** + * Codec round-trip tests for the JSON envelope used by the GoNext + * plugin ABI. The Go side's golden is + * `packages/go/plugins/abi/hooks/marshal.go`; we cross-check the + * encoded shapes match what `MarshalActionPayload` / + * `MarshalFilterPayload` produce. + */ + +import { describe, expect, it } from 'vitest'; + +import { + CodecError, + PayloadKind, + ResultStatus, + marshalActionPayload, + marshalFilterPayload, + marshalFilterResult, + unmarshalActionPayload, + unmarshalFilterPayload, +} from '../src/codec.ts'; + +describe('marshalActionPayload', () => { + it('encodes an empty action payload', () => { + expect(marshalActionPayload()).toBe('{"kind":"action","args":[]}'); + }); + + it('normalises null args to an empty array', () => { + expect(marshalActionPayload(null)).toBe('{"kind":"action","args":[]}'); + }); + + it('preserves primitive args in order', () => { + expect(marshalActionPayload([1, 'two', true, null])).toBe( + '{"kind":"action","args":[1,"two",true,null]}', + ); + }); + + it('round-trips through unmarshalActionPayload', () => { + const args = [{ post_id: 42 }, ['tag-a', 'tag-b']]; + const wire = marshalActionPayload(args); + const decoded = unmarshalActionPayload(wire); + expect(decoded.kind).toBe(PayloadKind.Action); + expect(decoded.args).toEqual(args); + }); +}); + +describe('marshalFilterPayload', () => { + it('emits an explicit null when value is undefined', () => { + expect(marshalFilterPayload(undefined)).toBe( + '{"kind":"filter","value":null,"args":[]}', + ); + }); + + it('treats null value as a legitimate sentinel', () => { + expect(marshalFilterPayload(null)).toBe( + '{"kind":"filter","value":null,"args":[]}', + ); + }); + + it('encodes a complex value and extras', () => { + const wire = marshalFilterPayload( + { post_id: 7, body: 'hello' }, + ['ctx-a', 'ctx-b'], + ); + const decoded = unmarshalFilterPayload(wire); + expect(decoded.kind).toBe(PayloadKind.Filter); + expect(decoded.value).toEqual({ post_id: 7, body: 'hello' }); + expect(decoded.args).toEqual(['ctx-a', 'ctx-b']); + }); + + it('round-trips an array value', () => { + const wire = marshalFilterPayload([1, 2, 3], null); + const decoded = unmarshalFilterPayload(wire); + expect(decoded.value).toEqual([1, 2, 3]); + expect(decoded.args).toEqual([]); + }); +}); + +describe('marshalFilterResult', () => { + it('emits null when undefined', () => { + expect(marshalFilterResult(undefined)).toBe('{"value":null}'); + }); + + it('emits the value verbatim', () => { + expect(marshalFilterResult({ ok: true })).toBe('{"value":{"ok":true}}'); + }); + + it('preserves strings', () => { + expect(marshalFilterResult('hello')).toBe('{"value":"hello"}'); + }); +}); + +describe('unmarshalActionPayload — error paths', () => { + it('rejects malformed JSON with CodecError(BadPayload)', () => { + expect(() => unmarshalActionPayload('{nope')).toThrow(CodecError); + try { + unmarshalActionPayload('{nope'); + } catch (err) { + expect((err as CodecError).status).toBe(ResultStatus.BadPayload); + } + }); + + it('rejects payloads with the wrong kind', () => { + const wire = marshalFilterPayload(null); + expect(() => unmarshalActionPayload(wire)).toThrow(CodecError); + }); + + it('rejects payloads with non-array args', () => { + const wire = '{"kind":"action","args":"not-an-array"}'; + expect(() => unmarshalActionPayload(wire)).toThrow(CodecError); + }); + + it('rejects non-object roots', () => { + expect(() => unmarshalActionPayload('null')).toThrow(CodecError); + expect(() => unmarshalActionPayload('[]')).toThrow(CodecError); + }); +}); + +describe('unmarshalFilterPayload — error paths', () => { + it('rejects malformed JSON', () => { + expect(() => unmarshalFilterPayload('not json')).toThrow(CodecError); + }); + + it('rejects payloads missing the value field', () => { + const wire = '{"kind":"filter","args":[]}'; + expect(() => unmarshalFilterPayload(wire)).toThrow(CodecError); + }); + + it('rejects payloads with wrong kind', () => { + const wire = marshalActionPayload([]); + expect(() => unmarshalFilterPayload(wire)).toThrow(CodecError); + }); + + it('rejects payloads with non-array args', () => { + const wire = '{"kind":"filter","value":null,"args":42}'; + expect(() => unmarshalFilterPayload(wire)).toThrow(CodecError); + }); +}); + +describe('ResultStatus sentinels', () => { + it('matches the Go abi/hooks ResultStatus constants', () => { + expect(ResultStatus.OK).toBe(0); + expect(ResultStatus.Error).toBe(-1); + expect(ResultStatus.OutOfMemory).toBe(-2); + expect(ResultStatus.BadPayload).toBe(-3); + expect(ResultStatus.UnknownHook).toBe(-4); + expect(ResultStatus.Trap).toBe(-5); + }); +}); diff --git a/packages/ts/sdk-plugin/test/dispatch.test.ts b/packages/ts/sdk-plugin/test/dispatch.test.ts new file mode 100644 index 00000000..2a18a3f5 --- /dev/null +++ b/packages/ts/sdk-plugin/test/dispatch.test.ts @@ -0,0 +1,116 @@ +/** + * Dispatcher integration test. Exercises registerAction / + * registerFilter plus the dispatch() helper without touching the + * Javy bridge — the dispatcher is JSON-in, JSON-out, so we can drive + * it from a vanilla vitest case. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + ResultStatus, + _resetForTests, + dispatch, + marshalActionPayload, + marshalFilterPayload, + pluginInit, + registerAction, + registerFilter, +} from '../src/index.ts'; + +describe('dispatch', () => { + afterEach(() => _resetForTests()); + + it('routes to a registered action handler', async () => { + const seen: unknown[][] = []; + registerAction('save_post', (args) => { + seen.push(args); + }); + + const result = await dispatch( + 'save_post', + marshalActionPayload([{ post_id: 1 }]), + ); + + expect(result.status).toBe(ResultStatus.OK); + expect(result.body).toBeUndefined(); + expect(seen).toEqual([[{ post_id: 1 }]]); + }); + + it('routes to a registered filter handler and returns the value', async () => { + registerFilter('the_content', (value) => `${value}`); + + const result = await dispatch( + 'the_content', + marshalFilterPayload('hello'), + ); + + expect(result.status).toBe(ResultStatus.OK); + expect(result.body).toBe('{"value":"hello"}'); + }); + + it('returns UnknownHook for an unregistered hook', async () => { + const result = await dispatch( + 'never_registered', + marshalActionPayload(), + ); + expect(result.status).toBe(ResultStatus.UnknownHook); + }); + + it('returns BadPayload for malformed JSON', async () => { + registerAction('save_post', () => undefined); + const result = await dispatch('save_post', 'not json'); + expect(result.status).toBe(ResultStatus.BadPayload); + }); + + it('returns Error when a handler throws', async () => { + registerAction('save_post', () => { + throw new Error('boom'); + }); + const result = await dispatch('save_post', marshalActionPayload()); + expect(result.status).toBe(ResultStatus.Error); + }); + + it('awaits an async handler before responding', async () => { + let resolved = false; + registerFilter('async_filter', async (value) => { + await new Promise((r) => setTimeout(r, 1)); + resolved = true; + return `${value}!`; + }); + const result = await dispatch( + 'async_filter', + marshalFilterPayload('hi'), + ); + expect(resolved).toBe(true); + expect(result.body).toBe('{"value":"hi!"}'); + }); + + it('replaces a handler on re-registration', async () => { + registerFilter('the_content', () => 'first'); + registerFilter('the_content', () => 'second'); + const result = await dispatch('the_content', marshalFilterPayload(null)); + expect(result.body).toBe('{"value":"second"}'); + }); +}); + +describe('pluginInit', () => { + afterEach(() => { + _resetForTests(); + delete (globalThis as Record)['gn_handle_hook']; + }); + + it('installs the dispatcher onto globalThis', () => { + pluginInit(); + expect(typeof (globalThis as Record)['gn_handle_hook']).toBe( + 'function', + ); + }); + + it('lets handlers register either before or after pluginInit', async () => { + pluginInit(); + registerAction('post-init', () => undefined); + const result = await dispatch('post-init', marshalActionPayload()); + expect(result.status).toBe(ResultStatus.OK); + }); +}); diff --git a/packages/ts/sdk-plugin/test/manifest.test.ts b/packages/ts/sdk-plugin/test/manifest.test.ts new file mode 100644 index 00000000..98e95d98 --- /dev/null +++ b/packages/ts/sdk-plugin/test/manifest.test.ts @@ -0,0 +1,223 @@ +/** + * Validation tests for the TypeScript-side manifest builder. + * + * The host-side schema in `packages/go/plugins/manifest/schema.json` + * is authoritative; these tests pin the same constraints on the + * TypeScript surface so a plugin author catches mistakes locally + * BEFORE shipping the bundle. + */ + +import { describe, expect, it } from 'vitest'; + +import { + MANIFEST_API_VERSION, + ManifestError, + buildManifest, + manifestToJSON, + type ManifestInput, +} from '../src/manifest.ts'; + +function minimal(over?: Partial): ManifestInput { + return { + name: 'gn-hello', + version: '0.1.0', + entry: 'plugin.wasm', + ...over, + }; +} + +describe('buildManifest — happy path', () => { + it('produces a canonical manifest with apiVersion injected', () => { + const m = buildManifest(minimal()); + expect(m.apiVersion).toBe(MANIFEST_API_VERSION); + expect(m.name).toBe('gn-hello'); + expect(m.version).toBe('0.1.0'); + expect(m.entry).toBe('plugin.wasm'); + }); + + it('omits empty optional collections from the output', () => { + const m = buildManifest( + minimal({ capabilities: [], jobs: [], hooks: { actions: [] } }), + ); + expect(m.capabilities).toBeUndefined(); + expect(m.jobs).toBeUndefined(); + expect(m.hooks).toBeUndefined(); + }); + + it('round-trips through manifestToJSON', () => { + const m = buildManifest( + minimal({ + capabilities: ['kv.read', 'kv.write'], + hooks: { actions: ['save_post'], filters: ['the_content'] }, + requires: { host: '>=0.1.0' }, + }), + ); + const json = manifestToJSON(m); + const reparsed = JSON.parse(json); + expect(reparsed.apiVersion).toBe(MANIFEST_API_VERSION); + expect(reparsed.capabilities).toEqual(['kv.read', 'kv.write']); + expect(reparsed.hooks.actions).toEqual(['save_post']); + expect(reparsed.hooks.filters).toEqual(['the_content']); + expect(reparsed.requires).toEqual({ host: '>=0.1.0' }); + }); +}); + +describe('buildManifest — name validation', () => { + it.each([ + ['ab', 'too short'], + ['Hello', 'uppercase letter'], + ['hello!', 'illegal character'], + ['-leading-hyphen', 'leading hyphen'], + ['1numeric-start', 'numeric start'], + ])('rejects %s (%s)', (name) => { + expect(() => buildManifest(minimal({ name }))).toThrow(ManifestError); + }); + + it('accepts a typical slug', () => { + expect(() => buildManifest(minimal({ name: 'gn-seo-pro' }))).not.toThrow(); + }); +}); + +describe('buildManifest — semver validation', () => { + it('accepts strict semver', () => { + expect(() => buildManifest(minimal({ version: '1.2.3' }))).not.toThrow(); + expect(() => + buildManifest(minimal({ version: '1.0.0-beta.1+exp.sha.5114f85' })), + ).not.toThrow(); + }); + + it('rejects loose versions', () => { + expect(() => buildManifest(minimal({ version: '1.2' }))).toThrow(); + expect(() => buildManifest(minimal({ version: '01.2.3' }))).toThrow(); + expect(() => buildManifest(minimal({ version: 'v1.2.3' }))).toThrow(); + }); +}); + +describe('buildManifest — entry validation', () => { + it('rejects parent-traversal segments', () => { + expect(() => buildManifest(minimal({ entry: '../plugin.wasm' }))).toThrow( + ManifestError, + ); + expect(() => + buildManifest(minimal({ entry: 'subdir/../plugin.wasm' })), + ).toThrow(ManifestError); + }); + + it('requires a .wasm extension', () => { + expect(() => buildManifest(minimal({ entry: 'plugin.js' }))).toThrow(); + }); + + it('accepts nested POSIX paths', () => { + expect(() => + buildManifest(minimal({ entry: 'dist/plugin.wasm' })), + ).not.toThrow(); + }); +}); + +describe('buildManifest — capability validation', () => { + it('rejects non-dotted-token capabilities', () => { + expect(() => + buildManifest(minimal({ capabilities: ['Kv.Read'] })), + ).toThrow(); + expect(() => + buildManifest(minimal({ capabilities: ['kv.read.write!'] })), + ).toThrow(); + }); + + it('rejects duplicate capabilities', () => { + try { + buildManifest( + minimal({ capabilities: ['kv.read', 'kv.read', 'kv.write'] }), + ); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ManifestError); + const issues = (err as ManifestError).issues; + expect( + issues.find((i) => i.path === '/capabilities')?.message, + ).toMatch(/unique/); + } + }); +}); + +describe('buildManifest — hook validation', () => { + it('accepts underscores in hook names', () => { + expect(() => + buildManifest( + minimal({ hooks: { actions: ['save_post', 'wp_head'] } }), + ), + ).not.toThrow(); + }); + + it('rejects empty hook names', () => { + expect(() => + buildManifest(minimal({ hooks: { actions: [''] } })), + ).toThrow(); + }); + + it('rejects duplicates', () => { + expect(() => + buildManifest( + minimal({ hooks: { actions: ['save_post', 'save_post'] } }), + ), + ).toThrow(); + }); +}); + +describe('buildManifest — signature validation', () => { + it('accepts 128 lowercase hex chars', () => { + const sig = 'a'.repeat(128); + expect(() => buildManifest(minimal({ signature: sig }))).not.toThrow(); + }); + + it('rejects uppercase hex', () => { + expect(() => + buildManifest(minimal({ signature: 'A'.repeat(128) })), + ).toThrow(); + }); + + it('rejects wrong length', () => { + expect(() => + buildManifest(minimal({ signature: 'a'.repeat(127) })), + ).toThrow(); + }); +}); + +describe('buildManifest — storage validation', () => { + it('accepts numeric quotas', () => { + const m = buildManifest( + minimal({ storage: { kv: { max_bytes: 1024, max_keys: 100 } } }), + ); + expect(m.storage?.kv?.max_bytes).toBe(1024); + expect(m.storage?.kv?.max_keys).toBe(100); + }); + + it('rejects negative or fractional quotas', () => { + expect(() => + buildManifest(minimal({ storage: { kv: { max_bytes: -1 } } })), + ).toThrow(); + expect(() => + buildManifest(minimal({ storage: { kv: { max_keys: 1.5 } } })), + ).toThrow(); + }); +}); + +describe('ManifestError', () => { + it('aggregates every issue in a single throw', () => { + try { + buildManifest({ + name: 'Bad-Slug', + version: 'nope', + entry: 'plugin.wasm', + }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ManifestError); + const e = err as ManifestError; + expect(e.issues.length).toBeGreaterThanOrEqual(2); + const paths = e.issues.map((i) => i.path); + expect(paths).toContain('/name'); + expect(paths).toContain('/version'); + } + }); +}); diff --git a/packages/ts/sdk-plugin/tsconfig.json b/packages/ts/sdk-plugin/tsconfig.json new file mode 100644 index 00000000..3efd508c --- /dev/null +++ b/packages/ts/sdk-plugin/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist", "coverage", "bin"] +} diff --git a/packages/ts/sdk-plugin/vitest.config.ts b/packages/ts/sdk-plugin/vitest.config.ts new file mode 100644 index 00000000..c71d3291 --- /dev/null +++ b/packages/ts/sdk-plugin/vitest.config.ts @@ -0,0 +1,28 @@ +/** + * Vitest configuration for @gonext/sdk-plugin. + * + * Pure logic package — Node environment, no DOM. Coverage threshold + * matches the project-wide ≥85% bar. + */ +import { defineConfig, mergeConfig } from 'vitest/config'; +import { baseConfig } from '@gonext/test-config'; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.{test,spec}.ts'], + coverage: { + include: ['src/**/*.ts'], + exclude: ['src/**/*.d.ts'], + thresholds: { + lines: 85, + functions: 85, + branches: 80, + statements: 85, + }, + }, + }, + }), +);