Skip to content
Merged
62 changes: 43 additions & 19 deletions apps/cli/src/shared/functions/serve.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ export function prepareUserRequest(req: Request): Request {
return clonedReq;
}

const servicePathSlugs = new Map<string, string>();
const servicePathCreateQueues = new Map<string, Promise<void>>();

Deno.serve({
handler: async (req: Request) => {
const url = new URL(req.url);
Expand Down Expand Up @@ -331,6 +334,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,
Comment thread
raulb marked this conversation as resolved.
Comment thread
raulb marked this conversation as resolved.
};
if (SUPABASE_PUBLISHABLE_KEY) {
envVarsObj["SUPABASE_PUBLISHABLE_KEYS"] = JSON.stringify({
Expand All @@ -347,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;
Expand All @@ -365,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;
Comment thread
raulb marked this conversation as resolved.
Outdated
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);
Expand Down
45 changes: 44 additions & 1 deletion packages/stack/src/functions.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
73 changes: 51 additions & 22 deletions packages/stack/src/services/edge-runtime-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -184,26 +181,58 @@ 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,
};
}

const servicePathSlugs = new Map<string, string>();
const servicePathCreateQueues = new Map<string, Promise<void>>();

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<void>((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<ReturnType<typeof EdgeRuntime.userWorkers.create>>;
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);
Expand Down
Loading