@aprovan/mcp-app-server 0.1.0-dev.4d82df8

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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +23 -0
  2. package/E2E_TESTING.md +224 -0
  3. package/LICENSE +373 -0
  4. package/README.md +164 -0
  5. package/dist/index.d.ts +128 -0
  6. package/dist/index.js +990 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  9. package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
  10. package/dist/runtime/index.html +38 -0
  11. package/dist/server.d.ts +6 -0
  12. package/dist/server.js +1511 -0
  13. package/dist/server.js.map +1 -0
  14. package/dist/shell/shell.js +67 -0
  15. package/docs/widget-preview.png +0 -0
  16. package/e2e/__snapshots__/.gitkeep +0 -0
  17. package/e2e/global-setup.ts +114 -0
  18. package/e2e/global-teardown.ts +15 -0
  19. package/e2e/visual-regression.test.ts +86 -0
  20. package/e2e/widget-smoke.test.ts +109 -0
  21. package/index.html +32 -0
  22. package/package.json +51 -0
  23. package/playwright.config.ts +43 -0
  24. package/src/__tests__/live-update.test.ts +158 -0
  25. package/src/__tests__/local-backend.test.ts +100 -0
  26. package/src/__tests__/memory-backend.ts +144 -0
  27. package/src/__tests__/registry-backend.test.ts +256 -0
  28. package/src/__tests__/services.test.ts +153 -0
  29. package/src/__tests__/shim.test.ts +60 -0
  30. package/src/__tests__/widget-store.test.ts +188 -0
  31. package/src/e2e-visual.ts +148 -0
  32. package/src/index.ts +608 -0
  33. package/src/live-update.ts +150 -0
  34. package/src/logger.ts +44 -0
  35. package/src/reference-widgets/live-dashboard.ts +162 -0
  36. package/src/registry-backend.ts +306 -0
  37. package/src/runtime/index.html +38 -0
  38. package/src/runtime/main.ts +164 -0
  39. package/src/scripts/export-artifacts.ts +51 -0
  40. package/src/server.ts +398 -0
  41. package/src/services.ts +307 -0
  42. package/src/shell/main.ts +172 -0
  43. package/src/shim.ts +106 -0
  44. package/src/tunnel.ts +123 -0
  45. package/src/widget-store/index.ts +10 -0
  46. package/src/widget-store/local-backend.ts +77 -0
  47. package/src/widget-store/store.ts +242 -0
  48. package/src/widget-store/types.ts +53 -0
  49. package/tsconfig.json +10 -0
  50. package/tsup.config.ts +14 -0
  51. package/vite.runtime.config.ts +28 -0
  52. package/vite.shell.config.ts +30 -0
  53. package/vitest.config.ts +7 -0
