Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace startup polling with readiness synchronization

In the offline Docker test, this fixed sleep turns cold-worker startup into wall-clock polling; on slow CI it delays every retry and, if the worker remains unavailable, eventually throws without the runtime/container diagnostics needed to investigate the failure. Synchronize on an explicit worker or container readiness signal instead, retaining stderr/stdout diagnostics; the repository explicitly treats startup polling delays and arbitrary sleeps as blocking.

AGENTS.md reference: AGENTS.md:L318-L322

Useful? React with 👍 / 👎.

}

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