From 13abd5a1c4e51b264ac9dea02f86024a6cff570c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:22:13 +0100 Subject: [PATCH 1/3] [C3] Bump @tanstack/cli from 0.69.1 to 0.69.2 in /packages/create-cloudflare/src/frameworks (#14235) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Wrangler automated PR updater --- .changeset/c3-frameworks-update-14235.md | 11 +++++++++++ .../create-cloudflare/src/frameworks/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/c3-frameworks-update-14235.md diff --git a/.changeset/c3-frameworks-update-14235.md b/.changeset/c3-frameworks-update-14235.md new file mode 100644 index 0000000000..396fc92986 --- /dev/null +++ b/.changeset/c3-frameworks-update-14235.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": patch +--- + +Update dependencies of "create-cloudflare" + +The following dependency versions have been updated: + +| Dependency | From | To | +| ------------- | ------ | ------ | +| @tanstack/cli | 0.69.1 | 0.69.2 | diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index e35d9265b5..11bd58b5f5 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -2,7 +2,7 @@ "name": "frameworks_clis_info", "dependencies": { "@angular/create": "22.0.0", - "@tanstack/cli": "0.69.1", + "@tanstack/cli": "0.69.2", "create-analog": "2.6.0", "create-astro": "5.0.6", "create-docusaurus": "3.10.1", From b190702ecc38051c99165c9a42c72bb13f31ee71 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:22:34 +0100 Subject: [PATCH 2/3] move deploy() and versionsUpload() to deploy-helpers (#14171) --- packages/cli/package.json | 2 +- packages/create-cloudflare/package.json | 4 +- packages/deploy-helpers/package.json | 28 +- packages/deploy-helpers/scripts/deps.ts | 18 +- .../src/deploy/deploy.ts | 382 +++--- .../src/deploy/helpers/assets.ts | 401 ++++++ .../src/deploy/helpers/binding-utils.ts | 400 ++++++ .../src/deploy/helpers/bundle-reporter.ts | 44 + .../src/deploy/helpers/capnp.ts | 39 + .../helpers/check-remote-secrets-override.ts | 180 +++ .../helpers/check-workflow-conflicts.ts | 91 ++ .../src/deploy/helpers/config-diffs.ts | 688 +++++++++++ .../confirm-latest-deployment-overwrite.ts | 102 ++ .../helpers/create-worker-upload-form.ts | 882 ++++++++++++++ .../src/deploy/helpers/deploy-confirm.ts | 23 + .../src/deploy/helpers/deploy-wfp.ts | 10 + .../src/deploy/helpers/diff-json.ts | 113 ++ .../deploy/helpers/download-worker-config.ts | 140 +++ .../src/deploy/helpers/durable.ts | 108 ++ .../src/deploy/helpers/environments.ts | 59 + .../src/deploy/helpers/error-codes.ts | 6 + .../helpers/friendly-validator-errors.ts | 171 +++ .../deploy-helpers/src/deploy/helpers/hash.ts | 13 + .../deploy-helpers/src/deploy/helpers/jwt.ts | 24 + .../src/deploy/helpers/match-tag.ts | 90 ++ .../src/deploy/helpers/node-compat.ts | 54 + .../src/deploy/helpers/parse-bulk-input.ts | 125 ++ .../src/deploy/helpers/placement.ts | 31 + .../src/deploy/helpers/preview-alias.ts | 107 ++ .../src/deploy/helpers/print-bindings.ts | 1071 ++++++++++++++++ .../src/deploy/helpers/secrets-validation.ts | 78 ++ .../src/deploy/helpers/source-maps.ts | 202 ++++ .../src/deploy/helpers/sourcemap.ts | 322 +++++ .../helpers/use-service-environments.ts | 14 + .../src/deploy/helpers/validate-routes.ts | 71 ++ .../src/deploy/helpers/versions-api.ts | 187 +++ .../src/deploy/helpers/versions-types.ts | 55 + .../deploy/helpers/worker-not-found-error.ts | 31 + .../deploy/helpers/workers-sites-bindings.ts | 32 + .../src/deploy/versions-upload.ts | 540 +++++++++ packages/deploy-helpers/src/index.ts | 39 + packages/deploy-helpers/src/shared/context.ts | 44 + packages/deploy-helpers/src/shared/types.ts | 4 + .../deploy-helpers/src/triggers/deploy.ts | 159 ++- .../src/triggers/publish-routes.ts | 53 +- .../src/triggers/queue-consumers.ts | 157 +-- .../deploy-helpers/src/triggers/subdomain.ts | 39 +- packages/deploy-helpers/src/triggers/zones.ts | 10 +- packages/deploy-helpers/tests/index.test.ts | 24 +- packages/deploy-helpers/tsup.config.ts | 22 +- packages/deploy-helpers/vitest.config.mts | 1 + packages/workers-utils/package.json | 2 +- packages/workers-utils/src/cfetch/index.ts | 64 +- packages/workers-utils/src/cloudflared.ts | 26 +- packages/workers-utils/src/index.ts | 3 +- packages/workers-utils/src/logger.ts | 30 +- packages/workers-utils/src/tunnel.ts | 6 +- packages/workers-utils/src/types.ts | 199 +++ packages/wrangler/package.json | 9 +- .../api/startDevWorker/utils.test.ts | 2 +- .../src/__tests__/deploy/assets.test.ts | 10 +- .../src/__tests__/deploy/bindings.test.ts | 8 +- .../src/__tests__/deploy/build.test.ts | 10 +- .../check-remote-secrets-override.test.ts | 49 +- .../deploy/check-workflow-conflicts.test.ts | 74 +- .../deploy/config-args-merging.test.ts | 8 +- .../__tests__/deploy/config-remote.test.ts | 64 +- .../src/__tests__/deploy/core.test.ts | 10 +- .../deploy/deploy-interactive-prompts.test.ts | 4 - .../__tests__/deploy/durable-objects.test.ts | 24 +- .../src/__tests__/deploy/entry-points.test.ts | 10 +- .../src/__tests__/deploy/environments.test.ts | 10 +- .../src/__tests__/deploy/formats.test.ts | 10 +- .../__tests__/deploy/legacy-assets.test.ts | 10 +- .../src/__tests__/deploy/open-next.test.ts | 10 +- .../src/__tests__/deploy/queues.test.ts | 10 +- .../src/__tests__/deploy/routes.test.ts | 10 +- .../src/__tests__/deploy/secrets.test.ts | 10 +- .../src/__tests__/deploy/workers-dev.test.ts | 10 +- .../src/__tests__/deploy/workflows.test.ts | 10 +- .../friendly-validator-errors.test.ts | 9 +- .../wrangler/src/__tests__/provision.test.ts | 12 +- .../versions/versions.upload.test.ts | 5 +- .../wrangler/src/__tests__/vitest.setup.ts | 21 + packages/wrangler/src/__tests__/zones.test.ts | 103 +- .../src/api/deploy-helpers-context.ts | 23 + packages/wrangler/src/api/index.ts | 6 +- .../src/api/integrations/platform/index.ts | 2 +- .../api/startDevWorker/BundlerController.ts | 2 +- .../api/startDevWorker/ConfigController.ts | 10 +- .../wrangler/src/api/startDevWorker/DevEnv.ts | 20 + .../src/api/startDevWorker/binding-utils.ts | 54 + .../startDevWorker/bundle-allowed-paths.ts | 6 +- .../wrangler/src/api/startDevWorker/index.ts | 2 +- .../wrangler/src/api/startDevWorker/types.ts | 233 +--- .../wrangler/src/api/startDevWorker/utils.ts | 431 +------ packages/wrangler/src/api/test-harness.ts | 11 +- packages/wrangler/src/assets.ts | 404 +------ packages/wrangler/src/cfetch/internal.ts | 27 +- .../src/core/deploy-helpers-context.ts | 37 - .../src/core/register-yargs-command.ts | 22 +- packages/wrangler/src/core/types.ts | 9 +- .../deploy/check-remote-secrets-override.ts | 167 +-- .../src/deploy/check-workflow-conflicts.ts | 92 +- packages/wrangler/src/deploy/config-diffs.ts | 758 +----------- packages/wrangler/src/deploy/index.ts | 19 +- .../src/deployment-bundle/bindings.ts | 16 +- .../src/deployment-bundle/bundle-reporter.ts | 45 +- .../wrangler/src/deployment-bundle/capnp.ts | 40 +- .../create-worker-upload-form.ts | 890 +------------- .../deployment-bundle/merge-config-args.ts | 2 +- .../src/deployment-bundle/node-compat.ts | 55 +- .../deployment-bundle/resolve-config-args.ts | 68 +- .../deployment-bundle/secrets-validation.ts | 83 +- .../src/deployment-bundle/source-maps.ts | 188 +-- packages/wrangler/src/dev.ts | 2 +- .../wrangler/src/dev/create-worker-preview.ts | 2 - packages/wrangler/src/dev/inspect.ts | 7 +- packages/wrangler/src/dev/miniflare/index.ts | 8 +- packages/wrangler/src/dev/start-dev.ts | 4 +- packages/wrangler/src/durable.ts | 110 +- packages/wrangler/src/environments/index.ts | 65 +- packages/wrangler/src/logger.ts | 14 +- packages/wrangler/src/match-tag.ts | 85 +- packages/wrangler/src/pages/deploy.ts | 4 +- packages/wrangler/src/pages/hash.ts | 14 +- packages/wrangler/src/pages/upload.ts | 29 +- packages/wrangler/src/queues/client.ts | 53 +- packages/wrangler/src/secret/index.ts | 148 +-- packages/wrangler/src/sourcemap.ts | 328 +---- packages/wrangler/src/triggers/index.ts | 19 +- packages/wrangler/src/utils/diff-json.ts | 129 +- .../src/utils/download-worker-config.ts | 139 +-- packages/wrangler/src/utils/error-codes.ts | 7 +- packages/wrangler/src/utils/fetch-secrets.ts | 21 - .../src/utils/friendly-validator-errors.ts | 167 +-- packages/wrangler/src/utils/placement.ts | 32 +- packages/wrangler/src/utils/print-bindings.ts | 1076 +---------------- .../src/utils/useServiceEnvironments.ts | 23 +- .../src/utils/worker-not-found-error.ts | 37 +- packages/wrangler/src/versions/api.ts | 149 +-- packages/wrangler/src/versions/deploy.ts | 106 +- packages/wrangler/src/versions/upload.ts | 636 +--------- packages/wrangler/src/zones.ts | 16 +- pnpm-lock.yaml | 98 +- pnpm-workspace.yaml | 3 + 146 files changed, 8152 insertions(+), 7443 deletions(-) rename packages/{wrangler => deploy-helpers}/src/deploy/deploy.ts (75%) create mode 100644 packages/deploy-helpers/src/deploy/helpers/assets.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/binding-utils.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/bundle-reporter.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/capnp.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/check-remote-secrets-override.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/config-diffs.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/deploy-confirm.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/deploy-wfp.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/diff-json.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/download-worker-config.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/durable.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/environments.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/error-codes.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/friendly-validator-errors.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/hash.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/jwt.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/match-tag.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/node-compat.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/parse-bulk-input.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/placement.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/preview-alias.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/print-bindings.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/secrets-validation.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/source-maps.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/sourcemap.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/use-service-environments.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/validate-routes.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/versions-api.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/versions-types.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/worker-not-found-error.ts create mode 100644 packages/deploy-helpers/src/deploy/helpers/workers-sites-bindings.ts create mode 100644 packages/deploy-helpers/src/deploy/versions-upload.ts create mode 100644 packages/deploy-helpers/src/shared/context.ts create mode 100644 packages/wrangler/src/api/deploy-helpers-context.ts create mode 100644 packages/wrangler/src/api/startDevWorker/binding-utils.ts delete mode 100644 packages/wrangler/src/core/deploy-helpers-context.ts delete mode 100644 packages/wrangler/src/utils/fetch-secrets.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index b72e3db414..e20ee77c34 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -70,7 +70,7 @@ "dependencies": { "@clack/core": "1.2.0", "@cloudflare/workers-utils": "workspace:*", - "chalk": "5.3.0", + "chalk": "catalog:default", "ci-info": "catalog:default", "cross-spawn": "7.0.6", "log-update": "5.0.1" diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index fef2f6c0a1..16f5b9c41f 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -57,13 +57,13 @@ "@types/semver": "^7.5.1", "@types/which-pm-runs": "^1.0.0", "@types/yargs": "^17.0.22", - "command-exists": "^1.2.9", + "command-exists": "catalog:default", "comment-json": "^4.5.0", "cross-spawn": "^7.0.3", "deepmerge": "^4.3.1", "degit": "^2.8.4", "dns2": "^2.1.0", - "dotenv": "^16.0.0", + "dotenv": "catalog:default", "esbuild": "catalog:default", "execa": "^7.1.1", "exit-hook": "2.2.1", diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index 2d5e891c7c..c246d638ed 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -21,6 +21,10 @@ ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.mts" + }, + "./context": { + "import": "./dist/context.mjs", + "types": "./dist/context.d.mts" } }, "scripts": { @@ -33,16 +37,30 @@ "type:tests": "tsc -p ./tests/tsconfig.json" }, "dependencies": { - "@cloudflare/workers-utils": "workspace:*" + "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/containers-shared": "workspace:*", + "@cloudflare/workers-shared": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", + "blake3-wasm": "2.1.5", + "chalk": "catalog:default", + "command-exists": "catalog:default", + "dotenv": "catalog:default", + "miniflare": "workspace:*", + "p-queue": "9.0.0", + "pretty-bytes": "6.1.1", + "undici": "catalog:default" }, "devDependencies": { - "@cloudflare/containers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", + "@cspotcode/source-map-support": "0.8.1", + "@types/command-exists": "^1.2.0", + "@types/json-diff": "^1.0.3", "@types/node": "catalog:default", - "chalk": "^5.2.0", "concurrently": "^8.2.2", - "miniflare": "workspace:*", - "p-queue": "^9.0.0", + "devtools-protocol": "^0.0.1182435", + "esbuild": "catalog:default", + "json-diff": "^1.0.6", + "ts-dedent": "^2.2.0", "tsup": "8.3.0", "typescript": "catalog:default", "vitest": "catalog:default" diff --git a/packages/deploy-helpers/scripts/deps.ts b/packages/deploy-helpers/scripts/deps.ts index bdc4c68d80..709957e156 100644 --- a/packages/deploy-helpers/scripts/deps.ts +++ b/packages/deploy-helpers/scripts/deps.ts @@ -5,7 +5,21 @@ * This list is validated by `tools/deployments/validate-package-dependencies.ts`. */ export const EXTERNAL_DEPENDENCIES = [ - // Workspace package kept external so consumers share a single copy of - // workers-utils types and runtime code (e.g. ParseError instanceof checks). + // Workspace packages kept external so consumers share a single copy of + // types and runtime code (e.g. ParseError instanceof checks). + "@cloudflare/cli-shared-helpers", + "@cloudflare/containers-shared", "@cloudflare/workers-utils", + "@cloudflare/workers-shared", + "miniflare", + + // These are externalized to avoid duplication in wrangler's bundle, + // which already bundles these packages itself. + "blake3-wasm", + "chalk", + "command-exists", + "dotenv", + "p-queue", + "pretty-bytes", + "undici", ]; diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/deploy-helpers/src/deploy/deploy.ts similarity index 75% rename from packages/wrangler/src/deploy/deploy.ts rename to packages/deploy-helpers/src/deploy/deploy.ts index 89ace738ad..2c1d7dacdf 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/deploy-helpers/src/deploy/deploy.ts @@ -4,116 +4,148 @@ import path from "node:path"; import { URLSearchParams } from "node:url"; import { cancel } from "@cloudflare/cli-shared-helpers"; import { verifyDockerInstalled } from "@cloudflare/containers-shared"; -import { triggersDeploy } from "@cloudflare/deploy-helpers"; import { APIError, configFileName, experimental_patchConfig, - getTodaysCompatDate, formatConfigSnippet, + formatTime, getDockerPath, + getTodaysCompatDate, parseNonHyphenedUuid, + retryOnAPIFailure, UserError, - formatTime, } from "@cloudflare/workers-utils"; import { Response } from "undici"; -import { buildAssetManifest, syncAssets } from "../assets"; -import { fetchResult } from "../cfetch"; -import { buildContainer } from "../containers/build"; -import { getNormalizedContainerOptions } from "../containers/config"; -import { deployContainers } from "../containers/deploy"; -import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; -import { printBundleSize } from "../deployment-bundle/bundle-reporter"; -import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; -import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; -import { validateRoutes } from "../deployment-bundle/resolve-config-args"; -import { - addRequiredSecretsInheritBindings, - handleMissingSecretsError, -} from "../deployment-bundle/secrets-validation"; -import { loadSourceMaps } from "../deployment-bundle/source-maps"; -import { confirm } from "../dialogs"; -import { getMigrationsToUpload } from "../durable"; +import { confirm, fetchResult, logger } from "../shared/context"; +import { triggersDeploy } from "../triggers/deploy"; +import { ensureQueuesExistByConfig } from "../triggers/queue-consumers"; +import { buildAssetManifest, syncAssets } from "./helpers/assets"; +import { getBindings } from "./helpers/binding-utils"; +import { printBundleSize } from "./helpers/bundle-reporter"; +import { checkRemoteSecretsOverride } from "./helpers/check-remote-secrets-override"; +import { checkWorkflowConflicts } from "./helpers/check-workflow-conflicts"; +import { getConfigPatch, getRemoteConfigDiff } from "./helpers/config-diffs"; +import { confirmLatestDeploymentOverwrite } from "./helpers/confirm-latest-deployment-overwrite"; +import { createWorkerUploadForm } from "./helpers/create-worker-upload-form"; +import { getDeployConfirmFunction } from "./helpers/deploy-confirm"; +import { deployWfpUserWorker } from "./helpers/deploy-wfp"; +import { downloadWorkerConfig } from "./helpers/download-worker-config"; +import { getMigrationsToUpload } from "./helpers/durable"; import { applyServiceAndEnvironmentTags, tagsAreEqual, warnOnErrorUpdatingServiceAndEnvironmentTags, -} from "../environments"; -import { isNonInteractiveOrCI } from "../is-interactive"; -import { logger } from "../logger"; -import { verifyWorkerMatchesCITag } from "../match-tag"; -import { ensureQueuesExistByConfig } from "../queues/client"; -import { parseBulkInputToObject } from "../secret"; -import { syncWorkersSite } from "../sites"; +} from "./helpers/environments"; +import { helpIfErrorIsSizeOrScriptStartup } from "./helpers/friendly-validator-errors"; +import { verifyWorkerMatchesCITag } from "./helpers/match-tag"; +import { validateNodeCompatMode } from "./helpers/node-compat"; +import { parseBulkInputToObject } from "./helpers/parse-bulk-input"; +import { parseConfigPlacement } from "./helpers/placement"; +import { printBindings } from "./helpers/print-bindings"; +import { + addRequiredSecretsInheritBindings, + handleMissingSecretsError, +} from "./helpers/secrets-validation"; +import { loadSourceMaps } from "./helpers/source-maps"; import { getSourceMappedString, maybeRetrieveFileSourceMap, -} from "../sourcemap"; -import { downloadWorkerConfig } from "../utils/download-worker-config"; -import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors"; -import { parseConfigPlacement } from "../utils/placement"; -import { printBindings } from "../utils/print-bindings"; -import { retryOnAPIFailure } from "../utils/retry"; -import { useServiceEnvironments as useServiceEnvironmentsConfig } from "../utils/useServiceEnvironments"; -import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; +} from "./helpers/sourcemap"; +import { useServiceEnvironments as useServiceEnvironmentsConfig } from "./helpers/use-service-environments"; +import { validateRoutes } from "./helpers/validate-routes"; import { createDeployment, patchNonVersionedScriptSettings, -} from "../versions/api"; -import { confirmLatestDeploymentOverwrite } from "../versions/deploy"; -import { checkRemoteSecretsOverride } from "./check-remote-secrets-override"; -import { checkWorkflowConflicts } from "./check-workflow-conflicts"; -import { getConfigPatch, getRemoteConfigDiff } from "./config-diffs"; -import type { StartDevWorkerInput } from "../api/startDevWorker/types"; -import type { HandlerContext } from "../core/types"; -import type { RetrieveSourceMapFunction } from "../sourcemap"; -import type { ApiVersion, Percentage, VersionId } from "../versions/types"; -import type { DeployProps, HandleBuild } from "@cloudflare/deploy-helpers"; +} from "./helpers/versions-api"; +import { isWorkerNotFoundError } from "./helpers/worker-not-found-error"; +import { addWorkersSitesBindings } from "./helpers/workers-sites-bindings"; +import type { DeployProps, HandleBuild } from "../shared/types"; +import type { RetrieveSourceMapFunction } from "./helpers/sourcemap"; +import type { + ApiVersion, + Percentage, + VersionId, +} from "./helpers/versions-types"; import type { + ContainerNormalizedConfig, + ImageURIConfig, +} from "@cloudflare/containers-shared"; +import type { + Binding, CfModule, - CfScriptFormat, CfWorkerInit, + ComplianceConfig, Config, + LegacyAssetPaths, RawConfig, } from "@cloudflare/workers-utils"; import type { FormData } from "undici"; /** - * Inject bindings into the Worker to support Workers Sites. These are injected at the last minute so that - * they don't display in the output of `printBindings()` + * Wrangler-specific functions injected into `deploy()`. These remain in + * wrangler because they depend on wrangler-only systems (account selection, + * metrics, the dev-mode worker registry, container orchestration, etc.). */ -function addWorkersSitesBindings( - bindings: NonNullable, - namespace: string | undefined, - manifest: - | { - [filePath: string]: string; - } - | undefined, - format: CfScriptFormat -) { - const withSites = { ...bindings }; - if (namespace) { - withSites["__STATIC_CONTENT"] = { - type: "kv_namespace", - id: namespace, - }; - } - - if (manifest && format === "service-worker") { - withSites["__STATIC_CONTENT_MANIFEST"] = { - type: "text_blob", - source: { contents: "__STATIC_CONTENT_MANIFEST" }, - }; - } - return withSites; -} +export type DeployCallbacks = { + syncWorkersSite: + | (( + complianceConfig: ComplianceConfig, + accountId: string | undefined, + scriptName: string, + siteAssets: LegacyAssetPaths | undefined, + preview: boolean, + dryRun: boolean | undefined, + oldAssetTTL: number | undefined + ) => Promise<{ + manifest: { [filePath: string]: string } | undefined; + namespace: string | undefined; + }>) + | undefined; + provisionBindings: + | (( + bindings: Record, + accountId: string, + scriptName: string, + autoCreate: boolean, + config: Config, + requireRemote?: boolean + ) => Promise) + | undefined; + getNormalizedContainerOptions: + | (( + config: Config, + args: { + containersRollout?: "gradual" | "immediate" | "none"; + dryRun?: boolean; + } + ) => Promise) + | undefined; + buildContainer: + | (( + containerConfig: Exclude, + imageTag: string, + dryRun: boolean, + pathToDocker: string + ) => Promise) + | undefined; + deployContainers: + | (( + config: Config, + normalisedContainerConfig: ContainerNormalizedConfig[], + args: { versionId: string; accountId: string; scriptName: string } + ) => Promise) + | undefined; + analyseBundle: + | ((workerBundle: string | FormData) => Promise>) + | undefined; +}; export default async function deploy( props: DeployProps, config: Config, buildWorker: HandleBuild, - ctx: Omit + callbacks: DeployCallbacks ): Promise<{ sourceMapSize?: number; versionId: string | null; @@ -144,7 +176,9 @@ export default async function deploy( await verifyWorkerMatchesCITag(config, accountId, name, config.configPath); } - const deployConfirm = getDeployConfirmFunction(props.strict); + const deployConfirm = getDeployConfirmFunction({ + strictMode: props.strict, + }); // TODO: warn if git/hg has uncommitted changes let workerTag: string | null = null; @@ -245,6 +279,7 @@ export default async function deploy( if (accountId) { const remoteSecretsCheck = await checkRemoteSecretsOverride( config, + accountId, props.env ); @@ -370,10 +405,9 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m const isDryRun = props.dryRun; - const normalisedContainerConfig = await getNormalizedContainerOptions( - config, - props - ); + const normalisedContainerConfig = callbacks.getNormalizedContainerOptions + ? await callbacks.getNormalizedContainerOptions(config, props) + : []; const { modules, dependencies, @@ -413,19 +447,21 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m await buildAssetManifest(props.assetsOptions.directory); } - const workersSitesAssets = await syncWorkersSite( - config, - accountId, - // When we're using the newer service environments, we wouldn't - // have added the env name on to the script name. However, we must - // include it in the kv namespace name regardless (since there's no - // concept of service environments for kv namespaces yet). - scriptName + (useServiceEnvironments ? `-${props.env}` : ""), - props.legacyAssetPaths, - false, - isDryRun, - props.oldAssetTtl - ); + const workersSitesAssets = callbacks.syncWorkersSite + ? await callbacks.syncWorkersSite( + config, + accountId, + // When we're using the newer service environments, we wouldn't + // have added the env name on to the script name. However, we must + // include it in the kv namespace name regardless (since there's no + // concept of service environments for kv namespaces yet). + scriptName + (useServiceEnvironments ? `-${props.env}` : ""), + props.legacyAssetPaths, + false, + isDryRun, + props.oldAssetTtl + ) + : { manifest: undefined, namespace: undefined }; const bindings = getBindings(config); @@ -567,8 +603,12 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m if (isDryRun) { if (normalisedContainerConfig.length) { for (const container of normalisedContainerConfig) { - if ("dockerfile" in container && props.containersRollout !== "none") { - await buildContainer( + if ( + "dockerfile" in container && + props.containersRollout !== "none" && + callbacks.buildContainer + ) { + await callbacks.buildContainer( container, workerTag ?? "worker-tag", isDryRun, @@ -602,8 +642,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } else { assert(accountId, "Missing accountId"); - if (props.resourcesProvision) { - await provisionBindings( + if (props.resourcesProvision && callbacks.provisionBindings) { + await callbacks.provisionBindings( bindings ?? {}, accountId, scriptName, @@ -625,7 +665,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } ); - await ensureQueuesExistByConfig(config); + await ensureQueuesExistByConfig(config, accountId); let bindingsPrinted = false; // Upload the script so it has time to propagate. @@ -642,19 +682,21 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m // If we're using the new APIs, first upload the version if (canUseNewVersionsDeploymentsApi) { // Upload new version - const versionResult = await retryOnAPIFailure(async () => - fetchResult( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions`, - { - method: "POST", - body: workerBundle, - headers: props.sendMetrics - ? { metricsEnabled: "true" } - : undefined, - }, - new URLSearchParams({ bindings_inherit: "strict" }) - ) + const versionResult = await retryOnAPIFailure( + async () => + fetchResult( + config, + `/accounts/${accountId}/workers/scripts/${scriptName}/versions`, + { + method: "POST", + body: workerBundle, + headers: props.sendMetrics + ? { metricsEnabled: "true" } + : undefined, + }, + new URLSearchParams({ bindings_inherit: "strict" }) + ), + logger ); // Deploy new version to 100% @@ -665,7 +707,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m accountId, scriptName, versionMap, - props.message + props.message, + undefined ); // Update service and environment tags when using environments @@ -695,31 +738,33 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m startup_time_ms: versionResult.startup_time_ms, }; } else { - result = await retryOnAPIFailure(async () => - fetchResult<{ - id: string | null; - etag: string | null; - pipeline_hash: string | null; - mutable_pipeline_id: string | null; - deployment_id: string | null; - startup_time_ms: number; - }>( - config, - workerUrl, - { - method: "PUT", - body: workerBundle, - headers: props.sendMetrics - ? { metricsEnabled: "true" } - : undefined, - }, - new URLSearchParams({ - // pass excludeScript so the whole body of the - // script doesn't get included in the response - excludeScript: "true", - bindings_inherit: "strict", - }) - ) + result = await retryOnAPIFailure( + async () => + fetchResult<{ + id: string | null; + etag: string | null; + pipeline_hash: string | null; + mutable_pipeline_id: string | null; + deployment_id: string | null; + startup_time_ms: number; + }>( + config, + workerUrl, + { + method: "PUT", + body: workerBundle, + headers: props.sendMetrics + ? { metricsEnabled: "true" } + : undefined, + }, + new URLSearchParams({ + // pass excludeScript so the whole body of the + // script doesn't get included in the response + excludeScript: "true", + bindings_inherit: "strict", + }) + ), + logger ); // Update service and environment tags when using environments @@ -789,7 +834,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m err, dependencies, workerBundle, - projectRoot + projectRoot, + callbacks.analyseBundle ); if (message !== null) { logger.error(message); @@ -867,9 +913,13 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m logger.log("Uploaded", workerName, formatTime(uploadMs)); - if (normalisedContainerConfig.length && props.containersRollout !== "none") { + if ( + normalisedContainerConfig.length && + props.containersRollout !== "none" && + callbacks.deployContainers + ) { assert(versionId && accountId); - await deployContainers(config, normalisedContainerConfig, { + await callbacks.deployContainers(config, normalisedContainerConfig, { versionId, accountId, scriptName, @@ -883,19 +933,16 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } assert(accountId); // deploy triggers - const targets = await triggersDeploy( - { - config, - accountId, - scriptName, - env: props.env, - crons: props.triggers, - useServiceEnvironments, - firstDeploy: !workerExists, - routes: allDeploymentRoutes, - }, - ctx - ); + const targets = await triggersDeploy({ + config, + accountId, + scriptName, + env: props.env, + crons: props.triggers, + useServiceEnvironments, + firstDeploy: !workerExists, + routes: allDeploymentRoutes, + }); logger.log("Current Version ID:", versionId); @@ -906,32 +953,3 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m targets: targets ?? [], }; } - -function deployWfpUserWorker( - dispatchNamespace: string, - versionId: string | null -) { - // Will go under the "Uploaded" text - logger.log(" Dispatch Namespace:", dispatchNamespace); - logger.log("Current Version ID:", versionId); -} - -function getDeployConfirmFunction( - strictMode = false -): (text: string) => Promise { - const nonInteractive = isNonInteractiveOrCI(); - - if (nonInteractive && strictMode) { - return async () => { - logger.error( - "Aborting the deployment operation because of conflicts. To override and deploy anyway remove the `--strict` flag" - ); - process.exitCode = 1; - return false; - }; - } else if (nonInteractive) { - // if its not in strict mode, continue without asking - return async () => true; - } - return confirm; -} diff --git a/packages/deploy-helpers/src/deploy/helpers/assets.ts b/packages/deploy-helpers/src/deploy/helpers/assets.ts new file mode 100644 index 0000000000..f0cd5b570c --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/assets.ts @@ -0,0 +1,401 @@ +import assert from "node:assert"; +import { readdir, readFile, stat } from "node:fs/promises"; +import * as path from "node:path"; +import { + CF_ASSETS_IGNORE_FILENAME, + MAX_ASSET_SIZE, +} from "@cloudflare/workers-shared/utils/constants"; +import { + createAssetsIgnoreFunction, + getContentType, + normalizeFilePath, +} from "@cloudflare/workers-shared/utils/helpers"; +import { + APIError, + FatalError, + formatTime, + LOGGER_LEVELS, + UserError, +} from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import PQueue from "p-queue"; +import prettyBytes from "pretty-bytes"; +import { FormData } from "undici"; +import { fetchResult, logger } from "../../shared/context"; +import { hashFile } from "./hash"; +import { isJwtExpired } from "./jwt"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; + +export type AssetManifest = { [path: string]: { hash: string; size: number } }; + +type InitializeAssetsResponse = { + // string of file hashes per bucket + buckets: string[][]; + jwt: string; +}; + +type UploadResponse = { + jwt?: string; +}; + +// constants same as Pages for now +const BULK_UPLOAD_CONCURRENCY = 3; +const MAX_UPLOAD_ATTEMPTS = 5; +const MAX_UPLOAD_GATEWAY_ERRORS = 5; + +const MAX_DIFF_LINES = 100; + +export const syncAssets = async ( + complianceConfig: ComplianceConfig, + accountId: string | undefined, + assetDirectory: string, + scriptName: string, + dispatchNamespace?: string +): Promise => { + assert(accountId, "Missing accountId"); + + // 1. generate asset manifest + logger.info("πŸŒ€ Building list of assets..."); + const manifest = await buildAssetManifest(assetDirectory); + + const url = dispatchNamespace + ? `/accounts/${accountId}/workers/dispatch/namespaces/${dispatchNamespace}/scripts/${scriptName}/assets-upload-session` + : `/accounts/${accountId}/workers/scripts/${scriptName}/assets-upload-session`; + + // 2. fetch buckets w/ hashes + logger.info("πŸŒ€ Starting asset upload..."); + const initializeAssetsResponse = + await fetchResult(complianceConfig, url, { + headers: { "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify({ manifest: manifest }), + }); + + // In the past we've seen the endpoint return that incorrectly doesn't contain + // a null response (see: https://github.com/cloudflare/workers-sdk/issues/9465). + // So just to be extra sure here we check the object and provide a clear error message to the user + // if it is falsy. + if (!initializeAssetsResponse) { + throw new FatalError( + "An unexpected response has been received from the Cloudflare API for assets upload. Please try again.", + { code: 1, telemetryMessage: "assets upload unexpected api response" } + ); + } + + // if nothing to upload, return + if (initializeAssetsResponse.buckets.flat().length === 0) { + if (!initializeAssetsResponse.jwt) { + throw new FatalError( + "Could not find assets information to attach to deployment. Please try again.", + { code: 1, telemetryMessage: "assets upload missing completion token" } + ); + } + logger.info( + `No updated asset files to upload. Proceeding with deployment...` + ); + return initializeAssetsResponse.jwt; + } + + // 3. fill buckets and upload assets + const numberFilesToUpload = initializeAssetsResponse.buckets.flat().length; + logger.info( + `πŸŒ€ Found ${numberFilesToUpload} new or modified static asset${ + numberFilesToUpload > 1 ? "s" : "" + } to upload. Proceeding with upload...` + ); + + // Create the buckets outside of doUpload so we can retry without losing track of potential duplicate files + // But don't add the actual content until uploading so we don't run out of memory + const manifestLookup = Object.entries(manifest); + let assetLogCount = 0; + const assetBuckets = initializeAssetsResponse.buckets.map((bucket) => { + return bucket.map((fileHash) => { + const manifestEntry = manifestLookup.find( + (file) => file[1].hash === fileHash + ); + if (manifestEntry === undefined) { + throw new FatalError( + `A file was requested that does not appear to exist.`, + { + code: 1, + telemetryMessage: + "A file was requested that does not appear to exist. (asset manifest upload)", + } + ); + } + // just logging file uploads at the moment... + // unsure how to log deletion vs unchanged file ignored/if we want to log this + assetLogCount = logAssetUpload(`+ ${manifestEntry[0]}`, assetLogCount); + return manifestEntry; + }); + }); + + const queue = new PQueue({ concurrency: BULK_UPLOAD_CONCURRENCY }); + const queuePromises: Array> = []; + let attempts = 0; + const start = Date.now(); + let completionJwt = ""; + let uploadedAssetsCount = 0; + + for (const [bucketIndex, bucket] of assetBuckets.entries()) { + attempts = 0; + let gatewayErrors = 0; + const doUpload = async (): Promise => { + // Populate the payload only when actually uploading (this is limited to 3 concurrent uploads at 50 MiB per bucket meaning we'd only load in a max of ~150 MiB) + // This is so we don't run out of memory trying to upload the files. + const payload = new FormData(); + const uploadedFiles: string[] = []; + for (const manifestEntry of bucket) { + const absFilePath = path.join(assetDirectory, manifestEntry[0]); + uploadedFiles.push(manifestEntry[0]); + payload.append( + manifestEntry[1].hash, + new File( + [(await readFile(absFilePath)).toString("base64")], + manifestEntry[1].hash, + { + // Most formdata body encoders (incl. undici's) will override with "application/octet-stream" if you use a falsy value here + // Additionally, it appears that undici doesn't support non-standard main types (e.g. "null") + // So, to make it easier for any other clients, we'll just parse "application/null" on the API + // to mean actually null (signal to not send a Content-Type header with the response) + type: getContentType(absFilePath) ?? "application/null", + } + ), + manifestEntry[1].hash + ); + } + + try { + const res = await fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/assets/upload?base64=true`, + { + method: "POST", + headers: { + Authorization: `Bearer ${initializeAssetsResponse.jwt}`, + }, + body: payload, + } + ); + uploadedAssetsCount += bucket.length; + logAssetsUploadStatus( + numberFilesToUpload, + uploadedAssetsCount, + uploadedFiles + ); + return res; + } catch (e) { + if (attempts < MAX_UPLOAD_ATTEMPTS) { + logger.info( + chalk.dim( + `Asset upload failed. Retrying... ${attempts + 1} of ${MAX_UPLOAD_ATTEMPTS} attempts.\n` + ) + ); + logger.debug(e); + // Exponential backoff, 1 second first time, then 2 second, then 4 second etc. + await new Promise((resolvePromise) => + setTimeout(resolvePromise, Math.pow(2, attempts) * 1000) + ); + if (e instanceof APIError && e.isGatewayError()) { + // Gateway problem, wait for some additional time and set concurrency to 1 + queue.concurrency = 1; + await new Promise((resolvePromise) => + setTimeout(resolvePromise, Math.pow(2, gatewayErrors) * 5000) + ); + gatewayErrors++; + // only count as a failed attempt after a few initial gateway errors + if (gatewayErrors >= MAX_UPLOAD_GATEWAY_ERRORS) { + attempts++; + } + } else { + attempts++; + } + return doUpload(); + } else if (isJwtExpired(initializeAssetsResponse.jwt)) { + throw new FatalError( + `Upload took too long.\n` + + `Asset upload took too long on bucket ${bucketIndex + 1}/${ + initializeAssetsResponse.buckets.length + }. Please try again.\n` + + `Assets already uploaded have been saved, so the next attempt will automatically resume from this point.`, + { telemetryMessage: "Asset upload took too long" } + ); + } else { + throw e; + } + } + }; + // add to queue and run it if we haven't reached concurrency limit + queuePromises.push( + queue.add(() => + doUpload().then((res) => { + completionJwt = res.jwt || completionJwt; + }) + ) + ); + } + queue.on("error", (error) => { + logger.error(error.message); + throw error; + }); + // using Promise.all() here instead of queue.onIdle() to ensure + // we actually throw errors that occur within queued promises. + await Promise.all(queuePromises); + + // if queue finishes without receiving JWT from asset upload service (AUS) + // AUS only returns this in the final bucket upload response + if (!completionJwt) { + throw new FatalError("Failed to complete asset upload. Please try again.", { + code: 1, + telemetryMessage: "assets upload completion failed", + }); + } + + const uploadMs = Date.now() - start; + const skipped = Object.keys(manifest).length - numberFilesToUpload; + const skippedMessage = skipped > 0 ? `(${skipped} already uploaded) ` : ""; + + logger.log( + `✨ Success! Uploaded ${numberFilesToUpload} file${ + numberFilesToUpload > 1 ? "s" : "" + } ${skippedMessage}${formatTime(uploadMs)}\n` + ); + + return completionJwt; +}; + +export const buildAssetManifest = async (dir: string) => { + const files = await readdir(dir, { recursive: true }); + logReadFilesFromDirectory(dir, files); + + const manifest: AssetManifest = {}; + + const { assetsIgnoreFunction, assetsIgnoreFilePresent } = + await createAssetsIgnoreFunction(dir); + + await Promise.all( + files.map(async (relativeFilepath) => { + if (assetsIgnoreFunction(relativeFilepath)) { + logger.debug("Ignoring asset:", relativeFilepath); + // This file should not be included in the manifest. + return; + } + + const filepath = path.join(dir, relativeFilepath); + const filestat = await stat(filepath); + + if (filestat.isSymbolicLink() || filestat.isDirectory()) { + return; + } else { + errorOnLegacyPagesWorkerJSAsset( + relativeFilepath, + assetsIgnoreFilePresent + ); + + if (filestat.size > MAX_ASSET_SIZE) { + throw new UserError( + `Asset too large.\n` + + `Cloudflare Workers supports assets with sizes of up to ${prettyBytes( + MAX_ASSET_SIZE, + { + binary: true, + } + )}. We found a file ${filepath} with a size of ${prettyBytes( + filestat.size, + { + binary: true, + } + )}.\n` + + `Ensure all assets in your assets directory "${dir}" conform with the Workers maximum size requirement.`, + { telemetryMessage: "Asset too large" } + ); + } + manifest[normalizeFilePath(relativeFilepath)] = { + hash: hashFile(filepath), + size: filestat.size, + }; + } + }) + ); + return manifest; +}; + +function logAssetUpload(line: string, diffCount: number) { + const level = logger.loggerLevel ?? "log"; + if (LOGGER_LEVELS[level] >= LOGGER_LEVELS.debug) { + // If we're logging as debug level, we want *all* diff lines to be logged + // at debug level, not just the first MAX_DIFF_LINES + logger.debug(line); + } else if (diffCount < MAX_DIFF_LINES) { + // Otherwise, log the first MAX_DIFF_LINES diffs at info level... + logger.info(line); + } else if (diffCount === MAX_DIFF_LINES) { + // ...and warn when we start to truncate it + const msg = + " (truncating changed assets log, set `WRANGLER_LOG=debug` environment variable to see full diff)"; + logger.info(chalk.dim(msg)); + } + return ++diffCount; +} + +/** + * Logs a summary of the assets upload status ("Uploaded of assets"), + * and the list of uploaded files if in debug log level. + */ +function logAssetsUploadStatus( + numberFilesToUpload: number, + uploadedAssetsCount: number, + uploadedAssetFiles: string[] +) { + logger.info( + `Uploaded ${uploadedAssetsCount} of ${numberFilesToUpload} asset${ + numberFilesToUpload === 1 ? "" : "s" + }` + ); + uploadedAssetFiles.forEach((file) => logger.debug(`✨ ${file}`)); +} + +/** + * Logs a summary of files read from a given directory ("Read + * files from directory "), and the list of read files if in + * debug log level. + */ +function logReadFilesFromDirectory(directory: string, assetFiles: string[]) { + logger.info( + `✨ Read ${assetFiles.length} file${ + assetFiles.length === 1 ? "" : "s" + } from the assets directory ${directory}` + ); + assetFiles.forEach((file) => logger.debug(`/${file}`)); +} + +const WORKER_JS_FILENAME = "_worker.js"; + +/** + * Throws an error if the project has no `.assetsIgnore` file and is uploading + * _worker.js code as an asset, which could expose server-side code publicly. + */ +function errorOnLegacyPagesWorkerJSAsset( + file: string, + hasAssetsIgnoreFile: boolean +) { + if (!hasAssetsIgnoreFile) { + const workerJsType: "file" | "directory" | null = + file === WORKER_JS_FILENAME + ? "file" + : file.startsWith(WORKER_JS_FILENAME) + ? "directory" + : null; + if (workerJsType !== null) { + throw new UserError( + ` +Uploading a Pages ${WORKER_JS_FILENAME} ${workerJsType} as an asset. +This could expose your private server-side code to the public Internet. Is this intended? +If you do not want to upload this ${workerJsType}, either remove it or add an "${CF_ASSETS_IGNORE_FILENAME}" file, to the root of your asset directory, containing "${WORKER_JS_FILENAME}" to avoid uploading. +If you do want to upload this ${workerJsType}, you can add an empty "${CF_ASSETS_IGNORE_FILENAME}" file, to the root of your asset directory, to hide this error. + `.trim(), + { telemetryMessage: "assets validation legacy pages worker asset" } + ); + } + } +} diff --git a/packages/deploy-helpers/src/deploy/helpers/binding-utils.ts b/packages/deploy-helpers/src/deploy/helpers/binding-utils.ts new file mode 100644 index 0000000000..bb22968dea --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/binding-utils.ts @@ -0,0 +1,400 @@ +import type { + Binding, + Config, + ConfigBindingFieldName, +} from "@cloudflare/workers-utils"; + +function assertNever(_value: never) {} + +interface ConvertBindingsOptions { + /** + * Use preview IDs (preview_id, preview_bucket_name, preview_database_id) instead of production IDs when resolving a binding ID. + * This means that the rest of Wrangler does not need to be aware of preview IDs, and can just use regular IDs. + */ + usePreviewIds?: boolean; + /** + * Exclude bindings that Pages doesn't support + */ + pages?: boolean; +} + +/** + * Convert Config to the Record format for consistent internal use. + */ +export function convertConfigToBindings( + config: Partial>, + options?: ConvertBindingsOptions +): Record { + const { usePreviewIds = false, pages = false } = options ?? {}; + const output: Record = {}; + + type Entries = { [K in keyof T]: [K, T[K]] }[keyof T][]; + type ConfigIterable = Entries>>; + const configIterable = Object.entries(config) as ConfigIterable; + + for (const [type, info] of configIterable) { + if (info === undefined) { + continue; + } + + switch (type) { + case "vars": { + for (const [key, value] of Object.entries(info)) { + if (typeof value === "string") { + output[key] = { type: "plain_text", value }; + } else { + output[key] = { type: "json", value }; + } + } + break; + } + case "kv_namespaces": { + for (const { binding, ...x } of info) { + output[binding] = { + type: "kv_namespace", + ...x, + id: usePreviewIds ? (x.preview_id ?? x.id) : x.id, + }; + } + break; + } + case "send_email": { + if (pages) { + break; + } + for (const { name, ...x } of info) { + output[name] = { type: "send_email", ...x }; + } + break; + } + case "wasm_modules": { + if (pages) { + break; + } + for (const [key, value] of Object.entries(info)) { + if (typeof value === "string") { + output[key] = { type: "wasm_module", source: { path: value } }; + } else { + output[key] = { type: "wasm_module", source: { contents: value } }; + } + } + break; + } + case "text_blobs": { + if (pages) { + break; + } + for (const [key, value] of Object.entries(info)) { + output[key] = { type: "text_blob", source: { path: value } }; + } + break; + } + case "data_blobs": { + if (pages) { + break; + } + for (const [key, value] of Object.entries(info)) { + if (typeof value === "string") { + output[key] = { type: "data_blob", source: { path: value } }; + } else { + output[key] = { type: "data_blob", source: { contents: value } }; + } + } + break; + } + case "browser": { + const { binding, ...x } = info; + output[binding] = { type: "browser", ...x }; + break; + } + case "durable_objects": { + for (const { name, ...x } of info.bindings ?? []) { + output[name] = { type: "durable_object_namespace", ...x }; + } + break; + } + case "workflows": { + for (const { binding, ...x } of info) { + output[binding] = { type: "workflow", ...x }; + } + break; + } + case "queues": { + for (const { binding, ...x } of info.producers ?? []) { + output[binding] = { + type: "queue", + queue_name: x.queue, + ...x, + }; + } + break; + } + case "r2_buckets": { + for (const { binding, ...x } of info) { + output[binding] = { + type: "r2_bucket", + ...x, + bucket_name: usePreviewIds + ? (x.preview_bucket_name ?? x.bucket_name) + : x.bucket_name, + }; + } + break; + } + case "d1_databases": { + for (const { binding, ...x } of info) { + output[binding] = { + type: "d1", + ...x, + database_id: usePreviewIds + ? (x.preview_database_id ?? x.database_id) + : x.database_id, + }; + } + break; + } + case "services": { + for (const { binding, ...x } of info) { + output[binding] = { type: "service", ...x }; + } + break; + } + case "analytics_engine_datasets": { + for (const { binding, ...x } of info) { + output[binding] = { type: "analytics_engine", ...x }; + } + break; + } + case "dispatch_namespaces": { + if (pages) { + break; + } + for (const { binding, ...x } of info) { + output[binding] = { type: "dispatch_namespace", ...x }; + } + break; + } + case "mtls_certificates": { + for (const { binding, ...x } of info) { + output[binding] = { type: "mtls_certificate", ...x }; + } + break; + } + case "logfwdr": { + if (pages) { + break; + } + for (const { name, ...x } of info.bindings ?? []) { + output[name] = { type: "logfwdr", ...x }; + } + break; + } + case "ai": { + const { binding, ...x } = info; + output[binding] = { type: "ai", ...x }; + break; + } + case "images": { + const { binding, ...x } = info; + output[binding] = { type: "images", ...x }; + break; + } + case "stream": { + const { binding, ...x } = info; + output[binding] = { type: "stream", ...x }; + break; + } + case "version_metadata": { + const { binding, ...x } = info; + output[binding] = { type: "version_metadata", ...x }; + break; + } + case "hyperdrive": { + for (const { binding, ...x } of info) { + output[binding] = { type: "hyperdrive", ...x }; + } + break; + } + case "vectorize": { + for (const { binding, ...x } of info) { + output[binding] = { type: "vectorize", ...x }; + } + break; + } + case "ai_search_namespaces": { + for (const { binding, ...x } of info) { + output[binding] = { type: "ai_search_namespace", ...x }; + } + break; + } + case "ai_search": { + for (const { binding, ...x } of info) { + output[binding] = { type: "ai_search", ...x }; + } + break; + } + case "websearch": { + const { binding, ...x } = info; + output[binding] = { type: "websearch", ...x }; + break; + } + case "agent_memory": { + for (const { binding, ...x } of info) { + output[binding] = { type: "agent_memory", ...x }; + } + break; + } + case "unsafe": { + if (pages) { + break; + } + for (const { type: unsafeType, name, ...data } of info.bindings ?? []) { + output[name] = { type: `unsafe_${unsafeType}`, ...data }; + } + break; + } + case "assets": { + if (pages) { + break; + } + if (info.binding) { + output[info.binding] = { type: "assets" }; + } + break; + } + case "pipelines": { + if (pages) { + break; + } + for (const { binding, ...x } of info) { + output[binding] = { type: "pipeline", ...x }; + } + break; + } + case "secrets_store_secrets": { + for (const { binding, ...x } of info) { + output[binding] = { type: "secrets_store_secret", ...x }; + } + break; + } + case "artifacts": { + for (const { binding, ...x } of info) { + output[binding] = { type: "artifacts", ...x }; + } + break; + } + case "unsafe_hello_world": { + if (pages) { + break; + } + for (const { binding, ...x } of info) { + output[binding] = { type: "unsafe_hello_world", ...x }; + } + break; + } + case "flagship": { + for (const { binding, ...x } of info) { + output[binding] = { type: "flagship", ...x }; + } + break; + } + case "ratelimits": { + for (const { name, ...x } of info) { + output[name] = { type: "ratelimit", ...x }; + } + break; + } + case "worker_loaders": { + for (const { binding, ...x } of info) { + output[binding] = { type: "worker_loader", ...x }; + } + break; + } + case "vpc_services": { + for (const { binding, ...x } of info) { + output[binding] = { type: "vpc_service", ...x }; + } + break; + } + case "vpc_networks": { + for (const { binding, ...x } of info) { + output[binding] = { type: "vpc_network", ...x }; + } + break; + } + case "media": { + const { binding, ...x } = info; + output[binding] = { type: "media", ...x }; + break; + } + default: + assertNever(type); + } + } + + return output; +} + +export function isUnsafeBindingType(type: string): type is `unsafe_${string}` { + return type.startsWith("unsafe_"); +} + +/** + * What configuration key does this binding use for referring to it's binding name? + */ +const nameBindings = [ + "durable_object_namespace", + "logfwdr", + "ratelimit", + "unsafe_ratelimit", + "send_email", +] as const; + +function getBindingKey(type: Binding["type"]) { + if ((nameBindings as readonly string[]).includes(type)) { + return "name"; + } + return "binding"; +} + +type FlatBinding = Extract & + (Type extends (typeof nameBindings)[number] + ? { + name: string; + } + : { + binding: string; + }); + +export function extractBindingsOfType( + type: Type, + bindings: Record | undefined +): FlatBinding[] { + return Object.entries(bindings ?? {}) + .filter( + (binding): binding is [string, Extract] => + binding[1].type === type + ) + .map((binding) => ({ + ...binding[1], + [getBindingKey(type)]: binding[0], + })) as FlatBinding[]; +} + +/** + * Get bindings from a Config object in the standard Record format. + */ +export function getBindings( + config: Config | undefined, + options?: { + pages?: boolean; + } +): Record { + if (!config) { + return {}; + } + return convertConfigToBindings(config, { + usePreviewIds: false, + pages: options?.pages, + }); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/bundle-reporter.ts b/packages/deploy-helpers/src/deploy/helpers/bundle-reporter.ts new file mode 100644 index 0000000000..ab891b3a91 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/bundle-reporter.ts @@ -0,0 +1,44 @@ +import { Blob } from "node:buffer"; +import { gzipSync } from "node:zlib"; +import chalk from "chalk"; +import { logger } from "../../shared/context"; +import type { CfModule } from "@cloudflare/workers-utils"; + +const ONE_KIB_BYTES = 1024; +// Current max is 3 MiB for free accounts, 10 MiB for paid accounts. +// See https://developers.cloudflare.com/workers/platform/limits/#worker-size +const MAX_GZIP_SIZE_BYTES = 3 * ONE_KIB_BYTES * ONE_KIB_BYTES; + +async function getSize(modules: Pick[]) { + const gzipSize = gzipSync( + await new Blob(modules.map((file) => file.content)).arrayBuffer() + ).byteLength; + const aggregateSize = new Blob(modules.map((file) => file.content)).size; + + return { size: aggregateSize, gzipSize }; +} + +export async function printBundleSize( + main: { + name: string; + content: string; + }, + modules: CfModule[] +) { + const { size, gzipSize } = await getSize([...modules, main]); + + const bundleReport = `${(size / ONE_KIB_BYTES).toFixed(2)} KiB / gzip: ${( + gzipSize / ONE_KIB_BYTES + ).toFixed(2)} KiB`; + + const percentage = (gzipSize / MAX_GZIP_SIZE_BYTES) * 100; + + const colorizedReport = + percentage > 90 + ? chalk.red(bundleReport) + : percentage > 70 + ? chalk.yellow(bundleReport) + : chalk.green(bundleReport); + + logger.log(`Total Upload: ${colorizedReport}`); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/capnp.ts b/packages/deploy-helpers/src/deploy/helpers/capnp.ts new file mode 100644 index 0000000000..4899eddc25 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/capnp.ts @@ -0,0 +1,39 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; +import { sync as commandExistsSync } from "command-exists"; +import type { CfCapnp } from "@cloudflare/workers-utils"; + +export function handleUnsafeCapnp(capnp: CfCapnp): Buffer { + if (capnp.compiled_schema) { + return readFileSync(resolve(capnp.compiled_schema)); + } + + const { base_path, source_schemas } = capnp; + const capnpSchemas = (source_schemas ?? []).map((x) => + resolve(base_path as string, x) + ); + if (!commandExistsSync("capnp")) { + throw new UserError( + "The capnp compiler is required to upload capnp schemas, but is not present.", + { telemetryMessage: "capnp compiler missing" } + ); + } + const srcPrefix = resolve(base_path ?? "."); + const capnpProcess = spawnSync( + "capnp", + ["compile", "-o-", `--src-prefix=${srcPrefix}`, ...capnpSchemas], + // This number was chosen arbitrarily. If you get ENOBUFS because your compiled schema is still + // too large, then we may need to bump this again or figure out another approach. + // https://github.com/cloudflare/workers-sdk/pull/10217 + { maxBuffer: 3 * 1024 * 1024 } + ); + if (capnpProcess.error) { + throw capnpProcess.error; + } + if (capnpProcess.stderr.length) { + throw new Error(capnpProcess.stderr.toString()); + } + return capnpProcess.stdout; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/check-remote-secrets-override.ts b/packages/deploy-helpers/src/deploy/helpers/check-remote-secrets-override.ts new file mode 100644 index 0000000000..85b95718aa --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/check-remote-secrets-override.ts @@ -0,0 +1,180 @@ +import { fetchResult } from "../../shared/context"; +import { useServiceEnvironments } from "./use-service-environments"; +import { isWorkerNotFoundError } from "./worker-not-found-error"; +import type { Config } from "@cloudflare/workers-utils"; + +export async function fetchSecrets( + config: Config, + accountId: string, + environment: string | undefined +): Promise<{ name: string; type: string }[]> { + const isServiceEnv = environment && useServiceEnvironments(config); + + const scriptName = config.name; + + const url = isServiceEnv + ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${environment}/secrets` + : `/accounts/${accountId}/workers/scripts/${scriptName}/secrets`; + + return fetchResult<{ name: string; type: string }[]>(config, url); +} + +export async function checkRemoteSecretsOverride( + config: Config, + accountId: string, + targetEnv: string | undefined +): Promise< + | { + override: false; + } + | { + override: true; + deployErrorMessage: string; + } +> { + const envVarNames = Object.keys(config.vars ?? {}); + const bindingNames = extractBindingNames(config); + + if (envVarNames.length + bindingNames.length > 0) { + const secretNames = new Set(); + + try { + const secrets = await fetchSecrets(config, accountId, targetEnv); + + for (const secret of secrets) { + secretNames.add(secret.name); + } + } catch (e) { + if (isWorkerNotFoundError(e)) { + return { override: false }; + } + throw e; + } + + const envVarNamesOverridingSecrets = envVarNames.filter((name) => + secretNames.has(name) + ); + + const bindingNamesOverridingSecrets = bindingNames.filter((name) => + secretNames.has(name) + ); + + if ( + envVarNamesOverridingSecrets.length + + bindingNamesOverridingSecrets.length === + 0 + ) { + return { override: false }; + } + + if ( + envVarNamesOverridingSecrets.length && + !bindingNamesOverridingSecrets.length + ) { + return { + override: true, + deployErrorMessage: constructSingleTypeDeployErrorMessage( + envVarNamesOverridingSecrets, + "variable" + ), + }; + } + + if ( + bindingNamesOverridingSecrets.length && + !envVarNamesOverridingSecrets.length + ) { + return { + override: true, + deployErrorMessage: constructSingleTypeDeployErrorMessage( + bindingNamesOverridingSecrets, + "binding" + ), + }; + } + + const affectedSecrets = [ + ...envVarNamesOverridingSecrets, + ...bindingNamesOverridingSecrets, + ]; + + return { + override: true, + deployErrorMessage: `Configuration values (${listNames(affectedSecrets)}) conflict with existing remote secrets. This deployment will replace these remote secrets with the configuration values.`, + }; + } + + return { override: false }; +} + +function extractBindingNames(config: Config): string[] { + return Object.entries(config).flatMap((entry) => { + const key = entry[0] as keyof Config; + const untypedValue = entry[1]; + + switch (key) { + case "durable_objects": { + const value: Config[typeof key] = untypedValue; + return value.bindings.map((doBinding) => doBinding.name); + } + case "workflows": + case "d1_databases": + case "kv_namespaces": + case "r2_buckets": + case "vectorize": + case "ai_search_namespaces": + case "ai_search": + case "agent_memory": + case "services": + case "mtls_certificates": + case "dispatch_namespaces": + case "vpc_services": + case "vpc_networks": { + const value: Config[typeof key] = untypedValue; + return (value ?? []).map((workflowBinding) => workflowBinding.binding); + } + case "browser": + case "ai": + case "websearch": { + const value: Config[typeof key] = untypedValue; + return value ? [value.binding] : []; + } + case "queues": { + const value: Config[typeof key] = untypedValue; + return (value.producers ?? []).map( + (queueProducer) => queueProducer.binding + ); + } + default: + return []; + } + }); +} + +function listNames(names: string[]): string { + if (names.length <= 1) { + return `\`${names[0]}\``; + } + + if (names.length == 2) { + return `\`${names[0]}\` and \`${names[1]}\``; + } + + return `${names + .slice(0, -1) + .map((name) => `\`${name}\`, `) + .join("")}and \`${names.at(-1)}\``; +} + +function constructSingleTypeDeployErrorMessage( + names: string[], + type: "variable" | "binding" +) { + const multiple = names.length > 1; + + const conflictMessage = `${type === "variable" ? "Environment variable" : "Binding"}${multiple ? "s" : ""} ${listNames(names)} conflict${multiple ? "" : "s"} with ${multiple ? "" : "an "}existing remote secret${multiple ? "s" : ""}.`; + + const deploymentMessage = `This deployment will replace ${multiple ? "these" : "the"} remote secret${multiple ? "s" : ""} with your ${type === "variable" ? "environment variable" : "binding"}${multiple ? "s" : ""}.`; + + return `${conflictMessage} ${deploymentMessage}`; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts b/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts new file mode 100644 index 0000000000..6fa2db0b96 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/check-workflow-conflicts.ts @@ -0,0 +1,91 @@ +import { APIError } from "@cloudflare/workers-utils"; +import { fetchResult } from "../../shared/context"; +import type { Config } from "@cloudflare/workers-utils"; + +export type Workflow = { + name: string; + id: string; + created_on: string; + modified_on: string; + script_name: string; + class_name: string; +}; + +export interface WorkflowConflict { + name: string; + currentOwner: string; +} + +export const WORKFLOW_NOT_FOUND_CODE = 10200; + +/** + * Fetches a workflow by name from the Cloudflare API. + * + * @param config - The compliance/config object for API requests + * @param accountId - The account ID + * @param workflowName - The name of the workflow to fetch + * @returns The workflow if it exists, or `null` if not found (API error code 10200) + * @throws {APIError} Re-throws any API error that is not a "workflow not found" error (e.g., network errors, auth errors, rate limits) + */ +async function getWorkflow( + config: Config, + accountId: string, + workflowName: string +): Promise { + try { + return await fetchResult( + config, + `/accounts/${accountId}/workflows/${workflowName}` + ); + } catch (e) { + if (e instanceof APIError && e.code === WORKFLOW_NOT_FOUND_CODE) { + return null; + } + throw e; + } +} + +export async function checkWorkflowConflicts( + config: Config, + accountId: string, + scriptName: string +): Promise< + | { hasConflicts: false } + | { hasConflicts: true; conflicts: WorkflowConflict[]; message: string } +> { + const workflowsToDeploy = config.workflows?.filter( + (w) => w.script_name === undefined || w.script_name === scriptName + ); + + if (!workflowsToDeploy?.length) { + return { hasConflicts: false }; + } + + const workflowChecks = await Promise.all( + workflowsToDeploy.map(async (workflow) => { + const existing = await getWorkflow(config, accountId, workflow.name); + if (existing && existing.script_name !== scriptName) { + return { name: workflow.name, currentOwner: existing.script_name }; + } + return null; + }) + ); + + const conflicts = workflowChecks.filter( + (c): c is WorkflowConflict => c !== null + ); + + if (conflicts.length === 0) { + return { hasConflicts: false }; + } + + const conflictList = conflicts + .map((c) => ` - "${c.name}" (currently belongs to "${c.currentOwner}")`) + .join("\n"); + + const message = + `The following workflow(s) already exist and belong to different workers:\n${conflictList}\n\n` + + `Deploying will reassign these workflows to "${scriptName}".`; + + return { hasConflicts: true, conflicts, message }; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts b/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts new file mode 100644 index 0000000000..6b6c68b761 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts @@ -0,0 +1,688 @@ +import assert from "node:assert"; +import { getSubdomainValuesAPIMock } from "../../triggers/deploy"; +import { + diffJsonObjects, + isModifiedDiffValue, + isNonDestructive, +} from "./diff-json"; +import type { JsonLike } from "./diff-json"; +import type { + Config, + ConfigBindingFieldName, + RawConfig, +} from "@cloudflare/workers-utils"; + +// Exhaustive map of all binding keys in CfWorkerInit["bindings"]. +// When a new binding type is added, TypeScript will error here until it is handled. +const reorderableBindings = { + // Top-level binding arrays + kv_namespaces: true, + r2_buckets: true, + d1_databases: true, + services: true, + send_email: true, + vectorize: true, + ai_search_namespaces: true, + ai_search: true, + agent_memory: true, + hyperdrive: true, + workflows: true, + dispatch_namespaces: true, + mtls_certificates: true, + pipelines: true, + secrets_store_secrets: true, + artifacts: true, + ratelimits: true, + analytics_engine_datasets: true, + unsafe_hello_world: true, + flagship: true, + worker_loaders: true, + vpc_services: true, + vpc_networks: true, + + // Wrapper objects containing binding arrays + durable_objects: true, + queues: true, + logfwdr: true, + + // Non-array bindings (nothing to reorder) + vars: false, + wasm_modules: false, + text_blobs: false, + data_blobs: false, + browser: false, + ai: false, + images: false, + stream: false, + media: false, + websearch: false, + version_metadata: false, + unsafe: false, + assets: false, +} satisfies Record; + +/** Extracts the keys of T whose values are `true` */ +type ReorderableKeys> = { + [K in keyof T]: T[K] extends true ? K : never; +}[keyof T]; + +/** + * Object representing the difference of two configuration objects. + */ +type ConfigDiff = { + /** The actual (raw) computed diff of the two objects */ + diff: Record | null; + /** + * Flag indicating whether the difference includes some destructive changes. + * + * In other words, if the second config is not applying any change or only adding options, such diff is considered non destructive, on the other hand if the config is removing or modifying values it is considered destructive instead. + */ + nonDestructive: boolean; +}; + +/** + * Computes the difference between a remote representation of a Worker's config and a local configuration. + * + * @param remoteConfig The remote representation of a Worker's config + * @param localResolvedConfig The local (resolved) config + * @returns Object containing the diffing information + */ +export function getRemoteConfigDiff( + remoteConfig: RawConfig, + localResolvedConfig: Config +): ConfigDiff { + const normalizedLocalConfig = + normalizeLocalResolvedConfigAsRemote(localResolvedConfig); + const normalizedRemoteConfig = normalizeRemoteConfigAsResolvedLocal( + remoteConfig, + normalizedLocalConfig + ); + + const diff = diffJsonObjects( + normalizedRemoteConfig as unknown as Record, + normalizedLocalConfig as unknown as Record + ); + + return { + diff, + nonDestructive: isNonDestructive(diff), + }; +} + +/** + * Normalized a local (resolved) config object so that it can be compared against + * the remote config object. This mainly means resolving and setting defaults to + * the local configuration to match the values in the remote one. + * + * @param localResolvedConfig The local (resolved) config object to normalize + * @returns The normalized config + */ +function normalizeLocalResolvedConfigAsRemote( + localResolvedConfig: Config +): Config { + const subdomainValues = getSubdomainValuesAPIMock( + localResolvedConfig.workers_dev, + localResolvedConfig.preview_urls, + localResolvedConfig.routes ?? [] + ); + const normalizedConfig: Config = { + ...structuredClone(localResolvedConfig), + workers_dev: subdomainValues.workers_dev, + preview_urls: subdomainValues.preview_urls, + observability: normalizeObservability(localResolvedConfig.observability), + }; + + removeRemoteConfigFieldFromBindings(normalizedConfig); + + // Currently remotely we only get the assets' binding name, so we need remove + // everything else, if present, from the local one + if (normalizedConfig.assets) { + normalizedConfig.assets = { + binding: normalizedConfig.assets.binding, + }; + } + + return normalizedConfig; +} + +/** + * Given a configuration object removes all the `remote` config settings from all the bindings + * in the configuration (this is used as part of the config normalization since the `remote` + * key is not present in the remote configuration object) + * + * @param normalizedConfig The target configuration object (which gets updated side-effectfully) + */ +function removeRemoteConfigFieldFromBindings(normalizedConfig: Config): void { + for (const bindingField of [ + "kv_namespaces", + "r2_buckets", + "d1_databases", + ] as const) { + if (normalizedConfig[bindingField]?.length) { + normalizedConfig[bindingField] = normalizedConfig[bindingField].map( + ({ remote: _, ...binding }) => binding + ); + } + } + + if (normalizedConfig.services?.length) { + normalizedConfig.services = normalizedConfig.services.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.vpc_services?.length) { + normalizedConfig.vpc_services = normalizedConfig.vpc_services.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.vpc_networks?.length) { + normalizedConfig.vpc_networks = normalizedConfig.vpc_networks.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.workflows?.length) { + normalizedConfig.workflows = normalizedConfig.workflows.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.dispatch_namespaces?.length) { + normalizedConfig.dispatch_namespaces = + normalizedConfig.dispatch_namespaces.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.mtls_certificates?.length) { + normalizedConfig.mtls_certificates = normalizedConfig.mtls_certificates.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.pipelines?.length) { + normalizedConfig.pipelines = normalizedConfig.pipelines.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.vectorize?.length) { + normalizedConfig.vectorize = normalizedConfig.vectorize.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.queues?.producers?.length) { + normalizedConfig.queues.producers = normalizedConfig.queues.producers.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.send_email) { + normalizedConfig.send_email = normalizedConfig.send_email.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.ai_search_namespaces?.length) { + normalizedConfig.ai_search_namespaces = + normalizedConfig.ai_search_namespaces.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.ai_search?.length) { + normalizedConfig.ai_search = normalizedConfig.ai_search.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.agent_memory?.length) { + normalizedConfig.agent_memory = normalizedConfig.agent_memory.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.flagship?.length) { + normalizedConfig.flagship = normalizedConfig.flagship.map( + ({ remote: _, ...binding }) => binding + ); + } + + if (normalizedConfig.artifacts?.length) { + normalizedConfig.artifacts = normalizedConfig.artifacts.map( + ({ remote: _, ...binding }) => binding + ); + } + + const singleBindingFields = [ + "browser", + "ai", + "images", + "stream", + "media", + "websearch", + ] as const; + for (const singleBindingField of singleBindingFields) { + if ( + normalizedConfig[singleBindingField] && + "remote" in normalizedConfig[singleBindingField] + ) { + delete normalizedConfig[singleBindingField].remote; + } + } +} + +/** + * Normalizes an observability config object (either the remote or resolved local one) to a fully filled form, this + * helps us resolve any inconsistencies between the local and remote default values. + * + * @param obs The observability config object to normalize + * @returns The normalized observability object + */ +function normalizeObservability( + obs: RawConfig["observability"] +): Config["observability"] { + const normalized = structuredClone(obs); + + const enabled = obs?.enabled === true ? true : false; + + const fullObservabilityDefaults = { + enabled, + head_sampling_rate: 1, + logs: { + enabled, + head_sampling_rate: 1, + invocation_logs: true, + persist: true, + }, + traces: { enabled: false, persist: true, head_sampling_rate: 1 }, + } as const; + + if (!normalized) { + return fullObservabilityDefaults; + } + + const fillUndefinedFields = ( + target: Record, + defaults: Record + ) => { + Object.entries(defaults).forEach(([key, value]) => { + if (target[key] === undefined) { + target[key] = value; + return; + } + + if ( + typeof value === "object" && + value !== null && + typeof target[key] === "object" && + target[key] !== null + ) { + fillUndefinedFields( + target[key] as Record, + value as Record + ); + } + }); + }; + + fillUndefinedFields( + normalized as Record, + fullObservabilityDefaults + ); + + return normalized; +} + +/** + * Normalizes a remote config object (or more precisely our representation of it) into an object that can be + * compared to the local target config. + * + * The normalization is comprized of: + * - making sure that the various config fields are in the same order + * - adding to the remote config object all the non-remote config keys + * - removing from the remote config all the default values that in the local config are either not present or undefined + * + * @param remoteConfig The remote config object to normalize + * @param localConfig The target/local (resolved) config object + * @returns The remote config object normalized and ready to be compared with the local one + */ +function normalizeRemoteConfigAsResolvedLocal( + remoteConfig: RawConfig, + localConfig: Config +): Config { + let normalizedRemote = {} as Config; + + // We start by adding all the local configs to the normalized remote config object + // in this way we can make sure that local-only configurations are not shown as + // differences between local and remote configs + Object.entries(localConfig).forEach(([key, value]) => { + if ( + // We want to skip observability since it has a remote default behavior + // different from that of wrangler + key !== "observability" && + // We want to skip assets since it is a special case, the issue being that + // remotely assets configs only include at most the binding name and we + // already address that in the local config normalization already + key !== "assets" + ) { + (normalizedRemote as unknown as Record)[key] = value; + } + }); + + // We then override the configs present in the remote config object + Object.entries(remoteConfig).forEach(([key, value]) => { + if (key !== "main" && value !== undefined) { + (normalizedRemote as unknown as Record)[key] = value; + } + }); + + normalizedRemote.observability = normalizeObservability( + normalizedRemote.observability + ); + + // We reorder the remote config so that its ordering follows that + // of the local one (this ensures that the diff users see lists + // the configuration options in the same order as their config file) + normalizedRemote = orderObjectFields( + normalizedRemote as unknown as Record, + localConfig as unknown as Record + ) as unknown as Config; + + // Reorder binding arrays to match local's order so the diff is intuitive. + // Binding array order doesn't matter semantically, but positional diffing + // would show spurious changes if the same elements appear in different order. + for (const [bindingKey, shouldReorder] of Object.entries( + reorderableBindings + )) { + if (!shouldReorder) { + continue; + } + + const key = bindingKey as ReorderableKeys; + + // Handle wrapper objects that contain binding arrays as nested properties + if (key === "queues") { + // Only producers are bindings (accessible from Worker code). + // Consumers configure message delivery to the Worker and are + // managed through the Queues API, not the Worker bindings API, + // so they don't appear in the remote config. + if (normalizedRemote.queues?.producers && localConfig.queues?.producers) { + normalizedRemote.queues.producers = reorderBindings( + normalizedRemote.queues.producers, + localConfig.queues.producers + ); + } + continue; + } + + if (key === "durable_objects") { + if ( + normalizedRemote.durable_objects?.bindings && + localConfig.durable_objects?.bindings + ) { + normalizedRemote.durable_objects.bindings = reorderBindings( + normalizedRemote.durable_objects.bindings, + localConfig.durable_objects.bindings + ); + } + continue; + } + + if (key === "logfwdr") { + if (normalizedRemote.logfwdr?.bindings && localConfig.logfwdr?.bindings) { + normalizedRemote.logfwdr.bindings = reorderBindings( + normalizedRemote.logfwdr.bindings, + localConfig.logfwdr.bindings + ); + } + continue; + } + + // Top-level binding arrays + reorderConfigBindings(normalizedRemote, localConfig, key); + } + + return normalizedRemote; +} + +/** + * Generates a stable key for a binding object by JSON-serializing it with sorted keys, + * so that objects with the same properties in different order produce the same key. + */ +function getBindingKey(obj: unknown): string { + return JSON.stringify(obj, (_, v) => + v && typeof v === "object" && !Array.isArray(v) + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, v[k]]) + ) + : v + ); +} + +/** + * Reorders a remote binding array to match the local array's order. + * Elements present in both arrays are placed first (in local order), + * followed by elements only in the remote array. + */ +function reorderBindings(remote: T[], local: T[]): T[] { + const remoteByKey = new Map(remote.map((el) => [getBindingKey(el), el])); + const used = new Set(); + const result: T[] = []; + for (const binding of local) { + const key = getBindingKey(binding); + const remoteEl = remoteByKey.get(key); + if (remoteEl !== undefined) { + result.push(remoteEl); + used.add(key); + } + } + for (const binding of remote) { + if (!used.has(getBindingKey(binding))) { + result.push(binding); + } + } + return result; +} + +/** + * Reorders a top-level binding array on the remote config to match the local config's order. + * Uses a generic key parameter so TypeScript can correlate the types of both accesses. + */ +function reorderConfigBindings< + K extends ReorderableKeys, +>(normalizedRemote: Config, localConfig: Config, key: K): void { + const remoteArr = normalizedRemote[key]; + const localArr = localConfig[key]; + if (Array.isArray(remoteArr) && Array.isArray(localArr)) { + normalizedRemote[key] = reorderBindings(remoteArr, localArr) as Config[K]; + } +} + +/** + * This function reorders the fields of a given object so that they follow a given target object. + * All the fields of the given object not present in the target object will be ordered last. + */ +function orderObjectFields>( + source: T, + target: Record +): T { + const targetKeysIndexesMap = Object.fromEntries( + Object.keys(target).map((key, i) => [key, i]) + ); + + const orderedSource = Object.fromEntries( + Object.entries(source).sort(([keyA], [keyB]) => { + if (keyA in target && !(keyB in target)) { + return -1; + } + + if (!(keyA in target) && keyB in target) { + return 1; + } + + if (!(keyA in target) && !(keyB in target)) { + return 0; + } + + return targetKeysIndexesMap[keyA] - targetKeysIndexesMap[keyB]; + }) + ) as T; + + for (const [key, value] of Object.entries(orderedSource)) { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof target[key] === "object" && + target[key] !== null && + !Array.isArray(target[key]) + ) { + (orderedSource as Record)[key] = orderObjectFields( + value as Record, + target[key] as Record + ); + } + } + + return orderedSource; +} + +/** + * Given a config diff generates a patch object that can be passed to `experimental_patchConfig` to revert the + * changes in the config object that are described by the config diff. + * + * If the config is for a specific target environment, only the environment config object will be targeted for the patch. + * + * @param configDiff The target config diff + * @param targetEnvironment the target environment if any + * @returns The patch object to pass to `experimental_patchConfig` to revert the changes + */ +export function getConfigPatch( + configDiff: { + diff: Record | null; + nonDestructive: boolean; + }["diff"], + targetEnvironment?: string | undefined +): RawConfig { + const patchObj: RawConfig = {}; + + populateConfigPatch( + configDiff, + patchObj as Record, + targetEnvironment + ); + + return patchObj; +} + +function populateConfigPatch( + diff: JsonLike, + patchObj: Record | JsonLike[], + targetEnvironment?: string +): void { + if (!diff || typeof diff !== "object") { + return; + } + + if (Array.isArray(diff)) { + assert(Array.isArray(patchObj)); + return populateConfigPatchArray(diff, patchObj); + } + + assert(!Array.isArray(patchObj)); + return populateConfigPatchObject(diff, patchObj, targetEnvironment); +} + +function populateConfigPatchArray(diff: JsonLike[], patchArray: JsonLike[]) { + const elementsToAppend: JsonLike[] = []; + + Object.values(diff).forEach((element) => { + if (!Array.isArray(element)) { + return; + } + + if (element.length === 1 && element[0] === " ") { + patchArray.push({}); + return; + } + + if (element.length === 2) { + if (element[0] === "-") { + elementsToAppend.push(element[1]); + return; + } + + if (element[0] === "~" && element[1]) { + const patchEl = {}; + populateConfigPatch(element[1], patchEl); + patchArray.push(patchEl); + return; + } + } + }); + elementsToAppend.forEach((el) => patchArray.push(el)); +} + +function populateConfigPatchObject( + diff: { [id: string]: JsonLike }, + patchObj: Record, + targetEnvironment?: string +) { + const getEnvObj = (targetEnv: string) => { + patchObj.env ??= {}; + const patchObjEnv = patchObj.env as Record>; + patchObjEnv[targetEnv] ??= {}; + return patchObjEnv[targetEnv]; + }; + Object.keys(diff) + .filter((key) => diff[key] && typeof diff[key] === "object") + .forEach((key) => { + if (isModifiedDiffValue(diff[key])) { + if (targetEnvironment) { + getEnvObj(targetEnvironment)[key] = diff[key].__old; + } else { + patchObj[key] = diff[key].__old; + } + return; + } + + if (targetEnvironment) { + getEnvObj(targetEnvironment)[key] ??= Array.isArray(diff[key]) + ? [] + : {}; + } else { + patchObj[key] ??= Array.isArray(diff[key]) ? [] : {}; + } + + Object.entries(diff[key] as Record).forEach( + ([entryKey, entryValue]) => { + if (entryKey.endsWith("__deleted")) { + let patchObjectToUpdate = patchObj[key] as Record; + if (targetEnvironment) { + const envObj = getEnvObj(targetEnvironment); + envObj[key] ??= {}; + patchObjectToUpdate = envObj[key] as Record; + } + patchObjectToUpdate[entryKey.replace("__deleted", "")] = entryValue; + return; + } + } + ); + + if (diff[key] && typeof diff[key] === "object") { + populateConfigPatch( + diff[key], + (targetEnvironment + ? getEnvObj(targetEnvironment)[key] + : patchObj[key]) as Record | JsonLike[] + ); + return; + } + }); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts b/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts new file mode 100644 index 0000000000..d2433302a4 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/confirm-latest-deployment-overwrite.ts @@ -0,0 +1,102 @@ +import * as cli from "@cloudflare/cli-shared-helpers"; +import { brandColor, gray, white } from "@cloudflare/cli-shared-helpers/colors"; +import { inputPrompt, leftT } from "@cloudflare/cli-shared-helpers/interactive"; +import { isNonInteractiveOrCI } from "../../shared/context"; +import { fetchDeploymentVersions, fetchLatestDeployment } from "./versions-api"; +import { isWorkerNotFoundError } from "./worker-not-found-error"; +import type { + ApiDeployment, + ApiVersion, + Percentage, + VersionCache, + VersionId, +} from "./versions-types"; +import type { Config } from "@cloudflare/workers-utils"; + +const BLANK_INPUT = "-"; + +export async function confirmLatestDeploymentOverwrite( + config: Config, + accountId: string, + scriptName: string +) { + try { + const latest = await fetchLatestDeployment(config, accountId, scriptName); + if (latest && latest.versions.length >= 2) { + const versionCache: VersionCache = new Map(); + + cli.warn( + `Your last deployment has multiple versions. To progress that deployment use "wrangler versions deploy" instead.`, + { shape: cli.shapes.corners.tl, newlineBefore: false } + ); + cli.newline(); + await printDeployment( + config, + accountId, + scriptName, + latest, + "last", + versionCache + ); + + return inputPrompt({ + type: "confirm", + question: `"wrangler deploy" will upload a new version and deploy it globally immediately.\nAre you sure you want to continue?`, + label: "", + defaultValue: isNonInteractiveOrCI(), + acceptDefault: isNonInteractiveOrCI(), + }); + } + } catch (e) { + if (!isWorkerNotFoundError(e)) { + throw e; + } + } + return true; +} + +async function printDeployment( + config: Config, + accountId: string, + workerName: string, + deployment: ApiDeployment | undefined, + adjective: "current" | "last", + versionCache: VersionCache +) { + const [versions, traffic] = await fetchDeploymentVersions( + config, + accountId, + workerName, + deployment, + versionCache + ); + cli.logRaw( + `${leftT} Your ${adjective} deployment has ${versions.length} version(s):` + ); + printVersions(versions, traffic); +} + +export function printVersions( + versions: ApiVersion[], + traffic: Map +) { + cli.newline(); + cli.log(formatVersions(versions, traffic)); + cli.newline(); +} + +function formatVersions( + versions: ApiVersion[], + traffic: Map +) { + return versions + .map((version) => { + const trafficString = brandColor(`(${traffic.get(version.id)}%)`); + const versionIdString = white(version.id); + return gray(`${trafficString} ${versionIdString} + Created: ${version.metadata.created_on} + Tag: ${version.annotations?.["workers/tag"] ?? BLANK_INPUT} + Message: ${version.annotations?.["workers/message"] ?? BLANK_INPUT}`); + }) + .join("\n\n"); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts new file mode 100644 index 0000000000..2ca7d2b8e1 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts @@ -0,0 +1,882 @@ +import assert from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { INHERIT_SYMBOL, UserError } from "@cloudflare/workers-utils"; +import { FormData } from "undici"; +import { extractBindingsOfType, isUnsafeBindingType } from "./binding-utils"; +import { handleUnsafeCapnp } from "./capnp"; +import type { + AssetConfigMetadata, + Binding, + CfCapnp, + CfModuleType, + CfSendEmailBindings, + CfWorkerInit, + WorkerMetadata, + WorkerMetadataBinding, +} from "@cloudflare/workers-utils"; + +export const moduleTypeMimeType: { + [type in CfModuleType]: string | undefined; +} = { + esm: "application/javascript+module", + commonjs: "application/javascript", + "compiled-wasm": "application/wasm", + buffer: "application/octet-stream", + text: "text/plain", + python: "text/x-python", + "python-requirement": "text/x-python-requirement", +}; + +function toMimeType(type: CfModuleType): string { + const mimeType = moduleTypeMimeType[type]; + if (mimeType === undefined) { + throw new TypeError("Unsupported module: " + type); + } + + return mimeType; +} + +export function fromMimeType(mimeType: string): CfModuleType { + const moduleType = Object.keys(moduleTypeMimeType).find( + (type) => moduleTypeMimeType[type as CfModuleType] === mimeType + ) as CfModuleType | undefined; + if (moduleType === undefined) { + throw new TypeError("Unsupported mime type: " + mimeType); + } + + return moduleType; +} + +/** + * Creates a `FormData` upload from Worker data and bindings + */ +export function createWorkerUploadForm( + worker: Omit, + bindings: Record | undefined, + options?: { + dryRun?: true; + unsafe?: { metadata?: Record; capnp?: CfCapnp }; + } +): FormData { + const formData = new FormData(); + const { + main, + sourceMaps, + migrations, + compatibility_date, + compatibility_flags, + keepVars, + keepSecrets, + keepBindings, + logpush, + placement, + tail_consumers, + streaming_tail_consumers, + limits, + annotations, + keep_assets, + assets, + observability, + cache, + } = worker; + + const assetConfig: AssetConfigMetadata = { + html_handling: assets?.assetConfig?.html_handling, + not_found_handling: assets?.assetConfig?.not_found_handling, + run_worker_first: assets?.run_worker_first, + _redirects: assets?._redirects, + _headers: assets?._headers, + }; + + // short circuit if static assets upload only + if (assets && !assets.routerConfig.has_user_worker) { + formData.set( + "metadata", + JSON.stringify({ + assets: { + jwt: assets.jwt, + config: assetConfig, + }, + ...(annotations && { annotations }), + ...(compatibility_date && { compatibility_date }), + ...(compatibility_flags && { compatibility_flags }), + }) + ); + return formData; + } + let { modules } = worker; + + const metadataBindings: WorkerMetadataBinding[] = []; + + const plain_text = extractBindingsOfType("plain_text", bindings); + const json_bindings = extractBindingsOfType("json", bindings); + const secret_text = extractBindingsOfType("secret_text", bindings); + const kv_namespaces = extractBindingsOfType("kv_namespace", bindings); + const send_email = extractBindingsOfType("send_email", bindings); + const durable_objects = extractBindingsOfType( + "durable_object_namespace", + bindings + ); + const workflows = extractBindingsOfType("workflow", bindings); + const queues = extractBindingsOfType("queue", bindings); + const r2_buckets = extractBindingsOfType("r2_bucket", bindings); + const d1_databases = extractBindingsOfType("d1", bindings); + const vectorize = extractBindingsOfType("vectorize", bindings); + const ai_search_namespaces = extractBindingsOfType( + "ai_search_namespace", + bindings + ); + const ai_search = extractBindingsOfType("ai_search", bindings); + const websearch = extractBindingsOfType("websearch", bindings)[0]; + const agent_memory = extractBindingsOfType("agent_memory", bindings); + const hyperdrive = extractBindingsOfType("hyperdrive", bindings); + const secrets_store_secrets = extractBindingsOfType( + "secrets_store_secret", + bindings + ); + const artifacts = extractBindingsOfType("artifacts", bindings); + const unsafe_hello_world = extractBindingsOfType( + "unsafe_hello_world", + bindings + ); + const flagship = extractBindingsOfType("flagship", bindings); + const ratelimits = extractBindingsOfType("ratelimit", bindings); + const vpc_services = extractBindingsOfType("vpc_service", bindings); + const vpc_networks = extractBindingsOfType("vpc_network", bindings); + const services = extractBindingsOfType("service", bindings); + const analytics_engine_datasets = extractBindingsOfType( + "analytics_engine", + bindings + ); + const dispatch_namespaces = extractBindingsOfType( + "dispatch_namespace", + bindings + ); + const mtls_certificates = extractBindingsOfType("mtls_certificate", bindings); + const pipelines = extractBindingsOfType("pipeline", bindings); + const worker_loaders = extractBindingsOfType("worker_loader", bindings); + const logfwdr = extractBindingsOfType("logfwdr", bindings); + const wasm_modules = extractBindingsOfType("wasm_module", bindings); + const browser = extractBindingsOfType("browser", bindings)[0]; + const ai = extractBindingsOfType("ai", bindings)[0]; + const images = extractBindingsOfType("images", bindings)[0]; + const stream = extractBindingsOfType("stream", bindings)[0]; + const media = extractBindingsOfType("media", bindings)[0]; + const version_metadata = extractBindingsOfType( + "version_metadata", + bindings + )[0]; + const assetsBinding = extractBindingsOfType("assets", bindings)[0]; + const text_blobs = extractBindingsOfType("text_blob", bindings); + const data_blobs = extractBindingsOfType("data_blob", bindings); + const inherit_bindings = extractBindingsOfType("inherit", bindings); + + inherit_bindings.forEach(({ binding }) => { + metadataBindings.push({ name: binding, type: "inherit" }); + }); + + plain_text.forEach(({ binding, value }) => { + metadataBindings.push({ name: binding, type: "plain_text", text: value }); + }); + json_bindings.forEach(({ binding, value }) => { + metadataBindings.push({ name: binding, type: "json", json: value }); + }); + secret_text.forEach(({ binding, value }) => { + metadataBindings.push({ name: binding, type: "secret_text", text: value }); + }); + + kv_namespaces.forEach(({ id, binding, raw }) => { + // If we're doing a dry run there's no way to know whether or not a KV namespace + // is inheritable or requires provisioning (since that would require hitting the API). + // As such, _assume_ any undefined IDs are inheritable when doing a dry run. + // When this Worker is actually deployed, some may be provisioned at the point of deploy + if (options?.dryRun) { + id ??= INHERIT_SYMBOL; + } + + if (id === undefined) { + throw new UserError(`${binding} bindings must have an "id" field`, { + telemetryMessage: "kv namespace binding missing id", + }); + } + + if (id === INHERIT_SYMBOL) { + metadataBindings.push({ + name: binding, + type: "inherit", + }); + } else { + metadataBindings.push({ + name: binding, + type: "kv_namespace", + namespace_id: id, + raw, + }); + } + }); + + send_email.forEach((emailBinding: CfSendEmailBindings) => { + const destination_address = + "destination_address" in emailBinding + ? emailBinding.destination_address + : undefined; + const allowed_destination_addresses = + "allowed_destination_addresses" in emailBinding + ? emailBinding.allowed_destination_addresses + : undefined; + const allowed_sender_addresses = + "allowed_sender_addresses" in emailBinding + ? emailBinding.allowed_sender_addresses + : undefined; + metadataBindings.push({ + name: emailBinding.name, + type: "send_email", + destination_address, + allowed_destination_addresses, + allowed_sender_addresses, + }); + }); + + durable_objects.forEach(({ name, class_name, script_name, environment }) => { + metadataBindings.push({ + name, + type: "durable_object_namespace", + class_name: class_name, + ...(script_name && { script_name }), + ...(environment && { environment }), + }); + }); + + workflows.forEach(({ binding, name, class_name, script_name, raw }) => { + metadataBindings.push({ + type: "workflow", + name: binding, + workflow_name: name, + class_name, + script_name, + raw, + }); + }); + + queues.forEach(({ binding, queue_name, delivery_delay, raw }) => { + metadataBindings.push({ + type: "queue", + name: binding, + queue_name, + delivery_delay, + raw, + }); + }); + + r2_buckets.forEach(({ binding, bucket_name, jurisdiction, raw }) => { + if (options?.dryRun) { + bucket_name ??= INHERIT_SYMBOL; + } + if (bucket_name === undefined) { + throw new UserError( + `${binding} bindings must have a "bucket_name" field`, + { telemetryMessage: "r2 bucket binding missing bucket_name" } + ); + } + + if (bucket_name === INHERIT_SYMBOL) { + metadataBindings.push({ + name: binding, + type: "inherit", + }); + } else { + metadataBindings.push({ + name: binding, + type: "r2_bucket", + bucket_name, + jurisdiction, + raw, + }); + } + }); + + d1_databases.forEach( + ({ binding, database_id, database_internal_env, raw }) => { + if (options?.dryRun) { + database_id ??= INHERIT_SYMBOL; + } + if (database_id === undefined) { + throw new UserError( + `${binding} bindings must have a "database_id" field`, + { telemetryMessage: "d1 database binding missing database_id" } + ); + } + + if (database_id === INHERIT_SYMBOL) { + metadataBindings.push({ + name: binding, + type: "inherit", + }); + } else { + metadataBindings.push({ + name: binding, + type: "d1", + id: database_id, + internalEnv: database_internal_env, + raw, + }); + } + } + ); + + vectorize.forEach(({ binding, index_name, raw }) => { + metadataBindings.push({ + name: binding, + type: "vectorize", + index_name: index_name, + raw, + }); + }); + + ai_search_namespaces.forEach(({ binding, namespace }) => { + if (options?.dryRun) { + namespace ??= INHERIT_SYMBOL; + } + if (namespace === undefined) { + throw new UserError(`${binding} bindings must have a "namespace" field`, { + telemetryMessage: "ai search namespace binding missing namespace", + }); + } + + if (namespace === INHERIT_SYMBOL) { + metadataBindings.push({ + name: binding, + type: "inherit", + }); + } else { + metadataBindings.push({ + name: binding, + type: "ai_search_namespace", + namespace, + }); + } + }); + + ai_search.forEach(({ binding, instance_name }) => { + metadataBindings.push({ + name: binding, + type: "ai_search", + instance_name, + }); + }); + + if (websearch !== undefined) { + metadataBindings.push({ + name: websearch.binding, + type: "websearch", + }); + } + + agent_memory.forEach(({ binding, namespace }) => { + if (options?.dryRun) { + namespace ??= INHERIT_SYMBOL; + } + if (namespace === undefined) { + throw new UserError(`${binding} bindings must have a "namespace" field`, { + telemetryMessage: false, + }); + } + + if (namespace === INHERIT_SYMBOL) { + metadataBindings.push({ + name: binding, + type: "inherit", + }); + } else { + metadataBindings.push({ + name: binding, + type: "agent_memory", + namespace, + }); + } + }); + + hyperdrive.forEach(({ binding, id }) => { + metadataBindings.push({ + name: binding, + type: "hyperdrive", + id: id, + }); + }); + + secrets_store_secrets.forEach(({ binding, store_id, secret_name }) => { + metadataBindings.push({ + name: binding, + type: "secrets_store_secret", + store_id, + secret_name, + }); + }); + + artifacts.forEach(({ binding, namespace }) => { + metadataBindings.push({ + name: binding, + type: "artifacts", + namespace, + }); + }); + + unsafe_hello_world.forEach(({ binding, enable_timer }) => { + metadataBindings.push({ + name: binding, + type: "unsafe_hello_world", + enable_timer, + }); + }); + + flagship.forEach(({ binding, app_id }) => { + metadataBindings.push({ + name: binding, + type: "flagship", + app_id, + }); + }); + + ratelimits.forEach(({ name, namespace_id, simple }) => { + metadataBindings.push({ + name, + type: "ratelimit", + namespace_id, + simple, + }); + }); + + vpc_services.forEach(({ binding, service_id }) => { + metadataBindings.push({ + name: binding, + type: "vpc_service", + service_id, + }); + }); + + vpc_networks.forEach(({ binding, tunnel_id, network_id }) => { + metadataBindings.push({ + name: binding, + type: "vpc_network", + ...(tunnel_id !== undefined ? { tunnel_id } : { network_id }), + }); + }); + + services.forEach( + ({ + binding, + service, + environment, + entrypoint, + props, + cross_account_grant, + }) => { + metadataBindings.push({ + name: binding, + type: "service", + service, + cross_account_grant, + ...(environment && { environment }), + ...(entrypoint && { entrypoint }), + ...(props && { props }), + }); + } + ); + + analytics_engine_datasets.forEach(({ binding, dataset }) => { + metadataBindings.push({ + name: binding, + type: "analytics_engine", + dataset, + }); + }); + + dispatch_namespaces.forEach(({ binding, namespace, outbound }) => { + metadataBindings.push({ + name: binding, + type: "dispatch_namespace", + namespace, + ...(outbound && { + outbound: { + worker: { + service: outbound.service, + environment: outbound.environment, + }, + params: outbound.parameters?.map((p) => ({ name: p })), + }, + }), + }); + }); + + mtls_certificates.forEach(({ binding, certificate_id }) => { + metadataBindings.push({ + name: binding, + type: "mtls_certificate", + certificate_id, + }); + }); + + pipelines.forEach(({ binding, stream: pipelineStream, pipeline }) => { + if (pipelineStream) { + metadataBindings.push({ + name: binding, + type: "pipelines", + stream: pipelineStream, + }); + } else if (pipeline) { + metadataBindings.push({ + name: binding, + type: "pipelines", + pipeline, + }); + } else { + throw new Error("Pipeline binding must specify a stream or pipeline"); + } + }); + + worker_loaders.forEach(({ binding }) => { + metadataBindings.push({ + name: binding, + type: "worker_loader", + }); + }); + + logfwdr.forEach(({ name, destination }) => { + metadataBindings.push({ + name: name, + type: "logfwdr", + destination, + }); + }); + + wasm_modules.forEach(({ binding: name, source }) => { + metadataBindings.push({ + name, + type: "wasm_module", + part: name, + }); + + formData.set( + name, + new File( + [ + "contents" in source + ? source.contents + : readFileSync(source.path as string), + ], + "path" in source ? (source.path ?? name) : name, + { type: "application/wasm" } + ) + ); + }); + + if (browser !== undefined) { + metadataBindings.push({ + name: browser.binding, + type: "browser", + raw: browser.raw, + }); + } + + if (ai !== undefined) { + metadataBindings.push({ + name: ai.binding, + staging: ai.staging, + type: "ai", + raw: ai.raw, + }); + } + + if (images !== undefined) { + metadataBindings.push({ + name: images.binding, + type: "images", + raw: images.raw, + }); + } + + if (stream !== undefined) { + metadataBindings.push({ + name: stream.binding, + type: "stream", + }); + } + + if (media !== undefined) { + metadataBindings.push({ + name: media.binding, + type: "media", + }); + } + + if (version_metadata !== undefined) { + metadataBindings.push({ + name: version_metadata.binding, + type: "version_metadata", + }); + } + + if (assetsBinding !== undefined) { + metadataBindings.push({ + name: assetsBinding.binding, + type: "assets", + }); + } + + text_blobs.forEach(({ binding: name, source }) => { + metadataBindings.push({ + name, + type: "text_blob", + part: name, + }); + + if (name !== "__STATIC_CONTENT_MANIFEST") { + if ("contents" in source) { + formData.set( + name, + new File([source.contents], source.path ?? name, { + type: "text/plain", + }) + ); + } else { + formData.set( + name, + new File([readFileSync(source.path)], source.path, { + type: "text/plain", + }) + ); + } + } + }); + + data_blobs.forEach(({ binding: name, source }) => { + metadataBindings.push({ + name, + type: "data_blob", + part: name, + }); + + formData.set( + name, + new File( + [ + "contents" in source + ? source.contents + : readFileSync(source.path as string), + ], + "path" in source ? (source.path ?? name) : name, + { type: "application/octet-stream" } + ) + ); + }); + + // Handle generic unsafe_* bindings (excluding unsafe_hello_world which is handled above) + for (const [bindingName, config] of Object.entries(bindings ?? {})) { + if ( + isUnsafeBindingType(config.type) && + config.type !== "unsafe_hello_world" + ) { + const { type, ...data } = config; + metadataBindings.push({ + name: bindingName, + type: type.slice("unsafe_".length), + ...data, + } as WorkerMetadataBinding); + } + } + + const manifestModuleName = "__STATIC_CONTENT_MANIFEST"; + const hasManifest = modules?.some(({ name }) => name === manifestModuleName); + if (hasManifest && main.type === "esm") { + assert(modules !== undefined); + // Each modules-format worker has a virtual file system for module + // resolution. For example, uploading modules with names `1.mjs`, + // `a/2.mjs` and `a/b/3.mjs`, creates virtual directories `a` and `a/b`. + // `1.mjs` is in the virtual root directory. + // + // The above code adds the `__STATIC_CONTENT_MANIFEST` module to the root + // directory. This means `import manifest from "__STATIC_CONTENT_MANIFEST"` + // will only work if the importing module is also in the root. If the + // importing module was `a/b/3.mjs` for example, the import would need to + // be `import manifest from "../../__STATIC_CONTENT_MANIFEST"`. + // + // When Wrangler bundles all user code, this isn't a problem, as code is + // only ever uploaded to the root. However, once `--no-bundle` or + // `find_additional_modules` is enabled, the user controls the directory + // structure. + // + // To fix this, if we've got a modules-format worker, we add stub modules + // in each subdirectory that re-export the manifest module from the root. + // This allows the manifest to be imported as `__STATIC_CONTENT_MANIFEST` + // in every directory, whilst avoiding duplication of the manifest. + + // Collect unique subdirectories + const subDirs = new Set( + modules.map((module) => path.posix.dirname(module.name)) + ); + for (const subDir of subDirs) { + // Ignore `.` as it's not a subdirectory, and we don't want to + // register the manifest module in the root twice. + if (subDir === ".") { + continue; + } + const relativePath = path.posix.relative(subDir, manifestModuleName); + const filePath = path.posix.join(subDir, manifestModuleName); + modules.push({ + name: filePath, + filePath, + content: `export { default } from ${JSON.stringify(relativePath)};`, + type: "esm", + }); + } + } + + if (main.type === "commonjs") { + // This is a service-worker format worker. + for (const module of Object.values([...(modules || [])])) { + if (module.name === "__STATIC_CONTENT_MANIFEST") { + // Add the manifest to the form data. + formData.set( + module.name, + new File([module.content], module.name, { + type: "text/plain", + }) + ); + // And then remove it from the modules collection + modules = modules?.filter((m) => m !== module); + } else if ( + module.type === "compiled-wasm" || + module.type === "text" || + module.type === "buffer" + ) { + // Convert all wasm/text/data modules into `wasm_module`/`text_blob`/`data_blob` bindings. + // The "name" of the module is a file path. We use it + // to instead be a "part" of the body, and a reference + // that we can use inside our source. This identifier has to be a valid + // JS identifier, so we replace all non alphanumeric characters + // with an underscore. + const name = module.name.replace(/[^a-zA-Z0-9_$]/g, "_"); + metadataBindings.push({ + name, + type: + module.type === "compiled-wasm" + ? "wasm_module" + : module.type === "text" + ? "text_blob" + : "data_blob", + part: name, + }); + + // Add the module to the form data. + formData.set( + name, + new File([module.content], module.name, { + type: + module.type === "compiled-wasm" + ? "application/wasm" + : module.type === "text" + ? "text/plain" + : "application/octet-stream", + }) + ); + // And then remove it from the modules collection + modules = modules?.filter((m) => m !== module); + } + } + } + + let capnpSchemaOutputFile: string | undefined; + if (options?.unsafe?.capnp) { + const capnpOutput = handleUnsafeCapnp(options.unsafe.capnp); + capnpSchemaOutputFile = `./capnp-${Date.now()}.compiled`; + formData.set( + capnpSchemaOutputFile, + new File([capnpOutput], capnpSchemaOutputFile, { + type: "application/octet-stream", + }) + ); + } + + let keep_bindings: WorkerMetadata["keep_bindings"] = undefined; + if (keepVars) { + keep_bindings ??= []; + keep_bindings.push("plain_text", "json"); + } + if (keepSecrets) { + keep_bindings ??= []; + keep_bindings.push("secret_text", "secret_key"); + } + if (keepBindings) { + keep_bindings ??= []; + keep_bindings.push(...keepBindings); + } + + const metadata: WorkerMetadata = { + ...(main.type !== "commonjs" + ? { main_module: main.name } + : { body_part: main.name }), + bindings: metadataBindings, + containers: + worker.containers === undefined + ? undefined + : worker.containers.map((c) => ({ class_name: c.class_name })), + + ...(compatibility_date && { compatibility_date }), + ...(compatibility_flags && { + compatibility_flags, + }), + ...(migrations && { migrations }), + capnp_schema: capnpSchemaOutputFile, + ...(keep_bindings && { keep_bindings }), + ...(logpush !== undefined && { logpush }), + ...(placement && { placement }), + ...(tail_consumers && { tail_consumers }), + ...(streaming_tail_consumers && { streaming_tail_consumers }), + ...(limits && { limits }), + ...(annotations && { annotations }), + ...(keep_assets !== undefined && { keep_assets }), + ...(assets && { + assets: { + jwt: assets.jwt, + config: assetConfig, + }, + }), + ...(observability && { observability }), + ...(cache && { cache_options: cache }), + }; + + if (options?.unsafe?.metadata !== undefined) { + for (const key of Object.keys(options.unsafe.metadata)) { + metadata[key] = options.unsafe.metadata[key]; + } + } + + formData.set("metadata", JSON.stringify(metadata)); + + if (main.type === "commonjs" && modules && modules.length > 0) { + throw new TypeError( + "More than one module can only be specified when type = 'esm'" + ); + } + + for (const module of [main].concat(modules || [])) { + formData.set( + module.name, + new File([module.content], module.name, { + type: toMimeType(module.type ?? main.type ?? "esm"), + }) + ); + } + + for (const sourceMap of sourceMaps || []) { + formData.set( + sourceMap.name, + new File([sourceMap.content], sourceMap.name, { + type: "application/source-map", + }) + ); + } + + return formData; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/deploy-confirm.ts b/packages/deploy-helpers/src/deploy/helpers/deploy-confirm.ts new file mode 100644 index 0000000000..b9b180e8d0 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/deploy-confirm.ts @@ -0,0 +1,23 @@ +import { confirm, isNonInteractiveOrCI, logger } from "../../shared/context"; + +export function getDeployConfirmFunction(options: { + strictMode?: boolean; +}): (text: string) => Promise { + const { strictMode = false } = options; + const nonInteractive = isNonInteractiveOrCI(); + + if (nonInteractive && strictMode) { + return async () => { + logger.error( + "Aborting the deployment operation because of conflicts. To override and deploy anyway remove the `--strict` flag" + ); + process.exitCode = 1; + return false; + }; + } else if (nonInteractive) { + // if its not in strict mode, continue without asking + return async () => true; + } + + return confirm; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/deploy-wfp.ts b/packages/deploy-helpers/src/deploy/helpers/deploy-wfp.ts new file mode 100644 index 0000000000..5dd550b7b4 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/deploy-wfp.ts @@ -0,0 +1,10 @@ +import { logger } from "../../shared/context"; + +export function deployWfpUserWorker( + dispatchNamespace: string, + versionId: string | null +) { + // Will go under the "Uploaded" text + logger.log(" Dispatch Namespace:", dispatchNamespace); + logger.log("Current Version ID:", versionId); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/diff-json.ts b/packages/deploy-helpers/src/deploy/helpers/diff-json.ts new file mode 100644 index 0000000000..c434d963cc --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/diff-json.ts @@ -0,0 +1,113 @@ +import jsonDiff from "json-diff"; + +export type JsonLike = + | string + | number + | boolean + | null + | JsonLike[] + | undefined // undefined is not a JSON type but it needs to be included here since it is present in the diff objects + | { [id: string]: JsonLike }; + +/** + * Given two objects A and B that are Json serializable this function computes the difference between them + * + * The difference object includes: + * - fields in object B but not in object A included as `` + * - fields in object A but not in object B included as `` + * - fields present in both objects but modified as `: { __old: , __new: }` + * + * Additionally the difference object contains a `toString` method that can be used to generate a string representation + * of the difference between the two objects (to be presented to users) + * + * @param jsonObjA The first target object + * @param jsonObjB The second target object + * @returns An object representing the diff between the two objects, or null if the objects are equal + */ +export function diffJsonObjects( + jsonObjA: Record, + jsonObjB: Record +): Record | null { + const result = jsonDiff.diff(jsonObjA, jsonObjB); + + if (result) { + result.toString = () => jsonDiff.diffString(jsonObjA, jsonObjB); + return result; + } else { + return null; + } +} + +/** + * Given a diff object (generated by `diffJsonObjects`) this function computes whether the + * difference is non-destructive, i.e. if the second object only contained additions to the + * first one and no removal nor modifications. + * + * @param diff The difference object to use (generated by `diffJsonObjects`) + * @returns `true` if the difference is non-destructive, `false` if it is + */ +export function isNonDestructive(diff: JsonLike): boolean { + if (diff === null || typeof diff !== "object") { + return true; + } + + if ( + Object.keys(diff).some( + (key) => key === "__old" || key.endsWith("__deleted") + ) + ) { + return false; + } + + if (Array.isArray(diff)) { + for (const element of diff) { + if (Array.isArray(element) && element.length === 2) { + if (element[0] === "-") { + return false; + } else if (element[0] === "~") { + return false; + } else if (element[0] !== "+") { + continue; + } + + for (const innerElement of element) { + if (!isNonDestructive(innerElement)) { + return false; + } + } + } else if (!isNonDestructive(element)) { + return false; + } + } + } else { + for (const field in diff) { + if (!isNonDestructive(diff[field])) { + return false; + } + } + } + + return true; +} + +/** + * A modified value in json-diff is represented as an object with two properties: + * `__old` and `__new`. Where the former contains the old version of the value and + * the latter the new one. + * This utility, given an arbitrary value, discerns whether the value represents the + * diff of a modified value. + * + * @param value The target value to check + * @returns True if the value represents a value modified, false otherwise + */ +export function isModifiedDiffValue( + value: unknown +): value is { __old: T; __new: T } { + return !!( + value && + typeof value === "object" && + Object.keys(value).length === 2 && + "__old" in value && + "__new" in value + ); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/download-worker-config.ts b/packages/deploy-helpers/src/deploy/helpers/download-worker-config.ts new file mode 100644 index 0000000000..d98737385c --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/download-worker-config.ts @@ -0,0 +1,140 @@ +import { + COMPLIANCE_REGION_CONFIG_UNKNOWN, + constructWranglerConfig, +} from "@cloudflare/workers-utils"; +import { fetchResult } from "../../shared/context"; +import type { + RawConfig, + ServiceMetadataRes, + WorkerMetadata, +} from "@cloudflare/workers-utils"; + +type CustomDomainsRes = { + id: string; + zone_id: string; + zone_name: string; + hostname: string; + service: string; + environment: string; + cert_id: string; + enabled: boolean; + previews_enabled: boolean; +}[]; + +type WorkerSubdomainRes = { + enabled: boolean; + previews_enabled: boolean; +}; +type CronTriggersRes = { + schedules: { + cron: string; + created_on: Date; + modified_on: Date; + }[]; +}; + +type RoutesRes = { + id: string; + pattern: string; + zone_name: string; + script: string; +}[]; + +/** + * Downloads all information required to construct a Wrangler config file for a Worker from the API + */ +export async function fetchWorkerConfig( + accountId: string, + workerName: string, + environment: string +) { + const [ + bindings, + routes, + customDomains, + subdomainStatus, + serviceEnvMetadata, + cronTriggers, + ] = await Promise.all([ + fetchResult( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}/bindings` + ), + fetchResult( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}/routes?show_zonename=true` + ), + fetchResult( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + `/accounts/${accountId}/workers/domains/records?page=0&per_page=5&service=${workerName}&environment=${environment}` + ), + fetchResult( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}/subdomain` + ), + fetchResult( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}` + ), + fetchResult( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + `/accounts/${accountId}/workers/scripts/${workerName}/schedules` + ), + ]).catch((e) => { + throw new Error( + `Error Occurred: Unable to fetch bindings, routes, or services metadata from the dashboard. Please try again later.`, + { cause: e } + ); + }); + return { + bindings, + routes, + customDomains, + subdomainStatus, + serviceEnvMetadata, + cronTriggers, + }; +} + +/** + * Downloads all the remote information we can gather for a worker and from them generates a raw configuration object that + * approximates what a wrangler config object for the worker was/would have been. + */ +export async function downloadWorkerConfig( + workerName: string, + environment: string, + entrypoint: string, + accountId: string +): Promise { + const { + bindings, + routes, + customDomains, + subdomainStatus, + serviceEnvMetadata, + cronTriggers, + } = await fetchWorkerConfig(accountId, workerName, environment); + + return constructWranglerConfig({ + name: workerName, + entrypoint, + compatibility_date: serviceEnvMetadata.script.compatibility_date, + compatibility_flags: serviceEnvMetadata.script.compatibility_flags, + tags: serviceEnvMetadata.script.tags, + migration_tag: serviceEnvMetadata.script.migration_tag, + tail_consumers: serviceEnvMetadata.script.tail_consumers, + observability: serviceEnvMetadata.script.observability, + limits: serviceEnvMetadata.script.limits, + bindings, + routes, + domains: customDomains, + subdomain: subdomainStatus, + schedules: cronTriggers.schedules.map((s) => ({ + cron: s.cron, + })), + placement: serviceEnvMetadata.script.placement_mode + ? { mode: serviceEnvMetadata.script.placement_mode } + : undefined, + logpush: undefined, + }); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/durable.ts b/packages/deploy-helpers/src/deploy/helpers/durable.ts new file mode 100644 index 0000000000..3c851d5f05 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/durable.ts @@ -0,0 +1,108 @@ +import assert from "node:assert"; +import { configFileName } from "@cloudflare/workers-utils"; +import { fetchResult, logger } from "../../shared/context"; +import { isWorkerNotFoundError } from "./worker-not-found-error"; +import type { CfWorkerInit, Config } from "@cloudflare/workers-utils"; + +/** + * For a given Worker + migrations config, figure out which migrations + * to upload based on the current migration tag of the deployed Worker. + */ +export async function getMigrationsToUpload( + scriptName: string, + props: { + accountId: string | undefined; + config: Config; + useServiceEnvironments: boolean | undefined; + env: string | undefined; + dispatchNamespace: string | undefined; + } +): Promise { + const { config, accountId } = props; + + assert(accountId, "Missing accountId"); + let migrations; + if (config.migrations.length > 0) { + type ScriptData = { id: string; migration_tag?: string }; + let script: ScriptData | undefined; + if (props.dispatchNamespace) { + try { + const scriptData = await fetchResult<{ script: ScriptData }>( + config, + `/accounts/${accountId}/workers/dispatch/namespaces/${props.dispatchNamespace}/scripts/${scriptName}` + ); + script = scriptData.script; + } catch (err) { + suppressNotFoundError(err); + } + } else { + if (props.useServiceEnvironments) { + try { + if (props.env) { + const scriptData = await fetchResult<{ + script: ScriptData; + }>( + config, + `/accounts/${accountId}/workers/services/${scriptName}/environments/${props.env}` + ); + script = scriptData.script; + } else { + const scriptData = await fetchResult<{ + default_environment: { + script: ScriptData; + }; + }>(config, `/accounts/${accountId}/workers/services/${scriptName}`); + script = scriptData.default_environment.script; + } + } catch (err) { + suppressNotFoundError(err); + } + } else { + const scripts = await fetchResult( + config, + `/accounts/${accountId}/workers/scripts` + ); + script = scripts.find(({ id }) => id === scriptName); + } + } + + if (script?.migration_tag) { + const scriptMigrationTag = script.migration_tag; + const foundIndex = config.migrations.findIndex( + (migration) => migration.tag === scriptMigrationTag + ); + if (foundIndex === -1) { + logger.warn( + `The published script ${scriptName} has a migration tag "${script.migration_tag}, which was not found in your ${configFileName(config.configPath)} file. You may have already deleted it. Applying all available migrations to the script...` + ); + migrations = { + old_tag: script.migration_tag, + new_tag: config.migrations[config.migrations.length - 1].tag, + steps: config.migrations.map(({ tag: _tag, ...rest }) => rest), + }; + } else { + if (foundIndex !== config.migrations.length - 1) { + migrations = { + old_tag: script.migration_tag, + new_tag: config.migrations[config.migrations.length - 1].tag, + steps: config.migrations + .slice(foundIndex + 1) + .map(({ tag: _tag, ...rest }) => rest), + }; + } + } + } else { + migrations = { + new_tag: config.migrations[config.migrations.length - 1].tag, + steps: config.migrations.map(({ tag: _tag, ...rest }) => rest), + }; + } + } + return migrations; +} + +const suppressNotFoundError = (err: unknown) => { + if (!isWorkerNotFoundError(err) && (err as { code: number }).code !== 10092) { + throw err; + } +}; diff --git a/packages/deploy-helpers/src/deploy/helpers/environments.ts b/packages/deploy-helpers/src/deploy/helpers/environments.ts new file mode 100644 index 0000000000..7d7ae68537 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/environments.ts @@ -0,0 +1,59 @@ +import { + ENVIRONMENT_TAG_PREFIX, + SERVICE_TAG_PREFIX, +} from "@cloudflare/workers-utils"; +import { logger } from "../../shared/context"; +import { useServiceEnvironments } from "./use-service-environments"; +import type { Config } from "@cloudflare/workers-utils"; + +export function hasDefinedEnvironments(config: Config) { + return ( + !useServiceEnvironments(config) && + Boolean(config.definedEnvironments?.length) + ); +} + +export function applyServiceAndEnvironmentTags(config: Config, tags: string[]) { + const env = config.targetEnvironment; + const serviceName = config.topLevelName; + const shouldApplyTags = hasDefinedEnvironments(config); + + if (shouldApplyTags && !serviceName) { + logger.warn( + "No top-level `name` has been defined in Wrangler configuration. Add a top-level `name` to group this Worker together with its sibling environments in the Cloudflare dashboard." + ); + } + + const serviceTag = + shouldApplyTags && serviceName + ? `${SERVICE_TAG_PREFIX}${serviceName}` + : null; + const environmentTag = + serviceTag && env ? `${ENVIRONMENT_TAG_PREFIX}${env}` : null; + + tags = tags.filter( + (tag) => + !tag.startsWith(SERVICE_TAG_PREFIX) && + !tag.startsWith(ENVIRONMENT_TAG_PREFIX) + ); + + if (serviceTag) { + tags.push(serviceTag); + } + + if (environmentTag) { + tags.push(environmentTag); + } + + return tags; +} + +export function warnOnErrorUpdatingServiceAndEnvironmentTags() { + logger.warn( + "Could not apply service and environment tags. This Worker will not appear grouped together with its sibling environments in the Cloudflare dashboard." + ); +} + +export function tagsAreEqual(a: string[], b: string[]) { + return a.length === b.length && a.every((el, i) => b[i] === el); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/error-codes.ts b/packages/deploy-helpers/src/deploy/helpers/error-codes.ts new file mode 100644 index 0000000000..5391bdd505 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/error-codes.ts @@ -0,0 +1,6 @@ +/** + * Cloudflare API error codes used by deploy-helpers. + */ + +/** The inherit binding references a binding that does not exist on the previous version. */ +export const INVALID_INHERIT_BINDING_CODE = 10057 as const; diff --git a/packages/deploy-helpers/src/deploy/helpers/friendly-validator-errors.ts b/packages/deploy-helpers/src/deploy/helpers/friendly-validator-errors.ts new file mode 100644 index 0000000000..0e5efe7ce2 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/friendly-validator-errors.ts @@ -0,0 +1,171 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { getWranglerTmpDir, ParseError } from "@cloudflare/workers-utils"; +import dedent from "ts-dedent"; +import { logger } from "../../shared/context"; +import type { Metafile } from "esbuild"; +import type { FormData } from "undici"; + +export async function helpIfErrorIsSizeOrScriptStartup( + err: unknown, + dependencies: { [path: string]: { bytesInOutput: number } }, + workerBundle: FormData | string, + projectRoot: string | undefined, + analyseBundle?: (bundle: FormData | string) => Promise +): Promise { + if (errIsScriptSize(err)) { + return diagnoseScriptSizeError(err, dependencies); + } + if (errIsStartupErr(err)) { + return await diagnoseStartupError( + err, + workerBundle, + projectRoot, + analyseBundle + ); + } + return null; +} + +/** + * Returns a formatted error message that describes the script size error. + * It includes the largest dependencies if available. + */ +export function diagnoseScriptSizeError( + err: ParseError, + dependencies: { [path: string]: { bytesInOutput: number } } +): string { + let message = dedent` + Your Worker failed validation because it exceeded size limits. + + ${err.text} + ${err.notes.map((note) => ` - ${note.text}`).join("\n")} + `; + + const dependenciesMessage = getOffendingDependenciesMessage(dependencies); + if (dependenciesMessage) { + message += dependenciesMessage; + } + + return message; +} + +/** + * Returns a formatted error message that describes the startup error. + * If profiling is successful, it will include a link to the generated CPU profile. + */ +export async function diagnoseStartupError( + err: ParseError, + workerBundle: FormData | string, + projectRoot: string | undefined, + analyseBundle?: (bundle: FormData | string) => Promise +): Promise { + let errorMessage = dedent` + Your Worker failed validation because it exceeded startup limits. + + ${err.text} + ${err.notes.map((note) => ` - ${note.text}`).join("\n")} + + To ensure fast responses, there are constraints on Worker startup, such as how much CPU it can use, or how long it can take. Your Worker has hit one of these startup limits. Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler. + + Refer to https://developers.cloudflare.com/workers/platform/limits/#worker-startup-time for more details`; + + try { + if (!analyseBundle) { + return errorMessage; + } + const cpuProfile = await analyseBundle(workerBundle); + const tmpDir = await getWranglerTmpDir( + projectRoot, + "startup-profile", + false + ); + const profile = path.relative( + projectRoot ?? process.cwd(), + path.join(tmpDir.path, `worker.cpuprofile`) + ); + await writeFile(profile, JSON.stringify(cpuProfile)); + + errorMessage += dedent` + + A CPU Profile of your Worker's startup phase has been written to ${profile} - load it into the Chrome DevTools profiler (or directly in VSCode) to view a flamegraph.`; + } catch (profilingError) { + logger.debug( + `An error occurred while trying to locally profile the Worker: ${profilingError}` + ); + } + + return errorMessage; +} + +/** + * Gets a message that describes the largest dependencies in the script or `null` if there are none. + */ +function getOffendingDependenciesMessage( + dependencies: Metafile["outputs"][string]["inputs"] +): string | null { + const dependenciesSorted = Object.entries(dependencies); + if (dependenciesSorted.length === 0) { + return null; + } + + dependenciesSorted.sort( + ([, aData], [, bData]) => bData.bytesInOutput - aData.bytesInOutput + ); + + const topLargest = dependenciesSorted.slice(0, 5); + const ONE_KIB_BYTES = 1024; + return [ + "", + `Here are the ${topLargest.length} largest dependencies included in your script:`, + "", + ...topLargest.map( + ([dep, data]) => + `- ${dep} - ${(data.bytesInOutput / ONE_KIB_BYTES).toFixed(2)} KiB` + ), + "", + "If these are unnecessary, consider removing them", + "", + ].join("\n"); +} + +/** + * Returns true if the error is a script size error. + */ +function errIsScriptSize(err: unknown): err is ParseError & { code: 10027 } { + if (!(err instanceof ParseError)) { + return false; + } + + // 10027 = workers.api.error.script_too_large + if ("code" in err && err.code === 10027) { + return true; + } + + return false; +} + +/** + * Returns true if the error is a startup error. + */ +function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } { + if (!(err instanceof ParseError)) { + return false; + } + + // 10021 = validation error + // no explicit error code for more granular errors than "invalid script" + // but the error will contain a string error message directly from the + // validator. + // the error always SHOULD look like "Script startup exceeded CPU limit." + // (or the less likely "Script startup exceeded memory limits.") + if ( + "code" in err && + err.code === 10021 && + /startup/i.test(err.notes[0]?.text) + ) { + return true; + } + + return false; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/hash.ts b/packages/deploy-helpers/src/deploy/helpers/hash.ts new file mode 100644 index 0000000000..dca3ffd98a --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/hash.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { extname } from "node:path"; +import { hash as blake3hash } from "blake3-wasm"; + +export const hashFile = (filepath: string) => { + const contents = readFileSync(filepath); + const base64Contents = contents.toString("base64"); + const extension = extname(filepath).substring(1); + + return blake3hash(base64Contents + extension) + .toString("hex") + .slice(0, 32); +}; diff --git a/packages/deploy-helpers/src/deploy/helpers/jwt.ts b/packages/deploy-helpers/src/deploy/helpers/jwt.ts new file mode 100644 index 0000000000..93a63391d7 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/jwt.ts @@ -0,0 +1,24 @@ +export const isJwtExpired = (token: string): boolean | undefined => { + // During testing we don't use valid JWTs, so don't try and parse them. + if ( + "vitest" in globalThis && + (token === "<>" || + token === "<>" || + token === "<>") + ) { + return false; + } + try { + const decodedJwt = JSON.parse( + Buffer.from(token.split(".")[1], "base64").toString() + ); + + const dateNow = new Date().getTime() / 1000; + + return decodedJwt.exp <= dateNow; + } catch (e) { + if (e instanceof Error) { + throw new Error(`Invalid token: ${e.message}`); + } + } +}; diff --git a/packages/deploy-helpers/src/deploy/helpers/match-tag.ts b/packages/deploy-helpers/src/deploy/helpers/match-tag.ts new file mode 100644 index 0000000000..22b6c05c84 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/match-tag.ts @@ -0,0 +1,90 @@ +import { + APIError, + configFileName, + FatalError, + formatConfigSnippet, + getCIMatchTag, + getEnvironmentVariableFactory, +} from "@cloudflare/workers-utils"; +import { fetchResult, logger } from "../../shared/context"; +import { isWorkerNotFoundError } from "./worker-not-found-error"; +import type { + ComplianceConfig, + ServiceMetadataRes, +} from "@cloudflare/workers-utils"; + +const getCloudflareAccountIdFromEnv = getEnvironmentVariableFactory({ + variableName: "CLOUDFLARE_ACCOUNT_ID", + deprecatedName: "CF_ACCOUNT_ID", +}); + +export async function verifyWorkerMatchesCITag( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + configPath: string | undefined +) { + const matchTag = getCIMatchTag(); + + logger.debug( + `Starting verifyWorkerMatchesCITag() with tag: ${matchTag}, name: ${workerName}` + ); + + if (!matchTag) { + logger.debug( + "No WRANGLER_CI_MATCH_TAG variable provided, aborting verifyWorkerMatchesCITag()" + ); + return; + } + + const envAccountID = getCloudflareAccountIdFromEnv(); + + if (accountId !== envAccountID) { + throw new FatalError( + `The \`account_id\` in your ${configFileName(configPath)} file must match the \`account_id\` for this account. Please update your ${configFileName(configPath)} file with \`${formatConfigSnippet({ account_id: envAccountID }, configPath, false)}\``, + { telemetryMessage: "ci match tag account mismatch" } + ); + } + + let tag; + + try { + const worker = await fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/services/${workerName}` + ); + tag = worker.default_environment.script.tag; + logger.debug(`API returned with tag: ${tag} for worker: ${workerName}`); + } catch (e) { + logger.debug(e); + if (isWorkerNotFoundError(e)) { + throw new FatalError( + `The name in your ${configFileName(configPath)} file (${workerName}) must match the name of your Worker. Please update the name field in your ${configFileName(configPath)} file.`, + { telemetryMessage: "ci match tag worker not found" } + ); + } else if (e instanceof APIError) { + throw new FatalError( + "An error occurred while trying to validate that the Worker name matches what is expected by the build system.\n" + + e.message + + "\n" + + e.notes.map((note) => note.text).join("\n"), + { telemetryMessage: "ci match tag validation api error" } + ); + } else { + throw new FatalError( + "Wrangler cannot validate that your Worker name matches what is expected by the build system. Please retry the build. " + + "If the problem persists, please contact support.", + { telemetryMessage: "ci match tag validation failed" } + ); + } + } + if (tag !== matchTag) { + logger.debug( + `Failed to match Worker tag. The API returned "${tag}", but the CI system expected "${matchTag}"` + ); + throw new FatalError( + `The name in your ${configFileName(configPath)} file (${workerName}) must match the name of your Worker. Please update the name field in your ${configFileName(configPath)} file.`, + { telemetryMessage: "ci match tag tag mismatch" } + ); + } +} diff --git a/packages/deploy-helpers/src/deploy/helpers/node-compat.ts b/packages/deploy-helpers/src/deploy/helpers/node-compat.ts new file mode 100644 index 0000000000..805fce613d --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/node-compat.ts @@ -0,0 +1,54 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { getNodeCompat } from "miniflare"; +import { logger } from "../../shared/context"; +import type { NodeJSCompatMode } from "miniflare"; + +/** + * Computes and validates the Node.js compatibility mode we are running. + * + * NOTES: + * - The v2 mode is configured via `nodejs_compat_v2` compat flag or via `nodejs_compat` plus a compatibility date of Sept 23rd. 2024 or later. + * - See `EnvironmentInheritable` for `noBundle`. + * + * @param compatibilityDateStr The compatibility date + * @param compatibilityFlags The compatibility flags + * @param noBundle Whether to skip internal build steps and directly deploy script + * + */ export function validateNodeCompatMode( + compatibilityDateStr: string = "2000-01-01", // Default to some arbitrary old date + compatibilityFlags: string[], + { + noBundle = undefined, + }: { + noBundle?: boolean; + } +): NodeJSCompatMode { + const { + mode, + hasNodejsCompatFlag, + hasNodejsCompatV2Flag, + hasExperimentalNodejsCompatV2Flag, + } = getNodeCompat(compatibilityDateStr, compatibilityFlags); + + if (hasExperimentalNodejsCompatV2Flag) { + throw new UserError( + "The `experimental:` prefix on `nodejs_compat_v2` is no longer valid. Please remove it and try again.", + { telemetryMessage: "experimental nodejs compat v2 prefix unsupported" } + ); + } + + if (hasNodejsCompatFlag && hasNodejsCompatV2Flag) { + throw new UserError( + "The `nodejs_compat` and `nodejs_compat_v2` compatibility flags cannot be used in together. Please select just one.", + { telemetryMessage: "conflicting nodejs compat flags" } + ); + } + + if (noBundle && hasNodejsCompatV2Flag) { + logger.warn( + "`nodejs_compat_v2` compatibility flag and `--no-bundle` can't be used together. If you want to polyfill Node.js built-ins and disable Wrangler's bundling, please polyfill as part of your own bundling process." + ); + } + + return mode; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/parse-bulk-input.ts b/packages/deploy-helpers/src/deploy/helpers/parse-bulk-input.ts new file mode 100644 index 0000000000..6c91c8eb8d --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/parse-bulk-input.ts @@ -0,0 +1,125 @@ +import path from "node:path"; +import readline from "node:readline"; +import { + FatalError, + parseJSON, + readFileSync, + UserError, +} from "@cloudflare/workers-utils"; +import { parse as dotenvParse } from "dotenv"; + +export function validateFileSecrets( + content: unknown, + jsonFilePath: string +): content is Record { + if (content === null || typeof content !== "object") { + throw new FatalError( + `The contents of "${jsonFilePath}" is not valid. It should be a JSON object of string values.`, + { telemetryMessage: "secret bulk file invalid contents" } + ); + } + const entries = Object.entries(content); + for (const [key, value] of entries) { + if (value != null && typeof value !== "string") { + throw new FatalError( + `The value for "${key}" in "${jsonFilePath}" is not null or a "string" instead it is of type "${typeof value}"`, + { telemetryMessage: "secret bulk file invalid value type" } + ); + } + } + return true; +} + +/** Error thrown when no input is provided to parseBulkInputToObject */ +export class NoInputError extends Error { + constructor() { + super("No input provided"); + this.name = "NoInputError"; + } +} + +/** Result from parsing bulk secret input without nullable values, including metadata for analytics */ +export type BulkInputResult = { + content: Record; + secretSource: "file" | "stdin"; + secretFormat: "json" | "dotenv"; +}; + +/** Result from parsing bulk secret input with nullable values, including metadata for analytics */ +export type BulkInputNullableResult = { + content: Record; + secretSource: "file" | "stdin"; + secretFormat: "json" | "dotenv"; +}; + +/** Override for callers that need non-nullable */ +export async function parseBulkInputToObject( + input?: string, + includeNull?: false +): Promise; + +/** Override for callers that need nullable */ +export async function parseBulkInputToObject( + input?: string, + includeNull?: true +): Promise; + +export async function parseBulkInputToObject( + input?: string, + includeNull: boolean = false +): Promise { + let content: Record; + let secretSource: "file" | "stdin"; + let secretFormat: "json" | "dotenv"; + + if (input) { + secretSource = "file"; + const jsonFilePath = path.resolve(input); + const fileContent = readFileSync(jsonFilePath); + try { + content = parseJSON(fileContent) as Record; + secretFormat = "json"; + } catch { + content = dotenvParse(fileContent); + secretFormat = "dotenv"; + // dotenvParse does not error unless fileContent is undefined, no keys === error + if (Object.keys(content).length === 0) { + throw new UserError(`The contents of "${input}" is not valid.`, { + telemetryMessage: "secret bulk invalid input", + }); + } + } + } else { + secretSource = "stdin"; + try { + const rl = readline.createInterface({ input: process.stdin }); + const pipedInputLines: string[] = []; + for await (const line of rl) { + pipedInputLines.push(line); + } + const pipedInput = pipedInputLines.join("\n"); + try { + content = parseJSON(pipedInput) as Record; + secretFormat = "json"; + } catch (e) { + content = dotenvParse(pipedInput); + secretFormat = "dotenv"; + // dotenvParse does not error unless fileContent is undefined, no keys === error + if (Object.keys(content).length === 0) { + throw e; + } + } + } catch { + return; + } + } + validateFileSecrets(content, input ?? "piped input"); + if (!includeNull) { + content = Object.fromEntries( + Object.entries(content).filter( + (entry): entry is [string, string] => entry[1] != null + ) + ); + } + return { content, secretSource, secretFormat }; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/placement.ts b/packages/deploy-helpers/src/deploy/helpers/placement.ts new file mode 100644 index 0000000000..17d3dac68e --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/placement.ts @@ -0,0 +1,31 @@ +import type { CfPlacement, Config } from "@cloudflare/workers-utils"; + +/** + * Parse placement out of a Config + */ +export function parseConfigPlacement(config: Config): CfPlacement | undefined { + if (config.placement) { + const configPlacement = config.placement; + const hint = "hint" in configPlacement ? configPlacement.hint : undefined; + + if (!hint && configPlacement.mode === "off") { + return undefined; + } else if (hint || configPlacement.mode === "smart") { + return { mode: "smart", hint: hint }; + } else { + // mode is undefined or "targeted", which both map to the targeted variant + // TypeScript needs explicit checks to narrow the union type + if ("region" in configPlacement && configPlacement.region) { + return { mode: "targeted", region: configPlacement.region }; + } else if ("host" in configPlacement && configPlacement.host) { + return { mode: "targeted", host: configPlacement.host }; + } else if ("hostname" in configPlacement && configPlacement.hostname) { + return { mode: "targeted", hostname: configPlacement.hostname }; + } else { + return undefined; + } + } + } else { + return undefined; + } +} diff --git a/packages/deploy-helpers/src/deploy/helpers/preview-alias.ts b/packages/deploy-helpers/src/deploy/helpers/preview-alias.ts new file mode 100644 index 0000000000..88bb186361 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/preview-alias.ts @@ -0,0 +1,107 @@ +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { getWorkersCIBranchName } from "@cloudflare/workers-utils"; +import { logger } from "../../shared/context"; + +const MAX_DNS_LABEL_LENGTH = 63; +const HASH_LENGTH = 4; +const ALIAS_VALIDATION_REGEX = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/i; + +/** + * Sanitizes a branch name to create a valid DNS label alias. + * Converts to lowercase, replaces invalid chars with dashes, removes consecutive dashes. + */ +export function sanitizeBranchName(branchName: string): string { + return branchName + .replace(/[^a-zA-Z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); +} + +/** + * Gets the current branch name from CI environment or git. + */ +export function getBranchName(): string | undefined { + // Try CI environment variable first + const ciBranchName = getWorkersCIBranchName(); + if (ciBranchName) { + return ciBranchName; + } + + // Fall back to git commands + try { + execSync(`git rev-parse --is-inside-work-tree`, { stdio: "ignore" }); + return execSync(`git rev-parse --abbrev-ref HEAD`).toString().trim(); + } catch { + return undefined; + } +} + +/** + * Creates a truncated alias with hash suffix when the branch name is too long. + * Hash from original branch name to preserve uniqueness. + */ +export function createTruncatedAlias( + branchName: string, + sanitizedAlias: string, + availableSpace: number +): string | undefined { + const spaceForHash = HASH_LENGTH + 1; // +1 for hyphen separator + const maxPrefixLength = availableSpace - spaceForHash; + + if (maxPrefixLength < 1) { + // Not enough space even with truncation + return undefined; + } + + const hash = createHash("sha256") + .update(branchName) + .digest("hex") + .slice(0, HASH_LENGTH); + + const truncatedPrefix = sanitizedAlias.slice(0, maxPrefixLength); + return `${truncatedPrefix}-${hash}`; +} + +/** + * Generates a preview alias based on the current git branch. + * Alias must be <= 63 characters, alphanumeric + dashes only, and start with a letter. + * Returns undefined if not in a git directory or requirements cannot be met. + */ +export function generatePreviewAlias(scriptName: string): string | undefined { + const warnAndExit = () => { + logger.warn( + `Preview alias generation requested, but could not be autogenerated.` + ); + return undefined; + }; + + const branchName = getBranchName(); + if (!branchName) { + return warnAndExit(); + } + + const sanitizedAlias = sanitizeBranchName(branchName); + + // Validate the sanitized alias meets DNS label requirements + if (!ALIAS_VALIDATION_REGEX.test(sanitizedAlias)) { + return warnAndExit(); + } + + const availableSpace = MAX_DNS_LABEL_LENGTH - scriptName.length - 1; + + // If the sanitized alias fits within the remaining space, return it, + // otherwise try truncation with hash suffixed + if (sanitizedAlias.length <= availableSpace) { + return sanitizedAlias; + } + + const truncatedAlias = createTruncatedAlias( + branchName, + sanitizedAlias, + availableSpace + ); + + return truncatedAlias || warnAndExit(); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts new file mode 100644 index 0000000000..6403a98a70 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts @@ -0,0 +1,1071 @@ +import { stripVTControlCharacters } from "node:util"; +import { brandColor, dim, white } from "@cloudflare/cli-shared-helpers/colors"; +import { + assertNever, + getBindingLocalSupport, + getBindingTypeFriendlyName, + UserError, +} from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { logger } from "../../shared/context"; +import { extractBindingsOfType, isUnsafeBindingType } from "./binding-utils"; +import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; +import type { + CfSendEmailBindings, + CfTailConsumer, + ContainerApp, +} from "@cloudflare/workers-utils"; +import type { WorkerRegistry } from "miniflare"; + +/** + * Tracks whether we have already explained the connected status + */ +let isConnectedStatusExplained = false; + +type PrintContext = { + log?: (message: string) => void; + registry?: WorkerRegistry | null; + local?: boolean; + isMultiWorker?: boolean; + remoteBindingsDisabled?: boolean; + name?: string; + provisioning?: boolean; + warnIfNoBindings?: boolean; + unsafeMetadata?: Record; +}; + +/** + * Print all the bindings a worker would have access to. + * Accepts StartDevWorkerInput["bindings"] format + */ +export function printBindings( + bindings: StartDevWorkerInput["bindings"], + tailConsumers: CfTailConsumer[] = [], + streamingTailConsumers: CfTailConsumer[] = [], + containers: ContainerApp[] = [], + context: PrintContext = {} +) { + let hasConnectionStatus = false; + + const log = context.log ?? logger.log; + const isMultiWorker = context.isMultiWorker ?? false; + const getMode = createGetMode({ + isProvisioning: context.provisioning, + isLocalDev: context.local, + }); + const truncate = (item: string | Record, maxLength = 40) => { + const s = typeof item === "string" ? item : JSON.stringify(item); + if (s.length < maxLength) { + return s; + } + + return `${s.substring(0, maxLength - 3)}...`; + }; + + const output: { + name: string; + type: string; + value: string | undefined | symbol; + mode: string | undefined; + }[] = []; + + // Extract bindings by type + const data_blobs = extractBindingsOfType("data_blob", bindings); + const durable_objects = extractBindingsOfType( + "durable_object_namespace", + bindings + ); + const workflows = extractBindingsOfType("workflow", bindings); + const kv_namespaces = extractBindingsOfType("kv_namespace", bindings); + const send_email = extractBindingsOfType("send_email", bindings); + const queues = extractBindingsOfType("queue", bindings); + const d1_databases = extractBindingsOfType("d1", bindings); + const vectorize = extractBindingsOfType("vectorize", bindings); + const ai_search_namespaces = extractBindingsOfType( + "ai_search_namespace", + bindings + ); + const ai_search = extractBindingsOfType("ai_search", bindings); + const websearch = extractBindingsOfType("websearch", bindings); + const agent_memory = extractBindingsOfType("agent_memory", bindings); + const hyperdrive = extractBindingsOfType("hyperdrive", bindings); + const r2_buckets = extractBindingsOfType("r2_bucket", bindings); + const logfwdr = extractBindingsOfType("logfwdr", bindings); + const secrets_store_secrets = extractBindingsOfType( + "secrets_store_secret", + bindings + ); + const artifacts = extractBindingsOfType("artifacts", bindings); + const services = extractBindingsOfType("service", bindings); + const vpc_services = extractBindingsOfType("vpc_service", bindings); + const vpc_networks = extractBindingsOfType("vpc_network", bindings); + const analytics_engine_datasets = extractBindingsOfType( + "analytics_engine", + bindings + ); + const text_blobs = extractBindingsOfType("text_blob", bindings); + const browser = extractBindingsOfType("browser", bindings); + const images = extractBindingsOfType("images", bindings); + const stream = extractBindingsOfType("stream", bindings); + const ai = extractBindingsOfType("ai", bindings); + const version_metadata = extractBindingsOfType("version_metadata", bindings); + // Extract all vars (plain_text, json, secret_text) together to preserve insertion order + const vars = Object.entries(bindings ?? {}) + .filter( + ([_, binding]) => + binding.type === "plain_text" || + binding.type === "json" || + binding.type === "secret_text" + ) + .map(([name, binding]) => ({ + binding: name, + ...(binding as + | Extract + | Extract + | Extract), + })); + const wasm_modules = extractBindingsOfType("wasm_module", bindings); + const dispatch_namespaces = extractBindingsOfType( + "dispatch_namespace", + bindings + ); + const mtls_certificates = extractBindingsOfType("mtls_certificate", bindings); + const pipelines = extractBindingsOfType("pipeline", bindings); + const ratelimits = extractBindingsOfType("ratelimit", bindings); + const assets = extractBindingsOfType("assets", bindings); + const unsafe_hello_world = extractBindingsOfType( + "unsafe_hello_world", + bindings + ); + const flagship = extractBindingsOfType("flagship", bindings); + const media = extractBindingsOfType("media", bindings); + const worker_loaders = extractBindingsOfType("worker_loader", bindings); + + // Extract generic unsafe bindings (type starts with "unsafe_" but isn't "unsafe_hello_world") + const unsafe_bindings = Object.entries(bindings ?? {}) + .filter( + ([_, binding]) => + isUnsafeBindingType(binding.type) && + binding.type !== "unsafe_hello_world" + ) + .map(([name, binding]) => ({ name, ...binding })); + + if (data_blobs.length > 0) { + output.push( + ...data_blobs.map(({ binding, source }) => ({ + name: binding, + type: getBindingTypeFriendlyName("data_blob"), + value: "contents" in source ? "" : truncate(source.path), + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + + if (durable_objects.length > 0) { + output.push( + ...durable_objects.map(({ name, class_name, script_name }) => { + let value = class_name; + let mode = undefined; + if (script_name) { + if (context.local && context.registry !== null) { + const registryDefinition = context.registry?.[script_name]; + + hasConnectionStatus = true; + if (registryDefinition && registryDefinition.debugPortAddress) { + value += `, defined in ${script_name}`; + mode = getMode({ isSimulatedLocally: true, connected: true }); + } else { + value += `, defined in ${script_name}`; + mode = getMode({ isSimulatedLocally: true, connected: false }); + } + } else { + value += `, defined in ${script_name}`; + mode = getMode({ isSimulatedLocally: true }); + } + } else { + mode = getMode({ isSimulatedLocally: true }); + } + + return { + name, + type: getBindingTypeFriendlyName("durable_object_namespace"), + value: value, + mode, + }; + }) + ); + } + + if (workflows.length > 0) { + output.push( + ...workflows.map(({ class_name, script_name, binding, remote }) => { + let value = class_name; + if (script_name) { + value += ` (defined in ${script_name})`; + } + + return { + name: binding, + type: getBindingTypeFriendlyName("workflow"), + value: value, + mode: getMode({ + isSimulatedLocally: + script_name && !context.remoteBindingsDisabled ? !remote : true, + }), + }; + }) + ); + } + + if (kv_namespaces.length > 0) { + output.push( + ...kv_namespaces.map(({ binding, id, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("kv_namespace"), + value: id, + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + }; + }) + ); + } + + if (send_email.length > 0) { + output.push( + ...send_email.map((emailBinding: CfSendEmailBindings) => { + const destination_address = + "destination_address" in emailBinding + ? emailBinding.destination_address + : undefined; + const allowed_destination_addresses = + "allowed_destination_addresses" in emailBinding + ? emailBinding.allowed_destination_addresses + : undefined; + const allowed_sender_addresses = + "allowed_sender_addresses" in emailBinding + ? emailBinding.allowed_sender_addresses + : undefined; + let value = + destination_address || + allowed_destination_addresses?.join(", ") || + "unrestricted"; + + if (allowed_sender_addresses) { + value += ` - senders: ${allowed_sender_addresses.join(", ")}`; + } + return { + name: emailBinding.name, + type: getBindingTypeFriendlyName("send_email"), + value, + mode: getMode({ + isSimulatedLocally: + context.remoteBindingsDisabled || !emailBinding.remote, + }), + }; + }) + ); + } + + if (queues.length > 0) { + output.push( + ...queues.map(({ binding, queue_name, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("queue"), + value: queue_name, + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + }; + }) + ); + } + + if (d1_databases.length > 0) { + output.push( + ...d1_databases.map( + ({ + binding, + database_name, + database_id, + preview_database_id, + remote, + }) => { + const value = + typeof database_id == "symbol" + ? database_id + : (preview_database_id ?? database_name ?? database_id); + + return { + name: binding, + type: getBindingTypeFriendlyName("d1"), + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + value, + }; + } + ) + ); + } + + if (vectorize.length > 0) { + output.push( + ...vectorize.map(({ binding, index_name, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("vectorize"), + value: index_name, + mode: getMode({ + isSimulatedLocally: + remote && !context.remoteBindingsDisabled ? false : undefined, + }), + }; + }) + ); + } + + if (ai_search_namespaces.length > 0) { + output.push( + ...ai_search_namespaces.map(({ binding, namespace }) => ({ + name: binding, + type: getBindingTypeFriendlyName("ai_search_namespace"), + // Preserve `namespace` as-is so `typeof === "symbol"` handling + // downstream can render INHERIT_SYMBOL as `"inherited"`. Using + // `String(namespace)` would stringify it to + // `"Symbol(inherit_binding)"` and defeat that check. + value: namespace ?? undefined, + mode: getMode({ isSimulatedLocally: false }), + })) + ); + } + + if (ai_search.length > 0) { + output.push( + ...ai_search.map(({ binding, instance_name }) => ({ + name: binding, + type: getBindingTypeFriendlyName("ai_search"), + value: instance_name ? String(instance_name) : undefined, + mode: getMode({ isSimulatedLocally: false }), + })) + ); + } + + if (websearch.length > 0) { + output.push( + ...websearch.map(({ binding }) => ({ + name: binding, + type: getBindingTypeFriendlyName("websearch"), + value: undefined, + mode: getMode({ isSimulatedLocally: false }), + })) + ); + } + + if (agent_memory.length > 0) { + output.push( + ...agent_memory.map(({ binding, namespace }) => ({ + name: binding, + type: getBindingTypeFriendlyName("agent_memory"), + value: namespace ?? undefined, + mode: getMode({ isSimulatedLocally: false }), + })) + ); + } + + if (hyperdrive.length > 0) { + output.push( + ...hyperdrive.map(({ binding, id }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("hyperdrive"), + value: id, + mode: getMode({ isSimulatedLocally: true }), + }; + }) + ); + } + + if (vpc_services.length > 0) { + output.push( + ...vpc_services.map(({ binding, service_id, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("vpc_service"), + value: service_id, + mode: getMode({ + isSimulatedLocally: + remote && !context.remoteBindingsDisabled ? false : undefined, + }), + }; + }) + ); + } + + if (vpc_networks.length > 0) { + output.push( + ...vpc_networks.map(({ binding, tunnel_id, network_id, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("vpc_network"), + value: tunnel_id ?? network_id, + mode: getMode({ + isSimulatedLocally: + remote && !context.remoteBindingsDisabled ? false : undefined, + }), + }; + }) + ); + } + + if (r2_buckets.length > 0) { + output.push( + ...r2_buckets.map(({ binding, bucket_name, jurisdiction, remote }) => { + const value = + typeof bucket_name === "symbol" + ? bucket_name + : bucket_name + ? `${bucket_name}${jurisdiction ? ` (${jurisdiction})` : ""}` + : undefined; + + return { + name: binding, + type: getBindingTypeFriendlyName("r2_bucket"), + value: value, + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + }; + }) + ); + } + + if (logfwdr.length > 0) { + output.push( + ...logfwdr.map(({ name, destination }) => { + return { + name, + type: getBindingTypeFriendlyName("logfwdr"), + value: destination, + mode: getMode(), + }; + }) + ); + } + + if (secrets_store_secrets.length > 0) { + output.push( + ...secrets_store_secrets.map(({ binding, store_id, secret_name }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("secrets_store_secret"), + value: `${store_id}/${secret_name}`, + mode: getMode({ isSimulatedLocally: true }), + }; + }) + ); + } + + if (artifacts.length > 0) { + output.push( + ...artifacts.map(({ binding, namespace }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("artifacts"), + value: namespace, + mode: getMode({ isSimulatedLocally: false }), + }; + }) + ); + } + + if (unsafe_hello_world.length > 0) { + output.push( + ...unsafe_hello_world.map(({ binding, enable_timer }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("unsafe_hello_world"), + value: enable_timer ? `Timer enabled` : `Timer disabled`, + mode: getMode({ isSimulatedLocally: true }), + }; + }) + ); + } + + if (flagship.length > 0) { + output.push( + ...flagship.map(({ binding, app_id }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("flagship"), + value: app_id, + mode: getMode({ + isSimulatedLocally: !context.remoteBindingsDisabled + ? false + : undefined, + }), + }; + }) + ); + } + + if (services.length > 0) { + output.push( + ...services.map(({ binding, service, entrypoint, remote }) => { + let value = service; + let mode = undefined; + + if (entrypoint) { + value += `#${entrypoint}`; + } + + if (remote) { + mode = getMode({ isSimulatedLocally: false }); + } else if (context.local && context.registry !== null) { + const isSelfBinding = service === context.name; + + if (isSelfBinding) { + hasConnectionStatus = true; + mode = getMode({ isSimulatedLocally: true, connected: true }); + } else { + const registryDefinition = context.registry?.[service]; + hasConnectionStatus = true; + + if (registryDefinition && registryDefinition.debugPortAddress) { + mode = getMode({ isSimulatedLocally: true, connected: true }); + } else { + mode = getMode({ isSimulatedLocally: true, connected: false }); + } + } + } + + return { + name: binding, + type: getBindingTypeFriendlyName("service"), + value, + mode, + }; + }) + ); + } + + if (analytics_engine_datasets.length > 0) { + output.push( + ...analytics_engine_datasets.map(({ binding, dataset }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("analytics_engine"), + value: dataset ?? binding, + mode: getMode({ isSimulatedLocally: true }), + }; + }) + ); + } + + if (text_blobs.length > 0) { + output.push( + ...text_blobs.map(({ binding, source }) => ({ + name: binding, + type: getBindingTypeFriendlyName("text_blob"), + value: + "contents" in source + ? truncate(source.contents) + : "path" in source + ? truncate(source.path) + : undefined, + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + + if (browser.length > 0) { + output.push( + ...browser.map(({ binding, remote }) => ({ + name: binding, + type: getBindingTypeFriendlyName("browser"), + value: undefined, + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + })) + ); + } + + if (images.length > 0) { + output.push( + ...images.map(({ binding, remote }) => ({ + name: binding, + type: getBindingTypeFriendlyName("images"), + value: undefined, + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + })) + ); + } + + if (stream.length > 0) { + output.push( + ...stream.map(({ binding, remote }) => ({ + name: binding, + type: getBindingTypeFriendlyName("stream"), + value: undefined, + mode: getMode({ + isSimulatedLocally: + (remote === true || remote === undefined) && + !context.remoteBindingsDisabled + ? false + : undefined, + }), + })) + ); + } + + if (media.length > 0) { + output.push( + ...media.map(({ binding, remote }) => ({ + name: binding, + type: getBindingTypeFriendlyName("media"), + value: undefined, + mode: getMode({ + isSimulatedLocally: + (remote === true || remote === undefined) && + !context.remoteBindingsDisabled + ? false + : undefined, + }), + })) + ); + } + + if (ai.length > 0) { + output.push( + ...ai.map(({ binding, staging, remote }) => ({ + name: binding, + type: getBindingTypeFriendlyName("ai"), + value: staging ? `staging` : undefined, + mode: getMode({ + isSimulatedLocally: + (remote === true || remote === undefined) && + !context.remoteBindingsDisabled + ? false + : undefined, + }), + })) + ); + } + + if (pipelines.length > 0) { + output.push( + ...pipelines.map( + ({ binding, stream: pipelineStream, pipeline, remote }) => ({ + name: binding, + type: getBindingTypeFriendlyName("pipeline"), + value: pipelineStream || pipeline, + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), + }) + ) + ); + } + + if (ratelimits.length > 0) { + output.push( + ...ratelimits.map(({ name, simple }) => ({ + name, + type: getBindingTypeFriendlyName("ratelimit"), + value: `${simple.limit} requests/${simple.period}s`, + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + + if (assets.length > 0) { + output.push( + ...assets.map(({ binding }) => ({ + name: binding, + type: getBindingTypeFriendlyName("assets"), + value: undefined, + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + + if (version_metadata.length > 0) { + output.push( + ...version_metadata.map(({ binding }) => ({ + name: binding, + type: getBindingTypeFriendlyName("version_metadata"), + value: undefined, + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + if (unsafe_bindings.length > 0) { + output.push( + ...unsafe_bindings.map((binding) => { + const dev = "dev" in binding ? binding.dev : undefined; + // Strip the "unsafe_" prefix to get the original binding type for display + const originalType = binding.type.slice("unsafe_".length); + return { + name: binding.name, + type: dev + ? dev.plugin.name + : getBindingTypeFriendlyName(binding.type), + value: originalType, + mode: getMode({ + isSimulatedLocally: !!dev, + }), + }; + }) + ); + } + + if (vars.length > 0) { + output.push( + ...vars.map((variable) => { + const { binding, type: varType, value: varValue } = variable; + let parsedValue; + /** + * @see packages/workers-utils/src/types.ts for details on the hidden property + */ + if (varType === "plain_text" && variable.hidden !== true) { + parsedValue = `"${truncate(varValue)}"`; + } else if (varType === "json") { + parsedValue = truncate(JSON.stringify(varValue)); + } else { + parsedValue = `"(hidden)"`; + } + return { + name: binding, + type: getBindingTypeFriendlyName(varType), + value: parsedValue, + mode: getMode({ isSimulatedLocally: true }), + }; + }) + ); + } + + if (wasm_modules.length > 0) { + output.push( + ...wasm_modules.map(({ binding, source }) => ({ + name: binding, + type: getBindingTypeFriendlyName("wasm_module"), + value: "contents" in source ? "" : truncate(source.path), + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + + if (dispatch_namespaces.length > 0) { + output.push( + ...dispatch_namespaces.map(({ binding, namespace, outbound, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("dispatch_namespace"), + value: outbound + ? `${namespace} (outbound -> ${outbound.service})` + : namespace, + mode: getMode({ + isSimulatedLocally: + remote && !context.remoteBindingsDisabled ? false : undefined, + }), + }; + }) + ); + } + + if (mtls_certificates.length > 0) { + output.push( + ...mtls_certificates.map(({ binding, certificate_id, remote }) => { + return { + name: binding, + type: getBindingTypeFriendlyName("mtls_certificate"), + value: certificate_id, + mode: getMode({ + isSimulatedLocally: + remote && !context.remoteBindingsDisabled ? false : undefined, + }), + }; + }) + ); + } + + if (worker_loaders.length > 0) { + output.push( + ...worker_loaders.map(({ binding }) => ({ + name: binding, + type: getBindingTypeFriendlyName("worker_loader"), + value: undefined, + mode: getMode({ isSimulatedLocally: true }), + })) + ); + } + + if (output.length === 0) { + if (context.warnIfNoBindings) { + if (context.name && isMultiWorker) { + log(`No bindings found for ${chalk.blue(context.name)}`); + } else { + log("No bindings found."); + } + } + } else { + let title: string; + if (context.provisioning) { + title = `${chalk.red("Experimental:")} The following bindings need to be provisioned:`; + } else if (context.name && isMultiWorker) { + title = `${chalk.blue(context.name)} has access to the following bindings:`; + } else { + title = "Your Worker has access to the following bindings:"; + } + + const headings = { + binding: "Binding", + resource: "Resource", + mode: "Mode", + } as const; + + const maxValueLength = Math.max( + ...output.map((b) => + typeof b.value === "symbol" + ? "inherited".length + : (b.value?.length ?? 0) + ) + ); + const maxNameLength = Math.max(...output.map((b) => b.name.length)); + const maxTypeLength = Math.max( + ...output.map((b) => b.type.length), + headings.resource.length + ); + const maxModeLength = Math.max( + ...output.map((b) => + b.mode ? stripVTControlCharacters(b.mode).length : headings.mode.length + ) + ); + + const hasMode = output.some((b) => b.mode); + const bindingPrefix = `env.`; + const bindingLength = + bindingPrefix.length + + maxNameLength + + " (".length + + maxValueLength + + ")".length; + + const columnGapSpaces = 6; + const columnGapSpacesWrapped = 4; + + const shouldWrap = + bindingLength + + columnGapSpaces + + maxTypeLength + + columnGapSpaces + + maxModeLength >= + process.stdout.columns; + + log(title); + const columnGap = shouldWrap + ? " ".repeat(columnGapSpacesWrapped) + : " ".repeat(columnGapSpaces); + + log( + `${padEndAnsi(dim(headings.binding), shouldWrap ? bindingPrefix.length + maxNameLength : bindingLength)}${columnGap}${padEndAnsi(dim(headings.resource), maxTypeLength)}${columnGap}${hasMode ? dim(headings.mode) : ""}` + ); + + for (const binding of output) { + const bindingValue = dim( + typeof binding.value === "symbol" + ? chalk.italic("inherited") + : (binding.value ?? "") + ); + const bindingString = padEndAnsi( + `${white(`env.${binding.name}`)}${binding.value && !shouldWrap ? ` (${bindingValue})` : ""}`, + shouldWrap ? bindingPrefix.length + maxNameLength : bindingLength + ); + + const suffix = shouldWrap + ? binding.value + ? `\n ${bindingValue}` + : "" + : ""; + + log( + `${bindingString}${columnGap}${brandColor(binding.type.padEnd(maxTypeLength))}${columnGap}${hasMode ? binding.mode : ""}${suffix}` + ); + } + log(""); + } + let title: string; + if (context.name && isMultiWorker) { + title = `${chalk.blue(context.name)} is sending Tail events to the following Workers:`; + } else { + title = "Your Worker is sending Tail events to the following Workers:"; + } + + const allTailConsumers = [ + ...(tailConsumers ?? []).map((c) => ({ + service: c.service, + streaming: false, + })), + ...(streamingTailConsumers ?? []).map((c) => ({ + service: c.service, + streaming: true, + })), + ]; + if (allTailConsumers.length > 0) { + log( + `${title}\n${allTailConsumers + .map(({ service, streaming }) => { + const displayName = `${service}${streaming ? ` (streaming)` : ""}`; + if (context.local && context.registry !== null) { + const registryDefinition = context.registry?.[service]; + hasConnectionStatus = true; + + if (registryDefinition) { + return `- ${displayName} ${chalk.green("[connected]")}`; + } else { + return `- ${displayName} ${chalk.red("[not connected]")}`; + } + } else { + return `- ${displayName}`; + } + }) + .join("\n")}` + ); + } + + if (containers.length > 0 && !context.provisioning) { + let containersTitle = "The following containers are available:"; + if (context.name && isMultiWorker) { + containersTitle = `The following containers are available from ${chalk.blue(context.name)}:`; + } + + log( + `${containersTitle}\n${containers + .map((c) => `- ${c.name} (${c.image})`) + .join("\n")}` + ); + log(""); + } + + if (context.unsafeMetadata) { + log("The following unsafe metadata will be attached to your Worker:"); + log(JSON.stringify(context.unsafeMetadata, null, 2)); + } + + if (hasConnectionStatus && !isConnectedStatusExplained) { + log( + dim( + `\nService bindings, Durable Object bindings, and Tail consumers connect to other Wrangler or Vite dev processes running locally, with their connection status indicated by ${chalk.green("[connected]")} or ${chalk.red("[not connected]")}. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development\n` + ) + ); + isConnectedStatusExplained = true; + } +} + +// Exactly the same as String.padEnd, but doesn't miscount ANSI control characters +function padEndAnsi(str: string, length: number) { + return ( + str + " ".repeat(Math.max(0, length - stripVTControlCharacters(str).length)) + ); +} + +/** + * Creates a function for adding a suffix to the value of a binding in the console. + * + * The suffix is only for local dev so it can be used to determine whether a binding is + * simulated locally or connected to a remote resource. + */ +function createGetMode({ + isProvisioning = false, + isLocalDev = false, +}: { + isProvisioning?: boolean; + isLocalDev?: boolean; +}) { + return function bindingMode({ + isSimulatedLocally, + connected, + }: { + // Is this binding running locally? + isSimulatedLocally?: boolean; + // If this is an external service/tail/etc... binding, is it connected? + // true = connected via the dev registry + // false = trying to connect via the dev registry, but the target is not found + // undefined = dev registry is disabled or the binding is in remote mode (which always implies connection) + connected?: boolean; + } = {}): string | undefined { + if (isProvisioning || !isLocalDev) { + return undefined; + } + if (isSimulatedLocally === undefined) { + return dim("not supported"); + } + + return `${isSimulatedLocally ? chalk.blue("local") : chalk.yellow("remote")}${connected === undefined ? "" : connected ? chalk.green(" [connected]") : chalk.red(" [not connected]")}`; + }; +} + +/** + * Validates the user's `remote` setting for a given binding against the + * binding type's local-development capabilities (sourced from + * {@link getBindingLocalSupport}). Throws `UserError` for invalid combinations + * and emits warnings for valid-but-noteworthy ones. + */ +export function warnOrError( + type: Binding["type"], + remote: boolean | undefined +) { + const support = getBindingLocalSupport(type); + switch (support) { + case "local-and-remote": + return; + case "local-only": + if (remote === true) { + throw new UserError( + `${getBindingTypeFriendlyName(type)} bindings do not support accessing remote resources.`, + { + telemetryMessage: "utils bindings unsupported remote resources", + } + ); + } + return; + case "remote": + if (remote === false) { + throw new UserError( + `${getBindingTypeFriendlyName(type)} bindings do not support local development. You can set \`remote: true\` for the binding definition in your configuration file to access a remote version of the resource.`, + { + telemetryMessage: "utils bindings unsupported local development", + } + ); + } + if (remote === undefined) { + logger.warn( + `${getBindingTypeFriendlyName(type)} bindings do not support local development, and so parts of your Worker may not work correctly. You can set \`remote: true\` for the binding definition in your configuration file to access a remote version of the resource.` + ); + } + return; + case "DO-NOT-USE-this-resource-will-never-have-a-local-simulator": + if (remote === false) { + throw new UserError( + `${getBindingTypeFriendlyName(type)} bindings do not support local development. You can set \`remote: true\` for the binding definition in your configuration file to access a remote version of the resource.`, + { + telemetryMessage: + "utils bindings unsupported local development always remote", + } + ); + } + if (remote === undefined) { + logger.warn( + `${getBindingTypeFriendlyName(type)} bindings always access remote resources, and so may incur usage charges even in local dev. To suppress this warning, set \`remote: true\` for the binding definition in your configuration file.` + ); + } + return; + default: + assertNever(support); + } +} diff --git a/packages/deploy-helpers/src/deploy/helpers/secrets-validation.ts b/packages/deploy-helpers/src/deploy/helpers/secrets-validation.ts new file mode 100644 index 0000000000..0407394bbe --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/secrets-validation.ts @@ -0,0 +1,78 @@ +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { INVALID_INHERIT_BINDING_CODE } from "./error-codes"; +import type { Binding, Config } from "@cloudflare/workers-utils"; + +type SecretsValidationOptions = + | { type: "deploy"; workerExists: boolean } + | { type: "upload" }; + +/** + * When `secrets.required` is defined in config, validate the secrets exist on the Worker. + * For deploy, if the Worker doesn't exist yet, fail immediately. + * For upload, always add inherit bindings β€” the API handles the case where + * the Worker doesn't exist (versions upload cannot create new Workers). + * Secrets already provided (e.g. via --secrets-file) are excluded since + * they are part of the upload and don't need to be inherited. + */ +export function addRequiredSecretsInheritBindings( + config: Config, + bindings: Record, + options: SecretsValidationOptions +): void { + if (!config.secrets?.required?.length) { + return; + } + + const inheritedSecrets = config.secrets.required.filter( + (secretName) => !(secretName in bindings) + ); + + if (inheritedSecrets.length === 0) { + return; + } + + if (options.type === "deploy" && !options.workerExists) { + throw new UserError( + `The following required secrets have not been set: ${inheritedSecrets.join(", ")}\n` + + `Use \`wrangler secret put \` to set secrets before deploying.\n` + + `See https://developers.cloudflare.com/workers/configuration/secrets/#secrets-on-deployed-workers for more information.`, + { telemetryMessage: "required secrets missing before deploy" } + ); + } + + for (const secretName of inheritedSecrets) { + bindings[secretName] = { type: "inherit" }; + } +} + +/** + * Reformats API errors for strict inherit binding validation failures into + * user-friendly messages listing the missing required secrets. + * The API returns all missing inherit bindings at once, each as a separate + * error in response.errors, which maps to individual err.notes entries. + */ +export function handleMissingSecretsError( + err: unknown, + config: Config, + options: SecretsValidationOptions +): void { + if (!(err instanceof APIError) || err.code !== INVALID_INHERIT_BINDING_CODE) { + return; + } + + const missingSecretNames = err.notes + .map((note) => note.text.match(/^inherit binding '(.+?)' is invalid/)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => match[1]) + .filter((secretName) => config.secrets?.required?.includes(secretName)); + + if (missingSecretNames.length > 0) { + err.preventReport(); + throw new UserError( + `The following required secrets have not been set: ${missingSecretNames.join(", ")}\n` + + `Use \`wrangler ${options.type === "deploy" ? "secret put" : "versions secret put"} \` to set secrets before ${options.type === "deploy" ? "deploying" : "uploading"}.\n` + + `See https://developers.cloudflare.com/workers/configuration/secrets/#secrets-on-deployed-workers for more information.`, + { telemetryMessage: "required secrets missing during upload or deploy" } + ); + } +} diff --git a/packages/deploy-helpers/src/deploy/helpers/source-maps.ts b/packages/deploy-helpers/src/deploy/helpers/source-maps.ts new file mode 100644 index 0000000000..1e9ba99377 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/source-maps.ts @@ -0,0 +1,202 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { CfModule, CfWorkerSourceMap } from "@cloudflare/workers-utils"; + +interface SourceMapMetadata { + tmpDir: string; + entryDirectory: string; +} + +export interface SourceMapBundle { + sourceMapPath?: string | undefined; + sourceMapMetadata?: SourceMapMetadata | undefined; + [key: string]: unknown; +} + +/** + * Loads source maps that appear in the given build output. + */ +export function loadSourceMaps( + main: CfModule, + modules: CfModule[], + bundle: SourceMapBundle +): CfWorkerSourceMap[] { + const { sourceMapPath, sourceMapMetadata } = bundle; + if (sourceMapPath && sourceMapMetadata) { + // This worker was bundled by Wrangler, so we already know where + // the source map is located. + return loadSourceMap(main, sourceMapPath, sourceMapMetadata); + } else { + // Don't know where source maps are located, so need to find + // them by scanning the contents of the user-specified modules. + return scanSourceMaps([main, ...modules]); + } +} + +/** + * Load and normalize a source map emitted by Wrangler using the given path and + * directory metadata. + */ +function loadSourceMap( + { name, filePath }: CfModule, + sourceMapPath: string, + { entryDirectory }: SourceMapMetadata +): CfWorkerSourceMap[] { + if (filePath === undefined) { + return []; + } + const map = JSON.parse( + fs.readFileSync(path.join(entryDirectory, sourceMapPath), "utf8") + ) as RawSourceMap; + // Overwrite the file property of the source map to match the name of the + // main module in the multipart upload. + map.file = name; + if (map.sourceRoot) { + // Remove the temporary directory prefix generated by Wrangler that appears + // in the source root path. + const sourceRootPath = path.dirname( + path.join(entryDirectory, sourceMapPath) + ); + map.sourceRoot = path.relative(sourceRootPath, map.sourceRoot); + } + map.sources = map.sources.map((source) => { + const originalPath = path.join(path.dirname(filePath), source); + return path.relative(entryDirectory, originalPath); + }); + return [ + { + name: name + ".map", + content: JSON.stringify(map), + }, + ]; +} + +/** + * Find source maps by scanning module contents for special `//# + * sourceMappingURL=` comments pointing to source map files. + */ +function scanSourceMaps(modules: CfModule[]): CfWorkerSourceMap[] { + const maps: CfWorkerSourceMap[] = []; + for (const module of modules) { + const maybeSourcemap = sourceMapForModule(module); + if (maybeSourcemap) { + maps.push(maybeSourcemap); + } + } + return maps; +} + +/** + * Attaches a sourcemap, if found, to a JavaScript module. + */ +export function tryAttachSourcemapToModule(module: CfModule) { + if (module.type !== "esm" && module.type !== "commonjs") { + return; + } + + const sourceMap = sourceMapForModule(module); + if (sourceMap) { + module.sourceMap = sourceMap; + } +} + +function getSourceMappingUrl(module: CfModule): string | undefined { + const content = + typeof module.content === "string" + ? module.content + : new TextDecoder().decode(module.content); + + const commentPrefix = "//# sourceMappingURL="; + + // Scan trailing lines from the bottom up so that the `//# sourceMappingURL=` + // comment is still detected when other magic comments (e.g. + // `//# debugId=` injected by `sentry-cli sourcemaps inject`) follow it. + const lines = content.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.length === 0) { + continue; + } + if (line.startsWith(commentPrefix)) { + // Assume the source map path in the comment is relative to the + // generated file it appears in. + const commentPath = stripPrefix(commentPrefix, line).trim(); + if (commentPath.startsWith("data:")) { + throw new Error( + `Unsupported source map path in ${module.filePath}: expected file path but found data URL.` + ); + } + return commentPath; + } + // Skip past other trailing magic comments (`//# debugId=`, `//# sourceURL=`, + // etc.) and keep looking for the sourceMappingURL above them. Stop as + // soon as we hit any non-magic-comment content. + if (!line.startsWith("//#") && !line.startsWith("//@")) { + return undefined; + } + } + + return undefined; +} + +function sourceMapForModule(module: CfModule): CfWorkerSourceMap | undefined { + if (module.filePath === undefined) { + // virtual modules don't have sourcemaps so we can exit early here + return undefined; + } + + const sourceMapUrl = getSourceMappingUrl(module); + if (sourceMapUrl === undefined) { + return; + } + + // Convert source map path to an absolute path that we can read. + const sourcemapPath = path.join(path.dirname(module.filePath), sourceMapUrl); + if (!fs.existsSync(sourcemapPath)) { + throw new Error( + `Invalid source map path in ${module.filePath}: ${sourcemapPath} does not exist.` + ); + } + const map = JSON.parse( + fs.readFileSync(sourcemapPath, "utf8") + ) as RawSourceMap; + // Overwrite the file property of the sourcemap to match the name of the + // corresponding module in the multipart upload. + map.file = module.name; + if (map.sourceRoot) { + map.sourceRoot = cleanPathPrefix(map.sourceRoot); + } + map.sources = map.sources.map(cleanPathPrefix); + return { + name: module.name + ".map", + content: JSON.stringify(map), + }; +} + +/** + * Removes leading "." and ".." segments from the given file path. + */ +function cleanPathPrefix(filePath: string): string { + // Don't assume that the path separator matches the current OS. + return stripPrefix( + "..\\", + stripPrefix("../", stripPrefix(".\\", stripPrefix("./", filePath))) + ); +} + +function stripPrefix(prefix: string, input: string): string { + let stripped = input; + while (stripped.startsWith(prefix)) { + stripped = stripped.slice(prefix.length); + } + return stripped; +} + +/** + * Minimal source map type β€” only the fields used by this module. + */ +interface RawSourceMap { + file?: string; + sourceRoot?: string; + sources: string[]; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/sourcemap.ts b/packages/deploy-helpers/src/deploy/helpers/sourcemap.ts new file mode 100644 index 0000000000..8aa08433f2 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/sourcemap.ts @@ -0,0 +1,322 @@ +import assert from "node:assert"; +import url from "node:url"; +import { maybeGetFile } from "@cloudflare/workers-shared"; +import { getFreshSourceMapSupport } from "miniflare"; +import type { Options } from "@cspotcode/source-map-support"; +import type Protocol from "devtools-protocol"; + +export type RetrieveSourceMapFunction = NonNullable< + Options["retrieveSourceMap"] +>; +export function maybeRetrieveFileSourceMap( + filePath?: string +): ReturnType { + if (filePath === undefined) { + return null; + } + const contents = maybeGetFile(filePath); + if (contents === undefined) { + return null; + } + + // Find the last source mapping URL if any + const mapRegexp = /# sourceMappingURL=(.+)/g; + const matches = [...contents.matchAll(mapRegexp)]; + // If we couldn't find a source mapping URL, there's nothing we can do + if (matches.length === 0) { + return null; + } + const mapMatch = matches[matches.length - 1]; + + // Get the source map + const fileUrl = url.pathToFileURL(filePath); + const mapUrl = new URL(mapMatch[1], fileUrl); + if ( + mapUrl.protocol === "data:" && + mapUrl.pathname.startsWith("application/json;base64,") + ) { + // sourceMappingURL=data:application/json;base64,ew... + const base64 = mapUrl.href.substring(mapUrl.href.indexOf(",") + 1); + const map = Buffer.from(base64, "base64").toString(); + return { map, url: fileUrl.href }; + } else { + const map = maybeGetFile(mapUrl); + if (map === undefined) { + return null; + } + return { map, url: mapUrl.href }; + } +} + +// `sourceMappingPrepareStackTrace` is initialised on the first call to +// `getSourceMappingPrepareStackTrace()`. Subsequent calls to +// `getSourceMappingPrepareStackTrace()` will not update it. We'd like to be +// able to customise source map retrieval on each call though. Therefore, we +// make `retrieveSourceMapOverride` a module level variable, so +// `sourceMappingPrepareStackTrace` always has access to the latest override. +let sourceMappingPrepareStackTrace: typeof Error.prepareStackTrace; +let retrieveSourceMapOverride: RetrieveSourceMapFunction | undefined; + +function getSourceMappingPrepareStackTrace( + retrieveSourceMap?: RetrieveSourceMapFunction +): NonNullable { + // Source mapping is synchronous, so setting a module level variable is fine + retrieveSourceMapOverride = retrieveSourceMap; + // If we already have a source mapper, return it + if (sourceMappingPrepareStackTrace !== undefined) { + return sourceMappingPrepareStackTrace; + } + + const support: typeof import("@cspotcode/source-map-support") = + getFreshSourceMapSupport(); + const originalPrepareStackTrace = Error.prepareStackTrace; + support.install({ + environment: "node", + // Don't add Node `uncaughtException` handler + handleUncaughtExceptions: false, + // Don't hook Node `require` function + hookRequire: false, + redirectConflictingLibrary: false, + // Make sure we're using fresh copies of files each time we source map + emptyCacheBetweenOperations: true, + // Allow retriever to be overridden at prepare stack trace time + retrieveSourceMap(path) { + return retrieveSourceMapOverride?.(path) ?? null; + }, + }); + sourceMappingPrepareStackTrace = Error.prepareStackTrace; + assert(sourceMappingPrepareStackTrace !== undefined); + Error.prepareStackTrace = originalPrepareStackTrace; + + return sourceMappingPrepareStackTrace; +} + +export function getSourceMappedStack( + details: Protocol.Runtime.ExceptionDetails +): string { + const description = details.exception?.description ?? ""; + const callFrames = details.stackTrace?.callFrames; + // If this exception didn't come with `callFrames`, we can't do any source + // mapping without parsing the stack, so just return the description as is + if (callFrames === undefined) { + return description; + } + + const nameMessage = details.exception?.description?.split("\n")[0] ?? ""; + const colonIndex = nameMessage.indexOf(":"); + const error = new Error(nameMessage.substring(colonIndex + 2)); + error.name = nameMessage.substring(0, colonIndex); + const callSites = callFrames.map(callFrameToCallSite); + return getSourceMappingPrepareStackTrace()(error, callSites); +} + +function callFrameToCallSite(frame: Protocol.Runtime.CallFrame): CallSite { + return new CallSite({ + typeName: null, + functionName: frame.functionName, + methodName: null, + fileName: frame.url, + lineNumber: frame.lineNumber + 1, + columnNumber: frame.columnNumber + 1, + native: false, + }); +} + +const placeholderError = new Error(); +export function getSourceMappedString( + value: string, + retrieveSourceMap?: RetrieveSourceMapFunction +): string { + // We could use `.replace()` here with a function replacer, but + // `getSourceMappingPrepareStackTrace()` clears its source map caches between + // operations. It's likely call sites in this `value` will share source maps, + // so instead we find all call sites, source map them together, then replace. + // Note this still works if there are multiple instances of the same call site + // (e.g. stack overflow error), as the final `.replace()`s will only replace + // the first instance. If they replace the value with itself, all instances + // of the call site would've been replaced with the same thing. + const callSiteLines = Array.from(value.matchAll(CALL_SITE_REGEXP)); + const callSites = callSiteLines.map(lineMatchToCallSite); + const prepareStack = getSourceMappingPrepareStackTrace(retrieveSourceMap); + const sourceMappedStackTrace: string = prepareStack( + placeholderError, + callSites + ); + const sourceMappedCallSiteLines = sourceMappedStackTrace.split("\n").slice(1); + + for (let i = 0; i < callSiteLines.length; i++) { + // If a call site doesn't have a file name, it's likely invalid, so don't + // apply source mapping (see cloudflare/workers-sdk#4668) + if (callSites[i].getFileName() === undefined) { + continue; + } + + const callSiteLine = callSiteLines[i][0]; + const callSiteAtIndex = callSiteLine.indexOf("at"); + assert(callSiteAtIndex !== -1); // Matched against `CALL_SITE_REGEXP` + const callSiteLineLeftPad = callSiteLine.substring(0, callSiteAtIndex); + value = value.replace( + callSiteLine, + callSiteLineLeftPad + sourceMappedCallSiteLines[i].trimStart() + ); + } + return value; +} + +// Adapted from `node-stack-trace`: +/*! + * Copyright (c) 2011 Felix GeisendΓΆrfer (felix@debuggable.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +const CALL_SITE_REGEXP = + // Validation errors from `wrangler deploy` have a 2 space indent, whereas + // regular stack traces have a 4 space indent. + // eslint-disable-next-line no-control-regex -- Intentionally matches ANSI escape sequences in stack traces + /^(?:\s+(?:\x1B\[\d+m)?'?)? {2,4}at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/gm; +function lineMatchToCallSite(lineMatch: RegExpMatchArray): CallSite { + let object: string | null = null; + let method: string | null = null; + let functionName: string | null = null; + let typeName: string | null = null; + let methodName: string | null = null; + const isNative = lineMatch[5] === "native"; + + if (lineMatch[1]) { + functionName = lineMatch[1]; + let methodStart = functionName.lastIndexOf("."); + if (functionName[methodStart - 1] == ".") { + methodStart--; + } + if (methodStart > 0) { + object = functionName.substring(0, methodStart); + method = functionName.substring(methodStart + 1); + const objectEnd = object.indexOf(".Module"); + if (objectEnd > 0) { + functionName = functionName.substring(objectEnd + 1); + object = object.substring(0, objectEnd); + } + } + } + + if (method) { + typeName = object; + methodName = method; + } + + if (method === "") { + methodName = null; + functionName = null; + } + + return new CallSite({ + typeName, + functionName, + methodName, + fileName: lineMatch[2], + lineNumber: parseInt(lineMatch[3]) || null, + columnNumber: parseInt(lineMatch[4]) || null, + native: isNative, + }); +} + +interface CallSiteOptions { + typeName: string | null; + functionName: string | null; + methodName: string | null; + fileName: string; + lineNumber: number | null; + columnNumber: number | null; + native: boolean; +} + +// https://v8.dev/docs/stack-trace-api#customizing-stack-traces +// This class supports the subset of options implemented by `node-stack-trace`: +// https://github.com/felixge/node-stack-trace/blob/4c41a4526e74470179b3b6dd5d75191ca8c56c17/index.js +class CallSite implements NodeJS.CallSite { + constructor(private readonly opts: CallSiteOptions) {} + getScriptHash(): string { + throw new Error("Method not implemented."); + } + getEnclosingColumnNumber(): number { + throw new Error("Method not implemented."); + } + getEnclosingLineNumber(): number { + throw new Error("Method not implemented."); + } + getPosition(): number { + throw new Error("Method not implemented."); + } + getThis(): unknown { + return null; + } + getTypeName(): string | null { + return this.opts.typeName; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- V8 CallSite interface requires Function return type + getFunction(): Function | undefined { + return undefined; + } + getFunctionName(): string | null { + return this.opts.functionName; + } + getMethodName(): string | null { + return this.opts.methodName; + } + getFileName(): string | null { + return this.opts.fileName ?? null; + } + getScriptNameOrSourceURL(): string { + return this.opts.fileName; + } + getLineNumber(): number | null { + return this.opts.lineNumber; + } + getColumnNumber(): number | null { + return this.opts.columnNumber; + } + getEvalOrigin(): string | undefined { + return undefined; + } + isToplevel(): boolean { + return false; + } + isEval(): boolean { + return false; + } + isNative(): boolean { + return this.opts.native; + } + isConstructor(): boolean { + return false; + } + isAsync(): boolean { + return false; + } + isPromiseAll(): boolean { + return false; + } + isPromiseAny(): boolean { + return false; + } + getPromiseIndex(): number | null { + return null; + } +} diff --git a/packages/deploy-helpers/src/deploy/helpers/use-service-environments.ts b/packages/deploy-helpers/src/deploy/helpers/use-service-environments.ts new file mode 100644 index 0000000000..4861475100 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/use-service-environments.ts @@ -0,0 +1,14 @@ +import type { Config } from "@cloudflare/workers-utils"; + +/** + * Whether deprecated service environments are enabled. + */ +export function useServiceEnvironments( + config: + | Config + | { legacy_env?: boolean; legacy: { useServiceEnvironments?: boolean } } +): boolean { + return "legacy_env" in config + ? !config.legacy_env + : Boolean(config.legacy.useServiceEnvironments); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/validate-routes.ts b/packages/deploy-helpers/src/deploy/helpers/validate-routes.ts new file mode 100644 index 0000000000..56a7c5ee14 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/validate-routes.ts @@ -0,0 +1,71 @@ +import path from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; +import { logger } from "../../shared/context"; +import type { AssetsOptions, Route } from "@cloudflare/workers-utils"; + +export const validateRoutes = ( + routes: Route[], + assets: AssetsOptions | undefined +) => { + const invalidRoutes: Record = {}; + const mountedAssetRoutes: string[] = []; + + for (const route of routes) { + if (typeof route !== "string" && route.custom_domain) { + if (route.pattern.includes("*")) { + invalidRoutes[route.pattern] ??= []; + invalidRoutes[route.pattern].push( + `Wildcard operators (*) are not allowed in Custom Domains` + ); + } + if (route.pattern.includes("/")) { + invalidRoutes[route.pattern] ??= []; + invalidRoutes[route.pattern].push( + `Paths are not allowed in Custom Domains` + ); + } + } else if ( + // If we have Assets but we're not always hitting the Worker then validate + assets?.directory !== undefined && + assets.routerConfig.invoke_user_worker_ahead_of_assets !== true + ) { + const pattern = typeof route === "string" ? route : route.pattern; + const components = pattern.split("/"); + + // If this isn't `domain.com/*` then we're mounting to a path + if (!(components.length === 2 && components[1] === "*")) { + mountedAssetRoutes.push(pattern); + } + } + } + if (Object.keys(invalidRoutes).length > 0) { + throw new UserError( + `Invalid Routes:\n` + + Object.entries(invalidRoutes) + .map(([route, errors]) => `${route}:\n` + errors.join("\n")) + .join(`\n\n`), + { telemetryMessage: "deploy invalid routes" } + ); + } + + if (mountedAssetRoutes.length > 0 && assets?.directory !== undefined) { + const relativeAssetsDir = path.relative(process.cwd(), assets.directory); + + const warnFn = logger.once?.warn ?? logger.warn; + warnFn( + `Warning: The following routes will attempt to serve Assets on a configured path:\n${mountedAssetRoutes + .map((route) => { + const routeNoScheme = route.replace(/https?:\/\//g, ""); + const assetPath = path.join( + relativeAssetsDir, + routeNoScheme.substring(routeNoScheme.indexOf("/")) + ); + return ` β€’ ${route} (Will match assets: ${assetPath})`; + }) + .join("\n")}` + + (assets?.routerConfig.has_user_worker + ? "\n\nRequests not matching an asset will be forwarded to the Worker's code." + : "") + ); + } +}; diff --git a/packages/deploy-helpers/src/deploy/helpers/versions-api.ts b/packages/deploy-helpers/src/deploy/helpers/versions-api.ts new file mode 100644 index 0000000000..4c7ad7cc1c --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/versions-api.ts @@ -0,0 +1,187 @@ +import { fetchResult } from "../../shared/context"; +import type { + ApiDeployment, + ApiVersion, + Percentage, + VersionCache, + VersionId, +} from "./versions-types"; +import type { + ComplianceConfig, + Observability, + StreamingTailConsumer, + TailConsumer, +} from "@cloudflare/workers-utils"; + +export async function fetchVersion( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + versionId: VersionId, + versionCache: VersionCache | undefined +): Promise { + const cachedVersion = versionCache?.get(versionId); + if (cachedVersion) { + return cachedVersion; + } + + const version = await fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/scripts/${workerName}/versions/${versionId}` + ); + + versionCache?.set(version.id, version); + + return version; +} + +export async function fetchVersions( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + versionCache: VersionCache | undefined, + versionIds: VersionId[] +) { + return Promise.all( + versionIds.map((versionId) => + fetchVersion( + complianceConfig, + accountId, + workerName, + versionId, + versionCache + ) + ) + ); +} + +export async function fetchLatestDeployments( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string +): Promise { + const { deployments } = await fetchResult<{ + deployments: ApiDeployment[]; + }>( + complianceConfig, + `/accounts/${accountId}/workers/scripts/${workerName}/deployments` + ); + + return deployments; +} +export async function fetchLatestDeployment( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string +): Promise { + const deployments = await fetchLatestDeployments( + complianceConfig, + accountId, + workerName + ); + + return deployments.at(0); +} + +export async function fetchDeploymentVersions( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + deployment: ApiDeployment | undefined, + versionCache: VersionCache +): Promise<[ApiVersion[], Map]> { + if (!deployment) { + return [[], new Map()]; + } + + const versionTraffic = new Map( + deployment.versions.map((v) => [v.version_id, v.percentage]) + ); + + const versions = await fetchVersions( + complianceConfig, + accountId, + workerName, + versionCache, + [...versionTraffic.keys()] + ); + + return [versions, versionTraffic]; +} + +export async function fetchDeployableVersions( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + versionCache: VersionCache +): Promise { + const { items: versions } = await fetchResult<{ + items: ApiVersion[]; + }>( + complianceConfig, + `/accounts/${accountId}/workers/scripts/${workerName}/versions?deployable=true` + ); + + for (const version of versions) { + versionCache.set(version.id, version); + } + + return versions; +} + +export async function createDeployment( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + versionTraffic: Map, + message: string | undefined, + force: boolean | undefined +) { + return await fetchResult<{ id: string }>( + complianceConfig, + `/accounts/${accountId}/workers/scripts/${workerName}/deployments${force ? "?force=true" : ""}`, + + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + strategy: "percentage", + versions: Array.from(versionTraffic).map( + ([version_id, percentage]) => ({ version_id, percentage }) + ), + annotations: { + "workers/message": message, + }, + }), + } + ); +} + +export type NonVersionedScriptSettings = { + logpush: boolean; + tags: string[] | null; + tail_consumers: TailConsumer[]; + streaming_tail_consumers: StreamingTailConsumer[]; + observability: Observability; +}; + +export async function patchNonVersionedScriptSettings( + complianceConfig: ComplianceConfig, + accountId: string, + workerName: string, + settings: Partial +) { + const res = await fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/scripts/${workerName}/script-settings`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + } + ); + + // TODO: handle specific errors + + return res; +} diff --git a/packages/deploy-helpers/src/deploy/helpers/versions-types.ts b/packages/deploy-helpers/src/deploy/helpers/versions-types.ts new file mode 100644 index 0000000000..2ad2dce21f --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/versions-types.ts @@ -0,0 +1,55 @@ +import type { + CfUserLimits, + WorkerMetadataBinding, +} from "@cloudflare/workers-utils"; + +export type Percentage = number; +type UUID = string; +export type VersionId = UUID; + +export type ApiDeployment = { + id: string; + source: "api" | string; + strategy: "percentage" | string; + author_email: string; + annotations?: Record; + created_on: string; + versions: Array<{ + version_id: VersionId; + percentage: Percentage; + }>; +}; +export type ApiVersion = { + id: VersionId; + number: number; + metadata: { + created_on: string; + modified_on: string; + source: "api" | string; + author_id: string; + author_email: string; + }; + annotations?: { + "workers/triggered_by"?: "upload" | string; + "workers/message"?: string; + "workers/tag"?: string; + }; + resources: { + bindings: WorkerMetadataBinding[]; + script: { + etag: string; + handlers: string[] | null; + placement_mode?: "smart"; + last_deployed_from: string; + }; + script_runtime: { + compatibility_date?: string; + compatibility_flags?: string[]; + usage_model: "bundled" | "unbound" | "standard"; + limits: CfUserLimits; + }; + }; + startup_time_ms?: number; +}; + +export type VersionCache = Map; diff --git a/packages/deploy-helpers/src/deploy/helpers/worker-not-found-error.ts b/packages/deploy-helpers/src/deploy/helpers/worker-not-found-error.ts new file mode 100644 index 0000000000..dacb1e5ea6 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/worker-not-found-error.ts @@ -0,0 +1,31 @@ +/** + This is the error code from the Cloudflare API signaling that a worker could not be found on the target account + */ +export const WORKER_NOT_FOUND_ERR_CODE = 10007 as const; + +/** + This is the error code from the Cloudflare API signaling that a worker environment (legacy) could not be found on the target account + */ +export const WORKER_LEGACY_ENVIRONMENT_NOT_FOUND_ERR_CODE = 10090 as const; + +/** + This is the error message from the Cloudflare API signaling that a worker could not be found on the target account + */ +export const workerNotFoundErrorMessage = + "This Worker does not exist on your account."; + +/** + * Given an error from the Cloudflare API discerns whether it is caused by a worker that could not be found on the target account + * + * @param error The error object + * @returns true if the object represents an error from the Cloudflare API caused by a not found worker, false otherwise + */ +export function isWorkerNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === WORKER_NOT_FOUND_ERR_CODE || + error.code === WORKER_LEGACY_ENVIRONMENT_NOT_FOUND_ERR_CODE) + ); +} diff --git a/packages/deploy-helpers/src/deploy/helpers/workers-sites-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/workers-sites-bindings.ts new file mode 100644 index 0000000000..b27c65c80a --- /dev/null +++ b/packages/deploy-helpers/src/deploy/helpers/workers-sites-bindings.ts @@ -0,0 +1,32 @@ +import type { Binding, CfScriptFormat } from "@cloudflare/workers-utils"; + +/** + * Inject bindings into the Worker to support Workers Sites. These are injected at the last minute so that + * they don't display in the output of `printBindings()` + */ +export function addWorkersSitesBindings( + bindings: Record, + namespace: string | undefined, + manifest: + | { + [filePath: string]: string; + } + | undefined, + format: CfScriptFormat +) { + const withSites = { ...bindings }; + if (namespace) { + withSites["__STATIC_CONTENT"] = { + type: "kv_namespace", + id: namespace, + }; + } + + if (manifest && format === "service-worker") { + withSites["__STATIC_CONTENT_MANIFEST"] = { + type: "text_blob", + source: { contents: "__STATIC_CONTENT_MANIFEST" }, + }; + } + return withSites; +} diff --git a/packages/deploy-helpers/src/deploy/versions-upload.ts b/packages/deploy-helpers/src/deploy/versions-upload.ts new file mode 100644 index 0000000000..ce718dbee9 --- /dev/null +++ b/packages/deploy-helpers/src/deploy/versions-upload.ts @@ -0,0 +1,540 @@ +import assert from "node:assert"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { blue, gray } from "@cloudflare/cli-shared-helpers/colors"; +import { + configFileName, + formatConfigSnippet, + formatTime, + getTodaysCompatDate, + ParseError, + retryOnAPIFailure, + UserError, +} from "@cloudflare/workers-utils"; +import { Response } from "undici"; +import { confirm, fetchResult, logger } from "../shared/context"; +import { ensureQueuesExistByConfig } from "../triggers/queue-consumers"; +import { getWorkersDevSubdomain } from "../triggers/subdomain"; +import { syncAssets } from "./helpers/assets"; +import { getBindings } from "./helpers/binding-utils"; +import { printBundleSize } from "./helpers/bundle-reporter"; +import { createWorkerUploadForm } from "./helpers/create-worker-upload-form"; +import { getMigrationsToUpload } from "./helpers/durable"; +import { + applyServiceAndEnvironmentTags, + tagsAreEqual, + warnOnErrorUpdatingServiceAndEnvironmentTags, +} from "./helpers/environments"; +import { helpIfErrorIsSizeOrScriptStartup } from "./helpers/friendly-validator-errors"; +import { verifyWorkerMatchesCITag } from "./helpers/match-tag"; +import { validateNodeCompatMode } from "./helpers/node-compat"; +import { parseBulkInputToObject } from "./helpers/parse-bulk-input"; +import { parseConfigPlacement } from "./helpers/placement"; +import { printBindings } from "./helpers/print-bindings"; +import { + addRequiredSecretsInheritBindings, + handleMissingSecretsError, +} from "./helpers/secrets-validation"; +import { loadSourceMaps } from "./helpers/source-maps"; +import { + getSourceMappedString, + maybeRetrieveFileSourceMap, +} from "./helpers/sourcemap"; +import { useServiceEnvironments as useServiceEnvironmentsConfig } from "./helpers/use-service-environments"; +import { patchNonVersionedScriptSettings } from "./helpers/versions-api"; +import { isWorkerNotFoundError } from "./helpers/worker-not-found-error"; +import type { HandleBuild, VersionsUploadProps } from "../shared/types"; +import type { DeployCallbacks } from "./deploy"; +import type { RetrieveSourceMapFunction } from "./helpers/sourcemap"; +import type { CfWorkerInit, Config } from "@cloudflare/workers-utils"; +import type { FormData } from "undici"; + +export type VersionsUploadCallbacks = Pick< + DeployCallbacks, + "provisionBindings" | "analyseBundle" +>; + +export default async function versionsUpload( + props: VersionsUploadProps, + config: Config, + buildWorker: HandleBuild, + callbacks: VersionsUploadCallbacks +): Promise<{ + versionId: string | null; + workerTag: string | null; + versionPreviewUrl?: string | undefined; + versionPreviewAliasUrl?: string | undefined; +}> { + if (!props.name) { + throw new UserError( + 'You need to provide a name of your worker. Either pass it as a cli arg with `--name ` or in your config file as `name = ""`', + { telemetryMessage: "versions upload missing worker name" } + ); + } + const { + entry, + name, + compatibilityDate, + compatibilityFlags, + keepVars, + minify, + noBundle, + uploadSourceMaps, + accountId, + } = props; + + if (!props.dryRun) { + assert(accountId, "Missing account ID"); + await verifyWorkerMatchesCITag(config, accountId, name, config.configPath); + } + let versionId: string | null = null; + let workerTag: string | null = null; + let tags: string[] = []; // arbitrary metadata tags, not to be confused with script tag or annotations + + if (accountId && name) { + try { + const { + default_environment: { script }, + } = await fetchResult<{ + default_environment: { + script: { + tag: string; + tags: string[] | null; + last_deployed_from: "dash" | "wrangler" | "api"; + }; + }; + }>( + config, + `/accounts/${accountId}/workers/services/${name}` // TODO(consider): should this be a /versions endpoint? + ); + + workerTag = script.tag; + tags = script.tags ?? tags; + + if (script.last_deployed_from === "dash") { + logger.warn( + `You are about to upload a Worker Version that was last published via the Cloudflare Dashboard.\nEdits that have been made via the dashboard will be overridden by your local code and config.` + ); + if (!(await confirm("Would you like to continue?"))) { + return { + versionId, + workerTag, + }; + } + } else if (script.last_deployed_from === "api") { + logger.warn( + `You are about to upload a Workers Version that was last updated via the API.\nEdits that have been made via the API will be overridden by your local code and config.` + ); + if (!(await confirm("Would you like to continue?"))) { + return { + versionId, + workerTag, + }; + } + } + } catch (e) { + if (!isWorkerNotFoundError(e)) { + throw e; + } + } + } + + if (!compatibilityDate) { + const compatibilityDateStr = getTodaysCompatDate(); + + throw new UserError( + `A compatibility_date is required when uploading a Worker Version. Add the following to your ${configFileName(config.configPath)} file: + \`\`\` + ${formatConfigSnippet({ compatibility_date: compatibilityDateStr }, config.configPath, false)} + \`\`\` + Or you could pass it in your terminal as \`--compatibility-date ${compatibilityDateStr}\` +See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information.`, + { + telemetryMessage: "versions upload missing compatibility date", + } + ); + } + + const nodejsCompatMode = validateNodeCompatMode( + compatibilityDate, + compatibilityFlags, + { noBundle } + ); + + // Warn if user tries minify or node-compat with no-bundle + if (noBundle && minify) { + logger.warn( + "`--minify` and `--no-bundle` can't be used together. If you want to minify your Worker and disable Wrangler's bundling, please minify as part of your own bundling process." + ); + } + + const scriptName = name; + + if (config.site && !config.site.bucket) { + throw new UserError( + "A [site] definition requires a `bucket` field with a path to the site's assets directory.", + { telemetryMessage: "versions upload sites missing bucket" } + ); + } + + const start = Date.now(); + const workerName = scriptName; + const workerUrl = `/accounts/${accountId}/workers/scripts/${scriptName}`; + + const { format } = entry; + const projectRoot = entry.projectRoot; + + if (config.wasm_modules && format === "modules") { + throw new UserError( + "You cannot configure [wasm_modules] with an ES module worker. Instead, import the .wasm module directly in your code", + { + telemetryMessage: + "versions upload wasm modules unsupported module worker", + } + ); + } + + if (config.text_blobs && format === "modules") { + throw new UserError( + `You cannot configure [text_blobs] with an ES module worker. Instead, import the file directly in your code, and optionally configure \`[rules]\` in your ${configFileName(config.configPath)} file`, + { + telemetryMessage: + "versions upload text blobs unsupported module worker", + } + ); + } + + if (config.data_blobs && format === "modules") { + throw new UserError( + `You cannot configure [data_blobs] with an ES module worker. Instead, import the file directly in your code, and optionally configure \`[rules]\` in your ${configFileName(config.configPath)} file`, + { + telemetryMessage: + "versions upload data blobs unsupported module worker", + } + ); + } + + let hasPreview = false; + + const { + modules, + dependencies, + resolvedEntryPointPath, + bundleType, + content, + bundle, + } = await buildWorker.build(props, config, { + nodejsCompatMode, + }); + const bindings = getBindings(config); + + // Vars from the CLI (--var) are hidden so their values aren't logged to the terminal + for (const [bindingName, value] of Object.entries(props.cliVars)) { + bindings[bindingName] = { + type: "plain_text", + value, + hidden: true, + }; + } + + // durable object migrations + const migrations = !props.dryRun + ? await getMigrationsToUpload(scriptName, { + accountId, + config, + useServiceEnvironments: useServiceEnvironmentsConfig(config), + env: props.env, + dispatchNamespace: undefined, + }) + : undefined; + + // Upload assets if assets is being used + const assetsJwt = + props.assetsOptions && !props.dryRun + ? await syncAssets( + config, + accountId, + props.assetsOptions.directory, + scriptName + ) + : undefined; + + if (props.secretsFile) { + const secretsResult = await parseBulkInputToObject(props.secretsFile); + if (secretsResult) { + for (const [secretName, secretValue] of Object.entries( + secretsResult.content + )) { + bindings[secretName] = { + type: "secret_text", + value: secretValue, + }; + } + } + } + + addRequiredSecretsInheritBindings(config, bindings, { type: "upload" }); + + const placement = parseConfigPlacement(config); + + const entryPointName = path.basename(resolvedEntryPointPath); + const main = { + name: entryPointName, + filePath: resolvedEntryPointPath, + content: content, + type: bundleType, + }; + const worker: CfWorkerInit = { + name: scriptName, + main, + migrations, + modules, + containers: config.containers, + sourceMaps: uploadSourceMaps + ? loadSourceMaps(main, modules, bundle) + : undefined, + compatibility_date: compatibilityDate, + compatibility_flags: compatibilityFlags, + keepVars, + // we never delete secret bindings when uploading, even if we are setting secrets from a file + // so inherit all unchanged secrets from the previous Worker Version + keepSecrets: true, + placement, + tail_consumers: config.tail_consumers, + limits: config.limits, + annotations: { + "workers/message": props.message, + "workers/tag": props.tag, + "workers/alias": props.previewAlias, + }, + assets: + props.assetsOptions && assetsJwt + ? { + jwt: assetsJwt, + routerConfig: props.assetsOptions.routerConfig, + assetConfig: props.assetsOptions.assetConfig, + _redirects: props.assetsOptions._redirects, + _headers: props.assetsOptions._headers, + run_worker_first: props.assetsOptions.run_worker_first, + } + : undefined, + logpush: undefined, // logpush and observability are non-versioned settings + observability: undefined, + cache: config.cache, // cache is a versioned setting + }; + + if (config.containers && config.containers.length > 0) { + logger.warn( + `Your Worker has Containers configured. Container configuration changes (such as image, max_instances, etc.) will not be gradually rolled out with versions. These changes will only take effect after running \`wrangler deploy\`.` + ); + } + + await printBundleSize( + { name: path.basename(resolvedEntryPointPath), content: content }, + modules + ); + + let workerBundle: FormData; + + if (props.dryRun) { + workerBundle = createWorkerUploadForm(worker, bindings, { + dryRun: true, + unsafe: config.unsafe, + }); + printBindings( + bindings, + config.tail_consumers, + config.streaming_tail_consumers, + undefined, + { unsafeMetadata: config.unsafe?.metadata } + ); + } else { + assert(accountId, "Missing accountId"); + if (props.resourcesProvision && callbacks.provisionBindings) { + await callbacks.provisionBindings( + bindings, + accountId, + scriptName, + props.experimentalAutoCreate, + config + ); + } + workerBundle = createWorkerUploadForm(worker, bindings, { + unsafe: config.unsafe, + }); + + await ensureQueuesExistByConfig(config, accountId); + let bindingsPrinted = false; + + // Upload the version. + try { + const result = await retryOnAPIFailure( + async () => + fetchResult<{ + id: string; + startup_time_ms: number; + metadata: { + has_preview: boolean; + }; + }>( + config, + `${workerUrl}/versions`, + { + method: "POST", + body: workerBundle, + headers: props.sendMetrics + ? { metricsEnabled: "true" } + : undefined, + }, + new URLSearchParams({ bindings_inherit: "strict" }) + ), + logger + ); + + logger.log("Worker Startup Time:", result.startup_time_ms, "ms"); + bindingsPrinted = true; + printBindings( + bindings, + config.tail_consumers, + config.streaming_tail_consumers, + undefined, + { unsafeMetadata: config.unsafe?.metadata } + ); + versionId = result.id; + hasPreview = result.metadata.has_preview; + } catch (err) { + if (!bindingsPrinted) { + printBindings( + bindings, + config.tail_consumers, + config.streaming_tail_consumers, + undefined, + { unsafeMetadata: config.unsafe?.metadata } + ); + } + + const message = await helpIfErrorIsSizeOrScriptStartup( + err, + dependencies, + workerBundle, + projectRoot, + callbacks.analyseBundle + ); + if (message) { + logger.error(message); + } + + handleMissingSecretsError(err, config, { type: "upload" }); + + // Apply source mapping to validation startup errors if possible + if ( + err instanceof ParseError && + "code" in err && + err.code === 10021 /* validation error */ && + err.notes.length > 0 + ) { + const maybeNameToFilePath = (moduleName: string) => { + // If this is a service worker, always return the entrypoint path. + // Service workers can't have additional JavaScript modules. + if (bundleType === "commonjs") { + return resolvedEntryPointPath; + } + // Similarly, if the name matches the entrypoint, return its path + if (moduleName === entryPointName) { + return resolvedEntryPointPath; + } + // Otherwise, return the file path of the matching module (if any) + for (const module of modules) { + if (moduleName === module.name) { + return module.filePath; + } + } + }; + const retrieveSourceMap: RetrieveSourceMapFunction = (moduleName) => + maybeRetrieveFileSourceMap(maybeNameToFilePath(moduleName)); + + err.notes[0].text = getSourceMappedString( + err.notes[0].text, + retrieveSourceMap + ); + } + + throw err; + } + + // Update service and environment tags when using environments + + const nextTags = applyServiceAndEnvironmentTags(config, tags); + if (!tagsAreEqual(tags, nextTags)) { + try { + await patchNonVersionedScriptSettings(config, accountId, scriptName, { + tags: nextTags, + }); + } catch { + warnOnErrorUpdatingServiceAndEnvironmentTags(); + } + } + } + if (props.outfile) { + // we're using a custom output file, + // so let's first ensure it's parent directory exists + mkdirSync(path.dirname(props.outfile), { recursive: true }); + + const serializedFormData = await new Response(workerBundle).arrayBuffer(); + + writeFileSync(props.outfile, Buffer.from(serializedFormData)); + } + + if (props.dryRun) { + logger.log(`--dry-run: exiting now.`); + return { versionId, workerTag }; + } + if (!accountId) { + throw new UserError("Missing accountId", { + telemetryMessage: "versions upload missing account id", + }); + } + + const uploadMs = Date.now() - start; + + logger.log("Uploaded", workerName, formatTime(uploadMs)); + logger.log("Worker Version ID:", versionId); + + let versionPreviewUrl: string | undefined = undefined; + let versionPreviewAliasUrl: string | undefined = undefined; + + if (versionId && hasPreview) { + const { previews_enabled: previews_available_on_subdomain } = + await fetchResult<{ + previews_enabled: boolean; + }>(config, `${workerUrl}/subdomain`); + + if (previews_available_on_subdomain) { + const userSubdomain = await getWorkersDevSubdomain(config, accountId, { + configPath: config.configPath, + }); + const shortVersion = versionId.slice(0, 8); + versionPreviewUrl = `https://${shortVersion}-${workerName}.${userSubdomain}`; + logger.log(`Version Preview URL: ${versionPreviewUrl}`); + + if (props.previewAlias) { + versionPreviewAliasUrl = `https://${props.previewAlias}-${workerName}.${userSubdomain}`; + logger.log(`Version Preview Alias URL: ${versionPreviewAliasUrl}`); + } + } + } + + const cmdVersionsDeploy = blue("wrangler versions deploy"); + const cmdTriggersDeploy = blue("wrangler triggers deploy"); + logger.info( + gray(` +To deploy this version to production traffic use the command ${cmdVersionsDeploy} + +Changes to non-versioned settings (config properties 'logpush' or 'tail_consumers') take effect after your next deployment using the command ${cmdVersionsDeploy} + +Changes to triggers (routes, custom domains, cron schedules, etc) must be applied with the command ${cmdTriggersDeploy} +`) + ); + + return { versionId, workerTag, versionPreviewUrl, versionPreviewAliasUrl }; +} diff --git a/packages/deploy-helpers/src/index.ts b/packages/deploy-helpers/src/index.ts index 3538db76f4..a550c25e13 100644 --- a/packages/deploy-helpers/src/index.ts +++ b/packages/deploy-helpers/src/index.ts @@ -1,6 +1,45 @@ export * from "./shared/types"; +export { initDeployHelpersContext } from "./shared/context"; +export { default as deploy } from "./deploy/deploy"; +export type { DeployCallbacks } from "./deploy/deploy"; +export { default as versionsUpload } from "./deploy/versions-upload"; +export type { VersionsUploadCallbacks } from "./deploy/versions-upload"; export * from "./triggers/deploy"; export * from "./triggers/subdomain"; export * from "./triggers/zones"; export * from "./triggers/publish-routes"; export * from "./triggers/queue-consumers"; +export * from "./deploy/helpers/placement"; +export * from "./deploy/helpers/worker-not-found-error"; +export * from "./deploy/helpers/use-service-environments"; +export * from "./deploy/helpers/error-codes"; +export * from "./deploy/helpers/secrets-validation"; +export * from "./deploy/helpers/source-maps"; +export * from "./deploy/helpers/diff-json"; +export * from "./deploy/helpers/config-diffs"; +export * from "./deploy/helpers/parse-bulk-input"; +export * from "./deploy/helpers/binding-utils"; +export * from "./deploy/helpers/capnp"; +export * from "./deploy/helpers/create-worker-upload-form"; +export * from "./deploy/helpers/workers-sites-bindings"; +export * from "./deploy/helpers/deploy-wfp"; +export * from "./deploy/helpers/deploy-confirm"; +export * from "./deploy/helpers/preview-alias"; +export * from "./deploy/helpers/sourcemap"; +export * from "./deploy/helpers/bundle-reporter"; +export * from "./deploy/helpers/node-compat"; +export * from "./deploy/helpers/validate-routes"; +export * from "./deploy/helpers/environments"; +export * from "./deploy/helpers/friendly-validator-errors"; +export * from "./deploy/helpers/versions-types"; +export * from "./deploy/helpers/versions-api"; +export * from "./deploy/helpers/check-workflow-conflicts"; +export * from "./deploy/helpers/download-worker-config"; +export * from "./deploy/helpers/durable"; +export * from "./deploy/helpers/match-tag"; +export * from "./deploy/helpers/check-remote-secrets-override"; +export * from "./deploy/helpers/confirm-latest-deployment-overwrite"; +export * from "./deploy/helpers/print-bindings"; +export * from "./deploy/helpers/assets"; +export * from "./deploy/helpers/hash"; +export * from "./deploy/helpers/jwt"; diff --git a/packages/deploy-helpers/src/shared/context.ts b/packages/deploy-helpers/src/shared/context.ts new file mode 100644 index 0000000000..c977c537af --- /dev/null +++ b/packages/deploy-helpers/src/shared/context.ts @@ -0,0 +1,44 @@ +import type { DeployHelpersContext } from "./types"; +import type { + FetchKVGetValueFetcher, + FetchListResultFetcher, + FetchPagedListResultFetcher, + FetchResultFetcher, + Logger, +} from "@cloudflare/workers-utils"; + +/** + * Module-level context globals for deploy-helpers. + * + * These are typed as non-nullable but are undefined until initDeployHelpersContext() + * is called. Consumers must import the live binding directly (e.g. `import { logger }`) + * and NOT destructure or cache the value at module-load time, otherwise they will + * capture `undefined` before init runs. + * + * Example: + * import { logger } from "./context"; // correct: live binding + * const { logger } = await import("./context"); // WRONG: captures undefined + */ +export let logger: Logger; +export let fetchResult: FetchResultFetcher; +export let fetchListResult: FetchListResultFetcher; +export let fetchPagedListResult: FetchPagedListResultFetcher; +export let fetchKVGetValue: FetchKVGetValueFetcher; +export let confirm: DeployHelpersContext["confirm"]; +export let prompt: DeployHelpersContext["prompt"]; +export let isNonInteractiveOrCI: () => boolean; + +/** + * Set the global context for deploy-helpers. Must be called once at + * startup before any deploy-helpers function that needs these values. + */ +export function initDeployHelpersContext(ctx: DeployHelpersContext): void { + logger = ctx.logger; + fetchResult = ctx.fetchResult; + fetchListResult = ctx.fetchListResult; + fetchPagedListResult = ctx.fetchPagedListResult; + fetchKVGetValue = ctx.fetchKVGetValue; + confirm = ctx.confirm; + prompt = ctx.prompt; + isNonInteractiveOrCI = ctx.isNonInteractiveOrCI; +} diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index b5f84b93e5..0e858c1f79 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -5,8 +5,10 @@ import type { CfModuleType, Config, EphemeralDirectory, + FetchKVGetValueFetcher, FetchResultFetcher, FetchListResultFetcher, + FetchPagedListResultFetcher, Logger, Route, Entry, @@ -20,6 +22,8 @@ import type { NodeJSCompatMode } from "miniflare"; export type DeployHelpersContext = { fetchResult: FetchResultFetcher; fetchListResult: FetchListResultFetcher; + fetchPagedListResult: FetchPagedListResultFetcher; + fetchKVGetValue: FetchKVGetValueFetcher; logger: Logger; confirm: ( text: string, diff --git a/packages/deploy-helpers/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts index b74fe3bb4c..88891716ef 100644 --- a/packages/deploy-helpers/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -6,6 +6,12 @@ import { } from "@cloudflare/workers-utils"; import chalk from "chalk"; import PQueue from "p-queue"; +import { + fetchListResult, + fetchResult, + isNonInteractiveOrCI, + logger, +} from "../shared/context"; import { publishCustomDomains, publishRoutes, @@ -14,17 +20,12 @@ import { import { updateQueueConsumers } from "./queue-consumers"; import { getWorkersDevSubdomain } from "./subdomain"; import { getZoneForRoute } from "./zones"; -import type { - DeployHelpersContext, - TriggerDeployment, - TriggerProps, -} from "../shared/types"; +import type { TriggerDeployment, TriggerProps } from "../shared/types"; import type { RouteObject } from "./publish-routes"; import type { Config, Route } from "@cloudflare/workers-utils"; export async function triggersDeploy( - props: TriggerProps, - ctx: DeployHelpersContext + props: TriggerProps ): Promise { const { config, accountId, scriptName, routes, crons } = props; @@ -61,8 +62,7 @@ export async function triggersDeploy( workerUrl, routes, deployments, - props.firstDeploy, - ctx + props.firstDeploy ); if (!wantWorkersDev && workersDevInSync && routes.length !== 0) { @@ -99,7 +99,6 @@ export async function triggersDeploy( const zone = await getZoneForRoute( config, { route, accountId }, - ctx, zoneIdCache ); if (!zone) { @@ -113,11 +112,11 @@ export async function triggersDeploy( if (!routesInZone) { routesInZone = retryOnAPIFailure( () => - ctx.fetchListResult<{ + fetchListResult<{ pattern: string; script: string; }>(config, `/zones/${zone.id}/workers/routes`), - ctx.logger + logger ); zoneRoutesCache.set(zone.id, routesInZone); } @@ -163,7 +162,7 @@ export async function triggersDeploy( } if (!wantWorkersDev && hasWorkflowsDefinedInThisScript) { - await getWorkersDevSubdomain(config, accountId, ctx, { + await getWorkersDevSubdomain(config, accountId, { configPath: config.configPath, registrationContext: "workflows", }); @@ -172,17 +171,12 @@ export async function triggersDeploy( // Update routing table for the script. if (routesOnly.length > 0) { deployments.push( - publishRoutes( - config, - routesOnly, - { - workerUrl, - scriptName, - useServiceEnvironments: props.useServiceEnvironments, - accountId, - }, - ctx - ).then( + publishRoutes(config, routesOnly, { + workerUrl, + scriptName, + useServiceEnvironments: props.useServiceEnvironments, + accountId, + }).then( () => { if (routesOnly.length > 10) { return { @@ -206,8 +200,7 @@ export async function triggersDeploy( config, workerUrl, accountId, - customDomainsOnly, - ctx + customDomainsOnly ).catch((error) => ({ targets: [], error })) ); } @@ -217,21 +210,19 @@ export async function triggersDeploy( // If it is an empty array we will remove all schedules. if (crons) { deployments.push( - ctx - .fetchResult(config, `${workerUrl}/schedules`, { - // Note: PUT will override previous schedules on this script. - method: "PUT", - body: JSON.stringify(crons.map((cron) => ({ cron }))), - headers: { - "Content-Type": "application/json", - }, - }) - .then( - () => ({ - targets: crons.map((trigger) => `schedule: ${trigger}`), - }), - (error) => ({ targets: [], error }) - ) + fetchResult(config, `${workerUrl}/schedules`, { + // Note: PUT will override previous schedules on this script. + method: "PUT", + body: JSON.stringify(crons.map((cron) => ({ cron }))), + headers: { + "Content-Type": "application/json", + }, + }).then( + () => ({ + targets: crons.map((trigger) => `schedule: ${trigger}`), + }), + (error) => ({ targets: [], error }) + ) ); } @@ -248,8 +239,7 @@ export async function triggersDeploy( config, accountId, scriptName, - config, - ctx + config ); deployments.push(...consumerUpdates); } @@ -284,32 +274,30 @@ export async function triggersDeploy( } deployments.push( - ctx - .fetchResult( - config, - `/accounts/${accountId}/workflows/${workflow.name}`, - { - method: "PUT", - body: JSON.stringify({ - script_name: scriptName, - class_name: workflow.class_name, - ...(workflow.limits && { limits: workflow.limits }), - ...(workflow.schedules && { - schedules: (Array.isArray(workflow.schedules) - ? workflow.schedules - : [workflow.schedules] - ).map((cron) => ({ cron })), - }), + fetchResult( + config, + `/accounts/${accountId}/workflows/${workflow.name}`, + { + method: "PUT", + body: JSON.stringify({ + script_name: scriptName, + class_name: workflow.class_name, + ...(workflow.limits && { limits: workflow.limits }), + ...(workflow.schedules && { + schedules: (Array.isArray(workflow.schedules) + ? workflow.schedules + : [workflow.schedules] + ).map((cron) => ({ cron })), }), - headers: { - "Content-Type": "application/json", - }, - } - ) - .then( - () => ({ targets: [`workflow: ${workflow.name}`] }), - (error) => ({ targets: [], error }) - ) + }), + headers: { + "Content-Type": "application/json", + }, + } + ).then( + () => ({ targets: [`workflow: ${workflow.name}`] }), + (error) => ({ targets: [], error }) + ) ); } } @@ -328,12 +316,12 @@ export async function triggersDeploy( (target) => (target.endsWith("workers.dev") ? "https://" : "") + target ); if (targets.length > 0) { - ctx.logger.log(`Deployed ${workerName} triggers`, formatTime(deployMs)); + logger.log(`Deployed ${workerName} triggers`, formatTime(deployMs)); for (const target of targets) { - ctx.logger.log(" ", target); + logger.log(" ", target); } } else { - ctx.logger.log("No targets deployed for", workerName, formatTime(deployMs)); + logger.log("No targets deployed for", workerName, formatTime(deployMs)); } const errors = completedDeployments @@ -419,8 +407,7 @@ async function validateSubdomainMixedState( scriptName: string, before: { workers_dev: boolean; preview_urls: boolean }, after: { workers_dev: boolean; preview_urls: boolean }, - firstDeploy: boolean, - ctx: DeployHelpersContext + firstDeploy: boolean ): Promise<{ workers_dev: boolean; preview_urls: boolean; @@ -442,7 +429,7 @@ async function validateSubdomainMixedState( } // Early return if non-interactive or CI - if (ctx.isNonInteractiveOrCI()) { + if (isNonInteractiveOrCI()) { return after; } @@ -456,14 +443,14 @@ async function validateSubdomainMixedState( return after; } - const userSubdomain = await getWorkersDevSubdomain(config, accountId, ctx, { + const userSubdomain = await getWorkersDevSubdomain(config, accountId, { configPath: config.configPath, }); const previewUrl = `https://-${scriptName}.${userSubdomain}`; // Scenario 1: User disables workers.dev while having preview URLs enabled if (!after.workers_dev && after.preview_urls) { - ctx.logger.warn( + logger.warn( [ "You are disabling the 'workers.dev' subdomain for this Worker, but Preview URLs are still enabled.", "Preview URLs will automatically generate a unique, shareable link for each new version which will be accessible at:", @@ -476,7 +463,7 @@ async function validateSubdomainMixedState( // Scenario 2: User enables workers.dev when Preview URLs are off if (after.workers_dev && !after.preview_urls) { - ctx.logger.warn( + logger.warn( [ "You are enabling the 'workers.dev' subdomain for this Worker, but Preview URLs are still disabled.", "Preview URLs will automatically generate a unique, shareable link for each new version which will be accessible at:", @@ -498,8 +485,7 @@ async function subdomainDeploy( workerUrl: string, routes: Route[], deployments: Promise[], - firstDeploy: boolean, - ctx: DeployHelpersContext + firstDeploy: boolean ) { const { config } = props; @@ -510,7 +496,7 @@ async function subdomainDeploy( // workers.dev URL is only set if we want to deploy to workers.dev. if (wantWorkersDev) { - const userSubdomain = await getWorkersDevSubdomain(config, accountId, ctx, { + const userSubdomain = await getWorkersDevSubdomain(config, accountId, { configPath: config.configPath, }); const workersDevURL = @@ -521,7 +507,7 @@ async function subdomainDeploy( } // Get current subdomain enablement status. - const before = await ctx.fetchResult<{ + const before = await fetchResult<{ enabled: boolean; previews_enabled: boolean; }>(config, `${workerUrl}/subdomain`); @@ -531,7 +517,7 @@ async function subdomainDeploy( // we retry this request a few times to mitigate that. const after = await retryOnAPIFailure( async () => - ctx.fetchResult<{ + fetchResult<{ enabled: boolean; previews_enabled: boolean; }>(config, `${workerUrl}/subdomain`, { @@ -545,7 +531,7 @@ async function subdomainDeploy( "Cloudflare-Workers-Script-Api-Date": "2025-08-01", }, }), - ctx.logger + logger ); // Warn about mismatching config and current values. @@ -561,7 +547,7 @@ async function subdomainDeploy( return enabled ? "enable" : "disable"; } }; - ctx.logger.warn( + logger.warn( [ `Because 'workers_dev' is not in your Wrangler file, it will be ${status(after.enabled, true)} for this deployment by default.`, `To override this setting, you can ${status(before.enabled, false)} workers.dev by explicitly setting 'workers_dev = ${before.enabled}' in your Wrangler file.`, @@ -581,7 +567,7 @@ async function subdomainDeploy( return enabled ? "enable" : "disable"; } }; - ctx.logger.warn( + logger.warn( [ `Because your 'workers.dev' route is ${status(after.enabled, true)} and your 'preview_urls' setting is not in your Wrangler file, Preview URLs will be ${status(after.previews_enabled, true)} for this deployment by default.`, `To override this setting, you can ${status(before.previews_enabled, false)} Preview URLs by explicitly setting 'preview_urls = ${before.previews_enabled}' in your Wrangler file.`, @@ -596,8 +582,7 @@ async function subdomainDeploy( scriptName, { workers_dev: before.enabled, preview_urls: before.previews_enabled }, { workers_dev: after.enabled, preview_urls: after.previews_enabled }, - firstDeploy, - ctx + firstDeploy ); return { diff --git a/packages/deploy-helpers/src/triggers/publish-routes.ts b/packages/deploy-helpers/src/triggers/publish-routes.ts index e00b6f11d2..75ecf0d5dc 100644 --- a/packages/deploy-helpers/src/triggers/publish-routes.ts +++ b/packages/deploy-helpers/src/triggers/publish-routes.ts @@ -1,7 +1,13 @@ import { ParseError, UserError } from "@cloudflare/workers-utils"; import PQueue from "p-queue"; +import { + confirm, + fetchListResult, + fetchResult, + logger, +} from "../shared/context"; import { getZoneForRoute } from "./zones"; -import type { DeployHelpersContext, TriggerDeployment } from "../shared/types"; +import type { TriggerDeployment } from "../shared/types"; import type { ComplianceConfig, CustomDomainRoute, @@ -95,11 +101,10 @@ export async function publishRoutes( scriptName: string; useServiceEnvironments: boolean; accountId: string; - }, - ctx: DeployHelpersContext + } ): Promise { try { - return await ctx.fetchResult(complianceConfig, `${workerUrl}/routes`, { + return await fetchResult(complianceConfig, `${workerUrl}/routes`, { // Note: PUT will delete previous routes on this script. method: "PUT", body: JSON.stringify( @@ -115,16 +120,11 @@ export async function publishRoutes( if (isAuthenticationError(e)) { // An authentication error is probably due to a known issue, // where the user is logged in via an API token that does not have "All Zones". - return await publishRoutesFallback( - complianceConfig, - routes, - { - scriptName, - useServiceEnvironments, - accountId, - }, - ctx - ); + return await publishRoutesFallback(complianceConfig, routes, { + scriptName, + useServiceEnvironments, + accountId, + }); } else { throw e; } @@ -142,8 +142,7 @@ async function publishRoutesFallback( scriptName, useServiceEnvironments, accountId, - }: { scriptName: string; useServiceEnvironments: boolean; accountId: string }, - ctx: DeployHelpersContext + }: { scriptName: string; useServiceEnvironments: boolean; accountId: string } ) { if (useServiceEnvironments) { throw new UserError( @@ -155,7 +154,7 @@ async function publishRoutesFallback( } ); } - ctx.logger.info( + logger.info( "The current authentication token does not have 'All Zones' permissions.\n" + "Falling back to using the zone-based API endpoint to update each route individually.\n" + "Note that there is no access to routes associated with zones that the API token does not have permission for.\n" + @@ -177,7 +176,6 @@ async function publishRoutesFallback( const zone = await getZoneForRoute( complianceConfig, { route, accountId }, - ctx, zoneIdCache ); if (zone) { @@ -199,7 +197,7 @@ async function publishRoutesFallback( queuePromises.push( queue.add(async () => { try { - for (const { pattern, script } of await ctx.fetchListResult<{ + for (const { pattern, script } of await fetchListResult<{ pattern: string; script: string; }>(complianceConfig, `/zones/${zone}/workers/routes`)) { @@ -239,7 +237,7 @@ async function publishRoutesFallback( } } - const { pattern } = await ctx.fetchResult<{ pattern: string }>( + const { pattern } = await fetchResult<{ pattern: string }>( complianceConfig, `/zones/${zoneId}/workers/routes`, { @@ -258,7 +256,7 @@ async function publishRoutesFallback( } if (alreadyDeployedRoutes.size) { - ctx.logger.warn( + logger.warn( "Previously deployed routes:\n" + "The following routes were already associated with this worker, and have not been deleted:\n" + [...alreadyDeployedRoutes.values()].map((route) => ` - "${route}"\n`) + @@ -273,8 +271,7 @@ export async function publishCustomDomains( complianceConfig: ComplianceConfig, workerUrl: string, accountId: string, - domains: Array, - ctx: DeployHelpersContext + domains: Array ): Promise { const options = { override_scope: true, @@ -310,7 +307,7 @@ export async function publishCustomDomains( options.override_existing_origin = true; options.override_existing_dns_record = true; } else { - const changeset = await ctx.fetchResult( + const changeset = await fetchResult( complianceConfig, `${workerUrl}/domains/changeset?replace_state=true`, { @@ -328,7 +325,7 @@ export async function publishCustomDomains( if (updatesRequired.length > 0) { const existing = await Promise.all( updatesRequired.map((domain) => - ctx.fetchResult( + fetchResult( complianceConfig, `/accounts/${accountId}/workers/domains/records/${domain.id}` ) @@ -343,7 +340,7 @@ export async function publishCustomDomains( const message = `Custom Domains already exist for these domains: ${existingRendered} Update them to point to this script instead?`; - if (!(await ctx.confirm(message))) { + if (!(await confirm(message))) { return fail(); } options.override_existing_origin = true; @@ -356,14 +353,14 @@ Update them to point to this script instead?`; const message = `You already have DNS records that conflict for these Custom Domains: ${conflicitingRendered} Update them to point to this script instead?`; - if (!(await ctx.confirm(message))) { + if (!(await confirm(message))) { return fail(); } options.override_existing_dns_record = true; } } - await ctx.fetchResult(complianceConfig, `${workerUrl}/domains/records`, { + await fetchResult(complianceConfig, `${workerUrl}/domains/records`, { method: "PUT", body: JSON.stringify({ ...options, origins }), headers: { diff --git a/packages/deploy-helpers/src/triggers/queue-consumers.ts b/packages/deploy-helpers/src/triggers/queue-consumers.ts index 8bccd79cea..8968a90236 100644 --- a/packages/deploy-helpers/src/triggers/queue-consumers.ts +++ b/packages/deploy-helpers/src/triggers/queue-consumers.ts @@ -1,5 +1,6 @@ import { UserError } from "@cloudflare/workers-utils"; -import type { DeployHelpersContext, TriggerDeployment } from "../shared/types"; +import { fetchPagedListResult, fetchResult, logger } from "../shared/context"; +import type { TriggerDeployment } from "../shared/types"; import type { Config, ComplianceConfig } from "@cloudflare/workers-utils"; export interface PostQueueBody { @@ -87,7 +88,6 @@ export interface PurgeQueueResponse { export async function listQueues( complianceConfig: ComplianceConfig, accountId: string, - ctx: DeployHelpersContext, page?: number, name?: string ): Promise { @@ -98,7 +98,7 @@ export async function listQueues( params.append("name", name); } - return ctx.fetchResult( + return fetchResult( complianceConfig, `/accounts/${accountId}/queues`, {}, @@ -109,16 +109,9 @@ export async function listQueues( export async function getQueue( complianceConfig: ComplianceConfig, accountId: string, - queueName: string, - ctx: DeployHelpersContext + queueName: string ): Promise { - const queues = await listQueues( - complianceConfig, - accountId, - ctx, - 1, - queueName - ); + const queues = await listQueues(complianceConfig, accountId, 1, queueName); if (queues.length === 0) { throw new UserError( `Queue "${queueName}" does not exist. To create it, run: wrangler queues create ${queueName}`, @@ -132,27 +125,19 @@ export async function postConsumer( complianceConfig: ComplianceConfig, accountId: string, queueName: string, - body: PostTypedConsumerBody, - ctx: DeployHelpersContext + body: PostTypedConsumerBody ): Promise { - const queue = await getQueue(complianceConfig, accountId, queueName, ctx); - return postConsumerById( - complianceConfig, - accountId, - ctx, - queue.queue_id, - body - ); + const queue = await getQueue(complianceConfig, accountId, queueName); + return postConsumerById(complianceConfig, accountId, queue.queue_id, body); } async function postConsumerById( config: ComplianceConfig, accountId: string, - ctx: DeployHelpersContext, queueId: string, body: PostTypedConsumerBody ): Promise { - return ctx.fetchResult( + return fetchResult( config, `/accounts/${accountId}/queues/${queueId}/consumers`, { @@ -167,10 +152,9 @@ export async function putConsumerById( accountId: string, queueId: string, consumerId: string, - body: PostTypedConsumerBody, - ctx: DeployHelpersContext + body: PostTypedConsumerBody ): Promise { - return ctx.fetchResult( + return fetchResult( complianceConfig, `/accounts/${accountId}/queues/${queueId}/consumers/${consumerId}`, { @@ -186,25 +170,22 @@ export async function putConsumer( queueName: string, scriptName: string, envName: string | undefined, - body: PostTypedConsumerBody, - ctx: DeployHelpersContext + body: PostTypedConsumerBody ): Promise { - const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const queue = await getQueue(complianceConfig, accountId, queueName); const targetConsumer = await resolveWorkerConsumerByName( complianceConfig, accountId, scriptName, envName, - queue, - ctx + queue ); return putConsumerById( complianceConfig, accountId, queue.queue_id, targetConsumer.consumer_id, - body, - ctx + body ); } @@ -213,8 +194,7 @@ async function resolveWorkerConsumerByName( accountId: string, consumerName: string, envName: string | undefined, - queue: QueueResponse, - ctx: DeployHelpersContext + queue: QueueResponse ): Promise { const queueName = queue.queue_name; const consumers = queue.consumers.filter( @@ -235,7 +215,7 @@ async function resolveWorkerConsumerByName( if (consumers.length > 1) { const targetEnv = envName ?? - (await getDefaultService(complianceConfig, accountId, consumerName, ctx)); + (await getDefaultService(complianceConfig, accountId, consumerName)); const targetConsumers = consumers.filter( (c) => c.environment === targetEnv ); @@ -252,7 +232,7 @@ async function resolveWorkerConsumerByName( if (consumers[0].service) { const targetEnv = envName ?? - (await getDefaultService(complianceConfig, accountId, consumerName, ctx)); + (await getDefaultService(complianceConfig, accountId, consumerName)); if (targetEnv != consumers[0].environment) { throw new UserError( `No worker consumer '${consumerName}' exists for queue ${queueName}`, @@ -273,10 +253,9 @@ interface WorkerService { async function getDefaultService( complianceConfig: ComplianceConfig, accountId: string, - serviceName: string, - ctx: DeployHelpersContext + serviceName: string ): Promise { - const service = await ctx.fetchResult( + const service = await fetchResult( complianceConfig, `/accounts/${accountId}/workers/services/${serviceName}`, { @@ -284,7 +263,7 @@ async function getDefaultService( } ); - ctx.logger.info(service); + logger.info(service); return service.default_environment.environment; } @@ -293,10 +272,9 @@ async function deleteConsumerById( complianceConfig: ComplianceConfig, accountId: string, queueId: string, - consumerId: string, - ctx: DeployHelpersContext + consumerId: string ): Promise { - return ctx.fetchResult( + return fetchResult( complianceConfig, `/accounts/${accountId}/queues/${queueId}/consumers/${consumerId}`, { @@ -308,10 +286,9 @@ async function deleteConsumerById( export async function deletePullConsumer( complianceConfig: ComplianceConfig, accountId: string, - queueName: string, - ctx: DeployHelpersContext + queueName: string ): Promise { - const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const queue = await getQueue(complianceConfig, accountId, queueName); const consumer = queue.consumers[0]; if (consumer?.type !== "http_pull") { throw new UserError(`No http_pull consumer exists for queue ${queueName}`, { @@ -322,18 +299,16 @@ export async function deletePullConsumer( complianceConfig, accountId, queue.queue_id, - consumer.consumer_id, - ctx + consumer.consumer_id ); } export async function listConsumers( complianceConfig: ComplianceConfig, accountId: string, - queueName: string, - ctx: DeployHelpersContext + queueName: string ): Promise { - const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const queue = await getQueue(complianceConfig, accountId, queueName); return queue.consumers; } @@ -342,24 +317,21 @@ export async function deleteWorkerConsumer( accountId: string, queueName: string, scriptName: string, - envName: string | undefined, - ctx: DeployHelpersContext + envName: string | undefined ): Promise { - const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const queue = await getQueue(complianceConfig, accountId, queueName); const targetConsumer = await resolveWorkerConsumerByName( complianceConfig, accountId, scriptName, envName, - queue, - ctx + queue ); return deleteConsumerById( complianceConfig, accountId, queue.queue_id, - targetConsumer.consumer_id, - ctx + targetConsumer.consumer_id ); } @@ -367,18 +339,12 @@ export async function updateQueueConsumers( complianceConfig: ComplianceConfig, accountId: string, scriptName: string, - config: Config, - ctx: DeployHelpersContext + config: Config ): Promise[]> { const consumers = config.queues.consumers || []; const updateConsumers: Promise[] = []; for (const consumer of consumers) { - const queue = await getQueue( - complianceConfig, - accountId, - consumer.queue, - ctx - ); + const queue = await getQueue(complianceConfig, accountId, consumer.queue); const body: PostTypedConsumerBody = { type: "worker", @@ -410,8 +376,7 @@ export async function updateQueueConsumers( consumer.queue, scriptName, envName, - body, - ctx + body ).then( () => ({ targets: [`Consumer for ${consumer.queue}`] }), (error) => ({ targets: [], error }) @@ -420,7 +385,7 @@ export async function updateQueueConsumers( continue; } updateConsumers.push( - postConsumer(complianceConfig, accountId, consumer.queue, body, ctx).then( + postConsumer(complianceConfig, accountId, consumer.queue, body).then( () => ({ targets: [`Consumer for ${consumer.queue}`], }), @@ -431,3 +396,53 @@ export async function updateQueueConsumers( return updateConsumers; } + +const queuesUrl = (accountId: string, queueId?: string): string => { + let url = `/accounts/${accountId}/queues`; + if (queueId) { + url += `/${queueId}`; + } + return url; +}; + +export async function ensureQueuesExistByConfig( + config: Config, + accountId: string +) { + const producers = (config.queues.producers || []).map( + (producer) => producer.queue + ); + const consumers = (config.queues.consumers || []).map( + (consumer) => consumer.queue + ); + + const queueNames = producers.concat(consumers); + if (queueNames.length > 0) { + const params = new URLSearchParams(); + queueNames.forEach((e) => { + params.append("name", e); + }); + + const existingQueues = ( + await fetchPagedListResult( + config, + queuesUrl(accountId), + {}, + params + ) + ).map((q) => q.queue_name); + + if (queueNames.length !== existingQueues.length) { + const queueSet = new Set(existingQueues); + + for (const queue of queueNames) { + if (!queueSet.has(queue)) { + throw new UserError( + `Queue "${queue}" does not exist. To create it, run: wrangler queues create ${queue}`, + { telemetryMessage: "queues config missing queue" } + ); + } + } + } + } +} diff --git a/packages/deploy-helpers/src/triggers/subdomain.ts b/packages/deploy-helpers/src/triggers/subdomain.ts index a628bbb1fd..423accd949 100644 --- a/packages/deploy-helpers/src/triggers/subdomain.ts +++ b/packages/deploy-helpers/src/triggers/subdomain.ts @@ -4,7 +4,7 @@ import { UserError, } from "@cloudflare/workers-utils"; import chalk from "chalk"; -import type { DeployHelpersContext } from "../shared/types"; +import { confirm, fetchResult, logger, prompt } from "../shared/context"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; type WorkersDevSubdomainRegistrationContext = "workers_dev" | "workflows"; @@ -21,7 +21,6 @@ type GetWorkersDevSubdomainOptions = { export async function getWorkersDevSubdomain( complianceConfig: ComplianceConfig, accountId: string, - ctx: DeployHelpersContext, options: GetWorkersDevSubdomainOptions = {} ): Promise { const { @@ -32,7 +31,7 @@ export async function getWorkersDevSubdomain( try { // note: API docs say that this field is "name", but they're lying. - const { subdomain } = await ctx.fetchResult<{ subdomain: string }>( + const { subdomain } = await fetchResult<{ subdomain: string }>( complianceConfig, `/accounts/${accountId}/workers/subdomain`, undefined, @@ -48,9 +47,9 @@ export async function getWorkersDevSubdomain( // 10007 error code: not found // https://api.cloudflare.com/#worker-subdomain-get-subdomain - ctx.logger.warn(getRegistrationWarning(registrationContext)); + logger.warn(getRegistrationWarning(registrationContext)); - const wantsToRegister = await ctx.confirm( + const wantsToRegister = await confirm( "Would you like to register a workers.dev subdomain now?", { fallbackValue: false } ); @@ -66,8 +65,7 @@ export async function getWorkersDevSubdomain( complianceConfig, accountId, configPath, - registrationContext, - ctx + registrationContext ); } } @@ -118,25 +116,24 @@ async function registerSubdomain( complianceConfig: ComplianceConfig, accountId: string, configPath: string | undefined, - registrationContext: WorkersDevSubdomainRegistrationContext, - ctx: DeployHelpersContext + registrationContext: WorkersDevSubdomainRegistrationContext ): Promise { let subdomain: string | undefined; while (subdomain === undefined) { - const potentialName = await ctx.prompt( + const potentialName = await prompt( "What would you like your workers.dev subdomain to be? It will be accessible at https://.workers.dev" ); if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(potentialName)) { - ctx.logger.warn( + logger.warn( `${potentialName} is invalid, please choose another subdomain.` ); continue; } try { - await ctx.fetchResult<{ subdomain: string }>( + await fetchResult<{ subdomain: string }>( complianceConfig, `/accounts/${accountId}/workers/subdomains/${potentialName}` ); @@ -151,18 +148,18 @@ async function registerSubdomain( // oddly enough, this is a `subdomain_unavailable` error, meaning...that the subdomain // doesn't exist. and we can register it. this is exactly how the dashboard does it. } else if (subdomainAvailabilityCheckError.code === 10031) { - ctx.logger.error( + logger.error( "Subdomain is unavailable, please try a different subdomain" ); continue; } else { - ctx.logger.error("An unexpected error occurred, please try again."); + logger.error("An unexpected error occurred, please try again."); continue; } } } - const ok = await ctx.confirm( + const ok = await confirm( `Creating a workers.dev subdomain for your account at ${chalk.blue( chalk.underline( `https://${potentialName}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev` @@ -178,7 +175,7 @@ async function registerSubdomain( } try { - const result = await ctx.fetchResult<{ subdomain: string }>( + const result = await fetchResult<{ subdomain: string }>( complianceConfig, `/accounts/${accountId}/workers/subdomain`, { @@ -196,22 +193,20 @@ async function registerSubdomain( ) { switch (subdomainCreationError.code) { case 10031: - ctx.logger.error( + logger.error( "Subdomain is unavailable, please try a different subdomain." ); break; default: - ctx.logger.error("An unexpected error occurred, please try again."); + logger.error("An unexpected error occurred, please try again."); break; } } } } - ctx.logger.log( - "Success! It may take a few minutes for DNS records to update." - ); - ctx.logger.log( + logger.log("Success! It may take a few minutes for DNS records to update."); + logger.log( `Visit ${chalk.blue( chalk.underline( `https://dash.cloudflare.com/${accountId}/workers/subdomain` diff --git a/packages/deploy-helpers/src/triggers/zones.ts b/packages/deploy-helpers/src/triggers/zones.ts index be633b08ca..ce27cef2ea 100644 --- a/packages/deploy-helpers/src/triggers/zones.ts +++ b/packages/deploy-helpers/src/triggers/zones.ts @@ -3,7 +3,7 @@ import { retryOnAPIFailure, UserError, } from "@cloudflare/workers-utils"; -import type { DeployHelpersContext } from "../shared/types"; +import { fetchListResult, logger } from "../shared/context"; import type { ComplianceConfig, Route } from "@cloudflare/workers-utils"; export interface Zone { @@ -19,7 +19,6 @@ export async function getZoneForRoute( route: Route; accountId: string; }, - ctx: DeployHelpersContext, zoneIdCache: ZoneIdCache = new Map() ): Promise { const { route, accountId } = from; @@ -32,14 +31,12 @@ export async function getZoneForRoute( id = await getZoneIdFromHost( complianceConfig, { host: route.zone_name, accountId }, - ctx, zoneIdCache ); } else if (host) { id = await getZoneIdFromHost( complianceConfig, { host, accountId }, - ctx, zoneIdCache ); } @@ -60,7 +57,6 @@ export async function getZoneIdFromHost( host: string; accountId: string; }, - ctx: DeployHelpersContext, zoneIdCache: ZoneIdCache = new Map() ): Promise { const hostPieces = from.host.split("."); @@ -72,7 +68,7 @@ export async function getZoneIdFromHost( cacheKey, retryOnAPIFailure( () => - ctx.fetchListResult<{ id: string }>( + fetchListResult<{ id: string }>( complianceConfig, `/zones`, {}, @@ -81,7 +77,7 @@ export async function getZoneIdFromHost( "account.id": from.accountId, }) ), - ctx.logger + logger ).then((zones) => zones[0]?.id ?? null) ); } diff --git a/packages/deploy-helpers/tests/index.test.ts b/packages/deploy-helpers/tests/index.test.ts index bb30edb9cf..939b1931ae 100644 --- a/packages/deploy-helpers/tests/index.test.ts +++ b/packages/deploy-helpers/tests/index.test.ts @@ -1,7 +1,25 @@ +import { initDeployHelpersContext } from "@cloudflare/deploy-helpers"; +import { logger } from "@cloudflare/deploy-helpers/context"; import { describe, it } from "vitest"; -describe("placeholder", () => { - it("should pass", ({ expect }) => { - expect(true).toBe(true); +describe("context singleton", () => { + // Verifies that both package entry points (. and ./context) share the same + // context module. This only holds if tsup's splitting is enabled β€” if it's + // disabled, each entry bundles its own copy and this test will fail. + it("init from main entry propagates to context entry", ({ expect }) => { + const mockLogger = { debug: () => {}, log: () => {} }; + + initDeployHelpersContext({ + logger: mockLogger as never, + fetchResult: (() => {}) as never, + fetchListResult: (() => {}) as never, + fetchPagedListResult: (() => {}) as never, + fetchKVGetValue: (() => {}) as never, + confirm: (() => {}) as never, + prompt: (() => {}) as never, + isNonInteractiveOrCI: () => false, + }); + + expect(logger).toBe(mockLogger); }); }); diff --git a/packages/deploy-helpers/tsup.config.ts b/packages/deploy-helpers/tsup.config.ts index 1595fdb8ae..a6cb0dcb92 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -4,7 +4,14 @@ export default defineConfig(() => [ { treeshake: true, keepNames: true, - entry: ["src/index.ts"], + // Two entry points share context.ts as a singleton. esbuild's default + // `splitting: true` dedupes it into a shared chunk. If splitting is + // disabled, each entry bundles its own copy and init (via one entry) + // won't populate globals read via the other. Keep splitting enabled. + entry: { + index: "src/index.ts", + context: "src/shared/context.ts", + }, platform: "node", format: "esm", dts: true, @@ -12,6 +19,17 @@ export default defineConfig(() => [ tsconfig: "tsconfig.json", metafile: true, sourcemap: process.env.SOURCEMAPS !== "false", - external: [/^@cloudflare\//], + external: [ + /^@cloudflare\//, + "blake3-wasm", + "miniflare", + "p-queue", + "pretty-bytes", + "undici", + "chalk", + "dotenv", + "command-exists", + "esbuild", + ], }, ]); diff --git a/packages/deploy-helpers/vitest.config.mts b/packages/deploy-helpers/vitest.config.mts index d4cc444315..c9db5d1fa6 100644 --- a/packages/deploy-helpers/vitest.config.mts +++ b/packages/deploy-helpers/vitest.config.mts @@ -7,5 +7,6 @@ export default defineConfig({ include: ["**/tests/**/*.test.ts"], reporters: ["default"], mockReset: true, + unstubEnvs: true, }, }); diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index 53f786017b..ff265cd0fc 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -53,7 +53,7 @@ "@types/signal-exit": "^3.0.1", "@vitest/ui": "catalog:default", "cloudflare": "^5.2.0", - "command-exists": "^1.2.9", + "command-exists": "catalog:default", "concurrently": "^8.2.2", "empathic": "^2.0.0", "jsonc-parser": "catalog:default", diff --git a/packages/workers-utils/src/cfetch/index.ts b/packages/workers-utils/src/cfetch/index.ts index 1c12f7f6f4..e821f6fff5 100644 --- a/packages/workers-utils/src/cfetch/index.ts +++ b/packages/workers-utils/src/cfetch/index.ts @@ -44,10 +44,17 @@ export type FetchListResultFetcher = ( queryParams?: URLSearchParams ) => Promise; +export type FetchPagedListResultFetcher = ( + complianceConfig: ComplianceConfig, + resource: string, + init?: RequestInit, + queryParams?: URLSearchParams +) => Promise; + function logHeaders(headers: Headers, logger: Logger): void { const clone = cloneHeaders(headers); clone.delete("Authorization"); - logger.debugWithSanitization( + logger.debugWithSanitization?.( "HEADERS:", JSON.stringify(Object.fromEntries(clone), null, 2) ); @@ -83,12 +90,12 @@ export async function performApiFetchBase( logger.debug( `-- START CF API REQUEST: ${method} ${getCloudflareApiBaseUrl(complianceConfig)}${resource}` ); - logger.debugWithSanitization("QUERY STRING:", queryString); + logger.debugWithSanitization?.("QUERY STRING:", queryString); logHeaders(headers, logger); - logger.debugWithSanitization("INIT:", JSON.stringify({ ...init }, null, 2)); + logger.debugWithSanitization?.("INIT:", JSON.stringify({ ...init }, null, 2)); if (init.body instanceof FormData) { - logger.debugWithSanitization( + logger.debugWithSanitization?.( "BODY:", await new Response(init.body).text(), null, @@ -135,7 +142,7 @@ export async function fetchInternalBase( response.status ); logHeaders(response.headers, logger); - logger.debugWithSanitization("RESPONSE:", jsonText); + logger.debugWithSanitization?.("RESPONSE:", jsonText); logger.debug("-- END CF API RESPONSE"); if (!jsonText && (response.status === 204 || response.status === 205)) { @@ -465,6 +472,53 @@ function throwWAFBlockError( }); } +/** + * Fetch a raw KV value from the Cloudflare API. + * + * This is special-cased because it's the only API endpoint that returns raw + * binary data instead of a JSON envelope. + * + * Note: callers must call encodeURIComponent on `key` before passing it. + */ +export async function fetchKVGetValueBase( + complianceConfig: ComplianceConfig, + accountId: string, + namespaceId: string, + key: string, + userAgent: string, + logger: Logger, + credentials: ApiCredentials +): Promise { + const headers = new Headers(); + addAuthorizationHeader(headers, credentials); + headers.set("User-Agent", userAgent); + maybeAddTraceHeader(headers); + + const resource = `${getCloudflareApiBaseUrl(complianceConfig)}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}`; + + logger.debug(`-- START CF API REQUEST: GET ${resource}`); + logger.debug("-- END CF API REQUEST"); + + const response = await fetch(resource, { + method: "GET", + headers, + }); + if (response.ok) { + return await response.arrayBuffer(); + } else { + throw new Error( + `Failed to fetch ${resource} - ${response.status}: ${response.statusText}` + ); + } +} + +export type FetchKVGetValueFetcher = ( + complianceConfig: ComplianceConfig, + accountId: string, + namespaceId: string, + key: string +) => Promise; + export function hasCursor( result_info: unknown ): result_info is { cursor: string } { diff --git a/packages/workers-utils/src/cloudflared.ts b/packages/workers-utils/src/cloudflared.ts index d4681ae0d1..54ba029314 100644 --- a/packages/workers-utils/src/cloudflared.ts +++ b/packages/workers-utils/src/cloudflared.ts @@ -27,6 +27,7 @@ import { getCloudflaredPathFromEnv } from "./environment-variables/misc-variable import { UserError } from "./errors"; import { removeDirSync } from "./fs-helpers"; import { getGlobalWranglerConfigPath } from "./global-wrangler-config-path"; +import type { Logger } from "./logger"; import type { ChildProcess } from "node:child_process"; /** @@ -51,12 +52,6 @@ interface VersionResponse { error: string; } -export interface Logger { - log: typeof console.log; - warn: typeof console.warn; - debug: typeof console.debug; -} - const CLOUDFLARED_VERSION_PATTERN = /^\d{4}\.\d+\.\d+$/; function sha256Hex(buffer: Buffer): string { @@ -135,7 +130,7 @@ export function getAssetFilename(goOS: string, goArch: string): string { async function queryUpdateService( goOS: string, goArch: string, - options?: { logger?: Logger } + options?: { logger?: Pick } ): Promise { const { logger } = options ?? {}; const url = new URL(UPDATE_SERVICE_URL); @@ -202,7 +197,7 @@ async function queryUpdateService( * the GitHub release URL directly. */ async function getLatestVersionInfo(options?: { - logger?: Logger; + logger?: Pick; }): Promise { const { logger } = options ?? {}; const goOS = getGoOS(); @@ -280,7 +275,10 @@ function isBinaryExecutable(binPath: string): boolean { /** * Validate that a binary works correctly by running --version. */ -function validateBinary(binPath: string, options?: { logger?: Logger }): void { +function validateBinary( + binPath: string, + options?: { logger?: Pick } +): void { const { logger } = options ?? {}; try { const output = execFileSync(binPath, ["--version"], { @@ -329,7 +327,7 @@ export function redactCloudflaredArgsForLogging(args: string[]): string[] { } function tryGetCloudflaredFromPath(options?: { - logger?: Logger; + logger?: Pick; }): string | null { const { logger } = options ?? {}; if (!commandExistsSync("cloudflared")) { @@ -388,7 +386,7 @@ export function isVersionOutdated(installed: string, latest: string): boolean { */ async function warnIfOutdated( binPath: string, - options?: { logger?: Logger } + options?: { logger?: Pick } ): Promise { const { logger } = options ?? {}; try { @@ -443,7 +441,7 @@ function writeFileAtomic(filePath: string, contents: Buffer): void { async function downloadCloudflared( versionInfo: VersionResponse, binPath: string, - options?: { logger?: Logger } + options?: { logger?: Pick } ): Promise { const { logger } = options ?? {}; const { url, version, checksum, compressed } = versionInfo; @@ -609,7 +607,7 @@ async function downloadBinary( export async function getCloudflaredPath(options?: { skipVersionCheck?: boolean; confirmDownload?: (message: string) => Promise; - logger?: Logger; + logger?: Pick; }): Promise { const logger = options?.logger; // Check for environment variable override first @@ -700,7 +698,7 @@ export async function spawnCloudflared( env?: Record; skipVersionCheck?: boolean; confirmDownload?: (message: string) => Promise; - logger?: Logger; + logger?: Pick; } ): Promise { const logger = options?.logger; diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index 02f3489e71..c065627560 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -116,7 +116,8 @@ export * from "./cfetch"; export { fetchLatestNpmVersion } from "./update-check"; export type { NpmVersionCheckResult } from "./update-check"; -export type { Logger } from "./logger"; +export { LOGGER_LEVELS } from "./logger"; +export type { Logger, LoggerLevel } from "./logger"; export { retryOnAPIFailure } from "./retry"; export { formatTime } from "./format-time"; diff --git a/packages/workers-utils/src/logger.ts b/packages/workers-utils/src/logger.ts index 60698de494..42bc17ab79 100644 --- a/packages/workers-utils/src/logger.ts +++ b/packages/workers-utils/src/logger.ts @@ -1,8 +1,26 @@ +export const LOGGER_LEVELS = { + none: -1, + error: 0, + warn: 1, + info: 2, + log: 3, + debug: 4, +} as const; + +export type LoggerLevel = keyof typeof LOGGER_LEVELS; + export type Logger = { - debug: (...args: unknown[]) => void; - debugWithSanitization: (label: string, ...args: unknown[]) => void; - log: (...args: unknown[]) => void; - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; + loggerLevel?: LoggerLevel; + debug: typeof console.debug; + debugWithSanitization?: (label: string, ...args: unknown[]) => void; + log: typeof console.log; + info: typeof console.info; + warn: typeof console.warn; + error: typeof console.error; + once?: { + info: typeof console.info; + log: typeof console.log; + warn: typeof console.warn; + error: typeof console.error; + }; }; diff --git a/packages/workers-utils/src/tunnel.ts b/packages/workers-utils/src/tunnel.ts index a8a7420f77..ba3b9458fc 100644 --- a/packages/workers-utils/src/tunnel.ts +++ b/packages/workers-utils/src/tunnel.ts @@ -1,6 +1,6 @@ import { spawnCloudflared } from "./cloudflared"; import { UserError } from "./errors"; -import type { Logger } from "./cloudflared"; +import type { Logger } from "./logger"; import type { ChildProcess } from "node:child_process"; /** @@ -43,7 +43,7 @@ export interface TunnelOptions { expiryMs?: number; reminderIntervalMs?: number; extendHint?: string; - logger?: Logger; + logger?: Pick; } /** @@ -249,7 +249,7 @@ function terminateCloudflared(cloudflared: ChildProcess) { function waitForQuickTunnelReady( cloudflared: ChildProcess, timeoutMs: number, - options: { logger?: Logger; origin: URL } + options: { logger?: Pick; origin: URL } ): Promise { return new Promise((resolve, reject) => { let resolved = false; diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index 2a0e530f8c..203e6711c5 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -1,7 +1,12 @@ +import type { ApiCredentials } from "./cfetch"; import type { Config } from "./config"; import type { CustomDomainRoute, + ContainerApp, + ContainerEngine, + DurableObjectMigration, Observability, + Rule, TailConsumer, ZoneIdRoute, ZoneNameRoute, @@ -25,6 +30,7 @@ import type { CfLogfwdrBinding, CfMediaBinding, CfMTlsCertificate, + CfModule, CfPipeline, CfPlacement, CfQueue, @@ -45,8 +51,10 @@ import type { CfWorkerLoader, CfWorkflow, CfScriptFormat, + CfUnsafe, } from "./worker"; import type { AssetConfig, RouterConfig } from "@cloudflare/workers-shared"; +import type { MockAgent } from "undici"; export type Json = | string @@ -396,6 +404,197 @@ export type Binding = | { type: "assets" } | { type: "inherit" }; +export interface CfAccount { + /** + * An API token. + * + * @link https://api.cloudflare.com/#user-api-tokens-properties + */ + apiToken: ApiCredentials; + /** + * An account ID. + */ + accountId: string; +} + +export type HookValues = string | number | boolean | object | undefined | null; +export type Hook = + | T + | ((...args: Args) => T); +export type AsyncHook = + | Hook + | Hook, Args>; + +export type LogLevel = "debug" | "info" | "log" | "warn" | "error" | "none"; + +// Duplicate of Miniflare's NodeJSCompatMode to keep workers-utils from depending on Miniflare. +export type NodeJSCompatMode = "als" | "v1" | "v2" | null; + +export interface StartDevWorkerInput { + /** The name of the worker. */ + name?: string; + /** + * The javascript or typescript entry-point of the worker. + * This is the `main` property of a Wrangler configuration file. + */ + entrypoint?: string; + /** The configuration path of the worker, or a normalized configuration object. */ + config?: string | Config; + + /** The compatibility date for the workerd runtime. */ + compatibilityDate?: string; + /** The compatibility flags for the workerd runtime. */ + compatibilityFlags?: string[]; + + /** Specify the compliance region mode of the Worker. */ + complianceRegion?: Config["compliance_region"]; + + /** Configuration for Python modules. */ + pythonModules?: { + /** A list of glob patterns to exclude files from the python_modules directory when bundling. */ + exclude?: string[]; + }; + + env?: string; + + /** + * An array of paths to the .env files to load for this worker, relative to the project directory. + * + * If not specified, defaults to the standard `.env` files as given from Wrangler. + * The project directory is where the Wrangler configuration file is located or the current working directory otherwise. + */ + envFiles?: string[]; + + /** The bindings available to the worker. The specified binding type will be exposed to the worker on the `env` object under the same key. */ + bindings?: Record; + /** + * Default bindings that can be overridden by config bindings. + * Useful for injecting environment-specific defaults like CF_PAGES variables. + */ + defaultBindings?: Record>; + migrations?: DurableObjectMigration[]; + containers?: ContainerApp[]; + /** The triggers which will cause the worker's exported default handlers to be called. */ + triggers?: Trigger[]; + + tailConsumers?: CfTailConsumer[]; + streamingTailConsumers?: CfTailConsumer[]; + + /** + * Whether Wrangler should send usage metrics to Cloudflare for this project. + * + * When defined this will override any user settings. + * Otherwise, Wrangler will use the user's preference. + */ + sendMetrics?: boolean; + + /** Options applying to the worker's build step. Applies to deploy and dev. */ + build?: { + /** Whether the worker and its dependencies are bundled. Defaults to true. */ + bundle?: boolean; + + additionalModules?: CfModule[]; + + findAdditionalModules?: boolean; + processEntrypoint?: boolean; + /** Specifies types of modules matched by globs. */ + moduleRules?: Rule[]; + /** Replace global identifiers with constant expressions, e.g. { debug: 'true', version: '"1.0.0"' }. Only takes effect if bundle: true. */ + define?: Record; + /** Alias modules */ + alias?: Record; + /** Whether the bundled worker is minified. Only takes effect if bundle: true. */ + minify?: boolean; + /** Whether to keep function names after JavaScript transpilations. */ + keepNames?: boolean; + /** Options controlling a custom build step. */ + custom?: { + /** Custom shell command to run before bundling. Runs even if bundle. */ + command?: string; + /** The cwd to run the command in. */ + workingDirectory?: string; + /** Filepath(s) to watch for changes. Upon changes, the command will be rerun. */ + watch?: string | string[]; + }; + jsxFactory?: string; + jsxFragment?: string; + tsconfig?: string; + nodejsCompatMode?: Hook; + + moduleRoot?: string; + }; + + /** Options applying to the worker's development preview environment. */ + dev?: { + /** Options applying to the worker's inspector server. False disables the inspector server. */ + inspector?: { hostname?: string; port?: number; secure?: boolean } | false; + /** Whether the worker runs on the edge or locally. */ + remote?: boolean | "minimal"; + /** Cloudflare Account credentials. Can be provided upfront or as a function which will be called only when required. */ + auth?: AsyncHook]>; + /** Whether local storage (KV, Durable Objects, R2, D1, etc) is persisted. You can also specify the directory to persist data to. Set to `false` to disable persistence. */ + persist?: string | false; + /** Controls which logs are logged. */ + logLevel?: LogLevel; + /** Whether the worker server restarts upon source/config file changes. */ + watch?: boolean; + /** Whether a script tag is inserted on text/html responses which will reload the page upon file changes. Defaults to false. */ + liveReload?: boolean; + + /** The local address to reach your worker. Applies to remote: true (remote mode) and remote: false (local mode). */ + server?: { + hostname?: string; + port?: number; + secure?: boolean; + httpsKeyPath?: string; + httpsCertPath?: string; + }; + /** Controls what request.url looks like inside the worker. */ + origin?: { hostname?: string; secure?: boolean }; + /** A hook for outbound fetch calls from within the worker. */ + outboundService?: ServiceFetch; + /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ + mockFetch?: MockAgent; + + testScheduled?: boolean; + + /** Treat this as the primary worker in a multiworker setup (i.e. the first Worker in Miniflare's options) */ + multiworkerPrimary?: boolean; + /** Whether to infer the local request origin from configured routes. */ + inferOriginFromRoutes?: boolean; + + containerBuildId?: string; + /** Whether to build and connect to containers during local dev. Requires Docker daemon to be running. Defaults to true. */ + enableContainers?: boolean; + + /** Path to the dev registry directory */ + registry?: string; + + /** Path to the docker executable. Defaults to 'docker' */ + dockerPath?: string; + + /** Options for the container engine */ + containerEngine?: ContainerEngine; + + /** Re-generate your worker types when your Wrangler configuration file changes */ + generateTypes?: boolean; + + /** Tunnel configuration for this dev session. */ + tunnel?: { + enabled: boolean; + name?: string; + }; + }; + legacy?: { + site?: Hook; + useServiceEnvironments?: boolean; + }; + unsafe?: Omit; + assets?: string; + + experimental?: Record; +} + /** * An entry point for the Worker. * diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 2ede3fe4f9..be714e71a8 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -98,7 +98,6 @@ "@types/esprima": "^4.0.3", "@types/glob-to-regexp": "^0.4.1", "@types/javascript-time-ago": "^2.0.3", - "@types/json-diff": "^1.0.3", "@types/mime": "^3.0.4", "@types/minimatch": "^5.1.2", "@types/node": "catalog:default", @@ -114,17 +113,17 @@ "@webcontainer/env": "^1.1.0", "am-i-vibing": "^0.4.0", "capnweb": "catalog:default", - "chalk": "^5.2.0", + "chalk": "catalog:default", "chokidar": "^4.0.1", "ci-info": "catalog:default", "cli-table3": "^0.6.3", "cloudflare": "^5.2.0", "cmd-shim": "^4.1.0", - "command-exists": "^1.2.9", + "command-exists": "catalog:default", "concurrently": "^8.2.2", "date-fns": "^4.1.0", "devtools-protocol": "^0.0.1182435", - "dotenv": "^16.3.1", + "dotenv": "catalog:default", "dotenv-expand": "^12.0.2", "empathic": "^2.0.0", "esprima": "4.0.1", @@ -134,7 +133,6 @@ "https-proxy-agent": "7.0.2", "itty-time": "^1.0.6", "javascript-time-ago": "^2.5.4", - "json-diff": "^1.0.6", "jsonc-parser": "catalog:default", "md5-file": "5.0.0", "mime": "^3.0.0", @@ -155,7 +153,6 @@ "shell-quote": "^1.8.1", "signal-exit": "catalog:default", "smol-toml": "catalog:default", - "source-map": "^0.6.1", "supports-color": "^9.2.2", "timeago.js": "4.0.2", "tree-kill": "catalog:default", diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts index 3ed77dee18..c15b866502 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts @@ -2,7 +2,7 @@ import { assert, describe, it } from "vitest"; import { convertConfigBindingsToStartWorkerBindings, convertStartDevOptionsToBindings, -} from "../../../api/startDevWorker/utils"; +} from "../../../api/startDevWorker/binding-utils"; describe("convertConfigBindingsToStartWorkerBindings", () => { it("converts config bindings into startWorker bindings", async ({ diff --git a/packages/wrangler/src/__tests__/deploy/assets.test.ts b/packages/wrangler/src/__tests__/deploy/assets.test.ts index 0c9644b400..d12062bad4 100644 --- a/packages/wrangler/src/__tests__/deploy/assets.test.ts +++ b/packages/wrangler/src/__tests__/deploy/assets.test.ts @@ -8,7 +8,6 @@ import dedent from "ts-dedent"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -41,8 +40,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -81,9 +78,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/bindings.test.ts b/packages/wrangler/src/__tests__/deploy/bindings.test.ts index 442a342247..1aa1c587f9 100644 --- a/packages/wrangler/src/__tests__/deploy/bindings.test.ts +++ b/packages/wrangler/src/__tests__/deploy/bindings.test.ts @@ -11,7 +11,6 @@ import * as TOML from "smol-toml"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -41,8 +40,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -90,9 +87,12 @@ describe("deploy", () => { async () => { return HttpResponse.json(createFetchResult({})); } + ), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/build.test.ts b/packages/wrangler/src/__tests__/deploy/build.test.ts index 300510e0c1..fde17bfd63 100644 --- a/packages/wrangler/src/__tests__/deploy/build.test.ts +++ b/packages/wrangler/src/__tests__/deploy/build.test.ts @@ -13,7 +13,6 @@ import { afterEach, beforeEach, describe, it, test, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { printBundleSize } from "../../deployment-bundle/bundle-reporter"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { diagnoseScriptSizeError } from "../../utils/friendly-validator-errors"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; @@ -42,8 +41,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -82,9 +79,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/check-remote-secrets-override.test.ts b/packages/wrangler/src/__tests__/deploy/check-remote-secrets-override.test.ts index bc3225c32f..272ef5cdb3 100644 --- a/packages/wrangler/src/__tests__/deploy/check-remote-secrets-override.test.ts +++ b/packages/wrangler/src/__tests__/deploy/check-remote-secrets-override.test.ts @@ -1,25 +1,41 @@ -import { assert, describe, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { assert, describe, it } from "vitest"; import { checkRemoteSecretsOverride } from "../../deploy/check-remote-secrets-override"; -import { fetchSecrets } from "../../utils/fetch-secrets"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { createFetchResult, msw } from "../helpers/msw"; import type { Config } from "@cloudflare/workers-utils"; -vi.mock("../../utils/fetch-secrets"); +function mockSecrets(secrets: string[]) { + msw.use( + http.get("*/accounts/:accountId/workers/scripts/:scriptName/secrets", () => + HttpResponse.json( + createFetchResult( + secrets.map((name) => ({ name, type: "secret_text" })) + ) + ) + ) + ); +} -async function runMockedCheckRemoteSecretsOverride( +async function runCheckRemoteSecretsOverride( config: Partial, remoteSecrets: string[] ): ReturnType { - vi.mocked(fetchSecrets).mockResolvedValue( - remoteSecrets.map((secret) => ({ name: secret, type: "secret_text" })) - ); - return checkRemoteSecretsOverride(config as Config); + mockSecrets(remoteSecrets); + return checkRemoteSecretsOverride({ + ...config, + name: "test-script", + } as Config); } describe("checkRemoteSecretsOverride", () => { + mockAccountId(); + mockApiToken(); + it("should return { override: false } when there are no possible overrides", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_VAR: "var", @@ -39,7 +55,7 @@ describe("checkRemoteSecretsOverride", () => { it("should detect and provide a valid deploy error message when a variable name overrides a secret", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_VAR: "var", @@ -64,7 +80,7 @@ describe("checkRemoteSecretsOverride", () => { it("should detect and provide a valid deploy error message when multiple (2) variable names override secrets", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_VAR: "var", @@ -89,7 +105,7 @@ describe("checkRemoteSecretsOverride", () => { it("should detect and provide a valid deploy error message when multiple (3) variable names override secrets", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_VAR: "var", @@ -115,7 +131,7 @@ describe("checkRemoteSecretsOverride", () => { it("should detect and provide a valid deploy error message when a binding name overrides a secret", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_VAR: "var", @@ -142,7 +158,7 @@ describe("checkRemoteSecretsOverride", () => { it("should detect and provide a valid deploy error message when multiple binding names override secrets", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_VAR: "var", @@ -173,7 +189,7 @@ describe("checkRemoteSecretsOverride", () => { it("should detect and provide a valid deploy error message when a combination of variables and binding names override secrets", async ({ expect, }) => { - const checkResult = await runMockedCheckRemoteSecretsOverride( + const checkResult = await runCheckRemoteSecretsOverride( { vars: { MY_SECRET_1: "var", @@ -197,8 +213,7 @@ describe("checkRemoteSecretsOverride", () => { it("should not unnecessarily fetch secrets when there are no env vars nor bindings in the config file", async ({ expect, }) => { - const result = await runMockedCheckRemoteSecretsOverride({}, ["MY_SECRET"]); + const result = await runCheckRemoteSecretsOverride({}, ["MY_SECRET"]); expect(result.override).toBeFalsy(); - expect(fetchSecrets).not.toHaveBeenCalled(); }); }); diff --git a/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts b/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts index 7a67f6275b..3247a6d8ae 100644 --- a/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts +++ b/packages/wrangler/src/__tests__/deploy/check-workflow-conflicts.test.ts @@ -1,49 +1,49 @@ -import { APIError } from "@cloudflare/workers-utils"; -import { describe, it, vi } from "vitest"; -import { fetchResult } from "../../cfetch"; +import { http, HttpResponse } from "msw"; +import { describe, it } from "vitest"; import { checkWorkflowConflicts, WORKFLOW_NOT_FOUND_CODE, } from "../../deploy/check-workflow-conflicts"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { createFetchResult, msw } from "../helpers/msw"; import type { WorkflowConflict } from "../../deploy/check-workflow-conflicts"; import type { Workflow } from "../../workflows/types"; import type { Config } from "@cloudflare/workers-utils"; -vi.mock("../../cfetch"); - function mockWorkflowGet(workflowsByName: Record) { - vi.mocked(fetchResult).mockImplementation( - async (_config, resource: string) => { - const match = resource.match(/\/accounts\/[^/]+\/workflows\/(.+)$/); - if (match) { - const workflowName = match[1]; - const workflow = workflowsByName[workflowName]; - if (workflow === null || workflow === undefined) { - const error = new APIError({ - text: "Workflow not found", - telemetryMessage: false, - }); - error.code = WORKFLOW_NOT_FOUND_CODE; - throw error; - } - return workflow; + msw.use( + http.get("*/accounts/:accountId/workflows/:workflowName", ({ params }) => { + const workflowName = params["workflowName"] as string; + const workflow = workflowsByName[workflowName]; + if (workflow === null || workflow === undefined) { + return HttpResponse.json( + createFetchResult(null, false, [ + { + code: WORKFLOW_NOT_FOUND_CODE, + message: "Workflow not found", + }, + ]), + { status: 404 } + ); } - throw new Error(`Unexpected resource: ${resource}`); - } + return HttpResponse.json(createFetchResult(workflow)); + }) ); } describe("checkWorkflowConflicts", () => { + mockAccountId(); + mockApiToken(); + it("should return { hasConflicts: false } when there are no workflows in config", async ({ expect, }) => { const result = await checkWorkflowConflicts( { workflows: [] } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(false); - expect(fetchResult).not.toHaveBeenCalled(); }); it("should return { hasConflicts: false } when workflows is undefined", async ({ @@ -51,11 +51,10 @@ describe("checkWorkflowConflicts", () => { }) => { const result = await checkWorkflowConflicts( {} as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(false); - expect(fetchResult).not.toHaveBeenCalled(); }); it("should return { hasConflicts: false } when workflow does not exist yet", async ({ @@ -68,7 +67,7 @@ describe("checkWorkflowConflicts", () => { { binding: "WF", name: "my-workflow", class_name: "MyWorkflow" }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(false); @@ -93,7 +92,7 @@ describe("checkWorkflowConflicts", () => { { binding: "WF", name: "my-workflow", class_name: "MyWorkflow" }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(false); @@ -118,7 +117,7 @@ describe("checkWorkflowConflicts", () => { { binding: "WF", name: "my-workflow", class_name: "MyWorkflow" }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(true); @@ -163,7 +162,7 @@ describe("checkWorkflowConflicts", () => { { binding: "WF2", name: "workflow-b", class_name: "B" }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(true); @@ -182,8 +181,6 @@ describe("checkWorkflowConflicts", () => { it("should skip workflows that bind to another script", async ({ expect, }) => { - // This workflow exists and belongs to other-worker, but we're not deploying it - // (it has script_name pointing to the external worker) mockWorkflowGet({ "external-workflow": { id: "1", @@ -205,13 +202,10 @@ describe("checkWorkflowConflicts", () => { }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); - // This workflow has script_name set to another worker, so it's not being deployed by us expect(result.hasConflicts).toBe(false); - // fetchResult should not have been called because the workflow is filtered out - expect(fetchResult).not.toHaveBeenCalled(); }); it("should only flag workflows being deployed by this script", async ({ @@ -226,14 +220,11 @@ describe("checkWorkflowConflicts", () => { created_on: "", modified_on: "", }, - // external-workflow won't be queried because it's filtered out }); const result = await checkWorkflowConflicts( { workflows: [ - // This one will be deployed by us (no script_name) { binding: "WF1", name: "local-workflow", class_name: "A" }, - // This one is external (script_name points to another worker) { binding: "WF2", name: "external-workflow", @@ -242,7 +233,7 @@ describe("checkWorkflowConflicts", () => { }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(true); @@ -251,7 +242,6 @@ describe("checkWorkflowConflicts", () => { conflicts: WorkflowConflict[]; message: string; }; - // Only the local workflow should be flagged as a conflict expect(conflicts).toHaveLength(1); expect(conflicts[0].name).toBe("local-workflow"); }); @@ -275,7 +265,7 @@ describe("checkWorkflowConflicts", () => { { binding: "WF", name: "my-workflow", class_name: "MyWorkflow" }, ], } as unknown as Config, - "account-id", + "some-account-id", "my-worker" ); expect(result.hasConflicts).toBe(true); diff --git a/packages/wrangler/src/__tests__/deploy/config-args-merging.test.ts b/packages/wrangler/src/__tests__/deploy/config-args-merging.test.ts index dc7b8efb64..854e2068d0 100644 --- a/packages/wrangler/src/__tests__/deploy/config-args-merging.test.ts +++ b/packages/wrangler/src/__tests__/deploy/config-args-merging.test.ts @@ -18,7 +18,6 @@ import { import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -55,7 +54,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }, }; }); -vi.mock("../../utils/fetch-secrets"); vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -82,9 +80,11 @@ function setupDeployMocks() { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get("*/accounts/:accountId/workers/scripts/:scriptName/secrets", () => + HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); } /** Mock the GET /workers/services/:name endpoint for versions upload */ diff --git a/packages/wrangler/src/__tests__/deploy/config-remote.test.ts b/packages/wrangler/src/__tests__/deploy/config-remote.test.ts index eb4dc91a84..f4d053b172 100644 --- a/packages/wrangler/src/__tests__/deploy/config-remote.test.ts +++ b/packages/wrangler/src/__tests__/deploy/config-remote.test.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs"; -import { APIError } from "@cloudflare/workers-utils"; import { normalizeString, runInTempDir, @@ -9,7 +8,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; @@ -50,8 +48,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -89,9 +85,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); @@ -538,17 +537,22 @@ describe("deploy", () => { }, } as unknown as ServiceMetadataRes["default_environment"]); - vi.mocked(fetchSecrets).mockResolvedValue([ - { name: "MY_SECRET", type: "secret_text" }, - ]); + msw.use( + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => + HttpResponse.json( + createFetchResult([{ name: "MY_SECRET", type: "secret_text" }]) + ), + { once: true } + ) + ); mockConfirm({ text: "Would you like to continue?", result: true, }); await runWrangler("deploy"); - - expect(fetchSecrets).toHaveBeenCalled(); expect(normalizeLogWithConfigDiff(std.warn)).toMatchInlineSnapshot(` "β–² [WARNING] Environment variable \`MY_SECRET\` conflicts with an existing remote secret. This deployment will replace the remote secret with your environment variable. @@ -579,23 +583,19 @@ describe("deploy", () => { msw.use( http.get( `*/accounts/:accountId/workers/scripts/:scriptName/secrets`, - () => { - const workerNotFoundAPIError = new APIError({ - status: 404, - text: "A request to the Cloudflare API (/accounts/xxx/workers/scripts/yyy/secrets) failed.", - telemetryMessage: false, - }); - - workerNotFoundAPIError.code = 10007; - throw workerNotFoundAPIError; - }, + () => + HttpResponse.json( + createFetchResult(null, false, [ + { code: 10007, message: "workers.api.error.not_found" }, + ]), + { status: 404 } + ), { once: true } ) ); await runWrangler("deploy"); - expect(fetchSecrets).toHaveBeenCalled(); expect(std.warn).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` " @@ -635,13 +635,8 @@ describe("deploy", () => { // Note: we don't set any mocks here since in dry-run we don't expect wragnler to interact // with the rest API in any way - vi.mocked(fetchSecrets).mockResolvedValue([ - { name: "MY_SECRET", type: "secret_text" }, - ]); - await runWrangler("deploy --dry-run"); - expect(fetchSecrets).not.toHaveBeenCalled(); expect(normalizeLogWithConfigDiff(std.warn)).toMatchInlineSnapshot(`""`); }); @@ -687,14 +682,19 @@ describe("deploy", () => { }, } as unknown as ServiceMetadataRes["default_environment"]); - vi.mocked(fetchSecrets).mockResolvedValue([ - { name: "MY_SECRET", type: "secret_text" }, - ]); + msw.use( + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => + HttpResponse.json( + createFetchResult([{ name: "MY_SECRET", type: "secret_text" }]) + ), + { once: true } + ) + ); await runWrangler("deploy --strict"); - expect(fetchSecrets).toHaveBeenCalled(); - expect(normalizeLogWithConfigDiff(std.warn)).toMatchInlineSnapshot(` "β–² [WARNING] Environment variable \`MY_SECRET\` conflicts with an existing remote secret. This deployment will replace the remote secret with your environment variable. diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index 1b874ecd4b..0f4ea1fd99 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -16,7 +16,6 @@ import { runAutoConfig } from "../../autoconfig/run"; import { clearOutputFilePath } from "../../output"; import { NpmPackageManager } from "../../package-manager"; import { writeAuthConfigFile } from "../../user"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockAuthDomain } from "../helpers/mock-auth-domain"; import { mockConsoleMethods } from "../helpers/mock-console"; @@ -64,8 +63,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -109,9 +106,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts b/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts index 444cff2723..1b4b21f214 100644 --- a/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts +++ b/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts @@ -7,7 +7,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm, mockPrompt } from "../helpers/mock-dialogs"; @@ -33,8 +32,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -72,7 +69,6 @@ describe("deploy: interactive deploy config prompts", () => { return HttpResponse.json(createFetchResult({})); }) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/durable-objects.test.ts b/packages/wrangler/src/__tests__/deploy/durable-objects.test.ts index 935bad9cca..651a091b14 100644 --- a/packages/wrangler/src/__tests__/deploy/durable-objects.test.ts +++ b/packages/wrangler/src/__tests__/deploy/durable-objects.test.ts @@ -7,7 +7,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -36,8 +35,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -76,9 +73,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); @@ -436,6 +436,12 @@ describe("deploy", () => { "index.js", `export class SomeClass{}; export class SomeOtherClass{}; export default {};` ); + msw.use( + http.get( + "*/accounts/:accountId/workers/services/:scriptName/environments/:envName/secrets", + () => HttpResponse.json(createFetchResult([]), { status: 200 }) + ) + ); mockSubDomainRequest(); mockServiceScriptData({ env: "xyz" }); // no scripts at all mockUploadWorkerRequest({ @@ -578,10 +584,18 @@ describe("deploy", () => { `export class SomeClass{}; export class SomeOtherClass{}; export default {};` ); mockSubDomainRequest(); + mockLastDeploymentRequest(); mockServiceScriptData({ script: { id: "test-name", migration_tag: "v1" }, env: "xyz", }); + msw.use( + http.get( + "*/accounts/:accountId/workers/services/:scriptName/environments/:envName/secrets", + () => HttpResponse.json(createFetchResult([]), { status: 200 }), + { once: true } + ) + ); mockUploadWorkerRequest({ useServiceEnvironments: true, env: "xyz", diff --git a/packages/wrangler/src/__tests__/deploy/entry-points.test.ts b/packages/wrangler/src/__tests__/deploy/entry-points.test.ts index 3405328c14..f981ec791d 100644 --- a/packages/wrangler/src/__tests__/deploy/entry-points.test.ts +++ b/packages/wrangler/src/__tests__/deploy/entry-points.test.ts @@ -12,7 +12,6 @@ import dedent from "ts-dedent"; import { afterEach, assert, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm, mockPrompt } from "../helpers/mock-dialogs"; @@ -48,8 +47,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -88,9 +85,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/environments.test.ts b/packages/wrangler/src/__tests__/deploy/environments.test.ts index e1e4b53912..d0e315dcce 100644 --- a/packages/wrangler/src/__tests__/deploy/environments.test.ts +++ b/packages/wrangler/src/__tests__/deploy/environments.test.ts @@ -8,7 +8,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, test, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -45,8 +44,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -86,9 +83,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/formats.test.ts b/packages/wrangler/src/__tests__/deploy/formats.test.ts index 3a731da748..81af5d4ee7 100644 --- a/packages/wrangler/src/__tests__/deploy/formats.test.ts +++ b/packages/wrangler/src/__tests__/deploy/formats.test.ts @@ -11,7 +11,6 @@ import dedent from "ts-dedent"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -39,8 +38,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -79,9 +76,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts b/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts index 0811bc56f2..ba8b9c5117 100644 --- a/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts +++ b/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts @@ -7,7 +7,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -43,8 +42,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -83,9 +80,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/open-next.test.ts b/packages/wrangler/src/__tests__/deploy/open-next.test.ts index 5fadc5f7a4..256c560f5a 100644 --- a/packages/wrangler/src/__tests__/deploy/open-next.test.ts +++ b/packages/wrangler/src/__tests__/deploy/open-next.test.ts @@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getDetailsForAutoConfig } from "../../autoconfig/details"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -47,8 +46,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -88,9 +85,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); vi.mocked(getDetailsForAutoConfig).mockResolvedValue({ configured: true, diff --git a/packages/wrangler/src/__tests__/deploy/queues.test.ts b/packages/wrangler/src/__tests__/deploy/queues.test.ts index cf7fe31543..8c26220e5f 100644 --- a/packages/wrangler/src/__tests__/deploy/queues.test.ts +++ b/packages/wrangler/src/__tests__/deploy/queues.test.ts @@ -7,7 +7,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -39,8 +38,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -79,9 +76,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/routes.test.ts b/packages/wrangler/src/__tests__/deploy/routes.test.ts index 9d99e4348e..dc665df972 100644 --- a/packages/wrangler/src/__tests__/deploy/routes.test.ts +++ b/packages/wrangler/src/__tests__/deploy/routes.test.ts @@ -6,7 +6,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; @@ -56,8 +55,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -96,9 +93,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/secrets.test.ts b/packages/wrangler/src/__tests__/deploy/secrets.test.ts index 9f1e5ddfe4..bb469b6d3f 100644 --- a/packages/wrangler/src/__tests__/deploy/secrets.test.ts +++ b/packages/wrangler/src/__tests__/deploy/secrets.test.ts @@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; import { INVALID_INHERIT_BINDING_CODE } from "../../utils/error-codes"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs } from "../helpers/mock-dialogs"; @@ -37,8 +36,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -76,9 +73,12 @@ describe("deploy secrets", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); writeWranglerConfig({ diff --git a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts index 1dfc4ac3d0..b06b579895 100644 --- a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts @@ -11,7 +11,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; @@ -46,8 +45,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -86,9 +83,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/deploy/workflows.test.ts b/packages/wrangler/src/__tests__/deploy/workflows.test.ts index 12fc48862e..0b0e947f6b 100644 --- a/packages/wrangler/src/__tests__/deploy/workflows.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workflows.test.ts @@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { WORKFLOW_NOT_FOUND_CODE } from "../../deploy/check-workflow-conflicts"; import { clearOutputFilePath } from "../../output"; -import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; @@ -36,8 +35,6 @@ vi.mock("../../check/commands", async (importOriginal) => { }; }); -vi.mock("../../utils/fetch-secrets"); - vi.mock("../../package-manager", async (importOriginal) => ({ ...(await importOriginal()), sniffUserAgent: () => "npm", @@ -76,9 +73,12 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); - vi.mocked(fetchSecrets).mockResolvedValue([]); vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); }); diff --git a/packages/wrangler/src/__tests__/friendly-validator-errors.test.ts b/packages/wrangler/src/__tests__/friendly-validator-errors.test.ts index 6a2163e536..123e733614 100644 --- a/packages/wrangler/src/__tests__/friendly-validator-errors.test.ts +++ b/packages/wrangler/src/__tests__/friendly-validator-errors.test.ts @@ -34,7 +34,8 @@ describe("helpIfErrorIsSizeOrScriptStartup", () => { makeStartupError("Script startup exceeded CPU limit."), {}, // no dependencies new FormData(), // mock worker bundle - "/test" + "/test", + mockAnalyseBundle ) ).toMatchInlineSnapshot(` "Your Worker failed validation because it exceeded startup limits. @@ -68,7 +69,8 @@ describe("helpIfErrorIsSizeOrScriptStartup", () => { makeScriptSizeError("Script size exceeded limits."), { "test.js": { bytesInOutput: 1000 } }, // mock dependencies new FormData(), // mock worker bundle - "/test" + "/test", + mockAnalyseBundle ) ).toMatchInlineSnapshot(` "Your Worker failed validation because it exceeded size limits. @@ -102,7 +104,8 @@ describe("helpIfErrorIsSizeOrScriptStartup", () => { makeStartupError("Exceeded startup limits."), {}, // no dependencies new FormData(), // mock worker bundle - process.cwd() // mock project root (the tmp dir) + process.cwd(), // mock project root (the tmp dir) + mockAnalyseBundle ); expect(normalizeString(message ?? "")).toMatchInlineSnapshot(` diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index 650e8839e6..c41692c502 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -6,7 +6,7 @@ import { writeWranglerConfig, } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; -import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, it } from "vitest"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { clearDialogs, mockPrompt, mockSelect } from "./helpers/mock-dialogs"; @@ -29,10 +29,6 @@ import { writeWorkerSource } from "./helpers/write-worker-source"; import type { DatabaseInfo } from "../d1/types"; import type { ExpectStatic } from "vitest"; -vi.mock("../utils/fetch-secrets", () => ({ - fetchSecrets: async () => [], -})); - describe("resource provisioning", () => { const std = mockConsoleMethods(); mockAccountId(); @@ -44,7 +40,11 @@ describe("resource provisioning", () => { setIsTTY(true); msw.use( ...mswSuccessDeploymentScriptMetadata, - ...mswListNewDeploymentsLatestFull + ...mswListNewDeploymentsLatestFull, + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/secrets", + () => HttpResponse.json(createFetchResult([])) + ) ); mockSubDomainRequest(); writeWorkerSource(); diff --git a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts index b36e223398..64123d6263 100644 --- a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts @@ -1,4 +1,6 @@ +import assert from "node:assert"; import * as fs from "node:fs"; +import { generatePreviewAlias } from "@cloudflare/deploy-helpers"; import { runInTempDir, writeRedirectedWranglerConfig, @@ -9,9 +11,8 @@ import { http, HttpResponse } from "msw"; * Uses assert/expect in MSW handlers and top-level mock setup * TODO: remove this `expect` import */ -import { assert, beforeEach, describe, expect, it, test, vi } from "vitest"; +import { beforeEach, describe, expect, it, test, vi } from "vitest"; import { dedent } from "../../utils/dedent"; -import { generatePreviewAlias } from "../../versions/upload"; import { makeApiRequestAsserter } from "../helpers/assert-request"; import { captureRequestsFrom } from "../helpers/capture-requests-from"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; diff --git a/packages/wrangler/src/__tests__/vitest.setup.ts b/packages/wrangler/src/__tests__/vitest.setup.ts index 2a78aaa665..485225d939 100644 --- a/packages/wrangler/src/__tests__/vitest.setup.ts +++ b/packages/wrangler/src/__tests__/vitest.setup.ts @@ -1,12 +1,33 @@ import { PassThrough } from "node:stream"; +import { initDeployHelpersContext } from "@cloudflare/deploy-helpers/context"; import chalk from "chalk"; import { passthrough } from "msw"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; +import { + fetchKVGetValue, + fetchResult, + fetchListResult, + fetchPagedListResult, +} from "../cfetch"; +import { confirm, prompt } from "../dialogs"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; import { msw } from "./helpers/msw"; //turn off chalk for tests due to inconsistencies between operating systems chalk.level = 0; +initDeployHelpersContext({ + logger, + fetchResult, + fetchListResult, + fetchPagedListResult, + fetchKVGetValue, + confirm, + prompt, + isNonInteractiveOrCI, +}); + // In general we don't want the ConfigController to watch the config files // as this tends to make the tests flaky. // eslint-disable-next-line turbo/no-undeclared-env-vars -- Test-only env var to prevent flaky config file watching diff --git a/packages/wrangler/src/__tests__/zones.test.ts b/packages/wrangler/src/__tests__/zones.test.ts index fa04874860..b88c60e465 100644 --- a/packages/wrangler/src/__tests__/zones.test.ts +++ b/packages/wrangler/src/__tests__/zones.test.ts @@ -6,7 +6,6 @@ import { http, HttpResponse } from "msw"; * TODO: remove this `expect` import */ import { describe, expect, it, test } from "vitest"; -import { createDeployHelpersContext } from "../core/deploy-helpers-context"; import { getHostFromUrl, getZoneFromRoute } from "../zones"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { msw } from "./helpers/msw"; @@ -83,14 +82,10 @@ describe("Zones", () => { test("string route", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: "example.com/*", - accountId: "some-account-id", - }, - createDeployHelpersContext() - ) + await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: "example.com/*", + accountId: "some-account-id", + }) ).toEqual({ host: "example.com", id: "example-id", @@ -100,14 +95,10 @@ describe("Zones", () => { test("string route (not a zone)", async () => { mockGetZones("wrong.com", []); await expect( - getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: "wrong.com/*", - accountId: "some-account-id", - }, - createDeployHelpersContext() - ) + getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: "wrong.com/*", + accountId: "some-account-id", + }) ).rejects.toMatchInlineSnapshot(` [Error: Could not find zone for \`wrong.com\`. Make sure the domain is set up to be proxied by Cloudflare. For more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route] @@ -118,14 +109,10 @@ describe("Zones", () => { // when a zone_id is provided in the route mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: { pattern: "example.com/*", zone_id: "other-id" }, - accountId: "some-account-id", - }, - createDeployHelpersContext() - ) + await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: { pattern: "example.com/*", zone_id: "other-id" }, + accountId: "some-account-id", + }) ).toEqual({ host: "example.com", id: "other-id", @@ -136,17 +123,13 @@ describe("Zones", () => { // when a zone_id is provided in the route mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: { - pattern: "some.third-party.com/*", - zone_id: "other-id", - }, - accountId: "some-account-id", + await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: { + pattern: "some.third-party.com/*", + zone_id: "other-id", }, - createDeployHelpersContext() - ) + accountId: "some-account-id", + }) ).toEqual({ host: "some.third-party.com", id: "other-id", @@ -156,17 +139,13 @@ describe("Zones", () => { test("zone_name route (apex)", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: { - pattern: "example.com/*", - zone_name: "example.com", - }, - accountId: "some-account-id", + await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: { + pattern: "example.com/*", + zone_name: "example.com", }, - createDeployHelpersContext() - ) + accountId: "some-account-id", + }) ).toEqual({ host: "example.com", id: "example-id", @@ -175,17 +154,13 @@ describe("Zones", () => { test("zone_name route (subdomain)", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: { - pattern: "subdomain.example.com/*", - zone_name: "example.com", - }, - accountId: "some-account-id", + await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: { + pattern: "subdomain.example.com/*", + zone_name: "example.com", }, - createDeployHelpersContext() - ) + accountId: "some-account-id", + }) ).toEqual({ host: "subdomain.example.com", id: "example-id", @@ -194,17 +169,13 @@ describe("Zones", () => { test("zone_name route (custom hostname)", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - { - route: { - pattern: "some.third-party.com/*", - zone_name: "example.com", - }, - accountId: "some-account-id", + await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { + route: { + pattern: "some.third-party.com/*", + zone_name: "example.com", }, - createDeployHelpersContext() - ) + accountId: "some-account-id", + }) ).toEqual({ host: "some.third-party.com", id: "example-id", @@ -308,7 +279,6 @@ describe("Zones", () => { }, accountId: "some-account-id", }, - createDeployHelpersContext(), zoneIdCache ) ).toEqual({ @@ -332,7 +302,6 @@ describe("Zones", () => { }, accountId: "some-account-id", }, - createDeployHelpersContext(), zoneIdCache ) ).toEqual({ diff --git a/packages/wrangler/src/api/deploy-helpers-context.ts b/packages/wrangler/src/api/deploy-helpers-context.ts new file mode 100644 index 0000000000..5aead6616e --- /dev/null +++ b/packages/wrangler/src/api/deploy-helpers-context.ts @@ -0,0 +1,23 @@ +import { initDeployHelpersContext } from "@cloudflare/deploy-helpers/context"; +import { + fetchKVGetValue, + fetchListResult, + fetchPagedListResult, + fetchResult, +} from "../cfetch"; +import { confirm, prompt } from "../dialogs"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; + +export function initApiDeployHelpersContext(): void { + initDeployHelpersContext({ + logger, + fetchResult, + fetchListResult, + fetchPagedListResult, + fetchKVGetValue, + confirm, + prompt, + isNonInteractiveOrCI, + }); +} diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts index 7a306af033..011ad58c85 100644 --- a/packages/wrangler/src/api/index.ts +++ b/packages/wrangler/src/api/index.ts @@ -1,3 +1,7 @@ +import { initApiDeployHelpersContext } from "./deploy-helpers-context"; + +initApiDeployHelpersContext(); + export { unstable_dev } from "./dev"; export type { Unstable_DevWorker, Unstable_DevOptions } from "./dev"; export { unstable_pages } from "./pages"; @@ -17,7 +21,7 @@ export { } from "./mtls-certificate"; // Exports from ./startDevWorker -export { convertConfigBindingsToStartWorkerBindings } from "./startDevWorker/utils"; +export { convertConfigBindingsToStartWorkerBindings } from "./startDevWorker/binding-utils"; export { DevEnv } from "./startDevWorker/DevEnv"; export { startWorker } from "./startDevWorker"; export type { diff --git a/packages/wrangler/src/api/integrations/platform/index.ts b/packages/wrangler/src/api/integrations/platform/index.ts index 0a2da465de..e6ac1d83e9 100644 --- a/packages/wrangler/src/api/integrations/platform/index.ts +++ b/packages/wrangler/src/api/integrations/platform/index.ts @@ -1,4 +1,5 @@ import { resolveDockerHost } from "@cloudflare/containers-shared"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; import { getDockerPath, getRegistryPath, @@ -21,7 +22,6 @@ import { getSiteAssetPaths } from "../../../sites"; import { dedent } from "../../../utils/dedent"; import { getZoneFromRoute } from "../../../zones"; import { maybeStartOrUpdateRemoteProxySession } from "../../remoteBindings"; -import { extractBindingsOfType } from "../../startDevWorker/utils"; import { CacheStorage } from "./caches"; import { ExecutionContext } from "./executionContext"; // TODO: import from `@cloudflare/workers-utils` after migrating to `tsdown` diff --git a/packages/wrangler/src/api/startDevWorker/BundlerController.ts b/packages/wrangler/src/api/startDevWorker/BundlerController.ts index 9256d8a872..3e9ca97e94 100644 --- a/packages/wrangler/src/api/startDevWorker/BundlerController.ts +++ b/packages/wrangler/src/api/startDevWorker/BundlerController.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { readFileSync, realpathSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; import { getWranglerTmpDir } from "@cloudflare/workers-utils"; import { watch } from "chokidar"; import { bundleWorker, shouldCheckFetch } from "../../deployment-bundle/bundle"; @@ -18,7 +19,6 @@ import { isNavigatorDefined } from "../../navigator-user-agent"; import { debounce } from "../../utils/debounce"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; -import { extractBindingsOfType } from "./utils"; import type { BundleResult } from "../../deployment-bundle/bundle"; import type { EsbuildBundle } from "../../dev/use-esbuild"; import type { ConfigUpdateEvent } from "./events"; diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 4501074ed0..bae479b536 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import path from "node:path"; import { resolveDockerHost } from "@cloudflare/containers-shared"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; import { configFileName, formatConfigSnippet, @@ -20,6 +21,7 @@ import { getEntry } from "../../deployment-bundle/entry"; import { getBindings, getHostAndRoutes, getInferredHost } from "../../dev"; import { getDurableObjectClassNameToUseSQLiteMap } from "../../dev/class-names-sqlite"; import { getLocalPersistencePath } from "../../dev/get-local-persistence-path"; +import { getFlag } from "../../experimental-flags"; import { logger, runWithLogLevel } from "../../logger"; import { checkTypesDiff } from "../../type-generation/helpers"; import { @@ -39,12 +41,13 @@ import { useServiceEnvironments } from "../../utils/useServiceEnvironments"; import { getZoneIdForPreview } from "../../zones"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; -import { extractBindingsOfType, unwrapHook } from "./utils"; +import { unwrapHook } from "./utils"; import type { DevRegistryUpdateEvent } from "./events"; import type { StartDevWorkerInput, StartDevWorkerOptions, Trigger, + WranglerStartDevWorkerInput, } from "./types"; import type { CfUnsafe, Config } from "@cloudflare/workers-utils"; import type { WorkerRegistry } from "miniflare"; @@ -54,7 +57,7 @@ const getLocalPort = memoizeGetPort(DEFAULT_LOCAL_PORT, "localhost"); async function resolveInspectorConfig( config: Config, - input: StartDevWorkerInput + input: WranglerStartDevWorkerInput ): Promise { if (input.dev?.inspector === false) { return false; @@ -73,7 +76,7 @@ async function resolveInspectorConfig( async function resolveDevConfig( config: Config, - input: StartDevWorkerInput + input: WranglerStartDevWorkerInput ): Promise { const auth = async () => { if (input.dev?.remote) { @@ -206,6 +209,7 @@ async function resolveBindings( { registry, local: !input.dev?.remote, + isMultiWorker: getFlag("MULTIWORKER"), remoteBindingsDisabled: input.dev?.remote === false, name: config.name, } diff --git a/packages/wrangler/src/api/startDevWorker/DevEnv.ts b/packages/wrangler/src/api/startDevWorker/DevEnv.ts index 2757c816e9..2d47e0568e 100644 --- a/packages/wrangler/src/api/startDevWorker/DevEnv.ts +++ b/packages/wrangler/src/api/startDevWorker/DevEnv.ts @@ -1,11 +1,20 @@ import assert from "node:assert"; import { EventEmitter } from "node:events"; +import { initDeployHelpersContext } from "@cloudflare/deploy-helpers/context"; import { ParseError, UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; +import { + fetchKVGetValue, + fetchListResult, + fetchPagedListResult, + fetchResult, +} from "../../cfetch"; import { isBuildFailure, isBuildFailureFromCause, } from "../../deployment-bundle/build-failures"; +import { confirm, prompt } from "../../dialogs"; +import { isNonInteractiveOrCI } from "../../is-interactive"; import { logBuildFailure, logger, runWithLogLevel } from "../../logger"; import { BundlerController } from "./BundlerController"; import { ConfigController } from "./ConfigController"; @@ -30,6 +39,17 @@ export class DevEnv extends EventEmitter implements ControllerBus { proxy: ProxyController; async startWorker(options: StartDevWorkerInput): Promise { + initDeployHelpersContext({ + logger, + fetchResult, + fetchListResult, + fetchPagedListResult, + fetchKVGetValue, + confirm, + prompt, + isNonInteractiveOrCI, + }); + const worker = createWorkerObject(this); try { diff --git a/packages/wrangler/src/api/startDevWorker/binding-utils.ts b/packages/wrangler/src/api/startDevWorker/binding-utils.ts new file mode 100644 index 0000000000..35ca2c42ae --- /dev/null +++ b/packages/wrangler/src/api/startDevWorker/binding-utils.ts @@ -0,0 +1,54 @@ +import { convertConfigToBindings } from "@cloudflare/deploy-helpers"; +import type { StartDevWorkerOptions } from "."; +import type { AdditionalDevProps } from "../../dev"; +import type { Config, ConfigBindingFieldName } from "@cloudflare/workers-utils"; + +export function convertConfigBindingsToStartWorkerBindings( + configBindings: Partial> +): StartDevWorkerOptions["bindings"] { + return convertConfigToBindings(configBindings, { + usePreviewIds: true, + }); +} + +/** + * Bindings that can be passed via the StartDevOptions CLI interface. + */ +export type StartDevOptionsBindings = Pick< + AdditionalDevProps, + | "vars" + | "kv" + | "durableObjects" + | "services" + | "r2" + | "ai" + | "stream" + | "version_metadata" + | "d1Databases" +>; + +/** + * Convert StartDevOptions bindings to the flat StartDevWorkerInput["bindings"] format. + */ +export function convertStartDevOptionsToBindings( + inputBindings: StartDevOptionsBindings +): StartDevWorkerOptions["bindings"] { + // Map StartDevOptionsBindings field names to Config field names + const configBindings = { + vars: inputBindings.vars, + kv_namespaces: inputBindings.kv, + durable_objects: inputBindings.durableObjects + ? { bindings: inputBindings.durableObjects } + : undefined, + services: inputBindings.services, + r2_buckets: inputBindings.r2, + ai: inputBindings.ai, + stream: inputBindings.stream, + version_metadata: inputBindings.version_metadata, + d1_databases: inputBindings.d1Databases, + }; + + return convertConfigToBindings(configBindings, { + usePreviewIds: true, + }); +} diff --git a/packages/wrangler/src/api/startDevWorker/bundle-allowed-paths.ts b/packages/wrangler/src/api/startDevWorker/bundle-allowed-paths.ts index 2fa82733e4..6de5f381e8 100644 --- a/packages/wrangler/src/api/startDevWorker/bundle-allowed-paths.ts +++ b/packages/wrangler/src/api/startDevWorker/bundle-allowed-paths.ts @@ -10,7 +10,11 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { EsbuildBundle } from "../../dev/use-esbuild"; -import type { RawSourceMap } from "source-map"; +interface RawSourceMap { + file?: string; + sourceRoot?: string; + sources: string[]; +} export function isAllowedSourcePath( bundle: EsbuildBundle, diff --git a/packages/wrangler/src/api/startDevWorker/index.ts b/packages/wrangler/src/api/startDevWorker/index.ts index 20c28c6f9b..4f166ce84f 100644 --- a/packages/wrangler/src/api/startDevWorker/index.ts +++ b/packages/wrangler/src/api/startDevWorker/index.ts @@ -1,7 +1,7 @@ import { DevEnv } from "./DevEnv"; import type { StartDevWorkerInput, Worker } from "./types"; -export { convertConfigBindingsToStartWorkerBindings } from "./utils"; +export { convertConfigBindingsToStartWorkerBindings } from "./binding-utils"; export { DevEnv }; export * from "./types"; diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index e44bf976be..0f6af34c1d 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -1,34 +1,44 @@ -import type { CfAccount } from "../../dev/create-worker-preview"; import type { EsbuildBundle } from "../../dev/use-esbuild"; import type { ConfigController } from "./ConfigController"; import type { DevEnv } from "./DevEnv"; import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { + AsyncHook, AssetsOptions, BinaryFile, Binding, + CfAccount, CfModule, CfScriptFormat, - CfTailConsumer, - CfUnsafe, Config, - ContainerApp, - ContainerEngine, - DurableObjectMigration, File, + Hook, + HookValues, + LogLevel, + NodeJSCompatMode, Rule, ServiceFetch, + StartDevWorkerInput, Trigger, } from "@cloudflare/workers-utils"; -import type { - DispatchFetch, - Miniflare, - NodeJSCompatMode, - WorkerdStructuredLog, -} from "miniflare"; +import type { DispatchFetch, Miniflare, WorkerdStructuredLog } from "miniflare"; import type * as undici from "undici"; type MiniflareWorker = Awaited>; + +/** + * Extended StartDevWorkerInput with wrangler-specific fields that depend on miniflare types. + * The base StartDevWorkerInput in workers-utils is kept dependency-free. + */ +export type WranglerStartDevWorkerInput = Omit & { + dev?: StartDevWorkerInput["dev"] & { + /** Handles structured runtime logs. */ + structuredLogsHandler?: (log: WorkerdStructuredLog) => void; + /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ + mockFetch?: undici.MockAgent; + }; +}; + export interface Worker { ready: Promise; url: Promise; @@ -43,178 +53,6 @@ export interface Worker { raw: DevEnv; } -export interface StartDevWorkerInput { - /** The name of the worker. */ - name?: string; - /** - * The javascript or typescript entry-point of the worker. - * This is the `main` property of a Wrangler configuration file. - */ - entrypoint?: string; - /** The configuration path of the worker, or a normalized configuration object. */ - config?: string | Config; - - /** The compatibility date for the workerd runtime. */ - compatibilityDate?: string; - /** The compatibility flags for the workerd runtime. */ - compatibilityFlags?: string[]; - - /** Specify the compliance region mode of the Worker. */ - complianceRegion?: Config["compliance_region"]; - - /** Configuration for Python modules. */ - pythonModules?: { - /** A list of glob patterns to exclude files from the python_modules directory when bundling. */ - exclude?: string[]; - }; - - env?: string; - - /** - * An array of paths to the .env files to load for this worker, relative to the project directory. - * - * If not specified, defaults to the standard `.env` files as given by `getDefaultEnvFiles()`. - * The project directory is where the Wrangler configuration file is located or the current working directory otherwise. - */ - envFiles?: string[]; - - /** The bindings available to the worker. The specified bindind type will be exposed to the worker on the `env` object under the same key. */ - bindings?: Record; // Type level constraint for bindings not sharing names - /** - * Default bindings that can be overridden by config bindings. - * Useful for injecting environment-specific defaults like CF_PAGES variables. - */ - defaultBindings?: Record>; - migrations?: DurableObjectMigration[]; - containers?: ContainerApp[]; - /** The triggers which will cause the worker's exported default handlers to be called. */ - triggers?: Trigger[]; - - tailConsumers?: CfTailConsumer[]; - streamingTailConsumers?: CfTailConsumer[]; - - /** - * Whether Wrangler should send usage metrics to Cloudflare for this project. - * - * When defined this will override any user settings. - * Otherwise, Wrangler will use the user's preference. - */ - sendMetrics?: boolean; - - /** Options applying to the worker's build step. Applies to deploy and dev. */ - build?: { - /** Whether the worker and its dependencies are bundled. Defaults to true. */ - bundle?: boolean; - - additionalModules?: CfModule[]; - - findAdditionalModules?: boolean; - processEntrypoint?: boolean; - /** Specifies types of modules matched by globs. */ - moduleRules?: Rule[]; - /** Replace global identifiers with constant expressions, e.g. { debug: 'true', version: '"1.0.0"' }. Only takes effect if bundle: true. */ - define?: Record; - /** Alias modules */ - alias?: Record; - /** Whether the bundled worker is minified. Only takes effect if bundle: true. */ - minify?: boolean; - /** Whether to keep function names after JavaScript transpilations. */ - keepNames?: boolean; - /** Options controlling a custom build step. */ - custom?: { - /** Custom shell command to run before bundling. Runs even if bundle. */ - command?: string; - /** The cwd to run the command in. */ - workingDirectory?: string; - /** Filepath(s) to watch for changes. Upon changes, the command will be rerun. */ - watch?: string | string[]; - }; - jsxFactory?: string; - jsxFragment?: string; - tsconfig?: string; - // HACK: Resolving the nodejs compat mode is complex and fraught with backwards-compat concerns - nodejsCompatMode?: Hook; - - moduleRoot?: string; - }; - - /** Options applying to the worker's development preview environment. */ - dev?: { - /** Options applying to the worker's inspector server. False disables the inspector server. */ - inspector?: { hostname?: string; port?: number; secure?: boolean } | false; - /** Whether the worker runs on the edge or locally. This has several options: - * - true | "minimal": Run your Worker's code & bindings in a remote preview session, optionally using minimal mode as an internal detail - * - false: Run your Worker's code & bindings in a local simulator - * - undefined (default): Run your Worker's code locally, and any configured remote bindings remotely - */ - remote?: boolean | "minimal"; - /** Cloudflare Account credentials. Can be provided upfront or as a function which will be called only when required. */ - auth?: AsyncHook]>; // provide config.account_id as a hook param - /** Whether local storage (KV, Durable Objects, R2, D1, etc) is persisted. You can also specify the directory to persist data to. Set to `false` to disable persistence. */ - persist?: string | false; - /** Controls which logs are logged πŸ€™. */ - logLevel?: LogLevel; - /** Whether the worker server restarts upon source/config file changes. */ - watch?: boolean; - /** Whether a script tag is inserted on text/html responses which will reload the page upon file changes. Defaults to false. */ - liveReload?: boolean; - - /** The local address to reach your worker. Applies to remote: true (remote mode) and remote: false (local mode). */ - server?: { - hostname?: string; // --ip - port?: number; // --port - secure?: boolean; // --local-protocol==https - httpsKeyPath?: string; - httpsCertPath?: string; - }; - /** Controls what request.url looks like inside the worker. */ - origin?: { hostname?: string; secure?: boolean }; // hostname: --host (remote)/--local-upstream (local), port: doesn't make sense in remote/=== server.port in local, secure: --upstream-protocol - /** A hook for outbound fetch calls from within the worker. */ - outboundService?: ServiceFetch; - /** Handles structured runtime logs. */ - structuredLogsHandler?: (log: WorkerdStructuredLog) => void; - /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ - mockFetch?: undici.MockAgent; - - testScheduled?: boolean; - - /** Treat this as the primary worker in a multiworker setup (i.e. the first Worker in Miniflare's options) */ - multiworkerPrimary?: boolean; - /** Whether to infer the local request origin from configured routes. */ - inferOriginFromRoutes?: boolean; - - containerBuildId?: string; - /** Whether to build and connect to containers during local dev. Requires Docker daemon to be running. Defaults to true. */ - enableContainers?: boolean; - - /** Path to the dev registry directory */ - registry?: string; - - /** Path to the docker executable. Defaults to 'docker' */ - dockerPath?: string; - - /** Options for the container engine */ - containerEngine?: ContainerEngine; - - /** Re-generate your worker types when your Wrangler configuration file changes */ - generateTypes?: boolean; - - /** Tunnel configuration for this dev session. */ - tunnel?: { - enabled: boolean; - name?: string; - }; - }; - legacy?: { - site?: Hook; - useServiceEnvironments?: boolean; - }; - unsafe?: Omit; - assets?: string; - - experimental?: Record; -} - export type StartDevWorkerOptions = Omit< StartDevWorkerInput, "assets" | "config" | "containers" | "dev" @@ -240,6 +78,10 @@ export type StartDevWorkerOptions = Omit< dev: StartDevWorkerInput["dev"] & { persist: string | false; auth?: AsyncHook; // redefine without config.account_id hook param (can only be provided by ConfigController with access to the Wrangler configuration file, not by other controllers eg RemoteRuntimeContoller) + /** Handles structured runtime logs. */ + structuredLogsHandler?: (log: WorkerdStructuredLog) => void; + /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ + mockFetch?: undici.MockAgent; }; entrypoint: string; assets?: AssetsOptions; @@ -248,16 +90,17 @@ export type StartDevWorkerOptions = Omit< complianceRegion: Config["compliance_region"]; }; -export type HookValues = string | number | boolean | object | undefined | null; -export type Hook = - | T - | ((...args: Args) => T); -export type AsyncHook = - | Hook - | Hook, Args>; - export type Bundle = EsbuildBundle; -export type LogLevel = "debug" | "info" | "log" | "warn" | "error" | "none"; - -export type { Trigger, Binding, File, BinaryFile, ServiceFetch }; +export type { + StartDevWorkerInput, + Trigger, + Binding, + File, + BinaryFile, + ServiceFetch, + HookValues, + Hook, + AsyncHook, + LogLevel, +}; diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 88902e5a8d..54153a4b55 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -1,6 +1,5 @@ import assert from "node:assert"; import { readFile } from "node:fs/promises"; -import type { AdditionalDevProps } from "../../dev"; import type { Binding, File, @@ -8,11 +7,7 @@ import type { HookValues, StartDevWorkerOptions, } from "./types"; -import type { - Config, - ConfigBindingFieldName, - WorkerMetadataBinding, -} from "@cloudflare/workers-utils"; +import type { WorkerMetadataBinding } from "@cloudflare/workers-utils"; export function assertNever(_value: never) {} @@ -85,413 +80,6 @@ export async function getBinaryFileContents(file: File) { return readFile(file.path); } -export function convertConfigBindingsToStartWorkerBindings( - configBindings: Partial> -): StartDevWorkerOptions["bindings"] { - return convertConfigToBindings(configBindings, { - usePreviewIds: true, - }); -} - -interface ConvertBindingsOptions { - /** - * Use preview IDs (preview_id, preview_bucket_name, preview_database_id) instead of production IDs when resolving a binding ID. - * This means that the rest of Wrangler does not need to be aware of preview IDs, and can just use regular IDs. - */ - usePreviewIds?: boolean; - /** - * Exclude bindings that Pages doesn't support - */ - pages?: boolean; -} - -/** - * Convert Config to the StartDevWorkerInput["bindings"] format for consistent internal use. - */ -export function convertConfigToBindings( - config: Partial>, - options?: ConvertBindingsOptions -): NonNullable { - const { usePreviewIds = false, pages = false } = options ?? {}; - const output: NonNullable = {}; - - type Entries = { [K in keyof T]: [K, T[K]] }[keyof T][]; - type ConfigIterable = Entries>>; - const configIterable = Object.entries(config) as ConfigIterable; - - for (const [type, info] of configIterable) { - if (info === undefined) { - continue; - } - - switch (type) { - case "vars": { - for (const [key, value] of Object.entries(info)) { - if (typeof value === "string") { - output[key] = { type: "plain_text", value }; - } else { - output[key] = { type: "json", value }; - } - } - break; - } - case "kv_namespaces": { - for (const { binding, ...x } of info) { - output[binding] = { - type: "kv_namespace", - ...x, - id: usePreviewIds ? (x.preview_id ?? x.id) : x.id, - }; - } - break; - } - case "send_email": { - if (pages) { - break; - } - for (const { name, ...x } of info) { - output[name] = { type: "send_email", ...x }; - } - break; - } - case "wasm_modules": { - if (pages) { - break; - } - for (const [key, value] of Object.entries(info)) { - if (typeof value === "string") { - output[key] = { type: "wasm_module", source: { path: value } }; - } else { - output[key] = { type: "wasm_module", source: { contents: value } }; - } - } - break; - } - case "text_blobs": { - if (pages) { - break; - } - for (const [key, value] of Object.entries(info)) { - output[key] = { type: "text_blob", source: { path: value } }; - } - break; - } - case "data_blobs": { - if (pages) { - break; - } - for (const [key, value] of Object.entries(info)) { - if (typeof value === "string") { - output[key] = { type: "data_blob", source: { path: value } }; - } else { - output[key] = { type: "data_blob", source: { contents: value } }; - } - } - break; - } - case "browser": { - const { binding, ...x } = info; - output[binding] = { type: "browser", ...x }; - break; - } - case "durable_objects": { - for (const { name, ...x } of info.bindings ?? []) { - output[name] = { type: "durable_object_namespace", ...x }; - } - break; - } - case "workflows": { - for (const { binding, ...x } of info) { - output[binding] = { type: "workflow", ...x }; - } - break; - } - case "queues": { - for (const { binding, ...x } of info.producers ?? []) { - output[binding] = { - type: "queue", - queue_name: x.queue, - ...x, - }; - } - break; - } - case "r2_buckets": { - for (const { binding, ...x } of info) { - output[binding] = { - type: "r2_bucket", - ...x, - bucket_name: usePreviewIds - ? (x.preview_bucket_name ?? x.bucket_name) - : x.bucket_name, - }; - } - break; - } - case "d1_databases": { - for (const { binding, ...x } of info) { - output[binding] = { - type: "d1", - ...x, - database_id: usePreviewIds - ? (x.preview_database_id ?? x.database_id) - : x.database_id, - }; - } - break; - } - case "services": { - for (const { binding, ...x } of info) { - output[binding] = { type: "service", ...x }; - } - break; - } - case "analytics_engine_datasets": { - for (const { binding, ...x } of info) { - output[binding] = { type: "analytics_engine", ...x }; - } - break; - } - case "dispatch_namespaces": { - if (pages) { - break; - } - for (const { binding, ...x } of info) { - output[binding] = { type: "dispatch_namespace", ...x }; - } - break; - } - case "mtls_certificates": { - for (const { binding, ...x } of info) { - output[binding] = { type: "mtls_certificate", ...x }; - } - break; - } - case "logfwdr": { - if (pages) { - break; - } - for (const { name, ...x } of info.bindings ?? []) { - output[name] = { type: "logfwdr", ...x }; - } - break; - } - case "ai": { - const { binding, ...x } = info; - output[binding] = { type: "ai", ...x }; - break; - } - case "images": { - const { binding, ...x } = info; - output[binding] = { type: "images", ...x }; - break; - } - case "stream": { - const { binding, ...x } = info; - output[binding] = { type: "stream", ...x }; - break; - } - case "version_metadata": { - const { binding, ...x } = info; - output[binding] = { type: "version_metadata", ...x }; - break; - } - case "hyperdrive": { - for (const { binding, ...x } of info) { - output[binding] = { type: "hyperdrive", ...x }; - } - break; - } - case "vectorize": { - for (const { binding, ...x } of info) { - output[binding] = { type: "vectorize", ...x }; - } - break; - } - case "ai_search_namespaces": { - for (const { binding, ...x } of info) { - output[binding] = { type: "ai_search_namespace", ...x }; - } - break; - } - case "ai_search": { - for (const { binding, ...x } of info) { - output[binding] = { type: "ai_search", ...x }; - } - break; - } - case "websearch": { - const { binding, ...x } = info; - output[binding] = { type: "websearch", ...x }; - break; - } - case "agent_memory": { - for (const { binding, ...x } of info) { - output[binding] = { type: "agent_memory", ...x }; - } - break; - } - case "unsafe": { - if (pages) { - break; - } - for (const { type: unsafeType, name, ...data } of info.bindings ?? []) { - output[name] = { type: `unsafe_${unsafeType}`, ...data }; - } - break; - } - case "assets": { - if (pages) { - break; - } - if (info.binding) { - output[info.binding] = { type: "assets" }; - } - break; - } - case "pipelines": { - if (pages) { - break; - } - for (const { binding, ...x } of info) { - output[binding] = { type: "pipeline", ...x }; - } - break; - } - case "secrets_store_secrets": { - for (const { binding, ...x } of info) { - output[binding] = { type: "secrets_store_secret", ...x }; - } - break; - } - case "artifacts": { - for (const { binding, ...x } of info) { - output[binding] = { type: "artifacts", ...x }; - } - break; - } - case "unsafe_hello_world": { - if (pages) { - break; - } - for (const { binding, ...x } of info) { - output[binding] = { type: "unsafe_hello_world", ...x }; - } - break; - } - case "flagship": { - for (const { binding, ...x } of info) { - output[binding] = { type: "flagship", ...x }; - } - break; - } - case "ratelimits": { - for (const { name, ...x } of info) { - output[name] = { type: "ratelimit", ...x }; - } - break; - } - case "worker_loaders": { - for (const { binding, ...x } of info) { - output[binding] = { type: "worker_loader", ...x }; - } - break; - } - case "vpc_services": { - for (const { binding, ...x } of info) { - output[binding] = { type: "vpc_service", ...x }; - } - break; - } - case "vpc_networks": { - for (const { binding, ...x } of info) { - output[binding] = { type: "vpc_network", ...x }; - } - break; - } - case "media": { - const { binding, ...x } = info; - output[binding] = { type: "media", ...x }; - break; - } - default: - assertNever(type); - } - } - - return output; -} - -/** - * Bindings that can be passed via the StartDevOptions CLI interface. - */ -export type StartDevOptionsBindings = Pick< - AdditionalDevProps, - | "vars" - | "kv" - | "durableObjects" - | "services" - | "r2" - | "ai" - | "stream" - | "version_metadata" - | "d1Databases" ->; - -/** - * Convert StartDevOptions bindings to the flat StartDevWorkerInput["bindings"] format. - */ -export function convertStartDevOptionsToBindings( - inputBindings: StartDevOptionsBindings -): StartDevWorkerOptions["bindings"] { - // Map StartDevOptionsBindings field names to Config field names - const configBindings = { - vars: inputBindings.vars, - kv_namespaces: inputBindings.kv, - durable_objects: inputBindings.durableObjects - ? { bindings: inputBindings.durableObjects } - : undefined, - services: inputBindings.services, - r2_buckets: inputBindings.r2, - ai: inputBindings.ai, - stream: inputBindings.stream, - version_metadata: inputBindings.version_metadata, - d1_databases: inputBindings.d1Databases, - }; - - return convertConfigToBindings(configBindings, { - usePreviewIds: true, - }); -} -export function isUnsafeBindingType(type: string): type is `unsafe_${string}` { - return type.startsWith("unsafe_"); -} -/** - * What configuration key does this binding use for referring to it's binding name? - */ -const nameBindings = [ - "durable_object_namespace", - "logfwdr", - "ratelimit", - "unsafe_ratelimit", - "send_email", -] as const; -function getBindingKey(type: Binding["type"]) { - if ((nameBindings as readonly string[]).includes(type)) { - return "name"; - } - return "binding"; -} - -type FlatBinding = Extract & - (Type extends (typeof nameBindings)[number] - ? { - name: string; - } - : { - binding: string; - }); - /** * Convert WorkerMetadataBinding[] (API format) to flat bindings format (Record) * @@ -761,20 +349,3 @@ export function convertWorkerMetadataBindingsToFlatBindings( return output; } - -export function extractBindingsOfType< - Type extends NonNullable[string]["type"], ->( - type: Type, - bindings: StartDevWorkerOptions["bindings"] -): FlatBinding[] { - return Object.entries(bindings ?? {}) - .filter( - (binding): binding is [string, Extract] => - binding[1].type === type - ) - .map((binding) => ({ - ...binding[1], - [getBindingKey(type)]: binding[0], - })) as FlatBinding[]; -} diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index 83c762ca24..4940510df8 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { convertConfigToBindings } from "@cloudflare/deploy-helpers"; import { normalizeAndValidateConfig, UserError, @@ -11,10 +12,9 @@ import { requireApiToken, requireAuth } from "../user"; import { DevEnv } from "./startDevWorker/DevEnv"; import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; import { NoOpProxyController } from "./startDevWorker/NoOpProxyController"; -import { convertConfigToBindings } from "./startDevWorker/utils"; import type { CfAccount } from "../dev/create-worker-preview"; import type { ErrorEvent } from "./startDevWorker/events"; -import type { StartDevWorkerInput } from "./startDevWorker/types"; +import type { WranglerStartDevWorkerInput } from "./startDevWorker/types"; import type { FetcherScheduledOptions, FetcherScheduledResult, @@ -280,7 +280,7 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { function resolveWorkerInputs( serverOptions: TestHarnessOptions - ): StartDevWorkerInput[] { + ): WranglerStartDevWorkerInput[] { if (serverOptions.workers.length === 0) { throw new Error("Test harness requires at least one worker."); } @@ -314,7 +314,8 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { persist: false, inspector: false, registry: undefined, - structuredLogsHandler: (log) => captureStructuredLog(log), + structuredLogsHandler: (log: WorkerdStructuredLog) => + captureStructuredLog(log), outboundService: (request) => { /** * Miniflare passes its own undici-based Request here. Pass the URL as @@ -378,7 +379,7 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { async function updateConfig( session: ServerSession, - inputs: StartDevWorkerInput[] + inputs: WranglerStartDevWorkerInput[] ) { for (const [index, workerInput] of inputs.entries()) { const devEnv = session.devEnvs[index]; diff --git a/packages/wrangler/src/assets.ts b/packages/wrangler/src/assets.ts index a8618aa8f3..02d8fe5609 100644 --- a/packages/wrangler/src/assets.ts +++ b/packages/wrangler/src/assets.ts @@ -1,384 +1,22 @@ -import assert from "node:assert"; import { statSync } from "node:fs"; -import { readdir, readFile, stat } from "node:fs/promises"; import * as path from "node:path"; import { parseStaticRouting } from "@cloudflare/workers-shared/utils/configuration/parseStaticRouting"; import { - CF_ASSETS_IGNORE_FILENAME, HEADERS_FILENAME, - MAX_ASSET_SIZE, REDIRECTS_FILENAME, } from "@cloudflare/workers-shared/utils/constants"; -import { - createAssetsIgnoreFunction, - getContentType, - maybeGetFile, - normalizeFilePath, -} from "@cloudflare/workers-shared/utils/helpers"; -import { APIError, FatalError, UserError } from "@cloudflare/workers-utils"; -import { formatTime } from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import PQueue from "p-queue"; -import prettyBytes from "pretty-bytes"; -import { FormData } from "undici"; -import { fetchResult } from "./cfetch"; -import { logger, LOGGER_LEVELS } from "./logger"; -import { hashFile } from "./pages/hash"; -import { isJwtExpired } from "./pages/upload"; +import { maybeGetFile } from "@cloudflare/workers-shared/utils/helpers"; +import { UserError } from "@cloudflare/workers-utils"; +import { logger } from "./logger"; import { getBasePath } from "./paths"; -import { dedent } from "./utils/dedent"; import type { StartDevWorkerOptions } from "./api"; import type { DeployArgs } from "./deploy"; import type { StartDevOptions } from "./dev"; import type { AssetConfig, RouterConfig } from "@cloudflare/workers-shared"; -import type { - AssetsOptions, - ComplianceConfig, - Config, -} from "@cloudflare/workers-utils"; - -export type AssetManifest = { [path: string]: { hash: string; size: number } }; - -type InitializeAssetsResponse = { - // string of file hashes per bucket - buckets: string[][]; - jwt: string; -}; - -type UploadResponse = { - jwt?: string; -}; - -// constants same as Pages for now -const BULK_UPLOAD_CONCURRENCY = 3; -const MAX_UPLOAD_ATTEMPTS = 5; -const MAX_UPLOAD_GATEWAY_ERRORS = 5; - -const MAX_DIFF_LINES = 100; - -export const syncAssets = async ( - complianceConfig: ComplianceConfig, - accountId: string | undefined, - assetDirectory: string, - scriptName: string, - dispatchNamespace?: string -): Promise => { - assert(accountId, "Missing accountId"); - - // 1. generate asset manifest - logger.info("πŸŒ€ Building list of assets..."); - const manifest = await buildAssetManifest(assetDirectory); - - const url = dispatchNamespace - ? `/accounts/${accountId}/workers/dispatch/namespaces/${dispatchNamespace}/scripts/${scriptName}/assets-upload-session` - : `/accounts/${accountId}/workers/scripts/${scriptName}/assets-upload-session`; - - // 2. fetch buckets w/ hashes - logger.info("πŸŒ€ Starting asset upload..."); - const initializeAssetsResponse = - await fetchResult(complianceConfig, url, { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify({ manifest: manifest }), - }); - - // In the past we've seen the endpoint return that incorrectly doesn't contain - // a null response (see: https://github.com/cloudflare/workers-sdk/issues/9465). - // So just to be extra sure here we check the object and provide a clear error message to the user - // if it is falsy. - if (!initializeAssetsResponse) { - throw new FatalError( - "An unexpected response has been received from the Cloudflare API for assets upload. Please try again.", - { code: 1, telemetryMessage: "assets upload unexpected api response" } - ); - } - - // if nothing to upload, return - if (initializeAssetsResponse.buckets.flat().length === 0) { - if (!initializeAssetsResponse.jwt) { - throw new FatalError( - "Could not find assets information to attach to deployment. Please try again.", - { code: 1, telemetryMessage: "assets upload missing completion token" } - ); - } - logger.info( - `No updated asset files to upload. Proceeding with deployment...` - ); - return initializeAssetsResponse.jwt; - } - - // 3. fill buckets and upload assets - const numberFilesToUpload = initializeAssetsResponse.buckets.flat().length; - logger.info( - `πŸŒ€ Found ${numberFilesToUpload} new or modified static asset${ - numberFilesToUpload > 1 ? "s" : "" - } to upload. Proceeding with upload...` - ); - - // Create the buckets outside of doUpload so we can retry without losing track of potential duplicate files - // But don't add the actual content until uploading so we don't run out of memory - const manifestLookup = Object.entries(manifest); - let assetLogCount = 0; - const assetBuckets = initializeAssetsResponse.buckets.map((bucket) => { - return bucket.map((fileHash) => { - const manifestEntry = manifestLookup.find( - (file) => file[1].hash === fileHash - ); - if (manifestEntry === undefined) { - throw new FatalError( - `A file was requested that does not appear to exist.`, - { - code: 1, - telemetryMessage: - "A file was requested that does not appear to exist. (asset manifest upload)", - } - ); - } - // just logging file uploads at the moment... - // unsure how to log deletion vs unchanged file ignored/if we want to log this - assetLogCount = logAssetUpload(`+ ${manifestEntry[0]}`, assetLogCount); - return manifestEntry; - }); - }); - - const queue = new PQueue({ concurrency: BULK_UPLOAD_CONCURRENCY }); - const queuePromises: Array> = []; - let attempts = 0; - const start = Date.now(); - let completionJwt = ""; - let uploadedAssetsCount = 0; - - for (const [bucketIndex, bucket] of assetBuckets.entries()) { - attempts = 0; - let gatewayErrors = 0; - const doUpload = async (): Promise => { - // Populate the payload only when actually uploading (this is limited to 3 concurrent uploads at 50 MiB per bucket meaning we'd only load in a max of ~150 MiB) - // This is so we don't run out of memory trying to upload the files. - const payload = new FormData(); - const uploadedFiles: string[] = []; - for (const manifestEntry of bucket) { - const absFilePath = path.join(assetDirectory, manifestEntry[0]); - uploadedFiles.push(manifestEntry[0]); - payload.append( - manifestEntry[1].hash, - new File( - [(await readFile(absFilePath)).toString("base64")], - manifestEntry[1].hash, - { - // Most formdata body encoders (incl. undici's) will override with "application/octet-stream" if you use a falsy value here - // Additionally, it appears that undici doesn't support non-standard main types (e.g. "null") - // So, to make it easier for any other clients, we'll just parse "application/null" on the API - // to mean actually null (signal to not send a Content-Type header with the response) - type: getContentType(absFilePath) ?? "application/null", - } - ), - manifestEntry[1].hash - ); - } - - try { - const res = await fetchResult( - complianceConfig, - `/accounts/${accountId}/workers/assets/upload?base64=true`, - { - method: "POST", - headers: { - Authorization: `Bearer ${initializeAssetsResponse.jwt}`, - }, - body: payload, - } - ); - uploadedAssetsCount += bucket.length; - logAssetsUploadStatus( - numberFilesToUpload, - uploadedAssetsCount, - uploadedFiles - ); - return res; - } catch (e) { - if (attempts < MAX_UPLOAD_ATTEMPTS) { - logger.info( - chalk.dim( - `Asset upload failed. Retrying... ${attempts + 1} of ${MAX_UPLOAD_ATTEMPTS} attempts.\n` - ) - ); - logger.debug(e); - // Exponential backoff, 1 second first time, then 2 second, then 4 second etc. - await new Promise((resolvePromise) => - setTimeout(resolvePromise, Math.pow(2, attempts) * 1000) - ); - if (e instanceof APIError && e.isGatewayError()) { - // Gateway problem, wait for some additional time and set concurrency to 1 - queue.concurrency = 1; - await new Promise((resolvePromise) => - setTimeout(resolvePromise, Math.pow(2, gatewayErrors) * 5000) - ); - gatewayErrors++; - // only count as a failed attempt after a few initial gateway errors - if (gatewayErrors >= MAX_UPLOAD_GATEWAY_ERRORS) { - attempts++; - } - } else { - attempts++; - } - return doUpload(); - } else if (isJwtExpired(initializeAssetsResponse.jwt)) { - throw new FatalError( - `Upload took too long.\n` + - `Asset upload took too long on bucket ${bucketIndex + 1}/${ - initializeAssetsResponse.buckets.length - }. Please try again.\n` + - `Assets already uploaded have been saved, so the next attempt will automatically resume from this point.`, - { telemetryMessage: "Asset upload took too long" } - ); - } else { - throw e; - } - } - }; - // add to queue and run it if we haven't reached concurrency limit - queuePromises.push( - queue.add(() => - doUpload().then((res) => { - completionJwt = res.jwt || completionJwt; - }) - ) - ); - } - queue.on("error", (error) => { - logger.error(error.message); - throw error; - }); - // using Promise.all() here instead of queue.onIdle() to ensure - // we actually throw errors that occur within queued promises. - await Promise.all(queuePromises); - - // if queue finishes without receiving JWT from asset upload service (AUS) - // AUS only returns this in the final bucket upload response - if (!completionJwt) { - throw new FatalError("Failed to complete asset upload. Please try again.", { - code: 1, - telemetryMessage: "assets upload completion failed", - }); - } +import type { AssetsOptions, Config } from "@cloudflare/workers-utils"; - const uploadMs = Date.now() - start; - const skipped = Object.keys(manifest).length - numberFilesToUpload; - const skippedMessage = skipped > 0 ? `(${skipped} already uploaded) ` : ""; - - logger.log( - `✨ Success! Uploaded ${numberFilesToUpload} file${ - numberFilesToUpload > 1 ? "s" : "" - } ${skippedMessage}${formatTime(uploadMs)}\n` - ); - - return completionJwt; -}; - -export const buildAssetManifest = async (dir: string) => { - const files = await readdir(dir, { recursive: true }); - logReadFilesFromDirectory(dir, files); - - const manifest: AssetManifest = {}; - - const { assetsIgnoreFunction, assetsIgnoreFilePresent } = - await createAssetsIgnoreFunction(dir); - - await Promise.all( - files.map(async (relativeFilepath) => { - if (assetsIgnoreFunction(relativeFilepath)) { - logger.debug("Ignoring asset:", relativeFilepath); - // This file should not be included in the manifest. - return; - } - - const filepath = path.join(dir, relativeFilepath); - const filestat = await stat(filepath); - - if (filestat.isSymbolicLink() || filestat.isDirectory()) { - return; - } else { - errorOnLegacyPagesWorkerJSAsset( - relativeFilepath, - assetsIgnoreFilePresent - ); - - if (filestat.size > MAX_ASSET_SIZE) { - throw new UserError( - `Asset too large.\n` + - `Cloudflare Workers supports assets with sizes of up to ${prettyBytes( - MAX_ASSET_SIZE, - { - binary: true, - } - )}. We found a file ${filepath} with a size of ${prettyBytes( - filestat.size, - { - binary: true, - } - )}.\n` + - `Ensure all assets in your assets directory "${dir}" conform with the Workers maximum size requirement.`, - { telemetryMessage: "Asset too large" } - ); - } - manifest[normalizeFilePath(relativeFilepath)] = { - hash: hashFile(filepath), - size: filestat.size, - }; - } - }) - ); - return manifest; -}; - -function logAssetUpload(line: string, diffCount: number) { - const level = logger.loggerLevel; - if (LOGGER_LEVELS[level] >= LOGGER_LEVELS.debug) { - // If we're logging as debug level, we want *all* diff lines to be logged - // at debug level, not just the first MAX_DIFF_LINES - logger.debug(line); - } else if (diffCount < MAX_DIFF_LINES) { - // Otherwise, log the first MAX_DIFF_LINES diffs at info level... - logger.info(line); - } else if (diffCount === MAX_DIFF_LINES) { - // ...and warn when we start to truncate it - const msg = - " (truncating changed assets log, set `WRANGLER_LOG=debug` environment variable to see full diff)"; - logger.info(chalk.dim(msg)); - } - return ++diffCount; -} - -/** - * Logs a summary of the assets upload status ("Uploaded of assets"), - * and the list of uploaded files if in debug log level. - */ -function logAssetsUploadStatus( - numberFilesToUpload: number, - uploadedAssetsCount: number, - uploadedAssetFiles: string[] -) { - logger.info( - `Uploaded ${uploadedAssetsCount} of ${numberFilesToUpload} asset${ - numberFilesToUpload === 1 ? "" : "s" - }` - ); - uploadedAssetFiles.forEach((file) => logger.debug(`✨ ${file}`)); -} - -/** - * Logs a summary of files read from a given directory ("Read - * files from directory "), and the list of read files if in - * debug log level. - */ -function logReadFilesFromDirectory(directory: string, assetFiles: string[]) { - logger.info( - `✨ Read ${assetFiles.length} file${ - assetFiles.length === 1 ? "" : "s" - } from the assets directory ${directory}` - ); - assetFiles.forEach((file) => logger.debug(`/${file}`)); -} +export { buildAssetManifest, syncAssets } from "@cloudflare/deploy-helpers"; +export type { AssetManifest } from "@cloudflare/deploy-helpers"; /** * Returns the base path of the assets to upload. @@ -594,33 +232,3 @@ export function validateAssetsArgsAndConfig( ); } } - -const WORKER_JS_FILENAME = "_worker.js"; - -/** - * Creates a function that logs a warning (only once) if the project has no `.assetsIgnore` file and is uploading _worker.js code as an asset. - */ -function errorOnLegacyPagesWorkerJSAsset( - file: string, - hasAssetsIgnoreFile: boolean -) { - if (!hasAssetsIgnoreFile) { - const workerJsType: "file" | "directory" | null = - file === WORKER_JS_FILENAME - ? "file" - : file.startsWith(WORKER_JS_FILENAME) - ? "directory" - : null; - if (workerJsType !== null) { - throw new UserError( - dedent` - Uploading a Pages ${WORKER_JS_FILENAME} ${workerJsType} as an asset. - This could expose your private server-side code to the public Internet. Is this intended? - If you do not want to upload this ${workerJsType}, either remove it or add an "${CF_ASSETS_IGNORE_FILENAME}" file, to the root of your asset directory, containing "${WORKER_JS_FILENAME}" to avoid uploading. - If you do want to upload this ${workerJsType}, you can add an empty "${CF_ASSETS_IGNORE_FILENAME}" file, to the root of your asset directory, to hide this error. - `, - { telemetryMessage: "assets validation legacy pages worker asset" } - ); - } - } -} diff --git a/packages/wrangler/src/cfetch/internal.ts b/packages/wrangler/src/cfetch/internal.ts index 1c751f8685..85a9c80531 100644 --- a/packages/wrangler/src/cfetch/internal.ts +++ b/packages/wrangler/src/cfetch/internal.ts @@ -2,6 +2,7 @@ import { addAuthorizationHeader, APIError, fetchInternalBase, + fetchKVGetValueBase, getCloudflareApiBaseUrl, performApiFetchBase, UserError, @@ -187,22 +188,16 @@ export async function fetchKVGetValue( namespaceId: string, key: string ): Promise { - await requireLoggedIn(complianceConfig); - const auth = requireApiToken(); - const headers = new Headers(); - addAuthorizationHeader(headers, auth); - const resource = `${getCloudflareApiBaseUrl(complianceConfig)}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}`; - const response = await fetch(resource, { - method: "GET", - headers, - }); - if (response.ok) { - return await response.arrayBuffer(); - } else { - throw new Error( - `Failed to fetch ${resource} - ${response.status}: ${response.statusText});` - ); - } + const credentials = await resolveCredentials(complianceConfig); + return fetchKVGetValueBase( + complianceConfig, + accountId, + namespaceId, + key, + `wrangler/${wranglerVersion}`, + logger, + credentials + ); } /** diff --git a/packages/wrangler/src/core/deploy-helpers-context.ts b/packages/wrangler/src/core/deploy-helpers-context.ts deleted file mode 100644 index 7f86b4d4ce..0000000000 --- a/packages/wrangler/src/core/deploy-helpers-context.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { fetchListResult, fetchResult } from "../cfetch"; -import { confirm, prompt } from "../dialogs"; -import { isNonInteractiveOrCI } from "../is-interactive"; -import { logger } from "../logger"; -import type { DeployHelpersContext } from "@cloudflare/deploy-helpers"; -import type { ApiCredentials } from "@cloudflare/workers-utils"; - -/** - * Builds a `DeployHelpersContext` from Wrangler's singletons (logger, auth'd - * fetchers and interactive prompts) so that `@cloudflare/deploy-helpers` - * functions can be called from code paths that don't have easy access to command - * `HandlerContext`. - * - * An optional `apiToken` can be provided to override the credentials used by - * `fetchResult` (e.g. for remote preview sessions that authenticate with a - * per-request token rather than the global account credentials). - */ -export function createDeployHelpersContext(options?: { - apiToken?: ApiCredentials; -}): DeployHelpersContext { - return { - fetchResult: (complianceConfig, resource, init, queryParams, abortSignal) => - fetchResult( - complianceConfig, - resource, - init, - queryParams, - abortSignal, - options?.apiToken - ), - fetchListResult, - logger, - confirm, - prompt, - isNonInteractiveOrCI, - }; -} diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index 89e2ee43b5..690b762dd4 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -1,3 +1,4 @@ +import { initDeployHelpersContext } from "@cloudflare/deploy-helpers"; import { defaultWranglerConfig, FatalError, @@ -8,7 +9,12 @@ import { } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { maybeInstallCloudflareSkillsGlobally } from "../agents-skills-install"; -import { fetchResult, fetchListResult } from "../cfetch"; +import { + fetchKVGetValue, + fetchResult, + fetchListResult, + fetchPagedListResult, +} from "../cfetch"; import { createCloudflareClient } from "../cfetch/internal"; import { readConfig } from "../config"; import { confirm, prompt } from "../dialogs"; @@ -261,6 +267,18 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { ); try { + // sets these values in the scope of deploy-helpers + initDeployHelpersContext({ + logger, + fetchResult, + fetchListResult, + fetchPagedListResult, + fetchKVGetValue, + confirm, + prompt, + isNonInteractiveOrCI, + }); + const result = await def.handler(args, { sdk: createCloudflareClient(config), config, @@ -268,6 +286,8 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { logger, fetchResult, fetchListResult, + fetchPagedListResult, + fetchKVGetValue, prompt, confirm, isNonInteractiveOrCI, diff --git a/packages/wrangler/src/core/types.ts b/packages/wrangler/src/core/types.ts index 7cac3abf24..c0bbe7107a 100644 --- a/packages/wrangler/src/core/types.ts +++ b/packages/wrangler/src/core/types.ts @@ -1,4 +1,9 @@ -import type { fetchResult, fetchListResult } from "../cfetch"; +import type { + fetchKVGetValue, + fetchResult, + fetchListResult, + fetchPagedListResult, +} from "../cfetch"; import type { confirm, prompt } from "../dialogs"; import type { ExperimentalFlags } from "../experimental-flags"; import type { Logger } from "../logger"; @@ -111,6 +116,8 @@ export type HandlerContext = { */ fetchResult: typeof fetchResult; fetchListResult: typeof fetchListResult; + fetchPagedListResult: typeof fetchPagedListResult; + fetchKVGetValue: typeof fetchKVGetValue; /** * Interactive prompts diff --git a/packages/wrangler/src/deploy/check-remote-secrets-override.ts b/packages/wrangler/src/deploy/check-remote-secrets-override.ts index 7f2a3223c9..ad4cc5b293 100644 --- a/packages/wrangler/src/deploy/check-remote-secrets-override.ts +++ b/packages/wrangler/src/deploy/check-remote-secrets-override.ts @@ -1,170 +1,11 @@ -import { fetchSecrets } from "../utils/fetch-secrets"; -import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; +import { checkRemoteSecretsOverride as checkRemoteSecretsOverrideBase } from "@cloudflare/deploy-helpers"; +import { requireAuth } from "../user"; import type { Config } from "@cloudflare/workers-utils"; -/** - * Checks whether some remote secrets would be overridden by either some env variable or binding names. - * - * @param config The resolved config - * @param targetEnv The target environment if any - * @returns object with an `override` flag indicating whether there are overrides, if there are a error message for `wrangler deploy` is also returned - */ export async function checkRemoteSecretsOverride( config: Config, targetEnv?: string -): Promise< - | { - override: false; - } - | { - override: true; - deployErrorMessage: string; - } -> { - const envVarNames = Object.keys(config.vars ?? {}); - const bindingNames = extractBindingNames(config); - - if (envVarNames.length + bindingNames.length > 0) { - const secretNames = new Set(); - - try { - const secrets = await fetchSecrets(config, targetEnv); - - for (const secret of secrets) { - secretNames.add(secret.name); - } - } catch (e) { - if (isWorkerNotFoundError(e)) { - // Worker doesn't exist yet (first deployment), so no secrets to override - return { override: false }; - } - throw e; - } - - const envVarNamesOverridingSecrets = envVarNames.filter((name) => - secretNames.has(name) - ); - - const bindingNamesOverridingSecrets = bindingNames.filter((name) => - secretNames.has(name) - ); - - if ( - envVarNamesOverridingSecrets.length + - bindingNamesOverridingSecrets.length === - 0 - ) { - return { override: false }; - } - - if ( - envVarNamesOverridingSecrets.length && - !bindingNamesOverridingSecrets.length - ) { - return { - override: true, - deployErrorMessage: constructSingleTypeDeployErrorMessage( - envVarNamesOverridingSecrets, - "variable" - ), - }; - } - - if ( - bindingNamesOverridingSecrets.length && - !envVarNamesOverridingSecrets.length - ) { - return { - override: true, - deployErrorMessage: constructSingleTypeDeployErrorMessage( - bindingNamesOverridingSecrets, - "binding" - ), - }; - } - - const affectedSecrets = [ - ...envVarNamesOverridingSecrets, - ...bindingNamesOverridingSecrets, - ]; - - return { - override: true, - deployErrorMessage: `Configuration values (${listNames(affectedSecrets)}) conflict with existing remote secrets. This deployment will replace these remote secrets with the configuration values.`, - }; - } - - return { override: false }; -} - -function extractBindingNames(config: Config): string[] { - return Object.entries(config).flatMap((entry) => { - const key = entry[0] as keyof Config; - const untypedValue = entry[1]; - - switch (key) { - case "durable_objects": { - const value: Config[typeof key] = untypedValue; - return value.bindings.map((doBinding) => doBinding.name); - } - case "workflows": - case "d1_databases": - case "kv_namespaces": - case "r2_buckets": - case "vectorize": - case "ai_search_namespaces": - case "ai_search": - case "agent_memory": - case "services": - case "mtls_certificates": - case "dispatch_namespaces": - case "vpc_services": - case "vpc_networks": { - const value: Config[typeof key] = untypedValue; - return (value ?? []).map((workflowBinding) => workflowBinding.binding); - } - case "browser": - case "ai": - case "websearch": { - const value: Config[typeof key] = untypedValue; - return value ? [value.binding] : []; - } - case "queues": { - const value: Config[typeof key] = untypedValue; - return (value.producers ?? []).map( - (queueProducer) => queueProducer.binding - ); - } - default: - return []; - } - }); -} - -function listNames(names: string[]): string { - if (names.length <= 1) { - return `\`${names[0]}\``; - } - - if (names.length == 2) { - return `\`${names[0]}\` and \`${names[1]}\``; - } - - return `${names - .slice(0, -1) - .map((name) => `\`${name}\`, `) - .join("")}and \`${names.at(-1)}\``; -} - -function constructSingleTypeDeployErrorMessage( - names: string[], - type: "variable" | "binding" ) { - const multiple = names.length > 1; - - const conflictMessage = `${type === "variable" ? "Environment variable" : "Binding"}${multiple ? "s" : ""} ${listNames(names)} conflict${multiple ? "" : "s"} with ${multiple ? "" : "an "}existing remote secret${multiple ? "s" : ""}.`; - - const deploymentMessage = `This deployment will replace ${multiple ? "these" : "the"} remote secret${multiple ? "s" : ""} with your ${type === "variable" ? "environment variable" : "binding"}${multiple ? "s" : ""}.`; - - return `${conflictMessage} ${deploymentMessage}`; + const accountId = await requireAuth(config); + return checkRemoteSecretsOverrideBase(config, accountId, targetEnv); } diff --git a/packages/wrangler/src/deploy/check-workflow-conflicts.ts b/packages/wrangler/src/deploy/check-workflow-conflicts.ts index 08dc2763b9..b7fc44ae34 100644 --- a/packages/wrangler/src/deploy/check-workflow-conflicts.ts +++ b/packages/wrangler/src/deploy/check-workflow-conflicts.ts @@ -1,95 +1,13 @@ -import { APIError } from "@cloudflare/workers-utils"; -import { fetchResult } from "../cfetch"; -import type { Workflow } from "../workflows/types"; +import { checkWorkflowConflicts as checkWorkflowConflictsBase } from "@cloudflare/deploy-helpers"; import type { Config } from "@cloudflare/workers-utils"; -export interface WorkflowConflict { - name: string; - currentOwner: string; -} - -export const WORKFLOW_NOT_FOUND_CODE = 10200; +export type { WorkflowConflict } from "@cloudflare/deploy-helpers"; +export { WORKFLOW_NOT_FOUND_CODE } from "@cloudflare/deploy-helpers"; -/** - * Fetches a single workflow by name from the Cloudflare API. - * - * @param config - The resolved config - * @param accountId - The account ID - * @param workflowName - The name of the workflow to fetch - * @returns The workflow if it exists, or `null` if not found (API error code 10200) - * @throws {APIError} Re-throws any API error that is not a "workflow not found" error (e.g., network errors, auth errors, rate limits) - */ -async function getWorkflow( - config: Config, - accountId: string, - workflowName: string -): Promise { - try { - return await fetchResult( - config, - `/accounts/${accountId}/workflows/${workflowName}` - ); - } catch (e) { - if (e instanceof APIError && e.code === WORKFLOW_NOT_FOUND_CODE) { - return null; - } - throw e; - } -} - -/** - * Checks whether any workflows being deployed already exist and belong to a different worker. - * - * @param config The resolved config - * @param accountId The account ID - * @param scriptName The name of the worker script being deployed - * @returns object with a `hasConflicts` flag, and if true, the list of conflicts and a message - */ export async function checkWorkflowConflicts( config: Config, accountId: string, scriptName: string -): Promise< - | { hasConflicts: false } - | { hasConflicts: true; conflicts: WorkflowConflict[]; message: string } -> { - // Only check workflows that will be deployed by this script - // script_name defines the worker name of the workflow owner - // Workflows with script_name set to another worker are external bindings - // referencing workflows owned by other workers and should be skipped - const workflowsToDeploy = config.workflows?.filter( - (w) => w.script_name === undefined || w.script_name === scriptName - ); - - if (!workflowsToDeploy?.length) { - return { hasConflicts: false }; - } - - const workflowChecks = await Promise.all( - workflowsToDeploy.map(async (workflow) => { - const existing = await getWorkflow(config, accountId, workflow.name); - if (existing && existing.script_name !== scriptName) { - return { name: workflow.name, currentOwner: existing.script_name }; - } - return null; - }) - ); - - const conflicts = workflowChecks.filter( - (c): c is WorkflowConflict => c !== null - ); - - if (conflicts.length === 0) { - return { hasConflicts: false }; - } - - const conflictList = conflicts - .map((c) => ` - "${c.name}" (currently belongs to "${c.currentOwner}")`) - .join("\n"); - - const message = - `The following workflow(s) already exist and belong to different workers:\n${conflictList}\n\n` + - `Deploying will reassign these workflows to "${scriptName}".`; - - return { hasConflicts: true, conflicts, message }; +) { + return checkWorkflowConflictsBase(config, accountId, scriptName); } diff --git a/packages/wrangler/src/deploy/config-diffs.ts b/packages/wrangler/src/deploy/config-diffs.ts index 6f0298a2c9..9235e7b7f5 100644 --- a/packages/wrangler/src/deploy/config-diffs.ts +++ b/packages/wrangler/src/deploy/config-diffs.ts @@ -1,754 +1,4 @@ -import assert from "node:assert"; -import { getSubdomainValuesAPIMock } from "@cloudflare/deploy-helpers"; -import { - diffJsonObjects, - isModifiedDiffValue, - isNonDestructive, -} from "../utils/diff-json"; -import type { JsonLike } from "../utils/diff-json"; -import type { - Config, - ConfigBindingFieldName, - RawConfig, -} from "@cloudflare/workers-utils"; - -// Exhaustive map of all binding keys in CfWorkerInit["bindings"]. -// When a new binding type is added, TypeScript will error here until it is handled. -const reorderableBindings = { - // Top-level binding arrays - kv_namespaces: true, - r2_buckets: true, - d1_databases: true, - services: true, - send_email: true, - vectorize: true, - ai_search_namespaces: true, - ai_search: true, - agent_memory: true, - hyperdrive: true, - workflows: true, - dispatch_namespaces: true, - mtls_certificates: true, - pipelines: true, - secrets_store_secrets: true, - artifacts: true, - ratelimits: true, - analytics_engine_datasets: true, - unsafe_hello_world: true, - flagship: true, - worker_loaders: true, - vpc_services: true, - vpc_networks: true, - - // Wrapper objects containing binding arrays - durable_objects: true, - queues: true, - logfwdr: true, - - // Non-array bindings (nothing to reorder) - vars: false, - wasm_modules: false, - text_blobs: false, - data_blobs: false, - browser: false, - ai: false, - images: false, - stream: false, - media: false, - websearch: false, - version_metadata: false, - unsafe: false, - assets: false, -} satisfies Record; - -/** Extracts the keys of T whose values are `true` */ -type ReorderableKeys> = { - [K in keyof T]: T[K] extends true ? K : never; -}[keyof T]; - -/** - * Object representing the difference of two configuration objects. - */ -type ConfigDiff = { - /** The actual (raw) computed diff of the two objects */ - diff: Record | null; - /** - * Flag indicating whether the difference includes some destructive changes. - * - * In other words, if the second config is not applying any change or only adding options, such diff is considered non destructive, on the other hand if the config is removing or modifying values it is considered destructive instead. - */ - nonDestructive: boolean; -}; - -/** - * Computes the difference between a remote representation of a Worker's config and a local configuration. - * - * @param remoteConfig The remote representation of a Worker's config - * @param localResolvedConfig The local (resolved) config - * @returns Object containing the diffing information - */ -export function getRemoteConfigDiff( - remoteConfig: RawConfig, - localResolvedConfig: Config -): ConfigDiff { - const normalizedLocalConfig = - normalizeLocalResolvedConfigAsRemote(localResolvedConfig); - const normalizedRemoteConfig = normalizeRemoteConfigAsResolvedLocal( - remoteConfig, - normalizedLocalConfig - ); - - const diff = diffJsonObjects( - normalizedRemoteConfig as unknown as Record, - normalizedLocalConfig as unknown as Record - ); - - return { - diff, - nonDestructive: isNonDestructive(diff), - }; -} - -/** - * Normalized a local (resolved) config object so that it can be compared against - * the remote config object. This mainly means resolving and setting defaults to - * the local configuration to match the values in the remote one. - * - * @param localResolvedConfig The local (resolved) config object to normalize - * @returns The normalized config - */ -function normalizeLocalResolvedConfigAsRemote( - localResolvedConfig: Config -): Config { - const subdomainValues = getSubdomainValuesAPIMock( - localResolvedConfig.workers_dev, - localResolvedConfig.preview_urls, - localResolvedConfig.routes ?? [] - ); - const normalizedConfig: Config = { - ...structuredClone(localResolvedConfig), - workers_dev: subdomainValues.workers_dev, - preview_urls: subdomainValues.preview_urls, - observability: normalizeObservability(localResolvedConfig.observability), - }; - - removeRemoteConfigFieldFromBindings(normalizedConfig); - - // Currently remotely we only get the assets' binding name, so we need remove - // everything else, if present, from the local one - if (normalizedConfig.assets) { - normalizedConfig.assets = { - binding: normalizedConfig.assets.binding, - }; - } - - return normalizedConfig; -} - -/** - * Given a configuration object removes all the `remote` config settings from all the bindings - * in the configuration (this is used as part of the config normalization since the `remote` - * key is not present in the remote configuration object) - * - * @param normalizedConfig The target configuration object (which gets updated side-effectfully) - */ -function removeRemoteConfigFieldFromBindings(normalizedConfig: Config): void { - for (const bindingField of [ - "kv_namespaces", - "r2_buckets", - "d1_databases", - ] as const) { - if (normalizedConfig[bindingField]?.length) { - normalizedConfig[bindingField] = normalizedConfig[bindingField].map( - ({ remote: _, ...binding }) => binding - ); - } - } - - if (normalizedConfig.services?.length) { - normalizedConfig.services = normalizedConfig.services.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.vpc_services?.length) { - normalizedConfig.vpc_services = normalizedConfig.vpc_services.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.vpc_networks?.length) { - normalizedConfig.vpc_networks = normalizedConfig.vpc_networks.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.workflows?.length) { - normalizedConfig.workflows = normalizedConfig.workflows.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.dispatch_namespaces?.length) { - normalizedConfig.dispatch_namespaces = - normalizedConfig.dispatch_namespaces.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.mtls_certificates?.length) { - normalizedConfig.mtls_certificates = normalizedConfig.mtls_certificates.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.pipelines?.length) { - normalizedConfig.pipelines = normalizedConfig.pipelines.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.vectorize?.length) { - normalizedConfig.vectorize = normalizedConfig.vectorize.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.queues?.producers?.length) { - normalizedConfig.queues.producers = normalizedConfig.queues.producers.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.send_email) { - normalizedConfig.send_email = normalizedConfig.send_email.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.ai_search_namespaces?.length) { - normalizedConfig.ai_search_namespaces = - normalizedConfig.ai_search_namespaces.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.ai_search?.length) { - normalizedConfig.ai_search = normalizedConfig.ai_search.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.agent_memory?.length) { - normalizedConfig.agent_memory = normalizedConfig.agent_memory.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.flagship?.length) { - normalizedConfig.flagship = normalizedConfig.flagship.map( - ({ remote: _, ...binding }) => binding - ); - } - - if (normalizedConfig.artifacts?.length) { - normalizedConfig.artifacts = normalizedConfig.artifacts.map( - ({ remote: _, ...binding }) => binding - ); - } - - const singleBindingFields = [ - "browser", - "ai", - "images", - "stream", - "media", - "websearch", - ] as const; - for (const singleBindingField of singleBindingFields) { - if ( - normalizedConfig[singleBindingField] && - "remote" in normalizedConfig[singleBindingField] - ) { - delete normalizedConfig[singleBindingField].remote; - } - } -} - -/** - * Normalizes an observability config object (either the remote or resolved local one) to a fully filled form, this - * helps us resolve any inconsistencies between the local and remote default values. - * - * @param obs The observability config object to normalize - * @returns The normalized observability object - */ -function normalizeObservability( - obs: RawConfig["observability"] -): Config["observability"] { - const normalized = structuredClone(obs); - - const enabled = obs?.enabled === true ? true : false; - - const fullObservabilityDefaults = { - enabled, - head_sampling_rate: 1, - logs: { - enabled, - head_sampling_rate: 1, - invocation_logs: true, - persist: true, - }, - traces: { enabled: false, persist: true, head_sampling_rate: 1 }, - } as const; - - if (!normalized) { - return fullObservabilityDefaults; - } - - const fillUndefinedFields = ( - target: Record, - defaults: Record - ) => { - Object.entries(defaults).forEach(([key, value]) => { - if (target[key] === undefined) { - target[key] = value; - return; - } - - if ( - typeof value === "object" && - value !== null && - typeof target[key] === "object" && - target[key] !== null - ) { - fillUndefinedFields( - target[key] as Record, - value as Record - ); - } - }); - }; - - fillUndefinedFields( - normalized as Record, - fullObservabilityDefaults - ); - - return normalized; -} - -/** - * Normalizes a remote config object (or more precisely our representation of it) into an object that can be - * compared to the local target config. - * - * The normalization is comprized of: - * - making sure that the various config fields are in the same order - * - adding to the remote config object all the non-remote config keys - * - removing from the remote config all the default values that in the local config are either not present or undefined - * - * @param remoteConfig The remote config object to normalize - * @param localConfig The target/local (resolved) config object - * @returns The remote config object normalized and ready to be compared with the local one - */ -function normalizeRemoteConfigAsResolvedLocal( - remoteConfig: RawConfig, - localConfig: Config -): Config { - let normalizedRemote = {} as Config; - - // We start by adding all the local configs to the normalized remote config object - // in this way we can make sure that local-only configurations are not shown as - // differences between local and remote configs - Object.entries(localConfig).forEach(([key, value]) => { - if ( - // We want to skip observability since it has a remote default behavior - // different from that of wrangler - key !== "observability" && - // We want to skip assets since it is a special case, the issue being that - // remotely assets configs only include at most the binding name and we - // already address that in the local config normalization already - key !== "assets" - ) { - (normalizedRemote as unknown as Record)[key] = value; - } - }); - - // We then override the configs present in the remote config object - Object.entries(remoteConfig).forEach(([key, value]) => { - if (key !== "main" && value !== undefined) { - (normalizedRemote as unknown as Record)[key] = value; - } - }); - - normalizedRemote.observability = normalizeObservability( - normalizedRemote.observability - ); - - // We reorder the remote config so that its ordering follows that - // of the local one (this ensures that the diff users see lists - // the configuration options in the same order as their config file) - normalizedRemote = orderObjectFields( - normalizedRemote as unknown as Record, - localConfig as unknown as Record - ) as unknown as Config; - - // Reorder binding arrays to match local's order so the diff is intuitive. - // Binding array order doesn't matter semantically, but positional diffing - // would show spurious changes if the same elements appear in different order. - for (const [bindingKey, shouldReorder] of Object.entries( - reorderableBindings - )) { - if (!shouldReorder) { - continue; - } - - const key = bindingKey as ReorderableKeys; - - // Handle wrapper objects that contain binding arrays as nested properties - if (key === "queues") { - // Only producers are bindings (accessible from Worker code). - // Consumers configure message delivery to the Worker and are - // managed through the Queues API, not the Worker bindings API, - // so they don't appear in the remote config. - if (normalizedRemote.queues?.producers && localConfig.queues?.producers) { - normalizedRemote.queues.producers = reorderBindings( - normalizedRemote.queues.producers, - localConfig.queues.producers - ); - } - continue; - } - - if (key === "durable_objects") { - if ( - normalizedRemote.durable_objects?.bindings && - localConfig.durable_objects?.bindings - ) { - normalizedRemote.durable_objects.bindings = reorderBindings( - normalizedRemote.durable_objects.bindings, - localConfig.durable_objects.bindings - ); - } - continue; - } - - if (key === "logfwdr") { - if (normalizedRemote.logfwdr?.bindings && localConfig.logfwdr?.bindings) { - normalizedRemote.logfwdr.bindings = reorderBindings( - normalizedRemote.logfwdr.bindings, - localConfig.logfwdr.bindings - ); - } - continue; - } - - // Top-level binding arrays - reorderConfigBindings(normalizedRemote, localConfig, key); - } - - return normalizedRemote; -} - -/** - * Generates a stable key for a binding object by JSON-serializing it with sorted keys, - * so that objects with the same properties in different order produce the same key. - */ -function getBindingKey(obj: unknown): string { - return JSON.stringify(obj, (_, v) => - v && typeof v === "object" && !Array.isArray(v) - ? Object.fromEntries( - Object.keys(v) - .sort() - .map((k) => [k, v[k]]) - ) - : v - ); -} - -/** - * Reorders a remote binding array to match the local array's order. - * Elements present in both arrays are placed first (in local order), - * followed by elements only in the remote array. - * - * @example - * ```ts - * reorderBindings( - * [{ binding: "A" }, { binding: "B" }, { binding: "C" }], // remote - * [{ binding: "C" }, { binding: "A" }, { binding: "D" }] // local - * ) - * // => [{ binding: "C" }, { binding: "A" }, { binding: "B" }] - * // matched C and A are placed in local order, then unmatched B is appended - * ``` - */ -function reorderBindings(remote: T[], local: T[]): T[] { - const remoteByKey = new Map(remote.map((el) => [getBindingKey(el), el])); - const used = new Set(); - const result: T[] = []; - for (const binding of local) { - const key = getBindingKey(binding); - const remoteEl = remoteByKey.get(key); - if (remoteEl !== undefined) { - result.push(remoteEl); - used.add(key); - } - } - for (const binding of remote) { - if (!used.has(getBindingKey(binding))) { - result.push(binding); - } - } - return result; -} - -/** - * Reorders a top-level binding array on the remote config to match the local config's order. - * Uses a generic key parameter so TypeScript can correlate the types of both accesses. - */ -function reorderConfigBindings< - K extends ReorderableKeys, ->(normalizedRemote: Config, localConfig: Config, key: K): void { - const remoteArr = normalizedRemote[key]; - const localArr = localConfig[key]; - if (Array.isArray(remoteArr) && Array.isArray(localArr)) { - normalizedRemote[key] = reorderBindings(remoteArr, localArr) as Config[K]; - } -} - -/** - * This function reorders the fields of a given object so that they follow a given target object. - * All the fields of the given object not present in the target object will be ordered last. - * - * Note: this function also recursively reorders the fields of nested objects - * - * For example: - * orderObjectFields( - * { - * d: '' - * b: '', - * a: '', - * e: '', - * f: '', - * }, - * { - * a: '', - * b: '', - * c: '', - * d: '', - * } - * ) === { - * a: '', // `a` and `b` are the first two fields of the target object, so they go first - * b: '', - * // the source object doesn't have a `c` field - * d: '', // `d` is the next value present in the target object - * e: '', // `e` and `f` are not in the target object so they go last - * f: '', - * } - * - * @param source The source object which fields should be ordered - * @param target The target object which ordering should be followed - * @returns The source object with its fields reordered - */ -function orderObjectFields>( - source: T, - target: Record -): T { - const targetKeysIndexesMap = Object.fromEntries( - Object.keys(target).map((key, i) => [key, i]) - ); - - const orderedSource = Object.fromEntries( - Object.entries(source).sort(([keyA], [keyB]) => { - if (keyA in target && !(keyB in target)) { - return -1; - } - - if (!(keyA in target) && keyB in target) { - return 1; - } - - if (!(keyA in target) && !(keyB in target)) { - return 0; - } - - return targetKeysIndexesMap[keyA] - targetKeysIndexesMap[keyB]; - }) - ) as T; - - for (const [key, value] of Object.entries(orderedSource)) { - if ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - typeof target[key] === "object" && - target[key] !== null && - !Array.isArray(target[key]) - ) { - (orderedSource as Record)[key] = orderObjectFields( - value as Record, - target[key] as Record - ); - } - } - - return orderedSource; -} - -/** - * Given a config diff generates a patch object that can be passed to `experimental_patchConfig` to revert the - * changes in the config object that are described by the config diff. - * - * If the config is for a specific target environment, only the environment config object will be targeted for the patch. - * - * @param configDiff The target config diff - * @param targetEnvironment the target environment if any - * @returns The patch object to pass to `experimental_patchConfig` to revert the changes - */ -export function getConfigPatch( - configDiff: ConfigDiff["diff"], - targetEnvironment?: string | undefined -): RawConfig { - const patchObj: RawConfig = {}; - - populateConfigPatch( - configDiff, - patchObj as Record, - targetEnvironment - ); - - return patchObj; -} - -/** - * Recursive call for `getConfigPatch`, it side-effectfully populates the patch object at the current level - * - * @param diff The current section of the config diff that is being analyzed - * @param patchObj The current section of the patch object that is being populated - * @param targetEnvironment the target environment if any - */ -function populateConfigPatch( - diff: JsonLike, - patchObj: Record | JsonLike[], - targetEnvironment?: string -): void { - if (!diff || typeof diff !== "object") { - return; - } - - if (Array.isArray(diff)) { - // This is a recursive call since we're populating the - // patchObj we know that it is an array - assert(Array.isArray(patchObj)); - return populateConfigPatchArray(diff, patchObj); - } - - // We know that patchObj is not an array here - assert(!Array.isArray(patchObj)); - return populateConfigPatchObject(diff, patchObj, targetEnvironment); -} - -/** - * Recursive call for `getConfigPatch`, it side-effectfully populates the array present at the config patch level - * - * @param diff The current section of the config diff that is being analyzed - * @param patchArray The current section of the patch object that is being populated - */ -function populateConfigPatchArray(diff: JsonLike[], patchArray: JsonLike[]) { - // We create a temporary array since removed elements should be pushed back at the end - const elementsToAppend: JsonLike[] = []; - - Object.values(diff).forEach((element) => { - if (!Array.isArray(element)) { - return; - } - - if (element.length === 1 && element[0] === " ") { - // An array with a single element equal to a simple space indicates - // that the element hasn't been modified - patchArray.push({}); - return; - } - - if (element.length === 2) { - if (element[0] === "-") { - elementsToAppend.push(element[1]); - return; - } - - if (element[0] === "~" && element[1]) { - const patchEl = {}; - populateConfigPatch(element[1], patchEl); - patchArray.push(patchEl); - return; - } - } - }); - elementsToAppend.forEach((el) => patchArray.push(el)); -} - -/** - * Recursive call for `getConfigPatch`, it side-effectfully populates the object present at the config patch level - * - * @param diff The current section of the config diff that is being analyzed - * @param patchObj The current section of the patch object that is being populated - * @param targetEnvironment the target environment if any - */ -function populateConfigPatchObject( - diff: { [id: string]: JsonLike }, - patchObj: Record, - targetEnvironment?: string -) { - const getEnvObj = (targetEnv: string) => { - patchObj.env ??= {}; - const patchObjEnv = patchObj.env as Record>; - patchObjEnv[targetEnv] ??= {}; - return patchObjEnv[targetEnv]; - }; - Object.keys(diff) - .filter((key) => diff[key] && typeof diff[key] === "object") - .forEach((key) => { - if (isModifiedDiffValue(diff[key])) { - if (targetEnvironment) { - getEnvObj(targetEnvironment)[key] = diff[key].__old; - } else { - patchObj[key] = diff[key].__old; - } - return; - } - - if (targetEnvironment) { - getEnvObj(targetEnvironment)[key] ??= Array.isArray(diff[key]) - ? [] - : {}; - } else { - patchObj[key] ??= Array.isArray(diff[key]) ? [] : {}; - } - - Object.entries(diff[key] as Record).forEach( - ([entryKey, entryValue]) => { - if (entryKey.endsWith("__deleted")) { - let patchObjectToUpdate = patchObj[key] as Record; - if (targetEnvironment) { - const envObj = getEnvObj(targetEnvironment); - envObj[key] ??= {}; - patchObjectToUpdate = envObj[key] as Record; - } - patchObjectToUpdate[entryKey.replace("__deleted", "")] = entryValue; - return; - } - } - ); - - if (diff[key] && typeof diff[key] === "object") { - populateConfigPatch( - diff[key], - (targetEnvironment - ? getEnvObj(targetEnvironment)[key] - : patchObj[key]) as Record | JsonLike[] - // Note: we are not passing the target environment since in the recursive calls - // we are already one level deep and dealing with the environment specific - // patch object - ); - return; - } - }); -} +export { + getRemoteConfigDiff, + getConfigPatch, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 2350209ece..6b6dd5d900 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -1,4 +1,10 @@ +import { deploy } from "@cloudflare/deploy-helpers"; +import { analyseBundle } from "../check/commands"; +import { buildContainer } from "../containers/build"; +import { getNormalizedContainerOptions } from "../containers/config"; +import { deployContainers } from "../containers/deploy"; import { createCommand } from "../core/create-command"; +import { provisionBindings } from "../deployment-bundle/bindings"; import { sharedDeployVersionsArgs, validateDeployVersionsArgs, @@ -10,9 +16,9 @@ import { } from "../deployment-bundle/merge-config-args"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; +import { syncWorkersSite } from "../sites"; import { getScriptName } from "../utils/getScriptName"; import { maybeRunAutoConfig, promptForMissingDeployConfig } from "./autoconfig"; -import deploy from "./deploy"; import { maybeDelegateToOpenNextDeployCommand } from "./open-next"; export const deployCommand = createCommand({ @@ -104,7 +110,7 @@ export const deployCommand = createCommand({ validateArgs(args) { validateDeployVersionsArgs(args, "deploy"); }, - async handler(args, { config, ...ctx }) { + async handler(args, { config }) { // --- Step 0. Auto-config --- // const autoConfigResult = await maybeRunAutoConfig(args, config); if (autoConfigResult.aborted) { @@ -142,7 +148,14 @@ export const deployCommand = createCommand({ mergedProps, config, handleBuild, - ctx + { + syncWorkersSite, + provisionBindings, + getNormalizedContainerOptions, + buildContainer, + deployContainers, + analyseBundle, + } ); writeOutput({ diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index dd75827cd7..da475996c3 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -12,7 +12,6 @@ import { getAgentMemoryNamespace, } from "../agent-memory/provisioning"; import { createAISearchNamespace, getAISearchNamespace } from "../ai-search"; -import { convertConfigToBindings } from "../api/startDevWorker/utils"; import { fetchResult } from "../cfetch"; import { createD1Database } from "../d1/create"; import { listDatabases } from "../d1/list"; @@ -42,20 +41,7 @@ import type { WorkerMetadataBinding, } from "@cloudflare/workers-utils"; -export function getBindings( - config: Config | undefined, - options?: { - pages?: boolean; - } -): NonNullable { - if (!config) { - return {}; - } - return convertConfigToBindings(config, { - usePreviewIds: false, - pages: options?.pages, - }); -} +export { getBindings } from "@cloudflare/deploy-helpers"; export type Settings = { bindings: Array; diff --git a/packages/wrangler/src/deployment-bundle/bundle-reporter.ts b/packages/wrangler/src/deployment-bundle/bundle-reporter.ts index 4a82e4b7f6..e578708e72 100644 --- a/packages/wrangler/src/deployment-bundle/bundle-reporter.ts +++ b/packages/wrangler/src/deployment-bundle/bundle-reporter.ts @@ -1,44 +1 @@ -import { Blob } from "node:buffer"; -import { gzipSync } from "node:zlib"; -import chalk from "chalk"; -import { logger } from "../logger"; -import type { CfModule } from "@cloudflare/workers-utils"; - -const ONE_KIB_BYTES = 1024; -// Current max is 3 MiB for free accounts, 10 MiB for paid accounts. -// See https://developers.cloudflare.com/workers/platform/limits/#worker-size -const MAX_GZIP_SIZE_BYTES = 3 * ONE_KIB_BYTES * ONE_KIB_BYTES; - -async function getSize(modules: Pick[]) { - const gzipSize = gzipSync( - await new Blob(modules.map((file) => file.content)).arrayBuffer() - ).byteLength; - const aggregateSize = new Blob(modules.map((file) => file.content)).size; - - return { size: aggregateSize, gzipSize }; -} - -export async function printBundleSize( - main: { - name: string; - content: string; - }, - modules: CfModule[] -) { - const { size, gzipSize } = await getSize([...modules, main]); - - const bundleReport = `${(size / ONE_KIB_BYTES).toFixed(2)} KiB / gzip: ${( - gzipSize / ONE_KIB_BYTES - ).toFixed(2)} KiB`; - - const percentage = (gzipSize / MAX_GZIP_SIZE_BYTES) * 100; - - const colorizedReport = - percentage > 90 - ? chalk.red(bundleReport) - : percentage > 70 - ? chalk.yellow(bundleReport) - : chalk.green(bundleReport); - - logger.log(`Total Upload: ${colorizedReport}`); -} +export { printBundleSize } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/deployment-bundle/capnp.ts b/packages/wrangler/src/deployment-bundle/capnp.ts index 4899eddc25..34f77feac5 100644 --- a/packages/wrangler/src/deployment-bundle/capnp.ts +++ b/packages/wrangler/src/deployment-bundle/capnp.ts @@ -1,39 +1 @@ -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { UserError } from "@cloudflare/workers-utils"; -import { sync as commandExistsSync } from "command-exists"; -import type { CfCapnp } from "@cloudflare/workers-utils"; - -export function handleUnsafeCapnp(capnp: CfCapnp): Buffer { - if (capnp.compiled_schema) { - return readFileSync(resolve(capnp.compiled_schema)); - } - - const { base_path, source_schemas } = capnp; - const capnpSchemas = (source_schemas ?? []).map((x) => - resolve(base_path as string, x) - ); - if (!commandExistsSync("capnp")) { - throw new UserError( - "The capnp compiler is required to upload capnp schemas, but is not present.", - { telemetryMessage: "capnp compiler missing" } - ); - } - const srcPrefix = resolve(base_path ?? "."); - const capnpProcess = spawnSync( - "capnp", - ["compile", "-o-", `--src-prefix=${srcPrefix}`, ...capnpSchemas], - // This number was chosen arbitrarily. If you get ENOBUFS because your compiled schema is still - // too large, then we may need to bump this again or figure out another approach. - // https://github.com/cloudflare/workers-sdk/pull/10217 - { maxBuffer: 3 * 1024 * 1024 } - ); - if (capnpProcess.error) { - throw capnpProcess.error; - } - if (capnpProcess.stderr.length) { - throw new Error(capnpProcess.stderr.toString()); - } - return capnpProcess.stdout; -} +export { handleUnsafeCapnp } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index 360d19dd9e..1a539efcca 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -1,885 +1,5 @@ -import assert from "node:assert"; -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { INHERIT_SYMBOL, UserError } from "@cloudflare/workers-utils"; -import { FormData } from "undici"; -import { - extractBindingsOfType, - isUnsafeBindingType, -} from "../api/startDevWorker/utils"; -import { handleUnsafeCapnp } from "./capnp"; -import type { StartDevWorkerInput } from "../api/startDevWorker/types"; -import type { - AssetConfigMetadata, - CfCapnp, - CfModuleType, - CfSendEmailBindings, - CfWorkerInit, - WorkerMetadata, - WorkerMetadataBinding, -} from "@cloudflare/workers-utils"; - -export const moduleTypeMimeType: { - [type in CfModuleType]: string | undefined; -} = { - esm: "application/javascript+module", - commonjs: "application/javascript", - "compiled-wasm": "application/wasm", - buffer: "application/octet-stream", - text: "text/plain", - python: "text/x-python", - "python-requirement": "text/x-python-requirement", -}; - -function toMimeType(type: CfModuleType): string { - const mimeType = moduleTypeMimeType[type]; - if (mimeType === undefined) { - throw new TypeError("Unsupported module: " + type); - } - - return mimeType; -} - -export function fromMimeType(mimeType: string): CfModuleType { - const moduleType = Object.keys(moduleTypeMimeType).find( - (type) => moduleTypeMimeType[type as CfModuleType] === mimeType - ) as CfModuleType | undefined; - if (moduleType === undefined) { - throw new TypeError("Unsupported mime type: " + mimeType); - } - - return moduleType; -} - -/** - * Creates a `FormData` upload from Worker data and bindings - */ -export function createWorkerUploadForm( - worker: Omit, - bindings: StartDevWorkerInput["bindings"], - options?: { - dryRun?: true; - unsafe?: { metadata?: Record; capnp?: CfCapnp }; - } -): FormData { - const formData = new FormData(); - const { - main, - sourceMaps, - migrations, - compatibility_date, - compatibility_flags, - keepVars, - keepSecrets, - keepBindings, - logpush, - placement, - tail_consumers, - streaming_tail_consumers, - limits, - annotations, - keep_assets, - assets, - observability, - cache, - } = worker; - - const assetConfig: AssetConfigMetadata = { - html_handling: assets?.assetConfig?.html_handling, - not_found_handling: assets?.assetConfig?.not_found_handling, - run_worker_first: assets?.run_worker_first, - _redirects: assets?._redirects, - _headers: assets?._headers, - }; - - // short circuit if static assets upload only - if (assets && !assets.routerConfig.has_user_worker) { - formData.set( - "metadata", - JSON.stringify({ - assets: { - jwt: assets.jwt, - config: assetConfig, - }, - ...(annotations && { annotations }), - ...(compatibility_date && { compatibility_date }), - ...(compatibility_flags && { compatibility_flags }), - }) - ); - return formData; - } - let { modules } = worker; - - const metadataBindings: WorkerMetadataBinding[] = []; - - const plain_text = extractBindingsOfType("plain_text", bindings); - const json_bindings = extractBindingsOfType("json", bindings); - const secret_text = extractBindingsOfType("secret_text", bindings); - const kv_namespaces = extractBindingsOfType("kv_namespace", bindings); - const send_email = extractBindingsOfType("send_email", bindings); - const durable_objects = extractBindingsOfType( - "durable_object_namespace", - bindings - ); - const workflows = extractBindingsOfType("workflow", bindings); - const queues = extractBindingsOfType("queue", bindings); - const r2_buckets = extractBindingsOfType("r2_bucket", bindings); - const d1_databases = extractBindingsOfType("d1", bindings); - const vectorize = extractBindingsOfType("vectorize", bindings); - const ai_search_namespaces = extractBindingsOfType( - "ai_search_namespace", - bindings - ); - const ai_search = extractBindingsOfType("ai_search", bindings); - const websearch = extractBindingsOfType("websearch", bindings)[0]; - const agent_memory = extractBindingsOfType("agent_memory", bindings); - const hyperdrive = extractBindingsOfType("hyperdrive", bindings); - const secrets_store_secrets = extractBindingsOfType( - "secrets_store_secret", - bindings - ); - const artifacts = extractBindingsOfType("artifacts", bindings); - const unsafe_hello_world = extractBindingsOfType( - "unsafe_hello_world", - bindings - ); - const flagship = extractBindingsOfType("flagship", bindings); - const ratelimits = extractBindingsOfType("ratelimit", bindings); - const vpc_services = extractBindingsOfType("vpc_service", bindings); - const vpc_networks = extractBindingsOfType("vpc_network", bindings); - const services = extractBindingsOfType("service", bindings); - const analytics_engine_datasets = extractBindingsOfType( - "analytics_engine", - bindings - ); - const dispatch_namespaces = extractBindingsOfType( - "dispatch_namespace", - bindings - ); - const mtls_certificates = extractBindingsOfType("mtls_certificate", bindings); - const pipelines = extractBindingsOfType("pipeline", bindings); - const worker_loaders = extractBindingsOfType("worker_loader", bindings); - const logfwdr = extractBindingsOfType("logfwdr", bindings); - const wasm_modules = extractBindingsOfType("wasm_module", bindings); - const browser = extractBindingsOfType("browser", bindings)[0]; - const ai = extractBindingsOfType("ai", bindings)[0]; - const images = extractBindingsOfType("images", bindings)[0]; - const stream = extractBindingsOfType("stream", bindings)[0]; - const media = extractBindingsOfType("media", bindings)[0]; - const version_metadata = extractBindingsOfType( - "version_metadata", - bindings - )[0]; - const assetsBinding = extractBindingsOfType("assets", bindings)[0]; - const text_blobs = extractBindingsOfType("text_blob", bindings); - const data_blobs = extractBindingsOfType("data_blob", bindings); - const inherit_bindings = extractBindingsOfType("inherit", bindings); - - inherit_bindings.forEach(({ binding }) => { - metadataBindings.push({ name: binding, type: "inherit" }); - }); - - plain_text.forEach(({ binding, value }) => { - metadataBindings.push({ name: binding, type: "plain_text", text: value }); - }); - json_bindings.forEach(({ binding, value }) => { - metadataBindings.push({ name: binding, type: "json", json: value }); - }); - secret_text.forEach(({ binding, value }) => { - metadataBindings.push({ name: binding, type: "secret_text", text: value }); - }); - - kv_namespaces.forEach(({ id, binding, raw }) => { - // If we're doing a dry run there's no way to know whether or not a KV namespace - // is inheritable or requires provisioning (since that would require hitting the API). - // As such, _assume_ any undefined IDs are inheritable when doing a dry run. - // When this Worker is actually deployed, some may be provisioned at the point of deploy - if (options?.dryRun) { - id ??= INHERIT_SYMBOL; - } - - if (id === undefined) { - throw new UserError(`${binding} bindings must have an "id" field`, { - telemetryMessage: "kv namespace binding missing id", - }); - } - - if (id === INHERIT_SYMBOL) { - metadataBindings.push({ - name: binding, - type: "inherit", - }); - } else { - metadataBindings.push({ - name: binding, - type: "kv_namespace", - namespace_id: id, - raw, - }); - } - }); - - send_email.forEach((emailBinding: CfSendEmailBindings) => { - const destination_address = - "destination_address" in emailBinding - ? emailBinding.destination_address - : undefined; - const allowed_destination_addresses = - "allowed_destination_addresses" in emailBinding - ? emailBinding.allowed_destination_addresses - : undefined; - const allowed_sender_addresses = - "allowed_sender_addresses" in emailBinding - ? emailBinding.allowed_sender_addresses - : undefined; - metadataBindings.push({ - name: emailBinding.name, - type: "send_email", - destination_address, - allowed_destination_addresses, - allowed_sender_addresses, - }); - }); - - durable_objects.forEach(({ name, class_name, script_name, environment }) => { - metadataBindings.push({ - name, - type: "durable_object_namespace", - class_name: class_name, - ...(script_name && { script_name }), - ...(environment && { environment }), - }); - }); - - workflows.forEach(({ binding, name, class_name, script_name, raw }) => { - metadataBindings.push({ - type: "workflow", - name: binding, - workflow_name: name, - class_name, - script_name, - raw, - }); - }); - - queues.forEach(({ binding, queue_name, delivery_delay, raw }) => { - metadataBindings.push({ - type: "queue", - name: binding, - queue_name, - delivery_delay, - raw, - }); - }); - - r2_buckets.forEach(({ binding, bucket_name, jurisdiction, raw }) => { - if (options?.dryRun) { - bucket_name ??= INHERIT_SYMBOL; - } - if (bucket_name === undefined) { - throw new UserError( - `${binding} bindings must have a "bucket_name" field`, - { telemetryMessage: "r2 bucket binding missing bucket_name" } - ); - } - - if (bucket_name === INHERIT_SYMBOL) { - metadataBindings.push({ - name: binding, - type: "inherit", - }); - } else { - metadataBindings.push({ - name: binding, - type: "r2_bucket", - bucket_name, - jurisdiction, - raw, - }); - } - }); - - d1_databases.forEach( - ({ binding, database_id, database_internal_env, raw }) => { - if (options?.dryRun) { - database_id ??= INHERIT_SYMBOL; - } - if (database_id === undefined) { - throw new UserError( - `${binding} bindings must have a "database_id" field`, - { telemetryMessage: "d1 database binding missing database_id" } - ); - } - - if (database_id === INHERIT_SYMBOL) { - metadataBindings.push({ - name: binding, - type: "inherit", - }); - } else { - metadataBindings.push({ - name: binding, - type: "d1", - id: database_id, - internalEnv: database_internal_env, - raw, - }); - } - } - ); - - vectorize.forEach(({ binding, index_name, raw }) => { - metadataBindings.push({ - name: binding, - type: "vectorize", - index_name: index_name, - raw, - }); - }); - - ai_search_namespaces.forEach(({ binding, namespace }) => { - if (options?.dryRun) { - namespace ??= INHERIT_SYMBOL; - } - if (namespace === undefined) { - throw new UserError(`${binding} bindings must have a "namespace" field`, { - telemetryMessage: "ai search namespace binding missing namespace", - }); - } - - if (namespace === INHERIT_SYMBOL) { - metadataBindings.push({ - name: binding, - type: "inherit", - }); - } else { - metadataBindings.push({ - name: binding, - type: "ai_search_namespace", - namespace, - }); - } - }); - - ai_search.forEach(({ binding, instance_name }) => { - metadataBindings.push({ - name: binding, - type: "ai_search", - instance_name, - }); - }); - - if (websearch !== undefined) { - metadataBindings.push({ - name: websearch.binding, - type: "websearch", - }); - } - - agent_memory.forEach(({ binding, namespace }) => { - if (options?.dryRun) { - namespace ??= INHERIT_SYMBOL; - } - if (namespace === undefined) { - throw new UserError(`${binding} bindings must have a "namespace" field`, { - telemetryMessage: false, - }); - } - - if (namespace === INHERIT_SYMBOL) { - metadataBindings.push({ - name: binding, - type: "inherit", - }); - } else { - metadataBindings.push({ - name: binding, - type: "agent_memory", - namespace, - }); - } - }); - - hyperdrive.forEach(({ binding, id }) => { - metadataBindings.push({ - name: binding, - type: "hyperdrive", - id: id, - }); - }); - - secrets_store_secrets.forEach(({ binding, store_id, secret_name }) => { - metadataBindings.push({ - name: binding, - type: "secrets_store_secret", - store_id, - secret_name, - }); - }); - - artifacts.forEach(({ binding, namespace }) => { - metadataBindings.push({ - name: binding, - type: "artifacts", - namespace, - }); - }); - - unsafe_hello_world.forEach(({ binding, enable_timer }) => { - metadataBindings.push({ - name: binding, - type: "unsafe_hello_world", - enable_timer, - }); - }); - - flagship.forEach(({ binding, app_id }) => { - metadataBindings.push({ - name: binding, - type: "flagship", - app_id, - }); - }); - - ratelimits.forEach(({ name, namespace_id, simple }) => { - metadataBindings.push({ - name, - type: "ratelimit", - namespace_id, - simple, - }); - }); - - vpc_services.forEach(({ binding, service_id }) => { - metadataBindings.push({ - name: binding, - type: "vpc_service", - service_id, - }); - }); - - vpc_networks.forEach(({ binding, tunnel_id, network_id }) => { - metadataBindings.push({ - name: binding, - type: "vpc_network", - ...(tunnel_id !== undefined ? { tunnel_id } : { network_id }), - }); - }); - - services.forEach( - ({ - binding, - service, - environment, - entrypoint, - props, - cross_account_grant, - }) => { - metadataBindings.push({ - name: binding, - type: "service", - service, - cross_account_grant, - ...(environment && { environment }), - ...(entrypoint && { entrypoint }), - ...(props && { props }), - }); - } - ); - - analytics_engine_datasets.forEach(({ binding, dataset }) => { - metadataBindings.push({ - name: binding, - type: "analytics_engine", - dataset, - }); - }); - - dispatch_namespaces.forEach(({ binding, namespace, outbound }) => { - metadataBindings.push({ - name: binding, - type: "dispatch_namespace", - namespace, - ...(outbound && { - outbound: { - worker: { - service: outbound.service, - environment: outbound.environment, - }, - params: outbound.parameters?.map((p) => ({ name: p })), - }, - }), - }); - }); - - mtls_certificates.forEach(({ binding, certificate_id }) => { - metadataBindings.push({ - name: binding, - type: "mtls_certificate", - certificate_id, - }); - }); - - pipelines.forEach(({ binding, stream: pipelineStream, pipeline }) => { - if (pipelineStream) { - metadataBindings.push({ - name: binding, - type: "pipelines", - stream: pipelineStream, - }); - } else if (pipeline) { - metadataBindings.push({ - name: binding, - type: "pipelines", - pipeline, - }); - } else { - throw new Error("Pipeline binding must specify a stream or pipeline"); - } - }); - - worker_loaders.forEach(({ binding }) => { - metadataBindings.push({ - name: binding, - type: "worker_loader", - }); - }); - - logfwdr.forEach(({ name, destination }) => { - metadataBindings.push({ - name: name, - type: "logfwdr", - destination, - }); - }); - - wasm_modules.forEach(({ binding: name, source }) => { - metadataBindings.push({ - name, - type: "wasm_module", - part: name, - }); - - formData.set( - name, - new File( - [ - "contents" in source - ? source.contents - : readFileSync(source.path as string), - ], - "path" in source ? (source.path ?? name) : name, - { type: "application/wasm" } - ) - ); - }); - - if (browser !== undefined) { - metadataBindings.push({ - name: browser.binding, - type: "browser", - raw: browser.raw, - }); - } - - if (ai !== undefined) { - metadataBindings.push({ - name: ai.binding, - staging: ai.staging, - type: "ai", - raw: ai.raw, - }); - } - - if (images !== undefined) { - metadataBindings.push({ - name: images.binding, - type: "images", - raw: images.raw, - }); - } - - if (stream !== undefined) { - metadataBindings.push({ - name: stream.binding, - type: "stream", - }); - } - - if (media !== undefined) { - metadataBindings.push({ - name: media.binding, - type: "media", - }); - } - - if (version_metadata !== undefined) { - metadataBindings.push({ - name: version_metadata.binding, - type: "version_metadata", - }); - } - - if (assetsBinding !== undefined) { - metadataBindings.push({ - name: assetsBinding.binding, - type: "assets", - }); - } - - text_blobs.forEach(({ binding: name, source }) => { - metadataBindings.push({ - name, - type: "text_blob", - part: name, - }); - - if (name !== "__STATIC_CONTENT_MANIFEST") { - if ("contents" in source) { - formData.set( - name, - new File([source.contents], source.path ?? name, { - type: "text/plain", - }) - ); - } else { - formData.set( - name, - new File([readFileSync(source.path)], source.path, { - type: "text/plain", - }) - ); - } - } - }); - - data_blobs.forEach(({ binding: name, source }) => { - metadataBindings.push({ - name, - type: "data_blob", - part: name, - }); - - formData.set( - name, - new File( - [ - "contents" in source - ? source.contents - : readFileSync(source.path as string), - ], - "path" in source ? (source.path ?? name) : name, - { type: "application/octet-stream" } - ) - ); - }); - - // Handle generic unsafe_* bindings (excluding unsafe_hello_world which is handled above) - for (const [bindingName, config] of Object.entries(bindings ?? {})) { - if ( - isUnsafeBindingType(config.type) && - config.type !== "unsafe_hello_world" - ) { - const { type, ...data } = config; - metadataBindings.push({ - name: bindingName, - type: type.slice("unsafe_".length), - ...data, - } as WorkerMetadataBinding); - } - } - - const manifestModuleName = "__STATIC_CONTENT_MANIFEST"; - const hasManifest = modules?.some(({ name }) => name === manifestModuleName); - if (hasManifest && main.type === "esm") { - assert(modules !== undefined); - // Each modules-format worker has a virtual file system for module - // resolution. For example, uploading modules with names `1.mjs`, - // `a/2.mjs` and `a/b/3.mjs`, creates virtual directories `a` and `a/b`. - // `1.mjs` is in the virtual root directory. - // - // The above code adds the `__STATIC_CONTENT_MANIFEST` module to the root - // directory. This means `import manifest from "__STATIC_CONTENT_MANIFEST"` - // will only work if the importing module is also in the root. If the - // importing module was `a/b/3.mjs` for example, the import would need to - // be `import manifest from "../../__STATIC_CONTENT_MANIFEST"`. - // - // When Wrangler bundles all user code, this isn't a problem, as code is - // only ever uploaded to the root. However, once `--no-bundle` or - // `find_additional_modules` is enabled, the user controls the directory - // structure. - // - // To fix this, if we've got a modules-format worker, we add stub modules - // in each subdirectory that re-export the manifest module from the root. - // This allows the manifest to be imported as `__STATIC_CONTENT_MANIFEST` - // in every directory, whilst avoiding duplication of the manifest. - - // Collect unique subdirectories - const subDirs = new Set( - modules.map((module) => path.posix.dirname(module.name)) - ); - for (const subDir of subDirs) { - // Ignore `.` as it's not a subdirectory, and we don't want to - // register the manifest module in the root twice. - if (subDir === ".") { - continue; - } - const relativePath = path.posix.relative(subDir, manifestModuleName); - const filePath = path.posix.join(subDir, manifestModuleName); - modules.push({ - name: filePath, - filePath, - content: `export { default } from ${JSON.stringify(relativePath)};`, - type: "esm", - }); - } - } - - if (main.type === "commonjs") { - // This is a service-worker format worker. - for (const module of Object.values([...(modules || [])])) { - if (module.name === "__STATIC_CONTENT_MANIFEST") { - // Add the manifest to the form data. - formData.set( - module.name, - new File([module.content], module.name, { - type: "text/plain", - }) - ); - // And then remove it from the modules collection - modules = modules?.filter((m) => m !== module); - } else if ( - module.type === "compiled-wasm" || - module.type === "text" || - module.type === "buffer" - ) { - // Convert all wasm/text/data modules into `wasm_module`/`text_blob`/`data_blob` bindings. - // The "name" of the module is a file path. We use it - // to instead be a "part" of the body, and a reference - // that we can use inside our source. This identifier has to be a valid - // JS identifier, so we replace all non alphanumeric characters - // with an underscore. - const name = module.name.replace(/[^a-zA-Z0-9_$]/g, "_"); - metadataBindings.push({ - name, - type: - module.type === "compiled-wasm" - ? "wasm_module" - : module.type === "text" - ? "text_blob" - : "data_blob", - part: name, - }); - - // Add the module to the form data. - formData.set( - name, - new File([module.content], module.name, { - type: - module.type === "compiled-wasm" - ? "application/wasm" - : module.type === "text" - ? "text/plain" - : "application/octet-stream", - }) - ); - // And then remove it from the modules collection - modules = modules?.filter((m) => m !== module); - } - } - } - - let capnpSchemaOutputFile: string | undefined; - if (options?.unsafe?.capnp) { - const capnpOutput = handleUnsafeCapnp(options.unsafe.capnp); - capnpSchemaOutputFile = `./capnp-${Date.now()}.compiled`; - formData.set( - capnpSchemaOutputFile, - new File([capnpOutput], capnpSchemaOutputFile, { - type: "application/octet-stream", - }) - ); - } - - let keep_bindings: WorkerMetadata["keep_bindings"] = undefined; - if (keepVars) { - keep_bindings ??= []; - keep_bindings.push("plain_text", "json"); - } - if (keepSecrets) { - keep_bindings ??= []; - keep_bindings.push("secret_text", "secret_key"); - } - if (keepBindings) { - keep_bindings ??= []; - keep_bindings.push(...keepBindings); - } - - const metadata: WorkerMetadata = { - ...(main.type !== "commonjs" - ? { main_module: main.name } - : { body_part: main.name }), - bindings: metadataBindings, - containers: - worker.containers === undefined - ? undefined - : worker.containers.map((c) => ({ class_name: c.class_name })), - - ...(compatibility_date && { compatibility_date }), - ...(compatibility_flags && { - compatibility_flags, - }), - ...(migrations && { migrations }), - capnp_schema: capnpSchemaOutputFile, - ...(keep_bindings && { keep_bindings }), - ...(logpush !== undefined && { logpush }), - ...(placement && { placement }), - ...(tail_consumers && { tail_consumers }), - ...(streaming_tail_consumers && { streaming_tail_consumers }), - ...(limits && { limits }), - ...(annotations && { annotations }), - ...(keep_assets !== undefined && { keep_assets }), - ...(assets && { - assets: { - jwt: assets.jwt, - config: assetConfig, - }, - }), - ...(observability && { observability }), - ...(cache && { cache_options: cache }), - }; - - if (options?.unsafe?.metadata !== undefined) { - for (const key of Object.keys(options.unsafe.metadata)) { - metadata[key] = options.unsafe.metadata[key]; - } - } - - formData.set("metadata", JSON.stringify(metadata)); - - if (main.type === "commonjs" && modules && modules.length > 0) { - throw new TypeError( - "More than one module can only be specified when type = 'esm'" - ); - } - - for (const module of [main].concat(modules || [])) { - formData.set( - module.name, - new File([module.content], module.name, { - type: toMimeType(module.type ?? main.type ?? "esm"), - }) - ); - } - - for (const sourceMap of sourceMaps || []) { - formData.set( - sourceMap.name, - new File([sourceMap.content], sourceMap.name, { - type: "application/source-map", - }) - ); - } - - return formData; -} +export { + moduleTypeMimeType, + fromMimeType, + createWorkerUploadForm, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/deployment-bundle/merge-config-args.ts b/packages/wrangler/src/deployment-bundle/merge-config-args.ts index 24e0e054b4..0b91a1097c 100644 --- a/packages/wrangler/src/deployment-bundle/merge-config-args.ts +++ b/packages/wrangler/src/deployment-bundle/merge-config-args.ts @@ -1,3 +1,4 @@ +import { generatePreviewAlias } from "@cloudflare/deploy-helpers"; import { getCIGeneratePreviewAlias, getCIOverrideName, @@ -14,7 +15,6 @@ import { requireAuth } from "../user"; import { collectKeyValues } from "../utils/collectKeyValues"; import { getScriptName } from "../utils/getScriptName"; import { useServiceEnvironmentApi } from "../utils/useServiceEnvironments"; -import { generatePreviewAlias } from "../versions/upload"; import { getEntry } from "./entry"; import type { HandlerArgs } from "../core/types"; import type { DeployArgs } from "../deploy/index"; diff --git a/packages/wrangler/src/deployment-bundle/node-compat.ts b/packages/wrangler/src/deployment-bundle/node-compat.ts index 2ec064ff95..39daa88ad4 100644 --- a/packages/wrangler/src/deployment-bundle/node-compat.ts +++ b/packages/wrangler/src/deployment-bundle/node-compat.ts @@ -1,54 +1 @@ -import { UserError } from "@cloudflare/workers-utils"; -import { getNodeCompat } from "miniflare"; -import { logger } from "../logger"; -import type { NodeJSCompatMode } from "miniflare"; - -/** - * Computes and validates the Node.js compatibility mode we are running. - * - * NOTES: - * - The v2 mode is configured via `nodejs_compat_v2` compat flag or via `nodejs_compat` plus a compatibility date of Sept 23rd. 2024 or later. - * - See `EnvironmentInheritable` for `noBundle`. - * - * @param compatibilityDateStr The compatibility date - * @param compatibilityFlags The compatibility flags - * @param noBundle Whether to skip internal build steps and directly deploy script - * - */ export function validateNodeCompatMode( - compatibilityDateStr: string = "2000-01-01", // Default to some arbitrary old date - compatibilityFlags: string[], - { - noBundle = undefined, - }: { - noBundle?: boolean; - } -): NodeJSCompatMode { - const { - mode, - hasNodejsCompatFlag, - hasNodejsCompatV2Flag, - hasExperimentalNodejsCompatV2Flag, - } = getNodeCompat(compatibilityDateStr, compatibilityFlags); - - if (hasExperimentalNodejsCompatV2Flag) { - throw new UserError( - "The `experimental:` prefix on `nodejs_compat_v2` is no longer valid. Please remove it and try again.", - { telemetryMessage: "experimental nodejs compat v2 prefix unsupported" } - ); - } - - if (hasNodejsCompatFlag && hasNodejsCompatV2Flag) { - throw new UserError( - "The `nodejs_compat` and `nodejs_compat_v2` compatibility flags cannot be used in together. Please select just one.", - { telemetryMessage: "conflicting nodejs compat flags" } - ); - } - - if (noBundle && hasNodejsCompatV2Flag) { - logger.warn( - "`nodejs_compat_v2` compatibility flag and `--no-bundle` can't be used together. If you want to polyfill Node.js built-ins and disable Wrangler's bundling, please polyfill as part of your own bundling process." - ); - } - - return mode; -} +export { validateNodeCompatMode } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/deployment-bundle/resolve-config-args.ts b/packages/wrangler/src/deployment-bundle/resolve-config-args.ts index c8ef8fcad7..6ee45c98d7 100644 --- a/packages/wrangler/src/deployment-bundle/resolve-config-args.ts +++ b/packages/wrangler/src/deployment-bundle/resolve-config-args.ts @@ -1,12 +1,13 @@ -import path from "node:path"; +import { validateRoutes } from "@cloudflare/deploy-helpers"; import { UserError } from "@cloudflare/workers-utils"; import { getAssetsOptions } from "../assets"; -import { logger } from "../logger"; import { getScriptName } from "../utils/getScriptName"; import { useServiceEnvironmentApi } from "../utils/useServiceEnvironments"; import type { triggersDeployCommand } from "../triggers"; import type { AssetsOptions, Config, Route } from "@cloudflare/workers-utils"; +export { validateRoutes } from "@cloudflare/deploy-helpers"; + /** * for wrangler triggers deploy - non dry-run/API calling validation and resolution */ @@ -53,66 +54,3 @@ function resolveRoutes( function resolveCronTriggers(args: { triggers?: string[] }, config: Config) { return args.triggers ?? config.triggers?.crons; } - -export const validateRoutes = (routes: Route[], assets?: AssetsOptions) => { - const invalidRoutes: Record = {}; - const mountedAssetRoutes: string[] = []; - - for (const route of routes) { - if (typeof route !== "string" && route.custom_domain) { - if (route.pattern.includes("*")) { - invalidRoutes[route.pattern] ??= []; - invalidRoutes[route.pattern].push( - `Wildcard operators (*) are not allowed in Custom Domains` - ); - } - if (route.pattern.includes("/")) { - invalidRoutes[route.pattern] ??= []; - invalidRoutes[route.pattern].push( - `Paths are not allowed in Custom Domains` - ); - } - } else if ( - // If we have Assets but we're not always hitting the Worker then validate - assets?.directory !== undefined && - assets.routerConfig.invoke_user_worker_ahead_of_assets !== true - ) { - const pattern = typeof route === "string" ? route : route.pattern; - const components = pattern.split("/"); - - // If this isn't `domain.com/*` then we're mounting to a path - if (!(components.length === 2 && components[1] === "*")) { - mountedAssetRoutes.push(pattern); - } - } - } - if (Object.keys(invalidRoutes).length > 0) { - throw new UserError( - `Invalid Routes:\n` + - Object.entries(invalidRoutes) - .map(([route, errors]) => `${route}:\n` + errors.join("\n")) - .join(`\n\n`), - { telemetryMessage: "deploy invalid routes" } - ); - } - - if (mountedAssetRoutes.length > 0 && assets?.directory !== undefined) { - const relativeAssetsDir = path.relative(process.cwd(), assets.directory); - - logger.once.warn( - `Warning: The following routes will attempt to serve Assets on a configured path:\n${mountedAssetRoutes - .map((route) => { - const routeNoScheme = route.replace(/https?:\/\//g, ""); - const assetPath = path.join( - relativeAssetsDir, - routeNoScheme.substring(routeNoScheme.indexOf("/")) - ); - return ` β€’ ${route} (Will match assets: ${assetPath})`; - }) - .join("\n")}` + - (assets?.routerConfig.has_user_worker - ? "\n\nRequests not matching an asset will be forwarded to the Worker's code." - : "") - ); - } -}; diff --git a/packages/wrangler/src/deployment-bundle/secrets-validation.ts b/packages/wrangler/src/deployment-bundle/secrets-validation.ts index bc08a2c27f..c2f6a1251a 100644 --- a/packages/wrangler/src/deployment-bundle/secrets-validation.ts +++ b/packages/wrangler/src/deployment-bundle/secrets-validation.ts @@ -1,79 +1,4 @@ -import { APIError, UserError } from "@cloudflare/workers-utils"; -import { INVALID_INHERIT_BINDING_CODE } from "../utils/error-codes"; -import type { StartDevWorkerInput } from "../api/startDevWorker/types"; -import type { Config } from "@cloudflare/workers-utils"; - -type SecretsValidationOptions = - | { type: "deploy"; workerExists: boolean } - | { type: "upload" }; - -/** - * When `secrets.required` is defined in config, validate the secrets exist on the Worker. - * For deploy, if the Worker doesn't exist yet, fail immediately. - * For upload, always add inherit bindings β€” the API handles the case where - * the Worker doesn't exist (versions upload cannot create new Workers). - * Secrets already provided (e.g. via --secrets-file) are excluded since - * they are part of the upload and don't need to be inherited. - */ -export function addRequiredSecretsInheritBindings( - config: Config, - bindings: NonNullable, - options: SecretsValidationOptions -): void { - if (!config.secrets?.required?.length) { - return; - } - - const inheritedSecrets = config.secrets.required.filter( - (secretName) => !(secretName in bindings) - ); - - if (inheritedSecrets.length === 0) { - return; - } - - if (options.type === "deploy" && !options.workerExists) { - throw new UserError( - `The following required secrets have not been set: ${inheritedSecrets.join(", ")}\n` + - `Use \`wrangler secret put \` to set secrets before deploying.\n` + - `See https://developers.cloudflare.com/workers/configuration/secrets/#secrets-on-deployed-workers for more information.`, - { telemetryMessage: "required secrets missing before deploy" } - ); - } - - for (const secretName of inheritedSecrets) { - bindings[secretName] = { type: "inherit" }; - } -} - -/** - * Reformats API errors for strict inherit binding validation failures into - * user-friendly messages listing the missing required secrets. - * The API returns all missing inherit bindings at once, each as a separate - * error in response.errors, which maps to individual err.notes entries. - */ -export function handleMissingSecretsError( - err: unknown, - config: Config, - options: SecretsValidationOptions -): void { - if (!(err instanceof APIError) || err.code !== INVALID_INHERIT_BINDING_CODE) { - return; - } - - const missingSecretNames = err.notes - .map((note) => note.text.match(/^inherit binding '(.+?)' is invalid/)) - .filter((match): match is RegExpMatchArray => match !== null) - .map((match) => match[1]) - .filter((secretName) => config.secrets?.required?.includes(secretName)); - - if (missingSecretNames.length > 0) { - err.preventReport(); - throw new UserError( - `The following required secrets have not been set: ${missingSecretNames.join(", ")}\n` + - `Use \`wrangler ${options.type === "deploy" ? "secret put" : "versions secret put"} \` to set secrets before ${options.type === "deploy" ? "deploying" : "uploading"}.\n` + - `See https://developers.cloudflare.com/workers/configuration/secrets/#secrets-on-deployed-workers for more information.`, - { telemetryMessage: "required secrets missing during upload or deploy" } - ); - } -} +export { + addRequiredSecretsInheritBindings, + handleMissingSecretsError, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/deployment-bundle/source-maps.ts b/packages/wrangler/src/deployment-bundle/source-maps.ts index 90eccb29c4..5763c92326 100644 --- a/packages/wrangler/src/deployment-bundle/source-maps.ts +++ b/packages/wrangler/src/deployment-bundle/source-maps.ts @@ -1,184 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { BundleResult, SourceMapMetadata } from "./bundle"; -import type { CfModule, CfWorkerSourceMap } from "@cloudflare/workers-utils"; -import type { RawSourceMap } from "source-map"; - -/** - * Loads source maps that appear in the given build output. - */ -export function loadSourceMaps( - main: CfModule, - modules: CfModule[], - bundle: Partial -): CfWorkerSourceMap[] { - const { sourceMapPath, sourceMapMetadata } = bundle; - if (sourceMapPath && sourceMapMetadata) { - // This worker was bundled by Wrangler, so we already know where - // the source map is located. - return loadSourceMap(main, sourceMapPath, sourceMapMetadata); - } else { - // Don't know where source maps are located, so need to find - // them by scanning the contents of the user-specified modules. - return scanSourceMaps([main, ...modules]); - } -} - -/** - * Load and normalize a source map emitted by Wrangler using the given path and - * directory metadata. - */ -function loadSourceMap( - { name, filePath }: CfModule, - sourceMapPath: string, - { entryDirectory }: SourceMapMetadata -): CfWorkerSourceMap[] { - if (filePath === undefined) { - return []; - } - const map = JSON.parse( - fs.readFileSync(path.join(entryDirectory, sourceMapPath), "utf8") - ) as RawSourceMap; - // Overwrite the file property of the source map to match the name of the - // main module in the multipart upload. - map.file = name; - if (map.sourceRoot) { - // Remove the temporary directory prefix generated by Wrangler that appears - // in the source root path. - const sourceRootPath = path.dirname( - path.join(entryDirectory, sourceMapPath) - ); - map.sourceRoot = path.relative(sourceRootPath, map.sourceRoot); - } - map.sources = map.sources.map((source) => { - const originalPath = path.join(path.dirname(filePath), source); - return path.relative(entryDirectory, originalPath); - }); - return [ - { - name: name + ".map", - content: JSON.stringify(map), - }, - ]; -} - -/** - * Find source maps by scanning module contents for special `//# - * sourceMappingURL=` comments pointing to source map files. - */ -function scanSourceMaps(modules: CfModule[]): CfWorkerSourceMap[] { - const maps: CfWorkerSourceMap[] = []; - for (const module of modules) { - const maybeSourcemap = sourceMapForModule(module); - if (maybeSourcemap) { - maps.push(maybeSourcemap); - } - } - return maps; -} - -/** - * Attaches a sourcemap, if found, to a JavaScript module. - */ -export function tryAttachSourcemapToModule(module: CfModule) { - if (module.type !== "esm" && module.type !== "commonjs") { - return; - } - - const sourceMap = sourceMapForModule(module); - if (sourceMap) { - module.sourceMap = sourceMap; - } -} - -function getSourceMappingUrl(module: CfModule): string | undefined { - const content = - typeof module.content === "string" - ? module.content - : new TextDecoder().decode(module.content); - - const commentPrefix = "//# sourceMappingURL="; - - // Scan trailing lines from the bottom up so that the `//# sourceMappingURL=` - // comment is still detected when other magic comments (e.g. - // `//# debugId=` injected by `sentry-cli sourcemaps inject`) follow it. - const lines = content.split("\n"); - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (line.length === 0) { - continue; - } - if (line.startsWith(commentPrefix)) { - // Assume the source map path in the comment is relative to the - // generated file it appears in. - const commentPath = stripPrefix(commentPrefix, line).trim(); - if (commentPath.startsWith("data:")) { - throw new Error( - `Unsupported source map path in ${module.filePath}: expected file path but found data URL.` - ); - } - return commentPath; - } - // Skip past other trailing magic comments (`//# debugId=`, `//# sourceURL=`, - // etc.) and keep looking for the sourceMappingURL above them. Stop as - // soon as we hit any non-magic-comment content. - if (!line.startsWith("//#") && !line.startsWith("//@")) { - return undefined; - } - } - - return undefined; -} - -function sourceMapForModule(module: CfModule): CfWorkerSourceMap | undefined { - if (module.filePath === undefined) { - // virtual modules don't have sourcemaps so we can exit early here - return undefined; - } - - const sourceMapUrl = getSourceMappingUrl(module); - if (sourceMapUrl === undefined) { - return; - } - - // Convert source map path to an absolute path that we can read. - const sourcemapPath = path.join(path.dirname(module.filePath), sourceMapUrl); - if (!fs.existsSync(sourcemapPath)) { - throw new Error( - `Invalid source map path in ${module.filePath}: ${sourcemapPath} does not exist.` - ); - } - const map = JSON.parse( - fs.readFileSync(sourcemapPath, "utf8") - ) as RawSourceMap; - // Overwrite the file property of the sourcemap to match the name of the - // corresponding module in the multipart upload. - map.file = module.name; - if (map.sourceRoot) { - map.sourceRoot = cleanPathPrefix(map.sourceRoot); - } - map.sources = map.sources.map(cleanPathPrefix); - return { - name: module.name + ".map", - content: JSON.stringify(map), - }; -} - -/** - * Removes leading "." and ".." segments from the given file path. - */ -function cleanPathPrefix(filePath: string): string { - // Don't assume that the path separator matches the current OS. - return stripPrefix( - "..\\", - stripPrefix("../", stripPrefix(".\\", stripPrefix("./", filePath))) - ); -} - -function stripPrefix(prefix: string, input: string): string { - let stripped = input; - while (stripped.startsWith(prefix)) { - stripped = stripped.slice(prefix.length); - } - return stripped; -} +export { + loadSourceMaps, + tryAttachSourcemapToModule, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 86c6f82ac1..0c39d33fc5 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import events from "node:events"; +import { convertConfigToBindings } from "@cloudflare/deploy-helpers"; import { configFileName, formatConfigSnippet, @@ -7,7 +8,6 @@ import { } from "@cloudflare/workers-utils"; import { getHostFromRoute } from "@cloudflare/workers-utils"; import { isWebContainer } from "@webcontainer/env"; -import { convertConfigToBindings } from "./api/startDevWorker/utils"; import { getAssetsOptions } from "./assets"; import { createCommand } from "./core/create-command"; import { validateRoutes } from "./deployment-bundle/resolve-config-args"; diff --git a/packages/wrangler/src/dev/create-worker-preview.ts b/packages/wrangler/src/dev/create-worker-preview.ts index 1d1c26b502..039a431573 100644 --- a/packages/wrangler/src/dev/create-worker-preview.ts +++ b/packages/wrangler/src/dev/create-worker-preview.ts @@ -4,7 +4,6 @@ import { getWorkersDevSubdomain } from "@cloudflare/deploy-helpers"; import { ParseError, parseJSON, UserError } from "@cloudflare/workers-utils"; import { fetch } from "undici"; import { fetchResult } from "../cfetch"; -import { createDeployHelpersContext } from "../core/deploy-helpers-context"; import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; import { logger } from "../logger"; import { getAccessHeaders } from "../user/access"; @@ -219,7 +218,6 @@ export async function createPreviewSession( const subdomain = await getWorkersDevSubdomain( complianceConfig, account.accountId, - createDeployHelpersContext({ apiToken }), { abortSignal: withTimeout(abortSignal), } diff --git a/packages/wrangler/src/dev/inspect.ts b/packages/wrangler/src/dev/inspect.ts index c8efc68274..cd13388e23 100644 --- a/packages/wrangler/src/dev/inspect.ts +++ b/packages/wrangler/src/dev/inspect.ts @@ -10,7 +10,12 @@ import { logger } from "../logger"; import { getSourceMappedString } from "../sourcemap"; import type { EsbuildBundle } from "../dev/use-esbuild"; import type Protocol from "devtools-protocol"; -import type { RawSourceMap } from "source-map"; + +interface RawSourceMap { + file?: string; + sourceRoot?: string; + sources: string[]; +} /** * This function converts a message serialized as a devtools event diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 939bd59b23..230c223621 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -1,16 +1,16 @@ import assert from "node:assert"; import path from "node:path"; import { getDevContainerImageName } from "@cloudflare/containers-shared"; +import { + extractBindingsOfType, + isUnsafeBindingType, +} from "@cloudflare/deploy-helpers"; import { getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, UserError, } from "@cloudflare/workers-utils"; import { Log, LogLevel } from "miniflare"; -import { - extractBindingsOfType, - isUnsafeBindingType, -} from "../../api/startDevWorker/utils"; import { ModuleTypeToRuleType } from "../../deployment-bundle/module-collection"; import { withSourceURLs } from "../../deployment-bundle/source-url"; import { logger } from "../../logger"; diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 8067fb18e9..31bfcea842 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -5,9 +5,9 @@ import { generateContainerBuildId } from "@cloudflare/containers-shared"; import { getRegistryPath } from "@cloudflare/workers-utils"; import dedent from "ts-dedent"; import { DevEnv } from "../api"; +import { convertStartDevOptionsToBindings } from "../api/startDevWorker/binding-utils"; import { MultiworkerRuntimeController } from "../api/startDevWorker/MultiworkerRuntimeController"; import { NoOpProxyController } from "../api/startDevWorker/NoOpProxyController"; -import { convertStartDevOptionsToBindings } from "../api/startDevWorker/utils"; import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; import registerDevHotKeys from "../dev/hotkeys"; import isInteractive from "../is-interactive"; @@ -20,7 +20,7 @@ import { collectPlainTextVars, } from "../utils/collectKeyValues"; import type { AsyncHook, StartDevWorkerInput, Trigger } from "../api"; -import type { StartDevOptionsBindings } from "../api/startDevWorker/utils"; +import type { StartDevOptionsBindings } from "../api/startDevWorker/binding-utils"; import type { StartDevOptions } from "../dev"; import type { EnablePagesAssetsServiceBindingOptions } from "../miniflare-cli/types"; import type { CfAccount } from "./create-worker-preview"; diff --git a/packages/wrangler/src/durable.ts b/packages/wrangler/src/durable.ts index 5e939c440c..9217078032 100644 --- a/packages/wrangler/src/durable.ts +++ b/packages/wrangler/src/durable.ts @@ -1,121 +1,15 @@ -import assert from "node:assert"; -import { configFileName } from "@cloudflare/workers-utils"; -import { fetchResult } from "./cfetch"; -import { logger } from "./logger"; -import { isWorkerNotFoundError } from "./utils/worker-not-found-error"; +import { getMigrationsToUpload as getMigrationsToUploadBase } from "@cloudflare/deploy-helpers"; import type { CfWorkerInit, Config } from "@cloudflare/workers-utils"; -/** - * For a given Worker + migrations config, figure out which migrations - * to upload based on the current migration tag of the deployed Worker. - */ export async function getMigrationsToUpload( scriptName: string, props: { accountId: string | undefined; config: Config; - /** Deprecated service environments. Previously known as !legacyEnv :-) */ useServiceEnvironments: boolean | undefined; env: string | undefined; dispatchNamespace: string | undefined; } ): Promise { - const { config, accountId } = props; - - assert(accountId, "Missing accountId"); - // if config.migrations - let migrations; - if (config.migrations.length > 0) { - // get current migration tag - type ScriptData = { id: string; migration_tag?: string }; - let script: ScriptData | undefined; - if (props.dispatchNamespace) { - try { - const scriptData = await fetchResult<{ script: ScriptData }>( - config, - `/accounts/${accountId}/workers/dispatch/namespaces/${props.dispatchNamespace}/scripts/${scriptName}` - ); - script = scriptData.script; - } catch (err) { - suppressNotFoundError(err); - } - } else { - if (props.useServiceEnvironments) { - try { - if (props.env) { - const scriptData = await fetchResult<{ - script: ScriptData; - }>( - config, - `/accounts/${accountId}/workers/services/${scriptName}/environments/${props.env}` - ); - script = scriptData.script; - } else { - const scriptData = await fetchResult<{ - default_environment: { - script: ScriptData; - }; - }>(config, `/accounts/${accountId}/workers/services/${scriptName}`); - script = scriptData.default_environment.script; - } - } catch (err) { - suppressNotFoundError(err); - } - } else { - const scripts = await fetchResult( - config, - `/accounts/${accountId}/workers/scripts` - ); - script = scripts.find(({ id }) => id === scriptName); - } - } - - if (script?.migration_tag) { - // was already published once - const scriptMigrationTag = script.migration_tag; - const foundIndex = config.migrations.findIndex( - (migration) => migration.tag === scriptMigrationTag - ); - if (foundIndex === -1) { - logger.warn( - `The published script ${scriptName} has a migration tag "${script.migration_tag}, which was not found in your ${configFileName(config.configPath)} file. You may have already deleted it. Applying all available migrations to the script...` - ); - migrations = { - old_tag: script.migration_tag, - new_tag: config.migrations[config.migrations.length - 1].tag, - steps: config.migrations.map(({ tag: _tag, ...rest }) => rest), - }; - } else { - if (foundIndex !== config.migrations.length - 1) { - // there are new migrations to send up - migrations = { - old_tag: script.migration_tag, - new_tag: config.migrations[config.migrations.length - 1].tag, - steps: config.migrations - .slice(foundIndex + 1) - .map(({ tag: _tag, ...rest }) => rest), - }; - } - // else, we're up to date, no migrations to send - } - } else { - // first time publishing durable objects to this script, - // so we send all the migrations - migrations = { - new_tag: config.migrations[config.migrations.length - 1].tag, - steps: config.migrations.map(({ tag: _tag, ...rest }) => rest), - }; - } - } - return migrations; + return getMigrationsToUploadBase(scriptName, props); } - -const suppressNotFoundError = (err: unknown) => { - if ( - !isWorkerNotFoundError(err) && - (err as { code: number }).code !== 10092 // workers.api.error.environment_not_found, so the script wasn't published to this environment yet - ) { - throw err; - } - // else it's a 404, no script found, and we can proceed -}; diff --git a/packages/wrangler/src/environments/index.ts b/packages/wrangler/src/environments/index.ts index dd929648d3..83b5a38ed3 100644 --- a/packages/wrangler/src/environments/index.ts +++ b/packages/wrangler/src/environments/index.ts @@ -1,59 +1,6 @@ -import { - ENVIRONMENT_TAG_PREFIX, - SERVICE_TAG_PREFIX, -} from "@cloudflare/workers-utils"; -import { logger } from "../logger"; -import { useServiceEnvironments } from "../utils/useServiceEnvironments"; -import type { Config } from "@cloudflare/workers-utils"; - -export function hasDefinedEnvironments(config: Config) { - return ( - !useServiceEnvironments(config) && - Boolean(config.definedEnvironments?.length) - ); -} - -export function applyServiceAndEnvironmentTags(config: Config, tags: string[]) { - const env = config.targetEnvironment; - const serviceName = config.topLevelName; - const shouldApplyTags = hasDefinedEnvironments(config); - - if (shouldApplyTags && !serviceName) { - logger.warn( - "No top-level `name` has been defined in Wrangler configuration. Add a top-level `name` to group this Worker together with its sibling environments in the Cloudflare dashboard." - ); - } - - const serviceTag = - shouldApplyTags && serviceName - ? `${SERVICE_TAG_PREFIX}${serviceName}` - : null; - const environmentTag = - serviceTag && env ? `${ENVIRONMENT_TAG_PREFIX}${env}` : null; - - tags = tags.filter( - (tag) => - !tag.startsWith(SERVICE_TAG_PREFIX) && - !tag.startsWith(ENVIRONMENT_TAG_PREFIX) - ); - - if (serviceTag) { - tags.push(serviceTag); - } - - if (environmentTag) { - tags.push(environmentTag); - } - - return tags; -} - -export function warnOnErrorUpdatingServiceAndEnvironmentTags() { - logger.warn( - "Could not apply service and environment tags. This Worker will not appear grouped together with its sibling environments in the Cloudflare dashboard." - ); -} - -export function tagsAreEqual(a: string[], b: string[]) { - return a.length === b.length && a.every((el, i) => b[i] === el); -} +export { + applyServiceAndEnvironmentTags, + hasDefinedEnvironments, + tagsAreEqual, + warnOnErrorUpdatingServiceAndEnvironmentTags, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/logger.ts b/packages/wrangler/src/logger.ts index 11ca4c7d59..1b835cd73f 100644 --- a/packages/wrangler/src/logger.ts +++ b/packages/wrangler/src/logger.ts @@ -3,6 +3,7 @@ import { format } from "node:util"; import { getEnvironmentVariableFactory, getSanitizeLogs, + LOGGER_LEVELS, ParseError, } from "@cloudflare/workers-utils"; import chalk from "chalk"; @@ -10,18 +11,11 @@ import CLITable from "cli-table3"; import { formatMessagesSync } from "esbuild"; import { formatMessage } from "./utils/format-message"; import { appendToDebugLogFile } from "./utils/log-file"; +import type { LoggerLevel } from "@cloudflare/workers-utils"; import type { Message } from "esbuild"; -export const LOGGER_LEVELS = { - none: -1, - error: 0, - warn: 1, - info: 2, - log: 3, - debug: 4, -} as const; - -export type LoggerLevel = keyof typeof LOGGER_LEVELS; +export { LOGGER_LEVELS }; +export type { LoggerLevel }; /** A map from LOGGER_LEVEL to the error `kind` needed by `formatMessagesSync()`. */ const LOGGER_LEVEL_FORMAT_TYPE_MAP = { diff --git a/packages/wrangler/src/match-tag.ts b/packages/wrangler/src/match-tag.ts index 4763cd0ad4..f2cdafb35b 100644 --- a/packages/wrangler/src/match-tag.ts +++ b/packages/wrangler/src/match-tag.ts @@ -1,18 +1,5 @@ -import { - APIError, - configFileName, - FatalError, - formatConfigSnippet, - getCIMatchTag, -} from "@cloudflare/workers-utils"; -import { fetchResult } from "./cfetch"; -import { logger } from "./logger"; -import { getCloudflareAccountIdFromEnv } from "./user/auth-variables"; -import { isWorkerNotFoundError } from "./utils/worker-not-found-error"; -import type { - ComplianceConfig, - ServiceMetadataRes, -} from "@cloudflare/workers-utils"; +import { verifyWorkerMatchesCITag as verifyWorkerMatchesCITagBase } from "@cloudflare/deploy-helpers"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; export async function verifyWorkerMatchesCITag( complianceConfig: ComplianceConfig, @@ -20,68 +7,10 @@ export async function verifyWorkerMatchesCITag( workerName: string, configPath?: string ) { - const matchTag = getCIMatchTag(); - - logger.debug( - `Starting verifyWorkerMatchesCITag() with tag: ${matchTag}, name: ${workerName}` + return verifyWorkerMatchesCITagBase( + complianceConfig, + accountId, + workerName, + configPath ); - - // If no tag is provided through the environment, nothing needs to be verified - if (!matchTag) { - logger.debug( - "No WRANGLER_CI_MATCH_TAG variable provided, aborting verifyWorkerMatchesCITag()" - ); - return; - } - - const envAccountID = getCloudflareAccountIdFromEnv(); - - if (accountId !== envAccountID) { - throw new FatalError( - `The \`account_id\` in your ${configFileName(configPath)} file must match the \`account_id\` for this account. Please update your ${configFileName(configPath)} file with \`${formatConfigSnippet({ account_id: envAccountID }, configPath, false)}\``, - { telemetryMessage: "ci match tag account mismatch" } - ); - } - - let tag; - - try { - const worker = await fetchResult( - complianceConfig, - `/accounts/${accountId}/workers/services/${workerName}` - ); - tag = worker.default_environment.script.tag; - logger.debug(`API returned with tag: ${tag} for worker: ${workerName}`); - } catch (e) { - logger.debug(e); - if (isWorkerNotFoundError(e)) { - throw new FatalError( - `The name in your ${configFileName(configPath)} file (${workerName}) must match the name of your Worker. Please update the name field in your ${configFileName(configPath)} file.`, - { telemetryMessage: "ci match tag worker not found" } - ); - } else if (e instanceof APIError) { - throw new FatalError( - "An error occurred while trying to validate that the Worker name matches what is expected by the build system.\n" + - e.message + - "\n" + - e.notes.map((note) => note.text).join("\n"), - { telemetryMessage: "ci match tag validation api error" } - ); - } else { - throw new FatalError( - "Wrangler cannot validate that your Worker name matches what is expected by the build system. Please retry the build. " + - "If the problem persists, please contact support.", - { telemetryMessage: "ci match tag validation failed" } - ); - } - } - if (tag !== matchTag) { - logger.debug( - `Failed to match Worker tag. The API returned "${tag}", but the CI system expected "${matchTag}"` - ); - throw new FatalError( - `The name in your ${configFileName(configPath)} file (${workerName}) must match the name of your Worker. Please update the name field in your ${configFileName(configPath)} file.`, - { telemetryMessage: "ci match tag tag mismatch" } - ); - } } diff --git a/packages/wrangler/src/pages/deploy.ts b/packages/wrangler/src/pages/deploy.ts index bf94fb3b35..11a7591cc0 100644 --- a/packages/wrangler/src/pages/deploy.ts +++ b/packages/wrangler/src/pages/deploy.ts @@ -11,6 +11,7 @@ import { } from "@cloudflare/workers-utils"; import { deploy } from "../api/pages/deploy"; import { fetchResult } from "../cfetch"; +import { analyseBundle } from "../check/commands"; import { readPagesConfig } from "../config"; import { getConfigCache, saveToConfigCache } from "../config-cache"; import { createAlias, createCommand } from "../core/create-command"; @@ -543,7 +544,8 @@ export const pagesDeployCommand = createCommand({ await diagnoseStartupError( startupError, filePath, - getPagesProjectRoot() + getPagesProjectRoot(), + analyseBundle ), { telemetryMessage: "pages deploy startup error" } ); diff --git a/packages/wrangler/src/pages/hash.ts b/packages/wrangler/src/pages/hash.ts index dca3ffd98a..4b4dd2dffd 100644 --- a/packages/wrangler/src/pages/hash.ts +++ b/packages/wrangler/src/pages/hash.ts @@ -1,13 +1 @@ -import { readFileSync } from "node:fs"; -import { extname } from "node:path"; -import { hash as blake3hash } from "blake3-wasm"; - -export const hashFile = (filepath: string) => { - const contents = readFileSync(filepath); - const base64Contents = contents.toString("base64"); - const extension = extname(filepath).substring(1); - - return blake3hash(base64Contents + extension) - .toString("hex") - .slice(0, 32); -}; +export { hashFile } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/pages/upload.ts b/packages/wrangler/src/pages/upload.ts index 59ecf053e5..553c8b99f5 100644 --- a/packages/wrangler/src/pages/upload.ts +++ b/packages/wrangler/src/pages/upload.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; +import { isJwtExpired } from "@cloudflare/deploy-helpers"; import { APIError, COMPLIANCE_REGION_CONFIG_PUBLIC, @@ -27,6 +28,8 @@ import { validate } from "./validate"; import type { UploadPayloadFile } from "./types"; import type { FileContainer } from "./validate"; +export { isJwtExpired } from "@cloudflare/deploy-helpers"; + export const pagesProjectUploadCommand = createCommand({ metadata: { description: "Upload files to a project", @@ -391,32 +394,6 @@ export const upload = async ( ); }; -// Decode and check that the current JWT has not expired -export const isJwtExpired = (token: string): boolean | undefined => { - // During testing we don't use valid JWTs, so don't try and parse them - if ( - typeof vitest !== "undefined" && - (token === "<>" || - token === "<>" || - token === "<>") - ) { - return false; - } - try { - const decodedJwt = JSON.parse( - Buffer.from(token.split(".")[1], "base64").toString() - ); - - const dateNow = new Date().getTime() / 1000; - - return decodedJwt.exp <= dateNow; - } catch (e) { - if (e instanceof Error) { - throw new Error(`Invalid token: ${e.message}`); - } - } -}; - export const maxFileCountAllowedFromClaims = (token: string): number => { // During testing we don't use valid JWTs, so don't try and parse them if ( diff --git a/packages/wrangler/src/queues/client.ts b/packages/wrangler/src/queues/client.ts index afff8ed787..c18ccd46b6 100644 --- a/packages/wrangler/src/queues/client.ts +++ b/packages/wrangler/src/queues/client.ts @@ -11,7 +11,6 @@ import { } from "@cloudflare/deploy-helpers"; import { UserError } from "@cloudflare/workers-utils"; import { fetchPagedListResult, fetchResult } from "../cfetch"; -import { createDeployHelpersContext } from "../core/deploy-helpers-context"; import { requireAuth } from "../user"; import type { CreateEventSubscriptionRequest, @@ -99,13 +98,7 @@ export async function listQueues( ): Promise { const accountId = await requireAuth(config); - return listQueuesImpl( - config, - accountId, - createDeployHelpersContext(), - page, - name - ); + return listQueuesImpl(config, accountId, page, name); } async function listAllQueues( @@ -127,12 +120,7 @@ export async function getQueue( queueName: string ): Promise { const accountId = await requireAuth(config); - return getQueueImpl( - config, - accountId, - queueName, - createDeployHelpersContext() - ); + return getQueueImpl(config, accountId, queueName); } export async function ensureQueuesExistByConfig(config: Config) { @@ -203,13 +191,7 @@ export async function postConsumer( body: PostTypedConsumerBody ): Promise { const accountId = await requireAuth(config); - return postConsumerImpl( - config, - accountId, - queueName, - body, - createDeployHelpersContext() - ); + return postConsumerImpl(config, accountId, queueName, body); } export async function putConsumerById( @@ -219,14 +201,7 @@ export async function putConsumerById( body: PostTypedConsumerBody ): Promise { const accountId = await requireAuth(config); - return putConsumerByIdImpl( - config, - accountId, - queueId, - consumerId, - body, - createDeployHelpersContext() - ); + return putConsumerByIdImpl(config, accountId, queueId, consumerId, body); } export async function putConsumer( @@ -243,8 +218,7 @@ export async function putConsumer( queueName, scriptName, envName, - body, - createDeployHelpersContext() + body ); } @@ -253,12 +227,7 @@ export async function deletePullConsumer( queueName: string ): Promise { const accountId = await requireAuth(config); - return deletePullConsumerImpl( - config, - accountId, - queueName, - createDeployHelpersContext() - ); + return deletePullConsumerImpl(config, accountId, queueName); } export async function listConsumers( @@ -266,12 +235,7 @@ export async function listConsumers( queueName: string ): Promise { const accountId = await requireAuth(config); - return listConsumersImpl( - config, - accountId, - queueName, - createDeployHelpersContext() - ); + return listConsumersImpl(config, accountId, queueName); } export async function deleteWorkerConsumer( @@ -286,8 +250,7 @@ export async function deleteWorkerConsumer( accountId, queueName, scriptName, - envName, - createDeployHelpersContext() + envName ); } diff --git a/packages/wrangler/src/secret/index.ts b/packages/wrangler/src/secret/index.ts index 8b36d2c183..6da9bbef92 100644 --- a/packages/wrangler/src/secret/index.ts +++ b/packages/wrangler/src/secret/index.ts @@ -1,14 +1,9 @@ -import path from "node:path"; -import readline from "node:readline"; import { - APIError, - configFileName, - FatalError, - parseJSON, - readFileSync, - UserError, -} from "@cloudflare/workers-utils"; -import { parse as dotenvParse } from "dotenv"; + fetchSecrets, + isWorkerNotFoundError, + parseBulkInputToObject, +} from "@cloudflare/deploy-helpers"; +import { APIError, configFileName, UserError } from "@cloudflare/workers-utils"; import { fetchResult } from "../cfetch"; import { createCommand, createNamespace } from "../core/create-command"; import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; @@ -16,11 +11,9 @@ import { confirm, prompt } from "../dialogs"; import { logger } from "../logger"; import * as metrics from "../metrics"; import { requireAuth } from "../user"; -import { fetchSecrets } from "../utils/fetch-secrets"; import { getLegacyScriptName } from "../utils/getLegacyScriptName"; import { readFromStdin, trimTrailingWhitespace } from "../utils/std"; import { useServiceEnvironments } from "../utils/useServiceEnvironments"; -import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; import type { Config } from "@cloudflare/workers-utils"; export const VERSION_NOT_DEPLOYED_ERR_CODE = 10215; @@ -354,10 +347,15 @@ export const secretListCommand = createCommand({ ); } + const accountId = await requireAuth(config); let secrets: Awaited>; try { - secrets = await fetchSecrets({ ...config, name: scriptName }, args.env); + secrets = await fetchSecrets( + { ...config, name: scriptName }, + accountId, + args.env + ); } catch (e) { if (isWorkerNotFoundError(e)) { throw new UserError( @@ -571,118 +569,12 @@ export const secretBulkCommand = createCommand({ }, }); -export function validateFileSecrets( - content: unknown, - jsonFilePath: string -): content is Record { - if (content === null || typeof content !== "object") { - throw new FatalError( - `The contents of "${jsonFilePath}" is not valid. It should be a JSON object of string values.`, - { telemetryMessage: "secret bulk file invalid contents" } - ); - } - const entries = Object.entries(content); - for (const [key, value] of entries) { - if (value != null && typeof value !== "string") { - throw new FatalError( - `The value for "${key}" in "${jsonFilePath}" is not null or a "string" instead it is of type "${typeof value}"`, - { telemetryMessage: "secret bulk file invalid value type" } - ); - } - } - return true; -} - -/** Error thrown when no input is provided to parseBulkInputToObject */ -export class NoInputError extends Error { - constructor() { - super("No input provided"); - this.name = "NoInputError"; - } -} - -/** Result from parsing bulk secret input without nullable values, including metadata for analytics */ -export type BulkInputResult = { - content: Record; - secretSource: "file" | "stdin"; - secretFormat: "json" | "dotenv"; -}; - -/** Result from parsing bulk secret input with nullable values, including metadata for analytics */ -export type BulkInputNullableResult = { - content: Record; - secretSource: "file" | "stdin"; - secretFormat: "json" | "dotenv"; -}; - -/** Override for callers that need non-nullable */ -export async function parseBulkInputToObject( - input?: string, - includeNull?: false -): Promise; - -/** Override for callers that need nullable */ -export async function parseBulkInputToObject( - input?: string, - includeNull?: true -): Promise; - -export async function parseBulkInputToObject( - input?: string, - includeNull: boolean = false -): Promise { - let content: Record; - let secretSource: "file" | "stdin"; - let secretFormat: "json" | "dotenv"; - - if (input) { - secretSource = "file"; - const jsonFilePath = path.resolve(input); - const fileContent = readFileSync(jsonFilePath); - try { - content = parseJSON(fileContent) as Record; - secretFormat = "json"; - } catch { - content = dotenvParse(fileContent); - secretFormat = "dotenv"; - // dotenvParse does not error unless fileContent is undefined, no keys === error - if (Object.keys(content).length === 0) { - throw new UserError(`The contents of "${input}" is not valid.`, { - telemetryMessage: "secret bulk invalid input", - }); - } - } - } else { - secretSource = "stdin"; - try { - const rl = readline.createInterface({ input: process.stdin }); - const pipedInputLines: string[] = []; - for await (const line of rl) { - pipedInputLines.push(line); - } - const pipedInput = pipedInputLines.join("\n"); - try { - content = parseJSON(pipedInput) as Record; - secretFormat = "json"; - } catch (e) { - content = dotenvParse(pipedInput); - secretFormat = "dotenv"; - // dotenvParse does not error unless fileContent is undefined, no keys === error - if (Object.keys(content).length === 0) { - throw e; - } - } - } catch { - return; - } - } - validateFileSecrets(content, input ?? "piped input"); - if (!includeNull) { - content = Object.fromEntries( - Object.entries(content).filter( - (entry): entry is [string, string] => entry[1] != null - ) - ); - } - return { content, secretSource, secretFormat }; -} +export { + validateFileSecrets, + NoInputError, + parseBulkInputToObject, +} from "@cloudflare/deploy-helpers"; +export type { + BulkInputResult, + BulkInputNullableResult, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/sourcemap.ts b/packages/wrangler/src/sourcemap.ts index 8aa08433f2..31df1e9fcc 100644 --- a/packages/wrangler/src/sourcemap.ts +++ b/packages/wrangler/src/sourcemap.ts @@ -1,322 +1,6 @@ -import assert from "node:assert"; -import url from "node:url"; -import { maybeGetFile } from "@cloudflare/workers-shared"; -import { getFreshSourceMapSupport } from "miniflare"; -import type { Options } from "@cspotcode/source-map-support"; -import type Protocol from "devtools-protocol"; - -export type RetrieveSourceMapFunction = NonNullable< - Options["retrieveSourceMap"] ->; -export function maybeRetrieveFileSourceMap( - filePath?: string -): ReturnType { - if (filePath === undefined) { - return null; - } - const contents = maybeGetFile(filePath); - if (contents === undefined) { - return null; - } - - // Find the last source mapping URL if any - const mapRegexp = /# sourceMappingURL=(.+)/g; - const matches = [...contents.matchAll(mapRegexp)]; - // If we couldn't find a source mapping URL, there's nothing we can do - if (matches.length === 0) { - return null; - } - const mapMatch = matches[matches.length - 1]; - - // Get the source map - const fileUrl = url.pathToFileURL(filePath); - const mapUrl = new URL(mapMatch[1], fileUrl); - if ( - mapUrl.protocol === "data:" && - mapUrl.pathname.startsWith("application/json;base64,") - ) { - // sourceMappingURL=data:application/json;base64,ew... - const base64 = mapUrl.href.substring(mapUrl.href.indexOf(",") + 1); - const map = Buffer.from(base64, "base64").toString(); - return { map, url: fileUrl.href }; - } else { - const map = maybeGetFile(mapUrl); - if (map === undefined) { - return null; - } - return { map, url: mapUrl.href }; - } -} - -// `sourceMappingPrepareStackTrace` is initialised on the first call to -// `getSourceMappingPrepareStackTrace()`. Subsequent calls to -// `getSourceMappingPrepareStackTrace()` will not update it. We'd like to be -// able to customise source map retrieval on each call though. Therefore, we -// make `retrieveSourceMapOverride` a module level variable, so -// `sourceMappingPrepareStackTrace` always has access to the latest override. -let sourceMappingPrepareStackTrace: typeof Error.prepareStackTrace; -let retrieveSourceMapOverride: RetrieveSourceMapFunction | undefined; - -function getSourceMappingPrepareStackTrace( - retrieveSourceMap?: RetrieveSourceMapFunction -): NonNullable { - // Source mapping is synchronous, so setting a module level variable is fine - retrieveSourceMapOverride = retrieveSourceMap; - // If we already have a source mapper, return it - if (sourceMappingPrepareStackTrace !== undefined) { - return sourceMappingPrepareStackTrace; - } - - const support: typeof import("@cspotcode/source-map-support") = - getFreshSourceMapSupport(); - const originalPrepareStackTrace = Error.prepareStackTrace; - support.install({ - environment: "node", - // Don't add Node `uncaughtException` handler - handleUncaughtExceptions: false, - // Don't hook Node `require` function - hookRequire: false, - redirectConflictingLibrary: false, - // Make sure we're using fresh copies of files each time we source map - emptyCacheBetweenOperations: true, - // Allow retriever to be overridden at prepare stack trace time - retrieveSourceMap(path) { - return retrieveSourceMapOverride?.(path) ?? null; - }, - }); - sourceMappingPrepareStackTrace = Error.prepareStackTrace; - assert(sourceMappingPrepareStackTrace !== undefined); - Error.prepareStackTrace = originalPrepareStackTrace; - - return sourceMappingPrepareStackTrace; -} - -export function getSourceMappedStack( - details: Protocol.Runtime.ExceptionDetails -): string { - const description = details.exception?.description ?? ""; - const callFrames = details.stackTrace?.callFrames; - // If this exception didn't come with `callFrames`, we can't do any source - // mapping without parsing the stack, so just return the description as is - if (callFrames === undefined) { - return description; - } - - const nameMessage = details.exception?.description?.split("\n")[0] ?? ""; - const colonIndex = nameMessage.indexOf(":"); - const error = new Error(nameMessage.substring(colonIndex + 2)); - error.name = nameMessage.substring(0, colonIndex); - const callSites = callFrames.map(callFrameToCallSite); - return getSourceMappingPrepareStackTrace()(error, callSites); -} - -function callFrameToCallSite(frame: Protocol.Runtime.CallFrame): CallSite { - return new CallSite({ - typeName: null, - functionName: frame.functionName, - methodName: null, - fileName: frame.url, - lineNumber: frame.lineNumber + 1, - columnNumber: frame.columnNumber + 1, - native: false, - }); -} - -const placeholderError = new Error(); -export function getSourceMappedString( - value: string, - retrieveSourceMap?: RetrieveSourceMapFunction -): string { - // We could use `.replace()` here with a function replacer, but - // `getSourceMappingPrepareStackTrace()` clears its source map caches between - // operations. It's likely call sites in this `value` will share source maps, - // so instead we find all call sites, source map them together, then replace. - // Note this still works if there are multiple instances of the same call site - // (e.g. stack overflow error), as the final `.replace()`s will only replace - // the first instance. If they replace the value with itself, all instances - // of the call site would've been replaced with the same thing. - const callSiteLines = Array.from(value.matchAll(CALL_SITE_REGEXP)); - const callSites = callSiteLines.map(lineMatchToCallSite); - const prepareStack = getSourceMappingPrepareStackTrace(retrieveSourceMap); - const sourceMappedStackTrace: string = prepareStack( - placeholderError, - callSites - ); - const sourceMappedCallSiteLines = sourceMappedStackTrace.split("\n").slice(1); - - for (let i = 0; i < callSiteLines.length; i++) { - // If a call site doesn't have a file name, it's likely invalid, so don't - // apply source mapping (see cloudflare/workers-sdk#4668) - if (callSites[i].getFileName() === undefined) { - continue; - } - - const callSiteLine = callSiteLines[i][0]; - const callSiteAtIndex = callSiteLine.indexOf("at"); - assert(callSiteAtIndex !== -1); // Matched against `CALL_SITE_REGEXP` - const callSiteLineLeftPad = callSiteLine.substring(0, callSiteAtIndex); - value = value.replace( - callSiteLine, - callSiteLineLeftPad + sourceMappedCallSiteLines[i].trimStart() - ); - } - return value; -} - -// Adapted from `node-stack-trace`: -/*! - * Copyright (c) 2011 Felix GeisendΓΆrfer (felix@debuggable.com) - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -const CALL_SITE_REGEXP = - // Validation errors from `wrangler deploy` have a 2 space indent, whereas - // regular stack traces have a 4 space indent. - // eslint-disable-next-line no-control-regex -- Intentionally matches ANSI escape sequences in stack traces - /^(?:\s+(?:\x1B\[\d+m)?'?)? {2,4}at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/gm; -function lineMatchToCallSite(lineMatch: RegExpMatchArray): CallSite { - let object: string | null = null; - let method: string | null = null; - let functionName: string | null = null; - let typeName: string | null = null; - let methodName: string | null = null; - const isNative = lineMatch[5] === "native"; - - if (lineMatch[1]) { - functionName = lineMatch[1]; - let methodStart = functionName.lastIndexOf("."); - if (functionName[methodStart - 1] == ".") { - methodStart--; - } - if (methodStart > 0) { - object = functionName.substring(0, methodStart); - method = functionName.substring(methodStart + 1); - const objectEnd = object.indexOf(".Module"); - if (objectEnd > 0) { - functionName = functionName.substring(objectEnd + 1); - object = object.substring(0, objectEnd); - } - } - } - - if (method) { - typeName = object; - methodName = method; - } - - if (method === "") { - methodName = null; - functionName = null; - } - - return new CallSite({ - typeName, - functionName, - methodName, - fileName: lineMatch[2], - lineNumber: parseInt(lineMatch[3]) || null, - columnNumber: parseInt(lineMatch[4]) || null, - native: isNative, - }); -} - -interface CallSiteOptions { - typeName: string | null; - functionName: string | null; - methodName: string | null; - fileName: string; - lineNumber: number | null; - columnNumber: number | null; - native: boolean; -} - -// https://v8.dev/docs/stack-trace-api#customizing-stack-traces -// This class supports the subset of options implemented by `node-stack-trace`: -// https://github.com/felixge/node-stack-trace/blob/4c41a4526e74470179b3b6dd5d75191ca8c56c17/index.js -class CallSite implements NodeJS.CallSite { - constructor(private readonly opts: CallSiteOptions) {} - getScriptHash(): string { - throw new Error("Method not implemented."); - } - getEnclosingColumnNumber(): number { - throw new Error("Method not implemented."); - } - getEnclosingLineNumber(): number { - throw new Error("Method not implemented."); - } - getPosition(): number { - throw new Error("Method not implemented."); - } - getThis(): unknown { - return null; - } - getTypeName(): string | null { - return this.opts.typeName; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- V8 CallSite interface requires Function return type - getFunction(): Function | undefined { - return undefined; - } - getFunctionName(): string | null { - return this.opts.functionName; - } - getMethodName(): string | null { - return this.opts.methodName; - } - getFileName(): string | null { - return this.opts.fileName ?? null; - } - getScriptNameOrSourceURL(): string { - return this.opts.fileName; - } - getLineNumber(): number | null { - return this.opts.lineNumber; - } - getColumnNumber(): number | null { - return this.opts.columnNumber; - } - getEvalOrigin(): string | undefined { - return undefined; - } - isToplevel(): boolean { - return false; - } - isEval(): boolean { - return false; - } - isNative(): boolean { - return this.opts.native; - } - isConstructor(): boolean { - return false; - } - isAsync(): boolean { - return false; - } - isPromiseAll(): boolean { - return false; - } - isPromiseAny(): boolean { - return false; - } - getPromiseIndex(): number | null { - return null; - } -} +export { + maybeRetrieveFileSourceMap, + getSourceMappedStack, + getSourceMappedString, +} from "@cloudflare/deploy-helpers"; +export type { RetrieveSourceMapFunction } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/triggers/index.ts b/packages/wrangler/src/triggers/index.ts index ffdebca065..c2486e2b40 100644 --- a/packages/wrangler/src/triggers/index.ts +++ b/packages/wrangler/src/triggers/index.ts @@ -61,7 +61,7 @@ export const triggersDeployCommand = createCommand({ behaviour: { warnIfMultipleEnvsConfiguredButNoneSpecified: true, }, - async handler(args, { config, ...ctx }) { + async handler(args, { config }) { metrics.sendMetricsEvent("deploy worker triggers", { sendMetrics: config.send_metrics, }); @@ -76,15 +76,12 @@ export const triggersDeployCommand = createCommand({ const accountId = await requireAuth(config); await ensureQueuesExistByConfig(config); - await triggersDeploy( - { - config, - accountId, - env: args.env, - firstDeploy: false, - ...props, - }, - ctx - ); + await triggersDeploy({ + config, + accountId, + env: args.env, + firstDeploy: false, + ...props, + }); }, }); diff --git a/packages/wrangler/src/utils/diff-json.ts b/packages/wrangler/src/utils/diff-json.ts index af546ccaf8..36cbef60c7 100644 --- a/packages/wrangler/src/utils/diff-json.ts +++ b/packages/wrangler/src/utils/diff-json.ts @@ -1,123 +1,6 @@ -import jsonDiff from "json-diff"; - -export type JsonLike = - | string - | number - | boolean - | null - | JsonLike[] - | undefined // undefined is not a JSON type but it needs to be included here since it is present in the diff objects - | { [id: string]: JsonLike }; - -/** - * Given two objects A and B that are Json serializable this function computes the difference between them - * - * The difference object includes: - * - fields in object B but not in object A included as `` - * - fields in object A but not in object B included as `` - * - fields present in both objects but modified as `: { __old: , __new: }` - * - * Additionally the difference object contains a `toString` method that can be used to generate a string representation - * of the difference between the two objects (to be presented to users) - * - * @param jsonObjA The first target object - * @param jsonObjB The second target object - * @returns An object representing the diff between the two objects, or null if the objects are equal - */ -export function diffJsonObjects( - jsonObjA: Record, - jsonObjB: Record -): Record | null { - const result = jsonDiff.diff(jsonObjA, jsonObjB); - - if (result) { - result.toString = () => jsonDiff.diffString(jsonObjA, jsonObjB); - return result; - } else { - return null; - } -} - -/** - * Given a diff object (generated by `diffJsonObjects`) this function computes whether the - * difference is non-destructive, i.e. if the second object only contained additions to the - * first one and no removal nor modifications. - * - * @param diff The difference object to use (generated by `diffJsonObjects`) - * @returns `true` if the difference is non-destructive, `false` if it is - */ -export function isNonDestructive(diff: JsonLike): boolean { - if (diff === null || typeof diff !== "object") { - return true; - } - - if ( - Object.keys(diff).some( - (key) => key === "__old" || key.endsWith("__deleted") - ) - ) { - return false; - } - - if (Array.isArray(diff)) { - for (const element of diff) { - if (Array.isArray(element) && element.length === 2) { - if (element[0] === "-") { - // json-diff shows a removed element by representing it as the following array: - // ["-", ], so if the first value here is "-" we assume that this is - // a removal - return false; - } else if (element[0] === "~") { - // json-diff shows a modified element by representing it as the following array: - // ["~", ], so if the first value here is "~" we assume that this is - // a modification - return false; - } else if (element[0] !== "+") { - // json-diff shows an added element by representing it as the following array: - // ["+", ], so if the first value here is "+" we assume that this is - // an addition (so we skip this) - continue; - } - - // Otherwise we check all the elements in the array - for (const innerElement of element) { - if (!isNonDestructive(innerElement)) { - return false; - } - } - } else if (!isNonDestructive(element)) { - return false; - } - } - } else { - for (const field in diff) { - if (!isNonDestructive(diff[field])) { - return false; - } - } - } - - return true; -} - -/** - * A modified value in json-diff is represented as an object with two properties: - * `__old` and `__new`. Where the former contains the old version of the value and - * the latter the new one. - * This utility, given an arbitrary value, discerns whether the value represents the - * diff of a modified value. - * - * @param value The target value to check - * @returns True if the value represents a value modified, false otherwise - */ -export function isModifiedDiffValue( - value: unknown -): value is { __old: T; __new: T } { - return !!( - value && - typeof value === "object" && - Object.keys(value).length === 2 && - "__old" in value && - "__new" in value - ); -} +export { + diffJsonObjects, + isNonDestructive, + isModifiedDiffValue, +} from "@cloudflare/deploy-helpers"; +export type { JsonLike } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/utils/download-worker-config.ts b/packages/wrangler/src/utils/download-worker-config.ts index 3902f12b1f..cdfc29ae2d 100644 --- a/packages/wrangler/src/utils/download-worker-config.ts +++ b/packages/wrangler/src/utils/download-worker-config.ts @@ -1,146 +1,27 @@ import { - COMPLIANCE_REGION_CONFIG_UNKNOWN, - constructWranglerConfig, -} from "@cloudflare/workers-utils"; -import { fetchResult } from "../cfetch"; -import type { - RawConfig, - ServiceMetadataRes, - WorkerMetadata, -} from "@cloudflare/workers-utils"; + downloadWorkerConfig as downloadWorkerConfigBase, + fetchWorkerConfig as fetchWorkerConfigBase, +} from "@cloudflare/deploy-helpers"; +import type { RawConfig } from "@cloudflare/workers-utils"; -type CustomDomainsRes = { - id: string; - zone_id: string; - zone_name: string; - hostname: string; - service: string; - environment: string; - cert_id: string; - enabled: boolean; - previews_enabled: boolean; -}[]; - -type WorkerSubdomainRes = { - enabled: boolean; - previews_enabled: boolean; -}; -type CronTriggersRes = { - schedules: { - cron: string; - created_on: Date; - modified_on: Date; - }[]; -}; - -type RoutesRes = { - id: string; - pattern: string; - zone_name: string; - script: string; -}[]; - -/** - * Downloads all information required to construct a Wrangler config file for a Worker from the API - */ export async function fetchWorkerConfig( accountId: string, workerName: string, environment: string ) { - const [ - bindings, - routes, - customDomains, - subdomainStatus, - serviceEnvMetadata, - cronTriggers, - ] = await Promise.all([ - fetchResult( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}/bindings` - ), - fetchResult( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}/routes?show_zonename=true` - ), - fetchResult( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - `/accounts/${accountId}/workers/domains/records?page=0&per_page=5&service=${workerName}&environment=${environment}` - ), - fetchResult( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}/subdomain` - ), - fetchResult( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - `/accounts/${accountId}/workers/services/${workerName}/environments/${environment}` - ), - fetchResult( - COMPLIANCE_REGION_CONFIG_UNKNOWN, - `/accounts/${accountId}/workers/scripts/${workerName}/schedules` - ), - ]).catch((e) => { - throw new Error( - `Error Occurred: Unable to fetch bindings, routes, or services metadata from the dashboard. Please try again later.`, - { cause: e } - ); - }); - return { - bindings, - routes, - customDomains, - subdomainStatus, - serviceEnvMetadata, - cronTriggers, - }; + return fetchWorkerConfigBase(accountId, workerName, environment); } -/** - * Downloads all the remote information we can gather for a worker and from them generates a raw configuration object that - * approximates what a wrangler config object for the worker was/would have been. - * - * @param workerName The name of the worker - * @param environment The target environment for the worker - * @param entrypoint The worker's entrypoint - * @param accountId The ID of the account owning the worker - * @returns A RawConfig object that bests represents the remote configuration of the worker - */ export async function downloadWorkerConfig( workerName: string, environment: string, entrypoint: string, accountId: string ): Promise { - const { - bindings, - routes, - customDomains, - subdomainStatus, - serviceEnvMetadata, - cronTriggers, - } = await fetchWorkerConfig(accountId, workerName, environment); - - return constructWranglerConfig({ - name: workerName, + return downloadWorkerConfigBase( + workerName, + environment, entrypoint, - compatibility_date: serviceEnvMetadata.script.compatibility_date, - compatibility_flags: serviceEnvMetadata.script.compatibility_flags, - tags: serviceEnvMetadata.script.tags, - migration_tag: serviceEnvMetadata.script.migration_tag, - tail_consumers: serviceEnvMetadata.script.tail_consumers, - observability: serviceEnvMetadata.script.observability, - limits: serviceEnvMetadata.script.limits, - bindings, - routes, - domains: customDomains, - subdomain: subdomainStatus, - schedules: cronTriggers.schedules.map((s) => ({ - cron: s.cron, - })), - placement: serviceEnvMetadata.script.placement_mode - ? { mode: serviceEnvMetadata.script.placement_mode } - : undefined, - logpush: undefined, - }); + accountId + ); } diff --git a/packages/wrangler/src/utils/error-codes.ts b/packages/wrangler/src/utils/error-codes.ts index 94f5c5c4a4..cea9318af6 100644 --- a/packages/wrangler/src/utils/error-codes.ts +++ b/packages/wrangler/src/utils/error-codes.ts @@ -1,6 +1 @@ -/** - * Cloudflare API error codes used by Wrangler. - */ - -/** The inherit binding references a binding that does not exist on the previous version. */ -export const INVALID_INHERIT_BINDING_CODE = 10057 as const; +export { INVALID_INHERIT_BINDING_CODE } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/utils/fetch-secrets.ts b/packages/wrangler/src/utils/fetch-secrets.ts deleted file mode 100644 index a63e93f8bb..0000000000 --- a/packages/wrangler/src/utils/fetch-secrets.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { fetchResult } from "../cfetch"; -import { requireAuth } from "../user"; -import { useServiceEnvironments } from "./useServiceEnvironments"; -import type { Config } from "@cloudflare/workers-utils"; - -export async function fetchSecrets( - config: Config, - environment?: string -): Promise<{ name: string; type: string }[]> { - const accountId = await requireAuth(config); - - const isServiceEnv = environment && useServiceEnvironments(config); - - const scriptName = config.name; - - const url = isServiceEnv - ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${environment}/secrets` - : `/accounts/${accountId}/workers/scripts/${scriptName}/secrets`; - - return fetchResult<{ name: string; type: string }[]>(config, url); -} diff --git a/packages/wrangler/src/utils/friendly-validator-errors.ts b/packages/wrangler/src/utils/friendly-validator-errors.ts index 1b22e84c7a..f4e327938b 100644 --- a/packages/wrangler/src/utils/friendly-validator-errors.ts +++ b/packages/wrangler/src/utils/friendly-validator-errors.ts @@ -1,162 +1,5 @@ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { getWranglerTmpDir, ParseError } from "@cloudflare/workers-utils"; -import dedent from "ts-dedent"; -import { analyseBundle } from "../check/commands"; -import { logger } from "../logger"; -import type { Metafile } from "esbuild"; -import type { FormData } from "undici"; - -export async function helpIfErrorIsSizeOrScriptStartup( - err: unknown, - dependencies: { [path: string]: { bytesInOutput: number } }, - workerBundle: FormData | string, - projectRoot: string | undefined -): Promise { - if (errIsScriptSize(err)) { - return await diagnoseScriptSizeError(err, dependencies); - } - if (errIsStartupErr(err)) { - return await diagnoseStartupError(err, workerBundle, projectRoot); - } - return null; -} - -/** - * Returns a formatted error message that describes the script size error. - * It includes the largest dependencies if available. - */ -export function diagnoseScriptSizeError( - err: ParseError, - dependencies: { [path: string]: { bytesInOutput: number } } -): string { - let message = dedent` - Your Worker failed validation because it exceeded size limits. - - ${err.text} - ${err.notes.map((note) => ` - ${note.text}`).join("\n")} - `; - - const dependenciesMessage = getOffendingDependenciesMessage(dependencies); - if (dependenciesMessage) { - message += dependenciesMessage; - } - - return message; -} - -/** - * Returns a formatted error message that describes the startup error. - * If profiling is successful, it will include a link to the generated CPU profile. - */ -export async function diagnoseStartupError( - err: ParseError, - workerBundle: FormData | string, - projectRoot: string | undefined -): Promise { - let errorMessage = dedent` - Your Worker failed validation because it exceeded startup limits. - - ${err.text} - ${err.notes.map((note) => ` - ${note.text}`).join("\n")} - - To ensure fast responses, there are constraints on Worker startup, such as how much CPU it can use, or how long it can take. Your Worker has hit one of these startup limits. Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler. - - Refer to https://developers.cloudflare.com/workers/platform/limits/#worker-startup-time for more details`; - - try { - const cpuProfile = await analyseBundle(workerBundle); - const tmpDir = await getWranglerTmpDir( - projectRoot, - "startup-profile", - false - ); - const profile = path.relative( - projectRoot ?? process.cwd(), - path.join(tmpDir.path, `worker.cpuprofile`) - ); - await writeFile(profile, JSON.stringify(cpuProfile)); - - errorMessage += dedent` - - A CPU Profile of your Worker's startup phase has been written to ${profile} - load it into the Chrome DevTools profiler (or directly in VSCode) to view a flamegraph.`; - } catch (profilingError) { - logger.debug( - `An error occurred while trying to locally profile the Worker: ${profilingError}` - ); - } - - return errorMessage; -} - -/** - * Gets a message that describes the largest dependencies in the script or `null` if there are none. - */ -function getOffendingDependenciesMessage( - dependencies: Metafile["outputs"][string]["inputs"] -): string | null { - const dependenciesSorted = Object.entries(dependencies); - if (dependenciesSorted.length === 0) { - return null; - } - - dependenciesSorted.sort( - ([, aData], [, bData]) => bData.bytesInOutput - aData.bytesInOutput - ); - - const topLargest = dependenciesSorted.slice(0, 5); - const ONE_KIB_BYTES = 1024; - return [ - "", - `Here are the ${topLargest.length} largest dependencies included in your script:`, - "", - ...topLargest.map( - ([dep, data]) => - `- ${dep} - ${(data.bytesInOutput / ONE_KIB_BYTES).toFixed(2)} KiB` - ), - "", - "If these are unnecessary, consider removing them", - "", - ].join("\n"); -} - -/** - * Returns true if the error is a script size error. - */ -function errIsScriptSize(err: unknown): err is ParseError & { code: 10027 } { - if (!(err instanceof ParseError)) { - return false; - } - - // 10027 = workers.api.error.script_too_large - if ("code" in err && err.code === 10027) { - return true; - } - - return false; -} - -/** - * Returns true if the error is a startup error. - */ -function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } { - if (!(err instanceof ParseError)) { - return false; - } - - // 10021 = validation error - // no explicit error code for more granular errors than "invalid script" - // but the error will contain a string error message directly from the - // validator. - // the error always SHOULD look like "Script startup exceeded CPU limit." - // (or the less likely "Script startup exceeded memory limits.") - if ( - "code" in err && - err.code === 10021 && - /startup/i.test(err.notes[0]?.text) - ) { - return true; - } - - return false; -} +export { + diagnoseScriptSizeError, + diagnoseStartupError, + helpIfErrorIsSizeOrScriptStartup, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/utils/placement.ts b/packages/wrangler/src/utils/placement.ts index 17d3dac68e..c59dd86f2a 100644 --- a/packages/wrangler/src/utils/placement.ts +++ b/packages/wrangler/src/utils/placement.ts @@ -1,31 +1 @@ -import type { CfPlacement, Config } from "@cloudflare/workers-utils"; - -/** - * Parse placement out of a Config - */ -export function parseConfigPlacement(config: Config): CfPlacement | undefined { - if (config.placement) { - const configPlacement = config.placement; - const hint = "hint" in configPlacement ? configPlacement.hint : undefined; - - if (!hint && configPlacement.mode === "off") { - return undefined; - } else if (hint || configPlacement.mode === "smart") { - return { mode: "smart", hint: hint }; - } else { - // mode is undefined or "targeted", which both map to the targeted variant - // TypeScript needs explicit checks to narrow the union type - if ("region" in configPlacement && configPlacement.region) { - return { mode: "targeted", region: configPlacement.region }; - } else if ("host" in configPlacement && configPlacement.host) { - return { mode: "targeted", host: configPlacement.host }; - } else if ("hostname" in configPlacement && configPlacement.hostname) { - return { mode: "targeted", hostname: configPlacement.hostname }; - } else { - return undefined; - } - } - } else { - return undefined; - } -} +export { parseConfigPlacement } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/utils/print-bindings.ts b/packages/wrangler/src/utils/print-bindings.ts index 67a10e0da9..5a4fb12bc2 100644 --- a/packages/wrangler/src/utils/print-bindings.ts +++ b/packages/wrangler/src/utils/print-bindings.ts @@ -1,1075 +1 @@ -import { stripVTControlCharacters } from "node:util"; -import { brandColor, dim, white } from "@cloudflare/cli-shared-helpers/colors"; -import { - assertNever, - getBindingLocalSupport, - getBindingTypeFriendlyName, - UserError, -} from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { - extractBindingsOfType, - isUnsafeBindingType, -} from "../api/startDevWorker/utils"; -import { getFlag } from "../experimental-flags"; -import { logger } from "../logger"; -import type { Binding, StartDevWorkerInput } from "../api/startDevWorker/types"; -import type { - CfSendEmailBindings, - CfTailConsumer, - ContainerApp, -} from "@cloudflare/workers-utils"; -import type { WorkerRegistry } from "miniflare"; - -/** - * Tracks whether we have already explained the connected status - */ -let isConnectedStatusExplained = false; - -type PrintContext = { - log?: (message: string) => void; - registry?: WorkerRegistry | null; - local?: boolean; - isMultiWorker?: boolean; - remoteBindingsDisabled?: boolean; - name?: string; - provisioning?: boolean; - warnIfNoBindings?: boolean; - unsafeMetadata?: Record; -}; - -/** - * Print all the bindings a worker would have access to. - * Accepts StartDevWorkerInput["bindings"] format - */ -export function printBindings( - bindings: StartDevWorkerInput["bindings"], - tailConsumers: CfTailConsumer[] = [], - streamingTailConsumers: CfTailConsumer[] = [], - containers: ContainerApp[] = [], - context: PrintContext = {} -) { - let hasConnectionStatus = false; - - const log = context.log ?? logger.log; - const isMultiWorker = context.isMultiWorker ?? getFlag("MULTIWORKER"); - const getMode = createGetMode({ - isProvisioning: context.provisioning, - isLocalDev: context.local, - }); - const truncate = (item: string | Record, maxLength = 40) => { - const s = typeof item === "string" ? item : JSON.stringify(item); - if (s.length < maxLength) { - return s; - } - - return `${s.substring(0, maxLength - 3)}...`; - }; - - const output: { - name: string; - type: string; - value: string | undefined | symbol; - mode: string | undefined; - }[] = []; - - // Extract bindings by type - const data_blobs = extractBindingsOfType("data_blob", bindings); - const durable_objects = extractBindingsOfType( - "durable_object_namespace", - bindings - ); - const workflows = extractBindingsOfType("workflow", bindings); - const kv_namespaces = extractBindingsOfType("kv_namespace", bindings); - const send_email = extractBindingsOfType("send_email", bindings); - const queues = extractBindingsOfType("queue", bindings); - const d1_databases = extractBindingsOfType("d1", bindings); - const vectorize = extractBindingsOfType("vectorize", bindings); - const ai_search_namespaces = extractBindingsOfType( - "ai_search_namespace", - bindings - ); - const ai_search = extractBindingsOfType("ai_search", bindings); - const websearch = extractBindingsOfType("websearch", bindings); - const agent_memory = extractBindingsOfType("agent_memory", bindings); - const hyperdrive = extractBindingsOfType("hyperdrive", bindings); - const r2_buckets = extractBindingsOfType("r2_bucket", bindings); - const logfwdr = extractBindingsOfType("logfwdr", bindings); - const secrets_store_secrets = extractBindingsOfType( - "secrets_store_secret", - bindings - ); - const artifacts = extractBindingsOfType("artifacts", bindings); - const services = extractBindingsOfType("service", bindings); - const vpc_services = extractBindingsOfType("vpc_service", bindings); - const vpc_networks = extractBindingsOfType("vpc_network", bindings); - const analytics_engine_datasets = extractBindingsOfType( - "analytics_engine", - bindings - ); - const text_blobs = extractBindingsOfType("text_blob", bindings); - const browser = extractBindingsOfType("browser", bindings); - const images = extractBindingsOfType("images", bindings); - const stream = extractBindingsOfType("stream", bindings); - const ai = extractBindingsOfType("ai", bindings); - const version_metadata = extractBindingsOfType("version_metadata", bindings); - // Extract all vars (plain_text, json, secret_text) together to preserve insertion order - const vars = Object.entries(bindings ?? {}) - .filter( - ([_, binding]) => - binding.type === "plain_text" || - binding.type === "json" || - binding.type === "secret_text" - ) - .map(([name, binding]) => ({ - binding: name, - ...(binding as - | Extract - | Extract - | Extract), - })); - const wasm_modules = extractBindingsOfType("wasm_module", bindings); - const dispatch_namespaces = extractBindingsOfType( - "dispatch_namespace", - bindings - ); - const mtls_certificates = extractBindingsOfType("mtls_certificate", bindings); - const pipelines = extractBindingsOfType("pipeline", bindings); - const ratelimits = extractBindingsOfType("ratelimit", bindings); - const assets = extractBindingsOfType("assets", bindings); - const unsafe_hello_world = extractBindingsOfType( - "unsafe_hello_world", - bindings - ); - const flagship = extractBindingsOfType("flagship", bindings); - const media = extractBindingsOfType("media", bindings); - const worker_loaders = extractBindingsOfType("worker_loader", bindings); - - // Extract generic unsafe bindings (type starts with "unsafe_" but isn't "unsafe_hello_world") - const unsafe_bindings = Object.entries(bindings ?? {}) - .filter( - ([_, binding]) => - isUnsafeBindingType(binding.type) && - binding.type !== "unsafe_hello_world" - ) - .map(([name, binding]) => ({ name, ...binding })); - - if (data_blobs.length > 0) { - output.push( - ...data_blobs.map(({ binding, source }) => ({ - name: binding, - type: getBindingTypeFriendlyName("data_blob"), - value: "contents" in source ? "" : truncate(source.path), - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - - if (durable_objects.length > 0) { - output.push( - ...durable_objects.map(({ name, class_name, script_name }) => { - let value = class_name; - let mode = undefined; - if (script_name) { - if (context.local && context.registry !== null) { - const registryDefinition = context.registry?.[script_name]; - - hasConnectionStatus = true; - if (registryDefinition && registryDefinition.debugPortAddress) { - value += `, defined in ${script_name}`; - mode = getMode({ isSimulatedLocally: true, connected: true }); - } else { - value += `, defined in ${script_name}`; - mode = getMode({ isSimulatedLocally: true, connected: false }); - } - } else { - value += `, defined in ${script_name}`; - mode = getMode({ isSimulatedLocally: true }); - } - } else { - mode = getMode({ isSimulatedLocally: true }); - } - - return { - name, - type: getBindingTypeFriendlyName("durable_object_namespace"), - value: value, - mode, - }; - }) - ); - } - - if (workflows.length > 0) { - output.push( - ...workflows.map(({ class_name, script_name, binding, remote }) => { - let value = class_name; - if (script_name) { - value += ` (defined in ${script_name})`; - } - - return { - name: binding, - type: getBindingTypeFriendlyName("workflow"), - value: value, - mode: getMode({ - isSimulatedLocally: - script_name && !context.remoteBindingsDisabled ? !remote : true, - }), - }; - }) - ); - } - - if (kv_namespaces.length > 0) { - output.push( - ...kv_namespaces.map(({ binding, id, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("kv_namespace"), - value: id, - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - }; - }) - ); - } - - if (send_email.length > 0) { - output.push( - ...send_email.map((emailBinding: CfSendEmailBindings) => { - const destination_address = - "destination_address" in emailBinding - ? emailBinding.destination_address - : undefined; - const allowed_destination_addresses = - "allowed_destination_addresses" in emailBinding - ? emailBinding.allowed_destination_addresses - : undefined; - const allowed_sender_addresses = - "allowed_sender_addresses" in emailBinding - ? emailBinding.allowed_sender_addresses - : undefined; - let value = - destination_address || - allowed_destination_addresses?.join(", ") || - "unrestricted"; - - if (allowed_sender_addresses) { - value += ` - senders: ${allowed_sender_addresses.join(", ")}`; - } - return { - name: emailBinding.name, - type: getBindingTypeFriendlyName("send_email"), - value, - mode: getMode({ - isSimulatedLocally: - context.remoteBindingsDisabled || !emailBinding.remote, - }), - }; - }) - ); - } - - if (queues.length > 0) { - output.push( - ...queues.map(({ binding, queue_name, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("queue"), - value: queue_name, - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - }; - }) - ); - } - - if (d1_databases.length > 0) { - output.push( - ...d1_databases.map( - ({ - binding, - database_name, - database_id, - preview_database_id, - remote, - }) => { - const value = - typeof database_id == "symbol" - ? database_id - : (preview_database_id ?? database_name ?? database_id); - - return { - name: binding, - type: getBindingTypeFriendlyName("d1"), - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - value, - }; - } - ) - ); - } - - if (vectorize.length > 0) { - output.push( - ...vectorize.map(({ binding, index_name, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("vectorize"), - value: index_name, - mode: getMode({ - isSimulatedLocally: - remote && !context.remoteBindingsDisabled ? false : undefined, - }), - }; - }) - ); - } - - if (ai_search_namespaces.length > 0) { - output.push( - ...ai_search_namespaces.map(({ binding, namespace }) => ({ - name: binding, - type: getBindingTypeFriendlyName("ai_search_namespace"), - // Preserve `namespace` as-is so `typeof === "symbol"` handling - // downstream can render INHERIT_SYMBOL as `"inherited"`. Using - // `String(namespace)` would stringify it to - // `"Symbol(inherit_binding)"` and defeat that check. - value: namespace ?? undefined, - mode: getMode({ isSimulatedLocally: false }), - })) - ); - } - - if (ai_search.length > 0) { - output.push( - ...ai_search.map(({ binding, instance_name }) => ({ - name: binding, - type: getBindingTypeFriendlyName("ai_search"), - value: instance_name ? String(instance_name) : undefined, - mode: getMode({ isSimulatedLocally: false }), - })) - ); - } - - if (websearch.length > 0) { - output.push( - ...websearch.map(({ binding }) => ({ - name: binding, - type: getBindingTypeFriendlyName("websearch"), - value: undefined, - mode: getMode({ isSimulatedLocally: false }), - })) - ); - } - - if (agent_memory.length > 0) { - output.push( - ...agent_memory.map(({ binding, namespace }) => ({ - name: binding, - type: getBindingTypeFriendlyName("agent_memory"), - value: namespace ?? undefined, - mode: getMode({ isSimulatedLocally: false }), - })) - ); - } - - if (hyperdrive.length > 0) { - output.push( - ...hyperdrive.map(({ binding, id }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("hyperdrive"), - value: id, - mode: getMode({ isSimulatedLocally: true }), - }; - }) - ); - } - - if (vpc_services.length > 0) { - output.push( - ...vpc_services.map(({ binding, service_id, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("vpc_service"), - value: service_id, - mode: getMode({ - isSimulatedLocally: - remote && !context.remoteBindingsDisabled ? false : undefined, - }), - }; - }) - ); - } - - if (vpc_networks.length > 0) { - output.push( - ...vpc_networks.map(({ binding, tunnel_id, network_id, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("vpc_network"), - value: tunnel_id ?? network_id, - mode: getMode({ - isSimulatedLocally: - remote && !context.remoteBindingsDisabled ? false : undefined, - }), - }; - }) - ); - } - - if (r2_buckets.length > 0) { - output.push( - ...r2_buckets.map(({ binding, bucket_name, jurisdiction, remote }) => { - const value = - typeof bucket_name === "symbol" - ? bucket_name - : bucket_name - ? `${bucket_name}${jurisdiction ? ` (${jurisdiction})` : ""}` - : undefined; - - return { - name: binding, - type: getBindingTypeFriendlyName("r2_bucket"), - value: value, - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - }; - }) - ); - } - - if (logfwdr.length > 0) { - output.push( - ...logfwdr.map(({ name, destination }) => { - return { - name, - type: getBindingTypeFriendlyName("logfwdr"), - value: destination, - mode: getMode(), - }; - }) - ); - } - - if (secrets_store_secrets.length > 0) { - output.push( - ...secrets_store_secrets.map(({ binding, store_id, secret_name }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("secrets_store_secret"), - value: `${store_id}/${secret_name}`, - mode: getMode({ isSimulatedLocally: true }), - }; - }) - ); - } - - if (artifacts.length > 0) { - output.push( - ...artifacts.map(({ binding, namespace }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("artifacts"), - value: namespace, - mode: getMode({ isSimulatedLocally: false }), - }; - }) - ); - } - - if (unsafe_hello_world.length > 0) { - output.push( - ...unsafe_hello_world.map(({ binding, enable_timer }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("unsafe_hello_world"), - value: enable_timer ? `Timer enabled` : `Timer disabled`, - mode: getMode({ isSimulatedLocally: true }), - }; - }) - ); - } - - if (flagship.length > 0) { - output.push( - ...flagship.map(({ binding, app_id }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("flagship"), - value: app_id, - mode: getMode({ - isSimulatedLocally: !context.remoteBindingsDisabled - ? false - : undefined, - }), - }; - }) - ); - } - - if (services.length > 0) { - output.push( - ...services.map(({ binding, service, entrypoint, remote }) => { - let value = service; - let mode = undefined; - - if (entrypoint) { - value += `#${entrypoint}`; - } - - if (remote) { - mode = getMode({ isSimulatedLocally: false }); - } else if (context.local && context.registry !== null) { - const isSelfBinding = service === context.name; - - if (isSelfBinding) { - hasConnectionStatus = true; - mode = getMode({ isSimulatedLocally: true, connected: true }); - } else { - const registryDefinition = context.registry?.[service]; - hasConnectionStatus = true; - - if (registryDefinition && registryDefinition.debugPortAddress) { - mode = getMode({ isSimulatedLocally: true, connected: true }); - } else { - mode = getMode({ isSimulatedLocally: true, connected: false }); - } - } - } - - return { - name: binding, - type: getBindingTypeFriendlyName("service"), - value, - mode, - }; - }) - ); - } - - if (analytics_engine_datasets.length > 0) { - output.push( - ...analytics_engine_datasets.map(({ binding, dataset }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("analytics_engine"), - value: dataset ?? binding, - mode: getMode({ isSimulatedLocally: true }), - }; - }) - ); - } - - if (text_blobs.length > 0) { - output.push( - ...text_blobs.map(({ binding, source }) => ({ - name: binding, - type: getBindingTypeFriendlyName("text_blob"), - value: - "contents" in source - ? truncate(source.contents) - : "path" in source - ? truncate(source.path) - : undefined, - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - - if (browser.length > 0) { - output.push( - ...browser.map(({ binding, remote }) => ({ - name: binding, - type: getBindingTypeFriendlyName("browser"), - value: undefined, - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - })) - ); - } - - if (images.length > 0) { - output.push( - ...images.map(({ binding, remote }) => ({ - name: binding, - type: getBindingTypeFriendlyName("images"), - value: undefined, - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - })) - ); - } - - if (stream.length > 0) { - output.push( - ...stream.map(({ binding, remote }) => ({ - name: binding, - type: getBindingTypeFriendlyName("stream"), - value: undefined, - mode: getMode({ - isSimulatedLocally: - (remote === true || remote === undefined) && - !context.remoteBindingsDisabled - ? false - : undefined, - }), - })) - ); - } - - if (media.length > 0) { - output.push( - ...media.map(({ binding, remote }) => ({ - name: binding, - type: getBindingTypeFriendlyName("media"), - value: undefined, - mode: getMode({ - isSimulatedLocally: - (remote === true || remote === undefined) && - !context.remoteBindingsDisabled - ? false - : undefined, - }), - })) - ); - } - - if (ai.length > 0) { - output.push( - ...ai.map(({ binding, staging, remote }) => ({ - name: binding, - type: getBindingTypeFriendlyName("ai"), - value: staging ? `staging` : undefined, - mode: getMode({ - isSimulatedLocally: - (remote === true || remote === undefined) && - !context.remoteBindingsDisabled - ? false - : undefined, - }), - })) - ); - } - - if (pipelines.length > 0) { - output.push( - ...pipelines.map( - ({ binding, stream: pipelineStream, pipeline, remote }) => ({ - name: binding, - type: getBindingTypeFriendlyName("pipeline"), - value: pipelineStream || pipeline, - mode: getMode({ - isSimulatedLocally: context.remoteBindingsDisabled || !remote, - }), - }) - ) - ); - } - - if (ratelimits.length > 0) { - output.push( - ...ratelimits.map(({ name, simple }) => ({ - name, - type: getBindingTypeFriendlyName("ratelimit"), - value: `${simple.limit} requests/${simple.period}s`, - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - - if (assets.length > 0) { - output.push( - ...assets.map(({ binding }) => ({ - name: binding, - type: getBindingTypeFriendlyName("assets"), - value: undefined, - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - - if (version_metadata.length > 0) { - output.push( - ...version_metadata.map(({ binding }) => ({ - name: binding, - type: getBindingTypeFriendlyName("version_metadata"), - value: undefined, - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - if (unsafe_bindings.length > 0) { - output.push( - ...unsafe_bindings.map((binding) => { - const dev = "dev" in binding ? binding.dev : undefined; - // Strip the "unsafe_" prefix to get the original binding type for display - const originalType = binding.type.slice("unsafe_".length); - return { - name: binding.name, - type: dev - ? dev.plugin.name - : getBindingTypeFriendlyName(binding.type), - value: originalType, - mode: getMode({ - isSimulatedLocally: !!dev, - }), - }; - }) - ); - } - - if (vars.length > 0) { - output.push( - ...vars.map((variable) => { - const { binding, type: varType, value: varValue } = variable; - let parsedValue; - /** - * @see packages/workers-utils/src/types.ts for details on the hidden property - */ - if (varType === "plain_text" && variable.hidden !== true) { - parsedValue = `"${truncate(varValue)}"`; - } else if (varType === "json") { - parsedValue = truncate(JSON.stringify(varValue)); - } else { - parsedValue = `"(hidden)"`; - } - return { - name: binding, - type: getBindingTypeFriendlyName(varType), - value: parsedValue, - mode: getMode({ isSimulatedLocally: true }), - }; - }) - ); - } - - if (wasm_modules.length > 0) { - output.push( - ...wasm_modules.map(({ binding, source }) => ({ - name: binding, - type: getBindingTypeFriendlyName("wasm_module"), - value: "contents" in source ? "" : truncate(source.path), - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - - if (dispatch_namespaces.length > 0) { - output.push( - ...dispatch_namespaces.map(({ binding, namespace, outbound, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("dispatch_namespace"), - value: outbound - ? `${namespace} (outbound -> ${outbound.service})` - : namespace, - mode: getMode({ - isSimulatedLocally: - remote && !context.remoteBindingsDisabled ? false : undefined, - }), - }; - }) - ); - } - - if (mtls_certificates.length > 0) { - output.push( - ...mtls_certificates.map(({ binding, certificate_id, remote }) => { - return { - name: binding, - type: getBindingTypeFriendlyName("mtls_certificate"), - value: certificate_id, - mode: getMode({ - isSimulatedLocally: - remote && !context.remoteBindingsDisabled ? false : undefined, - }), - }; - }) - ); - } - - if (worker_loaders.length > 0) { - output.push( - ...worker_loaders.map(({ binding }) => ({ - name: binding, - type: getBindingTypeFriendlyName("worker_loader"), - value: undefined, - mode: getMode({ isSimulatedLocally: true }), - })) - ); - } - - if (output.length === 0) { - if (context.warnIfNoBindings) { - if (context.name && isMultiWorker) { - log(`No bindings found for ${chalk.blue(context.name)}`); - } else { - log("No bindings found."); - } - } - } else { - let title: string; - if (context.provisioning) { - title = `${chalk.red("Experimental:")} The following bindings need to be provisioned:`; - } else if (context.name && isMultiWorker) { - title = `${chalk.blue(context.name)} has access to the following bindings:`; - } else { - title = "Your Worker has access to the following bindings:"; - } - - const headings = { - binding: "Binding", - resource: "Resource", - mode: "Mode", - } as const; - - const maxValueLength = Math.max( - ...output.map((b) => - typeof b.value === "symbol" - ? "inherited".length - : (b.value?.length ?? 0) - ) - ); - const maxNameLength = Math.max(...output.map((b) => b.name.length)); - const maxTypeLength = Math.max( - ...output.map((b) => b.type.length), - headings.resource.length - ); - const maxModeLength = Math.max( - ...output.map((b) => - b.mode ? stripVTControlCharacters(b.mode).length : headings.mode.length - ) - ); - - const hasMode = output.some((b) => b.mode); - const bindingPrefix = `env.`; - const bindingLength = - bindingPrefix.length + - maxNameLength + - " (".length + - maxValueLength + - ")".length; - - const columnGapSpaces = 6; - const columnGapSpacesWrapped = 4; - - const shouldWrap = - bindingLength + - columnGapSpaces + - maxTypeLength + - columnGapSpaces + - maxModeLength >= - process.stdout.columns; - - log(title); - const columnGap = shouldWrap - ? " ".repeat(columnGapSpacesWrapped) - : " ".repeat(columnGapSpaces); - - log( - `${padEndAnsi(dim(headings.binding), shouldWrap ? bindingPrefix.length + maxNameLength : bindingLength)}${columnGap}${padEndAnsi(dim(headings.resource), maxTypeLength)}${columnGap}${hasMode ? dim(headings.mode) : ""}` - ); - - for (const binding of output) { - const bindingValue = dim( - typeof binding.value === "symbol" - ? chalk.italic("inherited") - : (binding.value ?? "") - ); - const bindingString = padEndAnsi( - `${white(`env.${binding.name}`)}${binding.value && !shouldWrap ? ` (${bindingValue})` : ""}`, - shouldWrap ? bindingPrefix.length + maxNameLength : bindingLength - ); - - const suffix = shouldWrap - ? binding.value - ? `\n ${bindingValue}` - : "" - : ""; - - log( - `${bindingString}${columnGap}${brandColor(binding.type.padEnd(maxTypeLength))}${columnGap}${hasMode ? binding.mode : ""}${suffix}` - ); - } - log(""); - } - let title: string; - if (context.name && isMultiWorker) { - title = `${chalk.blue(context.name)} is sending Tail events to the following Workers:`; - } else { - title = "Your Worker is sending Tail events to the following Workers:"; - } - - const allTailConsumers = [ - ...(tailConsumers ?? []).map((c) => ({ - service: c.service, - streaming: false, - })), - ...(streamingTailConsumers ?? []).map((c) => ({ - service: c.service, - streaming: true, - })), - ]; - if (allTailConsumers.length > 0) { - log( - `${title}\n${allTailConsumers - .map(({ service, streaming }) => { - const displayName = `${service}${streaming ? ` (streaming)` : ""}`; - if (context.local && context.registry !== null) { - const registryDefinition = context.registry?.[service]; - hasConnectionStatus = true; - - if (registryDefinition) { - return `- ${displayName} ${chalk.green("[connected]")}`; - } else { - return `- ${displayName} ${chalk.red("[not connected]")}`; - } - } else { - return `- ${displayName}`; - } - }) - .join("\n")}` - ); - } - - if (containers.length > 0 && !context.provisioning) { - let containersTitle = "The following containers are available:"; - if (context.name && isMultiWorker) { - containersTitle = `The following containers are available from ${chalk.blue(context.name)}:`; - } - - log( - `${containersTitle}\n${containers - .map((c) => `- ${c.name} (${c.image})`) - .join("\n")}` - ); - log(""); - } - - if (context.unsafeMetadata) { - log("The following unsafe metadata will be attached to your Worker:"); - log(JSON.stringify(context.unsafeMetadata, null, 2)); - } - - if (hasConnectionStatus && !isConnectedStatusExplained) { - log( - dim( - `\nService bindings, Durable Object bindings, and Tail consumers connect to other Wrangler or Vite dev processes running locally, with their connection status indicated by ${chalk.green("[connected]")} or ${chalk.red("[not connected]")}. For more details, refer to https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#local-development\n` - ) - ); - isConnectedStatusExplained = true; - } -} - -// Exactly the same as String.padEnd, but doesn't miscount ANSI control characters -function padEndAnsi(str: string, length: number) { - return ( - str + " ".repeat(Math.max(0, length - stripVTControlCharacters(str).length)) - ); -} - -/** - * Creates a function for adding a suffix to the value of a binding in the console. - * - * The suffix is only for local dev so it can be used to determine whether a binding is - * simulated locally or connected to a remote resource. - */ -function createGetMode({ - isProvisioning = false, - isLocalDev = false, -}: { - isProvisioning?: boolean; - isLocalDev?: boolean; -}) { - return function bindingMode({ - isSimulatedLocally, - connected, - }: { - // Is this binding running locally? - isSimulatedLocally?: boolean; - // If this is an external service/tail/etc... binding, is it connected? - // true = connected via the dev registry - // false = trying to connect via the dev registry, but the target is not found - // undefined = dev registry is disabled or the binding is in remote mode (which always implies connection) - connected?: boolean; - } = {}): string | undefined { - if (isProvisioning || !isLocalDev) { - return undefined; - } - if (isSimulatedLocally === undefined) { - return dim("not supported"); - } - - return `${isSimulatedLocally ? chalk.blue("local") : chalk.yellow("remote")}${connected === undefined ? "" : connected ? chalk.green(" [connected]") : chalk.red(" [not connected]")}`; - }; -} - -/** - * Validates the user's `remote` setting for a given binding against the - * binding type's local-development capabilities (sourced from - * {@link getBindingLocalSupport}). Throws `UserError` for invalid combinations - * and emits warnings for valid-but-noteworthy ones. - */ -export function warnOrError( - type: Binding["type"], - remote: boolean | undefined -) { - const support = getBindingLocalSupport(type); - switch (support) { - case "local-and-remote": - return; - case "local-only": - if (remote === true) { - throw new UserError( - `${getBindingTypeFriendlyName(type)} bindings do not support accessing remote resources.`, - { - telemetryMessage: "utils bindings unsupported remote resources", - } - ); - } - return; - case "remote": - if (remote === false) { - throw new UserError( - `${getBindingTypeFriendlyName(type)} bindings do not support local development. You can set \`remote: true\` for the binding definition in your configuration file to access a remote version of the resource.`, - { - telemetryMessage: "utils bindings unsupported local development", - } - ); - } - if (remote === undefined) { - logger.warn( - `${getBindingTypeFriendlyName(type)} bindings do not support local development, and so parts of your Worker may not work correctly. You can set \`remote: true\` for the binding definition in your configuration file to access a remote version of the resource.` - ); - } - return; - case "DO-NOT-USE-this-resource-will-never-have-a-local-simulator": - if (remote === false) { - throw new UserError( - `${getBindingTypeFriendlyName(type)} bindings do not support local development. You can set \`remote: true\` for the binding definition in your configuration file to access a remote version of the resource.`, - { - telemetryMessage: - "utils bindings unsupported local development always remote", - } - ); - } - if (remote === undefined) { - logger.warn( - `${getBindingTypeFriendlyName(type)} bindings always access remote resources, and so may incur usage charges even in local dev. To suppress this warning, set \`remote: true\` for the binding definition in your configuration file.` - ); - } - return; - default: - assertNever(support); - } -} +export { printBindings, warnOrError } from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/utils/useServiceEnvironments.ts b/packages/wrangler/src/utils/useServiceEnvironments.ts index ccf5ba1fca..bee419520f 100644 --- a/packages/wrangler/src/utils/useServiceEnvironments.ts +++ b/packages/wrangler/src/utils/useServiceEnvironments.ts @@ -1,26 +1,7 @@ -import type { StartDevWorkerOptions } from "../api"; +import { useServiceEnvironments } from "@cloudflare/deploy-helpers"; import type { Config } from "@cloudflare/workers-utils"; -/** - * whether deprecated service environments are enabled. - */ -export function useServiceEnvironments( - config: Config | StartDevWorkerOptions -): boolean { - // legacy env refers to wrangler environments, which are not actually legacy in any way. - // This is opposed to service environments, which are deprecated. - // Unfortunately legacy-env is a public facing arg and config option, so we have to leave the name. - // However we can change the internal handling to be less confusing. - // - // We only read from config here, because we've already accounted for - // // args["legacy-env"] in https://github.com/cloudflare/workers-sdk/blob/b24aeb5722370c2e04bce97a84a1fa1e55725d79/packages/wrangler/src/config/validation.ts#L94-L98 - // return "legacy_env" in config - // ? config.legacy_env - // : !config.legacy.useServiceEnvironments; - return "legacy_env" in config - ? !config.legacy_env - : Boolean(config.legacy.useServiceEnvironments); -} +export { useServiceEnvironments }; /** * even though service environments might be enabled, we might not need to use the service environments api diff --git a/packages/wrangler/src/utils/worker-not-found-error.ts b/packages/wrangler/src/utils/worker-not-found-error.ts index dacb1e5ea6..cf8eeca341 100644 --- a/packages/wrangler/src/utils/worker-not-found-error.ts +++ b/packages/wrangler/src/utils/worker-not-found-error.ts @@ -1,31 +1,6 @@ -/** - This is the error code from the Cloudflare API signaling that a worker could not be found on the target account - */ -export const WORKER_NOT_FOUND_ERR_CODE = 10007 as const; - -/** - This is the error code from the Cloudflare API signaling that a worker environment (legacy) could not be found on the target account - */ -export const WORKER_LEGACY_ENVIRONMENT_NOT_FOUND_ERR_CODE = 10090 as const; - -/** - This is the error message from the Cloudflare API signaling that a worker could not be found on the target account - */ -export const workerNotFoundErrorMessage = - "This Worker does not exist on your account."; - -/** - * Given an error from the Cloudflare API discerns whether it is caused by a worker that could not be found on the target account - * - * @param error The error object - * @returns true if the object represents an error from the Cloudflare API caused by a not found worker, false otherwise - */ -export function isWorkerNotFoundError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error.code === WORKER_NOT_FOUND_ERR_CODE || - error.code === WORKER_LEGACY_ENVIRONMENT_NOT_FOUND_ERR_CODE) - ); -} +export { + WORKER_NOT_FOUND_ERR_CODE, + WORKER_LEGACY_ENVIRONMENT_NOT_FOUND_ERR_CODE, + workerNotFoundErrorMessage, + isWorkerNotFoundError, +} from "@cloudflare/deploy-helpers"; diff --git a/packages/wrangler/src/versions/api.ts b/packages/wrangler/src/versions/api.ts index a2c0bfa214..d3fd470008 100644 --- a/packages/wrangler/src/versions/api.ts +++ b/packages/wrangler/src/versions/api.ts @@ -1,4 +1,13 @@ -import { fetchResult } from "../cfetch"; +import { + createDeployment as createDeploymentBase, + fetchDeployableVersions as fetchDeployableVersionsBase, + fetchDeploymentVersions as fetchDeploymentVersionsBase, + fetchLatestDeployment as fetchLatestDeploymentBase, + fetchLatestDeployments as fetchLatestDeploymentsBase, + fetchVersion as fetchVersionBase, + fetchVersions as fetchVersionsBase, + patchNonVersionedScriptSettings as patchNonVersionedScriptSettingsBase, +} from "@cloudflare/deploy-helpers"; import type { ApiDeployment, ApiVersion, @@ -6,12 +15,9 @@ import type { VersionCache, VersionId, } from "./types"; -import type { - ComplianceConfig, - Observability, - StreamingTailConsumer, - TailConsumer, -} from "@cloudflare/workers-utils"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; + +export type { NonVersionedScriptSettings } from "@cloudflare/deploy-helpers"; export async function fetchVersion( complianceConfig: ComplianceConfig, @@ -20,19 +26,13 @@ export async function fetchVersion( versionId: VersionId, versionCache?: VersionCache ): Promise { - const cachedVersion = versionCache?.get(versionId); - if (cachedVersion) { - return cachedVersion; - } - - const version = await fetchResult( + return fetchVersionBase( complianceConfig, - `/accounts/${accountId}/workers/scripts/${workerName}/versions/${versionId}` + accountId, + workerName, + versionId, + versionCache ); - - versionCache?.set(version.id, version); - - return version; } export async function fetchVersions( @@ -42,16 +42,12 @@ export async function fetchVersions( versionCache: VersionCache | undefined, ...versionIds: VersionId[] ) { - return Promise.all( - versionIds.map((versionId) => - fetchVersion( - complianceConfig, - accountId, - workerName, - versionId, - versionCache - ) - ) + return fetchVersionsBase( + complianceConfig, + accountId, + workerName, + versionCache, + versionIds ); } @@ -60,27 +56,15 @@ export async function fetchLatestDeployments( accountId: string, workerName: string ): Promise { - const { deployments } = await fetchResult<{ - deployments: ApiDeployment[]; - }>( - complianceConfig, - `/accounts/${accountId}/workers/scripts/${workerName}/deployments` - ); - - return deployments; + return fetchLatestDeploymentsBase(complianceConfig, accountId, workerName); } + export async function fetchLatestDeployment( complianceConfig: ComplianceConfig, accountId: string, workerName: string ): Promise { - const deployments = await fetchLatestDeployments( - complianceConfig, - accountId, - workerName - ); - - return deployments.at(0); + return fetchLatestDeploymentBase(complianceConfig, accountId, workerName); } export async function fetchDeploymentVersions( @@ -90,23 +74,13 @@ export async function fetchDeploymentVersions( deployment: ApiDeployment | undefined, versionCache: VersionCache ): Promise<[ApiVersion[], Map]> { - if (!deployment) { - return [[], new Map()]; - } - - const versionTraffic = new Map( - deployment.versions.map((v) => [v.version_id, v.percentage]) - ); - - const versions = await fetchVersions( + return fetchDeploymentVersionsBase( complianceConfig, accountId, workerName, - versionCache, - ...versionTraffic.keys() + deployment, + versionCache ); - - return [versions, versionTraffic]; } export async function fetchDeployableVersions( @@ -115,18 +89,12 @@ export async function fetchDeployableVersions( workerName: string, versionCache: VersionCache ): Promise { - const { items: versions } = await fetchResult<{ - items: ApiVersion[]; - }>( + return fetchDeployableVersionsBase( complianceConfig, - `/accounts/${accountId}/workers/scripts/${workerName}/versions?deployable=true` + accountId, + workerName, + versionCache ); - - for (const version of versions) { - versionCache.set(version.id, version); - } - - return versions; } export async function createDeployment( @@ -137,51 +105,28 @@ export async function createDeployment( message: string | undefined, force?: boolean ) { - return await fetchResult<{ id: string }>( + return createDeploymentBase( complianceConfig, - `/accounts/${accountId}/workers/scripts/${workerName}/deployments${force ? "?force=true" : ""}`, - - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - strategy: "percentage", - versions: Array.from(versionTraffic).map( - ([version_id, percentage]) => ({ version_id, percentage }) - ), - annotations: { - "workers/message": message, - }, - }), - } + accountId, + workerName, + versionTraffic, + message, + force ); } -export type NonVersionedScriptSettings = { - logpush: boolean; - tags: string[] | null; - tail_consumers: TailConsumer[]; - streaming_tail_consumers: StreamingTailConsumer[]; - observability: Observability; -}; - export async function patchNonVersionedScriptSettings( complianceConfig: ComplianceConfig, accountId: string, workerName: string, - settings: Partial + settings: Partial< + import("@cloudflare/deploy-helpers").NonVersionedScriptSettings + > ) { - const res = await fetchResult( + return patchNonVersionedScriptSettingsBase( complianceConfig, - `/accounts/${accountId}/workers/scripts/${workerName}/script-settings`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settings), - } + accountId, + workerName, + settings ); - - // TODO: handle specific errors - - return res; } diff --git a/packages/wrangler/src/versions/deploy.ts b/packages/wrangler/src/versions/deploy.ts index e11ccc27b0..6e55c2b805 100644 --- a/packages/wrangler/src/versions/deploy.ts +++ b/packages/wrangler/src/versions/deploy.ts @@ -7,15 +7,14 @@ import { leftT, spinnerWhile, } from "@cloudflare/cli-shared-helpers/interactive"; +import { type ApiVersion, printVersions } from "@cloudflare/deploy-helpers"; import { UserError } from "@cloudflare/workers-utils"; import { fetchResult } from "../cfetch"; import { createCommand } from "../core/create-command"; -import { isNonInteractiveOrCI } from "../is-interactive"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; import { requireAuth } from "../user"; import formatLabelledValues from "../utils/render-labelled-values"; -import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; import { createDeployment, fetchDeployableVersions, @@ -24,13 +23,7 @@ import { fetchVersions, patchNonVersionedScriptSettings, } from "./api"; -import type { - ApiDeployment, - ApiVersion, - Percentage, - VersionCache, - VersionId, -} from "./types"; +import type { Percentage, VersionCache, VersionId } from "./types"; import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; const EPSILON = 0.001; // used to avoid floating-point errors. Comparions to a value +/- EPSILON will mean "roughly equals the value". @@ -251,50 +244,10 @@ export const versionsDeployCommand = createCommand({ }, }); -/** - * Prompts the user for confirmation when overwriting the latest deployment, given that it's split. - */ -export async function confirmLatestDeploymentOverwrite( - config: Config, - accountId: string, - scriptName: string -) { - try { - const latest = await fetchLatestDeployment(config, accountId, scriptName); - if (latest && latest.versions.length >= 2) { - const versionCache: VersionCache = new Map(); - - // Print message and confirmation. - - cli.warn( - `Your last deployment has multiple versions. To progress that deployment use "wrangler versions deploy" instead.`, - { shape: cli.shapes.corners.tl, newlineBefore: false } - ); - cli.newline(); - await printDeployment( - config, - accountId, - scriptName, - latest, - "last", - versionCache - ); - - return inputPrompt({ - type: "confirm", - question: `"wrangler deploy" will upload a new version and deploy it globally immediately.\nAre you sure you want to continue?`, - label: "", - defaultValue: isNonInteractiveOrCI(), // defaults to true in CI for back-compat - acceptDefault: isNonInteractiveOrCI(), - }); - } - } catch (e) { - if (!isWorkerNotFoundError(e)) { - throw e; - } - } - return true; -} +export { + confirmLatestDeploymentOverwrite, + printVersions, +} from "@cloudflare/deploy-helpers"; export async function printLatestDeployment( config: Config, @@ -308,62 +261,19 @@ export async function printLatestDeployment( return fetchLatestDeployment(config, accountId, workerName); }, }); - await printDeployment( - config, - accountId, - workerName, - latestDeployment, - "current", - versionCache - ); -} - -async function printDeployment( - config: Config, - accountId: string, - workerName: string, - deployment: ApiDeployment | undefined, - adjective: "current" | "last", - versionCache: VersionCache -) { const [versions, traffic] = await fetchDeploymentVersions( config, accountId, workerName, - deployment, + latestDeployment, versionCache ); cli.logRaw( - `${leftT} Your ${adjective} deployment has ${versions.length} version(s):` + `${leftT} Your current deployment has ${versions.length} version(s):` ); printVersions(versions, traffic); } -export function printVersions( - versions: ApiVersion[], - traffic: Map -) { - cli.newline(); - cli.log(formatVersions(versions, traffic)); - cli.newline(); -} - -function formatVersions( - versions: ApiVersion[], - traffic: Map -) { - return versions - .map((version) => { - const trafficString = brandColor(`(${traffic.get(version.id)}%)`); - const versionIdString = white(version.id); - return gray(`${trafficString} ${versionIdString} - Created: ${version.metadata.created_on} - Tag: ${version.annotations?.["workers/tag"] ?? BLANK_INPUT} - Message: ${version.annotations?.["workers/message"] ?? BLANK_INPUT}`); - }) - .join("\n\n"); -} - /** * Prompts the user to select which versions they want to deploy. * The list of possible versions will include: diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 69ef843e19..6830a63716 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -1,27 +1,7 @@ -import assert from "node:assert"; -import { execSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { blue, gray } from "@cloudflare/cli-shared-helpers/colors"; -import { getWorkersDevSubdomain } from "@cloudflare/deploy-helpers"; -import { - configFileName, - getTodaysCompatDate, - formatConfigSnippet, - getWorkersCIBranchName, - ParseError, - UserError, - formatTime, -} from "@cloudflare/workers-utils"; -import { Response } from "undici"; -import { syncAssets } from "../assets"; -import { fetchResult } from "../cfetch"; +import { versionsUpload as versionsUploadBase } from "@cloudflare/deploy-helpers"; +import { analyseBundle } from "../check/commands"; import { createCommand } from "../core/create-command"; -import { createDeployHelpersContext } from "../core/deploy-helpers-context"; -import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; -import { printBundleSize } from "../deployment-bundle/bundle-reporter"; -import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; +import { provisionBindings } from "../deployment-bundle/bindings"; import { sharedDeployVersionsArgs, validateDeployVersionsArgs, @@ -31,44 +11,14 @@ import { cleanupDestination, mergeVersionsUploadConfigArgs, } from "../deployment-bundle/merge-config-args"; -import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; -import { - addRequiredSecretsInheritBindings, - handleMissingSecretsError, -} from "../deployment-bundle/secrets-validation"; -import { loadSourceMaps } from "../deployment-bundle/source-maps"; -import { confirm } from "../dialogs"; -import { getMigrationsToUpload } from "../durable"; -import { - applyServiceAndEnvironmentTags, - tagsAreEqual, - warnOnErrorUpdatingServiceAndEnvironmentTags, -} from "../environments"; -import { logger } from "../logger"; -import { verifyWorkerMatchesCITag } from "../match-tag"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; -import { ensureQueuesExistByConfig } from "../queues/client"; -import { parseBulkInputToObject } from "../secret"; -import { - getSourceMappedString, - maybeRetrieveFileSourceMap, -} from "../sourcemap"; -import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors"; import { getScriptName } from "../utils/getScriptName"; -import { parseConfigPlacement } from "../utils/placement"; -import { printBindings } from "../utils/print-bindings"; -import { retryOnAPIFailure } from "../utils/retry"; -import { useServiceEnvironments as useServiceEnvironmentsConfig } from "../utils/useServiceEnvironments"; -import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; -import { patchNonVersionedScriptSettings } from "./api"; -import type { RetrieveSourceMapFunction } from "../sourcemap"; import type { HandleBuild, VersionsUploadProps, } from "@cloudflare/deploy-helpers"; -import type { CfWorkerInit, Config } from "@cloudflare/workers-utils"; -import type { FormData } from "undici"; +import type { Config } from "@cloudflare/workers-utils"; export const versionsUploadCommand = createCommand({ metadata: { @@ -153,580 +103,8 @@ export default async function versionsUpload( versionPreviewUrl?: string | undefined; versionPreviewAliasUrl?: string | undefined; }> { - if (!props.name) { - throw new UserError( - 'You need to provide a name of your worker. Either pass it as a cli arg with `--name ` or in your config file as `name = ""`', - { telemetryMessage: "versions upload missing worker name" } - ); - } - const { - entry, - name, - compatibilityDate, - compatibilityFlags, - keepVars, - minify, - noBundle, - uploadSourceMaps, - accountId, - } = props; - - if (!props.dryRun) { - assert(accountId, "Missing account ID"); - await verifyWorkerMatchesCITag(config, accountId, name, config.configPath); - } - let versionId: string | null = null; - let workerTag: string | null = null; - let tags: string[] = []; // arbitrary metadata tags, not to be confused with script tag or annotations - - if (accountId && name) { - try { - const { - default_environment: { script }, - } = await fetchResult<{ - default_environment: { - script: { - tag: string; - tags: string[] | null; - last_deployed_from: "dash" | "wrangler" | "api"; - }; - }; - }>( - config, - `/accounts/${accountId}/workers/services/${name}` // TODO(consider): should this be a /versions endpoint? - ); - - workerTag = script.tag; - tags = script.tags ?? tags; - - if (script.last_deployed_from === "dash") { - logger.warn( - `You are about to upload a Worker Version that was last published via the Cloudflare Dashboard.\nEdits that have been made via the dashboard will be overridden by your local code and config.` - ); - if (!(await confirm("Would you like to continue?"))) { - return { - versionId, - workerTag, - }; - } - } else if (script.last_deployed_from === "api") { - logger.warn( - `You are about to upload a Workers Version that was last updated via the API.\nEdits that have been made via the API will be overridden by your local code and config.` - ); - if (!(await confirm("Would you like to continue?"))) { - return { - versionId, - workerTag, - }; - } - } - } catch (e) { - if (!isWorkerNotFoundError(e)) { - throw e; - } - } - } - - if (!compatibilityDate) { - const compatibilityDateStr = getTodaysCompatDate(); - - throw new UserError( - `A compatibility_date is required when uploading a Worker Version. Add the following to your ${configFileName(config.configPath)} file: - \`\`\` - ${formatConfigSnippet({ compatibility_date: compatibilityDateStr }, config.configPath, false)} - \`\`\` - Or you could pass it in your terminal as \`--compatibility-date ${compatibilityDateStr}\` -See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information.`, - { - telemetryMessage: "versions upload missing compatibility date", - } - ); - } - - const nodejsCompatMode = validateNodeCompatMode( - compatibilityDate, - compatibilityFlags, - { noBundle } - ); - - // Warn if user tries minify or node-compat with no-bundle - if (noBundle && minify) { - logger.warn( - "`--minify` and `--no-bundle` can't be used together. If you want to minify your Worker and disable Wrangler's bundling, please minify as part of your own bundling process." - ); - } - - const scriptName = name; - - if (config.site && !config.site.bucket) { - throw new UserError( - "A [site] definition requires a `bucket` field with a path to the site's assets directory.", - { telemetryMessage: "versions upload sites missing bucket" } - ); - } - - const start = Date.now(); - const workerName = scriptName; - const workerUrl = `/accounts/${accountId}/workers/scripts/${scriptName}`; - - const { format } = entry; - const projectRoot = entry.projectRoot; - - if (config.wasm_modules && format === "modules") { - throw new UserError( - "You cannot configure [wasm_modules] with an ES module worker. Instead, import the .wasm module directly in your code", - { - telemetryMessage: - "versions upload wasm modules unsupported module worker", - } - ); - } - - if (config.text_blobs && format === "modules") { - throw new UserError( - `You cannot configure [text_blobs] with an ES module worker. Instead, import the file directly in your code, and optionally configure \`[rules]\` in your ${configFileName(config.configPath)} file`, - { - telemetryMessage: - "versions upload text blobs unsupported module worker", - } - ); - } - - if (config.data_blobs && format === "modules") { - throw new UserError( - `You cannot configure [data_blobs] with an ES module worker. Instead, import the file directly in your code, and optionally configure \`[rules]\` in your ${configFileName(config.configPath)} file`, - { - telemetryMessage: - "versions upload data blobs unsupported module worker", - } - ); - } - - let hasPreview = false; - - const { - modules, - dependencies, - resolvedEntryPointPath, - bundleType, - content, - bundle, - } = await buildWorker.build(props, config, { - nodejsCompatMode, + return versionsUploadBase(props, config, buildWorker, { + provisionBindings, + analyseBundle, }); - const bindings = getBindings(config); - - // Vars from the CLI (--var) are hidden so their values aren't logged to the terminal - for (const [bindingName, value] of Object.entries(props.cliVars)) { - bindings[bindingName] = { - type: "plain_text", - value, - hidden: true, - }; - } - - // durable object migrations - const migrations = !props.dryRun - ? await getMigrationsToUpload(scriptName, { - accountId, - config, - useServiceEnvironments: useServiceEnvironmentsConfig(config), - env: props.env, - dispatchNamespace: undefined, - }) - : undefined; - - // Upload assets if assets is being used - const assetsJwt = - props.assetsOptions && !props.dryRun - ? await syncAssets( - config, - accountId, - props.assetsOptions.directory, - scriptName - ) - : undefined; - - if (props.secretsFile) { - const secretsResult = await parseBulkInputToObject(props.secretsFile); - if (secretsResult) { - for (const [secretName, secretValue] of Object.entries( - secretsResult.content - )) { - bindings[secretName] = { - type: "secret_text", - value: secretValue, - }; - } - } - } - - addRequiredSecretsInheritBindings(config, bindings, { type: "upload" }); - - const placement = parseConfigPlacement(config); - - const entryPointName = path.basename(resolvedEntryPointPath); - const main = { - name: entryPointName, - filePath: resolvedEntryPointPath, - content: content, - type: bundleType, - }; - const worker: CfWorkerInit = { - name: scriptName, - main, - migrations, - modules, - containers: config.containers, - sourceMaps: uploadSourceMaps - ? loadSourceMaps(main, modules, bundle) - : undefined, - compatibility_date: compatibilityDate, - compatibility_flags: compatibilityFlags, - keepVars, - // we never delete secret bindings when uploading, even if we are setting secrets from a file - // so inherit all unchanged secrets from the previous Worker Version - keepSecrets: true, - placement, - tail_consumers: config.tail_consumers, - limits: config.limits, - annotations: { - "workers/message": props.message, - "workers/tag": props.tag, - "workers/alias": props.previewAlias, - }, - assets: - props.assetsOptions && assetsJwt - ? { - jwt: assetsJwt, - routerConfig: props.assetsOptions.routerConfig, - assetConfig: props.assetsOptions.assetConfig, - _redirects: props.assetsOptions._redirects, - _headers: props.assetsOptions._headers, - run_worker_first: props.assetsOptions.run_worker_first, - } - : undefined, - logpush: undefined, // logpush and observability are non-versioned settings - observability: undefined, - cache: config.cache, // cache is a versioned setting - }; - - if (config.containers && config.containers.length > 0) { - logger.warn( - `Your Worker has Containers configured. Container configuration changes (such as image, max_instances, etc.) will not be gradually rolled out with versions. These changes will only take effect after running \`wrangler deploy\`.` - ); - } - - await printBundleSize( - { name: path.basename(resolvedEntryPointPath), content: content }, - modules - ); - - let workerBundle: FormData; - - if (props.dryRun) { - workerBundle = createWorkerUploadForm(worker, bindings, { - dryRun: true, - unsafe: config.unsafe, - }); - printBindings( - bindings, - config.tail_consumers, - config.streaming_tail_consumers, - undefined, - { unsafeMetadata: config.unsafe?.metadata } - ); - } else { - assert(accountId, "Missing accountId"); - if (props.resourcesProvision) { - await provisionBindings( - bindings, - accountId, - scriptName, - props.experimentalAutoCreate, - config - ); - } - workerBundle = createWorkerUploadForm(worker, bindings, { - unsafe: config.unsafe, - }); - - await ensureQueuesExistByConfig(config); - let bindingsPrinted = false; - - // Upload the version. - try { - const result = await retryOnAPIFailure(async () => - fetchResult<{ - id: string; - startup_time_ms: number; - metadata: { - has_preview: boolean; - }; - }>( - config, - `${workerUrl}/versions`, - { - method: "POST", - body: workerBundle, - headers: props.sendMetrics ? { metricsEnabled: "true" } : undefined, - }, - new URLSearchParams({ bindings_inherit: "strict" }) - ) - ); - - logger.log("Worker Startup Time:", result.startup_time_ms, "ms"); - bindingsPrinted = true; - printBindings( - bindings, - config.tail_consumers, - config.streaming_tail_consumers, - undefined, - { unsafeMetadata: config.unsafe?.metadata } - ); - versionId = result.id; - hasPreview = result.metadata.has_preview; - } catch (err) { - if (!bindingsPrinted) { - printBindings( - bindings, - config.tail_consumers, - config.streaming_tail_consumers, - undefined, - { unsafeMetadata: config.unsafe?.metadata } - ); - } - - const message = await helpIfErrorIsSizeOrScriptStartup( - err, - dependencies, - workerBundle, - projectRoot - ); - if (message) { - logger.error(message); - } - - handleMissingSecretsError(err, config, { type: "upload" }); - - // Apply source mapping to validation startup errors if possible - if ( - err instanceof ParseError && - "code" in err && - err.code === 10021 /* validation error */ && - err.notes.length > 0 - ) { - const maybeNameToFilePath = (moduleName: string) => { - // If this is a service worker, always return the entrypoint path. - // Service workers can't have additional JavaScript modules. - if (bundleType === "commonjs") { - return resolvedEntryPointPath; - } - // Similarly, if the name matches the entrypoint, return its path - if (moduleName === entryPointName) { - return resolvedEntryPointPath; - } - // Otherwise, return the file path of the matching module (if any) - for (const module of modules) { - if (moduleName === module.name) { - return module.filePath; - } - } - }; - const retrieveSourceMap: RetrieveSourceMapFunction = (moduleName) => - maybeRetrieveFileSourceMap(maybeNameToFilePath(moduleName)); - - err.notes[0].text = getSourceMappedString( - err.notes[0].text, - retrieveSourceMap - ); - } - - throw err; - } - - // Update service and environment tags when using environments - - const nextTags = applyServiceAndEnvironmentTags(config, tags); - if (!tagsAreEqual(tags, nextTags)) { - try { - await patchNonVersionedScriptSettings(config, accountId, scriptName, { - tags: nextTags, - }); - } catch { - warnOnErrorUpdatingServiceAndEnvironmentTags(); - } - } - } - if (props.outfile) { - // we're using a custom output file, - // so let's first ensure it's parent directory exists - mkdirSync(path.dirname(props.outfile), { recursive: true }); - - const serializedFormData = await new Response(workerBundle).arrayBuffer(); - - writeFileSync(props.outfile, Buffer.from(serializedFormData)); - } - - if (props.dryRun) { - logger.log(`--dry-run: exiting now.`); - return { versionId, workerTag }; - } - if (!accountId) { - throw new UserError("Missing accountId", { - telemetryMessage: "versions upload missing account id", - }); - } - - const uploadMs = Date.now() - start; - - logger.log("Uploaded", workerName, formatTime(uploadMs)); - logger.log("Worker Version ID:", versionId); - - let versionPreviewUrl: string | undefined = undefined; - let versionPreviewAliasUrl: string | undefined = undefined; - - if (versionId && hasPreview) { - const { previews_enabled: previews_available_on_subdomain } = - await fetchResult<{ - previews_enabled: boolean; - }>(config, `${workerUrl}/subdomain`); - - if (previews_available_on_subdomain) { - const userSubdomain = await getWorkersDevSubdomain( - config, - accountId, - createDeployHelpersContext(), - { - configPath: config.configPath, - } - ); - const shortVersion = versionId.slice(0, 8); - versionPreviewUrl = `https://${shortVersion}-${workerName}.${userSubdomain}`; - logger.log(`Version Preview URL: ${versionPreviewUrl}`); - - if (props.previewAlias) { - versionPreviewAliasUrl = `https://${props.previewAlias}-${workerName}.${userSubdomain}`; - logger.log(`Version Preview Alias URL: ${versionPreviewAliasUrl}`); - } - } - } - - const cmdVersionsDeploy = blue("wrangler versions deploy"); - const cmdTriggersDeploy = blue("wrangler triggers deploy"); - logger.info( - gray(` -To deploy this version to production traffic use the command ${cmdVersionsDeploy} - -Changes to non-versioned settings (config properties 'logpush' or 'tail_consumers') take effect after your next deployment using the command ${cmdVersionsDeploy} - -Changes to triggers (routes, custom domains, cron schedules, etc) must be applied with the command ${cmdTriggersDeploy} -`) - ); - - return { versionId, workerTag, versionPreviewUrl, versionPreviewAliasUrl }; -} - -// Constants for DNS label constraints and hash configuration -const MAX_DNS_LABEL_LENGTH = 63; -const HASH_LENGTH = 4; -const ALIAS_VALIDATION_REGEX = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/i; - -/** - * Sanitizes a branch name to create a valid DNS label alias. - * Converts to lowercase, replaces invalid chars with dashes, removes consecutive dashes. - */ -function sanitizeBranchName(branchName: string): string { - return branchName - .replace(/[^a-zA-Z0-9-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase(); -} - -/** - * Gets the current branch name from CI environment or git. - */ -function getBranchName(): string | undefined { - // Try CI environment variable first - const ciBranchName = getWorkersCIBranchName(); - if (ciBranchName) { - return ciBranchName; - } - - // Fall back to git commands - try { - execSync(`git rev-parse --is-inside-work-tree`, { stdio: "ignore" }); - return execSync(`git rev-parse --abbrev-ref HEAD`).toString().trim(); - } catch { - return undefined; - } -} - -/** - * Creates a truncated alias with hash suffix when the branch name is too long. - * Hash from original branch name to preserve uniqueness. - */ -function createTruncatedAlias( - branchName: string, - sanitizedAlias: string, - availableSpace: number -): string | undefined { - const spaceForHash = HASH_LENGTH + 1; // +1 for hyphen separator - const maxPrefixLength = availableSpace - spaceForHash; - - if (maxPrefixLength < 1) { - // Not enough space even with truncation - return undefined; - } - - const hash = createHash("sha256") - .update(branchName) - .digest("hex") - .slice(0, HASH_LENGTH); - - const truncatedPrefix = sanitizedAlias.slice(0, maxPrefixLength); - return `${truncatedPrefix}-${hash}`; -} - -/** - * Generates a preview alias based on the current git branch. - * Alias must be <= 63 characters, alphanumeric + dashes only, and start with a letter. - * Returns undefined if not in a git directory or requirements cannot be met. - */ -export function generatePreviewAlias(scriptName: string): string | undefined { - const warnAndExit = () => { - logger.warn( - `Preview alias generation requested, but could not be autogenerated.` - ); - return undefined; - }; - - const branchName = getBranchName(); - if (!branchName) { - return warnAndExit(); - } - - const sanitizedAlias = sanitizeBranchName(branchName); - - // Validate the sanitized alias meets DNS label requirements - if (!ALIAS_VALIDATION_REGEX.test(sanitizedAlias)) { - return warnAndExit(); - } - - const availableSpace = MAX_DNS_LABEL_LENGTH - scriptName.length - 1; - - // If the sanitized alias fits within the remaining space, return it, - // otherwise otherwise try truncation with hash suffixed - if (sanitizedAlias.length <= availableSpace) { - return sanitizedAlias; - } - - const truncatedAlias = createTruncatedAlias( - branchName, - sanitizedAlias, - availableSpace - ); - - return truncatedAlias || warnAndExit(); } diff --git a/packages/wrangler/src/zones.ts b/packages/wrangler/src/zones.ts index 7db007dac3..aa965ae352 100644 --- a/packages/wrangler/src/zones.ts +++ b/packages/wrangler/src/zones.ts @@ -1,7 +1,6 @@ import { getZoneForRoute, getZoneIdFromHost } from "@cloudflare/deploy-helpers"; import { configFileName, UserError } from "@cloudflare/workers-utils"; import { fetchListResult } from "./cfetch"; -import { createDeployHelpersContext } from "./core/deploy-helpers-context"; import type { ZoneIdCache } from "@cloudflare/deploy-helpers"; import type { ComplianceConfig, Route } from "@cloudflare/workers-utils"; @@ -19,7 +18,6 @@ export async function getZoneIdForPreview( accountId: string; } ) { - const ctx = createDeployHelpersContext(); const zoneIdCache: ZoneIdCache = new Map(); const { host, routes, accountId } = from; let zoneId: string | undefined; @@ -27,7 +25,6 @@ export async function getZoneIdForPreview( zoneId = await getZoneIdFromHost( complianceConfig, { host, accountId }, - ctx, zoneIdCache ); } @@ -39,7 +36,6 @@ export async function getZoneIdForPreview( route: firstRoute, accountId, }, - ctx, zoneIdCache ); if (zone) { @@ -125,14 +121,10 @@ export async function getWorkerForZone( configPath: string | undefined ) { const { worker, accountId } = from; - const zone = await getZoneForRoute( - complianceConfig, - { - route: worker, - accountId, - }, - createDeployHelpersContext() - ); + const zone = await getZoneForRoute(complianceConfig, { + route: worker, + accountId, + }); if (!zone) { throw new UserError( `The route '${worker}' is not part of one of your zones. Either add this zone from the Cloudflare dashboard, or try using a route within one of your existing zones.`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3e55a4084..a7ea98b2c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,18 @@ catalogs: capnweb: specifier: 0.5.0 version: 0.5.0 + chalk: + specifier: 5.3.0 + version: 5.3.0 ci-info: specifier: 4.4.0 version: 4.4.0 + command-exists: + specifier: 1.2.9 + version: 1.2.9 + dotenv: + specifier: 16.3.1 + version: 16.3.1 esbuild: specifier: 0.27.3 version: 0.27.3 @@ -1558,7 +1567,7 @@ importers: specifier: workspace:* version: link:../workers-utils chalk: - specifier: 5.3.0 + specifier: catalog:default version: 5.3.0 ci-info: specifier: catalog:default @@ -1721,7 +1730,7 @@ importers: specifier: ^17.0.22 version: 17.0.24 command-exists: - specifier: ^1.2.9 + specifier: catalog:default version: 1.2.9 comment-json: specifier: ^4.5.0 @@ -1739,7 +1748,7 @@ importers: specifier: ^2.1.0 version: 2.1.0 dotenv: - specifier: ^16.0.0 + specifier: catalog:default version: 16.3.1 esbuild: specifier: catalog:default @@ -1816,31 +1825,73 @@ importers: packages/deploy-helpers: dependencies: - '@cloudflare/workers-utils': + '@cloudflare/cli-shared-helpers': specifier: workspace:* - version: link:../workers-utils - devDependencies: + version: link:../cli '@cloudflare/containers-shared': specifier: workspace:* version: link:../containers-shared + '@cloudflare/workers-shared': + specifier: workspace:* + version: link:../workers-shared + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + blake3-wasm: + specifier: 2.1.5 + version: 2.1.5 + chalk: + specifier: catalog:default + version: 5.3.0 + command-exists: + specifier: catalog:default + version: 1.2.9 + dotenv: + specifier: catalog:default + version: 16.3.1 + miniflare: + specifier: workspace:* + version: link:../miniflare + p-queue: + specifier: 9.0.0 + version: 9.0.0 + pretty-bytes: + specifier: 6.1.1 + version: 6.1.1 + undici: + specifier: catalog:default + version: 7.24.8 + devDependencies: '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig + '@cspotcode/source-map-support': + specifier: 0.8.1 + version: 0.8.1 + '@types/command-exists': + specifier: ^1.2.0 + version: 1.2.0 + '@types/json-diff': + specifier: ^1.0.3 + version: 1.0.3 '@types/node': specifier: 22.15.17 version: 22.15.17 - chalk: - specifier: ^5.2.0 - version: 5.3.0 concurrently: specifier: ^8.2.2 version: 8.2.2 - miniflare: - specifier: workspace:* - version: link:../miniflare - p-queue: - specifier: ^9.0.0 - version: 9.0.0 + devtools-protocol: + specifier: ^0.0.1182435 + version: 0.0.1182435 + esbuild: + specifier: catalog:default + version: 0.27.3 + json-diff: + specifier: ^1.0.6 + version: 1.0.6 + ts-dedent: + specifier: ^2.2.0 + version: 2.2.0 tsup: specifier: 8.3.0 version: 8.3.0(@microsoft/api-extractor@7.52.8(@types/node@22.15.17))(jiti@2.6.1)(postcss@8.5.14)(supports-color@9.2.2)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.1) @@ -3990,7 +4041,7 @@ importers: specifier: ^5.2.0 version: 5.2.0(encoding@0.1.13) command-exists: - specifier: ^1.2.9 + specifier: catalog:default version: 1.2.9 concurrently: specifier: ^8.2.2 @@ -4162,9 +4213,6 @@ importers: '@types/javascript-time-ago': specifier: ^2.0.3 version: 2.0.3 - '@types/json-diff': - specifier: ^1.0.3 - version: 1.0.3 '@types/mime': specifier: ^3.0.4 version: 3.0.4 @@ -4211,7 +4259,7 @@ importers: specifier: catalog:default version: 0.5.0 chalk: - specifier: ^5.2.0 + specifier: catalog:default version: 5.3.0 chokidar: specifier: ^4.0.1 @@ -4229,7 +4277,7 @@ importers: specifier: ^4.1.0 version: 4.1.0 command-exists: - specifier: ^1.2.9 + specifier: catalog:default version: 1.2.9 concurrently: specifier: ^8.2.2 @@ -4241,7 +4289,7 @@ importers: specifier: ^0.0.1182435 version: 0.0.1182435 dotenv: - specifier: ^16.3.1 + specifier: catalog:default version: 16.3.1 dotenv-expand: specifier: ^12.0.2 @@ -4270,9 +4318,6 @@ importers: javascript-time-ago: specifier: ^2.5.4 version: 2.5.7 - json-diff: - specifier: ^1.0.6 - version: 1.0.6 jsonc-parser: specifier: catalog:default version: 3.2.0 @@ -4333,9 +4378,6 @@ importers: smol-toml: specifier: catalog:default version: 1.5.2 - source-map: - specifier: ^0.6.1 - version: 0.6.1 supports-color: specifier: ^9.2.2 version: 9.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 079b8e21b4..19bf9fb885 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -99,6 +99,9 @@ patchedDependencies: # ────────────────────────────────────────────────────────────────────────────── catalog: + chalk: "5.3.0" + command-exists: "1.2.9" + dotenv: "16.3.1" "@hey-api/openapi-ts": "0.94.0" "@types/node": "22.15.17" From 88519f9d0c3caf9a008a228c31568010477d80c6 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 9 Jun 2026 17:47:21 +0100 Subject: [PATCH 3/3] [create-cloudflare] Fix ERR_PNPM_IGNORED_BUILDS on pnpm 11 + close C3 e2e test gap (#14193) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/c3-exit-code-on-error.md | 5 + .changeset/swift-fox-flies.md | 9 + .github/workflows/c3-e2e.yml | 132 ++++++- .../e2e/helpers/framework-helpers.ts | 26 +- .../create-cloudflare/e2e/helpers/run-c3.ts | 41 ++ .../e2e/tests/cli/cli.test.ts | 169 +++++---- .../e2e/tests/frameworks/test-config.ts | 21 ++ packages/create-cloudflare/src/cli.ts | 20 +- .../src/helpers/__tests__/packages.test.ts | 224 +++++++++++ .../__tests__/pnpmBuildApprovals.test.ts | 356 ++++++++++++++++++ .../create-cloudflare/src/helpers/packages.ts | 149 +++++++- .../src/helpers/pnpmBuildApprovals.ts | 221 +++++++++++ 12 files changed, 1274 insertions(+), 99 deletions(-) create mode 100644 .changeset/c3-exit-code-on-error.md create mode 100644 .changeset/swift-fox-flies.md create mode 100644 packages/create-cloudflare/src/helpers/__tests__/packages.test.ts create mode 100644 packages/create-cloudflare/src/helpers/__tests__/pnpmBuildApprovals.test.ts create mode 100644 packages/create-cloudflare/src/helpers/pnpmBuildApprovals.ts diff --git a/.changeset/c3-exit-code-on-error.md b/.changeset/c3-exit-code-on-error.md new file mode 100644 index 0000000000..e618468124 --- /dev/null +++ b/.changeset/c3-exit-code-on-error.md @@ -0,0 +1,5 @@ +--- +"create-cloudflare": patch +--- + +Fix `create cloudflare` exiting with code `0` even after an unhandled error diff --git a/.changeset/swift-fox-flies.md b/.changeset/swift-fox-flies.md new file mode 100644 index 0000000000..d45ba36510 --- /dev/null +++ b/.changeset/swift-fox-flies.md @@ -0,0 +1,9 @@ +--- +"create-cloudflare": patch +--- + +Fix `create cloudflare` aborting with `ERR_PNPM_IGNORED_BUILDS` on pnpm 11 + +pnpm 11 flipped `strictDepBuilds` to `true` by default, which makes the install fail when dependencies have unapproved build scripts. `wrangler` depends on `workerd` and `esbuild`, and (via miniflare) on `sharp` β€” all three need their postinstall scripts to produce platform binaries. + +C3 now writes or merges in a `pnpm-workspace.yaml` in the generated project that approves exactly those three packages. If other packages trigger this error, C3 also now interactively offers to retry with `pnpm approve-builds …` diff --git a/.github/workflows/c3-e2e.yml b/.github/workflows/c3-e2e.yml index 544f20b327..1d64a38b2f 100644 --- a/.github/workflows/c3-e2e.yml +++ b/.github/workflows/c3-e2e.yml @@ -7,14 +7,30 @@ permissions: contents: read pull-requests: read +# pnpm 11-specific settings - recognised by pnpm 11+ and +# silently ignored by pnpm 10. +# +# - pmOnFail=ignore: pnpm 11's default `download` would silently re-exec as +# the monorepo's pinned pnpm 10.33.0, defeating the matrix override. +# - verifyDepsBeforeRun=false: pnpm 11's default `install` would re-run +# `pnpm install` at the monorepo root before invoking the script, which +# then trips on engines.pnpm. +env: + pnpm_config_pm_on_fail: ignore + pnpm_config_verify_deps_before_run: "false" + jobs: e2e: - # Runs the non-frameworks C3 E2E tests on all supported operating systems and package managers. + # Runs the non-frameworks C3 E2E tests on all supported operating systems + # and package managers. timeout-minutes: 45 concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os.name }}-${{ matrix.pm.name }}-${{ matrix.pm.version }}${{ matrix.experimental && '-experimental' || '' }}-${{ matrix.filter }} cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} - name: ${{ format('C3 E2E ({0}, {1}{2}) - {3}', matrix.pm.name, matrix.os.description, matrix.experimental && ', experimental' || '', matrix.filter) }} + # `matrix.pm.label` is the display name in job/artifact names. Default + # pm entries use the plain name (e.g. `pnpm`) to keep historical required- + # check names stable; non-default entries (e.g. pnpm 11) suffix the version. + name: ${{ format('C3 E2E ({0}, {1}{2}) - {3}', matrix.pm.label, matrix.os.description, matrix.experimental && ', experimental' || '', matrix.filter) }} strategy: fail-fast: false matrix: @@ -22,23 +38,27 @@ jobs: filter: ["cli", "workers"] os: [{ name: ubuntu-latest, description: Linux }] pm: - - { name: pnpm, version: "10.33.0" } - - { name: npm, version: "0.0.0" } + - { name: pnpm, version: "10.33.0", label: "pnpm" } + - { name: pnpm, version: "11.5.1", label: "pnpm@11.5.1" } + - { name: npm, version: "0.0.0", label: "npm" } # The yarn tests keep failing on Linux with out of space errors, with no clear reason why. Disabling for now. - # - { name: yarn, version: "1.0.0" } + # - { name: yarn, version: "1.0.0", label: "yarn" } include: + # Windows and experimental entries stay on the default pnpm to + # preserve historical required-check names. pnpm 11 coverage comes + # from the Linux base entries above. - os: { name: windows-latest, description: Windows } - pm: { name: pnpm, version: "10.33.0" } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } filter: "cli" - os: { name: windows-latest, description: Windows } - pm: { name: pnpm, version: "10.33.0" } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } filter: "workers" - os: { name: ubuntu-latest, description: Linux } - pm: { name: pnpm, version: "10.33.0" } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } experimental: true filter: "cli" - os: { name: ubuntu-latest, description: Linux } - pm: { name: pnpm, version: "10.33.0" } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } experimental: true filter: "workers" runs-on: ${{ matrix.os.name }} @@ -82,6 +102,45 @@ jobs: git config --global user.email wrangler@cloudflare.com git config --global user.name 'Wrangler Tester' + # Put the matrix pnpm version on PATH so scaffolded-project installs + # run it (not the monorepo's pinned pnpm 10.33.0). Without this, the + # matrix only relabels C3's `npm_config_user_agent` and pnpm 11 + # regressions go undetected. + # + # `pnpm/action-setup` refuses when both `version` input and the root + # `package.json#packageManager` are set, so we point it at a stub + # `package.json`. We also loosen `engines.pnpm` to avoid pnpm 11 + # bailing with ERR_PNPM_UNSUPPORTED_ENGINE at the monorepo root. + - name: Prepare workspace for matrix pnpm install + if: matrix.pm.name == 'pnpm' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + mkdir -p .ci-pnpm-setup-stub + printf '{"name":"ci-stub","private":true}\n' > .ci-pnpm-setup-stub/package.json + node -e " + const fs = require('node:fs'); + const pkg = JSON.parse(fs.readFileSync('package.json','utf8')); + if (pkg.engines && pkg.engines.pnpm) pkg.engines.pnpm = '*'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + - name: Install matrix pnpm version for scaffolded installs + if: matrix.pm.name == 'pnpm' && steps.changes.outputs.everything_but_markdown == 'true' + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + with: + version: ${{ matrix.pm.version }} + package_json_file: .ci-pnpm-setup-stub/package.json + # Distinct dest so `addPath` prepends ahead of the monorepo's + # pinned pnpm 10.x from the earlier `~/setup-pnpm`. + dest: ~/matrix-pnpm + - name: Verify pnpm on PATH matches matrix version + if: matrix.pm.name == 'pnpm' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + got="$(pnpm --version)" + want="${{ matrix.pm.version }}" + echo "pnpm on PATH: $got (expected $want)" + [ "$got" = "$want" ] || { echo "ERROR: PATH pnpm is $got, not $want β€” scaffolded installs would silently run the wrong version."; exit 1; } + - id: run-e2e if: steps.changes.outputs.everything_but_markdown == 'true' shell: bash @@ -99,7 +158,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ !cancelled() && steps.changes.outputs.everything_but_markdown == 'true' }} with: - name: ${{ format('e2e-logs-{0}-{1}-{2}-{3}', matrix.pm.name, matrix.os.description, matrix.experimental && 'experimental' || 'normal', matrix.filter) }} + name: ${{ format('e2e-logs-{0}-{1}-{2}-{3}', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal', matrix.filter) }} path: packages/create-cloudflare/.e2e-logs${{matrix.experimental == 'true' && '-experimental' || ''}}/${{ matrix.pm.name }}/${{ matrix.filter }} include-hidden-files: true @@ -107,28 +166,36 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ !cancelled() && steps.changes.outputs.everything_but_markdown == 'true' }} with: - name: ${{ format('turbo-runs-{0}-{1}-{2}-{3}', matrix.pm.name, matrix.os.description, matrix.experimental && 'experimental' || 'normal', matrix.filter) }} + name: ${{ format('turbo-runs-{0}-{1}-{2}-{3}', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal', matrix.filter) }} path: .turbo/runs include-hidden-files: true frameworks-e2e: # Runs the C3 frameworks E2E tests only in the merge queue, on the release branch, if create-cloudflare has changed, or when explicitly requested via label. + # + # Frameworks that delegate install to their own generator (e.g. `hono`) + # don't route through C3's `ERR_PNPM_IGNORED_BUILDS` recovery path; those + # tests opt out of pnpm 11 via `unsupportedPmRanges` in the test config. timeout-minutes: 45 concurrency: group: ${{ github.workflow }}-frameworks${{ matrix.experimental && '-experimental' || '' }}-${{ github.ref }}-${{ matrix.os.name }}-${{ matrix.pm.name }}-${{ matrix.pm.version }} cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} - name: ${{ format('C3 E2E ({0}, {1}{2}) - frameworks', matrix.pm.name, matrix.os.description, matrix.experimental && ', experimental' || '') }} + name: ${{ format('C3 E2E ({0}, {1}{2}) - frameworks', matrix.pm.label, matrix.os.description, matrix.experimental && ', experimental' || '') }} strategy: fail-fast: false matrix: experimental: [false] - os: [{ name: ubuntu-latest, description: Linux }] + os: + - { name: ubuntu-latest, description: Linux } pm: - - { name: pnpm, version: "10.33.0" } - - { name: npm, version: "0.0.0" } + - { name: pnpm, version: "10.33.0", label: "pnpm" } + - { name: pnpm, version: "11.5.1", label: "pnpm@11.5.1" } + - { name: npm, version: "0.0.0", label: "npm" } include: + # Experimental stays on the default pnpm to preserve historical + # required-check names; pnpm 11 coverage comes from the base entry. - os: { name: ubuntu-latest, description: Linux } - pm: { name: pnpm, version: "10.33.0" } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } experimental: true runs-on: ${{ matrix.os.name }} steps: @@ -197,6 +264,35 @@ jobs: git config --global user.email wrangler@cloudflare.com git config --global user.name 'Wrangler Tester' + # See the equivalent step in the `e2e` job above for context. + - name: Prepare workspace for matrix pnpm install + if: matrix.pm.name == 'pnpm' && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + mkdir -p .ci-pnpm-setup-stub + printf '{"name":"ci-stub","private":true}\n' > .ci-pnpm-setup-stub/package.json + node -e " + const fs = require('node:fs'); + const pkg = JSON.parse(fs.readFileSync('package.json','utf8')); + if (pkg.engines && pkg.engines.pnpm) pkg.engines.pnpm = '*'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + - name: Install matrix pnpm version for scaffolded installs + if: matrix.pm.name == 'pnpm' && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + with: + version: ${{ matrix.pm.version }} + package_json_file: .ci-pnpm-setup-stub/package.json + dest: ~/matrix-pnpm + - name: Verify pnpm on PATH matches matrix version + if: matrix.pm.name == 'pnpm' && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + got="$(pnpm --version)" + want="${{ matrix.pm.version }}" + echo "pnpm on PATH: $got (expected $want)" + [ "$got" = "$want" ] || { echo "ERROR: PATH pnpm is $got, not $want β€” scaffolded installs would silently run the wrong version."; exit 1; } + - id: run-e2e if: steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' shell: bash @@ -214,7 +310,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ !cancelled() && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' }} with: - name: ${{ format('e2e-logs-{0}-{1}-{2}-frameworks', matrix.pm.name, matrix.os.description, matrix.experimental && 'experimental' || 'normal') }} + name: ${{ format('e2e-logs-{0}-{1}-{2}-frameworks', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal') }} path: packages/create-cloudflare/.e2e-logs${{matrix.experimental == 'true' && '-experimental' || ''}}/${{ matrix.pm.name }}/frameworks include-hidden-files: true @@ -222,6 +318,6 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: ${{ !cancelled() && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' }} with: - name: ${{ format('turbo-runs-{0}-{1}-{2}-frameworks', matrix.pm.name, matrix.os.description, matrix.experimental && 'experimental' || 'normal') }} + name: ${{ format('turbo-runs-{0}-{1}-{2}-frameworks', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal') }} path: .turbo/runs include-hidden-files: true diff --git a/packages/create-cloudflare/e2e/helpers/framework-helpers.ts b/packages/create-cloudflare/e2e/helpers/framework-helpers.ts index 9260b1f8ce..31d11014d1 100644 --- a/packages/create-cloudflare/e2e/helpers/framework-helpers.ts +++ b/packages/create-cloudflare/e2e/helpers/framework-helpers.ts @@ -14,6 +14,7 @@ import { import { detectPackageManager } from "helpers/packageManagers"; import { retry } from "helpers/retry"; import * as jsonc from "jsonc-parser"; +import semver from "semver"; import { fetch } from "undici"; import { version } from "../../package.json"; import { getFrameworkMap } from "../../src/templates"; @@ -23,6 +24,7 @@ import { isExperimental, runDeployTests, testPackageManager, + testPackageManagerVersion, } from "./constants"; import { runC3 } from "./run-c3"; import { kill, spawnWithLogging } from "./spawn"; @@ -36,6 +38,13 @@ export type FrameworkTestConfig = RunnerConfig & { nodeCompat: boolean; unsupportedPms?: string[]; unsupportedOSs?: string[]; + /** + * Per–package-manager semver ranges to skip. Keys are pm names ("pnpm", + * "npm", "yarn", "bun"); values are semver ranges. Used on pnpm >=11 for + * frameworks that either run their own install (e.g. Hono) or whose + * recovery prompt fires after the e2e harness has closed stdin. + */ + unsupportedPmRanges?: Partial>; flags?: string[]; extraEnv?: Record; }; @@ -417,10 +426,25 @@ export function shouldRunTest(testConfig: FrameworkTestConfig) { // Skip if the package manager is unsupported !testConfig.unsupportedPms?.includes(testPackageManager) && // Skip if the OS is unsupported - !testConfig.unsupportedOSs?.includes(process.platform) + !testConfig.unsupportedOSs?.includes(process.platform) && + // Skip if the package-manager version falls inside an unsupported range + !isUnsupportedPmVersion(testConfig) ); } +function isUnsupportedPmVersion(testConfig: FrameworkTestConfig): boolean { + const range = testConfig.unsupportedPmRanges?.[testPackageManager]; + if (!range || !testPackageManagerVersion) { + return false; + } + // Coerce to handle suffixes like "11.5.1-canary"; unparseable β†’ run test. + const coerced = semver.coerce(testPackageManagerVersion); + if (!coerced) { + return false; + } + return semver.satisfies(coerced, range); +} + /** * Gets the framework config and test info given a `frameworkKey`. * diff --git a/packages/create-cloudflare/e2e/helpers/run-c3.ts b/packages/create-cloudflare/e2e/helpers/run-c3.ts index 527b222a46..c8c386dec7 100644 --- a/packages/create-cloudflare/e2e/helpers/run-c3.ts +++ b/packages/create-cloudflare/e2e/helpers/run-c3.ts @@ -22,6 +22,16 @@ export type PromptHandler = { }; }; +/** + * Responder for "opportunistic" prompts that only show up under specific + * conditions (e.g. the pnpm 11 approve-builds confirmation). Fires at most + * once per run, independent of the ordered `promptHandlers` queue. + */ +type BackgroundResponder = { + matcher: RegExp; + keys: string[]; +}; + export type RunnerConfig = { promptHandlers?: PromptHandler[]; argv?: string[]; @@ -86,10 +96,41 @@ export const runC3 = async ( logStream ); + // Responders for prompts that may or may not appear. Note that a responder + // can only write to stdin while it's still open β€” `handlePrompt` closes + // stdin once the last ordered handler is consumed. Framework tests whose + // recovery prompt fires after that should opt out via `unsupportedPmRanges`. + const backgroundResponders: BackgroundResponder[] = [ + { + matcher: /Run `pnpm approve-builds [^`]+` and retry the install\?/, + keys: [keys.enter], + }, + ]; + const handledBackground = new WeakSet(); + const onData = (data: string) => { + handleBackgroundPrompts(data); handlePrompt(data); }; + const handleBackgroundPrompts = (data: string) => { + const text = stripAnsi(data.toString()); + for (const responder of backgroundResponders) { + if (handledBackground.has(responder)) { + continue; + } + if (!responder.matcher.test(text)) { + continue; + } + handledBackground.add(responder); + for (const keystroke of responder.keys) { + if (proc.stdin.writable) { + proc.stdin.write(keystroke); + } + } + } + }; + // Clone the prompt handlers so we can consume them destructively promptHandlers = [...promptHandlers]; diff --git a/packages/create-cloudflare/e2e/tests/cli/cli.test.ts b/packages/create-cloudflare/e2e/tests/cli/cli.test.ts index 2e8513a1bb..869cf18cce 100644 --- a/packages/create-cloudflare/e2e/tests/cli/cli.test.ts +++ b/packages/create-cloudflare/e2e/tests/cli/cli.test.ts @@ -322,21 +322,23 @@ describe("Create Cloudflare CLI", () => { test.skipIf(isWindows)( "Error when --lang=python is used with a category that has no Python templates", async ({ expect, logStream, project }) => { - const { errors } = await runC3( - [ - project.path, - "--lang=python", - "--category=demo", - "--no-deploy", - "--git=false", - ], - [], - logStream - ); - - expect(errors).toContain( - `No templates available for language "python" in the "demo" category` - ); + await expect( + runC3( + [ + project.path, + "--lang=python", + "--category=demo", + "--no-deploy", + "--git=false", + ], + [], + logStream + ) + ).rejects.toMatchObject({ + errors: expect.stringContaining( + `No templates available for language "python" in the "demo" category` + ), + }); } ); @@ -745,14 +747,17 @@ describe("Create Cloudflare CLI", () => { expect, logStream, }) => { - const { errors } = await runC3( - ["--platform=pages", `--framework=${framework}`, "my-app"], - [], - logStream - ); - expect(errors).toMatch( - /Error: The .*? framework doesn't support the "pages" platform/ - ); + await expect( + runC3( + ["--platform=pages", `--framework=${framework}`, "my-app"], + [], + logStream + ) + ).rejects.toMatchObject({ + errors: expect.stringMatching( + /Error: The .*? framework doesn't support the "pages" platform/ + ), + }); }) ); @@ -760,63 +765,72 @@ describe("Create Cloudflare CLI", () => { expect, logStream, }) => { - const { errors } = await runC3( - [ - "my-app", - "--framework=react", - "--platform=workers", - "--variant=invalid-variant", - "--no-deploy", - "--git=false", - ], - [], - logStream - ); - expect(errors).toContain( - 'Unknown variant "invalid-variant". Valid variants are: react-ts, react' - ); + await expect( + runC3( + [ + "my-app", + "--framework=react", + "--platform=workers", + "--variant=invalid-variant", + "--no-deploy", + "--git=false", + ], + [], + logStream + ) + ).rejects.toMatchObject({ + errors: expect.stringContaining( + 'Unknown variant "invalid-variant". Valid variants are: react-ts, react' + ), + }); }); test("error when using deprecated SWC --variant for React Workers framework", async ({ expect, logStream, }) => { - const { errors } = await runC3( - [ - "my-app", - "--framework=react", - "--platform=workers", - "--variant=react-swc-ts", - "--no-deploy", - "--git=false", - ], - [], - logStream - ); - expect(errors).toContain( - 'The React variant "react-swc-ts" is no longer available. Use "react-ts" instead.' - ); + await expect( + runC3( + [ + "my-app", + "--framework=react", + "--platform=workers", + "--variant=react-swc-ts", + "--no-deploy", + "--git=false", + ], + [], + logStream + ) + ).rejects.toMatchObject({ + errors: expect.stringContaining( + 'The React variant "react-swc-ts" is no longer available. Use "react-ts" instead.' + ), + }); }); test("error when using invalid --variant for React Pages framework", async ({ expect, logStream, }) => { - const { errors } = await runC3( - [ - "my-app", - "--framework=react", - "--platform=pages", - "--variant=invalid-variant", - "--no-deploy", - "--git=false", - ], - [], - logStream - ); - expect(errors).toContain( - 'Unknown variant "invalid-variant". Valid variants are: react-ts, react-swc-ts, react, react-swc' - ); + await expect( + runC3( + [ + "my-app", + "--framework=react", + "--platform=pages", + "--variant=invalid-variant", + "--no-deploy", + "--git=false", + ], + [], + logStream + ) + ).rejects.toMatchObject({ + errors: expect.stringContaining( + 'Unknown variant "invalid-variant". Valid variants are: react-ts, react-swc-ts, react, react-swc' + ), + }); }); test("accepts --variant for React Pages framework without prompting", async ({ @@ -921,14 +935,17 @@ describe("Create Cloudflare CLI", () => { logStream, project, }) => { - const { errors } = await runC3( - [project.path, "--platform=pages", `--category=${category}`], - [], - logStream - ); - expect(errors).toContain( - `The "${category}" category is not available for the "pages" platform` - ); + await expect( + runC3( + [project.path, "--platform=pages", `--category=${category}`], + [], + logStream + ) + ).rejects.toMatchObject({ + errors: expect.stringContaining( + `The "${category}" category is not available for the "pages" platform` + ), + }); }) ); }); diff --git a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts index 903ae760de..f75efee352 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts @@ -90,6 +90,10 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { name: "docusaurus:pages", argv: ["--platform", "pages"], unsupportedPms: ["bun"], + // `create-docusaurus` installs internally (no `--no-install`), + // tripping `ERR_PNPM_IGNORED_BUILDS` on pnpm 11 before C3's + // recovery path can engage. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, testCommitMessage: true, unsupportedOSs: ["win32"], timeout: LONG_TIMEOUT, @@ -119,6 +123,8 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { name: "docusaurus:workers", argv: ["--platform", "workers"], unsupportedPms: ["bun"], + // See note on docusaurus:pages above. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, testCommitMessage: true, unsupportedOSs: ["win32"], timeout: LONG_TIMEOUT, @@ -246,6 +252,9 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { argv: ["--platform", "pages"], testCommitMessage: true, unsupportedOSs: ["win32"], + // `create-hono --install` runs the install inside the generator, + // before C3's recovery path can engage. Fails on pnpm 11. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", expectedText: "Hello!", @@ -268,6 +277,8 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { argv: ["--platform", "workers"], testCommitMessage: true, unsupportedOSs: ["win32"], + // See note on hono:pages above. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/message", expectedText: "Hello Hono!", @@ -361,6 +372,10 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { timeout: LONG_TIMEOUT, unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 unsupportedOSs: ["win32"], + // Nuxt's deps trip `ERR_PNPM_IGNORED_BUILDS` on pnpm 11; the e2e + // harness has closed stdin by the time C3's recovery prompt fires + // (real-TTY users are unaffected). + unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", expectedText: "Welcome to Nuxt!", @@ -386,6 +401,8 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { timeout: LONG_TIMEOUT, unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 unsupportedOSs: ["win32"], + // See note on nuxt:pages above. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", expectedText: "Welcome to Nuxt!", @@ -706,6 +723,8 @@ function getExperimentalFrameworkTestConfig( name: "docusaurus:workers", argv: ["--platform", "workers"], unsupportedPms: ["bun"], + // See note on docusaurus:pages above. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, testCommitMessage: true, unsupportedOSs: ["win32"], timeout: LONG_TIMEOUT, @@ -802,6 +821,8 @@ function getExperimentalFrameworkTestConfig( testCommitMessage: true, timeout: LONG_TIMEOUT, unsupportedOSs: ["win32"], + // See note on nuxt:pages above. + unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", expectedText: "Welcome to Nuxt!", diff --git a/packages/create-cloudflare/src/cli.ts b/packages/create-cloudflare/src/cli.ts index 5cf0948745..56cdf1d682 100644 --- a/packages/create-cloudflare/src/cli.ts +++ b/packages/create-cloudflare/src/cli.ts @@ -22,6 +22,11 @@ import { rectifyPmMismatch, } from "helpers/packageManagers"; import { installWrangler, npmInstall } from "helpers/packages"; +import { + getPnpmIgnoredBuildsGuidance, + isIgnoredBuildsError, + writePnpmBuildApprovals, +} from "helpers/pnpmBuildApprovals"; import { version } from "../package.json"; import { maybeOpenBrowser, offerToDeploy, runDeploy } from "./deploy"; import { printSummary, printWelcomeMessage } from "./dialog"; @@ -151,6 +156,8 @@ const create = async (ctx: C3Context) => { } updatePackageName(ctx); + writePnpmBuildApprovals(ctx.project.path); + chdir(ctx.project.path); await npmInstall(ctx); await rectifyPmMismatch(ctx); @@ -245,17 +252,24 @@ const offerAgentsMd = async (ctx: C3Context) => { main(process.argv) .catch((e) => { + // User-initiated cancellation isn't a failure; leave exit code at 0. if (e instanceof CancelError) { cancel(e.message); + return; + } + + if (isIgnoredBuildsError(e)) { + error(`${e.message}\n\n${getPnpmIgnoredBuildsGuidance(e.packages)}`); } else { error(e); } + + // `process.exit()` in the finally below picks this up. + process.exitCode = 1; }) .finally(async () => { await reporter.waitForAllEventsSettled(); - // ensure we explicitly exit the process, otherwise any ongoing async - // calls or leftover tasks in the stack queue will keep running until - // completed + // Force-exit so leftover async work doesn't keep the event loop alive. process.exit(); }); diff --git a/packages/create-cloudflare/src/helpers/__tests__/packages.test.ts b/packages/create-cloudflare/src/helpers/__tests__/packages.test.ts new file mode 100644 index 0000000000..952309e58a --- /dev/null +++ b/packages/create-cloudflare/src/helpers/__tests__/packages.test.ts @@ -0,0 +1,224 @@ +import { existsSync } from "node:fs"; +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { CancelError } from "@cloudflare/cli-shared-helpers/error"; +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { npmInstall } from "helpers/packages"; +import { isIgnoredBuildsError } from "helpers/pnpmBuildApprovals"; +import { beforeEach, describe, test, vi } from "vitest"; +import { mockPackageManager, mockSpinner } from "./mocks"; +import type { IgnoredBuildsError } from "helpers/pnpmBuildApprovals"; +import type { C3Context } from "types"; + +vi.mock("node:fs"); +vi.mock("@cloudflare/cli-shared-helpers/command"); +vi.mock("@cloudflare/cli-shared-helpers/interactive"); +vi.mock("which-pm-runs"); + +const ctx = (): C3Context => + ({ + project: { name: "x", path: "/tmp/x" }, + }) as unknown as C3Context; + +const pnpmIgnoredBuildsError = (packagesLine: string): Error => + new Error( + `Packages: +1\n+\n[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: ${packagesLine}\n\nRun "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.\n` + ); + +describe("npmInstall", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockSpinner(); + // project directory does not yet have node_modules + vi.mocked(existsSync).mockReturnValue(false); + }); + + test("falls through to plain `npm install` for non-pnpm", async ({ + expect, + }) => { + mockPackageManager("npm"); + vi.mocked(runCommand).mockResolvedValueOnce(""); + + await npmInstall(ctx()); + + expect(runCommand).toHaveBeenCalledTimes(1); + const [cmd] = vi.mocked(runCommand).mock.calls[0]; + expect(cmd).toEqual(["npm", "install"]); + }); + + test("skips entirely when node_modules already exists", async ({ + expect, + }) => { + mockPackageManager("pnpm", "11.5.1"); + vi.mocked(existsSync).mockReturnValue(true); + + await npmInstall(ctx()); + + expect(runCommand).not.toHaveBeenCalled(); + }); + + describe("pnpm", () => { + beforeEach(() => { + mockPackageManager("pnpm", "11.5.1"); + }); + + test("runs a single `pnpm install` when nothing is flagged", async ({ + expect, + }) => { + vi.mocked(runCommand).mockResolvedValueOnce(""); + + await npmInstall(ctx()); + + expect(runCommand).toHaveBeenCalledTimes(1); + const [cmd, opts] = vi.mocked(runCommand).mock.calls[0]; + expect(cmd).toEqual(["pnpm", "install"]); + // Spinner managed outside runCommand to suppress noisy failure output. + expect(opts).toMatchObject({ silent: true, useSpinner: false }); + }); + + test("rethrows non-ignored-builds install failures verbatim", async ({ + expect, + }) => { + const networkErr = new Error("ENOTFOUND registry.npmjs.org"); + vi.mocked(runCommand).mockRejectedValueOnce(networkErr); + + await expect(npmInstall(ctx())).rejects.toBe(networkErr); + expect(runCommand).toHaveBeenCalledTimes(1); + expect(inputPrompt).not.toHaveBeenCalled(); + }); + + test("prompt approved: runs `pnpm approve-builds ` and retries install once", async ({ + expect, + }) => { + vi.mocked(inputPrompt).mockResolvedValueOnce(true); + // 1) install fails with ignored builds, 2) approve-builds OK, 3) retry install OK + vi.mocked(runCommand) + .mockRejectedValueOnce(pnpmIgnoredBuildsError("@parcel/watcher@2.5.6")) + .mockResolvedValueOnce("") // approve-builds + .mockResolvedValueOnce(""); // retry install + + await npmInstall(ctx()); + + expect(inputPrompt).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledTimes(3); + expect(vi.mocked(runCommand).mock.calls[0][0]).toEqual([ + "pnpm", + "install", + ]); + // approve-builds is invoked with the parsed package list, NOT --all. + expect(vi.mocked(runCommand).mock.calls[1][0]).toEqual([ + "pnpm", + "approve-builds", + "@parcel/watcher", + ]); + expect(vi.mocked(runCommand).mock.calls[2][0]).toEqual([ + "pnpm", + "install", + ]); + }); + + test("prompt declined: throws IgnoredBuildsError without running approve-builds", async ({ + expect, + }) => { + vi.mocked(inputPrompt).mockResolvedValueOnce(false); + vi.mocked(runCommand).mockRejectedValueOnce( + pnpmIgnoredBuildsError("@parcel/watcher@2.5.6") + ); + + let caught: unknown; + await npmInstall(ctx()).catch((e) => { + caught = e; + }); + + expect(isIgnoredBuildsError(caught)).toBe(true); + expect((caught as IgnoredBuildsError).packages).toEqual([ + "@parcel/watcher", + ]); + expect(runCommand).toHaveBeenCalledTimes(1); // no approve-builds, no retry + }); + + test("prompt cancelled (no TTY / Ctrl-C): throws IgnoredBuildsError carrying the parsed list", async ({ + expect, + }) => { + vi.mocked(inputPrompt).mockRejectedValueOnce( + new CancelError("Operation cancelled") + ); + vi.mocked(runCommand).mockRejectedValueOnce( + pnpmIgnoredBuildsError("@parcel/watcher@2.5.6, lmdb@2.8.1") + ); + + let caught: unknown; + await npmInstall(ctx()).catch((e) => { + caught = e; + }); + + expect(isIgnoredBuildsError(caught)).toBe(true); + expect((caught as IgnoredBuildsError).packages).toEqual([ + "@parcel/watcher", + "lmdb", + ]); + expect(runCommand).toHaveBeenCalledTimes(1); // no approve-builds, no retry + }); + + test("prompt errors for an unrelated reason: rethrows verbatim", async ({ + expect, + }) => { + const promptErr = new Error("rendering broke"); + vi.mocked(inputPrompt).mockRejectedValueOnce(promptErr); + vi.mocked(runCommand).mockRejectedValueOnce( + pnpmIgnoredBuildsError("@parcel/watcher@2.5.6") + ); + + await expect(npmInstall(ctx())).rejects.toBe(promptErr); + }); + + test("retry still fails with ignored builds: throws IgnoredBuildsError carrying the second list", async ({ + expect, + }) => { + vi.mocked(inputPrompt).mockResolvedValueOnce(true); + vi.mocked(runCommand) + .mockRejectedValueOnce(pnpmIgnoredBuildsError("@parcel/watcher@2.5.6")) + .mockResolvedValueOnce("") // approve-builds + .mockRejectedValueOnce(pnpmIgnoredBuildsError("lmdb@2.8.1")); // retry: new package flagged + + let caught: unknown; + await npmInstall(ctx()).catch((e) => { + caught = e; + }); + + expect(isIgnoredBuildsError(caught)).toBe(true); + expect((caught as IgnoredBuildsError).packages).toEqual(["lmdb"]); + }); + + test("retry fails for an unrelated reason: rethrows verbatim", async ({ + expect, + }) => { + vi.mocked(inputPrompt).mockResolvedValueOnce(true); + const retryErr = new Error("disk full"); + vi.mocked(runCommand) + .mockRejectedValueOnce(pnpmIgnoredBuildsError("@parcel/watcher@2.5.6")) + .mockResolvedValueOnce("") // approve-builds + .mockRejectedValueOnce(retryErr); + + await expect(npmInstall(ctx())).rejects.toBe(retryErr); + }); + + test("unparseable ignored-builds error: throws IgnoredBuildsError with empty list", async ({ + expect, + }) => { + // pnpm error without a recognisable `Ignored build scripts:` line + vi.mocked(runCommand).mockRejectedValueOnce( + new Error("[ERR_PNPM_IGNORED_BUILDS] something unexpected") + ); + + let caught: unknown; + await npmInstall(ctx()).catch((e) => { + caught = e; + }); + + expect(isIgnoredBuildsError(caught)).toBe(true); + expect((caught as IgnoredBuildsError).packages).toEqual([]); + // We never prompt if we can't tell the user which packages need approval. + expect(inputPrompt).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/create-cloudflare/src/helpers/__tests__/pnpmBuildApprovals.test.ts b/packages/create-cloudflare/src/helpers/__tests__/pnpmBuildApprovals.test.ts new file mode 100644 index 0000000000..b7025258e4 --- /dev/null +++ b/packages/create-cloudflare/src/helpers/__tests__/pnpmBuildApprovals.test.ts @@ -0,0 +1,356 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { readFile, writeFile } from "helpers/files"; +import { + extractIgnoredBuildPackages, + getPnpmIgnoredBuildsGuidance, + IgnoredBuildsError, + isIgnoredBuildsError, + isPnpmIgnoredBuildsError, + mergeAllowBuilds, + writePnpmBuildApprovals, +} from "helpers/pnpmBuildApprovals"; +import { beforeEach, describe, test, vi } from "vitest"; +import whichPMRuns from "which-pm-runs"; +import { mockPackageManager } from "./mocks"; + +vi.mock("fs"); +vi.mock("helpers/files"); +vi.mock("which-pm-runs"); + +const projectPath = join("/path/to/my-project"); +const yamlPath = join(projectPath, "pnpm-workspace.yaml"); + +describe("writePnpmBuildApprovals", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(existsSync).mockReturnValue(false); + }); + + test("writes a fresh yaml when pnpm is in use and no file exists", ({ + expect, + }) => { + mockPackageManager("pnpm", "11.5.1"); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).toHaveBeenCalledTimes(1); + const [calledPath, calledContent] = vi.mocked(writeFile).mock.calls[0]; + expect(calledPath).toBe(yamlPath); + expect(calledContent).toMatch(/^allowBuilds:$/m); + expect(calledContent).toMatch(/^ {2}esbuild: true$/m); + expect(calledContent).toMatch(/^ {2}workerd: true$/m); + expect(calledContent).toMatch(/^ {2}sharp: true$/m); + }); + + test("writes the yaml for pnpm 10 as well (no version gate)", ({ + expect, + }) => { + mockPackageManager("pnpm", "10.33.0"); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).toHaveBeenCalledTimes(1); + expect(vi.mocked(writeFile).mock.calls[0][0]).toBe(yamlPath); + }); + + test("is a no-op for npm", ({ expect }) => { + mockPackageManager("npm"); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).not.toHaveBeenCalled(); + expect(vi.mocked(readFile)).not.toHaveBeenCalled(); + }); + + test("is a no-op for yarn", ({ expect }) => { + mockPackageManager("yarn", "3.5.1"); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).not.toHaveBeenCalled(); + }); + + test("is a no-op for bun", ({ expect }) => { + mockPackageManager("bun", "1.1.0"); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).not.toHaveBeenCalled(); + }); + + test("is a no-op when no package manager can be detected", ({ expect }) => { + // Falls back to npm when c3 is invoked outside any PM. + vi.mocked(whichPMRuns).mockReturnValue( + undefined as unknown as ReturnType + ); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).not.toHaveBeenCalled(); + }); + + test("converts our placeholders to true while leaving framework keys untouched", ({ + expect, + }) => { + mockPackageManager("pnpm", "11.5.1"); + vi.mocked(existsSync).mockImplementation( + (path) => path.toString() === yamlPath + ); + vi.mocked(readFile).mockReturnValue( + [ + "allowBuilds:", + " esbuild: set this to true or false", + " '@parcel/watcher': set this to true or false", + " sharp: set this to true or false", + "", + ].join("\n") + ); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).toHaveBeenCalledTimes(1); + const written = vi.mocked(writeFile).mock.calls[0][1] as string; + // Our keys: placeholders β†’ `true`; missing keys are added. + expect(written).toMatch(/^ {2}esbuild: true$/m); + expect(written).toMatch(/^ {2}sharp: true$/m); + expect(written).toMatch(/^ {2}workerd: true$/m); + // Framework key: untouched. + expect(written).toMatch( + /^ {2}'@parcel\/watcher': set this to true or false$/m + ); + }); + + test("respects an explicit `false` for one of our keys", ({ expect }) => { + mockPackageManager("pnpm", "11.5.1"); + vi.mocked(existsSync).mockImplementation( + (path) => path.toString() === yamlPath + ); + vi.mocked(readFile).mockReturnValue( + [ + "allowBuilds:", + " esbuild: false", + " workerd: true", + " sharp: true", + "", + ].join("\n") + ); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).not.toHaveBeenCalled(); + }); + + test("is a no-op when all our keys are already approved", ({ expect }) => { + mockPackageManager("pnpm", "11.5.1"); + vi.mocked(existsSync).mockImplementation( + (path) => path.toString() === yamlPath + ); + vi.mocked(readFile).mockReturnValue( + [ + "allowBuilds:", + " esbuild: true", + " workerd: true", + " sharp: true", + "", + ].join("\n") + ); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).not.toHaveBeenCalled(); + }); + + test("appends an allowBuilds block when the file has none", ({ expect }) => { + mockPackageManager("pnpm", "11.5.1"); + vi.mocked(existsSync).mockImplementation( + (path) => path.toString() === yamlPath + ); + vi.mocked(readFile).mockReturnValue( + ["packages:", " - 'packages/*'", ""].join("\n") + ); + + writePnpmBuildApprovals(projectPath); + + expect(vi.mocked(writeFile)).toHaveBeenCalledTimes(1); + const written = vi.mocked(writeFile).mock.calls[0][1] as string; + expect(written).toMatch(/^packages:$/m); + expect(written).toMatch(/^ {2}- 'packages\/\*'$/m); + expect(written).toMatch(/^allowBuilds:$/m); + expect(written).toMatch(/^ {2}esbuild: true$/m); + expect(written).toMatch(/^ {2}workerd: true$/m); + expect(written).toMatch(/^ {2}sharp: true$/m); + }); +}); + +describe("mergeAllowBuilds", () => { + test("preserves CRLF line endings on Windows-style input", ({ expect }) => { + const input = [ + "allowBuilds:", + " esbuild: set this to true or false", + " workerd: set this to true or false", + " sharp: set this to true or false", + "", + ].join("\r\n"); + + const output = mergeAllowBuilds(input); + + expect(output).toContain("\r\n"); + expect(output).toMatch(/^ {2}esbuild: true$/m); + }); + + test("never flips an explicit boolean on one of our keys", ({ expect }) => { + const input = [ + "allowBuilds:", + " esbuild: false", + " workerd: true", + " sharp: true", + "", + ].join("\n"); + + expect(mergeAllowBuilds(input)).toBe(input); + }); + + test("never touches a value on a key that isn't ours", ({ expect }) => { + const input = [ + "allowBuilds:", + " esbuild: true", + " workerd: true", + " sharp: true", + " '@parcel/watcher': set this to true or false", + " '@swc/core': false", + "", + ].join("\n"); + + expect(mergeAllowBuilds(input)).toBe(input); + }); +}); + +describe("isPnpmIgnoredBuildsError", () => { + test("matches pnpm's error code substring", ({ expect }) => { + const err = new Error( + "[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild, sharp, workerd" + ); + expect(isPnpmIgnoredBuildsError(err)).toBe(true); + }); + + test("returns false for unrelated errors", ({ expect }) => { + expect(isPnpmIgnoredBuildsError(new Error("network timed out"))).toBe( + false + ); + }); + + test("returns false for non-Error values", ({ expect }) => { + expect(isPnpmIgnoredBuildsError("ERR_PNPM_IGNORED_BUILDS")).toBe(false); + expect(isPnpmIgnoredBuildsError(undefined)).toBe(false); + expect(isPnpmIgnoredBuildsError(null)).toBe(false); + expect( + isPnpmIgnoredBuildsError({ message: "ERR_PNPM_IGNORED_BUILDS" }) + ).toBe(false); + }); +}); + +describe("getPnpmIgnoredBuildsGuidance", () => { + test("points the user at pnpm approve-builds", ({ expect }) => { + const guidance = getPnpmIgnoredBuildsGuidance(); + expect(guidance).toContain("pnpm approve-builds"); + expect(guidance).toMatch(/framework|own/i); + }); + + test("inlines the package list when one is known", ({ expect }) => { + const guidance = getPnpmIgnoredBuildsGuidance(["@parcel/watcher", "lmdb"]); + expect(guidance).toContain("pnpm approve-builds @parcel/watcher lmdb"); + }); + + test("falls back to the bare command when no packages are known", ({ + expect, + }) => { + const guidance = getPnpmIgnoredBuildsGuidance([]); + expect(guidance).toMatch(/^ {2}pnpm approve-builds$/m); + }); +}); + +describe("extractIgnoredBuildPackages", () => { + test("parses a single flagged package with version suffix", ({ expect }) => { + const err = new Error( + [ + "Packages: +442", + "Progress: resolved 527, reused 205, downloaded 239, added 442, done", + "", + "[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @parcel/watcher@2.5.6", + "", + 'Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.', + ].join("\n") + ); + expect(extractIgnoredBuildPackages(err)).toEqual(["@parcel/watcher"]); + }); + + test("parses multiple comma-separated packages and dedupes", ({ expect }) => { + const err = new Error( + "[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @parcel/watcher@2.5.6, lmdb@2.8.1, esbuild@0.27.7, lmdb@2.8.1" + ); + expect(extractIgnoredBuildPackages(err)).toEqual([ + "@parcel/watcher", + "lmdb", + "esbuild", + ]); + }); + + test("handles unscoped and scoped packages without version suffixes", ({ + expect, + }) => { + const err = new Error( + "[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild, @swc/core" + ); + expect(extractIgnoredBuildPackages(err)).toEqual(["esbuild", "@swc/core"]); + }); + + test("accepts a raw string in addition to an Error", ({ expect }) => { + expect( + extractIgnoredBuildPackages( + "[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: sharp@0.34.5" + ) + ).toEqual(["sharp"]); + }); + + test("returns an empty array when no list can be parsed", ({ expect }) => { + expect(extractIgnoredBuildPackages(new Error("network timeout"))).toEqual( + [] + ); + expect(extractIgnoredBuildPackages(undefined)).toEqual([]); + expect(extractIgnoredBuildPackages(null)).toEqual([]); + expect(extractIgnoredBuildPackages({ message: "anything" })).toEqual([]); + }); +}); + +describe("IgnoredBuildsError", () => { + test("renders the parsed package list in the message", ({ expect }) => { + const err = new IgnoredBuildsError(["@parcel/watcher", "lmdb"]); + expect(err.message).toContain("@parcel/watcher"); + expect(err.message).toContain("lmdb"); + expect(err.packages).toEqual(["@parcel/watcher", "lmdb"]); + }); + + test("falls back to a placeholder when no packages are known", ({ + expect, + }) => { + const err = new IgnoredBuildsError([]); + expect(err.message).toMatch(/unknown/); + }); + + test("`isIgnoredBuildsError` discriminates on instance, not message text", ({ + expect, + }) => { + expect(isIgnoredBuildsError(new IgnoredBuildsError(["x"]))).toBe(true); + // Raw pnpm errors trip `isPnpmIgnoredBuildsError` but not `isIgnoredBuildsError`. + const raw = new Error("[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: x"); + expect(isIgnoredBuildsError(raw)).toBe(false); + expect(isPnpmIgnoredBuildsError(raw)).toBe(true); + }); + + test("preserves the underlying pnpm error as `cause`", ({ expect }) => { + const original = new Error("raw pnpm output"); + const wrapped = new IgnoredBuildsError(["sharp"], original); + expect(wrapped.cause).toBe(original); + }); +}); diff --git a/packages/create-cloudflare/src/helpers/packages.ts b/packages/create-cloudflare/src/helpers/packages.ts index d3965ed9f1..836ad454ef 100644 --- a/packages/create-cloudflare/src/helpers/packages.ts +++ b/packages/create-cloudflare/src/helpers/packages.ts @@ -1,10 +1,21 @@ import { existsSync } from "node:fs"; import nodePath from "node:path"; -import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { updateStatus } from "@cloudflare/cli-shared-helpers"; +import { brandColor, dim, red } from "@cloudflare/cli-shared-helpers/colors"; import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { CancelError } from "@cloudflare/cli-shared-helpers/error"; +import { + inputPrompt, + spinner, +} from "@cloudflare/cli-shared-helpers/interactive"; import * as cliPackages from "@cloudflare/cli-shared-helpers/packages"; import { fetch } from "undici"; import { detectPackageManager } from "./packageManagers"; +import { + extractIgnoredBuildPackages, + IgnoredBuildsError, + isPnpmIgnoredBuildsError, +} from "./pnpmBuildApprovals"; import type { C3Context } from "types"; type InstallConfig = { @@ -48,6 +59,11 @@ export const npmInstall = async (ctx: C3Context) => { const { npm } = detectPackageManager(); + if (npm === "pnpm") { + await pnpmInstallWithBuildApprovalRetry(npm); + return; + } + await runCommand([npm, "install"], { silent: true, startText: "Installing dependencies", @@ -55,6 +71,137 @@ export const npmInstall = async (ctx: C3Context) => { }); }; +/** + * Run `pnpm install` under our own spinner so that on failure we don't dump + * the captured pnpm transcript into the spinner's stop line. + */ +const runPnpmInstallQuiet = async ( + npm: string, + startText: string +): Promise => { + const s = spinner(); + s.start(startText); + try { + await runCommand([npm, "install"], { silent: true, useSpinner: false }); + s.stop(`${brandColor("installed")} ${dim(`via \`${npm} install\``)}`); + } catch (err) { + s.stop(red("install failed")); + throw err; + } +}; + +const pnpmInstallWithBuildApprovalRetry = async ( + npm: string +): Promise => { + try { + await runPnpmInstallQuiet(npm, "Installing dependencies"); + return; + } catch (err) { + if (!isPnpmIgnoredBuildsError(err)) { + throw err; + } + await recoverFromIgnoredBuilds(npm, err); + } +}; + +// Sentinel for "stdin closed before the prompt was answered", to +// distinguish from a normal `CancelError`. Both map to `IgnoredBuildsError`. +const STDIN_EOF_MARKER = "__c3_stdin_eof__"; +const isStdinEOFError = (err: unknown): boolean => + err instanceof Error && err.message === STDIN_EOF_MARKER; + +/** + * Race the approve-builds confirm prompt against stdin's `end` event. In a + * TTY the event never fires; in the e2e harness the prompt resolves first; + * in a fully non-interactive shell (no TTY, stdin at EOF) we reject with a + * sentinel so the caller can convert it to `IgnoredBuildsError` instead of + * letting the process exit silently with code 0. + */ +const promptOrEOF = async (packages: string[]): Promise => { + const prompt = inputPrompt({ + type: "confirm", + question: `Run \`pnpm approve-builds ${packages.join(" ")}\` and retry the install?`, + label: "approve-builds", + defaultValue: true, + throwOnError: true, + }); + + // A real terminal won't EOF on its own; no need for the race. + if (process.stdin.isTTY) { + return prompt; + } + + let onEnd: (() => void) | undefined; + const eof = new Promise((_, reject) => { + onEnd = () => reject(new Error(STDIN_EOF_MARKER)); + process.stdin.once("end", onEnd); + // Keep the event loop alive so the `end` event can fire. + process.stdin.resume(); + }); + + // Swallow late prompt rejections (e.g. after EOF wins the race) so they + // don't surface as unhandled rejections. + prompt.catch(() => {}); + + try { + return await Promise.race([prompt, eof]); + } finally { + if (onEnd) { + process.stdin.removeListener("end", onEnd); + } + } +}; + +const recoverFromIgnoredBuilds = async ( + npm: string, + originalErr: Error +): Promise => { + const packages = extractIgnoredBuildPackages(originalErr); + + if (packages.length === 0) { + // Flagged ignored builds but couldn't parse the list β€” bail out + // rather than guess. + throw new IgnoredBuildsError([], originalErr); + } + + updateStatus( + `${red("pnpm refused to run build scripts for:")} ${packages.join(", ")}` + ); + + let approve: boolean; + try { + approve = await promptOrEOF(packages); + } catch (promptErr) { + if (promptErr instanceof CancelError || isStdinEOFError(promptErr)) { + throw new IgnoredBuildsError(packages, originalErr); + } + throw promptErr; + } + + if (!approve) { + throw new IgnoredBuildsError(packages, originalErr); + } + + // Non-interactive when packages are listed explicitly. + await runCommand([npm, "approve-builds", ...packages], { + silent: true, + startText: "Approving dependency build scripts", + doneText: `${brandColor("approved")} ${dim(packages.join(", "))}`, + }); + + try { + await runPnpmInstallQuiet(npm, "Re-running install"); + } catch (retryErr) { + if (isPnpmIgnoredBuildsError(retryErr)) { + throw new IgnoredBuildsError( + extractIgnoredBuildPackages(retryErr), + retryErr + ); + } + throw retryErr; + } +}; + type NpmInfoResponse = { "dist-tags": { latest: string }; }; diff --git a/packages/create-cloudflare/src/helpers/pnpmBuildApprovals.ts b/packages/create-cloudflare/src/helpers/pnpmBuildApprovals.ts new file mode 100644 index 0000000000..317ef33634 --- /dev/null +++ b/packages/create-cloudflare/src/helpers/pnpmBuildApprovals.ts @@ -0,0 +1,221 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { readFile, writeFile } from "./files"; +import { detectPackageManager } from "./packageManagers"; + +// Build-script packages C3 pre-approves: wrangler depends on `esbuild` and +// `workerd`, and (via miniflare) on `sharp`. Framework-introduced build +// scripts are out of scope. +const APPROVED_BUILDS = ["esbuild", "workerd", "sharp"] as const; +const APPROVED_BUILDS_SET = new Set(APPROVED_BUILDS); + +/** + * Write or merge `allowBuilds` entries for `APPROVED_BUILDS` into the + * generated project's `pnpm-workspace.yaml`. Without these, pnpm 11+ aborts + * the install with `ERR_PNPM_IGNORED_BUILDS`. No-op for non-pnpm package + * managers. Preserves any pre-existing entries the user/generator added. + */ +export const writePnpmBuildApprovals = (projectPath: string) => { + const { npm } = detectPackageManager(); + if (npm !== "pnpm") { + return; + } + + const yamlPath = join(projectPath, "pnpm-workspace.yaml"); + + if (!existsSync(yamlPath)) { + writeFile(yamlPath, freshWorkspaceYaml()); + return; + } + + const original = readFile(yamlPath); + const updated = mergeAllowBuilds(original); + if (updated !== original) { + writeFile(yamlPath, updated); + } +}; + +const FRESH_HEADER = [ + "# Pre-approve build scripts for the packages C3 itself installs that need", + "# them: `workerd` downloads the platform binary, `esbuild` and `sharp`", + "# (via miniflare) download/build native bindings. Without these, pnpm 11+", + "# aborts the install with ERR_PNPM_IGNORED_BUILDS.", +]; + +// Quote scoped names (`@…`) so YAML 1.1 doesn't treat `@` as a node anchor. +const formatEntry = (pkg: string) => + pkg.startsWith("@") ? ` '${pkg}': true` : ` ${pkg}: true`; + +const freshWorkspaceYaml = () => + [ + ...FRESH_HEADER, + "allowBuilds:", + ...APPROVED_BUILDS.map(formatEntry), + "", + ].join("\n"); + +// Captures a 2-space-indented YAML entry: key (optionally quoted) and value. +const ALLOW_BUILDS_ENTRY = /^( {2})(['"]?)([^'":]+)\2:\s*(.*)$/; + +/** Exported for unit testing. */ +export const mergeAllowBuilds = (original: string): string => { + const eol = detectEol(original); + const lines = original.split(/\r?\n/); + + const headerIdx = lines.findIndex((line) => /^allowBuilds:\s*$/.test(line)); + + if (headerIdx === -1) { + // No allowBuilds block: append a fresh one. + const needsLeadingBlank = + lines.length > 0 && lines[lines.length - 1] !== ""; + const block = [ + ...(needsLeadingBlank ? [""] : []), + ...FRESH_HEADER, + "allowBuilds:", + ...APPROVED_BUILDS.map(formatEntry), + "", + ].join(eol); + return original.replace(/(\r?\n)?$/, eol + block); + } + + // For our keys only, convert non-boolean placeholders to `true`. Other + // keys are left untouched. + const seenOurKeys = new Set(); + let blockEnd = lines.length; + for (let i = headerIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === "") { + continue; + } + if (!line.startsWith(" ")) { + blockEnd = i; + break; + } + const match = line.match(ALLOW_BUILDS_ENTRY); + if (!match) { + continue; + } + const [, indent, quote, key, value] = match; + if (!APPROVED_BUILDS_SET.has(key)) { + continue; + } + seenOurKeys.add(key); + const trimmed = value.trim(); + if (trimmed !== "true" && trimmed !== "false") { + // Placeholder or unrecognised value β€” approve. Explicit + // `true`/`false` is respected. + lines[i] = `${indent}${quote}${key}${quote}: true`; + } + } + + const missing = APPROVED_BUILDS.filter((pkg) => !seenOurKeys.has(pkg)).map( + formatEntry + ); + if (missing.length > 0) { + lines.splice(blockEnd, 0, ...missing); + } + + return lines.join(eol); +}; + +const detectEol = (text: string): string => + text.includes("\r\n") ? "\r\n" : "\n"; + +export const isPnpmIgnoredBuildsError = (error: unknown): error is Error => { + if (!(error instanceof Error)) { + return false; + } + return error.message.includes("ERR_PNPM_IGNORED_BUILDS"); +}; + +const IGNORED_BUILDS_LINE = /Ignored build scripts:\s*([^\n\r]+)/; + +/** + * Parse the package list pnpm prints after `Ignored build scripts:`, returning + * names without their `@version` suffix (so they can be passed to + * `pnpm approve-builds`). Returns `[]` if the input has no recognisable list. + */ +export const extractIgnoredBuildPackages = (error: unknown): string[] => { + const text = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : ""; + const match = text.match(IGNORED_BUILDS_LINE); + if (!match) { + return []; + } + + const seen = new Set(); + const result: string[] = []; + for (const raw of match[1].split(",")) { + const trimmed = raw.trim(); + if (!trimmed) { + continue; + } + const name = stripPackageVersion(trimmed); + if (name && !seen.has(name)) { + seen.add(name); + result.push(name); + } + } + return result; +}; + +// `@scope/name@1.2.3` β†’ `@scope/name`; `name@1.2.3` β†’ `name`. +const stripPackageVersion = (spec: string): string => { + if (spec.startsWith("@")) { + const slashIdx = spec.indexOf("/"); + if (slashIdx === -1) { + return spec; + } + const versionAt = spec.indexOf("@", slashIdx); + return versionAt === -1 ? spec : spec.slice(0, versionAt); + } + const atIdx = spec.indexOf("@"); + return atIdx === -1 ? spec : spec.slice(0, atIdx); +}; + +/** + * Thrown when pnpm refused to run dependency build scripts and recovery + * failed (or the user declined). Carries the parsed package list so the + * top-level error handler can render a concise message. + */ +export class IgnoredBuildsError extends Error { + readonly packages: readonly string[]; + + constructor(packages: readonly string[], cause?: unknown) { + const list = packages.length > 0 ? packages.join(", ") : "(unknown)"; + super(`pnpm blocked unapproved dependency build scripts: ${list}`); + this.name = "IgnoredBuildsError"; + this.packages = packages; + if (cause !== undefined) { + this.cause = cause; + } + } +} + +export const isIgnoredBuildsError = ( + error: unknown +): error is IgnoredBuildsError => error instanceof IgnoredBuildsError; + +export const getPnpmIgnoredBuildsGuidance = ( + packages: readonly string[] = [] +) => { + const approveCommand = + packages.length > 0 + ? ` pnpm approve-builds ${packages.join(" ")}` + : " pnpm approve-builds"; + return [ + "create-cloudflare only pre-approves build scripts for the packages it", + "installs itself. The packages flagged above were introduced by the", + "framework generator and need to be approved separately.", + "", + "Inside the generated project, run:", + "", + approveCommand, + "", + "then re-run the install.", + ].join("\n"); +};