@frockbot/applet-sdk 0.7.20 → 0.7.22

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 CHANGED
@@ -51,20 +51,33 @@ The Cloudflare programming model is not hidden: an Applet is a Durable Object
51
51
  with SQLite and hibernating sockets. What the SDK does hide is every binding
52
52
  name — an author sees `tables`, `tools`, and `this.db`.
53
53
 
54
- ## Wire protocol v1
54
+ ## Wire protocol
55
55
 
56
56
  JSON frames, at most 64 KB each, decoded by `src/protocol/` at both ends;
57
- an unknown type, field, or table fails closed.
58
-
59
- | Direction | Frame | Carries |
60
- | --------------- | ---------- | ----------------------------------------------------------- |
61
- | server → client | `hello` | contract, generationId, viewer, tables, revision, cursor |
62
- | client → server | `hello` | contract, optional `since` cursor for catch-up |
63
- | server → client | `snapshot` | every row of every table, plus the cursor |
64
- | server → client | `changes` | ordered row changes, optionally tagged with a client txn id |
65
- | client → server | `mutate` | one client transaction: insert/update/delete |
66
- | server → client | `ack` | the resulting rows for that txn |
67
- | server → client | `reject` | why the txn was refused (the client rolls back) |
57
+ an unknown type, field, or table fails closed. Two versions are spoken on the
58
+ same server, told apart by the socket URL: a page built against v2 opens with
59
+ `v=2`, and a page built before it opens with nothing and is spoken to in v1.
60
+
61
+ | Direction | Frame | Carries |
62
+ | --------------- | ---------- | ------------------------------------------------------------------------------------------- |
63
+ | server → client | `hello` | contract, generationId, viewer, tables, revision, cursor — and in v2, the `snapshot` itself |
64
+ | client → server | `hello` | contract, optional `since` cursor for catch-up; in v2 only on a resume or when asked |
65
+ | server → client | `snapshot` | every row of every table, plus the cursor |
66
+ | server → client | `changes` | ordered row changes, optionally tagged with a client txn id |
67
+ | client → server | `mutate` | one client transaction: insert/update/delete |
68
+ | server → client | `ack` | the resulting rows for that txn |
69
+ | server → client | `reject` | why the txn was refused (the client rolls back) |
70
+
71
+ The host hands the page its credential in an `init` postMessage, and a fresh
72
+ credential later in a `refresh` of the same shape; the page reconnects in
73
+ place rather than being reloaded, and with its cursor on the URL that is the
74
+ `changes` path.
75
+
76
+ A v2 page's first render waits on one frame: the server's `hello` carries the
77
+ snapshot when the URL named no `since` cursor, and the page marks its
78
+ collections ready on it. A reconnect puts `since` on the URL, gets a plain
79
+ `hello`, and asks for `changes` as v1 does. A snapshot that would not fit the
80
+ frame is left out of the hello and the v1 exchange follows.
68
81
 
69
82
  ## Tests
70
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/applet-sdk",
3
- "version": "0.7.20",
3
+ "version": "0.7.22",
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.",
@@ -87,8 +87,13 @@ export interface CreateAppletOptions extends AppletTransportOptions {
87
87
  autoConnect?: boolean;
88
88
  }
89
89
 
90
- /** The host's `init`, with the fields an Applet page needs. */
90
+ /**
91
+ * The host's `init`, with the fields an Applet page needs — or its `refresh`,
92
+ * the same shape carrying a fresh viewer credential for a page that is
93
+ * already running, so the document need not be rebuilt to hold it.
94
+ */
91
95
  export interface AppletHostInitV1 {
96
+ type: "init" | "refresh";
92
97
  themeTokens: Record<string, string>;
93
98
  applet: AppletInitV1;
94
99
  }
