@stage-labs/metro 0.1.0-beta.55 → 0.1.0-beta.57

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.55",
3
+ "version": "0.1.0-beta.57",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,4 +38,4 @@ export function publicConnectHint(url: string, owner: string | null): string {
38
38
  export const tunnelPendingHint = (kind: 'quick' | 'tailscale'): string =>
39
39
  kind === 'quick'
40
40
  ? 'Bringing up a public address through a Cloudflare quick tunnel…\n'
41
- : 'Publishing this daemon through Tailscale Funnel…\n';
41
+ : 'Publishing this daemon through Tailscale Funnel… (if Tailscale asks to enable Funnel, its link is in the log)\n';
@@ -1,4 +1,4 @@
1
- import { spawn, type ChildProcess } from 'node:child_process';
1
+ import { execFile, spawn, type ChildProcess } from 'node:child_process';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { Resolver } from 'node:dns/promises';
4
4
  import { homedir } from 'node:os';
@@ -7,6 +7,9 @@ import { errMsg, log } from './log.js';
7
7
  import { readJson } from './secure-fs.js';
8
8
 
9
9
  const RESTART_DELAY_MS = 2_000;
10
+ const RESTART_DELAY_MAX_MS = 30_000;
11
+ const TAKEN_RE = /listener already exists/i;
12
+ const FUNNEL_ON_RE = /\(Funnel on\)/i;
10
13
 
11
14
  export type TunnelKind = 'quick' | 'tailscale';
12
15
 
@@ -27,12 +30,43 @@ export const quickTunnelUrlIn = (text: string): string | null =>
27
30
  export const funnelUrlIn = (text: string): string | null =>
28
31
  FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
29
32
 
33
+ export interface Adopted {
34
+ url: string | null;
35
+ hint: string;
36
+ }
37
+
30
38
  export interface TunnelDriver {
31
39
  name: string;
32
40
  command: string;
33
41
  args: string[];
34
42
  urlIn: (text: string) => string | null;
35
43
  waitsForDns: boolean;
44
+ adopt?: () => Promise<Adopted>;
45
+ }
46
+
47
+ const runText = (command: string, args: string[]): Promise<string> =>
48
+ new Promise((resolve) => {
49
+ execFile(command, args, { encoding: 'utf8', timeout: 10_000 }, (_err, stdout, stderr) => {
50
+ resolve(`${stdout}\n${stderr}`);
51
+ });
52
+ });
53
+
54
+ export function funnelAlreadyServing(status: string, port: number): Adopted {
55
+ const lines = status.split('\n');
56
+ const target = `http://127.0.0.1:${String(port)}`;
57
+ for (const [i, line] of lines.entries()) {
58
+ if (line.trimStart().startsWith('#')) continue;
59
+ const url = funnelUrlIn(line);
60
+ if (url === null) continue;
61
+ const body = lines.slice(i + 1, i + 6).join('\n');
62
+ if (!body.includes(target)) continue;
63
+ if (FUNNEL_ON_RE.test(line)) return { url, hint: `a Funnel already publishes this daemon at ${url}; using it` };
64
+ return {
65
+ url: null,
66
+ hint: `${url} is a tailnet-only serve config on this node, not a Funnel; run: tailscale serve reset`,
67
+ };
68
+ }
69
+ return { url: null, hint: 'port 443 is held by another serve config on this node; run: tailscale serve reset' };
36
70
  }
37
71
 
38
72
  export const quickDriver = (port: number): TunnelDriver => ({
@@ -53,7 +87,8 @@ export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver =
53
87
  command: bin,
54
88
  args: ['funnel', String(port)],
55
89
  urlIn: funnelUrlIn,
56
- waitsForDns: false,
90
+ waitsForDns: true,
91
+ adopt: async () => funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port),
57
92
  });
58
93
 
59
94
  export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
@@ -65,18 +100,23 @@ export const currentTunnelUrl = (): string | null => liveUrl;
65
100
 
