Skip to content

Commit 36d4ae1

Browse files
committed
Use @hongminhee/localtunnel in startTunnel
1 parent 2fd8df1 commit 36d4ae1

4 files changed

Lines changed: 28 additions & 90 deletions

File tree

examples/rfc-9421-test/deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"imports": {
3+
"@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.3.0",
34
"hono": "jsr:@hono/hono@^4.7.1"
45
},
56
"tasks": {

examples/rfc-9421-test/dev.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,29 @@ const logger = getLogger(["fedify", "examples", "rfc-9421-test", "dev"]);
1414
const port = parseInt(Deno.env.get("PORT") ?? "8000", 10);
1515

1616
// 1. Start the tunnel (owned by this process, not the watched child).
17-
const tunnel = await startTunnel(port, 30_000);
17+
const tunnel = await startTunnel(port);
1818
if (!tunnel) {
1919
logger.error("Tunnel failed. Aborting dev mode.");
2020
Deno.exit(1);
2121
}
22-
logger.info("Tunnel ready: {url}", { url: tunnel.url });
22+
logger.info("Tunnel ready: {url}", { url: tunnel.url.href });
2323

2424
// 2. Spawn the server in --watch mode, passing the tunnel URL via ORIGIN.
2525
const child = new Deno.Command("deno", {
2626
args: ["run", "-A", "--watch", "main.ts"],
2727
cwd: import.meta.dirname!,
28-
env: { ...Deno.env.toObject(), ORIGIN: tunnel.url },
28+
env: { ...Deno.env.toObject(), ORIGIN: tunnel.url.href },
2929
stdin: "inherit",
3030
stdout: "inherit",
3131
stderr: "inherit",
3232
}).spawn();
3333

3434
// 3. Clean up on SIGINT: kill server, then tunnel.
35-
Deno.addSignalListener("SIGINT", () => {
35+
Deno.addSignalListener("SIGINT", async () => {
3636
child.kill("SIGTERM");
37-
tunnel.child.kill("SIGTERM");
37+
await tunnel.close();
3838
Deno.exit(0);
3939
});
4040

4141
await child.status;
42-
tunnel.child.kill("SIGTERM");
42+
await tunnel.close();

examples/rfc-9421-test/main.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,18 @@ if (import.meta.main) {
6969
const origin = Deno.env.get("ORIGIN");
7070
if (origin) {
7171
logger.info("Public URL (external tunnel): {url}", { url: origin });
72-
logger.info("Actor: @{id}@{host}", {
73-
id: ACTOR_ID,
74-
host: new URL(origin).hostname,
72+
logger.info("Actor: {actor}", {
73+
actor: `@${ACTOR_ID}@${new URL(origin).hostname}`,
7574
});
7675
} else {
77-
const tunnel = await startTunnel(port, 30_000);
76+
const tunnel = await startTunnel(port);
7877
if (tunnel) {
79-
logger.info("Public URL: {url}", { url: tunnel.url });
78+
logger.info("Public URL: {url}", { url: tunnel.url.href });
8079
logger.info("Actor: {actor}", {
81-
actor: `@${ACTOR_ID}@${new URL(tunnel.url).hostname}`,
80+
actor: `@${ACTOR_ID}@${tunnel.url.hostname}`,
8281
});
83-
Deno.addSignalListener("SIGINT", () => {
84-
tunnel.child.kill("SIGTERM");
82+
Deno.addSignalListener("SIGINT", async () => {
83+
await tunnel.close();
8584
Deno.exit(0);
8685
});
8786
} else {

examples/rfc-9421-test/tunnel.ts

Lines changed: 14 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,23 @@
1-
import $, { type CommandChild } from "@david/dax";
1+
import { openTunnel, type Tunnel } from "@hongminhee/localtunnel";
22
import { getLogger } from "@logtape/logtape";
33

4+
const logger = getLogger(["fedify", "examples", "tunnel"]);
5+
46
/**
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.
1010
*/
1111
export default async function startTunnel(
1212
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 });
8015
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;
8422
}
8523
}

0 commit comments

Comments
 (0)