@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.
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Applet wire protocol v1.
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:
11
+ * server -> hello on accept (contract, generation, viewer, cursor)
12
+ * client -> hello with `since` when it is resuming, otherwise absent
13
+ * server -> snapshot full state, or `changes` when the cursor is resumable
14
+ * client -> mutate one client transaction
15
+ * server -> ack|reject to the originator; `changes` to every other socket
16
+ */
17
+
18
+ export const APPLET_CONTRACT_VERSION = 1 as const;
19
+ export const APPLET_FRAME_BYTE_LIMIT = 64 * 1024;
20
+
21
+ export type ChangeOperation = "insert" | "update" | "delete";
22
+
23
+ export interface AppletChangeV1 {
24
+ table: string;
25
+ op: ChangeOperation;
26
+ key: string;
27
+ /** The resulting row for insert and update; absent for delete. */
28
+ row?: Record<string, unknown>;
29
+ }
30
+
31
+ export interface AppletMutationV1 {
32
+ table: string;
33
+ op: ChangeOperation;
34
+ /** Required for update and delete; server-generated for insert when absent. */
35
+ key?: string;
36
+ /** Full row for insert, partial patch for update, absent for delete. */
37
+ value?: Record<string, unknown>;
38
+ }
39
+
40
+ export interface AppletViewerV1 {
41
+ id: string;
42
+ /** Whether this socket may send `mutate` frames. */
43
+ canWrite: boolean;
44
+ }
45
+
46
+ export type AppletServerFrameV1 =
47
+ | {
48
+ v: 1;
49
+ type: "hello";
50
+ contract: 1;
51
+ generationId: string;
52
+ viewer: AppletViewerV1;
53
+ tables: string[];
54
+ schemaRevision: number;
55
+ lastChangeId: number;
56
+ }
57
+ | {
58
+ v: 1;
59
+ type: "snapshot";
60
+ lastChangeId: number;
61
+ tables: Record<string, Array<Record<string, unknown>>>;
62
+ }
63
+ | {
64
+ v: 1;
65
+ type: "changes";
66
+ lastChangeId: number;
67
+ txnId?: string;
68
+ changes: AppletChangeV1[];
69
+ }
70
+ | {
71
+ v: 1;
72
+ type: "ack";
73
+ txnId: string;
74
+ lastChangeId: number;
75
+ changes: AppletChangeV1[];
76
+ }
77
+ | { v: 1; type: "reject"; txnId: string; reason: string };
78
+
79
+ export type AppletClientFrameV1 =
80
+ | { v: 1; type: "hello"; contract: 1; since?: number }
81
+ | { v: 1; type: "mutate"; txnId: string; mutations: AppletMutationV1[] };
82
+
83
+ export class AppletProtocolError extends Error {}
84
+
85
+ function fail(message: string): never {
86
+ throw new AppletProtocolError(message);
87
+ }
88
+
89
+ function object(value: unknown, label: string): Record<string, unknown> {
90
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
91
+ fail(`${label} must be an object`);
92
+ }
93
+ return value as Record<string, unknown>;
94
+ }
95
+
96
+ function exact(
97
+ value: Record<string, unknown>,
98
+ required: readonly string[],
99
+ optional: readonly string[],
100
+ label: string,
101
+ ): void {
102
+ for (const field of required) {
103
+ if (!Object.hasOwn(value, field)) fail(`${label} is missing "${field}"`);
104
+ }
105
+ for (const field of Object.keys(value)) {
106
+ if (!required.includes(field) && !optional.includes(field)) {
107
+ fail(`${label} has an unknown field "${field}"`);
108
+ }
109
+ }
110
+ }
111
+
112
+ function name(value: unknown, label: string): string {
113
+ if (
114
+ typeof value !== "string" ||
115
+ !/^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(value)
116
+ ) {
117
+ fail(`${label} must be an identifier`);
118
+ }
119
+ return value;
120
+ }
121
+
122
+ function bounded(value: unknown, label: string, maximum = 256): string {
123
+ if (
124
+ typeof value !== "string" ||
125
+ value.length === 0 ||
126
+ value.length > maximum
127
+ ) {
128
+ fail(`${label} must be a bounded non-empty string`);
129
+ }
130
+ return value;
131
+ }
132
+
133
+ function cursor(value: unknown, label: string): number {
134
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
135
+ fail(`${label} must be a non-negative integer`);
136
+ }
137
+ return value as number;
138
+ }
139
+
140
+ function jsonDepth(value: unknown, label: string, depth = 0): void {
141
+ if (depth > 16) fail(`${label} is too deeply nested`);
142
+ if (
143
+ value === null ||
144
+ typeof value === "string" ||
145
+ typeof value === "boolean" ||
146
+ (typeof value === "number" && Number.isFinite(value))
147
+ ) {
148
+ return;
149
+ }
150
+ if (Array.isArray(value)) {
151
+ if (value.length > 1024) fail(`${label} has too many entries`);
152
+ for (const entry of value) jsonDepth(entry, label, depth + 1);
153
+ return;
154
+ }
155
+ const record = object(value, label);
156
+ if (Object.keys(record).length > 256) fail(`${label} has too many fields`);
157
+ for (const entry of Object.values(record)) jsonDepth(entry, label, depth + 1);
158
+ }
159
+
160
+ function row(value: unknown, label: string): Record<string, unknown> {
161
+ const record = object(value, label);
162
+ for (const key of Object.keys(record)) name(key, `${label} column`);
163
+ jsonDepth(record, label);
164
+ return record;
165
+ }
166
+
167
+ /** Encode a frame, refusing anything over the wire limit. */
168
+ export function encodeFrame(
169
+ frame: AppletServerFrameV1 | AppletClientFrameV1,
170
+ ): string {
171
+ const wire = JSON.stringify(frame);
172
+ if (wire === undefined) fail("Applet frame is not JSON");
173
+ if (new TextEncoder().encode(wire).byteLength > APPLET_FRAME_BYTE_LIMIT) {
174
+ fail("Applet frame exceeds the 64 KB wire limit");
175
+ }
176
+ return wire;
177
+ }
178
+
179
+ function parse(message: unknown, label: string): Record<string, unknown> {
180
+ if (typeof message !== "string") fail(`${label} must be a text frame`);
181
+ if (new TextEncoder().encode(message).byteLength > APPLET_FRAME_BYTE_LIMIT) {
182
+ fail(`${label} exceeds the 64 KB wire limit`);
183
+ }
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(message);
187
+ } catch {
188
+ fail(`${label} is not valid JSON`);
189
+ }
190
+ const value = object(parsed, label);
191
+ if (value.v !== 1) fail(`${label} speaks an unsupported protocol version`);
192
+ return value;
193
+ }
194
+
195
+ function decodeChange(candidate: unknown, label: string): AppletChangeV1 {
196
+ const value = object(candidate, label);
197
+ exact(value, ["table", "op", "key"], ["row"], label);
198
+ const op = value.op;
199
+ if (op !== "insert" && op !== "update" && op !== "delete") {
200
+ fail(`${label}.op is invalid`);
201
+ }
202
+ const change: AppletChangeV1 = {
203
+ table: name(value.table, `${label}.table`),
204
+ op,
205
+ key: bounded(value.key, `${label}.key`),
206
+ };
207
+ if (op === "delete") {
208
+ if (value.row !== undefined) fail(`${label} must not carry a row`);
209
+ return change;
210
+ }
211
+ change.row = row(value.row, `${label}.row`);
212
+ return change;
213
+ }
214
+
215
+ /** Decode a client -> server frame. */
216
+ export function decodeClientFrame(message: unknown): AppletClientFrameV1 {
217
+ const value = parse(message, "Applet client frame");
218
+ if (value.type === "hello") {
219
+ exact(value, ["v", "type", "contract"], ["since"], "Applet hello");
220
+ if (value.contract !== APPLET_CONTRACT_VERSION) {
221
+ fail("Applet hello declares an unsupported contract");
222
+ }
223
+ const since =
224
+ value.since === undefined
225
+ ? undefined
226
+ : cursor(value.since, "Applet hello.since");
227
+ return {
228
+ v: 1,
229
+ type: "hello",
230
+ contract: 1,
231
+ ...(since === undefined ? {} : { since }),
232
+ };
233
+ }
234
+ if (value.type === "mutate") {
235
+ exact(value, ["v", "type", "txnId", "mutations"], [], "Applet mutate");
236
+ if (!Array.isArray(value.mutations) || value.mutations.length === 0) {
237
+ fail("Applet mutate.mutations must be a non-empty array");
238
+ }
239
+ if (value.mutations.length > 256) {
240
+ fail("Applet mutate.mutations has too many entries");
241
+ }
242
+ const mutations = value.mutations.map((candidate, index) => {
243
+ const label = `Applet mutate.mutations[${index}]`;
244
+ const mutation = object(candidate, label);
245
+ exact(mutation, ["table", "op"], ["key", "value"], label);
246
+ const op = mutation.op;
247
+ if (op !== "insert" && op !== "update" && op !== "delete") {
248
+ fail(`${label}.op is invalid`);
249
+ }
250
+ const decoded: AppletMutationV1 = {
251
+ table: name(mutation.table, `${label}.table`),
252
+ op,
253
+ };
254
+ if (mutation.key !== undefined) {
255
+ decoded.key = bounded(mutation.key, `${label}.key`);
256
+ }
257
+ if (op === "delete") {
258
+ if (mutation.value !== undefined)
259
+ fail(`${label} must not carry a value`);
260
+ if (decoded.key === undefined) fail(`${label} requires a key`);
261
+ return decoded;
262
+ }
263
+ if (op === "update" && decoded.key === undefined) {
264
+ fail(`${label} requires a key`);
265
+ }
266
+ decoded.value = row(mutation.value, `${label}.value`);
267
+ return decoded;
268
+ });
269
+ return {
270
+ v: 1,
271
+ type: "mutate",
272
+ txnId: bounded(value.txnId, "Applet mutate.txnId", 64),
273
+ mutations,
274
+ };
275
+ }
276
+ return fail("Applet client frame type is invalid");
277
+ }
278
+
279
+ /** Decode a server -> client frame. */
280
+ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
281
+ const value = parse(message, "Applet server frame");
282
+ if (value.type === "hello") {
283
+ exact(
284
+ value,
285
+ [
286
+ "v",
287
+ "type",
288
+ "contract",
289
+ "generationId",
290
+ "viewer",
291
+ "tables",
292
+ "schemaRevision",
293
+ "lastChangeId",
294
+ ],
295
+ [],
296
+ "Applet server hello",
297
+ );
298
+ if (value.contract !== APPLET_CONTRACT_VERSION) {
299
+ fail("Applet server speaks an unsupported contract");
300
+ }
301
+ const viewer = object(value.viewer, "Applet server hello.viewer");
302
+ exact(viewer, ["id", "canWrite"], [], "Applet server hello.viewer");
303
+ if (typeof viewer.canWrite !== "boolean") {
304
+ fail("Applet server hello.viewer.canWrite must be a boolean");
305
+ }
306
+ if (!Array.isArray(value.tables) || value.tables.length > 32) {
307
+ fail("Applet server hello.tables must be a bounded array");
308
+ }
309
+ return {
310
+ v: 1,
311
+ type: "hello",
312
+ contract: 1,
313
+ generationId: bounded(
314
+ value.generationId,
315
+ "Applet server hello.generationId",
316
+ ),
317
+ viewer: {
318
+ id: bounded(viewer.id, "Applet server hello.viewer.id"),
319
+ canWrite: viewer.canWrite,
320
+ },
321
+ tables: value.tables.map((entry, index) =>
322
+ name(entry, `Applet server hello.tables[${index}]`),
323
+ ),
324
+ schemaRevision: cursor(
325
+ value.schemaRevision,
326
+ "Applet server hello.schemaRevision",
327
+ ),
328
+ lastChangeId: cursor(
329
+ value.lastChangeId,
330
+ "Applet server hello.lastChangeId",
331
+ ),
332
+ };
333
+ }
334
+ if (value.type === "snapshot") {
335
+ exact(
336
+ value,
337
+ ["v", "type", "lastChangeId", "tables"],
338
+ [],
339
+ "Applet snapshot",
340
+ );
341
+ const tables = object(value.tables, "Applet snapshot.tables");
342
+ const decoded: Record<string, Array<Record<string, unknown>>> = {};
343
+ for (const [table, rows] of Object.entries(tables)) {
344
+ const label = `Applet snapshot.tables.${table}`;
345
+ name(table, label);
346
+ if (!Array.isArray(rows)) fail(`${label} must be an array`);
347
+ decoded[table] = rows.map((entry, index) =>
348
+ row(entry, `${label}[${index}]`),
349
+ );
350
+ }
351
+ return {
352
+ v: 1,
353
+ type: "snapshot",
354
+ lastChangeId: cursor(value.lastChangeId, "Applet snapshot.lastChangeId"),
355
+ tables: decoded,
356
+ };
357
+ }
358
+ if (value.type === "changes") {
359
+ exact(
360
+ value,
361
+ ["v", "type", "lastChangeId", "changes"],
362
+ ["txnId"],
363
+ "Applet changes",
364
+ );
365
+ if (!Array.isArray(value.changes))
366
+ fail("Applet changes.changes must be an array");
367
+ const changes = value.changes.map((candidate, index) =>
368
+ decodeChange(candidate, `Applet changes.changes[${index}]`),
369
+ );
370
+ const txnId =
371
+ value.txnId === undefined
372
+ ? undefined
373
+ : bounded(value.txnId, "Applet changes.txnId", 64);
374
+ return {
375
+ v: 1,
376
+ type: "changes",
377
+ lastChangeId: cursor(value.lastChangeId, "Applet changes.lastChangeId"),
378
+ ...(txnId === undefined ? {} : { txnId }),
379
+ changes,
380
+ };
381
+ }
382
+ if (value.type === "ack") {
383
+ exact(
384
+ value,
385
+ ["v", "type", "txnId", "lastChangeId", "changes"],
386
+ [],
387
+ "Applet ack",
388
+ );
389
+ if (!Array.isArray(value.changes))
390
+ fail("Applet ack.changes must be an array");
391
+ return {
392
+ v: 1,
393
+ type: "ack",
394
+ txnId: bounded(value.txnId, "Applet ack.txnId", 64),
395
+ lastChangeId: cursor(value.lastChangeId, "Applet ack.lastChangeId"),
396
+ changes: value.changes.map((candidate, index) =>
397
+ decodeChange(candidate, `Applet ack.changes[${index}]`),
398
+ ),
399
+ };
400
+ }
401
+ if (value.type === "reject") {
402
+ exact(value, ["v", "type", "txnId", "reason"], [], "Applet reject");
403
+ return {
404
+ v: 1,
405
+ type: "reject",
406
+ txnId: bounded(value.txnId, "Applet reject.txnId", 64),
407
+ reason: bounded(value.reason, "Applet reject.reason", 512),
408
+ };
409
+ }
410
+ return fail("Applet server frame type is invalid");
411
+ }