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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 one poll of /v1/changes while a phone is subscribed, because reading the lists counts as the user having seen them.
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
- /** agens-backend's own page polls every 10 s. */
12
- const DEFAULT_POLL_SECONDS = 10;
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
- listeners = new Set ();
128
+ subs = new Set ();
89
129
  timer = null;
90
130
  polling = false;
91
- /** Where the change log was read to; null until a poll has found its end. */
92
- after = null;
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 poll still on its way does not speak for the next round. */
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: AbortSignal.timeout(READ_TIMEOUT_MS),
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
- /** Tells `listener` about the board until the returned function is called. Polls only while someone listens. */
134
- subscribe(listener ) {
135
- this.listeners.add(listener);
136
- if (this.up !== null) listener({ t: "board/state", up: this.up });
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.listeners.delete(listener);
144
- if (!this.listeners.size) this.stop();
202
+ this.subs.delete(sub);
203
+ if (!this.subs.size) this.stop();
145
204
  };
146
205
  }
147
206
 
148
207
  stop() {
149
- this.listeners.clear();
208
+ this.subs.clear();
150
209
  if (this.timer) clearInterval(this.timer);
151
210
  this.timer = null;
152
- this.after = null;
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
- emit(event ) {
159
- for (const listener of this.listeners) listener(event);
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
- return { items: page.items, next: typeof page.next === "number" ? page.next : after };
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 changed = false, up = true, after = this.after;
292
+ let items = [], up = true, end = this.end;
175
293
  try {
176
- // The first poll only finds the end of the log: what happened before someone was listening is not news.
177
- if (after === null) {
178
- after = 0;
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(after);
181
- after = next;
182
- if (items.length < CHANGES_PAGE) break;
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 { items, next } = await this.changes(after);
186
- after = next;
187
- changed = items.length > 0;
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.after = after;
195
- if (up !== this.up) {
196
- this.up = up;
197
- this.emit({ t: "board/state", up });
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
- if (changed) this.emit({ t: "board/changed" });
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
  }
@@ -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. */