replica-bridge 0.3.4 → 0.3.6

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.4",
3
+ "version": "0.3.6",
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": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "qrcode": "1.5.4",
14
- "ws": "8.18.3"
14
+ "ws": "8.22.0"
15
15
  },
16
16
  "license": "UNLICENSED"
17
17
  }
@@ -22,6 +22,8 @@ export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(i
22
22
 
23
23
 
24
24
 
25
+
26
+
25
27
 
26
28
 
27
29
 
@@ -62,6 +64,7 @@ export function servicePlan(spec ) {
62
64
  <array>
63
65
  ${spec.program.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
64
66
  </array>
67
+ <key>WorkingDirectory</key><string>${xml(spec.home)}</string>
65
68
  <key>EnvironmentVariables</key>
66
69
  <dict>
67
70
  ${env}
@@ -110,6 +113,10 @@ WantedBy=default.target
110
113
  { command: "systemctl", args: ["--user", "daemon-reload"] },
111
114
  { command: "systemctl", args: ["--user", "enable", unit] },
112
115
  { command: "systemctl", args: ["--user", "restart", unit] },
116
+ // A user service starts with the user's session; lingering starts it at boot, before anyone logs in, so a
117
+ // computer that restarts unattended (a server, a Mac mini in a cupboard) comes back. Allowed for oneself
118
+ // on most distributions; where it is not, main.ts says how.
119
+ ...(spec.user ? [{ command: "loginctl", args: ["enable-linger", spec.user], optional: true }] : []),
113
120
  ],
114
121
  uninstall: [
115
122
  { command: "systemctl", args: ["--user", "disable", "--now", unit], optional: true },
@@ -157,10 +164,38 @@ function serviceEnv() {
157
164
  for (const [key, value] of Object.entries(process.env)) {
158
165
  if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
159
166
  }
167
+ for (const key of ["PATH", "Path"]) if (env[key]) env[key] = servicePATH(env[key]);
160
168
  env.REPLICA_BRIDGE_SERVICE = "1";
161
169
  return env;
162
170
  }
163
171
 
172
+ /**
173
+ * PATH without what npx put in front of it: its temporary package's .bin (npm may clear that cache), the
174
+ * node_modules/.bin of the folder npx ran in and of every folder above it, and npm's node-gyp shim.
175
+ */
176
+ export function servicePATH(value , delimiter = path.delimiter) {
177
+ return value.split(delimiter).filter((dir) => dir && !/[\\/]_npx[\\/]|[\\/]node_modules[\\/]\.bin$|node-gyp-bin$/.test(dir)).join(delimiter);
178
+ }
179
+
180
+ /**
181
+ * The node the service runs. process.execPath has its links resolved: with Homebrew it is
182
+ * /opt/homebrew/Cellar/node/<version>/bin/node, which `brew upgrade node` deletes, so at the next login (after a
183
+ * restart) launchd had nothing to run and the bridge never came back. A `node` on PATH that is the same program under
184
+ * a path without the version (/opt/homebrew/bin/node, which brew points at the new version) is used instead.
185
+ */
186
+ export function stableNodePath(execPath , pathValue , delimiter = path.delimiter, realpath = (p ) => fs.realpathSync(p)) {
187
+ let real ;
188
+ try { real = realpath(execPath); } catch { return execPath; }
189
+ const name = path.basename(execPath);
190
+ for (const dir of servicePATH(pathValue, delimiter).split(delimiter)) {
191
+ if (!dir) continue;
192
+ const candidate = path.join(dir, name);
193
+ if (candidate === execPath) return execPath; // already the path PATH finds, as with nvm or the official installer
194
+ try { if (realpath(candidate) === real) return candidate; } catch { /* not there */ }
195
+ }
196
+ return execPath;
197
+ }
198
+
164
199
  /**
165
200
  * The script the service runs. From npx (inside node_modules) a copy of this version is installed under
166
201
  * ~/.config/replica-bridge/app, since npm may clear its npx cache; from a working copy, the working copy itself.
@@ -206,14 +241,15 @@ export function readServiceInfo() {
206
241
  }
207
242
 
208
243
  function spec(program ) {
209
- return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid: os.userInfo().uid };
244
+ const { uid, username } = os.userInfo();
245
+ return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username };
210
246
  }
211
247
 
212
248
  /** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
213
249
  export function installService(startArgs , say ) {
214
250
  servicePlan(spec([])); // fails early on a platform without a service manager
215
251
  stopRunning();
216
- const program = [process.execPath, installedScript(say), "start", ...startArgs];
252
+ const program = [stableNodePath(process.execPath, process.env.PATH ?? process.env.Path ?? ""), installedScript(say), "start", ...startArgs];
217
253
  const plan = servicePlan(spec(program));
218
254
  fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
219
255
  for (const file of plan.files) {
@@ -249,10 +285,23 @@ export function clearPid() {
249
285
  try { if (fs.readFileSync(pidPath(), "utf8").trim() === String(process.pid)) fs.rmSync(pidPath()); } catch { /* gone */ }
250
286
  }
251
287
 
288
+ /**
289
+ * Whether a pid file was written before this boot. A computer that loses power or is shut down hard leaves it behind,
290
+ * and after the restart its pid can belong to any other process: `start` took that for a running bridge, exited 1,
291
+ * and the service manager retried every ten seconds for as long as that process lived. Five seconds of slack, since
292
+ * the boot time is worked out from the uptime.
293
+ */
294
+ export function writtenBeforeBoot(mtimeMs , now = Date.now(), uptimeSeconds = os.uptime()) {
295
+ return mtimeMs < now - uptimeSeconds * 1000 - 5000;
296
+ }
297
+
252
298
  /** The background bridge's pid while it runs. */
253
299
  export function runningPid() {
254
300
  let pid ;
255
- try { pid = Number(fs.readFileSync(pidPath(), "utf8").trim()); } catch { return null; }
301
+ try {
302
+ pid = Number(fs.readFileSync(pidPath(), "utf8").trim());
303
+ if (writtenBeforeBoot(fs.statSync(pidPath()).mtimeMs)) return null;
304
+ } catch { return null; }
256
305
  if (!pid || pid === process.pid) return null;
257
306
  try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
258
307
  }
@@ -12,6 +12,7 @@
12
12
  // devices
13
13
  // revoke <deviceId>
14
14
  import fs from "node:fs";
15
+ import os from "node:os";
15
16
  import readline from "node:readline/promises";
16
17
  import { parseArgs, format } from "node:util";
17
18
  import { relayRequest } from "../http.js";
@@ -109,7 +110,11 @@ function install() {
109
110
  const plan = installService(startArgs(), console.log);
110
111
  console.log(`Running in the background: ${plan.where}. It starts at login and comes back if it stops, so this terminal can be closed.`);
111
112
  console.log(`Log: ${logPath()}. Remove it with "npx replica-bridge uninstall".`);
112
- if (process.platform === "linux") console.log(`To keep it running while you are logged out: loginctl enable-linger ${process.env.USER ?? "$USER"}`);
113
+ // The install asked for lingering (the service starts at boot, not at login); some systems only let root grant it.
114
+ const user = os.userInfo().username;
115
+ if (process.platform === "linux" && !fs.existsSync(`/var/lib/systemd/linger/${user}`)) {
116
+ console.log(`To start it when the computer boots, before anyone logs in: sudo loginctl enable-linger ${user}`);
117
+ }
113
118
  }
114
119
 
115
120
  async function pairOrExplain() {
@@ -10,7 +10,7 @@ import { Duplex } from "node:stream";
10
10
  import { WebSocket } from "ws";
11
11
 
12
12
  import {
13
- CloseCode, FrameCipher, MAX_FRAME_BYTES, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
13
+ CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, encodeLine, b64url, fromB64url, generateKeyPair, hostSessionKeys,
14
14
  } from "../protocol.js";
15
15
 
16
16
  import { relaySocketURL } from "../http.js";
@@ -50,6 +50,12 @@ import { annotateWriterError } from "./writer.js";
50
50
 
51
51
 
52
52
 
53
+
54
+
55
+
56
+
57
+
58
+
53
59
 
54
60
 
55
61
 
@@ -125,6 +131,14 @@ export class BridgeService {
125
131
  });
126
132
  }
