@tanstack/ai-sandbox-cloudflare 0.3.11 → 0.3.14

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.
@@ -56,6 +56,66 @@ var PREVIEW_GUIDANCE = [
56
56
  "• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.",
57
57
  "Once it is listening, call `exposePreview` with that port, then share the URL."
58
58
  ].join("\n");
59
+ var LOCAL_PROBE_TIMEOUT_MS = 5e3;
60
+ var EDGE_PROBE_ATTEMPTS = 5;
61
+ var EDGE_PROBE_BASE_DELAY_MS = 250;
62
+ var EDGE_PROBE_FETCH_TIMEOUT_MS = 3e3;
63
+ /**
64
+ * Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP
65
+ * status when a listener answered (any response — 4xx/5xx included — proves one
66
+ * exists), or the failure symptom (a string) when nothing did.
67
+ */
68
+ async function localProbe(sandbox, port) {
69
+ let timeoutId;
70
+ const timeout = new Promise((_, reject) => {
71
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)), LOCAL_PROBE_TIMEOUT_MS);
72
+ });
73
+ try {
74
+ return (await Promise.race([sandbox.containerFetch("http://preview/", { method: "HEAD" }, port), timeout])).status;
75
+ } catch (error) {
76
+ return error instanceof Error ? error.message : String(error);
77
+ } finally {
78
+ clearTimeout(timeoutId);
79
+ }
80
+ }
81
+ /**
82
+ * Probe a tunnel URL through the public edge with bounded retries. Only 502/530
83
+ * — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as
84
+ * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'`
85
+ * keeps a login redirect from probing some other site), and even those are
86
+ * trusted when the app answered the SAME status locally, so an app's own
87
+ * 502/530 never gets its healthy tunnel destroyed. The verdict split exists
88
+ * because only an OBSERVED non-matching 502/530 ('stale') is evidence that
89
+ * justifies destroying the tunnel; fetch exceptions ('unverified') prove
90
+ * nothing about it — the probe path itself may be what failed. A throw
91
+ * anywhere in the retry window therefore wins over an earlier 502/530: the
92
+ * window did not finish as HTTP probes, so we must not destroy.
93
+ */
94
+ async function edgeProbeFailure(url, localStatus) {
95
+ let lastFailure = "no response";
96
+ let verdict = "unverified";
97
+ let sawFetchException = false;
98
+ for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) {
99
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)));
100
+ try {
101
+ const res = await fetch(url, {
102
+ method: "HEAD",
103
+ redirect: "manual",
104
+ signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS)
105
+ });
106
+ if (res.status !== 502 && res.status !== 530 || res.status === localStatus) return null;
107
+ lastFailure = `HTTP ${res.status}`;
108
+ verdict = "stale";
109
+ } catch (error) {
110
+ sawFetchException = true;
111
+ lastFailure = error instanceof Error ? error.message : String(error);
112
+ }
113
+ }
114
+ return {
115
+ verdict: sawFetchException ? "unverified" : verdict,
116
+ symptom: lastFailure
117
+ };
118
+ }
59
119
  /**
60
120
  * Build the `exposePreview` server tool for one run. Starting a tunnel is a
61
121
  * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from
@@ -71,7 +131,23 @@ function exposePreviewTool(input, env) {
71
131
  description: "Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.",
72
132
  inputSchema: z.object({ port: z.number().int().min(1024).max(65535).describe("The port the dev server is listening on, e.g. 5173.") })
73
133
  }).server(async ({ port }) => {
74
- return { url: (await getSandbox(env.Sandbox, input.threadId, { transport: "rpc" }).tunnels.get(port)).url };
134
+ const sandbox = getSandbox(env.Sandbox, input.threadId, { transport: "rpc" });
135
+ const local = await localProbe(sandbox, port);
136
+ if (typeof local === "string") throw new Error(`No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`);
137
+ const tunnel = await sandbox.tunnels.get(port);
138
+ const edgeFailure = await edgeProbeFailure(tunnel.url, local);
139
+ if (edgeFailure === null) return { url: tunnel.url };
140
+ if (edgeFailure.verdict === "unverified") throw new Error(`Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`);
141
+ await sandbox.tunnels.destroy(port);
142
+ const fresh = await sandbox.tunnels.get(port);
143
+ const freshFailure = await edgeProbeFailure(fresh.url, local);
144
+ if (freshFailure === null) return {
145
+ url: fresh.url,
146
+ note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`
147
+ };
148
+ if (freshFailure.verdict === "stale") await sandbox.tunnels.destroy(port);
149
+ const [diagnosis, hint] = freshFailure.verdict === "stale" ? ["its preview tunnel never became reachable", "Retry exposePreview, and if it keeps failing, restart the dev server and try again."] : ["the replacement preview tunnel could not be verified from the edge", "Retry exposePreview in a few seconds."];
150
+ throw new Error(`Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`);
75
151
  });
76
152
  }
77
153
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"preview-tool.js","names":[],"sources":["../../src/preview-tool.ts"],"sourcesContent":["/**\n * The browser-preview capability, as reusable building blocks rather than\n * per-app glue: a `chat()` server tool that mints a preview URL for a dev server\n * running inside the sandbox, plus the system-prompt guidance an agent needs to\n * produce a preview that works.\n *\n * Previews go over a **Cloudflare quick tunnel** (`sandbox.tunnels.get(port)` →\n * `https://<name>.trycloudflare.com`), served by `cloudflared` INSIDE the sandbox.\n * We deliberately do NOT use `exposePort` + `proxyToSandbox` here: that routes the\n * preview through the Worker's own origin, which in local dev is the example's Vite\n * dev server — and Vite's middleware then serves the preview's module/asset\n * requests (`/@vite/client`, `/src/*`, `/@fs/*`) from the HOST instead of the\n * container, breaking the page. A tunnel bypasses the Vite port entirely, needs no\n * custom domain on a deploy, and forwards WebSockets (so the app's HMR works).\n *\n * Both exports belong to THIS package because the transport is its concern, not any\n * particular app's. Wire them explicitly into your agent:\n *\n * ```ts\n * import {\n * exposePreviewTool,\n * PREVIEW_GUIDANCE,\n * } from '@tanstack/ai-sandbox-cloudflare/agent'\n *\n * createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * tools: (input, env) => [exposePreviewTool(input, env)],\n * systemPrompts: [PREVIEW_GUIDANCE],\n * })\n * ```\n *\n * Workers-only (imports `@cloudflare/sandbox`) — exported from the `/agent` entry.\n */\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type { StartRunInput } from './coordinator'\n\n/**\n * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it\n * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,\n * so the factory's `tools` resolver passes its env straight in.\n */\nexport interface PreviewToolEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/**\n * System-prompt guidance for any agent that exposes a dev server as a browser\n * preview. App-agnostic: the only requirement a quick tunnel imposes is that the\n * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by\n * default), so the rule is \"bind wide + allow all hosts\", not \"disable HMR\" — the\n * tunnel forwards WebSockets, so HMR works.\n */\nexport const PREVIEW_GUIDANCE: string = [\n 'PREVIEW SERVERS: to show the user a running web app, start its dev server bound',\n 'to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control',\n 'plane), then call the `exposePreview` tool with that port. It returns a public',\n 'Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight',\n 'from the sandbox — no custom domain needed, and HMR / live-reload WebSockets',\n 'work through the tunnel (you do NOT need to disable HMR). The ONE requirement:',\n 'the dev server must ACCEPT the tunnel hostname, which servers reject by default,',\n 'so allow all hosts in its config before starting:',\n '• Vite — `server: { host: true, allowedHosts: true }` in vite.config.',\n \"• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).\",\n '• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.',\n 'Once it is listening, call `exposePreview` with that port, then share the URL.',\n].join('\\n')\n\n/**\n * Build the `exposePreview` server tool for one run. Starting a tunnel is a\n * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from\n * bash — it calls this bridged tool instead. We address the run's container by\n * `threadId` and open (or reuse) a quick tunnel to the given port.\n *\n * Closes over the run's `input` + `env`, so build it inside the `tools` resolver\n * (`tools: (input, env) => [exposePreviewTool(input, env)]`).\n */\nexport function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {\n return toolDefinition({\n name: 'exposePreview',\n description:\n 'Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.',\n inputSchema: z.object({\n port: z\n .number()\n .int()\n .min(1024)\n .max(65535)\n .describe('The port the dev server is listening on, e.g. 5173.'),\n }),\n }).server(async ({ port }) => {\n // `sandbox.tunnels` only exists on the RPC transport (on HTTP/WebSocket it's a\n // stub that throws \"requires the RPC transport\"), so we must obtain the stub\n // with `transport: 'rpc'`. IMPORTANT: this must MATCH how the sandbox was\n // created — pass `transport: 'rpc'` on EVERY `getSandbox()` for this id (in your\n // sandbox provider too), or the differing transport disconnects the run's active\n // client. See the SDK `SandboxOptions.transport` note.\n const sandbox = getSandbox(env.Sandbox, input.threadId, {\n transport: 'rpc',\n })\n // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE\n // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite\n // can't hijack the preview's asset requests) and needs no custom domain on a\n // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.\n const tunnel = await sandbox.tunnels.get(port)\n return { url: tunnel.url }\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,IAAa,mBAA2B;CACtC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,IAAI;;;;;;;;;;AAWX,SAAgB,kBAAkB,OAAsB,KAAqB;CAC3E,OAAO,eAAe;EACpB,MAAM;EACN,aACE;EACF,aAAa,EAAE,OAAO,EACpB,MAAM,EACH,OAAO,CAAC,CACR,IAAI,CAAC,CACL,IAAI,IAAI,CAAC,CACT,IAAI,KAAK,CAAC,CACV,SAAS,qDAAqD,EACnE,CAAC;CACH,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,WAAW;EAe5B,OAAO,EAAE,MAAK,MARE,WAAW,IAAI,SAAS,MAAM,UAAU,EACtD,WAAW,MACb,CAKqB,CAAA,CAAQ,QAAQ,IAAI,IAAI,EAAA,CACxB,IAAI;CAC3B,CAAC;AACH"}
1
+ {"version":3,"file":"preview-tool.js","names":[],"sources":["../../src/preview-tool.ts"],"sourcesContent":["/**\n * The browser-preview capability, as reusable building blocks rather than\n * per-app glue: a `chat()` server tool that mints a preview URL for a dev server\n * running inside the sandbox, plus the system-prompt guidance an agent needs to\n * produce a preview that works.\n *\n * Previews go over a **Cloudflare quick tunnel** (`sandbox.tunnels.get(port)` →\n * `https://<name>.trycloudflare.com`), served by `cloudflared` INSIDE the sandbox.\n * We deliberately do NOT use `exposePort` + `proxyToSandbox` here: that routes the\n * preview through the Worker's own origin, which in local dev is the example's Vite\n * dev server — and Vite's middleware then serves the preview's module/asset\n * requests (`/@vite/client`, `/src/*`, `/@fs/*`) from the HOST instead of the\n * container, breaking the page. A tunnel bypasses the Vite port entirely, needs no\n * custom domain on a deploy, and forwards WebSockets (so the app's HMR works).\n *\n * Both exports belong to THIS package because the transport is its concern, not any\n * particular app's. Wire them explicitly into your agent:\n *\n * ```ts\n * import {\n * exposePreviewTool,\n * PREVIEW_GUIDANCE,\n * } from '@tanstack/ai-sandbox-cloudflare/agent'\n *\n * createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * tools: (input, env) => [exposePreviewTool(input, env)],\n * systemPrompts: [PREVIEW_GUIDANCE],\n * })\n * ```\n *\n * Workers-only (imports `@cloudflare/sandbox`) — exported from the `/agent` entry.\n */\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type { StartRunInput } from './coordinator'\n\n/**\n * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it\n * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,\n * so the factory's `tools` resolver passes its env straight in.\n */\nexport interface PreviewToolEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/**\n * System-prompt guidance for any agent that exposes a dev server as a browser\n * preview. App-agnostic: the only requirement a quick tunnel imposes is that the\n * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by\n * default), so the rule is \"bind wide + allow all hosts\", not \"disable HMR\" — the\n * tunnel forwards WebSockets, so HMR works.\n */\nexport const PREVIEW_GUIDANCE: string = [\n 'PREVIEW SERVERS: to show the user a running web app, start its dev server bound',\n 'to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control',\n 'plane), then call the `exposePreview` tool with that port. It returns a public',\n 'Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight',\n 'from the sandbox — no custom domain needed, and HMR / live-reload WebSockets',\n 'work through the tunnel (you do NOT need to disable HMR). The ONE requirement:',\n 'the dev server must ACCEPT the tunnel hostname, which servers reject by default,',\n 'so allow all hosts in its config before starting:',\n '• Vite — `server: { host: true, allowedHosts: true }` in vite.config.',\n \"• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).\",\n '• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.',\n 'Once it is listening, call `exposePreview` with that port, then share the URL.',\n].join('\\n')\n\nconst LOCAL_PROBE_TIMEOUT_MS = 5_000\n// Fresh quick tunnels need a few seconds of DNS/edge propagation, hence retries.\nconst EDGE_PROBE_ATTEMPTS = 5\nconst EDGE_PROBE_BASE_DELAY_MS = 250\nconst EDGE_PROBE_FETCH_TIMEOUT_MS = 3_000\n\n/**\n * Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP\n * status when a listener answered (any response — 4xx/5xx included — proves one\n * exists), or the failure symptom (a string) when nothing did.\n */\nasync function localProbe(\n sandbox: Sandbox,\n port: number,\n): Promise<number | string> {\n // A race instead of AbortSignal: signals don't serialize across the sandbox\n // RPC boundary, and a lost in-flight probe response is harmless.\n let timeoutId: ReturnType<typeof setTimeout> | undefined\n const timeout = new Promise<never>((_, reject) => {\n timeoutId = setTimeout(\n () => reject(new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)),\n LOCAL_PROBE_TIMEOUT_MS,\n )\n })\n try {\n const res = await Promise.race([\n sandbox.containerFetch('http://preview/', { method: 'HEAD' }, port),\n timeout,\n ])\n return res.status\n } catch (error) {\n return error instanceof Error ? error.message : String(error)\n } finally {\n clearTimeout(timeoutId)\n }\n}\n\n/**\n * Probe a tunnel URL through the public edge with bounded retries. Only 502/530\n * — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as\n * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'`\n * keeps a login redirect from probing some other site), and even those are\n * trusted when the app answered the SAME status locally, so an app's own\n * 502/530 never gets its healthy tunnel destroyed. The verdict split exists\n * because only an OBSERVED non-matching 502/530 ('stale') is evidence that\n * justifies destroying the tunnel; fetch exceptions ('unverified') prove\n * nothing about it — the probe path itself may be what failed. A throw\n * anywhere in the retry window therefore wins over an earlier 502/530: the\n * window did not finish as HTTP probes, so we must not destroy.\n */\nasync function edgeProbeFailure(\n url: string,\n localStatus: number,\n): Promise<{ verdict: 'stale' | 'unverified'; symptom: string } | null> {\n let lastFailure = 'no response'\n let verdict: 'stale' | 'unverified' = 'unverified'\n let sawFetchException = false\n for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) {\n if (attempt > 0) {\n await new Promise((resolve) =>\n setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)),\n )\n }\n try {\n const res = await fetch(url, {\n method: 'HEAD',\n redirect: 'manual',\n signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS),\n })\n if (\n (res.status !== 502 && res.status !== 530) ||\n res.status === localStatus\n ) {\n return null\n }\n lastFailure = `HTTP ${res.status}`\n verdict = 'stale'\n } catch (error) {\n sawFetchException = true\n lastFailure = error instanceof Error ? error.message : String(error)\n }\n }\n return {\n verdict: sawFetchException ? 'unverified' : verdict,\n symptom: lastFailure,\n }\n}\n\n/**\n * Build the `exposePreview` server tool for one run. Starting a tunnel is a\n * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from\n * bash — it calls this bridged tool instead. We address the run's container by\n * `threadId` and open (or reuse) a quick tunnel to the given port.\n *\n * Closes over the run's `input` + `env`, so build it inside the `tools` resolver\n * (`tools: (input, env) => [exposePreviewTool(input, env)]`).\n */\nexport function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {\n return toolDefinition({\n name: 'exposePreview',\n description:\n 'Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.',\n inputSchema: z.object({\n port: z\n .number()\n .int()\n .min(1024)\n .max(65535)\n .describe('The port the dev server is listening on, e.g. 5173.'),\n }),\n }).server(async ({ port }) => {\n // `sandbox.tunnels` only exists on the RPC transport (on HTTP/WebSocket it's a\n // stub that throws \"requires the RPC transport\"), so we must obtain the stub\n // with `transport: 'rpc'`. IMPORTANT: this must MATCH how the sandbox was\n // created — pass `transport: 'rpc'` on EVERY `getSandbox()` for this id (in your\n // sandbox provider too), or the differing transport disconnects the run's active\n // client. See the SDK `SandboxOptions.transport` note.\n const sandbox = getSandbox(env.Sandbox, input.threadId, {\n transport: 'rpc',\n })\n // Gate tunnel work on a live listener: a fresh tunnel to a dead port is still\n // a dead preview, and the failure the agent can FIX is \"start the server\".\n const local = await localProbe(sandbox, port)\n if (typeof local === 'string') {\n throw new Error(\n `No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`,\n )\n }\n // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE\n // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite\n // can't hijack the preview's asset requests) and needs no custom domain on a\n // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.\n const tunnel = await sandbox.tunnels.get(port)\n const edgeFailure = await edgeProbeFailure(tunnel.url, local)\n if (edgeFailure === null) return { url: tunnel.url }\n // Never destroy on 'unverified': the tunnel may be healthy with only the\n // probe path broken, so destroying it could kill a working preview.\n if (edgeFailure.verdict === 'unverified') {\n throw new Error(\n `Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`,\n )\n }\n // Local server healthy but the edge kept answering 502/530: the cached tunnel\n // record is suspect. Refresh, bounded to ONE so we never churn tunnels.\n await sandbox.tunnels.destroy(port)\n const fresh = await sandbox.tunnels.get(port)\n const freshFailure = await edgeProbeFailure(fresh.url, local)\n if (freshFailure === null) {\n return {\n url: fresh.url,\n note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`,\n }\n }\n // Don't leave a known-dead record in DO storage (the #992 failure mode).\n if (freshFailure.verdict === 'stale') {\n await sandbox.tunnels.destroy(port)\n }\n const [diagnosis, hint] =\n freshFailure.verdict === 'stale'\n ? [\n 'its preview tunnel never became reachable',\n 'Retry exposePreview, and if it keeps failing, restart the dev server and try again.',\n ]\n : [\n 'the replacement preview tunnel could not be verified from the edge',\n 'Retry exposePreview in a few seconds.',\n ]\n throw new Error(\n `Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`,\n )\n })\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox-cloudflare",
3
- "version": "0.3.11",
3
+ "version": "0.3.14",
4
4
  "description": "Cloudflare sandbox provider for TanStack AI — run harness adapters inside Cloudflare Containers (edge) through the uniform SandboxHandle.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "zod": "^4.0.0",
48
- "@tanstack/ai": "^0.53.0",
49
- "@tanstack/ai-sandbox": "^0.5.6",
48
+ "@tanstack/ai": "^0.55.0",
49
+ "@tanstack/ai-sandbox": "^0.5.9",
50
50
  "@tanstack/ai-sandbox-local-process": "^0.2.5"
51
51
  },
52
52
  "devDependencies": {
@@ -54,9 +54,9 @@
54
54
  "@types/node": "^24.10.1",
55
55
  "@vitest/coverage-v8": "4.1.10",
56
56
  "zod": "^4.2.0",
57
- "@tanstack/ai-sandbox": "0.5.6",
58
- "@tanstack/ai-sandbox-local-process": "0.2.5",
59
- "@tanstack/ai": "0.53.0"
57
+ "@tanstack/ai": "0.55.0",
58
+ "@tanstack/ai-sandbox": "0.5.9",
59
+ "@tanstack/ai-sandbox-local-process": "0.2.5"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "vite build",
@@ -68,6 +68,94 @@ export const PREVIEW_GUIDANCE: string = [
68
68
  'Once it is listening, call `exposePreview` with that port, then share the URL.',
69
69
  ].join('\n')
70
70
 
71
+ const LOCAL_PROBE_TIMEOUT_MS = 5_000
72
+ // Fresh quick tunnels need a few seconds of DNS/edge propagation, hence retries.
73
+ const EDGE_PROBE_ATTEMPTS = 5
74
+ const EDGE_PROBE_BASE_DELAY_MS = 250
75
+ const EDGE_PROBE_FETCH_TIMEOUT_MS = 3_000
76
+
77
+ /**
78
+ * Probe the port INSIDE the sandbox via `containerFetch`. Returns the HTTP
79
+ * status when a listener answered (any response — 4xx/5xx included — proves one
80
+ * exists), or the failure symptom (a string) when nothing did.
81
+ */
82
+ async function localProbe(
83
+ sandbox: Sandbox,
84
+ port: number,
85
+ ): Promise<number | string> {
86
+ // A race instead of AbortSignal: signals don't serialize across the sandbox
87
+ // RPC boundary, and a lost in-flight probe response is harmless.
88
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
89
+ const timeout = new Promise<never>((_, reject) => {
90
+ timeoutId = setTimeout(
91
+ () => reject(new Error(`no response within ${LOCAL_PROBE_TIMEOUT_MS}ms`)),
92
+ LOCAL_PROBE_TIMEOUT_MS,
93
+ )
94
+ })
95
+ try {
96
+ const res = await Promise.race([
97
+ sandbox.containerFetch('http://preview/', { method: 'HEAD' }, port),
98
+ timeout,
99
+ ])
100
+ return res.status
101
+ } catch (error) {
102
+ return error instanceof Error ? error.message : String(error)
103
+ } finally {
104
+ clearTimeout(timeoutId)
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Probe a tunnel URL through the public edge with bounded retries. Only 502/530
110
+ * — Cloudflare's tunnel/origin-unreachable signatures — mark the URL as
111
+ * unreachable (401/403/404 prove the server answered, and `redirect: 'manual'`
112
+ * keeps a login redirect from probing some other site), and even those are
113
+ * trusted when the app answered the SAME status locally, so an app's own
114
+ * 502/530 never gets its healthy tunnel destroyed. The verdict split exists
115
+ * because only an OBSERVED non-matching 502/530 ('stale') is evidence that
116
+ * justifies destroying the tunnel; fetch exceptions ('unverified') prove
117
+ * nothing about it — the probe path itself may be what failed. A throw
118
+ * anywhere in the retry window therefore wins over an earlier 502/530: the
119
+ * window did not finish as HTTP probes, so we must not destroy.
120
+ */
121
+ async function edgeProbeFailure(
122
+ url: string,
123
+ localStatus: number,
124
+ ): Promise<{ verdict: 'stale' | 'unverified'; symptom: string } | null> {
125
+ let lastFailure = 'no response'
126
+ let verdict: 'stale' | 'unverified' = 'unverified'
127
+ let sawFetchException = false
128
+ for (let attempt = 0; attempt < EDGE_PROBE_ATTEMPTS; attempt += 1) {
129
+ if (attempt > 0) {
130
+ await new Promise((resolve) =>
131
+ setTimeout(resolve, EDGE_PROBE_BASE_DELAY_MS * 2 ** (attempt - 1)),
132
+ )
133
+ }
134
+ try {
135
+ const res = await fetch(url, {
136
+ method: 'HEAD',
137
+ redirect: 'manual',
138
+ signal: AbortSignal.timeout(EDGE_PROBE_FETCH_TIMEOUT_MS),
139
+ })
140
+ if (
141
+ (res.status !== 502 && res.status !== 530) ||
142
+ res.status === localStatus
143
+ ) {
144
+ return null
145
+ }
146
+ lastFailure = `HTTP ${res.status}`
147
+ verdict = 'stale'
148
+ } catch (error) {
149
+ sawFetchException = true
150
+ lastFailure = error instanceof Error ? error.message : String(error)
151
+ }
152
+ }
153
+ return {
154
+ verdict: sawFetchException ? 'unverified' : verdict,
155
+ symptom: lastFailure,
156
+ }
157
+ }
158
+
71
159
  /**
72
160
  * Build the `exposePreview` server tool for one run. Starting a tunnel is a
73
161
  * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from
@@ -100,11 +188,55 @@ export function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {
100
188
  const sandbox = getSandbox(env.Sandbox, input.threadId, {
101
189
  transport: 'rpc',
102
190
  })
191
+ // Gate tunnel work on a live listener: a fresh tunnel to a dead port is still
192
+ // a dead preview, and the failure the agent can FIX is "start the server".
193
+ const local = await localProbe(sandbox, port)
194
+ if (typeof local === 'string') {
195
+ throw new Error(
196
+ `No server is listening on port ${port} inside the sandbox (${local}). Start the dev server (bound to 0.0.0.0:${port}) first, then retry exposePreview.`,
197
+ )
198
+ }
103
199
  // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE
104
200
  // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite
105
201
  // can't hijack the preview's asset requests) and needs no custom domain on a
106
202
  // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.
107
203
  const tunnel = await sandbox.tunnels.get(port)
108
- return { url: tunnel.url }
204
+ const edgeFailure = await edgeProbeFailure(tunnel.url, local)
205
+ if (edgeFailure === null) return { url: tunnel.url }
206
+ // Never destroy on 'unverified': the tunnel may be healthy with only the
207
+ // probe path broken, so destroying it could kill a working preview.
208
+ if (edgeFailure.verdict === 'unverified') {
209
+ throw new Error(
210
+ `Port ${port} is serving inside the sandbox, but the preview tunnel could not be verified from the edge (${edgeFailure.symptom}). The tunnel was left in place — retry exposePreview in a few seconds.`,
211
+ )
212
+ }
213
+ // Local server healthy but the edge kept answering 502/530: the cached tunnel
214
+ // record is suspect. Refresh, bounded to ONE so we never churn tunnels.
215
+ await sandbox.tunnels.destroy(port)
216
+ const fresh = await sandbox.tunnels.get(port)
217
+ const freshFailure = await edgeProbeFailure(fresh.url, local)
218
+ if (freshFailure === null) {
219
+ return {
220
+ url: fresh.url,
221
+ note: `The tunnel for port ${port} was stale, so it was replaced. Any previously shared preview URL for this port is dead — share this new URL instead.`,
222
+ }
223
+ }
224
+ // Don't leave a known-dead record in DO storage (the #992 failure mode).
225
+ if (freshFailure.verdict === 'stale') {
226
+ await sandbox.tunnels.destroy(port)
227
+ }
228
+ const [diagnosis, hint] =
229
+ freshFailure.verdict === 'stale'
230
+ ? [
231
+ 'its preview tunnel never became reachable',
232
+ 'Retry exposePreview, and if it keeps failing, restart the dev server and try again.',
233
+ ]
234
+ : [
235
+ 'the replacement preview tunnel could not be verified from the edge',
236
+ 'Retry exposePreview in a few seconds.',
237
+ ]
238
+ throw new Error(
239
+ `Port ${port} is serving inside the sandbox, but ${diagnosis} (old tunnel: ${edgeFailure.symptom}; replacement tunnel: ${freshFailure.symptom}). ${hint}`,
240
+ )
109
241
  })
110
242
  }