diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 2cf89e2e71..2c6a310eb7 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -122,6 +122,29 @@ const functionsConfig: Record = (() => { } })(); +// EdgeRuntime.userWorkers.create() pools workers by servicePath alone, so two configured +// function names whose entrypoints live in the same directory would share one cached +// worker's environment (and thus its SUPABASE_FUNCTION_SLUG). For those shared directories +// only, serialize worker creation per servicePath and force a fresh worker whenever the +// requested function differs from whichever one currently owns the cached worker. Module +// resolution keeps using each function's real entrypoint path, so imports that reach +// outside the directory (e.g. a sibling `_shared` folder) are unaffected. +const sharedServicePaths = (() => { + const counts = new Map(); + for (const config of Object.values(functionsConfig)) { + const servicePath = dirname(config.entrypointPath); + counts.set(servicePath, (counts.get(servicePath) ?? 0) + 1); + } + return new Set( + Array.from(counts) + .filter(([, count]) => count > 1) + .map(([servicePath]) => servicePath), + ); +})(); + +const sharedWorkerOwners = new Map(); +const sharedWorkerQueues = new Map>(); + /* --- JWT verification --- */ export function extractBearerToken(rawToken: string) { const tokenParts = rawToken.split(" "); @@ -331,6 +354,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({ @@ -347,7 +372,6 @@ Deno.serve({ ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), ); - const forceCreate = false; const customModuleRoot = ""; // empty string to allow any local path const cpuTimeSoftLimitMs = 1000; const cpuTimeHardLimitMs = 2000; @@ -364,26 +388,53 @@ Deno.serve({ const staticPatterns = functionsConfig[functionName].staticFiles; - try { - const worker = await EdgeRuntime.userWorkers.create({ - servicePath, - memoryLimitMb, - workerTimeoutMs, - noModuleCache, - noNpm: !usePackageJson, - importMapPath: functionsConfig[functionName].importMapPath, - envVars, - forceCreate, - customModuleRoot, - cpuTimeSoftLimitMs, - cpuTimeHardLimitMs, - decoratorType, - maybeEntrypoint, - context: { - useReadSyncFileAPI: true, - }, - staticPatterns, + // Only shared directories pay for coordination: serialize worker creation for this + // servicePath so a concurrent request for a different function can't race the pool's + // "which slug currently owns this worker" decision, then force a fresh worker only + // when the owner actually changed. + let forceCreate = false; + let releaseSharedWorkerSlot: (() => void) | undefined; + if (sharedServicePaths.has(servicePath)) { + const previousSlot = sharedWorkerQueues.get(servicePath) ?? Promise.resolve(); + let releaseSlot: () => void; + const currentSlot = new Promise((resolve) => { + releaseSlot = resolve; }); + sharedWorkerQueues.set( + servicePath, + previousSlot.then(() => currentSlot), + ); + await previousSlot; + releaseSharedWorkerSlot = releaseSlot!; + forceCreate = sharedWorkerOwners.get(servicePath) !== functionName; + sharedWorkerOwners.set(servicePath, functionName); + } + + try { + let worker; + try { + worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + noNpm: !usePackageJson, + importMapPath: functionsConfig[functionName].importMapPath, + envVars, + forceCreate, + customModuleRoot, + cpuTimeSoftLimitMs, + cpuTimeHardLimitMs, + decoratorType, + maybeEntrypoint, + context: { + useReadSyncFileAPI: true, + }, + staticPatterns, + }); + } finally { + releaseSharedWorkerSlot?.(); + } const userReq = prepareUserRequest(req); return await worker.fetch(userReq); diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 594cd70e58..b44eac4326 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -19,7 +19,7 @@ import { resolveFunctionsRuntimeConfig, type ResolvedFunctionsBundle, } from "./functions.ts"; -import { verifyRequest } from "./services/edge-runtime-main.ts"; +import { buildFunctionEnv, verifyRequest } from "./services/edge-runtime-main.ts"; const testPorts: PortSet = { apiPort: 40_000, @@ -303,6 +303,49 @@ 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"); + }); +}); + 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 41eb366eac..d6652d121f 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -171,11 +171,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, @@ -184,26 +181,86 @@ 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, + }; +} - try { - const worker = await EdgeRuntime.userWorkers.create({ - servicePath: dirname(functionConfig.entrypointPath), - memoryLimitMb: 256, - workerTimeoutMs: 400000, - noModuleCache: false, - noNpm: false, - importMapPath: functionConfig.importMapPath ?? undefined, - envVars, - forceCreate: false, - customModuleRoot: "", - cpuTimeSoftLimitMs: 1000, - cpuTimeHardLimitMs: 2000, - decoratorType: "tc39", - maybeEntrypoint: fileUrl(functionConfig.entrypointPath), - context: { useReadSyncFileAPI: true }, - staticPatterns: functionConfig.staticFiles, +// EdgeRuntime.userWorkers.create() pools workers by servicePath alone, so two configured +// functions whose entrypoints live in the same directory would share one cached worker's +// environment (and thus its SUPABASE_FUNCTION_SLUG). For those shared directories only, +// serialize worker creation per servicePath and force a fresh worker whenever the +// requested function differs from whichever one currently owns the cached worker. Module +// resolution keeps using each function's real entrypoint path, so imports that reach +// outside the directory (e.g. a sibling `_shared` folder) are unaffected. +function computeSharedServicePaths(functions: Record) { + const counts = new Map(); + for (const functionConfig of Object.values(functions)) { + const servicePath = dirname(functionConfig.entrypointPath); + counts.set(servicePath, (counts.get(servicePath) ?? 0) + 1); + } + return new Set( + Array.from(counts) + .filter(([, count]) => count > 1) + .map(([servicePath]) => servicePath), + ); +} + +const sharedWorkerOwners = new Map(); +const sharedWorkerQueues = new Map>(); + +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 = dirname(functionConfig.entrypointPath); + const sharedServicePaths = computeSharedServicePaths(config.functions ?? {}); + + // Only shared directories pay for coordination: serialize worker creation for this + // servicePath so a concurrent request for a different function can't race the pool's + // "which slug currently owns this worker" decision, then force a fresh worker only + // when the owner actually changed. + let forceCreate = false; + let releaseSharedWorkerSlot: (() => void) | undefined; + if (sharedServicePaths.has(servicePath)) { + const previousSlot = sharedWorkerQueues.get(servicePath) ?? Promise.resolve(); + let releaseSlot: () => void; + const currentSlot = new Promise((resolve) => { + releaseSlot = resolve; }); + sharedWorkerQueues.set( + servicePath, + previousSlot.then(() => currentSlot), + ); + await previousSlot; + releaseSharedWorkerSlot = releaseSlot!; + forceCreate = sharedWorkerOwners.get(servicePath) !== functionName; + sharedWorkerOwners.set(servicePath, functionName); + } + + try { + let worker; + try { + worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb: 256, + workerTimeoutMs: 400000, + noModuleCache: false, + noNpm: false, + importMapPath: functionConfig.importMapPath ?? undefined, + envVars, + forceCreate, + customModuleRoot: "", + cpuTimeSoftLimitMs: 1000, + cpuTimeHardLimitMs: 2000, + decoratorType: "tc39", + maybeEntrypoint: fileUrl(functionConfig.entrypointPath), + context: { useReadSyncFileAPI: true }, + staticPatterns: functionConfig.staticFiles, + }); + } finally { + releaseSharedWorkerSlot?.(); + } return await worker.fetch(req); } catch (error) { console.error(`Failed to serve Function ${functionName}`, error);