diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index fb8beae0a9..db10777ccd 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -290,7 +290,7 @@ Legend: | `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | | `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | | `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | | `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | | `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | | `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index 097e5af91a..77313405bc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -112,7 +112,8 @@ "ignore": [ "scripts/*.ts", "tests/**/*.ts", - "src/shared/telemetry/event-catalog.ts" + "src/shared/telemetry/event-catalog.ts", + "src/shared/functions/serve.main.ts" ], "ignoreBinaries": [ "nx" diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 53adf26f28..009546b250 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -27,19 +27,21 @@ const { values } = parseArgs({ }, }); -const version = values.version; -if (!version) { - console.error( - "Usage: pnpm exec bun apps/cli/scripts/build.ts --version --shell ", - ); - process.exit(1); -} - const shell = values.shell; if (shell !== "legacy" && shell !== "next") { console.error(`Invalid --shell value: ${String(shell)}. Expected "legacy" or "next".`); process.exit(1); } +const root = path.resolve(import.meta.dir, "../../.."); +const packageJsonPath = path.join(root, "apps/cli/package.json"); +const packageVersion = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: string }; +const version = values.version ?? packageVersion.version; +if (!version) { + console.error( + "Usage: pnpm exec bun apps/cli/scripts/build.ts [--version ] --shell ", + ); + process.exit(1); +} const TARGETS = [ { @@ -82,10 +84,13 @@ const TARGETS = [ }, ] as const; -const root = path.resolve(import.meta.dir, "../../.."); const entrypoint = path.join(root, "apps/cli/src", shell, "main.ts"); const distDir = path.join(root, "dist"); const goSource = path.resolve(root, "apps/cli-go"); +const serveMainTemplateSource = path.join(root, "apps/cli/src/shared/functions/serve.main.ts"); +const serveMainTemplateDefine = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify( + await readFile(serveMainTemplateSource, "utf8"), +)}`; const posthogBuildDefines = [ `--define=process.env.SUPABASE_CLI_POSTHOG_KEY=${JSON.stringify(process.env.POSTHOG_API_KEY ?? "")}`, `--define=process.env.SUPABASE_CLI_POSTHOG_HOST=${JSON.stringify(process.env.POSTHOG_ENDPOINT ?? "")}`, @@ -109,6 +114,18 @@ function libcForBunTarget(target: string): "glibc" | "musl" | "" { return target.includes("-musl") ? "musl" : "glibc"; } +async function runBunBuild(args: ReadonlyArray) { + const child = Bun.spawn({ + cmd: ["bun", ...args], + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`bun build failed with exit code ${exitCode}`); + } +} + async function buildTarget(target: (typeof TARGETS)[number]) { const binDir = path.join(root, "packages", target.pkg, "bin"); await mkdir(binDir, { recursive: true }); @@ -117,7 +134,18 @@ async function buildTarget(target: (typeof TARGETS)[number]) { const libc = libcForBunTarget(target.bunTarget); console.log(`[${target.pkg}] Compiling Bun CLI...`); - await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} ${posthogBuildDefines} --outfile=${outfile}`; + await runBunBuild([ + "build", + entrypoint, + "--compile", + "--minify", + `--target=${target.bunTarget}`, + `--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)}`, + `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, + serveMainTemplateDefine, + ...posthogBuildDefines, + `--outfile=${outfile}`, + ]); console.log(`[${target.pkg}] Done.`); } @@ -188,7 +216,18 @@ async function buildMuslBinaries() { const outfile = path.join(binDir, "supabase"); const libc = libcForBunTarget(target.bunTarget); console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`); - await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} ${posthogBuildDefines} --outfile=${outfile}`; + await runBunBuild([ + "build", + entrypoint, + "--compile", + "--minify", + `--target=${target.bunTarget}`, + `--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)}`, + `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, + serveMainTemplateDefine, + ...posthogBuildDefines, + `--outfile=${outfile}`, + ]); if (shell === "legacy") { // Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index 89d18e0ee0..bcc8a73e73 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -2,58 +2,82 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | --------------------------------------------------- | -| `/supabase/functions//index.ts` | TypeScript | always (loads function source for serving) | -| `/supabase/config.toml` | TOML | to resolve function config (verify_jwt, import_map) | -| `` | plain text | when `--env-file` is set | +| Path | Format | When | +| -------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | +| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | +| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | +| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | +| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | +| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `` | JSON | when `auth.signing_keys_path` is configured | +| `apps/cli/src/shared/functions/serve.main.ts` | TypeScript | as the CLI-owned worker bootstrap template source | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | --------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| `—` | `—` | `—` | `—` | `—` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (for Deno KV remote mode) | no | +| Variable | Purpose | Required? | +| --------------------------------------------- | ---------------------------------------------------------- | ------------------------------------ | +| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | +| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | +| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | +| env vars referenced by `supabase/config.toml` | config interpolation through `loadProjectEnvironment(...)` | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror | no (defaults to `public.ecr.aws`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------- | -| `0` | server stopped (SIGINT/SIGTERM) | -| `1` | Docker not running or unavailable | -| `1` | function serve startup failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------- | +| `0` | clean shutdown after `SIGINT`, `SIGTERM`, or stdin close | +| `1` | Docker unavailable / `docker info` fails | +| `1` | local DB container is not running | +| `1` | invalid inspect flag combination or invalid project/auth config | +| `1` | env file, signing key, import map, or function bind resolution failure | +| `1` | edge-runtime container startup, log streaming, or restart loop failure | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints startup information and live request logs as functions are invoked. +Writes lifecycle text to stderr / stdout while the command is running: + +- `Setting up Edge Functions runtime...` before each container start +- `Skipped serving Function: ` for disabled functions +- `File change detected: ()` when a watched file triggers a restart +- live `docker logs -f --timestamps` output from the edge-runtime container +- `Stopped serving supabase/functions` on clean shutdown ### `--output-format json` -Not applicable (proxied to Go binary). +Long-running raw log / error output only; there is no final success payload object for this command. ### `--output-format stream-json` -Not applicable (proxied to Go binary). +Long-running raw log / error events only; there is no terminal `result` event on success. ## Notes -- Serves all functions locally using Deno and the Supabase Edge Runtime (via Docker). -- `--no-verify-jwt` disables JWT verification for development. -- `--env-file` path to env file populated to Function environment. -- `--import-map` path to custom import map. -- `--inspect` / `--inspect-mode` activates Deno inspector for debugging. -- `--all` is a hidden flag (default true) retained for backward compatibility; it has no effect because the Go CLI always serves all functions. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- The hidden `--all` flag is still parsed but ignored; the native port always serves every discovered function, matching the Go command. +- Each restart re-reads config, rebuilds per-function bind mounts, recreates the `supabase_edge_runtime_` container, and best-effort reloads Kong afterwards. +- The command creates or reuses Docker resources derived from the resolved project id: + - container: `supabase_edge_runtime_` + - named volume: `supabase_edge_runtime_` + - network: `supabase_network_` unless `--network-id` overrides it +- Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`, matching the Go serve path. diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index 12183d612c..4c046ca876 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -1,12 +1,32 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { + FUNCTIONS_SERVE_INSPECT_MODES, + serveFileWatcherLayer, + type FunctionsServeFlags, +} from "../../../../shared/functions/serve.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyFunctionsServe } from "./serve.handler.ts"; -const INSPECT_MODES = ["run", "brk", "wait"] as const; +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const legacyFunctionsServeRuntimeLayer = Layer.mergeAll( + serveFileWatcherLayer, + cliConfig, + legacyDebugLoggerLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["functions", "serve"]), +); const config = { noVerifyJwt: Flag.boolean("no-verify-jwt").pipe( Flag.withDescription("Disable JWT verification for the Function."), + Flag.optional, ), envFile: Flag.string("env-file").pipe( Flag.withDescription("Path to an env file to be populated to the Function environment."), @@ -17,7 +37,7 @@ const config = { Flag.optional, ), inspect: Flag.boolean("inspect").pipe(Flag.withDescription("Alias of --inspect-mode brk.")), - inspectMode: Flag.choice("inspect-mode", INSPECT_MODES).pipe( + inspectMode: Flag.choice("inspect-mode", FUNCTIONS_SERVE_INSPECT_MODES).pipe( Flag.withDescription("Activate inspector capability for debugging."), Flag.optional, ), @@ -26,15 +46,22 @@ const config = { ), all: Flag.boolean("all").pipe( Flag.withDescription("Serve all Functions."), - Flag.optional, + Flag.withDefault(true), Flag.withHidden, ), } as const; -export type LegacyFunctionsServeFlags = CliCommand.Command.Config.Infer; +export type LegacyFunctionsServeFlags = CliCommand.Command.Config.Infer & + FunctionsServeFlags; export const legacyFunctionsServeCommand = Command.make("serve", config).pipe( Command.withDescription("Serve all Functions locally."), Command.withShortDescription("Serve all Functions locally"), - Command.withHandler((flags) => legacyFunctionsServe(flags)), + Command.withHandler((flags) => + legacyFunctionsServe(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyFunctionsServeRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 724b49edba..0718862e64 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,18 +1,37 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyFunctionsServeFlags } from "./serve.command.ts"; +import { Effect } from "effect"; +import { join } from "node:path"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + buildFunctionsServeInspectArgs, + resolveFunctionsServeInspectMode, + serveFunctions, + type FunctionsServeFlags, +} from "../../../../shared/functions/serve.ts"; + +export type LegacyFunctionsServeFlags = FunctionsServeFlags; + +export const legacyResolveFunctionsServeInspectMode = resolveFunctionsServeInspectMode; +export const legacyBuildFunctionsServeInspectArgs = buildFunctionsServeInspectArgs; export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function* ( flags: LegacyFunctionsServeFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["functions", "serve"]; - if (flags.noVerifyJwt) args.push("--no-verify-jwt"); - if (Option.isSome(flags.envFile)) args.push("--env-file", flags.envFile.value); - if (Option.isSome(flags.importMap)) args.push("--import-map", flags.importMap.value); - if (flags.inspect) args.push("--inspect"); - if (Option.isSome(flags.inspectMode)) args.push("--inspect-mode", flags.inspectMode.value); - if (flags.inspectMain) args.push("--inspect-main"); - if (Option.isSome(flags.all)) args.push(`--all=${flags.all.value ? "true" : "false"}`); - yield* proxy.exec(args); + const cliConfig = yield* LegacyCliConfig; + const runtimeInfo = yield* RuntimeInfo; + const telemetryState = yield* LegacyTelemetryState; + const debug = yield* LegacyDebugFlag; + const networkId = yield* LegacyNetworkIdFlag; + + yield* serveFunctions(flags, { + projectRoot: cliConfig.workdir, + supabaseDir: join(cliConfig.workdir, "supabase"), + flagCwd: runtimeInfo.cwd, + platform: runtimeInfo.platform, + debug, + networkId, + projectIdOverride: cliConfig.projectId, + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts new file mode 100644 index 0000000000..c15fa1eb31 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts @@ -0,0 +1,60 @@ +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; +import type { LegacyFunctionsServeFlags } from "./serve.command.ts"; +import { + legacyBuildFunctionsServeInspectArgs, + legacyResolveFunctionsServeInspectMode, +} from "./serve.handler.ts"; + +function baseFlags(): LegacyFunctionsServeFlags { + return { + noVerifyJwt: Option.none(), + envFile: Option.none(), + importMap: Option.none(), + inspect: false, + inspectMode: Option.none(), + inspectMain: false, + all: true, + }; +} + +describe("legacy functions serve inspect flags", () => { + it("treats --inspect as inspect-mode brk", () => { + expect(legacyResolveFunctionsServeInspectMode({ ...baseFlags(), inspect: true })).toBe("brk"); + }); + + it("uses the explicit inspect mode when set", () => { + expect( + legacyResolveFunctionsServeInspectMode({ + ...baseFlags(), + inspectMode: Option.some("wait"), + }), + ).toBe("wait"); + }); + + it("rejects setting both --inspect and --inspect-mode", () => { + expect(() => + legacyResolveFunctionsServeInspectMode({ + ...baseFlags(), + inspect: true, + inspectMode: Option.some("run"), + }), + ).toThrow( + "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", + ); + }); + + it("rejects --inspect-main without an inspect mode", () => { + expect(() => legacyBuildFunctionsServeInspectArgs(undefined, true)).toThrow( + "--inspect-main must be used together with one of these flags: [inspect inspect-mode]", + ); + }); + + it("builds the edge-runtime inspect flags for explicit modes", () => { + expect(legacyBuildFunctionsServeInspectArgs("wait", true)).toEqual([ + "--inspect-wait=0.0.0.0:8083", + "--inspect-main", + ]); + expect(legacyBuildFunctionsServeInspectArgs("run", false)).toEqual(["--inspect=0.0.0.0:8083"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts new file mode 100644 index 0000000000..3cef5cdd44 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -0,0 +1,1747 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, Exit, Fiber, Layer, Option, PubSub, Queue, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { beforeEach, vi } from "vitest"; + +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + FileWatcher, + type FileWatchEvent, +} from "../../../../shared/runtime/file-watcher.service.ts"; +import { + ProcessControl, + type CliProcessSignal, +} from "../../../../shared/runtime/process-control.service.ts"; +import type { LegacyFunctionsServeFlags } from "./serve.command.ts"; + +const deployMockState = vi.hoisted(() => ({ + isDockerRunning: true, + runCalls: [] as Array<{ + command: string; + args: ReadonlyArray; + options: unknown; + }>, + networkCalls: [] as Array<{ + networkMode: string; + projectId: string; + }>, + volumeCalls: [] as Array<{ + volumeName: string; + projectId: string; + }>, + runHandler: undefined as + | undefined + | (( + command: string, + args: ReadonlyArray, + options: unknown, + ) => { + exitCode: number; + stdout: string; + stderr: string; + }), + reset() { + this.isDockerRunning = true; + this.runCalls = []; + this.networkCalls = []; + this.volumeCalls = []; + this.runHandler = undefined; + }, +})); + +vi.mock("../../../../shared/functions/deploy.ts", async () => { + const actual = await vi.importActual( + "../../../../shared/functions/deploy.ts", + ); + const { Effect } = await import("effect"); + + return { + ...actual, + isDockerRunning: () => Effect.succeed(deployMockState.isDockerRunning), + ensureDockerNetwork: (networkMode: string, projectId: string) => + Effect.sync(() => { + deployMockState.networkCalls.push({ networkMode, projectId }); + }), + ensureDockerNamedVolume: (volumeName: string, projectId: string) => + Effect.sync(() => { + deployMockState.volumeCalls.push({ volumeName, projectId }); + }), + runChildProcess: (command: string, args: ReadonlyArray, options?: unknown) => + Effect.sync(() => { + const envFile = args.flatMap((value, index) => + args[index - 1] === "--env-file" ? [value] : [], + )[0]; + const multilineEnvDir = args + .flatMap((value, index) => (args[index - 1] === "-v" ? [value] : [])) + .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro")) + ?.slice(0, -":/root/.supabase/multiline-env:ro".length); + const enrichedOptions = + envFile === undefined && multilineEnvDir === undefined + ? options + : { + ...(typeof options === "object" && options !== null ? options : {}), + ...(envFile === undefined + ? {} + : { envFileContents: readFileSync(envFile, "utf8") }), + ...(multilineEnvDir === undefined + ? {} + : { + multilineEnvScript: readFileSync( + join(multilineEnvDir, "multiline-env.sh"), + "utf8", + ), + multilineEnvFiles: Object.fromEntries( + readdirSync(join(multilineEnvDir, "values")) + .filter((name) => name.startsWith("env-")) + .map((name) => [ + name, + readFileSync(join(multilineEnvDir, "values", name), "utf8"), + ]), + ), + }), + }; + deployMockState.runCalls.push({ command, args: [...args], options: enrichedOptions }); + return ( + deployMockState.runHandler?.(command, args, options) ?? { + exitCode: 0, + stdout: "", + stderr: "", + } + ); + }), + }; +}); + +const tempRoot = useLegacyTempWorkdir("supabase-functions-serve-int-"); + +const { legacyFunctionsServe } = await import("./serve.handler.ts"); + +interface LogProcessBehavior { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + readonly pending?: boolean; + readonly onSpawn?: () => void; +} + +function baseFlags(overrides: Partial = {}): LegacyFunctionsServeFlags { + return { + noVerifyJwt: Option.none(), + envFile: Option.none(), + importMap: Option.none(), + inspect: false, + inspectMode: Option.none(), + inspectMain: false, + all: true, + ...overrides, + }; +} + +function extractFlagValues(args: ReadonlyArray, flag: string) { + return args.flatMap((value, index) => (args[index - 1] === flag ? [value] : [])); +} + +async function extractDockerEnvEntries(call: { args: ReadonlyArray; options: unknown }) { + const values = extractFlagValues(call.args, "-e"); + if (values.some((value) => value.includes("="))) { + return values; + } + + const envFile = extractFlagValues(call.args, "--env-file")[0]; + if (envFile !== undefined) { + const options = + typeof call.options === "object" && call.options !== null ? call.options : undefined; + const envFileContents = + options !== undefined && "envFileContents" in options + ? (options.envFileContents as string | undefined) + : undefined; + const contents = envFileContents ?? (await readFile(envFile, "utf8")); + return contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } + + const options = + typeof call.options === "object" && call.options !== null ? call.options : undefined; + const env = + options !== undefined && "env" in options + ? (options.env as Readonly> | undefined) + : undefined; + if (env === undefined) { + return values; + } + return values.map((name) => `${name}=${env[name] ?? ""}`); +} + +function waitFor(condition: () => boolean, message: string) { + return Effect.gen(function* () { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (condition()) { + return; + } + yield* Effect.sleep(Duration.millis(20)); + } + return yield* Effect.fail(new Error(message)); + }); +} + +function mockQueuedProcessControl() { + const signals = Effect.runSync(Queue.unbounded()); + let exitCode: number | undefined; + + return { + layer: Layer.succeed( + ProcessControl, + ProcessControl.of({ + awaitSignal: () => Queue.take(signals), + awaitShutdown: Effect.never, + holdSignals: () => Effect.void, + exit: (code: number) => + Effect.gen(function* () { + exitCode = code; + return yield* Effect.never; + }), + setExitCode: (code: number) => + Effect.sync(() => { + exitCode = code; + }), + getExitCode: Effect.sync(() => exitCode), + }), + ), + signal(signal: CliProcessSignal = "SIGINT") { + Effect.runSync(Queue.offer(signals, signal)); + }, + }; +} + +function mockFileWatcher() { + const pubsub = Effect.runSync(PubSub.unbounded>({ replay: 8 })); + const watchCalls: Array<{ path: string; ignore?: ReadonlyArray }> = []; + + return { + layer: Layer.succeed( + FileWatcher, + FileWatcher.of({ + watch: (path, options) => { + watchCalls.push({ path, ignore: options?.ignore }); + return Stream.fromPubSub(pubsub); + }, + }), + ), + emit(events: ReadonlyArray) { + PubSub.publishUnsafe(pubsub, events); + }, + get watchCalls() { + return watchCalls; + }, + }; +} + +function mockDockerLogSpawner(behaviors: ReadonlyArray) { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + let index = 0; + + return { + layer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (command._tag !== "StandardCommand") { + throw new Error(`unexpected child process kind: ${command._tag}`); + } + + const record = { + command: command.command, + args: [...command.args], + }; + spawned.push(record); + const behavior = behaviors[Math.min(index, behaviors.length - 1)] ?? {}; + index += 1; + behavior.onSpawn?.(); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1_000 + spawned.length), + exitCode: + behavior.pending === true + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(behavior.exitCode ?? 0)), + isRunning: Effect.succeed(behavior.pending === true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: + behavior.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(behavior.stdout)), + stderr: + behavior.stderr === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(behavior.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ), + get spawned() { + return spawned; + }, + }; +} + +interface SetupOptions { + readonly debug?: boolean; + readonly networkId?: Option.Option; + readonly projectId?: Option.Option; + readonly processControl?: + | ReturnType + | ReturnType; + readonly fileWatcher?: ReturnType; + readonly childSpawner?: ReturnType; +} + +function setupServe(options: SetupOptions = {}) { + const out = mockOutput({ format: "text", interactive: false }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: options.projectId ?? Option.none(), + }); + const api = mockLegacyPlatformApiService({ v1: {} }); + const processControl = options.processControl ?? mockProcessControl(); + const fileWatcher = options.fileWatcher ?? mockFileWatcher(); + const childSpawner = options.childSpawner ?? mockDockerLogSpawner([{ exitCode: 1 }]); + + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + runtimeInfo: mockRuntimeInfo({ + cwd: tempRoot.current, + homeDir: tempRoot.current, + platform: "linux", + }), + processControl, + }), + fileWatcher.layer, + childSpawner.layer, + Layer.succeed(LegacyDebugFlag, options.debug ?? false), + Layer.succeed(LegacyNetworkIdFlag, options.networkId ?? Option.none()), + ); + + return { layer, out, telemetry, processControl, fileWatcher, childSpawner }; +} + +async function writeProjectConfig(content: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", "config.toml"), content); +} + +async function writeFunctionFile(slug: string, relativePath: string, contents: string) { + const pathname = join(tempRoot.current, "supabase", "functions", slug, relativePath); + await mkdir(dirname(pathname), { recursive: true }); + await writeFile(pathname, contents); +} + +async function writeProjectFile(relativePath: string, contents: string) { + const pathname = join(tempRoot.current, relativePath); + await mkdir(dirname(pathname), { recursive: true }); + await writeFile(pathname, contents); +} + +beforeEach(() => { + deployMockState.reset(); +}); + +describe("legacy functions serve integration", () => { + it.live( + "starts the runtime from config-defined functions and wires env, binds, and telemetry", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([ + { + exitCode: 1, + stderr: "error running container: exit 1", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/src/main.ts"', + 'import_map = "./functions/hello/deno.json"', + 'static_files = ["./shared/index.html"]', + "", + "[functions.disabled]", + "enabled = false", + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "src/main.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* Effect.promise(() => + writeProjectFile("supabase/shared/index.html", "

hello

\n"), + ); + yield* Effect.promise(() => + writeProjectFile( + join("supabase", "functions", ".env"), + ["HELLO=WORLD", "SUPABASE_SKIP=1", ""].join("\n"), + ), + ); + yield* Effect.promise(() => + writeProjectFile(join("supabase", ".temp", "edge-runtime-version"), "1.73.13\n"), + ); + + const { layer, out, telemetry } = setupServe({ childSpawner }); + + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("error running container: exit 1"); + } + + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_test-project", + projectId: "test-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_test-project", + projectId: "test-project", + }, + ]); + expect(telemetry.flushed).toBe(true); + expect(out.stderrText).toContain("Setting up Edge Functions runtime...\n"); + expect(out.stderrText).toContain("Skipped serving Function: disabled\n"); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + expect(dockerRun.args).toContain("--network"); + expect(dockerRun.args).toContain("supabase_network_test-project"); + expect(dockerRun.args).toContain("--add-host"); + expect(dockerRun.args).toContain("host.docker.internal:host-gateway"); + expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.73.13"); + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + expect(envs).toContain("HELLO=WORLD"); + expect(envs).not.toContain("SUPABASE_SKIP=1"); + const functionsConfig = envs.find((entry) => + entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), + ); + expect(functionsConfig).toBeDefined(); + if (functionsConfig === undefined) { + throw new Error("missing functions config env"); + } + + expect( + JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), + ).toEqual({ + hello: { + verifyJWT: true, + entrypointPath: "supabase/functions/hello/src/main.ts", + importMapPath: "supabase/functions/hello/deno.json", + staticFiles: ["supabase/shared/index.html"], + }, + }); + + expect(childSpawner.spawned).toEqual([ + { + command: "docker", + args: ["logs", "-f", "--timestamps", "supabase_edge_runtime_test-project"], + }, + ]); + }); + }, + ); + + it.live("mounts multiline env values without placing their contents in docker argv", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + let multilineEnvDirWhenLogsStarted: string | undefined; + let multilineEnvDirExistedWhenLogsStarted = false; + const childSpawner = mockDockerLogSpawner([ + { + exitCode: 1, + stderr: "error running container: exit 1", + onSpawn: () => { + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + if (dockerRun === undefined) { + throw new Error("expected docker run call before docker logs spawn"); + } + multilineEnvDirWhenLogsStarted = extractFlagValues(dockerRun.args, "-v") + .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro")) + ?.slice(0, -":/root/.supabase/multiline-env:ro".length); + multilineEnvDirExistedWhenLogsStarted = + multilineEnvDirWhenLogsStarted !== undefined && + existsSync(multilineEnvDirWhenLogsStarted); + }, + }, + ]); + + const multilineValue = ["-----BEGIN KEY-----", "EOF_ENV_0", "line-3", "-----END KEY-----"].join( + "\n", + ); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => + writeProjectFile( + join("supabase", "functions", ".env"), + [`MULTILINE_SECRET="${multilineValue}"`, ""].join("\n"), + ), + ); + + const { layer } = setupServe({ childSpawner }); + + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + expect(error).toBeInstanceOf(Error); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + expect(dockerRun.args.join(" ")).not.toContain(multilineValue); + expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); + + const multilineBind = extractFlagValues(dockerRun.args, "-v").find((value) => + value.endsWith(":/root/.supabase/multiline-env:ro"), + ); + expect(multilineBind).toBeDefined(); + if (multilineBind === undefined) { + throw new Error("expected multiline env bind"); + } + + const options = + typeof dockerRun.options === "object" && dockerRun.options !== null + ? dockerRun.options + : undefined; + const script = + options !== undefined && "multilineEnvScript" in options + ? (options.multilineEnvScript as string | undefined) + : undefined; + const files = + options !== undefined && "multilineEnvFiles" in options + ? (options.multilineEnvFiles as Record | undefined) + : undefined; + + expect(script).toBeDefined(); + expect(files).toBeDefined(); + expect(script).toContain( + 'export MULTILINE_SECRET="$(cat /root/.supabase/multiline-env/values/env-0)"', + ); + expect(script).not.toContain(multilineValue); + expect(script).not.toContain("EOF_ENV_0"); + expect(files?.["env-0"]).toBe(multilineValue); + expect(multilineEnvDirWhenLogsStarted).toBeDefined(); + if (multilineEnvDirWhenLogsStarted === undefined) { + throw new Error("expected multiline env dir when docker logs started"); + } + expect(multilineEnvDirExistedWhenLogsStarted).toBe(true); + expect(existsSync(multilineEnvDirWhenLogsStarted)).toBe(false); + }); + }); + + it.live("fails before startup when a multiline env name is not a shell identifier", () => { + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => + writeProjectFile( + join("supabase", "functions", ".env"), + ['FOO.BAR="line-1\nline-2"', ""].join("\n"), + ), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("invalid multiline environment variable name"); + expect(error.message).toContain("FOO.BAR"); + } + expect( + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "run", + ), + ).toHaveLength(0); + }); + }); + + it.live("sanitizes dotenv parse failures from config env files", () => { + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => writeProjectFile(".env.development", "API-KEY=secret-value\n")); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("failed to parse environment file:"); + expect(error.message).toContain(".env.development"); + expect(error.message).toContain("unexpected character '-' in variable name"); + expect(error.message).not.toContain("secret-value"); + expect(error.message).not.toContain('near "API-KEY=secret-value"'); + } + expect(deployMockState.runCalls).toHaveLength(0); + }); + }); + + it.live("skips missing unused import map targets during serve startup", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([ + { + exitCode: 1, + stderr: "error running container: exit 1", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + 'import_map = "./functions/hello/deno.json"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => + writeFunctionFile( + "hello", + "deno.json", + JSON.stringify({ + imports: { + "unused-alias/": "../missing-shared/", + }, + }), + ), + ); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("error running container: exit 1"); + } + expect( + deployMockState.runCalls.some( + (call) => call.command === "docker" && call.args[0] === "run", + ), + ).toBe(true); + }); + }); + + it.live("binds deno.json import map references outside the project root", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([ + { + exitCode: 1, + stderr: "external import map logs failed", + }, + ]); + + return Effect.gen(function* () { + const externalImportMapPath = join(dirname(tempRoot.current), "shared-import-map.json"); + + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + 'import_map = "./functions/hello/deno.json"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFile(externalImportMapPath, JSON.stringify({ imports: {} })), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => + writeFunctionFile( + "hello", + "deno.json", + JSON.stringify({ + importMap: "../../../../shared-import-map.json", + }), + ), + ); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("external import map logs failed"); + } + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run invocation"); + } + expect( + extractFlagValues(dockerRun.args, "-v").some( + (value) => + value.startsWith(`${externalImportMapPath}:`) && + value.endsWith("/shared-import-map.json:ro"), + ), + ).toBe(true); + }); + }); + + it.live("restarts the runtime when watched files change", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const fileWatcher = mockFileWatcher(); + const childSpawner = mockDockerLogSpawner([ + { pending: true }, + { exitCode: 1, stderr: "docker logs exited with 1" }, + ]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer, out } = setupServe({ fileWatcher, childSpawner }); + const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* waitFor( + () => + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "run", + ).length === 1, + "timed out waiting for first docker run", + ); + + fileWatcher.emit([ + { + path: join(tempRoot.current, "supabase", "functions", "hello", "index.ts"), + type: "update", + }, + ]); + + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("docker logs exited with 1"); + } + + expect( + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "run", + ), + ).toHaveLength(2); + expect(out.stderrText).toContain("File change detected:"); + }); + }); + + it.live("stops serving cleanly on a process signal", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const processControl = mockQueuedProcessControl(); + const childSpawner = mockDockerLogSpawner([{ pending: true }]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer, out } = setupServe({ processControl, childSpawner }); + const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* waitFor( + () => + deployMockState.runCalls.some( + (call) => call.command === "docker" && call.args[0] === "run", + ), + "timed out waiting for docker run", + ); + processControl.signal("SIGINT"); + + const exit = yield* Fiber.await(fiber); + expect(Exit.isSuccess(exit)).toBe(true); + expect( + out.stdoutText + .replaceAll("\u001b[1m", "") + .replaceAll("\u001b[22m", "") + .replaceAll("\\", "/"), + ).toContain("Stopped serving supabase/functions\n"); + }); + }); + + it.live("does not remove the existing runtime when interrupted before startup owns it", () => { + const processControl = mockQueuedProcessControl(); + + return Effect.gen(function* () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise(() => { + // Intentionally pending to keep startup in pre-removal work. + }), + ); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fetchMock.mockRestore(); + }), + ); + + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example.com"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer, out } = setupServe({ processControl }); + const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* waitFor(() => fetchMock.mock.calls.length > 0, "timed out waiting for JWKS fetch"); + processControl.signal("SIGINT"); + + const exit = yield* Fiber.await(fiber); + expect(Exit.isSuccess(exit)).toBe(true); + expect( + deployMockState.runCalls.some( + (call) => + call.command === "docker" && + call.args[0] === "container" && + call.args[1] === "rm" && + call.args.includes("supabase_edge_runtime_test-project"), + ), + ).toBe(false); + expect(out.stdoutText).toContain("Stopped serving"); + }); + }); + + it.live("passes inspect, debug, and custom network settings through to edge-runtime", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "inspect failed" }]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ + debug: true, + networkId: Option.some("custom-network"), + childSpawner, + }); + + const error = yield* legacyFunctionsServe( + baseFlags({ + inspectMode: Option.some("wait"), + inspectMain: true, + }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("inspect failed"); + } + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + expect(dockerRun.args).toContain("--network"); + expect(dockerRun.args).toContain("custom-network"); + expect(dockerRun.args).toContain("-p"); + expect(dockerRun.args).toContain("8083:8083"); + + const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; + expect(commandScript).toContain("--inspect-wait=0.0.0.0:8083"); + expect(commandScript).toContain("--inspect-main"); + expect(commandScript).toContain("--verbose"); + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + expect(envs).toContain("SUPABASE_INTERNAL_DEBUG=true"); + expect(envs).toContain("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0"); + expect(deployMockState.networkCalls).toEqual([ + { networkMode: "custom-network", projectId: "test-project" }, + ]); + }); + }); + + it.live("fetches remote jwks for enabled third-party auth providers", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); + + return Effect.gen(function* () { + const remoteKeys = [ + { + kty: "RSA", + kid: "remote-key", + alg: "RS256", + use: "sig", + n: "abc", + e: "AQAB", + }, + ]; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://issuer.example/.well-known/openid-configuration") { + return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === "https://issuer.example/jwks.json") { + return new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch url: ${url}`); + }); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fetchMock.mockRestore(); + }), + ); + + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("jwks logs failed"); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); + expect(jwks).toBeDefined(); + if (jwks === undefined) { + throw new Error("missing SUPABASE_JWKS"); + } + + expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ + keys: expect.arrayContaining([ + expect.objectContaining({ kid: "remote-key" }), + expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), + expect.objectContaining({ kty: "oct" }), + ]), + }); + }); + }); + + it.live( + "falls back to local jwks when remote jwks resolution fails for enabled third-party auth providers", + () => { + return Effect.gen(function* () { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + throw new Error("oidc discovery failed"); + }); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fetchMock.mockRestore(); + }), + ); + + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("jwks logs failed"); + } + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); + expect(jwks).toBeDefined(); + if (jwks === undefined) { + throw new Error("missing SUPABASE_JWKS"); + } + expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ + keys: expect.arrayContaining([ + expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), + expect.objectContaining({ kty: "oct" }), + ]), + }); + }); + }, + ); + + it.live("includes config-defined edge runtime secrets in the runtime env", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[edge_runtime]", + 'policy = "per_worker"', + "inspector_port = 8083", + "", + "[edge_runtime.secrets]", + 'FROM_CONFIG = "config-value"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("secrets logs failed"); + } + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + expect(envs).toContain("FROM_CONFIG=config-value"); + }); + }); + + it.live("uses the resolved project_id when deriving docker resource names", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const envName = "SUPABASE_SERVE_PROJECT_ID"; + const previous = process.env[envName]; + process.env[envName] = "env-backed-project"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig([`project_id = "env(${envName})"`, ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("serve logs failed"); + } + + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_env-backed-project", + projectId: "env-backed-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_env-backed-project", + projectId: "env-backed-project", + }, + ]); + expect(deployMockState.runCalls).toContainEqual( + expect.objectContaining({ + command: "docker", + args: ["container", "inspect", "supabase_db_env-backed-project"], + }), + ); + }); + }); + + it.live( + "prefers the legacy SUPABASE_PROJECT_ID override when deriving docker resource names", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "config-project"', + "", + "[functions.hello]", + "verify_jwt = true", + "", + "[remotes.override]", + 'project_id = "override-project"', + "", + "[remotes.override.functions.hello]", + "verify_jwt = false", + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ + childSpawner, + projectId: Option.some("override-project"), + }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("serve logs failed"); + } + + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_override-project", + projectId: "override-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_override-project", + projectId: "override-project", + }, + ]); + expect(deployMockState.runCalls).toContainEqual( + expect.objectContaining({ + command: "docker", + args: ["container", "inspect", "supabase_db_override-project"], + }), + ); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const functionsConfig = envs.find((entry) => + entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), + ); + expect(functionsConfig).toBeDefined(); + if (functionsConfig === undefined) { + throw new Error("missing SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); + } + + expect( + JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), + ).toEqual( + expect.objectContaining({ + hello: expect.objectContaining({ + verifyJWT: false, + }), + }), + ); + }); + }, + ); + + it.live("fails inspect flag conflicts before startup work begins", () => { + deployMockState.isDockerRunning = false; + + return Effect.gen(function* () { + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe( + baseFlags({ + inspect: true, + inspectMode: Option.some("run"), + }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain( + "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", + ); + } + expect(deployMockState.runCalls).toHaveLength(0); + expect(deployMockState.volumeCalls).toHaveLength(0); + expect(deployMockState.networkCalls).toHaveLength(0); + }); + }); + + it.live("fails when the project config is malformed", () => { + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig("not valid toml ][")); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(JSON.stringify(error)).toContain("ProjectConfigParseError"); + expect(deployMockState.runCalls).toHaveLength(0); + }); + }); + + it.live("fails when the local database is not running", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { + exitCode: 1, + stdout: "", + stderr: "Error: No such container: supabase_db_test-project", + }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("supabase start is not running."); + } + }); + }); + + it.live("resolves env() config values from root env development files", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "root env logs failed" }]); + const previousSupabaseEnv = process.env["SUPABASE_ENV"]; + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig([`project_id = "env(ROOT_PROJECT_ID)"`, ""].join("\n")), + ); + yield* Effect.promise(() => + writeProjectFile(".env.development", "ROOT_PROJECT_ID=root-env-project\n"), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + process.env["SUPABASE_ENV"] = "development"; + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("root env logs failed"); + } + + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_root-env-project", + projectId: "root-env-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_root-env-project", + projectId: "root-env-project", + }, + ]); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousSupabaseEnv === undefined) { + delete process.env["SUPABASE_ENV"]; + } else { + process.env["SUPABASE_ENV"] = previousSupabaseEnv; + } + }), + ), + ); + }); + + it.live("fails when the explicit env file is missing", () => { + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe( + baseFlags({ + envFile: Option.some(".env"), + }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain(".env"); + expect(error.message).toContain("no such file or directory"); + } + expect( + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "run", + ), + ).toHaveLength(0); + }); + }); +}); diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index edd6d5f455..d23ba3ea39 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -119,34 +119,35 @@ describe("native hidden flags", () => { const proxy = mockLegacyGoProxy(); await Effect.runPromise( - Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["start", "--preview"]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "stop", - "--backup=false", - ]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "functions", - "download", - "hello", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ]); - const useDockerExit = yield* Command.runWith(legacyTestRoot, { - version: "0.0.0-test", - })(["functions", "deploy", "hello", "--use-docker"]).pipe(Effect.exit); - const legacyBundleExit = yield* Command.runWith(legacyTestRoot, { - version: "0.0.0-test", - })(["functions", "deploy", "hello", "--legacy-bundle"]).pipe(Effect.exit); - expect(JSON.stringify(useDockerExit)).not.toContain("UnrecognizedFlag"); - expect(JSON.stringify(legacyBundleExit)).not.toContain("UnrecognizedFlag"); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "functions", - "serve", - "--all=false", - ]); - }).pipe( + Effect.scoped( + Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["start", "--preview"]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "stop", + "--backup=false", + ]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "functions", + "download", + "hello", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ]); + const useDockerExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["functions", "deploy", "hello", "--use-docker"]).pipe(Effect.exit); + const legacyBundleExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["functions", "deploy", "hello", "--legacy-bundle"]).pipe(Effect.exit); + expect(JSON.stringify(useDockerExit)).not.toContain("UnrecognizedFlag"); + expect(JSON.stringify(legacyBundleExit)).not.toContain("UnrecognizedFlag"); + const serveExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["functions", "serve", "--all=false"]).pipe(Effect.exit); + expect(JSON.stringify(serveExit)).not.toContain("UnrecognizedFlag"); + }), + ).pipe( Effect.provide( Layer.mergeAll( withEnv(authenticatedEnv), @@ -162,7 +163,6 @@ describe("native hidden flags", () => { ["start", "--preview"], ["stop", "--backup=false"], ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], - ["functions", "serve", "--all=false"], ]); }); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 57fa8a7914..a1846f7a0d 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -116,7 +116,35 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp }).pipe(Effect.provide(BunServices.layer)), ); - const useGlobalSignalInterrupt = !args.includes("start"); + const globalFlagsWithValues = new Set([ + "--output-format", + "--output", + "-o", + "--profile", + "--workdir", + "--network-id", + "--dns-resolver", + "--agent", + ]); + const commandArgs: Array = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg.startsWith("-")) { + const [flag] = arg.split("=", 1); + if (!arg.includes("=") && flag !== undefined && globalFlagsWithValues.has(flag)) { + index += 1; + } + continue; + } + commandArgs.push(arg); + } + const command = commandArgs[0]; + const subcommand = commandArgs[1]; + const useGlobalSignalInterrupt = !( + command === "start" || + (command === "db" && subcommand === "start") || + (command === "functions" && subcommand === "serve") + ); const outputFormat = await Effect.runPromise( Effect.gen(function* () { const aiTool = yield* AiTool; diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 08ab858902..0dec0bbdb9 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -60,13 +60,14 @@ interface DeployFunctionsDependencies { ) => Effect.Effect; } -interface ResolvedDeployFunctionConfig { +export interface ResolvedDeployFunctionConfig { readonly slug: string; readonly enabled: boolean; readonly verifyJwt: boolean; readonly entrypoint: string; readonly importMap: string; readonly staticFiles: ReadonlyArray; + readonly env: Readonly>; } interface SourceDeployMetadata { @@ -227,14 +228,14 @@ function toSlash(pathname: string) { return pathname.replaceAll("\\", "/"); } -function normalizeProjectId(source: string) { +export function normalizeProjectId(source: string) { const sanitized = source.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); return sanitized.length > MAX_PROJECT_ID_LENGTH ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) : sanitized; } -function localDockerId(name: string, projectId: string) { +export function localDockerId(name: string, projectId: string) { return `supabase_${name}_${normalizeProjectId(projectId)}`; } @@ -242,14 +243,14 @@ const dockerCliProjectLabel = "com.supabase.cli.project"; const dockerComposeProjectLabel = "com.docker.compose.project"; const dockerNpmEnvNames = ["NPM_CONFIG_REGISTRY", "NPM_AUTH_TOKEN"] as const; -function dockerProjectLabels(projectId: string) { +export function dockerProjectLabels(projectId: string) { return { [dockerCliProjectLabel]: projectId, [dockerComposeProjectLabel]: projectId, }; } -function toDockerPath(hostPath: string) { +export function toDockerPath(hostPath: string) { const normalized = toSlash(resolve(hostPath)); return normalized.replace(/^[A-Za-z]:/, ""); } @@ -260,7 +261,7 @@ function toBundledFileUrl(hostPath: string) { return url.toString(); } -function dockerBindHostPath(bind: string) { +export function dockerBindHostPath(bind: string) { const withoutMode = bind.replace(/:(?:ro|rw)$/, ""); const separatorIndex = withoutMode.lastIndexOf(":"); return separatorIndex === -1 ? withoutMode : withoutMode.slice(0, separatorIndex); @@ -847,6 +848,19 @@ async function resolveImportMapAllowedRoots(projectRoot: string, importMapPath: if (!isContainedPath(realProjectRoot, realImportMapPath)) { allowedRoots.push(dirname(realImportMapPath)); } + if (isDenoConfigFile(importMapPath)) { + const contents = await readFile(importMapPath); + const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); + const importMap = ImportMapFile.fromUnknown(parsed); + if (importMap.importMapReference.length > 0) { + const referencedImportMapPath = await realpath( + join(dirname(importMapPath), importMap.importMapReference), + ); + if (!isContainedPath(realProjectRoot, referencedImportMapPath)) { + allowedRoots.push(dirname(referencedImportMapPath)); + } + } + } return allowedRoots; } @@ -1046,21 +1060,40 @@ function sanitizeDockerBinds( return result; } -async function buildDockerBinds( +export async function buildDockerBinds( projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, + options: { + readonly additionalModuleRoots?: ReadonlyArray; + readonly onWarning?: (message: string) => Promise; + readonly skipMissingImportMapTargets?: boolean; + } = {}, ) { const hostFunctionsDir = resolve(functionsDir); const hostOutputDir = resolve(outputDir); const projectRoot = resolve(functionsDir, "..", ".."); const realProjectRoot = await realpath(projectRoot); - const importMapAllowedRoots = await resolveImportMapAllowedRoots(projectRoot, config.importMap); - const binds = [ - `${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`, - `${hostFunctionsDir}:${toDockerPath(hostFunctionsDir)}:ro`, + const moduleRoots = [ + realProjectRoot, + ...( + await Promise.all( + (options.additionalModuleRoots ?? []).map(async (root) => { + try { + return await realpath(root); + } catch { + return undefined; + } + }), + ) + ).flatMap((root) => (root === undefined ? [] : [root])), ]; + const importMapAllowedRoots = await resolveImportMapAllowedRoots(projectRoot, config.importMap); + const binds = [`${hostFunctionsDir}:${toDockerPath(hostFunctionsDir)}:ro`]; + if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { + binds.unshift(`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`); + } if (!hostOutputDir.startsWith(hostFunctionsDir)) { binds.push(`${hostOutputDir}:${toDockerPath(hostOutputDir)}:rw`); @@ -1076,6 +1109,8 @@ async function buildDockerBinds( }; const appendProjectBind = async (pathname: string, _contents: Uint8Array) => appendBindWithinRoots([realProjectRoot], pathname); + const appendModuleBind = async (pathname: string, _contents: Uint8Array) => + appendBindWithinRoots(moduleRoots, pathname); const appendImportMapBind = async (pathname: string, _contents: Uint8Array) => appendBindWithinRoots(importMapAllowedRoots, pathname); const importMap = @@ -1085,24 +1120,39 @@ async function buildDockerBinds( await walkImportPaths( importMap, config.entrypoint, - [realProjectRoot], + moduleRoots, projectRoot, - appendProjectBind, - async () => {}, + appendModuleBind, + options.onWarning ?? (async () => {}), ); await forEachLocalImportMapTarget(importMap, async (target) => { - await appendBindWithinRoots(importMapAllowedRoots, target); - if ((await stat(target)).isDirectory()) { - return; + try { + await appendBindWithinRoots(importMapAllowedRoots, target); + if ((await stat(target)).isDirectory()) { + return; + } + await walkLocalImportMapTargetImports( + importMap, + target, + importMapAllowedRoots, + projectRoot, + appendImportMapBind, + async () => {}, + ); + } catch (error) { + if ( + options.skipMissingImportMapTargets === true && + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + await (options.onWarning ?? (async () => {}))( + `WARN: Skipping missing import map target: ${target}\n`, + ); + return; + } + throw error; } - await walkLocalImportMapTargetImports( - importMap, - target, - importMapAllowedRoots, - projectRoot, - appendImportMapBind, - async () => {}, - ); }); for (const pattern of config.staticFiles) { let files: ReadonlyArray; @@ -1136,7 +1186,10 @@ function isUserDefinedDockerNetwork(networkMode: string) { ); } -const ensureDockerNetwork = Effect.fnUntraced(function* (networkMode: string, projectId: string) { +export const ensureDockerNetwork = Effect.fnUntraced(function* ( + networkMode: string, + projectId: string, +) { if (!isUserDefinedDockerNetwork(networkMode)) { return; } @@ -1171,7 +1224,7 @@ const ensureDockerNetwork = Effect.fnUntraced(function* (networkMode: string, pr } }); -const ensureDockerNamedVolume = Effect.fnUntraced(function* ( +export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( volumeName: string, projectId: string, ) { @@ -1213,13 +1266,14 @@ async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: stri } } -const runChildProcess = Effect.fnUntraced(function* ( +export const runChildProcess = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, opts: { readonly stdout?: "pipe" | "ignore"; readonly stderr?: "pipe" | "ignore"; readonly env?: Readonly>; + readonly extendEnv?: boolean; } = {}, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1229,6 +1283,7 @@ const runChildProcess = Effect.fnUntraced(function* ( stdout: opts.stdout ?? "pipe", stderr: opts.stderr ?? "pipe", env: opts.env, + extendEnv: opts.extendEnv ?? command === "docker", }), ); @@ -1243,7 +1298,7 @@ const runChildProcess = Effect.fnUntraced(function* ( return { exitCode, stdout, stderr }; }); -const isDockerRunning = Effect.fnUntraced(function* () { +export const isDockerRunning = Effect.fnUntraced(function* () { const result = yield* runChildProcess("docker", ["info"], { stdout: "ignore", stderr: "ignore", @@ -1663,7 +1718,7 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( ); }); -const discoverFunctionSlugs = Effect.fnUntraced(function* ( +export const discoverFunctionSlugs = Effect.fnUntraced(function* ( projectRoot: string, configDeclaredFunctions: Readonly>, ) { @@ -1683,7 +1738,7 @@ const discoverFunctionSlugs = Effect.fnUntraced(function* ( ); if (entries !== undefined) { for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!entry.isDirectory()) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { continue; } const slug = entry.name; @@ -1713,7 +1768,7 @@ const validateConfigFunctionSlugs = Effect.fnUntraced(function* ( return configSlugs; }); -const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { +export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { readonly slugs: ReadonlyArray; readonly cwd: string; readonly projectRoot: string; @@ -1809,6 +1864,7 @@ const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { entrypoint, importMap, staticFiles, + env: configured.env, }); } @@ -1942,7 +1998,7 @@ const deployViaDocker = Effect.fnUntraced(function* ( } }); -function resolveEdgeRuntimeVersion( +export function resolveEdgeRuntimeVersion( denoVersion: number | undefined, defaultVersion: string, ): Effect.Effect { diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts new file mode 100644 index 0000000000..7345bea444 --- /dev/null +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -0,0 +1,391 @@ +// @ts-nocheck +declare const Deno: any; +declare const EdgeRuntime: any; + +import { STATUS_CODE, STATUS_TEXT } from "https://deno.land/std/http/status.ts"; +import * as posix from "https://deno.land/std/path/posix/mod.ts"; + +import * as jose from "jsr:@panva/jose@6"; + +const SB_SPECIFIC_ERROR_CODE = { + BootError: STATUS_CODE.ServiceUnavailable /** Service Unavailable (RFC 7231, 6.6.4) */, + InvalidWorkerResponse: + STATUS_CODE.InternalServerError /** Internal Server Error (RFC 7231, 6.6.1) */, + WorkerLimit: 546 /** Extended */, +}; + +const SB_SPECIFIC_ERROR_TEXT = { + [SB_SPECIFIC_ERROR_CODE.BootError]: "BOOT_ERROR", + [SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse]: "WORKER_ERROR", + [SB_SPECIFIC_ERROR_CODE.WorkerLimit]: "WORKER_LIMIT", +}; + +const SB_SPECIFIC_ERROR_REASON = { + [SB_SPECIFIC_ERROR_CODE.BootError]: "Worker failed to boot (please check logs)", + [SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse]: + "Function exited due to an error (please check logs)", + [SB_SPECIFIC_ERROR_CODE.WorkerLimit]: + "Worker failed to respond due to a resource limit (please check logs)", +}; + +// OS stuff - we don't want to expose these to the functions. +const EXCLUDED_ENVS = ["HOME", "HOSTNAME", "PATH", "PWD"]; +const HOST_PORT = Deno.env.get("SUPABASE_INTERNAL_HOST_PORT")!; +const JWT_SECRET = Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET")!; +const JWKS_ENDPOINT = new URL("/auth/v1/.well-known/jwks.json", Deno.env.get("SUPABASE_URL")!); +const DEBUG = Deno.env.get("SUPABASE_INTERNAL_DEBUG") === "true"; +const FUNCTIONS_CONFIG_STRING = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG")!; + +const SUPABASE_PUBLISHABLE_KEY = Deno.env.get("SUPABASE_INTERNAL_PUBLISHABLE_KEY"); +const SUPABASE_SECRET_KEY = Deno.env.get("SUPABASE_INTERNAL_SECRET_KEY"); + +const WALLCLOCK_LIMIT_SEC = parseInt(Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC")); + +const DENO_SB_ERROR_MAP = new Map([ + [Deno.errors.InvalidWorkerCreation, SB_SPECIFIC_ERROR_CODE.BootError], + [Deno.errors.InvalidWorkerResponse, SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse], + [Deno.errors.WorkerRequestCancelled, SB_SPECIFIC_ERROR_CODE.WorkerLimit], +]); +const GENERIC_FUNCTION_SERVE_MESSAGE = `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/`; + +interface FunctionConfig { + entrypointPath: string; + importMapPath: string; + staticFiles: string[]; + verifyJWT: boolean; + env?: Record; +} + +function getResponse(payload: any, status: number, customHeaders = {}) { + const headers = { ...customHeaders }; + let body: string | null = null; + + if (payload) { + if (typeof payload === "object") { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(payload); + } else if (typeof payload === "string") { + headers["Content-Type"] = "text/plain"; + body = payload; + } else { + body = null; + } + } + + return new Response(body, { status, headers }); +} + +const functionsConfig: Record = (() => { + try { + const functionsConfig = JSON.parse(FUNCTIONS_CONFIG_STRING); + + if (DEBUG) { + console.log("Functions config:", JSON.stringify(functionsConfig, null, 2)); + } + + return functionsConfig; + } catch (cause) { + throw new Error("Failed to parse functions config", { cause }); + } +})(); + +/* --- JWT verification --- */ +export function extractBearerToken(rawToken: string) { + const tokenParts = rawToken.split(" "); + const [bearer, token] = tokenParts; + if (bearer !== "Bearer" || tokenParts.length !== 2) { + return null; + } + + return token; +} + +function getAuthToken(req: Request) { + const authHeader = req.headers.get("authorization"); + const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key"); + + // NOTE:(kallebysantos) Kong on legacy CLI stack pass it down as 'Bearer Token' format + const cleanSbApiKeyCompatibilityToken = sbApiKeyCompatibilityToken?.replace("Bearer", "")?.trim(); + + if (!authHeader && !cleanSbApiKeyCompatibilityToken) { + throw new Error("Missing authorization header"); + } + + // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: + // - API proxy mints a temp token + // - Original bearer is not present or is ApiKey + const bearerToken = extractBearerToken(authHeader ?? ""); + const token = + !bearerToken || bearerToken.startsWith("sb_") ? cleanSbApiKeyCompatibilityToken : bearerToken; + + if (!token) { + throw new Error(`Auth header is not 'Bearer {token}'`); + } + + return token; +} + +async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise { + const encoder = new TextEncoder(); + const secretKey = encoder.encode(jwtSecret); + try { + await jose.jwtVerify(jwt, secretKey); + } catch (e) { + console.error("Symmetric Legacy JWT verification error", e); + return false; + } + return true; +} + +// Lazy-loading JWKs +let jwks = (() => { + try { + // using injected JWKS from cli + return jose.createLocalJWKSet(JSON.parse(Deno.env.get("SUPABASE_JWKS"))); + } catch { + return null; + } +})(); + +async function isValidJWT(jwksUrl: URL, jwt: string): Promise { + try { + if (!jwks) { + // Loading from remote-url on fly + jwks = jose.createRemoteJWKSet(new URL(jwksUrl)); + } + await jose.jwtVerify(jwt, jwks); + } catch (e) { + console.error("Asymmetric JWT verification error", e); + return false; + } + return true; +} + +/** + * Applies hybrid JWT verification, using JWK as primary and Legacy Secret as fallback. + * Use only during 'New JWT Keys' migration period, while `JWT_SECRET` is still available. + */ +export async function verifyHybridJWT( + jwtSecret: string, + jwksUrl: URL, + jwt: string, +): Promise { + const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt); + + if (jwtAlgorithm === "HS256") { + console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`); + + return await isValidLegacyJWT(jwtSecret, jwt); + } + + if (jwtAlgorithm === "ES256" || jwtAlgorithm === "RS256") { + return await isValidJWT(jwksUrl, jwt); + } + + return false; +} + +// Ref: https://docs.deno.com/examples/checking_file_existence/ +async function shouldUsePackageJsonDiscovery({ + entrypointPath, + importMapPath, +}: FunctionConfig): Promise { + if (importMapPath) { + return false; + } + const packageJsonPath = posix.join(posix.dirname(entrypointPath), "package.json"); + try { + await Deno.lstat(packageJsonPath); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return false; + } + } + return true; +} + +export function prepareUserRequest(req: Request): Request { + const clonedURL = new URL(req.url); + const forwardedHost = req.headers.get("x-forwarded-host"); + clonedURL.hostname = forwardedHost ?? clonedURL.hostname; + const clonedReq = new Request(clonedURL, req.clone()); + + // remove custom api headers + clonedReq.headers.delete("sb-api-key"); + EdgeRuntime.applySupabaseTag(req, clonedReq); + + return clonedReq; +} + +Deno.serve({ + handler: async (req: Request) => { + const url = new URL(req.url); + const { pathname } = url; + + // handle health checks + if (pathname === "/_internal/health") { + return getResponse({ message: "ok" }, STATUS_CODE.OK); + } + + // handle metrics + if (pathname === "/_internal/metric") { + const metric = await EdgeRuntime.getRuntimeMetrics(); + return Response.json(metric); + } + + const pathParts = pathname.split("/"); + const functionName = pathParts[1]; + + if (!functionName || !(functionName in functionsConfig)) { + return getResponse("Function not found", STATUS_CODE.NotFound); + } + + if (req.method !== "OPTIONS" && functionsConfig[functionName].verifyJWT) { + try { + const token = getAuthToken(req); + const isValidJWT = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); + + if (!isValidJWT) { + return getResponse({ msg: "Invalid JWT" }, STATUS_CODE.Unauthorized); + } + } catch (e) { + console.error(e); + return getResponse({ msg: e.toString() }, STATUS_CODE.Unauthorized); + } + } + + const servicePath = posix.dirname(functionsConfig[functionName].entrypointPath); + console.error(`serving the request with ${servicePath}`); + + // Ref: https://supabase.com/docs/guides/functions/limits + const memoryLimitMb = 256; + const workerTimeoutMs = isFinite(WALLCLOCK_LIMIT_SEC) ? WALLCLOCK_LIMIT_SEC * 1000 : 400 * 1000; + const noModuleCache = false; + const envVarsObj = { + ...Deno.env.toObject(), + ...Object.fromEntries( + Object.entries(functionsConfig[functionName].env ?? {}).filter( + ([name, _]) => !name.startsWith("SUPABASE_"), + ), + ), + }; + if (SUPABASE_PUBLISHABLE_KEY) { + envVarsObj["SUPABASE_PUBLISHABLE_KEYS"] = JSON.stringify({ + default: SUPABASE_PUBLISHABLE_KEY, + }); + } + if (SUPABASE_SECRET_KEY) { + envVarsObj["SUPABASE_SECRET_KEYS"] = JSON.stringify({ + default: SUPABASE_SECRET_KEY, + }); + } + + const envVars = Object.entries(envVarsObj).filter( + ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), + ); + + const forceCreate = false; + const customModuleRoot = ""; // empty string to allow any local path + const cpuTimeSoftLimitMs = 1000; + const cpuTimeHardLimitMs = 2000; + + // NOTE(Nyannyacha): Decorator type has been set to tc39 by Lakshan's request, + // but in my opinion, we should probably expose this to customers at some + // point, as their migration process will not be easy. + // This need to be kept for Deno 1 compatibility. + const decoratorType = "tc39"; + + const absEntrypoint = posix.join(Deno.cwd(), functionsConfig[functionName].entrypointPath); + const maybeEntrypoint = posix.toFileUrl(absEntrypoint).href; + const usePackageJson = await shouldUsePackageJsonDiscovery(functionsConfig[functionName]); + + const staticPatterns = functionsConfig[functionName].staticFiles; + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + noNpm: !usePackageJson, + importMapPath: functionsConfig[functionName].importMapPath, + envVars, + forceCreate, + customModuleRoot, + cpuTimeSoftLimitMs, + cpuTimeHardLimitMs, + decoratorType, + maybeEntrypoint, + context: { + useReadSyncFileAPI: true, + }, + staticPatterns, + }); + + const userReq = prepareUserRequest(req); + return await worker.fetch(userReq); + } catch (e) { + console.error(e); + + for (const [denoError, sbCode] of DENO_SB_ERROR_MAP.entries()) { + if (denoError !== void 0 && e instanceof denoError) { + return getResponse( + { + code: SB_SPECIFIC_ERROR_TEXT[sbCode], + message: SB_SPECIFIC_ERROR_REASON[sbCode], + }, + sbCode, + ); + } + } + + return getResponse( + { + code: STATUS_TEXT[STATUS_CODE.InternalServerError], + message: "Request failed due to an internal server error", + trace: JSON.stringify(e.stack), + }, + STATUS_CODE.InternalServerError, + ); + } + }, + + onListen: () => { + try { + const functionsConfigString = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); + if (functionsConfigString) { + const MAX_FUNCTIONS_URL_EXAMPLES = 5; + const functionsConfig = JSON.parse(functionsConfigString) as Record; + const functionNames = Object.keys(functionsConfig); + const exampleFunctions = functionNames.slice(0, MAX_FUNCTIONS_URL_EXAMPLES); + const functionsUrls = exampleFunctions.map( + (fname) => ` - http://127.0.0.1:${HOST_PORT}/functions/v1/${fname}`, + ); + const functionsExamplesMessages = + functionNames.length > 0 + ? `\n${functionsUrls.join(`\n`)}${ + functionNames.length > MAX_FUNCTIONS_URL_EXAMPLES + ? `\n... and ${functionNames.length - MAX_FUNCTIONS_URL_EXAMPLES} more functions` + : "" + }` + : ""; + console.log( + `${GENERIC_FUNCTION_SERVE_MESSAGE}${functionsExamplesMessages}\nUsing ${Deno.version.deno}`, + ); + } + } catch { + console.log(`${GENERIC_FUNCTION_SERVE_MESSAGE}\nUsing ${Deno.version.deno}`); + } + }, + + onError: (e) => { + return getResponse( + { + code: STATUS_TEXT[STATUS_CODE.InternalServerError], + message: "Request failed due to an internal server error", + trace: JSON.stringify(e.stack), + }, + STATUS_CODE.InternalServerError, + ); + }, +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts new file mode 100644 index 0000000000..65014e051c --- /dev/null +++ b/apps/cli/src/shared/functions/serve.ts @@ -0,0 +1,1524 @@ +import { + ProjectConfigSchema, + findProjectPaths, + inferFunctionsManifest, + loadProjectConfig, + resolveProjectSubtree, + resolveProjectValue, + type ProjectConfig, + type ProjectEnvironment, + type ResolvedProjectValue, + type ResolvedFunctionConfig as ManifestFunctionConfig, +} from "@supabase/config"; +import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { + createHmac, + createPrivateKey, + sign as signJwtBytes, + type JsonWebKeyInput, +} from "node:crypto"; +import { readFileSync, watch } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { styleText } from "node:util"; +import { fileURLToPath } from "node:url"; +import { Cause, Duration, Effect, Layer, Option, Queue, Redacted, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; +import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; +import { Output } from "../output/output.service.ts"; +import { + FileWatcher, + FileWatcherError, + type FileWatchEvent, +} from "../runtime/file-watcher.service.ts"; +import { ProcessControl } from "../runtime/process-control.service.ts"; +import { + buildDockerBinds, + discoverFunctionSlugs, + dockerBindHostPath, + dockerProjectLabels, + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + normalizeProjectId, + resolveEdgeRuntimeVersion, + resolveFunctionConfigs, + runChildProcess, + toDockerPath, + type ResolvedDeployFunctionConfig, +} from "./deploy.ts"; +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const defaultProjectConfig = decodeProjectConfig({}); + +const dockerRuntimeServerPort = 8081; +const dockerRuntimeInspectorPort = 8083; +const defaultJwtExpiry = 1983812996; +const defaultSigningKey = { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", +} as const; +const functionsDirName = join("supabase", "functions"); +const fallbackEnvFilePath = join("supabase", "functions", ".env"); +const ignoredDirNames = new Set([ + ".git", + "node_modules", + ".vscode", + ".idea", + ".DS_Store", + "vendor", +]); +const dockerLogRetryDelay = Duration.millis(400); +const dockerLogDiagnosticTailLength = 4_096; +const remoteJwksTimeoutMs = 10_000; +const legacyDefaultEdgeRuntimeVersion = "v1.74.1"; +const defaultSupabaseEnv = "development"; +const clerkDomainPattern = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/; +const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; +const serveMainTypecheckPreamble = "declare const Deno: any;\ndeclare const EdgeRuntime: any;\n\n"; +const serveMainSourcePath = new URL("./serve.main.ts", import.meta.url); +let cachedLegacyFunctionsServeMainTemplate: string | undefined; +const watchIgnoreGlobs = [ + "**/.git/**", + "**/node_modules/**", + "**/.vscode/**", + "**/.idea/**", + "**/.DS_Store", + "**/vendor/**", + "**/*~", + "**/.*.swp", + "**/.*.swx", + "**/___*", + "**/*.tmp", + "**/.#*", +] as const; +const emptyStringArray: ReadonlyArray = []; + +export const FUNCTIONS_SERVE_INSPECT_MODES = ["run", "brk", "wait"] as const; + +export type FunctionsServeInspectMode = (typeof FUNCTIONS_SERVE_INSPECT_MODES)[number]; + +export interface FunctionsServeFlags { + readonly noVerifyJwt: Option.Option; + readonly envFile: Option.Option; + readonly importMap: Option.Option; + readonly inspect: boolean; + readonly inspectMode: Option.Option; + readonly inspectMain: boolean; + readonly all: boolean; +} + +export interface FunctionsServeDependencies { + readonly projectRoot: string; + readonly supabaseDir: string; + readonly flagCwd: string; + readonly platform: NodeJS.Platform; + readonly debug: boolean; + readonly networkId: Option.Option; + readonly projectIdOverride: Option.Option; +} + +interface PlainServeAuthConfig { + readonly signing_keys_path?: string; + readonly publishable_key?: string; + readonly secret_key?: string; + readonly jwt_secret?: string; + readonly anon_key?: string; + readonly service_role_key?: string; + readonly third_party: ProjectConfig["auth"]["third_party"]; +} + +interface PlainServeEdgeRuntimeConfig { + readonly policy: ProjectConfig["edge_runtime"]["policy"]; + readonly inspector_port: number; + readonly deno_version?: number; + readonly secrets: Readonly>; +} + +interface ServeResolvedConfig { + readonly projectId: string; + readonly apiPort: number; + readonly auth: PlainServeAuthConfig; + readonly edgeRuntime: PlainServeEdgeRuntimeConfig; + readonly configDeclaredFunctions: Readonly>; + readonly configFunctions: Readonly>; + readonly configPath?: string; +} + +interface ServeFunctionContainerConfig { + readonly verifyJWT: boolean; + readonly entrypointPath: string; + readonly importMapPath?: string; + readonly staticFiles?: ReadonlyArray; + readonly env?: Readonly>; +} + +interface WatchSpec { + readonly root: string; + readonly matchPaths?: ReadonlySet; +} + +interface StartedRuntime { + readonly containerId: string; + readonly cleanup: Effect.Effect; + readonly watchSpecs: ReadonlyArray; +} + +type SigningKeyJwk = JsonWebKeyInput["key"] & { + readonly kty: "EC" | "RSA"; + readonly kid?: string; + readonly use?: string; + readonly ext?: boolean; + readonly n?: string; + readonly e?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; + readonly alg?: "ES256" | "RS256"; + readonly key_ops?: ReadonlyArray; +}; + +declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; + +export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => + FileWatcher.of({ + watch: (root) => + Stream.callback, FileWatcherError>((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const watcher = watch(root, { recursive: true }, (_eventType, filename) => { + const pathname = + filename === null || filename === undefined || filename.length === 0 + ? root + : resolve(root, filename.toString()); + Queue.offerUnsafe(queue, [{ path: pathname, type: "update" }]); + }); + watcher.on("error", (cause) => { + Queue.failCauseUnsafe(queue, Cause.fail(new FileWatcherError({ path: root, cause }))); + }); + return watcher; + }), + (watcher) => + Effect.sync(() => { + watcher.close(); + }), + ), + ), + }), +); + +function getLegacyFunctionsServeMainTemplate(): string { + if (cachedLegacyFunctionsServeMainTemplate === undefined) { + const rawTemplateSource = + typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" + ? SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE + : readLegacyFunctionsServeMainTemplateFromDisk(); + + cachedLegacyFunctionsServeMainTemplate = rawTemplateSource.startsWith( + serveMainTypecheckPreamble, + ) + ? rawTemplateSource.slice(serveMainTypecheckPreamble.length) + : rawTemplateSource; + } + return cachedLegacyFunctionsServeMainTemplate; +} + +function readLegacyFunctionsServeMainTemplateFromDisk() { + const candidates = [ + fileURLToPath(serveMainSourcePath), + resolve(dirname(process.execPath), "..", "src", "shared", "functions", "serve.main.ts"), + ]; + + for (const candidate of candidates) { + try { + return readFileSync(candidate, "utf8"); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { + throw error; + } + } + } + + throw new Error("failed to load functions serve runtime template"); +} + +function reveal(value: string | Redacted.Redacted | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + return Redacted.isRedacted(value) ? Redacted.value(value) : value; +} + +function toPlainAuthConfig( + auth: ProjectConfig["auth"] | ResolvedProjectValue, +): PlainServeAuthConfig { + return { + signing_keys_path: reveal(auth.signing_keys_path), + publishable_key: reveal(auth.publishable_key), + secret_key: reveal(auth.secret_key), + jwt_secret: reveal(auth.jwt_secret), + anon_key: reveal(auth.anon_key), + service_role_key: reveal(auth.service_role_key), + third_party: { + firebase: { + enabled: auth.third_party.firebase.enabled, + project_id: reveal(auth.third_party.firebase.project_id), + }, + auth0: { + enabled: auth.third_party.auth0.enabled, + tenant: reveal(auth.third_party.auth0.tenant), + tenant_region: reveal(auth.third_party.auth0.tenant_region), + }, + aws_cognito: { + enabled: auth.third_party.aws_cognito.enabled, + user_pool_id: reveal(auth.third_party.aws_cognito.user_pool_id), + user_pool_region: reveal(auth.third_party.aws_cognito.user_pool_region), + }, + clerk: { + enabled: auth.third_party.clerk.enabled, + domain: reveal(auth.third_party.clerk.domain), + }, + workos: { + enabled: auth.third_party.workos.enabled, + issuer_url: reveal(auth.third_party.workos.issuer_url), + }, + }, + }; +} + +function toPlainEdgeRuntimeConfig( + edgeRuntime: ProjectConfig["edge_runtime"] | ResolvedProjectValue, +): PlainServeEdgeRuntimeConfig { + return { + policy: reveal(edgeRuntime.policy) ?? "", + inspector_port: edgeRuntime.inspector_port, + deno_version: edgeRuntime.deno_version, + secrets: Object.fromEntries( + Object.entries(edgeRuntime.secrets ?? {}).flatMap(([name, value]) => + Redacted.isRedacted(value) ? [[name.toUpperCase(), Redacted.value(value)] as const] : [], + ), + ), + }; +} + +function toPlainFunctionRecord( + functions: ProjectConfig["functions"] | ResolvedProjectValue, +): Readonly> { + return Object.fromEntries( + Object.entries(functions).map(([slug, config]) => [ + slug, + { + enabled: config.enabled, + verify_jwt: config.verify_jwt, + import_map: reveal(config.import_map) ?? "", + entrypoint: reveal(config.entrypoint) ?? "", + static_files: config.static_files.map((value) => reveal(value) ?? ""), + env: Object.fromEntries( + Object.entries(config.env).map(([name, value]) => [name, reveal(value) ?? ""]), + ), + } satisfies ManifestFunctionConfig, + ]), + ); +} + +function normalizeEnvPath(flagCwd: string, pathname: string) { + return isAbsolute(pathname) ? pathname : resolve(flagCwd, pathname); +} + +function encodeBase64Url(input: string) { + return Buffer.from(input).toString("base64url"); +} + +function toJsonWebKey(signingKey: SigningKeyJwk): JsonWebKeyInput["key"] { + return { + ...signingKey, + ...(signingKey.key_ops === undefined ? {} : { key_ops: [...signingKey.key_ops] }), + }; +} + +function jwtPayload(role: string, exp: number) { + return JSON.stringify({ iss: "supabase-demo", role, exp }); +} + +function generateSymmetricJwt(secret: string, role: string) { + const header = encodeBase64Url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = encodeBase64Url(jwtPayload(role, defaultJwtExpiry)); + const data = `${header}.${payload}`; + const signature = createHmac("sha256", secret).update(data).digest("base64url"); + return `${data}.${signature}`; +} + +function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string) { + const algorithm = signingKey.alg; + if (algorithm !== "ES256" && algorithm !== "RS256") { + throw new Error(`unsupported algorithm: ${String(algorithm)}`); + } + + const header = { + alg: algorithm, + typ: "JWT", + ...(signingKey.kid === undefined ? {} : { kid: signingKey.kid }), + }; + const payload = { + iss: "supabase-demo", + role, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 10, + }; + const encodedHeader = encodeBase64Url(JSON.stringify(header)); + const encodedPayload = encodeBase64Url(JSON.stringify(payload)); + const data = `${encodedHeader}.${encodedPayload}`; + const key = createPrivateKey({ + key: toJsonWebKey(signingKey), + format: "jwk", + }); + const signature = signJwtBytes("sha256", Buffer.from(data), { + key, + ...(algorithm === "ES256" ? { dsaEncoding: "ieee-p1363" as const } : {}), + }).toString("base64url"); + return `${data}.${signature}`; +} + +async function readSigningKeys(pathname: string): Promise> { + const decoded = JSON.parse(await readFile(pathname, "utf8")); + if (!Array.isArray(decoded)) { + throw new Error("expected a JSON array"); + } + return decoded as ReadonlyArray; +} + +function toPublicSigningKey(signingKey: SigningKeyJwk): SigningKeyJwk { + if (signingKey.kty === "RSA") { + return { + kty: "RSA", + kid: signingKey.kid, + use: signingKey.use, + key_ops: signingKey.key_ops?.filter((operation: string) => operation === "verify"), + alg: signingKey.alg, + ext: signingKey.ext, + n: signingKey.n, + e: signingKey.e, + }; + } + + return { + kty: "EC", + kid: signingKey.kid, + use: signingKey.use, + key_ops: signingKey.key_ops?.filter((operation: string) => operation === "verify"), + alg: signingKey.alg, + ext: signingKey.ext, + crv: signingKey.crv, + x: signingKey.x, + y: signingKey.y, + }; +} + +function enabledThirdPartyIssuer(thirdParty: PlainServeAuthConfig["third_party"]) { + const enabledProviders = [ + thirdParty.firebase.enabled ? "firebase" : undefined, + thirdParty.auth0.enabled ? "auth0" : undefined, + thirdParty.aws_cognito.enabled ? "aws_cognito" : undefined, + thirdParty.clerk.enabled ? "clerk" : undefined, + thirdParty.workos.enabled ? "workos" : undefined, + ].filter((value): value is NonNullable => value !== undefined); + + if (enabledProviders.length > 1) { + throw new Error( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + } + + if (thirdParty.firebase.enabled) { + if ((thirdParty.firebase.project_id ?? "").length === 0) { + throw new Error( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + } + return `https://securetoken.google.com/${thirdParty.firebase.project_id}`; + } + + if (thirdParty.auth0.enabled) { + if ((thirdParty.auth0.tenant ?? "").length === 0) { + throw new Error("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); + } + return thirdParty.auth0.tenant_region + ? `https://${thirdParty.auth0.tenant}.${thirdParty.auth0.tenant_region}.auth0.com` + : `https://${thirdParty.auth0.tenant}.auth0.com`; + } + + if (thirdParty.aws_cognito.enabled) { + if ((thirdParty.aws_cognito.user_pool_id ?? "").length === 0) { + throw new Error( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + } + if ((thirdParty.aws_cognito.user_pool_region ?? "").length === 0) { + throw new Error( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + } + return `https://cognito-idp.${thirdParty.aws_cognito.user_pool_region}.amazonaws.com/${thirdParty.aws_cognito.user_pool_id}`; + } + + if (thirdParty.clerk.enabled) { + const domain = thirdParty.clerk.domain; + if (domain === undefined || domain.length === 0) { + throw new Error("Invalid config: auth.third_party.clerk is enabled but without a domain."); + } + if (!clerkDomainPattern.test(domain)) { + throw new Error( + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + ); + } + return `https://${domain}`; + } + + if (thirdParty.workos.enabled) { + if ((thirdParty.workos.issuer_url ?? "").length === 0) { + throw new Error( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + } + return thirdParty.workos.issuer_url; + } + + return undefined; +} + +async function resolveRemoteJwks(issuerUrl: string): Promise> { + const discoveryResponse = await fetch(`${issuerUrl}/.well-known/openid-configuration`, { + signal: AbortSignal.timeout(remoteJwksTimeoutMs), + }); + if (!discoveryResponse.ok) { + throw new Error(`Failed to fetch ${issuerUrl}/.well-known/openid-configuration`); + } + + const discovery = (await discoveryResponse.json()) as { jwks_uri?: string }; + if (typeof discovery.jwks_uri !== "string" || discovery.jwks_uri.length === 0) { + throw new Error( + `auth.third_party: OIDC configuration at URL "${issuerUrl}/.well-known/openid-configuration" does not expose a jwks_uri property`, + ); + } + + const jwksResponse = await fetch(discovery.jwks_uri, { + signal: AbortSignal.timeout(remoteJwksTimeoutMs), + }); + if (!jwksResponse.ok) { + throw new Error(`Failed to fetch ${discovery.jwks_uri}`); + } + + const jwks = (await jwksResponse.json()) as { keys?: ReadonlyArray }; + if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) { + throw new Error( + `auth.third_party: JWKS at URL "${discovery.jwks_uri}" as discovered from "${issuerUrl}/.well-known/openid-configuration" does not contain any JWK keys`, + ); + } + + return jwks.keys; +} + +const resolveAuthArtifacts = Effect.fnUntraced(function* ( + auth: PlainServeAuthConfig, + configPath: string | undefined, +) { + const signingKeysPath = + auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 + ? "" + : isAbsolute(auth.signing_keys_path) + ? auth.signing_keys_path + : resolve( + dirname(configPath ?? join(process.cwd(), "supabase", "config.toml")), + auth.signing_keys_path, + ); + + const signingKeys = yield* Effect.tryPromise({ + try: async () => (signingKeysPath.length === 0 ? [] : await readSigningKeys(signingKeysPath)), + catch: (cause) => { + if (cause instanceof SyntaxError) { + return new Error(`failed to decode signing keys: ${cause.message}`); + } + return new Error( + `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + }, + }); + + const jwtSecret = + auth.jwt_secret === undefined || auth.jwt_secret.length === 0 + ? defaultJwtSecret + : auth.jwt_secret; + if (jwtSecret.length < 16) { + return yield* Effect.fail( + new Error("Invalid config for auth.jwt_secret. Must be at least 16 characters"), + ); + } + + const anonKey = + auth.anon_key === undefined || auth.anon_key.length === 0 + ? signingKeys.length > 0 + ? generateAsymmetricJwt(signingKeys[0]!, "anon") + : generateSymmetricJwt(jwtSecret, "anon") + : auth.anon_key; + const serviceRoleKey = + auth.service_role_key === undefined || auth.service_role_key.length === 0 + ? signingKeys.length > 0 + ? generateAsymmetricJwt(signingKeys[0]!, "service_role") + : generateSymmetricJwt(jwtSecret, "service_role") + : auth.service_role_key; + + const keys: unknown[] = []; + const issuerUrl = enabledThirdPartyIssuer(auth.third_party); + if (issuerUrl !== undefined) { + const remoteJwks = yield* Effect.tryPromise({ + try: () => resolveRemoteJwks(issuerUrl), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray))); + keys.push(...remoteJwks); + } + keys.push( + ...(signingKeys.length > 0 ? signingKeys.map(toPublicSigningKey) : [defaultSigningKey]), + ); + if (signingKeys.length === 0) { + keys.push({ + kty: "oct", + k: Buffer.from(jwtSecret).toString("base64url"), + }); + } + + return { + publishableKey: + auth.publishable_key === undefined || auth.publishable_key.length === 0 + ? defaultPublishableKey + : auth.publishable_key, + secretKey: + auth.secret_key === undefined || auth.secret_key.length === 0 + ? defaultSecretKey + : auth.secret_key, + jwtSecret, + anonKey, + serviceRoleKey, + jwks: JSON.stringify({ keys }), + }; +}); + +const resolveServeConfig = Effect.fnUntraced(function* ( + projectRoot: string, + projectIdOverride: Option.Option, +) { + const projectRef = Option.match(projectIdOverride, { + onNone: () => undefined, + onSome: (value) => { + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; + }, + }); + const loadedConfig = yield* loadProjectConfig( + projectRoot, + projectRef === undefined ? undefined : { projectRef }, + ); + const baseConfig = loadedConfig?.config ?? defaultProjectConfig; + const projectEnv = loadedConfig === null ? null : yield* loadServeProjectEnvironment(projectRoot); + + const auth = + projectEnv === null + ? toPlainAuthConfig(baseConfig.auth) + : toPlainAuthConfig(yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth")); + const edgeRuntime = + projectEnv === null + ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) + : toPlainEdgeRuntimeConfig( + yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime"), + ); + const apiPort = + projectEnv === null + ? baseConfig.api.port + : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api")).port; + const configDeclaredFunctions = + projectEnv === null + ? toPlainFunctionRecord(baseConfig.functions) + : toPlainFunctionRecord( + yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions"), + ); + const configForManifest: ProjectConfig = { + ...baseConfig, + functions: configDeclaredFunctions, + }; + const configFunctions = yield* inferFunctionsManifest({ + cwd: projectRoot, + config: configForManifest, + }); + const configProjectId = + projectEnv === null + ? (baseConfig.project_id ?? "") + : (reveal( + yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id"), + ) ?? ""); + const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); + const fallbackProjectId = basename(resolve(projectRoot)); + + return { + projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), + apiPort, + auth, + edgeRuntime, + configDeclaredFunctions, + configFunctions, + configPath: loadedConfig?.path, + }; +}); + +export function resolveFunctionsServeInspectMode( + flags: FunctionsServeFlags, +): FunctionsServeInspectMode | undefined { + if (flags.inspect && Option.isSome(flags.inspectMode)) { + throw new Error( + "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", + ); + } + if (Option.isSome(flags.inspectMode)) { + return flags.inspectMode.value; + } + return flags.inspect ? "brk" : undefined; +} + +export function buildFunctionsServeInspectArgs( + inspectMode: FunctionsServeInspectMode | undefined, + inspectMain: boolean, +) { + if (inspectMode === undefined) { + if (inspectMain) { + throw new Error( + "--inspect-main must be used together with one of these flags: [inspect inspect-mode]", + ); + } + return []; + } + + const flag = + inspectMode === "brk" ? "inspect-brk" : inspectMode === "wait" ? "inspect-wait" : "inspect"; + return [ + `--${flag}=0.0.0.0:${dockerRuntimeInspectorPort}`, + ...(inspectMain ? ["--inspect-main"] : []), + ]; +} + +const parseCustomEnvFile = Effect.fnUntraced(function* ( + envFileFlag: Option.Option, + projectRoot: string, + flagCwd: string, + configSecrets: Readonly>, +) { + const output = yield* Output; + const toEnvEntries = (parsed: Record) => { + const merged = new Map(Object.entries(configSecrets)); + for (const [name, value] of Object.entries(parsed)) { + merged.set(name, value); + } + return Effect.forEach([...merged], ([name, value]) => { + if (name.startsWith("SUPABASE_")) { + return output + .raw(`Env name cannot start with SUPABASE_, skipping: ${name}\n`, "stderr") + .pipe(Effect.as(emptyStringArray)); + } + return Effect.succeed([`${name}=${value}`] as const); + }).pipe(Effect.map((entries) => entries.flat())); + }; + + if (Option.isNone(envFileFlag)) { + const fallbackPath = join(projectRoot, fallbackEnvFilePath); + const exists = yield* Effect.tryPromise(() => + readFile(fallbackPath, "utf8").then( + (contents) => ({ contents, path: fallbackPath }), + (error) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw error; + }, + ), + ); + if (exists === undefined) { + return yield* toEnvEntries({}); + } + const parsed = yield* Effect.try({ + try: () => parseDotEnv(exists.contents), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + return yield* toEnvEntries(parsed); + } + + const envFilePath = normalizeEnvPath(flagCwd, envFileFlag.value); + const contents = yield* Effect.tryPromise({ + try: () => readFile(envFilePath, "utf8"), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + const parsed = yield* Effect.try({ + try: () => parseDotEnv(contents), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + return yield* toEnvEntries(parsed); +}); + +function toFunctionContainerConfig( + workdir: string, + config: ResolvedDeployFunctionConfig, +): ServeFunctionContainerConfig { + const toContainerPath = (pathname: string) => { + const resolvedPath = resolve(pathname); + const relativePath = relative(workdir, resolvedPath); + return relativePath.length === 0 ? basename(resolvedPath) : relativePath.replaceAll("\\", "/"); + }; + + return { + verifyJWT: config.verifyJwt, + entrypointPath: toContainerPath(config.entrypoint), + ...(config.importMap.length === 0 ? {} : { importMapPath: toContainerPath(config.importMap) }), + ...(config.staticFiles.length === 0 + ? {} + : { staticFiles: config.staticFiles.map((pathname) => toContainerPath(pathname)) }), + ...(Object.keys(config.env).length === 0 ? {} : { env: config.env }), + }; +} + +function splitEnvEntry(entry: string) { + const separatorIndex = entry.indexOf("="); + return separatorIndex === -1 + ? ([entry, ""] as const) + : ([entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)] as const); +} + +async function writeDockerEnvFile(env: Readonly>) { + const entries = Object.entries(env); + if (entries.length === 0) { + return undefined; + } + + const dir = await mkdtemp(join(tmpdir(), "supabase-functions-serve-env-")); + const path = join(dir, "docker.env"); + await writeFile( + path, + entries + .map(([name, value]) => `${name}=${value.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}`) + .join("\n"), + ); + + return { + path, + cleanup: () => rm(dir, { recursive: true, force: true }), + }; +} + +async function writeDockerMultilineEnvScript( + env: ReadonlyArray, + containerDir: string, +) { + if (env.length === 0) { + return undefined; + } + + const dir = await mkdtemp(join(tmpdir(), "supabase-functions-serve-multiline-env-")); + const scriptName = "multiline-env.sh"; + const path = join(dir, scriptName); + const envDir = join(containerDir, "values"); + const hostEnvDir = join(dir, "values"); + const script = env + .map(([name], index) => { + if (!shellVariableNamePattern.test(name)) { + throw new Error(`invalid multiline environment variable name for shell export: ${name}`); + } + const valueFile = `env-${index}`; + return `export ${name}="$(cat ${join(envDir, valueFile).replaceAll("\\", "/")})"`; + }) + .join("\n"); + await mkdir(hostEnvDir, { recursive: true }); + await Promise.all( + env.map(([_, value], index) => writeFile(join(hostEnvDir, `env-${index}`), value)), + ); + await writeFile(path, script); + + return { + bind: `${dir}:${containerDir}:ro`, + scriptPath: join(containerDir, scriptName).replaceAll("\\", "/"), + cleanup: () => rm(dir, { recursive: true, force: true }), + }; +} + +function partitionDockerEnvEntries(env: Readonly>) { + const singleLine: Record = {}; + const multiline: Array = []; + + for (const [name, value] of Object.entries(env)) { + if (value.includes("\n") || value.includes("\r")) { + multiline.push([name, value]); + continue; + } + singleLine[name] = value; + } + + return { singleLine, multiline } as const; +} + +function validateDockerMultilineEnvNames(env: ReadonlyArray) { + for (const [name] of env) { + if (!shellVariableNamePattern.test(name)) { + throw new Error(`invalid multiline environment variable name for shell export: ${name}`); + } + } +} + +function loadDefaultEnvFilenames(env: string) { + return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; +} + +function sanitizeDotEnvParseError(path: string, cause: unknown) { + if (!(cause instanceof Error)) { + return new Error(`failed to parse environment file: ${path}`); + } + const message = cause.message; + if (message.startsWith('unexpected character "')) { + const prefix = 'unexpected character "'; + const start = message.indexOf(prefix); + if (start !== -1) { + const charStart = start + prefix.length; + const charEnd = message.indexOf('"', charStart); + if (charEnd !== -1) { + const char = message.slice(charStart, charEnd); + return new Error( + `failed to parse environment file: ${path} (unexpected character '${char}' in variable name)`, + ); + } + } + return new Error( + `failed to parse environment file: ${path} (unexpected character in variable name)`, + ); + } + if (message.startsWith("unterminated quoted value")) { + return new Error(`failed to parse environment file: ${path} (unterminated quoted value)`); + } + if (message.includes("\n")) { + return new Error(`failed to parse environment file: ${path} (syntax error)`); + } + return new Error(`failed to load ${path}: ${message}`); +} + +function ambientProjectEnv() { + return Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => + value === undefined ? [] : [[key, value]], + ), + ); +} + +const loadServeProjectEnvironment = Effect.fnUntraced(function* (projectRoot: string) { + const paths = yield* findProjectPaths(projectRoot); + if (paths === null) { + return null; + } + + const values: Record = ambientProjectEnv(); + const sources: Record = Object.fromEntries( + Object.keys(values).map((key) => [key, "ambient"]), + ); + const loadedPaths: string[] = []; + const env = process.env["SUPABASE_ENV"] || defaultSupabaseEnv; + + for (const dir of [paths.supabaseDir, paths.projectRoot]) { + for (const filename of loadDefaultEnvFilenames(env)) { + const envPath = join(dir, filename); + const contents = yield* Effect.tryPromise(() => + readFile(envPath, "utf8").then( + (value) => value, + (error) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw error; + }, + ), + ).pipe( + Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), + ); + if (contents === undefined) { + continue; + } + loadedPaths.push(envPath); + const parsed = yield* Effect.try({ + try: () => parseDotEnv(contents), + catch: (cause) => sanitizeDotEnvParseError(envPath, cause), + }); + for (const [key, value] of Object.entries(parsed)) { + if (values[key] !== undefined) { + continue; + } + values[key] = value; + sources[key] = filename.includes(".local") ? ".env.local" : ".env"; + } + } + } + + return { paths, values, loadedPaths, sources } satisfies ProjectEnvironment; +}); + +async function buildWatchSpecs(binds: ReadonlyArray): Promise> { + const specs = new Map(); + + for (const bind of binds) { + const hostPath = dockerBindHostPath(bind); + if (!isAbsolute(hostPath)) { + continue; + } + + try { + const info = await stat(hostPath); + if (info.isDirectory()) { + specs.set(hostPath, { root: hostPath }); + } else { + const root = dirname(hostPath); + const existing = specs.get(root); + if (existing !== undefined && existing.matchPaths === undefined) { + continue; + } + const matchPaths = new Set(existing?.matchPaths ?? []); + matchPaths.add(hostPath); + specs.set(root, { root, matchPaths }); + } + } catch { + continue; + } + } + + return [...specs.values()]; +} + +function shouldIgnoreEvent(pathname: string) { + const normalized = pathname.replaceAll("\\", "/"); + const segments = normalized.split("/"); + if (segments.some((segment) => ignoredDirNames.has(segment))) { + return true; + } + const base = segments[segments.length - 1] ?? normalized; + return ( + base.endsWith("~") || + (base.startsWith(".") && base.endsWith(".swp")) || + (base.startsWith(".") && base.endsWith(".swx")) || + base.startsWith("___") || + base.endsWith(".tmp") || + base.startsWith(".#") + ); +} + +function eventMatchesSpec(spec: WatchSpec, event: FileWatchEvent) { + if (shouldIgnoreEvent(event.path)) { + return false; + } + if (spec.matchPaths === undefined) { + return true; + } + return spec.matchPaths.has(event.path); +} + +const waitForRestartSignal = Effect.fnUntraced(function* (watchSpecs: ReadonlyArray) { + if (watchSpecs.length === 0) { + return yield* Effect.never; + } + + const fileWatcher = yield* FileWatcher; + const output = yield* Output; + + const stream = Stream.mergeAll( + watchSpecs.map((spec) => + fileWatcher.watch(spec.root, { ignore: watchIgnoreGlobs }).pipe( + Stream.map((events) => events.filter((event) => eventMatchesSpec(spec, event))), + Stream.filter((events) => events.length > 0), + ), + ), + { concurrency: "unbounded" }, + ).pipe( + Stream.tap((events) => + Effect.forEach(events, (event) => + output.raw(`File change detected: ${event.path} (${event.type})\n`, "stderr"), + ).pipe(Effect.asVoid), + ), + Stream.debounce(Duration.millis(500)), + ); + + const next = yield* Stream.runHead(stream); + return Option.match(next, { + onNone: () => Effect.never, + onSome: () => Effect.void, + }); +}); + +function forwardByteStream( + stream: Stream.Stream, + write: (text: string, stream: "stdout" | "stderr") => Effect.Effect, + streamName: "stdout" | "stderr", +) { + const decoder = new TextDecoder(); + return Stream.runForEach(stream, (chunk) => + write(decoder.decode(chunk, { stream: true }), streamName), + ).pipe(Effect.andThen(write(decoder.decode(), streamName))); +} + +function isRetriableDockerLogsError(stderr: string) { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("no such container") || + normalized.includes("no such object") || + normalized.includes("conflict") || + normalized.includes("can not get logs from container which is dead or marked for removal") + ); +} + +function appendDiagnosticTail(existing: string, text: string) { + const combined = existing + text; + return combined.length <= dockerLogDiagnosticTailLength + ? combined + : combined.slice(combined.length - dockerLogDiagnosticTailLength); +} + +const inspectContainerExitCode = Effect.fnUntraced(function* (containerId: string) { + const result = yield* runChildProcess( + "docker", + ["container", "inspect", "--format", "{{.State.ExitCode}}", containerId], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || "failed to inspect container"; + return yield* Effect.fail(new Error(detail)); + } + + const exitCode = Number.parseInt(result.stdout.trim(), 10); + if (Number.isNaN(exitCode)) { + return yield* Effect.fail( + new Error(`failed to parse container exit code: ${result.stdout.trim()}`), + ); + } + + return exitCode; +}); + +const streamContainerLogs = Effect.fnUntraced(function* (containerId: string) { + const output = yield* Output; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + for (;;) { + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["logs", "-f", "--timestamps", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }), + ); + + let stderrText = ""; + const [exitCode] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + forwardByteStream(child.stdout, (text, stream) => output.raw(text, stream), "stdout"), + forwardByteStream( + child.stderr, + (text, stream) => { + stderrText = appendDiagnosticTail(stderrText, text); + return output.raw(text, stream); + }, + "stderr", + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode === 0) { + const containerExitCode = yield* inspectContainerExitCode(containerId); + if (containerExitCode === 0) { + return yield* Effect.fail(new Error(`container exited gracefully: ${containerId}`)); + } + if (containerExitCode === 137) { + yield* Effect.sleep(dockerLogRetryDelay); + continue; + } + return yield* Effect.fail(new Error(`error running container: exit ${containerExitCode}`)); + } + + const trimmedStderr = stderrText.trim(); + if (!isRetriableDockerLogsError(trimmedStderr)) { + return yield* Effect.fail( + new Error(trimmedStderr.length > 0 ? trimmedStderr : `docker logs exited with ${exitCode}`), + ); + } + + yield* Effect.sleep(dockerLogRetryDelay); + } +}); + +const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { + const dbId = localDockerId("db", projectId); + const result = yield* runChildProcess("docker", ["container", "inspect", dbId], { + stdout: "ignore", + stderr: "pipe", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + + if (result.exitCode === 0) { + return; + } + + if (result.stderr.includes("No such container") || result.stderr.includes("No such object")) { + return yield* Effect.fail(new Error("supabase start is not running.")); + } + + return yield* Effect.fail( + new Error( + result.stderr.trim().length > 0 + ? `failed to inspect service: ${result.stderr.trim()}` + : "failed to inspect service", + ), + ); +}); + +const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: string) { + yield* runChildProcess("docker", ["container", "rm", "-f", "-v", containerId], { + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.ignore); +}); + +const reloadKong = Effect.fnUntraced(function* (projectId: string) { + const output = yield* Output; + const kongId = localDockerId("kong", projectId); + const result = yield* runChildProcess("docker", ["exec", kongId, "kong", "reload"], { + stdout: "ignore", + stderr: "pipe", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + + if (result.exitCode !== 0) { + const suffix = result.stderr.trim().length > 0 ? ` ${result.stderr.trim()}` : ""; + yield* output.raw(`Warning: failed to reload Kong:${suffix}\n`, "stderr"); + } +}); + +const writeStoppedServingMessage = Effect.fnUntraced(function* () { + const output = yield* Output; + yield* output.raw(`Stopped serving ${styleText("bold", functionsDirName)}\n`, "stdout"); +}); + +function buildServeEntrypointScript( + template: string, + command: ReadonlyArray, + multilineEnvScriptPath?: string, +) { + return `cat <<'EOF' > /root/index.ts +${template} +EOF +${multilineEnvScriptPath === undefined ? "" : `. ${multilineEnvScriptPath}\n`}${command.join(" ")} +`; +} + +function edgeRuntimeImageTag(version: string) { + return version.startsWith("v") ? version : `v${version}`; +} + +const resolveServeFunctionConfigs = Effect.fnUntraced(function* ( + projectRoot: string, + supabaseDir: string, + config: ServeResolvedConfig, + importMapOverride: Option.Option, + noVerifyJwtOverride: Option.Option, + flagCwd: string, +) { + const slugs = yield* discoverFunctionSlugs(projectRoot, config.configDeclaredFunctions); + return yield* resolveFunctionConfigs({ + slugs, + cwd: flagCwd, + projectRoot, + supabaseDir, + configFunctions: config.configFunctions, + configDeclaredFunctions: config.configDeclaredFunctions, + importMapOverride, + noVerifyJwtOverride, + }); +}); + +const startEdgeRuntime = Effect.fnUntraced(function* (input: { + readonly flags: FunctionsServeFlags; + readonly dependencies: FunctionsServeDependencies; + readonly debug: boolean; + readonly networkId: Option.Option; + readonly inspectMode: FunctionsServeInspectMode | undefined; +}) { + const output = yield* Output; + + if (!(yield* isDockerRunning())) { + return yield* Effect.fail( + new Error( + "failed to run docker. Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", + ), + ); + } + + const resolved = yield* resolveServeConfig( + input.dependencies.projectRoot, + input.dependencies.projectIdOverride, + ); + const projectId = resolved.projectId; + const containerId = localDockerId("edge_runtime", projectId); + let ownsRuntime = false; + return yield* Effect.gen(function* () { + const networkMode = Option.getOrElse(input.networkId, () => + localDockerId("network", projectId), + ); + const authArtifacts = yield* resolveAuthArtifacts(resolved.auth, resolved.configPath); + const edgeRuntimeVersionOverride = yield* Effect.tryPromise(() => + readFile(join(input.dependencies.supabaseDir, ".temp", "edge-runtime-version"), "utf8"), + ).pipe( + Effect.map((value) => value.trim()), + Effect.catch(() => Effect.succeed("")), + Effect.map((value) => value || legacyDefaultEdgeRuntimeVersion), + ); + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + resolved.edgeRuntime.deno_version, + edgeRuntimeVersionOverride, + ); + + yield* assertLocalDbRunning(projectId); + yield* bestEffortRemoveContainer(containerId); + ownsRuntime = true; + + const functionConfigs = yield* resolveServeFunctionConfigs( + input.dependencies.projectRoot, + input.dependencies.supabaseDir, + resolved, + input.flags.importMap, + input.flags.noVerifyJwt, + input.dependencies.flagCwd, + ); + + const functionsDir = join(input.dependencies.projectRoot, functionsDirName); + const functionBinds = new Set(); + const functionsConfig: Record = {}; + + for (const config of functionConfigs) { + if (!config.enabled) { + yield* output.raw(`Skipped serving Function: ${config.slug}\n`, "stderr"); + continue; + } + + const bindWarnings: string[] = []; + for (const bind of yield* Effect.promise(() => + buildDockerBinds(projectId, functionsDir, functionsDir, config, { + additionalModuleRoots: [input.dependencies.flagCwd], + skipMissingImportMapTargets: true, + onWarning: async (message) => { + bindWarnings.push(message); + }, + }), + )) { + functionBinds.add(bind); + } + const missingSourceWarning = bindWarnings.find((warning) => + warning.includes("failed to read file:"), + ); + if (missingSourceWarning !== undefined) { + return yield* Effect.fail( + new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + ); + } + functionsConfig[config.slug] = toFunctionContainerConfig( + input.dependencies.projectRoot, + config, + ); + } + + const binds = new Set(functionBinds); + + yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNetwork(networkMode, projectId); + + const env = [ + ...(yield* parseCustomEnvFile( + input.flags.envFile, + input.dependencies.projectRoot, + input.dependencies.flagCwd, + resolved.edgeRuntime.secrets, + )), + "SUPABASE_URL=http://kong:8000", + `SUPABASE_ANON_KEY=${authArtifacts.anonKey}`, + `SUPABASE_SERVICE_ROLE_KEY=${authArtifacts.serviceRoleKey}`, + "SUPABASE_DB_URL=postgresql://postgres:postgres@db:5432/postgres", + `SUPABASE_INTERNAL_PUBLISHABLE_KEY=${authArtifacts.publishableKey}`, + `SUPABASE_INTERNAL_SECRET_KEY=${authArtifacts.secretKey}`, + `SUPABASE_INTERNAL_JWT_SECRET=${authArtifacts.jwtSecret}`, + `SUPABASE_JWKS=${authArtifacts.jwks}`, + `SUPABASE_INTERNAL_HOST_PORT=${resolved.apiPort}`, + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${JSON.stringify(functionsConfig)}`, + ...(input.debug ? ["SUPABASE_INTERNAL_DEBUG=true"] : []), + ]; + if (input.inspectMode !== undefined) { + env.push("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0"); + } + const dockerEnv = Object.fromEntries(env.map(splitEnvEntry)); + const { singleLine: singleLineDockerEnv, multiline: multilineDockerEnv } = + partitionDockerEnvEntries(dockerEnv); + yield* Effect.try({ + try: () => validateDockerMultilineEnvNames(multilineDockerEnv), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + const dockerEnvFile = yield* Effect.tryPromise(() => writeDockerEnvFile(singleLineDockerEnv)); + const multilineEnvDir = "/root/.supabase/multiline-env"; + const dockerMultilineEnvScript = yield* Effect.tryPromise(() => + writeDockerMultilineEnvScript(multilineDockerEnv, multilineEnvDir), + ).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))); + + const labels = dockerProjectLabels(projectId); + const runtimeCommand = [ + "edge-runtime", + "start", + "--main-service=/root", + `--port=${dockerRuntimeServerPort}`, + `--policy=${resolved.edgeRuntime.policy}`, + ...buildFunctionsServeInspectArgs(input.inspectMode, input.flags.inspectMain), + ...(input.debug ? ["--verbose"] : []), + ]; + const command = [ + "run", + "-d", + "--name", + containerId, + "--network", + networkMode, + "--network-alias", + "edge_runtime", + "--workdir", + toDockerPath(input.dependencies.projectRoot), + "--ulimit", + "nofile=65536:65536", + "--label", + `com.supabase.cli.project=${labels["com.supabase.cli.project"]}`, + "--label", + `com.docker.compose.project=${labels["com.docker.compose.project"]}`, + ...([...binds] as ReadonlyArray).flatMap((bind) => ["-v", bind]), + ...(dockerMultilineEnvScript === undefined ? [] : ["-v", dockerMultilineEnvScript.bind]), + ...(dockerEnvFile === undefined ? [] : ["--env-file", dockerEnvFile.path]), + ...(input.dependencies.platform === "linux" + ? ["--add-host", "host.docker.internal:host-gateway"] + : []), + ...(input.inspectMode === undefined + ? [] + : ["-p", `${resolved.edgeRuntime.inspector_port}:${dockerRuntimeInspectorPort}`]), + "--entrypoint", + "sh", + legacyGetRegistryImageUrl(`supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`), + "-c", + buildServeEntrypointScript( + getLegacyFunctionsServeMainTemplate(), + runtimeCommand, + dockerMultilineEnvScript?.scriptPath, + ), + ]; + + const cleanupRuntimeArtifacts = + dockerEnvFile === undefined + ? dockerMultilineEnvScript === undefined + ? Effect.void + : Effect.tryPromise(() => dockerMultilineEnvScript.cleanup()).pipe(Effect.orDie) + : Effect.tryPromise(() => dockerEnvFile.cleanup()).pipe( + Effect.andThen( + dockerMultilineEnvScript === undefined + ? Effect.void + : Effect.tryPromise(() => dockerMultilineEnvScript.cleanup()).pipe(Effect.orDie), + ), + Effect.orDie, + ); + + yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); + const result = yield* runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + }).pipe(Effect.onInterrupt(() => cleanupRuntimeArtifacts)); + if (result.exitCode !== 0) { + yield* cleanupRuntimeArtifacts; + const message = + result.stderr.trim() || result.stdout.trim() || "failed to start edge runtime"; + return yield* Effect.fail(new Error(message)); + } + + yield* reloadKong(projectId); + + return { + containerId, + cleanup: cleanupRuntimeArtifacts, + watchSpecs: yield* Effect.promise(() => buildWatchSpecs([...functionBinds])), + } satisfies StartedRuntime; + }).pipe( + Effect.onInterrupt(() => (ownsRuntime ? bestEffortRemoveContainer(containerId) : Effect.void)), + ); +}); + +export const serveFunctions = Effect.fn("functions.serve")(function* ( + flags: FunctionsServeFlags, + dependencies: FunctionsServeDependencies, +) { + const processControl = yield* ProcessControl; + const inspectMode = yield* Effect.try({ + try: () => { + const resolvedInspectMode = resolveFunctionsServeInspectMode(flags); + buildFunctionsServeInspectArgs(resolvedInspectMode, flags.inspectMain); + return resolvedInspectMode; + }, + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + + const loop = Effect.gen(function* () { + for (;;) { + const startOutcome = yield* Effect.raceFirst( + processControl.awaitSignal().pipe(Effect.as("shutdown" as const)), + startEdgeRuntime({ + flags, + dependencies, + debug: dependencies.debug, + networkId: dependencies.networkId, + inspectMode, + }).pipe(Effect.map((started) => ({ _tag: "started" as const, started }))), + ); + + if (startOutcome === "shutdown") { + yield* writeStoppedServingMessage(); + return; + } + + const started = startOutcome.started; + + const outcome = yield* Effect.raceFirst( + Effect.raceFirst( + processControl.awaitSignal().pipe(Effect.as("shutdown" as const)), + waitForRestartSignal(started.watchSpecs).pipe(Effect.as("restart" as const)), + ), + streamContainerLogs(started.containerId).pipe(Effect.as("logs" as const)), + ).pipe( + Effect.ensuring( + bestEffortRemoveContainer(started.containerId).pipe(Effect.ensuring(started.cleanup)), + ), + ); + + if (outcome === "shutdown") { + yield* writeStoppedServingMessage(); + return; + } + } + }); + + yield* Effect.scoped(loop); +});