From 697e405ace8fc7bbdc85250bc13f740b43ed80a3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 24 Jun 2026 11:49:28 +0200 Subject: [PATCH 1/2] fix(cli): serve edge functions offline by bundling the runtime template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge-runtime bootstrap template (`serve.main.ts`) imported `deno.land/std` and `jsr:@panva/jose` modules that Deno resolved over the network on every container start, so `supabase functions serve` failed offline even after the images had been pulled (supabase/supabase#45570). Inline the two trivial std deps (status codes + posix path helpers) into a local, unit-tested module, and bundle `jose` (npm) into the template via esbuild so the runtime entrypoint is fully self-contained. Compiled binaries embed the pre-bundled template through the existing `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define; running from source bundles on demand. The container launch path is unchanged — only the heredoc'd template contents differ. Co-Authored-By: Claude Opus 4.8 --- apps/cli/package.json | 11 ++- apps/cli/scripts/build-binary.ts | 24 +++++ apps/cli/scripts/build.ts | 4 +- .../commands/functions/serve/SIDE_EFFECTS.md | 21 ++-- .../shared/functions/serve-main-bundler.ts | 40 ++++++++ .../functions/serve-main-bundler.unit.test.ts | 24 +++++ .../src/shared/functions/serve-main-deps.ts | 95 +++++++++++++++++++ .../functions/serve-main-deps.unit.test.ts | 66 +++++++++++++ apps/cli/src/shared/functions/serve.main.ts | 13 ++- apps/cli/src/shared/functions/serve.ts | 79 ++++++--------- .../src/shared/functions/serve.unit.test.ts | 52 ++-------- pnpm-lock.yaml | 7 ++ 12 files changed, 315 insertions(+), 121 deletions(-) create mode 100644 apps/cli/scripts/build-binary.ts create mode 100644 apps/cli/src/shared/functions/serve-main-bundler.ts create mode 100644 apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts create mode 100644 apps/cli/src/shared/functions/serve-main-deps.ts create mode 100644 apps/cli/src/shared/functions/serve-main-deps.unit.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 0ea70e83f2..98a438b46b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -26,8 +26,8 @@ "scripts": { "build": "pnpm build:go-sidecar && pnpm build:next && pnpm build:legacy && pnpm build:shim", "build:go-sidecar": "mkdir -p dist && cp ../cli-go/supabase-go dist/supabase-go", - "build:next": "bun build src/next/main.ts --compile --outfile dist/supabase-next", - "build:legacy": "bun build src/legacy/main.ts --compile --outfile dist/supabase-legacy", + "build:next": "bun scripts/build-binary.ts next", + "build:legacy": "bun scripts/build-binary.ts legacy", "build:shim": "bun build src/shared/cli/bin.ts --outfile dist/supabase.js --target node", "dev:next": "pnpm exec bun src/next/main.ts", "dev:legacy": "pnpm exec bun src/legacy/main.ts", @@ -37,6 +37,9 @@ "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, + "dependencies": { + "jose": "^6.2.3" + }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.177", "@anthropic-ai/sdk": "^0.104.1", @@ -62,6 +65,7 @@ "@vitest/coverage-istanbul": "catalog:", "dotenv": "^17.4.2", "effect": "catalog:", + "esbuild": "^0.28.1", "ink": "^7.0.6", "ink-spinner": "^5.0.0", "knip": "catalog:", @@ -112,8 +116,7 @@ "ignore": [ "scripts/*.ts", "tests/**/*.ts", - "src/shared/telemetry/event-catalog.ts", - "src/shared/functions/serve.main.ts" + "src/shared/telemetry/event-catalog.ts" ], "ignoreBinaries": [ "nx", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts new file mode 100644 index 0000000000..453a050ee5 --- /dev/null +++ b/apps/cli/scripts/build-binary.ts @@ -0,0 +1,24 @@ +import { $ } from "bun"; +import process from "node:process"; + +import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; + +/** + * Compile a single CLI shell to a standalone binary, embedding the pre-bundled + * edge-runtime template via the `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define so + * the binary serves Functions offline without bundling at runtime + * (supabase/supabase#45570). Used by the `build:next` / `build:legacy` scripts; the + * multi-target release build in `build.ts` injects the same define. + */ +const shell = process.argv[2]; +if (shell !== "next" && shell !== "legacy") { + throw new Error(`expected shell "next" or "legacy", received "${shell ?? ""}"`); +} + +const entrypoint = `src/${shell}/main.ts`; +const outfile = `dist/supabase-${shell}`; +const defineArg = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify( + await bundleServeMainTemplate(), +)}`; + +await $`bun build ${entrypoint} --compile ${defineArg} --outfile ${outfile}`; diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 4e825d1521..347232d5cf 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -4,6 +4,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; const MUSL_TARGETS = [ { @@ -92,9 +93,8 @@ const TARGETS = [ const entrypoint = path.join(root, "apps/cli/src", shell, "main.ts"); const distDir = path.join(root, "dist"); const goSource = path.resolve(root, "apps/cli-go"); -const serveMainTemplateSource = path.join(root, "apps/cli/src/shared/functions/serve.main.ts"); const serveMainTemplateDefine = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify( - await readFile(serveMainTemplateSource, "utf8"), + await bundleServeMainTemplate(), )}`; const posthogBuildDefines = [ `--define=process.env.SUPABASE_CLI_POSTHOG_KEY=${JSON.stringify(process.env.POSTHOG_API_KEY ?? "")}`, diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index 9f3103aae3..40157b5ebf 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -2,16 +2,16 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` | TypeScript | as the CLI-owned worker bootstrap template source | +| Path | Format | When | +| ---------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | +| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | +| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | +| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | +| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | +| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `` | JSON | when `auth.signing_keys_path` is configured | +| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | ## Files Written @@ -99,3 +99,4 @@ Long-running raw log / error events only; there is no terminal `result` event on - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`, matching the Go serve path. - Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`, matching Go) and passed into `loadProjectConfig`. The command does not mutate `process.env` or move/hide any project files. - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container. The Go CLI never auto-restarts a crashed container. +- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. diff --git a/apps/cli/src/shared/functions/serve-main-bundler.ts b/apps/cli/src/shared/functions/serve-main-bundler.ts new file mode 100644 index 0000000000..e6960ff28c --- /dev/null +++ b/apps/cli/src/shared/functions/serve-main-bundler.ts @@ -0,0 +1,40 @@ +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +/** + * Absolute path to the edge-runtime bootstrap template. The template runs verbatim + * inside the edge-runtime (Deno) container as `/root/index.ts`. + */ +const serveMainEntrypoint = fileURLToPath(new URL("./serve.main.ts", import.meta.url)); + +/** + * Bundle `serve.main.ts` into a single self-contained ES module string with all of + * its dependencies inlined. + * + * The template used to import `deno.land/std` and `jsr:` modules that Deno resolved + * over the network on every container start, breaking `functions serve` offline + * (supabase/supabase#45570). Bundling inlines `jose` and the local `serve-main-deps` + * helpers so the runtime entrypoint needs no network access. + * + * `platform: "browser"` selects `jose`'s Web Crypto build, which runs under the + * edge-runtime's Deno. `Deno` and `EdgeRuntime` are left as free globals. + */ +export async function bundleServeMainTemplate(): Promise { + const result = await build({ + entryPoints: [serveMainEntrypoint], + bundle: true, + format: "esm", + platform: "browser", + minify: true, + write: false, + legalComments: "none", + logLevel: "silent", + }); + + const output = result.outputFiles[0]?.text; + if (output === undefined) { + throw new Error("esbuild produced no output for the functions serve runtime template"); + } + return output; +} diff --git a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts new file mode 100644 index 0000000000..b2677be895 --- /dev/null +++ b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; + +describe("bundleServeMainTemplate", () => { + it("produces a self-contained runtime template with no remote import specifiers", async () => { + const bundled = await bundleServeMainTemplate(); + + // The offline failure (#45570) was caused by these being resolved over the + // network on every container start. They must be inlined into the bundle. + expect(bundled).not.toContain("https://"); + expect(bundled).not.toContain("jsr:"); + expect(bundled).not.toMatch(/from\s*["']jose["']/); + }); + + it("preserves the template's Deno.serve entrypoint and inlines jose", async () => { + const bundled = await bundleServeMainTemplate(); + + // Template body survives bundling (Deno global left as a free reference). + expect(bundled).toContain("Deno.serve"); + // jose is inlined, so the bundle is materially larger than the ~12KB template. + expect(bundled.length).toBeGreaterThan(20_000); + }); +}); diff --git a/apps/cli/src/shared/functions/serve-main-deps.ts b/apps/cli/src/shared/functions/serve-main-deps.ts new file mode 100644 index 0000000000..6660ec1d42 --- /dev/null +++ b/apps/cli/src/shared/functions/serve-main-deps.ts @@ -0,0 +1,95 @@ +/** + * Runtime dependencies inlined into the edge-runtime bootstrap template + * (`serve.main.ts`). These replace the remote `deno.land/std` imports the template + * used to resolve over the network on every container start, which broke + * `functions serve` offline (supabase/supabase#45570). + * + * Kept as a normal, type-checked module so the path logic can be unit-tested; the + * template imports it relatively and the build inlines it via the bundler. + */ + +/** HTTP status codes used by the runtime template (subset of `deno.land/std/http/status.ts`). */ +export const STATUS_CODE = { + OK: 200, + Unauthorized: 401, + NotFound: 404, + InternalServerError: 500, + ServiceUnavailable: 503, +} as const; + +/** Canonical reason phrases for the status codes the template renders. */ +export const STATUS_TEXT: Record = { + [STATUS_CODE.OK]: "OK", + [STATUS_CODE.Unauthorized]: "Unauthorized", + [STATUS_CODE.NotFound]: "Not Found", + [STATUS_CODE.InternalServerError]: "Internal Server Error", + [STATUS_CODE.ServiceUnavailable]: "Service Unavailable", +}; + +/** Posix path normalization: collapse separators, resolve `.` and `..`. */ +function normalize(path: string): string { + const isAbsolute = path.startsWith("/"); + const out: string[] = []; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") { + continue; + } + if (segment === "..") { + if (out.length > 0 && out[out.length - 1] !== "..") { + out.pop(); + } else if (!isAbsolute) { + out.push(".."); + } + continue; + } + out.push(segment); + } + const joined = out.join("/"); + if (isAbsolute) { + return "/" + joined; + } + return joined === "" ? "." : joined; +} + +/** Posix `join`: concatenate segments with a single separator and normalize. */ +export function join(...paths: string[]): string { + const joined = paths.filter((part) => part.length > 0).join("/"); + return joined === "" ? "." : normalize(joined); +} + +/** Posix `dirname`: the directory portion of a path. */ +export function dirname(path: string): string { + if (path.length === 0) { + return "."; + } + let end = path.length; + while (end > 1 && path[end - 1] === "/") { + end -= 1; + } + const stripped = path.slice(0, end); + const lastSlash = stripped.lastIndexOf("/"); + if (lastSlash === -1) { + return "."; + } + if (lastSlash === 0) { + return "/"; + } + return stripped.slice(0, lastSlash); +} + +function encodeWhitespace(value: string): string { + return value.replace( + /\s/g, + (char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase()}`, + ); +} + +/** Posix `toFileUrl`: convert an absolute path to a `file://` URL. */ +export function toFileUrl(path: string): URL { + if (!path.startsWith("/")) { + throw new TypeError(`Path must be absolute: received "${path}"`); + } + const url = new URL("file:///"); + url.pathname = encodeWhitespace(path.replace(/%/g, "%25").replace(/\\/g, "%5C")); + return url; +} diff --git a/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts b/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts new file mode 100644 index 0000000000..8f221781b1 --- /dev/null +++ b/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; + +describe("posix join", () => { + it("joins absolute segments with a single separator", () => { + expect(join("/a/b", "c")).toBe("/a/b/c"); + }); + + it("collapses a trailing separator on the base", () => { + expect(join("/a/b/", "c")).toBe("/a/b/c"); + }); + + it("joins a relative base with a file name", () => { + expect(join("supabase/functions/hello", "package.json")).toBe( + "supabase/functions/hello/package.json", + ); + }); + + it("resolves parent-directory segments", () => { + expect(join("/a/b", "../c")).toBe("/a/c"); + }); +}); + +describe("posix dirname", () => { + it("returns the directory of an absolute file path", () => { + expect(dirname("/a/b/index.ts")).toBe("/a/b"); + }); + + it("returns the directory of a relative file path", () => { + expect(dirname("supabase/functions/hello/index.ts")).toBe("supabase/functions/hello"); + }); + + it("returns the root for a top-level absolute path", () => { + expect(dirname("/index.ts")).toBe("/"); + }); +}); + +describe("posix toFileUrl", () => { + it("converts an absolute path to a file URL", () => { + expect(toFileUrl("/a/b/index.ts").href).toBe("file:///a/b/index.ts"); + }); + + it("percent-encodes whitespace in the path", () => { + expect(toFileUrl("/a b/index.ts").href).toBe("file:///a%20b/index.ts"); + }); + + it("rejects a relative path", () => { + expect(() => toFileUrl("a/b.ts")).toThrow(); + }); +}); + +describe("status constants", () => { + it("exposes the HTTP status codes used by the runtime template", () => { + expect(STATUS_CODE.OK).toBe(200); + expect(STATUS_CODE.Unauthorized).toBe(401); + expect(STATUS_CODE.NotFound).toBe(404); + expect(STATUS_CODE.InternalServerError).toBe(500); + expect(STATUS_CODE.ServiceUnavailable).toBe(503); + }); + + it("maps status codes to their canonical reason phrases", () => { + expect(STATUS_TEXT[STATUS_CODE.InternalServerError]).toBe("Internal Server Error"); + expect(STATUS_TEXT[STATUS_CODE.OK]).toBe("OK"); + }); +}); diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 7345bea444..e86b82ebda 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -2,10 +2,9 @@ declare const Deno: any; declare const EdgeRuntime: any; -import { STATUS_CODE, STATUS_TEXT } from "https://deno.land/std/http/status.ts"; -import * as posix from "https://deno.land/std/path/posix/mod.ts"; +import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; -import * as jose from "jsr:@panva/jose@6"; +import * as jose from "jose"; const SB_SPECIFIC_ERROR_CODE = { BootError: STATUS_CODE.ServiceUnavailable /** Service Unavailable (RFC 7231, 6.6.4) */, @@ -193,7 +192,7 @@ async function shouldUsePackageJsonDiscovery({ if (importMapPath) { return false; } - const packageJsonPath = posix.join(posix.dirname(entrypointPath), "package.json"); + const packageJsonPath = join(dirname(entrypointPath), "package.json"); try { await Deno.lstat(packageJsonPath); } catch (err) { @@ -254,7 +253,7 @@ Deno.serve({ } } - const servicePath = posix.dirname(functionsConfig[functionName].entrypointPath); + const servicePath = dirname(functionsConfig[functionName].entrypointPath); console.error(`serving the request with ${servicePath}`); // Ref: https://supabase.com/docs/guides/functions/limits @@ -295,8 +294,8 @@ Deno.serve({ // This need to be kept for Deno 1 compatibility. const decoratorType = "tc39"; - const absEntrypoint = posix.join(Deno.cwd(), functionsConfig[functionName].entrypointPath); - const maybeEntrypoint = posix.toFileUrl(absEntrypoint).href; + const absEntrypoint = join(Deno.cwd(), functionsConfig[functionName].entrypointPath); + const maybeEntrypoint = toFileUrl(absEntrypoint).href; const usePackageJson = await shouldUsePackageJsonDiscovery(functionsConfig[functionName]); const staticPatterns = functionsConfig[functionName].staticFiles; diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 5b214f9d0f..057f878e9a 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -17,12 +17,11 @@ import { sign as signJwtBytes, type JsonWebKeyInput, } from "node:crypto"; -import { readFileSync, watch } from "node:fs"; +import { watch } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { styleText } from "node:util"; -import { fileURLToPath } from "node:url"; import { Cause, Duration, Effect, Layer, Option, Queue, Redacted, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; @@ -88,7 +87,6 @@ const legacyDefaultEdgeRuntimeVersion = "v1.74.1"; const defaultSupabaseEnv = "development"; const clerkDomainPattern = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; -const serveMainSourcePath = new URL("./serve.main.ts", import.meta.url); let cachedLegacyFunctionsServeMainTemplate: string | undefined; const watchIgnoreGlobs = [ "**/.git/**", @@ -221,57 +219,33 @@ export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => ); /** - * `serve.main.ts` is authored as a TypeScript module so it can be type-checked - * and linted in this repo, but it runs verbatim as a Deno entrypoint inside the - * edge-runtime container. Strip the TypeScript-only preamble — the - * `// @ts-nocheck` pragma and the `declare const` ambient shims — so the injected - * `/root/index.ts` matches the Go CLI's `templates/main.ts` (which starts at the - * first `import`). Tolerant of reordering/extra blank lines so a small edit to the - * preamble does not silently ship the shims into the container. + * `serve.main.ts` runs verbatim as a Deno entrypoint inside the edge-runtime + * container (written to `/root/index.ts`). It is bundled into a single + * self-contained module so its `jose` and local helper dependencies are inlined and + * the runtime needs no network access on start (supabase/supabase#45570). + * + * Compiled builds embed the pre-bundled template via the + * `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define (see `scripts/build.ts`), so the + * shipped binary never bundles at runtime. Running from source (`bun src/supabase.ts`) + * bundles on demand. */ -export function stripServeMainTypecheckPreamble(source: string): string { - const lines = source.split("\n"); - let start = 0; - while (start < lines.length) { - const line = lines[start]!; - if (line === "// @ts-nocheck" || line.length === 0 || line.startsWith("declare ")) { - start += 1; - continue; - } - break; +function getLegacyFunctionsServeMainTemplate(): Promise { + if (cachedLegacyFunctionsServeMainTemplate !== undefined) { + return Promise.resolve(cachedLegacyFunctionsServeMainTemplate); } - return lines.slice(start).join("\n"); -} - -function getLegacyFunctionsServeMainTemplate(): string { - if (cachedLegacyFunctionsServeMainTemplate === undefined) { - const rawTemplateSource = - typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" - ? SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE - : readLegacyFunctionsServeMainTemplateFromDisk(); - - cachedLegacyFunctionsServeMainTemplate = stripServeMainTypecheckPreamble(rawTemplateSource); + if (typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string") { + cachedLegacyFunctionsServeMainTemplate = SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE; + return Promise.resolve(cachedLegacyFunctionsServeMainTemplate); } - return cachedLegacyFunctionsServeMainTemplate; -} - -function readLegacyFunctionsServeMainTemplateFromDisk() { - const candidates = [ - fileURLToPath(serveMainSourcePath), - resolve(dirname(process.execPath), "..", "src", "shared", "functions", "serve.main.ts"), - ]; - - for (const candidate of candidates) { - try { - return readFileSync(candidate, "utf8"); - } catch (error) { - if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { - throw error; - } - } - } - - throw new Error("failed to load functions serve runtime template"); + // Running from source: the build-time define is absent, so bundle on demand. The + // bundler (and its esbuild dependency) is imported lazily and only here, so it is + // never loaded by shipped binaries — which always take the define branch above. + return import("./serve-main-bundler.ts") + .then(({ bundleServeMainTemplate }) => bundleServeMainTemplate()) + .then((bundled) => { + cachedLegacyFunctionsServeMainTemplate = bundled; + return bundled; + }); } function reveal(value: string | Redacted.Redacted | undefined): string | undefined { @@ -1445,6 +1419,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { ...buildFunctionsServeInspectArgs(input.inspectMode, input.flags.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; + const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); const command = [ "run", "-d", @@ -1476,7 +1451,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { legacyGetRegistryImageUrl(`supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`), "-c", buildServeEntrypointScript( - getLegacyFunctionsServeMainTemplate(), + serveMainTemplate, runtimeCommand, dockerMultilineEnvScript?.scriptPath, ), diff --git a/apps/cli/src/shared/functions/serve.unit.test.ts b/apps/cli/src/shared/functions/serve.unit.test.ts index 259e57e931..f265c28412 100644 --- a/apps/cli/src/shared/functions/serve.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.unit.test.ts @@ -1,43 +1,7 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { buildServeEntrypointScript, stripServeMainTypecheckPreamble } from "./serve.ts"; - -const serveMainSource = readFileSync( - fileURLToPath(new URL("./serve.main.ts", import.meta.url)), - "utf8", -); - -describe("stripServeMainTypecheckPreamble", () => { - it("removes the @ts-nocheck pragma and ambient declare shims", () => { - const source = [ - "// @ts-nocheck", - "declare const Deno: any;", - "declare const EdgeRuntime: any;", - "", - 'import { foo } from "https://example.com/foo.ts";', - "const x = 1;", - ].join("\n"); - - expect(stripServeMainTypecheckPreamble(source)).toBe( - ['import { foo } from "https://example.com/foo.ts";', "const x = 1;"].join("\n"), - ); - }); - - it("leaves a template that has no preamble untouched", () => { - const source = ['import { foo } from "x";', "const x = 1;"].join("\n"); - expect(stripServeMainTypecheckPreamble(source)).toBe(source); - }); - - it("strips the real serve.main.ts down to its first import, matching the Go template head", () => { - const stripped = stripServeMainTypecheckPreamble(serveMainSource); - expect(stripped.startsWith("import ")).toBe(true); - expect(stripped).not.toContain("@ts-nocheck"); - expect(stripped).not.toContain("declare const Deno"); - expect(stripped).not.toContain("declare const EdgeRuntime"); - }); -}); +import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; +import { buildServeEntrypointScript } from "./serve.ts"; describe("buildServeEntrypointScript", () => { const template = ['import { x } from "y";', "Deno.serve(() => new Response());"].join("\n"); @@ -62,13 +26,9 @@ describe("buildServeEntrypointScript", () => { ); }); - it("does not let the real serve.main.ts template close the heredoc early", () => { - expect(serveMainSource.split("\n")).not.toContain("EOF"); - expect(() => - buildServeEntrypointScript(stripServeMainTypecheckPreamble(serveMainSource), [ - "edge-runtime", - "start", - ]), - ).not.toThrow(); + it("does not let the real bundled serve.main.ts template close the heredoc early", async () => { + const bundled = await bundleServeMainTemplate(); + expect(bundled.split("\n")).not.toContain("EOF"); + expect(() => buildServeEntrypointScript(bundled, ["edge-runtime", "start"])).not.toThrow(); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60e97e4945..b87a9903dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,10 @@ importers: version: 6.7.2(typanion@3.14.0) apps/cli: + dependencies: + jose: + specifier: ^6.2.3 + version: 6.2.3 devDependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.177 @@ -161,6 +165,9 @@ importers: effect: specifier: 'catalog:' version: 4.0.0-beta.83 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 ink: specifier: ^7.0.6 version: 7.0.6(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7) From 383b5bcaf7f8d3b2746c7f19f0806ee18c609fbc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 24 Jun 2026 13:53:57 +0200 Subject: [PATCH 2/2] test(cli): add offline e2e for the edge functions serve template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot the bundled serve.main.ts template under edge-runtime with `--network none` and assert it reaches the "Serving functions" log line with no remote module resolution. This codifies the supabase/supabase#45570 regression contract — a control run of the unbundled template fails here with a DNS error. Skips when Docker is unavailable. Exports `LEGACY_EDGE_RUNTIME_IMAGE` so the test pins the same image the CLI uses. Co-Authored-By: Claude Opus 4.8 --- .../shared/legacy-edge-runtime-image.ts | 2 +- .../functions/serve-main-offline.e2e.test.ts | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 1df2b005d6..da146970ba 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -13,7 +13,7 @@ import { Effect, type FileSystem, type Path } from "effect"; */ // `FROM supabase/edge-runtime:v1.74.1 AS edgeruntime` (embedded Dockerfile). -const LEGACY_EDGE_RUNTIME_IMAGE = "supabase/edge-runtime:v1.74.1"; +export const LEGACY_EDGE_RUNTIME_IMAGE = "supabase/edge-runtime:v1.74.1"; // `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts new file mode 100644 index 0000000000..dad054aaf1 --- /dev/null +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -0,0 +1,105 @@ +import { execSync, spawnSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { LEGACY_EDGE_RUNTIME_IMAGE } from "../../legacy/shared/legacy-edge-runtime-image.ts"; +import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; + +/** + * Regression guard for supabase/supabase#45570: the edge-runtime worker bootstrap + * template must boot with **no network access**. Before bundling, the template + * imported `deno.land/std` and `jsr:` modules that Deno resolved over the network on + * every start, so `functions serve` failed offline. + * + * This boots the real bundled template as an edge-runtime main service with + * `--network none` and asserts it reaches the template's own "Serving functions" + * log line without any remote fetch. The service is mounted at `/app` (read-only) so + * `/root` stays writable for Deno's module cache — isolating the network as the only + * variable (a control run of the unbundled template fails here with a DNS error). + */ + +function hasDocker(): boolean { + try { + execSync("docker info", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const dockerAvailable = hasDocker(); +const SERVE_OFFLINE_STARTUP_TIMEOUT_MS = 60_000; +const SERVE_OFFLINE_TEST_TIMEOUT_MS = 120_000; + +function containerLogs(container: string): string { + const result = spawnSync("docker", ["logs", container], { encoding: "utf8" }); + return `${result.stdout ?? ""}\n${result.stderr ?? ""}`; +} + +describe("functions serve runtime template (offline)", () => { + test.skipIf(!dockerAvailable)( + "boots under edge-runtime with networking disabled and fetches nothing remote", + { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, + async () => { + const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); + const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; + try { + await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); + + const run = spawnSync( + "docker", + [ + "run", + "-d", + "--name", + container, + "--network", + "none", + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=offline-e2e", + "-e", + "SUPABASE_URL=http://127.0.0.1:54321", + "-e", + "SUPABASE_INTERNAL_FUNCTIONS_CONFIG={}", + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + LEGACY_EDGE_RUNTIME_IMAGE, + "start", + "--main-service=/app", + "--port=8081", + ], + { encoding: "utf8" }, + ); + expect(run.status, run.stderr).toBe(0); + + const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; + let logs = ""; + while (Date.now() < deadline) { + logs = containerLogs(container); + if (/Serving functions on/.test(logs) || /worker boot error/i.test(logs)) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + // The template's own onListen message — proves the bundled worker booted. + expect(logs).toMatch(/Serving functions on/); + // No remote module resolution occurred (the #45570 failure mode). + expect(logs).not.toMatch(/deno\.land|jsr\.io/); + expect(logs).not.toMatch(/dns error|name resolution|worker boot error/i); + } finally { + spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" }); + await rm(dir, { recursive: true, force: true }); + } + }, + ); +});