|
1 | | -import $, { type CommandChild } from "@david/dax"; |
| 1 | +import { openTunnel, type Tunnel } from "@hongminhee/localtunnel"; |
2 | 2 | import { getLogger } from "@logtape/logtape"; |
3 | 3 |
|
| 4 | +const logger = getLogger(["fedify", "examples", "tunnel"]); |
| 5 | + |
4 | 6 | /** |
5 | | - * Starts `fedify tunnel -s pinggy.io <port>` and waits up to `timeoutMs` |
6 | | - * for the tunnel URL to appear in its output. The tunnel process is kept |
7 | | - * alive and returned to the caller; it must be killed when no longer needed. |
8 | | - * |
9 | | - * Returns `null` if the URL was not found before the timeout. |
| 7 | + * Opens a tunnel to expose a local port using `@hongminhee/localtunnel`. |
| 8 | + * Returns the {@link Tunnel} object (with `.url` and `.close()`), or `null` |
| 9 | + * if it fails. |
10 | 10 | */ |
11 | 11 | export default async function startTunnel( |
12 | 12 | port: number, |
13 | | - timeoutMs: number, |
14 | | -): Promise<{ child: CommandChild; url: string } | null> { |
15 | | - const tunnelLogger = getLogger(["fedify", "examples", "tunnel"]); |
16 | | - tunnelLogger.info("Opening localhost.run tunnel on port {port}", { port }); |
17 | | - |
18 | | - const child = $`mise cli tunnel -s pinggy.io ${String(port)}` |
19 | | - .stdout("piped") |
20 | | - .stderr("piped") |
21 | | - .noThrow() |
22 | | - .spawn(); |
23 | | - |
24 | | - // Accumulate text from both streams while logging each chunk at DEBUG. |
25 | | - const textChunks: string[] = []; |
26 | | - const decoder = new TextDecoder(); |
27 | | - |
28 | | - const readStream = (stream: ReadableStream<Uint8Array>) => { |
29 | | - (async () => { |
30 | | - const reader = stream.getReader(); |
31 | | - try { |
32 | | - while (true) { |
33 | | - const { done, value } = await reader.read(); |
34 | | - if (done) break; |
35 | | - const text = decoder.decode(value, { stream: true }); |
36 | | - textChunks.push(text); |
37 | | - const trimmed = text.trim(); |
38 | | - if (trimmed) tunnelLogger.debug("{output}", { output: trimmed }); |
39 | | - } |
40 | | - } catch { |
41 | | - // Stream may error when the process is killed. |
42 | | - } |
43 | | - })(); |
44 | | - }; |
45 | | - |
46 | | - readStream(child.stdout()); |
47 | | - readStream(child.stderr()); |
48 | | - |
49 | | - // Poll until we find an https URL in the accumulated output. |
50 | | - // The `message` template tag from @optique/run may wrap the URL in double |
51 | | - // quotes in non-TTY output, so we stop matching at whitespace or quotes. |
52 | | - const deadline = Date.now() + timeoutMs; |
53 | | - while (Date.now() < deadline) { |
54 | | - const match = textChunks.join("").match(/https:\/\/[^\s"']+/); |
55 | | - if (match) { |
56 | | - tunnelLogger.info("Tunnel established at {url}", { url: match[0] }); |
57 | | - return { child, url: match[0] }; |
58 | | - } |
59 | | - await new Promise((r) => setTimeout(r, 200)); |
60 | | - } |
61 | | - |
62 | | - tunnelLogger.error( |
63 | | - "Tunnel did not produce a URL within {timeout} ms", |
64 | | - { timeout: timeoutMs }, |
65 | | - ); |
66 | | - forceKillChild(child); |
67 | | - return null; |
68 | | -} |
69 | | - |
70 | | -/** |
71 | | - * Sends SIGKILL to `child` immediately. A rejection handler is attached to |
72 | | - * the CommandChild promise (which extends Promise<CommandResult>) so that the |
73 | | - * eventual rejection from the killed process does not surface as an unhandled |
74 | | - * promise rejection. We intentionally do **not** await the promise because |
75 | | - * dax keeps it pending until all piped streams are fully consumed, which may |
76 | | - * never happen once the process is forcibly killed. |
77 | | - */ |
78 | | -function forceKillChild(child: CommandChild): void { |
79 | | - child.catch(() => {}); |
| 13 | +): Promise<Tunnel | null> { |
| 14 | + logger.info("Opening tunnel on port {port}…", { port }); |
80 | 15 | try { |
81 | | - child.kill("SIGKILL"); |
82 | | - } catch { |
83 | | - // Process already exited. |
| 16 | + const tunnel = await openTunnel({ port, service: "pinggy.io" }); |
| 17 | + logger.info("Tunnel established at {url}", { url: tunnel.url.href }); |
| 18 | + return tunnel; |
| 19 | + } catch (error) { |
| 20 | + logger.error("Failed to open tunnel: {error}", { error }); |
| 21 | + return null; |
84 | 22 | } |
85 | 23 | } |
0 commit comments