replica-bridge 0.3.17 → 0.3.18
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/main.js +66 -8
- package/src/bridge/service.js +59 -5
package/package.json
CHANGED
package/src/bridge/main.js
CHANGED
|
@@ -83,12 +83,19 @@ function startBridge() {
|
|
|
83
83
|
return service;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell).
|
|
88
|
+
* The shell is interactive (-i) so it reads the rc file where people put Codex on PATH, and an interactive shell that
|
|
89
|
+
* shares this terminal takes job control of it: zsh, and some bash set-ups, left the terminal to their own process
|
|
90
|
+
* group, so the next read of the terminal (the [Y/n] question, or "Allow?") stopped this process ("[1]+ Stopped
|
|
91
|
+
* npx replica-bridge", 2026-11-08) and with it the relay connection. `detached` starts it in its own session, with no
|
|
92
|
+
* terminal to take; it still reads the rc file.
|
|
93
|
+
*/
|
|
87
94
|
function checkCodex() {
|
|
88
95
|
const codex = values.codex ?? "codex";
|
|
89
96
|
const loginShell = process.platform !== "win32" && !values["no-login-shell"] && process.env.SHELL;
|
|
90
97
|
const result = loginShell
|
|
91
|
-
? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
|
|
98
|
+
? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000, detached: true })
|
|
92
99
|
: spawnCommandSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
|
|
93
100
|
if (result.status === 0) return;
|
|
94
101
|
console.warn(`Codex did not answer "${codex} --version". Install it (npm install -g @openai/codex) and sign in (codex login); the phone cannot use this computer until then.`);
|
|
@@ -111,7 +118,55 @@ async function wantBackground() {
|
|
|
111
118
|
return ask("Keep it running in the background and start it at login, so you can close this terminal? [Y/n] ");
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
|
|
121
|
+
/** How long to wait for the relay socket before saying it has not connected; a working network takes well under a second. */
|
|
122
|
+
const RELAY_WAIT_MS = Number(process.env.REPLICA_BRIDGE_RELAY_WAIT_MS ?? 10_000);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Said when the bridge has not reached the relay. Enrolling and pairing are plain HTTPS requests, so they work on a
|
|
126
|
+
* network that blocks the WebSocket; then the phone pairs and shows the computer offline with no hint why
|
|
127
|
+
* (2026-11-08). The bridge keeps retrying, so this is a warning, not a stop.
|
|
128
|
+
*/
|
|
129
|
+
function relayWarning(relay , problem ) {
|
|
130
|
+
const socket = relay.replace(/^http/, "ws");
|
|
131
|
+
return [
|
|
132
|
+
`This computer has not connected to the relay yet${problem ? `: ${problem}` : ` (no answer within ${RELAY_WAIT_MS / 1000} s)`}.`,
|
|
133
|
+
"Until it does, the phone shows this computer offline. Pairing still works, since it uses plain HTTPS, and the bridge keeps trying.",
|
|
134
|
+
`A proxy or firewall that lets HTTPS through but blocks WebSocket connections to ${socket} causes exactly this.`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Waits for this process's bridge to reach the relay and warns when it does not. */
|
|
139
|
+
async function checkRelay(bridge , relay ) {
|
|
140
|
+
if (await bridge.waitForRelay(RELAY_WAIT_MS)) return true;
|
|
141
|
+
console.warn(relayWarning(relay, bridge.relayProblem));
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The background service's log past `offset` bytes, or "" before it exists. */
|
|
146
|
+
function logSince(offset ) {
|
|
147
|
+
try { return fs.readFileSync(logPath()).subarray(offset).toString("utf8"); } catch { return ""; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function logSize() {
|
|
151
|
+
try { return fs.statSync(logPath()).size; } catch { return 0; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The background service is another process: its log is the only place that says whether it reached the relay. */
|
|
155
|
+
async function checkServiceRelay(offset , relay ) {
|
|
156
|
+
for (let waited = 0; waited < RELAY_WAIT_MS; waited += 250) {
|
|
157
|
+
if (/ connected to relay /.test(logSince(offset))) return;
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
159
|
+
}
|
|
160
|
+
const text = logSince(offset);
|
|
161
|
+
if (/ connected to relay /.test(text)) return;
|
|
162
|
+
const problems = [...text.matchAll(/relay connection error: (.*)|relay refused the host connection: (HTTP \d+)/g)];
|
|
163
|
+
const last = problems.at(-1);
|
|
164
|
+
console.warn(relayWarning(relay, last ? (last[1] ?? `${last[2]} instead of a WebSocket`) : null));
|
|
165
|
+
console.warn(`The background service keeps trying; its log is ${logPath()}.`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function install() {
|
|
169
|
+
const offset = logSize();
|
|
115
170
|
const plan = installService(startArgs(), console.log);
|
|
116
171
|
// The service writes its pid once it has started; `status` right after the install said "not running" before that.
|
|
117
172
|
for (let waited = 0; !runningPid() && waited < 10_000; waited += 200) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
@@ -122,6 +177,7 @@ function install() {
|
|
|
122
177
|
if (process.platform === "linux" && !fs.existsSync(`/var/lib/systemd/linger/${user}`)) {
|
|
123
178
|
console.log(`To start it when the computer boots, before anyone logs in: sudo loginctl enable-linger ${user}`);
|
|
124
179
|
}
|
|
180
|
+
await checkServiceRelay(offset, loadState().relay);
|
|
125
181
|
}
|
|
126
182
|
|
|
127
183
|
async function pairOrExplain() {
|
|
@@ -156,19 +212,20 @@ async function main() {
|
|
|
156
212
|
// The background service serves the phone: restart it onto a new set-up or a newer version, then pair.
|
|
157
213
|
if (moved) console.log("Restarting the background service with the new set-up.");
|
|
158
214
|
else if (installed.version !== PACKAGE_VERSION) console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
|
|
159
|
-
if (moved || values.install || installed.version !== PACKAGE_VERSION) install();
|
|
215
|
+
if (moved || values.install || installed.version !== PACKAGE_VERSION) await install();
|
|
160
216
|
if (moved || values.pair || !state.devices.length) await pairOrExplain();
|
|
161
217
|
else status();
|
|
162
218
|
break;
|
|
163
219
|
}
|
|
164
220
|
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
|
|
165
221
|
let bridge = startBridge();
|
|
222
|
+
await checkRelay(bridge, state.relay);
|
|
166
223
|
if (values.pair || !state.devices.length) await pairOrExplain();
|
|
167
224
|
if (loadState().devices.length && await wantBackground()) {
|
|
168
225
|
// One bridge per computer: the relay keeps only the newest connection for it.
|
|
169
226
|
bridge.stop();
|
|
170
227
|
try {
|
|
171
|
-
install();
|
|
228
|
+
await install();
|
|
172
229
|
process.exit(0);
|
|
173
230
|
} catch (error) {
|
|
174
231
|
console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
|
|
@@ -177,11 +234,12 @@ async function main() {
|
|
|
177
234
|
}
|
|
178
235
|
for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
|
|
179
236
|
console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
|
|
237
|
+
if (!(await bridge.waitForRelay(RELAY_WAIT_MS))) console.warn(`Still not connected to the relay${bridge.relayProblem ? ` (${bridge.relayProblem})` : ""}: the phone shows ${state.hostName} offline until it is.`);
|
|
180
238
|
break;
|
|
181
239
|
}
|
|
182
240
|
case "install":
|
|
183
241
|
await ensureRelay(values.relay, console.log, values.name);
|
|
184
|
-
install();
|
|
242
|
+
await install();
|
|
185
243
|
break;
|
|
186
244
|
case "uninstall": {
|
|
187
245
|
const plan = uninstallService();
|
|
@@ -196,7 +254,7 @@ async function main() {
|
|
|
196
254
|
const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
|
|
197
255
|
console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
|
|
198
256
|
console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
|
|
199
|
-
if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); install(); }
|
|
257
|
+
if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); await install(); }
|
|
200
258
|
break;
|
|
201
259
|
}
|
|
202
260
|
case "config": {
|
|
@@ -209,7 +267,7 @@ async function main() {
|
|
|
209
267
|
case "pair":
|
|
210
268
|
if ((await ensureRelay(values.relay, console.log, values.name)).moved && readServiceInfo()) {
|
|
211
269
|
console.log("Restarting the background service with the new set-up.");
|
|
212
|
-
install();
|
|
270
|
+
await install();
|
|
213
271
|
}
|
|
214
272
|
await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
|
|
215
273
|
if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
|
package/src/bridge/service.js
CHANGED
|
@@ -121,12 +121,42 @@ export class BridgeService {
|
|
|
121
121
|
|
|
122
122
|
get streamCount() { return this.streams.size; }
|
|
123
123
|
|
|
124
|
+
/** The relay socket is open: phones see this computer online. */
|
|
125
|
+
get relayConnected() { return this.relay?.readyState === WebSocket.OPEN; }
|
|
126
|
+
|
|
127
|
+
/** Why the last attempt to reach the relay failed, while it has not connected since. */
|
|
128
|
+
get relayProblem() { return this.lastRelayProblem; }
|
|
129
|
+
|
|
130
|
+
lastRelayProblem = null;
|
|
131
|
+
relayWaiters = new Set ();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Waits up to `ms` for the relay socket to open. Pairing goes over plain HTTPS, so a network that lets HTTPS through
|
|
135
|
+
* but not the WebSocket (2026-11-08: two computers paired, never once came online, and the phone showed both
|
|
136
|
+
* offline) pairs fine and leaves nothing for the phone to reach; the caller says so instead of looking finished.
|
|
137
|
+
*/
|
|
138
|
+
async waitForRelay(ms ) {
|
|
139
|
+
if (this.relayConnected) return true;
|
|
140
|
+
await new Promise ((resolve) => {
|
|
141
|
+
const done = () => { clearTimeout(timer); this.relayWaiters.delete(done); resolve(); };
|
|
142
|
+
const timer = setTimeout(done, ms);
|
|
143
|
+
this.relayWaiters.add(done);
|
|
144
|
+
});
|
|
145
|
+
return this.relayConnected;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Only an open socket ends the wait early: a first attempt that fails often succeeds on the retry a second later. */
|
|
149
|
+
relayChanged() {
|
|
150
|
+
for (const waiter of [...this.relayWaiters]) waiter();
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
// MARK: - Relay connection
|
|
125
154
|
|
|
126
155
|
connectRelay() {
|
|
127
156
|
const state = loadState();
|
|
128
157
|
const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
|
|
129
158
|
this.relay = ws;
|
|
159
|
+
let refused = false;
|
|
130
160
|
// A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
|
|
131
161
|
// notice until the bridge sent something: the computer stayed offline for the phones. A missed pong ends it.
|
|
132
162
|
let answered = true;
|
|
@@ -138,7 +168,14 @@ export class BridgeService {
|
|
|
138
168
|
}, (this.options.relayPingSeconds ?? 30) * 1000);
|
|
139
169
|
ws.on("pong", () => { answered = true; });
|
|
140
170
|
ws.on("ping", () => { answered = true; });
|
|
141
|
-
ws.on("open", () => {
|
|
171
|
+
ws.on("open", () => {
|
|
172
|
+
answered = true;
|
|
173
|
+
this.retry = 0;
|
|
174
|
+
this.lastRelayProblem = null;
|
|
175
|
+
this.log(`connected to relay ${state.relay} as ${state.hostId}`);
|
|
176
|
+
this.options.onRelayConnected?.(true);
|
|
177
|
+
this.relayChanged();
|
|
178
|
+
});
|
|
142
179
|
ws.on("message", (data ) => {
|
|
143
180
|
let envelope ;
|
|
144
181
|
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
@@ -149,10 +186,16 @@ export class BridgeService {
|
|
|
149
186
|
// process ran out of work and exited 0, which the background services read as "replaced" and left stopped.
|
|
150
187
|
// Ending the socket goes through close and the usual backoff instead.
|
|
151
188
|
ws.on("unexpected-response", (_req, res) => {
|
|
189
|
+
refused = true;
|
|
190
|
+
this.lastRelayProblem = `HTTP ${res.statusCode} instead of a WebSocket`;
|
|
152
191
|
this.log(`relay refused the host connection: HTTP ${res.statusCode}; retrying`);
|
|
153
192
|
ws.terminate();
|
|
154
193
|
});
|
|
155
|
-
ws.on("error", (error) =>
|
|
194
|
+
ws.on("error", (error) => {
|
|
195
|
+
// Ending a refused socket also reports "closed before the connection was established"; the status says more.
|
|
196
|
+
if (!refused) this.lastRelayProblem = describeSocketError(error);
|
|
197
|
+
this.log(`relay connection error: ${this.lastRelayProblem}`);
|
|
198
|
+
});
|
|
156
199
|
ws.on("close", (code) => {
|
|
157
200
|
clearInterval(ping);
|
|
158
201
|
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
|
@@ -420,9 +463,10 @@ export class BridgeService {
|
|
|
420
463
|
}
|
|
421
464
|
|
|
422
465
|
/** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
|
|
423
|
-
|
|
466
|
+
/** `detached` for an interactive login shell, which would otherwise take the terminal of a bridge run in one (main.ts checkCodex). */
|
|
467
|
+
run(command , args , detached = false) {
|
|
424
468
|
return new Promise((resolve, reject) => {
|
|
425
|
-
const child = spawnCommand(command, args, { stdio: "ignore" });
|
|
469
|
+
const child = spawnCommand(command, args, { stdio: "ignore", detached });
|
|
426
470
|
const timer = setTimeout(() => { killCommand(child); resolve(false); }, 30_000);
|
|
427
471
|
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
428
472
|
child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
@@ -457,7 +501,7 @@ export class BridgeService {
|
|
|
457
501
|
// (a SHELL from Git Bash is a POSIX path Node cannot run), so Codex is asked directly there.
|
|
458
502
|
const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell;
|
|
459
503
|
const started = this.options.loginShell !== false && shell
|
|
460
|
-
? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
|
|
504
|
+
? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex], true).catch(() => false)
|
|
461
505
|
: false;
|
|
462
506
|
if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
|
|
463
507
|
throw new Error("codex app-server daemon start failed");
|
|
@@ -510,3 +554,13 @@ export function fitLine(line , limit = RELAY_REPLY_LIMIT)
|
|
|
510
554
|
if (id === undefined || id === null) return null;
|
|
511
555
|
return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
|
|
512
556
|
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* An error's message, or its code when Node leaves the message empty: a refused connection tried on both IPv4 and
|
|
560
|
+
* IPv6 is an AggregateError with message "" and the reasons in `errors`.
|
|
561
|
+
*/
|
|
562
|
+
export function describeSocketError(error ) {
|
|
563
|
+
const parts = error instanceof AggregateError ? error.errors : [error];
|
|
564
|
+
const text = parts.map((e ) => e?.message || e?.code || String(e)).filter(Boolean);
|
|
565
|
+
return [...new Set(text)].join("; ") || error.name;
|
|
566
|
+
}
|