@frockbot/applet-sdk 0.7.35 → 0.7.37
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 +25 -8
- package/package.json +4 -1
- package/plugin/index.d.ts +386 -0
- package/plugin/template/plugin.json +25 -0
- package/plugin/template/plugin.ts +64 -0
- package/src/build/paths.ts +3 -0
- package/src/build/plugin.ts +494 -0
package/README.md
CHANGED
|
@@ -12,14 +12,16 @@ at any point.
|
|
|
12
12
|
|
|
13
13
|
## Entry points
|
|
14
14
|
|
|
15
|
-
| Import
|
|
16
|
-
|
|
|
17
|
-
| `@frockbot/applet-sdk/server`
|
|
18
|
-
| `@frockbot/applet-sdk/client`
|
|
19
|
-
| `@frockbot/applet-sdk/kit`
|
|
20
|
-
| `@frockbot/applet-sdk/lint`
|
|
21
|
-
| `@frockbot/applet-sdk/protocol`
|
|
22
|
-
| `@frockbot/applet-sdk/build`
|
|
15
|
+
| Import | For |
|
|
16
|
+
| ----------------------------------- | -------------------------------------------------------------------- |
|
|
17
|
+
| `@frockbot/applet-sdk/server` | `Applet`, `table`, `t` — the Applet's `server.ts` |
|
|
18
|
+
| `@frockbot/applet-sdk/client` | `createApplet`, `mount`, `newId` — the Applet's `ui.tsx` |
|
|
19
|
+
| `@frockbot/applet-sdk/kit` | the fourteen components (`src/kit/README.md`) |
|
|
20
|
+
| `@frockbot/applet-sdk/lint` | the flat ESLint config and the five custom rules |
|
|
21
|
+
| `@frockbot/applet-sdk/protocol` | wire protocol v1, for the kernel and for tests |
|
|
22
|
+
| `@frockbot/applet-sdk/build` | `runAppletBuildV1` — the five stages, for the service |
|
|
23
|
+
| `@frockbot/applet-sdk/plugin` | types only: `PluginModule`, `PluginContext` — a Plugin's `plugin.ts` |
|
|
24
|
+
| `@frockbot/applet-sdk/build/plugin` | `runPluginBuildV1` — the four Plugin stages, for the service |
|
|
23
25
|
|
|
24
26
|
## The build
|
|
25
27
|
|
|
@@ -38,6 +40,21 @@ with the code.
|
|
|
38
40
|
`scripts/build-applets-assets.ts` turns it into `applets/template.generated.ts`,
|
|
39
41
|
which `applet_create` writes through the Workspace.
|
|
40
42
|
|
|
43
|
+
## Plugins
|
|
44
|
+
|
|
45
|
+
A Plugin (ADR 0026) is written against `@frockbot/applet-sdk/plugin`, which
|
|
46
|
+
is declarations only: `plugin.ts` exports `tools` and `execute`, and may
|
|
47
|
+
export `hooks`, `services` and `triggers`, beside a `plugin.json` descriptor.
|
|
48
|
+
`tools` may be empty — a Plugin that only serves hooks is admissible, because
|
|
49
|
+
the kernel's own descriptor contract admits one.
|
|
50
|
+
`runPluginBuildV1(directory, { mode, id })` is four stages — `descriptor`,
|
|
51
|
+
`typecheck`, `bundle`, `describe` — with no lint stage, because a Plugin's
|
|
52
|
+
reach is a grant the descriptor declares and the kernel enforces. The bundle
|
|
53
|
+
is one ESM module with every import inlined, and the manifest is read by
|
|
54
|
+
running that module in Miniflare with no outbound network. `plugin/template/`
|
|
55
|
+
is the scaffold a new Plugin starts as. `PluginContext` is held to the
|
|
56
|
+
kernel's own `ctx` keys by `app/plugins/sdk-types.test.ts`.
|
|
57
|
+
|
|
41
58
|
## What runs where
|
|
42
59
|
|
|
43
60
|
`server.ts` becomes a single ESM file whose only import is `cloudflare:workers`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/applet-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.37",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Authoring SDK for FrockBot Applets: schema-first Durable Object server, TanStack DB client, component kit, linter, and the build pipeline.",
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
"./lint": "./src/lint/index.ts",
|
|
13
13
|
"./protocol": "./src/protocol/index.ts",
|
|
14
14
|
"./build": "./src/build/pipeline.ts",
|
|
15
|
+
"./build/plugin": "./src/build/plugin.ts",
|
|
16
|
+
"./plugin": "./plugin/index.d.ts",
|
|
15
17
|
"./package.json": "./package.json"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
20
|
"src",
|
|
19
21
|
"types",
|
|
20
22
|
"template",
|
|
23
|
+
"plugin",
|
|
21
24
|
"README.md"
|
|
22
25
|
],
|
|
23
26
|
"scripts": {
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@frockbot/applet-sdk/plugin` — what a Plugin's `plugin.ts` is written
|
|
3
|
+
* against (ADR 0026).
|
|
4
|
+
*
|
|
5
|
+
* A Plugin is one ESM module with no imports of its own. It exports `tools`
|
|
6
|
+
* and `execute`, and may export `hooks`, `services` and `triggers`. The
|
|
7
|
+
* kernel's generated index imports the built module, checks these exports
|
|
8
|
+
* against the Plugin's `plugin.json` once at mount, and hands every call a
|
|
9
|
+
* narrow `ctx` naming only what that Plugin declared it may do.
|
|
10
|
+
*
|
|
11
|
+
* These declarations are types only. They mirror `core/contracts/isolate.ts`
|
|
12
|
+
* member for member — `BOT_ISOLATE_CONTEXT_KEYS_V1` is the list this file's
|
|
13
|
+
* `PluginContext` is tested against — so a Plugin that type-checks here sees
|
|
14
|
+
* the `ctx` the wrapper actually builds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A JSON Schema object, as a tool's `inputSchema`. */
|
|
18
|
+
export type JsonSchema = { [key: string]: unknown };
|
|
19
|
+
|
|
20
|
+
/** The loop events a Plugin may hook, in the order the loop raises them. */
|
|
21
|
+
export type PluginHookEvent =
|
|
22
|
+
| "system-prompt/assemble"
|
|
23
|
+
| "agent/tool-exposure"
|
|
24
|
+
| "agent/request"
|
|
25
|
+
| "tools/pre-execute"
|
|
26
|
+
| "tools/post-execute"
|
|
27
|
+
| "agent/turn-stopping";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The grants a Plugin may declare in `plugin.json`, in the kernel's order.
|
|
31
|
+
* `storage`, `http`, `schedule`, `ai`, `memory` and `workspace` each open one
|
|
32
|
+
* member of `ctx`; `files` and `computer` are declared to the authority and
|
|
33
|
+
* open nothing here.
|
|
34
|
+
*/
|
|
35
|
+
export type PluginGrant =
|
|
36
|
+
| "storage"
|
|
37
|
+
| "http"
|
|
38
|
+
| "schedule"
|
|
39
|
+
| "ai"
|
|
40
|
+
| "files"
|
|
41
|
+
| "memory"
|
|
42
|
+
| "workspace"
|
|
43
|
+
| "computer";
|
|
44
|
+
|
|
45
|
+
/** One tool the Plugin offers the Bot. Its name must be in `plugin.json` too. */
|
|
46
|
+
export interface PluginTool {
|
|
47
|
+
/** `^[a-z][a-z0-9_]{0,63}$`, unique within the Plugin. */
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
inputSchema: JsonSchema;
|
|
51
|
+
/** True when a retry under the same input is harmless. Default false. */
|
|
52
|
+
idempotent?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Which Turn types may call it. Absent means every Turn type. Subagent
|
|
55
|
+
* roles narrow it further.
|
|
56
|
+
*/
|
|
57
|
+
admission?: { turnTypes: string[]; subagentRoles?: string[] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A capability call the authority could not serve. Never an exception. */
|
|
61
|
+
export interface CapabilityFailure {
|
|
62
|
+
status: "unavailable";
|
|
63
|
+
reason: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface StorageEntry {
|
|
67
|
+
key: string;
|
|
68
|
+
value: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `storage` grant: a key-value store scoped to this Plugin and this Bot.
|
|
73
|
+
* Keys are `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`; a value serializes to at
|
|
74
|
+
* most 64 KiB.
|
|
75
|
+
*/
|
|
76
|
+
export interface PluginStorage {
|
|
77
|
+
get(request: {
|
|
78
|
+
key: string;
|
|
79
|
+
}): Promise<{ status: "available"; value: unknown } | CapabilityFailure>;
|
|
80
|
+
put(request: {
|
|
81
|
+
key: string;
|
|
82
|
+
value: unknown;
|
|
83
|
+
}): Promise<{ status: "available"; value: unknown } | CapabilityFailure>;
|
|
84
|
+
delete(request: {
|
|
85
|
+
key: string;
|
|
86
|
+
}): Promise<{ status: "available"; value: unknown } | CapabilityFailure>;
|
|
87
|
+
list(request: {
|
|
88
|
+
prefix?: string;
|
|
89
|
+
limit?: number;
|
|
90
|
+
cursor?: string;
|
|
91
|
+
}): Promise<
|
|
92
|
+
| { status: "available"; entries: StorageEntry[]; cursor?: string }
|
|
93
|
+
| CapabilityFailure
|
|
94
|
+
>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** This Plugin's settings values for this Bot, as the User set them. */
|
|
98
|
+
export interface PluginSettings {
|
|
99
|
+
read(): Promise<
|
|
100
|
+
{ status: "available"; values: Record<string, unknown> } | CapabilityFailure
|
|
101
|
+
>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One Connection the Bot holds, as `capabilities.list()` names it. */
|
|
105
|
+
export interface ConnectionSummary {
|
|
106
|
+
connectionId: string;
|
|
107
|
+
packageId: string;
|
|
108
|
+
connectionTypeId: string;
|
|
109
|
+
displayName: string;
|
|
110
|
+
generation: string;
|
|
111
|
+
safeMetadata: Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The Bot's configured model, as `capabilities.list()` names it. */
|
|
115
|
+
export interface ModelBindingSummary {
|
|
116
|
+
connectionId: string;
|
|
117
|
+
packageId: string;
|
|
118
|
+
provider: string;
|
|
119
|
+
providerModelId: string;
|
|
120
|
+
connectionGeneration: string;
|
|
121
|
+
catalogGeneration?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What the Bot holds right now. Nothing here widens what the Plugin may do. */
|
|
125
|
+
export interface CapabilityList {
|
|
126
|
+
status: "available";
|
|
127
|
+
connections: ConnectionSummary[];
|
|
128
|
+
model?: ModelBindingSummary;
|
|
129
|
+
memory: boolean;
|
|
130
|
+
workspace: boolean;
|
|
131
|
+
schedule: true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** An opaque, short-lived reference to a Connection — never its credential. */
|
|
135
|
+
export interface ConnectionLease {
|
|
136
|
+
status: "available";
|
|
137
|
+
leaseId: string;
|
|
138
|
+
connectionId: string;
|
|
139
|
+
generation: string;
|
|
140
|
+
expiresAt: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One streamed model event. `text-delta` carries the text a reply grows by. */
|
|
144
|
+
export type ModelStreamEvent =
|
|
145
|
+
| { type: "text-delta"; text: string }
|
|
146
|
+
| { type: string; [key: string]: unknown };
|
|
147
|
+
|
|
148
|
+
/** The `ai` grant: the Bot's own configured model, never another. */
|
|
149
|
+
export interface PluginModel {
|
|
150
|
+
invoke(request: { [key: string]: unknown }): Promise<
|
|
151
|
+
| {
|
|
152
|
+
status: "streaming";
|
|
153
|
+
requestId: string;
|
|
154
|
+
events: AsyncIterable<ModelStreamEvent>;
|
|
155
|
+
}
|
|
156
|
+
| CapabilityFailure
|
|
157
|
+
>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type MemoryScope = "bot" | "user" | "project";
|
|
161
|
+
export type MemoryTier = "profile" | "log" | "note";
|
|
162
|
+
|
|
163
|
+
/** The `memory` grant. */
|
|
164
|
+
export interface PluginMemory {
|
|
165
|
+
read(request: {
|
|
166
|
+
scope: MemoryScope;
|
|
167
|
+
projectId?: string;
|
|
168
|
+
}): Promise<{ status: "available"; value: unknown } | CapabilityFailure>;
|
|
169
|
+
write(request: {
|
|
170
|
+
scope: MemoryScope;
|
|
171
|
+
projectId?: string;
|
|
172
|
+
tier?: MemoryTier;
|
|
173
|
+
fact: string;
|
|
174
|
+
}): Promise<{ status: "available"; value: unknown } | CapabilityFailure>;
|
|
175
|
+
forget(request: {
|
|
176
|
+
scope: MemoryScope;
|
|
177
|
+
projectId?: string;
|
|
178
|
+
tier?: MemoryTier;
|
|
179
|
+
fact: string;
|
|
180
|
+
}): Promise<{ status: "available"; value: unknown } | CapabilityFailure>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A Workspace root without a User id; the authority supplies its own. */
|
|
184
|
+
export type WorkspaceRoot =
|
|
185
|
+
| { kind: "bot-instructions"; botId: string }
|
|
186
|
+
| { kind: "user-instructions" }
|
|
187
|
+
| { kind: "package-declared"; packageId: string; rootId: string };
|
|
188
|
+
|
|
189
|
+
export interface WorkspacePath {
|
|
190
|
+
root: WorkspaceRoot;
|
|
191
|
+
path: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type WorkspaceOutcome =
|
|
195
|
+
{ status: "available"; value: unknown } | CapabilityFailure;
|
|
196
|
+
|
|
197
|
+
/** The `workspace` grant. */
|
|
198
|
+
export interface PluginWorkspace {
|
|
199
|
+
read(path: WorkspacePath): Promise<WorkspaceOutcome>;
|
|
200
|
+
list(request: {
|
|
201
|
+
root: WorkspaceRoot;
|
|
202
|
+
prefix?: string;
|
|
203
|
+
cursor?: string;
|
|
204
|
+
limit?: number;
|
|
205
|
+
}): Promise<WorkspaceOutcome>;
|
|
206
|
+
stat(path: WorkspacePath): Promise<WorkspaceOutcome>;
|
|
207
|
+
write(request: {
|
|
208
|
+
path: WorkspacePath;
|
|
209
|
+
bytes: Uint8Array;
|
|
210
|
+
expectedGenerationId: string | null;
|
|
211
|
+
mediaType?: string;
|
|
212
|
+
}): Promise<WorkspaceOutcome>;
|
|
213
|
+
delete(request: {
|
|
214
|
+
path: WorkspacePath;
|
|
215
|
+
expectedGenerationId: string;
|
|
216
|
+
}): Promise<WorkspaceOutcome>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The `ctx` every tool call and hook receives.
|
|
221
|
+
*
|
|
222
|
+
* Three context keys — `user`, `bot`, `session` — and one member per grant
|
|
223
|
+
* the Plugin declared. A grant the Plugin did not ask for is absent from
|
|
224
|
+
* `ctx` entirely, which is why every grant member is optional here.
|
|
225
|
+
*/
|
|
226
|
+
export interface PluginContext {
|
|
227
|
+
/** The tool being executed; absent inside a hook. */
|
|
228
|
+
readonly tool?: string;
|
|
229
|
+
/** The hook event being raised; absent inside a tool call. */
|
|
230
|
+
readonly event?: PluginHookEvent;
|
|
231
|
+
readonly user: { readonly userId: string };
|
|
232
|
+
readonly bot: { readonly botId: string };
|
|
233
|
+
readonly session: {
|
|
234
|
+
readonly sessionId: string;
|
|
235
|
+
readonly runId: string;
|
|
236
|
+
readonly turnId: string;
|
|
237
|
+
readonly generationId: string;
|
|
238
|
+
};
|
|
239
|
+
/** This Plugin's id. */
|
|
240
|
+
readonly packageId: string;
|
|
241
|
+
/** How long this call may run, in milliseconds. */
|
|
242
|
+
readonly deadlineMs: number;
|
|
243
|
+
/** The names of the bindings the worker was given. */
|
|
244
|
+
readonly bindings: string[];
|
|
245
|
+
readonly capabilities: {
|
|
246
|
+
list(): Promise<CapabilityList | CapabilityFailure>;
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* The services other Plugins in the worker provide and this Plugin declared
|
|
250
|
+
* it consumes, by service name. Empty for a Plugin that consumes nothing.
|
|
251
|
+
*/
|
|
252
|
+
readonly services: Record<string, unknown>;
|
|
253
|
+
readonly settings: PluginSettings;
|
|
254
|
+
/** The `ai` grant. */
|
|
255
|
+
readonly model?: PluginModel;
|
|
256
|
+
/** The `memory` grant. */
|
|
257
|
+
readonly memory?: PluginMemory;
|
|
258
|
+
/** The `workspace` grant. */
|
|
259
|
+
readonly workspace?: PluginWorkspace;
|
|
260
|
+
/** The `http` grant: a named Connection, credential attached server-side. */
|
|
261
|
+
readonly connection?: (
|
|
262
|
+
connectionId: string,
|
|
263
|
+
) => Promise<ConnectionLease | CapabilityFailure>;
|
|
264
|
+
/** The `schedule` grant: a durable Routine operation attributed to this call. */
|
|
265
|
+
readonly schedule?: (request: {
|
|
266
|
+
callId: string;
|
|
267
|
+
input: unknown;
|
|
268
|
+
}) => Promise<
|
|
269
|
+
| { status: "completed"; content: string; isError: boolean }
|
|
270
|
+
| CapabilityFailure
|
|
271
|
+
>;
|
|
272
|
+
/** The `storage` grant. */
|
|
273
|
+
readonly storage?: PluginStorage;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** `ctx` inside `execute`: the tool is named, no event is. */
|
|
277
|
+
export interface PluginExecutionContext extends PluginContext {
|
|
278
|
+
readonly tool: string;
|
|
279
|
+
readonly event?: never;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `ctx` inside a hook: the event is named, no tool is. */
|
|
283
|
+
export interface PluginHookContext extends PluginContext {
|
|
284
|
+
readonly tool?: never;
|
|
285
|
+
readonly event: PluginHookEvent;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** One step of the loop, as a hook payload carries it. */
|
|
289
|
+
export interface StepSnapshot {
|
|
290
|
+
step: number;
|
|
291
|
+
[key: string]: unknown;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One tool as the model is offered it. */
|
|
295
|
+
export interface ToolSchema {
|
|
296
|
+
name: string;
|
|
297
|
+
description: string;
|
|
298
|
+
inputSchema: JsonSchema;
|
|
299
|
+
[key: string]: unknown;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* What each hook receives, and the one value it may replace. A hook returns
|
|
304
|
+
* `undefined` to leave the value alone, or the replacement — the *whole*
|
|
305
|
+
* value, not a patch. A later Plugin sees an earlier Plugin's replacement.
|
|
306
|
+
* `agent/turn-stopping` is a notification: its return is ignored.
|
|
307
|
+
*/
|
|
308
|
+
export interface PluginHookPayloads {
|
|
309
|
+
"system-prompt/assemble": {
|
|
310
|
+
context: { [key: string]: unknown };
|
|
311
|
+
assembly: { [key: string]: unknown };
|
|
312
|
+
};
|
|
313
|
+
"agent/tool-exposure": { step: StepSnapshot; tools: ToolSchema[] };
|
|
314
|
+
"agent/request": { step: StepSnapshot; request: { [key: string]: unknown } };
|
|
315
|
+
"tools/pre-execute": {
|
|
316
|
+
call: { [key: string]: unknown };
|
|
317
|
+
context: { [key: string]: unknown };
|
|
318
|
+
preparation: { [key: string]: unknown };
|
|
319
|
+
};
|
|
320
|
+
"tools/post-execute": {
|
|
321
|
+
call: { [key: string]: unknown };
|
|
322
|
+
context: { [key: string]: unknown };
|
|
323
|
+
result: { [key: string]: unknown };
|
|
324
|
+
};
|
|
325
|
+
"agent/turn-stopping": { agent: { [key: string]: unknown }; turn: number };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface PluginHookReplacements {
|
|
329
|
+
"system-prompt/assemble": PluginHookPayloads["system-prompt/assemble"]["assembly"];
|
|
330
|
+
"agent/tool-exposure": ToolSchema[];
|
|
331
|
+
"agent/request": PluginHookPayloads["agent/request"]["request"];
|
|
332
|
+
"tools/pre-execute": PluginHookPayloads["tools/pre-execute"]["preparation"];
|
|
333
|
+
"tools/post-execute": PluginHookPayloads["tools/post-execute"]["result"];
|
|
334
|
+
"agent/turn-stopping": never;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export type PluginHook<Event extends PluginHookEvent> = (
|
|
338
|
+
payload: PluginHookPayloads[Event],
|
|
339
|
+
ctx: PluginHookContext,
|
|
340
|
+
) =>
|
|
341
|
+
| Promise<PluginHookReplacements[Event] | undefined | void>
|
|
342
|
+
| PluginHookReplacements[Event]
|
|
343
|
+
| undefined
|
|
344
|
+
| void;
|
|
345
|
+
|
|
346
|
+
export type PluginHooks = {
|
|
347
|
+
[Event in PluginHookEvent]?: PluginHook<Event>;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** A trigger handler: what it returns, when non-empty, is what the Bot reads. */
|
|
351
|
+
export type PluginTrigger = (
|
|
352
|
+
event: { [key: string]: unknown },
|
|
353
|
+
ctx: PluginContext,
|
|
354
|
+
) => Promise<string | undefined | void> | string | undefined | void;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A tool call's answer. A string is handed to the Bot as it is; anything else
|
|
358
|
+
* is JSON-serialized. Throw to answer with an error the Bot can read — the
|
|
359
|
+
* wrapper turns a thrown `Error` into an error result with its message.
|
|
360
|
+
*/
|
|
361
|
+
export type ToolResult = string | unknown;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The module shape `plugin.ts` must satisfy. There is no default export —
|
|
365
|
+
* export each member by name:
|
|
366
|
+
*
|
|
367
|
+
* ```ts
|
|
368
|
+
* export const tools: PluginTool[] = [...];
|
|
369
|
+
* export const execute: PluginExecute = async (tool, input, ctx) => {...};
|
|
370
|
+
* export const hooks: PluginHooks = {...};
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export type PluginExecute = (
|
|
374
|
+
tool: string,
|
|
375
|
+
input: unknown,
|
|
376
|
+
ctx: PluginExecutionContext,
|
|
377
|
+
) => Promise<ToolResult> | ToolResult;
|
|
378
|
+
|
|
379
|
+
export interface PluginModule {
|
|
380
|
+
tools: PluginTool[];
|
|
381
|
+
execute: PluginExecute;
|
|
382
|
+
hooks?: PluginHooks;
|
|
383
|
+
/** Values other Plugins that `consume` a service of the same name receive. */
|
|
384
|
+
services?: Record<string, unknown>;
|
|
385
|
+
triggers?: Record<string, PluginTrigger>;
|
|
386
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "__PLUGIN_ID__",
|
|
3
|
+
"displayName": "__PLUGIN_NAME__",
|
|
4
|
+
"version": "1",
|
|
5
|
+
"contractVersion": 4,
|
|
6
|
+
"tools": [
|
|
7
|
+
{
|
|
8
|
+
"name": "note_count",
|
|
9
|
+
"description": "How many notes this Plugin has kept for this Bot.",
|
|
10
|
+
"inputSchema": { "type": "object", "properties": {} }
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "note_add",
|
|
14
|
+
"description": "Keep one short note for this Bot.",
|
|
15
|
+
"inputSchema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": { "text": { "type": "string" } },
|
|
18
|
+
"required": ["text"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"hooks": [],
|
|
23
|
+
"grants": ["storage"],
|
|
24
|
+
"contextKeys": ["user", "bot", "session"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PluginExecute, PluginTool } from "@frockbot/applet-sdk/plugin";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* __PLUGIN_NAME__: a starting point that already builds.
|
|
5
|
+
*
|
|
6
|
+
* Every tool here is also named in `plugin.json`, and the `storage` grant the
|
|
7
|
+
* descriptor declares is what makes `ctx.storage` exist. Change both files
|
|
8
|
+
* together: a tool the module exports but the descriptor omits, or the other
|
|
9
|
+
* way round, is refused at publish.
|
|
10
|
+
*/
|
|
11
|
+
export const tools: PluginTool[] = [
|
|
12
|
+
{
|
|
13
|
+
name: "note_count",
|
|
14
|
+
description: "How many notes this Plugin has kept for this Bot.",
|
|
15
|
+
inputSchema: { type: "object", properties: {} },
|
|
16
|
+
idempotent: true,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "note_add",
|
|
20
|
+
description: "Keep one short note for this Bot.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: { text: { type: "string" } },
|
|
24
|
+
required: ["text"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
interface Notes {
|
|
30
|
+
entries: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readNotes(
|
|
34
|
+
storage: NonNullable<Parameters<PluginExecute>[2]["storage"]>,
|
|
35
|
+
): Promise<Notes> {
|
|
36
|
+
const outcome = await storage.get({ key: "notes" });
|
|
37
|
+
if (outcome.status !== "available" || !outcome.value) return { entries: [] };
|
|
38
|
+
return outcome.value as Notes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A string answer goes to the Bot as it is. Throwing answers with an error
|
|
43
|
+
* the Bot can read, so a refusal is a thrown `Error`, never a quiet string.
|
|
44
|
+
*/
|
|
45
|
+
export const execute: PluginExecute = async (tool, input, ctx) => {
|
|
46
|
+
const storage = ctx.storage;
|
|
47
|
+
if (!storage) throw new Error("the storage grant is not open");
|
|
48
|
+
switch (tool) {
|
|
49
|
+
case "note_count": {
|
|
50
|
+
const notes = await readNotes(storage);
|
|
51
|
+
return `${notes.entries.length} note(s).`;
|
|
52
|
+
}
|
|
53
|
+
case "note_add": {
|
|
54
|
+
const text = String((input as { text?: unknown } | null)?.text ?? "");
|
|
55
|
+
if (text.length === 0) throw new Error("text is required");
|
|
56
|
+
const notes = await readNotes(storage);
|
|
57
|
+
notes.entries.push(text);
|
|
58
|
+
await storage.put({ key: "notes", value: notes });
|
|
59
|
+
return `Kept it. ${notes.entries.length} note(s) now.`;
|
|
60
|
+
}
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`unknown tool ${tool}`);
|
|
63
|
+
}
|
|
64
|
+
};
|
package/src/build/paths.ts
CHANGED
|
@@ -60,6 +60,9 @@ export const SDK_WORKERS_TYPES = join(
|
|
|
60
60
|
"types/cloudflare-workers.d.ts",
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
+
/** The Plugin declarations (`@frockbot/applet-sdk/plugin`), types only. */
|
|
64
|
+
export const SDK_PLUGIN_TYPES = join(SDK_ROOT, "plugin/index.d.ts");
|
|
65
|
+
|
|
63
66
|
function packageDirectory(specifier: string): string {
|
|
64
67
|
return dirname(require.resolve(`${specifier}/package.json`));
|
|
65
68
|
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Plugin build (ADR 0026): four named stages over one directory, sharing
|
|
3
|
+
* the Applet build's type checker, bundler and Miniflare boot.
|
|
4
|
+
*
|
|
5
|
+
* 1. `descriptor` — `plugin.json` parses and names the Plugin the caller
|
|
6
|
+
* asked for. Nothing more is decided here: the app Worker holds the full
|
|
7
|
+
* descriptor decoder and compares the manifest with it before it stores
|
|
8
|
+
* anything.
|
|
9
|
+
* 2. `typecheck` — `plugin.ts` and its siblings against
|
|
10
|
+
* `@frockbot/applet-sdk/plugin`, strict.
|
|
11
|
+
* 3. `bundle` — one ESM module. No import survives: the module is loaded by
|
|
12
|
+
* a Worker with no bindings but the kernel's loopback, so a specifier the
|
|
13
|
+
* bundler could not inline is a build failure, not a mount surprise.
|
|
14
|
+
* 4. `describe` — the bundle runs in Miniflare, with no outbound network,
|
|
15
|
+
* and reports what it exports. That is the manifest: the truth about the
|
|
16
|
+
* module, read by running it, exactly as the kernel's index will.
|
|
17
|
+
*
|
|
18
|
+
* `check` stops after the type checker. There is no lint stage: a Plugin's
|
|
19
|
+
* network access is a grant the descriptor declares and the kernel enforces
|
|
20
|
+
* at the egress, not a rule a linter could state.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import { join, relative, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { build as esbuild } from "esbuild";
|
|
28
|
+
import { convertV4MiniflareOptions, Miniflare } from "miniflare";
|
|
29
|
+
import ts from "typescript";
|
|
30
|
+
|
|
31
|
+
import type { AppletDiagnostic } from "../lint/index.js";
|
|
32
|
+
import { APPLET_COMPATIBILITY_DATE } from "./runtime.js";
|
|
33
|
+
import { SDK_PLUGIN_TYPES } from "./paths.js";
|
|
34
|
+
|
|
35
|
+
export type PluginBuildStage =
|
|
36
|
+
"descriptor" | "typecheck" | "bundle" | "describe";
|
|
37
|
+
|
|
38
|
+
/** The Plugin's `plugin.json`, as far as the build reads it. */
|
|
39
|
+
export interface PluginBuildDescriptorV1 {
|
|
40
|
+
id: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the built module exports, read by running it. */
|
|
44
|
+
export interface PluginDescriptionV1 {
|
|
45
|
+
tools: {
|
|
46
|
+
name: string;
|
|
47
|
+
description: string;
|
|
48
|
+
inputSchema: Record<string, unknown>;
|
|
49
|
+
}[];
|
|
50
|
+
hooks: string[];
|
|
51
|
+
services: string[];
|
|
52
|
+
triggers: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PluginBuildManifestV1 extends PluginDescriptionV1 {
|
|
56
|
+
contract: 1;
|
|
57
|
+
hashes: { module: string };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type PluginBuildOutcome =
|
|
61
|
+
| { status: "checked" }
|
|
62
|
+
| { status: "built"; manifest: PluginBuildManifestV1; module: string }
|
|
63
|
+
| {
|
|
64
|
+
status: "failed";
|
|
65
|
+
stage: PluginBuildStage;
|
|
66
|
+
diagnostics: AppletDiagnostic[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const PLUGIN_ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
70
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
71
|
+
const MAX_TOOLS = 64;
|
|
72
|
+
|
|
73
|
+
const COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
74
|
+
target: ts.ScriptTarget.ES2022,
|
|
75
|
+
module: ts.ModuleKind.ESNext,
|
|
76
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
77
|
+
strict: true,
|
|
78
|
+
noEmit: true,
|
|
79
|
+
skipLibCheck: true,
|
|
80
|
+
esModuleInterop: true,
|
|
81
|
+
forceConsistentCasingInFileNames: true,
|
|
82
|
+
// ES2022 for the language; the DOM lib for `fetch`, `Request` and
|
|
83
|
+
// `Response`, which the Workers runtime provides under the same names.
|
|
84
|
+
lib: ["lib.es2022.d.ts", "lib.dom.d.ts", "lib.dom.iterable.d.ts"],
|
|
85
|
+
types: [],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function thrown(error: unknown, file = "plugin.json"): AppletDiagnostic[] {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
file,
|
|
92
|
+
line: 1,
|
|
93
|
+
column: 1,
|
|
94
|
+
message: error instanceof Error ? error.message : String(error),
|
|
95
|
+
severity: "error",
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sha256(text: string): string {
|
|
101
|
+
return createHash("sha256").update(text).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function readPluginDescriptor(
|
|
105
|
+
directory: string,
|
|
106
|
+
): Promise<PluginBuildDescriptorV1> {
|
|
107
|
+
let text: string;
|
|
108
|
+
try {
|
|
109
|
+
text = await readFile(join(directory, "plugin.json"), "utf8");
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(`No plugin.json in the Plugin's source`);
|
|
112
|
+
}
|
|
113
|
+
let parsed: unknown;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(text);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`plugin.json is not JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
122
|
+
throw new Error("plugin.json must be an object");
|
|
123
|
+
}
|
|
124
|
+
const id = (parsed as { id?: unknown }).id;
|
|
125
|
+
if (typeof id !== "string" || !PLUGIN_ID.test(id)) {
|
|
126
|
+
throw new Error('plugin.json "id" must match /^[a-z][a-z0-9-]{0,63}$/');
|
|
127
|
+
}
|
|
128
|
+
return { id };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function pluginSources(directory: string): Promise<string[]> {
|
|
132
|
+
const found: string[] = [];
|
|
133
|
+
const walk = async (current: string): Promise<void> => {
|
|
134
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
135
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
136
|
+
const path = join(current, entry.name);
|
|
137
|
+
if (entry.isDirectory()) await walk(path);
|
|
138
|
+
else if (/\.ts$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
139
|
+
found.push(path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
await walk(directory);
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Type-check the Plugin against the SDK's Plugin declarations. */
|
|
148
|
+
export async function typeCheckPlugin(
|
|
149
|
+
directory: string,
|
|
150
|
+
): Promise<AppletDiagnostic[]> {
|
|
151
|
+
const root = resolve(directory);
|
|
152
|
+
const files = await pluginSources(root);
|
|
153
|
+
if (!files.some((file) => relative(root, file) === "plugin.ts")) {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
file: "plugin.ts",
|
|
157
|
+
line: 1,
|
|
158
|
+
column: 1,
|
|
159
|
+
message: "No plugin.ts found; a Plugin's module is plugin.ts.",
|
|
160
|
+
severity: "error",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
const program = ts.createProgram(files, {
|
|
165
|
+
...COMPILER_OPTIONS,
|
|
166
|
+
paths: { "@frockbot/applet-sdk/plugin": [SDK_PLUGIN_TYPES] },
|
|
167
|
+
});
|
|
168
|
+
return ts
|
|
169
|
+
.getPreEmitDiagnostics(program)
|
|
170
|
+
.filter(
|
|
171
|
+
(diagnostic) =>
|
|
172
|
+
!diagnostic.file || diagnostic.file.fileName.startsWith(root),
|
|
173
|
+
)
|
|
174
|
+
.map((diagnostic) => {
|
|
175
|
+
const message = ts.flattenDiagnosticMessageText(
|
|
176
|
+
diagnostic.messageText,
|
|
177
|
+
" ",
|
|
178
|
+
);
|
|
179
|
+
if (!diagnostic.file || diagnostic.start === undefined) {
|
|
180
|
+
return {
|
|
181
|
+
file: "plugin.ts",
|
|
182
|
+
line: 1,
|
|
183
|
+
column: 1,
|
|
184
|
+
message,
|
|
185
|
+
severity: "error" as const,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const position = diagnostic.file.getLineAndCharacterOfPosition(
|
|
189
|
+
diagnostic.start,
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
file: relative(root, diagnostic.file.fileName),
|
|
193
|
+
line: position.line + 1,
|
|
194
|
+
column: position.character + 1,
|
|
195
|
+
message: `${message} (TS${diagnostic.code})`,
|
|
196
|
+
severity:
|
|
197
|
+
diagnostic.category === ts.DiagnosticCategory.Error
|
|
198
|
+
? ("error" as const)
|
|
199
|
+
: ("warning" as const),
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One ESM module, every import inlined. `@frockbot/applet-sdk/plugin` is
|
|
206
|
+
* types only, so a value import of it is the one specifier that can never
|
|
207
|
+
* resolve — the message says so rather than reporting a missing package.
|
|
208
|
+
*/
|
|
209
|
+
export async function bundlePlugin(directory: string): Promise<string> {
|
|
210
|
+
const result = await esbuild({
|
|
211
|
+
entryPoints: [join(directory, "plugin.ts")],
|
|
212
|
+
bundle: true,
|
|
213
|
+
write: false,
|
|
214
|
+
format: "esm",
|
|
215
|
+
platform: "neutral",
|
|
216
|
+
target: "es2022",
|
|
217
|
+
minify: false,
|
|
218
|
+
legalComments: "none",
|
|
219
|
+
external: [],
|
|
220
|
+
plugins: [
|
|
221
|
+
{
|
|
222
|
+
name: "plugin-sdk-is-types-only",
|
|
223
|
+
setup(build) {
|
|
224
|
+
build.onResolve(
|
|
225
|
+
{ filter: /^@frockbot\/applet-sdk\/plugin$/ },
|
|
226
|
+
() => ({
|
|
227
|
+
errors: [
|
|
228
|
+
{
|
|
229
|
+
text: '"@frockbot/applet-sdk/plugin" is types only; import it with `import type`.',
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
238
|
+
logLevel: "silent",
|
|
239
|
+
});
|
|
240
|
+
const file = result.outputFiles?.[0];
|
|
241
|
+
if (!file) throw new Error("The bundler produced no output");
|
|
242
|
+
// The bundle's module comments carry the temp directory; keep the artifact a
|
|
243
|
+
// function of the source alone, as the Applet build does.
|
|
244
|
+
return file.text
|
|
245
|
+
.split("\n")
|
|
246
|
+
.map((line) =>
|
|
247
|
+
line.startsWith("// ") && line.includes(directory)
|
|
248
|
+
? `// ${relative(directory, line.slice(3))}`
|
|
249
|
+
: line,
|
|
250
|
+
)
|
|
251
|
+
.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The describing Worker. It imports the built module and answers `/describe`
|
|
256
|
+
* with what the module exports, in the shape the manifest carries. Anything
|
|
257
|
+
* the module does at import time runs here, inside workerd, with no outbound
|
|
258
|
+
* network and no bindings — never in this process.
|
|
259
|
+
*/
|
|
260
|
+
const DESCRIBE_WORKER = `
|
|
261
|
+
import * as plugin from "./plugin.js";
|
|
262
|
+
|
|
263
|
+
var TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
264
|
+
|
|
265
|
+
function names(value, label) {
|
|
266
|
+
if (value === undefined) return [];
|
|
267
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
268
|
+
throw new Error('"' + label + '" must be an object');
|
|
269
|
+
}
|
|
270
|
+
return Object.keys(value).map(function (name) {
|
|
271
|
+
if (typeof value[name] !== "function" && label !== "services") {
|
|
272
|
+
throw new Error('"' + label + '"."' + name + '" must be a function');
|
|
273
|
+
}
|
|
274
|
+
return name;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function describe() {
|
|
279
|
+
if (!Array.isArray(plugin.tools)) {
|
|
280
|
+
throw new Error('the module must export a "tools" array');
|
|
281
|
+
}
|
|
282
|
+
if (typeof plugin.execute !== "function") {
|
|
283
|
+
throw new Error('the module must export an "execute" function');
|
|
284
|
+
}
|
|
285
|
+
var tools = plugin.tools.map(function (tool) {
|
|
286
|
+
if (!tool || typeof tool.name !== "string" || !TOOL_NAME.test(tool.name)) {
|
|
287
|
+
throw new Error("a tool has an invalid name");
|
|
288
|
+
}
|
|
289
|
+
if (typeof tool.description !== "string" || tool.description.length === 0) {
|
|
290
|
+
throw new Error('tool "' + tool.name + '" needs a description');
|
|
291
|
+
}
|
|
292
|
+
var schema =
|
|
293
|
+
tool.inputSchema && typeof tool.inputSchema === "object" && !Array.isArray(tool.inputSchema)
|
|
294
|
+
? tool.inputSchema
|
|
295
|
+
: { type: "object" };
|
|
296
|
+
return { name: tool.name, description: tool.description, inputSchema: schema };
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
tools: tools,
|
|
300
|
+
hooks: names(plugin.hooks, "hooks"),
|
|
301
|
+
services: names(plugin.services, "services"),
|
|
302
|
+
triggers: names(plugin.triggers, "triggers"),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export default {
|
|
307
|
+
async fetch(request) {
|
|
308
|
+
try {
|
|
309
|
+
return Response.json({ ok: true, description: describe() });
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return Response.json(
|
|
312
|
+
{ ok: false, error: String((error && error.message) || error) },
|
|
313
|
+
{ status: 500 },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
/** How long a workerd boot is given before the build gives up on it. */
|
|
321
|
+
const BOOT_DEADLINE_MS = 30_000;
|
|
322
|
+
|
|
323
|
+
/** A workerd that never reported ready; the boot, not the Plugin, failed. */
|
|
324
|
+
class RuntimeDidNotStart extends Error {}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Ask the built module what it exports, by running it.
|
|
328
|
+
*
|
|
329
|
+
* Each build spawns its own workerd, and a spawn occasionally never reports
|
|
330
|
+
* ready. A boot that misses the deadline is let go of and tried once more, so
|
|
331
|
+
* a build answers rather than hanging on a runtime that never came up.
|
|
332
|
+
*/
|
|
333
|
+
export async function describePlugin(
|
|
334
|
+
moduleCode: string,
|
|
335
|
+
): Promise<PluginDescriptionV1> {
|
|
336
|
+
try {
|
|
337
|
+
return await describeInWorkerd(moduleCode);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (!(error instanceof RuntimeDidNotStart)) throw error;
|
|
340
|
+
return await describeInWorkerd(moduleCode);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function describeInWorkerd(
|
|
345
|
+
moduleCode: string,
|
|
346
|
+
): Promise<PluginDescriptionV1> {
|
|
347
|
+
const miniflare = new Miniflare(
|
|
348
|
+
convertV4MiniflareOptions({
|
|
349
|
+
modules: [
|
|
350
|
+
{ type: "ESModule", path: "/index.mjs", contents: DESCRIBE_WORKER },
|
|
351
|
+
{ type: "ESModule", path: "/plugin.js", contents: moduleCode },
|
|
352
|
+
],
|
|
353
|
+
modulesRoot: "/",
|
|
354
|
+
compatibilityDate: APPLET_COMPATIBILITY_DATE,
|
|
355
|
+
// Import-time code runs with no way out: every fetch is answered here.
|
|
356
|
+
outboundService: async () =>
|
|
357
|
+
new Response("the build describes a Plugin without a network", {
|
|
358
|
+
status: 403,
|
|
359
|
+
}),
|
|
360
|
+
host: "127.0.0.1",
|
|
361
|
+
port: 0,
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
let started = false;
|
|
365
|
+
try {
|
|
366
|
+
const url = await bootedWithin(miniflare.ready);
|
|
367
|
+
started = true;
|
|
368
|
+
const response = (await miniflare.dispatchFetch(
|
|
369
|
+
new URL(`/describe?${randomUUID()}`, url).toString(),
|
|
370
|
+
)) as unknown as Response;
|
|
371
|
+
const body = (await response.json()) as
|
|
372
|
+
| { ok: true; description: PluginDescriptionV1 }
|
|
373
|
+
| { ok: false; error: string };
|
|
374
|
+
if (!body.ok) {
|
|
375
|
+
throw new Error(`The Plugin could not describe itself: ${body.error}`);
|
|
376
|
+
}
|
|
377
|
+
return validateDescription(body.description);
|
|
378
|
+
} finally {
|
|
379
|
+
// A runtime that never started is let go of rather than waited on.
|
|
380
|
+
if (started) await miniflare.dispose();
|
|
381
|
+
else void miniflare.dispose().catch(() => {});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function bootedWithin(ready: Promise<URL>): Promise<URL> {
|
|
386
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
388
|
+
timer = setTimeout(
|
|
389
|
+
() =>
|
|
390
|
+
reject(
|
|
391
|
+
new RuntimeDidNotStart(
|
|
392
|
+
`The Workers runtime did not start within ${BOOT_DEADLINE_MS}ms`,
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
BOOT_DEADLINE_MS,
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
try {
|
|
399
|
+
return await Promise.race([ready, deadline]);
|
|
400
|
+
} finally {
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validateDescription(input: PluginDescriptionV1): PluginDescriptionV1 {
|
|
406
|
+
if (!Array.isArray(input.tools) || input.tools.length > MAX_TOOLS) {
|
|
407
|
+
throw new Error(`The Plugin declares more than ${MAX_TOOLS} tools`);
|
|
408
|
+
}
|
|
409
|
+
const seen = new Set<string>();
|
|
410
|
+
for (const tool of input.tools) {
|
|
411
|
+
if (seen.has(tool.name)) {
|
|
412
|
+
throw new Error(`The Plugin declares "${tool.name}" twice`);
|
|
413
|
+
}
|
|
414
|
+
seen.add(tool.name);
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
tools: input.tools.map((tool) => ({
|
|
418
|
+
name: tool.name,
|
|
419
|
+
description: tool.description,
|
|
420
|
+
inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)) as Record<
|
|
421
|
+
string,
|
|
422
|
+
unknown
|
|
423
|
+
>,
|
|
424
|
+
})),
|
|
425
|
+
hooks: [...input.hooks],
|
|
426
|
+
services: [...input.services],
|
|
427
|
+
triggers: [...input.triggers],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface PluginBuildPipelineOptions {
|
|
432
|
+
/** `check` stops after the type checker; `build` goes on to the module. */
|
|
433
|
+
mode: "check" | "build";
|
|
434
|
+
/** The id the caller asked for; `plugin.json` must agree. */
|
|
435
|
+
id?: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function runPluginBuildV1(
|
|
439
|
+
directory: string,
|
|
440
|
+
options: PluginBuildPipelineOptions,
|
|
441
|
+
): Promise<PluginBuildOutcome> {
|
|
442
|
+
let descriptor: PluginBuildDescriptorV1;
|
|
443
|
+
try {
|
|
444
|
+
descriptor = await readPluginDescriptor(directory);
|
|
445
|
+
if (options.id !== undefined && descriptor.id !== options.id) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`plugin.json names "${descriptor.id}" but this build is for "${options.id}"`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
return {
|
|
452
|
+
status: "failed",
|
|
453
|
+
stage: "descriptor",
|
|
454
|
+
diagnostics: thrown(error),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const types = await typeCheckPlugin(directory);
|
|
459
|
+
if (types.some((diagnostic) => diagnostic.severity === "error")) {
|
|
460
|
+
return { status: "failed", stage: "typecheck", diagnostics: types };
|
|
461
|
+
}
|
|
462
|
+
if (options.mode === "check") return { status: "checked" };
|
|
463
|
+
|
|
464
|
+
let moduleCode: string;
|
|
465
|
+
try {
|
|
466
|
+
moduleCode = await bundlePlugin(directory);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return {
|
|
469
|
+
status: "failed",
|
|
470
|
+
stage: "bundle",
|
|
471
|
+
diagnostics: thrown(error, "plugin.ts"),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let description: PluginDescriptionV1;
|
|
476
|
+
try {
|
|
477
|
+
description = await describePlugin(moduleCode);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return {
|
|
480
|
+
status: "failed",
|
|
481
|
+
stage: "describe",
|
|
482
|
+
diagnostics: thrown(error, "plugin.ts"),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
status: "built",
|
|
487
|
+
manifest: {
|
|
488
|
+
contract: 1,
|
|
489
|
+
...description,
|
|
490
|
+
hashes: { module: sha256(moduleCode) },
|
|
491
|
+
},
|
|
492
|
+
module: moduleCode,
|
|
493
|
+
};
|
|
494
|
+
}
|