Skip to content
Merged
35 changes: 31 additions & 4 deletions apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?? "",
Expand Down Expand Up @@ -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], {
Expand Down Expand Up @@ -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`),
Comment thread
raulb marked this conversation as resolved.
Outdated
]);
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"');
Expand All @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion apps/cli/src/shared/functions/serve.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,29 @@ const functionsConfig: Record<string, FunctionConfig> = (() => {
}
})();

// 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<string, number>();
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
: join(sourcePath, ".supabase-worker", encodeURIComponent(functionName));
Comment thread
raulb marked this conversation as resolved.
Outdated
return [functionName, servicePath];
}),
);
})();

/* --- JWT verification --- */
export function extractBearerToken(rawToken: string) {
const tokenParts = rawToken.split(" ");
Expand Down Expand Up @@ -317,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
Expand All @@ -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,
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 Down
65 changes: 64 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,11 @@ import {
resolveFunctionsRuntimeConfig,
type ResolvedFunctionsBundle,
} from "./functions.ts";
import { verifyRequest } from "./services/edge-runtime-main.ts";
import {
buildFunctionEnv,
resolveWorkerServicePath,
verifyRequest,
} from "./services/edge-runtime-main.ts";

const testPorts: PortSet = {
apiPort: 40_000,
Expand Down Expand Up @@ -303,6 +307,65 @@ 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 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", () => {
for (const { name, authorization, code, message } of authFailureCases) {
it(name, async () => {
Expand Down
41 changes: 34 additions & 7 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,11 +181,41 @@ 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 resolveWorkerServicePath(
functions: Record<string, { entrypointPath: string }>,
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,
);

// 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) {
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,
Expand Down
50 changes: 48 additions & 2 deletions packages/stack/tests/createStack.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand Down Expand Up @@ -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"]) });
Expand Down Expand Up @@ -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<string>,
Expand All @@ -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: {},
Expand Down
Loading