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 index d67188e633..2a6bc16193 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -65,16 +65,37 @@ const KONG_FUNCTIONS_CONFIG = JSON.stringify({ FUNCTION_SECRET: "must-not-appear-in-debug-logs", }, }, + "custom-alias": { + entrypointPath: "/app/functions/custom/index.ts", + importMapPath: "", + staticFiles: [], + verifyJWT: false, + }, + "nested-worker-path": { + entrypointPath: "/app/functions/custom/.supabase-worker/custom/index.ts", + importMapPath: "", + staticFiles: [], + verifyJWT: false, + }, }); -const CUSTOM_FUNCTION = `Deno.serve(() => new Response("ok", { +const CUSTOM_FUNCTION = `import { sharedValue } from "../_shared/value.ts"; + +Deno.serve(() => new Response("ok", { headers: { "X-Custom-Id": "abc123", + "X-Function-Slug": Deno.env.get("SUPABASE_FUNCTION_SLUG") ?? "", + "X-Shared-Import": sharedValue, "X-Shared": Deno.env.get("SHARED") ?? "", "X-Function-Only": Deno.env.get("FUNCTION_ONLY") ?? "", "X-Global-Only": Deno.env.get("GLOBAL_ONLY") ?? "", "Access-Control-Expose-Headers": "X-Custom-Id", }, }));`; +const NESTED_FUNCTION = `Deno.serve(() => new Response("ok", { + headers: { + "X-Function-Slug": Deno.env.get("SUPABASE_FUNCTION_SLUG") ?? "", + }, +}));`; function jwtWithInvalidSignature(algorithm?: string): string { const header = Buffer.from(JSON.stringify({ alg: algorithm, typ: "JWT" })).toString("base64url"); @@ -125,6 +146,24 @@ function containerLogs(container: string): string { return `${result.stdout ?? ""}\n${result.stderr ?? ""}`; } +async function fetchFunctionWhenReady(url: string, init?: RequestInit): Promise { + const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(url, init); + if (response.status !== 502 && response.status !== 503) return response; + lastError = new Error(`Received ${response.status} from ${url}`); + } catch (error) { + lastError = error; + } + await Bun.sleep(250); + } + + throw new Error(`Function at ${url} did not become ready`, { cause: lastError }); +} + async function writeKongConfig(dir: string, edgeRuntimeContainer: string) { // Was: read straight from apps/cli-go/internal/start/templates/kong.yml. That // package was deleted outright (CLI-1966; unreachable from the TS CLI, directly @@ -303,7 +342,19 @@ describe("functions serve runtime template (offline)", () => { try { await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); await mkdir(join(dir, "functions", "custom"), { recursive: true }); + await mkdir(join(dir, "functions", "_shared"), { recursive: true }); + await mkdir(join(dir, "functions", "custom", ".supabase-worker", "custom"), { + recursive: true, + }); await writeFile(join(dir, "functions", "custom", "index.ts"), CUSTOM_FUNCTION); + await writeFile( + join(dir, "functions", "custom", ".supabase-worker", "custom", "index.ts"), + NESTED_FUNCTION, + ); + await writeFile( + join(dir, "functions", "_shared", "value.ts"), + 'export const sharedValue = "shared-import-ok";\n', + ); await writeKongConfig(dir, runtimeContainer); const createNetwork = spawnSync("docker", ["network", "create", network], { @@ -405,17 +456,28 @@ describe("functions serve runtime template (offline)", () => { true, ); - const customResponse = await fetch(`${functionsUrl}/custom`, { - headers: { Origin: "http://localhost:3000" }, - }); + const [customResponse, aliasResponse] = await Promise.all([ + fetchFunctionWhenReady(`${functionsUrl}/custom`, { + headers: { Origin: "http://localhost:3000" }, + }), + fetchFunctionWhenReady(`${functionsUrl}/custom-alias`), + ]); expect(customResponse.status).toBe(200); expect(customResponse.headers.get("x-custom-id")).toBe("abc123"); + expect(customResponse.headers.get("x-function-slug")).toBe("custom"); + expect(customResponse.headers.get("x-shared-import")).toBe("shared-import-ok"); expect(customResponse.headers.get("x-shared")).toBe("function"); expect(customResponse.headers.get("x-function-only")).toBe("function"); expect(customResponse.headers.get("x-global-only")).toBe("global"); expect(customResponse.headers.get("access-control-expose-headers")?.toLowerCase()).toBe( "x-custom-id", ); + expect(aliasResponse.status).toBe(200); + expect(aliasResponse.headers.get("x-function-slug")).toBe("custom-alias"); + expect(aliasResponse.headers.get("x-shared-import")).toBe("shared-import-ok"); + const nestedResponse = await fetchFunctionWhenReady(`${functionsUrl}/nested-worker-path`); + expect(nestedResponse.status).toBe(200); + expect(nestedResponse.headers.get("x-function-slug")).toBe("nested-worker-path"); const runtimeLogs = containerLogs(runtimeContainer); expect(runtimeLogs).toContain("Functions config:"); expect(runtimeLogs).toContain('"custom"'); @@ -433,6 +495,10 @@ describe("functions serve runtime template (offline)", () => { message: "Missing authorization header", msg: "Missing authorization header", }); + + const reusedCustomResponse = await fetch(`${functionsUrl}/custom`); + expect(reusedCustomResponse.status).toBe(200); + expect(reusedCustomResponse.headers.get("x-function-slug")).toBe("custom"); } finally { spawnSync("docker", ["rm", "-f", kongContainer, runtimeContainer], { stdio: "ignore", diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 2cf89e2e71..7d8efa357c 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -122,6 +122,30 @@ const functionsConfig: Record = (() => { } })(); +// Edge Runtime pools user workers by servicePath. Keep the source directory for the +// common case, but give each function a process-owned temporary path when multiple +// configured functions share that directory. Deno creates each path outside the set of +// existing source directories, so a real function directory cannot use the same pool key. +// maybeEntrypoint still points at the real source file, so module resolution is unchanged. +const workerServicePaths = (() => { + const sourcePathCounts = new Map(); + for (const config of Object.values(functionsConfig)) { + const sourcePath = dirname(config.entrypointPath); + sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1); + } + + return Object.fromEntries( + Object.entries(functionsConfig).map(([functionName, config]) => { + const sourcePath = dirname(config.entrypointPath); + const servicePath = + sourcePathCounts.get(sourcePath) === 1 + ? sourcePath + : Deno.makeTempDirSync({ prefix: "supabase-worker-" }); + return [functionName, servicePath]; + }), + ); +})(); + /* --- JWT verification --- */ export function extractBearerToken(rawToken: string) { const tokenParts = rawToken.split(" "); @@ -317,7 +341,7 @@ Deno.serve({ } } - const servicePath = dirname(functionsConfig[functionName].entrypointPath); + const servicePath = workerServicePaths[functionName]; console.error(`serving the request with ${servicePath}`); // Ref: https://supabase.com/docs/guides/functions/limits @@ -331,6 +355,8 @@ Deno.serve({ ([name, _]) => !name.startsWith("SUPABASE_"), ), ), + // Listed after the spreads so neither the container env nor function config can shadow it + SUPABASE_FUNCTION_SLUG: functionName, }; if (SUPABASE_PUBLISHABLE_KEY) { envVarsObj["SUPABASE_PUBLISHABLE_KEYS"] = JSON.stringify({ diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index d28c0259da..6e84a91ac4 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -21,7 +21,11 @@ import { resolveFunctionsRuntimeConfig, type ResolvedFunctionsBundle, } from "./functions.ts"; -import { verifyRequest } from "./services/edge-runtime-main.ts"; +import { + buildFunctionEnv, + createWorkerServicePathResolver, + verifyRequest, +} from "./services/edge-runtime-main.ts"; const testPorts: PortSet = { apiPort: 40_000, @@ -305,6 +309,72 @@ describe("stack Functions runtime config", () => { }); }); +describe("stack Functions runtime env", () => { + const config = { + env: { SHARED: "shared-value" }, + supabaseUrl: "http://api-gw:8000", + publishableKey: "publishable-key", + secretKey: "secret-key", + dbUrl: "postgresql://db", + }; + + it("injects the resolved function name as SUPABASE_FUNCTION_SLUG", () => { + const env = buildFunctionEnv(config, { env: {} }, "notes-mcp"); + + expect(env.SUPABASE_FUNCTION_SLUG).toBe("notes-mcp"); + }); + + it("keeps the slug per-function across calls", () => { + expect(buildFunctionEnv(config, { env: {} }, "notes-mcp").SUPABASE_FUNCTION_SLUG).toBe( + "notes-mcp", + ); + expect(buildFunctionEnv(config, { env: {} }, "echo-headers").SUPABASE_FUNCTION_SLUG).toBe( + "echo-headers", + ); + }); + + it("does not let container or function env shadow the slug", () => { + const env = buildFunctionEnv( + { ...config, env: { ...config.env, SUPABASE_FUNCTION_SLUG: "container-spoof" } }, + { env: { SUPABASE_FUNCTION_SLUG: "function-spoof" } }, + "notes-mcp", + ); + + expect(env.SUPABASE_FUNCTION_SLUG).toBe("notes-mcp"); + }); + + it("still passes through project env and Supabase connection vars", () => { + const env = buildFunctionEnv(config, { env: { FUNCTION_ONLY: "function-value" } }, "notes-mcp"); + + expect(env.SHARED).toBe("shared-value"); + expect(env.FUNCTION_ONLY).toBe("function-value"); + expect(env.SUPABASE_URL).toBe("http://api-gw:8000"); + }); + + it("uses stable temporary worker paths when functions share a source directory", () => { + let nextWorkerId = 0; + const resolveWorkerServicePath = createWorkerServicePathResolver( + () => `/tmp/supabase-worker-${++nextWorkerId}`, + ); + const functions = { + alpha: { entrypointPath: "/supabase/functions/shared/alpha.ts" }, + beta: { entrypointPath: "/supabase/functions/shared/beta.ts" }, + isolated: { entrypointPath: "/supabase/functions/isolated/index.ts" }, + nested: { + entrypointPath: "/supabase/functions/shared/.supabase-worker/alpha/index.ts", + }, + }; + + expect(resolveWorkerServicePath(functions, "alpha")).toBe("/tmp/supabase-worker-1"); + expect(resolveWorkerServicePath(functions, "beta")).toBe("/tmp/supabase-worker-2"); + expect(resolveWorkerServicePath(functions, "alpha")).toBe("/tmp/supabase-worker-1"); + expect(resolveWorkerServicePath(functions, "isolated")).toBe("/supabase/functions/isolated"); + expect(resolveWorkerServicePath(functions, "nested")).toBe( + "/supabase/functions/shared/.supabase-worker/alpha", + ); + }); +}); + describe("stack Functions runtime auth", () => { for (const { name, authorization, code, message } of authFailureCases) { it(name, async () => { diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 0075dc0ef4..58b4b0b451 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -173,11 +173,8 @@ function fileUrl(path: string) { return new URL(`file://${path}`).href; } -async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { - const authError = await verifyRequest(req, config, functionConfig); - if (authError) return authError; - - const envVars = Object.entries({ +export function buildFunctionEnv(config: any, functionConfig: any, functionName: string) { + return { ...config.env, ...functionConfig.env, SUPABASE_URL: config.supabaseUrl, @@ -186,11 +183,52 @@ async function serveFunction(req: Request, config: any, functionName: string, fu SUPABASE_DB_URL: config.dbUrl, SUPABASE_PUBLISHABLE_KEYS: JSON.stringify({ default: config.publishableKey }), SUPABASE_SECRET_KEYS: JSON.stringify({ default: config.secretKey }), - }); + SUPABASE_FUNCTION_SLUG: functionName, + }; +} + +export function createWorkerServicePathResolver(makeTempDir: () => string) { + const sharedWorkerPaths = new Map(); + + return (functions: Record, functionName: string) => { + const functionConfig = functions[functionName]; + if (!functionConfig) { + throw new Error(`Function ${functionName} is not configured`); + } + const sourcePath = dirname(functionConfig.entrypointPath); + const sharesSourcePath = Object.entries(functions).some( + ([otherName, otherConfig]) => + otherName !== functionName && dirname(otherConfig.entrypointPath) === sourcePath, + ); + if (!sharesSourcePath) return sourcePath; + + // Edge Runtime pools user workers by servicePath. A real temporary directory cannot + // collide with an existing source directory. maybeEntrypoint remains the real source + // file, so the temporary path changes only the worker's cache identity. + const key = `${sourcePath}\0${functionName}`; + const existingPath = sharedWorkerPaths.get(key); + if (existingPath) return existingPath; + + const workerPath = makeTempDir(); + sharedWorkerPaths.set(key, workerPath); + return workerPath; + }; +} + +const resolveWorkerServicePath = createWorkerServicePathResolver(() => + Deno.makeTempDirSync({ prefix: "supabase-worker-" }), +); + +async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { + const authError = await verifyRequest(req, config, functionConfig); + if (authError) return authError; + + const envVars = Object.entries(buildFunctionEnv(config, functionConfig, functionName)); + const servicePath = resolveWorkerServicePath(config.functions, functionName); try { const worker = await EdgeRuntime.userWorkers.create({ - servicePath: dirname(functionConfig.entrypointPath), + servicePath, memoryLimitMb: 256, workerTimeoutMs: 400000, noModuleCache: false, diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 93aa1a7e78..d636a3ecf5 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -21,10 +21,17 @@ describe("createStack e2e", () => { dataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-")); projectDir = mkdtempSync(join(tmpdir(), "supabase-e2e-project-")); writeFunction(projectDir, "hello", "hello"); + writeSharedFunction(projectDir); + writeNestedWorkerPathFunction(projectDir); stack = await createStack({ projectDir, - functions: functionsBundle(projectDir, ["hello"]), + functions: functionsBundle(projectDir, [ + "hello", + "shared-alpha", + "shared-beta", + "nested-worker-path", + ]), jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, }); @@ -70,6 +77,28 @@ describe("createStack e2e", () => { }, ); + test( + "keeps worker env isolated for functions sharing a source directory", + { timeout: 30_000 }, + async () => { + const [alpha, beta] = await Promise.all([ + fetchFunctionWhenReady(`${stack.url}/functions/v1/shared-alpha`), + fetchFunctionWhenReady(`${stack.url}/functions/v1/shared-beta`), + ]); + const reusedAlpha = await fetchFunctionWhenReady(`${stack.url}/functions/v1/shared-alpha`); + const nested = await fetchFunctionWhenReady(`${stack.url}/functions/v1/nested-worker-path`); + + expect(alpha.status).toBe(200); + expect(await alpha.text()).toBe("shared-alpha:shared-import-ok"); + expect(beta.status).toBe(200); + expect(await beta.text()).toBe("shared-beta:shared-import-ok"); + expect(reusedAlpha.status).toBe(200); + expect(await reusedAlpha.text()).toBe("shared-alpha:shared-import-ok"); + expect(nested.status).toBe(200); + expect(await nested.text()).toBe("nested-worker-path:nested-source"); + }, + ); + test("reloadFunctions picks up newly added Edge Functions", { timeout: 30_000 }, async () => { writeFunction(projectDir, "later", "later"); await stack.reloadFunctions({ functions: functionsBundle(projectDir, ["hello", "later"]) }); @@ -149,6 +178,45 @@ function writeFunction(projectDir: string, slug: string, body: string) { writeFileSync(join(dir, "index.ts"), `Deno.serve(() => new Response(${codeSafeJson(body)}));\n`); } +function writeSharedFunction(projectDir: string) { + const functionsDir = join(projectDir, "supabase", "functions"); + const sharedDir = join(functionsDir, "shared"); + mkdirSync(sharedDir, { recursive: true }); + mkdirSync(join(functionsDir, "_shared"), { recursive: true }); + writeFileSync( + join(functionsDir, "_shared", "value.ts"), + 'export const sharedValue = "shared-import-ok";\n', + ); + writeFileSync( + join(sharedDir, "index.ts"), + `import { sharedValue } from "../_shared/value.ts"; + +Deno.serve(() => new Response( + (Deno.env.get("SUPABASE_FUNCTION_SLUG") ?? "") + ":" + sharedValue, +)); +`, + ); +} + +function writeNestedWorkerPathFunction(projectDir: string) { + const dir = join( + projectDir, + "supabase", + "functions", + "shared", + ".supabase-worker", + "shared-alpha", + ); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "index.ts"), + `Deno.serve(() => new Response( + (Deno.env.get("SUPABASE_FUNCTION_SLUG") ?? "") + ":nested-source", +)); +`, + ); +} + function functionsBundle( projectDir: string, names: ReadonlyArray, @@ -158,7 +226,17 @@ function functionsBundle( functions: names.map((name) => ({ name, verifyJWT: false, - entrypointPath: join(projectDir, "supabase", "functions", name, "index.ts"), + entrypointPath: join( + projectDir, + "supabase", + "functions", + name === "nested-worker-path" + ? join("shared", ".supabase-worker", "shared-alpha") + : name.startsWith("shared-") + ? "shared" + : name, + "index.ts", + ), importMapPath: null, staticFiles: [], env: {},