conductor-remote 1.64.0 → 1.65.1
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/README.md +11 -0
- package/dist/assets/index-87r9u-0C.css +1 -0
- package/dist/assets/index-fu-yJ_gW.js +47 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +193 -13
- package/dist-node/src/dev-server.js +693 -0
- package/dist-node/src/routes.js +4 -0
- package/dist-node/src/server.js +32 -0
- package/dist-node/src/writes.js +58 -9
- package/package.json +1 -1
- package/dist/assets/index-BhxPTIyU.css +0 -1
- package/dist/assets/index-C8s68scA.js +0 -47
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local workspace dev servers: Conductor owns the process; Tailscale Serve owns
|
|
3
|
+
* the tailnet-only HTTPS port.
|
|
4
|
+
*
|
|
5
|
+
* The relay never spawns a repository command itself. Starting and stopping go
|
|
6
|
+
* through Conductor's real Run button (`writes.ts`), which preserves its task
|
|
7
|
+
* selection, environment, run-mode rules and process-group cleanup. This module
|
|
8
|
+
* discovers that workspace's allocated `CONDUCTOR_PORT`, waits for it, and gives
|
|
9
|
+
* it a root-mounted HTTPS origin on the Mac's MagicDNS name.
|
|
10
|
+
*
|
|
11
|
+
* Tailscale's reverse proxy preserves the public Host header. Dev servers such
|
|
12
|
+
* as Vite reject that by default, so a tiny loopback bridge rewrites Host/Origin
|
|
13
|
+
* to the local target while leaving paths and WebSocket upgrades untouched.
|
|
14
|
+
* Root-relative assets and HMR therefore work without application-specific base
|
|
15
|
+
* paths or HTML rewriting.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http, {} from 'node:http';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { promisify } from 'node:util';
|
|
24
|
+
import { stateDir } from "./config.js";
|
|
25
|
+
import { magicDnsName, tailscaleBin } from "./tailscale.js";
|
|
26
|
+
import { setRunTask } from "./writes.js";
|
|
27
|
+
const exec = promisify(execFile);
|
|
28
|
+
const STORE_VERSION = 1;
|
|
29
|
+
const PORT_WAIT_MS = 15_000;
|
|
30
|
+
const PORT_SNAPSHOT_TTL_MS = 5000;
|
|
31
|
+
function validPort(value) {
|
|
32
|
+
return Number.isInteger(value) && value > 0 && value <= 65535;
|
|
33
|
+
}
|
|
34
|
+
function processAlive(pid) {
|
|
35
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0)
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function bridgeMatches(record) {
|
|
46
|
+
if (!record.bridgeToken) {
|
|
47
|
+
// Compatibility for the first local prototype receipts. PID plus an open
|
|
48
|
+
// bridge is enough to avoid stealing one during this upgrade; every newly
|
|
49
|
+
// written receipt carries the unambiguous challenge below.
|
|
50
|
+
return processAlive(record.ownerPid) ? tcpOpen(record.bridgePort) : Promise.resolve(false);
|
|
51
|
+
}
|
|
52
|
+
return new Promise(resolve => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
const finish = (matches) => {
|
|
55
|
+
if (settled)
|
|
56
|
+
return;
|
|
57
|
+
settled = true;
|
|
58
|
+
request.destroy();
|
|
59
|
+
resolve(matches);
|
|
60
|
+
};
|
|
61
|
+
const request = http.request({
|
|
62
|
+
host: '127.0.0.1',
|
|
63
|
+
port: record.bridgePort,
|
|
64
|
+
method: 'GET',
|
|
65
|
+
path: '/',
|
|
66
|
+
headers: { 'x-conductor-remote-bridge': record.bridgeToken }
|
|
67
|
+
}, response => {
|
|
68
|
+
response.resume();
|
|
69
|
+
finish(response.statusCode === 204);
|
|
70
|
+
});
|
|
71
|
+
request.setTimeout(500, () => finish(false));
|
|
72
|
+
request.once('error', () => finish(false));
|
|
73
|
+
request.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Parse a macOS `ps eww -axo command=` snapshot without ever logging it: process
|
|
78
|
+
* environments can contain credentials. Multiple processes are expected, but
|
|
79
|
+
* conflicting ports for one workspace are not safe to guess through.
|
|
80
|
+
*/
|
|
81
|
+
export function parseWorkspacePort(snapshot, workspaceId) {
|
|
82
|
+
const ports = new Set();
|
|
83
|
+
for (const line of snapshot.split('\n')) {
|
|
84
|
+
const id = line.match(/(?:^|\s)CONDUCTOR_WORKSPACE_ID=([^\s]+)/)?.[1];
|
|
85
|
+
if (id !== workspaceId)
|
|
86
|
+
continue;
|
|
87
|
+
const port = Number(line.match(/(?:^|\s)CONDUCTOR_PORT=(\d+)(?:\s|$)/)?.[1]);
|
|
88
|
+
if (validPort(port))
|
|
89
|
+
ports.add(port);
|
|
90
|
+
}
|
|
91
|
+
return ports.size === 1 ? [...ports][0] : null;
|
|
92
|
+
}
|
|
93
|
+
let portSnapshot = null;
|
|
94
|
+
let portSnapshotInFlight = null;
|
|
95
|
+
async function readProcesses() {
|
|
96
|
+
const { stdout } = await exec('ps', ['eww', '-axo', 'command='], {
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
99
|
+
timeout: 5000
|
|
100
|
+
});
|
|
101
|
+
portSnapshot = { at: Date.now(), text: stdout };
|
|
102
|
+
return stdout;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One `ps` snapshot shared by every workspace. It carries every process
|
|
106
|
+
* environment on the Mac (~650 kB here), and the phone polls this state every
|
|
107
|
+
* 2.5s per open chat, so a read reuses a recent one and simultaneous readers
|
|
108
|
+
* await the same child process. A workspace whose Run task is stopped has no
|
|
109
|
+
* port to remember, which is why the miss path needs this and not just the hit
|
|
110
|
+
* path. A start asks for `maxAgeMs` 0, because the task it just pressed is
|
|
111
|
+
* younger than any cached snapshot.
|
|
112
|
+
*/
|
|
113
|
+
function processSnapshot(maxAgeMs) {
|
|
114
|
+
if (maxAgeMs <= 0)
|
|
115
|
+
return readProcesses();
|
|
116
|
+
if (portSnapshot && Date.now() - portSnapshot.at <= maxAgeMs)
|
|
117
|
+
return Promise.resolve(portSnapshot.text);
|
|
118
|
+
portSnapshotInFlight ??= readProcesses().finally(() => {
|
|
119
|
+
portSnapshotInFlight = null;
|
|
120
|
+
});
|
|
121
|
+
return portSnapshotInFlight;
|
|
122
|
+
}
|
|
123
|
+
async function workspacePort(workspaceId, maxAgeMs = 0) {
|
|
124
|
+
try {
|
|
125
|
+
return parseWorkspacePort(await processSnapshot(maxAgeMs), workspaceId);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Never reflect the error: `execFile` includes stdout, which is the process
|
|
129
|
+
// environment snapshot and may contain secrets.
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function tcpOpen(port, timeoutMs = 300) {
|
|
134
|
+
return new Promise(resolve => {
|
|
135
|
+
const socket = net.connect({ host: '127.0.0.1', port });
|
|
136
|
+
const finish = (open) => {
|
|
137
|
+
socket.destroy();
|
|
138
|
+
resolve(open);
|
|
139
|
+
};
|
|
140
|
+
socket.setTimeout(timeoutMs);
|
|
141
|
+
socket.once('connect', () => finish(true));
|
|
142
|
+
socket.once('timeout', () => finish(false));
|
|
143
|
+
socket.once('error', () => finish(false));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function waitForPort(port, open, timeoutMs) {
|
|
147
|
+
const deadline = Date.now() + timeoutMs;
|
|
148
|
+
do {
|
|
149
|
+
if ((await tcpOpen(port)) === open)
|
|
150
|
+
return true;
|
|
151
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
152
|
+
} while (Date.now() < deadline);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
function externalOrigin(req) {
|
|
156
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? 'https')
|
|
157
|
+
.split(',')[0]
|
|
158
|
+
.trim();
|
|
159
|
+
const forwardedHost = String(req.headers['x-forwarded-host'] ?? req.headers.host ?? '')
|
|
160
|
+
.split(',')[0]
|
|
161
|
+
.trim();
|
|
162
|
+
return `${forwardedProto || 'https'}://${forwardedHost}`;
|
|
163
|
+
}
|
|
164
|
+
function localOrigin(targetPort) {
|
|
165
|
+
return `http://127.0.0.1:${targetPort}`;
|
|
166
|
+
}
|
|
167
|
+
function proxyHeaders(req, targetPort) {
|
|
168
|
+
const headers = { ...req.headers };
|
|
169
|
+
const publicHost = String(req.headers['x-forwarded-host'] ?? req.headers.host ?? '');
|
|
170
|
+
headers.host = `127.0.0.1:${targetPort}`;
|
|
171
|
+
if (publicHost)
|
|
172
|
+
headers['x-forwarded-host'] = publicHost;
|
|
173
|
+
headers['x-forwarded-proto'] = String(req.headers['x-forwarded-proto'] ?? 'https');
|
|
174
|
+
// Framework dev servers commonly apply the same allowlist to WebSocket Origin
|
|
175
|
+
// that they apply to Host. Present the local origin they were configured for.
|
|
176
|
+
if (headers.origin)
|
|
177
|
+
headers.origin = localOrigin(targetPort);
|
|
178
|
+
if (headers.referer) {
|
|
179
|
+
try {
|
|
180
|
+
const ref = new URL(headers.referer);
|
|
181
|
+
headers.referer = `${localOrigin(targetPort)}${ref.pathname}${ref.search}`;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Preserve an unusual Referer rather than inventing one.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return headers;
|
|
188
|
+
}
|
|
189
|
+
function rewriteLocation(location, req, targetPort) {
|
|
190
|
+
if (!location)
|
|
191
|
+
return undefined;
|
|
192
|
+
const local = new RegExp(`^https?://(?:127\\.0\\.0\\.1|localhost)(?::${targetPort})?`, 'i');
|
|
193
|
+
return location.replace(local, externalOrigin(req));
|
|
194
|
+
}
|
|
195
|
+
function writeUpgradeRequest(upstream, req, head, targetPort) {
|
|
196
|
+
const headers = proxyHeaders(req, targetPort);
|
|
197
|
+
const lines = [`${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/${req.httpVersion}`];
|
|
198
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
199
|
+
if (value === undefined)
|
|
200
|
+
continue;
|
|
201
|
+
if (Array.isArray(value)) {
|
|
202
|
+
for (const item of value)
|
|
203
|
+
lines.push(`${name}: ${item}`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
lines.push(`${name}: ${value}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
210
|
+
if (head.length)
|
|
211
|
+
upstream.write(head);
|
|
212
|
+
}
|
|
213
|
+
/** Loopback reverse proxy with raw WebSocket tunnelling for Vite-style HMR. */
|
|
214
|
+
export function createDevProxy(targetPort) {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
217
|
+
const sockets = new Set();
|
|
218
|
+
const server = http.createServer((req, res) => {
|
|
219
|
+
if (req.headers['x-conductor-remote-bridge'] === token) {
|
|
220
|
+
res.writeHead(204);
|
|
221
|
+
return res.end();
|
|
222
|
+
}
|
|
223
|
+
const upstream = http.request({
|
|
224
|
+
host: '127.0.0.1',
|
|
225
|
+
port: targetPort,
|
|
226
|
+
method: req.method,
|
|
227
|
+
path: req.url,
|
|
228
|
+
headers: proxyHeaders(req, targetPort)
|
|
229
|
+
}, upstreamRes => {
|
|
230
|
+
const headers = { ...upstreamRes.headers };
|
|
231
|
+
const location = rewriteLocation(upstreamRes.headers.location, req, targetPort);
|
|
232
|
+
if (location)
|
|
233
|
+
headers.location = location;
|
|
234
|
+
res.writeHead(upstreamRes.statusCode ?? 502, headers);
|
|
235
|
+
upstreamRes.pipe(res);
|
|
236
|
+
});
|
|
237
|
+
upstream.once('error', () => {
|
|
238
|
+
if (!res.headersSent)
|
|
239
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
240
|
+
res.end('Dev server is not reachable');
|
|
241
|
+
});
|
|
242
|
+
req.pipe(upstream);
|
|
243
|
+
});
|
|
244
|
+
server.on('connection', socket => {
|
|
245
|
+
sockets.add(socket);
|
|
246
|
+
socket.once('close', () => sockets.delete(socket));
|
|
247
|
+
});
|
|
248
|
+
server.on('upgrade', (req, socket, head) => {
|
|
249
|
+
const upstream = net.connect({ host: '127.0.0.1', port: targetPort }, () => {
|
|
250
|
+
writeUpgradeRequest(upstream, req, head, targetPort);
|
|
251
|
+
socket.pipe(upstream).pipe(socket);
|
|
252
|
+
});
|
|
253
|
+
sockets.add(upstream);
|
|
254
|
+
upstream.once('close', () => sockets.delete(upstream));
|
|
255
|
+
upstream.once('error', () => socket.destroy());
|
|
256
|
+
socket.once('error', () => upstream.destroy());
|
|
257
|
+
});
|
|
258
|
+
server.once('error', reject);
|
|
259
|
+
server.listen(0, '127.0.0.1', () => {
|
|
260
|
+
const address = server.address();
|
|
261
|
+
if (!address || typeof address === 'string') {
|
|
262
|
+
server.close();
|
|
263
|
+
return reject(new Error('could not allocate the dev-server bridge port'));
|
|
264
|
+
}
|
|
265
|
+
resolve({
|
|
266
|
+
port: address.port,
|
|
267
|
+
token,
|
|
268
|
+
close: () => new Promise(done => {
|
|
269
|
+
for (const socket of sockets)
|
|
270
|
+
socket.destroy();
|
|
271
|
+
server.close(() => done());
|
|
272
|
+
})
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
export function serveProxyAt(status, port) {
|
|
278
|
+
for (const [origin, web] of Object.entries(status.Web ?? {})) {
|
|
279
|
+
if (!origin.endsWith(`:${port}`))
|
|
280
|
+
continue;
|
|
281
|
+
return web.Handlers?.['/']?.Proxy ?? null;
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
function servePorts(status) {
|
|
286
|
+
return new Set(Object.keys(status.TCP ?? {})
|
|
287
|
+
.map(Number)
|
|
288
|
+
.filter(validPort));
|
|
289
|
+
}
|
|
290
|
+
function chooseServePort(status, targetPort) {
|
|
291
|
+
const used = servePorts(status);
|
|
292
|
+
for (let offset = 0; offset < 10; offset++) {
|
|
293
|
+
const candidate = targetPort + offset;
|
|
294
|
+
if (validPort(candidate) && !used.has(candidate))
|
|
295
|
+
return candidate;
|
|
296
|
+
}
|
|
297
|
+
for (let candidate = 49152; candidate <= 65535; candidate++) {
|
|
298
|
+
if (!used.has(candidate))
|
|
299
|
+
return candidate;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
function forwardUrl(record) {
|
|
304
|
+
return `https://${record.host}${record.servePort === 443 ? '' : `:${record.servePort}`}/`;
|
|
305
|
+
}
|
|
306
|
+
export class DevServerController {
|
|
307
|
+
storeFile;
|
|
308
|
+
bin;
|
|
309
|
+
host;
|
|
310
|
+
records = new Map();
|
|
311
|
+
proxies = new Map();
|
|
312
|
+
ports = new Map();
|
|
313
|
+
actions = new Map();
|
|
314
|
+
constructor(storeFile = path.join(stateDir(), 'dev-forwards.json')) {
|
|
315
|
+
this.storeFile = storeFile;
|
|
316
|
+
this.bin = tailscaleBin();
|
|
317
|
+
this.host = this.bin ? magicDnsName(this.bin) : null;
|
|
318
|
+
this.load();
|
|
319
|
+
}
|
|
320
|
+
refreshTailscale() {
|
|
321
|
+
this.bin ??= tailscaleBin();
|
|
322
|
+
if (this.bin && !this.host)
|
|
323
|
+
this.host = magicDnsName(this.bin);
|
|
324
|
+
}
|
|
325
|
+
load() {
|
|
326
|
+
try {
|
|
327
|
+
const parsed = JSON.parse(fs.readFileSync(this.storeFile, 'utf8'));
|
|
328
|
+
if (parsed.version !== STORE_VERSION || !Array.isArray(parsed.forwards))
|
|
329
|
+
return;
|
|
330
|
+
for (const record of parsed.forwards) {
|
|
331
|
+
if (record.workspaceId &&
|
|
332
|
+
validPort(record.targetPort) &&
|
|
333
|
+
validPort(record.servePort) &&
|
|
334
|
+
validPort(record.bridgePort) &&
|
|
335
|
+
typeof record.host === 'string' &&
|
|
336
|
+
record.host.length > 0) {
|
|
337
|
+
this.records.set(record.workspaceId, record);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// First run, or a disposable corrupt cache. Never touch an unrecognised
|
|
343
|
+
// Tailscale mapping without a valid record proving this relay owns it.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
save() {
|
|
347
|
+
try {
|
|
348
|
+
fs.mkdirSync(path.dirname(this.storeFile), { recursive: true });
|
|
349
|
+
const body = { version: STORE_VERSION, forwards: [...this.records.values()] };
|
|
350
|
+
fs.writeFileSync(this.storeFile, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.warn(`⚠ could not persist dev-server forwards (${err instanceof Error ? err.message : err})`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async portFor(workspaceId) {
|
|
357
|
+
const cached = this.ports.get(workspaceId);
|
|
358
|
+
if (cached)
|
|
359
|
+
return cached;
|
|
360
|
+
const discovered = await workspacePort(workspaceId, PORT_SNAPSHOT_TTL_MS);
|
|
361
|
+
if (discovered)
|
|
362
|
+
this.ports.set(workspaceId, discovered);
|
|
363
|
+
return discovered;
|
|
364
|
+
}
|
|
365
|
+
async serveStatus() {
|
|
366
|
+
if (!this.bin)
|
|
367
|
+
throw new Error('Tailscale CLI is not available on this Mac');
|
|
368
|
+
const { stdout } = await exec(this.bin, ['serve', 'status', '--json'], {
|
|
369
|
+
encoding: 'utf8',
|
|
370
|
+
timeout: 10_000
|
|
371
|
+
});
|
|
372
|
+
return JSON.parse(stdout);
|
|
373
|
+
}
|
|
374
|
+
async setServe(servePort, bridgePort) {
|
|
375
|
+
if (!this.bin)
|
|
376
|
+
throw new Error('Tailscale CLI is not available on this Mac');
|
|
377
|
+
await exec(this.bin, ['serve', '--bg', '--yes', `--https=${servePort}`, `http://127.0.0.1:${bridgePort}`], {
|
|
378
|
+
encoding: 'utf8',
|
|
379
|
+
timeout: 15_000
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
async unsetServe(record) {
|
|
383
|
+
if (!this.bin)
|
|
384
|
+
return;
|
|
385
|
+
try {
|
|
386
|
+
const status = await this.serveStatus();
|
|
387
|
+
// The user may have replaced this endpoint since the relay created it.
|
|
388
|
+
// Only remove the exact loopback bridge our persisted receipt names.
|
|
389
|
+
if (serveProxyAt(status, record.servePort) !== `http://127.0.0.1:${record.bridgePort}`)
|
|
390
|
+
return;
|
|
391
|
+
await exec(this.bin, ['serve', '--yes', `--https=${record.servePort}`, 'off'], {
|
|
392
|
+
encoding: 'utf8',
|
|
393
|
+
timeout: 15_000
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Cleanup is best-effort. Keep the record so the next relay start can
|
|
398
|
+
// identify and remove its own stale mapping rather than forgetting it.
|
|
399
|
+
throw new Error(`could not remove the Tailscale Serve mapping on :${record.servePort}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async release(workspaceId) {
|
|
403
|
+
const record = this.records.get(workspaceId);
|
|
404
|
+
const proxy = this.proxies.get(workspaceId);
|
|
405
|
+
let cleanupError;
|
|
406
|
+
if (record) {
|
|
407
|
+
try {
|
|
408
|
+
await this.unsetServe(record);
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
cleanupError = err;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Even when the Serve CLI is temporarily unavailable, close the bridge so
|
|
415
|
+
// its stale HTTPS mapping cannot reach a later process that reuses the port.
|
|
416
|
+
if (proxy)
|
|
417
|
+
await proxy.close();
|
|
418
|
+
this.proxies.delete(workspaceId);
|
|
419
|
+
if (!cleanupError)
|
|
420
|
+
this.records.delete(workspaceId);
|
|
421
|
+
this.save();
|
|
422
|
+
if (cleanupError)
|
|
423
|
+
throw cleanupError;
|
|
424
|
+
}
|
|
425
|
+
async forward(workspaceId, targetPort) {
|
|
426
|
+
const existing = this.records.get(workspaceId);
|
|
427
|
+
if (existing?.targetPort === targetPort) {
|
|
428
|
+
const status = await this.serveStatus();
|
|
429
|
+
const expected = `http://127.0.0.1:${existing.bridgePort}`;
|
|
430
|
+
if (serveProxyAt(status, existing.servePort) === expected && (await bridgeMatches(existing)))
|
|
431
|
+
return existing;
|
|
432
|
+
}
|
|
433
|
+
if (existing)
|
|
434
|
+
await this.release(workspaceId);
|
|
435
|
+
if (!this.bin || !this.host)
|
|
436
|
+
throw new Error('Tailscale is not connected on this Mac');
|
|
437
|
+
const status = await this.serveStatus();
|
|
438
|
+
const servePort = chooseServePort(status, targetPort);
|
|
439
|
+
if (!servePort)
|
|
440
|
+
throw new Error('no free Tailscale Serve port is available');
|
|
441
|
+
const proxy = await createDevProxy(targetPort);
|
|
442
|
+
try {
|
|
443
|
+
await this.setServe(servePort, proxy.port);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
// `tailscale serve` may have applied the mapping just before its child
|
|
447
|
+
// process timed out. Remove only this exact bridge if it did, so a failed
|
|
448
|
+
// action never leaves an untracked endpoint behind.
|
|
449
|
+
try {
|
|
450
|
+
const statusAfterFailure = await this.serveStatus();
|
|
451
|
+
if (serveProxyAt(statusAfterFailure, servePort) === `http://127.0.0.1:${proxy.port}` && this.bin) {
|
|
452
|
+
await exec(this.bin, ['serve', '--yes', `--https=${servePort}`, 'off'], {
|
|
453
|
+
encoding: 'utf8',
|
|
454
|
+
timeout: 15_000
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// The bridge closes below regardless, making any stale mapping inert.
|
|
460
|
+
}
|
|
461
|
+
await proxy.close();
|
|
462
|
+
throw err;
|
|
463
|
+
}
|
|
464
|
+
const record = {
|
|
465
|
+
workspaceId,
|
|
466
|
+
targetPort,
|
|
467
|
+
servePort,
|
|
468
|
+
bridgePort: proxy.port,
|
|
469
|
+
host: this.host,
|
|
470
|
+
ownerPid: process.pid,
|
|
471
|
+
bridgeToken: proxy.token
|
|
472
|
+
};
|
|
473
|
+
this.proxies.set(workspaceId, proxy);
|
|
474
|
+
this.records.set(workspaceId, record);
|
|
475
|
+
this.save();
|
|
476
|
+
return record;
|
|
477
|
+
}
|
|
478
|
+
/** Rebuild loopback bridges after launchd or the self-updater restarts the relay. */
|
|
479
|
+
async restore() {
|
|
480
|
+
this.refreshTailscale();
|
|
481
|
+
// Tailscale can come up after a login LaunchAgent. Retain the receipts in
|
|
482
|
+
// that case; a later tap can safely replace or remove their exact mappings.
|
|
483
|
+
if (!this.bin || !this.host)
|
|
484
|
+
return;
|
|
485
|
+
for (const record of [...this.records.values()]) {
|
|
486
|
+
try {
|
|
487
|
+
this.ports.set(record.workspaceId, record.targetPort);
|
|
488
|
+
// `yarn dev` runs another relay beside the installed LaunchAgent. A
|
|
489
|
+
// node --watch restart must not steal the installed relay's live bridge
|
|
490
|
+
// merely because both instances share the same state directory.
|
|
491
|
+
if (record.ownerPid !== process.pid && (await bridgeMatches(record)))
|
|
492
|
+
continue;
|
|
493
|
+
const status = await this.serveStatus();
|
|
494
|
+
const owned = serveProxyAt(status, record.servePort) === `http://127.0.0.1:${record.bridgePort}`;
|
|
495
|
+
if (!owned) {
|
|
496
|
+
this.records.delete(record.workspaceId);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (!(await tcpOpen(record.targetPort))) {
|
|
500
|
+
await this.release(record.workspaceId);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const proxy = await createDevProxy(record.targetPort);
|
|
504
|
+
await this.setServe(record.servePort, proxy.port);
|
|
505
|
+
record.bridgePort = proxy.port;
|
|
506
|
+
record.ownerPid = process.pid;
|
|
507
|
+
record.bridgeToken = proxy.token;
|
|
508
|
+
this.proxies.set(record.workspaceId, proxy);
|
|
509
|
+
this.records.set(record.workspaceId, record);
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
console.warn(`⚠ could not restore dev-server forward for ${record.workspaceId} (${err instanceof Error ? err.message : err})`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
this.save();
|
|
516
|
+
}
|
|
517
|
+
async state(workspace) {
|
|
518
|
+
this.refreshTailscale();
|
|
519
|
+
const record = this.records.get(workspace.id);
|
|
520
|
+
const port = (await this.portFor(workspace.id)) ?? record?.targetPort ?? null;
|
|
521
|
+
if (port)
|
|
522
|
+
this.ports.set(workspace.id, port);
|
|
523
|
+
const running = port ? await tcpOpen(port) : false;
|
|
524
|
+
let forwarded = false;
|
|
525
|
+
if (running && record) {
|
|
526
|
+
try {
|
|
527
|
+
const status = await this.serveStatus();
|
|
528
|
+
forwarded =
|
|
529
|
+
serveProxyAt(status, record.servePort) === `http://127.0.0.1:${record.bridgePort}` &&
|
|
530
|
+
(await bridgeMatches(record));
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// A disconnected CLI makes the URL unverified, never optimistically live.
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
let error;
|
|
537
|
+
if (!this.bin || !this.host)
|
|
538
|
+
error = 'Tailscale is not connected on this Mac';
|
|
539
|
+
return {
|
|
540
|
+
available: !!this.bin && !!this.host,
|
|
541
|
+
running,
|
|
542
|
+
forwarded,
|
|
543
|
+
port,
|
|
544
|
+
url: forwarded && record ? forwardUrl(record) : null,
|
|
545
|
+
error
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
exclusive(workspace, operation) {
|
|
549
|
+
const previous = this.actions.get(workspace.id);
|
|
550
|
+
// Two phones can tap opposite actions at once. Preserve their order instead
|
|
551
|
+
// of answering Stop with the in-flight Start result.
|
|
552
|
+
const action = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(operation);
|
|
553
|
+
this.actions.set(workspace.id, action);
|
|
554
|
+
void action
|
|
555
|
+
.finally(() => {
|
|
556
|
+
if (this.actions.get(workspace.id) === action)
|
|
557
|
+
this.actions.delete(workspace.id);
|
|
558
|
+
})
|
|
559
|
+
.catch(() => undefined);
|
|
560
|
+
return action;
|
|
561
|
+
}
|
|
562
|
+
start(workspace) {
|
|
563
|
+
return this.exclusive(workspace, async () => {
|
|
564
|
+
this.refreshTailscale();
|
|
565
|
+
if (!this.bin || !this.host)
|
|
566
|
+
return { ok: false, ...(await this.state(workspace)) };
|
|
567
|
+
try {
|
|
568
|
+
// Fail before pressing Run when this Mac cannot currently configure
|
|
569
|
+
// Serve. Starting a process the requested one-tap action cannot expose
|
|
570
|
+
// would leave the phone showing failure while the task keeps running.
|
|
571
|
+
await this.serveStatus();
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
...(await this.state(workspace)),
|
|
577
|
+
error: 'Tailscale Serve is not available on this Mac'
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
let port = await this.portFor(workspace.id);
|
|
581
|
+
// A task already listening needs no button. Forwarding it touches only
|
|
582
|
+
// Tailscale and this relay, so it costs about a second, steals no focus
|
|
583
|
+
// from the Mac, and stays inside the tap activation a phone can open a
|
|
584
|
+
// tab with. Pressing Run here would also assert a pane for a press it
|
|
585
|
+
// then decides not to make.
|
|
586
|
+
if (port && (await tcpOpen(port))) {
|
|
587
|
+
try {
|
|
588
|
+
await this.forward(workspace.id, port);
|
|
589
|
+
return { ok: true, ...(await this.state(workspace)), changed: false };
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
return {
|
|
593
|
+
ok: false,
|
|
594
|
+
...(await this.state(workspace)),
|
|
595
|
+
changed: false,
|
|
596
|
+
error: err instanceof Error ? err.message : String(err)
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const run = await setRunTask(workspace, true);
|
|
601
|
+
if (!run.ok)
|
|
602
|
+
return { ok: false, ...(await this.state(workspace)), error: run.error };
|
|
603
|
+
port ??= run.ports?.[0] ?? null;
|
|
604
|
+
if (!port)
|
|
605
|
+
port = await workspacePort(workspace.id);
|
|
606
|
+
if (port)
|
|
607
|
+
this.ports.set(workspace.id, port);
|
|
608
|
+
if (!port) {
|
|
609
|
+
const rollback = run.changed ? await setRunTask(workspace, false) : null;
|
|
610
|
+
const suffix = rollback
|
|
611
|
+
? rollback.ok
|
|
612
|
+
? '; it was stopped again'
|
|
613
|
+
: `; stopping it again also failed: ${rollback.error}`
|
|
614
|
+
: '';
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
...(await this.state(workspace)),
|
|
618
|
+
task: run.task,
|
|
619
|
+
changed: run.changed,
|
|
620
|
+
error: `Conductor started the task, but its CONDUCTOR_PORT wasn’t visible${suffix}`
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
if (!(await waitForPort(port, true, PORT_WAIT_MS))) {
|
|
624
|
+
const rollback = run.changed ? await setRunTask(workspace, false) : null;
|
|
625
|
+
const suffix = rollback
|
|
626
|
+
? rollback.ok
|
|
627
|
+
? '; it was stopped again'
|
|
628
|
+
: `; stopping it again also failed: ${rollback.error}`
|
|
629
|
+
: '';
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
...(await this.state(workspace)),
|
|
633
|
+
task: run.task,
|
|
634
|
+
changed: run.changed,
|
|
635
|
+
error: `${run.task ?? 'Run task'} started, but nothing listened on :${port}${suffix}`
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
await this.forward(workspace.id, port);
|
|
640
|
+
return { ok: true, ...(await this.state(workspace)), task: run.task, changed: run.changed };
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
const rollback = run.changed ? await setRunTask(workspace, false) : null;
|
|
644
|
+
const suffix = rollback
|
|
645
|
+
? rollback.ok
|
|
646
|
+
? '; it was stopped again'
|
|
647
|
+
: `; stopping it again also failed: ${rollback.error}`
|
|
648
|
+
: '';
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
...(await this.state(workspace)),
|
|
652
|
+
task: run.task,
|
|
653
|
+
changed: run.changed,
|
|
654
|
+
error: `${err instanceof Error ? err.message : String(err)}${suffix}`
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
stop(workspace) {
|
|
660
|
+
return this.exclusive(workspace, async () => {
|
|
661
|
+
const before = await this.state(workspace);
|
|
662
|
+
const run = await setRunTask(workspace, false);
|
|
663
|
+
if (!run.ok)
|
|
664
|
+
return { ok: false, ...before, error: run.error };
|
|
665
|
+
let cleanupError;
|
|
666
|
+
try {
|
|
667
|
+
await this.release(workspace.id);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
cleanupError = err;
|
|
671
|
+
}
|
|
672
|
+
if (before.port && !(await waitForPort(before.port, false, 5000))) {
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
...(await this.state(workspace)),
|
|
676
|
+
task: run.task,
|
|
677
|
+
changed: run.changed,
|
|
678
|
+
error: `${run.task ?? 'Run task'} stopped, but :${before.port} is still listening`
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (cleanupError) {
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
...(await this.state(workspace)),
|
|
685
|
+
task: run.task,
|
|
686
|
+
changed: run.changed,
|
|
687
|
+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
return { ok: true, ...(await this.state(workspace)), task: run.task, changed: run.changed };
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|