Skip to content
Merged
74 changes: 70 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,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");
Expand Down Expand Up @@ -125,6 +146,24 @@ function containerLogs(container: string): string {
return `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
}

async function fetchFunctionWhenReady(url: string, init?: RequestInit): Promise<Response> {
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);
Comment thread
raulb marked this conversation as resolved.
Outdated
}

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
Expand Down Expand Up @@ -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], {
Expand Down Expand Up @@ -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"');
Expand All @@ -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",
Expand Down
28 changes: 27 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,30 @@ 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 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<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
: Deno.makeTempDirSync({ prefix: "supabase-worker-" });
return [functionName, servicePath];
}),
);
})();

/* --- JWT verification --- */
export function extractBearerToken(rawToken: string) {
const tokenParts = rawToken.split(" ");
Expand Down Expand Up @@ -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
Expand All @@ -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,
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
72 changes: 71 additions & 1 deletion packages/stack/src/functions.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
52 changes: 45 additions & 7 deletions packages/stack/src/services/edge-runtime-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string>();

return (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,
);
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,
Expand Down
Loading
Loading