replica-bridge 0.3.4 → 0.3.5
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 +1 -1
- package/src/bridge/install.js +52 -3
- package/src/bridge/main.js +6 -1
- package/src/bridge/service.js +26 -3
- package/src/protocol.js +6 -0
package/package.json
CHANGED
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, MAX_FRAME_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
|
|
14
14
|
} from "../protocol.js";
|
|
15
15
|
|
|
16
16
|
import { relaySocketURL } from "../http.js";
|
|
@@ -233,7 +233,11 @@ export class BridgeService {
|
|
|
233
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() };
|
|
234
234
|
this.streams.set(stream.id, stream);
|
|
235
235
|
appServer.on("message", (data ) => {
|
|
236
|
-
const
|
|
236
|
+
const text = data.toString();
|
|
237
|
+
const fitted = fitLine(text);
|
|
238
|
+
if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
|
|
239
|
+
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
|
+
const annotated = annotateWriterError(fitted, this.options.findWriter);
|
|
237
241
|
stream.relayed = stream.relayed.then(async () => {
|
|
238
242
|
const line = await annotated;
|
|
239
243
|
if (!this.streams.has(stream.id)) return;
|
|
@@ -290,7 +294,9 @@ export class BridgeService {
|
|
|
290
294
|
const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
|
|
291
295
|
// The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
|
|
292
296
|
for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
|
|
293
|
-
|
|
297
|
+
// Read app-server messages well past what one relay frame holds, so fitLine answers a big reply with an error
|
|
298
|
+
// rather than the oversized message closing the app-server connection.
|
|
299
|
+
const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
|
|
294
300
|
const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
|
|
295
301
|
child.once("error", (error) => fail(`could not run codex: ${error.message}`));
|
|
296
302
|
child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
|
|
@@ -339,3 +345,20 @@ export class BridgeService {
|
|
|
339
345
|
throw new Error(`codex app-server did not come up: ${last?.message}`);
|
|
340
346
|
}
|
|
341
347
|
}
|
|
348
|
+
|
|
349
|
+
/** Largest app-server message the bridge reads before refusing it. */
|
|
350
|
+
const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
|
|
351
|
+
|
|
352
|
+
/**
|
|
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.
|
|
356
|
+
*/
|
|
357
|
+
export function fitLine(line ) {
|
|
358
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
359
|
+
if (bytes <= RELAY_REPLY_LIMIT) return line;
|
|
360
|
+
let id ;
|
|
361
|
+
try { id = (JSON.parse(line) ).id; } catch { return null; }
|
|
362
|
+
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 } } } });
|
|
364
|
+
}
|
package/src/protocol.js
CHANGED
|
@@ -9,6 +9,12 @@ export const LINK_SCHEME = "chatreplica:";
|
|
|
9
9
|
export const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
10
10
|
export const CODE_LENGTH = 8;
|
|
11
11
|
export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* The longest app-server line the bridge forwards. A line travels as {t,seq,line} JSON, sealed (+16 bytes) and
|
|
14
|
+
* base64url encoded (x4/3) inside a frame envelope, so 11 MiB stays under MAX_FRAME_BYTES; longer replies (fs/readFile
|
|
15
|
+
* of a big file) come back as a JSON-RPC error instead of a frame the relay would refuse by closing the connection.
|
|
16
|
+
*/
|
|
17
|
+
export const RELAY_REPLY_LIMIT = 11 * 1024 * 1024;
|
|
12
18
|
|
|
13
19
|
export const b64url = (data ) => Buffer.from(data).toString("base64url");
|
|
14
20
|
export const fromB64url = (text ) => Buffer.from(text, "base64url");
|