diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 3ef6c637cc..16ab72415f 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -14,6 +14,9 @@ "!apps/cli/src/shared/feedback/**", "!apps/cli/src/shared/functions/delete.errors.ts", "!apps/cli/src/shared/functions/delete.ts", + "!apps/cli/src/shared/functions/deploy.errors.ts", + "!apps/cli/src/shared/functions/deploy.errors.unit.test.ts", + "!apps/cli/src/shared/functions/deploy.ts", "!apps/cli/src/shared/functions/download.errors.ts", "!apps/cli/src/shared/functions/download.errors.unit.test.ts", "!apps/cli/src/shared/functions/download.ts", diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 600907e826..f06ac21d68 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1,10 +1,23 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path, Stdio } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Path, + Stdio, +} from "effect"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { + type ApiHandler, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -12,9 +25,11 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { Output } from "../../../shared/output/output.service.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { deployFunctions, @@ -24,9 +39,11 @@ import { toDockerPath } from "../../../shared/functions/functions-docker.ts"; import { functionsGoConfigCompat } from "../../../command-internal/functions-go-config.ts"; import { ConflictingFunctionDeployFlagsError, + FunctionImportMapSyntaxError, InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "../../../shared/functions/deploy.errors.ts"; +import { FunctionsApiStatusError } from "../../../shared/functions/functions-api.errors.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { functionsDeploy } from "./deploy.handler.ts"; @@ -501,6 +518,336 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + function apiDeployHarness(handler: ApiHandler) { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ handler }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + return { out, api, layer }; + } + + const deployedHelloWorld: ApiHandler = (request) => + Effect.succeed( + request.method === "GET" + ? jsonResponse(request, 200, []) + : jsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + }), + ); + + it.live("warns about an invalid static_files glob instead of failing the deploy", () => { + const { out, api, layer } = apiDeployHarness(deployedHelloWorld); + + return Effect.gen(function* () { + yield* writeCliConfig( + tempRoot.current, + 'project_id = "test-project"\n[functions.hello-world]\nstatic_files = ["./assets/[z-a].txt"]\n', + ); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy(baseFlags); + + expect(out.stderrText).toContain("WARN: Invalid regular expression"); + expect( + api.requests.some( + (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), + ), + ).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live("reports a malformed deno.json with the schema decoder's message", () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* fs.writeFileString( + path.join(tempRoot.current, "supabase", "functions", "hello-world", "deno.json"), + "{", + ); + + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(FunctionImportMapSyntaxError); + expect(error).toMatchObject({ name: "SyntaxError", message: "Expected a valid JSON string" }); + expect(api.requests.some((request) => request.method === "POST")).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live( + "reports a failed progress write while collecting upload files as a typed failure", + () => { + const out = mockOutput({ format: "text" }); + const brokenPipe = new Error("EPIPE: broken pipe, write"); + const api = mockCommandPlatformApi({ handler: deployedHelloWorld }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out: { + ...out, + layer: Layer.effect( + Output, + Effect.gen(function* () { + const output = yield* Output; + return { + ...output, + raw: (text: string, stream?: "stdout" | "stderr") => + text.startsWith("Uploading asset") + ? Effect.die(brokenPipe) + : output.raw(text, stream), + }; + }), + ).pipe(Layer.provide(out.layer)), + }, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBe(brokenPipe); + expect(api.requests.some((request) => request.method === "POST")).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.live("keeps collecting upload files after the deploy is interrupted", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ handler: deployedHelloWorld }); + const started = Deferred.makeUnsafe(); + const gate = Deferred.makeUnsafe(); + const continued = Deferred.makeUnsafe(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out: { + ...out, + layer: Layer.effect( + Output, + Effect.gen(function* () { + const output = yield* Output; + return { + ...output, + raw: (text: string, stream?: "stdout" | "stderr") => + text.startsWith("Uploading asset") + ? Deferred.succeed(started, undefined).pipe( + Effect.flatMap((first) => + first + ? Deferred.await(gate).pipe(Effect.andThen(output.raw(text, stream))) + : output + .raw(text, stream) + .pipe(Effect.tap(Deferred.succeed(continued, undefined))), + ), + ) + : output.raw(text, stream), + }; + }), + ).pipe(Layer.provide(out.layer)), + }, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const deploy = yield* Effect.forkChild(functionsDeploy(baseFlags)); + yield* Deferred.await(started); + yield* Fiber.interrupt(deploy); + yield* Deferred.succeed(gate, undefined); + yield* Deferred.await(continued); + + expect(out.stderrText).toContain( + "Uploading asset (hello-world): supabase/functions/hello-world/deno.json\n", + ); + expect(out.stderrText).toContain( + "Uploading asset (hello-world): supabase/functions/hello-world/index.ts\n", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live("reports a malformed deploy response with the schema decoder's message", () => { + const { layer } = apiDeployHarness((request) => + Effect.succeed( + request.method === "GET" + ? jsonResponse(request, 200, []) + : HttpClientResponse.fromWeb(request, new Response("{", { status: 201 })), + ), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(FunctionsApiStatusError); + expect(error).toMatchObject({ + status: 201, + decode: true, + message: "failed to read deploy response: Expected a valid JSON string", + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live("reports a malformed functions list with the schema decoder's message", () => { + const { layer } = apiDeployHarness((request) => + Effect.succeed( + request.method === "GET" && request.url.endsWith("/functions") + ? HttpClientResponse.fromWeb(request, new Response("{", { status: 200 })) + : jsonResponse(request, 200, []), + ), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(FunctionsApiStatusError); + expect(error).toMatchObject({ + status: 200, + decode: true, + message: "failed to read functions list: Expected a valid JSON string", + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live( + "reports a malformed Docker-bundled function response with the schema decoder's message", + () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.method === "GET" + ? jsonResponse(request, 200, []) + : HttpClientResponse.fromWeb(request, new Response("{", { status: 201 })), + ), + }); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(FunctionsApiStatusError); + expect(error).toMatchObject({ + status: 201, + decode: true, + message: "failed to read function response: Expected a valid JSON string", + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.live("reports a NUL byte in an imported path as the raw host TypeError", () => { + const { layer } = apiDeployHarness(deployedHelloWorld); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction( + tempRoot.current, + "hello-world", + 'import "./a\u0000b.ts";\nDeno.serve(() => new Response())\n', + ); + + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(TypeError); + expect(error).toMatchObject({ + code: "ERR_INVALID_ARG_VALUE", + message: expect.stringContaining("without null bytes"), + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live("reports a bundle output directory that cannot be created as an unknown error", () => { + const out = mockOutput({ format: "text" }); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api: mockCommandPlatformApi({ handler: deployedHelloWorld }), + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* fs.writeFileString(path.join(tempRoot.current, "supabase", ".temp"), ""); + + const error = yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Cause.UnknownError); + expect(error).toMatchObject({ + message: "An error occurred in Effect.tryPromise", + cause: expect.objectContaining({ code: "EEXIST" }), + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.live("deploys config-declared custom entrypoints when deploying all functions", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ @@ -1335,6 +1682,71 @@ describe("functions deploy", () => { }, ); + it.live("passes --verbose to the bundler container when DEBUG=true", () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* withConfigEnv( + { DEBUG: "true" }, + functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }), + ); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("--verbose"); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.live("keeps --verbose off the bundler container when DEBUG=false", () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* withConfigEnv( + { DEBUG: "false" }, + functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }), + ); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("run"); + expect(runCommand?.args).not.toContain("--verbose"); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.live( "uses SUPABASE_NETWORK_ID as the bundler's docker network when no --network-id flag is passed", () => { diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index 6b9198ca21..2926d86327 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -50,3 +50,9 @@ export class FunctionImportNotDirectoryError extends Data.TaggedError( return actionability.invalidConfig; } } + +export class FunctionDeployError extends Data.Error<{ readonly message: string }> {} + +export class FunctionImportMapSyntaxError extends Data.Error<{ readonly message: string }> { + override readonly name = "SyntaxError"; +} diff --git a/apps/cli/src/shared/functions/deploy.errors.unit.test.ts b/apps/cli/src/shared/functions/deploy.errors.unit.test.ts new file mode 100644 index 0000000000..78289dbad5 --- /dev/null +++ b/apps/cli/src/shared/functions/deploy.errors.unit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; +import { FunctionDeployError, FunctionImportMapSyntaxError } from "./deploy.errors.ts"; + +describe("deploy error telemetry identity", () => { + it("classifies a FunctionDeployError like a plain Error", () => { + const message = "failed to bundle function: exit 1"; + const result = classifyCliErrorActionability(new FunctionDeployError({ message })); + expect(result).toEqual(classifyCliErrorActionability(new Error(message))); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("error:unknown"); + }); + + it("classifies a FunctionImportMapSyntaxError like a native SyntaxError", () => { + const result = classifyCliErrorActionability( + new FunctionImportMapSyntaxError({ message: "Expected a valid JSON string" }), + ); + expect(result).toEqual( + classifyCliErrorActionability(new SyntaxError("JSON Parse error: Expected '}'")), + ); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("panic"); + expect(result.error_fingerprint).toBe("error:SyntaxError"); + }); +}); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index b86e82613d..74f845eed7 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1,6 +1,4 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; -import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { URL } from "node:url"; import { FunctionResponse_Output, @@ -12,7 +10,19 @@ import { inferFunctionsManifest, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config/effect"; -import { Duration, Effect, Option, Schema } from "effect"; +import { + Cause, + Clock, + Config, + Duration, + Effect, + Fiber, + FileSystem, + Option, + Path, + type PlatformError, + Schema, +} from "effect"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { promptYesNo } from "../../command-internal/prompt-yes-no.ts"; @@ -38,6 +48,8 @@ import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDeployFlagsError, FunctionDeployCancelledError, + FunctionDeployError, + FunctionImportMapSyntaxError, FunctionImportNotDirectoryError, InvalidFunctionDeploySlugError, NoFunctionsToDeployError, @@ -48,6 +60,7 @@ import { ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, + nativePlatformFailure, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -170,10 +183,11 @@ const defaultManifestFunctionConfig: ManifestFunctionConfig = { env: {}, }; -const decodeFunctionListResponseSchema = Schema.decodeUnknownSync( +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeFunctionListResponseSchema = Schema.decodeUnknownEffect( Schema.Array(FunctionResponse_Output), ); -const decodeDeployFunctionResponseSchema = Schema.decodeUnknownSync( +const decodeDeployFunctionResponseSchema = Schema.decodeUnknownEffect( operationDefinitions.v1DeployAFunction.outputSchema, ); @@ -187,17 +201,30 @@ function omitNullableFields(value: unknown, fields: ReadonlySet) { ); } -function decodeDeployFunctionResponse(value: unknown): DeployFunctionResponse { - return decodeDeployFunctionResponseSchema( - omitNullableFields(value, nullableOptionalDeployFunctionFields), +function decodeDeployFunctionResponse( + body: string, +): Effect.Effect { + return decodeJson(body).pipe( + Effect.flatMap((value) => + decodeDeployFunctionResponseSchema( + omitNullableFields(value, nullableOptionalDeployFunctionFields), + ), + ), ); } -function decodeFunctionListResponse(value: unknown): ReadonlyArray { - const normalized = Array.isArray(value) - ? value.map((item) => omitNullableFields(item, nullableOptionalFunctionListFields)) - : value; - return decodeFunctionListResponseSchema(normalized); +function decodeFunctionListResponse( + body: string, +): Effect.Effect, Schema.SchemaError> { + return decodeJson(body).pipe( + Effect.flatMap((value) => + decodeFunctionListResponseSchema( + Array.isArray(value) + ? value.map((item) => omitNullableFields(item, nullableOptionalFunctionListFields)) + : value, + ), + ), + ); } // Formats a raw response body for an unexpected-status error message: re-stringifies JSON so the @@ -266,8 +293,8 @@ function validateDeploySlug(slug: string): Effect.Effect 0 ? relativePath : basename(resolved)); +function toApiRelativePath(path: Path.Path, cwd: string, hostPath: string) { + const resolved = path.resolve(hostPath); + const relativePath = path.relative(cwd, resolved); + return toSlash(relativePath.length > 0 ? relativePath : path.basename(resolved)); } -function isContainedPath(root: string, candidate: string) { - const relativePath = relative(resolve(root), resolve(candidate)); +function isContainedPath(path: Path.Path, root: string, candidate: string) { + const relativePath = path.relative(path.resolve(root), path.resolve(candidate)); return ( relativePath === "" || - (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)) + (!path.isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`)) ); } -function isContainedInAnyPath(roots: ReadonlyArray, candidate: string) { - return roots.some((root) => isContainedPath(root, candidate)); +function isContainedInAnyPath(path: Path.Path, roots: ReadonlyArray, candidate: string) { + return roots.some((root) => isContainedPath(path, root, candidate)); +} + +const hostError = (pathname: string) => (error: PlatformError.PlatformError) => + nativePlatformFailure(error, pathname).cause; + +const unknownHostError = (pathname: string) => (error: PlatformError.PlatformError) => + new Cause.UnknownError(hostError(pathname)(error), "An error occurred in Effect.tryPromise"); + +const debugEnvEnabled = Config.option(Config.string("DEBUG")).pipe( + Effect.map(Option.exists((value) => value === "true")), + Effect.orDie, +); + +function hasErrorCode(error: Error, code: string) { + return "code" in error && error.code === code; } +const hostRealPath = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.realPath(pathname).pipe(Effect.mapError(hostError(pathname))); +}); + +const hostReadFile = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFile(pathname).pipe(Effect.mapError(hostError(pathname))); +}); + +const hostStat = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(pathname).pipe(Effect.mapError(hostError(pathname))); +}); + /** * Rejects any path containing a `..` segment before it's uploaded. A workdir that differs from * the git root can otherwise produce a multipart `File` name like @@ -390,24 +449,21 @@ function hasParentPathSegment(relativePath: string) { .some((segment) => segment === ".."); } -async function realpathIfExists(pathname: string) { - try { - return await realpath(resolve(pathname)); - } catch (error) { - // ENOTDIR (a path routed through a file) is as nonexistent as ENOENT here. - if ( - error instanceof Error && - "code" in error && - (error.code === "ENOENT" || error.code === "ENOTDIR") - ) { - return resolve(pathname); - } - throw error; - } -} +const realpathIfExists = Effect.fnUntraced(function* (pathname: string) { + const path = yield* Path.Path; + return yield* hostRealPath(path.resolve(pathname)).pipe( + Effect.catchIf( + // ENOTDIR (a path routed through a file) is as nonexistent as ENOENT here. + (error) => hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR"), + () => Effect.succeed(path.resolve(pathname)), + ), + ); +}); -const resolveFunctionsSourceRoot = (projectRoot: string) => - findGitRootPath(projectRoot).pipe(Effect.map(Option.getOrElse(() => resolve(projectRoot)))); +const resolveFunctionsSourceRoot = Effect.fnUntraced(function* (projectRoot: string) { + const path = yield* Path.Path; + return Option.getOrElse(yield* findGitRootPath(projectRoot), () => path.resolve(projectRoot)); +}); function humanSize(bytes: number) { if (bytes < 1000) { @@ -499,7 +555,7 @@ function stripJsonComments(contents: string): string { return out.join(""); } -function resolveImportTarget(jsonPath: string, target: string) { +function resolveImportTarget(path: Path.Path, jsonPath: string, target: string) { if (target.startsWith("/")) { return target; } @@ -513,7 +569,7 @@ function resolveImportTarget(jsonPath: string, target: string) { // Fall through. } - const resolved = toSlash(join(dirname(jsonPath), target)); + const resolved = toSlash(path.join(path.dirname(jsonPath), target)); const normalized = resolved.startsWith("/") || WINDOWS_ABSOLUTE_PATH.test(resolved) || @@ -540,23 +596,27 @@ function getObjectProperty(input: object, key: string): unknown { return Reflect.get(input, key); } -function readStringMap(input: unknown, fieldName: string): Record { +const readStringMap = Effect.fnUntraced(function* (input: unknown, fieldName: string) { if (input === undefined) { return {}; } if (typeof input !== "object" || input === null || Array.isArray(input)) { - throw new Error(`failed to parse import map: expected ${fieldName} to be an object`); + return yield* new FunctionDeployError({ + message: `failed to parse import map: expected ${fieldName} to be an object`, + }); } const values: Record = {}; for (const [key, value] of Object.entries(input)) { if (typeof value !== "string") { - throw new Error(`failed to parse import map: expected ${fieldName}.${key} to be a string`); + return yield* new FunctionDeployError({ + message: `failed to parse import map: expected ${fieldName}.${key} to be a string`, + }); } values[key] = value; } return values; -} +}); class ImportMapFile { readonly imports: Record; @@ -573,7 +633,7 @@ class ImportMapFile { this.importMapReference = importMapReference; } - static fromUnknown(input: unknown) { + static readonly fromUnknown = Effect.fnUntraced(function* (input: unknown) { const imports: Record = {}; const scopes: Record> = {}; let importMapReference = ""; @@ -584,22 +644,24 @@ class ImportMapFile { importMapReference = importMap; } - Object.assign(imports, readStringMap(getObjectProperty(input, "imports"), "imports")); + Object.assign(imports, yield* readStringMap(getObjectProperty(input, "imports"), "imports")); const rawScopes = getObjectProperty(input, "scopes"); if (rawScopes === undefined) { return new ImportMapFile(imports, scopes, importMapReference); } if (typeof rawScopes !== "object" || rawScopes === null || Array.isArray(rawScopes)) { - throw new Error("failed to parse import map: expected scopes to be an object"); + return yield* new FunctionDeployError({ + message: "failed to parse import map: expected scopes to be an object", + }); } for (const [scopeName, scopeValue] of Object.entries(rawScopes)) { - scopes[scopeName] = readStringMap(scopeValue, `scopes.${scopeName}`); + scopes[scopeName] = yield* readStringMap(scopeValue, `scopes.${scopeName}`); } } return new ImportMapFile(imports, scopes, importMapReference); - } + }); isReference() { return ( @@ -609,20 +671,20 @@ class ImportMapFile { ); } - resolve(jsonPath: string) { + resolve(path: Path.Path, jsonPath: string) { const imports = Object.fromEntries( Object.entries(this.imports).map(([key, value]) => [ key, - resolveImportTarget(jsonPath, value), + resolveImportTarget(path, jsonPath, value), ]), ); const scopes = Object.fromEntries( Object.entries(this.scopes).map(([scopeName, scopeValue]) => [ - resolveImportTarget(jsonPath, scopeName), + resolveImportTarget(path, jsonPath, scopeName), Object.fromEntries( Object.entries(scopeValue).map(([key, value]) => [ key, - resolveImportTarget(jsonPath, value), + resolveImportTarget(path, jsonPath, value), ]), ), ]), @@ -631,28 +693,36 @@ class ImportMapFile { } } -async function loadImportMapFile( +const parseImportMapContents = (contents: Uint8Array) => + decodeJson(stripJsonComments(new TextDecoder().decode(contents))).pipe( + Effect.mapError((error) => new FunctionImportMapSyntaxError({ message: error.message })), + ); + +const loadImportMapFile = Effect.fnUntraced(function* ( pathname: string, - onRead?: (pathname: string, contents: Uint8Array) => Promise, - seen = new Set(), -): Promise { - const resolvedPath = resolve(pathname); - if (seen.has(resolvedPath)) { - throw new Error(`cyclic import map reference: ${pathname}`); - } - seen.add(resolvedPath); - const contents = await readFile(pathname); - if (onRead !== undefined) { - await onRead(pathname, contents); - } - const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); - const importMap = ImportMapFile.fromUnknown(parsed).resolve(toSlash(pathname)); - if (isDenoConfigFile(pathname) && importMap.isReference()) { - const nestedPath = join(dirname(pathname), importMap.importMapReference); - return loadImportMapFile(nestedPath, onRead, seen); - } - return importMap; -} + onRead?: (pathname: string, contents: Uint8Array) => Effect.Effect, +) { + const path = yield* Path.Path; + const seen = new Set(); + let current = pathname; + for (;;) { + const resolvedPath = path.resolve(current); + if (seen.has(resolvedPath)) { + return yield* new FunctionDeployError({ message: `cyclic import map reference: ${current}` }); + } + seen.add(resolvedPath); + const contents = yield* hostReadFile(current); + if (onRead !== undefined) { + yield* onRead(current, contents); + } + const parsed = yield* parseImportMapContents(contents); + const importMap = (yield* ImportMapFile.fromUnknown(parsed)).resolve(path, toSlash(current)); + if (!(isDenoConfigFile(path, current) && importMap.isReference())) { + return importMap; + } + current = path.join(path.dirname(current), importMap.importMapReference); + } +}); function substituteImportMapValue( mappings: Readonly>, @@ -726,14 +796,20 @@ function resolveImportSpecifier( return { path: resolved, substituted }; } -async function walkImportPaths( +const walkImportPaths = Effect.fnUntraced(function* < + FileError, + FileServices, + WarningError, + WarningServices, +>( importMap: ImportMapFile, srcPath: string, allowedRoots: ReadonlyArray, displayRoot: string, - onFile: (pathname: string, contents: Uint8Array) => Promise, - onWarning: (message: string) => Promise, + onFile: (pathname: string, contents: Uint8Array) => Effect.Effect, + onWarning: (message: string) => Effect.Effect, ) { + const path = yield* Path.Path; const seen = new Set(); const queue = [toSlash(srcPath)]; @@ -744,34 +820,39 @@ async function walkImportPaths( } seen.add(current); - let contents: Uint8Array; - try { - const resolvedCurrent = await realpath(resolve(current)); - if (!isContainedInAnyPath(allowedRoots, resolvedCurrent)) { - await onWarning(`WARN: Skipping import path outside source root: ${current}\n`); - continue; + const contents = yield* Effect.gen(function* () { + const resolvedCurrent = yield* hostRealPath(path.resolve(current)); + if (!isContainedInAnyPath(path, allowedRoots, resolvedCurrent)) { + yield* onWarning(`WARN: Skipping import path outside source root: ${current}\n`); + return Option.none(); } - contents = await readFile(resolvedCurrent); - } catch (error) { - if (error instanceof Error && "code" in error) { - if (error.code === "ENOENT") { - const message = `failed to read file: open ${toApiRelativePath(displayRoot, current)}: no such file or directory`; - await onWarning(`WARN: ${message}\n`); - continue; - } + return Option.some(yield* hostReadFile(resolvedCurrent)); + }).pipe( + Effect.catchIf( + (error) => error instanceof Error && hasErrorCode(error, "ENOENT"), + () => { + const message = `failed to read file: open ${toApiRelativePath(path, displayRoot, current)}: no such file or directory`; + return onWarning(`WARN: ${message}\n`).pipe(Effect.as(Option.none())); + }, + ), + Effect.catchIf( // An ENOTDIR (import path routed through a file) gets a classified, user-facing message // instead of an unhandled raw Node error, so telemetry books it as user-fixable config. - if (error.code === "ENOTDIR") { - throw new FunctionImportNotDirectoryError({ - message: `failed to read file: open ${toApiRelativePath(displayRoot, current)}: not a directory`, - }); - } - } - throw error; + (error) => error instanceof Error && hasErrorCode(error, "ENOTDIR"), + () => + Effect.fail( + new FunctionImportNotDirectoryError({ + message: `failed to read file: open ${toApiRelativePath(path, displayRoot, current)}: not a directory`, + }), + ), + ), + ); + if (Option.isNone(contents)) { + continue; } - await onFile(current, contents); - const text = new TextDecoder().decode(contents); + yield* onFile(current, contents.value); + const text = new TextDecoder().decode(contents.value); importPathPattern.lastIndex = 0; for (const match of text.matchAll(importPathPattern)) { const raw = match[1] ?? match[2]; @@ -804,30 +885,30 @@ async function walkImportPaths( } if (!substituted && (modulePath.startsWith("./") || modulePath.startsWith("../"))) { - modulePath = toSlash(join(dirname(current), modulePath)); + modulePath = toSlash(path.join(path.dirname(current), modulePath)); } - const resolvedModule = resolve(modulePath); - const containmentPath = await realpathIfExists(resolvedModule); - if (!isContainedInAnyPath(allowedRoots, containmentPath)) { - await onWarning(`WARN: Skipping import path outside source root: ${modulePath}\n`); + const resolvedModule = path.resolve(modulePath); + const containmentPath = yield* realpathIfExists(resolvedModule); + if (!isContainedInAnyPath(path, allowedRoots, containmentPath)) { + yield* onWarning(`WARN: Skipping import path outside source root: ${modulePath}\n`); continue; } queue.push(toSlash(resolvedModule)); } } -} +}); function hasGlobMeta(pattern: string) { return pattern.includes("*") || pattern.includes("?") || pattern.includes("["); } -function defaultFunctionEntrypoint(functionsDir: string, slug: string) { - return join(functionsDir, slug, "index.ts"); +function defaultFunctionEntrypoint(path: Path.Path, functionsDir: string, slug: string) { + return path.join(functionsDir, slug, "index.ts"); } -function defaultFunctionImportMap(functionsDir: string, slug: string) { - return join(functionsDir, slug, "deno.json"); +function defaultFunctionImportMap(path: Path.Path, functionsDir: string, slug: string) { + return path.join(functionsDir, slug, "deno.json"); } function globToRegExp(pattern: string) { @@ -866,10 +947,10 @@ function globToRegExp(pattern: string) { return new RegExp(source); } -function globBaseDirectory(pattern: string) { +function globBaseDirectory(path: Path.Path, pattern: string) { const normalized = toSlash(pattern); if (!hasGlobMeta(normalized)) { - return dirname(normalized); + return path.dirname(normalized); } const parts = normalized.split("/"); const stableParts: string[] = []; @@ -885,269 +966,309 @@ function globBaseDirectory(pattern: string) { return stableParts.join("/"); } -async function listPathsRecursive(root: string): Promise> { - const resolvedRoot = resolve(root); - const entries = await readdir(resolvedRoot, { withFileTypes: true }); - const paths: string[] = []; - for (const entry of entries) { - const pathname = join(resolvedRoot, entry.name); - paths.push(pathname); - if (entry.isDirectory()) { - paths.push(...(await listPathsRecursive(pathname))); - } +const isNonSymlinkDirectory = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + const isDirectory = yield* fs.stat(pathname).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (!isDirectory) { + return false; } - return paths; -} + return !(yield* Effect.isSuccess(fs.readLink(pathname))); +}); -async function expandStaticPattern(pattern: string): Promise> { - if (!hasGlobMeta(pattern)) { - try { - await stat(pattern); - } catch { - throw new Error(`no files matched pattern: ${pattern}`); +const listPathsRecursive: ( + root: string, +) => Effect.Effect, Error, FileSystem.FileSystem | Path.Path> = + Effect.fnUntraced(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolvedRoot = path.resolve(root); + const entries = yield* fs + .readDirectory(resolvedRoot) + .pipe(Effect.mapError(hostError(resolvedRoot))); + const paths: string[] = []; + for (const entry of entries) { + const pathname = path.join(resolvedRoot, entry); + paths.push(pathname); + if (yield* isNonSymlinkDirectory(pathname)) { + paths.push(...(yield* listPathsRecursive(pathname))); + } } + return paths; + }); + +const expandStaticPattern = Effect.fnUntraced(function* (pattern: string) { + const path = yield* Path.Path; + if (!hasGlobMeta(pattern)) { + yield* hostStat(pattern).pipe( + Effect.mapError( + () => new FunctionDeployError({ message: `no files matched pattern: ${pattern}` }), + ), + ); return [pattern]; } - const baseDir = globBaseDirectory(pattern); - const matcher = globToRegExp(toSlash(resolve(pattern))); - let candidates: ReadonlyArray; - try { - candidates = await listPathsRecursive(baseDir); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - throw new Error(`no files matched pattern: ${pattern}`); - } - throw error; - } - const matches = candidates.filter((candidate) => matcher.test(toSlash(resolve(candidate)))); + const baseDir = globBaseDirectory(path, pattern); + const matcher = yield* Effect.try({ + try: () => globToRegExp(toSlash(path.resolve(pattern))), + catch: (cause) => + new FunctionDeployError({ message: cause instanceof Error ? cause.message : String(cause) }), + }); + const candidates = yield* listPathsRecursive(baseDir).pipe( + Effect.mapError((error) => + hasErrorCode(error, "ENOENT") + ? new FunctionDeployError({ message: `no files matched pattern: ${pattern}` }) + : error, + ), + ); + const matches = candidates.filter((candidate) => matcher.test(toSlash(path.resolve(candidate)))); if (matches.length === 0) { - throw new Error(`no files matched pattern: ${pattern}`); + return yield* new FunctionDeployError({ message: `no files matched pattern: ${pattern}` }); } return matches; -} +}); -async function forEachLocalImportMapTarget( +const forEachLocalImportMapTarget = Effect.fnUntraced(function* ( importMap: ImportMapFile, - onTarget: (pathname: string, kind: "import" | "scope") => Promise, + onTarget: (pathname: string, kind: "import" | "scope") => Effect.Effect, ) { for (const target of Object.values(importMap.imports)) { if (isRemoteImportTarget(target)) { continue; } - await onTarget(target, "import"); + yield* onTarget(target, "import"); } for (const scope of Object.values(importMap.scopes)) { for (const target of Object.values(scope)) { if (isRemoteImportTarget(target)) { continue; } - await onTarget(target, "scope"); + yield* onTarget(target, "scope"); } } -} +}); -async function walkLocalImportMapTargetImports( +const walkLocalImportMapTargetImports = Effect.fnUntraced(function* < + FileError, + FileServices, + WarningError, + WarningServices, +>( importMap: ImportMapFile, pathname: string, allowedRoots: ReadonlyArray, displayRoot: string, - onFile: (pathname: string, contents: Uint8Array) => Promise, - onWarning: (message: string) => Promise, + onFile: (pathname: string, contents: Uint8Array) => Effect.Effect, + onWarning: (message: string) => Effect.Effect, ) { - if ((await stat(pathname)).isDirectory()) { + if ((yield* hostStat(pathname)).type === "Directory") { return; } - await walkImportPaths(importMap, pathname, allowedRoots, displayRoot, onFile, onWarning); -} + yield* walkImportPaths(importMap, pathname, allowedRoots, displayRoot, onFile, onWarning); +}); -async function isFile(pathname: string): Promise { - try { - return (await stat(pathname)).isFile(); - } catch { - return false; - } -} +const isFile = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(pathname).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); +}); -async function resolveImportMapAllowedRoots(projectRoot: string, importMapPath: string) { - const realProjectRoot = await realpath(projectRoot); +const resolveImportMapAllowedRoots = Effect.fnUntraced(function* ( + projectRoot: string, + importMapPath: string, +) { + const path = yield* Path.Path; + const realProjectRoot = yield* hostRealPath(projectRoot); const allowedRoots = [realProjectRoot]; if (importMapPath.length === 0) { return allowedRoots; } - const realImportMapPath = await realpath(importMapPath); - if (!isContainedPath(realProjectRoot, realImportMapPath)) { - allowedRoots.push(dirname(realImportMapPath)); + const realImportMapPath = yield* hostRealPath(importMapPath); + if (!isContainedPath(path, realProjectRoot, realImportMapPath)) { + allowedRoots.push(path.dirname(realImportMapPath)); } - if (isDenoConfigFile(importMapPath)) { - const contents = await readFile(importMapPath); - const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); - const importMap = ImportMapFile.fromUnknown(parsed); + if (isDenoConfigFile(path, importMapPath)) { + const contents = yield* hostReadFile(importMapPath); + const parsed = yield* parseImportMapContents(contents); + const importMap = yield* ImportMapFile.fromUnknown(parsed); if (importMap.importMapReference.length > 0) { - const referencedImportMapPath = await realpath( - join(dirname(importMapPath), importMap.importMapReference), + const referencedImportMapPath = yield* hostRealPath( + path.join(path.dirname(importMapPath), importMap.importMapReference), ); - if (!isContainedPath(realProjectRoot, referencedImportMapPath)) { - allowedRoots.push(dirname(referencedImportMapPath)); + if (!isContainedPath(path, realProjectRoot, referencedImportMapPath)) { + allowedRoots.push(path.dirname(referencedImportMapPath)); } } } return allowedRoots; -} +}); -async function writeSourceDeployForm( +const collectSourceDeployFiles = Effect.fnUntraced(function* ( sourceRoot: string, workdir: string, config: ResolvedDeployFunctionConfig, metadata: SourceDeployMetadata, outputRaw: (text: string) => Effect.Effect, ) { - const form = new FormData(); - form.append("metadata", JSON.stringify(metadata)); - const realSourceRoot = await realpath(sourceRoot); - const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); + const path = yield* Path.Path; + const files: Array = []; + const realSourceRoot = yield* hostRealPath(sourceRoot); + const importMapAllowedRoots = yield* resolveImportMapAllowedRoots(sourceRoot, config.importMap); const uploadedAssets = new Set(); - const appendAsset = async (pathname: string, contents: Uint8Array, realPathname: string) => { + const appendAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + realPathname: string, + ) { if (uploadedAssets.has(realPathname)) { return; } uploadedAssets.add(realPathname); // Uploaded file names are anchored at the workdir, not at `sourceRoot` — see the note in // `deployViaApi`. - const relativePath = toApiRelativePath(workdir, pathname); + const relativePath = toApiRelativePath(path, workdir, pathname); if (hasParentPathSegment(relativePath)) { - throw new Error(`failed to read file: open ${relativePath}: invalid argument`); + return yield* new FunctionDeployError({ + message: `failed to read file: open ${relativePath}: invalid argument`, + }); } - await Effect.runPromise(outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`)); - form.append("file", new File([contents], relativePath)); - }; + yield* outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`); + files.push(new File([contents], relativePath)); + }); - const uploadAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedPath(realSourceRoot, realPathname)) { - throw new Error(`refusing to upload asset outside source root: ${pathname}`); + const uploadAsset = Effect.fnUntraced(function* (pathname: string, contents: Uint8Array) { + const realPathname = yield* hostRealPath(pathname); + if (!isContainedPath(path, realSourceRoot, realPathname)) { + return yield* new FunctionDeployError({ + message: `refusing to upload asset outside source root: ${pathname}`, + }); } - await appendAsset(pathname, contents, realPathname); - }; + yield* appendAsset(pathname, contents, realPathname); + }); - const uploadImportMapAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { - throw new Error(`refusing to upload import map outside allowed roots: ${pathname}`); + const uploadImportMapAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + ) { + const realPathname = yield* hostRealPath(pathname); + if (!isContainedInAnyPath(path, importMapAllowedRoots, realPathname)) { + return yield* new FunctionDeployError({ + message: `refusing to upload import map outside allowed roots: ${pathname}`, + }); } - await appendAsset(pathname, contents, realPathname); - }; + yield* appendAsset(pathname, contents, realPathname); + }); - const uploadImportMapTargetAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), - ); + const uploadImportMapTargetAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + ) { + const realPathname = yield* hostRealPath(pathname); + if (!isContainedInAnyPath(path, importMapAllowedRoots, realPathname)) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`); return; } - await appendAsset(pathname, contents, realPathname); - }; + yield* appendAsset(pathname, contents, realPathname); + }); - const uploadScopeTarget = async (pathname: string) => { - let resolvedPath: string; - let pathInfo: Awaited>; - try { - resolvedPath = await realpath(pathname); - pathInfo = await stat(pathname); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOTDIR") { - await Effect.runPromise( - outputRaw(`WARN: Skipping import map target that is not a directory: ${pathname}\n`), - ); - return; - } - throw error; + const uploadScopeTarget = Effect.fnUntraced(function* (pathname: string) { + const target = yield* hostRealPath(pathname).pipe( + Effect.flatMap((resolvedPath) => + hostStat(pathname).pipe(Effect.map((pathInfo) => Option.some({ resolvedPath, pathInfo }))), + ), + Effect.catchIf( + (error) => hasErrorCode(error, "ENOTDIR"), + () => + outputRaw(`WARN: Skipping import map target that is not a directory: ${pathname}\n`).pipe( + Effect.as(Option.none()), + ), + ), + ); + if (Option.isNone(target)) { + return; } - if (!isContainedInAnyPath(importMapAllowedRoots, resolvedPath)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), - ); + const { resolvedPath, pathInfo } = target.value; + if (!isContainedInAnyPath(path, importMapAllowedRoots, resolvedPath)) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`); return; } - if (!pathInfo.isDirectory()) { - await uploadImportMapTargetAsset(pathname, await readFile(pathname)); - await walkLocalImportMapTargetImports( + if (pathInfo.type !== "Directory") { + yield* uploadImportMapTargetAsset(pathname, yield* hostReadFile(pathname)); + yield* walkLocalImportMapTargetImports( importMap, pathname, importMapAllowedRoots, workdir, uploadImportMapTargetAsset, - async (message) => { - await Effect.runPromise(outputRaw(message)); - }, + outputRaw, ); return; } - const nestedPaths = await listPathsRecursive(pathname); + const nestedPaths = yield* listPathsRecursive(pathname); for (const nestedPath of nestedPaths) { - if ((await stat(nestedPath)).isDirectory()) { + if ((yield* hostStat(nestedPath)).type === "Directory") { continue; } - const resolvedNestedPath = await realpath(nestedPath); - if (!isContainedInAnyPath(importMapAllowedRoots, resolvedNestedPath)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${nestedPath}\n`), - ); + const resolvedNestedPath = yield* hostRealPath(nestedPath); + if (!isContainedInAnyPath(path, importMapAllowedRoots, resolvedNestedPath)) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${nestedPath}\n`); continue; } - await uploadImportMapTargetAsset(nestedPath, await readFile(nestedPath)); + yield* uploadImportMapTargetAsset(nestedPath, yield* hostReadFile(nestedPath)); } - }; + }); if (metadata.import_map_path !== undefined && metadata.import_map_path.length > 0) { - await loadImportMapFile(config.importMap, uploadImportMapAsset); + yield* loadImportMapFile(config.importMap, uploadImportMapAsset); } for (const pattern of config.staticFiles) { - let files: ReadonlyArray; - try { - files = await expandStaticPattern(pattern); - } catch (error) { - await Effect.runPromise( - outputRaw(`WARN: ${error instanceof Error ? error.message : String(error)}\n`), - ); + const matches = yield* expandStaticPattern(pattern).pipe( + Effect.map(Option.some), + Effect.catch((error) => + outputRaw(`WARN: ${error.message}\n`).pipe(Effect.as(Option.none>())), + ), + ); + if (Option.isNone(matches)) { continue; } - for (const pathname of files) { - if ((await stat(pathname)).isDirectory()) { - throw new Error(`file path is a directory: ${pathname}`); + for (const pathname of matches.value) { + if ((yield* hostStat(pathname)).type === "Directory") { + return yield* new FunctionDeployError({ message: `file path is a directory: ${pathname}` }); } - await uploadAsset(pathname, await readFile(pathname)); + yield* uploadAsset(pathname, yield* hostReadFile(pathname)); } } const importMap = metadata.import_map_path !== undefined && metadata.import_map_path.length > 0 - ? await loadImportMapFile(config.importMap) + ? yield* loadImportMapFile(config.importMap) : new ImportMapFile(); - await walkImportPaths( + yield* walkImportPaths( importMap, config.entrypoint, [realSourceRoot], workdir, uploadAsset, - async (message) => { - await Effect.runPromise(outputRaw(message)); - }, + outputRaw, ); - await forEachLocalImportMapTarget(importMap, uploadScopeTarget); + yield* forEachLocalImportMapTarget(importMap, uploadScopeTarget); - return form; -} + return files; +}); /** * Server-recorded metadata paths are anchored at the workdir, with forward slashes regardless of * platform — see the note in `deployViaApi`. */ function createSourceMetadata( + path: Path.Path, workdir: string, config: ResolvedDeployFunctionConfig, remote?: RemoteFunction, @@ -1156,41 +1277,47 @@ function createSourceMetadata( return { name: config.slug, ...(verifyJwt === undefined ? {} : { verify_jwt: verifyJwt }), - entrypoint_path: toApiRelativePath(workdir, config.entrypoint), + entrypoint_path: toApiRelativePath(path, workdir, config.entrypoint), import_map_path: - config.importMap.length > 0 ? toApiRelativePath(workdir, config.importMap) : "", - static_patterns: config.staticFiles.map((pathname) => toApiRelativePath(workdir, pathname)), + config.importMap.length > 0 ? toApiRelativePath(path, workdir, config.importMap) : "", + static_patterns: config.staticFiles.map((pathname) => + toApiRelativePath(path, workdir, pathname), + ), }; } function createBundledMetadata( + path: Path.Path, config: ResolvedDeployFunctionConfig, sha256: string, ): BundledDeployMetadata { return { name: config.slug, ...(config.verifyJwt === undefined ? {} : { verify_jwt: config.verifyJwt }), - entrypoint_path: toBundledFileUrl(config.entrypoint), + entrypoint_path: toBundledFileUrl(path, config.entrypoint), sha256, - ...(config.importMap.length > 0 ? { import_map_path: toBundledFileUrl(config.importMap) } : {}), + ...(config.importMap.length > 0 + ? { import_map_path: toBundledFileUrl(path, config.importMap) } + : {}), ...(config.staticFiles.length > 0 - ? { static_patterns: config.staticFiles.map(toBundledFileUrl) } + ? { static_patterns: config.staticFiles.map((pathname) => toBundledFileUrl(path, pathname)) } : {}), }; } function sanitizeDockerBinds( + path: Path.Path, binds: ReadonlyArray, functionsDir: string, outputDir: string, ) { - const normalizedFunctionsDir = `${toSlash(resolve(functionsDir))}/`; - const normalizedOutputDir = `${toSlash(resolve(outputDir))}/`; + const normalizedFunctionsDir = `${toSlash(path.resolve(functionsDir))}/`; + const normalizedOutputDir = `${toSlash(path.resolve(outputDir))}/`; const seen = new Set(); const result: DockerBind[] = []; for (const bind of binds) { - const normalizedHostPath = toSlash(resolve(bind.hostPath)); + const normalizedHostPath = toSlash(path.resolve(bind.hostPath)); if ( normalizedHostPath.startsWith(normalizedFunctionsDir) || normalizedHostPath.startsWith(normalizedOutputDir) @@ -1209,57 +1336,56 @@ function sanitizeDockerBinds( type DockerBindsOptions = { readonly additionalModuleRoots?: ReadonlyArray; - readonly onWarning?: (message: string) => Promise; + readonly onWarning?: (message: string) => Effect.Effect; readonly skipMissingImportMapTargets?: boolean; /** Resolved marker presence, including an explicitly empty project value. */ readonly bitbucketCloneDirDefined?: boolean; }; -export const buildDockerBinds = ( +// Detached so an interrupt stops waiting without stopping the walk; only this join observes it. +const joinDetached = (walk: Effect.Effect) => + Effect.flatMap(Effect.forkDetach(walk, { startImmediately: true }), Fiber.join); + +export const buildDockerBinds = Effect.fnUntraced(function* ( projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, options: DockerBindsOptions = {}, -) => - resolveFunctionsSourceRoot(resolve(functionsDir, "..", "..")).pipe( - Effect.flatMap((sourceRoot) => - Effect.promise(() => - buildDockerBindsWithin(sourceRoot, projectId, functionsDir, outputDir, config, options), - ), - ), - ); +) { + const path = yield* Path.Path; + const sourceRoot = yield* resolveFunctionsSourceRoot(path.resolve(functionsDir, "..", "..")); + return yield* joinDetached( + buildDockerBindsWithin(sourceRoot, projectId, functionsDir, outputDir, config, options), + ).pipe(Effect.orDie); +}); -async function buildDockerBindsWithin( +const buildDockerBindsWithin = Effect.fnUntraced(function* ( sourceRoot: string, projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, options: DockerBindsOptions, -): Promise> { - const hostFunctionsDir = resolve(functionsDir); - const hostOutputDir = resolve(outputDir); - const realSourceRoot = await realpath(sourceRoot); +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostFunctionsDir = path.resolve(functionsDir); + const hostOutputDir = path.resolve(outputDir); + const realSourceRoot = yield* hostRealPath(sourceRoot); const moduleRoots = [ realSourceRoot, - ...( - await Promise.all( - (options.additionalModuleRoots ?? []).map(async (root) => { - try { - return await realpath(root); - } catch { - return undefined; - } - }), - ) - ).flatMap((root) => (root === undefined ? [] : [root])), + ...(yield* Effect.forEach( + options.additionalModuleRoots ?? [], + (root) => Effect.option(fs.realPath(root)), + { concurrency: "unbounded" }, + )).flatMap(Option.toArray), ]; - const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); + const importMapAllowedRoots = yield* resolveImportMapAllowedRoots(sourceRoot, config.importMap); const binds: DockerBind[] = [ { hostPath: hostFunctionsDir, - containerPath: toDockerPath(hostFunctionsDir, { resolve }), + containerPath: toDockerPath(hostFunctionsDir, path), mode: "ro", externalScope: false, }, @@ -1277,42 +1403,42 @@ async function buildDockerBindsWithin( if (!hostOutputDir.startsWith(hostFunctionsDir)) { binds.push({ hostPath: hostOutputDir, - containerPath: toDockerPath(hostOutputDir, { resolve }), + containerPath: toDockerPath(hostOutputDir, path), mode: "rw", externalScope: false, }); } - const warn = options.onWarning ?? (async () => {}); + const warn = options.onWarning ?? (() => Effect.void); const extraBinds: DockerBind[] = []; const explicitScopeBinds = new Map(); - const appendBindWithinRoots = async (roots: ReadonlyArray, pathname: string) => { - const hostPath = await realpath(pathname); - const contained = isContainedInAnyPath(roots, hostPath); + const appendBindWithinRoots = Effect.fnUntraced(function* ( + roots: ReadonlyArray, + pathname: string, + ) { + const hostPath = yield* hostRealPath(pathname); + const contained = isContainedInAnyPath(path, roots, hostPath); if (contained) { extraBinds.push({ hostPath, - containerPath: toDockerPath(hostPath, { resolve }), + containerPath: toDockerPath(hostPath, path), mode: "ro", externalScope: false, }); } return { hostPath, contained }; - }; - const appendProjectBind = async (pathname: string, _contents: Uint8Array) => { - await appendBindWithinRoots([realSourceRoot], pathname); - }; - const appendModuleBind = async (pathname: string, _contents: Uint8Array) => { - await appendBindWithinRoots(moduleRoots, pathname); - }; - const appendImportMapBind = async (pathname: string, _contents: Uint8Array) => { - await appendBindWithinRoots(importMapAllowedRoots, pathname); - }; + }); + const appendProjectBind = (pathname: string, _contents: Uint8Array) => + Effect.asVoid(appendBindWithinRoots([realSourceRoot], pathname)); + const appendModuleBind = (pathname: string, _contents: Uint8Array) => + Effect.asVoid(appendBindWithinRoots(moduleRoots, pathname)); + const appendImportMapBind = (pathname: string, _contents: Uint8Array) => + Effect.asVoid(appendBindWithinRoots(importMapAllowedRoots, pathname)); const importMap = config.importMap.length > 0 - ? await loadImportMapFile(config.importMap, appendImportMapBind) + ? yield* loadImportMapFile(config.importMap, appendImportMapBind) : new ImportMapFile(); - await walkImportPaths( + yield* walkImportPaths( importMap, config.entrypoint, moduleRoots, @@ -1320,14 +1446,14 @@ async function buildDockerBindsWithin( appendModuleBind, warn, ); - await forEachLocalImportMapTarget(importMap, async (target, kind) => { - try { - const { hostPath, contained } = await appendBindWithinRoots(importMapAllowedRoots, target); - const isDirectory = (await stat(target)).isDirectory(); + yield* forEachLocalImportMapTarget(importMap, (target, kind) => + Effect.gen(function* () { + const { hostPath, contained } = yield* appendBindWithinRoots(importMapAllowedRoots, target); + const isDirectory = (yield* hostStat(target)).type === "Directory"; if (!contained && kind === "scope") { const scopeBind: DockerBind = { hostPath, - containerPath: toDockerPath(target, { resolve }), + containerPath: toDockerPath(target, path), mode: "ro", externalScope: true, }; @@ -1336,48 +1462,48 @@ async function buildDockerBindsWithin( if (isDirectory) { return; } - await walkLocalImportMapTargetImports( + yield* walkLocalImportMapTargetImports( importMap, target, importMapAllowedRoots, sourceRoot, appendImportMapBind, - async () => {}, + () => Effect.void, ); - } catch (error) { - if (error instanceof Error && "code" in error) { - // ENOTDIR (a trailing-slash value routed through a file) is never a - // walkable target regardless of caller: an import that actually - // reaches through that file still fails via the walker's - // FunctionImportNotDirectoryError. - if (error.code === "ENOTDIR") { - await warn(`WARN: Skipping import map target that is not a directory: ${target}\n`); - return; - } - if (options.skipMissingImportMapTargets === true && error.code === "ENOENT") { - await warn(`WARN: Skipping missing import map target: ${target}\n`); - return; - } - } - throw error; - } - }); + }).pipe( + // ENOTDIR (a trailing-slash value routed through a file) is never a + // walkable target regardless of caller: an import that actually + // reaches through that file still fails via the walker's + // FunctionImportNotDirectoryError. + Effect.catchIf( + (error) => hasErrorCode(error, "ENOTDIR"), + () => warn(`WARN: Skipping import map target that is not a directory: ${target}\n`), + ), + Effect.catchIf( + (error) => options.skipMissingImportMapTargets === true && hasErrorCode(error, "ENOENT"), + () => warn(`WARN: Skipping missing import map target: ${target}\n`), + ), + ), + ); for (const pattern of config.staticFiles) { - let files: ReadonlyArray; - try { - files = await expandStaticPattern(pattern); - } catch { + const matches = yield* Effect.option(expandStaticPattern(pattern)); + if (Option.isNone(matches)) { continue; } - for (const pathname of files) { - if ((await stat(pathname)).isDirectory()) { - throw new Error(`file path is a directory: ${pathname}`); + for (const pathname of matches.value) { + if ((yield* hostStat(pathname)).type === "Directory") { + return yield* new FunctionDeployError({ message: `file path is a directory: ${pathname}` }); } - await appendProjectBind(pathname, new Uint8Array()); + yield* appendProjectBind(pathname, new Uint8Array()); } } - const sanitizedExtraBinds = sanitizeDockerBinds(extraBinds, hostFunctionsDir, hostOutputDir); + const sanitizedExtraBinds = sanitizeDockerBinds( + path, + extraBinds, + hostFunctionsDir, + hostOutputDir, + ); const occupiedContainerPaths = new Set( [...binds, ...sanitizedExtraBinds].map((bind) => bind.containerPath), ); @@ -1388,29 +1514,32 @@ async function buildDockerBindsWithin( } occupiedContainerPaths.add(bind.containerPath); uniqueScopeBinds.push(bind); - await warn( + yield* warn( `WARN: Mounting import map scope target outside the project root: ${bind.hostPath}\n`, ); } return [...binds, ...sanitizedExtraBinds, ...uniqueScopeBinds]; -} +}); -function shouldUseDenoJsonDiscovery(entrypoint: string, importMap: string) { - return isDenoConfigFile(importMap) && dirname(importMap) === dirname(entrypoint); +function shouldUseDenoJsonDiscovery(path: Path.Path, entrypoint: string, importMap: string) { + return isDenoConfigFile(path, importMap) && path.dirname(importMap) === path.dirname(entrypoint); } -async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: string) { +const shouldUsePackageJsonDiscovery = Effect.fnUntraced(function* ( + entrypoint: string, + importMap: string, +) { if (importMap.length > 0) { return false; } - try { - await stat(join(dirname(entrypoint), "package.json")); - return true; - } catch { - return false; - } -} + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.stat(path.join(path.dirname(entrypoint), "package.json")).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); interface BundleFunctionWithDockerOptions { readonly projectId: string; @@ -1439,29 +1568,31 @@ const bundleFunctionWithDocker = Effect.fn("functions.deploy.bundleWithDocker")( } = options; const bitbucketCloneDirDefined = Option.isSome(yield* bitbucketCloneDir(projectEnvValues)); const output = yield* Output; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* output.raw(`Bundling Function: ${styleEmphasis(config.slug)}\n`, "stderr"); - const outputRoot = resolve(functionsDir, "..", ".temp"); - yield* Effect.tryPromise(() => mkdir(outputRoot, { recursive: true })); - const outputDir = yield* Effect.tryPromise(() => - mkdtemp(join(outputRoot, `.supabase-output-${config.slug}-`)), - ); + const outputRoot = path.resolve(functionsDir, "..", ".temp"); + yield* fs + .makeDirectory(outputRoot, { recursive: true }) + .pipe(Effect.mapError(unknownHostError(outputRoot))); + const outputPrefix = `.supabase-output-${config.slug}-`; + const outputDir = yield* fs + .makeTempDirectory({ directory: outputRoot, prefix: outputPrefix }) + .pipe(Effect.mapError(unknownHostError(path.join(outputRoot, outputPrefix)))); try { // Go passes 0777 to MkdirAll, which Windows ignores. Calling chmod separately // adds an NTFS WRITE_ATTRIBUTES requirement that the Go CLI does not have. if (shouldChmodBundleOutputDirectory(process.platform)) { - yield* Effect.tryPromise({ - try: () => chmod(outputDir, 0o777), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + yield* fs.chmod(outputDir, 0o777).pipe(Effect.mapError(hostError(outputDir))); } - const outputPath = join(outputDir, "output.eszip"); + const outputPath = path.join(outputDir, "output.eszip"); // `edgeRuntimeImage` applies the tag verbatim — a `.temp/edge-runtime-version` pin flows // through unmodified, `v` prefix or not (see the helper's doc in `functions.shared.ts`). const rawImage = edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled); const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { bitbucketCloneDirDefined, - onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), + onWarning: (message) => output.raw(message, "stderr"), }); // Resolved per function rather than hoisted out of the loop (unlike `download.ts`'s // `PulledEdgeRuntimeImage`): the first resolve failure aborts the loop, and the only added @@ -1475,11 +1606,7 @@ const bundleFunctionWithDocker = Effect.fn("functions.deploy.bundleWithDocker")( ); const env: Array = []; - if ( - !(yield* Effect.promise(() => - shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap), - )) - ) { + if (!(yield* shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap))) { env.push("DENO_NO_PACKAGE_JSON=1"); } env.push(...dockerNpmEnv()); @@ -1487,20 +1614,20 @@ const bundleFunctionWithDocker = Effect.fn("functions.deploy.bundleWithDocker")( const containerArgs = [ "bundle", "--entrypoint", - toDockerPath(config.entrypoint, { resolve }), + toDockerPath(config.entrypoint, path), "--output", - toDockerPath(outputPath, { resolve }), + toDockerPath(outputPath, path), ]; if ( config.importMap.length > 0 && - !shouldUseDenoJsonDiscovery(config.entrypoint, config.importMap) + !shouldUseDenoJsonDiscovery(path, config.entrypoint, config.importMap) ) { - containerArgs.push("--import-map", toDockerPath(config.importMap, { resolve })); + containerArgs.push("--import-map", toDockerPath(config.importMap, path)); } for (const staticFile of config.staticFiles) { - containerArgs.push("--static", toDockerPath(staticFile, { resolve })); + containerArgs.push("--static", toDockerPath(staticFile, path)); } - if (verbose || process.env["DEBUG"] === "true") { + if (verbose || (yield* debugEnvEnabled)) { containerArgs.push("--verbose"); } @@ -1512,7 +1639,7 @@ const bundleFunctionWithDocker = Effect.fn("functions.deploy.bundleWithDocker")( env, // `functionsDir` is `/supabase/functions`, same derivation as `deployViaApi`'s // own `projectRoot`. - workingDir: toDockerPath(resolve(functionsDir, "..", ".."), { resolve }), + workingDir: toDockerPath(path.resolve(functionsDir, "..", ".."), path), containerArgs, }); @@ -1525,16 +1652,19 @@ const bundleFunctionWithDocker = Effect.fn("functions.deploy.bundleWithDocker")( onStderr: (chunk) => output.raw(chunk, "stderr"), }); if (result.exitCode !== 0) { - return yield* Effect.fail(new Error(`failed to bundle function: exit ${result.exitCode}`)); + return yield* new FunctionDeployError({ + message: `failed to bundle function: exit ${result.exitCode}`, + }); } - const eszip = yield* Effect.tryPromise({ - try: () => readFile(outputPath), - catch: (error) => - new Error( - `failed to open eszip: ${error instanceof Error ? error.message : String(error)}`, - ), - }); + const eszip = yield* fs.readFile(outputPath).pipe( + Effect.mapError( + (error) => + new FunctionDeployError({ + message: `failed to open eszip: ${hostError(outputPath)(error).message}`, + }), + ), + ); const compressed = new Uint8Array( Buffer.concat([ Buffer.from(COMPRESSED_ESZIP_MAGIC), @@ -1550,13 +1680,11 @@ const bundleFunctionWithDocker = Effect.fn("functions.deploy.bundleWithDocker")( yield* Effect.annotateCurrentSpan({ "bundle.bytes": compressed.byteLength }); return { slug: config.slug, - metadata: createBundledMetadata(config, hash), + metadata: createBundledMetadata(path, config, hash), body: compressed, } satisfies BundledFunction; } finally { - yield* Effect.tryPromise(() => rm(outputDir, { recursive: true, force: true })).pipe( - Effect.orElseSucceed(() => undefined), - ); + yield* fs.remove(outputDir, { recursive: true, force: true }).pipe(Effect.ignore); } }); @@ -1584,15 +1712,16 @@ const listRemoteFunctions = Effect.fn("functions.deploy.listRemoteFunctions")(fu // A 200 whose body is not the expected JSON is an API-response problem, // not a transport failure — surface it via FunctionsApiStatusError so it // classifies as api_status rather than network. - return yield* Effect.try({ - try: () => decodeFunctionListResponse(JSON.parse(body)), - catch: (error) => - new FunctionsApiStatusError({ - status: result.response.status, - message: `failed to read functions list: ${error instanceof Error ? error.message : String(error)}`, - decode: true, - }), - }); + return yield* decodeFunctionListResponse(body).pipe( + Effect.mapError( + (error) => + new FunctionsApiStatusError({ + status: result.response.status, + message: `failed to read functions list: ${error.message}`, + decode: true, + }), + ), + ); } lastError = new FunctionsApiStatusError({ status: result.response.status, @@ -1609,14 +1738,16 @@ const listRemoteFunctions = Effect.fn("functions.deploy.listRemoteFunctions")(fu yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } } - return yield* Effect.fail(lastError ?? new Error("failed to list functions")); + return yield* Effect.fail( + lastError ?? new FunctionDeployError({ message: "failed to list functions" }), + ); }); function headerValue(headers: Readonly>, name: string) { return headers[name.toLowerCase()] ?? headers[name]; } -function parseRateLimitDelay(value: string | undefined): number | undefined { +function parseRateLimitDelay(value: string | undefined, now: number): number | undefined { if (value === undefined || value.length === 0) { return undefined; } @@ -1626,7 +1757,7 @@ function parseRateLimitDelay(value: string | undefined): number | undefined { } const timestamp = Date.parse(value); if (!Number.isNaN(timestamp)) { - return Math.max(timestamp - Date.now(), 0); + return Math.max(timestamp - now, 0); } return undefined; } @@ -1634,10 +1765,11 @@ function parseRateLimitDelay(value: string | undefined): number | undefined { function rateLimitDelayMillis( headers: Readonly>, attempt: number, + now: number, ) { return ( - parseRateLimitDelay(headerValue(headers, "retry-after")) ?? - parseRateLimitDelay(headerValue(headers, "x-ratelimit-reset")) ?? + parseRateLimitDelay(headerValue(headers, "retry-after"), now) ?? + parseRateLimitDelay(headerValue(headers, "x-ratelimit-reset"), now) ?? 1_000 * 2 ** Math.min(attempt, 5) ); } @@ -1663,7 +1795,7 @@ const rateLimitedRequest = Effect.fnUntraced(function* ( if (response.status !== 429 || attempt >= DEPLOY_RATE_LIMIT_MAX_RETRIES) { return response; } - const delayMs = rateLimitDelayMillis(response.headers, attempt); + const delayMs = rateLimitDelayMillis(response.headers, attempt, yield* Clock.currentTimeMillis); yield* output.raw( `Rate limit exceeded while ${action}. Retrying in ${rateLimitDelayText(delayMs)}.\n`, "stderr", @@ -1682,15 +1814,17 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( bundleOnly: boolean, ) { const output = yield* Output; - const files = yield* Effect.tryPromise({ - try: async () => { - const form = await writeSourceDeployForm(sourceRoot, workdir, config, metadata, (text) => - output.raw(text, "stderr"), - ); - return form.getAll("file").flatMap((part) => (part instanceof Blob ? [part] : [])); - }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const files = yield* joinDetached( + collectSourceDeployFiles(sourceRoot, workdir, config, metadata, (text) => + output.raw(text, "stderr"), + ), + ).pipe( + Effect.catchDefect((defect) => + Effect.fail( + defect instanceof Error ? defect : new FunctionDeployError({ message: String(defect) }), + ), + ), + ); const response = yield* rateLimitedRequest(`deploying function ${config.slug}`, () => api .executeRaw(operationDefinitions.v1DeployAFunction, { @@ -1717,25 +1851,24 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( ); const body = yield* response.body; if (response.status !== 201) { - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, + }); } // A 201 whose body is not the expected JSON is an API-response problem, not a // transport failure — surface it via FunctionsApiStatusError so it classifies // as api_status rather than network. - return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(JSON.parse(body)), - catch: (error) => - new FunctionsApiStatusError({ - status: response.status, - message: `failed to read deploy response: ${error instanceof Error ? error.message : String(error)}`, - decode: true, - }), - }); + return yield* decodeDeployFunctionResponse(body).pipe( + Effect.mapError( + (error) => + new FunctionsApiStatusError({ + status: response.status, + message: `failed to read deploy response: ${error.message}`, + decode: true, + }), + ), + ); }); function toBulkUpdateItem(remote: RemoteFunction | DeployFunctionResponse): BulkUpdateFunction { @@ -1807,7 +1940,9 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } } - return yield* Effect.fail(lastError ?? new Error("failed to bulk update")); + return yield* Effect.fail( + lastError ?? new FunctionDeployError({ message: "failed to bulk update" }), + ); }); const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(function* ( @@ -1861,15 +1996,16 @@ const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(funct // API-response problem, not a transport failure — surface it via // FunctionsApiStatusError so it classifies as api_status not network. const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(JSON.parse(body)), - catch: (error) => - new FunctionsApiStatusError({ - status: response.value.status, - message: `failed to read function response: ${error instanceof Error ? error.message : String(error)}`, - decode: true, - }), - }); + return yield* decodeDeployFunctionResponse(body).pipe( + Effect.mapError( + (error) => + new FunctionsApiStatusError({ + status: response.value.status, + message: `failed to read function response: ${error.message}`, + decode: true, + }), + ), + ); } const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); @@ -1890,7 +2026,9 @@ const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(funct } } - return yield* Effect.fail(lastError ?? new Error("failed to upsert function")); + return yield* Effect.fail( + lastError ?? new FunctionDeployError({ message: "failed to upsert function" }), + ); }); const deleteRemoteFunction = Effect.fn("functions.deploy.deleteFunction")(function* ( @@ -1909,42 +2047,42 @@ const deleteRemoteFunction = Effect.fn("functions.deploy.deleteFunction")(functi return; } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected delete function status ${response.status}: ${body}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, + }); }); export const discoverFunctionSlugs = Effect.fn("functions.deploy.discoverSlugs")(function* ( projectRoot: string, configDeclaredFunctions: Readonly>, ) { - const functionsDir = join(projectRoot, SUPABASE_FUNCTIONS_DIR); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionsDir = path.join(projectRoot, SUPABASE_FUNCTIONS_DIR); const slugs: string[] = []; - const entries = yield* Effect.tryPromise({ - try: () => readdir(functionsDir, { withFileTypes: true }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe( - Effect.catch((error) => { - return "code" in error && error.code === "ENOENT" - ? Effect.succeed(undefined) - : Effect.fail(error); - }), + const entries = yield* fs.readDirectory(functionsDir).pipe( + Effect.mapError(hostError(functionsDir)), + Effect.catchIf( + (error) => hasErrorCode(error, "ENOENT"), + () => Effect.undefined, + ), ); if (entries !== undefined) { - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { + for (const slug of entries.sort((left, right) => left.localeCompare(right))) { + const isDirectory = yield* fs.stat(path.join(functionsDir, slug)).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (!isDirectory) { continue; } - const slug = entry.name; if (validateFunctionSlugMessage(slug) !== undefined) { continue; } - const hasDefaultEntrypoint = yield* Effect.promise(() => - isFile(defaultFunctionEntrypoint(functionsDir, slug)), + const hasDefaultEntrypoint = yield* isFile( + defaultFunctionEntrypoint(path, functionsDir, slug), ); if (hasDefaultEntrypoint) { slugs.push(slug); @@ -1979,17 +2117,18 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs readonly noVerifyJwtOverride: Option.Option; }) { const output = yield* Output; - const functionsDir = join(input.projectRoot, SUPABASE_FUNCTIONS_DIR); + const path = yield* Path.Path; + const functionsDir = path.join(input.projectRoot, SUPABASE_FUNCTIONS_DIR); const seenDeprecatedImportMap = new Set(); const seenFallbackImportMap = new Set(); const resolved: ResolvedDeployFunctionConfig[] = []; - const fallbackImportMapPath = join(functionsDir, "import_map.json"); - const fallbackExists = yield* Effect.promise(() => isFile(fallbackImportMapPath)); + const fallbackImportMapPath = path.join(functionsDir, "import_map.json"); + const fallbackExists = yield* isFile(fallbackImportMapPath); const importMapOverride = Option.match(input.importMapOverride, { onNone: () => "", - onSome: (pathname) => resolve(input.cwd, pathname), + onSome: (pathname) => path.resolve(input.cwd, pathname), }); for (const slug of input.slugs) { @@ -2004,13 +2143,13 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs onSome: (noVerifyJwt) => !noVerifyJwt, }); - const defaultEntrypoint = defaultFunctionEntrypoint(functionsDir, slug); + const defaultEntrypoint = defaultFunctionEntrypoint(path, functionsDir, slug); const entrypoint = configured.entrypoint === undefined || configured.entrypoint.length === 0 ? defaultEntrypoint - : resolve( - configured.entrypoint.startsWith(".") || !isAbsolute(configured.entrypoint) - ? join(input.supabaseDir, configured.entrypoint) + : path.resolve( + configured.entrypoint.startsWith(".") || !path.isAbsolute(configured.entrypoint) + ? path.join(input.supabaseDir, configured.entrypoint) : configured.entrypoint, ); @@ -2018,9 +2157,9 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs if (importMap.length === 0) { let configuredImportMap = ""; if (configured.import_map.length > 0) { - configuredImportMap = resolve( - configured.import_map.startsWith(".") || !isAbsolute(configured.import_map) - ? join(input.supabaseDir, configured.import_map) + configuredImportMap = path.resolve( + configured.import_map.startsWith(".") || !path.isAbsolute(configured.import_map) + ? path.join(input.supabaseDir, configured.import_map) : configured.import_map, ); } @@ -2030,21 +2169,21 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs !( (override === undefined || override.import_map.length === 0) && entrypoint !== defaultEntrypoint && - configuredImportMap === defaultFunctionImportMap(functionsDir, slug) + configuredImportMap === defaultFunctionImportMap(path, functionsDir, slug) ) ) { importMap = configuredImportMap; } else { - const functionDir = dirname(entrypoint); - const denoJson = join(functionDir, "deno.json"); - const denoJsonc = join(functionDir, "deno.jsonc"); - const deprecatedImportMap = join(functionDir, "import_map.json"); + const functionDir = path.dirname(entrypoint); + const denoJson = path.join(functionDir, "deno.json"); + const denoJsonc = path.join(functionDir, "deno.jsonc"); + const deprecatedImportMap = path.join(functionDir, "import_map.json"); - if (yield* Effect.promise(() => isFile(denoJson))) { + if (yield* isFile(denoJson)) { importMap = denoJson; - } else if (yield* Effect.promise(() => isFile(denoJsonc))) { + } else if (yield* isFile(denoJsonc)) { importMap = denoJsonc; - } else if (yield* Effect.promise(() => isFile(deprecatedImportMap))) { + } else if (yield* isFile(deprecatedImportMap)) { importMap = deprecatedImportMap; seenDeprecatedImportMap.add(slug); } else if (fallbackExists) { @@ -2057,7 +2196,7 @@ export const resolveFunctionConfigs = Effect.fn("functions.deploy.resolveConfigs } const staticFiles = configured.static_files.map((pathname) => - isAbsolute(pathname) ? pathname : join(input.supabaseDir, pathname), + path.isAbsolute(pathname) ? pathname : path.join(input.supabaseDir, pathname), ); resolved.push({ @@ -2101,6 +2240,7 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( jobs: number, ) { const output = yield* Output; + const path = yield* Path.Path; yield* Effect.annotateCurrentSpan({ "function.count": configs.length, "deploy.jobs": jobs }); // Uploaded file names and the server-recorded metadata paths are anchored at the workdir @@ -2114,9 +2254,7 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( } if (enabled.length === 0) { - return yield* Effect.fail( - new NoFunctionsToDeployError({ message: "All Functions are up to date." }), - ); + return yield* new NoFunctionsToDeployError({ message: "All Functions are up to date." }); } const remoteBySlug = enabled.some((config) => config.verifyJwt === undefined) @@ -2131,7 +2269,7 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( sourceRoot, projectRoot, config, - createSourceMetadata(projectRoot, config, remoteBySlug.get(config.slug)), + createSourceMetadata(path, projectRoot, config, remoteBySlug.get(config.slug)), false, ); return; @@ -2153,7 +2291,7 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( sourceRoot, projectRoot, config, - createSourceMetadata(projectRoot, config, remoteBySlug.get(config.slug)), + createSourceMetadata(path, projectRoot, config, remoteBySlug.get(config.slug)), true, ), ); @@ -2298,9 +2436,7 @@ const pruneFunctions = Effect.fn("functions.deploy.prune")(function* ( ].join("\n")}\n\n`; const confirmed = yield* promptYesNo(output, yes, prompt, false); if (!confirmed) { - return yield* Effect.fail( - new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } for (const slug of toDelete) { @@ -2309,209 +2445,204 @@ const pruneFunctions = Effect.fn("functions.deploy.prune")(function* ( } }); -export function deployFunctions( +export const deployFunctions = Effect.fn("functions.deploy")(function* < + ResolveError, + ResolveRequirements, +>( flags: FunctionsDeployFlags, dependencies: DeployFunctionsDependencies, ) { - return Effect.gen(function* () { - const output = yield* Output; - const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); - const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); - const commandPath = ["functions", "deploy"] as const; - // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — used only for - // the mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) key - // off the resolved `flags.useApi` value instead. - const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); - const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); - const explicitLegacyBundle = hasExplicitLongFlag( - dependencies.rawArgs, - commandPath, - "legacy-bundle", - ); + const output = yield* Output; + const path = yield* Path.Path; + const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); + const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); + const commandPath = ["functions", "deploy"] as const; + // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — used only for + // the mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) key + // off the resolved `flags.useApi` value instead. + const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); + const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); + const explicitLegacyBundle = hasExplicitLongFlag( + dependencies.rawArgs, + commandPath, + "legacy-bundle", + ); - const changedModes = [ - explicitUseApi ? "use-api" : undefined, - explicitUseDocker ? "use-docker" : undefined, - explicitLegacyBundle ? "legacy-bundle" : undefined, - ].filter((flag): flag is string => flag !== undefined); + const changedModes = [ + explicitUseApi ? "use-api" : undefined, + explicitUseDocker ? "use-docker" : undefined, + explicitLegacyBundle ? "legacy-bundle" : undefined, + ].filter((flag): flag is string => flag !== undefined); - if (changedModes.length > 1) { - return yield* Effect.fail( - new ConflictingFunctionDeployFlagsError({ - message: cobraMutuallyExclusiveErrorMessage(FUNCTIONS_BUNDLER_MUTEX_GROUP, changedModes), - }), - ); - } + if (changedModes.length > 1) { + return yield* new ConflictingFunctionDeployFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(FUNCTIONS_BUNDLER_MUTEX_GROUP, changedModes), + }); + } - // `--use-api=false` alone must not force the API path — it should fall through to whatever - // `--use-docker`/`--legacy-bundle` already resolved to. - const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); - const configuredJobs = Option.getOrElse(flags.jobs, () => 1); - const jobs = configuredJobs === 0 ? 1 : configuredJobs; - // Keyed on the resolved `--use-api` value alone, not on whether local bundling - // (Docker/legacy-bundle) is in play. - if (!flags.useApi && jobs > 1) { - return yield* Effect.fail(new Error("--jobs must be used together with --use-api")); - } - - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this - // already reflects any remote function/edge_runtime overrides, through the same - // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see - // `functions-config.ts`). Must precede the slug-validation loop below, so an invalid - // `config.toml` is reported ahead of a malformed slug when both are wrong. - const context = yield* loadFunctionsCliConfig({ - projectRoot: dependencies.projectRoot, - projectRef, - goConfigCompat: dependencies.goConfigCompat, + // `--use-api=false` alone must not force the API path — it should fall through to whatever + // `--use-docker`/`--legacy-bundle` already resolved to. + const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); + const configuredJobs = Option.getOrElse(flags.jobs, () => 1); + const jobs = configuredJobs === 0 ? 1 : configuredJobs; + // Keyed on the resolved `--use-api` value alone, not on whether local bundling + // (Docker/legacy-bundle) is in play. + if (!flags.useApi && jobs > 1) { + return yield* new FunctionDeployError({ + message: "--jobs must be used together with --use-api", }); + } - if (flags.functionNames.length > 0) { - for (const slug of flags.functionNames) { - yield* validateDeploySlug(slug); - } - } + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this + // already reflects any remote function/edge_runtime overrides, through the same + // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see + // `functions-config.ts`). Must precede the slug-validation loop below, so an invalid + // `config.toml` is reported ahead of a malformed slug when both are wrong. + const context = yield* loadFunctionsCliConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); - const noVerifyJwtOverride = explicitBooleanFlag( - dependencies.rawArgs, - ["functions", "deploy"], - "no-verify-jwt", - flags.noVerifyJwt, - ); - // `--debug=false` must resolve to `false` — a plain presence check would get that backwards - // (same rule as `download.ts`'s own `--debug` read). - const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; - const deployConfig = context.loaded?.config; - const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - context.denoVersion, - dependencies.edgeRuntimeVersion, - ); - const configFunctions = yield* inferFunctionsManifest({ - cwd: dependencies.projectRoot, - config: deployConfig, - // Matches `loadFunctionsCliConfig`'s own options above: no ancestor directory is searched - // past `dependencies.projectRoot` for either load, so they can never resolve two - // different projects. - search: dependencies.goConfigCompat === undefined, - }); - const configDeclaredFunctions = deployConfig?.functions ?? {}; - const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); - yield* validateConfigFunctionSlugs(configDeclaredFunctions); - const slugs = - flags.functionNames.length > 0 - ? [...flags.functionNames] - : yield* discoverFunctionSlugs(dependencies.projectRoot, configDeclaredFunctions); - - if (slugs.length === 0) { - return yield* Effect.fail( - new NoFunctionsToDeployError({ - // Styling is text-mode only: in `--output-format json`/`stream-json` this message - // lands in the structured error payload, which must stay free of ANSI escapes. - message: `No Functions specified or found in ${ - output.format === "text" - ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) - : SUPABASE_FUNCTIONS_DIR - }`, - }), - ); + if (flags.functionNames.length > 0) { + for (const slug of flags.functionNames) { + yield* validateDeploySlug(slug); } + } - const uniqueSlugs = [...new Set(slugs)]; - const configs = yield* resolveFunctionConfigs({ - slugs: uniqueSlugs, - cwd: dependencies.flagCwd, - projectRoot: dependencies.projectRoot, - supabaseDir: dependencies.supabaseDir, - configFunctions, - configDeclaredFunctions, - rawConfigFunctions, - importMapOverride: flags.importMap, - noVerifyJwtOverride, + const noVerifyJwtOverride = explicitBooleanFlag( + dependencies.rawArgs, + ["functions", "deploy"], + "no-verify-jwt", + flags.noVerifyJwt, + ); + // `--debug=false` must resolve to `false` — a plain presence check would get that backwards + // (same rule as `download.ts`'s own `--debug` read). + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; + const deployConfig = context.loaded?.config; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + context.denoVersion, + dependencies.edgeRuntimeVersion, + ); + const configFunctions = yield* inferFunctionsManifest({ + cwd: dependencies.projectRoot, + config: deployConfig, + // Matches `loadFunctionsCliConfig`'s own options above: no ancestor directory is searched + // past `dependencies.projectRoot` for either load, so they can never resolve two + // different projects. + search: dependencies.goConfigCompat === undefined, + }); + const configDeclaredFunctions = deployConfig?.functions ?? {}; + const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); + yield* validateConfigFunctionSlugs(configDeclaredFunctions); + const slugs = + flags.functionNames.length > 0 + ? [...flags.functionNames] + : yield* discoverFunctionSlugs(dependencies.projectRoot, configDeclaredFunctions); + + if (slugs.length === 0) { + return yield* new NoFunctionsToDeployError({ + // Styling is text-mode only: in `--output-format json`/`stream-json` this message + // lands in the structured error payload, which must stay free of ANSI escapes. + message: `No Functions specified or found in ${ + output.format === "text" ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) : SUPABASE_FUNCTIONS_DIR + }`, }); - const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; + } - const deployWithApi = deployViaApi( - projectRef, - dependencies.projectRoot, - configs, - dependencies.api, - jobs, - ).pipe( - Effect.as(true), - Effect.catchIf( - (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, - (error) => - (output.format === "text" - ? output.raw(`${error.message}\n`, "stderr") - : output.success(error.message, { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }) - ).pipe(Effect.as(false)), - ), - ); + const uniqueSlugs = [...new Set(slugs)]; + const configs = yield* resolveFunctionConfigs({ + slugs: uniqueSlugs, + cwd: dependencies.flagCwd, + projectRoot: dependencies.projectRoot, + supabaseDir: dependencies.supabaseDir, + configFunctions, + configDeclaredFunctions, + rawConfigFunctions, + importMapOverride: flags.importMap, + noVerifyJwtOverride, + }); + const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; - const styleWarning = dependencies.styleWarning ?? ((text: string) => text); - const deployed = useLocalBundler - ? yield* Effect.gen(function* () { - if (!(yield* isDockerRunning())) { - yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); - return yield* deployWithApi; - } + const deployWithApi = deployViaApi( + projectRef, + dependencies.projectRoot, + configs, + dependencies.api, + jobs, + ).pipe( + Effect.as(true), + Effect.catchIf( + (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, + (error) => + (output.format === "text" + ? output.raw(`${error.message}\n`, "stderr") + : output.success(error.message, { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }) + ).pipe(Effect.as(false)), + ), + ); - // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs "never touched" - // distinction `resolveDockerNetworkMode` needs — see that function's own doc comment. - // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only, `undefined` for library - // callers. - const networkMode = resolveDockerNetworkMode({ - explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), - envOverride: - context.projectEnvValues === undefined - ? undefined - : viperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - context.projectEnvValues, - ), - projectId: context.projectId, - }); - yield* deployViaDocker({ - projectId: context.projectId, - projectRef, - edgeRuntimeVersion, - functionsDir: join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), - configs, - api: dependencies.api, - networkMode, - verbose: debugEnabled, - styleEmphasis, - projectEnvValues: context.projectEnvValues, - }); - return true; - }) - : yield* deployWithApi; + const styleWarning = dependencies.styleWarning ?? ((text: string) => text); + const deployed = useLocalBundler + ? yield* Effect.gen(function* () { + if (!(yield* isDockerRunning())) { + yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); + return yield* deployWithApi; + } - if (!deployed) { - return; - } + // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs "never touched" + // distinction `resolveDockerNetworkMode` needs — see that function's own doc comment. + // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only, `undefined` for library + // callers. + const networkMode = resolveDockerNetworkMode({ + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), + envOverride: + context.projectEnvValues === undefined + ? undefined + : viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", context.projectEnvValues), + projectId: context.projectId, + }); + yield* deployViaDocker({ + projectId: context.projectId, + projectRef, + edgeRuntimeVersion, + functionsDir: path.join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), + configs, + api: dependencies.api, + networkMode, + verbose: debugEnabled, + styleEmphasis, + projectEnvValues: context.projectEnvValues, + }); + return true; + }) + : yield* deployWithApi; - if (output.format === "text") { - // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints - // "foo, foo". - yield* output.raw( - `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, - ); - yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); - } else { - yield* output.success("Deployed Functions.", { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }); - } + if (!deployed) { + return; + } - if (flags.prune) { - yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); - } - }).pipe(Effect.withSpan("functions.deploy")); -} + if (output.format === "text") { + // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints + // "foo, foo". + yield* output.raw( + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, + ); + yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); + } else { + yield* output.success("Deployed Functions.", { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }); + } + + if (flags.prune) { + yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); + } +}); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index eff5003774..2b01081a10 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { Effect } from "effect"; +import { Deferred, Effect, Fiber } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -120,9 +120,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => bind.hostPath === vendorIndexPath)).toBe(true); @@ -146,7 +147,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil let caught: unknown; try { await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async () => {}, + onWarning: () => Effect.void, }); } catch (error) { caught = error; @@ -176,9 +177,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => formatDockerBind(bind).includes("extra.ts"))).toBe(false); @@ -201,9 +203,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => bind.hostPath === vendorIndexPath)).toBe(true); @@ -223,9 +226,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); const matches = warnings.filter( @@ -255,9 +259,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => formatDockerBind(bind).includes("index.mjs/core"))).toBe(false); @@ -298,9 +303,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => formatDockerBind(bind).includes("index.mjs"))).toBe(false); @@ -331,9 +337,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), skipMissingImportMapTargets: true, }); @@ -359,9 +366,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), skipMissingImportMapTargets: true, }); @@ -420,9 +428,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeDependency, 'export const util = "thing";\n'); const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); const hostPaths = binds.map((bind) => bind.hostPath); @@ -461,9 +470,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); const functionsBinds = binds.filter((bind) => bind.containerPath === resolve(functionsDir)); @@ -495,9 +505,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(warnings).toEqual([]); @@ -521,9 +532,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => bind.hostPath === vendorIndexPath)).toBe(true); @@ -551,9 +563,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(binds.some((bind) => bind.hostPath === modPath)).toBe(true); @@ -582,9 +595,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), skipMissingImportMapTargets: true, }); @@ -617,9 +631,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }), }); expect(warnings.some((warning) => warning.includes("nested"))).toBe(false); @@ -627,6 +642,69 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await rm(root, { recursive: true, force: true }); } }); + + it("skips an invalid static_files glob instead of failing, binding nothing for it", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + const assetsDir = join(root, "supabase", "assets"); + await mkdir(assetsDir, { recursive: true }); + await writeFile(join(assetsDir, "[z-a].txt"), "a\n"); + + try { + const baseline = await runBuildDockerBinds("test-project", functionsDir, outputDir, config); + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, { + ...config, + staticFiles: [join(assetsDir, "[z-a].txt")], + }); + + expect(binds).toEqual(baseline); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("keeps walking imports after the caller is interrupted", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + {}, + ['import "../../../outside-a.ts";', 'import "../../../outside-b.ts";', ""].join("\n"), + ); + const started = Deferred.makeUnsafe(); + const gate = Deferred.makeUnsafe(); + const continued = Deferred.makeUnsafe(); + const warnings: Array = []; + + try { + const caller = Effect.runFork( + buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: (message) => + Effect.sync(() => { + warnings.push(message); + }).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.flatMap((first) => + first ? Deferred.await(gate) : Deferred.succeed(continued, undefined), + ), + Effect.asVoid, + ), + }).pipe(Effect.provide(BunServices.layer)), + ); + await Effect.runPromise(Deferred.await(started)); + await Effect.runPromise(Fiber.interrupt(caller)); + await Effect.runPromise(Deferred.succeed(gate, undefined)); + await Effect.runPromise(Deferred.await(continued)); + + expect(warnings).toEqual( + expect.arrayContaining([ + "WARN: failed to read file: open outside-a.ts: no such file or directory\n", + "WARN: failed to read file: open outside-b.ts: no such file or directory\n", + ]), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); describe("pruneRedundantDockerBinds — child binds covered by a parent bind", () => { diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 579d61fdd7..72dbcff0c8 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1692,10 +1692,10 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin bitbucketCloneDirDefined, additionalModuleRoots: [flagCwd], skipMissingImportMapTargets: true, - onWarning: (message) => { - bindWarnings.push(message); - return Promise.resolve(); - }, + onWarning: (message) => + Effect.sync(() => { + bindWarnings.push(message); + }), })) { binds.add(formatDockerBind(bind)); } @@ -1785,10 +1785,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo bitbucketCloneDirDefined, additionalModuleRoots: [input.flagCwd], skipMissingImportMapTargets: true, - onWarning: (message) => { - bindWarnings.push(message); - return Promise.resolve(); - }, + onWarning: (message) => + Effect.sync(() => { + bindWarnings.push(message); + }), })) { const key = formatDockerBind(bind); functionBinds.set(key, bind);