replica-bridge 0.3.3 → 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 +69 -7
- 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,19 +164,60 @@ 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.
|
|
167
202
|
*/
|
|
168
|
-
function installedScript(say ) {
|
|
169
|
-
const
|
|
170
|
-
|
|
203
|
+
export function installedScript(say , current = process.argv[1] ? fs.realpathSync(process.argv[1]) : "") {
|
|
204
|
+
const parts = current.split(path.sep);
|
|
205
|
+
const at = parts.lastIndexOf("node_modules");
|
|
206
|
+
if (at < 0) return current;
|
|
171
207
|
const app = path.join(bridgeHome(), "app");
|
|
172
|
-
|
|
208
|
+
if (!process.env.REPLICA_BRIDGE_PACKAGE) {
|
|
209
|
+
// Copy what npx already installed (this package and its dependencies) rather than asking npm again: npx may have
|
|
210
|
+
// used a registry the second npm would not (`npx --registry`, or a company feed without this package).
|
|
211
|
+
const from = parts.slice(0, at + 1).join(path.sep);
|
|
212
|
+
say(`Copying replica-bridge ${PACKAGE_VERSION} to ${app}…`);
|
|
213
|
+
const next = `${app}.${process.pid}.part`;
|
|
214
|
+
fs.rmSync(next, { recursive: true, force: true });
|
|
215
|
+
fs.cpSync(from, path.join(next, "node_modules"), { recursive: true, dereference: true });
|
|
216
|
+
fs.rmSync(app, { recursive: true, force: true });
|
|
217
|
+
fs.renameSync(next, app);
|
|
218
|
+
return path.join(app, "node_modules", ...parts.slice(at + 1));
|
|
219
|
+
}
|
|
220
|
+
const spec = process.env.REPLICA_BRIDGE_PACKAGE;
|
|
173
221
|
say(`Installing ${spec} in ${app}…`);
|
|
174
222
|
fs.mkdirSync(app, { recursive: true });
|
|
175
223
|
// npx's own npm_* settings (a temporary prefix, for one) must not leak into this install.
|
|
@@ -193,14 +241,15 @@ export function readServiceInfo() {
|
|
|
193
241
|
}
|
|
194
242
|
|
|
195
243
|
function spec(program ) {
|
|
196
|
-
|
|
244
|
+
const { uid, username } = os.userInfo();
|
|
245
|
+
return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username };
|
|
197
246
|
}
|
|
198
247
|
|
|
199
248
|
/** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
|
|
200
249
|
export function installService(startArgs , say ) {
|
|
201
250
|
servicePlan(spec([])); // fails early on a platform without a service manager
|
|
202
251
|
stopRunning();
|
|
203
|
-
const program = [process.execPath, installedScript(say), "start", ...startArgs];
|
|
252
|
+
const program = [stableNodePath(process.execPath, process.env.PATH ?? process.env.Path ?? ""), installedScript(say), "start", ...startArgs];
|
|
204
253
|
const plan = servicePlan(spec(program));
|
|
205
254
|
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
206
255
|
for (const file of plan.files) {
|
|
@@ -236,10 +285,23 @@ export function clearPid() {
|
|
|
236
285
|
try { if (fs.readFileSync(pidPath(), "utf8").trim() === String(process.pid)) fs.rmSync(pidPath()); } catch { /* gone */ }
|
|
237
286
|
}
|
|
238
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
|
+
|
|
239
298
|
/** The background bridge's pid while it runs. */
|
|
240
299
|
export function runningPid() {
|
|
241
300
|
let pid ;
|
|
242
|
-
try {
|
|
301
|
+
try {
|
|
302
|
+
pid = Number(fs.readFileSync(pidPath(), "utf8").trim());
|
|
303
|
+
if (writtenBeforeBoot(fs.statSync(pidPath()).mtimeMs)) return null;
|
|
304
|
+
} catch { return null; }
|
|
243
305
|
if (!pid || pid === process.pid) return null;
|
|
244
306
|
try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
|
|
245
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");
|