@standardagents/code-plugin-sdk 0.0.0-stub.0 → 1.0.0-alpha.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.
- package/README.md +90 -3
- package/package.json +16 -4
- package/src/index.d.ts +264 -0
- package/src/index.mjs +9 -0
- package/src/internal.d.ts +39 -0
- package/src/internal.mjs +5 -0
- package/src/manifest.mjs +86 -0
- package/src/protocol.mjs +166 -0
- package/src/runtime.mjs +354 -0
- package/src/testing.d.ts +24 -0
- package/src/testing.mjs +104 -0
package/README.md
CHANGED
|
@@ -1,4 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Standard Code plugin SDK
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
`@standardagents/code-plugin-sdk` is the authoring package for Standard Code
|
|
4
|
+
plugins. Standard Code bundles the SDK runtime into its signed release. The
|
|
5
|
+
manifest `apiVersion` gates compatibility between a plugin and the product.
|
|
6
|
+
|
|
7
|
+
A plugin exports `definePlugin({ id, activate })` as its default export.
|
|
8
|
+
Its `package.json` includes a static `standardPlugin` manifest.
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"name": "example-status",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"peerDependencies": { "@standardagents/code-plugin-sdk": "^1.0.0-alpha.0" },
|
|
15
|
+
"devDependencies": { "@standardagents/code-plugin-sdk": "^1.0.0-alpha.0" },
|
|
16
|
+
"standardPlugin": {
|
|
17
|
+
"apiVersion": 1,
|
|
18
|
+
"id": "example-status",
|
|
19
|
+
"name": "Example status",
|
|
20
|
+
"version": "0.1.0",
|
|
21
|
+
"entry": "./index.ts",
|
|
22
|
+
"capabilities": ["surfaces"],
|
|
23
|
+
"contributions": [{ "id": "status", "kind": "section", "anchor": "plugins" }]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { definePlugin } from '@standardagents/code-plugin-sdk'
|
|
30
|
+
|
|
31
|
+
export default definePlugin({
|
|
32
|
+
id: 'example-status',
|
|
33
|
+
activate(ctx) {
|
|
34
|
+
const section = ctx.section('status')
|
|
35
|
+
section.replace({ kind: 'rows', rows: [{ id: 'ready', spans: [{ text: 'Ready' }] }] })
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The SDK is a peer dependency. Inside Standard Code the exact specifier
|
|
41
|
+
`@standardagents/code-plugin-sdk` resolves to the bundled copy, so a plugin
|
|
42
|
+
never loads a second runtime from `node_modules`. Subpath imports of the SDK
|
|
43
|
+
package, such as `@standardagents/code-plugin-sdk/testing`, are refused when
|
|
44
|
+
the plugin runs inside Standard Code. The `testing` export serves the
|
|
45
|
+
plugin's own test suite.
|
|
46
|
+
|
|
47
|
+
The runtime disposes publishers, subscriptions, schedules and pending requests.
|
|
48
|
+
Plugins register additional cleanup through `ctx.onDispose` or an activation return value.
|
|
49
|
+
Handlers receive an abort signal.
|
|
50
|
+
Schedules allow one invocation at a time.
|
|
51
|
+
|
|
52
|
+
Capabilities control SDK and daemon operations.
|
|
53
|
+
Plugins retain Node access to files, networking and subprocesses under the user's identity.
|
|
54
|
+
Worker threads provide JavaScript fault isolation.
|
|
55
|
+
Memory exhaustion outside V8 limits and native crashes can affect the runner process.
|
|
56
|
+
|
|
57
|
+
## Testing
|
|
58
|
+
|
|
59
|
+
`@standardagents/code-plugin-sdk/testing` exports `createHarness`.
|
|
60
|
+
It uses explicit fixture handlers and a manual clock.
|
|
61
|
+
`activate`, `emit`, `visibility`, `advance`, `flush` and `dispose` drive its runtime.
|
|
62
|
+
`trace`, `surfaces`, `subscriptions` and `resources` expose recorded behavior.
|
|
63
|
+
`drainTrace` clears the bounded trace.
|
|
64
|
+
The harness starts no subprocesses.
|
|
65
|
+
|
|
66
|
+
## Supported interface
|
|
67
|
+
|
|
68
|
+
The public interface is the package's default export, its `testing` export,
|
|
69
|
+
and the declarations in `src/index.d.ts` and `src/testing.d.ts`.
|
|
70
|
+
|
|
71
|
+
The harness records the frames that the SDK runtime exchanges with its host,
|
|
72
|
+
and `harness.receive` accepts such a frame. That wire protocol between the
|
|
73
|
+
SDK runtime and the Standard Code plugin runner is visible through the
|
|
74
|
+
testing helpers, and it is not a supported public interface. Its frame
|
|
75
|
+
shapes, operation names, limits and version can change in any release
|
|
76
|
+
without notice. Plugin code and plugin tests should treat recorded frames as
|
|
77
|
+
opaque values and drive the plugin through `PluginContext` and the harness
|
|
78
|
+
methods.
|
|
79
|
+
|
|
80
|
+
Local `ctx.state` belongs to one machine.
|
|
81
|
+
The public context declarations are in `src/index.d.ts`.
|
|
82
|
+
|
|
83
|
+
## Releases
|
|
84
|
+
|
|
85
|
+
The Standard Code build workflow publishes this package when the version in
|
|
86
|
+
`package.json` changes. Prereleases publish under the `next` dist-tag and
|
|
87
|
+
releases under `latest`.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT. See `LICENSE`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@standardagents/code-plugin-sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
3
|
+
"version": "1.0.0-alpha.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Standard Code plugin authoring SDK",
|
|
5
6
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/standardagents/code-rs.git",
|
|
10
|
+
"directory": "packages/plugin-sdk"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": { "access": "public" },
|
|
13
|
+
"engines": { "node": ">=20" },
|
|
14
|
+
"exports": {
|
|
15
|
+
".": { "types": "./src/index.d.ts", "import": "./src/index.mjs" },
|
|
16
|
+
"./testing": { "types": "./src/testing.d.ts", "import": "./src/testing.mjs" }
|
|
17
|
+
},
|
|
18
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
19
|
+
"scripts": { "test": "node --test test/*.test.mjs" }
|
|
8
20
|
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
2
|
+
export type Capability = 'surfaces' | 'events' | 'hooks' | 'panes' | 'projects' |
|
|
3
|
+
'notifications' | 'url' | 'fetch' | 'secrets' | 'webhook';
|
|
4
|
+
export type SurfaceKind = 'section' | 'slot' | 'badge' | 'panel' | 'overlay' |
|
|
5
|
+
'menu' | 'command' | 'key' | 'link';
|
|
6
|
+
export type MenuPosition = 'top' | 'after-open' | 'before-danger' | 'bottom';
|
|
7
|
+
export type Anchor = 'plugins' | 'machine.before' | 'machine.after' |
|
|
8
|
+
'project.before' | 'project.after' | 'pane.header' | 'pane.footer' |
|
|
9
|
+
'account' | 'machine' | 'project' | 'pane' | 'section';
|
|
10
|
+
export interface ContributionDeclaration {
|
|
11
|
+
id: string;
|
|
12
|
+
kind: SurfaceKind;
|
|
13
|
+
anchor: Anchor;
|
|
14
|
+
title?: string;
|
|
15
|
+
merge?: 'by-machine' | 'by-identity';
|
|
16
|
+
width?: 'full' | 'half';
|
|
17
|
+
position?: MenuPosition;
|
|
18
|
+
group?: string;
|
|
19
|
+
chord?: string;
|
|
20
|
+
pattern?: string;
|
|
21
|
+
actionId?: string;
|
|
22
|
+
}
|
|
23
|
+
/** The package.json standardPlugin field is read before any plugin code runs. */
|
|
24
|
+
export interface PluginManifest {
|
|
25
|
+
apiVersion: 1;
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
version: string;
|
|
29
|
+
entry: string;
|
|
30
|
+
singleton: boolean;
|
|
31
|
+
order: number;
|
|
32
|
+
capabilities: Capability[];
|
|
33
|
+
contributions: ContributionDeclaration[];
|
|
34
|
+
configSchema?: { [key: string]: Json };
|
|
35
|
+
hookTimeoutMs: number;
|
|
36
|
+
}
|
|
37
|
+
export type ManifestInput = Pick<PluginManifest, 'apiVersion' | 'id' | 'name' | 'version' | 'entry'> &
|
|
38
|
+
Partial<Omit<PluginManifest, 'apiVersion' | 'id' | 'name' | 'version' | 'entry'>>;
|
|
39
|
+
export interface EntityRef {
|
|
40
|
+
kind: 'account' | 'machine' | 'project' | 'pane' | 'section';
|
|
41
|
+
id: string;
|
|
42
|
+
machineId?: string;
|
|
43
|
+
/** Decimal strings preserve 64-bit unsigned values across the JSON boundary. */
|
|
44
|
+
generation?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface Producer {
|
|
47
|
+
pluginId: string;
|
|
48
|
+
machineId: string;
|
|
49
|
+
epoch: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ContributionKey {
|
|
52
|
+
contributionId: string;
|
|
53
|
+
anchor: Anchor;
|
|
54
|
+
entity?: EntityRef;
|
|
55
|
+
}
|
|
56
|
+
export interface TextSpan {
|
|
57
|
+
text: string;
|
|
58
|
+
foreground?: string;
|
|
59
|
+
background?: string;
|
|
60
|
+
bold?: boolean;
|
|
61
|
+
italic?: boolean;
|
|
62
|
+
underline?: boolean;
|
|
63
|
+
actionId?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface NativeRow {
|
|
66
|
+
id: string;
|
|
67
|
+
identity?: string;
|
|
68
|
+
providerRevision?: string;
|
|
69
|
+
spans: TextSpan[];
|
|
70
|
+
actionId?: string;
|
|
71
|
+
meter?: { value: number; max: number; label?: string };
|
|
72
|
+
spark?: number[];
|
|
73
|
+
divider?: boolean;
|
|
74
|
+
}
|
|
75
|
+
export type NativeContent = { kind: 'rows'; rows: NativeRow[] } |
|
|
76
|
+
{ kind: 'text'; lines: TextSpan[][] } |
|
|
77
|
+
{ kind: 'badge'; spans: TextSpan[]; actionId?: string };
|
|
78
|
+
export interface CanvasSpec {
|
|
79
|
+
columns: number;
|
|
80
|
+
rows: number;
|
|
81
|
+
transparent?: boolean;
|
|
82
|
+
shade?: number;
|
|
83
|
+
captureInput?: boolean;
|
|
84
|
+
}
|
|
85
|
+
export type SurfaceContent = NativeContent | { kind: 'canvas'; canvas: CanvasSpec };
|
|
86
|
+
export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
|
|
87
|
+
export interface Disposable { dispose(): void }
|
|
88
|
+
export type Cleanup = () => void | Promise<void>;
|
|
89
|
+
export type Condition = { kind: 'always' } |
|
|
90
|
+
{ kind: 'section-visible' | 'slot-visible' | 'panel-open'; contributionId: string; entity?: EntityRef };
|
|
91
|
+
export interface LaunchSpec {
|
|
92
|
+
version: 1;
|
|
93
|
+
argv: string[];
|
|
94
|
+
cwd: string;
|
|
95
|
+
env: Record<string, string>;
|
|
96
|
+
agent?: { id: string; prompt?: string; resumeId?: string; delivery: 'argv' | 'stdin' };
|
|
97
|
+
bootstrap?: { argv: string[]; on: 'create' };
|
|
98
|
+
}
|
|
99
|
+
export interface PaneCreate {
|
|
100
|
+
machineId: string;
|
|
101
|
+
projectId?: string;
|
|
102
|
+
launch: LaunchSpec;
|
|
103
|
+
title?: string;
|
|
104
|
+
presentation?: 'workspace' | 'popup';
|
|
105
|
+
restart?: 'never' | 'on-failure';
|
|
106
|
+
contributionId?: string;
|
|
107
|
+
}
|
|
108
|
+
export interface PaneResult { pane: EntityRef; operationId: string }
|
|
109
|
+
export interface Selection { entity?: EntityRef; actionId: string; value?: Json }
|
|
110
|
+
export interface LinkSelection extends Selection { url: string }
|
|
111
|
+
export type ActionHandler = (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>;
|
|
112
|
+
/** True consumes the URL; false allows the next matching handler to run. */
|
|
113
|
+
export type LinkHandler = (event: LinkSelection, context: HandlerContext) => boolean | Promise<boolean>;
|
|
114
|
+
export interface RegistrationOptions {
|
|
115
|
+
entity?: EntityRef;
|
|
116
|
+
actionId?: string;
|
|
117
|
+
title?: string;
|
|
118
|
+
condition?: Condition;
|
|
119
|
+
}
|
|
120
|
+
export interface MenuOptions extends RegistrationOptions { position?: MenuPosition }
|
|
121
|
+
export interface CommandOptions extends RegistrationOptions { group?: string }
|
|
122
|
+
/** Chords use the native serialized form, such as ctrl+k or ctrl+b w. */
|
|
123
|
+
export interface KeyOptions extends RegistrationOptions { chord?: string }
|
|
124
|
+
/** Patterns are bounded URL match expressions interpreted by the host. */
|
|
125
|
+
export interface LinkOptions extends RegistrationOptions { pattern?: string }
|
|
126
|
+
export interface GroupCommand extends RegistrationOptions { id: string; handler: ActionHandler }
|
|
127
|
+
/** Registration metadata travels with an action subscription. */
|
|
128
|
+
export type ActionRegistration = ContributionKey & { actionId: string; title: string } & (
|
|
129
|
+
{ kind: 'menu'; position: MenuPosition } |
|
|
130
|
+
{ kind: 'command'; group?: string } |
|
|
131
|
+
{ kind: 'key'; chord: string } |
|
|
132
|
+
{ kind: 'link'; pattern: string }
|
|
133
|
+
);
|
|
134
|
+
export interface HookEvent {
|
|
135
|
+
name: string;
|
|
136
|
+
entity?: EntityRef;
|
|
137
|
+
launch?: LaunchSpec;
|
|
138
|
+
ancestry: string[];
|
|
139
|
+
operationId: string;
|
|
140
|
+
deadlineMs: number;
|
|
141
|
+
}
|
|
142
|
+
export type HookResult = { decision: 'proceed'; launch?: LaunchSpec } |
|
|
143
|
+
{ decision: 'cancel'; reason?: string };
|
|
144
|
+
export interface PluginEvent {
|
|
145
|
+
name: string;
|
|
146
|
+
entity?: EntityRef;
|
|
147
|
+
data: Json;
|
|
148
|
+
deliveryId?: string;
|
|
149
|
+
}
|
|
150
|
+
export interface Popover {
|
|
151
|
+
kind: 'chooser' | 'form' | 'confirm';
|
|
152
|
+
title: string;
|
|
153
|
+
message?: string;
|
|
154
|
+
entity?: EntityRef;
|
|
155
|
+
choices?: { id: string; label: string; destructive?: boolean }[];
|
|
156
|
+
fields?: { id: string; label: string; type: 'text' | 'secret' | 'boolean'; value?: Json }[];
|
|
157
|
+
}
|
|
158
|
+
export interface OperationMap {
|
|
159
|
+
'pane.create': { input: PaneCreate; output: PaneResult };
|
|
160
|
+
'pane.close': { input: { pane: EntityRef; confirmationId?: string }; output: { operationId: string } };
|
|
161
|
+
'pane.restart': { input: { pane: EntityRef; launch?: LaunchSpec }; output: PaneResult };
|
|
162
|
+
'pane.input': { input: { pane: EntityRef; text: string }; output: null };
|
|
163
|
+
'pane.focus': { input: { pane: EntityRef }; output: null };
|
|
164
|
+
'pane.wait': { input: { pane: EntityRef }; output: { exitCode: number | null; lastLine: string } };
|
|
165
|
+
'project.create': { input: { machineId: string; path: string; name?: string }; output: EntityRef };
|
|
166
|
+
'project.remove': { input: { project: EntityRef; confirmationId?: string }; output: { operationId: string } };
|
|
167
|
+
'notification.show': { input: { title: string; message: string }; output: null };
|
|
168
|
+
'url.open': { input: { url: string }; output: null };
|
|
169
|
+
'fetch': { input: { url: string; method?: string; headers?: Record<string, string>; body?: string }; output: { status: number; headers: Record<string, string>; body: string } };
|
|
170
|
+
'secret.get': { input: { name: string }; output: string | null };
|
|
171
|
+
'config.get': { input: Record<string, never>; output: Record<string, Json> };
|
|
172
|
+
'state.get': { input: { key: string }; output: Json };
|
|
173
|
+
'state.set': { input: { key: string; value: Json }; output: null };
|
|
174
|
+
'context.get': { input: Record<string, never>; output: Json };
|
|
175
|
+
'popover.open': { input: Popover; output: { choiceId?: string; values?: Record<string, Json>; confirmationId?: string } | null };
|
|
176
|
+
'canvas.write': { input: { key: ContributionKey; ansi: string }; output: null };
|
|
177
|
+
'canvas.focus': { input: { key: ContributionKey; capture: boolean }; output: null };
|
|
178
|
+
'subscription.add': { input: { id: string; name: string; condition: Condition } & (
|
|
179
|
+
{ kind: 'action'; registration?: ActionRegistration } |
|
|
180
|
+
{ kind: 'event' | 'hook' | 'input' | 'select' | 'resize' | 'activate' | 'deactivate' | 'visibility' }
|
|
181
|
+
); output: null };
|
|
182
|
+
'subscription.remove': { input: { id: string }; output: null };
|
|
183
|
+
'health.set': { input: { status: 'healthy' | 'degraded' | 'error'; message?: string }; output: null };
|
|
184
|
+
'webhook.ack': { input: { deliveryId: string }; output: null };
|
|
185
|
+
}
|
|
186
|
+
export type OperationName = keyof OperationMap;
|
|
187
|
+
export type Operation = { [K in OperationName]: { op: K; args: OperationMap[K]['input'] } }[OperationName];
|
|
188
|
+
export type Request = <K extends OperationName>(op: K, args: OperationMap[K]['input'], options?: RequestOptions) => Promise<OperationMap[K]['output']>;
|
|
189
|
+
export interface Publisher extends Disposable {
|
|
190
|
+
replace(content: SurfaceContent): void;
|
|
191
|
+
clear(): void;
|
|
192
|
+
}
|
|
193
|
+
export interface Canvas extends Publisher {
|
|
194
|
+
write(ansi: string, options?: RequestOptions): Promise<null>;
|
|
195
|
+
focus(capture: boolean, options?: RequestOptions): Promise<null>;
|
|
196
|
+
}
|
|
197
|
+
export interface Subscription extends Disposable { ready: Promise<null> }
|
|
198
|
+
export interface KeyRegistration extends Subscription {
|
|
199
|
+
/** Replaces this subscription's chord after its initial registration is ready. */
|
|
200
|
+
/** One update may be pending per registration. */
|
|
201
|
+
update(chord: string, options?: RequestOptions): Promise<null>;
|
|
202
|
+
}
|
|
203
|
+
export interface HandlerContext { signal: AbortSignal }
|
|
204
|
+
export interface PluginContext {
|
|
205
|
+
readonly manifest: Readonly<PluginManifest>;
|
|
206
|
+
readonly producer: Readonly<Producer>;
|
|
207
|
+
readonly signal: AbortSignal;
|
|
208
|
+
request: Request;
|
|
209
|
+
section(id: string, entity?: EntityRef): Publisher;
|
|
210
|
+
slot(id: string, entity: EntityRef): Publisher;
|
|
211
|
+
badge(id: string, entity: EntityRef): Publisher;
|
|
212
|
+
panel(id: string, entity?: EntityRef): Publisher;
|
|
213
|
+
overlay(id: string, entity?: EntityRef): Publisher;
|
|
214
|
+
canvas(id: string, spec: CanvasSpec, entity?: EntityRef): Canvas;
|
|
215
|
+
/** IDs match static manifest contributions. Options override declaration defaults. */
|
|
216
|
+
menu(id: string, options: MenuOptions, handler: ActionHandler): Subscription;
|
|
217
|
+
menu(id: string, handler: ActionHandler): Subscription;
|
|
218
|
+
/** Registers a palette command in its declared context and optional group. */
|
|
219
|
+
command(id: string, options: CommandOptions, handler: ActionHandler): Subscription;
|
|
220
|
+
command(id: string, handler: ActionHandler): Subscription;
|
|
221
|
+
/** Each command has a static contribution ID. Disposal removes the whole group. */
|
|
222
|
+
commandGroup(group: string, commands: readonly GroupCommand[]): Subscription;
|
|
223
|
+
key(id: string, options: KeyOptions, handler: ActionHandler): KeyRegistration;
|
|
224
|
+
key(id: string, handler: ActionHandler): KeyRegistration;
|
|
225
|
+
link(id: string, options: LinkOptions, handler: LinkHandler): Subscription;
|
|
226
|
+
link(id: string, handler: LinkHandler): Subscription;
|
|
227
|
+
onEvent(name: string, handler: (event: PluginEvent, context: HandlerContext) => void | Promise<void>, condition?: Condition): Subscription;
|
|
228
|
+
onHook(name: string, handler: (event: HookEvent, context: HandlerContext) => HookResult | Promise<HookResult>): Subscription;
|
|
229
|
+
onAction(name: string, handler: (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>): Subscription;
|
|
230
|
+
onInput(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
231
|
+
onSelect(name: string, handler: (event: Selection, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
232
|
+
onResize(name: string, handler: (event: { columns: number; rows: number }, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
233
|
+
onActivate(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
234
|
+
onDeactivate(name: string, handler: (event: Json, context: HandlerContext) => void | Promise<void>): Subscription;
|
|
235
|
+
schedule(intervalMs: number, handler: (context: HandlerContext) => void | Promise<void>, options?: { condition?: Condition; immediate?: boolean }): Disposable;
|
|
236
|
+
onDispose(cleanup: Cleanup): Disposable;
|
|
237
|
+
panes: {
|
|
238
|
+
create(args: PaneCreate, options?: RequestOptions): Promise<PaneResult>;
|
|
239
|
+
close(args: OperationMap['pane.close']['input'], options?: RequestOptions): Promise<{ operationId: string }>;
|
|
240
|
+
restart(args: OperationMap['pane.restart']['input'], options?: RequestOptions): Promise<PaneResult>;
|
|
241
|
+
input(args: OperationMap['pane.input']['input'], options?: RequestOptions): Promise<null>;
|
|
242
|
+
focus(args: OperationMap['pane.focus']['input'], options?: RequestOptions): Promise<null>;
|
|
243
|
+
wait(args: OperationMap['pane.wait']['input'], options?: RequestOptions): Promise<OperationMap['pane.wait']['output']>;
|
|
244
|
+
};
|
|
245
|
+
projects: { create(args: OperationMap['project.create']['input'], options?: RequestOptions): Promise<EntityRef>; remove(args: OperationMap['project.remove']['input'], options?: RequestOptions): Promise<{ operationId: string }> };
|
|
246
|
+
notifications: { show(args: OperationMap['notification.show']['input'], options?: RequestOptions): Promise<null> };
|
|
247
|
+
url: { open(url: string, options?: RequestOptions): Promise<null> };
|
|
248
|
+
fetch(args: OperationMap['fetch']['input'], options?: RequestOptions): Promise<OperationMap['fetch']['output']>;
|
|
249
|
+
secrets: { get(name: string, options?: RequestOptions): Promise<string | null> };
|
|
250
|
+
config: { get(options?: RequestOptions): Promise<Record<string, Json>> };
|
|
251
|
+
/** Local state for one machine. It is never shared with other machines. */
|
|
252
|
+
state: { get(key: string, options?: RequestOptions): Promise<Json>; set(key: string, value: Json, options?: RequestOptions): Promise<null> };
|
|
253
|
+
context: { get(options?: RequestOptions): Promise<Json> };
|
|
254
|
+
popover: { open(args: Popover, options?: RequestOptions): Promise<OperationMap['popover.open']['output']> };
|
|
255
|
+
health: { set(args: OperationMap['health.set']['input'], options?: RequestOptions): Promise<null> };
|
|
256
|
+
webhook: { ack(deliveryId: string, options?: RequestOptions): Promise<null> };
|
|
257
|
+
}
|
|
258
|
+
export interface PluginDefinition {
|
|
259
|
+
id: string;
|
|
260
|
+
activate(context: PluginContext): void | Cleanup | Promise<void | Cleanup>;
|
|
261
|
+
}
|
|
262
|
+
export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
|
|
263
|
+
export function validateManifest(value: unknown): Readonly<PluginManifest>;
|
|
264
|
+
export class PluginError extends Error { code: string; constructor(code: string, message: string) }
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
export { validateManifest, PluginError }
|
|
4
|
+
|
|
5
|
+
export function definePlugin(definition) {
|
|
6
|
+
ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
|
|
7
|
+
'invalid_plugin', 'definePlugin requires an id and activate function')
|
|
8
|
+
return Object.freeze({ id: definition.id, activate: definition.activate })
|
|
9
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ContributionKey, SurfaceContent, Producer, Operation, Json, PluginManifest, PluginContext, PluginDefinition, RequestOptions, Condition } from './index.js';
|
|
2
|
+
export * from './index.js';
|
|
3
|
+
export const RUNNER_PROTOCOL_VERSION: 1;
|
|
4
|
+
export const PLUGIN_API_VERSION: 1;
|
|
5
|
+
export const PLUGIN_PEER_CAPABILITY: 'plugins.v1';
|
|
6
|
+
export const LIMITS: Readonly<Record<string, number>>;
|
|
7
|
+
export interface PreparedPlugin {
|
|
8
|
+
root: string;
|
|
9
|
+
entry: string;
|
|
10
|
+
manifest: PluginManifest;
|
|
11
|
+
manifestDigest: string;
|
|
12
|
+
}
|
|
13
|
+
export type HostOperation =
|
|
14
|
+
{ op: 'runner.start' | 'runner.reload'; args: { root: string; manifestDigest: string; capabilities: string[]; linked?: boolean } } |
|
|
15
|
+
{ op: 'runner.stop' | 'runner.diagnose' | 'runner.ping'; args: Record<string, never> } |
|
|
16
|
+
{ op: 'runtime.invoke'; args: { subscriptionId: string; event: Json } } |
|
|
17
|
+
{ op: 'runtime.visibility'; args: { conditions: Condition[] } };
|
|
18
|
+
export type Envelope = { version: 1; producer: Producer } & (
|
|
19
|
+
{ kind: 'request'; id: string; operation: Operation | HostOperation; timeoutMs: number } |
|
|
20
|
+
{ kind: 'response'; id: string; result: { ok: true; value: Json } | { ok: false; error: { code: string; message: string } } } |
|
|
21
|
+
{ kind: 'cancel'; id: string } |
|
|
22
|
+
{ kind: 'surface'; key: ContributionKey; sequence: string; content: SurfaceContent | null }
|
|
23
|
+
);
|
|
24
|
+
export interface Clock { now(): number; setTimeout(callback: () => void | Promise<void>, ms: number): unknown; clearTimeout(id: unknown): void }
|
|
25
|
+
export interface Runtime {
|
|
26
|
+
context: PluginContext;
|
|
27
|
+
receive(frame: Envelope): void;
|
|
28
|
+
activate(definition: PluginDefinition): Promise<void>;
|
|
29
|
+
dispose(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export function createRuntime(options: { manifest: PluginManifest; producer: Producer; send(frame: Envelope): void; clock?: Clock; instanceId?: string; onError?(error: Error): void }): Runtime;
|
|
32
|
+
export function authorize(operation: Operation, capabilities: string[]): void;
|
|
33
|
+
export function validateEnvelope(frame: unknown): Envelope;
|
|
34
|
+
export class RpcPeer {
|
|
35
|
+
constructor(options: { producer: Producer; send(frame: Envelope): void; clock?: Clock; idPrefix?: string; onRequest?(operation: Operation | HostOperation, options: { signal: AbortSignal; timeoutMs: number }): Promise<Json> | Json; onSurface?(frame: Envelope): void; onError?(error: Error): void });
|
|
36
|
+
request(operation: Operation | HostOperation, options?: RequestOptions): Promise<Json>;
|
|
37
|
+
receive(frame: Envelope): void;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
}
|
package/src/internal.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Re-exports for the plugin runner, which imports this file by relative path.
|
|
2
|
+
// The package does not export this module; plugin code uses the public entry.
|
|
3
|
+
export * from './manifest.mjs'
|
|
4
|
+
export * from './protocol.mjs'
|
|
5
|
+
export { createRuntime } from './runtime.mjs'
|
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const CAPABILITIES = Object.freeze(['surfaces', 'events', 'hooks', 'panes', 'projects',
|
|
2
|
+
'notifications', 'url', 'fetch', 'secrets', 'webhook'])
|
|
3
|
+
export const SURFACE_KINDS = Object.freeze(['section', 'slot', 'badge', 'panel', 'overlay',
|
|
4
|
+
'menu', 'command', 'key', 'link'])
|
|
5
|
+
export const ANCHORS = Object.freeze(['plugins', 'machine.before', 'machine.after',
|
|
6
|
+
'project.before', 'project.after', 'pane.header', 'pane.footer',
|
|
7
|
+
'account', 'machine', 'project', 'pane', 'section'])
|
|
8
|
+
export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
|
|
9
|
+
pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
|
|
10
|
+
queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
|
|
11
|
+
canvasColumns: 512, canvasRows: 256 })
|
|
12
|
+
|
|
13
|
+
export class PluginError extends Error {
|
|
14
|
+
constructor(code, message) { super(message); this.name = 'PluginError'; this.code = code }
|
|
15
|
+
}
|
|
16
|
+
export function ensure(condition, code, message) {
|
|
17
|
+
if (!condition) throw new PluginError(code, message)
|
|
18
|
+
}
|
|
19
|
+
export function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
|
|
20
|
+
export function identifier(value) { return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) }
|
|
21
|
+
export function jsonBytes(value, limit = LIMITS.frameBytes) {
|
|
22
|
+
// Reject non-JSON values before serialization can silently change their meaning.
|
|
23
|
+
const seen = new Set()
|
|
24
|
+
function visit(item, depth) {
|
|
25
|
+
ensure(depth <= 32, 'invalid_payload', 'JSON nesting exceeds 32 levels')
|
|
26
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean') return
|
|
27
|
+
if (typeof item === 'number') { ensure(Number.isFinite(item), 'invalid_payload', 'JSON numbers must be finite'); return }
|
|
28
|
+
ensure(object(item) || Array.isArray(item), 'invalid_payload', 'Payload must contain JSON values')
|
|
29
|
+
ensure(!seen.has(item), 'invalid_payload', 'Payload contains a cycle')
|
|
30
|
+
ensure(Array.isArray(item) || [Object.prototype, null].includes(Object.getPrototypeOf(item)), 'invalid_payload', 'Payload must contain plain objects')
|
|
31
|
+
seen.add(item)
|
|
32
|
+
for (const child of Object.values(item)) visit(child, depth + 1)
|
|
33
|
+
seen.delete(item)
|
|
34
|
+
}
|
|
35
|
+
visit(value, 0)
|
|
36
|
+
const text = JSON.stringify(value)
|
|
37
|
+
ensure(Buffer.byteLength(text) <= limit, 'payload_too_large', `Payload exceeds ${limit} bytes`)
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
function freeze(value) {
|
|
41
|
+
if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) }
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
export function validateManifest(value) {
|
|
45
|
+
ensure(object(value), 'invalid_manifest', 'package.json must contain a standardPlugin object')
|
|
46
|
+
jsonBytes(value, LIMITS.manifestBytes)
|
|
47
|
+
const allowed = ['apiVersion', 'id', 'name', 'version', 'entry', 'singleton', 'order',
|
|
48
|
+
'capabilities', 'contributions', 'configSchema', 'hookTimeoutMs']
|
|
49
|
+
ensure(Object.keys(value).every(key => allowed.includes(key)), 'invalid_manifest', 'Unknown manifest field')
|
|
50
|
+
const manifest = structuredClone({ singleton: false, order: 0, capabilities: [], contributions: [], hookTimeoutMs: 5000, ...value })
|
|
51
|
+
ensure(manifest.apiVersion === 1, 'incompatible_version', 'Plugin requires a different SDK API version')
|
|
52
|
+
ensure(identifier(manifest.id), 'invalid_manifest', 'Plugin id must be a stable identifier')
|
|
53
|
+
ensure(typeof manifest.name === 'string' && manifest.name.trim().length > 0 && manifest.name.length <= 128,
|
|
54
|
+
'invalid_manifest', 'Plugin name is required and bounded to 128 characters')
|
|
55
|
+
ensure(typeof manifest.version === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version),
|
|
56
|
+
'invalid_manifest', 'Plugin version must be semantic')
|
|
57
|
+
ensure(typeof manifest.entry === 'string' && manifest.entry.length <= 512 &&
|
|
58
|
+
!/[\\\u0000?#]/.test(manifest.entry) && !manifest.entry.startsWith('/') &&
|
|
59
|
+
manifest.entry.split('/').every(part => part && part !== '..') && /\.(?:mjs|cjs|js|mts|ts)$/.test(manifest.entry),
|
|
60
|
+
'invalid_manifest', 'Plugin entry must be a relative JavaScript or TypeScript file')
|
|
61
|
+
ensure(typeof manifest.singleton === 'boolean' && Number.isSafeInteger(manifest.order) && Math.abs(manifest.order) <= 2147483647,
|
|
62
|
+
'invalid_manifest', 'Invalid singleton or order')
|
|
63
|
+
ensure(Number.isSafeInteger(manifest.hookTimeoutMs) && manifest.hookTimeoutMs > 0 && manifest.hookTimeoutMs <= LIMITS.hookTimeoutMs,
|
|
64
|
+
'invalid_manifest', 'Hook timeout must be between 1 and 60000 ms')
|
|
65
|
+
ensure(Array.isArray(manifest.capabilities) && manifest.capabilities.every(item => CAPABILITIES.includes(item)) &&
|
|
66
|
+
new Set(manifest.capabilities).size === manifest.capabilities.length, 'invalid_manifest', 'Unknown or repeated capability')
|
|
67
|
+
ensure(Array.isArray(manifest.contributions) && manifest.contributions.length <= LIMITS.contributions,
|
|
68
|
+
'invalid_manifest', 'Too many contributions')
|
|
69
|
+
const ids = new Set()
|
|
70
|
+
for (const declaration of manifest.contributions) {
|
|
71
|
+
ensure(object(declaration) && identifier(declaration.id) && !ids.has(declaration.id) &&
|
|
72
|
+
SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
|
|
73
|
+
ids.add(declaration.id)
|
|
74
|
+
for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
|
|
75
|
+
position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
|
|
76
|
+
ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
|
|
77
|
+
}
|
|
78
|
+
for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
|
|
79
|
+
ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
|
|
80
|
+
'invalid_manifest', `Invalid contribution ${key}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
|
|
84
|
+
ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
|
|
85
|
+
return freeze(manifest)
|
|
86
|
+
}
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ensure, identifier, object, jsonBytes, LIMITS, PluginError } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
export const RUNNER_PROTOCOL_VERSION = 1
|
|
4
|
+
export const PLUGIN_API_VERSION = 1
|
|
5
|
+
export const PLUGIN_PEER_CAPABILITY = 'plugins.v1'
|
|
6
|
+
export const OPERATIONS = Object.freeze({
|
|
7
|
+
'pane.create': 'panes', 'pane.close': 'panes', 'pane.restart': 'panes',
|
|
8
|
+
'pane.input': 'panes', 'pane.focus': 'panes', 'pane.wait': 'panes',
|
|
9
|
+
'project.create': 'projects', 'project.remove': 'projects',
|
|
10
|
+
'notification.show': 'notifications', 'url.open': 'url', fetch: 'fetch',
|
|
11
|
+
'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null,
|
|
12
|
+
'context.get': null, 'popover.open': 'surfaces', 'canvas.write': 'surfaces',
|
|
13
|
+
'canvas.focus': 'surfaces', 'subscription.add': null, 'subscription.remove': null,
|
|
14
|
+
'health.set': null, 'webhook.ack': 'webhook',
|
|
15
|
+
})
|
|
16
|
+
export const HOST_OPERATIONS = Object.freeze(['runner.start', 'runner.stop', 'runner.reload',
|
|
17
|
+
'runner.diagnose', 'runner.ping', 'runtime.invoke', 'runtime.visibility'])
|
|
18
|
+
|
|
19
|
+
export function authorize(operation, capabilities) {
|
|
20
|
+
ensure(object(operation) && Object.hasOwn(OPERATIONS, operation.op) && object(operation.args),
|
|
21
|
+
'unsupported_operation', 'Unknown SDK operation or invalid arguments')
|
|
22
|
+
let capability = OPERATIONS[operation.op]
|
|
23
|
+
if (operation.op === 'subscription.add') {
|
|
24
|
+
const { id, kind, name } = operation.args
|
|
25
|
+
ensure(identifier(id) && typeof name === 'string' && name.length <= 256 &&
|
|
26
|
+
['event', 'hook', 'action', 'input', 'select', 'resize', 'activate', 'deactivate', 'visibility'].includes(kind),
|
|
27
|
+
'invalid_payload', 'Invalid subscription')
|
|
28
|
+
capability = kind === 'hook' ? 'hooks' : kind === 'event' ? (name === 'webhook' ? 'webhook' : 'events') : 'surfaces'
|
|
29
|
+
}
|
|
30
|
+
ensure(!capability || capabilities.includes(capability), 'capability_denied', `Operation requires ${capability} capability`)
|
|
31
|
+
jsonBytes(operation)
|
|
32
|
+
}
|
|
33
|
+
export function validateProducer(producer) {
|
|
34
|
+
ensure(object(producer) && identifier(producer.pluginId) && typeof producer.machineId === 'string' &&
|
|
35
|
+
producer.machineId.length > 0 && producer.machineId.length <= 128 && typeof producer.epoch === 'string' &&
|
|
36
|
+
/^(0|[1-9]\d{0,19})$/.test(producer.epoch) && BigInt(producer.epoch) <= 18446744073709551615n,
|
|
37
|
+
'invalid_payload', 'Invalid plugin producer identity')
|
|
38
|
+
return producer
|
|
39
|
+
}
|
|
40
|
+
export function sameProducer(a, b) {
|
|
41
|
+
return a.pluginId === b.pluginId && a.machineId === b.machineId && a.epoch === b.epoch
|
|
42
|
+
}
|
|
43
|
+
export function validateEnvelope(frame) {
|
|
44
|
+
jsonBytes(frame)
|
|
45
|
+
ensure(object(frame) && frame.version === RUNNER_PROTOCOL_VERSION, 'incompatible_version', 'Daemon and runner protocol versions differ')
|
|
46
|
+
validateProducer(frame.producer)
|
|
47
|
+
ensure(['request', 'response', 'cancel', 'surface'].includes(frame.kind), 'invalid_payload', 'Unknown runner frame kind')
|
|
48
|
+
if (frame.kind === 'surface') {
|
|
49
|
+
ensure(object(frame.key) && identifier(frame.key.contributionId) && typeof frame.sequence === 'string' &&
|
|
50
|
+
/^(0|[1-9]\d{0,19})$/.test(frame.sequence) && BigInt(frame.sequence) <= 18446744073709551615n &&
|
|
51
|
+
Object.hasOwn(frame, 'content'), 'invalid_payload', 'Invalid surface replacement')
|
|
52
|
+
} else {
|
|
53
|
+
ensure(typeof frame.id === 'string' && frame.id.length > 0 && frame.id.length <= 128, 'invalid_payload', 'Missing correlation id')
|
|
54
|
+
if (frame.kind === 'request') {
|
|
55
|
+
ensure(object(frame.operation) && (Object.hasOwn(OPERATIONS, frame.operation.op) || HOST_OPERATIONS.includes(frame.operation.op)) &&
|
|
56
|
+
object(frame.operation.args) && Number.isSafeInteger(frame.timeoutMs) && frame.timeoutMs > 0 && frame.timeoutMs <= LIMITS.hookTimeoutMs,
|
|
57
|
+
'invalid_payload', 'Invalid runner request')
|
|
58
|
+
}
|
|
59
|
+
if (frame.kind === 'response') {
|
|
60
|
+
ensure(object(frame.result) && typeof frame.result.ok === 'boolean' &&
|
|
61
|
+
(frame.result.ok ? Object.hasOwn(frame.result, 'value') :
|
|
62
|
+
object(frame.result.error) && typeof frame.result.error.code === 'string' && typeof frame.result.error.message === 'string'),
|
|
63
|
+
'invalid_payload', 'Invalid runner response')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return frame
|
|
67
|
+
}
|
|
68
|
+
export function wireError(error) {
|
|
69
|
+
return { code: error instanceof PluginError ? error.code : 'plugin_error',
|
|
70
|
+
message: String(error?.message ?? 'Plugin operation failed').slice(0, 1024) }
|
|
71
|
+
}
|
|
72
|
+
export const realClock = Object.freeze({ now: () => Date.now(), setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) })
|
|
73
|
+
|
|
74
|
+
/** A single correlation path for commands, actions and hooks. Send must refuse overflow synchronously. */
|
|
75
|
+
export class RpcPeer {
|
|
76
|
+
constructor({ producer, send, clock = realClock, onRequest, onSurface, onError = () => {}, idPrefix = 'host' }) {
|
|
77
|
+
this.producer = Object.freeze({ ...validateProducer(producer) })
|
|
78
|
+
this.send = send
|
|
79
|
+
this.clock = clock
|
|
80
|
+
this.onRequest = onRequest
|
|
81
|
+
this.onSurface = onSurface
|
|
82
|
+
this.onError = onError
|
|
83
|
+
this.pending = new Map()
|
|
84
|
+
this.incoming = new Map()
|
|
85
|
+
this.nextId = 0
|
|
86
|
+
this.idPrefix = idPrefix
|
|
87
|
+
this.closed = false
|
|
88
|
+
}
|
|
89
|
+
frame(kind, values) { return { version: RUNNER_PROTOCOL_VERSION, producer: this.producer, kind, ...values } }
|
|
90
|
+
transmit(frame) { validateEnvelope(frame); this.send(frame) }
|
|
91
|
+
request(operation, { signal, timeoutMs = LIMITS.requestTimeoutMs } = {}) {
|
|
92
|
+
if (this.closed) return Promise.reject(new PluginError('disposed', 'Plugin runtime is disposed'))
|
|
93
|
+
if (signal?.aborted) return Promise.reject(new PluginError('cancelled', 'Request was cancelled'))
|
|
94
|
+
if (this.pending.size >= LIMITS.pendingRequests) return Promise.reject(new PluginError('queue_full', 'Too many pending requests'))
|
|
95
|
+
const id = `${this.idPrefix}-${++this.nextId}`
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let timer
|
|
98
|
+
const finish = (error, value) => {
|
|
99
|
+
if (!this.pending.delete(id)) return
|
|
100
|
+
this.clock.clearTimeout(timer)
|
|
101
|
+
signal?.removeEventListener('abort', cancel)
|
|
102
|
+
error ? reject(error) : resolve(value)
|
|
103
|
+
}
|
|
104
|
+
const cancelWith = code => {
|
|
105
|
+
finish(new PluginError(code, code === 'timeout' ? 'Request deadline elapsed' : 'Request was cancelled'))
|
|
106
|
+
try { this.transmit(this.frame('cancel', { id })) } catch (error) { this.onError(error) }
|
|
107
|
+
}
|
|
108
|
+
const cancel = () => cancelWith('cancelled')
|
|
109
|
+
this.pending.set(id, { finish })
|
|
110
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
111
|
+
timer = this.clock.setTimeout(() => cancelWith('timeout'), timeoutMs)
|
|
112
|
+
try { this.transmit(this.frame('request', { id, operation, timeoutMs })) }
|
|
113
|
+
catch (error) { finish(error) }
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
receive(frame) {
|
|
117
|
+
if (this.closed) return
|
|
118
|
+
validateEnvelope(frame)
|
|
119
|
+
ensure(sameProducer(frame.producer, this.producer), 'stale_producer', 'Frame belongs to another plugin instance')
|
|
120
|
+
if (frame.kind === 'surface') { this.onSurface?.(frame); return }
|
|
121
|
+
if (frame.kind === 'response') {
|
|
122
|
+
const result = frame.result
|
|
123
|
+
this.pending.get(frame.id)?.finish(result.ok ? null : new PluginError(result.error.code, result.error.message), result.value)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (frame.kind === 'cancel') { this.incoming.get(frame.id)?.cancel(); return }
|
|
127
|
+
ensure(!this.incoming.has(frame.id), 'duplicate_request', 'Request id is still in flight')
|
|
128
|
+
const respond = result => {
|
|
129
|
+
if (!this.closed) {
|
|
130
|
+
try { this.transmit(this.frame('response', { id: frame.id, result })) }
|
|
131
|
+
catch (error) { this.onError(error) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (this.incoming.size >= LIMITS.pendingRequests) {
|
|
135
|
+
respond({ ok: false, error: { code: 'queue_full', message: 'Too many incoming requests' } })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const controller = new AbortController()
|
|
139
|
+
// Retain the occupied slot until a cancelled handler settles. An uncooperative
|
|
140
|
+
// handler cannot turn repeated cancellation into unbounded concurrent work.
|
|
141
|
+
const cancel = () => { controller.abort(); this.clock.clearTimeout(timer) }
|
|
142
|
+
const timer = this.clock.setTimeout(() => {
|
|
143
|
+
cancel()
|
|
144
|
+
respond({ ok: false, error: { code: 'timeout', message: 'Handler deadline elapsed' } })
|
|
145
|
+
}, frame.timeoutMs)
|
|
146
|
+
this.incoming.set(frame.id, { cancel })
|
|
147
|
+
Promise.resolve().then(() => {
|
|
148
|
+
ensure(!controller.signal.aborted, 'cancelled', 'Request was cancelled')
|
|
149
|
+
ensure(typeof this.onRequest === 'function', 'unsupported_operation', 'No request handler')
|
|
150
|
+
return this.onRequest(frame.operation, { signal: controller.signal, timeoutMs: frame.timeoutMs })
|
|
151
|
+
}).then(value => {
|
|
152
|
+
if (!controller.signal.aborted) respond({ ok: true, value: value ?? null })
|
|
153
|
+
}, error => {
|
|
154
|
+
if (!controller.signal.aborted) respond({ ok: false, error: wireError(error) })
|
|
155
|
+
}).finally(() => { this.clock.clearTimeout(timer); this.incoming.delete(frame.id) })
|
|
156
|
+
}
|
|
157
|
+
dispose() {
|
|
158
|
+
if (this.closed) return
|
|
159
|
+
for (const [id, request] of this.pending) {
|
|
160
|
+
request.finish(new PluginError('disposed', 'Plugin runtime was disposed'))
|
|
161
|
+
try { this.transmit(this.frame('cancel', { id })) } catch (error) { this.onError(error) }
|
|
162
|
+
}
|
|
163
|
+
this.closed = true
|
|
164
|
+
for (const request of this.incoming.values()) request.cancel()
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
|
|
2
|
+
import { RpcPeer, authorize, realClock } from './protocol.mjs'
|
|
3
|
+
|
|
4
|
+
const always = Object.freeze({ kind: 'always' })
|
|
5
|
+
const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
|
|
6
|
+
const entityKinds = ['account', 'machine', 'project', 'pane', 'section']
|
|
7
|
+
function boundedText(value, label, limit = 512) {
|
|
8
|
+
ensure(typeof value === 'string' && value.trim().length > 0 && value.length <= limit &&
|
|
9
|
+
!/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
function validateEntity(entity) {
|
|
13
|
+
ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
|
|
14
|
+
boundedText(entity.id, 'entity id', 128)
|
|
15
|
+
if (entity.machineId !== undefined) boundedText(entity.machineId, 'machine id', 128)
|
|
16
|
+
if (entity.generation !== undefined) {
|
|
17
|
+
ensure(typeof entity.generation === 'string' && /^(0|[1-9]\d{0,19})$/.test(entity.generation) &&
|
|
18
|
+
BigInt(entity.generation) <= 18446744073709551615n, 'invalid_payload', 'Invalid entity generation')
|
|
19
|
+
}
|
|
20
|
+
return { kind: entity.kind, id: entity.id,
|
|
21
|
+
...(entity.machineId !== undefined ? { machineId: entity.machineId } : {}),
|
|
22
|
+
...(entity.generation !== undefined ? { generation: entity.generation } : {}) }
|
|
23
|
+
}
|
|
24
|
+
function validateChord(chord) {
|
|
25
|
+
boundedText(chord, 'key chord', 128)
|
|
26
|
+
const presses = chord.split(' ')
|
|
27
|
+
const namedKeys = ['space', 'enter', 'esc', 'tab', 'backspace', 'left', 'right', 'up', 'down', 'pageup', 'pagedown', 'home', 'end']
|
|
28
|
+
ensure(presses.length <= 2 && presses.every(press => {
|
|
29
|
+
const parts = press.split('+')
|
|
30
|
+
const modifiers = parts.filter(part => ['shift', 'alt', 'ctrl'].includes(part))
|
|
31
|
+
const keys = parts.filter(part => !['shift', 'alt', 'ctrl'].includes(part))
|
|
32
|
+
return new Set(modifiers).size === modifiers.length && keys.length === 1 &&
|
|
33
|
+
(namedKeys.includes(keys[0]) || Array.from(keys[0]).length === 1)
|
|
34
|
+
}), 'invalid_payload', 'Invalid native key chord')
|
|
35
|
+
return chord
|
|
36
|
+
}
|
|
37
|
+
function conditionKey(condition) { return JSON.stringify([condition.kind, condition.contributionId ?? null, condition.entity ?? null]) }
|
|
38
|
+
function validateCondition(condition) {
|
|
39
|
+
ensure(condition && ['always', 'section-visible', 'slot-visible', 'panel-open'].includes(condition.kind) &&
|
|
40
|
+
(condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
|
|
41
|
+
return structuredClone(condition)
|
|
42
|
+
}
|
|
43
|
+
function validateContent(content) {
|
|
44
|
+
jsonBytes(content)
|
|
45
|
+
ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
|
|
46
|
+
if (content.kind === 'canvas') {
|
|
47
|
+
const { columns, rows, shade = 0 } = content.canvas ?? {}
|
|
48
|
+
ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
|
|
49
|
+
Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
|
|
50
|
+
Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createRuntime({ manifest: input, producer, send, clock = realClock, onError = () => {}, instanceId = '0' }) {
|
|
55
|
+
const manifest = validateManifest(input)
|
|
56
|
+
ensure(producer.pluginId === manifest.id, 'identity_mismatch', 'Manifest and producer ids differ')
|
|
57
|
+
const lifetime = new AbortController()
|
|
58
|
+
const subscriptions = new Map()
|
|
59
|
+
const schedules = new Set()
|
|
60
|
+
const cleanups = new Set()
|
|
61
|
+
const publishers = new Map()
|
|
62
|
+
let visibility = new Set()
|
|
63
|
+
let counter = 0
|
|
64
|
+
let sequence = 0n
|
|
65
|
+
let disposal
|
|
66
|
+
ensure(identifier(instanceId), 'invalid_payload', 'Invalid runtime instance id')
|
|
67
|
+
const peer = new RpcPeer({ producer, send, clock, onError, idPrefix: 'sdk', onRequest: async (operation, options) => {
|
|
68
|
+
if (operation.op === 'runtime.visibility') {
|
|
69
|
+
ensure(Array.isArray(operation.args.conditions) && operation.args.conditions.length <= LIMITS.contributions,
|
|
70
|
+
'invalid_payload', 'Invalid visibility snapshot')
|
|
71
|
+
visibility = new Set(operation.args.conditions.map(condition => conditionKey(validateCondition(condition))))
|
|
72
|
+
for (const schedule of schedules) schedule.visibilityChanged(visibility)
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
ensure(operation.op === 'runtime.invoke', 'unsupported_operation', 'Unsupported worker operation')
|
|
76
|
+
const subscription = subscriptions.get(operation.args.subscriptionId)
|
|
77
|
+
ensure(subscription, 'subscription_missing', 'Subscription was disposed')
|
|
78
|
+
if (subscription.condition.kind !== 'always' && !visibility.has(conditionKey(subscription.condition))) return null
|
|
79
|
+
const result = await subscription.handler(operation.args.event, { signal: options.signal })
|
|
80
|
+
if (subscription.kind === 'hook') {
|
|
81
|
+
ensure(result && ['proceed', 'cancel'].includes(result.decision), 'invalid_payload', 'Hook must return proceed or cancel')
|
|
82
|
+
}
|
|
83
|
+
return result ?? null
|
|
84
|
+
} })
|
|
85
|
+
function active() { ensure(!lifetime.signal.aborted, 'disposed', 'Plugin runtime is disposed') }
|
|
86
|
+
function request(op, args, options) {
|
|
87
|
+
try { active(); authorize({ op, args }, manifest.capabilities) }
|
|
88
|
+
catch (error) { onError(error); return Promise.reject(error) }
|
|
89
|
+
return peer.request({ op, args }, options)
|
|
90
|
+
}
|
|
91
|
+
function publish(kind, id, entity) {
|
|
92
|
+
active()
|
|
93
|
+
ensure(manifest.capabilities.includes('surfaces'), 'capability_denied', 'Publishing requires surfaces capability')
|
|
94
|
+
const declaration = manifest.contributions.find(item => item.id === id)
|
|
95
|
+
ensure(declaration && (!kind || declaration.kind === kind) && ANCHORS.includes(declaration.anchor),
|
|
96
|
+
'contribution_missing', 'Surface must match its static contribution declaration')
|
|
97
|
+
const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity: structuredClone(entity) } : {}) }
|
|
98
|
+
const identity = JSON.stringify(key)
|
|
99
|
+
ensure(!publishers.has(identity), 'duplicate_contribution', 'Contribution publisher exists')
|
|
100
|
+
ensure(publishers.size < LIMITS.contributions, 'queue_full', 'Too many contribution publishers')
|
|
101
|
+
let disposed = false
|
|
102
|
+
const replace = content => {
|
|
103
|
+
active()
|
|
104
|
+
ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
|
|
105
|
+
if (content !== null) validateContent(content)
|
|
106
|
+
peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
|
|
107
|
+
}
|
|
108
|
+
const publisher = { replace, clear: () => replace(null), dispose() {
|
|
109
|
+
if (disposed) return
|
|
110
|
+
try { if (!lifetime.signal.aborted) replace(null) }
|
|
111
|
+
finally { disposed = true; publishers.delete(identity) }
|
|
112
|
+
} }
|
|
113
|
+
publishers.set(identity, publisher)
|
|
114
|
+
return { publisher, key }
|
|
115
|
+
}
|
|
116
|
+
function subscribe(kind, name, handler, condition = always, registration) {
|
|
117
|
+
active()
|
|
118
|
+
ensure(typeof handler === 'function', 'invalid_payload', 'Subscription requires a handler')
|
|
119
|
+
ensure(subscriptions.size < LIMITS.subscriptions, 'queue_full', 'Too many subscriptions')
|
|
120
|
+
condition = validateCondition(condition)
|
|
121
|
+
const id = `subscription-${instanceId}-${++counter}`
|
|
122
|
+
const args = { id, kind, name, condition, ...(registration ? { registration } : {}) }
|
|
123
|
+
authorize({ op: 'subscription.add', args }, manifest.capabilities)
|
|
124
|
+
const entry = { kind, handler, condition, registration }
|
|
125
|
+
subscriptions.set(id, entry)
|
|
126
|
+
let disposed = false
|
|
127
|
+
let updating = false
|
|
128
|
+
const remove = () => { if (!lifetime.signal.aborted) request('subscription.remove', { id }).catch(onError) }
|
|
129
|
+
const ready = request('subscription.add', args)
|
|
130
|
+
ready.then(() => { if (disposed) remove() }, error => {
|
|
131
|
+
disposed = true
|
|
132
|
+
subscriptions.delete(id)
|
|
133
|
+
remove()
|
|
134
|
+
onError(error)
|
|
135
|
+
})
|
|
136
|
+
const subscription = { ready, dispose() {
|
|
137
|
+
if (disposed) return
|
|
138
|
+
disposed = true
|
|
139
|
+
subscriptions.delete(id)
|
|
140
|
+
remove()
|
|
141
|
+
} }
|
|
142
|
+
if (registration?.kind === 'key') subscription.update = async (chord, options) => {
|
|
143
|
+
active()
|
|
144
|
+
ensure(!disposed, 'disposed', 'Key registration is disposed')
|
|
145
|
+
validateChord(chord)
|
|
146
|
+
ensure(!updating, 'request_in_flight', 'A key registration update is pending')
|
|
147
|
+
updating = true
|
|
148
|
+
try {
|
|
149
|
+
await ready
|
|
150
|
+
active()
|
|
151
|
+
ensure(!disposed, 'disposed', 'Key registration is disposed')
|
|
152
|
+
const next = { ...entry.registration, chord }
|
|
153
|
+
await request('subscription.add', { ...args, registration: next }, options)
|
|
154
|
+
if (!disposed) entry.registration = next
|
|
155
|
+
return null
|
|
156
|
+
} finally {
|
|
157
|
+
updating = false
|
|
158
|
+
if (disposed) remove()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return subscription
|
|
162
|
+
}
|
|
163
|
+
function prepareRegistration(kind, id, options, handler) {
|
|
164
|
+
active()
|
|
165
|
+
if (typeof options === 'function' && handler === undefined) { handler = options; options = {} }
|
|
166
|
+
ensure(identifier(id) && object(options) && typeof handler === 'function', 'invalid_payload', 'Invalid contribution registration')
|
|
167
|
+
jsonBytes(options, LIMITS.manifestBytes)
|
|
168
|
+
const declaration = manifest.contributions.find(item => item.id === id && item.kind === kind)
|
|
169
|
+
ensure(declaration, 'contribution_missing', 'Registration must match its static contribution declaration')
|
|
170
|
+
const actionId = options.actionId ?? declaration.actionId ?? id
|
|
171
|
+
ensure(identifier(actionId), 'invalid_payload', 'Invalid registration action id')
|
|
172
|
+
const registration = { contributionId: id, kind, anchor: declaration.anchor, actionId,
|
|
173
|
+
title: boundedText(options.title ?? declaration.title ?? id, 'registration title'),
|
|
174
|
+
...(options.entity !== undefined ? { entity: validateEntity(options.entity) } : {}) }
|
|
175
|
+
if (kind === 'menu') {
|
|
176
|
+
registration.position = options.position ?? declaration.position ?? 'bottom'
|
|
177
|
+
ensure(menuPositions.includes(registration.position), 'invalid_payload', 'Invalid menu position')
|
|
178
|
+
} else if (kind === 'command') {
|
|
179
|
+
const group = options.group ?? declaration.group
|
|
180
|
+
if (group !== undefined) registration.group = boundedText(group, 'command group')
|
|
181
|
+
} else if (kind === 'key') {
|
|
182
|
+
registration.chord = validateChord(options.chord ?? declaration.chord)
|
|
183
|
+
} else if (kind === 'link') {
|
|
184
|
+
registration.pattern = boundedText(options.pattern ?? declaration.pattern, 'link pattern')
|
|
185
|
+
}
|
|
186
|
+
const condition = validateCondition(options.condition ?? always)
|
|
187
|
+
if (condition.entity !== undefined) condition.entity = validateEntity(condition.entity)
|
|
188
|
+
jsonBytes({ registration, condition }, LIMITS.manifestBytes)
|
|
189
|
+
const invoke = async (event, context) => {
|
|
190
|
+
ensure(object(event) && event.actionId === actionId, 'invalid_payload', 'Invalid registered action selection')
|
|
191
|
+
if (event.entity !== undefined) validateEntity(event.entity)
|
|
192
|
+
if (kind === 'link') boundedText(event.url, 'link URL', 8192)
|
|
193
|
+
const result = await handler(event, context)
|
|
194
|
+
if (kind === 'link') ensure(typeof result === 'boolean', 'invalid_payload', 'Link handlers must return a boolean')
|
|
195
|
+
return result
|
|
196
|
+
}
|
|
197
|
+
return { registration, handler: invoke, condition }
|
|
198
|
+
}
|
|
199
|
+
const registrationKey = registration => JSON.stringify([registration.contributionId, registration.entity ?? null])
|
|
200
|
+
function registerPrepared({ registration, handler, condition }) {
|
|
201
|
+
const identity = registrationKey(registration)
|
|
202
|
+
ensure(![...subscriptions.values()].some(entry => entry.registration && registrationKey(entry.registration) === identity),
|
|
203
|
+
'duplicate_contribution', 'Contribution registration exists')
|
|
204
|
+
return subscribe('action', registration.actionId, handler, condition, registration)
|
|
205
|
+
}
|
|
206
|
+
function commandGroup(group, commands) {
|
|
207
|
+
active()
|
|
208
|
+
boundedText(group, 'command group')
|
|
209
|
+
ensure(Array.isArray(commands) && commands.length > 0 && commands.length <= LIMITS.contributions &&
|
|
210
|
+
subscriptions.size + commands.length <= LIMITS.subscriptions, 'queue_full', 'Invalid or excessive group commands')
|
|
211
|
+
const prepared = commands.map(command => {
|
|
212
|
+
ensure(object(command), 'invalid_payload', 'Invalid group command')
|
|
213
|
+
const { id, handler, ...options } = command
|
|
214
|
+
return prepareRegistration('command', id, { ...options, group }, handler)
|
|
215
|
+
})
|
|
216
|
+
const identities = new Set([...subscriptions.values()].filter(entry => entry.registration).map(entry => registrationKey(entry.registration)))
|
|
217
|
+
for (const { registration } of prepared) {
|
|
218
|
+
const identity = registrationKey(registration)
|
|
219
|
+
ensure(!identities.has(identity), 'duplicate_contribution', 'Contribution registration exists')
|
|
220
|
+
identities.add(identity)
|
|
221
|
+
}
|
|
222
|
+
const members = []
|
|
223
|
+
const dispose = () => { for (const member of members) member.dispose() }
|
|
224
|
+
try { for (const item of prepared) members.push(registerPrepared(item)) }
|
|
225
|
+
catch (error) { dispose(); throw error }
|
|
226
|
+
const ready = Promise.all(members.map(member => member.ready)).then(() => null)
|
|
227
|
+
ready.catch(dispose)
|
|
228
|
+
return { ready, dispose }
|
|
229
|
+
}
|
|
230
|
+
function schedule(intervalMs, handler, { condition = always, immediate = false } = {}) {
|
|
231
|
+
active()
|
|
232
|
+
ensure(Number.isFinite(intervalMs) && intervalMs >= 1000 / 30 && intervalMs <= 2147483647,
|
|
233
|
+
'invalid_payload', 'Schedule interval must be between 1000/30 and 2147483647 ms')
|
|
234
|
+
ensure(typeof handler === 'function' && schedules.size < LIMITS.schedules, 'queue_full', 'Invalid or excessive schedules')
|
|
235
|
+
condition = validateCondition(condition)
|
|
236
|
+
const interest = condition.kind === 'always' ? null : subscribe('visibility', condition.contributionId, () => {}, condition)
|
|
237
|
+
const conditionIdentity = conditionKey(condition)
|
|
238
|
+
const controller = new AbortController()
|
|
239
|
+
let activeRun = null
|
|
240
|
+
let timer
|
|
241
|
+
const beginRun = () => {
|
|
242
|
+
const runController = new AbortController()
|
|
243
|
+
const cancelFromSchedule = () => runController.abort()
|
|
244
|
+
const cancelFromLifetime = () => runController.abort()
|
|
245
|
+
controller.signal.addEventListener('abort', cancelFromSchedule, { once: true })
|
|
246
|
+
lifetime.signal.addEventListener('abort', cancelFromLifetime, { once: true })
|
|
247
|
+
const run = {
|
|
248
|
+
controller: runController,
|
|
249
|
+
finish() {
|
|
250
|
+
controller.signal.removeEventListener('abort', cancelFromSchedule)
|
|
251
|
+
lifetime.signal.removeEventListener('abort', cancelFromLifetime)
|
|
252
|
+
if (activeRun?.controller === runController) activeRun = null
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
activeRun = run
|
|
256
|
+
return run
|
|
257
|
+
}
|
|
258
|
+
const tick = async () => {
|
|
259
|
+
if (controller.signal.aborted || lifetime.signal.aborted) return
|
|
260
|
+
const visible = condition.kind === 'always' || visibility.has(conditionIdentity)
|
|
261
|
+
if (!visible) {
|
|
262
|
+
timer = clock.setTimeout(tick, intervalMs)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const run = beginRun()
|
|
266
|
+
try {
|
|
267
|
+
await handler({ signal: run.controller.signal })
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!run.controller.signal.aborted || condition.kind === 'always') onError(error)
|
|
270
|
+
} finally {
|
|
271
|
+
run.finish()
|
|
272
|
+
}
|
|
273
|
+
// Each schedule has at most one invocation in flight.
|
|
274
|
+
if (!controller.signal.aborted && !lifetime.signal.aborted) timer = clock.setTimeout(tick, intervalMs)
|
|
275
|
+
}
|
|
276
|
+
const disposable = {
|
|
277
|
+
visibilityChanged(nextVisibility) {
|
|
278
|
+
if (condition.kind !== 'always' && !nextVisibility.has(conditionIdentity)) activeRun?.controller.abort()
|
|
279
|
+
},
|
|
280
|
+
dispose() {
|
|
281
|
+
controller.abort()
|
|
282
|
+
activeRun?.controller.abort()
|
|
283
|
+
clock.clearTimeout(timer)
|
|
284
|
+
interest?.dispose()
|
|
285
|
+
schedules.delete(disposable)
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
schedules.add(disposable)
|
|
289
|
+
timer = clock.setTimeout(tick, immediate ? 0 : intervalMs)
|
|
290
|
+
return disposable
|
|
291
|
+
}
|
|
292
|
+
const method = op => (args, options) => request(op, args, options)
|
|
293
|
+
const context = Object.freeze({
|
|
294
|
+
manifest, producer: peer.producer, signal: lifetime.signal, request,
|
|
295
|
+
...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
|
|
296
|
+
(id, entity) => publish(kind, id, entity).publisher])),
|
|
297
|
+
canvas(id, spec, entity) {
|
|
298
|
+
const { publisher, key } = publish(null, id, entity)
|
|
299
|
+
try { publisher.replace({ kind: 'canvas', canvas: spec }) }
|
|
300
|
+
catch (error) { publisher.dispose(); throw error }
|
|
301
|
+
return { ...publisher, write: (ansi, options) => request('canvas.write', { key, ansi }, options),
|
|
302
|
+
focus: (capture, options) => request('canvas.focus', { key, capture }, options) }
|
|
303
|
+
},
|
|
304
|
+
...Object.fromEntries(['Event', 'Hook', 'Action', 'Input', 'Select', 'Resize', 'Activate', 'Deactivate'].map(name =>
|
|
305
|
+
[`on${name}`, (event, handler, condition) => subscribe(name.toLowerCase(), event, handler, condition)])),
|
|
306
|
+
...Object.fromEntries(['menu', 'command', 'key', 'link'].map(kind => [kind,
|
|
307
|
+
(id, options, handler) => registerPrepared(prepareRegistration(kind, id, options, handler))])),
|
|
308
|
+
commandGroup,
|
|
309
|
+
schedule,
|
|
310
|
+
onDispose(cleanup) {
|
|
311
|
+
active()
|
|
312
|
+
ensure(typeof cleanup === 'function' && cleanups.size < LIMITS.subscriptions, 'queue_full', 'Invalid or excessive cleanup callbacks')
|
|
313
|
+
cleanups.add(cleanup)
|
|
314
|
+
return { dispose: () => { cleanups.delete(cleanup) } }
|
|
315
|
+
},
|
|
316
|
+
panes: Object.freeze(Object.fromEntries(['create', 'close', 'restart', 'input', 'focus', 'wait'].map(name => [name, method(`pane.${name}`)]))),
|
|
317
|
+
projects: Object.freeze({ create: method('project.create'), remove: method('project.remove') }),
|
|
318
|
+
notifications: Object.freeze({ show: method('notification.show') }),
|
|
319
|
+
url: Object.freeze({ open: (url, options) => request('url.open', { url }, options) }),
|
|
320
|
+
fetch: method('fetch'),
|
|
321
|
+
secrets: Object.freeze({ get: (name, options) => request('secret.get', { name }, options) }),
|
|
322
|
+
config: Object.freeze({ get: options => request('config.get', {}, options) }),
|
|
323
|
+
state: Object.freeze({ get: (key, options) => request('state.get', { key }, options),
|
|
324
|
+
set: (key, value, options) => request('state.set', { key, value }, options) }),
|
|
325
|
+
context: Object.freeze({ get: options => request('context.get', {}, options) }),
|
|
326
|
+
popover: Object.freeze({ open: method('popover.open') }),
|
|
327
|
+
health: Object.freeze({ set: method('health.set') }),
|
|
328
|
+
webhook: Object.freeze({ ack: (deliveryId, options) => request('webhook.ack', { deliveryId }, options) }),
|
|
329
|
+
})
|
|
330
|
+
return {
|
|
331
|
+
context, receive: frame => peer.receive(frame),
|
|
332
|
+
async activate(definition) {
|
|
333
|
+
active()
|
|
334
|
+
ensure(definition?.id === manifest.id && typeof definition.activate === 'function', 'identity_mismatch', 'Runtime plugin must match static manifest id')
|
|
335
|
+
const cleanup = await definition.activate(context)
|
|
336
|
+
if (typeof cleanup === 'function') {
|
|
337
|
+
if (lifetime.signal.aborted) await cleanup()
|
|
338
|
+
else context.onDispose(cleanup)
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
dispose() {
|
|
342
|
+
if (disposal) return disposal
|
|
343
|
+
// Remove owned surfaces before the lifetime signal prevents new work.
|
|
344
|
+
for (const publisher of publishers.values()) { try { publisher.dispose() } catch (error) { onError(error) } }
|
|
345
|
+
lifetime.abort()
|
|
346
|
+
for (const disposable of schedules) disposable.dispose()
|
|
347
|
+
peer.dispose()
|
|
348
|
+
subscriptions.clear()
|
|
349
|
+
disposal = Promise.allSettled([...cleanups].reverse().map(cleanup => Promise.resolve().then(cleanup)))
|
|
350
|
+
.then(results => { for (const result of results) if (result.status === 'rejected') onError(result.reason); cleanups.clear() })
|
|
351
|
+
return disposal
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
package/src/testing.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
|
|
2
|
+
import type { Envelope } from './internal.js';
|
|
3
|
+
export interface Harness {
|
|
4
|
+
context: PluginContext;
|
|
5
|
+
trace: unknown[];
|
|
6
|
+
surfaces: Map<string, Envelope>;
|
|
7
|
+
subscriptions: Map<string, Json>;
|
|
8
|
+
activate(definition: PluginDefinition): Promise<void>;
|
|
9
|
+
flush(): Promise<void>;
|
|
10
|
+
advance(milliseconds: number): Promise<void>;
|
|
11
|
+
emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
|
|
12
|
+
visibility(conditions: Condition[]): Promise<Json>;
|
|
13
|
+
receive(frame: Envelope): void;
|
|
14
|
+
drainTrace(): unknown[];
|
|
15
|
+
readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
|
|
16
|
+
dispose(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export function createHarness(options: {
|
|
19
|
+
manifest: ManifestInput;
|
|
20
|
+
machineId?: string;
|
|
21
|
+
epoch?: string;
|
|
22
|
+
now?: number;
|
|
23
|
+
handlers?: { [K in keyof OperationMap]?: (args: OperationMap[K]['input'], context: { signal: AbortSignal; timeoutMs: number }) => OperationMap[K]['output'] | Promise<OperationMap[K]['output']> };
|
|
24
|
+
}): Harness;
|
package/src/testing.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createRuntime } from './runtime.mjs'
|
|
2
|
+
import { RpcPeer } from './protocol.mjs'
|
|
3
|
+
import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
|
|
4
|
+
|
|
5
|
+
/** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
|
|
6
|
+
export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
|
|
7
|
+
const manifest = validateManifest(input)
|
|
8
|
+
const producer = { pluginId: manifest.id, machineId, epoch }
|
|
9
|
+
const trace = []
|
|
10
|
+
const surfaces = new Map()
|
|
11
|
+
const subscriptions = new Map()
|
|
12
|
+
const localState = new Map()
|
|
13
|
+
const timers = new Map()
|
|
14
|
+
let timerId = 0
|
|
15
|
+
let disposed = false
|
|
16
|
+
const clock = {
|
|
17
|
+
now: () => now,
|
|
18
|
+
setTimeout(callback, delay) { const id = ++timerId; timers.set(id, { due: now + delay, callback }); return id },
|
|
19
|
+
clearTimeout(id) { timers.delete(id) },
|
|
20
|
+
}
|
|
21
|
+
const record = event => {
|
|
22
|
+
ensure(trace.length < 10000, 'queue_full', 'Harness trace reached 10000 entries; drain it before continuing')
|
|
23
|
+
trace.push(event)
|
|
24
|
+
}
|
|
25
|
+
let runtime
|
|
26
|
+
const host = new RpcPeer({ producer, clock,
|
|
27
|
+
send(frame) { record({ type: 'host', frame: structuredClone(frame) }); runtime.receive(frame) },
|
|
28
|
+
onSurface(frame) {
|
|
29
|
+
const key = JSON.stringify(frame.key)
|
|
30
|
+
frame.content === null ? surfaces.delete(key) : surfaces.set(key, structuredClone(frame))
|
|
31
|
+
},
|
|
32
|
+
onRequest(operation, options) {
|
|
33
|
+
record({ type: 'command', operation: structuredClone(operation) })
|
|
34
|
+
if (handlers[operation.op]) return handlers[operation.op](operation.args, options)
|
|
35
|
+
const args = operation.args
|
|
36
|
+
switch (operation.op) {
|
|
37
|
+
case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
|
|
38
|
+
case 'subscription.remove': subscriptions.delete(args.id); return null
|
|
39
|
+
case 'state.get': return structuredClone(localState.get(args.key) ?? null)
|
|
40
|
+
case 'state.set':
|
|
41
|
+
ensure(localState.has(args.key) || localState.size < LIMITS.contributions, 'queue_full', 'Harness state is full')
|
|
42
|
+
localState.set(args.key, structuredClone(args.value)); return null
|
|
43
|
+
case 'config.get': return {}
|
|
44
|
+
case 'context.get': return { machineId }
|
|
45
|
+
case 'health.set': return null
|
|
46
|
+
default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
onError: error => record({ type: 'error', code: error.code, message: error.message }),
|
|
50
|
+
})
|
|
51
|
+
runtime = createRuntime({ manifest, producer, clock,
|
|
52
|
+
send(frame) { record({ type: 'plugin', frame: structuredClone(frame) }); host.receive(frame) },
|
|
53
|
+
onError: error => record({ type: 'error', code: error.code, message: error.message }),
|
|
54
|
+
})
|
|
55
|
+
async function flush() { for (let turn = 0; turn < 64; turn++) await Promise.resolve() }
|
|
56
|
+
return {
|
|
57
|
+
context: runtime.context, trace, surfaces, subscriptions,
|
|
58
|
+
activate: definition => runtime.activate(definition),
|
|
59
|
+
flush,
|
|
60
|
+
async advance(milliseconds) {
|
|
61
|
+
ensure(Number.isFinite(milliseconds) && milliseconds >= 0, 'invalid_payload', 'Advance requires a nonnegative duration')
|
|
62
|
+
const end = now + milliseconds
|
|
63
|
+
await flush()
|
|
64
|
+
let ticks = 0
|
|
65
|
+
while (true) {
|
|
66
|
+
const next = [...timers].filter(([, timer]) => timer.due <= end)
|
|
67
|
+
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0]
|
|
68
|
+
if (!next) break
|
|
69
|
+
ensure(++ticks <= 10000, 'queue_full', 'Advance exceeds the harness timer budget')
|
|
70
|
+
now = next[1].due
|
|
71
|
+
timers.delete(next[0])
|
|
72
|
+
const result = next[1].callback()
|
|
73
|
+
Promise.resolve(result).catch(error => record({ type: 'error', code: error.code, message: error.message }))
|
|
74
|
+
await flush()
|
|
75
|
+
}
|
|
76
|
+
now = end
|
|
77
|
+
await flush()
|
|
78
|
+
},
|
|
79
|
+
async emit(kind, name, event, options) {
|
|
80
|
+
await flush()
|
|
81
|
+
const results = []
|
|
82
|
+
for (const [subscriptionId, subscription] of subscriptions) {
|
|
83
|
+
if (subscription.kind === kind && subscription.name === name) {
|
|
84
|
+
results.push(await host.request({ op: 'runtime.invoke', args: { subscriptionId, event } }, options))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results
|
|
88
|
+
},
|
|
89
|
+
visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
|
|
90
|
+
receive: frame => runtime.receive(frame),
|
|
91
|
+
drainTrace() { return trace.splice(0) },
|
|
92
|
+
get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
|
|
93
|
+
async dispose() {
|
|
94
|
+
if (disposed) return
|
|
95
|
+
disposed = true
|
|
96
|
+
await runtime.dispose()
|
|
97
|
+
host.dispose()
|
|
98
|
+
subscriptions.clear()
|
|
99
|
+
surfaces.clear()
|
|
100
|
+
timers.clear()
|
|
101
|
+
record({ type: 'disposed' })
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|