refactor(serve): remove tunnel/cloudflared, simplify to WS-only gateway
- Delete tunnel.ts (startTunnel/cloudflared), rename to gateway.ts - Remove --no-tunnel, --tunnel-url flags - ServeOptions: drop noTunnel, tunnelUrl fields - Two modes: gateway (with WORKFLOW_GATEWAY_SECRET) or local-only - WS reverse connection is the only gateway transport
This commit is contained in:
+4
-38
@@ -1,43 +1,9 @@
|
|||||||
import { printCliLine } from "../../cli-output.js";
|
import { printCliLine } from "../../cli-output.js";
|
||||||
|
|
||||||
type TunnelHandle = {
|
|
||||||
process: ReturnType<typeof Bun.spawn>;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
|
||||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
// cloudflared prints the URL to stderr
|
|
||||||
const reader = proc.stderr.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
const deadline = Date.now() + 30_000;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
||||||
if (match) {
|
|
||||||
// Release the reader so stderr keeps flowing without backpressure
|
|
||||||
reader.releaseLock();
|
|
||||||
return { process: proc, url: match[0] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.releaseLock();
|
|
||||||
proc.kill();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerWithGateway(
|
export async function registerWithGateway(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
tunnelUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
agentToken: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -45,7 +11,7 @@ export async function registerWithGateway(
|
|||||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
body: JSON.stringify({ name, url: localUrl, secret, agentToken }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
|
|||||||
export function startHeartbeat(
|
export function startHeartbeat(
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
tunnelUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
agentToken: string,
|
||||||
intervalMs: number,
|
intervalMs: number,
|
||||||
): ReturnType<typeof setInterval> {
|
): ReturnType<typeof setInterval> {
|
||||||
return setInterval(() => {
|
return setInterval(() => {
|
||||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
registerWithGateway(gatewayUrl, name, localUrl, secret, agentToken).catch(() => {});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { serve } from "bun";
|
|||||||
|
|
||||||
import { printCliLine } from "../../cli-output.js";
|
import { printCliLine } from "../../cli-output.js";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
|
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||||
import type { ServeOptions } from "./types.js";
|
import type { ServeOptions } from "./types.js";
|
||||||
import { startGatewayWsClient } from "./ws-client.js";
|
import { startGatewayWsClient } from "./ws-client.js";
|
||||||
|
|
||||||
@@ -52,8 +52,6 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|||||||
let port = 7860;
|
let port = 7860;
|
||||||
let hostname = "127.0.0.1";
|
let hostname = "127.0.0.1";
|
||||||
let name = osHostname().split(".")[0].toLowerCase();
|
let name = osHostname().split(".")[0].toLowerCase();
|
||||||
let noTunnel = false;
|
|
||||||
let tunnelUrl: string | null = null;
|
|
||||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||||
const stringFlags: Record<string, (v: string) => void> = {
|
const stringFlags: Record<string, (v: string) => void> = {
|
||||||
@@ -66,9 +64,6 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|||||||
"--gateway": (v) => {
|
"--gateway": (v) => {
|
||||||
gatewayUrl = v;
|
gatewayUrl = v;
|
||||||
},
|
},
|
||||||
"--tunnel-url": (v) => {
|
|
||||||
tunnelUrl = v;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
@@ -78,8 +73,6 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|||||||
if (!portResult.ok) return portResult;
|
if (!portResult.ok) return portResult;
|
||||||
port = portResult.value;
|
port = portResult.value;
|
||||||
i++;
|
i++;
|
||||||
} else if (arg === "--no-tunnel") {
|
|
||||||
noTunnel = true;
|
|
||||||
} else if (arg in stringFlags) {
|
} else if (arg in stringFlags) {
|
||||||
const r = requireNextArg(argv, i, arg);
|
const r = requireNextArg(argv, i, arg);
|
||||||
if (!r.ok) return r;
|
if (!r.ok) return r;
|
||||||
@@ -88,7 +81,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
|
return ok({ port, hostname, name, gatewayUrl, gatewaySecret });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
@@ -99,81 +92,62 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = parsed.value;
|
const options = parsed.value;
|
||||||
const agentToken = options.noTunnel ? null : randomUUID();
|
|
||||||
startServer(storageRoot, options, agentToken);
|
|
||||||
|
|
||||||
if (options.noTunnel) {
|
if (options.gatewaySecret === "") {
|
||||||
printCliLine("tunnel disabled (--no-tunnel)");
|
// No gateway — local-only mode
|
||||||
|
startServer(storageRoot, options, null);
|
||||||
|
printCliLine("no WORKFLOW_GATEWAY_SECRET — running in local-only mode");
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedTunnelUrl: string;
|
const agentToken = randomUUID();
|
||||||
let stopWsClient: (() => void) | null = null;
|
startServer(storageRoot, options, agentToken);
|
||||||
|
|
||||||
if (options.tunnelUrl !== null) {
|
// Start WebSocket reverse connection to gateway
|
||||||
resolvedTunnelUrl = options.tunnelUrl;
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
|
const stopWsClient = startGatewayWsClient({
|
||||||
} else {
|
gatewayUrl: options.gatewayUrl,
|
||||||
if (options.gatewaySecret === "") {
|
name: options.name,
|
||||||
printCliLine(
|
secret: options.gatewaySecret,
|
||||||
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
|
localPort: options.port,
|
||||||
);
|
log,
|
||||||
await new Promise(() => {});
|
});
|
||||||
return 0;
|
|
||||||
}
|
printCliLine("connected to gateway via WebSocket");
|
||||||
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
// Register with gateway for discovery
|
||||||
stopWsClient = startGatewayWsClient({
|
const localUrl = `http://127.0.0.1:${options.port}`;
|
||||||
gatewayUrl: options.gatewayUrl,
|
const registered = await registerWithGateway(
|
||||||
name: options.name,
|
options.gatewayUrl,
|
||||||
secret: options.gatewaySecret,
|
options.name,
|
||||||
localPort: options.port,
|
localUrl,
|
||||||
log,
|
options.gatewaySecret,
|
||||||
});
|
agentToken,
|
||||||
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
|
);
|
||||||
|
if (registered) {
|
||||||
|
printCliLine(`registered with gateway as "${options.name}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.gatewaySecret) {
|
const heartbeatTimer = startHeartbeat(
|
||||||
if (agentToken === null) {
|
options.gatewayUrl,
|
||||||
printCliLine("internal error: agent token missing");
|
options.name,
|
||||||
await new Promise(() => {});
|
localUrl,
|
||||||
return 1;
|
options.gatewaySecret,
|
||||||
}
|
agentToken,
|
||||||
const token = agentToken;
|
HEARTBEAT_INTERVAL_MS,
|
||||||
const registered = await registerWithGateway(
|
);
|
||||||
options.gatewayUrl,
|
|
||||||
options.name,
|
|
||||||
resolvedTunnelUrl,
|
|
||||||
options.gatewaySecret,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
if (registered) {
|
|
||||||
printCliLine(`registered with gateway as "${options.name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const heartbeatTimer = startHeartbeat(
|
const cleanup = async () => {
|
||||||
options.gatewayUrl,
|
clearInterval(heartbeatTimer);
|
||||||
options.name,
|
stopWsClient();
|
||||||
resolvedTunnelUrl,
|
printCliLine("unregistering from gateway...");
|
||||||
options.gatewaySecret,
|
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||||
token,
|
process.exit(0);
|
||||||
HEARTBEAT_INTERVAL_MS,
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
process.on("SIGINT", cleanup);
|
||||||
clearInterval(heartbeatTimer);
|
process.on("SIGTERM", cleanup);
|
||||||
stopWsClient?.();
|
|
||||||
printCliLine("unregistering from gateway...");
|
|
||||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", cleanup);
|
|
||||||
process.on("SIGTERM", cleanup);
|
|
||||||
} else {
|
|
||||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ export type ServeOptions = {
|
|||||||
port: number;
|
port: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
name: string;
|
name: string;
|
||||||
noTunnel: boolean;
|
|
||||||
tunnelUrl: string | null;
|
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
gatewaySecret: string;
|
gatewaySecret: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ ${commandSections.join("\n\n")}
|
|||||||
|
|
||||||
| Command | Args | Description |
|
| Command | Args | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with WebSocket gateway connection. \`--name\` registers with the gateway. |
|
||||||
|
|
||||||
## Typical Workflow
|
## Typical Workflow
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user