replica-bridge 0.3.5 → 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.5",
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
  }
@@ -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, RELAY_REPLY_LIMIT, 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,31 +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
262
  const text = data.toString();
237
- const fitted = fitLine(text);
263
+ const segmented = stream.segmented;
264
+ const fitted = fitLine(text, segmented ? REASSEMBLED_MAX_BYTES : RELAY_REPLY_LIMIT);
238
265
  if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
239
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`);
240
267
  const annotated = annotateWriterError(fitted, this.options.findWriter);
241
268
  stream.relayed = stream.relayed.then(async () => {
242
269
  const line = await annotated;
243
- if (!this.streams.has(stream.id)) return;
244
- let item;
245
- try { item = stream.out.push(line); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
246
- 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
+ }
247
278
  });
248
279
  });
249
280
  appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
@@ -282,11 +313,22 @@ export class BridgeService {
282
313
 
283
314
  // MARK: - codex app-server
284
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
+
285
327
  /**
286
328
  * One `codex app-server proxy` per stream, with WebSocket over its stdin and stdout. The proxy only relays bytes to
287
329
  * the app-server's control socket, wherever Codex keeps it; it exits at once when no app-server is running.
288
330
  */
289
- openProxy() {
331
+ openProxy() {
290
332
  return new Promise((resolve, reject) => {
291
333
  const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
292
334
  let stderr = "";
@@ -297,13 +339,18 @@ export class BridgeService {
297
339
  // Read app-server messages well past what one relay frame holds, so fitLine answers a big reply with an error
298
340
  // rather than the oversized message closing the app-server connection.
299
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
+ });
300
347
  const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
301
348
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
302
349
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
303
350
  ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
304
351
  ws.once("open", () => {
305
352
  ws.on("close", () => killCommand(child));
306
- resolve(ws);
353
+ resolve({ ws, frameBytes });
307
354
  });
308
355
  });
309
356
  }
@@ -318,7 +365,7 @@ export class BridgeService {
318
365
  });
319
366
  }
320
367
 
321
- async connectAppServer() {
368
+ async connectAppServer() {
322
369
  try {
323
370
  return await this.openProxy();
324
371
  } catch (error) {
@@ -349,16 +396,24 @@ export class BridgeService {
349
396
  /** Largest app-server message the bridge reads before refusing it. */
350
397
  const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
351
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
+
352
406
  /**
353
- * An app-server line as it can cross the relay: unchanged when it fits RELAY_REPLY_LIMIT, a JSON-RPC error with the
354
- * same id when it is a reply that does not (`error.data.replicaTooLarge: {bytes, limit}`, PROTOCOL.md), and null for a
355
- * notification that does not, which has no one to answer.
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.
356
411
  */
357
- export function fitLine(line ) {
412
+ export function fitLine(line , limit = RELAY_REPLY_LIMIT) {
358
413
  const bytes = Buffer.byteLength(line, "utf8");
359
- if (bytes <= RELAY_REPLY_LIMIT) return line;
414
+ if (bytes <= limit) return line;
360
415
  let id ;
361
416
  try { id = (JSON.parse(line) ).id; } catch { return null; }
362
417
  if (id === undefined || id === null) return null;
363
- return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${RELAY_REPLY_LIMIT})`, data: { replicaTooLarge: { bytes, limit: RELAY_REPLY_LIMIT } } } });
418
+ return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
364
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:";
@@ -15,6 +17,17 @@ export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
15
17
  * of a big file) come back as a JSON-RPC error instead of a frame the relay would refuse by closing the connection.
16
18
  */
17
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;
18
31
 
19
32
  export const b64url = (data ) => Buffer.from(data).toString("base64url");
20
33
  export const fromB64url = (text ) => Buffer.from(text, "base64url");
@@ -208,12 +221,78 @@ export class FrameCipher {
208
221
 
209
222
  /** What is inside a data frame. */
210
223
 
211
-
212
-
213
-
224
+
225
+
226
+
227
+
214
228
 
215
229
 
216
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
+
217
296
  /** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
218
297
 
219
298
 
@@ -236,26 +315,28 @@ export const CloseCode = {
236
315
  export class StreamBuffer {
237
316
  nextSeq = 1;
238
317
  received = 0;
239
- unacked = [];
318
+ unacked = [];
240
319
  bytes = 0;
241
320
 
242
321
  maxBytes ;
243
322
  constructor(maxBytes = 32 * 1024 * 1024) { this.maxBytes = maxBytes; }
244
323
 
245
- push(line ) {
246
- 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) };
247
328
  this.unacked.push(item);
248
329
  this.bytes += item.size;
249
330
  if (this.bytes > this.maxBytes) throw new Error("unacknowledged buffer full");
250
- return item;
331
+ return message;
251
332
  }
252
333
 
253
334
  ack(seq ) {
254
- 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;
255
336
  }
256
337
 
257
- pending(after ) {
258
- 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);
259
340
  }
260
341
 
261
342
  /** True when the line is new; duplicates from a resend are dropped. Gaps cannot happen on one ordered socket. */