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 ef5d96a9ae..6c217d3e1a 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..05c0e3af87 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 ?? "")}`, @@ -117,7 +122,7 @@ 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 $`bun 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 +193,7 @@ 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 $`bun 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..f46cc005fd --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -0,0 +1,1211 @@ +import { mkdir, 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(() => { + deployMockState.runCalls.push({ command, args: [...args], options }); + 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; +} + +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] : [])); +} + +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; + + 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 = extractFlagValues(dockerRun.args, "-e"); + 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("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("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 = extractFlagValues(dockerRun.args, "-e"); + 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 = extractFlagValues(dockerRun.args, "-e"); + 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 = extractFlagValues(dockerRun.args, "-e"); + 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 = extractFlagValues(dockerRun.args, "-e"); + 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 = extractFlagValues(dockerRun.args, "-e"); + 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("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/next/commands/functions/serve/serve.command.ts b/apps/cli/src/next/commands/functions/serve/serve.command.ts new file mode 100644 index 0000000000..a604a59f12 --- /dev/null +++ b/apps/cli/src/next/commands/functions/serve/serve.command.ts @@ -0,0 +1,57 @@ +import { Layer } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { provideProjectCommandRuntime } from "../../../config/project-runtime.layer.ts"; +import { + FUNCTIONS_SERVE_INSPECT_MODES, + serveFileWatcherLayer, + type FunctionsServeFlags as SharedFunctionsServeFlags, +} from "../../../../shared/functions/serve.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { functionsServe } from "./serve.handler.ts"; + +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."), + Flag.optional, + ), + importMap: Flag.string("import-map").pipe( + Flag.withDescription("Path to import map file."), + Flag.optional, + ), + inspect: Flag.boolean("inspect").pipe(Flag.withDescription("Alias of --inspect-mode brk.")), + inspectMode: Flag.choice("inspect-mode", FUNCTIONS_SERVE_INSPECT_MODES).pipe( + Flag.withDescription("Activate inspector capability for debugging."), + Flag.optional, + ), + inspectMain: Flag.boolean("inspect-main").pipe( + Flag.withDescription("Allow inspecting the main worker."), + ), + all: Flag.boolean("all").pipe( + Flag.withDescription("Serve all Functions."), + Flag.withDefault(true), + Flag.withHidden, + ), +} as const; + +export type FunctionsServeFlags = CliCommand.Command.Config.Infer & + SharedFunctionsServeFlags; + +const functionsServeRuntimeLayer = provideProjectCommandRuntime( + Layer.mergeAll(serveFileWatcherLayer, commandRuntimeLayer(["functions", "serve"])), +); + +const functionsServeCommand = Command.make("serve", config).pipe( + Command.withDescription("Serve all Functions locally."), + Command.withShortDescription("Serve all Functions locally"), + Command.withHandler((flags) => + functionsServe(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(functionsServeRuntimeLayer), +); diff --git a/apps/cli/src/next/commands/functions/serve/serve.handler.ts b/apps/cli/src/next/commands/functions/serve/serve.handler.ts new file mode 100644 index 0000000000..1aa62c2df3 --- /dev/null +++ b/apps/cli/src/next/commands/functions/serve/serve.handler.ts @@ -0,0 +1,21 @@ +import { Effect, Option } from "effect"; +import { CliConfig } from "../../../config/cli-config.service.ts"; +import { ProjectHome } from "../../../config/project-home.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { serveFunctions, type FunctionsServeFlags } from "../../../../shared/functions/serve.ts"; + +export const functionsServe = Effect.fn("functions.serve")(function* (flags: FunctionsServeFlags) { + const cliConfig = yield* CliConfig; + const projectHome = yield* ProjectHome; + const runtimeInfo = yield* RuntimeInfo; + + yield* serveFunctions(flags, { + projectRoot: projectHome.projectRoot, + supabaseDir: projectHome.supabaseDir, + flagCwd: runtimeInfo.cwd, + platform: runtimeInfo.platform, + debug: Option.isSome(cliConfig.debug), + networkId: Option.none(), + projectIdOverride: Option.none(), + }); +}); diff --git a/apps/cli/src/next/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/next/commands/functions/serve/serve.integration.test.ts new file mode 100644 index 0000000000..8c3d342a7a --- /dev/null +++ b/apps/cli/src/next/commands/functions/serve/serve.integration.test.ts @@ -0,0 +1,141 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { beforeEach, it, vi } from "vitest"; +import { CliConfig } from "../../../config/cli-config.service.ts"; +import { ProjectHome } from "../../../config/project-home.service.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; +import { FileWatcher } from "../../../../shared/runtime/file-watcher.service.ts"; +import type { FunctionsServeFlags } from "./serve.command.ts"; + +const serveMockState = vi.hoisted(() => ({ + calls: [] as Array<{ + flags: FunctionsServeFlags; + dependencies: { + projectRoot: string; + supabaseDir: string; + flagCwd: string; + platform: NodeJS.Platform; + debug: boolean; + networkId: Option.Option; + projectIdOverride: Option.Option; + }; + }>, + reset() { + this.calls = []; + }, +})); + +vi.mock("../../../../shared/functions/serve.ts", async () => { + const actual = await vi.importActual( + "../../../../shared/functions/serve.ts", + ); + const { Effect } = await import("effect"); + + return { + ...actual, + serveFunctions: ( + flags: FunctionsServeFlags, + dependencies: (typeof serveMockState.calls)[number]["dependencies"], + ) => + Effect.sync(() => { + serveMockState.calls.push({ flags, dependencies }); + }), + }; +}); + +const { functionsServe } = await import("./serve.handler.ts"); + +const BASE_FLAGS: FunctionsServeFlags = { + noVerifyJwt: Option.none(), + envFile: Option.none(), + importMap: Option.none(), + inspect: false, + inspectMode: Option.none(), + inspectMain: false, + all: true, +}; + +beforeEach(() => { + serveMockState.reset(); +}); + +describe("functions serve integration", () => { + it("forwards the project runtime context into the shared serve implementation", async () => { + const out = mockOutput(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + BunServices.layer, + Layer.succeed( + CliConfig, + CliConfig.of({ + apiUrl: "https://api.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + projectHost: "supabase.co", + telemetryPosthogHost: "https://us.i.posthog.com", + telemetryPosthogKey: Option.none(), + accessToken: Option.none(), + noKeyring: Option.none(), + supabaseHome: "/tmp/supabase-cli-test-home", + debug: Option.some("true"), + telemetryDebug: Option.none(), + telemetryDisabled: Option.none(), + doNotTrack: Option.none(), + }), + ), + Layer.succeed( + ProjectHome, + ProjectHome.of({ + projectRoot: "/project", + supabaseDir: "/project/supabase", + projectHomeDir: "/project/.supabase", + projectLinkPath: "/project/.supabase/project.json", + projectLocalVersionsPath: "/project/.supabase/local-versions.json", + ensureProjectHomeDir: Effect.void, + stackDir: (name) => `/project/.supabase/stacks/${name}`, + stackStatePath: (name) => `/project/.supabase/stacks/${name}/state.json`, + stackMetadataPath: (name) => `/project/.supabase/stacks/${name}/metadata.json`, + stackDataDir: (name) => `/project/.supabase/stacks/${name}/data`, + stackLogsDir: (name) => `/project/.supabase/stacks/${name}/logs`, + }), + ), + mockRuntimeInfo({ + cwd: "/flags", + homeDir: "/home/tester", + platform: "linux", + }), + out.layer, + processControl.layer, + Layer.succeed( + FileWatcher, + FileWatcher.of({ + watch: () => Stream.empty, + }), + ), + ); + + await Effect.scoped( + Effect.gen(function* () { + yield* functionsServe(BASE_FLAGS); + + expect(serveMockState.calls).toHaveLength(1); + expect(serveMockState.calls[0]).toEqual({ + flags: BASE_FLAGS, + dependencies: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + flagCwd: "/flags", + platform: "linux", + debug: true, + networkId: Option.none(), + projectIdOverride: Option.none(), + }, + }); + }).pipe(Effect.provide(layer)), + ).pipe(Effect.runPromise); + }); +}); 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/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1e7e0af0a6..e978b376c6 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -60,7 +60,7 @@ interface DeployFunctionsDependencies { ) => Effect.Effect; } -interface ResolvedDeployFunctionConfig { +export interface ResolvedDeployFunctionConfig { readonly slug: string; readonly enabled: boolean; readonly verifyJwt: boolean; @@ -227,28 +227,28 @@ 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)}`; } const dockerCliProjectLabel = "com.supabase.cli.project"; const dockerComposeProjectLabel = "com.docker.compose.project"; -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]:/, ""); } @@ -259,7 +259,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); @@ -1026,7 +1026,7 @@ function sanitizeDockerBinds( return result; } -async function buildDockerBinds( +export async function buildDockerBinds( projectId: string, functionsDir: string, outputDir: string, @@ -1105,7 +1105,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; } @@ -1140,7 +1143,7 @@ const ensureDockerNetwork = Effect.fnUntraced(function* (networkMode: string, pr } }); -const ensureDockerNamedVolume = Effect.fnUntraced(function* ( +export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( volumeName: string, projectId: string, ) { @@ -1182,7 +1185,7 @@ async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: stri } } -const runChildProcess = Effect.fnUntraced(function* ( +export const runChildProcess = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, opts: { @@ -1212,7 +1215,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", @@ -1632,7 +1635,7 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( ); }); -const discoverFunctionSlugs = Effect.fnUntraced(function* ( +export const discoverFunctionSlugs = Effect.fnUntraced(function* ( projectRoot: string, configDeclaredFunctions: Readonly>, ) { @@ -1682,7 +1685,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; @@ -1911,7 +1914,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..c575ed0a7a --- /dev/null +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -0,0 +1,383 @@ +// @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; +} + +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(); + 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..a256511877 --- /dev/null +++ b/apps/cli/src/shared/functions/serve.ts @@ -0,0 +1,1213 @@ +import { + ProjectConfigSchema, + inferFunctionsManifest, + loadProjectConfig, + loadProjectEnvironment, + resolveProjectSubtree, + resolveProjectValue, + type ProjectConfig, + type ResolvedProjectValue, + type ResolvedFunctionConfig as ManifestFunctionConfig, +} from "@supabase/config"; +import { + DEFAULT_VERSIONS, + 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 { readFile, stat } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { styleText } from "node:util"; +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 remoteJwksTimeoutMs = 10_000; +const clerkDomainPattern = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/; +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 = []; + +declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; + +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; +} + +interface WatchSpec { + readonly root: string; + readonly matchPaths?: ReadonlySet; +} + +interface StartedRuntime { + readonly containerId: string; + 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; +}; + +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 + : readFileSync(serveMainSourcePath, "utf8"); + + cachedLegacyFunctionsServeMainTemplate = rawTemplateSource.startsWith( + serveMainTypecheckPreamble, + ) + ? rawTemplateSource.slice(serveMainTypecheckPreamble.length) + : rawTemplateSource; + } + return cachedLegacyFunctionsServeMainTemplate; +} + +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, 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* loadProjectEnvironment({ cwd: projectRoot, baseEnv: process.env }); + + 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(); + + return { + projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : basename(projectRoot)), + 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)) }), + }; +} + +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); + 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) { + return ( + stderr.includes("No such container") || + stderr.includes("No such object") || + stderr.includes("Conflict") + ); +} + +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", + }), + ); + + 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 += text; + return output.raw(text, stream); + }, + "stderr", + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode === 0) { + return yield* Effect.fail(new Error(`container exited gracefully: ${containerId}`)); + } + + 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) { + return `cat <<'EOF' > /root/index.ts && ${command.join(" ")} +${template} +EOF +`; +} + +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); + 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 || DEFAULT_VERSIONS["edge-runtime"]), + ); + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + resolved.edgeRuntime.deno_version, + edgeRuntimeVersionOverride, + ); + + 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; + } + + for (const bind of yield* Effect.promise(() => + buildDockerBinds(projectId, functionsDir, functionsDir, config), + )) { + functionBinds.add(bind); + } + functionsConfig[config.slug] = toFunctionContainerConfig( + input.dependencies.projectRoot, + config, + ); + } + + const binds = new Set(functionBinds); + + yield* assertLocalDbRunning(projectId); + yield* bestEffortRemoveContainer(containerId); + 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"] : []), + ...(input.inspectMode === undefined ? [] : ["SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0"]), + ]; + + 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]), + ...env.flatMap((entry) => ["-e", entry]), + ...(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), + ]; + + yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); + const result = yield* runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + 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, + watchSpecs: yield* Effect.promise(() => buildWatchSpecs([...functionBinds])), + } satisfies StartedRuntime; + }).pipe(Effect.onInterrupt(() => bestEffortRemoveContainer(containerId))); +}); + +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))); + + if (outcome === "shutdown") { + yield* writeStoppedServingMessage(); + return; + } + } + }); + + yield* loop; +});