66
101
  export type Resolves = (host: string) => Promise<boolean>;
67
102
 
68
- const PUBLIC_RESOLVERS = ['1.1.1.1', '1.0.0.1'];
103
+ const PUBLIC_RESOLVERS = ['1.1.1.1', '8.8.8.8'];
69
104
  const RESOLVE_EVERY_MS = 3_000;
70
105
  const RESOLVE_GIVE_UP_MS = 180_000;
71
106
 
72
- export async function resolvesAtCloudflare(host: string): Promise<boolean> {
107
+ async function resolvesAt(server: string, host: string): Promise<boolean> {
73
108
  const resolver = new Resolver();
74
- resolver.setServers(PUBLIC_RESOLVERS);
75
- try {
76
- return (await resolver.resolve4(host)).length > 0;
77
- } catch {
78
- return false;
79
- }
109
+ resolver.setServers([server]);
110
+ const answers = await Promise.all([
111
+ resolver.resolve4(host).catch(() => []),
112
+ resolver.resolve6(host).catch(() => []),
113
+ ]);
114
+ return answers.some((a) => a.length > 0);
115
+ }
116
+
117
+ export async function resolvesAtCloudflare(host: string): Promise<boolean> {
118
+ const found = await Promise.all(PUBLIC_RESOLVERS.map((server) => resolvesAt(server, host)));
119
+ return found.some(Boolean);
80
120
  }
81
121
 
82
122
  async function untilResolvable(host: string, resolves: Resolves): Promise<boolean> {
@@ -148,6 +188,7 @@ export class Tunnel {
148
188
  private child: ChildProcess | null = null;
149
189
  private closed = false;
150
190
  private output: string[] = [];
191
+ private restartDelay = RESTART_DELAY_MS;
151
192
 
152
193
  constructor(
153
194
  private driver: TunnelDriver,
@@ -159,6 +200,7 @@ export class Tunnel {
159
200
  const url = this.driver.urlIn(text);
160
201
  if (url === null || url === liveUrl) return;
161
202
  liveUrl = url;
203
+ this.restartDelay = RESTART_DELAY_MS;
162
204
  if (!this.driver.waitsForDns) {
163
205
  log.info({ url }, `${this.driver.name} up`);
164
206
  this.onUrl(url);
@@ -212,10 +254,9 @@ export class Tunnel {
212
254
  this.child = null;
213
255
  liveUrl = null;
214
256
  if (this.closed) return;
215
- log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
216
- setTimeout(() => {
217
- this.start();
218
- }, RESTART_DELAY_MS);
257
+ this.afterExit(code).catch((err: unknown) => {
258
+ log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
259
+ });
219
260
  });
220
261
  child.on('error', (err) => {
221
262
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -230,6 +271,24 @@ export class Tunnel {
230
271
  });
231
272
  }
232
273
 
274
+ private async afterExit(code: number | null): Promise<void> {
275
+ const output = this.output.join('\n');
276
+ if (this.driver.adopt !== undefined && TAKEN_RE.test(output)) {
277
+ const found = await this.driver.adopt();
278
+ if (this.closed) return;
279
+ if (found.url !== null) {
280
+ log.info({ url: found.url }, `${this.driver.name}: ${found.hint}`);
281
+ this.noticeUrl(found.url);
282
+ return;
283
+ }
284
+ log.error({ output }, `${this.driver.name}: ${found.hint}`);
285
+ } else log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
286
+ setTimeout(() => {
287
+ this.start();
288
+ }, this.restartDelay);
289
+ this.restartDelay = Math.min(this.restartDelay * 2, RESTART_DELAY_MAX_MS);
290
+ }
291
+
233
292
  stop(): void {
234
293
  this.closed = true;
235
294
  this.child?.kill('SIGINT');
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.55"
2
+ "version": "0.1.0-beta.57"
3
3
  }