From 2989c69d32b4877bc71aea5188d3ff0faf36bf34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Wed, 26 Aug 2026 18:48:21 +0200 Subject: [PATCH 1/5] feat(cli): inject function slug into served fns Fixes AI-1129 --- apps/cli/src/shared/functions/serve.main.ts | 2 + packages/stack/src/functions.unit.test.ts | 45 ++++++++++++++++++- .../stack/src/services/edge-runtime-main.ts | 17 ++++--- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 2cf89e2e71..3fef51d76d 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -331,6 +331,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 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..7de37e61cf 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,7 +181,15 @@ 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, + }; +} + +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)); try { const worker = await EdgeRuntime.userWorkers.create({ From 954c972441184f45fc4769536245f2e859c56b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Thu, 27 Aug 2026 11:30:37 +0200 Subject: [PATCH 2/5] refactor: address PR feedback --- apps/cli/src/shared/functions/serve.main.ts | 60 +++++++++++++------ .../stack/src/services/edge-runtime-main.ts | 56 ++++++++++++----- 2 files changed, 81 insertions(+), 35 deletions(-) diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 3fef51d76d..bacaa3be3b 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -275,6 +275,9 @@ export function prepareUserRequest(req: Request): Request { return clonedReq; } +const servicePathSlugs = new Map(); +const servicePathCreateQueues = new Map>(); + Deno.serve({ handler: async (req: Request) => { const url = new URL(req.url); @@ -349,7 +352,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; @@ -367,25 +369,45 @@ 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, + let releaseWorkerCreate; + const currentWorkerCreate = new Promise((resolve) => { + releaseWorkerCreate = resolve; }); + const previousWorkerCreate = servicePathCreateQueues.get(servicePath) ?? Promise.resolve(); + const queuedWorkerCreate = previousWorkerCreate.then(() => currentWorkerCreate); + servicePathCreateQueues.set(servicePath, queuedWorkerCreate); + await previousWorkerCreate; + + // Keep this map in step with Edge Runtime's servicePath worker cache. + const forceCreate = servicePathSlugs.get(servicePath) !== functionName; + 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, + }); + servicePathSlugs.set(servicePath, functionName); + } finally { + releaseWorkerCreate(); + if (servicePathCreateQueues.get(servicePath) === queuedWorkerCreate) { + servicePathCreateQueues.delete(servicePath); + } + } const userReq = prepareUserRequest(req); return await worker.fetch(userReq); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 7de37e61cf..737a37ed1d 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -185,30 +185,54 @@ export function buildFunctionEnv(config: any, functionConfig: any, functionName: }; } +const servicePathSlugs = new Map(); +const servicePathCreateQueues = 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); 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, + let releaseWorkerCreate: () => void; + const currentWorkerCreate = new Promise((resolve) => { + releaseWorkerCreate = resolve; }); + const previousWorkerCreate = servicePathCreateQueues.get(servicePath) ?? Promise.resolve(); + const queuedWorkerCreate = previousWorkerCreate.then(() => currentWorkerCreate); + servicePathCreateQueues.set(servicePath, queuedWorkerCreate); + await previousWorkerCreate; + + // Keep this map in step with Edge Runtime's servicePath worker cache. + const forceCreate = servicePathSlugs.get(servicePath) !== functionName; + let worker: Awaited>; + 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, + }); + servicePathSlugs.set(servicePath, functionName); + } finally { + releaseWorkerCreate!(); + if (servicePathCreateQueues.get(servicePath) === queuedWorkerCreate) { + servicePathCreateQueues.delete(servicePath); + } + } return await worker.fetch(req); } catch (error) { console.error(`Failed to serve Function ${functionName}`, error); From cbc9aec54733004bf4337390c2809de21c7d82c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Thu, 27 Aug 2026 12:18:43 +0200 Subject: [PATCH 3/5] refactor: undo --- apps/cli/src/shared/functions/serve.main.ts | 74 +++++++++---------- .../stack/src/services/edge-runtime-main.ts | 66 +++++++---------- 2 files changed, 61 insertions(+), 79 deletions(-) diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index bacaa3be3b..c1095f541a 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -122,6 +122,19 @@ const functionsConfig: Record = (() => { } })(); +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), + ); +})(); + /* --- JWT verification --- */ export function extractBearerToken(rawToken: string) { const tokenParts = rawToken.split(" "); @@ -275,9 +288,6 @@ export function prepareUserRequest(req: Request): Request { return clonedReq; } -const servicePathSlugs = new Map(); -const servicePathCreateQueues = new Map>(); - Deno.serve({ handler: async (req: Request) => { const url = new URL(req.url); @@ -352,6 +362,8 @@ Deno.serve({ ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), ); + // Shared entrypoint directories need a fresh worker for the per-function slug. + const forceCreate = sharedServicePaths.has(servicePath); const customModuleRoot = ""; // empty string to allow any local path const cpuTimeSoftLimitMs = 1000; const cpuTimeHardLimitMs = 2000; @@ -369,45 +381,25 @@ Deno.serve({ const staticPatterns = functionsConfig[functionName].staticFiles; try { - let releaseWorkerCreate; - const currentWorkerCreate = new Promise((resolve) => { - releaseWorkerCreate = resolve; + 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, }); - const previousWorkerCreate = servicePathCreateQueues.get(servicePath) ?? Promise.resolve(); - const queuedWorkerCreate = previousWorkerCreate.then(() => currentWorkerCreate); - servicePathCreateQueues.set(servicePath, queuedWorkerCreate); - await previousWorkerCreate; - - // Keep this map in step with Edge Runtime's servicePath worker cache. - const forceCreate = servicePathSlugs.get(servicePath) !== functionName; - 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, - }); - servicePathSlugs.set(servicePath, functionName); - } finally { - releaseWorkerCreate(); - if (servicePathCreateQueues.get(servicePath) === queuedWorkerCreate) { - servicePathCreateQueues.delete(servicePath); - } - } const userReq = prepareUserRequest(req); return await worker.fetch(userReq); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 737a37ed1d..8acb4c3dfd 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -185,8 +185,17 @@ export function buildFunctionEnv(config: any, functionConfig: any, functionName: }; } -const servicePathSlugs = new Map(); -const servicePathCreateQueues = new Map>(); +function hasSharedServicePath(config: any, servicePath: string): boolean { + const functions: Record = config.functions ?? {}; + let count = 0; + for (const functionConfig of Object.values(functions)) { + if (dirname(functionConfig.entrypointPath) === servicePath) { + count += 1; + if (count > 1) return true; + } + } + return false; +} async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { const authError = await verifyRequest(req, config, functionConfig); @@ -196,43 +205,24 @@ async function serveFunction(req: Request, config: any, functionName: string, fu const servicePath = dirname(functionConfig.entrypointPath); try { - let releaseWorkerCreate: () => void; - const currentWorkerCreate = new Promise((resolve) => { - releaseWorkerCreate = resolve; + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb: 256, + workerTimeoutMs: 400000, + noModuleCache: false, + noNpm: false, + importMapPath: functionConfig.importMapPath ?? undefined, + envVars, + // Shared entrypoint directories need a fresh worker for the per-function slug. + forceCreate: hasSharedServicePath(config, servicePath), + customModuleRoot: "", + cpuTimeSoftLimitMs: 1000, + cpuTimeHardLimitMs: 2000, + decoratorType: "tc39", + maybeEntrypoint: fileUrl(functionConfig.entrypointPath), + context: { useReadSyncFileAPI: true }, + staticPatterns: functionConfig.staticFiles, }); - const previousWorkerCreate = servicePathCreateQueues.get(servicePath) ?? Promise.resolve(); - const queuedWorkerCreate = previousWorkerCreate.then(() => currentWorkerCreate); - servicePathCreateQueues.set(servicePath, queuedWorkerCreate); - await previousWorkerCreate; - - // Keep this map in step with Edge Runtime's servicePath worker cache. - const forceCreate = servicePathSlugs.get(servicePath) !== functionName; - let worker: Awaited>; - 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, - }); - servicePathSlugs.set(servicePath, functionName); - } finally { - releaseWorkerCreate!(); - if (servicePathCreateQueues.get(servicePath) === queuedWorkerCreate) { - servicePathCreateQueues.delete(servicePath); - } - } return await worker.fetch(req); } catch (error) { console.error(`Failed to serve Function ${functionName}`, error); From 10eb8ec2eaf07bba9b294ddb6ca9e6650ae60739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Fri, 28 Aug 2026 13:54:31 +0200 Subject: [PATCH 4/5] fix: address PR feedback --- .../functions/serve-main-offline.e2e.test.ts | 35 +++++++++++-- apps/cli/src/shared/functions/serve.main.ts | 31 ++++++++---- packages/stack/src/functions.unit.test.ts | 22 +++++++- .../stack/src/services/edge-runtime-main.ts | 32 +++++++----- packages/stack/tests/createStack.e2e.test.ts | 50 ++++++++++++++++++- 5 files changed, 140 insertions(+), 30 deletions(-) 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 6945a2d8f7..63668a2c98 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,10 +65,20 @@ 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, + }, }); -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") ?? "", @@ -303,7 +313,12 @@ 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 writeFile(join(dir, "functions", "custom", "index.ts"), CUSTOM_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 +420,25 @@ describe("functions serve runtime template (offline)", () => { true, ); - const customResponse = await fetch(`${functionsUrl}/custom`, { - headers: { Origin: "http://localhost:3000" }, - }); + const [customResponse, aliasResponse] = await Promise.all([ + fetch(`${functionsUrl}/custom`, { + headers: { Origin: "http://localhost:3000" }, + }), + fetch(`${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 runtimeLogs = containerLogs(runtimeContainer); expect(runtimeLogs).toContain("Functions config:"); expect(runtimeLogs).toContain('"custom"'); @@ -433,6 +456,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 c1095f541a..8e09e99439 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -122,16 +122,26 @@ const functionsConfig: Record = (() => { } })(); -const sharedServicePaths = (() => { - const counts = new Map(); +// Edge Runtime pools user workers by servicePath. Keep the source directory for the +// common case, but give each function a stable logical path when multiple configured +// functions share that directory. maybeEntrypoint still points at the real source file, +// so module resolution (including ../_shared imports) is unchanged. +const workerServicePaths = (() => { + const sourcePathCounts = new Map(); for (const config of Object.values(functionsConfig)) { - const servicePath = dirname(config.entrypointPath); - counts.set(servicePath, (counts.get(servicePath) ?? 0) + 1); + const sourcePath = dirname(config.entrypointPath); + sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1); } - return new Set( - Array.from(counts) - .filter(([, count]) => count > 1) - .map(([servicePath]) => servicePath), + + return Object.fromEntries( + Object.entries(functionsConfig).map(([functionName, config]) => { + const sourcePath = dirname(config.entrypointPath); + const servicePath = + sourcePathCounts.get(sourcePath) === 1 + ? sourcePath + : join(sourcePath, ".supabase-worker", encodeURIComponent(functionName)); + return [functionName, servicePath]; + }), ); })(); @@ -330,7 +340,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 @@ -362,8 +372,7 @@ Deno.serve({ ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), ); - // Shared entrypoint directories need a fresh worker for the per-function slug. - const forceCreate = sharedServicePaths.has(servicePath); + const forceCreate = false; const customModuleRoot = ""; // empty string to allow any local path const cpuTimeSoftLimitMs = 1000; const cpuTimeHardLimitMs = 2000; diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index b44eac4326..396a5f964f 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -19,7 +19,11 @@ import { resolveFunctionsRuntimeConfig, type ResolvedFunctionsBundle, } from "./functions.ts"; -import { buildFunctionEnv, verifyRequest } from "./services/edge-runtime-main.ts"; +import { + buildFunctionEnv, + resolveWorkerServicePath, + verifyRequest, +} from "./services/edge-runtime-main.ts"; const testPorts: PortSet = { apiPort: 40_000, @@ -344,6 +348,22 @@ describe("stack Functions runtime env", () => { expect(env.FUNCTION_ONLY).toBe("function-value"); expect(env.SUPABASE_URL).toBe("http://api-gw:8000"); }); + + it("uses distinct worker identities when functions share a source directory", () => { + const functions = { + alpha: { entrypointPath: "/supabase/functions/shared/alpha.ts" }, + beta: { entrypointPath: "/supabase/functions/shared/beta.ts" }, + isolated: { entrypointPath: "/supabase/functions/isolated/index.ts" }, + }; + + expect(resolveWorkerServicePath(functions, "alpha")).toBe( + "/supabase/functions/shared/.supabase-worker/alpha", + ); + expect(resolveWorkerServicePath(functions, "beta")).toBe( + "/supabase/functions/shared/.supabase-worker/beta", + ); + expect(resolveWorkerServicePath(functions, "isolated")).toBe("/supabase/functions/isolated"); + }); }); describe("stack Functions runtime auth", () => { diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 8acb4c3dfd..e01136c50d 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -185,16 +185,25 @@ export function buildFunctionEnv(config: any, functionConfig: any, functionName: }; } -function hasSharedServicePath(config: any, servicePath: string): boolean { - const functions: Record = config.functions ?? {}; - let count = 0; - for (const functionConfig of Object.values(functions)) { - if (dirname(functionConfig.entrypointPath) === servicePath) { - count += 1; - if (count > 1) return true; - } +export function resolveWorkerServicePath( + functions: Record, + functionName: string, +) { + const functionConfig = functions[functionName]; + if (!functionConfig) { + throw new Error(`Function ${functionName} is not configured`); } - return false; + const sourcePath = dirname(functionConfig.entrypointPath); + const sharesSourcePath = Object.entries(functions).some( + ([otherName, otherConfig]) => + otherName !== functionName && dirname(otherConfig.entrypointPath) === sourcePath, + ); + + // Edge Runtime pools user workers by servicePath. maybeEntrypoint remains the real + // source file, so this logical suffix changes only the worker's cache identity. + return sharesSourcePath + ? `${sourcePath}/.supabase-worker/${encodeURIComponent(functionName)}` + : sourcePath; } async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { @@ -202,7 +211,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu if (authError) return authError; const envVars = Object.entries(buildFunctionEnv(config, functionConfig, functionName)); - const servicePath = dirname(functionConfig.entrypointPath); + const servicePath = resolveWorkerServicePath(config.functions, functionName); try { const worker = await EdgeRuntime.userWorkers.create({ @@ -213,8 +222,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu noNpm: false, importMapPath: functionConfig.importMapPath ?? undefined, envVars, - // Shared entrypoint directories need a fresh worker for the per-function slug. - forceCreate: hasSharedServicePath(config, servicePath), + forceCreate: false, customModuleRoot: "", cpuTimeSoftLimitMs: 1000, cpuTimeHardLimitMs: 2000, diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index e2272b0861..e0a9fd6b19 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -19,10 +19,11 @@ describe("createStack e2e", () => { dataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-")); projectDir = mkdtempSync(join(tmpdir(), "supabase-e2e-project-")); writeFunction(projectDir, "hello", "hello"); + writeSharedFunction(projectDir); stack = await createStack({ projectDir, - functions: functionsBundle(projectDir, ["hello"]), + functions: functionsBundle(projectDir, ["hello", "shared-alpha", "shared-beta"]), jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, }); @@ -68,6 +69,25 @@ 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`); + + 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"); + }, + ); + test("reloadFunctions picks up newly added Edge Functions", { timeout: 30_000 }, async () => { writeFunction(projectDir, "later", "later"); await stack.reloadFunctions({ functions: functionsBundle(projectDir, ["hello", "later"]) }); @@ -147,6 +167,26 @@ 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 functionsBundle( projectDir: string, names: ReadonlyArray, @@ -156,7 +196,13 @@ function functionsBundle( functions: names.map((name) => ({ name, verifyJWT: false, - entrypointPath: join(projectDir, "supabase", "functions", name, "index.ts"), + entrypointPath: join( + projectDir, + "supabase", + "functions", + name.startsWith("shared-") ? "shared" : name, + "index.ts", + ), importMapPath: null, staticFiles: [], env: {}, From 55f2254f0b955116f5535c0e1467db99a3010aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Mon, 31 Aug 2026 17:29:18 +0200 Subject: [PATCH 5/5] fix: address p2 --- .../functions/serve-main-offline.e2e.test.ts | 43 ++++++++++++++++- apps/cli/src/shared/functions/serve.main.ts | 9 ++-- packages/stack/src/functions.unit.test.ts | 21 ++++++--- .../stack/src/services/edge-runtime-main.ts | 47 ++++++++++++------- packages/stack/tests/createStack.e2e.test.ts | 36 +++++++++++++- 5 files changed, 123 insertions(+), 33 deletions(-) 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 63668a2c98..7892c5f076 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 @@ -71,6 +71,12 @@ const KONG_FUNCTIONS_CONFIG = JSON.stringify({ staticFiles: [], verifyJWT: false, }, + "nested-worker-path": { + entrypointPath: "/app/functions/custom/.supabase-worker/custom/index.ts", + importMapPath: "", + staticFiles: [], + verifyJWT: false, + }, }); const CUSTOM_FUNCTION = `import { sharedValue } from "../_shared/value.ts"; @@ -85,6 +91,11 @@ Deno.serve(() => new Response("ok", { "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"); @@ -135,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 @@ -314,7 +343,14 @@ describe("functions serve runtime template (offline)", () => { 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', @@ -421,10 +457,10 @@ describe("functions serve runtime template (offline)", () => { ); const [customResponse, aliasResponse] = await Promise.all([ - fetch(`${functionsUrl}/custom`, { + fetchFunctionWhenReady(`${functionsUrl}/custom`, { headers: { Origin: "http://localhost:3000" }, }), - fetch(`${functionsUrl}/custom-alias`), + fetchFunctionWhenReady(`${functionsUrl}/custom-alias`), ]); expect(customResponse.status).toBe(200); expect(customResponse.headers.get("x-custom-id")).toBe("abc123"); @@ -439,6 +475,9 @@ describe("functions serve runtime template (offline)", () => { 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"'); diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 8e09e99439..7d8efa357c 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -123,9 +123,10 @@ const functionsConfig: Record = (() => { })(); // Edge Runtime pools user workers by servicePath. Keep the source directory for the -// common case, but give each function a stable logical path when multiple configured -// functions share that directory. maybeEntrypoint still points at the real source file, -// so module resolution (including ../_shared imports) is unchanged. +// 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)) { @@ -139,7 +140,7 @@ const workerServicePaths = (() => { const servicePath = sourcePathCounts.get(sourcePath) === 1 ? sourcePath - : join(sourcePath, ".supabase-worker", encodeURIComponent(functionName)); + : Deno.makeTempDirSync({ prefix: "supabase-worker-" }); return [functionName, servicePath]; }), ); diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 396a5f964f..1b7bd6dc7c 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -21,7 +21,7 @@ import { } from "./functions.ts"; import { buildFunctionEnv, - resolveWorkerServicePath, + createWorkerServicePathResolver, verifyRequest, } from "./services/edge-runtime-main.ts"; @@ -349,20 +349,27 @@ describe("stack Functions runtime env", () => { expect(env.SUPABASE_URL).toBe("http://api-gw:8000"); }); - it("uses distinct worker identities when functions share a source directory", () => { + 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( + 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", ); - expect(resolveWorkerServicePath(functions, "beta")).toBe( - "/supabase/functions/shared/.supabase-worker/beta", - ); - expect(resolveWorkerServicePath(functions, "isolated")).toBe("/supabase/functions/isolated"); }); }); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index e01136c50d..8b9de6ccf8 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -185,27 +185,38 @@ export function buildFunctionEnv(config: any, functionConfig: any, functionName: }; } -export function resolveWorkerServicePath( - 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, - ); +export function createWorkerServicePathResolver(makeTempDir: () => string) { + const sharedWorkerPaths = new Map(); - // Edge Runtime pools user workers by servicePath. maybeEntrypoint remains the real - // source file, so this logical suffix changes only the worker's cache identity. - return sharesSourcePath - ? `${sourcePath}/.supabase-worker/${encodeURIComponent(functionName)}` - : sourcePath; + 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; diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index e0a9fd6b19..834d4b6b14 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -20,10 +20,16 @@ describe("createStack e2e", () => { projectDir = mkdtempSync(join(tmpdir(), "supabase-e2e-project-")); writeFunction(projectDir, "hello", "hello"); writeSharedFunction(projectDir); + writeNestedWorkerPathFunction(projectDir); stack = await createStack({ projectDir, - functions: functionsBundle(projectDir, ["hello", "shared-alpha", "shared-beta"]), + functions: functionsBundle(projectDir, [ + "hello", + "shared-alpha", + "shared-beta", + "nested-worker-path", + ]), jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, }); @@ -78,6 +84,7 @@ describe("createStack e2e", () => { 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"); @@ -85,6 +92,8 @@ describe("createStack e2e", () => { 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"); }, ); @@ -187,6 +196,25 @@ Deno.serve(() => new Response( ); } +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, @@ -200,7 +228,11 @@ function functionsBundle( projectDir, "supabase", "functions", - name.startsWith("shared-") ? "shared" : name, + name === "nested-worker-path" + ? join("shared", ".supabase-worker", "shared-alpha") + : name.startsWith("shared-") + ? "shared" + : name, "index.ts", ), importMapPath: null,