replica-bridge 0.3.13 → 0.3.14
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 +195 -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;
|
|
@@ -82,27 +89,60 @@ export function boardPortFrom(env = process.env) {
|
|
|
82
89
|
|
|
83
90
|
|
|
84
91
|
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/** One subscriber and how far through the change log it has been told. */
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
/** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
|
|
105
|
+
function changedEvent(items ) {
|
|
106
|
+
const latest = new Map ();
|
|
107
|
+
for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
|
|
108
|
+
return latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function changeOf(value ) {
|
|
112
|
+
const item = value ;
|
|
113
|
+
if (!item || !Number.isSafeInteger(item.seq) || typeof item.recId !== "string" || typeof item.kind !== "string") return null;
|
|
114
|
+
return { seq: item.seq , recId: item.recId, kind: item.kind };
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
export class BoardReader {
|
|
86
118
|
port ;
|
|
87
119
|
pollMs ;
|
|
88
|
-
|
|
120
|
+
subs = new Set ();
|
|
89
121
|
timer = null;
|
|
90
122
|
polling = false;
|
|
91
|
-
/** Where the change log was read to; null until a
|
|
92
|
-
|
|
123
|
+
/** Where the change log was read to; null until a read has found its end. Kept while the backend is away. */
|
|
124
|
+
end = null;
|
|
93
125
|
up = null;
|
|
94
|
-
/** Bumped when polling stops, so a
|
|
126
|
+
/** Bumped when polling stops, so a read still on its way does not speak for the next round. */
|
|
95
127
|
round = 0;
|
|
128
|
+
/** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
|
|
129
|
+
stream = null;
|
|
130
|
+
streamSupported = null;
|
|
96
131
|
|
|
97
132
|
constructor(options = {}) {
|
|
98
133
|
this.port = options.port ?? boardPortFrom();
|
|
99
134
|
this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
|
|
100
135
|
}
|
|
101
136
|
|
|
137
|
+
/** How many phones are subscribed. */
|
|
138
|
+
get listeners() { return this.subs; }
|
|
139
|
+
/** Whether the backend is pushing changes now, rather than being polled. */
|
|
140
|
+
get streaming() { return this.stream !== null; }
|
|
141
|
+
|
|
102
142
|
/** 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 ) {
|
|
143
|
+
fetch(pathAndQuery , post , signal = AbortSignal.timeout(READ_TIMEOUT_MS)) {
|
|
104
144
|
return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, {
|
|
105
|
-
redirect: "error", signal
|
|
145
|
+
redirect: "error", signal,
|
|
106
146
|
...(post === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: post }),
|
|
107
147
|
});
|
|
108
148
|
}
|
|
@@ -130,72 +170,188 @@ export class BoardReader {
|
|
|
130
170
|
}
|
|
131
171
|
}
|
|
132
172
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Tells `listener` about the board until the returned function is called. Its first board/state carries `seq`, the
|
|
175
|
+
* point in the change log it starts from; with `after` (where the phone was) it then gets what changed since, else it
|
|
176
|
+
* reads the whole board. The log is read only while someone listens.
|
|
177
|
+
*/
|
|
178
|
+
subscribe(listener , after ) {
|
|
179
|
+
const sub = { listener, wanted: Number.isSafeInteger(after) && after >= 0 ? after : null, cursor: null, admitting: false };
|
|
180
|
+
this.subs.add(sub);
|
|
181
|
+
if (this.up === false) listener(this.state(false, null));
|
|
137
182
|
if (!this.timer) {
|
|
138
183
|
this.timer = setInterval(() => void this.poll(), this.pollMs);
|
|
139
184
|
this.timer.unref();
|
|
140
185
|
void this.poll();
|
|
141
|
-
}
|
|
186
|
+
} else if (this.up && this.end !== null) void this.admit(sub);
|
|
142
187
|
return () => {
|
|
143
|
-
this.
|
|
144
|
-
if (!this.
|
|
188
|
+
this.subs.delete(sub);
|
|
189
|
+
if (!this.subs.size) this.stop();
|
|
145
190
|
};
|
|
146
191
|
}
|
|
147
192
|
|
|
148
193
|
stop() {
|
|
149
|
-
this.
|
|
194
|
+
this.subs.clear();
|
|
150
195
|
if (this.timer) clearInterval(this.timer);
|
|
151
196
|
this.timer = null;
|
|
152
|
-
this.
|
|
197
|
+
this.stream?.abort();
|
|
198
|
+
this.stream = null;
|
|
199
|
+
this.streamSupported = null;
|
|
200
|
+
this.end = null;
|
|
153
201
|
this.up = null;
|
|
154
202
|
this.polling = false;
|
|
155
203
|
this.round++;
|
|
156
204
|
}
|
|
157
205
|
|
|
158
|
-
|
|
159
|
-
|
|
206
|
+
state(up , seq ) {
|
|
207
|
+
return up && seq !== null ? { t: "board/state", up, seq, caps: [...BOARD_CAPS], bridge: PACKAGE_VERSION } : { t: "board/state", up };
|
|
160
208
|
}
|
|
161
209
|
|
|
162
|
-
async changes(after )
|
|
210
|
+
async changes(after ) {
|
|
163
211
|
const response = await this.fetch(`/v1/changes?after=${after}&limit=${CHANGES_PAGE}`);
|
|
164
212
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
165
213
|
const page = await response.json() ;
|
|
166
214
|
if (!Array.isArray(page.items)) throw new Error("not a page of changes");
|
|
167
|
-
|
|
215
|
+
const items = page.items.map(changeOf).filter((item) => item !== null);
|
|
216
|
+
return { items, next: typeof page.next === "number" ? page.next : after };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Brings a new subscriber to the end of the log: its board/state, then what it missed, or a reset. */
|
|
220
|
+
async admit(sub ) {
|
|
221
|
+
if (sub.admitting || sub.cursor !== null || this.end === null) return;
|
|
222
|
+
sub.admitting = true;
|
|
223
|
+
const round = this.round;
|
|
224
|
+
const missed = [];
|
|
225
|
+
let reset = false, cursor = sub.wanted;
|
|
226
|
+
try {
|
|
227
|
+
if (cursor !== null && cursor > this.end) { reset = true; cursor = this.end; } // a log that went backwards: another backend, or a new database
|
|
228
|
+
// Read until this subscriber is at least where the shared reader is, so nothing falls between the two.
|
|
229
|
+
for (let page = 0; !reset && cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
|
|
230
|
+
const { items, next } = await this.changes(cursor);
|
|
231
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
232
|
+
// The log keeps 30 days: a first change past the next number means some were deleted unseen.
|
|
233
|
+
if (page === 0 && items.length && items[0].seq > cursor + 1) reset = true;
|
|
234
|
+
missed.push(...items);
|
|
235
|
+
if (missed.length > CHANGED_ITEMS_MAX * 4) reset = true;
|
|
236
|
+
if (next === cursor && items.length === 0) break;
|
|
237
|
+
cursor = next;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
sub.admitting = false; // the next read of the log tries again
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
244
|
+
sub.admitting = false;
|
|
245
|
+
sub.cursor = Math.max(cursor ?? this.end , this.end );
|
|
246
|
+
sub.listener(this.state(true, sub.cursor));
|
|
247
|
+
if (reset) sub.listener({ t: "board/changed", reset: true });
|
|
248
|
+
else if (missed.length) sub.listener(changedEvent(missed));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** New changes up to `end`: each subscriber gets those past its own place. */
|
|
252
|
+
deliver(items , end ) {
|
|
253
|
+
for (const sub of this.subs) {
|
|
254
|
+
if (sub.cursor === null) continue;
|
|
255
|
+
const fresh = items.filter((item) => item.seq > sub.cursor );
|
|
256
|
+
sub.cursor = Math.max(sub.cursor, end);
|
|
257
|
+
if (fresh.length) sub.listener(changedEvent(fresh));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
setUp(up ) {
|
|
262
|
+
if (up === this.up) return;
|
|
263
|
+
this.up = up;
|
|
264
|
+
for (const sub of this.subs) {
|
|
265
|
+
if (!up) sub.listener(this.state(false, null));
|
|
266
|
+
else if (sub.cursor !== null) sub.listener(this.state(true, sub.cursor));
|
|
267
|
+
}
|
|
168
268
|
}
|
|
169
269
|
|
|
170
270
|
async poll() {
|
|
171
|
-
if (this.polling) return;
|
|
271
|
+
if (this.polling || this.stream) return;
|
|
172
272
|
this.polling = true;
|
|
173
273
|
const round = this.round;
|
|
174
|
-
let
|
|
274
|
+
let items = [], up = true, end = this.end;
|
|
175
275
|
try {
|
|
176
|
-
// The first
|
|
177
|
-
if (
|
|
178
|
-
|
|
276
|
+
// The first read only finds the end of the log: what happened before someone was listening is not news.
|
|
277
|
+
if (end === null) {
|
|
278
|
+
end = 0;
|
|
179
279
|
for (let page = 0; page < CATCH_UP_PAGES; page++) {
|
|
180
|
-
const { items, next } = await this.changes(
|
|
181
|
-
|
|
182
|
-
if (
|
|
280
|
+
const { items: found, next } = await this.changes(end);
|
|
281
|
+
end = next;
|
|
282
|
+
if (found.length < CHANGES_PAGE) break;
|
|
183
283
|
}
|
|
184
284
|
} else {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
285
|
+
const page = await this.changes(end);
|
|
286
|
+
items = page.items;
|
|
287
|
+
end = page.next;
|
|
188
288
|
}
|
|
189
289
|
} catch {
|
|
190
290
|
up = false;
|
|
191
291
|
}
|
|
192
292
|
if (round !== this.round) return;
|
|
193
293
|
this.polling = false;
|
|
194
|
-
if (up) this.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
294
|
+
if (up) this.end = end;
|
|
295
|
+
this.setUp(up);
|
|
296
|
+
if (!up) return;
|
|
297
|
+
if (items.length) this.deliver(items, end );
|
|
298
|
+
for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
|
|
299
|
+
if (this.streamSupported !== false) void this.openStream(round);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Listens to the backend's push stream from where the log was read to. A backend without one answers 404 (or not as
|
|
304
|
+
* an event stream), and the bridge keeps polling; a stream that ends hands back to polling, which reopens it.
|
|
305
|
+
*/
|
|
306
|
+
async openStream(round ) {
|
|
307
|
+
if (this.stream || this.end === null) return;
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
this.stream = controller;
|
|
310
|
+
let response ;
|
|
311
|
+
try {
|
|
312
|
+
response = await this.fetch(`/v1/changes/stream?after=${this.end}`, undefined, controller.signal);
|
|
313
|
+
} catch {
|
|
314
|
+
if (this.stream === controller) this.stream = null;
|
|
315
|
+
return;
|
|
198
316
|
}
|
|
199
|
-
if (
|
|
317
|
+
if (round !== this.round || this.stream !== controller) { controller.abort(); return; }
|
|
318
|
+
if (!response.ok || !(response.headers.get("content-type") ?? "").startsWith("text/event-stream") || !response.body) {
|
|
319
|
+
this.streamSupported = false;
|
|
320
|
+
this.stream = null;
|
|
321
|
+
controller.abort();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.streamSupported = true;
|
|
325
|
+
let buffer = "", pending = [], flush = null;
|
|
326
|
+
const send = () => {
|
|
327
|
+
flush = null;
|
|
328
|
+
if (round !== this.round || this.stream !== controller) return;
|
|
329
|
+
const batch = pending;
|
|
330
|
+
pending = [];
|
|
331
|
+
const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
|
|
332
|
+
this.end = end;
|
|
333
|
+
this.deliver(batch, end);
|
|
334
|
+
};
|
|
335
|
+
try {
|
|
336
|
+
const decoder = new TextDecoder();
|
|
337
|
+
for await (const chunk of response.body ) {
|
|
338
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
339
|
+
let split ;
|
|
340
|
+
while ((split = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
341
|
+
const event = buffer.slice(0, split);
|
|
342
|
+
buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
|
|
343
|
+
const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
344
|
+
if (!data) continue; // a heartbeat
|
|
345
|
+
let item = null;
|
|
346
|
+
try { item = changeOf(JSON.parse(data)); } catch { /* not a change */ }
|
|
347
|
+
if (!item || item.seq <= (this.end ?? 0)) continue;
|
|
348
|
+
pending.push(item);
|
|
349
|
+
// Changes that land together (a maintenance round) go to the phone as one event.
|
|
350
|
+
flush ??= setTimeout(send, 20);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch { /* aborted, or the backend went away */ }
|
|
354
|
+
if (flush) { clearTimeout(flush); send(); }
|
|
355
|
+
if (this.stream === controller) this.stream = null;
|
|
200
356
|
}
|
|
201
357
|
}
|
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. */
|