From 42288d4886b7b7a516f5bcca6924a706201aa1e8 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Tue, 2 Jun 2026 15:22:19 +0100 Subject: [PATCH 1/6] fix: Include `currentAgentSkillsInstalled` in command telemetry events (#14155) --- .../include-agent-skills-in-command-events.md | 8 +++++++ .../wrangler/src/__tests__/metrics.test.ts | 1 + .../src/metrics/metrics-dispatcher.ts | 22 +++++++++++++------ 3 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 .changeset/include-agent-skills-in-command-events.md diff --git a/.changeset/include-agent-skills-in-command-events.md b/.changeset/include-agent-skills-in-command-events.md new file mode 100644 index 0000000000..60ac36e7e3 --- /dev/null +++ b/.changeset/include-agent-skills-in-command-events.md @@ -0,0 +1,8 @@ +--- +"wrangler": patch +--- + +Include agent skill installation status in all telemetry events + +The agent skill installation status is now consistently included in all telemetry events, not just a subset of them. + diff --git a/packages/wrangler/src/__tests__/metrics.test.ts b/packages/wrangler/src/__tests__/metrics.test.ts index 6f002e7a00..4c5a44cc66 100644 --- a/packages/wrangler/src/__tests__/metrics.test.ts +++ b/packages/wrangler/src/__tests__/metrics.test.ts @@ -253,6 +253,7 @@ describe("metrics", () => { agent: null, sanitizedCommand: "docs", sanitizedArgs: {}, + currentAgentSkillsInstalled: null, }; beforeEach(() => { // Default: no agent detected diff --git a/packages/wrangler/src/metrics/metrics-dispatcher.ts b/packages/wrangler/src/metrics/metrics-dispatcher.ts index 2f15388029..c7a98d9a33 100644 --- a/packages/wrangler/src/metrics/metrics-dispatcher.ts +++ b/packages/wrangler/src/metrics/metrics-dispatcher.ts @@ -162,13 +162,21 @@ export function getMetricsDispatcher(options: MetricsConfigOptions) { }; trackDispatch( - dispatch({ - name, - properties: { - ...commonCommandEventProperties, - ...properties, - }, - }) + telemetryCurrentAgentSkillsInstalled() + .catch(() => null) + .then((currentAgentSkillsInstalled) => { + return dispatch({ + name, + properties: { + ...commonCommandEventProperties, + ...properties, + currentAgentSkillsInstalled, + }, + }); + }) + .catch((err) => { + logger.debug("Error sending command metrics event", err); + }) ); } catch (err) { logger.debug("Error sending metrics event", err); From 3d7992e6ac69c6572449b1c1f74354cfdeeaa1ad Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 2 Jun 2026 15:24:47 +0100 Subject: [PATCH 2/6] [vitest-pool-workers] Fix module resolution for paths with spaces (#14152) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../fix-space-in-path-double-encoding.md | 7 ++++ .../src/pool/module-fallback.ts | 12 +++++++ .../test/file-url-import.test.ts | 36 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 .changeset/fix-space-in-path-double-encoding.md create mode 100644 packages/vitest-pool-workers/test/file-url-import.test.ts diff --git a/.changeset/fix-space-in-path-double-encoding.md b/.changeset/fix-space-in-path-double-encoding.md new file mode 100644 index 0000000000..e0c8f746f7 --- /dev/null +++ b/.changeset/fix-space-in-path-double-encoding.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vitest-pool-workers": patch +--- + +Fix module resolution failing when project path contains spaces + +When a project lived under a directory with spaces (e.g. `/Users/me/Documents/Master CMS/project`), the vitest pool would fail with `No such module "threads.js"` before any test executed. The module fallback service now uses the `rawSpecifier` from workerd's fallback request to correctly decode `file://` URLs, avoiding the double-encoding of spaces (`%20` → `%2520`) that occurred when workerd resolved these URLs as relative paths. diff --git a/packages/vitest-pool-workers/src/pool/module-fallback.ts b/packages/vitest-pool-workers/src/pool/module-fallback.ts index 9d939285bc..a7456de741 100644 --- a/packages/vitest-pool-workers/src/pool/module-fallback.ts +++ b/packages/vitest-pool-workers/src/pool/module-fallback.ts @@ -520,6 +520,18 @@ export async function handleModuleFallbackRequest( specifier = fileURLToPath(specifier); } + // When the raw specifier is a `file://` URL (e.g. from vitest's dynamic + // imports using `import.meta.url`), workerd may double-encode spaces in the + // resolved `specifier` (%20 → %2520). Use the raw specifier to recover the + // correct filesystem path for resolution. We override `specifier` (not + // `target`) so that `buildModuleResponse` still uses the original module name + // that workerd expects, and the mismatch triggers a redirect. + // See https://github.com/cloudflare/workers-sdk/issues/14107 + const rawSpecifier = url.searchParams.get("rawSpecifier"); + if (rawSpecifier?.startsWith("file:")) { + specifier = ensurePosixLikePath(fileURLToPath(rawSpecifier)); + } + if (isWindows) { // Convert paths like `/C:/a/index.mjs` to `C:/a/index.mjs` so they can be // passed to Node `fs` functions. diff --git a/packages/vitest-pool-workers/test/file-url-import.test.ts b/packages/vitest-pool-workers/test/file-url-import.test.ts new file mode 100644 index 0000000000..de6b4ac90e --- /dev/null +++ b/packages/vitest-pool-workers/test/file-url-import.test.ts @@ -0,0 +1,36 @@ +import dedent from "ts-dedent"; +import { test, vitestConfig } from "./helpers"; + +test( + "resolves dynamic file:// URL imports in paths with spaces", + { timeout: 45_000 }, + async ({ expect, seed, vitestRun }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14107 + // When the project path contains spaces, dynamic imports using file:// URLs + // (e.g. constructed via import.meta.url) would double-encode %20 → %2520, + // causing workerd to fail with "No such module". + await seed({ + "vitest.config.mts": vitestConfig({ + miniflare: { + compatibilityDate: "2025-12-02", + compatibilityFlags: ["nodejs_compat"], + }, + }), + "helper.ts": dedent` + export const value = 42; + `, + "index.test.ts": dedent` + import { it, expect } from "vitest"; + it("can dynamically import via file:// URL", async () => { + const helperUrl = new URL("./helper.ts", import.meta.url); + expect(helperUrl.protocol).toBe("file:"); + const mod = await import(helperUrl.href); + expect(mod.value).toBe(42); + }); + `, + }); + const result = await vitestRun(); + expect(result.stderr).not.toContain("%2520"); + expect(await result.exitCode).toBe(0); + } +); From 0b6042466efdc845b374f82ab49f977399e6c237 Mon Sep 17 00:00:00 2001 From: ANT Bot <116369605+workers-devprod@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:03:57 +0100 Subject: [PATCH 3/6] Version Packages (#14142) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../add-reauth-hint-to-account-errors.md | 7 -- .changeset/bump-am-i-vibing.md | 7 -- .changeset/c3-frameworks-update-14128.md | 11 --- .changeset/c3-frameworks-update-14129.md | 11 --- .changeset/cf-vite-delegate-binary.md | 7 -- .changeset/dependabot-update-14147.md | 12 --- .changeset/filter-categories-by-platform.md | 7 -- .../fix-assets-watcher-emfile-graceful.md | 13 --- .changeset/fix-complete-skip-skills-prompt.md | 9 -- .changeset/fix-inspector-localhost.md | 9 -- .changeset/fix-json-binding-mapping.md | 7 -- .changeset/fix-kv-metadata-invalid-json.md | 5 -- ...ngurl-detection-trailing-magic-comments.md | 7 -- .../fix-space-in-path-double-encoding.md | 7 -- .changeset/fix-trailing-period-urls.md | 10 --- .../fix-workflow-schedules-cron-mapping.md | 7 -- ...ix-wrangler-json-binding-init-from-dash.md | 12 --- .../include-agent-skills-in-command-events.md | 8 -- .../move-fetch-helpers-workers-utils.md | 9 -- .changeset/pin-non-bundled-dependencies.md | 11 --- .changeset/react-router-template-overlay.md | 11 --- .changeset/sharp-dogs-relax.md | 9 -- .changeset/tame-wrangler-secrets.md | 7 -- .../vite-plugin-websocket-upgrade-headers.md | 7 -- .changeset/workflows-restart-from-step-cli.md | 7 -- packages/cli/CHANGELOG.md | 11 +++ packages/cli/package.json | 2 +- packages/create-cloudflare/CHANGELOG.md | 34 ++++++++ packages/create-cloudflare/package.json | 2 +- packages/deploy-helpers/CHANGELOG.md | 8 ++ packages/deploy-helpers/package.json | 2 +- packages/miniflare/CHANGELOG.md | 26 ++++++ packages/miniflare/package.json | 2 +- packages/pages-shared/CHANGELOG.md | 7 ++ packages/pages-shared/package.json | 2 +- packages/vite-plugin-cloudflare/CHANGELOG.md | 16 ++++ packages/vite-plugin-cloudflare/package.json | 2 +- packages/vitest-pool-workers/CHANGELOG.md | 26 ++++++ packages/vitest-pool-workers/package.json | 2 +- packages/workers-editor-shared/CHANGELOG.md | 8 ++ packages/workers-editor-shared/package.json | 2 +- packages/workers-playground/CHANGELOG.md | 7 ++ packages/workers-playground/package.json | 2 +- packages/workers-utils/CHANGELOG.md | 16 ++++ packages/workers-utils/package.json | 2 +- packages/wrangler-bundler/CHANGELOG.md | 7 ++ packages/wrangler-bundler/package.json | 2 +- packages/wrangler/CHANGELOG.md | 84 +++++++++++++++++++ packages/wrangler/package.json | 2 +- 49 files changed, 262 insertions(+), 229 deletions(-) delete mode 100644 .changeset/add-reauth-hint-to-account-errors.md delete mode 100644 .changeset/bump-am-i-vibing.md delete mode 100644 .changeset/c3-frameworks-update-14128.md delete mode 100644 .changeset/c3-frameworks-update-14129.md delete mode 100644 .changeset/cf-vite-delegate-binary.md delete mode 100644 .changeset/dependabot-update-14147.md delete mode 100644 .changeset/filter-categories-by-platform.md delete mode 100644 .changeset/fix-assets-watcher-emfile-graceful.md delete mode 100644 .changeset/fix-complete-skip-skills-prompt.md delete mode 100644 .changeset/fix-inspector-localhost.md delete mode 100644 .changeset/fix-json-binding-mapping.md delete mode 100644 .changeset/fix-kv-metadata-invalid-json.md delete mode 100644 .changeset/fix-sourcemappingurl-detection-trailing-magic-comments.md delete mode 100644 .changeset/fix-space-in-path-double-encoding.md delete mode 100644 .changeset/fix-trailing-period-urls.md delete mode 100644 .changeset/fix-workflow-schedules-cron-mapping.md delete mode 100644 .changeset/fix-wrangler-json-binding-init-from-dash.md delete mode 100644 .changeset/include-agent-skills-in-command-events.md delete mode 100644 .changeset/move-fetch-helpers-workers-utils.md delete mode 100644 .changeset/pin-non-bundled-dependencies.md delete mode 100644 .changeset/react-router-template-overlay.md delete mode 100644 .changeset/sharp-dogs-relax.md delete mode 100644 .changeset/tame-wrangler-secrets.md delete mode 100644 .changeset/vite-plugin-websocket-upgrade-headers.md delete mode 100644 .changeset/workflows-restart-from-step-cli.md diff --git a/.changeset/add-reauth-hint-to-account-errors.md b/.changeset/add-reauth-hint-to-account-errors.md deleted file mode 100644 index 7af4104143..0000000000 --- a/.changeset/add-reauth-hint-to-account-errors.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Add re-authentication hint to account fetch error messages - -When Wrangler fails to automatically retrieve account IDs, the error messages now suggest running `wrangler login` as a troubleshooting step. This addresses confusion for users who encounter these errors after OAuth system changes or other authentication issues. diff --git a/.changeset/bump-am-i-vibing.md b/.changeset/bump-am-i-vibing.md deleted file mode 100644 index 52e4835465..0000000000 --- a/.changeset/bump-am-i-vibing.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Bump `am-i-vibing` from 0.1.1 to 0.4.0 - -This updates the agentic environment detection library to the latest version, which includes improved detection coverage for newer AI coding agents. diff --git a/.changeset/c3-frameworks-update-14128.md b/.changeset/c3-frameworks-update-14128.md deleted file mode 100644 index 9703d6fe0c..0000000000 --- a/.changeset/c3-frameworks-update-14128.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"create-cloudflare": patch ---- - -Update dependencies of "create-cloudflare" - -The following dependency versions have been updated: - -| Dependency | From | To | -| --------------- | ------- | ------- | -| @angular/create | 21.2.12 | 21.2.13 | diff --git a/.changeset/c3-frameworks-update-14129.md b/.changeset/c3-frameworks-update-14129.md deleted file mode 100644 index d462377e58..0000000000 --- a/.changeset/c3-frameworks-update-14129.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"create-cloudflare": patch ---- - -Update dependencies of "create-cloudflare" - -The following dependency versions have been updated: - -| Dependency | From | To | -| ------------------- | ------ | ------ | -| create-react-router | 7.15.1 | 7.16.0 | diff --git a/.changeset/cf-vite-delegate-binary.md b/.changeset/cf-vite-delegate-binary.md deleted file mode 100644 index fca9bd254b..0000000000 --- a/.changeset/cf-vite-delegate-binary.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cloudflare/vite-plugin": patch ---- - -Add an experimental, internal `cf-vite` delegate binary - -This adds an experimental `bin/cf-vite` binary that is spawned by Cloudflare's own parent tooling to drive the plugin as a long-running dev-server subprocess. It is not part of the plugin's public API surface, is not intended to be invoked directly, and its contract may change at any time without notice. diff --git a/.changeset/dependabot-update-14147.md b/.changeset/dependabot-update-14147.md deleted file mode 100644 index c5140dd254..0000000000 --- a/.changeset/dependabot-update-14147.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"miniflare": patch -"wrangler": patch ---- - -Update dependencies of "miniflare", "wrangler" - -The following dependency versions have been updated: - -| Dependency | From | To | -| ---------- | ------------ | ------------ | -| workerd | 1.20260529.1 | 1.20260601.1 | diff --git a/.changeset/filter-categories-by-platform.md b/.changeset/filter-categories-by-platform.md deleted file mode 100644 index f825727373..0000000000 --- a/.changeset/filter-categories-by-platform.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"create-cloudflare": minor ---- - -Hide non-framework categories when `--platform=pages` is specified - -When running C3 with `--platform=pages`, the "Hello World example" and "Application Starter" categories are now hidden since they only produce Workers projects. The framework list is also filtered to only show frameworks that support the Pages platform. This makes it clear that C3 can only create Pages projects when using a framework. diff --git a/.changeset/fix-assets-watcher-emfile-graceful.md b/.changeset/fix-assets-watcher-emfile-graceful.md deleted file mode 100644 index 9b511775b6..0000000000 --- a/.changeset/fix-assets-watcher-emfile-graceful.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"wrangler": patch ---- - -Gracefully handle EMFILE error when assets directory exceeds OS watcher limit - -Previously, when `wrangler dev` was pointed at an assets directory with more than ~4,096 subdirectories, the chokidar file watcher threw an `EMFILE: too many open files` error that was not caught, causing an infinite error loop that made the dev server unresponsive. - -Now the error is caught and wrangler: - -1. Logs a clear warning explaining the platform watcher limit was hit -2. Recommends reducing the number of subdirectories by flattening or restructuring the assets directory -3. Disables the assets watcher gracefully so the dev server continues working without hot-reload diff --git a/.changeset/fix-complete-skip-skills-prompt.md b/.changeset/fix-complete-skip-skills-prompt.md deleted file mode 100644 index 063672d68c..0000000000 --- a/.changeset/fix-complete-skip-skills-prompt.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"wrangler": patch ---- - -Fix `wrangler complete` printing the AI skills prompt into shell completion output - -Previously, running `eval "$(wrangler complete zsh)"` (or any other shell) would fail with errors like `zsh: command not found: --install-skills` because the interactive AI agent skills installation prompt was included in the completion script output. - -The skills prompt is now skipped when running `wrangler complete`, so the generated completion script is clean and can be sourced correctly. diff --git a/.changeset/fix-inspector-localhost.md b/.changeset/fix-inspector-localhost.md deleted file mode 100644 index 8782f3ae61..0000000000 --- a/.changeset/fix-inspector-localhost.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"miniflare": patch ---- - -Use `127.0.0.1` instead of `localhost` for the runtime inspector address - -On systems where `getaddrinfo("localhost")` returns `::1` but IPv6 is disabled at the kernel level, `workerd` fails to bind the inspector socket and silently continues without emitting the `listen-inspector` event to the control FD. This caused `wrangler dev` to hang indefinitely at "Starting local server..." with no error output. - -Using `127.0.0.1` explicitly is consistent with `DEFAULT_HOST`, `--debug-port`, and `resolveLocalhost()` already in the codebase. diff --git a/.changeset/fix-json-binding-mapping.md b/.changeset/fix-json-binding-mapping.md deleted file mode 100644 index 2b49c4b937..0000000000 --- a/.changeset/fix-json-binding-mapping.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cloudflare/workers-utils": patch ---- - -Correctly map JSON bindings in `mapWorkerMetadataBindings` - -The `json` binding case used literal keys `name` and `json` instead of a computed property key `[binding.name]: binding.json`. This caused JSON bindings to always produce `{ name: "", json: }` instead of `{ : }`, clobbering any existing vars with those keys. This is now consistent with how `plain_text` bindings are mapped. diff --git a/.changeset/fix-kv-metadata-invalid-json.md b/.changeset/fix-kv-metadata-invalid-json.md deleted file mode 100644 index 089280bc9b..0000000000 --- a/.changeset/fix-kv-metadata-invalid-json.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"wrangler": patch ---- - -Show a clear error when `--metadata` is not valid JSON instead of silently ignoring the value diff --git a/.changeset/fix-sourcemappingurl-detection-trailing-magic-comments.md b/.changeset/fix-sourcemappingurl-detection-trailing-magic-comments.md deleted file mode 100644 index a438d7d12d..0000000000 --- a/.changeset/fix-sourcemappingurl-detection-trailing-magic-comments.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Fix `wrangler deploy --upload-source-maps` silently skipping source maps when the entry file ends with magic comments after `//# sourceMappingURL=` - -Wrangler previously assumed the `//# sourceMappingURL=` comment was the last non-empty line of a module. Tools like `sentry-cli sourcemaps inject` append a `//# debugId=` comment after it, which silently caused source maps to be omitted from the upload form, most commonly when deploying with `--no-bundle --upload-source-maps`. Wrangler now scans trailing magic comments (lines starting with `//#` or `//@`) and detects the `//# sourceMappingURL=` comment regardless of which other magic comments follow it. diff --git a/.changeset/fix-space-in-path-double-encoding.md b/.changeset/fix-space-in-path-double-encoding.md deleted file mode 100644 index e0c8f746f7..0000000000 --- a/.changeset/fix-space-in-path-double-encoding.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cloudflare/vitest-pool-workers": patch ---- - -Fix module resolution failing when project path contains spaces - -When a project lived under a directory with spaces (e.g. `/Users/me/Documents/Master CMS/project`), the vitest pool would fail with `No such module "threads.js"` before any test executed. The module fallback service now uses the `rawSpecifier` from workerd's fallback request to correctly decode `file://` URLs, avoiding the double-encoding of spaces (`%20` → `%2520`) that occurred when workerd resolved these URLs as relative paths. diff --git a/.changeset/fix-trailing-period-urls.md b/.changeset/fix-trailing-period-urls.md deleted file mode 100644 index 4cf927ef0e..0000000000 --- a/.changeset/fix-trailing-period-urls.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"wrangler": patch -"miniflare": patch -"@cloudflare/vitest-pool-workers": patch -"@cloudflare/workers-utils": patch ---- - -Remove trailing periods from URLs in terminal output - -URLs printed to the terminal with a sentence-ending period (e.g. `https://example.com/path.`) would include the period when clicked in some terminal emulators, causing 404 errors. This removes trailing periods from all URLs displayed in CLI output across wrangler, miniflare, vitest-pool-workers, and workers-utils. diff --git a/.changeset/fix-workflow-schedules-cron-mapping.md b/.changeset/fix-workflow-schedules-cron-mapping.md deleted file mode 100644 index 80dac3a565..0000000000 --- a/.changeset/fix-workflow-schedules-cron-mapping.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Fix Workflows `schedules` deploy payload to match the control plane API - -When deploying a Workflow with a `schedules` binding property, Wrangler sent the cron expressions as a list of strings. The Workflows API expects a list of objects of the form `{ cron: string }`, so the request was rejected. Wrangler now maps each configured cron expression to `{ cron }` (normalizing a single string or an array) when building the request. The user-facing config still accepts a string or an array of strings. diff --git a/.changeset/fix-wrangler-json-binding-init-from-dash.md b/.changeset/fix-wrangler-json-binding-init-from-dash.md deleted file mode 100644 index 5b9c8037d9..0000000000 --- a/.changeset/fix-wrangler-json-binding-init-from-dash.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"wrangler": patch ---- - -Fix JSON variable bindings in `wrangler init --from-dash` and remote config diff - -When fetching a remote Worker's configuration, JSON variable bindings (e.g. `{"my_value": 5}`) were incorrectly serialized as `{ "name": "MY_JSON", "json": {"my_value": 5} }` instead of `{ "MY_JSON": {"my_value": 5} }`. This affected two areas: - -- `wrangler init --from-dash` would generate a `wrangler.json` with broken `vars` entries -- Remote config diff checks would always report JSON bindings as changed, since the malformed remote representation could never match the local config - -Both issues are now fixed and remote JSON bindings are now correctly mapped. diff --git a/.changeset/include-agent-skills-in-command-events.md b/.changeset/include-agent-skills-in-command-events.md deleted file mode 100644 index 60ac36e7e3..0000000000 --- a/.changeset/include-agent-skills-in-command-events.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"wrangler": patch ---- - -Include agent skill installation status in all telemetry events - -The agent skill installation status is now consistently included in all telemetry events, not just a subset of them. - diff --git a/.changeset/move-fetch-helpers-workers-utils.md b/.changeset/move-fetch-helpers-workers-utils.md deleted file mode 100644 index 2a70b077a7..0000000000 --- a/.changeset/move-fetch-helpers-workers-utils.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@cloudflare/workers-utils": patch -"@cloudflare/deploy-helpers": patch -"wrangler": patch ---- - -Move fetch helpers into `@cloudflare/workers-utils` - -Shared Cloudflare API fetch helper types and plumbing now live in `@cloudflare/workers-utils` so Wrangler and other clients can use the same implementation. diff --git a/.changeset/pin-non-bundled-dependencies.md b/.changeset/pin-non-bundled-dependencies.md deleted file mode 100644 index ad16714373..0000000000 --- a/.changeset/pin-non-bundled-dependencies.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"wrangler": patch -"miniflare": patch -"@cloudflare/vitest-pool-workers": patch -"@cloudflare/workers-editor-shared": patch -"@cloudflare/cli-shared-helpers": patch ---- - -Pin non-bundled runtime dependencies to exact versions - -Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. diff --git a/.changeset/react-router-template-overlay.md b/.changeset/react-router-template-overlay.md deleted file mode 100644 index 639fc67fba..0000000000 --- a/.changeset/react-router-template-overlay.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"create-cloudflare": patch ---- - -Switch the `react-router` template to scaffold from the upstream `create-react-router` default template and overlay Cloudflare-specific files locally - -Previously, C3 invoked `create-react-router` with `--template ` pointing at a specific commit of `remix-run/react-router-templates/cloudflare`. This pinning was needed because the upstream Cloudflare template had been deleted before, leaving us reliant on a third-party source we don't control. - -We now invoke `create-react-router` without `--template` (using the upstream default template) and overlay all Cloudflare-specific files — `workers/app.ts`, `wrangler.jsonc`, split `tsconfig`s, a Cloudflare-flavored `vite.config.ts`, `entry.server.tsx`, etc. — from `templates/react-router/ts/`. A `configure` step deletes `Dockerfile`/`.dockerignore` and the `@react-router/node`/`@react-router/serve` dependencies and `start` script that ship with the default template. - -This brings the `react-router` template in line with how `astro`, `svelte`, and `react` already work and removes our dependency on a deleted upstream template. The scaffolded project is functionally equivalent to before. diff --git a/.changeset/sharp-dogs-relax.md b/.changeset/sharp-dogs-relax.md deleted file mode 100644 index af4e76b7dd..0000000000 --- a/.changeset/sharp-dogs-relax.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@cloudflare/vitest-pool-workers": patch ---- - -Preserve Durable Object WebSocket handler invocation order - -Durable Object WebSocket events could begin executing out of order in the Workers Vitest integration when several events arrived while the test wrapper was resolving user code. - -Handler invocation now preserves arrival order while still allowing asynchronous handler completion to run concurrently. diff --git a/.changeset/tame-wrangler-secrets.md b/.changeset/tame-wrangler-secrets.md deleted file mode 100644 index e356fa4eef..0000000000 --- a/.changeset/tame-wrangler-secrets.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Fix `wrangler secret bulk` dropping newlines from `.env` input read from stdin - -Previously, `.env` input piped through stdin was concatenated without line breaks, so only the first secret could be parsed correctly. Stdin input now preserves line separators before parsing. diff --git a/.changeset/vite-plugin-websocket-upgrade-headers.md b/.changeset/vite-plugin-websocket-upgrade-headers.md deleted file mode 100644 index ea9fbd7751..0000000000 --- a/.changeset/vite-plugin-websocket-upgrade-headers.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cloudflare/vite-plugin": patch ---- - -Forward response headers from the Worker on WebSocket upgrade responses - -Headers set on a `new Response(null, { status: 101, webSocket, headers })` returned from the Worker are now propagated to the upgrade response sent to the browser during `vite dev`. Previously the headers were dropped, so cookies (`Set-Cookie`) and custom headers (`X-*`) on WebSocket handshake responses were invisible client-side — even though they were delivered correctly by `wrangler dev`. diff --git a/.changeset/workflows-restart-from-step-cli.md b/.changeset/workflows-restart-from-step-cli.md deleted file mode 100644 index 8ad7d03dc2..0000000000 --- a/.changeset/workflows-restart-from-step-cli.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": minor ---- - -Add restart-from-step options to `wrangler workflows instances restart` - -You can now restart a Workflow instance from a specific step using `--from-step-name`, with optional `--from-step-count` and `--from-step-type` disambiguation. These options work for both remote Workflow instances and local `wrangler dev --local` sessions. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 79a613d04d..725304f850 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,16 @@ # @cloudflare/cli +## 0.1.6 + +### Patch Changes + +- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions + + Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. + +- Updated dependencies [[`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5)]: + - @cloudflare/workers-utils@0.22.1 + ## 0.1.5 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 60afad5992..f31338a76f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/cli-shared-helpers", - "version": "0.1.5", + "version": "0.1.6", "description": "Internal shared CLI helpers for workers-sdk. Not intended for external use — APIs may change without notice.", "keywords": [ "cli", diff --git a/packages/create-cloudflare/CHANGELOG.md b/packages/create-cloudflare/CHANGELOG.md index 022f82eeda..b4b978d65c 100644 --- a/packages/create-cloudflare/CHANGELOG.md +++ b/packages/create-cloudflare/CHANGELOG.md @@ -1,5 +1,39 @@ # create-cloudflare +## 2.70.0 + +### Minor Changes + +- [#14095](https://github.com/cloudflare/workers-sdk/pull/14095) [`8b4e917`](https://github.com/cloudflare/workers-sdk/commit/8b4e9174a496ede02b97ed81779d1e3f450b7d53) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Hide non-framework categories when `--platform=pages` is specified + + When running C3 with `--platform=pages`, the "Hello World example" and "Application Starter" categories are now hidden since they only produce Workers projects. The framework list is also filtered to only show frameworks that support the Pages platform. This makes it clear that C3 can only create Pages projects when using a framework. + +### Patch Changes + +- [#14128](https://github.com/cloudflare/workers-sdk/pull/14128) [`7868998`](https://github.com/cloudflare/workers-sdk/commit/7868998b047e77b71ff58dabd448434e3612a70b) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "create-cloudflare" + + The following dependency versions have been updated: + + | Dependency | From | To | + | --------------- | ------- | ------- | + | @angular/create | 21.2.12 | 21.2.13 | + +- [#14129](https://github.com/cloudflare/workers-sdk/pull/14129) [`fe97ff8`](https://github.com/cloudflare/workers-sdk/commit/fe97ff8e8e5c74a03cf040c4fefb425f0cc59467) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "create-cloudflare" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ------------------- | ------ | ------ | + | create-react-router | 7.15.1 | 7.16.0 | + +- [#14113](https://github.com/cloudflare/workers-sdk/pull/14113) [`063d98e`](https://github.com/cloudflare/workers-sdk/commit/063d98e96e39a4e08cad6d6bccf4f382bc654967) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Switch the `react-router` template to scaffold from the upstream `create-react-router` default template and overlay Cloudflare-specific files locally + + Previously, C3 invoked `create-react-router` with `--template ` pointing at a specific commit of `remix-run/react-router-templates/cloudflare`. This pinning was needed because the upstream Cloudflare template had been deleted before, leaving us reliant on a third-party source we don't control. + + We now invoke `create-react-router` without `--template` (using the upstream default template) and overlay all Cloudflare-specific files — `workers/app.ts`, `wrangler.jsonc`, split `tsconfig`s, a Cloudflare-flavored `vite.config.ts`, `entry.server.tsx`, etc. — from `templates/react-router/ts/`. A `configure` step deletes `Dockerfile`/`.dockerignore` and the `@react-router/node`/`@react-router/serve` dependencies and `start` script that ship with the default template. + + This brings the `react-router` template in line with how `astro`, `svelte`, and `react` already work and removes our dependency on a deleted upstream template. The scaffolded project is functionally equivalent to before. + ## 2.69.0 ### Minor Changes diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index 0050c602cb..0b669d2e27 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "create-cloudflare", - "version": "2.69.0", + "version": "2.70.0", "description": "A CLI for creating and deploying new applications to Cloudflare.", "keywords": [ "cloudflare", diff --git a/packages/deploy-helpers/CHANGELOG.md b/packages/deploy-helpers/CHANGELOG.md index 9615b76541..d34b21d622 100644 --- a/packages/deploy-helpers/CHANGELOG.md +++ b/packages/deploy-helpers/CHANGELOG.md @@ -1,5 +1,13 @@ # @cloudflare/deploy-helpers +## 0.1.1 + +### Patch Changes + +- [#14063](https://github.com/cloudflare/workers-sdk/pull/14063) [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5) Thanks [@emily-shen](https://github.com/emily-shen)! - Move fetch helpers into `@cloudflare/workers-utils` + + Shared Cloudflare API fetch helper types and plumbing now live in `@cloudflare/workers-utils` so Wrangler and other clients can use the same implementation. + ## 0.1.0 ### Minor Changes diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index d357649b64..9af73670b6 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/deploy-helpers", - "version": "0.1.0", + "version": "0.1.1", "description": "Internal deploy helpers for workers-sdk. Not intended for external use — APIs may change without notice.", "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/deploy-helpers#readme", "bugs": { diff --git a/packages/miniflare/CHANGELOG.md b/packages/miniflare/CHANGELOG.md index 81b886d2b4..8a9811146c 100644 --- a/packages/miniflare/CHANGELOG.md +++ b/packages/miniflare/CHANGELOG.md @@ -1,5 +1,31 @@ # miniflare +## 4.20260601.0 + +### Patch Changes + +- [#14147](https://github.com/cloudflare/workers-sdk/pull/14147) [`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ---------- | ------------ | ------------ | + | workerd | 1.20260529.1 | 1.20260601.1 | + +- [#14086](https://github.com/cloudflare/workers-sdk/pull/14086) [`4ef790b`](https://github.com/cloudflare/workers-sdk/commit/4ef790b3ee22389db29c64f49564aac28022e40e) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Use `127.0.0.1` instead of `localhost` for the runtime inspector address + + On systems where `getaddrinfo("localhost")` returns `::1` but IPv6 is disabled at the kernel level, `workerd` fails to bind the inspector socket and silently continues without emitting the `listen-inspector` event to the control FD. This caused `wrangler dev` to hang indefinitely at "Starting local server..." with no error output. + + Using `127.0.0.1` explicitly is consistent with `DEFAULT_HOST`, `--debug-port`, and `resolveLocalhost()` already in the codebase. + +- [#14105](https://github.com/cloudflare/workers-sdk/pull/14105) [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Remove trailing periods from URLs in terminal output + + URLs printed to the terminal with a sentence-ending period (e.g. `https://example.com/path.`) would include the period when clicked in some terminal emulators, causing 404 errors. This removes trailing periods from all URLs displayed in CLI output across wrangler, miniflare, vitest-pool-workers, and workers-utils. + +- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions + + Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. + ## 4.20260529.0 ### Minor Changes diff --git a/packages/miniflare/package.json b/packages/miniflare/package.json index 3453821b39..47547a0d61 100644 --- a/packages/miniflare/package.json +++ b/packages/miniflare/package.json @@ -1,6 +1,6 @@ { "name": "miniflare", - "version": "4.20260529.0", + "version": "4.20260601.0", "description": "Fun, full-featured, fully-local simulator for Cloudflare Workers", "keywords": [ "cloudflare", diff --git a/packages/pages-shared/CHANGELOG.md b/packages/pages-shared/CHANGELOG.md index 829278781b..b053867083 100644 --- a/packages/pages-shared/CHANGELOG.md +++ b/packages/pages-shared/CHANGELOG.md @@ -1,5 +1,12 @@ # @cloudflare/pages-shared +## 0.13.142 + +### Patch Changes + +- Updated dependencies [[`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943), [`4ef790b`](https://github.com/cloudflare/workers-sdk/commit/4ef790b3ee22389db29c64f49564aac28022e40e), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6)]: + - miniflare@4.20260601.0 + ## 0.13.141 ### Patch Changes diff --git a/packages/pages-shared/package.json b/packages/pages-shared/package.json index 03f6c7f74f..dd24edf6e3 100644 --- a/packages/pages-shared/package.json +++ b/packages/pages-shared/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/pages-shared", - "version": "0.13.141", + "version": "0.13.142", "repository": { "type": "git", "url": "https://github.com/cloudflare/workers-sdk.git", diff --git a/packages/vite-plugin-cloudflare/CHANGELOG.md b/packages/vite-plugin-cloudflare/CHANGELOG.md index 91de82865e..04f434280d 100644 --- a/packages/vite-plugin-cloudflare/CHANGELOG.md +++ b/packages/vite-plugin-cloudflare/CHANGELOG.md @@ -1,5 +1,21 @@ # @cloudflare/vite-plugin +## 1.39.2 + +### Patch Changes + +- [#13893](https://github.com/cloudflare/workers-sdk/pull/13893) [`d8a16e7`](https://github.com/cloudflare/workers-sdk/commit/d8a16e7ff2de6f912a8f3148d464b56cf0cb6f93) Thanks [@penalosa](https://github.com/penalosa)! - Add an experimental, internal `cf-vite` delegate binary + + This adds an experimental `bin/cf-vite` binary that is spawned by Cloudflare's own parent tooling to drive the plugin as a long-running dev-server subprocess. It is not part of the plugin's public API surface, is not intended to be invoked directly, and its contract may change at any time without notice. + +- [#14117](https://github.com/cloudflare/workers-sdk/pull/14117) [`3c86121`](https://github.com/cloudflare/workers-sdk/commit/3c8612140b4beafeff03bd3bcf3aee37f32014f4) Thanks [@aicayzer](https://github.com/aicayzer)! - Forward response headers from the Worker on WebSocket upgrade responses + + Headers set on a `new Response(null, { status: 101, webSocket, headers })` returned from the Worker are now propagated to the upgrade response sent to the browser during `vite dev`. Previously the headers were dropped, so cookies (`Set-Cookie`) and custom headers (`X-*`) on WebSocket handshake responses were invisible client-side — even though they were delivered correctly by `wrangler dev`. + +- Updated dependencies [[`b210c5e`](https://github.com/cloudflare/workers-sdk/commit/b210c5eefdb22d83f937728527bc0091f9308070), [`aec1bb8`](https://github.com/cloudflare/workers-sdk/commit/aec1bb826aaba963bfc1ee96ba7359e284162bfa), [`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943), [`9a26191`](https://github.com/cloudflare/workers-sdk/commit/9a26191e1a8c4246f7999bdb3637a176b9166207), [`5565823`](https://github.com/cloudflare/workers-sdk/commit/5565823854b60937fcad7162425fcd9fad64558a), [`4ef790b`](https://github.com/cloudflare/workers-sdk/commit/4ef790b3ee22389db29c64f49564aac28022e40e), [`890fca7`](https://github.com/cloudflare/workers-sdk/commit/890fca7d63a6efab5a58e4829cf02bf731eab197), [`6fc9777`](https://github.com/cloudflare/workers-sdk/commit/6fc97775d688ab6b65c40cad1c403bb04346d77e), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`8e7b74f`](https://github.com/cloudflare/workers-sdk/commit/8e7b74fa837dc7b67c4affab1d4b28876ce4d3f2), [`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`42288d4`](https://github.com/cloudflare/workers-sdk/commit/42288d4886b7b7a516f5bcca6924a706201aa1e8), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5), [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6), [`64ef9fd`](https://github.com/cloudflare/workers-sdk/commit/64ef9fd46eeb590813bb8cbc61b58c407452362e), [`94b29f7`](https://github.com/cloudflare/workers-sdk/commit/94b29f76c6c6543c2504fb9d1967f15a3bad530d)]: + - wrangler@4.97.0 + - miniflare@4.20260601.0 + ## 1.39.1 ### Patch Changes diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index 05c4ac1059..59bcad0cdf 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/vite-plugin", - "version": "1.39.1", + "version": "1.39.2", "description": "Cloudflare plugin for Vite", "keywords": [ "cloudflare", diff --git a/packages/vitest-pool-workers/CHANGELOG.md b/packages/vitest-pool-workers/CHANGELOG.md index d6a5307fc3..f5e982f506 100644 --- a/packages/vitest-pool-workers/CHANGELOG.md +++ b/packages/vitest-pool-workers/CHANGELOG.md @@ -1,5 +1,31 @@ # @cloudflare/vitest-pool-workers +## 0.16.12 + +### Patch Changes + +- [#14152](https://github.com/cloudflare/workers-sdk/pull/14152) [`3d7992e`](https://github.com/cloudflare/workers-sdk/commit/3d7992e6ac69c6572449b1c1f74354cfdeeaa1ad) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix module resolution failing when project path contains spaces + + When a project lived under a directory with spaces (e.g. `/Users/me/Documents/Master CMS/project`), the vitest pool would fail with `No such module "threads.js"` before any test executed. The module fallback service now uses the `rawSpecifier` from workerd's fallback request to correctly decode `file://` URLs, avoiding the double-encoding of spaces (`%20` → `%2520`) that occurred when workerd resolved these URLs as relative paths. + +- [#14105](https://github.com/cloudflare/workers-sdk/pull/14105) [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Remove trailing periods from URLs in terminal output + + URLs printed to the terminal with a sentence-ending period (e.g. `https://example.com/path.`) would include the period when clicked in some terminal emulators, causing 404 errors. This removes trailing periods from all URLs displayed in CLI output across wrangler, miniflare, vitest-pool-workers, and workers-utils. + +- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions + + Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. + +- [#14061](https://github.com/cloudflare/workers-sdk/pull/14061) [`da8e306`](https://github.com/cloudflare/workers-sdk/commit/da8e306153843c6f42508bf7fe7737e91ac67241) Thanks [@Vardiak](https://github.com/Vardiak)! - Preserve Durable Object WebSocket handler invocation order + + Durable Object WebSocket events could begin executing out of order in the Workers Vitest integration when several events arrived while the test wrapper was resolving user code. + + Handler invocation now preserves arrival order while still allowing asynchronous handler completion to run concurrently. + +- Updated dependencies [[`b210c5e`](https://github.com/cloudflare/workers-sdk/commit/b210c5eefdb22d83f937728527bc0091f9308070), [`aec1bb8`](https://github.com/cloudflare/workers-sdk/commit/aec1bb826aaba963bfc1ee96ba7359e284162bfa), [`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943), [`9a26191`](https://github.com/cloudflare/workers-sdk/commit/9a26191e1a8c4246f7999bdb3637a176b9166207), [`5565823`](https://github.com/cloudflare/workers-sdk/commit/5565823854b60937fcad7162425fcd9fad64558a), [`4ef790b`](https://github.com/cloudflare/workers-sdk/commit/4ef790b3ee22389db29c64f49564aac28022e40e), [`890fca7`](https://github.com/cloudflare/workers-sdk/commit/890fca7d63a6efab5a58e4829cf02bf731eab197), [`6fc9777`](https://github.com/cloudflare/workers-sdk/commit/6fc97775d688ab6b65c40cad1c403bb04346d77e), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`8e7b74f`](https://github.com/cloudflare/workers-sdk/commit/8e7b74fa837dc7b67c4affab1d4b28876ce4d3f2), [`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`42288d4`](https://github.com/cloudflare/workers-sdk/commit/42288d4886b7b7a516f5bcca6924a706201aa1e8), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5), [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6), [`64ef9fd`](https://github.com/cloudflare/workers-sdk/commit/64ef9fd46eeb590813bb8cbc61b58c407452362e), [`94b29f7`](https://github.com/cloudflare/workers-sdk/commit/94b29f76c6c6543c2504fb9d1967f15a3bad530d)]: + - wrangler@4.97.0 + - miniflare@4.20260601.0 + ## 0.16.11 ### Patch Changes diff --git a/packages/vitest-pool-workers/package.json b/packages/vitest-pool-workers/package.json index b1f89800f1..dec10d6980 100644 --- a/packages/vitest-pool-workers/package.json +++ b/packages/vitest-pool-workers/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/vitest-pool-workers", - "version": "0.16.11", + "version": "0.16.12", "description": "Workers Vitest integration for writing Vitest unit and integration tests that run inside the Workers runtime", "keywords": [ "cloudflare", diff --git a/packages/workers-editor-shared/CHANGELOG.md b/packages/workers-editor-shared/CHANGELOG.md index 3cdc2ad579..4a5e9b4a12 100644 --- a/packages/workers-editor-shared/CHANGELOG.md +++ b/packages/workers-editor-shared/CHANGELOG.md @@ -1,5 +1,13 @@ # @cloudflare/workers-editor-shared +## 0.1.2 + +### Patch Changes + +- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions + + Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. + ## 0.1.1 ### Patch Changes diff --git a/packages/workers-editor-shared/package.json b/packages/workers-editor-shared/package.json index 2b257b32e4..7232a35a59 100644 --- a/packages/workers-editor-shared/package.json +++ b/packages/workers-editor-shared/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-editor-shared", - "version": "0.1.1", + "version": "0.1.2", "description": "Utilities for the Cloudflare Workers online editing experience", "keywords": [ "cloudflare", diff --git a/packages/workers-playground/CHANGELOG.md b/packages/workers-playground/CHANGELOG.md index 9fffd4c43a..5c68998222 100644 --- a/packages/workers-playground/CHANGELOG.md +++ b/packages/workers-playground/CHANGELOG.md @@ -1,5 +1,12 @@ # workers-playground +## 0.4.4 + +### Patch Changes + +- Updated dependencies [[`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6)]: + - @cloudflare/workers-editor-shared@0.1.2 + ## 0.4.3 ### Patch Changes diff --git a/packages/workers-playground/package.json b/packages/workers-playground/package.json index 82df58515f..7d8ed5c780 100644 --- a/packages/workers-playground/package.json +++ b/packages/workers-playground/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-playground", - "version": "0.4.3", + "version": "0.4.4", "private": true, "type": "module", "scripts": { diff --git a/packages/workers-utils/CHANGELOG.md b/packages/workers-utils/CHANGELOG.md index 4952b3779b..086a5d50cf 100644 --- a/packages/workers-utils/CHANGELOG.md +++ b/packages/workers-utils/CHANGELOG.md @@ -1,5 +1,21 @@ # @cloudflare/workers-utils +## 0.22.1 + +### Patch Changes + +- [#14084](https://github.com/cloudflare/workers-sdk/pull/14084) [`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Correctly map JSON bindings in `mapWorkerMetadataBindings` + + The `json` binding case used literal keys `name` and `json` instead of a computed property key `[binding.name]: binding.json`. This caused JSON bindings to always produce `{ name: "", json: }` instead of `{ : }`, clobbering any existing vars with those keys. This is now consistent with how `plain_text` bindings are mapped. + +- [#14105](https://github.com/cloudflare/workers-sdk/pull/14105) [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Remove trailing periods from URLs in terminal output + + URLs printed to the terminal with a sentence-ending period (e.g. `https://example.com/path.`) would include the period when clicked in some terminal emulators, causing 404 errors. This removes trailing periods from all URLs displayed in CLI output across wrangler, miniflare, vitest-pool-workers, and workers-utils. + +- [#14063](https://github.com/cloudflare/workers-sdk/pull/14063) [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5) Thanks [@emily-shen](https://github.com/emily-shen)! - Move fetch helpers into `@cloudflare/workers-utils` + + Shared Cloudflare API fetch helper types and plumbing now live in `@cloudflare/workers-utils` so Wrangler and other clients can use the same implementation. + ## 0.22.0 ### Minor Changes diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index 3c83df5ca1..6ad7ec8aee 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-utils", - "version": "0.22.0", + "version": "0.22.1", "description": "Internal utility package for workers-sdk. Not intended for external use — APIs may change without notice.", "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/workers-utils#readme", "bugs": { diff --git a/packages/wrangler-bundler/CHANGELOG.md b/packages/wrangler-bundler/CHANGELOG.md index 37d78ae72f..e27cc4147d 100644 --- a/packages/wrangler-bundler/CHANGELOG.md +++ b/packages/wrangler-bundler/CHANGELOG.md @@ -1,5 +1,12 @@ # @cloudflare/wrangler-bundler +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`b210c5e`](https://github.com/cloudflare/workers-sdk/commit/b210c5eefdb22d83f937728527bc0091f9308070), [`aec1bb8`](https://github.com/cloudflare/workers-sdk/commit/aec1bb826aaba963bfc1ee96ba7359e284162bfa), [`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943), [`9a26191`](https://github.com/cloudflare/workers-sdk/commit/9a26191e1a8c4246f7999bdb3637a176b9166207), [`5565823`](https://github.com/cloudflare/workers-sdk/commit/5565823854b60937fcad7162425fcd9fad64558a), [`890fca7`](https://github.com/cloudflare/workers-sdk/commit/890fca7d63a6efab5a58e4829cf02bf731eab197), [`6fc9777`](https://github.com/cloudflare/workers-sdk/commit/6fc97775d688ab6b65c40cad1c403bb04346d77e), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`8e7b74f`](https://github.com/cloudflare/workers-sdk/commit/8e7b74fa837dc7b67c4affab1d4b28876ce4d3f2), [`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`42288d4`](https://github.com/cloudflare/workers-sdk/commit/42288d4886b7b7a516f5bcca6924a706201aa1e8), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5), [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6), [`64ef9fd`](https://github.com/cloudflare/workers-sdk/commit/64ef9fd46eeb590813bb8cbc61b58c407452362e), [`94b29f7`](https://github.com/cloudflare/workers-sdk/commit/94b29f76c6c6543c2504fb9d1967f15a3bad530d)]: + - wrangler@4.97.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/wrangler-bundler/package.json b/packages/wrangler-bundler/package.json index 7f6cc6d7e2..de6a3e0f5d 100644 --- a/packages/wrangler-bundler/package.json +++ b/packages/wrangler-bundler/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/wrangler-bundler", - "version": "0.1.0", + "version": "0.1.1", "description": "esbuild-based dev server for Cloudflare Workers, extracted from `wrangler dev`", "license": "MIT OR Apache-2.0", "repository": { diff --git a/packages/wrangler/CHANGELOG.md b/packages/wrangler/CHANGELOG.md index f92e0ba116..c21ee9721c 100644 --- a/packages/wrangler/CHANGELOG.md +++ b/packages/wrangler/CHANGELOG.md @@ -1,5 +1,89 @@ # wrangler +## 4.97.0 + +### Minor Changes + +- [#13996](https://github.com/cloudflare/workers-sdk/pull/13996) [`94b29f7`](https://github.com/cloudflare/workers-sdk/commit/94b29f76c6c6543c2504fb9d1967f15a3bad530d) Thanks [@vaishnav-mk](https://github.com/vaishnav-mk)! - Add restart-from-step options to `wrangler workflows instances restart` + + You can now restart a Workflow instance from a specific step using `--from-step-name`, with optional `--from-step-count` and `--from-step-type` disambiguation. These options work for both remote Workflow instances and local `wrangler dev --local` sessions. + +### Patch Changes + +- [#14141](https://github.com/cloudflare/workers-sdk/pull/14141) [`b210c5e`](https://github.com/cloudflare/workers-sdk/commit/b210c5eefdb22d83f937728527bc0091f9308070) Thanks [@MattieTK](https://github.com/MattieTK)! - Add re-authentication hint to account fetch error messages + + When Wrangler fails to automatically retrieve account IDs, the error messages now suggest running `wrangler login` as a troubleshooting step. This addresses confusion for users who encounter these errors after OAuth system changes or other authentication issues. + +- [#14078](https://github.com/cloudflare/workers-sdk/pull/14078) [`aec1bb8`](https://github.com/cloudflare/workers-sdk/commit/aec1bb826aaba963bfc1ee96ba7359e284162bfa) Thanks [@MattieTK](https://github.com/MattieTK)! - Bump `am-i-vibing` from 0.1.1 to 0.4.0 + + This updates the agentic environment detection library to the latest version, which includes improved detection coverage for newer AI coding agents. + +- [#14147](https://github.com/cloudflare/workers-sdk/pull/14147) [`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ---------- | ------------ | ------------ | + | workerd | 1.20260529.1 | 1.20260601.1 | + +- [#14027](https://github.com/cloudflare/workers-sdk/pull/14027) [`9a26191`](https://github.com/cloudflare/workers-sdk/commit/9a26191e1a8c4246f7999bdb3637a176b9166207) Thanks [@matingathani](https://github.com/matingathani)! - Gracefully handle EMFILE error when assets directory exceeds OS watcher limit + + Previously, when `wrangler dev` was pointed at an assets directory with more than ~4,096 subdirectories, the chokidar file watcher threw an `EMFILE: too many open files` error that was not caught, causing an infinite error loop that made the dev server unresponsive. + + Now the error is caught and wrangler: + + 1. Logs a clear warning explaining the platform watcher limit was hit + 2. Recommends reducing the number of subdirectories by flattening or restructuring the assets directory + 3. Disables the assets watcher gracefully so the dev server continues working without hot-reload + +- [#14041](https://github.com/cloudflare/workers-sdk/pull/14041) [`5565823`](https://github.com/cloudflare/workers-sdk/commit/5565823854b60937fcad7162425fcd9fad64558a) Thanks [@matingathani](https://github.com/matingathani)! - Fix `wrangler complete` printing the AI skills prompt into shell completion output + + Previously, running `eval "$(wrangler complete zsh)"` (or any other shell) would fail with errors like `zsh: command not found: --install-skills` because the interactive AI agent skills installation prompt was included in the completion script output. + + The skills prompt is now skipped when running `wrangler complete`, so the generated completion script is clean and can be sourced correctly. + +- [#13881](https://github.com/cloudflare/workers-sdk/pull/13881) [`890fca7`](https://github.com/cloudflare/workers-sdk/commit/890fca7d63a6efab5a58e4829cf02bf731eab197) Thanks [@matingathani](https://github.com/matingathani)! - Show a clear error when `--metadata` is not valid JSON instead of silently ignoring the value + +- [#14149](https://github.com/cloudflare/workers-sdk/pull/14149) [`6fc9777`](https://github.com/cloudflare/workers-sdk/commit/6fc97775d688ab6b65c40cad1c403bb04346d77e) Thanks [@mattjohnsonpint](https://github.com/mattjohnsonpint)! - Fix `wrangler deploy --upload-source-maps` silently skipping source maps when the entry file ends with magic comments after `//# sourceMappingURL=` + + Wrangler previously assumed the `//# sourceMappingURL=` comment was the last non-empty line of a module. Tools like `sentry-cli sourcemaps inject` append a `//# debugId=` comment after it, which silently caused source maps to be omitted from the upload form, most commonly when deploying with `--no-bundle --upload-source-maps`. Wrangler now scans trailing magic comments (lines starting with `//#` or `//@`) and detects the `//# sourceMappingURL=` comment regardless of which other magic comments follow it. + +- [#14105](https://github.com/cloudflare/workers-sdk/pull/14105) [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Remove trailing periods from URLs in terminal output + + URLs printed to the terminal with a sentence-ending period (e.g. `https://example.com/path.`) would include the period when clicked in some terminal emulators, causing 404 errors. This removes trailing periods from all URLs displayed in CLI output across wrangler, miniflare, vitest-pool-workers, and workers-utils. + +- [#14150](https://github.com/cloudflare/workers-sdk/pull/14150) [`8e7b74f`](https://github.com/cloudflare/workers-sdk/commit/8e7b74fa837dc7b67c4affab1d4b28876ce4d3f2) Thanks [@avenceslau](https://github.com/avenceslau)! - Fix Workflows `schedules` deploy payload to match the control plane API + + When deploying a Workflow with a `schedules` binding property, Wrangler sent the cron expressions as a list of strings. The Workflows API expects a list of objects of the form `{ cron: string }`, so the request was rejected. Wrangler now maps each configured cron expression to `{ cron }` (normalizing a single string or an array) when building the request. The user-facing config still accepts a string or an array of strings. + +- [#14084](https://github.com/cloudflare/workers-sdk/pull/14084) [`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Fix JSON variable bindings in `wrangler init --from-dash` and remote config diff + + When fetching a remote Worker's configuration, JSON variable bindings (e.g. `{"my_value": 5}`) were incorrectly serialized as `{ "name": "MY_JSON", "json": {"my_value": 5} }` instead of `{ "MY_JSON": {"my_value": 5} }`. This affected two areas: + + - `wrangler init --from-dash` would generate a `wrangler.json` with broken `vars` entries + - Remote config diff checks would always report JSON bindings as changed, since the malformed remote representation could never match the local config + + Both issues are now fixed and remote JSON bindings are now correctly mapped. + +- [#14155](https://github.com/cloudflare/workers-sdk/pull/14155) [`42288d4`](https://github.com/cloudflare/workers-sdk/commit/42288d4886b7b7a516f5bcca6924a706201aa1e8) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Include agent skill installation status in all telemetry events + + The agent skill installation status is now consistently included in all telemetry events, not just a subset of them. + +- [#14063](https://github.com/cloudflare/workers-sdk/pull/14063) [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5) Thanks [@emily-shen](https://github.com/emily-shen)! - Move fetch helpers into `@cloudflare/workers-utils` + + Shared Cloudflare API fetch helper types and plumbing now live in `@cloudflare/workers-utils` so Wrangler and other clients can use the same implementation. + +- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions + + Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. + +- [#14124](https://github.com/cloudflare/workers-sdk/pull/14124) [`64ef9fd`](https://github.com/cloudflare/workers-sdk/commit/64ef9fd46eeb590813bb8cbc61b58c407452362e) Thanks [@odiak](https://github.com/odiak)! - Fix `wrangler secret bulk` dropping newlines from `.env` input read from stdin + + Previously, `.env` input piped through stdin was concatenated without line breaks, so only the first secret could be parsed correctly. Stdin input now preserves line separators before parsing. + +- Updated dependencies [[`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943), [`4ef790b`](https://github.com/cloudflare/workers-sdk/commit/4ef790b3ee22389db29c64f49564aac28022e40e), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6)]: + - miniflare@4.20260601.0 + ## 4.96.0 ### Minor Changes diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 0c40000ddf..c75134791e 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -1,6 +1,6 @@ { "name": "wrangler", - "version": "4.96.0", + "version": "4.97.0", "description": "Command-line interface for all things Cloudflare Workers", "keywords": [ "assembly", From 3b8b80ab32e3ac33b5df9f6944dca9cdf72c5495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aslak=20Helles=C3=B8y?= <1000+aslakhellesoy@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:41:27 +0100 Subject: [PATCH 4/6] feat(miniflare,wrangler): cross-worker workflow bindings via dev registry (#13863) --- ...getplatformproxy-cross-script-workflows.md | 11 ++++ .changeset/workflow-cross-worker-bindings.md | 13 ++++ .../tests/get-platform-proxy.env.test.ts | 16 +++-- packages/miniflare/src/index.ts | 25 +++++++ .../miniflare/src/plugins/workflows/index.ts | 36 ++++++++-- packages/miniflare/test/dev-registry.spec.ts | 66 +++++++++++++++++++ .../src/api/integrations/platform/index.ts | 26 ++++++-- 7 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 .changeset/getplatformproxy-cross-script-workflows.md create mode 100644 .changeset/workflow-cross-worker-bindings.md diff --git a/.changeset/getplatformproxy-cross-script-workflows.md b/.changeset/getplatformproxy-cross-script-workflows.md new file mode 100644 index 0000000000..ba314e21f4 --- /dev/null +++ b/.changeset/getplatformproxy-cross-script-workflows.md @@ -0,0 +1,11 @@ +--- +"wrangler": minor +--- + +`getPlatformProxy()` now passes through workflow bindings that have a `script_name` + +Workflows without a `script_name` are still stripped (and warned about) because the engine for an internal workflow can't run inside the empty proxy worker that backs `getPlatformProxy()`. Workflows with a `script_name` are handed to miniflare unchanged; miniflare reroutes the engine's `USER_WORKFLOW` binding through the dev-registry-proxy when the target worker is running in another Miniflare instance — the same mechanism Durable Objects already use. + +This means SvelteKit/Remix (and similar split-process setups) can call `platform.env.MY_WORKFLOW.create({ ... })` directly from their server-side request handlers in dev, as long as the workflow class is exposed by another worker registered in the dev registry. + +Closes [#7459](https://github.com/cloudflare/workers-sdk/issues/7459). diff --git a/.changeset/workflow-cross-worker-bindings.md b/.changeset/workflow-cross-worker-bindings.md new file mode 100644 index 0000000000..d73760aadf --- /dev/null +++ b/.changeset/workflow-cross-worker-bindings.md @@ -0,0 +1,13 @@ +--- +"miniflare": minor +--- + +Support cross-worker workflow bindings via the dev registry + +When a workflow binding has a `scriptName` that refers to a worker registered in another Miniflare instance (via `unsafeDevRegistryPath`), miniflare now reroutes the engine's `USER_WORKFLOW` binding through the dev-registry-proxy worker — the same mechanism Durable Objects already use for cross-worker `scriptName` bindings. + +Previously the workflow engine was bound directly to a local service `core:user:`, so workerd refused to start when that script lived in a different process. + +This unblocks `getPlatformProxy()` (and any other split-Miniflare setup) for users whose workflow class is defined in a separate worker — for example SvelteKit/Remix on Cloudflare, where `adapter-cloudflare`'s dev integration runs the user's worker in a sidecar. + +See [#7459](https://github.com/cloudflare/workers-sdk/issues/7459). diff --git a/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts b/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts index 81bbc0e1e6..a6e02e0feb 100644 --- a/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts +++ b/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts @@ -293,16 +293,22 @@ describe("getPlatformProxy - env", () => { expect(warn).not.toHaveBeenCalled(); }); - it("warns about Workflows and doesn't crash", async ({ expect }) => { + it("warns only about internal Workflows, not cross-script ones, and doesn't crash", async ({ + expect, + }) => { await getPlatformProxy({ configPath: path.join(__dirname, "..", "wrangler_workflow.jsonc"), }); + // Only MY_WORKFLOW_INTERNAL (no script_name) is unsupported in + // getPlatformProxy(); MY_WORKFLOW_EXTERNAL (script_name set) is + // passed through to miniflare, which routes it via the dev-registry + // proxy to the named worker. + expect(warn).toHaveBeenCalledTimes(1); expect(warn.mock.calls[0][0].replaceAll(/[\r\n]+/g, "\n")) .toMatchInlineSnapshot(` - "▲ [WARNING]  You have defined bindings to the following Workflows: - - {"binding":"MY_WORKFLOW_INTERNAL","name":"my-workflow-internal","class_name":"MyWorkflowInternal"} - - {"binding":"MY_WORKFLOW_EXTERNAL","name":"my-workflow-external","class_name":"MyWorkflowExternal","script_name":"OtherWorker"} - These are not available in local development, so you will not be able to bind to them when testing locally, but they should work in production. + "▲ [WARNING] You have defined bindings to the following Workflows without a script_name: + - {"binding":"MY_WORKFLOW_INTERNAL","name":"my-workflow-internal","class_name":"MyWorkflowInternal"} + These are not available in local development, so you will not be able to bind to them when testing locally, but they should work in production. " `); }); diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 391c4f135c..225f975af9 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -587,6 +587,31 @@ function getExternalServiceEntrypoints(allWorkerOpts: PluginWorkerOptions[]) { } } + // Cross-worker workflow bindings: when `scriptName` refers to a worker + // outside this Miniflare instance (registered in the dev registry), mark + // the workflow `external` so the workflows plugin reroutes the engine's + // USER_WORKFLOW binding through the dev-registry-proxy. Mirrors the DO + // block above. Without this, the engine binds to a non-existent local + // service `core:user:` and workerd refuses to start. + if (workerOpts.workflows.workflows) { + for (const [bindingName, workflow] of Object.entries( + workerOpts.workflows.workflows + )) { + const { scriptName, className, remoteProxyConnectionString } = workflow; + if ( + remoteProxyConnectionString === undefined && + scriptName && + !allWorkerNames.includes(scriptName) + ) { + workerOpts.workflows.workflows[bindingName] = { + ...workflow, + external: true, + }; + getEntrypoints(scriptName).entrypoints.add(className); + } + } + } + if (workerOpts.core.tails) { for (let i = 0; i < workerOpts.core.tails.length; i++) { const { diff --git a/packages/miniflare/src/plugins/workflows/index.ts b/packages/miniflare/src/plugins/workflows/index.ts index 2704cd0218..1885c11a89 100644 --- a/packages/miniflare/src/plugins/workflows/index.ts +++ b/packages/miniflare/src/plugins/workflows/index.ts @@ -8,6 +8,7 @@ import { getUserBindingServiceName, PersistenceSchema, ProxyNodeBinding, + SERVICE_DEV_REGISTRY_PROXY, } from "../shared"; import type { Service } from "../../runtime"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; @@ -19,6 +20,13 @@ export const WorkflowsOptionsSchema = z.object({ name: z.string(), className: z.string(), scriptName: z.string().optional(), + // When set, the workflow's `scriptName` refers to a worker that lives + // outside this Miniflare instance (registered in the wrangler dev + // registry). The engine's USER_WORKFLOW binding is rerouted through + // the dev-registry-proxy so calls reach the external worker. Set by + // `getExternalServiceEntrypoints` in `src/index.ts`; not part of the + // public API. + external: z.boolean().optional(), remoteProxyConnectionString: z .custom() .optional(), @@ -144,13 +152,27 @@ export const WORKFLOWS_PLUGIN: Plugin< name: "ENGINE", durableObjectNamespace: { className: "Engine" }, }, - { - name: "USER_WORKFLOW", - service: { - name: getUserServiceName(workflow.scriptName), - entrypoint: workflow.className, - }, - }, + workflow.external && workflow.scriptName + ? { + name: "USER_WORKFLOW", + service: { + name: getUserServiceName(SERVICE_DEV_REGISTRY_PROXY), + entrypoint: "ExternalServiceProxy", + props: { + json: JSON.stringify({ + service: workflow.scriptName, + entrypoint: workflow.className, + }), + }, + }, + } + : { + name: "USER_WORKFLOW", + service: { + name: getUserServiceName(workflow.scriptName), + entrypoint: workflow.className, + }, + }, { name: "BINDING_NAME", json: JSON.stringify(bindingName), diff --git a/packages/miniflare/test/dev-registry.spec.ts b/packages/miniflare/test/dev-registry.spec.ts index 660699011a..fe52f2e8f2 100644 --- a/packages/miniflare/test/dev-registry.spec.ts +++ b/packages/miniflare/test/dev-registry.spec.ts @@ -845,6 +845,72 @@ describe.sequential("DevRegistry", () => { ); }); + test("workflow with cross-worker scriptName", async ({ expect }) => { + const unsafeDevRegistryPath = await useTmp(); + const local = new Miniflare({ + name: "local-worker", + unsafeDevRegistryPath, + + workflows: { + MY_WORKFLOW: { + name: "MY_WORKFLOW", + className: "MyWorkflow", + scriptName: "remote-worker", + }, + }, + compatibilityDate: "2024-11-20", + modules: true, + script: ` + export default { + async fetch(request, env, ctx) { + const instance = await env.MY_WORKFLOW.create({ id: "cross-worker-instance" }); + return Response.json({ id: instance.id }); + } + } + `, + }); + useDispose(local); + + const remote = new Miniflare({ + name: "remote-worker", + unsafeDevRegistryPath, + + workflows: { + MY_WORKFLOW: { + name: "MY_WORKFLOW", + className: "MyWorkflow", + }, + }, + compatibilityDate: "2024-11-20", + modules: true, + script: ` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { + return await step.do("hello", async () => "from remote workflow"); + } + } + export default { + async fetch() { return new Response("ok"); } + } + `, + }); + useDispose(remote); + + await remote.ready; + // Workflow `create()` is queued asynchronously by the engine; routing + // goes engine (local) → ExternalServiceProxy → dev registry → remote + // MyWorkflow.run. Verify the instance is created with the expected id. + await vi.waitFor( + async () => { + const res = await local.dispatchFetch("http://placeholder"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: "cross-worker-instance" }); + }, + { timeout: 10_000, interval: 100 } + ); + }); + test("DO RPC survives remote worker restart", async ({ expect }) => { const unsafeDevRegistryPath = await useTmp(); diff --git a/packages/wrangler/src/api/integrations/platform/index.ts b/packages/wrangler/src/api/integrations/platform/index.ts index 7e10bdfe27..0a2da465de 100644 --- a/packages/wrangler/src/api/integrations/platform/index.ts +++ b/packages/wrangler/src/api/integrations/platform/index.ts @@ -257,16 +257,28 @@ async function getMiniflareOptionsFromConfig(args: { } if (config.workflows?.length > 0) { - logger.warn(dedent` - You have defined bindings to the following Workflows: - ${config.workflows.map((b) => `- ${JSON.stringify(b)}`).join("\n")} + // Workflow bindings without a `script_name` aren't routable in + // `getPlatformProxy()` — the engine inside this Miniflare instance has + // nowhere to dispatch USER_WORKFLOW. Strip those (and warn). + // Cross-worker workflows (with `script_name` referring to another worker + // registered in the dev registry) are passed through; Miniflare's + // workflows plugin reroutes them via the dev-registry-proxy. + const localWorkflows = config.workflows.filter((w) => !w.script_name); + if (localWorkflows.length > 0) { + logger.warn(dedent` + You have defined bindings to the following Workflows without a script_name: + ${localWorkflows.map((b) => `- ${JSON.stringify(b)}`).join("\n")} These are not available in local development, so you will not be able to bind to them when testing locally, but they should work in production. `); - // Remove workflows from bindings to prevent Miniflare from complaining - const workflowBindings = extractBindingsOfType("workflow", bindings); - for (const workflow of workflowBindings) { - delete bindings?.[workflow.binding]; + // Remove only the local workflows from bindings. + const allWorkflowBindings = extractBindingsOfType("workflow", bindings); + const localBindingNames = new Set(localWorkflows.map((w) => w.binding)); + for (const wf of allWorkflowBindings) { + if (localBindingNames.has(wf.binding)) { + delete bindings?.[wf.binding]; + } + } } } From 89307c54c738ffab5dc1aab8f06556bb0468aea5 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:26:44 +0100 Subject: [PATCH 5/6] move triggers deploy into deploy-helpers (#14069) --- packages/deploy-helpers/package.json | 6 +- packages/deploy-helpers/scripts/deps.ts | 11 + packages/deploy-helpers/src/index.ts | 5 + packages/deploy-helpers/src/shared/types.ts | 27 + .../src/triggers/deploy.ts | 297 +++++----- .../src/triggers/publish-routes.ts | 375 ++++++++++++ .../src/triggers/queue-consumers.ts | 433 ++++++++++++++ .../src/triggers/subdomain.ts} | 54 +- packages/deploy-helpers/src/triggers/zones.ts | 101 ++++ packages/deploy-helpers/tsup.config.ts | 2 +- packages/workers-utils/src/cfetch/index.ts | 7 + packages/workers-utils/src/format-time.ts | 3 + packages/workers-utils/src/index.ts | 8 + packages/workers-utils/src/retry.ts | 45 ++ packages/workers-utils/src/route-utils.ts | 84 +++ .../wrangler/src/__tests__/deploy/helpers.ts | 5 +- .../src/__tests__/deploy/workers-dev.test.ts | 2 +- .../__tests__/helpers/mock-upload-worker.ts | 8 +- packages/wrangler/src/__tests__/zones.test.ts | 106 ++-- packages/wrangler/src/assets.ts | 2 +- .../src/core/deploy-helpers-context.ts | 37 ++ .../src/core/register-yargs-command.ts | 8 +- packages/wrangler/src/core/types.ts | 16 +- packages/wrangler/src/deploy/config-diffs.ts | 2 +- packages/wrangler/src/deploy/deploy.ts | 552 +----------------- packages/wrangler/src/deploy/index.ts | 91 +-- .../deployment-bundle/resolve-config-args.ts | 118 ++++ packages/wrangler/src/dev.ts | 4 +- .../wrangler/src/dev/create-worker-preview.ts | 5 +- packages/wrangler/src/pages/upload.ts | 5 +- packages/wrangler/src/queues/client.ts | 324 +++------- packages/wrangler/src/triggers/index.ts | 46 +- .../src/utils/useServiceEnvironments.ts | 10 + packages/wrangler/src/versions/upload.ts | 19 +- packages/wrangler/src/zones.ts | 209 +------ pnpm-lock.yaml | 13 +- 36 files changed, 1758 insertions(+), 1282 deletions(-) create mode 100644 packages/deploy-helpers/scripts/deps.ts rename packages/{wrangler => deploy-helpers}/src/triggers/deploy.ts (77%) create mode 100644 packages/deploy-helpers/src/triggers/publish-routes.ts create mode 100644 packages/deploy-helpers/src/triggers/queue-consumers.ts rename packages/{wrangler/src/routes.ts => deploy-helpers/src/triggers/subdomain.ts} (84%) create mode 100644 packages/deploy-helpers/src/triggers/zones.ts create mode 100644 packages/workers-utils/src/format-time.ts create mode 100644 packages/workers-utils/src/retry.ts create mode 100644 packages/workers-utils/src/route-utils.ts create mode 100644 packages/wrangler/src/core/deploy-helpers-context.ts create mode 100644 packages/wrangler/src/deployment-bundle/resolve-config-args.ts diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index 9af73670b6..5e44ec9bce 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -32,13 +32,17 @@ "test:ci": "vitest run", "type:tests": "tsc -p ./tests/tsconfig.json" }, + "dependencies": { + "@cloudflare/workers-utils": "workspace:*" + }, "devDependencies": { "@cloudflare/containers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", - "@cloudflare/workers-utils": "workspace:*", "@types/node": "catalog:default", + "chalk": "^5.2.0", "concurrently": "^8.2.2", "miniflare": "workspace:*", + "p-queue": "^9.0.0", "tsup": "8.3.0", "typescript": "catalog:default", "vitest": "catalog:default" diff --git a/packages/deploy-helpers/scripts/deps.ts b/packages/deploy-helpers/scripts/deps.ts new file mode 100644 index 0000000000..bdc4c68d80 --- /dev/null +++ b/packages/deploy-helpers/scripts/deps.ts @@ -0,0 +1,11 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/deploy-helpers. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Workspace package kept external so consumers share a single copy of + // workers-utils types and runtime code (e.g. ParseError instanceof checks). + "@cloudflare/workers-utils", +]; diff --git a/packages/deploy-helpers/src/index.ts b/packages/deploy-helpers/src/index.ts index a4a4e0dc33..3538db76f4 100644 --- a/packages/deploy-helpers/src/index.ts +++ b/packages/deploy-helpers/src/index.ts @@ -1 +1,6 @@ export * from "./shared/types"; +export * from "./triggers/deploy"; +export * from "./triggers/subdomain"; +export * from "./triggers/zones"; +export * from "./triggers/publish-routes"; +export * from "./triggers/queue-consumers"; diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index fb88d6b823..37c5f57f2f 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -6,6 +6,7 @@ import type { Config, EphemeralDirectory, FetchResultFetcher, + FetchListResultFetcher, Logger, Route, Entry, @@ -18,7 +19,17 @@ import type { NodeJSCompatMode } from "miniflare"; */ export type DeployHelpersContext = { fetchResult: FetchResultFetcher; + fetchListResult: FetchListResultFetcher; logger: Logger; + confirm: ( + text: string, + options?: { defaultValue?: boolean; fallbackValue?: boolean } + ) => Promise; + prompt: ( + text: string, + options?: { defaultValue?: string } + ) => Promise; + isNonInteractiveOrCI: () => boolean; }; /** @@ -122,3 +133,19 @@ export type VersionsUploadProps = SharedDeployVersionsProps & { /** CLI-only (--preview-alias), or auto-generated from CI branch name. */ previewAlias: string | undefined; }; + +export interface TriggerDeployment { + targets: string[]; + error?: Error; +} + +export type TriggerProps = { + config: Config; + accountId: string; + scriptName: string; + env: string | undefined; + crons: string[] | undefined; + routes: Route[]; + useServiceEnvironments: boolean; + firstDeploy: boolean; +}; diff --git a/packages/wrangler/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts similarity index 77% rename from packages/wrangler/src/triggers/deploy.ts rename to packages/deploy-helpers/src/triggers/deploy.ts index 117dae0684..b74fe3bb4c 100644 --- a/packages/wrangler/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -1,56 +1,36 @@ import { + formatTime, getSubdomainMixedStateCheckDisabled, + retryOnAPIFailure, UserError, } from "@cloudflare/workers-utils"; import chalk from "chalk"; import PQueue from "p-queue"; -import { fetchListResult, fetchResult } from "../cfetch"; import { - formatTime, publishCustomDomains, 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 { RouteObject } from "../deploy/deploy"; -import type { AssetsOptions, Config, Route } from "@cloudflare/workers-utils"; - -type Props = { - config: Config; - accountId: string | undefined; - name: string | undefined; - env: string | undefined; - triggers: string[] | undefined; - routes: Route[] | undefined; - useServiceEnvironments: boolean | undefined; - dryRun: boolean | undefined; - assetsOptions: AssetsOptions | undefined; - firstDeploy: boolean; -}; - -export interface TriggerDeployment { - targets: string[]; - error?: Error; -} - -export default async function triggersDeploy( - props: Props +} from "./publish-routes"; +import { updateQueueConsumers } from "./queue-consumers"; +import { getWorkersDevSubdomain } from "./subdomain"; +import { getZoneForRoute } from "./zones"; +import type { + DeployHelpersContext, + TriggerDeployment, + TriggerProps, +} from "../shared/types"; +import type { RouteObject } from "./publish-routes"; +import type { Config, Route } from "@cloudflare/workers-utils"; + +export async function triggersDeploy( + props: TriggerProps, + ctx: DeployHelpersContext ): Promise { - const { config, accountId, name: scriptName } = props; + const { config, accountId, scriptName, 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); @@ -59,41 +39,14 @@ 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 hasWorkflowsDefinedInThisScript = config.workflows.some((workflow) => @@ -108,7 +61,8 @@ export default async function triggersDeploy( workerUrl, routes, deployments, - props.firstDeploy + props.firstDeploy, + ctx ); if (!wantWorkersDev && workersDevInSync && routes.length !== 0) { @@ -145,6 +99,7 @@ export default async function triggersDeploy( const zone = await getZoneForRoute( config, { route, accountId }, + ctx, zoneIdCache ); if (!zone) { @@ -156,11 +111,13 @@ export default async function triggersDeploy( let routesInZone = zoneRoutesCache.get(zone.id); if (!routesInZone) { - routesInZone = retryOnAPIFailure(() => - fetchListResult<{ - pattern: string; - script: string; - }>(config, `/zones/${zone.id}/workers/routes`) + routesInZone = retryOnAPIFailure( + () => + ctx.fetchListResult<{ + pattern: string; + script: string; + }>(config, `/zones/${zone.id}/workers/routes`), + ctx.logger ); zoneRoutesCache.set(zone.id, routesInZone); } @@ -206,7 +163,7 @@ export default async function triggersDeploy( } if (!wantWorkersDev && hasWorkflowsDefinedInThisScript) { - await getWorkersDevSubdomain(config, accountId, { + await getWorkersDevSubdomain(config, accountId, ctx, { configPath: config.configPath, registrationContext: "workflows", }); @@ -215,12 +172,17 @@ export default async function triggersDeploy( // Update routing table for the script. if (routesOnly.length > 0) { deployments.push( - publishRoutes(config, routesOnly, { - workerUrl, - scriptName, - useServiceEnvironments, - accountId, - }).then( + publishRoutes( + config, + routesOnly, + { + workerUrl, + scriptName, + useServiceEnvironments: props.useServiceEnvironments, + accountId, + }, + ctx + ).then( () => { if (routesOnly.length > 10) { return { @@ -244,7 +206,8 @@ export default async function triggersDeploy( config, workerUrl, accountId, - customDomainsOnly + customDomainsOnly, + ctx ).catch((error) => ({ targets: [], error })) ); } @@ -252,21 +215,23 @@ 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 }))), - headers: { - "Content-Type": "application/json", - }, - }).then( - () => ({ - targets: schedules.map((trigger) => `schedule: ${trigger}`), - }), - (error) => ({ targets: [], error }) - ) + ctx + .fetchResult(config, `${workerUrl}/schedules`, { + // Note: PUT will override previous schedules on this script. + method: "PUT", + body: JSON.stringify(crons.map((cron) => ({ cron }))), + headers: { + "Content-Type": "application/json", + }, + }) + .then( + () => ({ + targets: crons.map((trigger) => `schedule: ${trigger}`), + }), + (error) => ({ targets: [], error }) + ) ); } @@ -279,15 +244,21 @@ export default async function triggersDeploy( } if (config.queues.consumers && config.queues.consumers.length) { - const updateConsumers = await updateQueueConsumers(scriptName, config); - - deployments.push(...updateConsumers); + const consumerUpdates = await updateQueueConsumers( + config, + accountId, + scriptName, + config, + ctx + ); + deployments.push(...consumerUpdates); } if (config.workflows?.length) { + // NOTE: if the user provides a script_name thats not this script (aka bounds to another worker) + // we don't want to send this worker's config. + // TODO: move this earlier. for (const workflow of config.workflows) { - // NOTE: if the user provides a script_name thats not this script (aka bounds to another worker) - // we don't want to send this worker's config. if (!isWorkflowDefinedInThisScript(workflow, scriptName)) { if (workflow.limits) { throw new UserError( @@ -313,30 +284,32 @@ export default async function triggersDeploy( } deployments.push( - fetchResult( - config, - `/accounts/${accountId}/workflows/${workflow.name}`, - { - method: "PUT", - body: JSON.stringify({ - script_name: scriptName, - class_name: workflow.class_name, - ...(workflow.limits && { limits: workflow.limits }), - ...(workflow.schedules && { - schedules: (Array.isArray(workflow.schedules) - ? workflow.schedules - : [workflow.schedules] - ).map((cron) => ({ cron })), + ctx + .fetchResult( + config, + `/accounts/${accountId}/workflows/${workflow.name}`, + { + method: "PUT", + body: JSON.stringify({ + script_name: scriptName, + class_name: workflow.class_name, + ...(workflow.limits && { limits: workflow.limits }), + ...(workflow.schedules && { + schedules: (Array.isArray(workflow.schedules) + ? workflow.schedules + : [workflow.schedules] + ).map((cron) => ({ cron })), + }), }), - }), - headers: { - "Content-Type": "application/json", - }, - } - ).then( - () => ({ targets: [`workflow: ${workflow.name}`] }), - (error) => ({ targets: [], error }) - ) + headers: { + "Content-Type": "application/json", + }, + } + ) + .then( + () => ({ targets: [`workflow: ${workflow.name}`] }), + (error) => ({ targets: [], error }) + ) ); } } @@ -344,6 +317,10 @@ export default async function triggersDeploy( const completedDeployments = await Promise.all(deployments); const deployMs = Date.now() - start - uploadMs; + const workerName = props.useServiceEnvironments + ? `${scriptName} (${envName})` + : scriptName; + const targets = completedDeployments .flatMap((deployment) => deployment.targets) .map( @@ -351,12 +328,12 @@ export default async function triggersDeploy( (target) => (target.endsWith("workers.dev") ? "https://" : "") + target ); if (targets.length > 0) { - logger.log(`Deployed ${workerName} triggers`, formatTime(deployMs)); + ctx.logger.log(`Deployed ${workerName} triggers`, formatTime(deployMs)); for (const target of targets) { - logger.log(" ", target); + ctx.logger.log(" ", target); } } else { - logger.log("No targets deployed for", workerName, formatTime(deployMs)); + ctx.logger.log("No targets deployed for", workerName, formatTime(deployMs)); } const errors = completedDeployments @@ -437,12 +414,13 @@ export function getSubdomainValuesAPIMock( } async function validateSubdomainMixedState( - props: Props, + props: TriggerProps, accountId: string, scriptName: string, before: { workers_dev: boolean; preview_urls: boolean }, after: { workers_dev: boolean; preview_urls: boolean }, - firstDeploy: boolean + firstDeploy: boolean, + ctx: DeployHelpersContext ): Promise<{ workers_dev: boolean; preview_urls: boolean; @@ -464,7 +442,7 @@ async function validateSubdomainMixedState( } // Early return if non-interactive or CI - if (isNonInteractiveOrCI()) { + if (ctx.isNonInteractiveOrCI()) { return after; } @@ -478,14 +456,14 @@ async function validateSubdomainMixedState( return after; } - const userSubdomain = await getWorkersDevSubdomain(config, accountId, { + const userSubdomain = await getWorkersDevSubdomain(config, accountId, ctx, { configPath: config.configPath, }); const previewUrl = `https://-${scriptName}.${userSubdomain}`; // Scenario 1: User disables workers.dev while having preview URLs enabled if (!after.workers_dev && after.preview_urls) { - logger.warn( + ctx.logger.warn( [ "You are disabling the 'workers.dev' subdomain for this Worker, but Preview URLs are still enabled.", "Preview URLs will automatically generate a unique, shareable link for each new version which will be accessible at:", @@ -498,7 +476,7 @@ async function validateSubdomainMixedState( // Scenario 2: User enables workers.dev when Preview URLs are off if (after.workers_dev && !after.preview_urls) { - logger.warn( + ctx.logger.warn( [ "You are enabling the 'workers.dev' subdomain for this Worker, but Preview URLs are still disabled.", "Preview URLs will automatically generate a unique, shareable link for each new version which will be accessible at:", @@ -513,14 +491,15 @@ async function validateSubdomainMixedState( } async function subdomainDeploy( - props: Props, + props: TriggerProps, accountId: string, scriptName: string, envName: string, workerUrl: string, routes: Route[], deployments: Promise[], - firstDeploy: boolean + firstDeploy: boolean, + ctx: DeployHelpersContext ) { const { config } = props; @@ -530,9 +509,8 @@ async function subdomainDeploy( getSubdomainValues(config.workers_dev, config.preview_urls, routes); // workers.dev URL is only set if we want to deploy to workers.dev. - if (wantWorkersDev) { - const userSubdomain = await getWorkersDevSubdomain(config, accountId, { + const userSubdomain = await getWorkersDevSubdomain(config, accountId, ctx, { configPath: config.configPath, }); const workersDevURL = @@ -543,8 +521,7 @@ async function subdomainDeploy( } // Get current subdomain enablement status. - - const before = await fetchResult<{ + const before = await ctx.fetchResult<{ enabled: boolean; previews_enabled: boolean; }>(config, `${workerUrl}/subdomain`); @@ -552,26 +529,26 @@ async function subdomainDeploy( // Update subdomain status. // Occasionally this update to the subdomain endpoint fails due to some internal API error, // we retry this request a few times to mitigate that. - - const after = await retryOnAPIFailure(async () => - fetchResult<{ - enabled: boolean; - previews_enabled: boolean; - }>(config, `${workerUrl}/subdomain`, { - method: "POST", - body: JSON.stringify({ - enabled: wantWorkersDev, - previews_enabled: wantPreviews, + const after = await retryOnAPIFailure( + async () => + ctx.fetchResult<{ + enabled: boolean; + previews_enabled: boolean; + }>(config, `${workerUrl}/subdomain`, { + method: "POST", + body: JSON.stringify({ + enabled: wantWorkersDev, + previews_enabled: wantPreviews, + }), + headers: { + "Content-Type": "application/json", + "Cloudflare-Workers-Script-Api-Date": "2025-08-01", + }, }), - headers: { - "Content-Type": "application/json", - "Cloudflare-Workers-Script-Api-Date": "2025-08-01", - }, - }) + ctx.logger ); // Warn about mismatching config and current values. - if ( !firstDeploy && config.workers_dev == undefined && @@ -584,7 +561,7 @@ async function subdomainDeploy( return enabled ? "enable" : "disable"; } }; - logger.warn( + ctx.logger.warn( [ `Because 'workers_dev' is not in your Wrangler file, it will be ${status(after.enabled, true)} for this deployment by default.`, `To override this setting, you can ${status(before.enabled, false)} workers.dev by explicitly setting 'workers_dev = ${before.enabled}' in your Wrangler file.`, @@ -604,7 +581,7 @@ async function subdomainDeploy( return enabled ? "enable" : "disable"; } }; - logger.warn( + ctx.logger.warn( [ `Because your 'workers.dev' route is ${status(after.enabled, true)} and your 'preview_urls' setting is not in your Wrangler file, Preview URLs will be ${status(after.previews_enabled, true)} for this deployment by default.`, `To override this setting, you can ${status(before.previews_enabled, false)} Preview URLs by explicitly setting 'preview_urls = ${before.previews_enabled}' in your Wrangler file.`, @@ -613,18 +590,16 @@ async function subdomainDeploy( } // Warn about mixed status. - await validateSubdomainMixedState( props, accountId, scriptName, { workers_dev: before.enabled, preview_urls: before.previews_enabled }, { workers_dev: after.enabled, preview_urls: after.previews_enabled }, - firstDeploy + firstDeploy, + ctx ); - // Done. - return { wantWorkersDev, wantPreviews, diff --git a/packages/deploy-helpers/src/triggers/publish-routes.ts b/packages/deploy-helpers/src/triggers/publish-routes.ts new file mode 100644 index 0000000000..e00b6f11d2 --- /dev/null +++ b/packages/deploy-helpers/src/triggers/publish-routes.ts @@ -0,0 +1,375 @@ +import { ParseError, UserError } from "@cloudflare/workers-utils"; +import PQueue from "p-queue"; +import { getZoneForRoute } from "./zones"; +import type { DeployHelpersContext, TriggerDeployment } from "../shared/types"; +import type { + ComplianceConfig, + CustomDomainRoute, + Route, + ZoneIdRoute, + ZoneNameRoute, +} from "@cloudflare/workers-utils"; + +export type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; + +export type CustomDomain = { + id: string; + zone_id: string; + zone_name: string; + hostname: string; + service: string; + environment: string; + enabled: boolean; + previews_enabled: boolean; +}; + +type UpdatedCustomDomain = CustomDomain & { modified: boolean }; +type ConflictingCustomDomain = CustomDomain & { + external_dns_record_id?: string | null; + external_cert_id?: string; +}; + +export type CustomDomainChangeset = { + added: CustomDomain[]; + removed: CustomDomain[]; + updated: UpdatedCustomDomain[]; + conflicting: ConflictingCustomDomain[]; +}; + +export function renderRoute(route: Route): string { + let result = ""; + if (typeof route === "string") { + result = route; + } else { + result = route.pattern; + const isCustomDomain = Boolean( + "custom_domain" in route && route.custom_domain + ); + if (isCustomDomain && "zone_id" in route) { + result += ` (custom domain - zone id: ${route.zone_id})`; + } else if (isCustomDomain && "zone_name" in route) { + result += ` (custom domain - zone name: ${route.zone_name})`; + } else if (isCustomDomain) { + result += ` (custom domain)`; + } else if ("zone_id" in route) { + result += ` (zone id: ${route.zone_id})`; + } else if ("zone_name" in route) { + result += ` (zone name: ${route.zone_name})`; + } + + if (isCustomDomain) { + const flags: string[] = []; + if ("enabled" in route && route.enabled !== undefined) { + flags.push(route.enabled ? "enabled" : "disabled"); + } + if ("previews_enabled" in route && route.previews_enabled !== undefined) { + flags.push( + route.previews_enabled ? "previews: enabled" : "previews: disabled" + ); + } + if (flags.length > 0) { + result += ` [${flags.join(", ")}]`; + } + } + } + return result; +} + +function isAuthenticationError(e: unknown): e is ParseError { + return e instanceof ParseError && (e as { code?: number }).code === 10000; +} + +/** + * Associate the newly deployed Worker with the given routes. + */ +export async function publishRoutes( + complianceConfig: ComplianceConfig, + routes: Route[], + { + workerUrl, + scriptName, + useServiceEnvironments, + accountId, + }: { + workerUrl: string; + scriptName: string; + useServiceEnvironments: boolean; + accountId: string; + }, + ctx: DeployHelpersContext +): Promise { + try { + return await ctx.fetchResult(complianceConfig, `${workerUrl}/routes`, { + // Note: PUT will delete previous routes on this script. + method: "PUT", + body: JSON.stringify( + routes.map((route) => + typeof route !== "object" ? { pattern: route } : route + ) + ), + headers: { + "Content-Type": "application/json", + }, + }); + } catch (e) { + if (isAuthenticationError(e)) { + // An authentication error is probably due to a known issue, + // where the user is logged in via an API token that does not have "All Zones". + return await publishRoutesFallback( + complianceConfig, + routes, + { + scriptName, + useServiceEnvironments, + accountId, + }, + ctx + ); + } else { + throw e; + } + } +} +/** + * Try updating routes for the Worker using a less optimal zone-based API. + * + * Compute match zones to the routes, then for each route attempt to connect it to the Worker via the zone. + */ +async function publishRoutesFallback( + complianceConfig: ComplianceConfig, + routes: Route[], + { + scriptName, + useServiceEnvironments, + accountId, + }: { scriptName: string; useServiceEnvironments: boolean; accountId: string }, + ctx: DeployHelpersContext +) { + if (useServiceEnvironments) { + throw new UserError( + "Service environments combined with an API token that doesn't have 'All Zones' permissions is not supported.\n" + + "Either turn off service environments by setting `legacy_env = true`, creating an API token with 'All Zones' permissions, or logging in via OAuth", + { + telemetryMessage: + "deploy service environments require all zones permission", + } + ); + } + ctx.logger.info( + "The current authentication token does not have 'All Zones' permissions.\n" + + "Falling back to using the zone-based API endpoint to update each route individually.\n" + + "Note that there is no access to routes associated with zones that the API token does not have permission for.\n" + + "Existing routes for this Worker in such zones will not be deleted." + ); + + const deployedRoutes: string[] = []; + + const queue = new PQueue({ concurrency: 10 }); + const queuePromises: Array> = []; + const zoneIdCache = new Map(); + + // Collect the routes (and their zones) that will be deployed. + const activeZones = new Map(); + const routesToDeploy = new Map(); + for (const route of routes) { + queuePromises.push( + queue.add(async () => { + const zone = await getZoneForRoute( + complianceConfig, + { route, accountId }, + ctx, + zoneIdCache + ); + if (zone) { + activeZones.set(zone.id, zone.host); + routesToDeploy.set( + typeof route === "string" ? route : route.pattern, + zone.id + ); + } + }) + ); + } + await Promise.all(queuePromises.splice(0, queuePromises.length)); + + // Collect the routes that are already deployed. + const allRoutes = new Map(); + const alreadyDeployedRoutes = new Set(); + for (const [zone, host] of activeZones) { + queuePromises.push( + queue.add(async () => { + try { + for (const { pattern, script } of await ctx.fetchListResult<{ + pattern: string; + script: string; + }>(complianceConfig, `/zones/${zone}/workers/routes`)) { + allRoutes.set(pattern, script); + if (script === scriptName) { + alreadyDeployedRoutes.add(pattern); + } + } + } catch (e) { + if (isAuthenticationError(e)) { + e.notes.push({ + text: `This could be because the API token being used does not have permission to access the zone "${host}" (${zone}).`, + }); + } + throw e; + } + }) + ); + } + // using Promise.all() here instead of queue.onIdle() to ensure + // we actually throw errors that occur within queued promises. + await Promise.all(queuePromises); + + // Deploy each route that is not already deployed. + for (const [routePattern, zoneId] of routesToDeploy.entries()) { + if (allRoutes.has(routePattern)) { + const knownScript = allRoutes.get(routePattern); + if (knownScript === scriptName) { + // This route is already associated with this worker, so no need to hit the API. + alreadyDeployedRoutes.delete(routePattern); + continue; + } else { + throw new UserError( + `The route with pattern "${routePattern}" is already associated with another worker called "${knownScript}".`, + { telemetryMessage: "route already associated with another worker" } + ); + } + } + + const { pattern } = await ctx.fetchResult<{ pattern: string }>( + complianceConfig, + `/zones/${zoneId}/workers/routes`, + { + method: "POST", + body: JSON.stringify({ + pattern: routePattern, + script: scriptName, + }), + headers: { + "Content-Type": "application/json", + }, + } + ); + + deployedRoutes.push(pattern); + } + + if (alreadyDeployedRoutes.size) { + ctx.logger.warn( + "Previously deployed routes:\n" + + "The following routes were already associated with this worker, and have not been deleted:\n" + + [...alreadyDeployedRoutes.values()].map((route) => ` - "${route}"\n`) + + "If these routes are not wanted then you can remove them in the dashboard." + ); + } + + return deployedRoutes; +} + +export async function publishCustomDomains( + complianceConfig: ComplianceConfig, + workerUrl: string, + accountId: string, + domains: Array, + ctx: DeployHelpersContext +): Promise { + const options = { + override_scope: true, + override_existing_origin: false, + override_existing_dns_record: false, + }; + const origins = domains.map((domainRoute) => { + return { + hostname: domainRoute.pattern, + zone_id: "zone_id" in domainRoute ? domainRoute.zone_id : undefined, + zone_name: "zone_name" in domainRoute ? domainRoute.zone_name : undefined, + enabled: "enabled" in domainRoute ? domainRoute.enabled : undefined, + previews_enabled: + "previews_enabled" in domainRoute + ? domainRoute.previews_enabled + : undefined, + }; + }); + + const fail = (): TriggerDeployment => { + return { + targets: [], + error: new UserError( + domains.length > 1 + ? `Publishing to ${domains.length} Custom Domains was skipped, fix conflicts and try again` + : `Publishing to Custom Domain "${domains[0].pattern}" was skipped, fix conflict and try again`, + { telemetryMessage: "deploy custom domains skipped" } + ), + }; + }; + + if (!process.stdout.isTTY) { + options.override_existing_origin = true; + options.override_existing_dns_record = true; + } else { + const changeset = await ctx.fetchResult( + complianceConfig, + `${workerUrl}/domains/changeset?replace_state=true`, + { + method: "POST", + body: JSON.stringify(origins), + headers: { + "Content-Type": "application/json", + }, + } + ); + + const updatesRequired = changeset.updated.filter( + (domain) => domain.modified + ); + if (updatesRequired.length > 0) { + const existing = await Promise.all( + updatesRequired.map((domain) => + ctx.fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/domains/records/${domain.id}` + ) + ) + ); + const existingRendered = existing + .map( + (domain) => + `\t• ${domain.hostname} (used as a domain for "${domain.service}")` + ) + .join("\n"); + const message = `Custom Domains already exist for these domains: +${existingRendered} +Update them to point to this script instead?`; + if (!(await ctx.confirm(message))) { + return fail(); + } + options.override_existing_origin = true; + } + + if (changeset.conflicting.length > 0) { + const conflicitingRendered = changeset.conflicting + .map((domain) => `\t• ${domain.hostname}`) + .join("\n"); + const message = `You already have DNS records that conflict for these Custom Domains: +${conflicitingRendered} +Update them to point to this script instead?`; + if (!(await ctx.confirm(message))) { + return fail(); + } + options.override_existing_dns_record = true; + } + } + + await ctx.fetchResult(complianceConfig, `${workerUrl}/domains/records`, { + method: "PUT", + body: JSON.stringify({ ...options, origins }), + headers: { + "Content-Type": "application/json", + }, + }); + + return { targets: domains.map((domain) => renderRoute(domain)) }; +} diff --git a/packages/deploy-helpers/src/triggers/queue-consumers.ts b/packages/deploy-helpers/src/triggers/queue-consumers.ts new file mode 100644 index 0000000000..8bccd79cea --- /dev/null +++ b/packages/deploy-helpers/src/triggers/queue-consumers.ts @@ -0,0 +1,433 @@ +import { UserError } from "@cloudflare/workers-utils"; +import type { DeployHelpersContext, TriggerDeployment } from "../shared/types"; +import type { Config, ComplianceConfig } from "@cloudflare/workers-utils"; + +export interface PostQueueBody { + queue_name: string; + settings?: QueueSettings; +} + +export interface QueueSettings { + delivery_delay?: number; + delivery_paused?: boolean; + message_retention_period?: number; +} + +export interface PostQueueResponse { + queue_id: string; + queue_name: string; + settings?: QueueSettings; + created_on: string; + modified_on: string; +} + +export interface QueueResponse { + queue_id: string; + queue_name: string; + created_on: string; + modified_on: string; + producers: Producer[]; + producers_total_count: number; + consumers: Consumer[]; + consumers_total_count: number; + settings?: QueueSettings; +} + +export interface ScriptReference { + namespace?: string; + script?: string; + service?: string; + environment?: string; +} + +export type Producer = ScriptReference & { + type: string; + bucket_name?: string; +}; + +export type Consumer = ScriptReference & { + dead_letter_queue?: string; + settings: ConsumerSettings; + consumer_id: string; + bucket_name?: string; + type: string; +}; + +export interface TypedConsumerResponse extends Consumer { + queue_name: string; + created_on: string; +} + +export interface PostTypedConsumerBody { + type: string; + script_name?: string; + environment_name?: string; + settings: ConsumerSettings; + dead_letter_queue?: string; +} + +export interface ConsumerSettings { + batch_size?: number; + max_retries?: number; + max_wait_time_ms?: number; + max_concurrency?: number | null; + visibility_timeout_ms?: number; + retry_delay?: number; +} + +export interface PurgeQueueBody { + delete_messages_permanently: boolean; +} + +export interface PurgeQueueResponse { + started_at: string; + complete: boolean; +} + +export async function listQueues( + complianceConfig: ComplianceConfig, + accountId: string, + ctx: DeployHelpersContext, + page?: number, + name?: string +): Promise { + page = page ?? 1; + const params = new URLSearchParams({ page: page.toString() }); + + if (name) { + params.append("name", name); + } + + return ctx.fetchResult( + complianceConfig, + `/accounts/${accountId}/queues`, + {}, + params + ); +} + +export async function getQueue( + complianceConfig: ComplianceConfig, + accountId: string, + queueName: string, + ctx: DeployHelpersContext +): Promise { + const queues = await listQueues( + complianceConfig, + accountId, + ctx, + 1, + queueName + ); + if (queues.length === 0) { + throw new UserError( + `Queue "${queueName}" does not exist. To create it, run: wrangler queues create ${queueName}`, + { telemetryMessage: "queues lookup missing queue" } + ); + } + return queues[0]; +} + +export async function postConsumer( + complianceConfig: ComplianceConfig, + accountId: string, + queueName: string, + body: PostTypedConsumerBody, + ctx: DeployHelpersContext +): Promise { + const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + return postConsumerById( + complianceConfig, + accountId, + ctx, + queue.queue_id, + body + ); +} + +async function postConsumerById( + config: ComplianceConfig, + accountId: string, + ctx: DeployHelpersContext, + queueId: string, + body: PostTypedConsumerBody +): Promise { + return ctx.fetchResult( + config, + `/accounts/${accountId}/queues/${queueId}/consumers`, + { + method: "POST", + body: JSON.stringify(body), + } + ); +} + +export async function putConsumerById( + complianceConfig: ComplianceConfig, + accountId: string, + queueId: string, + consumerId: string, + body: PostTypedConsumerBody, + ctx: DeployHelpersContext +): Promise { + return ctx.fetchResult( + complianceConfig, + `/accounts/${accountId}/queues/${queueId}/consumers/${consumerId}`, + { + method: "PUT", + body: JSON.stringify(body), + } + ); +} + +export async function putConsumer( + complianceConfig: ComplianceConfig, + accountId: string, + queueName: string, + scriptName: string, + envName: string | undefined, + body: PostTypedConsumerBody, + ctx: DeployHelpersContext +): Promise { + const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const targetConsumer = await resolveWorkerConsumerByName( + complianceConfig, + accountId, + scriptName, + envName, + queue, + ctx + ); + return putConsumerById( + complianceConfig, + accountId, + queue.queue_id, + targetConsumer.consumer_id, + body, + ctx + ); +} + +async function resolveWorkerConsumerByName( + complianceConfig: ComplianceConfig, + accountId: string, + consumerName: string, + envName: string | undefined, + queue: QueueResponse, + ctx: DeployHelpersContext +): Promise { + const queueName = queue.queue_name; + const consumers = queue.consumers.filter( + (c) => + c.type === "worker" && + (c.script === consumerName || c.service === consumerName) + ); + + if (consumers.length === 0) { + throw new UserError( + `No worker consumer '${consumerName}' exists for queue ${queue.queue_name}`, + { telemetryMessage: "queues worker consumer missing" } + ); + } + + // If more than a consumer with the same name is found, it should be + // a service+environment combination + if (consumers.length > 1) { + const targetEnv = + envName ?? + (await getDefaultService(complianceConfig, accountId, consumerName, ctx)); + const targetConsumers = consumers.filter( + (c) => c.environment === targetEnv + ); + + if (targetConsumers.length === 0) { + throw new UserError( + `No worker consumer '${consumerName}' exists for queue ${queueName}`, + { telemetryMessage: "queues worker consumer missing environment" } + ); + } + return targetConsumers[0]; + } + + if (consumers[0].service) { + const targetEnv = + envName ?? + (await getDefaultService(complianceConfig, accountId, consumerName, ctx)); + if (targetEnv != consumers[0].environment) { + throw new UserError( + `No worker consumer '${consumerName}' exists for queue ${queueName}`, + { telemetryMessage: "queues worker consumer environment mismatch" } + ); + } + } + return consumers[0]; +} + +interface WorkerService { + id: string; + default_environment: { + environment: string; + }; +} + +async function getDefaultService( + complianceConfig: ComplianceConfig, + accountId: string, + serviceName: string, + ctx: DeployHelpersContext +): Promise { + const service = await ctx.fetchResult( + complianceConfig, + `/accounts/${accountId}/workers/services/${serviceName}`, + { + method: "GET", + } + ); + + ctx.logger.info(service); + + return service.default_environment.environment; +} + +async function deleteConsumerById( + complianceConfig: ComplianceConfig, + accountId: string, + queueId: string, + consumerId: string, + ctx: DeployHelpersContext +): Promise { + return ctx.fetchResult( + complianceConfig, + `/accounts/${accountId}/queues/${queueId}/consumers/${consumerId}`, + { + method: "DELETE", + } + ); +} + +export async function deletePullConsumer( + complianceConfig: ComplianceConfig, + accountId: string, + queueName: string, + ctx: DeployHelpersContext +): Promise { + const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const consumer = queue.consumers[0]; + if (consumer?.type !== "http_pull") { + throw new UserError(`No http_pull consumer exists for queue ${queueName}`, { + telemetryMessage: "queues http pull consumer missing", + }); + } + return deleteConsumerById( + complianceConfig, + accountId, + queue.queue_id, + consumer.consumer_id, + ctx + ); +} + +export async function listConsumers( + complianceConfig: ComplianceConfig, + accountId: string, + queueName: string, + ctx: DeployHelpersContext +): Promise { + const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + return queue.consumers; +} + +export async function deleteWorkerConsumer( + complianceConfig: ComplianceConfig, + accountId: string, + queueName: string, + scriptName: string, + envName: string | undefined, + ctx: DeployHelpersContext +): Promise { + const queue = await getQueue(complianceConfig, accountId, queueName, ctx); + const targetConsumer = await resolveWorkerConsumerByName( + complianceConfig, + accountId, + scriptName, + envName, + queue, + ctx + ); + return deleteConsumerById( + complianceConfig, + accountId, + queue.queue_id, + targetConsumer.consumer_id, + ctx + ); +} + +export async function updateQueueConsumers( + complianceConfig: ComplianceConfig, + accountId: string, + scriptName: string, + config: Config, + ctx: DeployHelpersContext +): Promise[]> { + const consumers = config.queues.consumers || []; + const updateConsumers: Promise[] = []; + for (const consumer of consumers) { + const queue = await getQueue( + complianceConfig, + accountId, + consumer.queue, + ctx + ); + + const body: PostTypedConsumerBody = { + type: "worker", + dead_letter_queue: consumer.dead_letter_queue, + script_name: scriptName, + settings: { + batch_size: consumer.max_batch_size, + max_retries: consumer.max_retries, + max_wait_time_ms: + consumer.max_batch_timeout !== undefined + ? 1000 * consumer.max_batch_timeout + : undefined, + max_concurrency: consumer.max_concurrency, + retry_delay: consumer.retry_delay, + }, + }; + + // Current script already assigned to queue? + const existingConsumer = + queue.consumers.filter( + (c) => c.script === scriptName || c.service === scriptName + ).length > 0; + const envName = undefined; // TODO: script environment for wrangler deploy? + if (existingConsumer) { + updateConsumers.push( + putConsumer( + complianceConfig, + accountId, + consumer.queue, + scriptName, + envName, + body, + ctx + ).then( + () => ({ targets: [`Consumer for ${consumer.queue}`] }), + (error) => ({ targets: [], error }) + ) + ); + continue; + } + updateConsumers.push( + postConsumer(complianceConfig, accountId, consumer.queue, body, ctx).then( + () => ({ + targets: [`Consumer for ${consumer.queue}`], + }), + (error) => ({ targets: [], error }) + ) + ); + } + + return updateConsumers; +} diff --git a/packages/wrangler/src/routes.ts b/packages/deploy-helpers/src/triggers/subdomain.ts similarity index 84% rename from packages/wrangler/src/routes.ts rename to packages/deploy-helpers/src/triggers/subdomain.ts index d1854745e4..a628bbb1fd 100644 --- a/packages/wrangler/src/routes.ts +++ b/packages/deploy-helpers/src/triggers/subdomain.ts @@ -4,19 +4,13 @@ import { UserError, } from "@cloudflare/workers-utils"; import chalk from "chalk"; -import { fetchResult } from "./cfetch"; -import { confirm, prompt } from "./dialogs"; -import { logger } from "./logger"; -import type { - ComplianceConfig, - ApiCredentials, -} from "@cloudflare/workers-utils"; +import type { DeployHelpersContext } from "../shared/types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; type WorkersDevSubdomainRegistrationContext = "workers_dev" | "workflows"; type GetWorkersDevSubdomainOptions = { configPath?: string | undefined; - apiToken?: ApiCredentials | undefined; abortSignal?: AbortSignal | undefined; registrationContext?: WorkersDevSubdomainRegistrationContext | undefined; }; @@ -27,24 +21,23 @@ type GetWorkersDevSubdomainOptions = { export async function getWorkersDevSubdomain( complianceConfig: ComplianceConfig, accountId: string, + ctx: DeployHelpersContext, options: GetWorkersDevSubdomainOptions = {} ): Promise { const { configPath, - apiToken, abortSignal, registrationContext = "workers_dev", } = options; try { // note: API docs say that this field is "name", but they're lying. - const { subdomain } = await fetchResult<{ subdomain: string }>( + const { subdomain } = await ctx.fetchResult<{ subdomain: string }>( complianceConfig, `/accounts/${accountId}/workers/subdomain`, undefined, undefined, - abortSignal, - apiToken + abortSignal ); return `${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; } catch (e) { @@ -55,9 +48,9 @@ export async function getWorkersDevSubdomain( // 10007 error code: not found // https://api.cloudflare.com/#worker-subdomain-get-subdomain - logger.warn(getRegistrationWarning(registrationContext)); + ctx.logger.warn(getRegistrationWarning(registrationContext)); - const wantsToRegister = await confirm( + const wantsToRegister = await ctx.confirm( "Would you like to register a workers.dev subdomain now?", { fallbackValue: false } ); @@ -73,7 +66,8 @@ export async function getWorkersDevSubdomain( complianceConfig, accountId, configPath, - registrationContext + registrationContext, + ctx ); } } @@ -124,24 +118,25 @@ async function registerSubdomain( complianceConfig: ComplianceConfig, accountId: string, configPath: string | undefined, - registrationContext: WorkersDevSubdomainRegistrationContext + registrationContext: WorkersDevSubdomainRegistrationContext, + ctx: DeployHelpersContext ): Promise { let subdomain: string | undefined; while (subdomain === undefined) { - const potentialName = await prompt( + const potentialName = await ctx.prompt( "What would you like your workers.dev subdomain to be? It will be accessible at https://.workers.dev" ); if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(potentialName)) { - logger.warn( + ctx.logger.warn( `${potentialName} is invalid, please choose another subdomain.` ); continue; } try { - await fetchResult<{ subdomain: string }>( + await ctx.fetchResult<{ subdomain: string }>( complianceConfig, `/accounts/${accountId}/workers/subdomains/${potentialName}` ); @@ -156,20 +151,18 @@ async function registerSubdomain( // oddly enough, this is a `subdomain_unavailable` error, meaning...that the subdomain // doesn't exist. and we can register it. this is exactly how the dashboard does it. } else if (subdomainAvailabilityCheckError.code === 10031) { - logger.error( + ctx.logger.error( "Subdomain is unavailable, please try a different subdomain" ); - continue; } else { - logger.error("An unexpected error occurred, please try again."); - + ctx.logger.error("An unexpected error occurred, please try again."); continue; } } } - const ok = await confirm( + const ok = await ctx.confirm( `Creating a workers.dev subdomain for your account at ${chalk.blue( chalk.underline( `https://${potentialName}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev` @@ -185,7 +178,7 @@ async function registerSubdomain( } try { - const result = await fetchResult<{ subdomain: string }>( + const result = await ctx.fetchResult<{ subdomain: string }>( complianceConfig, `/accounts/${accountId}/workers/subdomain`, { @@ -193,7 +186,6 @@ async function registerSubdomain( body: JSON.stringify({ subdomain: potentialName }), } ); - subdomain = result.subdomain; } catch (err) { const subdomainCreationError = err as { code?: number }; @@ -204,20 +196,22 @@ async function registerSubdomain( ) { switch (subdomainCreationError.code) { case 10031: - logger.error( + ctx.logger.error( "Subdomain is unavailable, please try a different subdomain." ); break; default: - logger.error("An unexpected error occurred, please try again."); + ctx.logger.error("An unexpected error occurred, please try again."); break; } } } } - logger.log("Success! It may take a few minutes for DNS records to update."); - logger.log( + ctx.logger.log( + "Success! It may take a few minutes for DNS records to update." + ); + ctx.logger.log( `Visit ${chalk.blue( chalk.underline( `https://dash.cloudflare.com/${accountId}/workers/subdomain` diff --git a/packages/deploy-helpers/src/triggers/zones.ts b/packages/deploy-helpers/src/triggers/zones.ts new file mode 100644 index 0000000000..be633b08ca --- /dev/null +++ b/packages/deploy-helpers/src/triggers/zones.ts @@ -0,0 +1,101 @@ +import { + getHostFromRoute, + retryOnAPIFailure, + UserError, +} from "@cloudflare/workers-utils"; +import type { DeployHelpersContext } from "../shared/types"; +import type { ComplianceConfig, Route } from "@cloudflare/workers-utils"; + +export interface Zone { + id: string; + host: string; +} + +export type ZoneIdCache = Map>; + +export async function getZoneForRoute( + complianceConfig: ComplianceConfig, + from: { + route: Route; + accountId: string; + }, + ctx: DeployHelpersContext, + zoneIdCache: ZoneIdCache = new Map() +): Promise { + const { route, accountId } = from; + const host = getHostFromRoute(route); + let id: string | undefined; + + if (typeof route === "object" && "zone_id" in route) { + id = route.zone_id; + } else if (typeof route === "object" && "zone_name" in route) { + id = await getZoneIdFromHost( + complianceConfig, + { host: route.zone_name, accountId }, + ctx, + zoneIdCache + ); + } else if (host) { + id = await getZoneIdFromHost( + complianceConfig, + { host, accountId }, + ctx, + zoneIdCache + ); + } + + return id && host ? { id, host } : undefined; +} + +/** + * Given something that resembles a host, try to infer a zone id from it. + * + * It's hard to get a 'valid' domain from a string, so we don't even try to validate TLDs, etc. + * For each domain-like part of the host (e.g. w.x.y.z) try to get a zone id for it by + * lopping off subdomains until we get a hit from the API. + */ +export async function getZoneIdFromHost( + complianceConfig: ComplianceConfig, + from: { + host: string; + accountId: string; + }, + ctx: DeployHelpersContext, + zoneIdCache: ZoneIdCache = new Map() +): Promise { + const hostPieces = from.host.split("."); + + while (hostPieces.length > 1) { + const cacheKey = `${from.accountId}:${hostPieces.join(".")}`; + if (!zoneIdCache.has(cacheKey)) { + zoneIdCache.set( + cacheKey, + retryOnAPIFailure( + () => + ctx.fetchListResult<{ id: string }>( + complianceConfig, + `/zones`, + {}, + new URLSearchParams({ + name: hostPieces.join("."), + "account.id": from.accountId, + }) + ), + ctx.logger + ).then((zones) => zones[0]?.id ?? null) + ); + } + + const cachedZone = await zoneIdCache.get(cacheKey); + if (cachedZone) { + return cachedZone; + } + + hostPieces.shift(); + } + + throw new UserError( + `Could not find zone for \`${from.host}\`. Make sure the domain is set up to be proxied by Cloudflare.\nFor more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route`, + { telemetryMessage: "zones route zone not found" } + ); +} diff --git a/packages/deploy-helpers/tsup.config.ts b/packages/deploy-helpers/tsup.config.ts index 9c4e367648..1595fdb8ae 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -12,6 +12,6 @@ export default defineConfig(() => [ tsconfig: "tsconfig.json", metafile: true, sourcemap: process.env.SOURCEMAPS !== "false", - external: ["@cloudflare/*"], + external: [/^@cloudflare\//], }, ]); diff --git a/packages/workers-utils/src/cfetch/index.ts b/packages/workers-utils/src/cfetch/index.ts index 758c093fbb..1c12f7f6f4 100644 --- a/packages/workers-utils/src/cfetch/index.ts +++ b/packages/workers-utils/src/cfetch/index.ts @@ -37,6 +37,13 @@ export type FetchResultFetcher = ( abortSignal?: AbortSignal ) => Promise; +export type FetchListResultFetcher = ( + complianceConfig: ComplianceConfig, + resource: string, + init?: RequestInit, + queryParams?: URLSearchParams +) => Promise; + function logHeaders(headers: Headers, logger: Logger): void { const clone = cloneHeaders(headers); clone.delete("Authorization"); diff --git a/packages/workers-utils/src/format-time.ts b/packages/workers-utils/src/format-time.ts new file mode 100644 index 0000000000..aa5270d954 --- /dev/null +++ b/packages/workers-utils/src/format-time.ts @@ -0,0 +1,3 @@ +export function formatTime(duration: number): string { + return `(${(duration / 1000).toFixed(2)} sec)`; +} diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index 1d68fdaddf..02f3489e71 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -117,3 +117,11 @@ export { fetchLatestNpmVersion } from "./update-check"; export type { NpmVersionCheckResult } from "./update-check"; export type { Logger } from "./logger"; + +export { retryOnAPIFailure } from "./retry"; +export { formatTime } from "./format-time"; +export { + getHostFromRoute, + getHostFromUrl, + getZoneFromRoute, +} from "./route-utils"; diff --git a/packages/workers-utils/src/retry.ts b/packages/workers-utils/src/retry.ts new file mode 100644 index 0000000000..97056ce4d3 --- /dev/null +++ b/packages/workers-utils/src/retry.ts @@ -0,0 +1,45 @@ +import { setTimeout } from "node:timers/promises"; +import { APIError } from "./parse"; +import type { Logger } from "./logger"; + +const MAX_ATTEMPTS = 3; + +export async function retryOnAPIFailure( + action: () => T | Promise, + logger: Logger, + backoff = 0, + attempts = MAX_ATTEMPTS, + abortSignal?: AbortSignal +): Promise { + try { + return await action(); + } catch (err) { + if (err instanceof APIError) { + if (!err.isRetryable()) { + throw err; + } + } else if (err instanceof DOMException && err.name === "TimeoutError") { + // Per-request timeouts (from AbortSignal.timeout()) are transient + // and should be retried, but user-initiated aborts (AbortError) + // should not. + } else if (!(err instanceof TypeError)) { + throw err; + } + + logger.debug(`Retrying API call after error...`); + logger.debug(err); + + if (attempts <= 1) { + throw err; + } + + await setTimeout(backoff, undefined, { signal: abortSignal }); + return retryOnAPIFailure( + action, + logger, + backoff + 1000, + attempts - 1, + abortSignal + ); + } +} diff --git a/packages/workers-utils/src/route-utils.ts b/packages/workers-utils/src/route-utils.ts new file mode 100644 index 0000000000..237fa543e0 --- /dev/null +++ b/packages/workers-utils/src/route-utils.ts @@ -0,0 +1,84 @@ +import type { Route } from "./config/environment"; + +/** + * Get the hostname on which to run a Worker. + * + * The most accurate place is usually + * `route.pattern`, as that includes any subdomains. For example: + * ```js + * { + * pattern: foo.example.com + * zone_name: example.com + * } + * ``` + * However, in the case of patterns that _can't_ be parsed as a hostname + * (primarily the pattern `*/ /*`), we fall back to the `zone_name` + * (and in the absence of that return undefined). + * @param route + */ +export function getHostFromRoute(route: Route): string | undefined { + let host: string | undefined; + + if (typeof route === "string") { + host = getHostFromUrl(route); + } else if (typeof route === "object") { + host = getHostFromUrl(route.pattern); + + if (host === undefined && "zone_name" in route) { + host = getHostFromUrl(route.zone_name); + } + } + + return host; +} + +/** + * Best-effort derivation of the Cloudflare zone name that owns a given route, + * for use as the `CF-Worker` header value on outbound subrequests in local + * development (see https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker). + * + * In production, `CF-Worker` is set to the zone name — for a route + * `foo.example.com/*` on zone `example.com`, the header is `example.com`. + * When the user has explicitly told us the zone name in their route config + * (`zone_name`), use it. Otherwise, fall back to {@link getHostFromRoute}, + * which returns the route pattern's hostname — this is the closest local + * approximation without performing an API lookup, and matches the behaviour + * users see when their route's hostname is already the apex (e.g. + * `example.com/*`). + */ +export function getZoneFromRoute(route: Route): string | undefined { + if (typeof route === "object" && "zone_name" in route && route.zone_name) { + return route.zone_name; + } + return getHostFromRoute(route); +} + +/** + * Given something that resembles a URL, try to extract a host from it. + */ +export function getHostFromUrl(urlLike: string): string | undefined { + // if the urlLike-pattern uses a splat for the entire host and is only concerned with the pathname, we cannot infer a host + if ( + urlLike.startsWith("*/") || + urlLike.startsWith("http://*/") || + urlLike.startsWith("https://*/") + ) { + return undefined; + } + + // if the urlLike-pattern uses a splat for the sub-domain (*.example.com) or for the root-domain (*example.com), remove the wildcard parts + urlLike = urlLike.replace(/\*(\.)?/g, ""); + + // prepend a protocol if the pattern did not specify one + if (!(urlLike.startsWith("http://") || urlLike.startsWith("https://"))) { + urlLike = "http://" + urlLike; + } + + // now we've done our best to make urlLike a valid url string which we can pass to `new URL()` to get the host + // if it still isn't, return undefined to indicate we couldn't infer a host + try { + return new URL(urlLike).host; + } catch { + return undefined; + } +} diff --git a/packages/wrangler/src/__tests__/deploy/helpers.ts b/packages/wrangler/src/__tests__/deploy/helpers.ts index 90272b54b9..0595854feb 100644 --- a/packages/wrangler/src/__tests__/deploy/helpers.ts +++ b/packages/wrangler/src/__tests__/deploy/helpers.ts @@ -15,8 +15,11 @@ import { mswSuccessDeploymentScriptMetadata, } from "../helpers/msw"; import type { AssetManifest } from "../../assets"; -import type { CustomDomain, CustomDomainChangeset } from "../../deploy/deploy"; import type { PostTypedConsumerBody, QueueResponse } from "../../queues/client"; +import type { + CustomDomain, + CustomDomainChangeset, +} from "@cloudflare/deploy-helpers"; import type { Config, ServiceMetadataRes, diff --git a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts index a9b0e117bf..1dfc4ac3d0 100644 --- a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts @@ -1,3 +1,4 @@ +import { getSubdomainValues } from "@cloudflare/deploy-helpers"; import { runInTempDir, writeWranglerConfig, @@ -10,7 +11,6 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getInstalledPackageVersion } from "../../autoconfig/frameworks/utils/packages"; import { clearOutputFilePath } from "../../output"; -import { getSubdomainValues } from "../../triggers/deploy"; import { fetchSecrets } from "../../utils/fetch-secrets"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; diff --git a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts index bd2bf70a18..1113c1b6f7 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts @@ -1,3 +1,7 @@ +import { + getSubdomainValues, + getSubdomainValuesAPIMock, +} from "@cloudflare/deploy-helpers"; import { ParseError } from "@cloudflare/workers-utils"; import { http, HttpResponse } from "msw"; /* eslint-disable-next-line no-restricted-imports -- @@ -5,10 +9,6 @@ import { http, HttpResponse } from "msw"; * TODO: remove this `expect` import */ import { expect } from "vitest"; -import { - getSubdomainValues, - getSubdomainValuesAPIMock, -} from "../../triggers/deploy"; import { mockGetWorkerSubdomain, mockUpdateWorkerSubdomain, diff --git a/packages/wrangler/src/__tests__/zones.test.ts b/packages/wrangler/src/__tests__/zones.test.ts index ad3e2bb070..fa04874860 100644 --- a/packages/wrangler/src/__tests__/zones.test.ts +++ b/packages/wrangler/src/__tests__/zones.test.ts @@ -1,3 +1,4 @@ +import { getZoneForRoute } from "@cloudflare/deploy-helpers"; import { COMPLIANCE_REGION_CONFIG_UNKNOWN } from "@cloudflare/workers-utils"; import { http, HttpResponse } from "msw"; /* eslint-disable-next-line no-restricted-imports -- @@ -5,7 +6,8 @@ import { http, HttpResponse } from "msw"; * TODO: remove this `expect` import */ import { describe, expect, it, test } from "vitest"; -import { getHostFromUrl, getZoneForRoute, getZoneFromRoute } from "../zones"; +import { createDeployHelpersContext } from "../core/deploy-helpers-context"; +import { getHostFromUrl, getZoneFromRoute } from "../zones"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { msw } from "./helpers/msw"; @@ -81,10 +83,14 @@ describe("Zones", () => { test("string route", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: "example.com/*", - accountId: "some-account-id", - }) + await getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: "example.com/*", + accountId: "some-account-id", + }, + createDeployHelpersContext() + ) ).toEqual({ host: "example.com", id: "example-id", @@ -94,10 +100,14 @@ describe("Zones", () => { test("string route (not a zone)", async () => { mockGetZones("wrong.com", []); await expect( - getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: "wrong.com/*", - accountId: "some-account-id", - }) + getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: "wrong.com/*", + accountId: "some-account-id", + }, + createDeployHelpersContext() + ) ).rejects.toMatchInlineSnapshot(` [Error: Could not find zone for \`wrong.com\`. Make sure the domain is set up to be proxied by Cloudflare. For more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route] @@ -108,10 +118,14 @@ describe("Zones", () => { // when a zone_id is provided in the route mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: { pattern: "example.com/*", zone_id: "other-id" }, - accountId: "some-account-id", - }) + await getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: { pattern: "example.com/*", zone_id: "other-id" }, + accountId: "some-account-id", + }, + createDeployHelpersContext() + ) ).toEqual({ host: "example.com", id: "other-id", @@ -122,13 +136,17 @@ describe("Zones", () => { // when a zone_id is provided in the route mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: { - pattern: "some.third-party.com/*", - zone_id: "other-id", + await getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: { + pattern: "some.third-party.com/*", + zone_id: "other-id", + }, + accountId: "some-account-id", }, - accountId: "some-account-id", - }) + createDeployHelpersContext() + ) ).toEqual({ host: "some.third-party.com", id: "other-id", @@ -138,13 +156,17 @@ describe("Zones", () => { test("zone_name route (apex)", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: { - pattern: "example.com/*", - zone_name: "example.com", + await getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: { + pattern: "example.com/*", + zone_name: "example.com", + }, + accountId: "some-account-id", }, - accountId: "some-account-id", - }) + createDeployHelpersContext() + ) ).toEqual({ host: "example.com", id: "example-id", @@ -153,13 +175,17 @@ describe("Zones", () => { test("zone_name route (subdomain)", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: { - pattern: "subdomain.example.com/*", - zone_name: "example.com", + await getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: { + pattern: "subdomain.example.com/*", + zone_name: "example.com", + }, + accountId: "some-account-id", }, - accountId: "some-account-id", - }) + createDeployHelpersContext() + ) ).toEqual({ host: "subdomain.example.com", id: "example-id", @@ -168,13 +194,17 @@ describe("Zones", () => { test("zone_name route (custom hostname)", async () => { mockGetZones("example.com", [{ id: "example-id" }]); expect( - await getZoneForRoute(COMPLIANCE_REGION_CONFIG_UNKNOWN, { - route: { - pattern: "some.third-party.com/*", - zone_name: "example.com", + await getZoneForRoute( + COMPLIANCE_REGION_CONFIG_UNKNOWN, + { + route: { + pattern: "some.third-party.com/*", + zone_name: "example.com", + }, + accountId: "some-account-id", }, - accountId: "some-account-id", - }) + createDeployHelpersContext() + ) ).toEqual({ host: "some.third-party.com", id: "example-id", @@ -278,6 +308,7 @@ describe("Zones", () => { }, accountId: "some-account-id", }, + createDeployHelpersContext(), zoneIdCache ) ).toEqual({ @@ -301,6 +332,7 @@ describe("Zones", () => { }, accountId: "some-account-id", }, + createDeployHelpersContext(), zoneIdCache ) ).toEqual({ diff --git a/packages/wrangler/src/assets.ts b/packages/wrangler/src/assets.ts index 7780525ddb..a8618aa8f3 100644 --- a/packages/wrangler/src/assets.ts +++ b/packages/wrangler/src/assets.ts @@ -16,12 +16,12 @@ import { normalizeFilePath, } from "@cloudflare/workers-shared/utils/helpers"; import { APIError, FatalError, UserError } from "@cloudflare/workers-utils"; +import { formatTime } from "@cloudflare/workers-utils"; import chalk from "chalk"; import PQueue from "p-queue"; import prettyBytes from "pretty-bytes"; import { FormData } from "undici"; import { fetchResult } from "./cfetch"; -import { formatTime } from "./deploy/deploy"; import { logger, LOGGER_LEVELS } from "./logger"; import { hashFile } from "./pages/hash"; import { isJwtExpired } from "./pages/upload"; diff --git a/packages/wrangler/src/core/deploy-helpers-context.ts b/packages/wrangler/src/core/deploy-helpers-context.ts new file mode 100644 index 0000000000..7f86b4d4ce --- /dev/null +++ b/packages/wrangler/src/core/deploy-helpers-context.ts @@ -0,0 +1,37 @@ +import { fetchListResult, fetchResult } from "../cfetch"; +import { confirm, prompt } from "../dialogs"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import type { DeployHelpersContext } from "@cloudflare/deploy-helpers"; +import type { ApiCredentials } from "@cloudflare/workers-utils"; + +/** + * Builds a `DeployHelpersContext` from Wrangler's singletons (logger, auth'd + * fetchers and interactive prompts) so that `@cloudflare/deploy-helpers` + * functions can be called from code paths that don't have easy access to command + * `HandlerContext`. + * + * An optional `apiToken` can be provided to override the credentials used by + * `fetchResult` (e.g. for remote preview sessions that authenticate with a + * per-request token rather than the global account credentials). + */ +export function createDeployHelpersContext(options?: { + apiToken?: ApiCredentials; +}): DeployHelpersContext { + return { + fetchResult: (complianceConfig, resource, init, queryParams, abortSignal) => + fetchResult( + complianceConfig, + resource, + init, + queryParams, + abortSignal, + options?.apiToken + ), + fetchListResult, + logger, + confirm, + prompt, + isNonInteractiveOrCI, + }; +} diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index b82ddde8b6..89e2ee43b5 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -8,10 +8,12 @@ import { } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { maybeInstallCloudflareSkillsGlobally } from "../agents-skills-install"; -import { fetchResult } from "../cfetch"; +import { fetchResult, fetchListResult } from "../cfetch"; import { createCloudflareClient } from "../cfetch/internal"; import { readConfig } from "../config"; +import { confirm, prompt } from "../dialogs"; import { run } from "../experimental-flags"; +import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { getMetricsDispatcher } from "../metrics"; import { @@ -265,6 +267,10 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { errors: { UserError, FatalError }, logger, fetchResult, + fetchListResult, + prompt, + confirm, + isNonInteractiveOrCI, }); const durationMs = Date.now() - startTime; diff --git a/packages/wrangler/src/core/types.ts b/packages/wrangler/src/core/types.ts index 48f2b1c69e..7cac3abf24 100644 --- a/packages/wrangler/src/core/types.ts +++ b/packages/wrangler/src/core/types.ts @@ -1,4 +1,5 @@ -import type { fetchResult } from "../cfetch"; +import type { fetchResult, fetchListResult } from "../cfetch"; +import type { confirm, prompt } from "../dialogs"; import type { ExperimentalFlags } from "../experimental-flags"; import type { Logger } from "../logger"; import type { CommonYargsOptions, RemoveIndex } from "../yargs-types"; @@ -109,6 +110,19 @@ export type HandlerContext = { * Use fetchResult to make *auth'd* requests to the Cloudflare API. */ fetchResult: typeof fetchResult; + fetchListResult: typeof fetchListResult; + + /** + * Interactive prompts + */ + confirm: typeof confirm; + prompt: typeof prompt; + + /** + * Whether the process is non-interactive or running in CI. + */ + isNonInteractiveOrCI: () => boolean; + /** * Error classes provided to the command implementor as a convenience * to aid discoverability and to encourage their usage. diff --git a/packages/wrangler/src/deploy/config-diffs.ts b/packages/wrangler/src/deploy/config-diffs.ts index 4a83e69a8f..c78970687e 100644 --- a/packages/wrangler/src/deploy/config-diffs.ts +++ b/packages/wrangler/src/deploy/config-diffs.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { getSubdomainValuesAPIMock } from "../triggers/deploy"; +import { getSubdomainValuesAPIMock } from "@cloudflare/deploy-helpers"; import { diffJsonObjects, isModifiedDiffValue, diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index b38e73cb40..22d717f3da 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { URLSearchParams } from "node:url"; import { cancel } from "@cloudflare/cli-shared-helpers"; import { verifyDockerInstalled } from "@cloudflare/containers-shared"; +import { triggersDeploy } from "@cloudflare/deploy-helpers"; import { APIError, configFileName, @@ -14,15 +15,14 @@ import { parseNonHyphenedUuid, getWranglerTmpDir, UserError, + formatTime, } from "@cloudflare/workers-utils"; -import PQueue from "p-queue"; import { Response } from "undici"; import { buildAssetManifest, syncAssets } from "../assets"; -import { fetchListResult, fetchResult } from "../cfetch"; +import { 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"; @@ -34,6 +34,7 @@ import { } from "../deployment-bundle/module-collection"; import { noBundleWorker } from "../deployment-bundle/no-bundle-worker"; import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; +import { validateRoutes } from "../deployment-bundle/resolve-config-args"; import { addRequiredSecretsInheritBindings, handleMissingSecretsError, @@ -51,19 +52,13 @@ import isInteractive, { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { getMetricsUsageHeaders } from "../metrics"; import { isNavigatorDefined } from "../navigator-user-agent"; -import { - ensureQueuesExistByConfig, - getQueue, - postConsumer, - putConsumer, -} from "../queues/client"; +import { ensureQueuesExistByConfig } 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"; @@ -75,29 +70,22 @@ import { patchNonVersionedScriptSettings, } from "../versions/api"; import { confirmLatestDeploymentOverwrite } from "../versions/deploy"; -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 { PostTypedConsumerBody } from "../queues/client"; +import type { HandlerContext } from "../core/types"; import type { RetrieveSourceMapFunction } from "../sourcemap"; -import type { TriggerDeployment } from "../triggers/deploy"; import type { ApiVersion, Percentage, VersionId } from "../versions/types"; import type { AssetsOptions, CfModule, CfScriptFormat, CfWorkerInit, - ComplianceConfig, Config, - CustomDomainRoute, Entry, LegacyAssetPaths, RawConfig, - Route, - ZoneIdRoute, - ZoneNameRoute, } from "@cloudflare/workers-utils"; import type { FormData } from "undici"; @@ -144,262 +132,6 @@ type Props = { secretsFile: string | undefined; }; -export type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; - -export type CustomDomain = { - id: string; - zone_id: string; - zone_name: string; - hostname: string; - service: string; - environment: string; - enabled: boolean; - previews_enabled: boolean; -}; -type UpdatedCustomDomain = CustomDomain & { modified: boolean }; -type ConflictingCustomDomain = CustomDomain & { - external_dns_record_id?: string; - external_cert_id?: string; -}; - -export type CustomDomainChangeset = { - added: CustomDomain[]; - removed: CustomDomain[]; - updated: UpdatedCustomDomain[]; - conflicting: ConflictingCustomDomain[]; -}; - -export const validateRoutes = (routes: Route[], assets?: AssetsOptions) => { - const invalidRoutes: Record = {}; - const mountedAssetRoutes: string[] = []; - - for (const route of routes) { - if (typeof route !== "string" && route.custom_domain) { - if (route.pattern.includes("*")) { - invalidRoutes[route.pattern] ??= []; - invalidRoutes[route.pattern].push( - `Wildcard operators (*) are not allowed in Custom Domains` - ); - } - if (route.pattern.includes("/")) { - invalidRoutes[route.pattern] ??= []; - invalidRoutes[route.pattern].push( - `Paths are not allowed in Custom Domains` - ); - } - } else if ( - // If we have Assets but we're not always hitting the Worker then validate - assets?.directory !== undefined && - assets.routerConfig.invoke_user_worker_ahead_of_assets !== true - ) { - const pattern = typeof route === "string" ? route : route.pattern; - const components = pattern.split("/"); - - // If this isn't `domain.com/*` then we're mounting to a path - if (!(components.length === 2 && components[1] === "*")) { - mountedAssetRoutes.push(pattern); - } - } - } - if (Object.keys(invalidRoutes).length > 0) { - throw new UserError( - `Invalid Routes:\n` + - Object.entries(invalidRoutes) - .map(([route, errors]) => `${route}:\n` + errors.join("\n")) - .join(`\n\n`), - { telemetryMessage: "deploy invalid routes" } - ); - } - - if (mountedAssetRoutes.length > 0 && assets?.directory !== undefined) { - const relativeAssetsDir = path.relative(process.cwd(), assets.directory); - - logger.once.warn( - `Warning: The following routes will attempt to serve Assets on a configured path:\n${mountedAssetRoutes - .map((route) => { - const routeNoScheme = route.replace(/https?:\/\//g, ""); - const assetPath = path.join( - relativeAssetsDir, - routeNoScheme.substring(routeNoScheme.indexOf("/")) - ); - return ` • ${route} (Will match assets: ${assetPath})`; - }) - .join("\n")}` + - (assets?.routerConfig.has_user_worker - ? "\n\nRequests not matching an asset will be forwarded to the Worker's code." - : "") - ); - } -}; - -export function renderRoute(route: Route): string { - let result = ""; - if (typeof route === "string") { - result = route; - } else { - result = route.pattern; - const isCustomDomain = Boolean( - "custom_domain" in route && route.custom_domain - ); - if (isCustomDomain && "zone_id" in route) { - result += ` (custom domain - zone id: ${route.zone_id})`; - } else if (isCustomDomain && "zone_name" in route) { - result += ` (custom domain - zone name: ${route.zone_name})`; - } else if (isCustomDomain) { - result += ` (custom domain)`; - } else if ("zone_id" in route) { - result += ` (zone id: ${route.zone_id})`; - } else if ("zone_name" in route) { - result += ` (zone name: ${route.zone_name})`; - } - - if (isCustomDomain) { - const flags: string[] = []; - if ("enabled" in route && route.enabled !== undefined) { - flags.push(route.enabled ? "enabled" : "disabled"); - } - if ("previews_enabled" in route && route.previews_enabled !== undefined) { - flags.push( - route.previews_enabled ? "previews: enabled" : "previews: disabled" - ); - } - if (flags.length > 0) { - result += ` [${flags.join(", ")}]`; - } - } - } - return result; -} - -// publishing to custom domains involves a few more steps than just updating -// the routing table, and thus the api implementing it is fairly defensive - -// it will error eagerly on conflicts against existing domains or existing -// managed DNS records - -// however, you can pass params to override the errors. to know if we should -// override the current state, we generate a "changeset" of required actions -// to get to the state we want (specified by the list of custom domains). the -// changeset returns an "updated" collection (existing custom domains -// connected to other scripts) and a "conflicting" collection (the requested -// custom domains that have a managed, conflicting DNS record preventing the -// host's use as a custom domain). with this information, we can prompt to -// the user what will occur if we create the custom domains requested, and -// add the override param if they confirm the action -// -// if a user does not confirm that they want to override, we skip publishing -// to these custom domains, but continue on through the rest of the -// deploy stage -export async function publishCustomDomains( - complianceConfig: ComplianceConfig, - workerUrl: string, - accountId: string, - domains: Array -): Promise { - const options = { - override_scope: true, - override_existing_origin: false, - override_existing_dns_record: false, - }; - const origins = domains.map((domainRoute) => { - return { - hostname: domainRoute.pattern, - zone_id: "zone_id" in domainRoute ? domainRoute.zone_id : undefined, - zone_name: "zone_name" in domainRoute ? domainRoute.zone_name : undefined, - enabled: "enabled" in domainRoute ? domainRoute.enabled : undefined, - previews_enabled: - "previews_enabled" in domainRoute - ? domainRoute.previews_enabled - : undefined, - }; - }); - - const fail = (): TriggerDeployment => { - return { - targets: [], - error: new UserError( - domains.length > 1 - ? `Publishing to ${domains.length} Custom Domains was skipped, fix conflicts and try again` - : `Publishing to Custom Domain "${domains[0].pattern}" was skipped, fix conflict and try again`, - { telemetryMessage: "deploy custom domains skipped" } - ), - }; - }; - - if (!process.stdout.isTTY) { - // running in non-interactive mode. - // existing origins / dns records are not indicative of errors, - // so we aggressively update rather than aggressively fail - options.override_existing_origin = true; - options.override_existing_dns_record = true; - } else { - // get a changeset for operations required to achieve a state with the requested domains - const changeset = await fetchResult( - complianceConfig, - `${workerUrl}/domains/changeset?replace_state=true`, - { - method: "POST", - body: JSON.stringify(origins), - headers: { - "Content-Type": "application/json", - }, - } - ); - - const updatesRequired = changeset.updated.filter( - (domain) => domain.modified - ); - if (updatesRequired.length > 0) { - // find out which scripts the conflict domains are already attached to - // so we can provide that in the confirmation prompt - const existing = await Promise.all( - updatesRequired.map((domain) => - fetchResult( - complianceConfig, - `/accounts/${accountId}/workers/domains/records/${domain.id}` - ) - ) - ); - const existingRendered = existing - .map( - (domain) => - `\t• ${domain.hostname} (used as a domain for "${domain.service}")` - ) - .join("\n"); - const message = `Custom Domains already exist for these domains: -${existingRendered} -Update them to point to this script instead?`; - if (!(await confirm(message))) { - return fail(); - } - options.override_existing_origin = true; - } - - if (changeset.conflicting.length > 0) { - const conflicitingRendered = changeset.conflicting - .map((domain) => `\t• ${domain.hostname}`) - .join("\n"); - const message = `You already have DNS records that conflict for these Custom Domains: -${conflicitingRendered} -Update them to point to this script instead?`; - if (!(await confirm(message))) { - return fail(); - } - options.override_existing_dns_record = true; - } - } - - // deploy to domains - await fetchResult(complianceConfig, `${workerUrl}/domains/records`, { - method: "PUT", - body: JSON.stringify({ ...options, origins }), - headers: { - "Content-Type": "application/json", - }, - }); - - return { targets: 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()` @@ -431,7 +163,10 @@ function addWorkersSitesBindings( return withSites; } -export default async function deploy(props: Props): Promise<{ +export default async function deploy( + props: Props, + ctx: Omit +): Promise<{ sourceMapSize?: number; versionId: string | null; workerTag: string | null; @@ -1318,13 +1053,21 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m deployWfpUserWorker(props.dispatchNamespace, versionId); return { versionId, workerTag }; } - + assert(accountId); // deploy triggers - const targets = await triggersDeploy({ - ...props, - firstDeploy: !workerExists, - routes: allDeploymentRoutes, - }); + const targets = await triggersDeploy( + { + config, + accountId, + scriptName, + env: props.env, + crons: props.triggers || config.triggers?.crons, + useServiceEnvironments, + firstDeploy: !workerExists, + routes: allDeploymentRoutes, + }, + ctx + ); logger.log("Current Version ID:", versionId); @@ -1345,253 +1088,6 @@ function deployWfpUserWorker( logger.log("Current Version ID:", versionId); } -export function formatTime(duration: number) { - return `(${(duration / 1000).toFixed(2)} sec)`; -} - -/** - * Associate the newly deployed Worker with the given routes. - */ -export async function publishRoutes( - complianceConfig: ComplianceConfig, - routes: Route[], - { - workerUrl, - scriptName, - useServiceEnvironments, - accountId, - }: { - workerUrl: string; - scriptName: string; - useServiceEnvironments: boolean; - accountId: string; - } -): Promise { - try { - return await fetchResult(complianceConfig, `${workerUrl}/routes`, { - // Note: PUT will delete previous routes on this script. - method: "PUT", - body: JSON.stringify( - routes.map((route) => - typeof route !== "object" ? { pattern: route } : route - ) - ), - headers: { - "Content-Type": "application/json", - }, - }); - } catch (e) { - if (isAuthenticationError(e)) { - // An authentication error is probably due to a known issue, - // where the user is logged in via an API token that does not have "All Zones". - return await publishRoutesFallback(complianceConfig, routes, { - scriptName, - useServiceEnvironments, - accountId, - }); - } else { - throw e; - } - } -} - -/** - * Try updating routes for the Worker using a less optimal zone-based API. - * - * Compute match zones to the routes, then for each route attempt to connect it to the Worker via the zone. - */ -async function publishRoutesFallback( - complianceConfig: ComplianceConfig, - routes: Route[], - { - scriptName, - useServiceEnvironments, - accountId, - }: { scriptName: string; useServiceEnvironments: boolean; accountId: string } -) { - if (useServiceEnvironments) { - throw new UserError( - "Service environments combined with an API token that doesn't have 'All Zones' permissions is not supported.\n" + - "Either turn off service environments by setting `legacy_env = true`, creating an API token with 'All Zones' permissions, or logging in via OAuth", - { - telemetryMessage: - "deploy service environments require all zones permission", - } - ); - } - logger.info( - "The current authentication token does not have 'All Zones' permissions.\n" + - "Falling back to using the zone-based API endpoint to update each route individually.\n" + - "Note that there is no access to routes associated with zones that the API token does not have permission for.\n" + - "Existing routes for this Worker in such zones will not be deleted." - ); - - const deployedRoutes: string[] = []; - - const queue = new PQueue({ concurrency: 10 }); - const queuePromises: Array> = []; - const zoneIdCache = new Map(); - - // Collect the routes (and their zones) that will be deployed. - const activeZones = new Map(); - const routesToDeploy = new Map(); - for (const route of routes) { - queuePromises.push( - queue.add(async () => { - const zone = await getZoneForRoute( - complianceConfig, - { route, accountId }, - zoneIdCache - ); - if (zone) { - activeZones.set(zone.id, zone.host); - routesToDeploy.set( - typeof route === "string" ? route : route.pattern, - zone.id - ); - } - }) - ); - } - await Promise.all(queuePromises.splice(0, queuePromises.length)); - - // Collect the routes that are already deployed. - const allRoutes = new Map(); - const alreadyDeployedRoutes = new Set(); - for (const [zone, host] of activeZones) { - queuePromises.push( - queue.add(async () => { - try { - for (const { pattern, script } of await fetchListResult<{ - pattern: string; - script: string; - }>(complianceConfig, `/zones/${zone}/workers/routes`)) { - allRoutes.set(pattern, script); - if (script === scriptName) { - alreadyDeployedRoutes.add(pattern); - } - } - } catch (e) { - if (isAuthenticationError(e)) { - e.notes.push({ - text: `This could be because the API token being used does not have permission to access the zone "${host}" (${zone}).`, - }); - } - throw e; - } - }) - ); - } - // using Promise.all() here instead of queue.onIdle() to ensure - // we actually throw errors that occur within queued promises. - await Promise.all(queuePromises); - - // Deploy each route that is not already deployed. - for (const [routePattern, zoneId] of routesToDeploy.entries()) { - if (allRoutes.has(routePattern)) { - const knownScript = allRoutes.get(routePattern); - if (knownScript === scriptName) { - // This route is already associated with this worker, so no need to hit the API. - alreadyDeployedRoutes.delete(routePattern); - continue; - } else { - throw new UserError( - `The route with pattern "${routePattern}" is already associated with another worker called "${knownScript}".`, - { telemetryMessage: "route already associated with another worker" } - ); - } - } - - const { pattern } = await fetchResult<{ pattern: string }>( - complianceConfig, - `/zones/${zoneId}/workers/routes`, - { - method: "POST", - body: JSON.stringify({ - pattern: routePattern, - script: scriptName, - }), - headers: { - "Content-Type": "application/json", - }, - } - ); - - deployedRoutes.push(pattern); - } - - if (alreadyDeployedRoutes.size) { - logger.warn( - "Previously deployed routes:\n" + - "The following routes were already associated with this worker, and have not been deleted:\n" + - [...alreadyDeployedRoutes.values()].map((route) => ` - "${route}"\n`) + - "If these routes are not wanted then you can remove them in the dashboard." - ); - } - - return deployedRoutes; -} - -export async function updateQueueConsumers( - scriptName: string | undefined, - config: Config -): Promise[]> { - const consumers = config.queues.consumers || []; - const updateConsumers: Promise[] = []; - for (const consumer of consumers) { - const queue = await getQueue(config, consumer.queue); - - if (scriptName === undefined) { - // TODO: how can we reliably get the current script name? - throw new UserError("Script name is required to update queue consumers", { - telemetryMessage: "deploy queue consumers missing script name", - }); - } - - const body: PostTypedConsumerBody = { - type: "worker", - dead_letter_queue: consumer.dead_letter_queue, - script_name: scriptName, - settings: { - batch_size: consumer.max_batch_size, - max_retries: consumer.max_retries, - max_wait_time_ms: - consumer.max_batch_timeout !== undefined - ? 1000 * consumer.max_batch_timeout - : undefined, - max_concurrency: consumer.max_concurrency, - retry_delay: consumer.retry_delay, - }, - }; - - // Current script already assigned to queue? - const existingConsumer = - queue.consumers.filter( - (c) => c.script === scriptName || c.service === scriptName - ).length > 0; - const envName = undefined; // TODO: script environment for wrangler deploy? - if (existingConsumer) { - updateConsumers.push( - putConsumer(config, consumer.queue, scriptName, envName, body).then( - () => ({ targets: [`Consumer for ${consumer.queue}`] }), - (error) => ({ targets: [], error }) - ) - ); - continue; - } - updateConsumers.push( - postConsumer(config, consumer.queue, body).then( - () => ({ - targets: [`Consumer for ${consumer.queue}`], - }), - (error) => ({ targets: [], error }) - ) - ); - } - - return updateConsumers; -} - function getDeployConfirmFunction( strictMode = false ): (text: string) => Promise { diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index a27d104b35..c958858c6c 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -115,7 +115,7 @@ export const deployCommand = createCommand({ validateArgs(args) { validateDeployVersionsArgs(args); }, - async handler(args, { config }) { + async handler(args, { config, ...ctx }) { // --- Step 0. Auto-config --- // const autoConfigResult = await maybeRunAutoConfig(args, config); if (autoConfigResult.aborted) { @@ -194,49 +194,52 @@ export const deployCommand = createCommand({ const projectRoot = config.userConfigPath && path.dirname(config.userConfigPath); - const { sourceMapSize, versionId, workerTag, targets } = await deploy({ - 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, - }); + const { sourceMapSize, versionId, workerTag, targets } = await deploy( + { + 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, + }, + ctx + ); writeOutput({ type: "deploy", diff --git a/packages/wrangler/src/deployment-bundle/resolve-config-args.ts b/packages/wrangler/src/deployment-bundle/resolve-config-args.ts new file mode 100644 index 0000000000..c8ef8fcad7 --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/resolve-config-args.ts @@ -0,0 +1,118 @@ +import path from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; +import { getAssetsOptions } from "../assets"; +import { logger } from "../logger"; +import { getScriptName } from "../utils/getScriptName"; +import { useServiceEnvironmentApi } from "../utils/useServiceEnvironments"; +import type { triggersDeployCommand } from "../triggers"; +import type { AssetsOptions, Config, Route } from "@cloudflare/workers-utils"; + +/** + * for wrangler triggers deploy - non dry-run/API calling validation and resolution + */ +export function resolveTriggersInput( + 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); + return { + crons: resolveCronTriggers(args, config), + useServiceEnvironments, + routes: resolveRoutes(args, config, assetsOptions) ?? [], + scriptName, + }; +} + +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; +} + +export const validateRoutes = (routes: Route[], assets?: AssetsOptions) => { + const invalidRoutes: Record = {}; + const mountedAssetRoutes: string[] = []; + + for (const route of routes) { + if (typeof route !== "string" && route.custom_domain) { + if (route.pattern.includes("*")) { + invalidRoutes[route.pattern] ??= []; + invalidRoutes[route.pattern].push( + `Wildcard operators (*) are not allowed in Custom Domains` + ); + } + if (route.pattern.includes("/")) { + invalidRoutes[route.pattern] ??= []; + invalidRoutes[route.pattern].push( + `Paths are not allowed in Custom Domains` + ); + } + } else if ( + // If we have Assets but we're not always hitting the Worker then validate + assets?.directory !== undefined && + assets.routerConfig.invoke_user_worker_ahead_of_assets !== true + ) { + const pattern = typeof route === "string" ? route : route.pattern; + const components = pattern.split("/"); + + // If this isn't `domain.com/*` then we're mounting to a path + if (!(components.length === 2 && components[1] === "*")) { + mountedAssetRoutes.push(pattern); + } + } + } + if (Object.keys(invalidRoutes).length > 0) { + throw new UserError( + `Invalid Routes:\n` + + Object.entries(invalidRoutes) + .map(([route, errors]) => `${route}:\n` + errors.join("\n")) + .join(`\n\n`), + { telemetryMessage: "deploy invalid routes" } + ); + } + + if (mountedAssetRoutes.length > 0 && assets?.directory !== undefined) { + const relativeAssetsDir = path.relative(process.cwd(), assets.directory); + + logger.once.warn( + `Warning: The following routes will attempt to serve Assets on a configured path:\n${mountedAssetRoutes + .map((route) => { + const routeNoScheme = route.replace(/https?:\/\//g, ""); + const assetPath = path.join( + relativeAssetsDir, + routeNoScheme.substring(routeNoScheme.indexOf("/")) + ); + return ` • ${route} (Will match assets: ${assetPath})`; + }) + .join("\n")}` + + (assets?.routerConfig.has_user_worker + ? "\n\nRequests not matching an asset will be forwarded to the Worker's code." + : "") + ); + } +}; diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 3efdf3b242..a76a53c03f 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -5,15 +5,15 @@ import { formatConfigSnippet, UserError, } from "@cloudflare/workers-utils"; +import { getHostFromRoute } from "@cloudflare/workers-utils"; import { isWebContainer } from "@webcontainer/env"; import { convertConfigToBindings } from "./api/startDevWorker/utils"; import { getAssetsOptions } from "./assets"; import { createCommand } from "./core/create-command"; -import { validateRoutes } from "./deploy/deploy"; +import { validateRoutes } from "./deployment-bundle/resolve-config-args"; import { getVarsForDev } from "./dev/dev-vars"; import { startDev } from "./dev/start-dev"; import { logger } from "./logger"; -import { getHostFromRoute } from "./zones"; import type { StartDevWorkerInput, Trigger } from "./api"; import type { EnablePagesAssetsServiceBindingOptions } from "./miniflare-cli/types"; import type { diff --git a/packages/wrangler/src/dev/create-worker-preview.ts b/packages/wrangler/src/dev/create-worker-preview.ts index f4c6b00f99..1d1c26b502 100644 --- a/packages/wrangler/src/dev/create-worker-preview.ts +++ b/packages/wrangler/src/dev/create-worker-preview.ts @@ -1,11 +1,12 @@ import crypto from "node:crypto"; import { URL } from "node:url"; +import { getWorkersDevSubdomain } from "@cloudflare/deploy-helpers"; import { ParseError, parseJSON, UserError } from "@cloudflare/workers-utils"; import { fetch } from "undici"; import { fetchResult } from "../cfetch"; +import { createDeployHelpersContext } from "../core/deploy-helpers-context"; import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; import { logger } from "../logger"; -import { getWorkersDevSubdomain } from "../routes"; import { getAccessHeaders } from "../user/access"; import type { CfWorkerInitWithName } from "./remote"; import type { @@ -218,8 +219,8 @@ export async function createPreviewSession( const subdomain = await getWorkersDevSubdomain( complianceConfig, account.accountId, + createDeployHelpersContext({ apiToken }), { - apiToken, abortSignal: withTimeout(abortSignal), } ); diff --git a/packages/wrangler/src/pages/upload.ts b/packages/wrangler/src/pages/upload.ts index fb1893ba08..59ecf053e5 100644 --- a/packages/wrangler/src/pages/upload.ts +++ b/packages/wrangler/src/pages/upload.ts @@ -5,6 +5,7 @@ import { APIError, COMPLIANCE_REGION_CONFIG_PUBLIC, FatalError, + formatTime, UserError, } from "@cloudflare/workers-utils"; import PQueue from "p-queue"; @@ -448,10 +449,6 @@ export const maxFileCountAllowedFromClaims = (token: string): number => { } }; -function formatTime(duration: number) { - return `(${(duration / 1000).toFixed(2)} sec)`; -} - function renderProgress(done: number, total: number) { const s = spinner(); if (isInteractive()) { diff --git a/packages/wrangler/src/queues/client.ts b/packages/wrangler/src/queues/client.ts index 237147e863..afff8ed787 100644 --- a/packages/wrangler/src/queues/client.ts +++ b/packages/wrangler/src/queues/client.ts @@ -1,102 +1,49 @@ import { URLSearchParams } from "node:url"; +import { + deletePullConsumer as deletePullConsumerImpl, + deleteWorkerConsumer as deleteWorkerConsumerImpl, + getQueue as getQueueImpl, + listConsumers as listConsumersImpl, + listQueues as listQueuesImpl, + postConsumer as postConsumerImpl, + putConsumer as putConsumerImpl, + putConsumerById as putConsumerByIdImpl, +} from "@cloudflare/deploy-helpers"; import { UserError } from "@cloudflare/workers-utils"; import { fetchPagedListResult, fetchResult } from "../cfetch"; -import { logger } from "../logger"; +import { createDeployHelpersContext } from "../core/deploy-helpers-context"; import { requireAuth } from "../user"; import type { CreateEventSubscriptionRequest, EventSubscription, } from "./subscription-types"; +import type { + Consumer, + PostQueueBody, + PostQueueResponse, + PostTypedConsumerBody, + PurgeQueueBody, + QueueResponse, + TypedConsumerResponse, +} from "@cloudflare/deploy-helpers"; import type { Config } from "@cloudflare/workers-utils"; -export interface PostQueueBody { - queue_name: string; - settings?: QueueSettings; -} - -interface WorkerService { - id: string; - default_environment: { - environment: string; - }; -} - -export interface QueueSettings { - delivery_delay?: number; - delivery_paused?: boolean; - message_retention_period?: number; -} - -export interface PostQueueResponse { - queue_id: string; - queue_name: string; - settings?: QueueSettings; - created_on: string; - modified_on: string; -} - -export interface QueueResponse { - queue_id: string; - queue_name: string; - created_on: string; - modified_on: string; - producers: Producer[]; - producers_total_count: number; - consumers: Consumer[]; - consumers_total_count: number; - settings?: QueueSettings; -} - -export interface ScriptReference { - namespace?: string; - script?: string; - service?: string; - environment?: string; -} - -export type Producer = ScriptReference & { - type: string; - bucket_name?: string; -}; - -export type Consumer = ScriptReference & { - dead_letter_queue?: string; - settings: ConsumerSettings; - consumer_id: string; - bucket_name?: string; - type: string; -}; - -export interface TypedConsumerResponse extends Consumer { - queue_name: string; - created_on: string; -} - -export interface PostTypedConsumerBody { - type: string; - script_name?: string; - environment_name?: string; - settings: ConsumerSettings; - dead_letter_queue?: string; -} - -export interface ConsumerSettings { - batch_size?: number; - max_retries?: number; - max_wait_time_ms?: number; - max_concurrency?: number | null; - visibility_timeout_ms?: number; - retry_delay?: number; -} - -export interface PurgeQueueBody { - delete_messages_permanently: boolean; -} - -export interface PurgeQueueResponse { - started_at: string; - complete: boolean; -} +// Queue types now live in `@cloudflare/deploy-helpers`; re-export them here so +// existing `../client` imports across the queue commands keep working. +export type { + Consumer, + ConsumerSettings, + PostQueueBody, + PostQueueResponse, + PostTypedConsumerBody, + Producer, + PurgeQueueBody, + PurgeQueueResponse, + QueueResponse, + QueueSettings, + ScriptReference, + TypedConsumerResponse, +} from "@cloudflare/deploy-helpers"; const queuesUrl = (accountId: string, queueId?: string): string => { let url = `/accounts/${accountId}/queues`; @@ -106,18 +53,6 @@ const queuesUrl = (accountId: string, queueId?: string): string => { return url; }; -const queueConsumersUrl = ( - accountId: string, - queueId: string, - consumerId?: string -): string => { - let url = `${queuesUrl(accountId, queueId)}/consumers`; - if (consumerId) { - url += `/${consumerId}`; - } - return url; -}; - export async function createQueue( config: Config, body: PostQueueBody @@ -162,15 +97,15 @@ export async function listQueues( page?: number, name?: string ): Promise { - page = page ?? 1; const accountId = await requireAuth(config); - const params = new URLSearchParams({ page: page.toString() }); - - if (name) { - params.append("name", name); - } - return fetchResult(config, queuesUrl(accountId), {}, params); + return listQueuesImpl( + config, + accountId, + createDeployHelpersContext(), + page, + name + ); } async function listAllQueues( @@ -191,14 +126,13 @@ export async function getQueue( config: Config, queueName: string ): Promise { - const queues = await listQueues(config, 1, queueName); - if (queues.length === 0) { - throw new UserError( - `Queue "${queueName}" does not exist. To create it, run: wrangler queues create ${queueName}`, - { telemetryMessage: "queues lookup missing queue" } - ); - } - return queues[0]; + const accountId = await requireAuth(config); + return getQueueImpl( + config, + accountId, + queueName, + createDeployHelpersContext() + ); } export async function ensureQueuesExistByConfig(config: Config) { @@ -267,21 +201,15 @@ export async function postConsumer( config: Config, queueName: string, body: PostTypedConsumerBody -): Promise { - const queue = await getQueue(config, queueName); - return postConsumerById(config, queue.queue_id, body); -} - -async function postConsumerById( - config: Config, - queueId: string, - body: PostTypedConsumerBody ): Promise { const accountId = await requireAuth(config); - return fetchResult(config, queueConsumersUrl(accountId, queueId), { - method: "POST", - body: JSON.stringify(body), - }); + return postConsumerImpl( + config, + accountId, + queueName, + body, + createDeployHelpersContext() + ); } export async function putConsumerById( @@ -291,13 +219,13 @@ export async function putConsumerById( body: PostTypedConsumerBody ): Promise { const accountId = await requireAuth(config); - return fetchResult( + return putConsumerByIdImpl( config, - queueConsumersUrl(accountId, queueId, consumerId), - { - method: "PUT", - body: JSON.stringify(body), - } + accountId, + queueId, + consumerId, + body, + createDeployHelpersContext() ); } @@ -308,84 +236,15 @@ export async function putConsumer( envName: string | undefined, body: PostTypedConsumerBody ): Promise { - const queue = await getQueue(config, queueName); - const targetConsumer = await resolveWorkerConsumerByName( + const accountId = await requireAuth(config); + return putConsumerImpl( config, + accountId, + queueName, scriptName, envName, - queue - ); - return putConsumerById( - config, - queue.queue_id, - targetConsumer.consumer_id, - body - ); -} - -async function resolveWorkerConsumerByName( - config: Config, - consumerName: string, - envName: string | undefined, - queue: QueueResponse -): Promise { - const queueName = queue.queue_name; - const consumers = queue.consumers.filter( - (c) => - c.type === "worker" && - (c.script === consumerName || c.service === consumerName) - ); - - if (consumers.length === 0) { - throw new UserError( - `No worker consumer '${consumerName}' exists for queue ${queue.queue_name}`, - { telemetryMessage: "queues worker consumer missing" } - ); - } - - // If more than a consumer with the same name is found, it should be - // a service+environment combination - if (consumers.length > 1) { - const targetEnv = - envName ?? (await getDefaultService(config, consumerName)); - const targetConsumers = consumers.filter( - (c) => c.environment === targetEnv - ); - - if (targetConsumers.length === 0) { - throw new UserError( - `No worker consumer '${consumerName}' exists for queue ${queueName}`, - { telemetryMessage: "queues worker consumer missing environment" } - ); - } - return consumers[0]; - } - - if (consumers[0].service) { - const targetEnv = - envName ?? (await getDefaultService(config, consumerName)); - if (targetEnv != consumers[0].environment) { - throw new UserError( - `No worker consumer '${consumerName}' exists for queue ${queueName}`, - { telemetryMessage: "queues worker consumer environment mismatch" } - ); - } - } - return consumers[0]; -} - -async function deleteConsumerById( - config: Config, - queueId: string, - consumerId: string -): Promise { - const accountId = await requireAuth(config); - return fetchResult( - config, - queueConsumersUrl(accountId, queueId, consumerId), - { - method: "DELETE", - } + body, + createDeployHelpersContext() ); } @@ -393,40 +252,26 @@ export async function deletePullConsumer( config: Config, queueName: string ): Promise { - const queue = await getQueue(config, queueName); - const consumer = queue.consumers[0]; - if (consumer?.type !== "http_pull") { - throw new UserError(`No http_pull consumer exists for queue ${queueName}`, { - telemetryMessage: "queues http pull consumer missing", - }); - } - return deleteConsumerById(config, queue.queue_id, consumer.consumer_id); -} - -async function getDefaultService( - config: Config, - serviceName: string -): Promise { const accountId = await requireAuth(config); - const service = await fetchResult( + return deletePullConsumerImpl( config, - `/accounts/${accountId}/workers/services/${serviceName}`, - { - method: "GET", - } + accountId, + queueName, + createDeployHelpersContext() ); - - logger.info(service); - - return service.default_environment.environment; } export async function listConsumers( config: Config, queueName: string ): Promise { - const queue = await getQueue(config, queueName); - return queue.consumers; + const accountId = await requireAuth(config); + return listConsumersImpl( + config, + accountId, + queueName, + createDeployHelpersContext() + ); } export async function deleteWorkerConsumer( @@ -435,14 +280,15 @@ export async function deleteWorkerConsumer( scriptName: string, envName?: string ): Promise { - const queue = await getQueue(config, queueName); - const targetConsumer = await resolveWorkerConsumerByName( + const accountId = await requireAuth(config); + return deleteWorkerConsumerImpl( config, + accountId, + queueName, scriptName, envName, - queue + createDeployHelpersContext() ); - return deleteConsumerById(config, queue.queue_id, targetConsumer.consumer_id); } export async function purgeQueue( diff --git a/packages/wrangler/src/triggers/index.ts b/packages/wrangler/src/triggers/index.ts index 01141ad89b..ffdebca065 100644 --- a/packages/wrangler/src/triggers/index.ts +++ b/packages/wrangler/src/triggers/index.ts @@ -1,10 +1,10 @@ -import { getAssetsOptions } from "../assets"; +import { triggersDeploy } from "@cloudflare/deploy-helpers"; import { createCommand, createNamespace } from "../core/create-command"; +import { resolveTriggersInput } from "../deployment-bundle/resolve-config-args"; +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({ metadata: { @@ -61,28 +61,30 @@ export const triggersDeployCommand = createCommand({ behaviour: { warnIfMultipleEnvsConfiguredButNoneSpecified: true, }, - async handler(args, { config }) { - const assetsOptions = getAssetsOptions({ - args: { assets: undefined }, - config, - }); + async handler(args, { config, ...ctx }) { metrics.sendMetricsEvent("deploy worker triggers", { sendMetrics: config.send_metrics, }); + const props = resolveTriggersInput(args, config); - const accountId = args.dryRun ? undefined : await requireAuth(config); + if (args.dryRun) { + logger.log(`--dry-run: exiting now.`); + return; + } - 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. - }); + // Any validation that requires auth goes below + const accountId = await requireAuth(config); + await ensureQueuesExistByConfig(config); + + await triggersDeploy( + { + config, + accountId, + env: args.env, + firstDeploy: false, + ...props, + }, + ctx + ); }, }); 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 e68554b0c7..121e62af2f 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -4,6 +4,7 @@ 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 { getWorkersDevSubdomain } from "@cloudflare/deploy-helpers"; import { configFileName, getTodaysCompatDate, @@ -14,6 +15,7 @@ import { getWranglerTmpDir, ParseError, UserError, + formatTime, } from "@cloudflare/workers-utils"; import { Response } from "undici"; import { @@ -23,6 +25,7 @@ import { } from "../assets"; import { fetchResult } from "../cfetch"; import { createCommand } from "../core/create-command"; +import { createDeployHelpersContext } from "../core/deploy-helpers-context"; import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; import { bundleWorker } from "../deployment-bundle/bundle"; import { printBundleSize } from "../deployment-bundle/bundle-reporter"; @@ -59,7 +62,6 @@ import * as metrics from "../metrics"; import { isNavigatorDefined } from "../navigator-user-agent"; import { writeOutput } from "../output"; import { ensureQueuesExistByConfig } from "../queues/client"; -import { getWorkersDevSubdomain } from "../routes"; import { parseBulkInputToObject } from "../secret"; import { getSourceMappedString, @@ -820,9 +822,14 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m }>(config, `${workerUrl}/subdomain`); if (previews_available_on_subdomain) { - const userSubdomain = await getWorkersDevSubdomain(config, accountId, { - configPath: config.configPath, - }); + const userSubdomain = await getWorkersDevSubdomain( + config, + accountId, + createDeployHelpersContext(), + { + configPath: config.configPath, + } + ); const shortVersion = versionId.slice(0, 8); versionPreviewUrl = `https://${shortVersion}-${workerName}.${userSubdomain}`; logger.log(`Version Preview URL: ${versionPreviewUrl}`); @@ -849,10 +856,6 @@ Changes to triggers (routes, custom domains, cron schedules, etc) must be applie return { versionId, workerTag, versionPreviewUrl, versionPreviewAliasUrl }; } -function formatTime(duration: number) { - return `(${(duration / 1000).toFixed(2)} sec)`; -} - // Constants for DNS label constraints and hash configuration const MAX_DNS_LABEL_LENGTH = 63; const HASH_LENGTH = 4; diff --git a/packages/wrangler/src/zones.ts b/packages/wrangler/src/zones.ts index 17eec0f29c..7db007dac3 100644 --- a/packages/wrangler/src/zones.ts +++ b/packages/wrangler/src/zones.ts @@ -1,139 +1,16 @@ +import { getZoneForRoute, getZoneIdFromHost } from "@cloudflare/deploy-helpers"; import { configFileName, UserError } from "@cloudflare/workers-utils"; import { fetchListResult } from "./cfetch"; -import { retryOnAPIFailure } from "./utils/retry"; +import { createDeployHelpersContext } from "./core/deploy-helpers-context"; +import type { ZoneIdCache } from "@cloudflare/deploy-helpers"; import type { ComplianceConfig, Route } from "@cloudflare/workers-utils"; -/** - * An object holding information about a zone for publishing. - */ -export interface Zone { - id: string; - host: string; -} - -/** - * Get the hostname on which to run a Worker. - * - * The most accurate place is usually - * `route.pattern`, as that includes any subdomains. For example: - * ```js - * { - * pattern: foo.example.com - * zone_name: example.com - * } - * ``` - * However, in the case of patterns that _can't_ be parsed as a hostname - * (primarily the pattern `*/ /*`), we fall back to the `zone_name` - * (and in the absence of that return undefined). - * @param route - */ -export function getHostFromRoute(route: Route): string | undefined { - let host: string | undefined; - - if (typeof route === "string") { - host = getHostFromUrl(route); - } else if (typeof route === "object") { - host = getHostFromUrl(route.pattern); - - if (host === undefined && "zone_name" in route) { - host = getHostFromUrl(route.zone_name); - } - } - - return host; -} - -/** - * Best-effort derivation of the Cloudflare zone name that owns a given route, - * for use as the `CF-Worker` header value on outbound subrequests in local - * development (see https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker). - * - * In production, `CF-Worker` is set to the zone name — for a route - * `foo.example.com/*` on zone `example.com`, the header is `example.com`. - * When the user has explicitly told us the zone name in their route config - * (`zone_name`), use it. Otherwise, fall back to {@link getHostFromRoute}, - * which returns the route pattern's hostname — this is the closest local - * approximation without performing an API lookup, and matches the behaviour - * users see when their route's hostname is already the apex (e.g. - * `example.com/*`). - */ -export function getZoneFromRoute(route: Route): string | undefined { - if (typeof route === "object" && "zone_name" in route && route.zone_name) { - return route.zone_name; - } - return getHostFromRoute(route); -} +export { + getHostFromRoute, + getHostFromUrl, + getZoneFromRoute, +} from "@cloudflare/workers-utils"; -/** - * Try to compute the a zone ID and host name for a route. - * - * When we're given a route, we do 2 things: - * - We try to extract a host from it - * - We try to get a zone id from the host - */ -export async function getZoneForRoute( - complianceConfig: ComplianceConfig, - from: { - route: Route; - accountId: string; - }, - zoneIdCache: ZoneIdCache = new Map() -): Promise { - const { route, accountId } = from; - const host = getHostFromRoute(route); - let id: string | undefined; - - if (typeof route === "object" && "zone_id" in route) { - id = route.zone_id; - } else if (typeof route === "object" && "zone_name" in route) { - id = await getZoneIdFromHost( - complianceConfig, - { - host: route.zone_name, - accountId, - }, - zoneIdCache - ); - } else if (host) { - id = await getZoneIdFromHost( - complianceConfig, - { host, accountId }, - zoneIdCache - ); - } - - return id && host ? { id, host } : undefined; -} - -/** - * Given something that resembles a URL, try to extract a host from it. - */ -export function getHostFromUrl(urlLike: string): string | undefined { - // if the urlLike-pattern uses a splat for the entire host and is only concerned with the pathname, we cannot infer a host - if ( - urlLike.startsWith("*/") || - urlLike.startsWith("http://*/") || - urlLike.startsWith("https://*/") - ) { - return undefined; - } - - // if the urlLike-pattern uses a splat for the sub-domain (*.example.com) or for the root-domain (*example.com), remove the wildcard parts - urlLike = urlLike.replace(/\*(\.)?/g, ""); - - // prepend a protocol if the pattern did not specify one - if (!(urlLike.startsWith("http://") || urlLike.startsWith("https://"))) { - urlLike = "http://" + urlLike; - } - - // now we've done our best to make urlLike a valid url string which we can pass to `new URL()` to get the host - // if it still isn't, return undefined to indicate we couldn't infer a host - try { - return new URL(urlLike).host; - } catch { - return undefined; - } -} export async function getZoneIdForPreview( complianceConfig: ComplianceConfig, from: { @@ -142,6 +19,7 @@ export async function getZoneIdForPreview( accountId: string; } ) { + const ctx = createDeployHelpersContext(); const zoneIdCache: ZoneIdCache = new Map(); const { host, routes, accountId } = from; let zoneId: string | undefined; @@ -149,6 +27,7 @@ export async function getZoneIdForPreview( zoneId = await getZoneIdFromHost( complianceConfig, { host, accountId }, + ctx, zoneIdCache ); } @@ -160,6 +39,7 @@ export async function getZoneIdForPreview( route: firstRoute, accountId, }, + ctx, zoneIdCache ); if (zone) { @@ -169,61 +49,6 @@ export async function getZoneIdForPreview( return zoneId; } -/** - * A mapping from account:host to zone id. - */ -export type ZoneIdCache = Map>; - -/** - * Given something that resembles a host, try to infer a zone id from it. - * - * It's hard to get a 'valid' domain from a string, so we don't even try to validate TLDs, etc. - * For each domain-like part of the host (e.g. w.x.y.z) try to get a zone id for it by - * lopping off subdomains until we get a hit from the API. - */ -async function getZoneIdFromHost( - complianceConfig: ComplianceConfig, - from: { - host: string; - accountId: string; - }, - zoneIdCache: ZoneIdCache -): Promise { - const hostPieces = from.host.split("."); - - while (hostPieces.length > 1) { - const cacheKey = `${from.accountId}:${hostPieces.join(".")}`; - if (!zoneIdCache.has(cacheKey)) { - zoneIdCache.set( - cacheKey, - retryOnAPIFailure(() => - fetchListResult<{ id: string }>( - complianceConfig, - `/zones`, - {}, - new URLSearchParams({ - name: hostPieces.join("."), - "account.id": from.accountId, - }) - ) - ).then((zones) => zones[0]?.id ?? null) - ); - } - - const cachedZone = await zoneIdCache.get(cacheKey); - if (cachedZone) { - return cachedZone; - } - - hostPieces.shift(); - } - - throw new UserError( - `Could not find zone for \`${from.host}\`. Make sure the domain is set up to be proxied by Cloudflare.\nFor more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route`, - { telemetryMessage: "zones route zone not found" } - ); -} - /** * An object holding information about an assigned worker route, returned from the API */ @@ -300,10 +125,14 @@ export async function getWorkerForZone( configPath: string | undefined ) { const { worker, accountId } = from; - const zone = await getZoneForRoute(complianceConfig, { - route: worker, - accountId, - }); + const zone = await getZoneForRoute( + complianceConfig, + { + route: worker, + accountId, + }, + createDeployHelpersContext() + ); if (!zone) { throw new UserError( `The route '${worker}' is not part of one of your zones. Either add this zone from the Cloudflare dashboard, or try using a route within one of your existing zones.`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e414cda84d..7c8b199612 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1788,6 +1788,10 @@ importers: version: 17.7.2 packages/deploy-helpers: + dependencies: + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils devDependencies: '@cloudflare/containers-shared': specifier: workspace:* @@ -1795,18 +1799,21 @@ importers: '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig - '@cloudflare/workers-utils': - specifier: workspace:* - version: link:../workers-utils '@types/node': specifier: 22.15.17 version: 22.15.17 + chalk: + specifier: ^5.2.0 + version: 5.3.0 concurrently: specifier: ^8.2.2 version: 8.2.2 miniflare: specifier: workspace:* version: link:../miniflare + p-queue: + specifier: ^9.0.0 + version: 9.0.0 tsup: specifier: 8.3.0 version: 8.3.0(@microsoft/api-extractor@7.52.8(@types/node@22.15.17))(jiti@2.6.1)(postcss@8.5.14)(supports-color@9.2.2)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.1) From b502d5445b9e9e030020a3d65c0334507393aa64 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:17:03 +0100 Subject: [PATCH 6/6] Rename web_search binding kind to websearch (#14164) --- .../rename-web-search-binding-to-websearch.md | 18 +++++++++++++ packages/miniflare/src/plugins/index.ts | 8 +++--- .../{web-search => websearch}/index.ts | 26 +++++++++---------- .../src/config/binding-local-support.ts | 2 +- packages/workers-utils/src/config/config.ts | 2 +- .../workers-utils/src/config/environment.ts | 2 +- .../workers-utils/src/config/validation.ts | 12 ++++----- .../src/map-worker-metadata-bindings.ts | 4 +-- packages/workers-utils/src/types.ts | 4 +-- .../normalize-and-validate-config.test.ts | 18 ++++++------- .../src/__tests__/type-generation.test.ts | 2 +- .../wrangler/src/api/startDevWorker/utils.ts | 6 ++--- .../deploy/check-remote-secrets-override.ts | 2 +- packages/wrangler/src/deploy/config-diffs.ts | 4 +-- .../create-worker-upload-form.ts | 8 +++--- packages/wrangler/src/dev/miniflare/index.ts | 12 ++++----- packages/wrangler/src/index.ts | 8 +++--- .../wrangler/src/type-generation/index.ts | 22 ++++++++-------- .../src/utils/add-created-resource-config.ts | 2 +- packages/wrangler/src/utils/print-bindings.ts | 8 +++--- packages/wrangler/src/websearch/index.ts | 2 +- packages/wrangler/src/websearch/search.ts | 2 +- 22 files changed, 96 insertions(+), 78 deletions(-) create mode 100644 .changeset/rename-web-search-binding-to-websearch.md rename packages/miniflare/src/plugins/{web-search => websearch}/index.ts (69%) diff --git a/.changeset/rename-web-search-binding-to-websearch.md b/.changeset/rename-web-search-binding-to-websearch.md new file mode 100644 index 0000000000..1f148bc459 --- /dev/null +++ b/.changeset/rename-web-search-binding-to-websearch.md @@ -0,0 +1,18 @@ +--- +"@cloudflare/workers-utils": minor +"miniflare": minor +"wrangler": minor +--- + +Rename the `web_search` binding kind to `websearch` + +Pre-launch rename of the public binding type from `web_search` to `websearch` so the on-the-wire shape matches the product name (Web Search). The wrangler config key, the binding-type string sent to the Cloudflare API, and the miniflare option key all move from `web_search` / `webSearch` to `websearch`. + +Update your wrangler config: + +```diff +- "web_search": { "binding": "WEBSEARCH" } ++ "websearch": { "binding": "WEBSEARCH" } +``` + +The runtime `WebSearch` type exposed on `env.WEBSEARCH` is unchanged. diff --git a/packages/miniflare/src/plugins/index.ts b/packages/miniflare/src/plugins/index.ts index 31b220c15b..f60738d9d2 100644 --- a/packages/miniflare/src/plugins/index.ts +++ b/packages/miniflare/src/plugins/index.ts @@ -41,7 +41,7 @@ import { } from "./version-metadata"; import { VPC_NETWORKS_PLUGIN, VPC_NETWORKS_PLUGIN_NAME } from "./vpc-networks"; import { VPC_SERVICES_PLUGIN, VPC_SERVICES_PLUGIN_NAME } from "./vpc-services"; -import { WEB_SEARCH_PLUGIN, WEB_SEARCH_PLUGIN_NAME } from "./web-search"; +import { WEBSEARCH_PLUGIN, WEBSEARCH_PLUGIN_NAME } from "./websearch"; import { WORKER_LOADER_PLUGIN, WORKER_LOADER_PLUGIN_NAME, @@ -70,7 +70,7 @@ export const PLUGINS = { [AI_PLUGIN_NAME]: AI_PLUGIN, [AGENT_MEMORY_PLUGIN_NAME]: AGENT_MEMORY_PLUGIN, [AI_SEARCH_PLUGIN_NAME]: AI_SEARCH_PLUGIN, - [WEB_SEARCH_PLUGIN_NAME]: WEB_SEARCH_PLUGIN, + [WEBSEARCH_PLUGIN_NAME]: WEBSEARCH_PLUGIN, [BROWSER_RENDERING_PLUGIN_NAME]: BROWSER_RENDERING_PLUGIN, [DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN, [IMAGES_PLUGIN_NAME]: IMAGES_PLUGIN, @@ -141,7 +141,7 @@ export type WorkerOptions = z.input & z.input & z.input & z.input & - z.input & + z.input & z.input & z.input & z.input & @@ -234,7 +234,7 @@ export * from "./analytics-engine"; export * from "./ai"; export * from "./agent-memory"; export * from "./ai-search"; -export * from "./web-search"; +export * from "./websearch"; export * from "./browser-rendering"; export * from "./dispatch-namespace"; export * from "./images"; diff --git a/packages/miniflare/src/plugins/web-search/index.ts b/packages/miniflare/src/plugins/websearch/index.ts similarity index 69% rename from packages/miniflare/src/plugins/web-search/index.ts rename to packages/miniflare/src/plugins/websearch/index.ts index 150f50f935..4b0a1acb9e 100644 --- a/packages/miniflare/src/plugins/web-search/index.ts +++ b/packages/miniflare/src/plugins/websearch/index.ts @@ -6,22 +6,22 @@ import { } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; -const WebSearchEntrySchema = z.object({ +const WebsearchEntrySchema = z.object({ remoteProxyConnectionString: z .custom() .optional(), }); -export const WebSearchOptionsSchema = z.object({ - webSearch: z.record(WebSearchEntrySchema).optional(), +export const WebsearchOptionsSchema = z.object({ + websearch: z.record(WebsearchEntrySchema).optional(), }); -export const WEB_SEARCH_PLUGIN_NAME = "web-search"; +export const WEBSEARCH_PLUGIN_NAME = "websearch"; -const WEB_SEARCH_SCOPE = "web-search"; +const WEBSEARCH_SCOPE = "websearch"; -export const WEB_SEARCH_PLUGIN: Plugin = { - options: WebSearchOptionsSchema, +export const WEBSEARCH_PLUGIN: Plugin = { + options: WebsearchOptionsSchema, async getBindings(options) { const bindings: { name: string; @@ -29,13 +29,13 @@ export const WEB_SEARCH_PLUGIN: Plugin = { }[] = []; for (const [bindingName, entry] of Object.entries( - options.webSearch ?? {} + options.websearch ?? {} )) { bindings.push({ name: bindingName, service: { name: getUserBindingServiceName( - WEB_SEARCH_SCOPE, + WEBSEARCH_SCOPE, bindingName, entry.remoteProxyConnectionString ), @@ -45,10 +45,10 @@ export const WEB_SEARCH_PLUGIN: Plugin = { return bindings; }, - getNodeBindings(options: z.infer) { + getNodeBindings(options: z.infer) { const nodeBindings: Record = {}; - for (const bindingName of Object.keys(options.webSearch ?? {})) { + for (const bindingName of Object.keys(options.websearch ?? {})) { nodeBindings[bindingName] = new ProxyNodeBinding(); } @@ -61,11 +61,11 @@ export const WEB_SEARCH_PLUGIN: Plugin = { }[] = []; for (const [bindingName, entry] of Object.entries( - options.webSearch ?? {} + options.websearch ?? {} )) { services.push({ name: getUserBindingServiceName( - WEB_SEARCH_SCOPE, + WEBSEARCH_SCOPE, bindingName, entry.remoteProxyConnectionString ), diff --git a/packages/workers-utils/src/config/binding-local-support.ts b/packages/workers-utils/src/config/binding-local-support.ts index 3052bc02aa..3d36d001e8 100644 --- a/packages/workers-utils/src/config/binding-local-support.ts +++ b/packages/workers-utils/src/config/binding-local-support.ts @@ -69,7 +69,7 @@ const BINDING_LOCAL_SUPPORT: Record< flagship: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", vpc_service: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", vpc_network: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", - web_search: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", + websearch: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", agent_memory: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", }; diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index eab814ae93..b7f36973ff 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -336,7 +336,7 @@ export const defaultWranglerConfig: Config = { vectorize: [], ai_search_namespaces: [], ai_search: [], - web_search: undefined, + websearch: undefined, agent_memory: [], hyperdrive: [], workflows: [], diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 018a61bc60..a69f1ed315 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1083,7 +1083,7 @@ export interface EnvironmentNonInheritable { * @default {} * @nonInheritable */ - web_search: + websearch: | { /** The binding name used to refer to Web Search in the Worker. */ binding: string; diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index a1bdda5b0a..61612f1e76 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -82,7 +82,7 @@ export type ConfigBindingFieldName = | "vectorize" | "ai_search_namespaces" | "ai_search" - | "web_search" + | "websearch" | "agent_memory" | "hyperdrive" | "r2_buckets" @@ -126,7 +126,7 @@ export const friendlyBindingNames: Record = { vectorize: "Vectorize Index", ai_search_namespaces: "AI Search Namespace", ai_search: "AI Search Instance", - web_search: "Web Search", + websearch: "Web Search", agent_memory: "Agent Memory", hyperdrive: "Hyperdrive Config", r2_buckets: "R2 Bucket", @@ -185,7 +185,7 @@ const bindingTypeFriendlyNames: Record = { vectorize: "Vectorize Index", ai_search_namespace: "AI Search Namespace", ai_search: "AI Search Instance", - web_search: "Web Search", + websearch: "Web Search", agent_memory: "Agent Memory", hyperdrive: "Hyperdrive Config", service: "Worker", @@ -1762,13 +1762,13 @@ function normalizeAndValidateEnvironment( validateBindingArray(envName, validateAISearchBinding), [] ), - web_search: notInheritable( + websearch: notInheritable( diagnostics, topLevelEnv, rawConfig, rawEnv, envName, - "web_search", + "websearch", validateNamedSimpleBinding(envName), undefined ), @@ -3090,7 +3090,7 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => { "ai", "ai_search_namespace", "ai_search", - "web_search", + "websearch", "agent_memory", "kv_namespace", "durable_object_namespace", diff --git a/packages/workers-utils/src/map-worker-metadata-bindings.ts b/packages/workers-utils/src/map-worker-metadata-bindings.ts index 8f52db82bd..987f4f4ef2 100644 --- a/packages/workers-utils/src/map-worker-metadata-bindings.ts +++ b/packages/workers-utils/src/map-worker-metadata-bindings.ts @@ -298,9 +298,9 @@ export function mapWorkerMetadataBindings( }, ]; break; - case "web_search": + case "websearch": { - configObj.web_search = { + configObj.websearch = { binding: binding.name, }; } diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index c3a43716f8..2a0e530f8c 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -76,7 +76,7 @@ export type WorkerMetadataBinding = | { type: "data_blob"; name: string; part: string } | { type: "ai_search_namespace"; name: string; namespace: string } | { type: "ai_search"; name: string; instance_name: string } - | { type: "web_search"; name: string } + | { type: "websearch"; name: string } | { type: "agent_memory"; name: string; namespace: string } | { type: "kv_namespace"; name: string; namespace_id: string; raw?: boolean } | { type: "media"; name: string } @@ -373,7 +373,7 @@ export type Binding = | ({ type: "vectorize" } & BindingOmit) | ({ type: "ai_search_namespace" } & BindingOmit) | ({ type: "ai_search" } & BindingOmit) - | ({ type: "web_search" } & BindingOmit) + | ({ type: "websearch" } & BindingOmit) | ({ type: "agent_memory" } & BindingOmit) | ({ type: "hyperdrive" } & BindingOmit) | ({ type: "service" } & BindingOmit) diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index 7704b58233..d28b2e426c 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -90,7 +90,7 @@ describe("normalizeAndValidateConfig()", () => { text_blobs: undefined, browser: undefined, ai: undefined, - web_search: undefined, + websearch: undefined, version_metadata: undefined, triggers: { crons: undefined, @@ -2021,10 +2021,10 @@ describe("normalizeAndValidateConfig()", () => { }); }); - describe("[web_search]", () => { - it("should accept a valid web_search binding", ({ expect }) => { + describe("[websearch]", () => { + it("should accept a valid websearch binding", ({ expect }) => { const { diagnostics } = normalizeAndValidateConfig( - { web_search: { binding: "WEBSEARCH" } } as RawConfig, + { websearch: { binding: "WEBSEARCH" } } as RawConfig, undefined, undefined, { env: undefined } @@ -2034,9 +2034,9 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasWarnings()).toBe(false); }); - it("should error if web_search is an array", ({ expect }) => { + it("should error if websearch is an array", ({ expect }) => { const { diagnostics } = normalizeAndValidateConfig( - { web_search: [] } as unknown as RawConfig, + { websearch: [] } as unknown as RawConfig, undefined, undefined, { env: undefined } @@ -2045,13 +2045,13 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasWarnings()).toBe(false); expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - - The field "web_search" should be an object but got []." + - The field "websearch" should be an object but got []." `); }); - it("should error if web_search has no binding name", ({ expect }) => { + it("should error if websearch has no binding name", ({ expect }) => { const { diagnostics } = normalizeAndValidateConfig( - { web_search: {} } as unknown as RawConfig, + { websearch: {} } as unknown as RawConfig, undefined, undefined, { env: undefined } diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 87207ae930..9039831e86 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -554,7 +554,7 @@ const bindingsConfigMock: Omit< }, ], vpc_networks: [], - web_search: undefined, + websearch: undefined, }; describe("generate types - CLI", () => { diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 0eadbc98c6..88902e5a8d 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -320,9 +320,9 @@ export function convertConfigToBindings( } break; } - case "web_search": { + case "websearch": { const { binding, ...x } = info; - output[binding] = { type: "web_search", ...x }; + output[binding] = { type: "websearch", ...x }; break; } case "agent_memory": { @@ -744,7 +744,7 @@ export function convertWorkerMetadataBindingsToFlatBindings( case "stream": case "version_metadata": case "media": - case "web_search": + case "websearch": case "inherit": { // These have the same structure (just type and possibly some flags) const { name: _name, ...rest } = binding; diff --git a/packages/wrangler/src/deploy/check-remote-secrets-override.ts b/packages/wrangler/src/deploy/check-remote-secrets-override.ts index d0003a169f..7f2a3223c9 100644 --- a/packages/wrangler/src/deploy/check-remote-secrets-override.ts +++ b/packages/wrangler/src/deploy/check-remote-secrets-override.ts @@ -125,7 +125,7 @@ function extractBindingNames(config: Config): string[] { } case "browser": case "ai": - case "web_search": { + case "websearch": { const value: Config[typeof key] = untypedValue; return value ? [value.binding] : []; } diff --git a/packages/wrangler/src/deploy/config-diffs.ts b/packages/wrangler/src/deploy/config-diffs.ts index c78970687e..6f0298a2c9 100644 --- a/packages/wrangler/src/deploy/config-diffs.ts +++ b/packages/wrangler/src/deploy/config-diffs.ts @@ -55,7 +55,7 @@ const reorderableBindings = { images: false, stream: false, media: false, - web_search: false, + websearch: false, version_metadata: false, unsafe: false, assets: false, @@ -263,7 +263,7 @@ function removeRemoteConfigFieldFromBindings(normalizedConfig: Config): void { "images", "stream", "media", - "web_search", + "websearch", ] as const; for (const singleBindingField of singleBindingFields) { if ( diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index 34b50a3a9e..360d19dd9e 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -131,7 +131,7 @@ export function createWorkerUploadForm( bindings ); const ai_search = extractBindingsOfType("ai_search", bindings); - const web_search = extractBindingsOfType("web_search", bindings)[0]; + const websearch = extractBindingsOfType("websearch", bindings)[0]; const agent_memory = extractBindingsOfType("agent_memory", bindings); const hyperdrive = extractBindingsOfType("hyperdrive", bindings); const secrets_store_secrets = extractBindingsOfType( @@ -369,10 +369,10 @@ export function createWorkerUploadForm( }); }); - if (web_search !== undefined) { + if (websearch !== undefined) { metadataBindings.push({ - name: web_search.binding, - type: "web_search", + name: websearch.binding, + type: "websearch", }); } diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 6ea53b94de..5b00d3ca4a 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -423,7 +423,7 @@ type WorkerOptionsBindings = Pick< | "ai" | "aiSearchNamespaces" | "aiSearchInstances" - | "webSearch" + | "websearch" | "agentMemory" | "textBlobBindings" | "dataBlobBindings" @@ -545,7 +545,7 @@ export function buildMiniflareBindingOptions( bindings ); const aiSearchInstanceBindings = extractBindingsOfType("ai_search", bindings); - const webSearchBindings = extractBindingsOfType("web_search", bindings); + const websearchBindings = extractBindingsOfType("websearch", bindings); const agentMemoryBindings = extractBindingsOfType("agent_memory", bindings); const imagesBindings = extractBindingsOfType("images", bindings); const mediaBindings = extractBindingsOfType("media", bindings); @@ -655,8 +655,8 @@ export function buildMiniflareBindingOptions( warnOrError("ai_search", inst.remote); } - for (const ws of webSearchBindings) { - warnOrError("web_search", ws.remote); + for (const ws of websearchBindings) { + warnOrError("websearch", ws.remote); } for (const memory of agentMemoryBindings) { @@ -785,8 +785,8 @@ export function buildMiniflareBindingOptions( ]) ), - webSearch: Object.fromEntries( - webSearchBindings.map((ws) => [ + websearch: Object.fromEntries( + websearchBindings.map((ws) => [ ws.binding, { remoteProxyConnectionString, diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index bf0b4e65c6..59913c3b81 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -471,8 +471,8 @@ import { vpcServiceGetCommand } from "./vpc/get"; import { vpcNamespace, vpcServiceNamespace } from "./vpc/index"; import { vpcServiceListCommand } from "./vpc/list"; import { vpcServiceUpdateCommand } from "./vpc/update"; -import { webSearchNamespace } from "./websearch/index"; -import { webSearchSearchCommand } from "./websearch/search"; +import { websearchNamespace } from "./websearch/index"; +import { websearchSearchCommand } from "./websearch/search"; import { workflowsInstanceNamespace, workflowsNamespace } from "./workflows"; import { workflowsDeleteCommand } from "./workflows/commands/delete"; import { workflowsDescribeCommand } from "./workflows/commands/describe"; @@ -1565,10 +1565,10 @@ export function createCLIParser(argv: string[]) { // websearch registry.define([ - { command: "wrangler websearch", definition: webSearchNamespace }, + { command: "wrangler websearch", definition: websearchNamespace }, { command: "wrangler websearch search", - definition: webSearchSearchCommand, + definition: websearchSearchCommand, }, ]); registry.registerNamespace("websearch"); diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 426cf72629..aad17fe144 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -2338,17 +2338,17 @@ function collectCoreBindings( addBinding(aiSearch.binding, "AiSearchInstance", "ai_search", envName); } - if (env.web_search) { - if (!env.web_search.binding) { + if (env.websearch) { + if (!env.websearch.binding) { throwMissingBindingError({ - binding: env.web_search, - bindingType: "web_search", + binding: env.websearch, + bindingType: "websearch", configPath: args.config, envName, fieldName: "binding", }); } else { - addBinding(env.web_search.binding, "WebSearch", "web_search", envName); + addBinding(env.websearch.binding, "WebSearch", "websearch", envName); } } @@ -3516,19 +3516,19 @@ function collectCoreBindingsPerEnvironment( }); } - if (env.web_search) { - if (!env.web_search.binding) { + if (env.websearch) { + if (!env.websearch.binding) { throwMissingBindingError({ - binding: env.web_search, - bindingType: "web_search", + binding: env.websearch, + bindingType: "websearch", configPath: args.config, envName, fieldName: "binding", }); } else { bindings.push({ - bindingCategory: "web_search", - name: env.web_search.binding, + bindingCategory: "websearch", + name: env.websearch.binding, type: "WebSearch", }); } diff --git a/packages/wrangler/src/utils/add-created-resource-config.ts b/packages/wrangler/src/utils/add-created-resource-config.ts index be86c359b3..6bc2e83730 100644 --- a/packages/wrangler/src/utils/add-created-resource-config.ts +++ b/packages/wrangler/src/utils/add-created-resource-config.ts @@ -20,7 +20,7 @@ type ValidKeys = Exclude< ConfigBindingFieldName, | "ai" | "browser" - | "web_search" + | "websearch" | "vars" | "wasm_modules" | "text_blobs" diff --git a/packages/wrangler/src/utils/print-bindings.ts b/packages/wrangler/src/utils/print-bindings.ts index de2b774074..67a10e0da9 100644 --- a/packages/wrangler/src/utils/print-bindings.ts +++ b/packages/wrangler/src/utils/print-bindings.ts @@ -90,7 +90,7 @@ export function printBindings( bindings ); const ai_search = extractBindingsOfType("ai_search", bindings); - const web_search = extractBindingsOfType("web_search", bindings); + const websearch = extractBindingsOfType("websearch", bindings); const agent_memory = extractBindingsOfType("agent_memory", bindings); const hyperdrive = extractBindingsOfType("hyperdrive", bindings); const r2_buckets = extractBindingsOfType("r2_bucket", bindings); @@ -357,11 +357,11 @@ export function printBindings( ); } - if (web_search.length > 0) { + if (websearch.length > 0) { output.push( - ...web_search.map(({ binding }) => ({ + ...websearch.map(({ binding }) => ({ name: binding, - type: getBindingTypeFriendlyName("web_search"), + type: getBindingTypeFriendlyName("websearch"), value: undefined, mode: getMode({ isSimulatedLocally: false }), })) diff --git a/packages/wrangler/src/websearch/index.ts b/packages/wrangler/src/websearch/index.ts index c4deb78cc7..add2412476 100644 --- a/packages/wrangler/src/websearch/index.ts +++ b/packages/wrangler/src/websearch/index.ts @@ -1,6 +1,6 @@ import { createNamespace } from "../core/create-command"; -export const webSearchNamespace = createNamespace({ +export const websearchNamespace = createNamespace({ metadata: { description: "🔎 Run queries against Cloudflare Web Search", status: "experimental", diff --git a/packages/wrangler/src/websearch/search.ts b/packages/wrangler/src/websearch/search.ts index 45360b4b33..0bfa5ded6e 100644 --- a/packages/wrangler/src/websearch/search.ts +++ b/packages/wrangler/src/websearch/search.ts @@ -2,7 +2,7 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { search } from "./client"; -export const webSearchSearchCommand = createCommand({ +export const websearchSearchCommand = createCommand({ metadata: { description: "Run a query against Cloudflare Web Search", status: "experimental",