Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@
}
},
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.lib.json",
"build": "pnpm run build:hosted-browser-module && rm -rf dist && tsc -p tsconfig.lib.json",
"build:hosted-browser-module": "tsx scripts/build-hosted-browser-module.ts",
"check": "pnpm run typecheck && pnpm run lint:check && pnpm run format:check",
"typecheck": "tsc -p tsconfig.json && pnpm --recursive --filter '!captun' --if-present run typecheck",
"format": "oxfmt",
"format:check": "oxfmt --check",
"lint": "oxlint . --threads 1 --deny-warnings",
"lint:check": "pnpm run lint",
"lint:fix": "oxlint . --fix",
"deploy": "wrangler deploy",
"deploy": "pnpm run build:hosted-browser-module && wrangler deploy",
"dev": "wrangler dev",
"test": "vitest run",
"test:unit": "vitest run test/worker.test.ts",
Expand All @@ -94,7 +95,10 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260515.1",
"@codemirror/lang-javascript": "6.2.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@types/node": "^25.8.0",
"codemirror": "6.0.1",
"esbuild": "^0.28.0",
"miniflare": "^4.20260515.0",
"oxfmt": "^0.35.0",
Expand Down
895 changes: 895 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions scripts/build-hosted-browser-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(await readFile(resolve(repoRoot, "package.json"), "utf8")) as {
dependencies: { capnweb: string };
};
const capnwebVersion = packageJson.dependencies.capnweb.replace(/^[^\d]*/, "");

const result = await build({
absWorkingDir: repoRoot,
bundle: true,
entryPoints: ["src/index.ts"],
external: [`https://esm.sh/capnweb@${capnwebVersion}`],
format: "esm",
keepNames: false,
minify: false,
platform: "browser",
plugins: [
{
name: "browser-capnweb-import",
setup(build) {
build.onResolve({ filter: /^capnweb$/ }, () => ({
external: true,
path: `https://esm.sh/capnweb@${capnwebVersion}`,
}));
},
},
],
sourcemap: false,
target: "es2022",
write: false,
});

const output = result.outputFiles[0]?.text;
if (!output) throw new Error("esbuild did not produce a browser module");

await writeFile(
resolve(repoRoot, "src/hosted/browser-module.generated.ts"),
[
"// Generated by scripts/build-hosted-browser-module.ts.",
"// Do not edit by hand.",
"export const WWW_BROWSER_MODULE =",
` ${jsStringLiteral(output)};`,
"",
].join("\n"),
);

