@frockbot/applet-sdk 0.7.19 → 0.7.21
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 -12
- package/package.json +1 -1
- package/src/client/index.ts +39 -5
- package/src/client/transport.ts +121 -22
- package/src/protocol/index.ts +114 -32
- package/src/server/applet.ts +11 -1
- package/src/server/session.ts +42 -12
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| server → client | `
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
| server → client | `
|
|
67
|
-
|
|
|
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.
|
|
3
|
+
"version": "0.7.21",
|
|
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.",
|
package/src/client/index.ts
CHANGED
|
@@ -87,8 +87,13 @@ export interface CreateAppletOptions extends AppletTransportOptions {
|
|
|
87
87
|
autoConnect?: boolean;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/**
|
|
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
|
|
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`
|
|
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(
|
|
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.
|
|
215
|
+
if (init.type === "refresh") transport.refresh(init.applet);
|
|
216
|
+
else transport.connect(init.applet);
|
|
183
217
|
});
|
|
184
218
|
window.parent.postMessage(
|
|
185
219
|
{
|
package/src/client/transport.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.#
|
|
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({
|
|
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)));
|
package/src/protocol/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Applet wire protocol
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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:
|
|
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:
|
|
105
|
+
v: AppletProtocolVersion;
|
|
59
106
|
type: "snapshot";
|
|
60
107
|
lastChangeId: number;
|
|
61
|
-
tables:
|
|
108
|
+
tables: AppletSnapshotTablesV1;
|
|
62
109
|
}
|
|
63
110
|
| {
|
|
64
|
-
v:
|
|
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:
|
|
118
|
+
v: AppletProtocolVersion;
|
|
72
119
|
type: "ack";
|
|
73
120
|
txnId: string;
|
|
74
121
|
lastChangeId: number;
|
|
75
122
|
changes: AppletChangeV1[];
|
|
76
123
|
}
|
|
77
|
-
| {
|
|
124
|
+
| {
|
|
125
|
+
v: AppletProtocolVersion;
|
|
126
|
+
type: "reject";
|
|
127
|
+
txnId: string;
|
|
128
|
+
reason: string;
|
|
129
|
+
};
|
|
78
130
|
|
|
79
131
|
export type AppletClientFrameV1 =
|
|
80
|
-
| {
|
|
81
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
434
|
+
v: versionOf(value),
|
|
353
435
|
type: "snapshot",
|
|
354
436
|
lastChangeId: cursor(value.lastChangeId, "Applet snapshot.lastChangeId"),
|
|
355
|
-
tables:
|
|
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:
|
|
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:
|
|
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:
|
|
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),
|
package/src/server/applet.ts
CHANGED
|
@@ -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
|
},
|
package/src/server/session.ts
CHANGED
|
@@ -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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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({
|
|
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:
|
|
174
|
+
v: peer.protocol,
|
|
145
175
|
type: "changes",
|
|
146
176
|
lastChangeId: this.store.lastChangeId,
|
|
147
177
|
changes: [],
|