@@ -96,7 +101,8 @@ export interface AppletHostInitV1 {
96
101
  function decodeHostInit(data: unknown): AppletHostInitV1 | undefined {
97
102
  if (!data || typeof data !== "object") return undefined;
98
103
  const message = data as Record<string, unknown>;
99
- if (message.schemaVersion !== 1 || message.type !== "init") return undefined;
104
+ if (message.schemaVersion !== 1) return undefined;
105
+ if (message.type !== "init" && message.type !== "refresh") return undefined;
100
106
  const applet = message.applet;
101
107
  const tokens = message.themeTokens;
102
108
  if (!applet || typeof applet !== "object") return undefined;
@@ -118,6 +124,7 @@ function decodeHostInit(data: unknown): AppletHostInitV1 | undefined {
118
124
  }
119
125
  }
120
126
  return {
127
+ type: message.type,
121
128
  themeTokens,
122
129
  applet: {
123
130
  socketUrl: value.socketUrl,
@@ -126,10 +133,24 @@ function decodeHostInit(data: unknown): AppletHostInitV1 | undefined {
126
133
  ...(value.tokenTransport === "subprotocol-v1"
127
134
  ? { tokenTransport: "subprotocol-v1" as const }
128
135
  : {}),
136
+ ...(value.timing === true ? { timing: true } : {}),
129
137
  },
130
138
  };
131
139
  }
132
140
 
141
+ /**
142
+ * One hop of the open path, posted to the host when its `init` asked for
143
+ * timing. The host keeps the log; the page only says when each hop landed,
144
+ * in its own clock, so the host can line them up with its own hops.
145
+ */
146
+ function postTiming(hop: string): void {
147
+ if (typeof window === "undefined") return;
148
+ window.parent.postMessage(
149
+ { schemaVersion: 1, type: "applet/timing", hop, at: performance.now() },
150
+ "*",
151
+ );
152
+ }
153
+
133
154
  /** Paint the host's semantic tokens onto the page as `--frockbot-*`. */
134
155
  export function applyThemeTokens(tokens: Record<string, string>): void {
135
156
  if (typeof document === "undefined") return;
@@ -138,7 +159,7 @@ export function applyThemeTokens(tokens: Record<string, string>): void {
138
159
  }
139
160
  }
140
161
 
141
- /** Subscribe to the host's `init` message. Returns an unsubscribe function. */
162
+ /** Subscribe to the host's `init` and `refresh` messages. Returns an unsubscribe function. */
142
163
  export function listenForAppletInit(
143
164
  handler: (init: AppletHostInitV1) => void,
144
165
  ): () => void {
@@ -156,7 +177,19 @@ export function createApplet<TServer extends { tables: TablesShape }>(
156
177
  options: CreateAppletOptions = {},
157
178
  ): AppletClient<TServer["tables"]> {
158
179
  const { autoConnect, ...transportOptions } = options;
159
- const transport = new AppletTransport(transportOptions);
180
+ const transport = new AppletTransport({
181
+ onTiming: (hop) => {
182
+ postTiming(hop);
183
+ // `ready` is the collections marked ready; the first paint of what
184
+ // they hold is the frame after React commits it.
185
+ if (hop === "ready" && typeof requestAnimationFrame === "function") {
186
+ requestAnimationFrame(() =>
187
+ requestAnimationFrame(() => postTiming("first-render")),
188
+ );
189
+ }
190
+ },
191
+ ...transportOptions,
192
+ });
160
193
  const collections = new Map<string, Collection<AppletRow, string>>();
161
194
 
162
195
  const tables = new Proxy({} as Record<string, unknown>, {
@@ -179,7 +212,8 @@ export function createApplet<TServer extends { tables: TablesShape }>(
179
212
  listenForAppletInit((init) => {
180
213
  applyThemeTokens(init.themeTokens);
181
214
  lastInit = init.applet;
182
- transport.connect(init.applet);
215
+ if (init.type === "refresh") transport.refresh(init.applet);
216
+ else transport.connect(init.applet);
183
217
  });
184
218
  window.parent.postMessage(
185
219
  {
@@ -9,10 +9,12 @@
9
9
 
10
10
  import {
11
11
  APPLET_CONTRACT_VERSION,
12
+ APPLET_PROTOCOL_VERSION,
12
13
  decodeServerFrame,
13
14
  encodeFrame,
14
15
  type AppletChangeV1,
15
16
  type AppletMutationV1,
17
+ type AppletSnapshotTablesV1,
16
18
  type AppletViewerV1,
17
19
  } from "../protocol/index.js";
18
20
 
@@ -23,8 +25,16 @@ export interface AppletInitV1 {
23
25
  token: string;
24
26
  generationId: string;
25
27
  tokenTransport?: "subprotocol-v1";
28
+ /**
29
+ * Report each hop of the open path — socket open, hello, ready — to the
30
+ * host, for the timing log it keeps behind its own flag. Off by default.
31
+ */
32
+ timing?: boolean;
26
33
  }
27
34
 
35
+ /** The hops a page reports when the host asked for timing. */
36
+ export type AppletTimingHop = "socket-open" | "hello" | "ready";
37
+
28
38
  export type AppletStatus =
29
39
  "idle" | "connecting" | "ready" | "reconnecting" | "closed";
30
40
 
@@ -69,6 +79,8 @@ export interface AppletTransportOptions {
69
79
  maximumBackoffMs?: number;
70
80
  /** Scheduler seam so tests do not wait in real time. */
71
81
  schedule?: (closure: () => void, delayMs: number) => unknown;
82
+ /** Where a timing hop goes when `init.timing` is set; the page posts to its host. */
83
+ onTiming?: (hop: AppletTimingHop) => void;
72
84
  }
73
85
 
74
86
  class RejectedMutation extends Error {}
@@ -78,8 +90,11 @@ function defaultSocketFactory(url: string, protocols?: string[]): AppletSocket {
78
90
  }
79
91
 
80
92
  export class AppletTransport {
81
- #options: Required<Omit<AppletTransportOptions, "socketFactory">> & {
93
+ #options: Required<
94
+ Omit<AppletTransportOptions, "socketFactory" | "onTiming">
95
+ > & {
82
96
  socketFactory: AppletSocketFactory;
97
+ onTiming: ((hop: AppletTimingHop) => void) | undefined;
83
98
  };
84
99
  #socket?: AppletSocket;
85
100
  #init?: AppletInitV1;
@@ -117,6 +132,13 @@ export class AppletTransport {
117
132
  */
118
133
  #retrySeq = 0;
119
134
  #pendingRetryId = 0;
135
+ /**
136
+ * Whether this socket has sent its own `hello`. A v2 socket that opened with
137
+ * no cursor sends none: the server's `hello` carries the snapshot, and only
138
+ * a hello that arrives without one — a server that could not fit it, or one
139
+ * that speaks v1 — is answered.
140
+ */
141
+ #helloSent = false;
120
142
 
121
143
  constructor(options: AppletTransportOptions = {}) {
122
144
  this.#options = {
@@ -125,6 +147,7 @@ export class AppletTransport {
125
147
  maximumBackoffMs: options.maximumBackoffMs ?? 8_000,
126
148
  schedule:
127
149
  options.schedule ?? ((closure, delay) => setTimeout(closure, delay)),
150
+ onTiming: options.onTiming,
128
151
  };
129
152
  }
130
153
 
@@ -151,6 +174,30 @@ export class AppletTransport {
151
174
  this.#open();
152
175
  }
153
176
 
177
+ /**
178
+ * A fresh viewer credential for the page that is already running: the
179
+ * host's `refresh` message. The document stays; the transport reconnects
180
+ * in place with the new token, and since it carries its cursor the server
181
+ * answers with `changes` rather than a snapshot. The same credential
182
+ * offered twice — a host re-sending what the page already holds — is not
183
+ * a reason to drop a live socket.
184
+ */
185
+ refresh(init: AppletInitV1): void {
186
+ const held = this.#init;
187
+ if (
188
+ held &&
189
+ this.#socket &&
190
+ !this.#closed &&
191
+ held.token === init.token &&
192
+ held.socketUrl === init.socketUrl &&
193
+ held.generationId === init.generationId
194
+ ) {
195
+ this.#init = init;
196
+ return;
197
+ }
198
+ this.connect(init);
199
+ }
200
+
154
201
  close(): void {
155
202
  this.#closed = true;
156
203
  this.#reset(1000, "closed");
@@ -189,6 +236,12 @@ export class AppletTransport {
189
236
  status: this.#attempt === 0 ? "connecting" : "reconnecting",
190
237
  });
191
238
  const url = new URL(this.#init.socketUrl);
239
+ // The version this page speaks and, on a reconnect, the cursor it will
240
+ // resume from, both settled before the first frame so the server's hello
241
+ // can carry the snapshot exactly when one is needed.
242
+ url.searchParams.set("v", String(APPLET_PROTOCOL_VERSION));
243
+ const since = this.#lastChangeId === 0 ? undefined : this.#lastChangeId;
244
+ if (since !== undefined) url.searchParams.set("since", String(since));
192
245
  const protocols =
193
246
  this.#init.tokenTransport === "subprotocol-v1"
194
247
  ? ["frockbot.applet.v1", `frockbot.viewer.${this.#init.token}`]
@@ -196,8 +249,14 @@ export class AppletTransport {
196
249
  if (!protocols) url.searchParams.set("token", this.#init.token);
197
250
  const socket = this.#options.socketFactory(url.toString(), protocols);
198
251
  this.#socket = socket;
252
+ this.#helloSent = false;
199
253
  socket.onopen = () => {
200
- if (this.#currentSocketId === id) this.#handshake();
254
+ if (this.#currentSocketId !== id) return;
255
+ this.#timing("socket-open");
256
+ // A resume asks for its catch-up at once, crossing the server's hello
257
+ // on the wire as it always has. A first connection waits: the hello on
258
+ // its way carries the snapshot.
259
+ if (since !== undefined) this.#handshake();
201
260
  };
202
261
  socket.onmessage = (event) => {
203
262
  if (this.#currentSocketId === id) this.#receive(event.data);
@@ -208,14 +267,19 @@ export class AppletTransport {
208
267
 
209
268
  #handshake(): void {
210
269
  const since = this.#lastChangeId === 0 ? undefined : this.#lastChangeId;
270
+ this.#helloSent = true;
211
271
  this.#write({
212
- v: 1,
272
+ v: APPLET_PROTOCOL_VERSION,
213
273
  type: "hello",
214
274
  contract: APPLET_CONTRACT_VERSION,
215
275
  ...(since === undefined ? {} : { since }),
216
276
  });
217
277
  }
218
278
 
279
+ #timing(hop: AppletTimingHop): void {
280
+ if (this.#init?.timing) this.#options.onTiming?.(hop);
281
+ }
282
+
219
283
  /**
220
284
  * One failure reaches here twice — `error` then `close` — and a socket the
221
285
  * transport already replaced can reach here at any time. Only the socket that
@@ -256,6 +320,7 @@ export class AppletTransport {
256
320
  }
257
321
 
258
322
  if (frame.type === "hello") {
323
+ this.#timing("hello");
259
324
  const changedGeneration =
260
325
  this.#state.generationId !== null &&
261
326
  this.#state.generationId !== frame.generationId;
@@ -268,28 +333,30 @@ export class AppletTransport {
268
333
  this.#lastChangeId = 0;
269
334
  this.#synced = false;
270
335
  this.#buffer = [];
271
- this.#write({ v: 1, type: "hello", contract: APPLET_CONTRACT_VERSION });
336
+ }
337
+ if (frame.snapshot) {
338
+ // The whole state came with the greeting: render on it, and send
339
+ // nothing back. A hello for a new generation carries that
340
+ // generation's rows, so the reset above is what it needs.
341
+ this.#snapshot(frame.lastChangeId, frame.snapshot);
342
+ return;
343
+ }
344
+ if (changedGeneration || !this.#helloSent) {
345
+ // Nothing came with the greeting: a server that could not fit the
346
+ // snapshot, one that speaks v1, or new code over the same storage.
347
+ // Ask, as v1 always did.
348
+ this.#helloSent = true;
349
+ this.#write({
350
+ v: APPLET_PROTOCOL_VERSION,
351
+ type: "hello",
352
+ contract: APPLET_CONTRACT_VERSION,
353
+ });
272
354
  }
273
355
  return;
274
356
  }
275
357
 
276
358
  if (frame.type === "snapshot") {
277
- this.#lastChangeId = frame.lastChangeId;
278
- for (const [name, sink] of this.#sinks) {
279
- // `truncate` only has meaning inside an open sync transaction.
280
- sink.begin();
281
- sink.truncate();
282
- for (const row of frame.tables[name] ?? [])
283
- sink.write({ type: "insert", value: row });
284
- sink.commit();
285
- sink.markReady();
286
- }
287
- this.#synced = true;
288
- this.#attempt = 0;
289
- this.#setState({ status: "ready" });
290
- const buffered = this.#buffer;
291
- this.#buffer = [];
292
- if (buffered.length > 0) this.#apply(buffered);
359
+ this.#snapshot(frame.lastChangeId, frame.tables);
293
360
  return;
294
361
  }
295
362
 
@@ -302,6 +369,7 @@ export class AppletTransport {
302
369
  this.#attempt = 0;
303
370
  this.#setState({ status: "ready" });
304
371
  for (const sink of this.#sinks.values()) sink.markReady();
372
+ this.#timing("ready");
305
373
  }
306
374
  this.#apply(frame.changes);
307
375
  return;
@@ -321,6 +389,27 @@ export class AppletTransport {
321
389
  this.#pending.delete(frame.txnId);
322
390
  }
323
391
 
392
+ /** The whole state, from a `snapshot` frame or a v2 hello: replace and mark ready. */
393
+ #snapshot(lastChangeId: number, tables: AppletSnapshotTablesV1): void {
394
+ this.#lastChangeId = lastChangeId;
395
+ for (const [name, sink] of this.#sinks) {
396
+ // `truncate` only has meaning inside an open sync transaction.
397
+ sink.begin();
398
+ sink.truncate();
399
+ for (const row of tables[name] ?? [])
400
+ sink.write({ type: "insert", value: row });
401
+ sink.commit();
402
+ sink.markReady();
403
+ }
404
+ this.#synced = true;
405
+ this.#attempt = 0;
406
+ this.#setState({ status: "ready" });
407
+ this.#timing("ready");
408
+ const buffered = this.#buffer;
409
+ this.#buffer = [];
410
+ if (buffered.length > 0) this.#apply(buffered);
411
+ }
412
+
324
413
  #apply(changes: AppletChangeV1[]): void {
325
414
  if (!this.#synced) {
326
415
  this.#buffer.push(...changes);
@@ -366,7 +455,12 @@ export class AppletTransport {
366
455
  this.#resyncQueued = false;
367
456
  if (!this.#socket) return;
368
457
  this.#synced = false;
369
- this.#write({ v: 1, type: "hello", contract: APPLET_CONTRACT_VERSION });
458
+ this.#helloSent = true;
459
+ this.#write({
460
+ v: APPLET_PROTOCOL_VERSION,
461
+ type: "hello",
462
+ contract: APPLET_CONTRACT_VERSION,
463
+ });
370
464
  });
371
465
  }
372
466
 
@@ -379,7 +473,12 @@ export class AppletTransport {
379
473
  return new Promise<AppletChangeV1[]>((resolve, reject) => {
380
474
  this.#pending.set(txnId, { resolve, reject });
381
475
  try {
382
- this.#write({ v: 1, type: "mutate", txnId, mutations });
476
+ this.#write({
477
+ v: APPLET_PROTOCOL_VERSION,
478
+ type: "mutate",
479
+ txnId,
480
+ mutations,
481
+ });
383
482
  } catch (error) {
384
483
  this.#pending.delete(txnId);
385
484
  reject(error instanceof Error ? error : new Error(String(error)));
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Applet wire protocol v1.
2
+ * Applet wire protocol, versions 1 and 2.
3
3
  *
4
4
  * One JSON frame per WebSocket message, at most 64 KB encoded. Both ends decode
5
5
  * with the functions here and nothing else: an unknown type, an unknown field,
@@ -7,17 +7,52 @@
7
7
  * that name a table or column it did not declare — that check needs the schema,
8
8
  * so it lives in `server/`, not here.
9
9
  *
10
- * Sequence:
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`):
11
20
  * server -> hello on accept (contract, generation, viewer, cursor)
12
21
  * client -> hello with `since` when it is resuming, otherwise absent
13
22
  * 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
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.
16
28
  */
17
29
 
18
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;
19
34
  export const APPLET_FRAME_BYTE_LIMIT = 64 * 1024;
20
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
+
21
56
  export type ChangeOperation = "insert" | "update" | "delete";
22
57
 
23
58
  export interface AppletChangeV1 {
@@ -43,9 +78,14 @@ export interface AppletViewerV1 {
43
78
  canWrite: boolean;
44
79
  }
45
80
 
81
+ export type AppletSnapshotTablesV1 = Record<
82
+ string,
83
+ Array<Record<string, unknown>>
84
+ >;
85
+
46
86
  export type AppletServerFrameV1 =
47
87
  | {
48
- v: 1;
88
+ v: AppletProtocolVersion;
49
89
  type: "hello";
50
90
  contract: 1;
51
91
  generationId: string;
@@ -53,32 +93,54 @@ export type AppletServerFrameV1 =
53
93
  tables: string[];
54
94
  schemaRevision: number;
55
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;
56
103
  }
57
104
  | {
58
- v: 1;
105
+ v: AppletProtocolVersion;
59
106
  type: "snapshot";
60
107
  lastChangeId: number;
61
- tables: Record<string, Array<Record<string, unknown>>>;
108
+ tables: AppletSnapshotTablesV1;
62
109
  }
63
110
  | {
64
- v: 1;
111
+ v: AppletProtocolVersion;
65
112
  type: "changes";
66
113
  lastChangeId: number;
67
114
  txnId?: string;
68
115
  changes: AppletChangeV1[];
69
116
  }
70
117
  | {
71
- v: 1;
118
+ v: AppletProtocolVersion;
72
119
  type: "ack";
73
120
  txnId: string;
74
121
  lastChangeId: number;
75
122
  changes: AppletChangeV1[];
76
123
  }
77
- | { v: 1; type: "reject"; txnId: string; reason: string };
124
+ | {
125
+ v: AppletProtocolVersion;
126
+ type: "reject";
127
+ txnId: string;
128
+ reason: string;
129
+ };
78
130
 
79
131
  export type AppletClientFrameV1 =
80
- | { v: 1; type: "hello"; contract: 1; since?: number }
81
- | { v: 1; type: "mutate"; txnId: string; mutations: AppletMutationV1[] };
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
+ };
82
144
 
83
145
  export class AppletProtocolError extends Error {}
84
146
 
@@ -188,10 +250,30 @@ function parse(message: unknown, label: string): Record<string, unknown> {
188
250
  fail(`${label} is not valid JSON`);
189
251
  }
190
252
  const value = object(parsed, label);
191
- if (value.v !== 1) fail(`${label} speaks an unsupported protocol version`);
253
+ if (value.v !== 1 && value.v !== 2) {
254
+ fail(`${label} speaks an unsupported protocol version`);
255
+ }
192
256
  return value;
193
257
  }
194
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
+
195
277
  function decodeChange(candidate: unknown, label: string): AppletChangeV1 {
196
278
  const value = object(candidate, label);
197
279
  exact(value, ["table", "op", "key"], ["row"], label);
@@ -225,7 +307,7 @@ export function decodeClientFrame(message: unknown): AppletClientFrameV1 {
225
307
  ? undefined
226
308
  : cursor(value.since, "Applet hello.since");
227
309
  return {
228
- v: 1,
310
+ v: versionOf(value),
229
311
  type: "hello",
230
312
  contract: 1,
231
313
  ...(since === undefined ? {} : { since }),
@@ -267,7 +349,7 @@ export function decodeClientFrame(message: unknown): AppletClientFrameV1 {
267
349
  return decoded;
268
350
  });
269
351
  return {
270
- v: 1,
352
+ v: versionOf(value),
271
353
  type: "mutate",
272
354
  txnId: bounded(value.txnId, "Applet mutate.txnId", 64),
273
355
  mutations,
@@ -292,7 +374,9 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
292
374
  "schemaRevision",
293
375
  "lastChangeId",
294
376
  ],
295
- [],
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"] : [],
296
380
  "Applet server hello",
297
381
  );
298
382
  if (value.contract !== APPLET_CONTRACT_VERSION) {
@@ -307,7 +391,7 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
307
391
  fail("Applet server hello.tables must be a bounded array");
308
392
  }
309
393
  return {
310
- v: 1,
394
+ v: versionOf(value),
311
395
  type: "hello",
312
396
  contract: 1,
313
397
  generationId: bounded(
@@ -329,6 +413,14 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
329
413
  value.lastChangeId,
330
414
  "Applet server hello.lastChangeId",
331
415
  ),
416
+ ...(value.snapshot === undefined
417
+ ? {}
418
+ : {
419
+ snapshot: snapshotTables(
420
+ value.snapshot,
421
+ "Applet server hello.snapshot",
422
+ ),
423
+ }),
332
424
  };
333
425
  }
334
426
  if (value.type === "snapshot") {
@@ -338,21 +430,11 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
338
430
  [],
339
431
  "Applet snapshot",
340
432
  );
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
433
  return {
352
- v: 1,
434
+ v: versionOf(value),
353
435
  type: "snapshot",
354
436
  lastChangeId: cursor(value.lastChangeId, "Applet snapshot.lastChangeId"),
355
- tables: decoded,
437
+ tables: snapshotTables(value.tables, "Applet snapshot.tables"),
356
438
  };
357
439
  }
358
440
  if (value.type === "changes") {
@@ -372,7 +454,7 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
372
454
  ? undefined
373
455
  : bounded(value.txnId, "Applet changes.txnId", 64);
374
456
  return {
375
- v: 1,
457
+ v: versionOf(value),
376
458
  type: "changes",
377
459
  lastChangeId: cursor(value.lastChangeId, "Applet changes.lastChangeId"),
378
460
  ...(txnId === undefined ? {} : { txnId }),
@@ -389,7 +471,7 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
389
471
  if (!Array.isArray(value.changes))
390
472
  fail("Applet ack.changes must be an array");
391
473
  return {
392
- v: 1,
474
+ v: versionOf(value),
393
475
  type: "ack",
394
476
  txnId: bounded(value.txnId, "Applet ack.txnId", 64),
395
477
  lastChangeId: cursor(value.lastChangeId, "Applet ack.lastChangeId"),
@@ -401,7 +483,7 @@ export function decodeServerFrame(message: unknown): AppletServerFrameV1 {
401
483
  if (value.type === "reject") {
402
484
  exact(value, ["v", "type", "txnId", "reason"], [], "Applet reject");
403
485
  return {
404
- v: 1,
486
+ v: versionOf(value),
405
487
  type: "reject",
406
488
  txnId: bounded(value.txnId, "Applet reject.txnId", 64),
407
489
  reason: bounded(value.reason, "Applet reject.reason", 512),
@@ -15,8 +15,10 @@ import type {
15
15
 
16
16
  import {
17
17
  APPLET_CONTRACT_VERSION,
18
+ appletHandshakeFromUrlV1,
18
19
  encodeFrame,
19
20
  type AppletChangeV1,
21
+ type AppletProtocolVersion,
20
22
  type AppletViewerV1,
21
23
  } from "../protocol/index.js";
22
24
  import {
@@ -111,6 +113,8 @@ interface ViewerAttachment {
111
113
  viewer: AppletViewerV1;
112
114
  /** Set once the socket has been sent its snapshot or catch-up. */
113
115
  synced: boolean;
116
+ /** The protocol the page opened with; absent on a socket accepted before v2. */
117
+ protocol?: AppletProtocolVersion;
114
118
  }
115
119
 
116
120
  /**
@@ -293,6 +297,10 @@ export abstract class Applet<
293
297
  url.searchParams.get("canWrite") ??
294
298
  "true") !== "false",
295
299
  };
300
+ // The page's version and, on a reconnect, its cursor, both settled by the
301
+ // URL before the first frame. The kernel forwards the upgrade with the
302
+ // viewer token removed and everything else the page put there kept.
303
+ const handshake = appletHandshakeFromUrlV1(url);
296
304
  const pair = new WebSocketPair();
297
305
  const client = pair[0];
298
306
  const server = pair[1];
@@ -300,8 +308,9 @@ export abstract class Applet<
300
308
  server.serializeAttachment({
301
309
  viewer,
302
310
  synced: false,
311
+ protocol: handshake.protocol,
303
312
  } satisfies ViewerAttachment);
304
- this.#protocol().greet(this.#peer(server));
313
+ this.#protocol().greet(this.#peer(server), handshake);
305
314
  return new Response(null, {
306
315
  status: 101,
307
316
  webSocket: client,
@@ -363,6 +372,7 @@ export abstract class Applet<
363
372
  }) as ViewerAttachment;
364
373
  return {
365
374
  viewer: attachment.viewer,
375
+ protocol: attachment.protocol ?? 1,
366
376
  get synced() {
367
377
  return attachment.synced;
368
378
  },
@@ -11,6 +11,7 @@ import {
11
11
  AppletProtocolError,
12
12
  decodeClientFrame,
13
13
  type AppletChangeV1,
14
+ type AppletProtocolVersion,
14
15
  type AppletServerFrameV1,
15
16
  type AppletViewerV1,
16
17
  } from "../protocol/index.js";
@@ -20,6 +21,11 @@ export interface AppletPeer {
20
21
  send(frame: AppletServerFrameV1): void;
21
22
  close(code: number, reason: string): void;
22
23
  readonly viewer: AppletViewerV1;
24
+ /**
25
+ * The protocol this socket's page speaks, settled by its URL before the
26
+ * first frame. Every frame sent to it carries this `v`.
27
+ */
28
+ readonly protocol: AppletProtocolVersion;
23
29
  /** False until the peer has been sent a snapshot or a catch-up. */
24
30
  synced: boolean;
25
31
  }
@@ -39,10 +45,17 @@ export class AppletProtocolServer {
39
45
  private readonly options: AppletProtocolServerOptions,
40
46
  ) {}
41
47
 
42
- /** The unprompted `hello` a peer gets the moment its socket is accepted. */
43
- greet(peer: AppletPeer): void {
44
- peer.send({
45
- v: 1,
48
+ /**
49
+ * The unprompted `hello` a peer gets the moment its socket is accepted.
50
+ *
51
+ * A v2 page that opened with no cursor is handed the snapshot in the same
52
+ * frame, so its first render waits on nothing else. A page resuming from a
53
+ * cursor asks for its catch-up itself, as v1 always has, and a snapshot that
54
+ * would not fit the frame is left for the v1 exchange too.
55
+ */
56
+ greet(peer: AppletPeer, handshake: { since?: number } = {}): void {
57
+ const hello: AppletServerFrameV1 = {
58
+ v: peer.protocol,
46
59
  type: "hello",
47
60
  contract: APPLET_CONTRACT_VERSION,
48
61
  generationId: this.options.generationId,
@@ -50,7 +63,17 @@ export class AppletProtocolServer {
50
63
  tables: Object.keys(this.store.tables),
51
64
  schemaRevision: this.options.schemaRevision,
52
65
  lastChangeId: this.store.lastChangeId,
53
- });
66
+ };
67
+ if (peer.protocol === 2 && handshake.since === undefined) {
68
+ try {
69
+ peer.send({ ...hello, snapshot: this.store.snapshot() });
70
+ peer.synced = true;
71
+ return;
72
+ } catch (error) {
73
+ if (!(error instanceof AppletProtocolError)) throw error;
74
+ }
75
+ }
76
+ peer.send(hello);
54
77
  }
55
78
 
56
79
  /** Handle one inbound frame. Never throws; a bad frame closes the socket. */
@@ -70,14 +93,14 @@ export class AppletProtocolServer {
70
93
  : this.store.changesSince(frame.since);
71
94
  if (catchUp) {
72
95
  peer.send({
73
- v: 1,
96
+ v: peer.protocol,
74
97
  type: "changes",
75
98
  lastChangeId: this.store.lastChangeId,
76
99
  changes: catchUp,
77
100
  });
78
101
  } else {
79
102
  peer.send({
80
- v: 1,
103
+ v: peer.protocol,
81
104
  type: "snapshot",
82
105
  lastChangeId: this.store.lastChangeId,
83
106
  tables: this.store.snapshot(),
@@ -89,7 +112,7 @@ export class AppletProtocolServer {
89
112
 
90
113
  if (!peer.viewer.canWrite) {
91
114
  peer.send({
92
- v: 1,
115
+ v: peer.protocol,
93
116
  type: "reject",
94
117
  txnId: frame.txnId,
95
118
  reason: "This viewer may not write",
@@ -104,7 +127,7 @@ export class AppletProtocolServer {
104
127
  );
105
128
  } catch (error) {
106
129
  peer.send({
107
- v: 1,
130
+ v: peer.protocol,
108
131
  type: "reject",
109
132
  txnId: frame.txnId,
110
133
  reason: describe(error),
@@ -113,7 +136,13 @@ export class AppletProtocolServer {
113
136
  }
114
137
 
115
138
  const lastChangeId = this.store.lastChangeId;
116
- peer.send({ v: 1, type: "ack", txnId: frame.txnId, lastChangeId, changes });
139
+ peer.send({
140
+ v: peer.protocol,
141
+ type: "ack",
142
+ txnId: frame.txnId,
143
+ lastChangeId,
144
+ changes,
145
+ });
117
146
  this.broadcast(
118
147
  { v: 1, type: "changes", lastChangeId, txnId: frame.txnId, changes },
119
148
  peer,
@@ -131,17 +160,18 @@ export class AppletProtocolServer {
131
160
  });
132
161
  }
133
162
 
163
+ /** One frame to every synced peer, each in the version its socket speaks. */
134
164
  private broadcast(frame: AppletServerFrameV1, except?: AppletPeer): void {
135
165
  for (const peer of this.options.peers()) {
136
166
  if (peer === except || !peer.synced) continue;
137
167
  try {
138
- peer.send(frame);
168
+ peer.send({ ...frame, v: peer.protocol });
139
169
  } catch (error) {
140
170
  if (!(error instanceof AppletProtocolError)) throw error;
141
171
  // A batch too large for one frame: tell the peer where the log now is
142
172
  // and let it resync from there rather than silently diverge.
143
173
  peer.send({
144
- v: 1,
174
+ v: peer.protocol,
145
175
  type: "changes",
146
176
  lastChangeId: this.store.lastChangeId,
147
177
  changes: [],