function jsStringLiteral(value: string) {
return `'${value
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029")}'`;
}
4 changes: 4 additions & 0 deletions src/hosted/browser-module.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Generated by scripts/build-hosted-browser-module.ts.
// Do not edit by hand.
export const WWW_BROWSER_MODULE =
'// src/index.ts\nimport { newWebSocketRpcSession as newWebSocketRpcSession2, RpcTarget } from "https://esm.sh/capnweb@0.8.0";\n\n// src/routing.ts\nvar HOSTED_CAPTUN_HOSTNAME = "captun.sh";\nvar HOSTED_CAPTUN_GATEWAY = "https://captun.sh";\nvar GATEWAY_CONNECT_QUERY_PARAM = "captun-connect";\nvar TUNNEL_NAME_QUERY_PARAM = "captun-name";\nvar CONNECT_TOKEN_QUERY_PARAM = "captun-token";\nvar TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic";\n\n// src/server-core.ts\nimport { newWebSocketRpcSession } from "https://esm.sh/capnweb@0.8.0";\nfunction fetcherStubFromRemoteCapability(remote, options) {\n remote.onRpcBroken(() => options.onDisconnect?.());\n return {\n fetch: (request) => remote.fetch(request),\n ready: (tunnel) => remote.ready(tunnel),\n [Symbol.dispose]: () => remote[Symbol.dispose]()\n };\n}\nfunction acceptFetcherCapabilityFromSocket(socket, options = {}) {\n const remote = newWebSocketRpcSession(socket);\n return fetcherStubFromRemoteCapability(remote, options);\n}\n\n// src/token.ts\nfunction randomConnectToken() {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");\n}\n\n// src/index.ts\nvar CaptunTunnelConnectError = class extends Error {\n response;\n constructor(message, response) {\n super(message);\n this.name = "CaptunTunnelConnectError";\n this.response = response;\n }\n};\nvar TUNNEL_READY_TIMEOUT_MS = 5e3;\nvar WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500;\nasync function createCaptunTunnel(options) {\n const connect = gatewayConnectRequest(options);\n const ready = Promise.withResolvers();\n const socket = createWebSocket(connect.url);\n const fetcher = new TunnelTargetFetcher({\n fetch: options.fetch,\n ready: (tunnel) => ready.resolve(tunnel)\n });\n const session = newWebSocketRpcSession2(socket, fetcher);\n try {\n await waitUntilOpen(socket, connect.url);\n const tunnel = await waitUntilReady(ready.promise);\n return {\n url: tunnel.url,\n token: tunnel.token || connect.token,\n [Symbol.dispose]: () => session[Symbol.dispose]()\n };\n } catch (error) {\n session[Symbol.dispose]();\n throw error;\n }\n}\nfunction gatewayConnectRequest(options) {\n const name = options.name || randomTunnelName();\n const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY);\n const token = options.token || (isHostedCaptunGateway(url) ? randomConnectToken() : void 0);\n url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1");\n url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name);\n if (token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, token);\n return { url: url.toString(), name, token };\n}\nfunction isHostedCaptunGateway(url) {\n return url.hostname === HOSTED_CAPTUN_HOSTNAME;\n}\nfunction randomTunnelName() {\n const bytes = new Uint8Array(8);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");\n}\nvar TunnelTargetFetcher = class extends RpcTarget {\n fetcher;\n onReady;\n constructor(options) {\n super();\n this.fetcher = { fetch: options.fetch };\n this.onReady = options.ready;\n }\n fetch(request) {\n return this.fetcher.fetch(request);\n }\n ready(tunnel) {\n this.onReady(tunnel);\n }\n};\nfunction createWebSocket(url) {\n const connectUrl = new URL(url);\n connectUrl.protocol = connectUrl.protocol === "https:" ? "wss:" : "ws:";\n return new WebSocket(connectUrl.href);\n}\nasync function waitUntilOpen(socket, connectUrl) {\n if (socket.readyState === WebSocket.OPEN) return;\n if (socket.readyState !== WebSocket.CONNECTING) {\n throw new Error("WebSocket closed before opening");\n }\n const listeners = new AbortController();\n await new Promise((resolve, reject) => {\n const settle = (callback) => {\n listeners.abort();\n callback();\n };\n socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal });\n socket.addEventListener(\n "error",\n () => settle(() => {\n void webSocketConnectionFailedError(connectUrl).then(reject);\n }),\n { signal: listeners.signal }\n );\n socket.addEventListener(\n "close",\n (event) => {\n listeners.abort();\n void webSocketConnectionFailedError(connectUrl).then((error) => {\n reject(\n error.response ? error : new Error(`WebSocket closed before opening: ${event.code} ${event.reason}`)\n );\n });\n },\n { signal: listeners.signal }\n );\n });\n}\nasync function webSocketConnectionFailedError(connectUrl) {\n const response = await readWebSocketRejection(connectUrl);\n if (!response) return new CaptunTunnelConnectError("WebSocket connection failed", void 0);\n return new CaptunTunnelConnectError(\n `WebSocket connection failed: ${response.status} ${response.statusText}: ${response.body}`.trim(),\n response\n );\n}\nasync function readWebSocketRejection(connectUrl) {\n const abort = new AbortController();\n const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS);\n try {\n const response = await fetch(connectUrl, {\n headers: { [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1" },\n signal: abort.signal\n });\n if (response.ok) return void 0;\n return {\n status: response.status,\n statusText: response.statusText || "Rejected",\n body: (await response.text()).trim()\n };\n } catch {\n return void 0;\n } finally {\n clearTimeout(timeout);\n }\n}\nasync function waitUntilReady(promise) {\n let timeout;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timeout = setTimeout(\n () => reject(new Error("Timed out waiting for tunnel gateway ready message")),\n TUNNEL_READY_TIMEOUT_MS\n );\n })\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\nfunction acceptFetcherCapability(options = {}) {\n const pair = new WebSocketPair();\n const clientSocket = pair[0];\n const serverSocket = pair[1];\n serverSocket.accept();\n const fetcher = acceptFetcherCapabilityFromSocket(serverSocket, options);\n return {\n fetcher,\n response: new Response(null, { status: 101, webSocket: clientSocket })\n };\n}\nexport {\n CaptunTunnelConnectError,\n acceptFetcherCapability,\n acceptFetcherCapabilityFromSocket,\n createCaptunTunnel\n};\n';
Loading
Loading