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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
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;
@@ -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
- listeners = new Set ();
120
+ subs = new Set ();
89
121
  timer = null;
90
122
  polling = false;
91
- /** Where the change log was read to; null until a poll has found its end. */
92
- after = null;
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 poll still on its way does not speak for the next round. */
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: AbortSignal.timeout(READ_TIMEOUT_MS),
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
- /** 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 });
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.listeners.delete(listener);
144
- if (!this.listeners.size) this.stop();
188
+ this.subs.delete(sub);
189
+ if (!this.subs.size) this.stop();
145
190
  };
146
191
  }
147
192
 
148
193
  stop() {
149
- this.listeners.clear();
194
+ this.subs.clear();
150
195
  if (this.timer) clearInterval(this.timer);
151
196
  this.timer = null;
152
- this.after = null;
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
- emit(event ) {
159
- for (const listener of this.listeners) listener(event);
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
- return { items: page.items, next: typeof page.next === "number" ? page.next : after };
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 changed = false, up = true, after = this.after;
274
+ let items = [], up = true, end = this.end;
175
275
  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;
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(after);
181
- after = next;
182
- if (items.length < CHANGES_PAGE) break;
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 { items, next } = await this.changes(after);
186
- after = next;
187
- changed = items.length > 0;
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.after = after;
195
- if (up !== this.up) {
196
- this.up = up;
197
- this.emit({ t: "board/state", up });
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 (changed) this.emit({ t: "board/changed" });
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
  }
@@ -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. */