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 +2 -2
- package/src/bridge/install.js +52 -3
- package/src/bridge/main.js +6 -1
- package/src/bridge/service.js +95 -17
- package/src/protocol.js +97 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replica-bridge",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
14
|
+
"ws": "8.22.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "UNLICENSED"
|
|
17
17
|
}
|
package/src/bridge/install.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
}
|
package/src/bridge/main.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|
package/src/bridge/service.js
CHANGED
|
@@ -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.
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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(
|
|
240
|
-
const
|
|
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
|
|
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. */
|