replica-bridge 0.3.14 → 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.14",
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": {
@@ -87,8 +87,16 @@ export function boardPortFrom(env = process.env) {
87
87
 
88
88
 
89
89
 
90
+
91
+
92
+
93
+
90
94
 
91
95
 
96
+ /** Three missed heartbeats. */
97
+ const DEFAULT_STREAM_IDLE_SECONDS = 45;
98
+ const DEFAULT_STREAM_RETRY_SECONDS = 30;
99
+
92
100
 
93
101
 
94
102
  /** One subscriber and how far through the change log it has been told. */
@@ -128,10 +136,16 @@ export class BoardReader {
128
136
  /** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
129
137
  stream = null;
130
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 ;
131
143
 
132
144
  constructor(options = {}) {
133
145
  this.port = options.port ?? boardPortFrom();
134
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;
135
149
  }
136
150
 
137
151
  /** How many phones are subscribed. */
@@ -197,6 +211,7 @@ export class BoardReader {
197
211
  this.stream?.abort();
198
212
  this.stream = null;
199
213
  this.streamSupported = null;
214
+ this.streamRetryAt = 0;
200
215
  this.end = null;
201
216
  this.up = null;
202
217
  this.polling = false;
@@ -237,7 +252,10 @@ export class BoardReader {
237
252
  cursor = next;
238
253
  }
239
254
  } catch {
240
- sub.admitting = false; // the next read of the log tries again
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();
241
259
  return;
242
260
  }
243
261
  if (round !== this.round || !this.subs.has(sub)) return;
@@ -296,7 +314,7 @@ export class BoardReader {
296
314
  if (!up) return;
297
315
  if (items.length) this.deliver(items, end );
298
316
  for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
299
- if (this.streamSupported !== false) void this.openStream(round);
317
+ if (this.streamSupported !== false && Date.now() >= this.streamRetryAt) void this.openStream(round);
300
318
  }
301
319
 
302
320
  /**
@@ -307,21 +325,36 @@ export class BoardReader {
307
325
  if (this.stream || this.end === null) return;
308
326
  const controller = new AbortController();
309
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
+ };
310
337
  let response ;
311
338
  try {
312
339
  response = await this.fetch(`/v1/changes/stream?after=${this.end}`, undefined, controller.signal);
313
340
  } catch {
341
+ clearTimeout(idle);
314
342
  if (this.stream === controller) this.stream = null;
315
343
  return;
316
344
  }
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;
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;
320
352
  this.stream = null;
321
353
  controller.abort();
322
354
  return;
323
355
  }
324
356
  this.streamSupported = true;
357
+ heard();
325
358
  let buffer = "", pending = [], flush = null;
326
359
  const send = () => {
327
360
  flush = null;
@@ -335,6 +368,7 @@ export class BoardReader {
335
368
  try {
336
369
  const decoder = new TextDecoder();
337
370
  for await (const chunk of response.body ) {
371
+ heard();
338
372
  buffer += decoder.decode(chunk, { stream: true });
339
373
  let split ;
340
374
  while ((split = buffer.search(/\r?\n\r?\n/)) >= 0) {
@@ -351,6 +385,7 @@ export class BoardReader {
351
385
  }
352
386
  }
353
387
  } catch { /* aborted, or the backend went away */ }
388
+ clearTimeout(idle);
354
389
  if (flush) { clearTimeout(flush); send(); }
355
390
  if (this.stream === controller) this.stream = null;
356
391
  }