replica-bridge 0.3.13 → 0.3.15
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/package.json +1 -1
- package/src/bridge/board.js +230 -39
- package/src/bridge/service.js +1 -1
- package/src/protocol.js +10 -2
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// The optional 任务 board (PROTOCOL.md "看板"): reads AgensWork's agens-backend, which only listens on this computer, for
|
|
2
2
|
// a phone that asked. The bridge does not start the backend or care how it started: it knows a port, and a computer
|
|
3
3
|
// where nothing answers there simply has no board. Reads and writes are short allowlists, and the only background traffic
|
|
4
|
-
// is
|
|
4
|
+
// is the change log (/v1/changes, or its push stream /v1/changes/stream where the backend has one) while a phone is
|
|
5
|
+
// subscribed, because reading the lists counts as the user having seen them. Each subscriber has its own place in the
|
|
6
|
+
// log, so a phone that comes back says where it was and gets only what changed since (0.3.14).
|
|
5
7
|
// The bridge stays thin: it checks the shape of a write and leaves what it means to the backend, whose status and body
|
|
6
8
|
// (REC_NOT_FOUND, REC_STATE_CONFLICT, …) go back to the phone unchanged.
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
import { PACKAGE_VERSION } from "./install.js";
|
|
8
11
|
|
|
9
12
|
/** agens-backend's default port, which its own AGENS_BACKEND_PORT changes. */
|
|
10
13
|
export const DEFAULT_BOARD_PORT = 4753;
|
|
11
|
-
/**
|
|
12
|
-
const DEFAULT_POLL_SECONDS =
|
|
14
|
+
/** How often the log is read when the backend cannot push it; one indexed query on this computer. */
|
|
15
|
+
const DEFAULT_POLL_SECONDS = 1;
|
|
13
16
|
/** Past this the backend is treated as gone, so a hung one cannot hold a phone waiting. */
|
|
14
17
|
const READ_TIMEOUT_MS = 5000;
|
|
15
18
|
/** Well under RELAY_REPLY_LIMIT, since the bridge sends board answers in one frame. */
|
|
@@ -17,6 +20,10 @@ const BODY_MAX_BYTES = 8 * 1024 * 1024;
|
|
|
17
20
|
const CHANGES_PAGE = 100;
|
|
18
21
|
/** Stops a catch-up on a change log that never ends; the backend keeps 30 days of it. */
|
|
19
22
|
const CATCH_UP_PAGES = 1000;
|
|
23
|
+
/** More changed tasks than this in one event, and the phone reads the whole board instead. */
|
|
24
|
+
export const CHANGED_ITEMS_MAX = 200;
|
|
25
|
+
/** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
|
|
26
|
+
export const BOARD_CAPS = ["post", "delta"] ;
|
|
20
27
|
|
|
21
28
|
/** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
|
|
22
29
|
export const POST_BODY_MAX_BYTES = 16 * 1024;
|
|
@@ -80,29 +87,76 @@ export function boardPortFrom(env = process.env) {
|
|
|
80
87
|
|
|
81
88
|
|
|
82
89
|
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
83
94
|
|
|
84
95
|
|
|
96
|
+
/** Three missed heartbeats. */
|
|
97
|
+
const DEFAULT_STREAM_IDLE_SECONDS = 45;
|
|
98
|
+
const DEFAULT_STREAM_RETRY_SECONDS = 30;
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/** One subscriber and how far through the change log it has been told. */
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
|
|
113
|
+
function changedEvent(items ) {
|
|
114
|
+
const latest = new Map ();
|
|
115
|
+
for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
|
|
116
|
+
return latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function changeOf(value ) {
|
|
120
|
+
const item = value ;
|
|
121
|
+
if (!item || !Number.isSafeInteger(item.seq) || typeof item.recId !== "string" || typeof item.kind !== "string") return null;
|
|
122
|
+
return { seq: item.seq , recId: item.recId, kind: item.kind };
|
|
123
|
+
}
|
|
124
|
+
|
|
85
125
|
export class BoardReader {
|
|
86
126
|
port ;
|
|
87
127
|
pollMs ;
|
|
88
|
-
|
|
128
|
+
subs = new Set ();
|
|
89
129
|
timer = null;
|
|
90
130
|
polling = false;
|
|
91
|
-
/** Where the change log was read to; null until a
|
|
92
|
-
|
|
131
|
+
/** Where the change log was read to; null until a read has found its end. Kept while the backend is away. */
|
|
132
|
+
end = null;
|
|
93
133
|
up = null;
|
|
94
|
-
/** Bumped when polling stops, so a
|
|
134
|
+
/** Bumped when polling stops, so a read still on its way does not speak for the next round. */
|
|
95
135
|
round = 0;
|
|
136
|
+
/** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
|
|
137
|
+
stream = null;
|
|
138
|
+
streamSupported = null;
|
|
139
|
+
/** Not before this time (ms) after the backend refused a stream for a passing reason. */
|
|
140
|
+
streamRetryAt = 0;
|
|
141
|
+
streamIdleMs ;
|
|
142
|
+
streamRetryMs ;
|
|
96
143
|
|
|
97
144
|
constructor(options = {}) {
|
|
98
145
|
this.port = options.port ?? boardPortFrom();
|
|
99
146
|
this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
|
|
147
|
+
this.streamIdleMs = (options.streamIdleSeconds ?? DEFAULT_STREAM_IDLE_SECONDS) * 1000;
|
|
148
|
+
this.streamRetryMs = (options.streamRetrySeconds ?? DEFAULT_STREAM_RETRY_SECONDS) * 1000;
|
|
100
149
|
}
|
|
101
150
|
|
|
151
|
+
/** How many phones are subscribed. */
|
|
152
|
+
get listeners() { return this.subs; }
|
|
153
|
+
/** Whether the backend is pushing changes now, rather than being polled. */
|
|
154
|
+
get streaming() { return this.stream !== null; }
|
|
155
|
+
|
|
102
156
|
/** The fetch adds the Host header the backend insists on (127.0.0.1:<port>) and no Origin, as a local caller. */
|
|
103
|
-
fetch(pathAndQuery , post ) {
|
|
157
|
+
fetch(pathAndQuery , post , signal = AbortSignal.timeout(READ_TIMEOUT_MS)) {
|
|
104
158
|
return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, {
|
|
105
|
-
redirect: "error", signal
|
|
159
|
+
redirect: "error", signal,
|
|
106
160
|
...(post === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: post }),
|
|
107
161
|
});
|
|
108
162
|
}
|
|
@@ -130,72 +184,209 @@ export class BoardReader {
|
|
|
130
184
|
}
|
|
131
185
|
}
|
|
132
186
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Tells `listener` about the board until the returned function is called. Its first board/state carries `seq`, the
|
|
189
|
+
* point in the change log it starts from; with `after` (where the phone was) it then gets what changed since, else it
|
|
190
|
+
* reads the whole board. The log is read only while someone listens.
|
|
191
|
+
*/
|
|
192
|
+
subscribe(listener , after ) {
|
|
193
|
+
const sub = { listener, wanted: Number.isSafeInteger(after) && after >= 0 ? after : null, cursor: null, admitting: false };
|
|
194
|
+
this.subs.add(sub);
|
|
195
|
+
if (this.up === false) listener(this.state(false, null));
|
|
137
196
|
if (!this.timer) {
|
|
138
197
|
this.timer = setInterval(() => void this.poll(), this.pollMs);
|
|
139
198
|
this.timer.unref();
|
|
140
199
|
void this.poll();
|
|
141
|
-
}
|
|
200
|
+
} else if (this.up && this.end !== null) void this.admit(sub);
|
|
142
201
|
return () => {
|
|
143
|
-
this.
|
|
144
|
-
if (!this.
|
|
202
|
+
this.subs.delete(sub);
|
|
203
|
+
if (!this.subs.size) this.stop();
|
|
145
204
|
};
|
|
146
205
|
}
|
|
147
206
|
|
|
148
207
|
stop() {
|
|
149
|
-
this.
|
|
208
|
+
this.subs.clear();
|
|
150
209
|
if (this.timer) clearInterval(this.timer);
|
|
151
210
|
this.timer = null;
|
|
152
|
-
this.
|
|
211
|
+
this.stream?.abort();
|
|
212
|
+
this.stream = null;
|
|
213
|
+
this.streamSupported = null;
|
|
214
|
+
this.streamRetryAt = 0;
|
|
215
|
+
this.end = null;
|
|
153
216
|
this.up = null;
|
|
154
217
|
this.polling = false;
|
|
155
218
|
this.round++;
|
|
156
219
|
}
|
|
157
220
|
|
|
158
|
-
|
|
159
|
-
|
|
221
|
+
state(up , seq ) {
|
|
222
|
+
return up && seq !== null ? { t: "board/state", up, seq, caps: [...BOARD_CAPS], bridge: PACKAGE_VERSION } : { t: "board/state", up };
|
|
160
223
|
}
|
|
161
224
|
|
|
162
|
-
async changes(after )
|
|
225
|
+
async changes(after ) {
|
|
163
226
|
const response = await this.fetch(`/v1/changes?after=${after}&limit=${CHANGES_PAGE}`);
|
|
164
227
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
165
228
|
const page = await response.json() ;
|
|
166
229
|
if (!Array.isArray(page.items)) throw new Error("not a page of changes");
|
|
167
|
-
|
|
230
|
+
const items = page.items.map(changeOf).filter((item) => item !== null);
|
|
231
|
+
return { items, next: typeof page.next === "number" ? page.next : after };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Brings a new subscriber to the end of the log: its board/state, then what it missed, or a reset. */
|
|
235
|
+
async admit(sub ) {
|
|
236
|
+
if (sub.admitting || sub.cursor !== null || this.end === null) return;
|
|
237
|
+
sub.admitting = true;
|
|
238
|
+
const round = this.round;
|
|
239
|
+
const missed = [];
|
|
240
|
+
let reset = false, cursor = sub.wanted;
|
|
241
|
+
try {
|
|
242
|
+
if (cursor !== null && cursor > this.end) { reset = true; cursor = this.end; } // a log that went backwards: another backend, or a new database
|
|
243
|
+
// Read until this subscriber is at least where the shared reader is, so nothing falls between the two.
|
|
244
|
+
for (let page = 0; !reset && cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
|
|
245
|
+
const { items, next } = await this.changes(cursor);
|
|
246
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
247
|
+
// The log keeps 30 days: a first change past the next number means some were deleted unseen.
|
|
248
|
+
if (page === 0 && items.length && items[0].seq > cursor + 1) reset = true;
|
|
249
|
+
missed.push(...items);
|
|
250
|
+
if (missed.length > CHANGED_ITEMS_MAX * 4) reset = true;
|
|
251
|
+
if (next === cursor && items.length === 0) break;
|
|
252
|
+
cursor = next;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
sub.admitting = false;
|
|
256
|
+
// Try again shortly. Polling would admit it on its next read, but while the backend pushes nothing polls.
|
|
257
|
+
const retry = setTimeout(() => { if (round === this.round && this.subs.has(sub)) void this.admit(sub); }, this.pollMs);
|
|
258
|
+
retry.unref();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
262
|
+
sub.admitting = false;
|
|
263
|
+
sub.cursor = Math.max(cursor ?? this.end , this.end );
|
|
264
|
+
sub.listener(this.state(true, sub.cursor));
|
|
265
|
+
if (reset) sub.listener({ t: "board/changed", reset: true });
|
|
266
|
+
else if (missed.length) sub.listener(changedEvent(missed));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** New changes up to `end`: each subscriber gets those past its own place. */
|
|
270
|
+
deliver(items , end ) {
|
|
271
|
+
for (const sub of this.subs) {
|
|
272
|
+
if (sub.cursor === null) continue;
|
|
273
|
+
const fresh = items.filter((item) => item.seq > sub.cursor );
|
|
274
|
+
sub.cursor = Math.max(sub.cursor, end);
|
|
275
|
+
if (fresh.length) sub.listener(changedEvent(fresh));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setUp(up ) {
|
|
280
|
+
if (up === this.up) return;
|
|
281
|
+
this.up = up;
|
|
282
|
+
for (const sub of this.subs) {
|
|
283
|
+
if (!up) sub.listener(this.state(false, null));
|
|
284
|
+
else if (sub.cursor !== null) sub.listener(this.state(true, sub.cursor));
|
|
285
|
+
}
|
|
168
286
|
}
|
|
169
287
|
|
|
170
288
|
async poll() {
|
|
171
|
-
if (this.polling) return;
|
|
289
|
+
if (this.polling || this.stream) return;
|
|
172
290
|
this.polling = true;
|
|
173
291
|
const round = this.round;
|
|
174
|
-
let
|
|
292
|
+
let items = [], up = true, end = this.end;
|
|
175
293
|
try {
|
|
176
|
-
// The first
|
|
177
|
-
if (
|
|
178
|
-
|
|
294
|
+
// The first read only finds the end of the log: what happened before someone was listening is not news.
|
|
295
|
+
if (end === null) {
|
|
296
|
+
end = 0;
|
|
179
297
|
for (let page = 0; page < CATCH_UP_PAGES; page++) {
|
|
180
|
-
const { items, next } = await this.changes(
|
|
181
|
-
|
|
182
|
-
if (
|
|
298
|
+
const { items: found, next } = await this.changes(end);
|
|
299
|
+
end = next;
|
|
300
|
+
if (found.length < CHANGES_PAGE) break;
|
|
183
301
|
}
|
|
184
302
|
} else {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
303
|
+
const page = await this.changes(end);
|
|
304
|
+
items = page.items;
|
|
305
|
+
end = page.next;
|
|
188
306
|
}
|
|
189
307
|
} catch {
|
|
190
308
|
up = false;
|
|
191
309
|
}
|
|
192
310
|
if (round !== this.round) return;
|
|
193
311
|
this.polling = false;
|
|
194
|
-
if (up) this.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
312
|
+
if (up) this.end = end;
|
|
313
|
+
this.setUp(up);
|
|
314
|
+
if (!up) return;
|
|
315
|
+
if (items.length) this.deliver(items, end );
|
|
316
|
+
for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
|
|
317
|
+
if (this.streamSupported !== false && Date.now() >= this.streamRetryAt) void this.openStream(round);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Listens to the backend's push stream from where the log was read to. A backend without one answers 404 (or not as
|
|
322
|
+
* an event stream), and the bridge keeps polling; a stream that ends hands back to polling, which reopens it.
|
|
323
|
+
*/
|
|
324
|
+
async openStream(round ) {
|
|
325
|
+
if (this.stream || this.end === null) return;
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
this.stream = controller;
|
|
328
|
+
// A backend that takes the connection and then says nothing must not hold the bridge: while a stream is open
|
|
329
|
+
// nothing polls, so silence would also hide the backend going away. Its heartbeat keeps this from firing.
|
|
330
|
+
let idle = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
|
|
331
|
+
idle.unref();
|
|
332
|
+
const heard = () => {
|
|
333
|
+
clearTimeout(idle);
|
|
334
|
+
idle = setTimeout(() => controller.abort(), this.streamIdleMs);
|
|
335
|
+
idle.unref();
|
|
336
|
+
};
|
|
337
|
+
let response ;
|
|
338
|
+
try {
|
|
339
|
+
response = await this.fetch(`/v1/changes/stream?after=${this.end}`, undefined, controller.signal);
|
|
340
|
+
} catch {
|
|
341
|
+
clearTimeout(idle);
|
|
342
|
+
if (this.stream === controller) this.stream = null;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (round !== this.round || this.stream !== controller) { clearTimeout(idle); controller.abort(); return; }
|
|
346
|
+
const eventStream = (response.headers.get("content-type") ?? "").startsWith("text/event-stream");
|
|
347
|
+
if (!response.ok || !eventStream || !response.body) {
|
|
348
|
+
clearTimeout(idle);
|
|
349
|
+
// Only an older backend lacks it (404, or a 200 that is not an event stream); busy (429) or starting (5xx) passes.
|
|
350
|
+
if (response.status === 404 || (response.ok && !eventStream)) this.streamSupported = false;
|
|
351
|
+
else this.streamRetryAt = Date.now() + this.streamRetryMs;
|
|
352
|
+
this.stream = null;
|
|
353
|
+
controller.abort();
|
|
354
|
+
return;
|
|
198
355
|
}
|
|
199
|
-
|
|
356
|
+
this.streamSupported = true;
|
|
357
|
+
heard();
|
|
358
|
+
let buffer = "", pending = [], flush = null;
|
|
359
|
+
const send = () => {
|
|
360
|
+
flush = null;
|
|
361
|
+
if (round !== this.round || this.stream !== controller) return;
|
|
362
|
+
const batch = pending;
|
|
363
|
+
pending = [];
|
|
364
|
+
const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
|
|
365
|
+
this.end = end;
|
|
366
|
+
this.deliver(batch, end);
|
|
367
|
+
};
|
|
368
|
+
try {
|
|
369
|
+
const decoder = new TextDecoder();
|
|
370
|
+
for await (const chunk of response.body ) {
|
|
371
|
+
heard();
|
|
372
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
373
|
+
let split ;
|
|
374
|
+
while ((split = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
375
|
+
const event = buffer.slice(0, split);
|
|
376
|
+
buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
|
|
377
|
+
const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
378
|
+
if (!data) continue; // a heartbeat
|
|
379
|
+
let item = null;
|
|
380
|
+
try { item = changeOf(JSON.parse(data)); } catch { /* not a change */ }
|
|
381
|
+
if (!item || item.seq <= (this.end ?? 0)) continue;
|
|
382
|
+
pending.push(item);
|
|
383
|
+
// Changes that land together (a maintenance round) go to the phone as one event.
|
|
384
|
+
flush ??= setTimeout(send, 20);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch { /* aborted, or the backend went away */ }
|
|
388
|
+
clearTimeout(idle);
|
|
389
|
+
if (flush) { clearTimeout(flush); send(); }
|
|
390
|
+
if (this.stream === controller) this.stream = null;
|
|
200
391
|
}
|
|
201
392
|
}
|
package/src/bridge/service.js
CHANGED
|
@@ -272,7 +272,7 @@ export class BridgeService {
|
|
|
272
272
|
} else if (message.t === "board/post") {
|
|
273
273
|
void this.board.post(message.path, message.body).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
|
|
274
274
|
} else if (message.t === "board/sub") {
|
|
275
|
-
conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event));
|
|
275
|
+
conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event), message.after);
|
|
276
276
|
} else if (message.t === "board/unsub") {
|
|
277
277
|
conn.boardUnsubscribe?.();
|
|
278
278
|
conn.boardUnsubscribe = null;
|
package/src/protocol.js
CHANGED
|
@@ -241,13 +241,21 @@ export class FrameCipher {
|
|
|
241
241
|
|
|
242
242
|
|
|
243
243
|
|
|
244
|
-
|
|
244
|
+
|
|
245
245
|
|
|
246
246
|
|
|
247
247
|
|
|
248
248
|
/** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
/** One task that changed, from agens-backend's change log (0.3.14). */
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What a subscriber hears. From 0.3.14 a board/state that says up also gives `seq` (where in the change log the phone
|
|
254
|
+
* starts), `caps` and the bridge's version, and board/changed names the tasks, or says `reset` when there are too many.
|
|
255
|
+
*/
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
251
259
|
|
|
252
260
|
|
|
253
261
|
/** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
|