Replace nested try/catch patterns with Result types from better-result: - transpileForRuntime() returns Result<string, TranspileError> (sync) - runtime_core uses Result.tryPromise() for VM execution - CF worker loader uses Result.tryPromise() for fetch + response parsing - typechecker uses Result.try() for TS module loading and semantic checks - Add TranspileError as a TaggedError for typed error discrimination
Unified · Split
| @@ -5,12 +5,17 @@ · 4 unmodified lines | ||
| 5 | 5 | "": { |
| 6 | 6 | "name": "executor", |
| 7 | 7 | "dependencies": { |
| 8 | + "better-result": "^2.7.0", | |
| 8 | 9 | "convex": "^1.31.7", |
| 9 | 10 | "graphql": "^16.12.0", |
| 10 | 11 | }, |
| 11 | 12 | }, |
| 12 | 13 | }, |
| 13 | 14 | "packages": { |
| 15 | + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], | |
| 16 | + | |
| 17 | + "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], | |
| 18 | + | |
| 14 | 19 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], |
| 15 | 20 | |
| 16 | 21 | "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], |
| @@ -63,12 +68,18 @@ · 46 unmodified lines | ||
| 63 | 68 | |
| 64 | 69 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], |
| 65 | 70 | |
| 71 | + "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], | |
| 72 | + | |
| 66 | 73 | "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="], |
| 67 | 74 | |
| 68 | 75 | "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], |
| 69 | 76 | |
| 70 | 77 | "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], |
| 71 | 78 | |
| 79 | + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], | |
| 80 | + | |
| 72 | 81 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 82 | + | |
| 83 | + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], | |
| 73 | 84 | } |
| 74 | 85 | } |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | +import { Result } from "better-result"; | |
| 3 | 4 | import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; |
| 4 | 5 | import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; |
| 5 | 6 | import { transpileForRuntime } from "./transpile"; |
| @@ -38,19 +39,30 @@ export async function runCodeWithCloudflareWorkerLoader( · 32 unmodified lines | ||
| 38 | 39 | const config = getCloudflareWorkerLoaderConfig(); |
| 39 | 40 | const startedAt = Date.now(); |
| 40 | 41 | |
| 41 | − const controller = new AbortController(); | |
| 42 | − const timeout = setTimeout( | |
| 43 | − () => controller.abort(), | |
| 44 | − config.requestTimeoutMs, | |
| 45 | − ); | |
| 42 | + const mkResult = ( | |
| 43 | + status: SandboxExecutionResult["status"], | |
| 44 | + opts?: { stdout?: string; stderr?: string; error?: string; exitCode?: number }, | |
| 45 | + ): SandboxExecutionResult => ({ | |
| 46 | + status, | |
| 47 | + stdout: opts?.stdout ?? "", | |
| 48 | + stderr: opts?.stderr ?? "", | |
| 49 | + exitCode: opts?.exitCode, | |
| 50 | + error: opts?.error, | |
| 51 | + durationMs: Date.now() - startedAt, | |
| 52 | + }); | |
| 46 | 53 | |
| 47 | − try { | |
| 48 | − // Transpile TS → JS on the Convex side before sending to the CF isolate. | |
| 49 | − // The dynamic isolate runs the code as plain JS (harness.js), so any | |
| 50 | − // TypeScript syntax must be stripped first. | |
| 51 | − const code = await transpileForRuntime(request.code); | |
| 54 | + // ── Transpile TS → JS on the Convex side ───────────────────────────── | |
| 55 | + const transpiled = transpileForRuntime(request.code); | |
| 56 | + if (transpiled.isErr()) { | |
| 57 | + return mkResult("failed", { error: transpiled.error.message }); | |
| 58 | + } | |
| 52 | 59 | |
| 53 | − const response = await fetch(config.runUrl, { | |
| 60 | + // ── POST to CF host worker ──────────────────────────────────────────── | |
| 61 | + const controller = new AbortController(); | |
| 62 | + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); | |
| 63 | + | |
| 64 | + const response = await Result.tryPromise(() => | |
| 65 | + fetch(config.runUrl, { | |
| 54 | 66 | method: "POST", |
| 55 | 67 | headers: { |
| 56 | 68 | "Content-Type": "application/json", |
| @@ -58,71 +70,66 @@ export async function runCodeWithCloudflareWorkerLoader( · 1 unmodified line | ||
| 58 | 70 | }, |
| 59 | 71 | body: JSON.stringify({ |
| 60 | 72 | taskId: request.taskId, |
| 61 | − code, | |
| 73 | + code: transpiled.value, | |
| 62 | 74 | timeoutMs: request.timeoutMs, |
| 63 | − // The host Worker needs these to call back to Convex for tool | |
| 64 | − // invocations and output streaming. | |
| 65 | 75 | callback: { |
| 66 | 76 | baseUrl: config.callbackBaseUrl, |
| 67 | 77 | authToken: config.callbackAuthToken, |
| 68 | 78 | }, |
| 69 | 79 | }), |
| 70 | 80 | signal: controller.signal, |
| 71 | − }); | |
| 81 | + }), | |
| 82 | + ); | |
| 72 | 83 | |
| 73 | − if (!response.ok) { | |
| 74 | − const text = await response.text().catch(() => response.statusText); | |
| 75 | − return { | |
| 76 | − status: "failed", | |
| 77 | − stdout: "", | |
| 78 | − stderr: text, | |
| 79 | − error: `Cloudflare sandbox returned ${response.status}: ${text}`, | |
| 80 | − durationMs: Date.now() - startedAt, | |
| 81 | − }; | |
| 84 | + clearTimeout(timeout); | |
| 85 | + | |
| 86 | + if (response.isErr()) { | |
| 87 | + const cause = response.error.cause; | |
| 88 | + const isAbort = cause instanceof DOMException && cause.name === "AbortError"; | |
| 89 | + if (isAbort) { | |
| 90 | + return mkResult("timed_out", { | |
| 91 | + error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`, | |
| 92 | + }); | |
| 82 | 93 | } |
| 94 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 95 | + return mkResult("failed", { | |
| 96 | + error: `Cloudflare sandbox request failed: ${message}`, | |
| 97 | + }); | |
| 98 | + } | |
| 83 | 99 | |
| 84 | − const result = (await response.json()) as { | |
| 100 | + // ── Handle non-OK HTTP status ───────────────────────────────────────── | |
| 101 | + if (!response.value.ok) { | |
| 102 | + const text = await Result.tryPromise(() => response.value.text()); | |
| 103 | + const body = text.unwrapOr(response.value.statusText); | |
| 104 | + return mkResult("failed", { | |
| 105 | + stderr: body, | |
| 106 | + error: `Cloudflare sandbox returned ${response.value.status}: ${body}`, | |
| 107 | + }); | |
| 108 | + } | |
| 109 | + | |
| 110 | + // ── Parse JSON response ─────────────────────────────────────────────── | |
| 111 | + const body = await Result.tryPromise(() => | |
| 112 | + response.value.json() as Promise<{ | |
| 85 | 113 | status?: string; |
| 86 | 114 | stdout?: string; |
| 87 | 115 | stderr?: string; |
| 88 | 116 | error?: string; |
| 89 | 117 | exitCode?: number; |
| 90 | − }; | |
| 91 | − | |
| 92 | − const status = mapStatus(result.status); | |
| 93 | − | |
| 94 | − return { | |
| 95 | − status, | |
| 96 | − stdout: result.stdout ?? "", | |
| 97 | − stderr: result.stderr ?? "", | |
| 98 | − exitCode: result.exitCode, | |
| 99 | − error: result.error, | |
| 100 | − durationMs: Date.now() - startedAt, | |
| 101 | − }; | |
| 102 | − } catch (error) { | |
| 103 | − const message = error instanceof Error ? error.message : String(error); | |
| 104 | − const isAbort = error instanceof DOMException && error.name === "AbortError"; | |
| 105 | − | |
| 106 | − if (isAbort) { | |
| 107 | − return { | |
| 108 | − status: "timed_out", | |
| 109 | − stdout: "", | |
| 110 | − stderr: "", | |
| 111 | − error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`, | |
| 112 | − durationMs: Date.now() - startedAt, | |
| 113 | − }; | |
| 114 | − } | |
| 118 | + }>, | |
| 119 | + ); | |
| 115 | 120 | |
| 116 | − return { | |
| 117 | − status: "failed", | |
| 118 | − stdout: "", | |
| 119 | − stderr: "", | |
| 120 | − error: `Cloudflare sandbox request failed: ${message}`, | |
| 121 | − durationMs: Date.now() - startedAt, | |
| 122 | − }; | |
| 123 | − } finally { | |
| 124 | − clearTimeout(timeout); | |
| 121 | + if (body.isErr()) { | |
| 122 | + return mkResult("failed", { | |
| 123 | + error: `Cloudflare sandbox returned invalid JSON`, | |
| 124 | + }); | |
| 125 | 125 | } |
| 126 | + | |
| 127 | + return mkResult(mapStatus(body.value.status), { | |
| 128 | + stdout: body.value.stdout, | |
| 129 | + stderr: body.value.stderr, | |
| 130 | + exitCode: body.value.exitCode, | |
| 131 | + error: body.value.error, | |
| 132 | + }); | |
| 126 | 133 | } |
| 127 | 134 | |
| 128 | 135 | function mapStatus( |
| @@ -5,6 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | // the core helpers here (formatArgs, createToolsProxy, console proxy, execution |
| 6 | 6 | // loop, result mapping) should be mirrored there. |
| 7 | 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants"; |
| 8 | +import { Result } from "better-result"; | |
| 8 | 9 | import { Script, createContext } from "node:vm"; |
| 9 | 10 | import type { |
| 10 | 11 | ExecutionAdapter, |
| @@ -17,11 +18,7 @@ function formatArgs(args: unknown[]): string { · 6 unmodified lines | ||
| 17 | 18 | return args |
| 18 | 19 | .map((value) => { |
| 19 | 20 | if (typeof value === "string") return value; |
| 20 | − try { | |
| 21 | − return JSON.stringify(value); | |
| 22 | − } catch { | |
| 23 | − return String(value); | |
| 24 | − } | |
| 21 | + return Result.try(() => JSON.stringify(value)).unwrapOr(String(value)); | |
| 25 | 22 | }) |
| 26 | 23 | .join(" "); |
| 27 | 24 | } |
| @@ -71,6 +68,30 @@ function createToolsProxy( · 43 unmodified lines | ||
| 71 | 68 | }); |
| 72 | 69 | } |
| 73 | 70 | |
| 71 | +/** Classify a caught error from VM execution into a result status. */ | |
| 72 | +function classifyExecutionError( | |
| 73 | + error: unknown, | |
| 74 | + request: SandboxExecutionRequest, | |
| 75 | +): { status: SandboxExecutionResult["status"]; message: string } { | |
| 76 | + const message = error instanceof Error ? error.message : String(error); | |
| 77 | + | |
| 78 | + if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) { | |
| 79 | + return { | |
| 80 | + status: "timed_out", | |
| 81 | + message: `Execution timed out after ${request.timeoutMs}ms`, | |
| 82 | + }; | |
| 83 | + } | |
| 84 | + | |
| 85 | + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 86 | + return { | |
| 87 | + status: "denied", | |
| 88 | + message: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), | |
| 89 | + }; | |
| 90 | + } | |
| 91 | + | |
| 92 | + return { status: "failed", message }; | |
| 93 | +} | |
| 94 | + | |
| 74 | 95 | export async function runCodeWithAdapter( |
| 75 | 96 | request: SandboxExecutionRequest, |
| 76 | 97 | adapter: ExecutionAdapter, |
| @@ -103,6 +124,26 @@ export async function runCodeWithAdapter( · 26 unmodified lines | ||
| 103 | 124 | ); |
| 104 | 125 | }; |
| 105 | 126 | |
| 127 | + const mkResult = ( | |
| 128 | + status: SandboxExecutionResult["status"], | |
| 129 | + opts?: { error?: string; exitCode?: number }, | |
| 130 | + ): SandboxExecutionResult => ({ | |
| 131 | + status, | |
| 132 | + stdout: stdoutLines.join("\n"), | |
| 133 | + stderr: stderrLines.join("\n"), | |
| 134 | + exitCode: opts?.exitCode, | |
| 135 | + error: opts?.error, | |
| 136 | + durationMs: Date.now() - startedAt, | |
| 137 | + }); | |
| 138 | + | |
| 139 | + // ── Transpile ────────────────────────────────────────────────────────── | |
| 140 | + const transpiled = transpileForRuntime(request.code); | |
| 141 | + if (transpiled.isErr()) { | |
| 142 | + appendStderr(transpiled.error.message); | |
| 143 | + return mkResult("failed", { error: transpiled.error.message }); | |
| 144 | + } | |
| 145 | + | |
| 146 | + // ── Sandbox setup ────────────────────────────────────────────────────── | |
| 106 | 147 | const tools = createToolsProxy(adapter, request.taskId); |
| 107 | 148 | const consoleProxy = { |
| 108 | 149 | log: (...args: unknown[]) => appendStdout(formatArgs(args)), |
| @@ -118,15 +159,14 @@ export async function runCodeWithAdapter( · 9 unmodified lines | ||
| 118 | 159 | clearTimeout, |
| 119 | 160 | }); |
| 120 | 161 | const context = createContext(sandbox, { |
| 121 | − codeGeneration: { | |
| 122 | − strings: false, | |
| 123 | − wasm: false, | |
| 124 | − }, | |
| 162 | + codeGeneration: { strings: false, wasm: false }, | |
| 125 | 163 | }); |
| 126 | 164 | |
| 127 | − const executableCode = await transpileForRuntime(request.code); | |
| 128 | − const runnerScript = new Script(`(async () => {\n"use strict";\n${executableCode}\n})()`); | |
| 165 | + const runnerScript = new Script( | |
| 166 | + `(async () => {\n"use strict";\n${transpiled.value}\n})()`, | |
| 167 | + ); | |
| 129 | 168 | |
| 169 | + // ── Execute with timeout ─────────────────────────────────────────────── | |
| 130 | 170 | let timeoutHandle: ReturnType<typeof setTimeout> | undefined; |
| 131 | 171 | const timeoutPromise = new Promise<never>((_resolve, reject) => { |
| 132 | 172 | timeoutHandle = setTimeout(() => { |
| @@ -134,60 +174,32 @@ export async function runCodeWithAdapter( · 1 unmodified line | ||
| 134 | 174 | }, request.timeoutMs); |
| 135 | 175 | }); |
| 136 | 176 | |
| 137 | − try { | |
| 177 | + const execution = await Result.tryPromise(async () => { | |
| 138 | 178 | const value = await Promise.race([ |
| 139 | − Promise.resolve(runnerScript.runInContext(context, { timeout: Math.max(1, request.timeoutMs) })), | |
| 179 | + Promise.resolve( | |
| 180 | + runnerScript.runInContext(context, { | |
| 181 | + timeout: Math.max(1, request.timeoutMs), | |
| 182 | + }), | |
| 183 | + ), | |
| 140 | 184 | timeoutPromise, |
| 141 | 185 | ]); |
| 186 | + return value; | |
| 187 | + }); | |
| 142 | 188 | |
| 143 | − if (value !== undefined) { | |
| 144 | − appendStdout(`result: ${formatArgs([value])}`); | |
| 145 | − } | |
| 146 | − | |
| 147 | − return { | |
| 148 | − status: "completed", | |
| 149 | − stdout: stdoutLines.join("\n"), | |
| 150 | − stderr: stderrLines.join("\n"), | |
| 151 | − exitCode: 0, | |
| 152 | − durationMs: Date.now() - startedAt, | |
| 153 | − }; | |
| 154 | − } catch (error) { | |
| 155 | − const message = error instanceof Error ? error.message : String(error); | |
| 156 | − if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) { | |
| 157 | − const timeoutMessage = `Execution timed out after ${request.timeoutMs}ms`; | |
| 158 | − appendStderr(timeoutMessage); | |
| 159 | − return { | |
| 160 | − status: "timed_out", | |
| 161 | − stdout: stdoutLines.join("\n"), | |
| 162 | − stderr: stderrLines.join("\n"), | |
| 163 | − error: timeoutMessage, | |
| 164 | − durationMs: Date.now() - startedAt, | |
| 165 | − }; | |
| 166 | − } | |
| 167 | − | |
| 168 | − if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 169 | − const deniedMessage = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); | |
| 170 | − appendStderr(deniedMessage); | |
| 171 | − return { | |
| 172 | − status: "denied", | |
| 173 | − stdout: stdoutLines.join("\n"), | |
| 174 | − stderr: stderrLines.join("\n"), | |
| 175 | − error: deniedMessage, | |
| 176 | − durationMs: Date.now() - startedAt, | |
| 177 | − }; | |
| 178 | − } | |
| 189 | + if (timeoutHandle) clearTimeout(timeoutHandle); | |
| 179 | 190 | |
| 191 | + if (execution.isErr()) { | |
| 192 | + const { status, message } = classifyExecutionError( | |
| 193 | + execution.error.cause, | |
| 194 | + request, | |
| 195 | + ); | |
| 180 | 196 | appendStderr(message); |
| 181 | − return { | |
| 182 | − status: "failed", | |
| 183 | − stdout: stdoutLines.join("\n"), | |
| 184 | − stderr: stderrLines.join("\n"), | |
| 185 | − error: message, | |
| 186 | − durationMs: Date.now() - startedAt, | |
| 187 | − }; | |
| 188 | − } finally { | |
| 189 | − if (timeoutHandle) { | |
| 190 | − clearTimeout(timeoutHandle); | |
| 191 | − } | |
| 197 | + return mkResult(status, { error: message }); | |
| 192 | 198 | } |
| 199 | + | |
| 200 | + if (execution.value !== undefined) { | |
| 201 | + appendStdout(`result: ${formatArgs([execution.value])}`); | |
| 202 | + } | |
| 203 | + | |
| 204 | + return mkResult("completed", { exitCode: 0 }); | |
| 193 | 205 | } |
| @@ -1,38 +1,78 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | +import { Result, TaggedError } from "better-result"; | |
| 4 | + | |
| 5 | +// --------------------------------------------------------------------------- | |
| 6 | +// Errors | |
| 7 | +// --------------------------------------------------------------------------- | |
| 8 | + | |
| 9 | +export class TranspileError extends TaggedError("TranspileError")<{ | |
| 10 | + message: string; | |
| 11 | +}>() {} | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// TypeScript module loader | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +let cachedTypeScript: typeof import("typescript") | null | undefined; | |
| 18 | + | |
| 19 | +function getTypeScriptModule(): typeof import("typescript") | null { | |
| 20 | + if (cachedTypeScript === undefined) { | |
| 21 | + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 22 | + cachedTypeScript = loaded.isOk() ? loaded.value : null; | |
| 23 | + } | |
| 24 | + return cachedTypeScript ?? null; | |
| 25 | +} | |
| 26 | + | |
| 27 | +// --------------------------------------------------------------------------- | |
| 28 | +// Public API | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 3 | 31 | /** |
| 4 | 32 | * Transpile TypeScript code to JavaScript using the `typescript` module. |
| 5 | 33 | * |
| 6 | − * If the `typescript` module is not available, the code is returned as-is | |
| 34 | + * Returns a Result — either the transpiled JS string or a TranspileError. | |
| 35 | + * If the TypeScript module is not available, the code is returned as-is | |
| 7 | 36 | * (graceful fallback for environments where TS isn't installed). |
| 8 | 37 | * |
| 9 | 38 | * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm, |
| 10 | 39 | * Cloudflare Workers isolates, etc.) without further downlevelling. |
| 11 | 40 | */ |
| 12 | −export async function transpileForRuntime(code: string): Promise<string> { | |
| 13 | − let ts: typeof import("typescript"); | |
| 14 | − try { | |
| 15 | − ts = require("typescript"); | |
| 16 | − } catch { | |
| 17 | − return code; | |
| 18 | − } | |
| 41 | +export function transpileForRuntime( | |
| 42 | + code: string, | |
| 43 | +): Result<string, TranspileError> { | |
| 44 | + const ts = getTypeScriptModule(); | |
| 45 | + if (!ts) return Result.ok(code); | |
| 19 | 46 | |
| 20 | 47 | const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext; |
| 21 | 48 | const moduleKind = ts.ModuleKind?.ESNext; |
| 22 | 49 | |
| 23 | − const result = ts.transpileModule(code, { | |
| 24 | − compilerOptions: { | |
| 25 | − ...(target !== undefined ? { target } : {}), | |
| 26 | − ...(moduleKind !== undefined ? { module: moduleKind } : {}), | |
| 27 | − }, | |
| 28 | − reportDiagnostics: true, | |
| 29 | − }); | |
| 50 | + return Result.try({ | |
| 51 | + try: () => { | |
| 52 | + const result = ts.transpileModule(code, { | |
| 53 | + compilerOptions: { | |
| 54 | + ...(target !== undefined ? { target } : {}), | |
| 55 | + ...(moduleKind !== undefined ? { module: moduleKind } : {}), | |
| 56 | + }, | |
| 57 | + reportDiagnostics: true, | |
| 58 | + }); | |
| 30 | 59 | |
| 31 | − if (result.diagnostics && result.diagnostics.length > 0) { | |
| 32 | − const first = result.diagnostics[0]; | |
| 33 | − const message = ts.flattenDiagnosticMessageText(first.messageText, "\n"); | |
| 34 | − throw new Error(`TypeScript transpile error: ${message}`); | |
| 35 | − } | |
| 60 | + if (result.diagnostics && result.diagnostics.length > 0) { | |
| 61 | + const first = result.diagnostics[0]; | |
| 62 | + const message = ts.flattenDiagnosticMessageText( | |
| 63 | + first.messageText, | |
| 64 | + "\n", | |
| 65 | + ); | |
| 66 | + throw new TranspileError({ message: `TypeScript transpile error: ${message}` }); | |
| 67 | + } | |
| 36 | 68 | |
| 37 | − return result.outputText || code; | |
| 69 | + return result.outputText || code; | |
| 70 | + }, | |
| 71 | + catch: (e) => | |
| 72 | + e instanceof TranspileError | |
| 73 | + ? e | |
| 74 | + : new TranspileError({ | |
| 75 | + message: e instanceof Error ? e.message : String(e), | |
| 76 | + }), | |
| 77 | + }); | |
| 38 | 78 | } |
| @@ -13,6 +13,7 @@ · 12 unmodified lines | ||
| 13 | 13 | * without needing their own TypeScript setup. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | +import { Result } from "better-result"; | |
| 16 | 17 | import type { ToolDescriptor } from "./types"; |
| 17 | 18 | |
| 18 | 19 | // --------------------------------------------------------------------------- |
| @@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; · 4 unmodified lines | ||
| 23 | 24 | |
| 24 | 25 | function getTypeScriptModule(): typeof import("typescript") | null { |
| 25 | 26 | if (cachedTypeScript === undefined) { |
| 26 | − try { | |
| 27 | − cachedTypeScript = require("typescript"); | |
| 28 | − } catch { | |
| 29 | − cachedTypeScript = null; | |
| 30 | − } | |
| 27 | + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 28 | + cachedTypeScript = loaded.isOk() ? loaded.value : null; | |
| 31 | 29 | } |
| 32 | 30 | return cachedTypeScript ?? null; |
| 33 | 31 | } |
| @@ -39,7 +37,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines | ||
| 39 | 37 | return !/[\r\n`]/.test(typeExpression); |
| 40 | 38 | } |
| 41 | 39 | |
| 42 | − try { | |
| 40 | + return Result.try(() => { | |
| 43 | 41 | const sourceFile = ts.createSourceFile( |
| 44 | 42 | "_type_expr_check.ts", |
| 45 | 43 | `type __T = ${typeExpression};`, |
| @@ -51,9 +49,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines | ||
| 51 | 49 | sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] } |
| 52 | 50 | ).parseDiagnostics ?? []; |
| 53 | 51 | return diagnostics.length === 0; |
| 54 | − } catch { | |
| 55 | − return false; | |
| 56 | − } | |
| 52 | + }).unwrapOr(false); | |
| 57 | 53 | } |
| 58 | 54 | |
| 59 | 55 | function safeTypeExpression(raw: string | undefined, fallback: string): string { |
| @@ -63,20 +59,62 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 3 unmodified lines | ||
| 63 | 59 | } |
| 64 | 60 | |
| 65 | 61 | const OPENAPI_HELPER_TYPES = ` |
| 66 | −type _OrEmpty<T> = [T] extends [never] ? {} : T; | |
| 62 | +type _Normalize<T> = Exclude<T, undefined | null>; | |
| 63 | +type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | |
| 67 | 64 | type _Simplify<T> = { [K in keyof T]: T[K] } & {}; |
| 65 | +type _ParamsOf<Op> = | |
| 66 | + Op extends { parameters: infer P } ? P : | |
| 67 | + Op extends { parameters?: infer P } ? P : | |
| 68 | + never; | |
| 69 | +type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> = | |
| 70 | + _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never; | |
| 71 | +type _BodyOf<Op> = | |
| 72 | + Op extends { requestBody?: infer B } ? B : | |
| 73 | + Op extends { requestBody: infer B } ? B : | |
| 74 | + never; | |
| 75 | +type _BodyContent<B> = | |
| 76 | + B extends { content: infer C } | |
| 77 | + ? C extends Record<string, infer V> ? V : never | |
| 78 | + : never; | |
| 68 | 79 | type ToolInput<Op> = _Simplify< |
| 69 | − _OrEmpty<Op extends { parameters: { query: infer Q } } ? { [K in keyof Q]: Q[K] } : never> & | |
| 70 | − _OrEmpty<Op extends { parameters: { path: infer P } } ? { [K in keyof P]: P[K] } : never> & | |
| 71 | − _OrEmpty<Op extends { requestBody: { content: { "application/json": infer B } } } ? B : never> | |
| 80 | + _OrEmpty<_ParamAt<Op, "query">> & | |
| 81 | + _OrEmpty<_ParamAt<Op, "path">> & | |
| 82 | + _OrEmpty<_ParamAt<Op, "header">> & | |
| 83 | + _OrEmpty<_ParamAt<Op, "cookie">> & | |
| 84 | + _OrEmpty<_BodyContent<_BodyOf<Op>>> | |
| 72 | 85 | >; |
| 73 | −type ToolOutput<Op> = | |
| 74 | − Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R : | |
| 75 | − Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R : | |
| 76 | − Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R : | |
| 77 | − Op extends { responses: { 204: unknown } } ? void : | |
| 78 | − Op extends { responses: { 205: unknown } } ? void : | |
| 79 | − unknown; | |
| 86 | +type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; | |
| 87 | +type _RespAt<Op, Code extends PropertyKey> = | |
| 88 | + _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never; | |
| 89 | +type _ResponsePayload<R> = | |
| 90 | + [R] extends [never] ? never : | |
| 91 | + R extends { content: infer C } | |
| 92 | + ? C extends Record<string, infer V> ? V : unknown | |
| 93 | + : R extends { schema: infer S } ? S : unknown; | |
| 94 | +type _HasStatus<Op, Code extends PropertyKey> = | |
| 95 | + [_RespAt<Op, Code>] extends [never] ? false : true; | |
| 96 | +type _PayloadAt<Op, Code extends PropertyKey> = | |
| 97 | + Code extends 204 | 205 | |
| 98 | + ? (_HasStatus<Op, Code> extends true ? void : never) | |
| 99 | + : _ResponsePayload<_RespAt<Op, Code>>; | |
| 100 | +type _FirstKnown<T extends readonly unknown[]> = | |
| 101 | + T extends readonly [infer H, ...infer Rest] | |
| 102 | + ? [H] extends [never] ? _FirstKnown<Rest> : H | |
| 103 | + : unknown; | |
| 104 | +type ToolOutput<Op> = _FirstKnown<[ | |
| 105 | + _PayloadAt<Op, 200>, | |
| 106 | + _PayloadAt<Op, 201>, | |
| 107 | + _PayloadAt<Op, 202>, | |
| 108 | + _PayloadAt<Op, 203>, | |
| 109 | + _PayloadAt<Op, 204>, | |
| 110 | + _PayloadAt<Op, 205>, | |
| 111 | + _PayloadAt<Op, 206>, | |
| 112 | + _PayloadAt<Op, 207>, | |
| 113 | + _PayloadAt<Op, 208>, | |
| 114 | + _PayloadAt<Op, 226>, | |
| 115 | + _PayloadAt<Op, "default">, | |
| 116 | + unknown | |
| 117 | +]>; | |
| 80 | 118 | `; |
| 81 | 119 | |
| 82 | 120 | function stripExportKeywordsForTypechecker(dts: string): string { |
| @@ -233,6 +271,8 @@ export interface TypecheckResult { · 150 unmodified lines | ||
| 233 | 271 | readonly errors: readonly string[]; |
| 234 | 272 | } |
| 235 | 273 | |
| 274 | +const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; | |
| 275 | + | |
| 236 | 276 | let warnedMissingCompilerHostSupport = false; |
| 237 | 277 | let warnedSemanticFallback = false; |
| 238 | 278 | |
| @@ -245,7 +285,7 @@ function runSyntaxOnlyTypecheck( · 6 unmodified lines | ||
| 245 | 285 | headerLineCount: number, |
| 246 | 286 | ) => string, |
| 247 | 287 | ): TypecheckResult { |
| 248 | − try { | |
| 288 | + return Result.try(() => { | |
| 249 | 289 | const sourceFile = ts.createSourceFile( |
| 250 | 290 | "generated.ts", |
| 251 | 291 | wrappedCode, |
| @@ -256,16 +296,72 @@ function runSyntaxOnlyTypecheck( · 4 unmodified lines | ||
| 256 | 296 | const diagnostics = ( |
| 257 | 297 | sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] } |
| 258 | 298 | ).parseDiagnostics ?? []; |
| 259 | − if (diagnostics.length === 0) { | |
| 260 | − return { ok: true, errors: [] }; | |
| 261 | − } | |
| 299 | + if (diagnostics.length === 0) return TYPECHECK_OK; | |
| 262 | 300 | return { |
| 263 | − ok: false, | |
| 264 | − errors: diagnostics.map((d: import("typescript").Diagnostic) => formatError(d, headerLineCount)), | |
| 301 | + ok: false as const, | |
| 302 | + errors: diagnostics.map((d) => formatError(d, headerLineCount)), | |
| 265 | 303 | }; |
| 266 | − } catch { | |
| 267 | − return { ok: true, errors: [] }; | |
| 268 | − } | |
| 304 | + }).unwrapOr(TYPECHECK_OK); | |
| 305 | +} | |
| 306 | + | |
| 307 | +function runSemanticTypecheck( | |
| 308 | + ts: typeof import("typescript"), | |
| 309 | + wrappedCode: string, | |
| 310 | + headerLineCount: number, | |
| 311 | + formatError: ( | |
| 312 | + diagnostic: import("typescript").Diagnostic, | |
| 313 | + headerLineCount: number, | |
| 314 | + ) => string, | |
| 315 | +): Result<TypecheckResult, Error> { | |
| 316 | + return Result.try({ | |
| 317 | + try: () => { | |
| 318 | + const sourceFile = ts.createSourceFile( | |
| 319 | + "generated.ts", | |
| 320 | + wrappedCode, | |
| 321 | + ts.ScriptTarget.ESNext, | |
| 322 | + true, | |
| 323 | + ts.ScriptKind.TS, | |
| 324 | + ); | |
| 325 | + | |
| 326 | + const compilerOptions: import("typescript").CompilerOptions = { | |
| 327 | + target: ts.ScriptTarget.ESNext, | |
| 328 | + module: ts.ModuleKind.ESNext, | |
| 329 | + strict: true, | |
| 330 | + noEmit: true, | |
| 331 | + lib: ["lib.es2022.d.ts"], | |
| 332 | + types: [], // prevent automatic @types/* from conflicting with sandbox declarations | |
| 333 | + }; | |
| 334 | + | |
| 335 | + const host = ts.createCompilerHost(compilerOptions); | |
| 336 | + const originalGetSourceFile = host.getSourceFile.bind(host); | |
| 337 | + host.getSourceFile = (fileName, languageVersion) => { | |
| 338 | + if (fileName === "generated.ts") return sourceFile; | |
| 339 | + return originalGetSourceFile(fileName, languageVersion); | |
| 340 | + }; | |
| 341 | + | |
| 342 | + const program = ts.createProgram(["generated.ts"], compilerOptions, host); | |
| 343 | + const diagnostics = program.getSemanticDiagnostics(sourceFile); | |
| 344 | + | |
| 345 | + if (diagnostics.length === 0) return TYPECHECK_OK; | |
| 346 | + | |
| 347 | + // Filter out errors from the .d.ts header — only report user code errors | |
| 348 | + const userErrors = diagnostics.filter((d) => { | |
| 349 | + if (d.start !== undefined && d.file) { | |
| 350 | + const { line } = d.file.getLineAndCharacterOfPosition(d.start); | |
| 351 | + return line + 1 > headerLineCount; | |
| 352 | + } | |
| 353 | + return false; | |
| 354 | + }); | |
| 355 | + | |
| 356 | + if (userErrors.length === 0) return TYPECHECK_OK; | |
| 357 | + | |
| 358 | + return { | |
| 359 | + ok: false as const, | |
| 360 | + errors: userErrors.map((d) => formatError(d, headerLineCount)), | |
| 361 | + }; | |
| 362 | + }, | |
| 363 | + catch: (e) => (e instanceof Error ? e : new Error(String(e))), | |
| 364 | + }); | |
| 269 | 365 | } |
| 270 | 366 | |
| 271 | 367 | /** |
| @@ -278,13 +374,8 @@ export function typecheckCode( · 6 unmodified lines | ||
| 278 | 374 | code: string, |
| 279 | 375 | toolDeclarations: string, |
| 280 | 376 | ): TypecheckResult { |
| 281 | − let ts: typeof import("typescript"); | |
| 282 | − try { | |
| 283 | − ts = require("typescript"); | |
| 284 | − } catch { | |
| 285 | − // TypeScript not available — skip typechecking | |
| 286 | − return { ok: true, errors: [] }; | |
| 287 | − } | |
| 377 | + const ts = getTypeScriptModule(); | |
| 378 | + if (!ts) return TYPECHECK_OK; | |
| 288 | 379 | |
| 289 | 380 | // Wrap the code in an async function with the tools declaration. |
| 290 | 381 | // We declare sandbox globals (console, setTimeout, etc.) ourselves since |
| @@ -301,12 +392,12 @@ export function typecheckCode( · 10 unmodified lines | ||
| 301 | 392 | |
| 302 | 393 | const formatError = ( |
| 303 | 394 | diagnostic: import("typescript").Diagnostic, |
| 304 | − headerLineCount: number, | |
| 395 | + hdrLineCount: number, | |
| 305 | 396 | ): string => { |
| 306 | 397 | const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); |
| 307 | 398 | if (diagnostic.start !== undefined && diagnostic.file) { |
| 308 | 399 | const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); |
| 309 | − const adjustedLine = line + 1 - headerLineCount; | |
| 400 | + const adjustedLine = line + 1 - hdrLineCount; | |
| 310 | 401 | if (adjustedLine > 0) { |
| 311 | 402 | return `Line ${adjustedLine}: ${message}`; |
| 312 | 403 | } |
| @@ -328,66 +419,16 @@ export function typecheckCode( · 15 unmodified lines | ||
| 328 | 419 | return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); |
| 329 | 420 | } |
| 330 | 421 | |
| 331 | − try { | |
| 332 | − const sourceFile = ts.createSourceFile( | |
| 333 | − "generated.ts", | |
| 334 | − wrappedCode, | |
| 335 | − ts.ScriptTarget.ESNext, | |
| 336 | − true, | |
| 337 | − ts.ScriptKind.TS, | |
| 338 | − ); | |
| 339 | − | |
| 340 | − const compilerOptions: import("typescript").CompilerOptions = { | |
| 341 | − target: ts.ScriptTarget.ESNext, | |
| 342 | − module: ts.ModuleKind.ESNext, | |
| 343 | − strict: true, | |
| 344 | − noEmit: true, | |
| 345 | − lib: ["lib.es2022.d.ts"], | |
| 346 | − types: [], // prevent automatic @types/* (e.g. @types/node) from conflicting with our sandbox declarations | |
| 347 | − }; | |
| 348 | − | |
| 349 | − const host = ts.createCompilerHost(compilerOptions); | |
| 350 | − const originalGetSourceFile = host.getSourceFile.bind(host); | |
| 351 | − host.getSourceFile = (fileName, languageVersion) => { | |
| 352 | − if (fileName === "generated.ts") return sourceFile; | |
| 353 | − return originalGetSourceFile(fileName, languageVersion); | |
| 354 | − }; | |
| 355 | − | |
| 356 | − const program = ts.createProgram(["generated.ts"], compilerOptions, host); | |
| 357 | − const diagnostics = program.getSemanticDiagnostics(sourceFile); | |
| 358 | − | |
| 359 | − if (diagnostics.length === 0) { | |
| 360 | − return { ok: true, errors: [] }; | |
| 361 | − } | |
| 362 | − | |
| 363 | − // Filter out errors from the .d.ts header (circular refs, etc.) — only report user code errors | |
| 364 | − const userErrors = diagnostics.filter((d) => { | |
| 365 | − if (d.start !== undefined && d.file) { | |
| 366 | − const { line } = d.file.getLineAndCharacterOfPosition(d.start); | |
| 367 | − return line + 1 > headerLineCount; | |
| 368 | − } | |
| 369 | − return false; | |
| 370 | − }); | |
| 422 | + const semantic = runSemanticTypecheck(ts, wrappedCode, headerLineCount, formatError); | |
| 423 | + if (semantic.isOk()) return semantic.value; | |
| 371 | 424 | |
| 372 | − if (userErrors.length === 0) { | |
| 373 | − return { ok: true, errors: [] }; | |
| 374 | − } | |
| 375 | − | |
| 376 | − return { | |
| 377 | − ok: false, | |
| 378 | − errors: userErrors.map((d) => formatError(d, headerLineCount)), | |
| 379 | − }; | |
| 380 | − } catch (error) { | |
| 381 | − // Some runtimes (e.g. Convex action isolates) can lack the full Node-backed | |
| 382 | − // TypeScript host environment. If semantic typechecking cannot initialize, | |
| 383 | − // fall back to syntax-only parsing instead of failing the MCP call. | |
| 384 | − if (!warnedSemanticFallback) { | |
| 385 | − warnedSemanticFallback = true; | |
| 386 | − console.warn( | |
| 387 | − `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${error instanceof Error ? error.message : String(error)}`, | |
| 388 | − ); | |
| 389 | − } | |
| 390 | − | |
| 391 | − return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); | |
| 425 | + // Semantic typechecking failed to initialize — fall back to syntax-only. | |
| 426 | + if (!warnedSemanticFallback) { | |
| 427 | + warnedSemanticFallback = true; | |
| 428 | + console.warn( | |
| 429 | + `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${semantic.error.message}`, | |
| 430 | + ); | |
| 392 | 431 | } |
| 432 | + | |
| 433 | + return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); | |
| 393 | 434 | } |
| @@ -14,6 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | "build:release": "bun run scripts/build-release.ts" |
| 15 | 15 | }, |
| 16 | 16 | "dependencies": { |
| 17 | + "better-result": "^2.7.0", | |
| 17 | 18 | "convex": "^1.31.7", |
| 18 | 19 | "graphql": "^16.12.0" |
| 19 | 20 | } |