@@ -0,0 +1,307 @@
1
+ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+
4
+ export interface ServiceBackend {
5
+ call(namespace: string, procedure: string, args: unknown[]): Promise<unknown>;
6
+ }
7
+
8
+ export interface ServiceToolInfo {
9
+ name: string;
10
+ namespace: string;
11
+ procedure: string;
12
+ description?: string;
13
+ parameters?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface ServiceBridgeConfig {
17
+ backend: ServiceBackend;
18
+ tools: ServiceToolInfo[];
19
+ }
20
+
21
+ const TOOL_SEPARATOR = "__";
22
+
23
+ function toMcpToolName(namespace: string, procedure: string): string {
24
+ return `${namespace}${TOOL_SEPARATOR}${procedure}`;
25
+ }
26
+
27
+ function jsonSchemaToZodShape(schema?: Record<string, unknown>): Record<string, z.ZodTypeAny> {
28
+ const properties = (schema?.["properties"] ?? {}) as Record<
29
+ string,
30
+ { type?: string; description?: string }
31
+ >;
32
+ const required = new Set((schema?.["required"] ?? []) as string[]);
33
+
34
+ const shape: Record<string, z.ZodTypeAny> = {};
35
+ for (const [key, prop] of Object.entries(properties)) {
36
+ let field: z.ZodTypeAny;
37
+ switch (prop.type) {
38
+ case "number":
39
+ case "integer":
40
+ field = z.number();
41
+ break;
42
+ case "boolean":
43
+ field = z.boolean();
44
+ break;
45
+ case "array":
46
+ field = z.array(z.unknown());
47
+ break;
48
+ case "object":
49
+ field = z.record(z.unknown());
50
+ break;
51
+ default:
52
+ field = z.string();
53
+ break;
54
+ }
55
+ if (prop.description) {
56
+ field = field.describe(prop.description);
57
+ }
58
+ if (!required.has(key)) {
59
+ field = field.optional();
60
+ }
61
+ shape[key] = field;
62
+ }
63
+
64
+ return shape;
65
+ }
66
+
67
+ export class ServiceBridge {
68
+ private backend: ServiceBackend;
69
+ private tools: Map<string, ServiceToolInfo> = new Map();
70
+
71
+ constructor(config: ServiceBridgeConfig) {
72
+ this.backend = config.backend;
73
+ for (const tool of config.tools) {
74
+ this.tools.set(tool.name, tool);
75
+ }
76
+ }
77
+
78
+ registerTools(server: McpServer): void {
79
+ for (const [, info] of this.tools) {
80
+ const mcpToolName = toMcpToolName(info.namespace, info.procedure);
81
+ const inputShape = jsonSchemaToZodShape(info.parameters);
82
+
83
+ server.registerTool(
84
+ mcpToolName,
85
+ {
86
+ description: info.description ?? `Call ${info.namespace}.${info.procedure}`,
87
+ inputSchema: inputShape,
88
+ },
89
+ async (args) => {
90
+ try {
91
+ const result = await this.backend.call(info.namespace, info.procedure, [args ?? {}]);
92
+ return {
93
+ content: [
94
+ {
95
+ type: "text" as const,
96
+ text: typeof result === "string" ? result : JSON.stringify(result),
97
+ },
98
+ ],
99
+ };
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text" as const,
106
+ text: `Service call failed: ${message}`,
107
+ },
108
+ ],
109
+ isError: true,
110
+ };
111
+ }
112
+ }
113
+ );
114
+ }
115
+ }
116
+
117
+ registerSearchServices(server: McpServer): void {
118
+ server.registerTool(
119
+ "search_services",
120
+ {
121
+ description:
122
+ "Search for available service tools that widgets can call. " +
123
+ "Returns matching services with their namespaces, procedures, and parameter schemas.",
124
+ inputSchema: {
125
+ query: z
126
+ .string()
127
+ .optional()
128
+ .describe(
129
+ 'Natural language description of what you want to do (e.g., "get weather forecast")'
130
+ ),
131
+ namespace: z
132
+ .string()
133
+ .optional()
134
+ .describe('Filter results to a specific service namespace (e.g., "weather")'),
135
+ tool_name: z
136
+ .string()
137
+ .optional()
138
+ .describe("Get detailed info about a specific tool by name"),
139
+ limit: z.number().optional().describe("Maximum number of results to return"),
140
+ },
141
+ },
142
+ async (args) => {
143
+ const query = args?.["query"] as string | undefined;
144
+ const namespace = args?.["namespace"] as string | undefined;
145
+ const toolName = args?.["tool_name"] as string | undefined;
146
+ const limit = (args?.["limit"] as number) ?? 10;
147
+
148
+ if (toolName) {
149
+ const dotName = toolName.replace(/__/g, ".");
150
+ const info = this.tools.get(toolName) ?? this.tools.get(dotName);
151
+ if (!info) {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text" as const,
156
+ text: JSON.stringify({
157
+ success: false,
158
+ error: `Tool '${toolName}' not found`,
159
+ }),
160
+ },
161
+ ],
162
+ };
163
+ }
164
+ return {
165
+ content: [
166
+ {
167
+ type: "text" as const,
168
+ text: JSON.stringify({ success: true, tool: info }),
169
+ },
170
+ ],
171
+ };
172
+ }
173
+
174
+ let results = Array.from(this.tools.values());
175
+
176
+ if (namespace) {
177
+ results = results.filter((info) => info.namespace === namespace);
178
+ }
179
+
180
+ if (query) {
181
+ const queryLower = query.toLowerCase();
182
+ const keywords = queryLower.split(/\s+/).filter(Boolean);
183
+ results = results
184
+ .map((info) => {
185
+ const searchText =
186
+ `${info.name} ${info.namespace} ${info.procedure} ${info.description ?? ""}`.toLowerCase();
187
+ const matchCount = keywords.filter((kw) => searchText.includes(kw)).length;
188
+ return { info, score: matchCount / keywords.length };
189
+ })
190
+ .filter(({ score }) => score > 0)
191
+ .sort((a, b) => b.score - a.score)
192
+ .map(({ info }) => info);
193
+ }
194
+
195
+ results = results.slice(0, limit);
196
+
197
+ return {
198
+ content: [
199
+ {
200
+ type: "text" as const,
201
+ text: JSON.stringify({
202
+ success: true,
203
+ count: results.length,
204
+ tools: results,
205
+ namespaces: this.getNamespaces(),
206
+ }),
207
+ },
208
+ ],
209
+ };
210
+ }
211
+ );
212
+
213
+ // call_service: Execute any service by namespace/procedure
214
+ server.registerTool(
215
+ "call_service",
216
+ {
217
+ description:
218
+ "Call a service tool by namespace and procedure. Use search_services to discover available tools first.",
219
+ inputSchema: {
220
+ namespace: z.string().describe('Service namespace (e.g., "github", "stripe")'),
221
+ procedure: z.string().describe('Procedure name (e.g., "repos_list", "customers_create")'),
222
+ args: z.record(z.unknown()).optional().describe("Arguments to pass to the procedure"),
223
+ },
224
+ },
225
+ async (params) => {
226
+ const namespace = params?.["namespace"] as string;
227
+ const procedure = params?.["procedure"] as string;
228
+ const args = (params?.["args"] ?? {}) as Record<string, unknown>;
229
+
230
+ if (!namespace || !procedure) {
231
+ return {
232
+ content: [
233
+ {
234
+ type: "text" as const,
235
+ text: JSON.stringify({
236
+ success: false,
237
+ error: "namespace and procedure are required",
238
+ }),
239
+ },
240
+ ],
241
+ isError: true,
242
+ };
243
+ }
244
+
245
+ // Look up tool info for validation
246
+ const toolKey = `${namespace}.${procedure}`;
247
+ const info = this.tools.get(toolKey);
248
+ if (!info) {
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text" as const,
253
+ text: JSON.stringify({
254
+ success: false,
255
+ error: `Tool '${namespace}.${procedure}' not found. Use search_services to discover available tools.`,
256
+ }),
257
+ },
258
+ ],
259
+ isError: true,
260
+ };
261
+ }
262
+
263
+ try {
264
+ const result = await this.backend.call(namespace, procedure, [args]);
265
+ return {
266
+ content: [
267
+ {
268
+ type: "text" as const,
269
+ text: typeof result === "string" ? result : JSON.stringify(result),
270
+ },
271
+ ],
272
+ };
273
+ } catch (error) {
274
+ const message = error instanceof Error ? error.message : String(error);
275
+ return {
276
+ content: [
277
+ {
278
+ type: "text" as const,
279
+ text: JSON.stringify({
280
+ success: false,
281
+ error: `Service call failed: ${message}`,
282
+ }),
283
+ },
284
+ ],
285
+ isError: true,
286
+ };
287
+ }
288
+ }
289
+ );
290
+ }
291
+
292
+ getNamespaces(): string[] {
293
+ const namespaces = new Set<string>();
294
+ for (const info of this.tools.values()) {
295
+ namespaces.add(info.namespace);
296
+ }
297
+ return Array.from(namespaces);
298
+ }
299
+
300
+ getToolInfos(): ServiceToolInfo[] {
301
+ return Array.from(this.tools.values());
302
+ }
303
+
304
+ has(namespace: string, procedure: string): boolean {
305
+ return this.tools.has(`${namespace}.${procedure}`);
306
+ }
307
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Patchwork MCP App "shell".
3
+ *
4
+ * This is the document Claude loads as the MCP App resource. Per the MCP Apps
5
+ * protocol the resource document itself must be the app that connects to the
6
+ * host (`App.connect()` → `window.parent`); it runs under a strict CSP with no
7
+ * `unsafe-eval`, so it cannot run the esbuild-wasm compiler.
8
+ *
9
+ * So the shell stays light: it runs the ext-apps {@link App} (handshake + sizing
10
+ * + host bridge) and embeds the Patchwork runtime in a nested, CSP-free iframe
11
+ * served from the widget host. The runtime compiles the widget in the browser
12
+ * and mounts it; the widget's `window.patchwork.*` / service-namespace calls are
13
+ * forwarded up here over postMessage and relayed to the host:
14
+ *
15
+ * widget (runtime iframe) ──postMessage──▶ shell (App) ──ext-apps──▶ host
16
+ */
17
+ import { App } from "@modelcontextprotocol/ext-apps";
18
+
19
+ interface ShellConfig {
20
+ /** Base URL of the runtime host page, e.g. https://host/runtime/ */
21
+ runtime: string;
22
+ /** "<name>/<hash>" of the saved widget to render. */
23
+ widget: string;
24
+ /** Startup props passed to the widget. */
25
+ inputs: Record<string, unknown>;
26
+ }
27
+
28
+ // Minimal MCP tool-result shape (avoids depending on SDK types in the bundle).
29
+ interface ToolResult {
30
+ isError?: boolean;
31
+ content?: Array<{ type: string; text?: string }>;
32
+ }
33
+
34
+ function readConfig(): ShellConfig {
35
+ const el = document.currentScript as HTMLScriptElement | null;
36
+ const raw = el?.dataset["config"] ?? "";
37
+ try {
38
+ const json = decodeURIComponent(escape(atob(raw)));
39
+ return JSON.parse(json) as ShellConfig;
40
+ } catch {
41
+ return { runtime: "", widget: "", inputs: {} };
42
+ }
43
+ }
44
+
45
+ function parseResult(result: ToolResult): unknown {
46
+ const textBlock = result?.content?.find((c) => c.type === "text");
47
+ if (textBlock?.text !== undefined) {
48
+ try {
49
+ return JSON.parse(textBlock.text);
50
+ } catch {
51
+ return textBlock.text;
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function boot(): void {
58
+ const config = readConfig();
59
+
60
+ const app = new App({ name: "patchwork-widget", version: "0.1.0" });
61
+
62
+ // Nested, CSP-free runtime iframe (compiles + mounts the widget in-browser).
63
+ const iframe = document.createElement("iframe");
64
+ iframe.style.cssText = "width:100%;border:none;display:block;";
65
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
66
+ const query = new URLSearchParams({
67
+ widget: config.widget,
68
+ inputs: JSON.stringify(config.inputs ?? {}),
69
+ });
70
+ iframe.src = `${config.runtime}?${query.toString()}`;
71
+ (document.getElementById("pw-root") ?? document.body).appendChild(iframe);
72
+
73
+ const post = (msg: Record<string, unknown>): void => {
74
+ iframe.contentWindow?.postMessage({ source: "patchwork-host", ...msg }, "*");
75
+ };
76
+
77
+ // Per-stream cursor for live updates.
78
+ const streams = new Map<string, number>();
79
+
80
+ const pollStream = async (stream: string): Promise<void> => {
81
+ const afterSeq = streams.get(stream) ?? 0;
82
+ try {
83
+ const res = (await app.callServerTool({
84
+ name: "poll_updates",
85
+ arguments: { stream, after_seq: afterSeq },
86
+ })) as ToolResult;
87
+ const parsed = parseResult(res) as { events?: Array<{ seq: number; data: unknown }> };
88
+ if (!parsed?.events?.length) return;
89
+ for (const ev of parsed.events) {
90
+ if (ev.seq > (streams.get(stream) ?? 0)) streams.set(stream, ev.seq);
91
+ post({ kind: "stream-event", stream, data: ev.data, seq: ev.seq });
92
+ }
93
+ } catch (err) {
94
+ console.warn("[patchwork-shell] poll_updates failed:", err);
95
+ }
96
+ };
97
+
98
+ const callTool = async (id: string, name: string, args: unknown): Promise<void> => {
99
+ try {
100
+ const res = (await app.callServerTool({
101
+ name,
102
+ arguments: (args as Record<string, unknown>) ?? {},
103
+ })) as ToolResult;
104
+ if (res.isError) {
105
+ const text = res.content?.find((c) => c.type === "text")?.text;
106
+ post({ kind: "result", id, ok: false, error: text ?? `Tool call failed: ${name}` });
107
+ } else {
108
+ post({ kind: "result", id, ok: true, value: parseResult(res) });
109
+ }
110
+ } catch (err) {
111
+ post({ kind: "result", id, ok: false, error: err instanceof Error ? err.message : String(err) });
112
+ }
113
+ };
114
+
115
+ window.addEventListener("message", (event: MessageEvent) => {
116
+ if (event.source !== iframe.contentWindow) return;
117
+ const m = event.data as Record<string, unknown> | undefined;
118
+ if (!m || m["source"] !== "patchwork") return;
119
+
120
+ switch (m["kind"]) {
121
+ case "size": {
122
+ const h = m["height"];
123
+ if (typeof h === "number" && h > 0) iframe.style.height = `${h}px`;
124
+ break;
125
+ }
126
+ case "service":
127
+ void callTool(m["id"] as string, `${m["namespace"]}__${m["procedure"]}`, m["args"]);
128
+ break;
129
+ case "fire":
130
+ void callTool(m["id"] as string, m["toolName"] as string, m["args"]);
131
+ break;
132
+ case "subscribe": {
133
+ const stream = m["stream"] as string;
134
+ if (streams.has(stream)) break;
135
+ streams.set(stream, 0);
136
+ void app
137
+ .callServerTool({ name: "subscribe_stream", arguments: { stream } })
138
+ .then((res) => {
139
+ const parsed = parseResult(res as ToolResult) as { seq?: number };
140
+ if (typeof parsed?.seq === "number") streams.set(stream, parsed.seq);
141
+ })
142
+ .catch((err) => console.warn("[patchwork-shell] subscribe_stream failed:", err));
143
+ break;
144
+ }
145
+ case "context": {
146
+ const content = m["content"];
147
+ const params =
148
+ typeof content === "string"
149
+ ? { content: [{ type: "text", text: content }] }
150
+ : Array.isArray(content)
151
+ ? { content }
152
+ : { structuredContent: content as Record<string, unknown> };
153
+ void app.updateModelContext(params).catch(() => {});
154
+ break;
155
+ }
156
+ }
157
+ });
158
+
159
+ // When the server signals new data (notifications/tools/list_changed), poll
160
+ // every subscribed stream and forward fresh events down to the widget.
161
+ app.fallbackNotificationHandler = async (notification: { method: string }) => {
162
+ if (notification.method === "notifications/tools/list_changed") {
163
+ for (const stream of streams.keys()) await pollStream(stream);
164
+ }
165
+ };
166
+
167
+ app.connect().catch((err) => {
168
+ console.error("[patchwork-shell] failed to connect to host:", err);
169
+ });
170
+ }
171
+
172
+ boot();
package/src/shim.ts ADDED
@@ -0,0 +1,106 @@
1
+ export interface BridgeShimOptions {
2
+ /** Service namespaces the widget calls (e.g. ["weather", "stripe"]). */
3
+ namespaces: string[];
4
+ }
5
+
6
+ /**
7
+ * Generate the runtime-side bridge shim.
8
+ *
9
+ * This runs inside the CSP-free runtime iframe (the nested frame that compiles
10
+ * and mounts the widget). It exposes `window.patchwork` and a proxy per service
11
+ * namespace, but instead of talking to the MCP host directly — the runtime
12
+ * iframe is one level removed from the host and can't — it forwards every call
13
+ * to the parent shell over `postMessage`. The shell holds the ext-apps `App`
14
+ * and relays to the host:
15
+ *
16
+ * widget → window.patchwork.* / namespace.* → postMessage(parent) → shell → host
17
+ *
18
+ * Messages sent to the parent: `{ source: 'patchwork', kind, ... }`.
19
+ * Messages received from the parent: `{ source: 'patchwork-host', kind, ... }`.
20
+ */
21
+ export function generateBridgeShim(options: BridgeShimOptions): string {
22
+ const namespacesJson = JSON.stringify(options.namespaces ?? []);
23
+
24
+ return `
25
+ (function () {
26
+ if (window.patchwork) return; // guard against double-injection
27
+
28
+ var __pending = {};
29
+ var __seq = 0;
30
+ var __streams = {};
31
+
32
+ function __post(msg) {
33
+ msg.source = 'patchwork';
34
+ window.parent.postMessage(msg, '*');
35
+ }
36
+
37
+ function __request(payload) {
38
+ return new Promise(function (resolve, reject) {
39
+ var id = 'r' + (++__seq);
40
+ __pending[id] = { resolve: resolve, reject: reject };
41
+ payload.id = id;
42
+ __post(payload);
43
+ });
44
+ }
45
+
46
+ window.addEventListener('message', function (e) {
47
+ var m = e.data;
48
+ if (!m || m.source !== 'patchwork-host') return;
49
+ if (m.kind === 'result' && m.id && __pending[m.id]) {
50
+ var p = __pending[m.id];
51
+ delete __pending[m.id];
52
+ if (m.ok) p.resolve(m.value);
53
+ else p.reject(new Error(m.error || 'Service call failed'));
54
+ } else if (m.kind === 'stream-event') {
55
+ var cbs = __streams[m.stream];
56
+ if (cbs) {
57
+ for (var i = 0; i < cbs.length; i++) {
58
+ try { cbs[i](m.data, m.seq, m.stream); }
59
+ catch (err) { console.error('[patchwork] subscribe callback error:', err); }
60
+ }
61
+ }
62
+ }
63
+ });
64
+
65
+ window.patchwork = {
66
+ /** Subscribe to a named server data stream. Returns an unsubscribe fn. */
67
+ subscribe: function (stream, cb) {
68
+ if (!__streams[stream]) {
69
+ __streams[stream] = [];
70
+ __post({ kind: 'subscribe', stream: stream });
71
+ }
72
+ __streams[stream].push(cb);
73
+ return function () {
74
+ var a = __streams[stream];
75
+ if (!a) return;
76
+ var i = a.indexOf(cb);
77
+ if (i !== -1) a.splice(i, 1);
78
+ };
79
+ },
80
+ /** Push widget state into the conversation context. */
81
+ updateContext: function (content) {
82
+ __post({ kind: 'context', content: content });
83
+ return Promise.resolve();
84
+ },
85
+ /** Fire a client event as an MCP tool call and await its result. */
86
+ fireEvent: function (toolName, args) {
87
+ return __request({ kind: 'fire', toolName: toolName, args: args || {} });
88
+ }
89
+ };
90
+
91
+ var __namespaces = ${namespacesJson};
92
+ for (var __i = 0; __i < __namespaces.length; __i++) {
93
+ (function (ns) {
94
+ window[ns] = new Proxy({}, {
95
+ get: function (target, prop) {
96
+ if (typeof prop !== 'string') return undefined;
97
+ return function (args) {
98
+ return __request({ kind: 'service', namespace: ns, procedure: prop, args: args || {} });
99
+ };
100
+ }
101
+ });
102
+ })(__namespaces[__i]);
103
+ }
104
+ })();
105
+ `;
106
+ }
package/src/tunnel.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { log, error } from "./logger.js";
3
+
4
+ let tunnelProcess: ChildProcess | null = null;
5
+ let tunnelUrl: string | null = null;
6
+
7
+ /**
8
+ * Poll the tunnel's public `/health` endpoint until it returns 200.
9
+ *
10
+ * cloudflared prints the `*.trycloudflare.com` URL as soon as the process
11
+ * registers, but the edge route is frequently not live yet — requests in that
12
+ * window return Cloudflare error 1033 ("unable to resolve"). Publishing the URL
13
+ * before it's actually reachable bakes a dead host into rendered widgets, so we
14
+ * verify reachability before adopting it.
15
+ */
16
+ async function waitForTunnelLive(url: string, timeoutMs = 30000): Promise<boolean> {
17
+ const deadline = Date.now() + timeoutMs;
18
+ while (Date.now() < deadline) {
19
+ try {
20
+ const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
21
+ if (res.ok) return true;
22
+ } catch {
23
+ // not reachable yet — keep polling
24
+ }
25
+ await new Promise((r) => setTimeout(r, 1000));
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Start a cloudflare tunnel to expose a local port publicly.
32
+ * Returns the public URL once the tunnel is established AND verified reachable.
33
+ */
34
+ export async function startTunnel(port: number): Promise<string> {
35
+ if (tunnelUrl) return tunnelUrl;
36
+
37
+ const detectedUrl = await new Promise<string>((resolve, reject) => {
38
+ const args = ["tunnel", "--url", `http://localhost:${port}`];
39
+
40
+ log("tunnel", `Starting cloudflared tunnel for port ${port}...`);
41
+
42
+ tunnelProcess = spawn("cloudflared", args, {
43
+ stdio: ["ignore", "pipe", "pipe"],
44
+ });
45
+
46
+ let resolved = false;
47
+ const timeout = setTimeout(() => {
48
+ if (!resolved) {
49
+ resolved = true;
50
+ reject(new Error("Tunnel startup timed out after 30s"));
51
+ }
52
+ }, 30000);
53
+
54
+ const handleOutput = (data: Buffer) => {
55
+ const text = data.toString();
56
+
57
+ // Look for the tunnel URL in the output
58
+ // cloudflared outputs: "https://xxx.trycloudflare.com"
59
+ const urlMatch = text.match(/https:\/\/[^\s]+\.trycloudflare\.com\/?/);
60
+ if (urlMatch && !resolved) {
61
+ resolved = true;
62
+ clearTimeout(timeout);
63
+ resolve(urlMatch[0].replace(/\/$/, ""));
64
+ }
65
+ };
66
+
67
+ tunnelProcess.stdout?.on("data", handleOutput);
68
+ tunnelProcess.stderr?.on("data", handleOutput);
69
+
70
+ tunnelProcess.on("error", (err) => {
71
+ if (!resolved) {
72
+ resolved = true;
73
+ clearTimeout(timeout);
74
+ error("tunnel", "Failed to start cloudflared:", err);
75
+ reject(err);
76
+ }
77
+ });
78
+
79
+ tunnelProcess.on("exit", (code) => {
80
+ if (!resolved) {
81
+ resolved = true;
82
+ clearTimeout(timeout);
83
+ reject(new Error(`cloudflared exited with code ${code}`));
84
+ }
85
+ tunnelProcess = null;
86
+ tunnelUrl = null;
87
+ });
88
+ });
89
+
90
+ // Verify-then-publish: don't adopt the hostname until the edge route is live,
91
+ // otherwise rendered widgets reference a host that returns Cloudflare 1033.
92
+ log("tunnel", `Tunnel hostname detected (${detectedUrl}); verifying reachability...`);
93
+ const live = await waitForTunnelLive(detectedUrl);
94
+ if (!live) {
95
+ error(
96
+ "tunnel",
97
+ `Tunnel ${detectedUrl} did not become reachable within 30s — publishing anyway, but widgets may fail to load until the edge route propagates.`,
98
+ );
99
+ } else {
100
+ log("tunnel", `Tunnel verified reachable: ${detectedUrl}`);
101
+ }
102
+
103
+ tunnelUrl = detectedUrl;
104
+ return tunnelUrl;
105
+ }
106
+
107
+ /**
108
+ * Stop the cloudflare tunnel if running.
109
+ */
110
+ export function stopTunnel(): void {
111
+ if (tunnelProcess) {
112
+ tunnelProcess.kill();
113
+ tunnelProcess = null;
114
+ tunnelUrl = null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get the current tunnel URL if available.
120
+ */
121
+ export function getTunnelUrl(): string | null {
122
+ return tunnelUrl;
123
+ }