@snaptrude/plugin-client 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ {
2
+ "package": "@snaptrude/plugin-client",
3
+ "latest": "0.8.0",
4
+ "spans": [
5
+ {
6
+ "from": "0.4.0",
7
+ "to": "0.5.0",
8
+ "file": "0.4.0-to-0.5.0.md",
9
+ "breaking": false
10
+ },
11
+ {
12
+ "from": "0.5.0",
13
+ "to": "0.6.0",
14
+ "file": "0.5.0-to-0.6.0.md",
15
+ "breaking": false
16
+ },
17
+ {
18
+ "from": "0.6.0",
19
+ "to": "0.7.0",
20
+ "file": "0.6.0-to-0.7.0.md",
21
+ "breaking": true
22
+ },
23
+ {
24
+ "from": "0.7.0",
25
+ "to": "0.7.1",
26
+ "file": "0.7.0-to-0.7.1.md",
27
+ "breaking": false
28
+ },
29
+ {
30
+ "from": "0.7.1",
31
+ "to": "0.8.0",
32
+ "file": "0.7.1-to-0.8.0.md",
33
+ "breaking": true
34
+ }
35
+ ]
36
+ }
package/AGENTS.md DELETED
@@ -1,86 +0,0 @@
1
- # Plugin Client — Agent Guide
2
-
3
- This package implements the plugin-core API for the **worker side** (plugin runtime).
4
- Plugins import `snaptrude` from this package to interact with the Snaptrude host.
5
-
6
- ## Relationship to plugin-core
7
-
8
- > Source of truth: [`packages/plugin-core/AGENTS.md`](../plugin-core/AGENTS.md)
9
-
10
- This package implements the API surface defined in `@snaptrude/plugin-core`. Only the
11
- root `PluginApi` class is extended; every namespace under it is satisfied structurally
12
- by a generic RPC proxy (see below). When plugin-core changes its top-level shape,
13
- this package must be updated to match.
14
-
15
- ## What this package contains
16
-
17
- - `src/api/index.ts` — `ClientPluginApi`: wires one generic RPC proxy per top-level namespace
18
- - `src/rpc-proxy.ts` — `createRpcNamespace()`: Proxy mapping dotted property paths to host RPC calls
19
- - `src/host-api.ts` — Comlink RPC wrapper (`HostApi.call()`); unwraps success/error results
20
- - `src/plugin-worker.ts` — `PluginWorker` base class (lifecycle, UI messaging, Comlink exposure)
21
- - `src/index.ts` — Exports the `snaptrude` singleton (`ClientPluginApi` instance)
22
-
23
- ## Quick commands
24
-
25
- ```bash
26
- pnpm check-types # typecheck (no emit)
27
- pnpm build # tsup build (ESM + CJS)
28
- pnpm dev # tsup watch mode
29
- ```
30
-
31
- ## Implementation patterns
32
-
33
- ### Everything is a generic RPC proxy (async, remote)
34
-
35
- Under the all-handle model there is no in-worker compute: `core.math.*` and
36
- `core.geom.*` values are opaque handles, so **every** namespace — `core.*`
37
- (including `core.units`), `design.*`, `entity.*`, `tools.*` — is a
38
- `createRpcNamespace<PluginXApi>("x")` Proxy. There are no hand-written
39
- per-method wrappers. Nested property access builds the dot-separated method
40
- path; the call marshals `{ method, args }` to the host via Comlink
41
- (`getHostApi().call()`):
42
-
43
- ```typescript
44
- // src/api/index.ts — one proxy per top-level namespace
45
- this.core = createRpcNamespace<PluginCoreApi>("core")
46
-
47
- // Plugin code — positional args (whole tuple crosses the wire), values are handles:
48
- const v = await snaptrude.core.math.vec3.new(1, 2, 3)
49
- await snaptrude.entity.space.createRectangular(position, dimensions)
50
- // → getHostApi().call({ method: "entity.space.createRectangular", args: [position, dimensions] })
51
- ```
52
-
53
- The proxy is structurally cast to the abstract API type, so argument and return
54
- types are enforced by plugin-core while dispatch stays dynamic. The `method`
55
- string is exactly the property path and must match `PluginApiMethod`
56
- (auto-inferred from the class hierarchy in `plugin-core/src/host-utils.ts`).
57
-
58
- ## When to update this package
59
-
60
- - **plugin-core adds/removes/changes a method**: Usually nothing to do here — the
61
- proxy dispatches dynamically and types flow from plugin-core. Rebuild plugin-core,
62
- then run `pnpm check-types` here.
63
- - **plugin-core adds a new top-level namespace on `PluginApi`**: Add a matching
64
- `createRpcNamespace<PluginNewApi>("new")` property in `ClientPluginApi`
65
- (`src/api/index.ts`). Nested modules under an existing namespace need no wiring.
66
-
67
- After making changes, always run `pnpm check-types` to verify everything compiles.
68
-
69
- ## File mapping (plugin-core -> plugin-client)
70
-
71
- There are no per-namespace files here anymore — the whole plugin-core API tree is
72
- served by two files:
73
-
74
- | plugin-core | plugin-client |
75
- | --------------------------------------------- | -------------------------------------------------------------------- |
76
- | `src/api/**` (all namespaces, e.g. `core.math.vec3`) | `src/api/index.ts` (`ClientPluginApi`, one `createRpcNamespace` each) |
77
- | `src/host-utils.ts` (`PluginApiMethod` types) | `src/rpc-proxy.ts` + `src/host-api.ts` (dispatch + Comlink transport) |
78
-
79
- ## Downstream consumers
80
-
81
- Changes to this package's **public exports** affect:
82
-
83
- - Internal plugins under `plugins/`
84
- - Example plugins under `examples/`
85
-
86
- If you change or remove an export, search these directories for usage.
package/CLAUDE.md DELETED
@@ -1,11 +0,0 @@
1
- # CLAUDE.md
2
-
3
- > **All coding standards, checks, and guidance live in [`AGENTS.md`](AGENTS.md).**
4
- > `CLAUDE.md` is a pointer only — add all new rules, patterns, and instructions to `AGENTS.md`.
5
-
6
- See [AGENTS.md](AGENTS.md) for:
7
-
8
- - Implementation pattern (generic RPC proxy — every namespace is async host RPC)
9
- - File mapping from plugin-core to plugin-client
10
- - When and how to update this package in response to plugin-core changes
11
- - Downstream consumer notes
package/src/api/index.ts DELETED
@@ -1,45 +0,0 @@
1
- import {
2
- PluginApi,
3
- PluginCoreApi,
4
- PluginDesignApi,
5
- PluginEntityApi,
6
- PluginProgramApi,
7
- PluginPresentationApi,
8
- PluginAnalysisApi,
9
- } from "@snaptrude/plugin-core"
10
- import { createRpcNamespace } from "../rpc-proxy"
11
-
12
- export class ClientPluginApi extends PluginApi {
13
- private static instance: ClientPluginApi
14
-
15
- /**
16
- * Every namespace is fully remote under the all-handle model: math/geom now
17
- * cross to the host (values are opaque handles), so there is no in-worker
18
- * compute left. All dispatch through a single generic RPC Proxy. Units live
19
- * under `core.units`, so they ride the `core` proxy.
20
- */
21
- public core: PluginCoreApi
22
- public design: PluginDesignApi
23
- public entity: PluginEntityApi
24
- public program: PluginProgramApi
25
- public presentation: PluginPresentationApi
26
- public analysis: PluginAnalysisApi
27
-
28
- private constructor() {
29
- super()
30
- this.core = createRpcNamespace<PluginCoreApi>("core")
31
- this.design = createRpcNamespace<PluginDesignApi>("design")
32
- this.entity = createRpcNamespace<PluginEntityApi>("entity")
33
- this.program = createRpcNamespace<PluginProgramApi>("program")
34
- this.presentation =
35
- createRpcNamespace<PluginPresentationApi>("presentation")
36
- this.analysis = createRpcNamespace<PluginAnalysisApi>("analysis")
37
- }
38
-
39
- static getInstance(): ClientPluginApi {
40
- if (!ClientPluginApi.instance) {
41
- ClientPluginApi.instance = new ClientPluginApi()
42
- }
43
- return ClientPluginApi.instance
44
- }
45
- }
package/src/host-api.ts DELETED
@@ -1,87 +0,0 @@
1
- import * as Comlink from "comlink"
2
- import type {
3
- PluginApiMethod,
4
- PluginApiCallPayload,
5
- PluginApiCallWrappedResult,
6
- PluginApiCallResult,
7
- } from "@snaptrude/plugin-core"
8
- import { PluginError, fromEnvelope, makeClientEnvelope } from "@snaptrude/plugin-core"
9
-
10
- export interface HostApi {
11
- call<M extends PluginApiMethod>(
12
- payload: PluginApiCallPayload<M>
13
- ): Promise<PluginApiCallWrappedResult<M>>
14
- }
15
-
16
- export interface HostApiWrapped {
17
- call<M extends PluginApiMethod>(
18
- payload: PluginApiCallPayload<M>
19
- ): Promise<PluginApiCallResult<M>>
20
- }
21
-
22
- export function createHostApi(endpoint?: Comlink.Endpoint): HostApi {
23
- return Comlink.wrap<HostApi>(
24
- endpoint ?? (globalThis as unknown as Comlink.Endpoint)
25
- ) as unknown as HostApi
26
- }
27
-
28
- let _instance: HostApi | null = null
29
-
30
- /** TEST SEAM ONLY: replace the Comlink host instance (pass `undefined` to reset). */
31
- export function __setHostApiInstance(instance?: HostApi): void {
32
- _instance = instance ?? null
33
- }
34
-
35
- export function getHostApi(): HostApiWrapped {
36
- if (!_instance) {
37
- _instance = createHostApi()
38
- }
39
- return {
40
- call: async <M extends PluginApiMethod>(payload: PluginApiCallPayload<M>): Promise<PluginApiCallResult<M>> => {
41
- if (!_instance) {
42
- throw new Error("Host API not initialized")
43
- }
44
-
45
- let result: PluginApiCallWrappedResult<M>
46
- try {
47
- result = await _instance.call(payload)
48
- } catch (transportErr) {
49
- // Comlink-level rejection: port closed, worker terminated, clone
50
- // failure. Never a routed failure — the router always RETURNS its
51
- // envelope — so normalize to a typed transport error.
52
- if (PluginError.is(transportErr)) throw transportErr
53
- throw rehydrate(
54
- makeClientEnvelope(
55
- "TRANSPORT_LOST",
56
- transportErr instanceof Error ? transportErr.message : String(transportErr),
57
- { methodPath: payload.method }
58
- )
59
- )
60
- }
61
-
62
- if (result.success) {
63
- return result.data
64
- }
65
-
66
- // Structured envelope when the host provides one; legacy hosts (string
67
- // `error` only) degrade to UNKNOWN with the message preserved.
68
- throw rehydrate(
69
- result.errorInfo ??
70
- makeClientEnvelope("UNKNOWN", result.error ?? "Unknown host error", {
71
- methodPath: payload.method,
72
- })
73
- )
74
- }
75
- }
76
- }
77
-
78
- /**
79
- * Envelope → typed `PluginError`, with the stack trimmed to the plugin's call
80
- * site (V8 only; harmless no-op elsewhere) instead of transport internals.
81
- */
82
- function rehydrate(envelope: Parameters<typeof fromEnvelope>[0]): PluginError {
83
- const error = fromEnvelope(envelope)
84
- ;(Error as { captureStackTrace?: (target: object, ctor: Function) => void })
85
- .captureStackTrace?.(error, rehydrate)
86
- return error
87
- }
package/src/index.ts DELETED
@@ -1,38 +0,0 @@
1
- import { ClientPluginApi } from "./api"
2
-
3
- export * from "./api"
4
- export * from "./host-api"
5
- export * from "./plugin-worker"
6
-
7
- // Error surface — plugins branch on `PluginError.is(e)` + `e.code`.
8
- export {
9
- PluginError,
10
- PluginValidationError,
11
- PluginNotFoundError,
12
- PluginPermissionError,
13
- PluginHandleError,
14
- PluginQuotaError,
15
- PluginTimeoutError,
16
- PluginTransportError,
17
- PluginLifecycleError,
18
- PluginExecutionError,
19
- PluginInternalError,
20
- fromEnvelope,
21
- isErrorEnvelope,
22
- isPluginErrorCode,
23
- PLUGIN_ERROR_CODES,
24
- CODE_META,
25
- } from "@snaptrude/plugin-core"
26
- export type {
27
- ErrorEnvelope,
28
- PluginErrorCode,
29
- WirePluginErrorCode,
30
- PluginErrorCategory,
31
- } from "@snaptrude/plugin-core"
32
-
33
- /**
34
- * The Snaptrude plugin client API.
35
- *
36
- * The main entry point for plugins to interact with the Snaptrude platform.
37
- */
38
- export const snaptrude = ClientPluginApi.getInstance()
@@ -1,99 +0,0 @@
1
- import * as Comlink from "comlink"
2
-
3
- export interface UIMessage {
4
- action: string
5
- payload: unknown
6
- }
7
-
8
- interface PluginConfig {
9
- pluginId: string
10
- }
11
-
12
- /**
13
- * Base class for Snaptrude plugin workers.
14
- *
15
- * Handles Comlink wiring, host communication, and the standard lifecycle
16
- * methods (`init`, `destroy`, `ping`, `onUIMessage`). Subclass this and
17
- * override only the methods you need — then call `start()` to expose the
18
- * worker API.
19
- *
20
- * The plugin ID is received automatically from the host during
21
- * initialization — no need to pass it manually.
22
- *
23
- * @example
24
- * ```ts
25
- * import { PluginWorker } from "@snaptrude/plugin-client";
26
- *
27
- * class MyPlugin extends PluginWorker {
28
- * async onUIMessage(message: UIMessage) {
29
- * // handle messages from the UI panel
30
- * }
31
- * }
32
- *
33
- * new MyPlugin().start();
34
- * ```
35
- */
36
- export abstract class PluginWorker {
37
- protected pluginId!: string
38
- private hostAPI: Comlink.Remote<Record<string, unknown>>
39
-
40
- constructor() {
41
- this.hostAPI = Comlink.wrap<Record<string, unknown>>(
42
- self as unknown as Comlink.Endpoint
43
- )
44
- }
45
-
46
- protected sendToUI(action: string, payload: unknown): void {
47
- ;(this.hostAPI as Record<string, any>).ui.sendToUI({ action, payload })
48
- }
49
-
50
- /**
51
- * Signal the host that this plugin has finished its work and should be
52
- * stopped. Use this in headless (UI-less) plugins that run a task and
53
- * self-terminate.
54
- */
55
- protected complete(): void {
56
- ;(this.hostAPI as Record<string, any>).lifecycle.complete()
57
- }
58
-
59
- async init(): Promise<void> {
60
- console.log(this.pluginId, "init() called")
61
- console.log(this.pluginId, "Initialization complete")
62
- }
63
-
64
- async destroy(): Promise<void> {
65
- console.log(this.pluginId, "destroy() called — cleaning up")
66
- }
67
-
68
- async ping(): Promise<string> {
69
- return "pong"
70
- }
71
-
72
- async onUIMessage(_message: UIMessage): Promise<void> {
73
- // Override in subclass to handle UI messages
74
- }
75
-
76
- /**
77
- * Expose the worker API via Comlink and start listening.
78
- * Call this once after constructing the plugin instance.
79
- *
80
- * The host calls `init(config)` with `{ pluginId }`,
81
- * which is captured here to set `this.pluginId` before the
82
- * subclass's `init()` runs.
83
- */
84
- start(): void {
85
- Comlink.expose(
86
- {
87
- init: (config: PluginConfig) => {
88
- this.pluginId = config.pluginId
89
- return this.init()
90
- },
91
- destroy: () => this.destroy(),
92
- ping: () => this.ping(),
93
- onUIMessage: (message: UIMessage) => this.onUIMessage(message),
94
- },
95
- self as unknown as Comlink.Endpoint
96
- )
97
- console.log("Worker loaded, API exposed via Comlink")
98
- }
99
- }
package/src/rpc-proxy.ts DELETED
@@ -1,56 +0,0 @@
1
- import type {
2
- PluginApiCallPayload,
3
- PluginApiMethod,
4
- } from "@snaptrude/plugin-core"
5
- import { getHostApi } from "./host-api"
6
-
7
- /**
8
- * Build a namespace object whose nested property access maps to a
9
- * dot-separated host RPC method path, and whose every call dispatches that
10
- * path through the host bridge.
11
- *
12
- * The host exposes the entire plugin API behind a single generic `call()`
13
- * (see the host `bridge.ts`), and the method string is exactly the property
14
- * path — so one Proxy replaces every hand-written per-method RPC wrapper for
15
- * every namespace (`core.*`, `design.*`, `entity.*`):
16
- *
17
- * The POSITIONAL transport forwards the whole argument tuple; the host router
18
- * spreads it back into the resolved method (`fn(...args)`):
19
- *
20
- * ```ts
21
- * snaptrude.core.math.vec3.new(1, 2, 3)
22
- * // → getHostApi().call({ method: "core.math.vec3.new", args: [1, 2, 3] })
23
- * ```
24
- *
25
- * Typed at the call site, e.g. `createRpcNamespace<PluginEntityApi>("entity")`.
26
- * The Proxy is structurally cast to the abstract API type — argument and
27
- * return types are enforced by that type, while dispatch is dynamic.
28
- *
29
- * Every namespace uses this — including `core.math.*` and `core.geom.*`: under
30
- * the all-handle model there is no in-worker compute; math and geometry are
31
- * host calls like everything else.
32
- */
33
- export function createRpcNamespace<T extends object>(basePath: string): T {
34
- const build = (path: string): unknown =>
35
- new Proxy(NOOP, {
36
- get(_target, prop) {
37
- // Symbols and `then` must not resolve to a callable proxy, otherwise
38
- // the namespace would look thenable and break Promise resolution if it
39
- // ever reached an `await`.
40
- if (typeof prop !== "string" || prop === "then") return undefined
41
- return build(`${path}.${prop}`)
42
- },
43
- apply(_target, _thisArg, argArray: unknown[]) {
44
- const payload = {
45
- method: path,
46
- args: argArray,
47
- } as unknown as PluginApiCallPayload<PluginApiMethod>
48
- return getHostApi().call(payload)
49
- },
50
- })
51
-
52
- return build(basePath) as T
53
- }
54
-
55
- /** Proxy target must be callable for the `apply` trap; identity is irrelevant. */
56
- const NOOP = (): void => {}
@@ -1,101 +0,0 @@
1
- // Node built-in test runner suite for host-api error rehydration.
2
- // Run: node --test test/host-api-errors.test.mjs
3
- // Imports the BUILT package (dist) — run `pnpm build` first.
4
- import test from "node:test"
5
- import assert from "node:assert/strict"
6
- import {
7
- getHostApi,
8
- __setHostApiInstance,
9
- PluginError,
10
- PluginQuotaError,
11
- PluginTransportError,
12
- PluginInternalError,
13
- } from "../dist/index.js"
14
-
15
- const PAYLOAD = { method: "design.boolean.union", args: [] }
16
-
17
- /** Install a fake Comlink instance whose call() resolves/rejects as directed. */
18
- function stubHost(behavior) {
19
- __setHostApiInstance({ call: behavior })
20
- }
21
-
22
- test.afterEach(() => {
23
- __setHostApiInstance(undefined)
24
- })
25
-
26
- test("success path returns data verbatim (handles are branded strings on the wire)", async () => {
27
- const data = { value: 42, h: "mass_x" }
28
- stubHost(async () => ({ success: true, data }))
29
- const result = await getHostApi().call(PAYLOAD)
30
- assert.equal(result, data) // pass-through: no cloning, no re-wrapping
31
- assert.equal(result.value, 42)
32
- assert.equal(result.h, "mass_x") // a handle IS its plain string id
33
- })
34
-
35
- test("errorInfo envelope rehydrates into the typed subclass with fields intact", async () => {
36
- stubHost(async () => ({
37
- success: false,
38
- error: "Execution error: Rate limit exceeded",
39
- errorInfo: {
40
- envelopeVersion: 1,
41
- code: "RATE_LIMITED",
42
- message: "Rate limit exceeded",
43
- errorId: "h-1",
44
- details: { retryAfterMs: 250 },
45
- methodPath: "design.boolean.union",
46
- },
47
- }))
48
- await assert.rejects(getHostApi().call(PAYLOAD), (err) => {
49
- assert.equal(PluginError.is(err), true)
50
- assert.ok(err instanceof PluginQuotaError)
51
- assert.equal(err.code, "RATE_LIMITED")
52
- assert.equal(err.errorId, "h-1")
53
- assert.deepEqual(err.details, { retryAfterMs: 250 })
54
- assert.equal(err.methodPath, "design.boolean.union")
55
- return true
56
- })
57
- })
58
-
59
- test("unknown envelope code still rehydrates as a PluginError (forward compat)", async () => {
60
- stubHost(async () => ({
61
- success: false,
62
- error: "Execution error: new thing",
63
- errorInfo: {
64
- envelopeVersion: 1,
65
- code: "FUTURE_CODE",
66
- message: "new thing",
67
- errorId: "h-2",
68
- },
69
- }))
70
- await assert.rejects(getHostApi().call(PAYLOAD), (err) => {
71
- assert.equal(PluginError.is(err), true)
72
- assert.equal(err.code, "FUTURE_CODE")
73
- return true
74
- })
75
- })
76
-
77
- test("legacy host (string error only) degrades to UNKNOWN with the message preserved", async () => {
78
- stubHost(async () => ({ success: false, error: "Execution error: something broke" }))
79
- await assert.rejects(getHostApi().call(PAYLOAD), (err) => {
80
- assert.equal(PluginError.is(err), true)
81
- assert.ok(err instanceof PluginInternalError)
82
- assert.equal(err.code, "UNKNOWN")
83
- assert.equal(err.message, "Execution error: something broke")
84
- assert.ok(err.errorId.startsWith("c-"))
85
- assert.equal(err.methodPath, "design.boolean.union")
86
- return true
87
- })
88
- })
89
-
90
- test("transport-level rejection normalizes to TRANSPORT_LOST", async () => {
91
- stubHost(async () => {
92
- throw new Error("port closed")
93
- })
94
- await assert.rejects(getHostApi().call(PAYLOAD), (err) => {
95
- assert.ok(err instanceof PluginTransportError)
96
- assert.equal(err.code, "TRANSPORT_LOST")
97
- assert.equal(err.message, "port closed")
98
- assert.ok(err.errorId.startsWith("c-"))
99
- return true
100
- })
101
- })
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "module": "ES2020",
5
- "moduleResolution": "bundler",
6
- "lib": ["ES2020", "WebWorker"],
7
- "strict": true,
8
- "esModuleInterop": true,
9
- "skipLibCheck": true,
10
- "emitDeclarationOnly": true,
11
- "typeRoots": [],
12
- "declaration": true,
13
- "declarationMap": true,
14
- "outDir": "dist"
15
- },
16
- "include": ["src/**/*.ts"]
17
- }
package/tsup.config.ts DELETED
@@ -1,12 +0,0 @@
1
- import { defineConfig } from "tsup"
2
-
3
- export default defineConfig( (options) => {
4
- return {
5
- entry: ["src/index.ts"],
6
- format: ["cjs", "esm"],
7
- dts: false,
8
- sourcemap: true,
9
- onSuccess: "tsc", // Run tsc to generate d.ts and d.ts.map
10
- watch: options.watch,
11
- }
12
- })