@frockbot/applet-sdk 0.0.0 → 0.3.13
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 +77 -1
- package/dist/cli.mjs +1007 -0
- package/package.json +51 -5
- package/src/cli/bin.ts +128 -0
- package/src/cli/build.ts +181 -0
- package/src/cli/check.ts +134 -0
- package/src/cli/dev.ts +84 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/manifest.ts +58 -0
- package/src/cli/new.ts +74 -0
- package/src/cli/paths.ts +98 -0
- package/src/cli/runtime.ts +123 -0
- package/src/client/collections.ts +72 -0
- package/src/client/index.ts +203 -0
- package/src/client/transport.ts +334 -0
- package/src/kit/README.md +130 -0
- package/src/kit/index.tsx +427 -0
- package/src/kit/styles.ts +200 -0
- package/src/lint/index.ts +148 -0
- package/src/lint/rules.ts +394 -0
- package/src/protocol/index.ts +411 -0
- package/src/schema/index.ts +436 -0
- package/src/server/applet.ts +399 -0
- package/src/server/index.ts +53 -0
- package/src/server/session.ts +156 -0
- package/src/server/store.ts +398 -0
- package/template/README.md +37 -0
- package/template/applet.json +5 -0
- package/template/server.ts +46 -0
- package/template/ui.tsx +113 -0
- package/types/cloudflare-workers.d.ts +55 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Applet`: the base class an Applet's `server.ts` extends.
|
|
3
|
+
*
|
|
4
|
+
* The authoring surface is deliberately three things — `tables`, `tools`, and
|
|
5
|
+
* `this.db` — plus an optional `migrate`. Everything below them (DDL, the
|
|
6
|
+
* change log, the socket protocol, tool declarations, broadcast) is the SDK's
|
|
7
|
+
* and is versioned once.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { DurableObject } from "cloudflare:workers";
|
|
11
|
+
import type {
|
|
12
|
+
AppletDurableObjectState,
|
|
13
|
+
AppletHibernatableWebSocket,
|
|
14
|
+
} from "cloudflare:workers";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
APPLET_CONTRACT_VERSION,
|
|
18
|
+
encodeFrame,
|
|
19
|
+
type AppletChangeV1,
|
|
20
|
+
type AppletViewerV1,
|
|
21
|
+
} from "../protocol/index.js";
|
|
22
|
+
import {
|
|
23
|
+
AppletValidationError,
|
|
24
|
+
assertTableNames,
|
|
25
|
+
decodeToolInput,
|
|
26
|
+
jsonSchemaFromColumns,
|
|
27
|
+
type Column,
|
|
28
|
+
type ColumnsShape,
|
|
29
|
+
type InsertOf,
|
|
30
|
+
type JsonSchemaObject,
|
|
31
|
+
type PatchOf,
|
|
32
|
+
type RowOf,
|
|
33
|
+
type TableDefinition,
|
|
34
|
+
type TablesShape,
|
|
35
|
+
} from "../schema/index.js";
|
|
36
|
+
import { AppletProtocolServer, type AppletPeer } from "./session.js";
|
|
37
|
+
import { AppletStore } from "./store.js";
|
|
38
|
+
|
|
39
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
40
|
+
|
|
41
|
+
/** The readable half of whatever escaped an entry point. */
|
|
42
|
+
function errorMessageV1(error: unknown): string {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One tool as the kernel's manifest declares it: `inputSchema` is JSON Schema. */
|
|
47
|
+
export interface AppletToolDeclarationV1 {
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
inputSchema: JsonSchemaObject;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the kernel reads after a mount to admit the generation. `tools` is the
|
|
55
|
+
* list of names, compared to the published manifest's declarations; the
|
|
56
|
+
* declarations themselves come from `describe()` at build time.
|
|
57
|
+
*/
|
|
58
|
+
export interface AppletHealthV1 {
|
|
59
|
+
contract: 1;
|
|
60
|
+
tools: string[];
|
|
61
|
+
schemaRevision: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The build-time description `applet build` writes into `dist/manifest.json`. */
|
|
65
|
+
export interface AppletDescriptionV1 {
|
|
66
|
+
contract: 1;
|
|
67
|
+
tools: AppletToolDeclarationV1[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ColumnValue<C> = C extends Column<infer V, boolean, boolean> ? V : never;
|
|
71
|
+
|
|
72
|
+
/** The argument a tool handler receives, derived from its declared input. */
|
|
73
|
+
export type ToolInputOf<TInput extends ColumnsShape> = {
|
|
74
|
+
[K in keyof TInput]: ColumnValue<TInput[K]>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface AppletToolSpec<TInput extends ColumnsShape> {
|
|
78
|
+
description: string;
|
|
79
|
+
input: TInput;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AppletTool<TInput extends ColumnsShape = ColumnsShape> {
|
|
83
|
+
readonly description: string;
|
|
84
|
+
readonly input: TInput;
|
|
85
|
+
readonly handler: (input: ToolInputOf<TInput>) => Promise<string> | string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A tool with its input type erased, as the `tools` record holds it. */
|
|
89
|
+
export interface AnyAppletTool {
|
|
90
|
+
readonly description: string;
|
|
91
|
+
readonly input: ColumnsShape;
|
|
92
|
+
readonly handler: (input: never) => Promise<string> | string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AppletTableApi<T extends TableDefinition> {
|
|
96
|
+
/** Insert a row; the key is generated when the insert omits it. */
|
|
97
|
+
insert(values: InsertOf<T>): RowOf<T>;
|
|
98
|
+
/** Patch a row; `undefined` when no row has that key. */
|
|
99
|
+
update(key: string, patch: PatchOf<T>): RowOf<T> | undefined;
|
|
100
|
+
/** Remove a row; `false` when no row had that key. */
|
|
101
|
+
delete(key: string): boolean;
|
|
102
|
+
/** Every row, or those whose columns all equal `filter`. */
|
|
103
|
+
select(filter?: PatchOf<T>): Array<RowOf<T>>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type AppletDb<TTables extends TablesShape> = {
|
|
107
|
+
[K in keyof TTables]: AppletTableApi<TTables[K]>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
interface ViewerAttachment {
|
|
111
|
+
viewer: AppletViewerV1;
|
|
112
|
+
/** Set once the socket has been sent its snapshot or catch-up. */
|
|
113
|
+
synced: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `TTables` is the declared schema, supplied by the subclass:
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* const tables = { todos: table({ id: t.id(), title: t.text() }) };
|
|
121
|
+
* export default class TodoApplet extends Applet<typeof tables> {
|
|
122
|
+
* tables = tables;
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* It is a type parameter rather than `this["tables"]` so that a tool handler
|
|
127
|
+
* can reach `this.db` without the class's own type becoming circular.
|
|
128
|
+
*/
|
|
129
|
+
export abstract class Applet<
|
|
130
|
+
TTables extends TablesShape = TablesShape,
|
|
131
|
+
Env = unknown,
|
|
132
|
+
> extends DurableObject<Env> {
|
|
133
|
+
/** Declared once with `table()`; the SDK derives DDL, wire, and types. */
|
|
134
|
+
abstract readonly tables: TTables;
|
|
135
|
+
|
|
136
|
+
/** Declared with `this.tool(...)`; `health()` reports them to the kernel. */
|
|
137
|
+
readonly tools: Record<string, AnyAppletTool> = {};
|
|
138
|
+
|
|
139
|
+
#store?: AppletStore;
|
|
140
|
+
#ready?: Promise<void>;
|
|
141
|
+
#revision = 0;
|
|
142
|
+
#pending: AppletChangeV1[] = [];
|
|
143
|
+
#db?: AppletDb<TTables>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Runs when a mount finds storage written under an earlier declared shape.
|
|
147
|
+
* `from` is the previous schema revision; new columns have already been
|
|
148
|
+
* added. Backfill or rewrite here and throw to fail the mount.
|
|
149
|
+
*/
|
|
150
|
+
async migrate(_from: number): Promise<void> {}
|
|
151
|
+
|
|
152
|
+
/** Declare a tool. Call it in the `tools` field initializer. */
|
|
153
|
+
protected tool<TInput extends ColumnsShape>(
|
|
154
|
+
spec: AppletToolSpec<TInput>,
|
|
155
|
+
handler: (input: ToolInputOf<TInput>) => Promise<string> | string,
|
|
156
|
+
): AppletTool<TInput> {
|
|
157
|
+
if (
|
|
158
|
+
typeof spec?.description !== "string" ||
|
|
159
|
+
spec.description.length === 0
|
|
160
|
+
) {
|
|
161
|
+
throw new Error("A tool needs a description");
|
|
162
|
+
}
|
|
163
|
+
if (spec.description.length > 1_024) {
|
|
164
|
+
throw new Error("A tool description may be at most 1024 characters");
|
|
165
|
+
}
|
|
166
|
+
return { description: spec.description, input: spec.input ?? {}, handler };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Typed access to the Applet's own tables. */
|
|
170
|
+
protected get db(): AppletDb<TTables> {
|
|
171
|
+
this.#db ??= this.#buildDb();
|
|
172
|
+
return this.#db;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#buildDb(): AppletDb<TTables> {
|
|
176
|
+
const applet = this;
|
|
177
|
+
const api: Record<string, AppletTableApi<TableDefinition>> = {};
|
|
178
|
+
for (const name of Object.keys(this.tables)) {
|
|
179
|
+
api[name] = {
|
|
180
|
+
insert: (values) =>
|
|
181
|
+
applet.#write(() =>
|
|
182
|
+
applet
|
|
183
|
+
.#requireStore()
|
|
184
|
+
.insert(name, values as Record<string, unknown>),
|
|
185
|
+
).row as never,
|
|
186
|
+
update: (key, patch) => {
|
|
187
|
+
const change = applet.#write(() =>
|
|
188
|
+
applet
|
|
189
|
+
.#requireStore()
|
|
190
|
+
.update(name, key, patch as Record<string, unknown>),
|
|
191
|
+
);
|
|
192
|
+
return change?.row as never;
|
|
193
|
+
},
|
|
194
|
+
delete: (key) =>
|
|
195
|
+
applet.#write(() => applet.#requireStore().delete(name, key)) !==
|
|
196
|
+
undefined,
|
|
197
|
+
select: (filter) =>
|
|
198
|
+
applet
|
|
199
|
+
.#requireStore()
|
|
200
|
+
.select(
|
|
201
|
+
name,
|
|
202
|
+
filter as Record<string, unknown> | undefined,
|
|
203
|
+
) as never,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return api as AppletDb<TTables>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#write<T extends AppletChangeV1 | undefined>(closure: () => T): T {
|
|
210
|
+
const change = this.ctx.storage.transactionSync(closure);
|
|
211
|
+
if (change) this.#pending.push(change);
|
|
212
|
+
return change;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#requireStore(): AppletStore {
|
|
216
|
+
if (!this.#store) {
|
|
217
|
+
throw new Error("The Applet's storage is not ready yet; await a handler");
|
|
218
|
+
}
|
|
219
|
+
return this.#store;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Idempotent: DDL on first use, then `migrate` when the shape has moved. */
|
|
223
|
+
protected ready(): Promise<void> {
|
|
224
|
+
this.#ready ??= this.ctx.blockConcurrencyWhile(async () => {
|
|
225
|
+
assertTableNames(this.tables);
|
|
226
|
+
for (const name of Object.keys(this.tools)) {
|
|
227
|
+
if (!TOOL_NAME.test(name))
|
|
228
|
+
throw new Error(`Tool name "${name}" is invalid`);
|
|
229
|
+
}
|
|
230
|
+
const store = new AppletStore(this.ctx.storage.sql, this.tables);
|
|
231
|
+
const state = this.ctx.storage.transactionSync(() =>
|
|
232
|
+
store.ensureSchema(),
|
|
233
|
+
);
|
|
234
|
+
this.#store = store;
|
|
235
|
+
this.#revision = state.revision;
|
|
236
|
+
if (state.changed && state.previousRevision > 0) {
|
|
237
|
+
await this.migrate(state.previousRevision);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return this.#ready;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** What the kernel calls after a mount to admit the generation. */
|
|
244
|
+
async health(): Promise<AppletHealthV1> {
|
|
245
|
+
await this.ready();
|
|
246
|
+
return {
|
|
247
|
+
contract: APPLET_CONTRACT_VERSION,
|
|
248
|
+
tools: Object.keys(this.tools),
|
|
249
|
+
schemaRevision: this.#revision,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The tool declarations, for `applet build` to write into the manifest. */
|
|
254
|
+
describe(): AppletDescriptionV1 {
|
|
255
|
+
return {
|
|
256
|
+
contract: APPLET_CONTRACT_VERSION,
|
|
257
|
+
tools: Object.entries(this.tools).map(([name, tool]) => ({
|
|
258
|
+
name,
|
|
259
|
+
description: tool.description,
|
|
260
|
+
inputSchema: jsonSchemaFromColumns(tool.input),
|
|
261
|
+
})),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** What an Applet tool call from a Bot's Turn routes to. */
|
|
266
|
+
async invokeTool(name: string, input: unknown): Promise<string> {
|
|
267
|
+
await this.ready();
|
|
268
|
+
const tool = this.tools[name];
|
|
269
|
+
if (!tool) throw new AppletValidationError(`Unknown tool "${name}"`);
|
|
270
|
+
const decoded = decodeToolInput(tool.input, input);
|
|
271
|
+
const result = await tool.handler(decoded as never);
|
|
272
|
+
this.#flush();
|
|
273
|
+
if (typeof result !== "string") {
|
|
274
|
+
throw new Error(`Tool "${name}" must return a string`);
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The viewer socket. The kernel forwards an already-authorised upgrade. */
|
|
280
|
+
async fetch(request: Request): Promise<Response> {
|
|
281
|
+
await this.ready();
|
|
282
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
283
|
+
return new Response("Expected a WebSocket upgrade", { status: 426 });
|
|
284
|
+
}
|
|
285
|
+
const url = new URL(request.url);
|
|
286
|
+
const viewer: AppletViewerV1 = {
|
|
287
|
+
id:
|
|
288
|
+
request.headers.get("x-applet-viewer") ??
|
|
289
|
+
url.searchParams.get("viewer") ??
|
|
290
|
+
"viewer",
|
|
291
|
+
canWrite:
|
|
292
|
+
(request.headers.get("x-applet-can-write") ??
|
|
293
|
+
url.searchParams.get("canWrite") ??
|
|
294
|
+
"true") !== "false",
|
|
295
|
+
};
|
|
296
|
+
const pair = new WebSocketPair();
|
|
297
|
+
const client = pair[0];
|
|
298
|
+
const server = pair[1];
|
|
299
|
+
this.ctx.acceptWebSocket(server);
|
|
300
|
+
server.serializeAttachment({
|
|
301
|
+
viewer,
|
|
302
|
+
synced: false,
|
|
303
|
+
} satisfies ViewerAttachment);
|
|
304
|
+
this.#protocol().greet(this.#peer(server));
|
|
305
|
+
return new Response(null, {
|
|
306
|
+
status: 101,
|
|
307
|
+
webSocket: client,
|
|
308
|
+
} as ResponseInit & { webSocket: unknown });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The mounted generation, reported in `hello` so a client can tell a code
|
|
313
|
+
* change from a reconnect. The kernel names the Durable Object after it.
|
|
314
|
+
*/
|
|
315
|
+
protected get generationId(): string {
|
|
316
|
+
return this.ctx.id.name ?? this.ctx.id.toString();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A socket callback is an entry point: workerd calls it with no caller of
|
|
320
|
+
// ours left to catch what escapes, so a malformed frame from one viewer — or
|
|
321
|
+
// an Applet's own tool throwing — became an uncaught exception in the object
|
|
322
|
+
// rather than one dropped frame. Recording it and returning is the whole of
|
|
323
|
+
// the recovery; the viewer retries, and every other viewer keeps its socket.
|
|
324
|
+
async webSocketMessage(
|
|
325
|
+
socket: AppletHibernatableWebSocket,
|
|
326
|
+
message: string | ArrayBuffer,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
try {
|
|
329
|
+
await this.ready();
|
|
330
|
+
this.#protocol().receive(
|
|
331
|
+
this.#peer(socket),
|
|
332
|
+
typeof message === "string"
|
|
333
|
+
? message
|
|
334
|
+
: new TextDecoder().decode(message),
|
|
335
|
+
);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error(`Applet socket message failed: ${errorMessageV1(error)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async webSocketClose(
|
|
342
|
+
socket: AppletHibernatableWebSocket,
|
|
343
|
+
code: number,
|
|
344
|
+
closeReason: string,
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
try {
|
|
347
|
+
// 1006 is never a valid code to echo back.
|
|
348
|
+
socket.close(code === 1006 ? 1000 : code, closeReason);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error(`Applet socket close failed: ${errorMessageV1(error)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A hibernating socket seen as a peer. The viewer identity and the "already
|
|
356
|
+
* sent a snapshot" flag live in the socket's attachment, which is what
|
|
357
|
+
* survives hibernation; no session state is held in memory.
|
|
358
|
+
*/
|
|
359
|
+
#peer(socket: AppletHibernatableWebSocket): AppletPeer {
|
|
360
|
+
const attachment = (socket.deserializeAttachment() ?? {
|
|
361
|
+
viewer: { id: "viewer", canWrite: true },
|
|
362
|
+
synced: false,
|
|
363
|
+
}) as ViewerAttachment;
|
|
364
|
+
return {
|
|
365
|
+
viewer: attachment.viewer,
|
|
366
|
+
get synced() {
|
|
367
|
+
return attachment.synced;
|
|
368
|
+
},
|
|
369
|
+
set synced(value: boolean) {
|
|
370
|
+
attachment.synced = value;
|
|
371
|
+
socket.serializeAttachment(attachment);
|
|
372
|
+
},
|
|
373
|
+
send: (frame) => socket.send(encodeFrame(frame)),
|
|
374
|
+
close: (code, reason) => socket.close(code, reason),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#protocol(): AppletProtocolServer {
|
|
379
|
+
return new AppletProtocolServer(this.#requireStore(), {
|
|
380
|
+
generationId: this.generationId,
|
|
381
|
+
schemaRevision: this.#revision,
|
|
382
|
+
transaction: (closure) => this.ctx.storage.transactionSync(closure),
|
|
383
|
+
peers: () => this.ctx.getWebSockets().map((socket) => this.#peer(socket)),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Send changes made outside a client transaction (a tool call) to viewers. */
|
|
388
|
+
#flush(): void {
|
|
389
|
+
if (this.#pending.length === 0) return;
|
|
390
|
+
const changes = this.#pending;
|
|
391
|
+
this.#pending = [];
|
|
392
|
+
this.#protocol().broadcastChanges(changes);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Exposed for the kernel's delete path; storage goes with the facet. */
|
|
396
|
+
protected get state(): AppletDurableObjectState {
|
|
397
|
+
return this.ctx;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@frockbot/applet-sdk/server` — everything an Applet's `server.ts` imports.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { Applet, table, t } from "@frockbot/applet-sdk/server";
|
|
6
|
+
*
|
|
7
|
+
* export default class TodoApplet extends Applet {
|
|
8
|
+
* tables = { todos: table({ id: t.id(), title: t.text() }) };
|
|
9
|
+
* tools = {
|
|
10
|
+
* add_todo: this.tool(
|
|
11
|
+
* { description: "Add a todo", input: { title: t.text() } },
|
|
12
|
+
* ({ title }) => { this.db.todos.insert({ title }); return `Added ${title}`; },
|
|
13
|
+
* ),
|
|
14
|
+
* };
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
Applet,
|
|
21
|
+
type AppletDb,
|
|
22
|
+
type AppletDescriptionV1,
|
|
23
|
+
type AppletHealthV1,
|
|
24
|
+
type AppletTableApi,
|
|
25
|
+
type AppletTool,
|
|
26
|
+
type AppletToolDeclarationV1,
|
|
27
|
+
type AppletToolSpec,
|
|
28
|
+
type ToolInputOf,
|
|
29
|
+
} from "./applet.js";
|
|
30
|
+
export {
|
|
31
|
+
AppletValidationError,
|
|
32
|
+
Column,
|
|
33
|
+
table,
|
|
34
|
+
t,
|
|
35
|
+
TableDefinition,
|
|
36
|
+
type ColumnKind,
|
|
37
|
+
type ColumnsShape,
|
|
38
|
+
type InsertOf,
|
|
39
|
+
type JsonSchemaObject,
|
|
40
|
+
type PatchOf,
|
|
41
|
+
type RowOf,
|
|
42
|
+
type TablesShape,
|
|
43
|
+
} from "../schema/index.js";
|
|
44
|
+
export {
|
|
45
|
+
APPLET_CONTRACT_VERSION,
|
|
46
|
+
APPLET_FRAME_BYTE_LIMIT,
|
|
47
|
+
type AppletChangeV1,
|
|
48
|
+
type AppletClientFrameV1,
|
|
49
|
+
type AppletMutationV1,
|
|
50
|
+
type AppletServerFrameV1,
|
|
51
|
+
type AppletViewerV1,
|
|
52
|
+
} from "../protocol/index.js";
|
|
53
|
+
export { AppletStore, type AppletSqlStorage } from "./store.js";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server half of wire protocol v1, with no Durable Object in sight.
|
|
3
|
+
*
|
|
4
|
+
* `Applet` supplies peers backed by hibernating WebSockets; a test supplies
|
|
5
|
+
* peers backed by anything. Either way this is the only implementation of the
|
|
6
|
+
* handshake, catch-up, and mutate/ack/reject rules, so the two can never drift.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
APPLET_CONTRACT_VERSION,
|
|
11
|
+
AppletProtocolError,
|
|
12
|
+
decodeClientFrame,
|
|
13
|
+
type AppletChangeV1,
|
|
14
|
+
type AppletServerFrameV1,
|
|
15
|
+
type AppletViewerV1,
|
|
16
|
+
} from "../protocol/index.js";
|
|
17
|
+
import type { AppletStore } from "./store.js";
|
|
18
|
+
|
|
19
|
+
export interface AppletPeer {
|
|
20
|
+
send(frame: AppletServerFrameV1): void;
|
|
21
|
+
close(code: number, reason: string): void;
|
|
22
|
+
readonly viewer: AppletViewerV1;
|
|
23
|
+
/** False until the peer has been sent a snapshot or a catch-up. */
|
|
24
|
+
synced: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AppletProtocolServerOptions {
|
|
28
|
+
generationId: string;
|
|
29
|
+
schemaRevision: number;
|
|
30
|
+
/** Wraps one client transaction; the Durable Object uses `transactionSync`. */
|
|
31
|
+
transaction<T>(closure: () => T): T;
|
|
32
|
+
/** Every currently attached peer, including the one being served. */
|
|
33
|
+
peers(): AppletPeer[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class AppletProtocolServer {
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly store: AppletStore,
|
|
39
|
+
private readonly options: AppletProtocolServerOptions,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
/** The unprompted `hello` a peer gets the moment its socket is accepted. */
|
|
43
|
+
greet(peer: AppletPeer): void {
|
|
44
|
+
peer.send({
|
|
45
|
+
v: 1,
|
|
46
|
+
type: "hello",
|
|
47
|
+
contract: APPLET_CONTRACT_VERSION,
|
|
48
|
+
generationId: this.options.generationId,
|
|
49
|
+
viewer: peer.viewer,
|
|
50
|
+
tables: Object.keys(this.store.tables),
|
|
51
|
+
schemaRevision: this.options.schemaRevision,
|
|
52
|
+
lastChangeId: this.store.lastChangeId,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Handle one inbound frame. Never throws; a bad frame closes the socket. */
|
|
57
|
+
receive(peer: AppletPeer, message: unknown): void {
|
|
58
|
+
let frame;
|
|
59
|
+
try {
|
|
60
|
+
frame = decodeClientFrame(message);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
peer.close(1008, describe(error));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (frame.type === "hello") {
|
|
67
|
+
const catchUp =
|
|
68
|
+
frame.since === undefined
|
|
69
|
+
? undefined
|
|
70
|
+
: this.store.changesSince(frame.since);
|
|
71
|
+
if (catchUp) {
|
|
72
|
+
peer.send({
|
|
73
|
+
v: 1,
|
|
74
|
+
type: "changes",
|
|
75
|
+
lastChangeId: this.store.lastChangeId,
|
|
76
|
+
changes: catchUp,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
peer.send({
|
|
80
|
+
v: 1,
|
|
81
|
+
type: "snapshot",
|
|
82
|
+
lastChangeId: this.store.lastChangeId,
|
|
83
|
+
tables: this.store.snapshot(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
peer.synced = true;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!peer.viewer.canWrite) {
|
|
91
|
+
peer.send({
|
|
92
|
+
v: 1,
|
|
93
|
+
type: "reject",
|
|
94
|
+
txnId: frame.txnId,
|
|
95
|
+
reason: "This viewer may not write",
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let changes: AppletChangeV1[];
|
|
101
|
+
try {
|
|
102
|
+
changes = this.options.transaction(() =>
|
|
103
|
+
this.store.applyMutations(frame.mutations, frame.txnId),
|
|
104
|
+
);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
peer.send({
|
|
107
|
+
v: 1,
|
|
108
|
+
type: "reject",
|
|
109
|
+
txnId: frame.txnId,
|
|
110
|
+
reason: describe(error),
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lastChangeId = this.store.lastChangeId;
|
|
116
|
+
peer.send({ v: 1, type: "ack", txnId: frame.txnId, lastChangeId, changes });
|
|
117
|
+
this.broadcast(
|
|
118
|
+
{ v: 1, type: "changes", lastChangeId, txnId: frame.txnId, changes },
|
|
119
|
+
peer,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Push changes made outside a client transaction — a tool call — to viewers. */
|
|
124
|
+
broadcastChanges(changes: AppletChangeV1[]): void {
|
|
125
|
+
if (changes.length === 0) return;
|
|
126
|
+
this.broadcast({
|
|
127
|
+
v: 1,
|
|
128
|
+
type: "changes",
|
|
129
|
+
lastChangeId: this.store.lastChangeId,
|
|
130
|
+
changes,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private broadcast(frame: AppletServerFrameV1, except?: AppletPeer): void {
|
|
135
|
+
for (const peer of this.options.peers()) {
|
|
136
|
+
if (peer === except || !peer.synced) continue;
|
|
137
|
+
try {
|
|
138
|
+
peer.send(frame);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof AppletProtocolError)) throw error;
|
|
141
|
+
// A batch too large for one frame: tell the peer where the log now is
|
|
142
|
+
// and let it resync from there rather than silently diverge.
|
|
143
|
+
peer.send({
|
|
144
|
+
v: 1,
|
|
145
|
+
type: "changes",
|
|
146
|
+
lastChangeId: this.store.lastChangeId,
|
|
147
|
+
changes: [],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function describe(error: unknown): string {
|
|
155
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 512);
|
|
156
|
+
}
|