@frockbot/applet-sdk 0.7.149 → 0.7.151

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.
@@ -1,493 +0,0 @@
1
- /**
2
- * Applet wire protocol, versions 1 and 2.
3
- *
4
- * One JSON frame per WebSocket message, at most 64 KB encoded. Both ends decode
5
- * with the functions here and nothing else: an unknown type, an unknown field,
6
- * or an out-of-range value fails closed. The server additionally refuses frames
7
- * that name a table or column it did not declare — that check needs the schema,
8
- * so it lives in `server/`, not here.
9
- *
10
- * Sequence, v2 (a page that opened its socket with `v=2` in the URL):
11
- * server -> hello on accept, carrying the `snapshot` when the URL
12
- * named no `since` cursor; the page renders on it
13
- * client -> hello only when it is resuming (`since`), or when the
14
- * server's hello carried no snapshot
15
- * server -> changes catch-up for a resumable cursor, else `snapshot`
16
- * client -> mutate one client transaction
17
- * server -> ack|reject to the originator; `changes` to every other socket
18
- *
19
- * Sequence, v1 (a page built before v2 sends no `v`):
20
- * server -> hello on accept (contract, generation, viewer, cursor)
21
- * client -> hello with `since` when it is resuming, otherwise absent
22
- * server -> snapshot full state, or `changes` when the cursor is resumable
23
- * then as above
24
- *
25
- * The version a socket speaks is settled by its URL before the first frame,
26
- * so a server never has to guess: the frames it sends carry that `v`, and a
27
- * v1 page is spoken to exactly as it always was.
28
- */
29
-
30
- export const APPLET_CONTRACT_VERSION = 1 as const;
31
- /** The newest protocol this code speaks; a socket may still be spoken to in 1. */
32
- export const APPLET_PROTOCOL_VERSION = 2 as const;
33
- export type AppletProtocolVersion = 1 | 2;
34
- export const APPLET_FRAME_BYTE_LIMIT = 64 * 1024;
35
-
36
- /**
37
- * What a socket's URL says about how to greet it: the protocol the page
38
- * speaks and, on a reconnect, the cursor it will ask to resume from. Read on
39
- * the server before the first frame, written by the client transport.
40
- */
41
- export interface AppletHandshakeV1 {
42
- protocol: AppletProtocolVersion;
43
- since?: number;
44
- }
45
-
46
- export function appletHandshakeFromUrlV1(url: URL): AppletHandshakeV1 {
47
- const protocol = url.searchParams.get("v") === "2" ? 2 : 1;
48
- const since = url.searchParams.get("since");
49
- const parsed = since === null ? Number.NaN : Number(since);
50
- return {
51
- protocol,
52
- ...(Number.isSafeInteger(parsed) && parsed > 0 ? { since: parsed } : {}),
53
- };
54
- }
55
-
56
- export type ChangeOperation = "insert" | "update" | "delete";
57
-
58
- export interface AppletChangeV1 {
59
- table: string;
60
- op: ChangeOperation;
61
- key: string;
62
- /** The resulting row for insert and update; absent for delete. */
63
- row?: Record<string, unknown>;
64
- }
65
-
66
- export interface AppletMutationV1 {
67
- table: string;
68
- op: ChangeOperation;
69
- /** Required for update and delete; server-generated for insert when absent. */
70
- key?: string;
71
- /** Full row for insert, partial patch for update, absent for delete. */
72
- value?: Record<string, unknown>;
73
- }
74
-
75
- export interface AppletViewerV1 {
76
- id: string;
77
- /** Whether this socket may send `mutate` frames. */
78
- canWrite: boolean;
79
- }
80
-
81
- export type AppletSnapshotTablesV1 = Record<
82
- string,
83
- Array<Record<string, unknown>>
84
- >;
85
-
86
- export type AppletServerFrameV1 =
87
- | {
88
- v: AppletProtocolVersion;
89
- type: "hello";
90
- contract: 1;
91
- generationId: string;
92
- viewer: AppletViewerV1;
93
- tables: string[];
94
- schemaRevision: number;
95
- lastChangeId: number;
96
- /**
97
- * Every row of every table as of `lastChangeId`, on a v2 socket that
98
- * opened with no cursor: the page renders on this frame and sends no
99
- * hello of its own. Absent when it would not fit the frame, in which
100
- * case the v1 exchange follows.
101
- */
102
- snapshot?: AppletSnapshotTablesV1;
103
- }
104
- | {
105
- v: AppletProtocolVersion;
106
- type: "snapshot";
107
- lastChangeId: number;
108
- tables: AppletSnapshotTablesV1;
109
- }
110
- | {
111
- v: AppletProtocolVersion;
112
- type: "changes";
113
- lastChangeId: number;
114
- txnId?: string;
115
- changes: AppletChangeV1[];
116
- }
117
- | {
118
- v: AppletProtocolVersion;
119
- type: "ack";
120
- txnId: string;
121
- lastChangeId: number;
122
- changes: AppletChangeV1[];
123
- }
124
- | {
125
- v: AppletProtocolVersion;
126
- type: "reject";
127
- txnId: string;
128
- reason: string;
129
- };
130
-
131
- export type AppletClientFrameV1 =
132
- | {
133
- v: AppletProtocolVersion;
134
- type: "hello";
135
- contract: 1;
136
- since?: number;
137
- }
138
- | {
139
- v: AppletProtocolVersion;
140
- type: "mutate";
141
- txnId: string;
142
- mutations: AppletMutationV1[];
143
- };
144
-
145
- export class AppletProtocolError extends Error {}
146
-
147
- function fail(message: string): never {
148
- throw new AppletProtocolError(message);
149
- }
150
-
151
- function object(value: unknown, label: string): Record<string, unknown> {
152
- if (!value || typeof value !== "object" || Array.isArray(value)) {
153
- fail(`${label} must be an object`);
154
- }
155
- return value as Record<string, unknown>;
156
- }
157
-
158
- function exact(
159
- value: Record<string, unknown>,
160
- required: readonly string[],
161
- optional: readonly string[],
162
- label: string,
163
- ): void {
164
- for (const field of required) {
165
- if (!Object.hasOwn(value, field)) fail(`${label} is missing "${field}"`);
166
- }
167
- for (const field of Object.keys(value)) {
168
- if (!required.includes(field) && !optional.includes(field)) {
169
- fail(`${label} has an unknown field "${field}"`);
170
- }
171
- }
172
- }
173
-
174
- function name(value: unknown, label: string): string {
175
- if (
176
- typeof value !== "string" ||
177
- !/^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(value)
178
- ) {
179
- fail(`${label} must be an identifier`);
180
- }
181
- return value;
182
- }
183
-
184
- function bounded(value: unknown, label: string, maximum = 256): string {
185
- if (
186
- typeof value !== "string" ||
187
- value.length === 0 ||
188
- value.length > maximum
189
- ) {
190
- fail(`${label} must be a bounded non-empty string`);
191
- }
192
- return value;
193
- }
194
-
195
- function cursor(value: unknown, label: string): number {
196
- if (!Number.isSafeInteger(value) || (value as number) < 0) {
197
- fail(`${label} must be a non-negative integer`);
198
- }
199
- return value as number;
200
- }
201
-
202
- function jsonDepth(value: unknown, label: string, depth = 0): void {
203
- if (depth > 16) fail(`${label} is too deeply nested`);
204
- if (
205
- value === null ||
206
- typeof value === "string" ||
207
- typeof value === "boolean" ||
208
- (typeof value === "number" && Number.isFinite(value))
209
- ) {
210
- return;
211
- }
212
- if (Array.isArray(value)) {
213
- if (value.length > 1024) fail(`${label} has too many entries`);
214
- for (const entry of value) jsonDepth(entry, label, depth + 1);
215
- return;
216
- }
217
- const record = object(value, label);
218
- if (Object.keys(record).length > 256) fail(`${label} has too many fields`);
219
- for (const entry of Object.values(record)) jsonDepth(entry, label, depth + 1);
220
- }
221
-
222
- function row(value: unknown, label: string): Record<string, unknown> {
223
- const record = object(value, label);
224
- for (const key of Object.keys(record)) name(key, `${label} column`);
225
- jsonDepth(record, label);
226
- return record;
227
- }
228
-
229
- /** Encode a frame, refusing anything over the wire limit. */
230
- export function encodeFrame(
231
- frame: AppletServerFrameV1 | AppletClientFrameV1,
232
- ): string {
233
- const wire = JSON.stringify(frame);
234
- if (wire === undefined) fail("Applet frame is not JSON");
235
- if (new TextEncoder().encode(wire).byteLength > APPLET_FRAME_BYTE_LIMIT) {
236
- fail("Applet frame exceeds the 64 KB wire limit");
237
- }
238
- return wire;
239
- }
240
-
241
- function parse(message: unknown, label: string): Record<string, unknown> {
242
- if (typeof message !== "string") fail(`${label} must be a text frame`);
243
- if (new TextEncoder().encode(message).byteLength > APPLET_FRAME_BYTE_LIMIT) {
244
- fail(`${label} exceeds the 64 KB wire limit`);
245
- }
246
- let parsed: unknown;
247
- try {
248
- parsed = JSON.parse(message);
249
- } catch {
250
- fail(`${label} is not valid JSON`);
251
- }
252
- const value = object(parsed, label);
253
- if (value.v !== 1 && value.v !== 2) {
254
- fail(`${label} speaks an unsupported protocol version`);
255
- }
256
- return value;
257
- }
258
-
259
- function versionOf(value: Record<string, unknown>): AppletProtocolVersion {
260
- return value.v === 2 ? 2 : 1;
261
- }
262
-
263
- function snapshotTables(value: unknown, label: string): AppletSnapshotTablesV1 {
264
- const tables = object(value, label);
265
- const decoded: AppletSnapshotTablesV1 = {};
266
- for (const [table, rows] of Object.entries(tables)) {
267
- const rowsLabel = `${label}.${table}`;
268
- name(table, rowsLabel);
269
- if (!Array.isArray(rows)) fail(`${rowsLabel} must be an array`);
270
- decoded[table] = rows.map((entry, index) =>
271
- row(entry, `${rowsLabel}[${index}]`),
272
- );
273
- }
274
- return decoded;
275
- }
276
-
277
- function decodeChange(candidate: unknown, label: string): AppletChangeV1 {
278
- const value = object(candidate, label);
279
- exact(value, ["table", "op", "key"], ["row"], label);
280
- const op = value.op;
281
- if (op !== "insert" && op !== "update" && op !== "delete") {
282
- fail(`${label}.op is invalid`);
283
- }
284
- const change: AppletChangeV1 = {
285
- table: name(value.table, `${label}.table`),
286
- op,
287
- key: bounded(value.key, `${label}.key`),
288
- };
289
- if (op === "delete") {
290
- if (value.row !== undefined) fail(`${label} must not carry a row`);
291
- return change;
292
- }
293
- change.row = row(value.row, `${label}.row`);
294
- return change;
295
- }
296
-
297
- /** Decode a client -> server frame. */
298
- export function decodeClientFrame(message: unknown): AppletClientFrameV1 {
299
- const value = parse(message, "Applet client frame");
300
- if (value.type === "hello") {
301
- exact(value, ["v", "type", "contract"], ["since"], "Applet hello");
302
- if (value.contract !== APPLET_CONTRACT_VERSION) {
303
- fail("Applet hello declares an unsupported contract");
304
- }
305
- const since =
306
- value.since === undefined
307
- ? undefined
308
- : cursor(value.since, "Applet hello.since");
309
- return {
310
- v: versionOf(value),
311
- type: "hello",
312
- contract: 1,
313
- ...(since === undefined ? {} : { since }),
314
- };
315
- }
316
- if (value.type === "mutate") {
317
- exact(value, ["v", "type", "txnId", "mutations"], [], "Applet mutate");
318
- if (!Array.isArray(value.mutations) || value.mutations.length === 0) {
319
- fail("Applet mutate.mutations must be a non-empty array");
320
- }
321
- if (value.mutations.length > 256) {
322
- fail("Applet mutate.mutations has too many entries");
323
- }
324
- const mutations = value.mutations.map((candidate, index) => {
325
- const label = `Applet mutate.mutations[${index}]`;
326
- const mutation = object(candidate, label);
327
- exact(mutation, ["table", "op"], ["key", "value"], label);
328
- const op = mutation.op;
329
- if (op !== "insert" && op !== "update" && op !== "delete") {
330
- fail(`${label}.op is invalid`);
331
- }
332
- const decoded: AppletMutationV1 = {
333
- table: name(mutation.table, `${label}.table`),
334
- op,
335
- };
336
- if (mutation.key !== undefined) {
337
- decoded.key = bounded(mutation.key, `${label}.key`);
338
- }
339
- if (op === "delete") {
340
- if (mutation.value !== undefined)
341
- fail(`${label} must not carry a value`);
342
- if (decoded.key === undefined) fail(`${label} requires a key`);
343
- return decoded;
344
- }
345
- if (op === "update" && decoded.key === undefined) {
346
- fail(`${label} requires a key`);
347
- }
348
- decoded.value = row(mutation.value, `${label}.value`);
349
- return decoded;
350
- });
351
- return {
352
- v: versionOf(value),
353
- type: "mutate",
354
- txnId: bounded(value.txnId, "Applet mutate.txnId", 64),
355
- mutations,
356
- };
357
- }
358
- return fail("Applet client frame type is invalid");
359
- }
360
-
361
- /** Decode a server -> client frame. */
362
- export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
363
- const value = parse(message, "Applet server frame");
364
- if (value.type === "hello") {
365
- exact(
366
- value,
367
- [
368
- "v",
369
- "type",
370
- "contract",
371
- "generationId",
372
- "viewer",
373
- "tables",
374
- "schemaRevision",
375
- "lastChangeId",
376
- ],
377
- // A v1 speaker never sends a snapshot in its hello, so a v1 frame that
378
- // carries one is not one this code produced.
379
- versionOf(value) === 2 ? ["snapshot"] : [],
380
- "Applet server hello",
381
- );
382
- if (value.contract !== APPLET_CONTRACT_VERSION) {
383
- fail("Applet server speaks an unsupported contract");
384
- }
385
- const viewer = object(value.viewer, "Applet server hello.viewer");
386
- exact(viewer, ["id", "canWrite"], [], "Applet server hello.viewer");
387
- if (typeof viewer.canWrite !== "boolean") {
388
- fail("Applet server hello.viewer.canWrite must be a boolean");
389
- }
390
- if (!Array.isArray(value.tables) || value.tables.length > 32) {
391
- fail("Applet server hello.tables must be a bounded array");
392
- }
393
- return {
394
- v: versionOf(value),
395
- type: "hello",
396
- contract: 1,
397
- generationId: bounded(
398
- value.generationId,
399
- "Applet server hello.generationId",
400
- ),
401
- viewer: {
402
- id: bounded(viewer.id, "Applet server hello.viewer.id"),
403
- canWrite: viewer.canWrite,
404
- },
405
- tables: value.tables.map((entry, index) =>
406
- name(entry, `Applet server hello.tables[${index}]`),
407
- ),
408
- schemaRevision: cursor(
409
- value.schemaRevision,
410
- "Applet server hello.schemaRevision",
411
- ),
412
- lastChangeId: cursor(
413
- value.lastChangeId,
414
- "Applet server hello.lastChangeId",
415
- ),
416
- ...(value.snapshot === undefined
417
- ? {}
418
- : {
419
- snapshot: snapshotTables(
420
- value.snapshot,
421
- "Applet server hello.snapshot",
422
- ),
423
- }),
424
- };
425
- }
426
- if (value.type === "snapshot") {
427
- exact(
428
- value,
429
- ["v", "type", "lastChangeId", "tables"],
430
- [],
431
- "Applet snapshot",
432
- );
433
- return {
434
- v: versionOf(value),
435
- type: "snapshot",
436
- lastChangeId: cursor(value.lastChangeId, "Applet snapshot.lastChangeId"),
437
- tables: snapshotTables(value.tables, "Applet snapshot.tables"),
438
- };
439
- }
440
- if (value.type === "changes") {
441
- exact(
442
- value,
443
- ["v", "type", "lastChangeId", "changes"],
444
- ["txnId"],
445
- "Applet changes",
446
- );
447
- if (!Array.isArray(value.changes))
448
- fail("Applet changes.changes must be an array");
449
- const changes = value.changes.map((candidate, index) =>
450
- decodeChange(candidate, `Applet changes.changes[${index}]`),
451
- );
452
- const txnId =
453
- value.txnId === undefined
454
- ? undefined
455
- : bounded(value.txnId, "Applet changes.txnId", 64);
456
- return {
457
- v: versionOf(value),
458
- type: "changes",
459
- lastChangeId: cursor(value.lastChangeId, "Applet changes.lastChangeId"),
460
- ...(txnId === undefined ? {} : { txnId }),
461
- changes,
462
- };
463
- }
464
- if (value.type === "ack") {
465
- exact(
466
- value,
467
- ["v", "type", "txnId", "lastChangeId", "changes"],
468
- [],
469
- "Applet ack",
470
- );
471
- if (!Array.isArray(value.changes))
472
- fail("Applet ack.changes must be an array");
473
- return {
474
- v: versionOf(value),
475
- type: "ack",
476
- txnId: bounded(value.txnId, "Applet ack.txnId", 64),
477
- lastChangeId: cursor(value.lastChangeId, "Applet ack.lastChangeId"),
478
- changes: value.changes.map((candidate, index) =>
479
- decodeChange(candidate, `Applet ack.changes[${index}]`),
480
- ),
481
- };
482
- }
483
- if (value.type === "reject") {
484
- exact(value, ["v", "type", "txnId", "reason"], [], "Applet reject");
485
- return {
486
- v: versionOf(value),
487
- type: "reject",
488
- txnId: bounded(value.txnId, "Applet reject.txnId", 64),
489
- reason: bounded(value.reason, "Applet reject.reason", 512),
490
- };
491
- }
492
- return fail("Applet server frame type is invalid");
493
- }