127
133
 
134
+ /**
135
+ * Waits while the relay socket has more than a few chunks queued, so a long message goes out at the network's pace
136
+ * instead of tens of MiB piling up in memory ahead of everything else on this computer's socket.
137
+ */
138
+ async drained() {
139
+ while (this.relay?.readyState === WebSocket.OPEN && this.relay.bufferedAmount > RELAY_SEND_QUEUE_BYTES) await new Promise((resolve) => setTimeout(resolve, 10));
140
+ }
141
+
128
142
  sendEnvelope(envelope ) {
129
143
  if (this.relay?.readyState === WebSocket.OPEN) this.relay.send(JSON.stringify(envelope));
130
144
  }
@@ -191,12 +205,20 @@ export class BridgeService {
191
205
  const message = conn.cipher.openFrame(frame);
192
206
  if (message.t === "resume") {
193
207
  if (conn.stream) throw new Error("second resume");
194
- await this.resume(conn, message.stream, message.ack);
208
+ await this.resume(conn, message.stream, message.ack, message.seg === 1);
195
209
  } else if (message.t === "msg") {
196
210
  const stream = conn.stream;
197
211
  if (!stream) throw new Error("message before resume");
198
212
  if (!stream.in.accept(message.seq)) return;
199
- if (stream.appServer.readyState === WebSocket.OPEN) stream.appServer.send(message.line);
213
+ if (stream.chunks.busy) throw new Error("message in the middle of a chunked one");
214
+ this.sendToAppServer(stream, message.line);
215
+ this.scheduleAck(stream);
216
+ } else if (message.t === "chunk") {
217
+ const stream = conn.stream;
218
+ if (!stream) throw new Error("message before resume");
219
+ if (!stream.in.accept(message.seq)) return;
220
+ const line = stream.chunks.take(message);
221
+ if (line !== null) this.sendToAppServer(stream, line);
200
222
  this.scheduleAck(stream);
201
223
  } else if (message.t === "ack") {
202
224
  conn.stream?.out.ack(message.seq);
@@ -209,7 +231,7 @@ export class BridgeService {
209
231
 
210
232
  // MARK: - Streams
211
233
 
212
- async resume(conn , streamId , peerAck ) {
234
+ async resume(conn , streamId , peerAck , segmented ) {
213
235
  let stream = streamId ? this.streams.get(streamId) : undefined;
214
236
  const resumed = !!stream && stream.deviceId === conn.deviceId;
215
237
  if (!resumed) {
@@ -219,27 +241,40 @@ export class BridgeService {
219
241
  if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
220
242
  if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
221
243
  s.conn = conn;
244
+ s.segmented = segmented;
222
245
  conn.stream = s;
223
- this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received });
246
+ this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1 });
224
247
  // A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
225
248
  const after = resumed ? peerAck : 0;
226
249
  s.out.ack(after);
227
- for (const item of s.out.pending(after)) this.sendInner(conn, { t: "msg", seq: item.seq, line: item.line });
250
+ for (const message of s.out.pending(after)) this.sendInner(conn, message);
228
251
  this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
229
252
  }
230
253
 
231
254
  async openStream(deviceId ) {
232
- const appServer = await this.connectAppServer();
233
- const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve() };
255
+ const { ws: appServer, frameBytes } = await this.connectAppServer();
256
+ const stream = {
257
+ id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(STREAM_BUFFER_BYTES), in: new StreamBuffer(STREAM_BUFFER_BYTES),
258
+ conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes,
259
+ };
234
260
  this.streams.set(stream.id, stream);
235
261
  appServer.on("message", (data ) => {
236
- const annotated = annotateWriterError(data.toString(), this.options.findWriter);
262
+ const text = data.toString();
263
+ const segmented = stream.segmented;
264
+ const fitted = fitLine(text, segmented ? REASSEMBLED_MAX_BYTES : RELAY_REPLY_LIMIT);
265
+ if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
266
+ if (fitted !== text) this.log(`stream ${stream.id}: answered a ${text.length}-byte app-server reply too large for the relay with an error`);
267
+ const annotated = annotateWriterError(fitted, this.options.findWriter);
237
268
  stream.relayed = stream.relayed.then(async () => {
238
269
  const line = await annotated;
239
- if (!this.streams.has(stream.id)) return;
240
- let item;
241
- try { item = stream.out.push(line); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
242
- if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
270
+ const parts = segmented ? await encodeLine(line) : [line];
271
+ for (const part of parts) {
272
+ if (!this.streams.has(stream.id)) return;
273
+ let message;
274
+ try { message = stream.out.push(part); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
275
+ if (stream.conn) this.sendInner(stream.conn, message);
276
+ if (parts.length > 1) await this.drained();
277
+ }
243
278
  });
244
279
  });
245
280
  appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
@@ -278,11 +313,22 @@ export class BridgeService {
278
313
 
279
314
  // MARK: - codex app-server
280
315
 
316
+ /** Sends a line to the app-server, as WebSocket fragments when it is longer than the app-server takes in one frame. */
317
+ sendToAppServer(stream , line ) {
318
+ const ws = stream.appServer;
319
+ if (ws.readyState !== WebSocket.OPEN) return;
320
+ const bytes = Buffer.from(line, "utf8");
321
+ if (bytes.length <= stream.appServerFrameBytes) return ws.send(line);
322
+ for (let at = 0; at < bytes.length; at += stream.appServerFrameBytes) {
323
+ ws.send(bytes.subarray(at, at + stream.appServerFrameBytes), { binary: false, fin: at + stream.appServerFrameBytes >= bytes.length });
324
+ }
325
+ }
326
+
281
327
  /**
282
328
  * One `codex app-server proxy` per stream, with WebSocket over its stdin and stdout. The proxy only relays bytes to
283
329
  * the app-server's control socket, wherever Codex keeps it; it exits at once when no app-server is running.
284
330
  */
285
- openProxy() {
331
+ openProxy() {
286
332
  return new Promise((resolve, reject) => {
287
333
  const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
288
334
  let stderr = "";
@@ -290,14 +336,21 @@ export class BridgeService {
290
336
  const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
291
337
  // The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
292
338
  for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
293
- const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: MAX_FRAME_BYTES });
339
+ // Read app-server messages well past what one relay frame holds, so fitLine answers a big reply with an error
340
+ // rather than the oversized message closing the app-server connection.
341
+ const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
342
+ let frameBytes = APP_SERVER_FRAME_BYTES;
343
+ ws.once("upgrade", (res) => {
344
+ const advertised = Number(res.headers["x-codex-websocket-max-unfragmented-message-bytes"]);
345
+ if (Number.isInteger(advertised) && advertised >= 64 * 1024) frameBytes = advertised;
346
+ });
294
347
  const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
295
348
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
296
349
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
297
350
  ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
298
351
  ws.once("open", () => {
299
352
  ws.on("close", () => killCommand(child));
300
- resolve(ws);
353
+ resolve({ ws, frameBytes });
301
354
  });
302
355
  });
303
356
  }
@@ -312,7 +365,7 @@ export class BridgeService {
312
365
  });
313
366
  }
314
367
 
315
- async connectAppServer() {
368
+ async connectAppServer() {
316
369
  try {
317
370
  return await this.openProxy();
318
371
  } catch (error) {
@@ -339,3 +392,28 @@ export class BridgeService {
339
392
  throw new Error(`codex app-server did not come up: ${last?.message}`);
340
393
  }
341
394
  }
395
+
396
+ /** Largest app-server message the bridge reads before refusing it. */
397
+ const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
398
+
399
+ /** What the bridge keeps per direction for a phone that is away: room for a REASSEMBLED_MAX_BYTES line deflate could not shrink. */
400
+ const STREAM_BUFFER_BYTES = 128 * 1024 * 1024;
401
+ /** Chunks queued on the relay socket before the next one waits. */
402
+ const RELAY_SEND_QUEUE_BYTES = 1024 * 1024;
403
+ /** What codex-cli 0.163.0 sends in x-codex-websocket-max-unfragmented-message-bytes, for an app-server that sends none. */
404
+ const APP_SERVER_FRAME_BYTES = 16 * 1024 * 1024;
405
+
406
+ /**
407
+ * An app-server line as it can cross the relay: unchanged when it fits `limit` (RELAY_REPLY_LIMIT, or
408
+ * REASSEMBLED_MAX_BYTES for a phone that takes chunks), a JSON-RPC error with the same id when it is a reply that does
409
+ * not (`error.data.replicaTooLarge: {bytes, limit}`, PROTOCOL.md), and null for a notification that does not, which
410
+ * has no one to answer.
411
+ */
412
+ export function fitLine(line , limit = RELAY_REPLY_LIMIT) {
413
+ const bytes = Buffer.byteLength(line, "utf8");
414
+ if (bytes <= limit) return line;
415
+ let id ;
416
+ try { id = (JSON.parse(line) ).id; } catch { return null; }
417
+ if (id === undefined || id === null) return null;
418
+ return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
419
+ }
package/src/protocol.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
3
3
  // Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
4
4
  import crypto from "node:crypto";
5
+ import zlib from "node:zlib";
6
+ import { promisify } from "node:util";
5
7
 
6
8
  export const PROTOCOL = "chatreplica-relay-v1";
7
9
  export const LINK_SCHEME = "chatreplica:";
@@ -9,6 +11,23 @@ export const LINK_SCHEME = "chatreplica:";
9
11
  export const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
10
12
  export const CODE_LENGTH = 8;
11
13
  export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
14
+ /**
15
+ * The longest app-server line the bridge forwards. A line travels as {t,seq,line} JSON, sealed (+16 bytes) and
16
+ * base64url encoded (x4/3) inside a frame envelope, so 11 MiB stays under MAX_FRAME_BYTES; longer replies (fs/readFile
17
+ * of a big file) come back as a JSON-RPC error instead of a frame the relay would refuse by closing the connection.
18
+ */
19
+ export const RELAY_REPLY_LIMIT = 11 * 1024 * 1024;
20
+ /**
21
+ * Long messages, the way Codex remote-control splits them (openai/codex app-server-transport remote_control/segment.rs):
22
+ * when both sides say `seg` (the phone in resume, the bridge in ready), a line over SEGMENT_BYTES goes as `chunk`
23
+ * messages of at most SEGMENT_BYTES each, at most SEGMENT_COUNT_MAX of them, up to REASSEMBLED_MAX_BYTES; the receiver
24
+ * checks every chunk's segment, count and size. Unlike remote-control the payload is raw deflate of the line (`z`),
25
+ * since the relay's frames are encrypted and nothing on the way can compress them. A chunk travels sealed and
26
+ * base64url encoded in about 180 KiB of frame, so pings still get through while a long message is on its way.
27
+ */
28
+ export const SEGMENT_BYTES = 100 * 1024;
29
+ export const SEGMENT_COUNT_MAX = 1024;
30
+ export const REASSEMBLED_MAX_BYTES = 100 * 1024 * 1024;
12
31
 
13
32
  export const b64url = (data ) => Buffer.from(data).toString("base64url");
14
33
  export const fromB64url = (text ) => Buffer.from(text, "base64url");
@@ -202,12 +221,78 @@ export class FrameCipher {
202
221
 
203
222
  /** What is inside a data frame. */
204
223
 
205
-
206
-
207
-
224
+
225
+
226
+
227
+
208
228
 
209
229
 
210
230
 
231
+
232
+ /** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
233
+
234
+
235
+ /** What a StreamBuffer keeps until the peer acknowledges it. */
236
+
237
+
238
+ /** The payload cut into chunks: SEGMENT_BYTES each, or larger only to stay within SEGMENT_COUNT_MAX. */
239
+ export function chunkParts(payload , z , segmentBytes = SEGMENT_BYTES) {
240
+ const size = payload.length;
241
+ const chunkSize = Math.max(segmentBytes, Math.ceil(size / SEGMENT_COUNT_MAX));
242
+ const segments = Math.max(1, Math.ceil(size / chunkSize));
243
+ return Array.from({ length: segments }, (_, segment) => ({
244
+ segment, segments, size, ...(z ? { z: 1 } : {}), data: payload.subarray(segment * chunkSize, (segment + 1) * chunkSize).toString("base64"),
245
+ }));
246
+ }
247
+
248
+ const deflateRaw = promisify(zlib.deflateRaw);
249
+
250
+ /** A line as a `seg` peer takes it: as it is up to SEGMENT_BYTES, otherwise deflated and in chunks. */
251
+ export async function encodeLine(line ) {
252
+ const bytes = Buffer.from(line, "utf8");
253
+ if (bytes.length <= SEGMENT_BYTES) return [line];
254
+ return chunkParts(await deflateRaw(bytes), true);
255
+ }
256
+
257
+ /** encodeLine without yielding, for the test phone. */
258
+ export function encodeLineSync(line ) {
259
+ const bytes = Buffer.from(line, "utf8");
260
+ if (bytes.length <= SEGMENT_BYTES) return [line];
261
+ return chunkParts(zlib.deflateRawSync(bytes), true);
262
+ }
263
+
264
+ /**
265
+ * Puts one stream's chunks back together. Chunks arrive in seq order, so a line's segments are consecutive; anything
266
+ * else (a skipped segment, a changed count or size, more bytes than announced, a `msg` in the middle) is a protocol
267
+ * error, since the peer is authenticated and only a bug would send it.
268
+ */
269
+ export class ChunkAssembler {
270
+ parts = [];
271
+ received = 0;
272
+ first = null;
273
+
274
+ get busy() { return this.first !== null; }
275
+
276
+ take(message ) {
277
+ const { segment, segments, size } = message;
278
+ if (!Number.isInteger(segments) || segments < 1 || segments > SEGMENT_COUNT_MAX || !Number.isInteger(size) || size < 1 || size > REASSEMBLED_MAX_BYTES) {
279
+ throw new Error("chunk count or size out of range");
280
+ }
281
+ const first = this.first ?? message;
282
+ if (segment !== this.parts.length || first.segments !== segments || first.size !== size || first.z !== message.z) throw new Error(`chunk ${segment} out of order`);
283
+ const data = Buffer.from(message.data, "base64");
284
+ this.received += data.length;
285
+ if (data.length === 0 || this.received > size) throw new Error("chunk carries more than its size");
286
+ this.first = first;
287
+ this.parts.push(data);
288
+ if (segment < segments - 1) return null;
289
+ if (this.received !== size) throw new Error("chunks carry less than their size");
290
+ const payload = Buffer.concat(this.parts);
291
+ this.parts = []; this.received = 0; this.first = null;
292
+ return (message.z ? zlib.inflateRawSync(payload, { maxOutputLength: REASSEMBLED_MAX_BYTES }) : payload).toString("utf8");
293
+ }
294
+ }
295
+
211
296
  /** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
212
297
 
213
298
 
@@ -230,26 +315,28 @@ export const CloseCode = {
230
315
  export class StreamBuffer {
231
316
  nextSeq = 1;
232
317
  received = 0;
233
- unacked = [];
318
+ unacked = [];
234
319
  bytes = 0;
235
320
 
236
321
  maxBytes ;
237
322
  constructor(maxBytes = 32 * 1024 * 1024) { this.maxBytes = maxBytes; }
238
323
 
239
- push(line ) {
240
- const item = { seq: this.nextSeq++, line, size: Buffer.byteLength(line) };
324
+ push(part ) {
325
+ const seq = this.nextSeq++;
326
+ const message = typeof part === "string" ? { t: "msg", seq, line: part } : { t: "chunk", seq, ...part };
327
+ const item = { message, size: Buffer.byteLength(message.t === "msg" ? message.line : message.data) };
241
328
  this.unacked.push(item);
242
329
  this.bytes += item.size;
243
330
  if (this.bytes > this.maxBytes) throw new Error("unacknowledged buffer full");
244
- return item;
331
+ return message;
245
332
  }
246
333
 
247
334
  ack(seq ) {
248
- while (this.unacked.length && this.unacked[0].seq <= seq) this.bytes -= this.unacked.shift() .size;
335
+ while (this.unacked.length && this.unacked[0].message.seq <= seq) this.bytes -= this.unacked.shift() .size;
249
336
  }
250
337
 
251
- pending(after ) {
252
- return this.unacked.filter((item) => item.seq > after);
338
+ pending(after ) {
339
+ return this.unacked.filter((item) => item.message.seq > after).map((item) => item.message);
253
340
  }
254
341
 
255
342
  /** True when the line is new; duplicates from a resend are dropped. Gaps cannot happen on one ordered socket. */