diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts index 8cd70eb28f..144c71c390 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts @@ -1,6 +1,13 @@ -import { basename, dirname, resolve } from "node:path"; -import type { FunctionsConfig } from "@supabase/stack/effect"; -import { Effect, Option } from "effect"; +import { + inferFunctionsManifest, + loadDotEnvFile, + loadProjectConfig, + loadProjectEnvironment, + resolveProjectSubtree, +} from "@supabase/config"; +import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; +import { Effect, Option, Redacted } from "effect"; +import { basename, dirname, join, resolve } from "node:path"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; @@ -14,16 +21,80 @@ export interface FunctionsDevWatchPath { readonly names?: ReadonlyArray; } -export function toStackFunctionsConfig(opts: FunctionsDevConfigOptions): FunctionsConfig { - return { - envFile: Option.match(opts.envFile, { - onNone: () => undefined, - onSome: (path) => path, - }), - noVerifyJwt: opts.noVerifyJwt, - }; +function reveal(value: string | Redacted.Redacted): string { + return Redacted.isRedacted(value) ? Redacted.value(value) : value; +} + +function absoluteProjectPath(supabaseDir: string, path: string): string { + const withoutDotSlash = path.startsWith("./") ? path.slice(2) : path; + return resolve(supabaseDir, withoutDotSlash); } +export const resolveFunctionsBundle = Effect.fnUntraced(function* ( + opts: FunctionsDevConfigOptions, +) { + const projectHome = yield* ProjectHome; + const runtimeInfo = yield* RuntimeInfo; + const projectEnvironment = yield* loadProjectEnvironment({ + cwd: projectHome.projectRoot, + baseEnv: process.env, + }); + const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot); + const projectConfig = + projectEnvironment === null || loadedConfig === null + ? undefined + : { + ...loadedConfig.config, + functions: Object.fromEntries( + Object.entries( + yield* resolveProjectSubtree( + loadedConfig.config.functions, + projectEnvironment, + "functions", + ), + ).map(([name, config]) => [ + name, + { + ...config, + entrypoint: reveal(config.entrypoint), + import_map: reveal(config.import_map), + static_files: config.static_files.map(reveal), + env: Object.fromEntries( + Object.entries(config.env).map(([key, value]) => [key, reveal(value)]), + ), + }, + ]), + ), + }; + const manifest = yield* inferFunctionsManifest({ + cwd: projectHome.projectRoot, + ...(projectConfig === undefined ? {} : { config: projectConfig }), + }); + const envFilePath = Option.match(opts.envFile, { + onNone: () => join(projectHome.supabaseDir, "functions", ".env"), + onSome: (path) => resolve(runtimeInfo.cwd, path), + }); + + return { + env: yield* loadDotEnvFile(envFilePath), + functions: Object.entries(manifest) + .filter(([, config]) => config.enabled) + .map(([name, config]) => ({ + name, + verifyJWT: opts.noVerifyJwt ? false : config.verify_jwt, + entrypointPath: absoluteProjectPath(projectHome.supabaseDir, config.entrypoint), + importMapPath: + config.import_map === "" + ? null + : absoluteProjectPath(projectHome.supabaseDir, config.import_map), + staticFiles: config.static_files.map((path) => + absoluteProjectPath(projectHome.supabaseDir, path), + ), + env: config.env, + })), + } satisfies ResolvedFunctionsBundle; +}); + export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Option.Option) { const projectHome = yield* ProjectHome; const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index 983a7a6a5e..6467afdc29 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -7,11 +7,7 @@ import { join } from "node:path"; import { Effect, Exit, Layer, Option } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - functionsDevWatchPaths, - toStackFunctionsConfig, - type FunctionsDevConfigOptions, -} from "./functions-dev-config.ts"; +import { functionsDevWatchPaths, resolveFunctionsBundle } from "./functions-dev-config.ts"; import { FunctionsDevEdgeRuntimeDisabledError, resolveFunctionsDevEdgeRuntimeConfig, @@ -61,16 +57,60 @@ describe("functions dev config", () => { expect(connectOrStartFunctionsDevStack).toBeTypeOf("function"); }); - it("converts CLI options to stack Functions config", () => { - const opts: FunctionsDevConfigOptions = { - envFile: Option.some("./custom.env"), - noVerifyJwt: true, - }; + it.live("resolves project functions, environment and absolute paths before stack handoff", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(cwd, "supabase", "functions", "hello", "assets"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", "functions", "hello", "index.ts"), "export {};\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", "functions", "hello", "deno.json"), "{}\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "FUNCTION_VALUE=resolved-secret\n"), + ); + yield* Effect.tryPromise(() => writeFile(join(cwd, "custom.env"), "SHARED=custom\n")); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `[functions.hello] +verify_jwt = true +entrypoint = "./functions/hello/index.ts" +import_map = "./functions/hello/deno.json" +static_files = ["./functions/hello/assets/*"] + +[functions.hello.env] +FUNCTION_VALUE = "env(FUNCTION_VALUE)" +`, + ), + ); - expect(toStackFunctionsConfig(opts)).toEqual({ - envFile: "./custom.env", - noVerifyJwt: true, - }); + const bundle = yield* resolveFunctionsBundle({ + envFile: Option.some("./custom.env"), + noVerifyJwt: true, + }); + + expect(bundle).toEqual({ + env: { SHARED: "custom" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: join(cwd, "supabase", "functions", "hello", "index.ts"), + importMapPath: join(cwd, "supabase", "functions", "hello", "deno.json"), + staticFiles: [join(cwd, "supabase", "functions", "hello", "assets", "*")], + env: { FUNCTION_VALUE: "resolved-secret" }, + }, + ], + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); }); it.live("selects supabase and explicit env directory watch paths", () => { diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index c3a1e69722..ba348f6aa6 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -30,7 +30,7 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { startStackWithProgress } from "../../../stack/stack.shared.ts"; import { functionsDevWatchPaths, - toStackFunctionsConfig, + resolveFunctionsBundle, type FunctionsDevConfigOptions, type FunctionsDevWatchPath, } from "./functions-dev-config.ts"; @@ -76,7 +76,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio projectStateRoot: projectHome.projectHomeDir, name: opts.stack, edgeRuntime: opts.edgeRuntime, - functions: toStackFunctionsConfig(opts), ...versionsFromContext(serviceVersionContext), }, daemonEntryPoint, @@ -180,9 +179,9 @@ function reloadEdgeRuntime( opts: FunctionsDevRuntimeOptions, edgeRuntime: EdgeRuntimeConfig, ) { - return stack.reloadEdgeRuntime({ - edgeRuntime, - functions: toStackFunctionsConfig(opts), + return Effect.gen(function* () { + const functions = yield* resolveFunctionsBundle(opts); + yield* stack.reloadEdgeRuntime({ edgeRuntime, functions }); }); } @@ -226,49 +225,51 @@ export const runFunctionsDevRuntime = Effect.fnUntraced(function* ( ...opts, edgeRuntime: edgeRuntimeState.config, }); - yield* ensureFunctionsDirectory(); - yield* reloadEdgeRuntime(stack, opts, edgeRuntimeState.config); - const info = yield* stack.getInfo(); - const watchPathList = yield* functionsDevWatchPaths(opts.envFile); - - yield* output.success("Edge Functions dev server is running.", { - functions_url: `${info.url}/functions/v1`, - }); - yield* output.info(`Functions URL: ${info.url}/functions/v1/`); - - const restartOnChange = watchPaths(watchPathList).pipe( - Stream.runForEach((change) => - Effect.gen(function* () { - const result = yield* applyWatchedChange(edgeRuntimeState, change); - if (result.action === "edge-runtime") { - yield* output.info("Edge runtime config changed. Restarting edge-runtime..."); - yield* reloadEdgeRuntime(stack, opts, result.state.config); + const restoreFunctions = startedByCommand + ? undefined + : yield* resolveFunctionsBundle({ envFile: Option.none(), noVerifyJwt: false }); + + yield* Effect.gen(function* () { + yield* ensureFunctionsDirectory(); + yield* reloadEdgeRuntime(stack, opts, edgeRuntimeState.config); + const info = yield* stack.getInfo(); + const watchPathList = yield* functionsDevWatchPaths(opts.envFile); + + yield* output.success("Edge Functions dev server is running.", { + functions_url: `${info.url}/functions/v1`, + }); + yield* output.info(`Functions URL: ${info.url}/functions/v1/`); + + const restartOnChange = watchPaths(watchPathList).pipe( + Stream.runForEach((change) => + Effect.gen(function* () { + const result = yield* applyWatchedChange(edgeRuntimeState, change); + if (result.action === "edge-runtime") { + yield* output.info("Edge runtime config changed. Restarting edge-runtime..."); + yield* reloadEdgeRuntime(stack, opts, result.state.config); + edgeRuntimeState = result.state; + return; + } edgeRuntimeState = result.state; - return; - } - edgeRuntimeState = result.state; - yield* output.info("Function files changed. Restarting edge-runtime..."); - yield* stack.reloadFunctions(toStackFunctionsConfig(opts)); - }).pipe( - Effect.catch((error) => - output.error(error instanceof Error ? error.message : String(error)), + yield* output.info("Function files changed. Restarting edge-runtime..."); + yield* stack.reloadFunctions({ functions: yield* resolveFunctionsBundle(opts) }); + }).pipe( + Effect.catch((error) => + output.error(error instanceof Error ? error.message : String(error)), + ), ), ), - ), - ); + ); - const logs = logEntryStream(stack).pipe(Stream.runForEach((event) => output.event(event))); - const shutdown = processControl.awaitShutdown; + const logs = logEntryStream(stack).pipe(Stream.runForEach((event) => output.event(event))); + const shutdown = processControl.awaitShutdown; - yield* Effect.raceFirst(Effect.raceFirst(restartOnChange, logs), shutdown).pipe( + yield* Effect.raceFirst(Effect.raceFirst(restartOnChange, logs), shutdown); + }).pipe( Effect.ensuring( - Effect.gen(function* () { - if (startedByCommand) { - yield* stack.dispose().pipe(Effect.ignore); - } else { - yield* stack.reloadFunctions({}).pipe(Effect.ignore); - } - }), + startedByCommand + ? stack.dispose().pipe(Effect.ignore) + : stack.reloadFunctions({ functions: restoreFunctions }).pipe(Effect.ignore), ), ); }); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index aed5911431..e2b9475830 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -37,6 +37,7 @@ export { type ProjectEnvironment, type ResolvedProjectValue, type ResolveProjectOptions, + loadDotEnvFile, loadProjectEnvironment, resolveProjectSubtree, resolveProjectValue, diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index eb1def2642..28f4c5cd3a 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -166,6 +166,15 @@ function parseDotEnv( }); } +/** Parse one explicit dotenv file without applying ambient or project-local precedence. */ +export const loadDotEnvFile = Effect.fnUntraced(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(path))) { + return {}; + } + return yield* parseDotEnv(path, yield* fs.readFileString(path)); +}); + function applySource( target: Record, sources: Record, diff --git a/packages/stack/README.md b/packages/stack/README.md index 461a1fd130..6d056e4908 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -130,6 +130,40 @@ const stack = await createStack({ }); ``` +### Edge Functions + +The stack accepts an explicit, fully resolved Functions bundle. Paths must be absolute and the +caller owns project-file discovery, environment-file parsing, and manifest interpretation: + +```typescript +const projectDir = "/absolute/project"; +const stack = await createStack({ + projectDir, + functions: { + env: { SHARED_VALUE: "available to every function" }, + functions: [ + { + name: "hello", + verifyJWT: true, + entrypointPath: "/absolute/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_VALUE: "available only to hello" }, + }, + ], + }, +}); +``` + +Every referenced path must be contained by `projectDir` so the same bundle works when Edge Runtime +runs in Docker and the project directory is bind-mounted into the container. + +Per-function environment values override shared values. Stack-owned runtime URLs and credentials +take final precedence. To update the active bundle, call +`reloadFunctions({ functions: nextBundle })`; `reloadFunctions()` preserves and reapplies the most +recent bundle. `reloadEdgeRuntime()` follows the same preservation rule when its optional +`functions` field is omitted. + ## Docker Mode Set `mode: "docker"` to force all services to run in Docker containers, bypassing native binary resolution: diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index d6e5a9a3b3..539d724c14 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -37,8 +37,9 @@ can use the same lifecycle calls against an in-process stack or a detached daemo ## Configuration and roots `StackConfig` is an in-memory library input, not the project configuration-file schema. Its -top-level fields choose runtime mode, startup mode, cache/runtime roots, API keys, JWT secret, -functions options, and per-service configuration. `false` disables an optional service. +top-level fields choose runtime mode, startup mode, cache/runtime roots, API keys, JWT secret, a +resolved Edge Functions bundle, and per-service configuration. `false` disables an optional +service. `StackConfigResolver.resolveConfig()`: @@ -192,15 +193,22 @@ Cleanup targets do not belong to `StackInfo`; they are internal runtime metadata ## Functions runtime configuration and reload -The current `functions.ts` Implementation discovers project configuration and function manifests, -resolves paths and environment values, combines them with stack URLs/keys, and writes -`functions-runtime-config.json` under the Edge Runtime workspace. The Edge Runtime factory mounts -or references that file. +Project discovery is outside the stack boundary. A caller supplies a serializable +`ResolvedFunctionsBundle` containing absolute entrypoint, optional import-map, and static-file +paths plus already-resolved shared and per-function environment values. The import-map path is +explicitly nullable. Per-function environment values override shared values; stack-owned runtime +URLs and credentials take final precedence when the worker is created. -`reloadFunctions()` rewrites the file and updates/restarts the Edge Runtime definition. -`reloadEdgeRuntime()` can change runtime settings and optionally functions settings. In detached -mode, `/functions/reload` currently carries `envFile` and `noVerifyJwt` as query parameters, while -`/edge-runtime/reload` accepts a validated JSON body. +`LocalStack` keeps the current bundle in runtime-local memory. `reloadFunctions({ functions })` +replaces it, while a reload without `functions` preserves the latest bundle. An Edge Runtime reload +uses that same current bundle unless its body supplies a replacement. The stack combines the +bundle with runtime URLs and credentials, atomically publishes `functions-runtime-config.json` +with owner-only permissions under the Edge Runtime workspace, and removes it on disposal. + +Detached stacks deliberately exclude resolved bundles from daemon startup IPC, durable metadata, +live state, logs, URLs, and rendered validation errors. Both `/functions/reload` and +`/edge-runtime/reload` accept validated JSON bodies over the local Unix socket. This keeps resolved +environment values confined to an explicit request body and the ephemeral runtime file. ## Port leases @@ -243,8 +251,9 @@ These paths overlap by design and must remain idempotent. Detached mode adds: - `daemonLayer()`: forks a runtime-specific daemon entrypoint and returns a `RemoteStack` layer; -- `daemon.ts`: receives the configuration over Node IPC, resolves ports, builds the foreground - daemon layer, claims live state, and waits for HTTP stop or a signal; +- `daemon.ts`: receives configuration excluding the resolved Functions bundle over Node IPC, + resolves ports, builds the foreground daemon layer, claims live state, and waits for HTTP stop or + a signal; - `DaemonServer`: exposes the `Stack` Interface over HTTP/SSE on a Unix-domain socket; - `RemoteStack`: maps that transport back to the same Effect `Stack` Interface; - `StateManager`: atomically persists and discovers durable metadata and live state. diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index 9fae1ee7b7..eaf7a96122 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -125,6 +125,12 @@ failures use validated JSON shapes. `RemoteStack` decodes that transport back in `Stack` Interface used in foreground mode, including `ServiceNotFoundError`, `ServiceReadyError`, `StackBuildError`, and `StackReadinessError`. +Functions and Edge Runtime reload routes also use validated JSON bodies. Resolved Functions +bundles may contain environment values, so they are deliberately excluded from daemon startup IPC, +query parameters, durable metadata, live state, logs, and rendered validation errors. The daemon +keeps only the active bundle in memory and writes the derived Edge Runtime file ephemerally with +owner-only permissions. + The management socket is not the public local API endpoint. `ApiProxy` still owns the configured HTTP API port inside the daemon process. diff --git a/packages/stack/package.json b/packages/stack/package.json index 5bc0c7d83f..ebb76b6b7b 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -22,7 +22,6 @@ "dependencies": { "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", - "@supabase/config": "workspace:*", "@supabase/process-compose": "workspace:*", "effect": "catalog:" }, diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts index 1cb2227f68..b78ca868d4 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -5,6 +5,7 @@ import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; import { StackReadinessError } from "./errors.ts"; +import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; @@ -57,6 +58,7 @@ const MOCK_LOGS: ReadonlyArray = [ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { let stopped = false; const serviceCalls: string[] = []; + const functionReloads: FunctionsReloadConfig[] = []; const layer = Layer.succeed(Stack, { getInfo: () => Effect.succeed(MOCK_INFO), @@ -96,8 +98,9 @@ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), - reloadFunctions: () => + reloadFunctions: (config) => Effect.sync(() => { + functionReloads.push(config ?? {}); serviceCalls.push("reload-functions"); }), reloadEdgeRuntime: () => @@ -142,9 +145,24 @@ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { return stopped; }, serviceCalls, + functionReloads, }; } +const functionsBundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "shared-secret-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "function-secret-value" }, + }, + ], +}; + // --------------------------------------------------------------------------- // Layer builder // --------------------------------------------------------------------------- @@ -346,12 +364,27 @@ describe("DaemonServer", () => { }); expect(serviceReady.status).toBe(200); - const malformed = await fetch(`${url}/ready`, { + const malformedStackReady = await fetch(`${url}/ready`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "finite", timeoutMs: 0 }), + }); + expect(malformedStackReady.status).toBe(400); + expect(await malformedStackReady.json()).toEqual({ + code: "STACK_BUILD_ERROR", + error: "Invalid readiness options", + }); + + const malformedServiceReady = await fetch(`${url}/services/postgres/ready`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ mode: "finite", timeoutMs: 0 }), }); - expect(malformed.status).not.toBe(200); + expect(malformedServiceReady.status).toBe(400); + expect(await malformedServiceReady.json()).toEqual({ + code: "STACK_BUILD_ERROR", + error: "Invalid readiness options", + }); }); test("POST /edge-runtime/reload returns 200", async () => { @@ -366,6 +399,43 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("reload-edge-runtime"); }); + test("POST /functions/reload validates and forwards its JSON body", async () => { + const res = await fetch(`${url}/functions/reload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ functions: functionsBundle }), + }); + + expect(res.status).toBe(200); + expect(mock.functionReloads).toContainEqual({ functions: functionsBundle }); + }); + + test("reload validation never renders resolved environment values", async () => { + const secret = "must-not-appear-in-errors"; + const res = await fetch(`${url}/functions/reload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + functions: { + env: { SECRET: secret }, + functions: [ + { + ...functionsBundle.functions[0], + entrypointPath: "relative/index.ts", + }, + ], + }, + }), + }); + const responseText = await res.text(); + + expect(res.status).toBe(400); + expect(responseText).toContain("Invalid Edge Functions reload payload"); + expect(JSON.parse(responseText)).toMatchObject({ code: "STACK_BUILD_ERROR" }); + expect(responseText).not.toContain(secret); + expect(responseText).not.toContain("relative/index.ts"); + }); + // ------------------------------------------------------------------------- // Error cases — service not found // ------------------------------------------------------------------------- diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 58bb409c78..fa836f3d69 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -8,6 +8,7 @@ import { } from "effect/unstable/http"; import * as Sse from "effect/unstable/encoding/Sse"; import type { DaemonErrorResponse } from "./DaemonProtocol.ts"; +import { FunctionsReloadConfigSchema } from "./functions.ts"; import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; import { ReadyOptionsSchema } from "./StackConfig.ts"; @@ -33,7 +34,7 @@ export class DaemonServer extends Context.Service< const server = yield* HttpServer.HttpServer; const shutdownDeferred = yield* Deferred.make(); const textEncoder = new TextEncoder(); - const errorResponse = (body: DaemonErrorResponse, status: 404 | 500) => + const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 500) => HttpServerResponse.jsonUnsafe(body, { status }); const notFoundResponse = (name: string) => errorResponse( @@ -52,6 +53,13 @@ export class DaemonServer extends Context.Service< ); const buildErrorResponse = (detail: string) => errorResponse({ code: "STACK_BUILD_ERROR", error: detail }, 500); + const invalidReloadPayloadResponse = () => + errorResponse( + { code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" }, + 400, + ); + const invalidReadinessOptionsResponse = () => + errorResponse({ code: "STACK_BUILD_ERROR", error: "Invalid readiness options" }, 400); const readinessTimeoutResponse = (target: string, timeoutMs: number, detail: string) => errorResponse( { @@ -152,6 +160,10 @@ export class DaemonServer extends Context.Service< yield* stack.waitAllReady(opts); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReadinessOptionsResponse()), + HttpServerError: () => Effect.succeed(invalidReadinessOptionsResponse()), + }), Effect.catchTag("ServiceReadyError", (e) => Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), @@ -257,6 +269,10 @@ export class DaemonServer extends Context.Service< yield* stack.waitReady(routeParams.name!, opts); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReadinessOptionsResponse()), + HttpServerError: () => Effect.succeed(invalidReadinessOptionsResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -316,13 +332,14 @@ export class DaemonServer extends Context.Service< "POST", "/functions/reload", Effect.gen(function* () { - const searchParams = yield* HttpServerRequest.ParsedSearchParams; - yield* stack.reloadFunctions({ - envFile: parseSingleParam(searchParams.envFile), - noVerifyJwt: parseBoolean(searchParams.noVerifyJwt), - }); + const body = yield* HttpServerRequest.schemaBodyJson(FunctionsReloadConfigSchema); + yield* stack.reloadFunctions(body); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -346,6 +363,10 @@ export class DaemonServer extends Context.Service< yield* stack.reloadEdgeRuntime(body); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -395,9 +416,3 @@ function parseSingleParam(value: string | ReadonlyArray | undefined): st if (value === undefined) return undefined; return typeof value === "string" ? value : value[0]; } - -function parseBoolean(value: string | ReadonlyArray | undefined): boolean | undefined { - const raw = parseSingleParam(value); - if (raw === undefined) return undefined; - return raw === "true"; -} diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 81f89e6b58..e10c6ab23f 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -11,6 +11,7 @@ import { Layer, Path, Ref, + Schema, Semaphore, Stream, SubscriptionRef, @@ -19,7 +20,12 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { cleanupLocalStackResources } from "./cleanup.ts"; import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; -import { configureFunctionsRuntime, type FunctionsConfig } from "./functions.ts"; +import { + clearFunctionsRuntimeConfig, + configureFunctionsRuntime, + resolvedFunctionsBundleSchemaForProject, + type ResolvedFunctionsBundle, +} from "./functions.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; import type { PortLease } from "./PortAllocator.ts"; import { @@ -179,6 +185,10 @@ export const localStackLayer = ( const enabledServices = enabledServicesForConfig(config); const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); const phaseRef = yield* Ref.make("idle"); + const functionsBundleRef = yield* Ref.make( + config.functions === false ? undefined : config.functions, + ); + const edgeRuntimeConfigRef = yield* Ref.make(config.edgeRuntime); const disposedSignal = yield* Deferred.make(); const lifecycleLock = Semaphore.makeUnsafe(1); const projectionLock = Semaphore.makeUnsafe(1); @@ -416,11 +426,24 @@ export const localStackLayer = ( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); + const decodeFunctionsBundle = (bundle: unknown) => + Schema.decodeUnknownEffect(resolvedFunctionsBundleSchemaForProject(config.projectDir))( + bundle, + ).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Invalid Edge Functions bundle", + cause, + }), + ), + ); const configureFunctions = ( nextConfig: ResolvedStackConfig, + bundle: ResolvedFunctionsBundle | undefined, ): Effect.Effect => Effect.gen(function* () { - yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost)); + yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost, bundle)); }).pipe( Effect.mapError( (cause) => @@ -430,36 +453,23 @@ export const localStackLayer = ( }), ), ); - const configWithFunctionOptions = (opts?: FunctionsConfig): ResolvedStackConfig => { - if (opts === undefined) { - return config; - } - const base = config.functions === false ? { noVerifyJwt: false } : config.functions; - return { - ...config, - functions: { - envFile: opts.envFile ?? base.envFile, - noVerifyJwt: opts.noVerifyJwt ?? base.noVerifyJwt, - }, - }; - }; const configWithEdgeRuntimeOptions = ( opts: EdgeRuntimeReloadConfig, ): Effect.Effect => Effect.gen(function* () { - if (config.edgeRuntime === false || opts.edgeRuntime.enabled === false) { + const currentEdgeRuntime = yield* Ref.get(edgeRuntimeConfigRef); + if (currentEdgeRuntime === false || opts.edgeRuntime.enabled === false) { return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); } - const base = configWithFunctionOptions(opts.functions); return { - ...base, + ...config, edgeRuntime: { - ...config.edgeRuntime, - enabled: opts.edgeRuntime.enabled ?? config.edgeRuntime.enabled, - inspectorPort: opts.edgeRuntime.inspectorPort ?? config.edgeRuntime.inspectorPort, - policy: opts.edgeRuntime.policy ?? config.edgeRuntime.policy, - env: opts.edgeRuntime.env ?? config.edgeRuntime.env, + ...currentEdgeRuntime, + enabled: opts.edgeRuntime.enabled ?? currentEdgeRuntime.enabled, + inspectorPort: opts.edgeRuntime.inspectorPort ?? currentEdgeRuntime.inspectorPort, + policy: opts.edgeRuntime.policy ?? currentEdgeRuntime.policy, + env: opts.edgeRuntime.env ?? currentEdgeRuntime.env, }, }; }); @@ -610,6 +620,7 @@ export const localStackLayer = ( cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, config, }).pipe( + Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))), Effect.ensuring(portLease.releaseAll), Effect.ensuring(Ref.set(phaseRef, "disposed")), ); @@ -699,7 +710,7 @@ export const localStackLayer = ( yield* requireMutable("start"); yield* Ref.set(phaseRef, "starting"); const runtime = yield* ensureRuntime; - yield* configureFunctions(config); + yield* configureFunctions(config, yield* Ref.get(functionsBundleRef)); serviceStartupBegan = true; if (config.startupMode === "lazy") { @@ -802,7 +813,13 @@ export const localStackLayer = ( const started = yield* Effect.gen(function* () { yield* requireMutable("reload functions"); yield* requireKnownService("edge-runtime"); - yield* configureFunctions(configWithFunctionOptions(opts)); + const currentBundle = yield* Ref.get(functionsBundleRef); + const nextBundle = + opts?.functions === undefined + ? currentBundle + : yield* decodeFunctionsBundle(opts.functions); + yield* configureFunctions(config, nextBundle); + yield* Ref.set(functionsBundleRef, nextBundle); const runtime = yield* ensureRuntime; const state = yield* runtime.orchestrator.getState("edge-runtime"); if (state.desired !== "running") { @@ -821,6 +838,11 @@ export const localStackLayer = ( yield* requireMutable("reload Edge Runtime"); yield* requireKnownService("edge-runtime"); const nextConfig = yield* configWithEdgeRuntimeOptions(opts); + const currentBundle = yield* Ref.get(functionsBundleRef); + const nextBundle = + opts.functions === undefined + ? currentBundle + : yield* decodeFunctionsBundle(opts.functions); const prepared = yield* ensurePrepared; const runtime = yield* ensureRuntime; const buildResult = yield* builder.build(nextConfig, prepared); @@ -832,7 +854,8 @@ export const localStackLayer = ( return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); } - yield* configureFunctions(nextConfig); + yield* configureFunctions(nextConfig, nextBundle); + yield* Ref.set(functionsBundleRef, nextBundle); yield* runtime.orchestrator .updateServiceDefinition("edge-runtime", edgeRuntimeDef) .pipe( @@ -844,6 +867,7 @@ export const localStackLayer = ( }), ), ); + yield* Ref.set(edgeRuntimeConfigRef, nextConfig.edgeRuntime); const state = yield* runtime.orchestrator.getState("edge-runtime"); if (state.desired !== "running") { return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index 43930a1a2a..7d2fc3f781 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -5,8 +5,9 @@ import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; import { StackBuildError, StackReadinessError } from "./errors.ts"; +import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { RemoteStack } from "./RemoteStack.ts"; -import { Stack, type StackInfo } from "./Stack.ts"; +import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts"; import type { ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; @@ -82,6 +83,8 @@ function mockStack( ) { let stopped = false; const serviceCalls: string[] = []; + const functionReloads: FunctionsReloadConfig[] = []; + const edgeRuntimeReloads: EdgeRuntimeReloadConfig[] = []; const readinessCalls: Array<{ readonly target: string; readonly options?: ReadyOptions }> = []; const layer = Layer.succeed(Stack, { @@ -129,12 +132,14 @@ function mockStack( : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), - reloadFunctions: () => + reloadFunctions: (config) => Effect.sync(() => { + functionReloads.push(config ?? {}); serviceCalls.push("reload-functions"); }), - reloadEdgeRuntime: () => + reloadEdgeRuntime: (config) => Effect.sync(() => { + edgeRuntimeReloads.push(config); serviceCalls.push("reload-edge-runtime"); }), getState: (name: string) => { @@ -200,9 +205,25 @@ function mockStack( }, serviceCalls, readinessCalls, + functionReloads, + edgeRuntimeReloads, }; } +const functionsBundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "shared-secret-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "function-secret-value" }, + }, + ], +}; + // --------------------------------------------------------------------------- // Layer builder — DaemonServer backed by mock Stack on TCP port // --------------------------------------------------------------------------- @@ -467,13 +488,46 @@ describe("RemoteStack integration", () => { expect(mock.serviceCalls).toContain("restart:postgres"); }); + test("reloadFunctions transports the validated bundle in a JSON body", async () => { + await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => stack.reloadFunctions({ functions: functionsBundle })), + ); + + expect(mock.functionReloads).toEqual([{ functions: functionsBundle }]); + }); + + test("reloadFunctions returns a typed build error for an invalid bundle", async () => { + const invalidBundle = { + ...functionsBundle, + functions: [{ ...functionsBundle.functions[0]!, entrypointPath: "relative/index.ts" }], + }; + + const error = await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => + stack.reloadFunctions({ functions: invalidBundle }).pipe(Effect.flip), + ), + ); + + expect(error).toBeInstanceOf(StackBuildError); + expect(error._tag).toBe("StackBuildError"); + if (error._tag === "StackBuildError") { + expect(error.detail).toBe("Invalid Edge Functions reload payload"); + } + }); + test("reloadEdgeRuntime records the call", async () => { await clientRuntime.runPromise( Effect.flatMap(Stack, (stack) => - stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }), + stack.reloadEdgeRuntime({ + edgeRuntime: { policy: "oneshot" }, + functions: functionsBundle, + }), ), ); expect(mock.serviceCalls).toContain("reload-edge-runtime"); + expect(mock.edgeRuntimeReloads).toEqual([ + { edgeRuntime: { policy: "oneshot" }, functions: functionsBundle }, + ]); }); test("logHistory returns entries", async () => { diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index dd0f8ca6a4..7bbd6fb72a 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -337,15 +337,11 @@ export const RemoteStack = { reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse( - socketPath, - `/functions/reload${encodeSearchParams({ - envFile: opts?.envFile, - noVerifyJwt: - opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), - })}`, - { method: "POST" }, - ); + const response = yield* unixResponse(socketPath, "/functions/reload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts ?? {}), + }); yield* expectDaemonOk(response, "edge-runtime"); }), ), diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 6d07355597..9693b08d02 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -2,7 +2,11 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Schema, Stream } from "effect"; import { StackBuildError, StackReadinessError } from "./errors.ts"; -import type { FunctionsConfig } from "./functions.ts"; +import { + ResolvedFunctionsBundleSchema, + type FunctionsReloadConfig, + type ResolvedFunctionsBundle, +} from "./functions.ts"; import type { EdgeRuntimeConfig, ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; @@ -33,19 +37,14 @@ const EdgeRuntimeConfigSchema = Schema.Struct({ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); -const FunctionsConfigSchema = Schema.Struct({ - envFile: Schema.optionalKey(Schema.String), - noVerifyJwt: Schema.optionalKey(Schema.Boolean), -}); - export const EdgeRuntimeReloadConfigSchema = Schema.Struct({ edgeRuntime: EdgeRuntimeConfigSchema, - functions: Schema.optionalKey(FunctionsConfigSchema), + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), }); export interface EdgeRuntimeReloadConfig { readonly edgeRuntime: EdgeRuntimeConfig; - readonly functions?: FunctionsConfig; + readonly functions?: ResolvedFunctionsBundle; } export class Stack extends Context.Service< @@ -74,7 +73,7 @@ export class Stack extends Context.Service< ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError >; readonly reloadFunctions: ( - opts?: FunctionsConfig, + opts?: FunctionsReloadConfig, ) => Effect.Effect< void, ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index baff8e3260..c5b569e441 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { chmod, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { StackBuildError } from "./errors.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { functionsRuntimeConfigPath, type ResolvedFunctionsBundle } from "./functions.ts"; import type { AllocatedPorts, PortField, PortLease } from "./PortAllocator.ts"; import { StackServiceActivator } from "./ServiceActivation.ts"; import { Stack } from "./Stack.ts"; @@ -105,6 +110,20 @@ const edgeRuntimeConfig: ResolvedStackConfig = { }, }; +const functionsBundle = (root: string, value: string): ResolvedFunctionsBundle => ({ + env: { SHARED: value }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: join(root, "hello", "index.ts"), + importMapPath: null, + staticFiles: [], + env: { FUNCTION_VALUE: value }, + }, + ], +}); + const noopPortLease = (ports: AllocatedPorts): PortLease => ({ ports, reserve: () => Effect.void, @@ -157,6 +176,110 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); + it.live("preserves current Functions and Edge Runtime settings across partial reloads", () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-")); + const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); + const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); + const config = { + ...edgeRuntimeConfig, + projectDir: runtimeRoot, + runtimeRoot, + functions: initialBundle, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { + name: "edge-runtime", + command: process.execPath, + restart: "unless-stopped", + }, + ]), + ); + const builtConfigs: ResolvedStackConfig[] = []; + const builderLayer = Layer.succeed(StackBuilder, { + build: (candidate) => + Effect.sync(() => { + builtConfigs.push(candidate); + return { + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([["edge-runtime", { visibility: "public" as const }]]), + }; + }), + }); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(BunServices.layer), + ); + const readRuntimeConfig = Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => + JSON.parse(contents), + ), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + yield* stack.reloadFunctions({ functions: replacementBundle }); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.reloadEdgeRuntime({ edgeRuntime: { env: { NEXT: "next-value" } } }); + expect(builtConfigs.at(-1)?.edgeRuntime).toMatchObject({ + policy: "oneshot", + env: { NEXT: "next-value" }, + }); + + yield* stack.reloadFunctions(); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + const duplicateBundle = { + ...replacementBundle, + functions: [replacementBundle.functions[0]!, replacementBundle.functions[0]!], + }; + expect( + (yield* stack.reloadFunctions({ functions: duplicateBundle }).pipe(Effect.flip))._tag, + ).toBe("StackBuildError"); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + const runtimeDirectory = join(runtimeRoot, "edge-runtime"); + yield* Effect.promise(() => chmod(runtimeDirectory, 0o500)); + const failedBundle = functionsBundle(runtimeRoot, "failed-secret"); + const error = yield* stack.reloadFunctions({ functions: failedBundle }).pipe(Effect.flip); + expect(error._tag).toBe("StackBuildError"); + + yield* Effect.promise(() => chmod(runtimeDirectory, 0o700)); + yield* stack.reloadFunctions(); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.dispose(); + expect( + yield* Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then( + () => true, + () => false, + ), + ), + ).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.promise(async () => { + await chmod(join(runtimeRoot, "edge-runtime"), 0o700).catch(() => {}); + await rm(runtimeRoot, { recursive: true, force: true }); + }), + ), + Effect.timeout("5 seconds"), + ); + }); + it.effect("getInfo returns valid JWT tokens", () => { const { layer } = setupLayer(); @@ -783,7 +906,7 @@ describe("Stack", () => { const config = { ...defaultConfig, startupMode: "lazy", - readiness: { mode: "finite", timeoutMs: 500 }, + readiness: { mode: "finite", timeoutMs: 1_000 }, readinessSource: "configured", } satisfies ResolvedStackConfig; const lease: PortLease = { @@ -804,7 +927,7 @@ describe("Stack", () => { expect(error._tag).toBe("StackReadinessError"); if (error._tag === "StackReadinessError") { expect(error.target).toBe("auth"); - expect(error.timeoutMs).toBe(500); + expect(error.timeoutMs).toBe(1_000); } expect(releasedAll).toBe(true); const spawnCountAfterDisposal = spawner.spawned.length; diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 2eb2e83cd1..90969048b0 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import type { FunctionsConfig, ResolvedFunctionsConfig } from "./functions.ts"; +import type { ResolvedFunctionsBundle } from "./functions.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; type StackMode = "native" | "auto" | "docker"; @@ -159,7 +159,7 @@ export interface StackConfig { readonly port?: number; readonly publishableKey?: string; readonly secretKey?: string; - readonly functions?: FunctionsConfig | false; + readonly functions?: ResolvedFunctionsBundle | false; readonly postgres?: PostgresConfig; readonly postgrest?: PostgrestConfig | false; readonly auth?: AuthConfig | false; @@ -289,7 +289,7 @@ export interface ResolvedStackConfig { readonly dbPort: number; readonly publishableKey: string; readonly secretKey: string; - readonly functions: ResolvedFunctionsConfig | false; + readonly functions: ResolvedFunctionsBundle | false; readonly autoManagedPaths: ReadonlyArray; readonly anonJwt: string; readonly serviceRoleJwt: string; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 03d3bcac16..e777a2e91b 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -2,7 +2,8 @@ import { mkdtempSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Schema } from "effect"; -import { toStackError } from "./errors.ts"; +import { StackBuildError, toStackError } from "./errors.ts"; +import { resolvedFunctionsBundleSchemaForProject } from "./functions.ts"; import { defaultJwtSecret, defaultPublishableKey, @@ -297,12 +298,17 @@ function resolveEdgeRuntimeConfig( }; } -function resolveFunctionsConfig(config: StackConfig) { - if (config.functions === false) return false; - return { - envFile: config.functions?.envFile, - noVerifyJwt: config.functions?.noVerifyJwt ?? false, - }; +async function resolveFunctionsConfig(config: StackConfig, projectDir: string) { + if (config.functions === undefined || config.functions === false) { + return false; + } + try { + return await Schema.decodeUnknownPromise(resolvedFunctionsBundleSchemaForProject(projectDir))( + config.functions, + ); + } catch (cause) { + throw new StackBuildError({ detail: "Invalid Edge Functions bundle", cause }); + } } function resolveStorageConfig( @@ -433,6 +439,7 @@ export async function resolveConfig( ): Promise { const config = input ?? {}; const projectDir = config.projectDir ?? process.cwd(); + const functions = await resolveFunctionsConfig(config, projectDir); const resolvedMode = config.mode ?? "auto"; const roots = resolveRoots(config, opts); const postgresInput = config.postgres ?? {}; @@ -514,7 +521,7 @@ export async function resolveConfig( dbPort: ports.dbPort, publishableKey: config.publishableKey ?? defaultPublishableKey, secretKey: config.secretKey ?? defaultSecretKey, - functions: resolveFunctionsConfig(config), + functions, autoManagedPaths: roots.autoManagedPaths, anonJwt, serviceRoleJwt, @@ -554,18 +561,26 @@ export async function resolveConfig( }; } -export type DaemonConfigInput = StackConfig & { +export type DaemonConfigInput = Omit & { readonly cwd: string; readonly name?: string; readonly projectDir?: string; readonly projectStateRoot?: string; }; +export function sanitizeDaemonConfigInput( + input: DaemonConfigInput & { readonly functions?: unknown }, +): DaemonConfigInput { + const { functions: _functions, ...config } = input; + return config; +} + export async function resolveDaemonConfig( input: DaemonConfigInput, opts: Pick = {}, ): Promise { - const { cwd, name, projectDir, projectStateRoot, ...stackConfig } = input; + const { cwd, name, projectDir, projectStateRoot, ...stackConfig } = + sanitizeDaemonConfigInput(input); if (stackConfig.stackRoot !== undefined || stackConfig.runtimeRoot !== undefined) { throw new Error("Managed daemon stacks derive stackRoot and runtimeRoot automatically"); } diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 658f26bace..5db6ebcca3 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -6,7 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ApiProxy } from "./ApiProxy.ts"; import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; import { toStackError } from "./errors.ts"; -import type { FunctionsConfig } from "./functions.ts"; +import type { FunctionsReloadConfig } from "./functions.ts"; import { daemonLayer, foregroundLayer, type DaemonStartError } from "./layers.ts"; import { LocalStackLifecycle } from "./LocalStack.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; @@ -62,7 +62,7 @@ export interface StackHandle extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; - reloadFunctions(opts?: FunctionsConfig): Promise; + reloadFunctions(opts?: FunctionsReloadConfig): Promise; reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; ready(opts?: ReadyOptions): Promise; serviceReady(name: string, opts?: ReadyOptions): Promise; @@ -81,7 +81,7 @@ export const projectDaemonLayer = (opts: { readonly projectStateRoot?: string; readonly name?: string; readonly daemonEntryPoint: string; - readonly stackConfig?: Omit; + readonly stackConfig?: Omit; }): Effect.Effect< Layer.Layer, DaemonStartError | InvalidStackStateError | StackAlreadyRunningError, diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 6200b53310..a8d7c72a3e 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -16,7 +16,11 @@ import type { ReadyOptions, StackConfig, } from "./StackConfig.ts"; -import { resolveConfig, resolveDaemonConfig } from "./StackConfigResolver.ts"; +import { + resolveConfig, + resolveDaemonConfig, + sanitizeDaemonConfigInput, +} from "./StackConfigResolver.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const DEFAULT_PORTS: AllocatedPorts = { @@ -192,6 +196,15 @@ describe("createStack types", () => { ); }); + it("strips function bundles from daemon configuration at runtime", () => { + const input = { + cwd: "/project", + functions: { environment: { SECRET: "must-not-cross-ipc" } }, + }; + + expect(sanitizeDaemonConfigInput(input)).toEqual({ cwd: "/project" }); + }); + it("resolveDaemonConfig prefers legacy defaults for a first named stack", async () => { await withTempCacheRoot(async (cacheRoot) => { const config = await resolveDaemonConfig({ diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index e757aaeb58..6a15a5c76d 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -97,14 +97,19 @@ export { StackBuilder } from "./StackBuilder.ts"; export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; export type { - FunctionsConfig, + FunctionsReloadConfig, FunctionsRuntimeConfig, - ResolvedFunctionsConfig, + ResolvedFunction, + ResolvedFunctionsBundle, } from "./functions.ts"; export { + clearFunctionsRuntimeConfig, configureFunctionsRuntime, + FunctionsReloadConfigSchema, functionsRuntimeConfigFileName, functionsRuntimeConfigPath, + ResolvedFunctionSchema, + ResolvedFunctionsBundleSchema, resolveFunctionsRuntimeConfig, } from "./functions.ts"; diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index fd95607f93..478790fe6e 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -1,25 +1,133 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; -import { - inferFunctionsManifest, - loadProjectConfig, - loadProjectEnvironment, - resolveProjectSubtree, - type ResolvedFunctionConfig, -} from "@supabase/config"; -import { Effect, FileSystem, Path, Redacted } from "effect"; +import { existsSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import type { ResolvedStackConfig } from "./StackConfig.ts"; -export interface FunctionsConfig { - readonly envFile?: string; - readonly noVerifyJwt?: boolean; +const absolutePath = Schema.String.check( + Schema.makeFilter((value) => + isAbsolute(value) ? undefined : { path: [], issue: "Expected an absolute path" }, + ), +); + +const environment = Schema.Record(Schema.String, Schema.String); + +export const ResolvedFunctionSchema = Schema.Struct({ + name: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/)), + verifyJWT: Schema.Boolean, + entrypointPath: absolutePath, + importMapPath: Schema.NullOr(absolutePath), + staticFiles: Schema.Array(absolutePath), + env: environment, +}); + +export interface ResolvedFunction extends Schema.Schema.Type {} + +/** + * Project-owned Edge Functions input. Every path and environment reference is + * resolved before the bundle crosses into the stack package. + * + * `env` contains values shared by every function. A function's own `env` + * overrides matching shared values when its worker is created. + */ +export const ResolvedFunctionsBundleSchema = Schema.Struct({ + env: environment, + functions: Schema.Array(ResolvedFunctionSchema), +}).check( + Schema.makeFilter((bundle) => { + const names = new Set(); + for (let index = 0; index < bundle.functions.length; index += 1) { + const name = bundle.functions[index]?.name; + if (name !== undefined && names.has(name)) { + return { + path: ["functions", index, "name"], + issue: `Duplicate function name: ${name}`, + }; + } + if (name !== undefined) { + names.add(name); + } + } + return undefined; + }), +); + +export interface ResolvedFunctionsBundle extends Schema.Schema.Type< + typeof ResolvedFunctionsBundleSchema +> {} + +function isWithinPath(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate); + return ( + relativePath === "" || + (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) + ); +} + +function nearestExistingAncestor(candidate: string): string | undefined { + let current = resolve(candidate); + while (!existsSync(current)) { + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } + return current; } -export interface ResolvedFunctionsConfig { - readonly envFile?: string; - readonly noVerifyJwt: boolean; +function isWithinProjectDir(projectDir: string, candidate: string): boolean { + const resolvedProjectDir = resolve(projectDir); + if (!isWithinPath(resolvedProjectDir, candidate)) return false; + + const existingCandidate = nearestExistingAncestor(candidate); + if (existingCandidate === undefined) return false; + try { + return isWithinPath(realpathSync(resolvedProjectDir), realpathSync(existingCandidate)); + } catch { + return false; + } } +/** + * Docker mounts `projectDir` at the same absolute path as the host. Keeping all + * referenced files below that root gives native and Docker runtimes the same + * bundle contract, including after a reload. + */ +export const resolvedFunctionsBundleSchemaForProject = (projectDir: string) => + ResolvedFunctionsBundleSchema.check( + Schema.makeFilter((bundle) => { + for (let index = 0; index < bundle.functions.length; index += 1) { + const fn = bundle.functions[index]; + if (fn === undefined) continue; + + const paths = [ + { field: "entrypointPath", value: fn.entrypointPath }, + ...(fn.importMapPath === null + ? [] + : [{ field: "importMapPath", value: fn.importMapPath }]), + ...fn.staticFiles.map((value, staticIndex) => ({ + field: `staticFiles.${staticIndex}`, + value, + })), + ]; + const outsideProject = paths.find(({ value }) => !isWithinProjectDir(projectDir, value)); + if (outsideProject !== undefined) { + return { + path: ["functions", index, outsideProject.field], + issue: "Function paths must be within projectDir", + }; + } + } + return undefined; + }), + ); + +export const FunctionsReloadConfigSchema = Schema.Struct({ + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), +}); + +export interface FunctionsReloadConfig extends Schema.Schema.Type< + typeof FunctionsReloadConfigSchema +> {} + export interface FunctionsRuntimeConfig { readonly functionsUrl: string; readonly supabaseUrl: string; @@ -34,8 +142,9 @@ export interface FunctionsRuntimeConfig { { readonly verifyJWT: boolean; readonly entrypointPath: string; - readonly importMapPath: string; + readonly importMapPath: string | null; readonly staticFiles: ReadonlyArray; + readonly env: Readonly>; } > >; @@ -55,142 +164,15 @@ export function functionsRuntimeConfigPath(runtimeRoot: string): string { return join(edgeRuntimeWorkspaceDir(runtimeRoot), functionsRuntimeConfigFileName); } -function reveal(value: string | Redacted.Redacted): string { - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} - -function absolutizeProjectPath(projectDir: string, relativePath: string): string { - if (relativePath.length === 0) { - return ""; - } - - const withoutDotSlash = relativePath.startsWith("./") ? relativePath.slice(2) : relativePath; - return isAbsolute(withoutDotSlash) - ? withoutDotSlash - : join(projectDir, "supabase", withoutDotSlash); -} - -function parseDotEnv(contents: string): Record { - const env: Record = {}; - const lines = contents.replace(/\r\n?/g, "\n").split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === "" || trimmed.startsWith("#")) { - continue; - } - - const equals = line.indexOf("="); - if (equals === -1) { - continue; - } - - const key = line - .slice(0, equals) - .trim() - .replace(/^export\s+/, ""); - let value = line.slice(equals + 1).trim(); - const quote = value[0]; - if ( - (quote === '"' || quote === "'" || quote === "`") && - value.endsWith(quote) && - value.length >= 2 - ) { - value = value.slice(1, -1); - } - if (quote === '"') { - value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); - } - env[key] = value; - } - - return env; -} - -function loadEnvFile(path: string): Record { - try { - return parseDotEnv(readFileSync(path, "utf8")); - } catch { - return {}; - } -} - -const resolveFunctionsProjectConfig = Effect.fnUntraced(function* (projectDir: string) { - const projectEnv = yield* loadProjectEnvironment({ cwd: projectDir, baseEnv: process.env }); - const loadedConfig = yield* loadProjectConfig(projectDir); - if (projectEnv === null || loadedConfig === null) { - return undefined; - } - - const resolvedFunctions = yield* resolveProjectSubtree( - loadedConfig.config.functions, - projectEnv, - "functions", - ); - - return { - ...loadedConfig.config, - functions: Object.fromEntries( - Object.entries(resolvedFunctions).map(([slug, config]) => [ - slug, - { - ...config, - entrypoint: reveal(config.entrypoint), - import_map: reveal(config.import_map), - static_files: config.static_files.map((path) => reveal(path)), - env: Object.fromEntries( - Object.entries(config.env).map(([name, value]) => [name, reveal(value)]), - ), - }, - ]), - ), - }; -}); - -function functionToRuntimeConfig( - projectDir: string, - noVerifyJwt: boolean, - config: ResolvedFunctionConfig, -) { - return { - verifyJWT: noVerifyJwt ? false : config.verify_jwt, - entrypointPath: absolutizeProjectPath(projectDir, config.entrypoint), - importMapPath: absolutizeProjectPath(projectDir, config.import_map), - staticFiles: config.static_files.map((path) => absolutizeProjectPath(projectDir, path)), - }; -} - -export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( +export function resolveFunctionsRuntimeConfig( stackConfig: ResolvedStackConfig, runtimeHost: FunctionsRuntimeHost, -) { - const functionsConfig = stackConfig.functions; - if (functionsConfig === false || stackConfig.edgeRuntime === false) { - return undefined; - } - - const projectConfig = yield* resolveFunctionsProjectConfig(stackConfig.projectDir); - const manifest = yield* inferFunctionsManifest({ - cwd: stackConfig.projectDir, - ...(projectConfig === undefined ? {} : { config: projectConfig }), - }); - const enabledManifest = Object.entries(manifest).filter(([, config]) => config.enabled); - if (enabledManifest.length === 0) { + bundle: ResolvedFunctionsBundle | undefined, +): FunctionsRuntimeConfig | undefined { + if (bundle === undefined || bundle.functions.length === 0 || stackConfig.edgeRuntime === false) { return undefined; } - const functionEnv = Object.fromEntries( - enabledManifest.flatMap(([, config]) => Object.entries(config.env)), - ); - const envFilePath = - functionsConfig.envFile === undefined - ? join(stackConfig.projectDir, "supabase", "functions", ".env") - : resolve(stackConfig.projectDir, functionsConfig.envFile); - const env = { - ...loadEnvFile(envFilePath), - ...functionEnv, - }; - return { functionsUrl: `http://127.0.0.1:${stackConfig.apiPort}/functions/v1`, supabaseUrl: `http://${runtimeHost.hostname}:${stackConfig.apiPort}`, @@ -198,15 +180,21 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( publishableKey: stackConfig.publishableKey, secretKey: stackConfig.secretKey, jwtSecret: stackConfig.jwtSecret, - env, + env: bundle.env, functions: Object.fromEntries( - enabledManifest.map(([slug, config]) => [ - slug, - functionToRuntimeConfig(stackConfig.projectDir, functionsConfig.noVerifyJwt, config), + bundle.functions.map((fn) => [ + fn.name, + { + verifyJWT: fn.verifyJWT, + entrypointPath: fn.entrypointPath, + importMapPath: fn.importMapPath, + staticFiles: fn.staticFiles, + env: fn.env, + }, ]), ), - } satisfies FunctionsRuntimeConfig; -}); + }; +} const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( runtimeRoot: string, @@ -215,20 +203,41 @@ const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const filePath = functionsRuntimeConfigPath(runtimeRoot); - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, `${JSON.stringify(config, null, 2)}\n`); + const directory = path.dirname(filePath); + const temporaryPath = `${filePath}.tmp-${crypto.randomUUID()}`; + + yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); + yield* Effect.gen(function* () { + yield* fs.writeFileString(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + yield* fs.chmod(temporaryPath, 0o600); + yield* fs.rename(temporaryPath, filePath); + }).pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore))); }); -const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { +export const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(functionsRuntimeConfigPath(runtimeRoot)).pipe(Effect.ignore); + const filePath = functionsRuntimeConfigPath(runtimeRoot); + const directory = yield* Path.Path.pipe(Effect.map((path) => path.dirname(filePath))); + + yield* fs.remove(filePath).pipe(Effect.ignore); + + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry.startsWith(`${functionsRuntimeConfigFileName}.tmp-`)), + (entry) => fs.remove(join(directory, entry)).pipe(Effect.ignore), + { discard: true }, + ); }); export const configureFunctionsRuntime = Effect.fnUntraced(function* ( stackConfig: ResolvedStackConfig, runtimeHost: FunctionsRuntimeHost, + bundle: ResolvedFunctionsBundle | undefined, ) { - const runtimeConfig = yield* resolveFunctionsRuntimeConfig(stackConfig, runtimeHost); + const runtimeConfig = resolveFunctionsRuntimeConfig(stackConfig, runtimeHost, bundle); if (runtimeConfig === undefined) { yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); } else { diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index e537ea9a90..46f9298c83 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtempSync, symlinkSync } from "node:fs"; +import { readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { resolveConfig } from "./StackConfigResolver.ts"; import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; import { + clearFunctionsRuntimeConfig, configureFunctionsRuntime, functionsRuntimeConfigPath, + ResolvedFunctionsBundleSchema, resolveFunctionsRuntimeConfig, + type ResolvedFunctionsBundle, } from "./functions.ts"; import { verifyRequest } from "./services/edge-runtime-main.ts"; @@ -18,42 +21,20 @@ function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); } -async function writeProject(cwd: string) { - await mkdir(join(cwd, "supabase", "functions", "hello-world"), { recursive: true }); - await mkdir(join(cwd, "supabase", "functions", "disabled-function"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "functions", "hello-world", "index.ts"), - "Deno.serve(() => Response.json({ ok: true }));\n", - ); - await writeFile( - join(cwd, "supabase", "functions", "disabled-function", "index.ts"), - "Deno.serve(() => Response.json({ disabled: true }));\n", - ); - await writeFile( - join(cwd, "supabase", ".env"), - "CONFIG_ONLY=from-project-env\nSHARED=from-project-env\n", - ); - await writeFile( - join(cwd, "supabase", "functions", ".env"), - "FILE_ONLY=from-functions-env\nSHARED=from-functions-env\n", - ); - await writeFile( - join(cwd, "supabase", "config.json"), - JSON.stringify({ - functions: { - "hello-world": { - verify_jwt: true, - env: { - CONFIG_ONLY: "env(CONFIG_ONLY)", - SHARED: "env(SHARED)", - }, - }, - "disabled-function": { - enabled: false, - }, +function makeBundle(root: string): ResolvedFunctionsBundle { + return { + env: { SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }, + functions: [ + { + name: "hello-world", + verifyJWT: true, + entrypointPath: join(root, "functions", "hello-world", "index.ts"), + importMapPath: null, + staticFiles: [join(root, "functions", "hello-world", "assets", "*")], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, }, - }), - ); + ], + }; } function jwtWithInvalidSignature(algorithm?: string): string { @@ -101,95 +82,152 @@ const authFailureCases = [ ]; describe("stack Functions runtime config", () => { - it.live("auto-detects enabled functions from projectDir", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); - - expect(config).toBeDefined(); - expect(Object.keys(config!.functions)).toEqual(["hello-world"]); - expect(config!.functions["hello-world"]).toEqual({ - verifyJWT: true, - entrypointPath: join(cwd, "supabase", "functions", "hello-world", "index.ts"), - importMapPath: "", - staticFiles: [], - }); - expect(config!.env).toMatchObject({ - FILE_ONLY: "from-functions-env", - CONFIG_ONLY: "from-project-env", - SHARED: "from-project-env", - }); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), + it("projects an explicit bundle without project discovery", async () => { + const root = makeTempProject(); + const stackConfig = await resolveConfig({ projectDir: root, functions: makeBundle(root) }); + const config = resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + makeBundle(root), ); + + expect(config?.env).toEqual({ SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }); + expect(config?.functions["hello-world"]).toEqual({ + verifyJWT: true, + entrypointPath: join(root, "functions", "hello-world", "index.ts"), + importMapPath: null, + staticFiles: [join(root, "functions", "hello-world", "assets", "*")], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, + }); + + await rm(root, { recursive: true, force: true }); }); - it.live("supports explicit env files and disabling JWT verification", () => { - const cwd = makeTempProject(); + it("validates paths, import maps, and unique function names", async () => { + const decode = Schema.decodeUnknownSync(ResolvedFunctionsBundleSchema); + const root = makeTempProject(); + const bundle = makeBundle(root); - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - yield* Effect.promise(() => writeFile(join(cwd, "custom.env"), "FILE_ONLY=custom\n")); - const stackConfig = yield* Effect.promise(() => - resolveConfig({ - projectDir: cwd, - functions: { - envFile: "custom.env", - noVerifyJwt: true, - }, - }), - ); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); + expect(decode(bundle).functions[0]?.importMapPath).toBeNull(); + expect(() => + decode({ + ...bundle, + functions: [{ ...bundle.functions[0], entrypointPath: "./index.ts" }], + }), + ).toThrow("Expected an absolute path"); + expect(() => + decode({ + ...bundle, + functions: [bundle.functions[0], bundle.functions[0]], + }), + ).toThrow("Duplicate function name: hello-world"); - expect(config!.env.FILE_ONLY).toBe("custom"); - expect(config!.functions["hello-world"]?.verifyJWT).toBe(false); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), - ); + await rm(root, { recursive: true, force: true }); }); - it.live("keeps placeholder mode when Functions are disabled", () => { - const cwd = makeTempProject(); + it("validates explicit bundles at config resolution", async () => { + const root = makeTempProject(); + const bundle = makeBundle(root); - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => - resolveConfig({ projectDir: cwd, functions: false }), - ); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); + await expect( + resolveConfig({ + projectDir: root, + functions: { + ...bundle, + functions: [bundle.functions[0]!, bundle.functions[0]!], + }, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + }); + await expect( + resolveConfig({ + projectDir: join(root, "project"), + functions: bundle, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + }); - expect(config).toBeUndefined(); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), + await rm(root, { recursive: true, force: true }); + }); + + it("rejects bundle paths that escape projectDir through a symlink", async () => { + const root = makeTempProject(); + const outside = makeTempProject(); + const bundle = makeBundle(root); + symlinkSync( + outside, + join(root, "linked-outside"), + process.platform === "win32" ? "junction" : "dir", ); + + await expect( + resolveConfig({ + projectDir: root, + functions: { + ...bundle, + functions: [ + { + ...bundle.functions[0]!, + entrypointPath: join(root, "linked-outside", "index.ts"), + }, + ], + }, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + }); + + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + }); + + it("keeps placeholder mode when no functions are supplied", async () => { + const stackConfig = await resolveConfig({ functions: false }); + + expect( + resolveFunctionsRuntimeConfig(stackConfig, { hostname: "127.0.0.1" }, undefined), + ).toBeUndefined(); + expect( + resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + { + env: {}, + functions: [], + }, + ), + ).toBeUndefined(); }); - it.live("writes generated runtime config into the stack runtime directory", () => { + it.live("atomically writes restrictive ephemeral config and removes it", () => { const cwd = makeTempProject(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); - yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }); - const written = JSON.parse( - yield* Effect.promise(() => - readFile(functionsRuntimeConfigPath(stackConfig.runtimeRoot), "utf8"), - ), - ) as { functions: Record }; + const bundle = makeBundle(cwd); + const stackConfig = yield* Effect.promise(() => + resolveConfig({ projectDir: cwd, runtimeRoot: cwd, functions: bundle }), + ); + yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); + const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); + const written = JSON.parse(yield* Effect.promise(() => readFile(filePath, "utf8"))) as { + functions: Record; + }; expect(Object.keys(written.functions)).toEqual(["hello-world"]); + expect((yield* Effect.promise(() => stat(filePath))).mode & 0o777).toBe(0o600); + expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([ + "functions-runtime-config.json", + ]); + + yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); + expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([]); }).pipe( Effect.provide(BunServices.layer), Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 8e990d0640..4b238c07b8 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -26,5 +26,10 @@ export type { ServiceName, VersionManifest } from "./versions.ts"; export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { StackHandle } from "./createStack.ts"; -export type { FunctionsConfig, FunctionsRuntimeConfig } from "./functions.ts"; +export type { + FunctionsReloadConfig, + FunctionsRuntimeConfig, + ResolvedFunction, + ResolvedFunctionsBundle, +} from "./functions.ts"; export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index be97567638..2edf127344 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -24,7 +24,7 @@ import { } from "./StateManager.ts"; import { StackBuilder } from "./StackBuilder.ts"; import type { ResolvedDaemonConfig, ResolvedStackConfig } from "./StackConfig.ts"; -import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { sanitizeDaemonConfigInput, type DaemonConfigInput } from "./StackConfigResolver.ts"; import { UnixHttpClient } from "./UnixHttpClient.ts"; import { resolveManagedStack } from "./managed-stack.ts"; import { @@ -169,21 +169,22 @@ export const daemonLayer = ( FileSystem.FileSystem | Path.Path | UnixHttpClient > => Effect.gen(function* () { - if (input.stackRoot !== undefined || input.runtimeRoot !== undefined) { + const daemonInput = sanitizeDaemonConfigInput(input); + if (daemonInput.stackRoot !== undefined || daemonInput.runtimeRoot !== undefined) { return yield* new DaemonStartError({ message: "Managed daemon stacks derive stackRoot and runtimeRoot automatically", }); } - const projectDir = input.projectDir ?? input.cwd; - const name = input.name ?? DEFAULT_MANAGED_STACK_NAME; - const cacheRoot = input.cacheRoot ?? defaultCacheRoot(); + const projectDir = daemonInput.projectDir ?? daemonInput.cwd; + const name = daemonInput.name ?? DEFAULT_MANAGED_STACK_NAME; + const cacheRoot = daemonInput.cacheRoot ?? defaultCacheRoot(); const stackRoot = - input.projectStateRoot !== undefined - ? join(input.projectStateRoot, "stacks", name) + daemonInput.projectStateRoot !== undefined + ? join(daemonInput.projectStateRoot, "stacks", name) : defaultManagedStackRoot(cacheRoot, projectDir, name); const runtimeRoot = defaultManagedRuntimeRoot(stackRoot); const config: DaemonConfigInput = { - ...input, + ...daemonInput, cacheRoot, projectDir, name, diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index cda66721c5..7bdd1cd266 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -177,6 +177,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu const envVars = Object.entries({ ...config.env, + ...functionConfig.env, SUPABASE_URL: config.supabaseUrl, SUPABASE_ANON_KEY: config.publishableKey, SUPABASE_SERVICE_ROLE_KEY: config.secretKey, @@ -192,7 +193,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu workerTimeoutMs: 400000, noModuleCache: false, noNpm: false, - importMapPath: functionConfig.importMapPath, + importMapPath: functionConfig.importMapPath ?? undefined, envVars, forceCreate: false, customModuleRoot: "", diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 9b734fa450..6511c750ad 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack, type StackHandle } from "../src/node.ts"; +import { createStack, type ResolvedFunctionsBundle, type StackHandle } from "../src/node.ts"; import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; const STACK_E2E_TEST_TIMEOUT_MS = 5_000; @@ -21,7 +21,7 @@ describe("createStack e2e", () => { stack = await createStack({ projectDir, - functions: { noVerifyJwt: true }, + functions: functionsBundle(projectDir, ["hello"]), jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, }); @@ -89,7 +89,7 @@ describe("createStack e2e", () => { test("reloadFunctions picks up newly added Edge Functions", { timeout: 30_000 }, async () => { writeFunction(projectDir, "later", "later"); - await stack.reloadFunctions({ noVerifyJwt: true }); + await stack.reloadFunctions({ functions: functionsBundle(projectDir, ["hello", "later"]) }); const res = await fetchFunctionWhenReady(`${stack.url}/functions/v1/later`); @@ -168,3 +168,20 @@ function writeFunction(projectDir: string, slug: string, body: string) { `Deno.serve(() => new Response(${JSON.stringify(body)}));\n`, ); } + +function functionsBundle( + projectDir: string, + names: ReadonlyArray, +): ResolvedFunctionsBundle { + return { + env: {}, + functions: names.map((name) => ({ + name, + verifyJWT: false, + entrypointPath: join(projectDir, "supabase", "functions", name, "index.ts"), + importMapPath: null, + staticFiles: [], + env: {}, + })), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 773f12d5f3..4624e456dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,9 +517,6 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1) - '@supabase/config': - specifier: workspace:* - version: link:../config '@supabase/process-compose': specifier: workspace:* version: link:../process-compose