diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index 2e738ce43f..6ac425a0de 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -533,23 +533,6 @@ describe("deploy", () => { `); }); - it("should error helpfully if pages_build_output_dir is set in wrangler.toml when --x-autoconfig=false", async ({ - expect, - }) => { - writeWranglerConfig({ - pages_build_output_dir: "public", - name: "test-name", - }); - await expect( - runWrangler("deploy --x-autoconfig=false") - ).rejects.toThrowErrorMatchingInlineSnapshot( - ` - [Error: It looks like you've run a Workers-specific command in a Pages project. - For Pages, please run \`wrangler pages deploy\` instead.] - ` - ); - }); - it("should error helpfully if pages_build_output_dir is set in wrangler.toml and --x-autoconfig is provided", async ({ expect, }) => { @@ -1146,6 +1129,16 @@ describe("deploy", () => { expect(std.warn).toMatchInlineSnapshot(`""`); }); + describe("legacy", () => { + it("uses the script name when no environment is specified", async ({ + expect, + }) => { + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + useServiceEnvironments: false, + }); describe("legacy", () => { it("uses the script name when no environment is specified", async ({ expect, diff --git a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts index 163860928f..03aac277c6 100644 --- a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts @@ -781,11 +781,11 @@ describe("deploy", () => { expect(err?.message.replaceAll(/\d/g, "X")).toMatchInlineSnapshot(` "A compatibility_date is required when publishing. Add the following to your Wrangler configuration file: - \`\`\` - {"compatibility_date":"XXXX-XX-XX"} - \`\`\` - Or you could pass it in your terminal as \`--compatibility-date XXXX-XX-XX\` - See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information." + \`\`\` + {"compatibility_date":"XXXX-XX-XX"} + \`\`\` + Or you could pass it in your terminal as \`--compatibility-date XXXX-XX-XX\` + See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information." `); }); @@ -801,11 +801,11 @@ describe("deploy", () => { async () => await runWrangler("deploy ./index.js --name my-worker") ).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: A compatibility_date is required when publishing. Add the following to your Wrangler configuration file: - \`\`\` - {"compatibility_date":"2020-12-01"} - \`\`\` - Or you could pass it in your terminal as \`--compatibility-date 2020-12-01\` - See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information.] + \`\`\` + {"compatibility_date":"2020-12-01"} + \`\`\` + Or you could pass it in your terminal as \`--compatibility-date 2020-12-01\` + See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information.] `); }); diff --git a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts index cdecc5b9ab..f77a5532d6 100644 --- a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts @@ -1105,7 +1105,7 @@ describe("versions upload", () => { writeWorkerSource(); await expect(runWrangler("versions upload --dry-run")).rejects.toThrow( - /A compatibility_date is required when uploading a Worker Version/ + /compatibility_date is required when publishing/ ); }); diff --git a/packages/wrangler/src/deploy/autoconfig.ts b/packages/wrangler/src/deploy/autoconfig.ts new file mode 100644 index 0000000000..406add1410 --- /dev/null +++ b/packages/wrangler/src/deploy/autoconfig.ts @@ -0,0 +1,399 @@ +import assert from "node:assert"; +import { statSync, writeFileSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { + configFileName, + getOpenNextDeployFromEnv, + getTodaysCompatDate, + UserError, + type Config, +} from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { getDetailsForAutoConfig } from "../autoconfig/details"; +import { getInstalledPackageVersion } from "../autoconfig/frameworks/utils/packages"; +import { runAutoConfig } from "../autoconfig/run"; +import { + sendAutoConfigProcessEndedMetricsEvent, + sendAutoConfigProcessStartedMetricsEvent, +} from "../autoconfig/telemetry-utils"; +import { readConfig } from "../config"; +import { confirm, prompt } from "../dialogs"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { writeOutput } from "../output"; +import { getPackageManager } from "../package-manager"; +import { type DeployArgs } from "."; + +/** + * Runs autoconfig if applicable, including the pages_build_output_dir guard and + * open-next delegation. Returns `{ aborted: true }` if deploy should not proceed + * (user declined or delegated to open-next), otherwise returns the + * potentially-updated config. + */ +export async function maybeRunAutoConfig( + args: DeployArgs, + config: Config +): Promise<{ config: Config; aborted: boolean }> { + const shouldRunAutoConfig = + args.experimentalAutoconfig && + // If there is a positional parameter, an assets directory specified via --assets, or an + // explicit --config path then we don't want to run autoconfig since we assume that the + // user knows what they are doing and that they are specifying what needs to be deployed + !args.script && + !args.assets && + !args.config; + + if (shouldRunAutoConfig) { + sendAutoConfigProcessStartedMetricsEvent({ + command: "wrangler deploy", + dryRun: !!args.dryRun, + }); + + try { + const details = await getDetailsForAutoConfig({ + wranglerConfig: config, + }); + + if (details.framework?.id === "cloudflare-pages") { + // If the project is a Pages project then warn the user but allow them to proceed if they wish so + logger.warn( + "It seems that you have run `wrangler deploy` on a Pages project, `wrangler pages deploy` should be used instead. Proceeding will likely produce unwanted results." + ); + const proceedWithPagesProject = await confirm( + "Are you sure that you want to proceed?", + { + defaultValue: false, + fallbackValue: true, + } + ); + + if (!proceedWithPagesProject) { + sendAutoConfigProcessEndedMetricsEvent({ + success: false, + command: "wrangler deploy", + dryRun: !!args.dryRun, + }); + return { config, aborted: true }; + } + } else if (!details.configured) { + // Only run auto config if the project is not already configured + const autoConfigSummary = await runAutoConfig(details); + + writeOutput({ + type: "autoconfig", + version: 1, + command: "deploy", + summary: autoConfigSummary, + }); + + // If autoconfig worked, there should now be a new config file, and so we need to read config again + config = readConfig(args, { + hideWarnings: false, + useRedirectIfAvailable: true, + }); + } + } catch (error) { + sendAutoConfigProcessEndedMetricsEvent({ + command: "wrangler deploy", + dryRun: !!args.dryRun, + success: false, + error, + }); + throw error; + } + + sendAutoConfigProcessEndedMetricsEvent({ + success: true, + command: "wrangler deploy", + dryRun: !!args.dryRun, + }); + } + + // Note: the open-next delegation should happen after we run the auto-config logic so that we + // make sure that the deployment of brand newly auto-configured Next.js apps is correctly + // delegated here + const deploymentDelegatedToOpenNext = + // Currently the delegation to open-next is gated behind the autoconfig experimental flag, this is because + // this behavior is currently only necessary in the autoconfig flow and having it un-gated/stable in wrangler + // releases caused different issues. All the issues should have been fixed (by + // https://github.com/cloudflare/workers-sdk/pull/11694 and https://github.com/cloudflare/workers-sdk/pull/11710) + // but as a precaution we're gating the feature under the autoconfig flag for the time being + args.experimentalAutoconfig && + // If the user explicitly provided a --config path, they are targeting a specific Worker config and we should not delegate to open-next + !args.config && + !args.dryRun && + (await maybeDelegateToOpenNextDeployCommand(process.cwd())); + + if (deploymentDelegatedToOpenNext) { + return { config, aborted: true }; + } + + return { config, aborted: false }; +} /** + * Interactively prompts for missing deploy configuration. Handles two phases: + * + * 1. If the positional `script` arg is a directory and no config file exists, + * asks whether the user intends to deploy static assets. + * 2. Prompts for missing name and compatibility date, and optionally writes a + * new wrangler.jsonc config file. + * + * No-op in non-interactive / CI environments. + */ + +export async function promptForMissingConfig( + args: DeployArgs, + config: { configPath?: string; compatibility_date?: string; name?: string } +): Promise { + // Phase 1: detect `wrangler deploy ` and offer to treat it as assets + let scriptIsDirectory = false; + if (!config.configPath && args.script) { + try { + const stats = statSync(args.script); + if (stats.isDirectory()) { + scriptIsDirectory = true; + args = await promptForMissingAssetFlag(args.script, args); + } + } catch (error) { + // If this is our UserError, re-throw it + if (error instanceof UserError) { + throw error; + } + // If stat fails, let the original flow handle the error + } + } + + // Phase 2: prompt for name / compat-date / config file. + // Skip when the user was offered an assets deployment and declined (script is still a directory) — + // getEntry will produce the appropriate error about the directory entry point. + if (scriptIsDirectory && !args.assets) { + return args; + } + + return promptForMissingDeployConfig(args, config); +} /** + * Handles the case where a user provides a directory as a positional argument, + * probably intending to deploy static assets. e.g. `wrangler deploy ./public`. + * If the user confirms, sets `args.assets` and clears `args.script`. + */ + +export async function promptForMissingAssetFlag( + assetDirectory: string, + args: DeployArgs +): Promise { + if (isNonInteractiveOrCI()) { + return args; + } + + // Ask if user intended to deploy assets only + logger.log(""); + if (!args.assets) { + const deployAssets = await confirm( + "It looks like you are trying to deploy a directory of static assets only. Is this correct?", + { defaultValue: true } + ); + logger.log(""); + if (deployAssets) { + args.assets = assetDirectory; + args.script = undefined; + } else { + // let the usual error handling path kick in + return args; + } + } + + return args; +} + +/** + * Interactively prompts for missing deployment configuration (name, compatibility date, + * and optionally config file writing when no config file exists). + * No-op in non-interactive/CI environments or when all required config is already present. + */ +export async function promptForMissingDeployConfig( + args: DeployArgs, + config: { configPath?: string; compatibility_date?: string; name?: string } +): Promise { + if (isNonInteractiveOrCI()) { + return args; + } + + let promptedForMissing = false; + + // Prompt for name when missing from both CLI args and config + if (!args.name && !config.name) { + const defaultName = process + .cwd() + .split(path.sep) + .pop() + ?.replaceAll("_", "-") + .trim(); + const isValidName = defaultName && /^[a-zA-Z0-9-]+$/.test(defaultName); + const projectName = await prompt("What do you want to name your project?", { + defaultValue: isValidName ? defaultName : "my-project", + }); + args.name = projectName; + logger.log(""); + promptedForMissing = true; + } + + // Prompt for compatibility date when missing + if (!args.latest && !args.compatibilityDate && !config.compatibility_date) { + const compatibilityDateStr = getTodaysCompatDate(); + + if ( + await confirm( + `No compatibility date is set. Would you like to use today's date (${compatibilityDateStr})?` + ) + ) { + args.compatibilityDate = compatibilityDateStr; + promptedForMissing = true; + logger.log(""); + } else { + throw new UserError( + `A compatibility_date is required when publishing. Add it to your ${configFileName(config.configPath)} file or pass \`--compatibility-date\` via CLI.\nSee https://developers.cloudflare.com/workers/platform/compatibility-dates for more information.`, + { telemetryMessage: "missing compatibility date when deploying" } + ); + } + } + + const hasConfigFile = !!config.configPath; + + // When no config file exists and we prompted for missing config, offer to write one + if (!hasConfigFile && promptedForMissing) { + // When --latest was used, the compat date prompt was skipped but we still + // need a concrete date in the config file for future deploys without --latest + const effectiveCompatDate = + args.compatibilityDate ?? + (args.latest ? getTodaysCompatDate() : undefined); + + const configContent: Record = { + name: args.name, + compatibility_date: effectiveCompatDate, + }; + if (args.script) { + configContent.main = args.script; + } + if (args.assets) { + configContent.assets = { directory: args.assets }; + } + + const writeConfigFile = await confirm( + `Do you want Wrangler to write a wrangler.jsonc config file to store this configuration?\n${chalk.dim( + "This will allow you to simply run `wrangler deploy` on future deployments." + )}` + ); + + if (writeConfigFile) { + const configPath = path.join(process.cwd(), "wrangler.jsonc"); + const jsonString = JSON.stringify(configContent, null, 2); + writeFileSync(configPath, jsonString); + logger.log(`Wrote \n${jsonString}\n to ${chalk.bold(configPath)}.`); + logger.log( + `Simply run ${chalk.bold("`wrangler deploy`")} next time. Wrangler will automatically use the configuration saved to wrangler.jsonc.` + ); + } else { + const scriptPart = args.script ? `${args.script} ` : ""; + const flagParts = [ + args.name ? `--name ${args.name}` : "", + effectiveCompatDate + ? `--compatibility-date ${effectiveCompatDate}` + : "", + args.assets ? `--assets ${args.assets}` : "", + ] + .filter(Boolean) + .join(" "); + logger.log( + `You should run ${chalk.bold( + `wrangler deploy ${scriptPart}${flagParts}` + )} next time to deploy this Worker without going through this flow again.` + ); + } + logger.log("\nProceeding with deployment...\n"); + } + + return args; +} /** + * Discerns if the project is an open-next one. This check is performed in an assertive way to ensure that + * no false positives happen. + * + * @param projectRoot The path to the project's root + * @returns true if the project is an open-next one, false otherwise + */ +export async function isOpenNextProject(projectRoot: string) { + try { + const dirFiles = await readdir(projectRoot); + + const nextConfigFile = dirFiles.find((file) => + /^next\.config\.(m|c)?(ts|js)$/.test(file) + ); + + if (!nextConfigFile) { + // If there is no next config file then the project is not a Next.js one + return false; + } + + const opeNextConfigFile = dirFiles.find((file) => + /^open-next\.config\.(ts|js)$/.test(file) + ); + + if (!opeNextConfigFile) { + // If there is no open-next config file then the project is not an OpenNext one + return false; + } + + const openNextVersion = getInstalledPackageVersion( + "@opennextjs/cloudflare", + projectRoot, + { + // We stop at the projectPath/root just to make extra sure we don't hit false positives + stopAtProjectPath: true, + } + ); + + return openNextVersion !== undefined; + } catch { + // If any error is thrown then we simply assume that we're not running in an OpenNext project + return false; + } +} + +/** + * If appropriate (when `wrangler deploy` is run in an OpenNext project without setting the `OPEN_NEXT_DEPLOY` environment variable) + * this function delegates the deployment operation to `@opennextjs/cloudflare`, otherwise it does nothing. + * + * @param projectRoot The path to the project's root + * @returns true is the deployment has been delegated to open-next, false otherwise + */ +async function maybeDelegateToOpenNextDeployCommand( + projectRoot: string +): Promise { + if (await isOpenNextProject(projectRoot)) { + const openNextDeploy = getOpenNextDeployFromEnv(); + if (!openNextDeploy) { + logger.log( + "OpenNext project detected, calling `opennextjs-cloudflare deploy`" + ); + + const deployArgIdx = process.argv.findIndex((arg) => arg === "deploy"); + assert(deployArgIdx !== -1, "Could not find `deploy` argument"); + const deployArguments = process.argv.slice(deployArgIdx + 1); + + const { npx } = await getPackageManager(); + + await runCommand( + [npx, "opennextjs-cloudflare", "deploy", ...deployArguments], + { + env: { + // We set `OPEN_NEXT_DEPLOY` here so that it's passed through to the `wrangler deploy` command that OpenNext delegates to in order to prevent an infinite loop + OPEN_NEXT_DEPLOY: "true", + }, + } + ); + + return true; + } + } + return false; +} diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index b11179e0e5..aba9aea538 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -1,147 +1,22 @@ -import assert from "node:assert"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { URLSearchParams } from "node:url"; -import { cancel } from "@cloudflare/cli-shared-helpers"; -import { verifyDockerInstalled } from "@cloudflare/containers-shared"; -import { - APIError, - configFileName, - experimental_patchConfig, - getTodaysCompatDate, - formatConfigSnippet, - getDockerPath, - parseNonHyphenedUuid, - UserError, -} from "@cloudflare/workers-utils"; +import { UserError } from "@cloudflare/workers-utils"; import PQueue from "p-queue"; -import { Response } from "undici"; -import { buildAssetManifest, syncAssets } from "../assets"; import { fetchListResult, fetchResult } from "../cfetch"; -import { buildContainer } from "../containers/build"; -import { getNormalizedContainerOptions } from "../containers/config"; -import { deployContainers } from "../containers/deploy"; import { isAuthenticationError } from "../core/handle-errors"; -import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; -import { bundleWorker } from "../deployment-bundle/bundle"; -import { printBundleSize } from "../deployment-bundle/bundle-reporter"; -import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; -import { logBuildOutput } from "../deployment-bundle/esbuild-plugins/log-build-output"; -import { - createModuleCollector, - getWrangler1xLegacyModuleReferences, -} from "../deployment-bundle/module-collection"; -import { noBundleWorker } from "../deployment-bundle/no-bundle-worker"; -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 { getFlag } from "../experimental-flags"; -import isInteractive, { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; -import { getMetricsUsageHeaders } from "../metrics"; -import { isNavigatorDefined } from "../navigator-user-agent"; -import { getWranglerTmpDir } from "../paths"; -import { - ensureQueuesExistByConfig, - getQueue, - postConsumer, - putConsumer, -} from "../queues/client"; -import { parseBulkInputToObject } from "../secret"; -import { syncWorkersSite } from "../sites"; -import { - getSourceMappedString, - maybeRetrieveFileSourceMap, -} from "../sourcemap"; -import triggersDeploy from "../triggers/deploy"; -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 { isWorkerNotFoundError } from "../utils/worker-not-found-error"; -import { - createDeployment, - patchNonVersionedScriptSettings, -} from "../versions/api"; -import { confirmLatestDeploymentOverwrite } from "../versions/deploy"; +import { getQueue, postConsumer, putConsumer } from "../queues/client"; import { getZoneForRoute } from "../zones"; -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 { AssetsOptions } from "../assets"; -import type { Entry } from "../deployment-bundle/entry"; import type { PostTypedConsumerBody } from "../queues/client"; -import type { LegacyAssetPaths } from "../sites"; -import type { RetrieveSourceMapFunction } from "../sourcemap"; -import type { ApiVersion, Percentage, VersionId } from "../versions/types"; import type { - CfModule, - CfScriptFormat, - CfWorkerInit, ComplianceConfig, Config, CustomDomainRoute, - RawConfig, Route, ZoneIdRoute, ZoneNameRoute, } from "@cloudflare/workers-utils"; -import type { FormData } from "undici"; - -type Props = { - config: Config; - accountId: string | undefined; - entry: Entry; - rules: Config["rules"]; - name: string; - env: string | undefined; - compatibilityDate: string | undefined; - compatibilityFlags: string[] | undefined; - legacyAssetPaths: LegacyAssetPaths | undefined; - assetsOptions: AssetsOptions | undefined; - vars: Record | undefined; - defines: Record | undefined; - alias: Record | undefined; - triggers: string[] | undefined; - routes: string[] | undefined; - domains: string[] | undefined; - /** Deprecated service environments.*/ - useServiceEnvironments: boolean | undefined; - jsxFactory: string | undefined; - jsxFragment: string | undefined; - tsconfig: string | undefined; - isWorkersSite: boolean; - minify: boolean | undefined; - outDir: string | undefined; - outFile: string | undefined; - dryRun: boolean | undefined; - noBundle: boolean | undefined; - keepVars: boolean | undefined; - logpush: boolean | undefined; - uploadSourceMaps: boolean | undefined; - oldAssetTtl: number | undefined; - projectRoot: string | undefined; - dispatchNamespace: string | undefined; - experimentalAutoCreate: boolean; - metafile: string | boolean | undefined; - containersRollout: "immediate" | "gradual" | undefined; - strict: boolean | undefined; - tag: string | undefined; - message: string | undefined; - secretsFile: string | undefined; -}; export type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; @@ -395,941 +270,6 @@ Update them to point to this script instead?`; return domains.map((domain) => renderRoute(domain)); } -/** - * 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()` - */ -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 default async function deploy(props: Props): Promise<{ - sourceMapSize?: number; - versionId: string | null; - workerTag: string | null; - targets?: string[]; -}> { - const deployConfirm = getDeployConfirmFunction(props.strict); - - // TODO: warn if git/hg has uncommitted changes - const { config, accountId, name, entry } = props; - let workerTag: string | null = null; - let versionId: string | null = null; - let tags: string[] = []; // arbitrary metadata tags, not to be confused with script tag or annotations - - let workerExists: boolean = true; - - const domainRoutes = (props.domains || []).map((domain) => ({ - pattern: domain, - custom_domain: true, - })); - const routes = - props.routes ?? config.routes ?? (config.route ? [config.route] : []); - const allDeploymentRoutes = [...routes, ...domainRoutes]; - - if (!props.dispatchNamespace && accountId) { - try { - const serviceMetaData = await fetchResult<{ - default_environment: { - environment: string; - script: { - tag: string; - tags: string[] | null; - last_deployed_from: "dash" | "wrangler" | "api"; - }; - }; - }>(config, `/accounts/${accountId}/workers/services/${name}`); - const { - default_environment: { script }, - } = serviceMetaData; - workerTag = script.tag; - tags = script.tags ?? tags; - - if (script.last_deployed_from === "dash") { - const remoteWorkerConfig = await downloadWorkerConfig( - name, - serviceMetaData.default_environment.environment, - entry.file, - accountId - ); - - const configDiff = getRemoteConfigDiff(remoteWorkerConfig, { - ...config, - // We also want to include all the routes used for deployment - routes: allDeploymentRoutes, - }); - - // If there are only additive changes (or no changes at all) there should be no problem, - // just using the local config (and override the remote one) should be totally fine - if (!configDiff.nonDestructive) { - logger.warn( - "The local configuration being used (generated from your local configuration file) differs from the remote configuration of your Worker set via the Cloudflare Dashboard:" + - `\n${configDiff.diff}\n\n` + - "Deploying the Worker will override the remote configuration with your local one." - ); - if (!(await deployConfirm("Would you like to continue?"))) { - if ( - config.userConfigPath && - /\.jsonc?$/.test(config.userConfigPath) - ) { - if ( - await confirm( - "Would you like to update the local config file with the remote values?", - { - defaultValue: true, - fallbackValue: true, - } - ) - ) { - const patchObj: RawConfig = getConfigPatch( - configDiff.diff, - props.env - ); - - experimental_patchConfig( - config.userConfigPath, - patchObj, - false - ); - } - } - - return { versionId, workerTag }; - } - } - } else if (script.last_deployed_from === "api") { - logger.warn( - `You are about to publish a Workers Service that was last updated via the script API.\nEdits that have been made via the script API will be overridden by your local code and config.` - ); - if (!(await deployConfirm("Would you like to continue?"))) { - return { versionId, workerTag }; - } - } - } catch (e) { - if (isWorkerNotFoundError(e)) { - workerExists = false; - } else { - throw e; - } - } - } - - if (accountId && (isInteractive() || props.strict)) { - const remoteSecretsCheck = await checkRemoteSecretsOverride( - config, - props.env - ); - - if (remoteSecretsCheck?.override) { - logger.warn(remoteSecretsCheck.deployErrorMessage); - if (!(await deployConfirm("Would you like to continue?"))) { - return { versionId, workerTag }; - } - } - } - - if ( - accountId && - config.workflows?.length && - (isInteractive() || props.strict) - ) { - const workflowCheck = await checkWorkflowConflicts(config, accountId, name); - - if (workflowCheck.hasConflicts) { - logger.warn(workflowCheck.message); - if (!(await deployConfirm("Do you want to continue?"))) { - return { versionId, workerTag }; - } - } - } - - const compatibilityDate = - props.compatibilityDate ?? config.compatibility_date; - const compatibilityFlags = - props.compatibilityFlags ?? config.compatibility_flags; - - if (!compatibilityDate) { - const compatibilityDateStr = getTodaysCompatDate(); - - throw new UserError( - `A compatibility_date is required when publishing. 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: "missing compatibility date when deploying" } - ); - } - - validateRoutes(allDeploymentRoutes, props.assetsOptions); - - const jsxFactory = props.jsxFactory || config.jsx_factory; - const jsxFragment = props.jsxFragment || config.jsx_fragment; - const keepVars = props.keepVars || config.keep_vars; - - const minify = props.minify ?? config.minify; - - const nodejsCompatMode = validateNodeCompatMode( - compatibilityDate, - compatibilityFlags, - { - noBundle: props.noBundle ?? config.no_bundle, - } - ); - - // Warn if user tries minify with no-bundle - if (props.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 = props.name; - - assert( - !config.site || config.site.bucket, - "A [site] definition requires a `bucket` field with a path to the site's assets directory." - ); - - if (props.outDir) { - // we're using a custom output directory, - // so let's first ensure it exists - mkdirSync(props.outDir, { recursive: true }); - // add a README - const readmePath = path.join(props.outDir, "README.md"); - writeFileSync( - readmePath, - `This folder contains the built output assets for the worker "${scriptName}" generated at ${new Date().toISOString()}.` - ); - } - - const destination = - props.outDir ?? getWranglerTmpDir(props.projectRoot, "deploy"); - const envName = props.env ?? "production"; - - const start = Date.now(); - /** Whether to use the deprecated service environments path */ - const useServiceEnvironments = Boolean( - props.useServiceEnvironments && props.env - ); - const workerName = useServiceEnvironments - ? `${scriptName} (${envName})` - : scriptName; - const workerUrl = props.dispatchNamespace - ? `/accounts/${accountId}/workers/dispatch/namespaces/${props.dispatchNamespace}/scripts/${scriptName}` - : useServiceEnvironments - ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${envName}` - : `/accounts/${accountId}/workers/scripts/${scriptName}`; - - const { format } = props.entry; - - if ( - !props.dispatchNamespace && - !useServiceEnvironments && - accountId && - scriptName - ) { - const yes = await confirmLatestDeploymentOverwrite( - config, - accountId, - scriptName - ); - if (!yes) { - cancel("Aborting deploy..."); - return { versionId, workerTag }; - } - } - - if ( - !props.isWorkersSite && - Boolean(props.legacyAssetPaths) && - format === "service-worker" - ) { - throw new UserError( - "You cannot use the service-worker format with an `assets` directory yet. For information on how to migrate to the module-worker format, see: https://developers.cloudflare.com/workers/learning/migrating-to-module-workers/", - { telemetryMessage: "deploy service worker assets unsupported" } - ); - } - - 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: "deploy wasm modules with es 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: "[text_blobs] with an ES 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: "[data_blobs] with an ES module worker" } - ); - } - - let sourceMapSize; - const normalisedContainerConfig = await getNormalizedContainerOptions( - config, - props - ); - try { - if (props.noBundle) { - // if we're not building, let's just copy the entry to the destination directory - const destinationDir = - typeof destination === "string" ? destination : destination.path; - mkdirSync(destinationDir, { recursive: true }); - writeFileSync( - path.join(destinationDir, path.basename(props.entry.file)), - readFileSync(props.entry.file, "utf-8") - ); - } - - const entryDirectory = path.dirname(props.entry.file); - const moduleCollector = createModuleCollector({ - wrangler1xLegacyModuleReferences: getWrangler1xLegacyModuleReferences( - entryDirectory, - props.entry.file - ), - entry: props.entry, - // `moduleCollector` doesn't get used when `props.noBundle` is set, so - // `findAdditionalModules` always defaults to `false` - findAdditionalModules: config.find_additional_modules ?? false, - rules: props.rules, - preserveFileNames: config.preserve_file_names ?? false, - }); - const uploadSourceMaps = - props.uploadSourceMaps ?? config.upload_source_maps; - - const { - modules, - dependencies, - resolvedEntryPointPath, - bundleType, - ...bundle - } = props.noBundle - ? await noBundleWorker( - props.entry, - props.rules, - props.outDir, - config.python_modules.exclude - ) - : await bundleWorker( - props.entry, - typeof destination === "string" ? destination : destination.path, - { - metafile: props.metafile, - bundle: true, - additionalModules: [], - moduleCollector, - doBindings: config.durable_objects.bindings, - workflowBindings: config.workflows ?? [], - jsxFactory, - jsxFragment, - tsconfig: props.tsconfig ?? config.tsconfig, - minify, - keepNames: config.keep_names ?? true, - sourcemap: uploadSourceMaps, - nodejsCompatMode, - compatibilityDate, - compatibilityFlags, - define: { ...config.define, ...props.defines }, - checkFetch: false, - alias: { ...config.alias, ...props.alias }, - // We want to know if the build is for development or publishing - // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? - targetConsumer: "deploy", - local: false, - projectRoot: props.projectRoot, - defineNavigatorUserAgent: isNavigatorDefined( - compatibilityDate, - compatibilityFlags - ), - plugins: [logBuildOutput(nodejsCompatMode)], - - // Pages specific options used by wrangler pages commands - entryName: undefined, - inject: undefined, - isOutfile: undefined, - external: undefined, - - // These options are dev-only - testScheduled: undefined, - watch: undefined, - } - ); - - // Add modules to dependencies for size warning - for (const module of modules) { - const modulePath = - module.filePath === undefined - ? module.name - : path.relative("", module.filePath); - const bytesInOutput = - typeof module.content === "string" - ? Buffer.byteLength(module.content) - : module.content.byteLength; - dependencies[modulePath] = { bytesInOutput }; - } - - const content = readFileSync(resolvedEntryPointPath, { - encoding: "utf-8", - }); - - // durable object migrations - const migrations = !props.dryRun - ? await getMigrationsToUpload(scriptName, { - accountId, - config, - useServiceEnvironments: props.useServiceEnvironments, - env: props.env, - dispatchNamespace: props.dispatchNamespace, - }) - : undefined; - - // Upload assets if assets is being used - const assetsJwt = - props.assetsOptions && !props.dryRun - ? await syncAssets( - config, - accountId, - props.assetsOptions.directory, - scriptName, - props.dispatchNamespace - ) - : undefined; - - // validate asset directory - if (props.assetsOptions && props.dryRun) { - 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, - props.dryRun, - props.oldAssetTtl - ); - - 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.vars ?? {})) { - bindings[bindingName] = { - type: "plain_text", - value, - hidden: true, - }; - } - - 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: "deploy", - workerExists, - }); - - if (workersSitesAssets.manifest) { - modules.push({ - name: "__STATIC_CONTENT_MANIFEST", - filePath: undefined, - content: JSON.stringify(workersSitesAssets.manifest), - type: "text", - }); - } - - const placement = parseConfigPlacement(config); - - const entryPointName = path.basename(resolvedEntryPointPath); - const main: CfModule = { - 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, - keepSecrets: keepVars || !!props.secretsFile, - logpush: props.logpush !== undefined ? props.logpush : config.logpush, - placement, - tail_consumers: config.tail_consumers, - streaming_tail_consumers: config.streaming_tail_consumers, - limits: config.limits, - annotations: - props.tag || props.message - ? { - "workers/message": props.message, - "workers/tag": props.tag, - } - : undefined, - 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, - observability: config.observability, - cache: config.cache, - }; - - sourceMapSize = worker.sourceMaps?.reduce( - (acc, m) => acc + m.content.length, - 0 - ); - - await printBundleSize( - { name: path.basename(resolvedEntryPointPath), content: content }, - modules - ); - - // We can use the new versions/deployments APIs if we: - // * are uploading a worker that already exists - // * aren't a dispatch namespace deploy - // * aren't a service env deploy - // * aren't a service Worker - // * we don't have DO migrations - // * we aren't an fpw - // * not a container worker - const canUseNewVersionsDeploymentsApi = - workerExists && - props.dispatchNamespace === undefined && - !useServiceEnvironments && - format === "modules" && - migrations === undefined && - !config.first_party_worker && - config.containers === undefined; - - let workerBundle: FormData; - const dockerPath = getDockerPath(); - - // lets fail earlier in the case where docker isn't installed - // and we have containers so that we don't get into a - // disjointed state where the worker updates but the container - // fails. - if (normalisedContainerConfig.length) { - // if you have a registry url specified, you don't need docker - const hasDockerfiles = normalisedContainerConfig.some( - (container) => "dockerfile" in container - ); - if (hasDockerfiles) { - await verifyDockerInstalled(dockerPath, false); - } - } - - if (props.dryRun) { - if (normalisedContainerConfig.length) { - for (const container of normalisedContainerConfig) { - if ("dockerfile" in container) { - await buildContainer( - container, - workerTag ?? "worker-tag", - props.dryRun, - dockerPath - ); - } - } - } - - workerBundle = createWorkerUploadForm( - worker, - addWorkersSitesBindings( - bindings ?? {}, - workersSitesAssets.namespace, - workersSitesAssets.manifest, - format - ), - { - dryRun: true, - unsafe: config.unsafe, - } - ); - - printBindings( - bindings, - config.tail_consumers, - config.streaming_tail_consumers, - config.containers, - { warnIfNoBindings: true, unsafeMetadata: config.unsafe?.metadata } - ); - } else { - assert(accountId, "Missing accountId"); - - if (getFlag("RESOURCES_PROVISION")) { - await provisionBindings( - bindings ?? {}, - accountId, - scriptName, - props.experimentalAutoCreate, - props.config - ); - } - - workerBundle = createWorkerUploadForm( - worker, - addWorkersSitesBindings( - bindings ?? {}, - workersSitesAssets.namespace, - workersSitesAssets.manifest, - format - ), - { - unsafe: config.unsafe, - } - ); - - await ensureQueuesExistByConfig(config); - let bindingsPrinted = false; - - // Upload the script so it has time to propagate. - try { - let result: { - id: string | null; - etag: string | null; - pipeline_hash: string | null; - mutable_pipeline_id: string | null; - deployment_id: string | null; - startup_time_ms?: number; - }; - - // 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: await getMetricsUsageHeaders(config.send_metrics), - }, - new URLSearchParams({ bindings_inherit: "strict" }) - ) - ); - - // Deploy new version to 100% - const versionMap = new Map(); - versionMap.set(versionResult.id, 100); - await createDeployment( - props.config, - accountId, - scriptName, - versionMap, - props.message - ); - - // Update service and environment tags when using environments - const nextTags = applyServiceAndEnvironmentTags(config, tags); - - try { - // Update tail consumers, logpush, and observability settings - await patchNonVersionedScriptSettings( - props.config, - accountId, - scriptName, - { - tail_consumers: worker.tail_consumers, - logpush: worker.logpush, - // If the user hasn't specified observability assume that they want it disabled if they have it on. - // This is a no-op in the event that they don't have observability enabled, but will remove observability - // if it has been removed from their Wrangler configuration file - observability: worker.observability ?? { enabled: false }, - tags: nextTags, - } - ); - } catch { - warnOnErrorUpdatingServiceAndEnvironmentTags(); - } - - result = { - id: null, // fpw - ignore - etag: versionResult.resources.script.etag, - pipeline_hash: null, // fpw - ignore - mutable_pipeline_id: null, // fpw - ignore - deployment_id: versionResult.id, // version id not deployment id but easier to adapt here - 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: await getMetricsUsageHeaders(config.send_metrics), - }, - new URLSearchParams({ - // pass excludeScript so the whole body of the - // script doesn't get included in the response - excludeScript: "true", - bindings_inherit: "strict", - }) - ) - ); - - // Update service and environment tags when using environments - const nextTags = applyServiceAndEnvironmentTags(config, tags); - if (!tagsAreEqual(tags, nextTags)) { - try { - await patchNonVersionedScriptSettings( - props.config, - accountId, - scriptName, - { - tags: nextTags, - } - ); - } catch { - warnOnErrorUpdatingServiceAndEnvironmentTags(); - } - } - } - - if (result.startup_time_ms) { - logger.log("Worker Startup Time:", result.startup_time_ms, "ms"); - } - bindingsPrinted = true; - - printBindings( - bindings, - config.tail_consumers, - config.streaming_tail_consumers, - config.containers, - { unsafeMetadata: config.unsafe?.metadata } - ); - - versionId = parseNonHyphenedUuid(result.deployment_id); - - if (config.first_party_worker) { - // Print some useful information returned after publishing - // Not all fields will be populated for every worker - // These fields are likely to be scraped by tools, so do not rename - if (result.id) { - logger.log("Worker ID: ", result.id); - } - if (result.etag) { - logger.log("Worker ETag: ", result.etag); - } - if (result.pipeline_hash) { - logger.log("Worker PipelineHash: ", result.pipeline_hash); - } - if (result.mutable_pipeline_id) { - logger.log( - "Worker Mutable PipelineID (Development ONLY!):", - result.mutable_pipeline_id - ); - } - } - } catch (err) { - if (!bindingsPrinted) { - printBindings( - bindings, - config.tail_consumers, - config.streaming_tail_consumers, - config.containers, - { unsafeMetadata: config.unsafe?.metadata } - ); - } - const message = await helpIfErrorIsSizeOrScriptStartup( - err, - dependencies, - workerBundle, - props.projectRoot - ); - if (message !== null) { - logger.error(message); - } - - handleMissingSecretsError(err, config, { - type: "deploy", - workerExists, - }); - - // Apply source mapping to validation startup errors if possible - if ( - err instanceof APIError && - "code" in err && - err.code === 10021 /* validation error */ && - err.notes.length > 0 - ) { - err.preventReport(); - - if ( - err.notes[0].text === - "binding DB of type d1 must have a valid `id` specified [code: 10021]" - ) { - throw new UserError( - "You must use a real database in the database_id configuration. You can find your databases using 'wrangler d1 list', or read how to develop locally with D1 here: https://developers.cloudflare.com/d1/configuration/local-development", - { telemetryMessage: "deploy d1 database binding invalid id" } - ); - } - - 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; - } - } - 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)); - } - } finally { - if (typeof destination !== "string") { - // this means we're using a temp dir, - // so let's clean up before we proceed - destination.remove(); - } - } - - if (props.dryRun) { - logger.log(`--dry-run: exiting now.`); - return { versionId, workerTag }; - } - - const uploadMs = Date.now() - start; - - logger.log("Uploaded", workerName, formatTime(uploadMs)); - - // Early exit for WfP since it doesn't need the below code - if (props.dispatchNamespace !== undefined) { - deployWfpUserWorker(props.dispatchNamespace, versionId); - return { versionId, workerTag }; - } - - if (normalisedContainerConfig.length) { - assert(versionId && accountId); - await deployContainers(config, normalisedContainerConfig, { - versionId, - accountId, - scriptName, - }); - } - - // deploy triggers - const targets = await triggersDeploy({ - ...props, - firstDeploy: !workerExists, - routes: allDeploymentRoutes, - }); - - logger.log("Current Version ID:", versionId); - - return { - sourceMapSize, - versionId, - workerTag, - 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); -} - export function formatTime(duration: number) { return `(${(duration / 1000).toFixed(2)} sec)`; } @@ -1572,21 +512,3 @@ export async function updateQueueConsumers( return updateConsumers; } - -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; - }; - } - - return confirm; -} diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 33deb55a0c..ece613984e 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -1,37 +1,92 @@ import assert from "node:assert"; -import { statSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { URLSearchParams } from "node:url"; +import { cancel } from "@cloudflare/cli-shared-helpers"; +import { verifyDockerInstalled } from "@cloudflare/containers-shared"; import { - configFileName, - getTodaysCompatDate, - getCIOverrideName, - UserError, + APIError, + experimental_patchConfig, + getDockerPath, + parseNonHyphenedUuid, } from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { getAssetsOptions, validateAssetsArgsAndConfig } from "../assets"; -import { getDetailsForAutoConfig } from "../autoconfig/details"; -import { runAutoConfig } from "../autoconfig/run"; -import { - sendAutoConfigProcessEndedMetricsEvent, - sendAutoConfigProcessStartedMetricsEvent, -} from "../autoconfig/telemetry-utils"; -import { readConfig } from "../config"; +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 { createCommand } from "../core/create-command"; -import { getEntry } from "../deployment-bundle/entry"; -import { confirm, prompt } from "../dialogs"; -import { isNonInteractiveOrCI } from "../is-interactive"; +import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; +import { bundleWorker } from "../deployment-bundle/bundle"; +import { printBundleSize } from "../deployment-bundle/bundle-reporter"; +import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; +import { logBuildOutput } from "../deployment-bundle/esbuild-plugins/log-build-output"; +import { + createModuleCollector, + getWrangler1xLegacyModuleReferences, +} from "../deployment-bundle/module-collection"; +import { noBundleWorker } from "../deployment-bundle/no-bundle-worker"; +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 { getFlag } from "../experimental-flags"; +import isInteractive, { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { verifyWorkerMatchesCITag } from "../match-tag"; +import { getMetricsUsageHeaders } from "../metrics"; import * as metrics from "../metrics"; +import { isNavigatorDefined } from "../navigator-user-agent"; import { writeOutput } from "../output"; -import { getSiteAssetPaths } from "../sites"; +import { getWranglerTmpDir } from "../paths"; +import { ensureQueuesExistByConfig } from "../queues/client"; +import { parseBulkInputToObject } from "../secret"; +import { syncWorkersSite } from "../sites"; +import { + getSourceMappedString, + maybeRetrieveFileSourceMap, +} from "../sourcemap"; +import triggersDeploy from "../triggers/deploy"; import { requireAuth } from "../user"; import { collectKeyValues } from "../utils/collectKeyValues"; -import { getRules } from "../utils/getRules"; -import { getScriptName } from "../utils/getScriptName"; +import { downloadWorkerConfig } from "../utils/download-worker-config"; +import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors"; +import { printBindings } from "../utils/print-bindings"; +import { retryOnAPIFailure } from "../utils/retry"; import { useServiceEnvironments } from "../utils/useServiceEnvironments"; -import deploy from "./deploy"; -import { maybeDelegateToOpenNextDeployCommand } from "./open-next"; +import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; +import { + createDeployment, + patchNonVersionedScriptSettings, +} from "../versions/api"; +import { confirmLatestDeploymentOverwrite } from "../versions/deploy"; +import { maybeRunAutoConfig, promptForMissingConfig } from "./autoconfig"; +import { checkRemoteSecretsOverride } from "./check-remote-secrets-override"; +import { checkWorkflowConflicts } from "./check-workflow-conflicts"; +import { getConfigPatch, getRemoteConfigDiff } from "./config-diffs"; +import { formatTime } from "./deploy"; +import { resolveDeployConfig, validateArgs } from "./shared"; +import type { StartDevWorkerInput } from "../api/startDevWorker/types"; +import type { Entry } from "../deployment-bundle/entry"; +import type { RetrieveSourceMapFunction } from "../sourcemap"; +import type { ApiVersion, Percentage, VersionId } from "../versions/types"; +import type { + CfModule, + CfScriptFormat, + CfWorkerInit, + Config, + RawConfig, +} from "@cloudflare/workers-utils"; +import type { FormData } from "undici"; export const deployCommand = createCommand({ metadata: { @@ -276,430 +331,983 @@ export const deployCommand = createCommand({ printMetricsBanner: true, }, validateArgs(args) { - if (args.nodeCompat) { - throw new UserError( - "The --node-compat flag is no longer supported as of Wrangler v4. Instead, use the `nodejs_compat` compatibility flag. This includes the functionality from legacy `node_compat` polyfills and natively implemented Node.js APIs. See https://developers.cloudflare.com/workers/runtime-apis/nodejs for more information.", - { telemetryMessage: "deploy command node compat unsupported" } - ); - } + validateArgs(args); }, async handler(args, { config }) { - const shouldRunAutoConfig = - args.experimentalAutoconfig && - // If there is a positional parameter, an assets directory specified via --assets, or an - // explicit --config path then we don't want to run autoconfig since we assume that the - // user knows what they are doing and that they are specifying what needs to be deployed - !args.script && - !args.assets && - !args.config; - - if ( - config.pages_build_output_dir && - // Note: autoconfig handle Pages projects on its own, so we don't want to hard fail here if autoconfig run - !shouldRunAutoConfig - ) { - throw new UserError( - "It looks like you've run a Workers-specific command in a Pages project.\n" + - "For Pages, please run `wrangler pages deploy` instead.", - { telemetryMessage: "deploy command pages project mismatch" } - ); + const autoConfigResult = await maybeRunAutoConfig(args, config); + if (autoConfigResult.aborted) { + return; } + config = autoConfigResult.config; - if (shouldRunAutoConfig) { - sendAutoConfigProcessStartedMetricsEvent({ - command: "wrangler deploy", - dryRun: !!args.dryRun, - }); + args = await promptForMissingConfig(args, config); + const beforeUpload = Date.now(); + + const { + name, + workerTag, + versionId, + targets, + workerNameOverridden, + entry, + sourceMapSize, + } = await deployWorker({ + config, + args, + }); + writeOutput({ + type: "deploy", + version: 1, + worker_name: name ?? null, + worker_tag: workerTag, + version_id: versionId, + targets, + wrangler_environment: args.env, + worker_name_overridden: workerNameOverridden ?? false, + }); + + metrics.sendMetricsEvent( + "deploy worker script", + { + usesTypeScript: /\.tsx?$/.test(entry.file), + durationMs: Date.now() - beforeUpload, + sourceMapSize, + }, + { + sendMetrics: config.send_metrics, + } + ); + }, +}); + +export type DeployArgs = (typeof deployCommand)["args"]; + +/** + * Core deploy logic extracted from the handler. This function handles the actual + * Worker upload, version creation, and trigger deployment. + */ +async function deployWorker({ + config, + args, +}: { + config: Config; + args: DeployArgs; +}): Promise<{ + versionId: string | null; + workerTag: string | null; + entry: Entry; + name: string | null; + workerNameOverridden?: boolean; + sourceMapSize?: number; + targets?: string[]; +}> { + const start = Date.now(); + + const mergedConfig = await resolveDeployConfig(args, config); + + // TODO: create a function for non-dry-run API requiring stuff + const accountId = args.dryRun ? undefined : await requireAuth(config); + + const deployConfirm = getDeployConfirmFunction(args.strict); + + // TODO: warn if git/hg has uncommitted changes + let workerTag: string | null = null; + let versionId: string | null = null; + let tags: string[] = []; // arbitrary metadata tags, not to be confused with script tag or annotations + + let workerExists: boolean = true; + + if (!args.dryRun) { + assert(accountId, "Missing account ID"); + await verifyWorkerMatchesCITag( + config, + accountId, + mergedConfig.name, + config.configPath + ); + if (!args.dispatchNamespace) { try { - const details = await getDetailsForAutoConfig({ - wranglerConfig: config, - }); + const serviceMetaData = await fetchResult<{ + default_environment: { + environment: string; + script: { + tag: string; + tags: string[] | null; + last_deployed_from: "dash" | "wrangler" | "api"; + }; + }; + }>( + config, + `/accounts/${accountId}/workers/services/${mergedConfig.name}` + ); + const { + default_environment: { script }, + } = serviceMetaData; + workerTag = script.tag; + tags = script.tags ?? tags; - if (details.framework?.id === "cloudflare-pages") { - // If the project is a Pages project then warn the user but allow them to proceed if they wish so - logger.warn( - "It seems that you have run `wrangler deploy` on a Pages project, `wrangler pages deploy` should be used instead. Proceeding will likely produce unwanted results." - ); - const proceedWithPagesProject = await confirm( - "Are you sure that you want to proceed?", - { - defaultValue: false, - fallbackValue: true, - } + if (script.last_deployed_from === "dash") { + const remoteWorkerConfig = await downloadWorkerConfig( + mergedConfig.name, + serviceMetaData.default_environment.environment, + mergedConfig.entry.file, + accountId ); - if (!proceedWithPagesProject) { - sendAutoConfigProcessEndedMetricsEvent({ - success: false, - command: "wrangler deploy", - dryRun: !!args.dryRun, - }); - return; - } - } else if (!details.configured) { - // Only run auto config if the project is not already configured - const autoConfigSummary = await runAutoConfig(details); - - writeOutput({ - type: "autoconfig", - version: 1, - command: "deploy", - summary: autoConfigSummary, - }); - - // If autoconfig worked, there should now be a new config file, and so we need to read config again - config = readConfig(args, { - hideWarnings: false, - useRedirectIfAvailable: true, + const configDiff = getRemoteConfigDiff(remoteWorkerConfig, { + ...config, + // We also want to include all the routes used for deployment + routes: mergedConfig.routes, }); - } - } catch (error) { - sendAutoConfigProcessEndedMetricsEvent({ - command: "wrangler deploy", - dryRun: !!args.dryRun, - success: false, - error, - }); - throw error; - } - sendAutoConfigProcessEndedMetricsEvent({ - success: true, - command: "wrangler deploy", - dryRun: !!args.dryRun, - }); - } + // If there are only additive changes (or no changes at all) there should be no problem, + // just using the local config (and override the remote one) should be totally fine + if (!configDiff.nonDestructive) { + logger.warn( + "The local configuration being used (generated from your local configuration file) differs from the remote configuration of your Worker set via the Cloudflare Dashboard:" + + `\n${configDiff.diff}\n\n` + + "Deploying the Worker will override the remote configuration with your local one." + ); + if (!(await deployConfirm("Would you like to continue?"))) { + if ( + config.userConfigPath && + /\.jsonc?$/.test(config.userConfigPath) + ) { + if ( + await confirm( + "Would you like to update the local config file with the remote values?", + { + defaultValue: true, + fallbackValue: true, + } + ) + ) { + const patchObj: RawConfig = getConfigPatch( + configDiff.diff, + args.env + ); - // Note: the open-next delegation should happen after we run the auto-config logic so that we - // make sure that the deployment of brand newly auto-configured Next.js apps is correctly - // delegated here - const deploymentDelegatedToOpenNext = - // Currently the delegation to open-next is gated behind the autoconfig experimental flag, this is because - // this behavior is currently only necessary in the autoconfig flow and having it un-gated/stable in wrangler - // releases caused different issues. All the issues should have been fixed (by - // https://github.com/cloudflare/workers-sdk/pull/11694 and https://github.com/cloudflare/workers-sdk/pull/11710) - // but as a precaution we're gating the feature under the autoconfig flag for the time being - args.experimentalAutoconfig && - // If the user explicitly provided a --config path, they are targeting a specific Worker config and we should not delegate to open-next - !args.config && - !args.dryRun && - (await maybeDelegateToOpenNextDeployCommand(process.cwd())); - - if (deploymentDelegatedToOpenNext) { - // We've delegated the deployment to open-next so we must not run any actual deployment logic now - return; - } + experimental_patchConfig( + config.userConfigPath, + patchObj, + false + ); + } + } - let scriptIsDirectory = false; - if (!config.configPath) { - // Attempt to interactively handle `wrangler deploy ` - if (args.script) { - try { - const stats = statSync(args.script); - if (stats.isDirectory()) { - scriptIsDirectory = true; - args = await promptForMissingAssetFlag(args.script, args); + return { + versionId, + workerTag, + entry: mergedConfig.entry, + name: mergedConfig.name, + }; + } } - } catch (error) { - // If this is our UserError, re-throw it - if (error instanceof UserError) { - throw error; + } else if (script.last_deployed_from === "api") { + logger.warn( + `You are about to publish a Workers Service that was last updated via the script API.\nEdits that have been made via the script API will be overridden by your local code and config.` + ); + if (!(await deployConfirm("Would you like to continue?"))) { + return { + versionId, + workerTag, + entry: mergedConfig.entry, + name: mergedConfig.name, + }; } - // If stat fails, let the original flow handle the error + } + } catch (e) { + if (isWorkerNotFoundError(e)) { + workerExists = false; + } else { + throw e; } } } - // Interactively prompt for missing deployment config (compat date, and name + config file when no config exists). - // Skip when the user was offered an assets deployment and declined (script is still a directory) — - // getEntry will produce the appropriate error about the directory entry point. - if (!(scriptIsDirectory && !args.assets)) { - args = await promptForMissingDeployConfig(args, config); - } + if (isInteractive() || args.strict) { + const remoteSecretsCheck = await checkRemoteSecretsOverride( + config, + args.env + ); - const entry = await getEntry(args, config, "deploy"); - validateAssetsArgsAndConfig(args, config); + if (remoteSecretsCheck?.override) { + logger.warn(remoteSecretsCheck.deployErrorMessage); + if (!(await deployConfirm("Would you like to continue?"))) { + return { + versionId, + workerTag, + name: mergedConfig.name, + entry: mergedConfig.entry, + }; + } + } + } - const assetsOptions = getAssetsOptions({ - args, - config, - }); + if (config.workflows?.length && (isInteractive() || args.strict)) { + const workflowCheck = await checkWorkflowConflicts( + config, + accountId, + mergedConfig.name + ); - if (args.latest) { - logger.warn( - `Using the latest version of the Workers runtime. To silence this warning, please choose a specific version of the runtime with --compatibility-date, or add a compatibility_date to your ${configFileName( - config.configPath - )} file.` + if (workflowCheck.hasConflicts) { + logger.warn(workflowCheck.message); + if (!(await deployConfirm("Do you want to continue?"))) { + return { + versionId, + workerTag, + name: mergedConfig.name, + entry: mergedConfig.entry, + }; + } + } + } + await ensureQueuesExistByConfig(config); + if (!args.dispatchNamespace && !mergedConfig.useServiceEnvApiPath) { + const yes = await confirmLatestDeploymentOverwrite( + config, + accountId, + mergedConfig.name ); + if (!yes) { + cancel("Aborting deploy..."); + return { + versionId, + workerTag, + entry: mergedConfig.entry, + name: mergedConfig.name, + }; + } } + } + const scriptName = mergedConfig.name; + const envName = args.env ?? "production"; - const cliVars = collectKeyValues(args.var); - const cliDefines = collectKeyValues(args.define); - const cliAlias = collectKeyValues(args.alias); - - const accountId = args.dryRun ? undefined : await requireAuth(config); + const workerName = mergedConfig.useServiceEnvApiPath + ? `${scriptName} (${envName})` + : scriptName; - const siteAssetPaths = getSiteAssetPaths( - config, - args.site, - args.siteInclude, - args.siteExclude + // build stuff starts here + if (args.outdir) { + // we're using a custom output directory, + // so let's first ensure it exists + mkdirSync(args.outdir, { recursive: true }); + // add a README + const readmePath = path.join(args.outdir, "README.md"); + writeFileSync( + readmePath, + `This folder contains the built output assets for the worker "${scriptName}" generated at ${new Date().toISOString()}.` ); + } - const beforeUpload = Date.now(); - let name = getScriptName(args, config); + const destination = + args.outdir ?? getWranglerTmpDir(mergedConfig.entry.projectRoot, "deploy"); - const ciOverrideName = getCIOverrideName(); - let workerNameOverridden = false; - if (ciOverrideName !== undefined && ciOverrideName !== name) { - logger.warn( - `Failed to match Worker name. Your config file is using the Worker name "${name}", but the CI system expected "${ciOverrideName}". Overriding using the CI provided Worker name. Workers Builds connected builds will attempt to open a pull request to resolve this config name mismatch.` - ); - name = ciOverrideName; - workerNameOverridden = true; + const workerUrl = args.dispatchNamespace + ? `/accounts/${accountId}/workers/dispatch/namespaces/${args.dispatchNamespace}/scripts/${scriptName}` + : mergedConfig.useServiceEnvApiPath + ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${envName}` + : `/accounts/${accountId}/workers/scripts/${scriptName}`; + + let sourceMapSize; + const normalisedContainerConfig = await getNormalizedContainerOptions( + config, + { + containersRollout: args.containersRollout, + dryRun: args.dryRun, } + ); - if (!name) { - throw new UserError( - 'You need to provide a name when publishing a worker. Either pass it as a cli arg with `--name ` or in your config file as `name = ""`', - { telemetryMessage: "deploy command missing worker name" } + try { + if (mergedConfig.noBundle) { + // if we're not building, let's just copy the entry to the destination directory + const destinationDir = + typeof destination === "string" ? destination : destination.path; + mkdirSync(destinationDir, { recursive: true }); + writeFileSync( + path.join(destinationDir, path.basename(mergedConfig.entry.file)), + readFileSync(mergedConfig.entry.file, "utf-8") ); } - if (!args.dryRun) { - assert(accountId, "Missing account ID"); - await verifyWorkerMatchesCITag( - config, - accountId, - name, - config.configPath - ); + const entryDirectory = path.dirname(mergedConfig.entry.file); + const moduleCollector = createModuleCollector({ + wrangler1xLegacyModuleReferences: getWrangler1xLegacyModuleReferences( + entryDirectory, + mergedConfig.entry.file + ), + entry: mergedConfig.entry, + // `moduleCollector` doesn't get used when `noBundle` is set, so + // `findAdditionalModules` always defaults to `false` + findAdditionalModules: config.find_additional_modules ?? false, + rules: mergedConfig.rules, + preserveFileNames: config.preserve_file_names ?? false, + }); + + const { + modules, + dependencies, + resolvedEntryPointPath, + bundleType, + ...bundle + } = mergedConfig.noBundle + ? await noBundleWorker( + mergedConfig.entry, + mergedConfig.rules, + args.outdir, + config.python_modules.exclude + ) + : await bundleWorker( + mergedConfig.entry, + typeof destination === "string" ? destination : destination.path, + { + metafile: args.metafile, + bundle: true, + additionalModules: [], + moduleCollector, + doBindings: config.durable_objects.bindings, + workflowBindings: config.workflows ?? [], + jsxFactory: mergedConfig.jsxFactory, + jsxFragment: mergedConfig.jsxFragment, + tsconfig: mergedConfig.tsconfig, + minify: mergedConfig.minify, + keepNames: config.keep_names ?? true, + sourcemap: mergedConfig.uploadSourceMaps, + nodejsCompatMode: mergedConfig.nodejsCompatMode, + compatibilityDate: mergedConfig.compatibilityDate, + compatibilityFlags: mergedConfig.compatibilityFlags, + define: mergedConfig.defines, + checkFetch: false, + alias: mergedConfig.alias, + // We want to know if the build is for development or publishing + // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? + targetConsumer: "deploy", + local: false, + projectRoot: mergedConfig.entry.projectRoot, + defineNavigatorUserAgent: isNavigatorDefined( + mergedConfig.compatibilityDate, + mergedConfig.compatibilityFlags + ), + plugins: [logBuildOutput(mergedConfig.nodejsCompatMode)], + + // Pages specific options used by wrangler pages commands + entryName: undefined, + inject: undefined, + isOutfile: undefined, + external: undefined, + + // These options are dev-only + testScheduled: undefined, + watch: undefined, + } + ); + + // Add modules to dependencies for size warning + for (const module of modules) { + const modulePath = + module.filePath === undefined + ? module.name + : path.relative("", module.filePath); + const bytesInOutput = + typeof module.content === "string" + ? Buffer.byteLength(module.content) + : module.content.byteLength; + dependencies[modulePath] = { bytesInOutput }; } - // We use the `userConfigPath` to compute the root of a project, - // rather than a redirected (potentially generated) `configPath`. - const projectRoot = - config.userConfigPath && path.dirname(config.userConfigPath); + const content = readFileSync(resolvedEntryPointPath, { + encoding: "utf-8", + }); + + // durable object migrations + const migrations = !args.dryRun + ? await getMigrationsToUpload(scriptName, { + accountId, + config, + // getMigrationsToUpload needs the raw config value (not the + // env-gated useServiceEnvApiPath) because it has a distinct + // code path for "service envs enabled, no explicit --env" + // that fetches the default environment's migration tag. + useServiceEnvironments: useServiceEnvironments(config), + env: args.env, + dispatchNamespace: args.dispatchNamespace, + }) + : undefined; + + // Upload assets if assets is being used + const assetsJwt = + mergedConfig.assetsOptions && !args.dryRun + ? await syncAssets( + config, + accountId, + mergedConfig.assetsOptions.directory, + scriptName, + args.dispatchNamespace + ) + : undefined; + + // validate asset directory + if (mergedConfig.assetsOptions && args.dryRun) { + await buildAssetManifest(mergedConfig.assetsOptions.directory); + } - const { sourceMapSize, versionId, workerTag, targets } = await deploy({ + const workersSitesAssets = await syncWorkersSite( config, accountId, - name, - rules: getRules(config), - entry, - env: args.env, - compatibilityDate: args.latest - ? getTodaysCompatDate() - : args.compatibilityDate, - compatibilityFlags: args.compatibilityFlags, - vars: cliVars, - defines: cliDefines, - alias: cliAlias, - triggers: args.triggers, - jsxFactory: args.jsxFactory, - jsxFragment: args.jsxFragment, - tsconfig: args.tsconfig, - routes: args.routes, - domains: args.domains, - assetsOptions, - legacyAssetPaths: siteAssetPaths, - useServiceEnvironments: useServiceEnvironments(config), - minify: args.minify, - isWorkersSite: Boolean(args.site || config.site), - outDir: args.outdir, - outFile: args.outfile, - dryRun: args.dryRun, - metafile: args.metafile, - noBundle: !(args.bundle ?? !config.no_bundle), - keepVars: args.keepVars, - logpush: args.logpush, - uploadSourceMaps: args.uploadSourceMaps, - oldAssetTtl: args.oldAssetTtl, - projectRoot, - dispatchNamespace: args.dispatchNamespace, - experimentalAutoCreate: args.experimentalAutoCreate, - containersRollout: args.containersRollout, - strict: args.strict, - tag: args.tag, - message: args.message, - secretsFile: args.secretsFile, - }); + // 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 + (mergedConfig.useServiceEnvApiPath ? `-${args.env}` : ""), + mergedConfig.legacyAssetPaths, + false, + args.dryRun, + args.oldAssetTtl + ); - writeOutput({ + 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( + collectKeyValues(args.var) ?? {} + )) { + bindings[bindingName] = { + type: "plain_text", + value, + hidden: true, + }; + } + + if (args.secretsFile) { + const secretsResult = await parseBulkInputToObject(args.secretsFile); + if (secretsResult) { + for (const [secretName, secretValue] of Object.entries( + secretsResult.content + )) { + bindings[secretName] = { + type: "secret_text", + value: secretValue, + }; + } + } + } + + addRequiredSecretsInheritBindings(config, bindings, { type: "deploy", - version: 1, - worker_name: name ?? null, - worker_tag: workerTag, - version_id: versionId, - targets, - wrangler_environment: args.env, - worker_name_overridden: workerNameOverridden, + workerExists, }); - metrics.sendMetricsEvent( - "deploy worker script", - { - usesTypeScript: /\.tsx?$/.test(entry.file), - durationMs: Date.now() - beforeUpload, - sourceMapSize, - }, - { - sendMetrics: config.send_metrics, - } - ); - }, -}); + if (workersSitesAssets.manifest) { + modules.push({ + name: "__STATIC_CONTENT_MANIFEST", + filePath: undefined, + content: JSON.stringify(workersSitesAssets.manifest), + type: "text", + }); + } -export type DeployArgs = (typeof deployCommand)["args"]; + const entryPointName = path.basename(resolvedEntryPointPath); + const main: CfModule = { + name: entryPointName, + filePath: resolvedEntryPointPath, + content: content, + type: bundleType, + }; + const worker: CfWorkerInit = { + name: scriptName, + main, + migrations, + modules, + containers: config.containers, + sourceMaps: mergedConfig.uploadSourceMaps + ? loadSourceMaps(main, modules, bundle) + : undefined, + compatibility_date: mergedConfig.compatibilityDate, + compatibility_flags: mergedConfig.compatibilityFlags, + keepVars: mergedConfig.keepVars, + keepSecrets: mergedConfig.keepVars || !!args.secretsFile, + logpush: args.logpush !== undefined ? args.logpush : config.logpush, + placement: mergedConfig.placement, + tail_consumers: config.tail_consumers, + streaming_tail_consumers: config.streaming_tail_consumers, + limits: config.limits, + annotations: + args.tag || args.message + ? { + "workers/message": args.message, + "workers/tag": args.tag, + } + : undefined, + assets: + mergedConfig.assetsOptions && assetsJwt + ? { + jwt: assetsJwt, + routerConfig: mergedConfig.assetsOptions.routerConfig, + assetConfig: mergedConfig.assetsOptions.assetConfig, + _redirects: mergedConfig.assetsOptions._redirects, + _headers: mergedConfig.assetsOptions._headers, + run_worker_first: mergedConfig.assetsOptions.run_worker_first, + } + : undefined, + observability: config.observability, + cache: config.cache, + }; -/** - * Handles the case where a user provides a directory as a positional argument, - * probably intending to deploy static assets. e.g. `wrangler deploy ./public`. - * If the user confirms, sets `args.assets` and clears `args.script`. - * - * @param assetDirectory - The directory path the user provided as the positional argument - * @param args - The current deploy command arguments (mutated in place) - * @returns The updated deploy args with `assets` set and `script` cleared if the user confirmed - */ -export async function promptForMissingAssetFlag( - assetDirectory: string, - args: DeployArgs -): Promise { - if (isNonInteractiveOrCI()) { - return args; - } + sourceMapSize = worker.sourceMaps?.reduce( + (acc, m) => acc + m.content.length, + 0 + ); - // Ask if user intended to deploy assets only - logger.log(""); - if (!args.assets) { - const deployAssets = await confirm( - "It looks like you are trying to deploy a directory of static assets only. Is this correct?", - { defaultValue: true } + await printBundleSize( + { name: path.basename(resolvedEntryPointPath), content: content }, + modules ); - logger.log(""); - if (deployAssets) { - args.assets = assetDirectory; - args.script = undefined; + + // We can use the new versions/deployments APIs if we: + // * are uploading a worker that already exists + // * aren't a dispatch namespace deploy + // * aren't a service env deploy + // * aren't a service Worker + // * we don't have DO migrations + // * we aren't an fpw + // * not a container worker + const canUseNewVersionsDeploymentsApi = + workerExists && + args.dispatchNamespace === undefined && + !mergedConfig.useServiceEnvApiPath && + mergedConfig.entry.format === "modules" && + migrations === undefined && + !config.first_party_worker && + config.containers === undefined; + + let workerBundle: FormData; + const dockerPath = getDockerPath(); + + // lets fail earlier in the case where docker isn't installed + // and we have containers so that we don't get into a + // disjointed state where the worker updates but the container + // fails. + if (normalisedContainerConfig.length) { + // if you have a registry url specified, you don't need docker + const hasDockerfiles = normalisedContainerConfig.some( + (container) => "dockerfile" in container + ); + if (hasDockerfiles) { + await verifyDockerInstalled(dockerPath, false); + } + } + + if (args.dryRun) { + if (normalisedContainerConfig.length) { + for (const container of normalisedContainerConfig) { + if ("dockerfile" in container) { + await buildContainer( + container, + workerTag ?? "worker-tag", + args.dryRun, + dockerPath + ); + } + } + } + + workerBundle = createWorkerUploadForm( + worker, + addWorkersSitesBindings( + bindings ?? {}, + workersSitesAssets.namespace, + workersSitesAssets.manifest, + mergedConfig.entry.format + ), + { + dryRun: true, + unsafe: config.unsafe, + } + ); + + printBindings( + bindings, + config.tail_consumers, + config.streaming_tail_consumers, + config.containers, + { warnIfNoBindings: true, unsafeMetadata: config.unsafe?.metadata } + ); } else { - // let the usual error handling path kick in - return args; + assert(accountId, "Missing accountId"); + + if (getFlag("RESOURCES_PROVISION")) { + await provisionBindings( + bindings ?? {}, + accountId, + scriptName, + args.experimentalAutoCreate, + config + ); + } + + workerBundle = createWorkerUploadForm( + worker, + addWorkersSitesBindings( + bindings ?? {}, + workersSitesAssets.namespace, + workersSitesAssets.manifest, + mergedConfig.entry.format + ), + { + unsafe: config.unsafe, + } + ); + + let bindingsPrinted = false; + + // Upload the script so it has time to propagate. + try { + let result: { + id: string | null; + etag: string | null; + pipeline_hash: string | null; + mutable_pipeline_id: string | null; + deployment_id: string | null; + startup_time_ms?: number; + }; + + // 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: await getMetricsUsageHeaders(config.send_metrics), + }, + new URLSearchParams({ bindings_inherit: "strict" }) + ) + ); + + // Deploy new version to 100% + const versionMap = new Map(); + versionMap.set(versionResult.id, 100); + await createDeployment( + config, + accountId, + scriptName, + versionMap, + args.message + ); + + // Update service and environment tags when using environments + const nextTags = applyServiceAndEnvironmentTags(config, tags); + + try { + // Update tail consumers, logpush, and observability settings + await patchNonVersionedScriptSettings( + config, + accountId, + scriptName, + { + tail_consumers: worker.tail_consumers, + logpush: worker.logpush, + // If the user hasn't specified observability assume that they want it disabled if they have it on. + // This is a no-op in the event that they don't have observability enabled, but will remove observability + // if it has been removed from their Wrangler configuration file + observability: worker.observability ?? { enabled: false }, + tags: nextTags, + } + ); + } catch { + warnOnErrorUpdatingServiceAndEnvironmentTags(); + } + + result = { + id: null, // fpw - ignore + etag: versionResult.resources.script.etag, + pipeline_hash: null, // fpw - ignore + mutable_pipeline_id: null, // fpw - ignore + deployment_id: versionResult.id, // version id not deployment id but easier to adapt here + 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: await getMetricsUsageHeaders(config.send_metrics), + }, + new URLSearchParams({ + // pass excludeScript so the whole body of the + // script doesn't get included in the response + excludeScript: "true", + bindings_inherit: "strict", + }) + ) + ); + + // 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 (result.startup_time_ms) { + logger.log("Worker Startup Time:", result.startup_time_ms, "ms"); + } + bindingsPrinted = true; + + printBindings( + bindings, + config.tail_consumers, + config.streaming_tail_consumers, + config.containers, + { unsafeMetadata: config.unsafe?.metadata } + ); + + versionId = parseNonHyphenedUuid(result.deployment_id); + + if (config.first_party_worker) { + // Print some useful information returned after publishing + // Not all fields will be populated for every worker + // These fields are likely to be scraped by tools, so do not rename + if (result.id) { + logger.log("Worker ID: ", result.id); + } + if (result.etag) { + logger.log("Worker ETag: ", result.etag); + } + if (result.pipeline_hash) { + logger.log("Worker PipelineHash: ", result.pipeline_hash); + } + if (result.mutable_pipeline_id) { + logger.log( + "Worker Mutable PipelineID (Development ONLY!):", + result.mutable_pipeline_id + ); + } + } + } catch (err) { + if (!bindingsPrinted) { + printBindings( + bindings, + config.tail_consumers, + config.streaming_tail_consumers, + config.containers, + { unsafeMetadata: config.unsafe?.metadata } + ); + } + const message = await helpIfErrorIsSizeOrScriptStartup( + err, + dependencies, + workerBundle, + mergedConfig.entry.projectRoot + ); + if (message !== null) { + logger.error(message); + } + + handleMissingSecretsError(err, config, { + type: "deploy", + workerExists, + }); + + // Apply source mapping to validation startup errors if possible + if ( + err instanceof APIError && + "code" in err && + err.code === 10021 /* validation error */ && + err.notes.length > 0 + ) { + err.preventReport(); + + 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; + } + } + if (args.outfile) { + // we're using a custom output file, + // so let's first ensure it's parent directory exists + mkdirSync(path.dirname(args.outfile), { recursive: true }); + + const serializedFormData = await new Response(workerBundle).arrayBuffer(); + + writeFileSync(args.outfile, Buffer.from(serializedFormData)); + } + } finally { + if (typeof destination !== "string") { + // this means we're using a temp dir, + // so let's clean up before we proceed + destination.remove(); } } - return args; -} + if (args.dryRun) { + logger.log(`--dry-run: exiting now.`); + return { + name: mergedConfig.name, + entry: mergedConfig.entry, + workerNameOverridden: mergedConfig.workerNameOverridden, + versionId, + workerTag, + }; + } + // will exist after dry run has exited + assert(accountId, "Missing account ID"); -/** - * Interactively prompts for missing deployment configuration (name, compatibility date, - * and optionally config file writing when no config file exists). - * No-op in non-interactive/CI environments or when all required config is already present. - * - * @param args - The current deploy command arguments (mutated in place) - * @param config - The resolved wrangler config, used to check for existing configPath, name, and compatibility_date - * @returns The updated deploy args with any prompted values filled in - */ -export async function promptForMissingDeployConfig( - args: DeployArgs, - config: { configPath?: string; compatibility_date?: string; name?: string } -): Promise { - if (isNonInteractiveOrCI()) { - return args; + const uploadMs = Date.now() - start; + + logger.log("Uploaded", workerName, formatTime(uploadMs)); + + // Early exit for WfP since it doesn't need the below code + if (args.dispatchNamespace !== undefined) { + deployWfpUserWorker(args.dispatchNamespace, versionId); + return { + name: mergedConfig.name, + entry: mergedConfig.entry, + workerNameOverridden: mergedConfig.workerNameOverridden, + versionId, + workerTag, + }; } - let promptedForMissing = false; - - // Prompt for name when missing from both CLI args and config - if (!args.name && !config.name) { - const defaultName = process - .cwd() - .split(path.sep) - .pop() - ?.replaceAll("_", "-") - .trim(); - const isValidName = defaultName && /^[a-zA-Z0-9-]+$/.test(defaultName); - const projectName = await prompt("What do you want to name your project?", { - defaultValue: isValidName ? defaultName : "my-project", + if (normalisedContainerConfig.length) { + assert(versionId); + await deployContainers(config, normalisedContainerConfig, { + versionId, + accountId, + scriptName, }); - args.name = projectName; - logger.log(""); - promptedForMissing = true; } - // Prompt for compatibility date when missing - if (!args.latest && !args.compatibilityDate && !config.compatibility_date) { - const compatibilityDateStr = getTodaysCompatDate(); - - if ( - await confirm( - `No compatibility date is set. Would you like to use today's date (${compatibilityDateStr})?` - ) - ) { - args.compatibilityDate = compatibilityDateStr; - promptedForMissing = true; - logger.log(""); - } else { - throw new UserError( - `A compatibility_date is required when publishing. Add it to your ${configFileName(config.configPath)} file or pass \`--compatibility-date\` via CLI.\nSee https://developers.cloudflare.com/workers/platform/compatibility-dates for more information.`, - { telemetryMessage: "missing compatibility date when deploying" } - ); - } - } + // deploy triggers + const targets = await triggersDeploy({ + config, + accountId, + scriptName: mergedConfig.name, + workerName, + env: args.env, + crons: mergedConfig.triggers, + routes: mergedConfig.routes, + useServiceEnvironments: mergedConfig.useServiceEnvApiPath, + firstDeploy: !workerExists, + }); - const hasConfigFile = !!config.configPath; + logger.log("Current Version ID:", versionId); - // When no config file exists and we prompted for missing config, offer to write one - if (!hasConfigFile && promptedForMissing) { - // When --latest was used, the compat date prompt was skipped but we still - // need a concrete date in the config file for future deploys without --latest - const effectiveCompatDate = - args.compatibilityDate ?? - (args.latest ? getTodaysCompatDate() : undefined); + return { + sourceMapSize, + versionId, + workerTag, + targets: targets ?? [], + name: mergedConfig.name, + workerNameOverridden: mergedConfig.workerNameOverridden, + entry: mergedConfig.entry, + }; +} - const configContent: Record = { - name: args.name, - compatibility_date: effectiveCompatDate, +/** + * 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()` + */ +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 (args.script) { - configContent.main = args.script; - } - if (args.assets) { - configContent.assets = { directory: args.assets }; - } + } - const writeConfigFile = await confirm( - `Do you want Wrangler to write a wrangler.jsonc config file to store this configuration?\n${chalk.dim( - "This will allow you to simply run `wrangler deploy` on future deployments." - )}` - ); + if (manifest && format === "service-worker") { + withSites["__STATIC_CONTENT_MANIFEST"] = { + type: "text_blob", + source: { contents: "__STATIC_CONTENT_MANIFEST" }, + }; + } + return withSites; +} - if (writeConfigFile) { - const configPath = path.join(process.cwd(), "wrangler.jsonc"); - const jsonString = JSON.stringify(configContent, null, 2); - writeFileSync(configPath, jsonString); - logger.log(`Wrote \n${jsonString}\n to ${chalk.bold(configPath)}.`); - logger.log( - `Simply run ${chalk.bold("`wrangler deploy`")} next time. Wrangler will automatically use the configuration saved to wrangler.jsonc.` - ); - } else { - const scriptPart = args.script ? `${args.script} ` : ""; - const flagParts = [ - args.name ? `--name ${args.name}` : "", - effectiveCompatDate - ? `--compatibility-date ${effectiveCompatDate}` - : "", - args.assets ? `--assets ${args.assets}` : "", - ] - .filter(Boolean) - .join(" "); - logger.log( - `You should run ${chalk.bold( - `wrangler deploy ${scriptPart}${flagParts}` - )} next time to deploy this Worker without going through this flow again.` +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" ); - } - logger.log("\nProceeding with deployment...\n"); + process.exitCode = 1; + return false; + }; } - return args; + return confirm; } diff --git a/packages/wrangler/src/deploy/open-next.ts b/packages/wrangler/src/deploy/open-next.ts deleted file mode 100644 index 1576d35d73..0000000000 --- a/packages/wrangler/src/deploy/open-next.ts +++ /dev/null @@ -1,91 +0,0 @@ -import assert from "node:assert"; -import { readdir } from "node:fs/promises"; -import { runCommand } from "@cloudflare/cli-shared-helpers/command"; -import { getOpenNextDeployFromEnv } from "@cloudflare/workers-utils"; -import { getInstalledPackageVersion } from "../autoconfig/frameworks/utils/packages"; -import { logger } from "../logger"; -import { getPackageManager } from "../package-manager"; - -/** - * If appropriate (when `wrangler deploy` is run in an OpenNext project without setting the `OPEN_NEXT_DEPLOY` environment variable) - * this function delegates the deployment operation to `@opennextjs/cloudflare`, otherwise it does nothing. - * - * @param projectRoot The path to the project's root - * @returns true is the deployment has been delegated to open-next, false otherwise - */ -export async function maybeDelegateToOpenNextDeployCommand( - projectRoot: string -): Promise { - if (await isOpenNextProject(projectRoot)) { - const openNextDeploy = getOpenNextDeployFromEnv(); - if (!openNextDeploy) { - logger.log( - "OpenNext project detected, calling `opennextjs-cloudflare deploy`" - ); - - const deployArgIdx = process.argv.findIndex((arg) => arg === "deploy"); - assert(deployArgIdx !== -1, "Could not find `deploy` argument"); - const deployArguments = process.argv.slice(deployArgIdx + 1); - - const { npx } = await getPackageManager(); - - await runCommand( - [npx, "opennextjs-cloudflare", "deploy", ...deployArguments], - { - env: { - // We set `OPEN_NEXT_DEPLOY` here so that it's passed through to the `wrangler deploy` command that OpenNext delegates to in order to prevent an infinite loop - OPEN_NEXT_DEPLOY: "true", - }, - } - ); - - return true; - } - } - return false; -} - -/** - * Discerns if the project is an open-next one. This check is performed in an assertive way to ensure that - * no false positives happen. - * - * @param projectRoot The path to the project's root - * @returns true if the project is an open-next one, false otherwise - */ -async function isOpenNextProject(projectRoot: string) { - try { - const dirFiles = await readdir(projectRoot); - - const nextConfigFile = dirFiles.find((file) => - /^next\.config\.(m|c)?(ts|js)$/.test(file) - ); - - if (!nextConfigFile) { - // If there is no next config file then the project is not a Next.js one - return false; - } - - const opeNextConfigFile = dirFiles.find((file) => - /^open-next\.config\.(ts|js)$/.test(file) - ); - - if (!opeNextConfigFile) { - // If there is no open-next config file then the project is not an OpenNext one - return false; - } - - const openNextVersion = getInstalledPackageVersion( - "@opennextjs/cloudflare", - projectRoot, - { - // We stop at the projectPath/root just to make extra sure we don't hit false positives - stopAtProjectPath: true, - } - ); - - return openNextVersion !== undefined; - } catch { - // If any error is thrown then we simply assume that we're not running in an OpenNext project - return false; - } -} diff --git a/packages/wrangler/src/deploy/shared.ts b/packages/wrangler/src/deploy/shared.ts new file mode 100644 index 0000000000..aa043891ad --- /dev/null +++ b/packages/wrangler/src/deploy/shared.ts @@ -0,0 +1,360 @@ +import { + configFileName, + formatConfigSnippet, + getCIGeneratePreviewAlias, + getCIOverrideName, + getTodaysCompatDate, + UserError, +} from "@cloudflare/workers-utils"; +import { + type AssetsOptions, + getAssetsOptions, + validateAssetsArgsAndConfig, +} from "../assets"; +import { type Entry, getEntry } from "../deployment-bundle/entry"; +import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; +import { logger } from "../logger"; +import { getSiteAssetPaths, type LegacyAssetPaths } from "../sites"; +import { collectKeyValues } from "../utils/collectKeyValues"; +import { getRules } from "../utils/getRules"; +import { getScriptName } from "../utils/getScriptName"; +import { parseConfigPlacement } from "../utils/placement"; +import { useServiceEnvironmentApi } from "../utils/useServiceEnvironments"; +import { + generatePreviewAlias, + type versionsUploadCommand, +} from "../versions/upload"; +import { validateRoutes } from "./deploy"; +import type { DeployArgs } from "."; +import type { triggersDeployCommand } from "../triggers"; +import type { CfPlacement, Config, Route } from "@cloudflare/workers-utils"; +import type { NodeJSCompatMode } from "miniflare"; + +/** + * Shared arg validation for both `wrangler deploy` and `wrangler versions upload`. + * Called from each command's `validateArgs` hook (before config is read). + */ +export function validateArgs(args: { + nodeCompat: boolean | undefined; + latest: boolean | undefined; + config: string | undefined; +}): void { + if (args.nodeCompat) { + throw new UserError( + "The --node-compat flag is no longer supported as of Wrangler v4. Instead, use the `nodejs_compat` compatibility flag. This includes the functionality from legacy `node_compat` polyfills and natively implemented Node.js APIs. See https://developers.cloudflare.com/workers/runtime-apis/nodejs for more information.", + { telemetryMessage: "deploy node compat unsupported" } + ); + } + + if (args.latest) { + logger.warn( + `Using the latest version of the Workers runtime. To silence this warning, please choose a specific version of the runtime with --compatibility-date, or add a compatibility_date to your ${configFileName(args.config)} file.` + ); + } +} + +type VersionsUploadArgs = (typeof versionsUploadCommand)["args"]; +/** + * Shared fields produced by merging CLI args with wrangler config. + * After this point, no raw config/arg merging should happen. + * No need to include something that is only ever derived from an arg + */ +export type SharedUploadProps = { + config: Config; + /** Merged from args.script/config.main/config.site.entry-point/config.assets. */ + entry: Entry; + /** From config.rules. */ + rules: Config["rules"]; + /** Merged: --name arg ?? config.name, with CI override applied. */ + name: string; + workerNameOverridden: boolean; + /** Merged: --compatibility-date arg ?? config.compatibility_date. Still optional — validated as required in stage 4. */ + compatibilityDate: string | undefined; + /** Merged: --compatibility-flags arg ?? config.compatibility_flags. */ + compatibilityFlags: string[]; + /** computed based on compat date and args */ + nodejsCompatMode: NodeJSCompatMode; + /** Merged from --assets arg and config.assets. */ + assetsOptions: AssetsOptions | undefined; + /** Merged: --jsx-factory arg || config.jsx_factory. */ + jsxFactory: string; + /** Merged: --jsx-fragment arg || config.jsx_fragment. */ + jsxFragment: string; + /** Merged: --tsconfig arg ?? config.tsconfig. */ + tsconfig: string | undefined; + /** Merged: --minify arg ?? config.minify. */ + minify: boolean | undefined; + /** Merged: !(--bundle arg ?? !config.no_bundle). */ + noBundle: boolean; + /** Merged: --upload-source-maps arg ?? config.upload_source_maps. */ + uploadSourceMaps: boolean | undefined; + /** Merged: --keep-vars arg || config.keep_vars. */ + keepVars: boolean; + /** Merged from --site arg and config.site. */ + isWorkersSite: boolean; + /** Merged: { ...config.define, ...--define arg }. CLI overrides config. */ + defines: Record; + /** Merged: { ...config.alias, ...--alias arg }. CLI overrides config. */ + alias: Record; + /** + * Whether to use the deprecated service environments API path. + * True only when config opts in (legacy_env: false) AND --env is specified. + */ + useServiceEnvApiPath: boolean; + placement: CfPlacement | undefined; +}; + +export type DeployProps = SharedUploadProps & { + /** Merged from --site arg and config.site. */ + legacyAssetPaths: LegacyAssetPaths | undefined; + /** Merged: --triggers arg ?? config.triggers.crons. */ + triggers: string[] | undefined; + /** Merged: --routes arg ?? config.routes ?? config.route. AND --domains and custom_domains*/ + routes: Route[]; + /** Merged: --logpush arg ?? config.logpush. */ + logpush: boolean | undefined; +}; + +export type VersionsUploadProps = SharedUploadProps & { + /** CLI-only (--preview-alias), or auto-generated from CI branch name. */ + previewAlias: string | undefined; +}; +/** + * Shared logic to merge CLI args with config for both `wrangler deploy` and + * `wrangler versions upload`. Collects CLI key-value overrides, resolves + * the worker name (with CI override), authenticates, and verifies CI tags. + */ +export async function resolveSharedConfig( + args: DeployArgs | VersionsUploadArgs, + config: Config, + command: "deploy" | "versions upload" +): Promise { + validateAssetsArgsAndConfig(args, config); + const assetsOptions = getAssetsOptions({ args, config }); + + const entry = await getEntry(args, config, command); + + /** start name stuff */ + let name = getScriptName(args, config); + let workerNameOverridden = false; + + const ciOverrideName = getCIOverrideName(); + if (ciOverrideName !== undefined && ciOverrideName !== name) { + logger.warn( + `Failed to match Worker name. Your config file is using the Worker name "${name}", but the CI system expected "${ciOverrideName}". Overriding using the CI provided Worker name. Workers Builds connected builds will attempt to open a pull request to resolve this config name mismatch.` + ); + name = ciOverrideName; + workerNameOverridden = true; + } + + if (!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: true } + ); + } + /** end name stuff */ + + const minify = args.minify ?? config.minify; + const noBundle = !(args.bundle ?? !config.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." + ); + } + + /** start compat stuff */ + const compatibilityDate = args.latest + ? getTodaysCompatDate() + : (args.compatibilityDate ?? config.compatibility_date); + + if (!compatibilityDate) { + const compatibilityDateStr = getTodaysCompatDate(); + + throw new UserError( + `A compatibility_date is required when publishing. 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: "missing compatibility date when deploying" } + ); + } + const compatibilityFlags = + args.compatibilityFlags ?? config.compatibility_flags ?? []; + const nodejsCompatMode = validateNodeCompatMode( + compatibilityDate, + compatibilityFlags, + { + noBundle, + } + ); + + if (config.wasm_modules && entry.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: "deploy wasm modules with es module worker" } + ); + } + + if (config.text_blobs && entry.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: "[text_blobs] with an ES module worker" } + ); + } + + if (config.data_blobs && entry.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: "[data_blobs] with an ES module worker" } + ); + } + + const placement = parseConfigPlacement(config); + + return { + config, + assetsOptions, + entry, + name, + workerNameOverridden, + compatibilityDate, + compatibilityFlags, + nodejsCompatMode, + noBundle, + minify, + placement, + rules: getRules(config), + jsxFactory: args.jsxFactory || config.jsx_factory, + jsxFragment: args.jsxFragment || config.jsx_fragment, + tsconfig: args.tsconfig ?? config.tsconfig, + + uploadSourceMaps: args.uploadSourceMaps ?? config.upload_source_maps, + keepVars: + ("keepVars" in args && Boolean(args.keepVars)) || + config.keep_vars || + false, + isWorkersSite: Boolean(args.site || config.site), + + defines: { ...config.define, ...collectKeyValues(args.define) }, + alias: { ...config.alias, ...collectKeyValues(args.alias) }, + useServiceEnvApiPath: useServiceEnvironmentApi(args, config), + }; +} + +export async function resolveVersionsUploadConfig( + args: VersionsUploadArgs, + config: Config +): Promise { + if (args.site || config.site) { + throw new UserError( + "Workers Sites does not support uploading versions through `wrangler versions upload`. You must use `wrangler deploy` instead.", + { telemetryMessage: "versions upload sites unsupported" } + ); + } + const shared = await resolveSharedConfig(args, config, "versions upload"); + const previewAlias = + args.previewAlias ?? + (getCIGeneratePreviewAlias() === "true" + ? generatePreviewAlias(shared.name) + : undefined); + + return { + ...shared, + previewAlias, + }; +} +/** + * Deploy ONLY config. + * If something is also used in versions upload or previews, + * it should go in resolveSharedConfig() + */ +export async function resolveDeployConfig( + args: DeployArgs, + config: Config +): Promise { + const shared = await resolveSharedConfig(args, config, "deploy"); + const siteAssetPaths = getSiteAssetPaths( + config, + args.site, + args.siteInclude, + args.siteExclude + ); + if (config.site && !config.site.bucket) { + throw new Error( + "A [site] definition requires a `bucket` field with a path to the site's assets directory." + ); + } + if ( + !(args.site || config.site) && + Boolean(siteAssetPaths) && + shared.entry.format === "service-worker" + ) { + throw new UserError( + "You cannot use the service-worker format with an `assets` directory yet. For information on how to migrate to the module-worker format, see: https://developers.cloudflare.com/workers/learning/migrating-to-module-workers/", + { telemetryMessage: "deploy service worker assets unsupported" } + ); + } + + return { + ...shared, + routes: resolveRoutes(args, config, shared.assetsOptions), + legacyAssetPaths: siteAssetPaths, + logpush: args.logpush ?? config.logpush, + triggers: resolveCronTriggers(args, config), + }; +} + +/** + * for wrangler triggers deploy - non dry-run/API calling validation and resolution + */ +export function resolveTriggersConfig( + args: (typeof triggersDeployCommand)["args"] & { domains?: string[] }, + config: Config +) { + const assetsOptions = getAssetsOptions({ + args: { assets: undefined }, + config, + }); + const scriptName = getScriptName(args, config); + if (!scriptName) { + throw new UserError( + 'You need to provide a name when uploading a Worker Version. Either pass it as a cli arg with `--name ` or in your config file as `name = ""`', + { telemetryMessage: "triggers deploy missing worker name" } + ); + } + const useServiceEnvironments = useServiceEnvironmentApi(args, config); + const workerName = useServiceEnvironments + ? `${scriptName} (${args.env ?? "production"})` + : scriptName; + return { + crons: resolveCronTriggers(args, config), + useServiceEnvironments, + routes: resolveRoutes(args, config, assetsOptions) ?? [], + scriptName, + workerName, + }; +} + +// only this needs to run in dry run +export function resolveRoutes( + args: { routes?: string[]; domains?: string[] }, + config: Config, + assetsOptions: AssetsOptions | undefined +): Route[] { + const domainRoutes = (args.domains || []).map((domain) => ({ + pattern: domain, + custom_domain: true, + })); + const routes = + args.routes ?? config.routes ?? (config.route ? [config.route] : []); + const allDeploymentRoutes = [...routes, ...domainRoutes]; + validateRoutes(allDeploymentRoutes, assetsOptions); + return allDeploymentRoutes; +} + +function resolveCronTriggers(args: { triggers?: string[] }, config: Config) { + return args.triggers ?? config.triggers?.crons; +} diff --git a/packages/wrangler/src/triggers/deploy.ts b/packages/wrangler/src/triggers/deploy.ts index ca3f76975f..97c9319840 100644 --- a/packages/wrangler/src/triggers/deploy.ts +++ b/packages/wrangler/src/triggers/deploy.ts @@ -11,42 +11,36 @@ import { publishRoutes, renderRoute, updateQueueConsumers, - validateRoutes, } from "../deploy/deploy"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; -import { ensureQueuesExistByConfig } from "../queues/client"; import { getWorkersDevSubdomain } from "../routes"; import { retryOnAPIFailure } from "../utils/retry"; import { getZoneForRoute } from "../zones"; -import type { AssetsOptions } from "../assets"; import type { RouteObject } from "../deploy/deploy"; import type { Config, Route } from "@cloudflare/workers-utils"; type Props = { config: Config; - accountId: string | undefined; - name: string | undefined; + accountId: string; + scriptName: string; + /** may include env name */ + workerName: string; env: string | undefined; - triggers: string[] | undefined; - routes: Route[] | undefined; - useServiceEnvironments: boolean | undefined; - dryRun: boolean | undefined; - assetsOptions: AssetsOptions | undefined; + crons: string[] | undefined; + routes: Route[]; + useServiceEnvironments: boolean; firstDeploy: boolean; }; export default async function triggersDeploy( props: Props ): Promise { - const { config, accountId, name: scriptName } = props; + const { config, accountId, scriptName, workerName, routes, crons } = props; - const schedules = props.triggers || config.triggers?.crons; - const routes = - props.routes ?? config.routes ?? (config.route ? [config.route] : []) ?? []; const routesOnly: Array = []; const customDomainsOnly: Array = []; - validateRoutes(routes, props.assetsOptions); + for (const route of routes) { if (typeof route !== "string" && route.custom_domain) { customDomainsOnly.push(route); @@ -55,53 +49,22 @@ export default async function triggersDeploy( } } - if (!scriptName) { - throw new UserError( - 'You need to provide a name when uploading a Worker Version. Either pass it as a cli arg with `--name ` or in your config file as `name = ""`', - { telemetryMessage: "triggers deploy missing worker name" } - ); - } - const envName = props.env ?? "production"; const start = Date.now(); - const useServiceEnvironments = Boolean( - props.useServiceEnvironments && props.env - ); - const workerName = useServiceEnvironments - ? `${scriptName} (${envName})` - : scriptName; - const workerUrl = useServiceEnvironments + + const workerUrl = props.useServiceEnvironments ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${envName}` : `/accounts/${accountId}/workers/scripts/${scriptName}`; - if (!props.dryRun) { - await ensureQueuesExistByConfig(config); - } - - if (props.dryRun) { - logger.log(`--dry-run: exiting now.`); - return; - } - - if (!accountId) { - throw new UserError("Missing accountId", { - telemetryMessage: "triggers deploy missing account id", - }); - } - const uploadMs = Date.now() - start; const deployments: Promise[] = []; const { wantWorkersDev, workersDevInSync } = await subdomainDeploy( props, - accountId, - scriptName, envName, workerUrl, - routes, - deployments, - props.firstDeploy + deployments ); if (!wantWorkersDev && workersDevInSync && routes.length !== 0) { @@ -204,7 +167,7 @@ export default async function triggersDeploy( publishRoutes(config, routesOnly, { workerUrl, scriptName, - useServiceEnvironments, + useServiceEnvironments: props.useServiceEnvironments, accountId, }).then(() => { if (routesOnly.length > 10) { @@ -228,16 +191,16 @@ export default async function triggersDeploy( // Configure any schedules for the script. // If schedules is not defined then we just leave whatever is previously deployed alone. // If it is an empty array we will remove all schedules. - if (schedules) { + if (crons) { deployments.push( fetchResult(config, `${workerUrl}/schedules`, { // Note: PUT will override previous schedules on this script. method: "PUT", - body: JSON.stringify(schedules.map((cron) => ({ cron }))), + body: JSON.stringify(crons.map((cron) => ({ cron }))), headers: { "Content-Type": "application/json", }, - }).then(() => schedules.map((trigger) => `schedule: ${trigger}`)) + }).then(() => crons.map((cron) => `schedule: ${cron}`)) ); } @@ -358,11 +321,8 @@ export function getSubdomainValuesAPIMock( async function validateSubdomainMixedState( props: Props, - accountId: string, - scriptName: string, before: { workers_dev: boolean; preview_urls: boolean }, - after: { workers_dev: boolean; preview_urls: boolean }, - firstDeploy: boolean + after: { workers_dev: boolean; preview_urls: boolean } ): Promise<{ workers_dev: boolean; preview_urls: boolean; @@ -389,7 +349,7 @@ async function validateSubdomainMixedState( } // Early return if this is the first deploy - if (firstDeploy) { + if (props.firstDeploy) { return after; } @@ -400,10 +360,10 @@ async function validateSubdomainMixedState( const userSubdomain = await getWorkersDevSubdomain( config, - accountId, + props.accountId, config.configPath ); - const previewUrl = `https://-${scriptName}.${userSubdomain}`; + const previewUrl = `https://-${props.scriptName}.${userSubdomain}`; // Scenario 1: User disables workers.dev while having preview URLs enabled if (!after.workers_dev && after.preview_urls) { @@ -436,20 +396,16 @@ async function validateSubdomainMixedState( async function subdomainDeploy( props: Props, - accountId: string, - scriptName: string, envName: string, workerUrl: string, - routes: Route[], - deployments: Array>, - firstDeploy: boolean + deployments: Array> ) { const { config } = props; // Get desired subdomain enablement status. const { workers_dev: wantWorkersDev, preview_urls: wantPreviews } = - getSubdomainValues(config.workers_dev, config.preview_urls, routes); + getSubdomainValues(config.workers_dev, config.preview_urls, props.routes); // workers.dev URL is only set if we want to deploy to workers.dev. @@ -457,13 +413,12 @@ async function subdomainDeploy( if (wantWorkersDev) { const userSubdomain = await getWorkersDevSubdomain( config, - accountId, + props.accountId, config.configPath ); - workersDevURL = - !props.useServiceEnvironments || !props.env - ? `${scriptName}.${userSubdomain}` - : `${envName}.${scriptName}.${userSubdomain}`; + workersDevURL = !props.useServiceEnvironments + ? `${props.scriptName}.${userSubdomain}` + : `${envName}.${props.scriptName}.${userSubdomain}`; } // Get current subdomain enablement status. @@ -497,7 +452,7 @@ async function subdomainDeploy( // Warn about mismatching config and current values. if ( - !firstDeploy && + !props.firstDeploy && config.workers_dev == undefined && after.enabled !== before.enabled ) { @@ -517,7 +472,7 @@ async function subdomainDeploy( } if ( - !firstDeploy && + !props.firstDeploy && config.preview_urls == undefined && after.previews_enabled !== before.previews_enabled ) { @@ -540,11 +495,8 @@ async function subdomainDeploy( await validateSubdomainMixedState( props, - accountId, - scriptName, { workers_dev: before.enabled, preview_urls: before.previews_enabled }, - { workers_dev: after.enabled, preview_urls: after.previews_enabled }, - firstDeploy + { workers_dev: after.enabled, preview_urls: after.previews_enabled } ); // Done. diff --git a/packages/wrangler/src/triggers/index.ts b/packages/wrangler/src/triggers/index.ts index 7c83015870..837d900e44 100644 --- a/packages/wrangler/src/triggers/index.ts +++ b/packages/wrangler/src/triggers/index.ts @@ -1,9 +1,9 @@ -import { getAssetsOptions } from "../assets"; import { createCommand, createNamespace } from "../core/create-command"; +import { resolveTriggersConfig } from "../deploy/shared"; +import { logger } from "../logger"; import * as metrics from "../metrics"; +import { ensureQueuesExistByConfig } from "../queues/client"; import { requireAuth } from "../user"; -import { getScriptName } from "../utils/getScriptName"; -import { useServiceEnvironments } from "../utils/useServiceEnvironments"; import triggersDeploy from "./deploy"; export const triggersNamespace = createNamespace({ @@ -55,27 +55,26 @@ export const triggersDeployCommand = createCommand({ warnIfMultipleEnvsConfiguredButNoneSpecified: true, }, async handler(args, { config }) { - const assetsOptions = getAssetsOptions({ - args: { assets: undefined }, - config, - }); metrics.sendMetricsEvent("deploy worker triggers", { sendMetrics: config.send_metrics, }); - const accountId = args.dryRun ? undefined : await requireAuth(config); + const props = resolveTriggersConfig(args, config); + + if (args.dryRun) { + logger.log(`--dry-run: exiting now.`); + return; + } + + const accountId = await requireAuth(config); + await ensureQueuesExistByConfig(config); await triggersDeploy({ config, accountId, - name: getScriptName(args, config), env: args.env, - triggers: args.triggers, - routes: args.routes, - useServiceEnvironments: useServiceEnvironments(config), - dryRun: args.dryRun, - assetsOptions, - firstDeploy: false, // at this point the Worker should already exist. + firstDeploy: false, + ...props, }); }, }); diff --git a/packages/wrangler/src/utils/useServiceEnvironments.ts b/packages/wrangler/src/utils/useServiceEnvironments.ts index 8e8b74c403..ccf5ba1fca 100644 --- a/packages/wrangler/src/utils/useServiceEnvironments.ts +++ b/packages/wrangler/src/utils/useServiceEnvironments.ts @@ -21,3 +21,13 @@ export function useServiceEnvironments( ? !config.legacy_env : Boolean(config.legacy.useServiceEnvironments); } + +/** + * even though service environments might be enabled, we might not need to use the service environments api + */ +export function useServiceEnvironmentApi( + args: { env: string | undefined }, + config: Config +) { + return Boolean(useServiceEnvironments(config) && args.env); +} diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index dec9fbb5b7..e584e74c9b 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -4,24 +4,12 @@ import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { blue, gray } from "@cloudflare/cli-shared-helpers/colors"; -import { - configFileName, - getTodaysCompatDate, - formatConfigSnippet, - getCIGeneratePreviewAlias, - getCIOverrideName, - getWorkersCIBranchName, - ParseError, - UserError, -} from "@cloudflare/workers-utils"; +import { getWorkersCIBranchName, ParseError } from "@cloudflare/workers-utils"; import { Response } from "undici"; -import { - getAssetsOptions, - syncAssets, - validateAssetsArgsAndConfig, -} from "../assets"; +import { syncAssets } from "../assets"; import { fetchResult } from "../cfetch"; import { createCommand } from "../core/create-command"; +import { resolveVersionsUploadConfig, validateArgs } from "../deploy/shared"; import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; import { bundleWorker } from "../deployment-bundle/bundle"; import { printBundleSize } from "../deployment-bundle/bundle-reporter"; @@ -33,7 +21,6 @@ import { getWrangler1xLegacyModuleReferences, } from "../deployment-bundle/module-collection"; import { noBundleWorker } from "../deployment-bundle/no-bundle-worker"; -import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; import { addRequiredSecretsInheritBindings, handleMissingSecretsError, @@ -64,53 +51,13 @@ import { import { requireAuth } from "../user"; import { collectKeyValues } from "../utils/collectKeyValues"; import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors"; -import { getRules } from "../utils/getRules"; -import { getScriptName } from "../utils/getScriptName"; -import { parseConfigPlacement } from "../utils/placement"; import { printBindings } from "../utils/print-bindings"; import { retryOnAPIFailure } from "../utils/retry"; import { useServiceEnvironments } from "../utils/useServiceEnvironments"; import { isWorkerNotFoundError } from "../utils/worker-not-found-error"; import { patchNonVersionedScriptSettings } from "./api"; -import type { AssetsOptions } from "../assets"; -import type { Entry } from "../deployment-bundle/entry"; import type { RetrieveSourceMapFunction } from "../sourcemap"; -import type { CfWorkerInit, Config } from "@cloudflare/workers-utils"; -import type { FormData } from "undici"; - -type Props = { - config: Config; - accountId: string | undefined; - entry: Entry; - rules: Config["rules"]; - name: string; - useServiceEnvironments: boolean | undefined; - env: string | undefined; - compatibilityDate: string | undefined; - compatibilityFlags: string[] | undefined; - assetsOptions: AssetsOptions | undefined; - vars: Record | undefined; - defines: Record | undefined; - alias: Record | undefined; - jsxFactory: string | undefined; - jsxFragment: string | undefined; - tsconfig: string | undefined; - isWorkersSite: boolean; - minify: boolean | undefined; - uploadSourceMaps: boolean | undefined; - outDir: string | undefined; - outFile: string | undefined; - dryRun: boolean | undefined; - noBundle: boolean | undefined; - keepVars: boolean | undefined; - projectRoot: string | undefined; - experimentalAutoCreate: boolean; - - tag: string | undefined; - message: string | undefined; - previewAlias: string | undefined; - secretsFile: string | undefined; -}; +import type { CfWorkerInit } from "@cloudflare/workers-utils"; export const versionsUploadCommand = createCommand({ metadata: { @@ -288,6 +235,9 @@ export const versionsUploadCommand = createCommand({ }), warnIfMultipleEnvsConfiguredButNoneSpecified: true, }, + validateArgs(args) { + validateArgs(args); + }, handler: async function versionsUploadHandler(args, { config }) { const entry = await getEntry(args, config, "versions upload"); metrics.sendMetricsEvent( @@ -300,69 +250,14 @@ export const versionsUploadCommand = createCommand({ } ); - if (args.nodeCompat) { - throw new UserError( - `The --node-compat flag is no longer supported as of Wrangler v4. Instead, use the \`nodejs_compat\` compatibility flag. This includes the functionality from legacy \`node_compat\` polyfills and natively implemented Node.js APIs. See https://developers.cloudflare.com/workers/runtime-apis/nodejs for more information.`, - { telemetryMessage: "versions upload node compat unsupported" } - ); - } - - if (args.site || config.site) { - throw new UserError( - "Workers Sites does not support uploading versions through `wrangler versions upload`. You must use `wrangler deploy` instead.", - { telemetryMessage: "versions upload sites unsupported" } - ); - } - - validateAssetsArgsAndConfig( - { - site: undefined, - assets: args.assets, - script: args.script, - }, - config - ); - - const assetsOptions = getAssetsOptions({ - args, - config, - }); - - if (args.latest) { - logger.warn( - `Using the latest version of the Workers runtime. To silence this warning, please choose a specific version of the runtime with --compatibility-date, or add a compatibility_date to your ${configFileName(config.configPath)} file.\n` - ); - } - - const cliVars = collectKeyValues(args.var); - const cliDefines = collectKeyValues(args.define); - const cliAlias = collectKeyValues(args.alias); + const { name, noBundle, rules, uploadSourceMaps, ...props } = + await resolveVersionsUploadConfig(args, config); const accountId = args.dryRun ? undefined : await requireAuth(config); - let name = getScriptName(args, config); - - const ciOverrideName = getCIOverrideName(); - let workerNameOverridden = false; - if (ciOverrideName !== undefined && ciOverrideName !== name) { - logger.warn( - `Failed to match Worker name. Your config file is using the Worker name "${name}", but the CI system expected "${ciOverrideName}". Overriding using the CI provided Worker name. Workers Builds connected builds will attempt to open a pull request to resolve this config name mismatch.` - ); - name = ciOverrideName; - workerNameOverridden = true; - } - - if (!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 previewAlias = - args.previewAlias ?? - (getCIGeneratePreviewAlias() === "true" - ? generatePreviewAlias(name) - : undefined); + // TODO: warn if git/hg has uncommitted changes + 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 (!args.dryRun) { assert(accountId, "Missing account ID"); @@ -372,486 +267,310 @@ export const versionsUploadCommand = createCommand({ name, config.configPath ); - } - const { versionId, workerTag, versionPreviewUrl, versionPreviewAliasUrl } = - await versionsUpload({ - config, - accountId, - name, - rules: getRules(config), - entry, - useServiceEnvironments: useServiceEnvironments(config), - env: args.env, - compatibilityDate: args.latest - ? getTodaysCompatDate() - : args.compatibilityDate, - compatibilityFlags: args.compatibilityFlags, - vars: cliVars, - defines: cliDefines, - alias: cliAlias, - jsxFactory: args.jsxFactory, - jsxFragment: args.jsxFragment, - tsconfig: args.tsconfig, - assetsOptions, - minify: args.minify, - uploadSourceMaps: args.uploadSourceMaps, - isWorkersSite: Boolean(args.site || config.site), - outDir: args.outdir, - dryRun: args.dryRun, - noBundle: !(args.bundle ?? !config.no_bundle), - keepVars: config.keep_vars, - projectRoot: entry.projectRoot, - tag: args.tag, - message: args.message, - previewAlias: previewAlias, - experimentalAutoCreate: args.experimentalAutoCreate, - outFile: args.outfile, - secretsFile: args.secretsFile, - }); - - writeOutput({ - type: "version-upload", - version: 1, - worker_name: name ?? null, - worker_tag: workerTag, - version_id: versionId, - preview_url: versionPreviewUrl, - preview_alias_url: versionPreviewAliasUrl, - wrangler_environment: args.env, - worker_name_overridden: workerNameOverridden, - }); - }, -}); - -export default async function versionsUpload(props: Props): Promise<{ - versionId: string | null; - workerTag: string | null; - versionPreviewUrl?: string | undefined; - versionPreviewAliasUrl?: string | undefined; -}> { - // TODO: warn if git/hg has uncommitted changes - const { config, accountId, name } = props; - 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"; + 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? - ); + }>( + config, + `/accounts/${accountId}/workers/services/${name}` // TODO(consider): should this be a /versions endpoint? + ); - workerTag = script.tag; - tags = script.tags ?? tags; + 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, - }; + 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?"))) { + writeOutput({ + type: "version-upload", + version: 1, + worker_name: name ?? null, + worker_tag: workerTag, + version_id: versionId, + preview_url: undefined, + preview_alias_url: undefined, + wrangler_environment: args.env, + worker_name_overridden: props.workerNameOverridden, + }); + return; + } + } 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?"))) { + writeOutput({ + type: "version-upload", + version: 1, + worker_name: name ?? null, + worker_tag: workerTag, + version_id: versionId, + preview_url: undefined, + preview_alias_url: undefined, + wrangler_environment: args.env, + worker_name_overridden: props.workerNameOverridden, + }); + return; + } } - } 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; } } - } catch (e) { - if (!isWorkerNotFoundError(e)) { - throw e; - } + await ensureQueuesExistByConfig(config); } - } - const compatibilityDate = - props.compatibilityDate || config.compatibility_date; - const compatibilityFlags = - props.compatibilityFlags ?? config.compatibility_flags; - - 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 jsxFactory = props.jsxFactory || config.jsx_factory; - const jsxFragment = props.jsxFragment || config.jsx_fragment; + const scriptName = name; - const minify = props.minify ?? config.minify; + const workerName = scriptName; + const workerUrl = `/accounts/${accountId}/workers/scripts/${scriptName}`; - const nodejsCompatMode = validateNodeCompatMode( - compatibilityDate, - compatibilityFlags, - { - noBundle: props.noBundle ?? config.no_bundle, + if (args.outdir) { + // we're using a custom output directory, + // so let's first ensure it exists + mkdirSync(args.outdir, { recursive: true }); + // add a README + const readmePath = path.join(args.outdir, "README.md"); + writeFileSync( + readmePath, + `This folder contains the built output assets for the worker "${scriptName}" generated at ${new Date().toISOString()}.` + ); } - ); - // Warn if user tries minify or node-compat with no-bundle - if (props.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 = props.name; + const destination = + args.outdir ?? getWranglerTmpDir(entry.projectRoot, "deploy"); - 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(); - if (props.outDir) { - // we're using a custom output directory, - // so let's first ensure it exists - mkdirSync(props.outDir, { recursive: true }); - // add a README - const readmePath = path.join(props.outDir, "README.md"); - writeFileSync( - readmePath, - `This folder contains the built output assets for the worker "${scriptName}" generated at ${new Date().toISOString()}.` - ); - } + let hasPreview = false; - const destination = - props.outDir ?? getWranglerTmpDir(props.projectRoot, "deploy"); + try { + if (noBundle) { + // if we're not building, let's just copy the entry to the destination directory + const destinationDir = + typeof destination === "string" ? destination : destination.path; + mkdirSync(destinationDir, { recursive: true }); + writeFileSync( + path.join(destinationDir, path.basename(entry.file)), + readFileSync(entry.file, "utf-8") + ); + } - const start = Date.now(); - const workerName = scriptName; - const workerUrl = `/accounts/${accountId}/workers/scripts/${scriptName}`; + const entryDirectory = path.dirname(entry.file); + const moduleCollector = createModuleCollector({ + wrangler1xLegacyModuleReferences: getWrangler1xLegacyModuleReferences( + entryDirectory, + entry.file + ), + entry: entry, + // `moduleCollector` doesn't get used when `noBundle` is set, so + // `findAdditionalModules` always defaults to `false` + findAdditionalModules: config.find_additional_modules ?? false, + rules: rules, + }); - const { format } = props.entry; + const bindings = getBindings(config); - 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", + // Vars from the CLI (--var) are hidden so their values aren't logged to the terminal + for (const [bindingName, value] of Object.entries( + collectKeyValues(args.var) ?? {} + )) { + bindings[bindingName] = { + type: "plain_text", + value, + hidden: true, + }; } - ); - } - 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", - } - ); - } + const { + modules, + dependencies, + resolvedEntryPointPath, + bundleType, + ...bundle + } = noBundle + ? await noBundleWorker( + entry, + rules, + args.outdir, + config.python_modules.exclude + ) + : await bundleWorker( + entry, + typeof destination === "string" ? destination : destination.path, + { + bundle: true, + additionalModules: [], + moduleCollector, + doBindings: config.durable_objects.bindings, + workflowBindings: config.workflows, + jsxFactory: props.jsxFactory, + jsxFragment: props.jsxFragment, + tsconfig: props.tsconfig, + minify: props.minify, + keepNames: config.keep_names ?? true, + sourcemap: uploadSourceMaps, + nodejsCompatMode: props.nodejsCompatMode, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + define: props.defines, + alias: props.alias, + checkFetch: false, + // We want to know if the build is for development or publishing + // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? + targetConsumer: "deploy", + local: false, + projectRoot: entry.projectRoot, + defineNavigatorUserAgent: isNavigatorDefined( + props.compatibilityDate, + props.compatibilityFlags + ), + plugins: [logBuildOutput(props.nodejsCompatMode)], + + // Pages specific options used by wrangler pages commands + entryName: undefined, + inject: undefined, + isOutfile: undefined, + external: undefined, + + // These options are dev-only + testScheduled: undefined, + watch: undefined, + metafile: undefined, + } + ); - 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", + // Add modules to dependencies for size warning + for (const module of modules) { + const modulePath = + module.filePath === undefined + ? module.name + : path.relative("", module.filePath); + const bytesInOutput = + typeof module.content === "string" + ? Buffer.byteLength(module.content) + : module.content.byteLength; + dependencies[modulePath] = { bytesInOutput }; } - ); - } - - let hasPreview = false; - try { - if (props.noBundle) { - // if we're not building, let's just copy the entry to the destination directory - const destinationDir = - typeof destination === "string" ? destination : destination.path; - mkdirSync(destinationDir, { recursive: true }); - writeFileSync( - path.join(destinationDir, path.basename(props.entry.file)), - readFileSync(props.entry.file, "utf-8") - ); - } - - const entryDirectory = path.dirname(props.entry.file); - const moduleCollector = createModuleCollector({ - wrangler1xLegacyModuleReferences: getWrangler1xLegacyModuleReferences( - entryDirectory, - props.entry.file - ), - entry: props.entry, - // `moduleCollector` doesn't get used when `props.noBundle` is set, so - // `findAdditionalModules` always defaults to `false` - findAdditionalModules: config.find_additional_modules ?? false, - rules: props.rules, - }); - const uploadSourceMaps = - props.uploadSourceMaps ?? config.upload_source_maps; - - 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.vars ?? {})) { - bindings[bindingName] = { - type: "plain_text", - value, - hidden: true, - }; - } - - const { - modules, - dependencies, - resolvedEntryPointPath, - bundleType, - ...bundle - } = props.noBundle - ? await noBundleWorker( - props.entry, - props.rules, - props.outDir, - config.python_modules.exclude - ) - : await bundleWorker( - props.entry, - typeof destination === "string" ? destination : destination.path, - { - bundle: true, - additionalModules: [], - moduleCollector, - doBindings: config.durable_objects.bindings, - workflowBindings: config.workflows, - jsxFactory, - jsxFragment, - tsconfig: props.tsconfig ?? config.tsconfig, - minify, - keepNames: config.keep_names ?? true, - sourcemap: uploadSourceMaps, - nodejsCompatMode, - compatibilityDate, - compatibilityFlags, - define: { ...config.define, ...props.defines }, - alias: { ...config.alias, ...props.alias }, - checkFetch: false, - // We want to know if the build is for development or publishing - // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? - targetConsumer: "deploy", - local: false, - projectRoot: props.projectRoot, - defineNavigatorUserAgent: isNavigatorDefined( - compatibilityDate, - compatibilityFlags - ), - plugins: [logBuildOutput(nodejsCompatMode)], - - // Pages specific options used by wrangler pages commands - entryName: undefined, - inject: undefined, - isOutfile: undefined, - external: undefined, - - // These options are dev-only - testScheduled: undefined, - watch: undefined, - metafile: undefined, - } - ); - - // Add modules to dependencies for size warning - for (const module of modules) { - const modulePath = - module.filePath === undefined - ? module.name - : path.relative("", module.filePath); - const bytesInOutput = - typeof module.content === "string" - ? Buffer.byteLength(module.content) - : module.content.byteLength; - dependencies[modulePath] = { bytesInOutput }; - } - - const content = readFileSync(resolvedEntryPointPath, { - encoding: "utf-8", - }); + const content = readFileSync(resolvedEntryPointPath, { + encoding: "utf-8", + }); - // durable object migrations - const migrations = !props.dryRun - ? await getMigrationsToUpload(scriptName, { - accountId, - config, - useServiceEnvironments: props.useServiceEnvironments, - env: props.env, - dispatchNamespace: undefined, - }) - : undefined; - - // Upload assets if assets is being used - const assetsJwt = - props.assetsOptions && !props.dryRun - ? await syncAssets( - config, + // durable object migrations + const migrations = !args.dryRun + ? await getMigrationsToUpload(scriptName, { accountId, - props.assetsOptions.directory, - scriptName - ) + config, + useServiceEnvironments: useServiceEnvironments(config), + env: args.env, + dispatchNamespace: undefined, + }) : 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, - }; + // Upload assets if assets is being used + const assetsJwt = + props.assetsOptions && !args.dryRun + ? await syncAssets( + config, + accountId, + props.assetsOptions.directory, + scriptName + ) + : undefined; + + if (args.secretsFile) { + const secretsResult = await parseBulkInputToObject(args.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: props.keepVars ?? false, - // 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 - ); + addRequiredSecretsInheritBindings(config, bindings, { type: "upload" }); - let workerBundle: FormData; + 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: props.compatibilityDate, + compatibility_flags: props.compatibilityFlags, + keepVars: config.keep_vars ?? false, + // 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: props.placement, + tail_consumers: config.tail_consumers, + limits: config.limits, + annotations: { + "workers/message": args.message, + "workers/tag": args.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 (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 (getFlag("RESOURCES_PROVISION")) { - await provisionBindings( - bindings, - accountId, - scriptName, - props.experimentalAutoCreate, - props.config + 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\`.` ); } - workerBundle = createWorkerUploadForm(worker, bindings, { - unsafe: config.unsafe, - }); - await ensureQueuesExistByConfig(config); - let bindingsPrinted = false; + await printBundleSize( + { name: path.basename(resolvedEntryPointPath), content: content }, + modules + ); - // 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: await getMetricsUsageHeaders(config.send_metrics), - }, - new URLSearchParams({ bindings_inherit: "strict" }) - ) - ); + let workerBundle: FormData; - logger.log("Worker Startup Time:", result.startup_time_ms, "ms"); - bindingsPrinted = true; + if (args.dryRun) { + workerBundle = createWorkerUploadForm(worker, bindings, { + dryRun: true, + unsafe: config.unsafe, + }); printBindings( bindings, config.tail_consumers, @@ -859,10 +578,46 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m undefined, { unsafeMetadata: config.unsafe?.metadata } ); - versionId = result.id; - hasPreview = result.metadata.has_preview; - } catch (err) { - if (!bindingsPrinted) { + } else { + assert(accountId, "Missing accountId"); + if (getFlag("RESOURCES_PROVISION")) { + await provisionBindings( + bindings, + accountId, + scriptName, + args.experimentalAutoCreate, + config + ); + } + workerBundle = createWorkerUploadForm(worker, bindings, { + unsafe: config.unsafe, + }); + + 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: await getMetricsUsageHeaders(config.send_metrics), + }, + new URLSearchParams({ bindings_inherit: "strict" }) + ) + ); + + logger.log("Worker Startup Time:", result.startup_time_ms, "ms"); + bindingsPrinted = true; printBindings( bindings, config.tail_consumers, @@ -870,141 +625,179 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m 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, - props.projectRoot - ); - if (message) { - logger.error(message); - } + const message = await helpIfErrorIsSizeOrScriptStartup( + err, + dependencies, + workerBundle, + entry.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; + 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; } - } - }; - const retrieveSourceMap: RetrieveSourceMapFunction = (moduleName) => - maybeRetrieveFileSourceMap(maybeNameToFilePath(moduleName)); + // 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 - ); + err.notes[0].text = getSourceMappedString( + err.notes[0].text, + retrieveSourceMap + ); + } + + throw err; } - 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 (args.outfile) { + // we're using a custom output file, + // so let's first ensure it's parent directory exists + mkdirSync(path.dirname(args.outfile), { recursive: true }); - // Update service and environment tags when using environments + const serializedFormData = await new Response( + workerBundle + ).arrayBuffer(); - const nextTags = applyServiceAndEnvironmentTags(config, tags); - if (!tagsAreEqual(tags, nextTags)) { - try { - await patchNonVersionedScriptSettings(config, accountId, scriptName, { - tags: nextTags, - }); - } catch { - warnOnErrorUpdatingServiceAndEnvironmentTags(); - } + writeFileSync(args.outfile, Buffer.from(serializedFormData)); + } + } finally { + if (typeof destination !== "string") { + // this means we're using a temp dir, + // so let's clean up before we proceed + destination.remove(); } } - 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)); - } - } finally { - if (typeof destination !== "string") { - // this means we're using a temp dir, - // so let's clean up before we proceed - destination.remove(); + if (args.dryRun) { + logger.log(`--dry-run: exiting now.`); + writeOutput({ + type: "version-upload", + version: 1, + worker_name: name ?? null, + worker_tag: workerTag, + version_id: versionId, + preview_url: undefined, + preview_alias_url: undefined, + wrangler_environment: args.env, + worker_name_overridden: props.workerNameOverridden, + }); + return; } - } - 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", - }); - } + // will exist after dry run has exited + assert(accountId, "Missing account ID"); - const uploadMs = Date.now() - start; + const uploadMs = Date.now() - start; - logger.log("Uploaded", workerName, formatTime(uploadMs)); - logger.log("Worker Version ID:", versionId); + logger.log("Uploaded", workerName, formatTime(uploadMs)); + logger.log("Worker Version ID:", versionId); - let versionPreviewUrl: string | undefined = undefined; - let versionPreviewAliasUrl: string | undefined = undefined; + 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 (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, - config.configPath - ); - const shortVersion = versionId.slice(0, 8); - versionPreviewUrl = `https://${shortVersion}-${workerName}.${userSubdomain}`; - logger.log(`Version Preview URL: ${versionPreviewUrl}`); + if (previews_available_on_subdomain) { + const userSubdomain = await getWorkersDevSubdomain( + config, + accountId, + 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}`); + 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(` + 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 }; -} + writeOutput({ + type: "version-upload", + version: 1, + worker_name: name ?? null, + worker_tag: workerTag, + version_id: versionId, + preview_url: versionPreviewUrl, + preview_alias_url: versionPreviewAliasUrl, + wrangler_environment: args.env, + worker_name_overridden: props.workerNameOverridden, + }); + }, +}); function formatTime(duration: number) { return `(${(duration / 1000).toFixed(2)} sec)`;