diff --git a/.changeset/c3-frameworks-update-14661.md b/.changeset/c3-frameworks-update-14661.md deleted file mode 100644 index 83f7bbf848..0000000000 --- a/.changeset/c3-frameworks-update-14661.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 | 8.1.0 | 8.2.0 | diff --git a/.changeset/calm-assets-flow.md b/.changeset/calm-assets-flow.md deleted file mode 100644 index 591c67dc75..0000000000 --- a/.changeset/calm-assets-flow.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@cloudflare/workers-shared": patch -"miniflare": patch ---- - -Improve asset serving performance by removing an unnecessary internal dispatch hop - -Asset requests and RPC calls now avoid an extra internal forwarding layer, reducing latency. The forwarding infrastructure is preserved for future use by cohort-based deployments. diff --git a/.changeset/calm-durable-objects-exit.md b/.changeset/calm-durable-objects-exit.md deleted file mode 100644 index 1ad4a3e0fd..0000000000 --- a/.changeset/calm-durable-objects-exit.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cloudflare/vitest-pool-workers": patch ---- - -Fix test runs hanging after a Durable Object logs and rejects `blockConcurrencyWhile()` - -Console messages emitted from another Durable Object are now buffered until execution returns to the test runner, avoiding I/O that cannot complete after the object's input gate breaks. diff --git a/.changeset/config-watch-failed-restart.md b/.changeset/config-watch-failed-restart.md deleted file mode 100644 index dd0cfb01bc..0000000000 --- a/.changeset/config-watch-failed-restart.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@cloudflare/vite-plugin": patch ---- - -Keep watching config changes after a failed dev server restart - -Previously, when a config change made the dev server restart fail — for example because the updated Worker config was invalid — the plugin stopped watching config changes entirely: the change handler (covering the Worker config files, local dev vars, and the assets configuration) removed itself before restarting, and only a successfully created server would register a fresh one. Since Vite keeps the current server running when a restart fails, every subsequent config change (including the one that fixes the config) was silently ignored for the rest of the session. - -The handler now stays registered and guards against re-entrant restarts instead, so fixing the config restarts the dev server as expected. diff --git a/.changeset/dependabot-update-14682.md b/.changeset/dependabot-update-14682.md deleted file mode 100644 index cd112e13d8..0000000000 --- a/.changeset/dependabot-update-14682.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"miniflare": patch -"wrangler": patch ---- - -Update dependencies of "miniflare", "wrangler" - -The following dependency versions have been updated: - -| Dependency | From | To | -| ------------------------- | ------------- | ------------- | -| @cloudflare/workers-types | ^5.20260710.1 | ^5.20260714.1 | -| workerd | 1.20260710.1 | 1.20260714.1 | diff --git a/.changeset/detect-nub-package-manager.md b/.changeset/detect-nub-package-manager.md deleted file mode 100644 index da4f083658..0000000000 --- a/.changeset/detect-nub-package-manager.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"create-cloudflare": patch -"@cloudflare/cli-shared-helpers": patch ---- - -Detect the `nub` package manager - -C3 resolves the invoking package manager with `which-pm-runs`, which already returns `nub`, but `detectPackageManager` had no `nub` case in its switch, so it fell through to the npm default and produced npm commands. `detectPackageManager` now maps `nub` to its `nub`/`nubx` executables, and `@cloudflare/cli-shared-helpers`'s package-install helpers accept `nub` as a package manager. diff --git a/.changeset/email-routing-addresses.md b/.changeset/email-routing-addresses.md deleted file mode 100644 index 3a628ea9bc..0000000000 --- a/.changeset/email-routing-addresses.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"wrangler": minor ---- - -Add a top-level `addresses` field to Wrangler configuration for Email Routing - -You can now declare the inbound email addresses handled by your Worker directly in `wrangler.json`: - -```json -{ - "name": "my-worker", - "main": "src/index.ts", - "compatibility_date": "2026-05-21", - "addresses": ["support@example.com", "*@example.com"] -} -``` diff --git a/.changeset/fix-images-binding-fit-gravity-background.md b/.changeset/fix-images-binding-fit-gravity-background.md new file mode 100644 index 0000000000..49074dc06f --- /dev/null +++ b/.changeset/fix-images-binding-fit-gravity-background.md @@ -0,0 +1,5 @@ +--- +"miniflare": patch +--- + +Fix the local Images binding transform (`env.IMAGES.input(...).transform(...)`) ignoring `fit`, `gravity`, and `background`. The resize call previously hardcoded sharp's `fit: "contain"` for every transform, which letterboxes the output with black bars regardless of what was requested. `fit`, `gravity`, and `background` are now forwarded to sharp using the same resolution logic already used for `cf.image` fetch subrequests, and an unspecified `fit` now matches production's non-padding default instead of always letterboxing. diff --git a/.changeset/kv-asset-retry.md b/.changeset/kv-asset-retry.md deleted file mode 100644 index 1c76960170..0000000000 --- a/.changeset/kv-asset-retry.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@cloudflare/workers-shared": patch ---- - -Retry asset reads from KV when they fail - -The asset worker reads static assets from KV, and a read can occasionally fail with a transient error. It previously retried only once before giving up. It now retries a few times with exponential backoff, which reduces the chance of serving an error. A missing asset is not treated as a failure and is not retried. diff --git a/.changeset/miniflare-handle-uncaught-error.md b/.changeset/miniflare-handle-uncaught-error.md deleted file mode 100644 index ec837ff0b4..0000000000 --- a/.changeset/miniflare-handle-uncaught-error.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"miniflare": minor ---- - -Add a `handleUncaughtError` shared option that receives uncaught Worker exceptions - -The runtime catches handler exceptions to build the 500 response, so they never reach the inspector — the one place an uncaught exception exists as a structured value in Node is the pretty-error path, where the error report from the Worker is revived into a source-mapped `Error`. Embedders can now pass `handleUncaughtError: (error: Error) => void` to observe that revived error programmatically; logging behavior is unchanged. - -The hook fires only where the pretty-error path does: requests reaching the Worker through the entry socket (a browser or another HTTP client against the dev server). `dispatchFetch()` is unaffected — it always sets `MF-Disable-Pretty-Error`, and the entry worker then propagates the exception by rejecting the returned promise instead, so `dispatchFetch()` callers already receive the error directly and the hook is not invoked. diff --git a/.changeset/miniflare-unmappable-frame-urls.md b/.changeset/miniflare-unmappable-frame-urls.md deleted file mode 100644 index a22f19b060..0000000000 --- a/.changeset/miniflare-unmappable-frame-urls.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"miniflare": patch ---- - -Keep reporting uncaught Worker errors when a stack frame's file URL has no local path - -`fileURLToPath` throws on `file://` URLs that cannot be represented as a local path (a non-local host; on Windows, any drive-less path — which is every `file:///...` URL reported by a POSIX-built bundle). Both the source-mapping machinery and `youch`'s error-page frame parsing convert stack-frame specifiers this way, so one such frame previously failed the whole pretty-error request: the error page was replaced by a raw Node stack, the error was not logged, and `handleUncaughtError` did not fire. Source mapping now degrades to the unmapped stack and the pretty page falls back to a plain stack response instead. diff --git a/.changeset/quiet-routers-loop.md b/.changeset/quiet-routers-loop.md deleted file mode 100644 index 4708eca1c0..0000000000 --- a/.changeset/quiet-routers-loop.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@cloudflare/workers-shared": patch -"miniflare": patch -"@cloudflare/vite-plugin": patch ---- - -Improve routing performance for Workers with assets - -Reduce request handling latency by streamlining the router Worker's request path. The loopback infrastructure remains available for future use. diff --git a/.changeset/start-worker-public-input-type.md b/.changeset/start-worker-public-input-type.md deleted file mode 100644 index 25018cd08f..0000000000 --- a/.changeset/start-worker-public-input-type.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"wrangler": patch ---- - -Type `unstable_startWorker`, `DevEnv.startWorker`, and `ConfigController.set`/`patch` against `WranglerStartDevWorkerInput`, so the wrangler-specific `dev.structuredLogsHandler` field the runtime already honors is expressible through the public API. Previously the public signatures took the base `StartDevWorkerInput`, and callers passing the handler needed a cast while internal callers (the test harness) routed the wider type around the signature. diff --git a/.changeset/workers-dev-subdomain-before-upload.md b/.changeset/workers-dev-subdomain-before-upload.md deleted file mode 100644 index c227d1b510..0000000000 --- a/.changeset/workers-dev-subdomain-before-upload.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Register a workers.dev subdomain before uploading a new Worker - -Deploying a Worker for the first time on an account that has no workers.dev subdomain failed with an opaque API error raised by the upload request itself (code 10063, "You need a workers.dev subdomain in order to proceed"). Wrangler now checks for a workers.dev subdomain before uploading a brand-new Worker that publishes to workers.dev and prompts you to register one, so you get a clear, actionable message instead of a cryptic API failure. The check is skipped for deploys that don't target workers.dev (routes-only deploys, or `workers_dev: false`) and for existing Workers, since their account already has a subdomain. diff --git a/.changeset/wrangler-runtime-error-event.md b/.changeset/wrangler-runtime-error-event.md deleted file mode 100644 index f670d16785..0000000000 --- a/.changeset/wrangler-runtime-error-event.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": minor ---- - -Emit a typed `runtimeError` event on the `unstable_startWorker` DevEnv for uncaught Worker exceptions - -Uncaught Worker exceptions were only source-mapped and printed, so programmatic consumers had to scrape terminal output to observe them. The DevEnv now re-emits a `RuntimeErrorEvent` (like `reloadComplete`) carrying the exception text and source-mapped stack — fed from Miniflare's pretty-error seam via the new `handleUncaughtError` option for exceptions the runtime catches, and from the inspector for those it does not. diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 90dfeed2fb..d73e444f47 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -58,6 +58,7 @@ "fixtures/interactive-dev-tests/src/startup-error.ts", "packages/vite-plugin-cloudflare/playground/**/*.d.ts", "fixtures/**/worker-configuration.d.ts", + "packages/**/worker-configuration.d.ts", "packages/local-explorer-ui/src/routeTree.gen.ts", "packages/local-explorer-ui/src/api/generated", ], diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index a80e10a377..bd21c3456b 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -206,12 +206,5 @@ "workers-sdk/no-wrangler-named-imports": "error", }, }, - // TODO: Remove the following override. - { - "files": ["packages/vite-plugin-cloudflare/**"], - "rules": { - "@typescript-eslint/no-deprecated": "off", - }, - }, ], } diff --git a/fixtures/container-app/package.json b/fixtures/container-app/package.json index b93d4aa1ae..28349d7fa5 100644 --- a/fixtures/container-app/package.json +++ b/fixtures/container-app/package.json @@ -8,13 +8,17 @@ "dev": "wrangler dev", "dev:registry": "wrangler dev -c ./wrangler.registry.jsonc", "start": "wrangler dev", - "start:registry": "wrangler dev -c ./wrangler.registry.jsonc" + "start:registry": "wrangler dev -c ./wrangler.registry.jsonc", + "test:ci": "vitest run", + "type:tests": "tsc -p ./tests/tsconfig.json" }, "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", + "@types/node": "catalog:default", "ts-dedent": "^2.2.0", "typescript": "catalog:default", + "vitest": "catalog:default", "wrangler": "workspace:*" } } diff --git a/fixtures/container-app/tests/container.test.ts b/fixtures/container-app/tests/container.test.ts new file mode 100644 index 0000000000..2f471949ec --- /dev/null +++ b/fixtures/container-app/tests/container.test.ts @@ -0,0 +1,60 @@ +import { execSync } from "child_process"; +import { afterAll, beforeAll, describe, test, vi } from "vitest"; +import { createTestHarness } from "wrangler"; + +const isCINonLinux = process.platform !== "linux" && process.env.CI === "true"; + +function isDockerRunning() { + try { + execSync("docker ps", { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +} + +/** Indicates whether the test is being run locally (not in CI) AND docker is currently not running on the system */ +const isLocalWithoutDockerRunning = + process.env.CI !== "true" && !isDockerRunning(); + +if (isLocalWithoutDockerRunning) { + console.warn( + "The tests are running locally but there is no docker instance running on the system, skipping containers tests" + ); +} + +describe.skipIf( + isCINonLinux || + // If the tests are being run locally and docker is not running we just skip this test + isLocalWithoutDockerRunning +)("container app", () => { + const server = createTestHarness({ + workers: [{ configPath: "./wrangler.jsonc" }], + }); + + beforeAll(async () => { + await server.listen(); + }); + + afterAll(async () => { + await server.close(); + }); + + test("starts and fetches from the container", async ({ expect }) => { + const statusResponse = await server.fetch("/status"); + expect(await statusResponse.json()).toBe(false); + + const startResponse = await server.fetch("/start"); + expect(await startResponse.text()).toBe("Container create request sent..."); + + await vi.waitFor( + async () => { + const fetchResponse = await server.fetch("/fetch"); + expect(await fetchResponse.text()).toBe( + "Hello World! Have an env var! I'm an env var!" + ); + }, + { interval: 500, timeout: 30_000 } + ); + }); +}); diff --git a/fixtures/container-app/tests/tsconfig.json b/fixtures/container-app/tests/tsconfig.json new file mode 100644 index 0000000000..b975fb096b --- /dev/null +++ b/fixtures/container-app/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "noEmit": true, + "types": ["node"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/fixtures/container-app/vitest.config.mts b/fixtures/container-app/vitest.config.mts new file mode 100644 index 0000000000..61dbe48b1f --- /dev/null +++ b/fixtures/container-app/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + include: ["tests/**/*.test.ts"], + retry: 0, + }, + }) +); diff --git a/fixtures/experimental-new-config/cloudflare.config.ts b/fixtures/experimental-new-config/cloudflare.config.ts index 936521a489..fbec075211 100644 --- a/fixtures/experimental-new-config/cloudflare.config.ts +++ b/fixtures/experimental-new-config/cloudflare.config.ts @@ -1,6 +1,14 @@ -import { bindings, defineWorker } from "wrangler/experimental-config"; +import { + bindings, + defineSettings, + defineWorker, +} from "wrangler/experimental-config"; import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; +export const settings = defineSettings({ + complianceRegion: "public", +}); + export default defineWorker((ctx) => ({ name: "experimental-new-config", entrypoint, diff --git a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts index c0d8a508e9..b6914ab21c 100644 --- a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts +++ b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts @@ -259,6 +259,8 @@ if (auth) { ).toMatchInlineSnapshot(` "X [ERROR] A request to the Cloudflare API (/accounts/NOT a valid account id/workers/subdomain/edge-preview) failed. + Could not route to /client/v4/accounts/NOT%20a%20valid%20account%20id/workers/subdomain/edge-preview, perhaps your object identifier is invalid? [code: 7003] + " `); }); diff --git a/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png b/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png index b53eecda60..ef48f18909 100644 Binary files a/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png and b/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png differ diff --git a/fixtures/unsafe-external-plugin/package.json b/fixtures/unsafe-external-plugin/package.json index dbaccda141..e7c460bd96 100644 --- a/fixtures/unsafe-external-plugin/package.json +++ b/fixtures/unsafe-external-plugin/package.json @@ -14,6 +14,6 @@ "esbuild": "catalog:default", "miniflare": "workspace:*", "tsx": "^3.12.8", - "zod": "3.22.3" + "zod": "catalog:default" } } diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts index ce2dd7fbc3..0487ea7fd8 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts @@ -48,10 +48,6 @@ export class Counter extends DurableObject { this.#webSocketMessages.push(value); ws.send(value); } - - webSocketClose() {} - - webSocketError() {} } export class SQLiteDurableObject extends DurableObject { diff --git a/packages/autoconfig/CHANGELOG.md b/packages/autoconfig/CHANGELOG.md index b472e98265..d255c9db85 100644 --- a/packages/autoconfig/CHANGELOG.md +++ b/packages/autoconfig/CHANGELOG.md @@ -1,5 +1,31 @@ # @cloudflare/autoconfig +## 0.2.0 + +### Minor Changes + +- [#14595](https://github.com/cloudflare/workers-sdk/pull/14595) [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924) Thanks [@colinhacks](https://github.com/colinhacks)! - Recognise nub as a package manager + + wrangler now detects nub — from its `npm_config_user_agent` and an installed `nub` binary — and autoconfig detects nub projects by their `nub.lock`, alongside npm, pnpm, yarn, and bun. + +### Patch Changes + +- [#14534](https://github.com/cloudflare/workers-sdk/pull/14534) [`a330170`](https://github.com/cloudflare/workers-sdk/commit/a330170e8dfbe481a99597b3e07c1438e20f5ebb) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Preserve existing Nuxt `modules` when configuring a project for Cloudflare + + Configuring an existing Nuxt project whose `nuxt.config.ts` already declares a `modules` array previously overwrote that array when adding `nitro-cloudflare-dev`, dropping modules such as `@nuxt/ui`. Existing entries are now retained and the Cloudflare module is appended instead. + +- Updated dependencies [[`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`a6c214f`](https://github.com/cloudflare/workers-sdk/commit/a6c214fb311215b1ed09b273171b7995033fb7d7)]: + - @cloudflare/workers-utils@0.28.0 + - @cloudflare/cli-shared-helpers@0.1.16 + +## 0.1.6 + +### Patch Changes + +- Updated dependencies [[`8cd805d`](https://github.com/cloudflare/workers-sdk/commit/8cd805db2f9901cba52d574b385577bafd595cb5)]: + - @cloudflare/cli-shared-helpers@0.1.15 + - @cloudflare/workers-utils@0.27.0 + ## 0.1.5 ### Patch Changes diff --git a/packages/autoconfig/package.json b/packages/autoconfig/package.json index 43e5403113..0785de05e0 100644 --- a/packages/autoconfig/package.json +++ b/packages/autoconfig/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/autoconfig", - "version": "0.1.5", + "version": "0.2.0", "description": "Framework autoconfig detection and configuration for Cloudflare Workers", "license": "MIT OR Apache-2.0", "repository": { diff --git a/packages/autoconfig/src/details/framework-detection.ts b/packages/autoconfig/src/details/framework-detection.ts index 3a64c2f601..9835f23a9c 100644 --- a/packages/autoconfig/src/details/framework-detection.ts +++ b/packages/autoconfig/src/details/framework-detection.ts @@ -4,6 +4,7 @@ import { BunPackageManager, FatalError, NpmPackageManager, + NubPackageManager, PnpmPackageManager, UserError, YarnPackageManager, @@ -82,7 +83,10 @@ export async function detectFramework( // Convert the package manager detected by @netlify/build-info to our PackageManager type. // This is populated after getBuildSettings() runs, which triggers the full detection chain. - const packageManager = convertDetectedPackageManager(project.packageManager); + const packageManager = convertDetectedPackageManager( + project.packageManager, + projectPath + ); const lockFileExists = packageManager.lockFiles.some((lockFile) => existsSync(join(projectPath, lockFile)) @@ -145,11 +149,20 @@ export async function detectFramework( * Falls back to npm if no package manager was detected. * * @param pkgManager The package manager detected by @netlify/build-info (from project.packageManager) + * @param projectPath Path to the project root, used to detect nub via its lock file * @returns A PackageManager object compatible with wrangler's package manager utilities */ function convertDetectedPackageManager( - pkgManager: { name: string } | null + pkgManager: { name: string } | null, + projectPath: string ): PackageManager { + // TODO: Remove this nub.lock check once netlify/build#7124 is merged and + // released, after which @netlify/build-info recognises nub directly. + // https://github.com/netlify/build/pull/7124 + if (existsSync(join(projectPath, "nub.lock"))) { + return NubPackageManager; + } + if (!pkgManager) { return NpmPackageManager; } diff --git a/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts b/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts index 22887c46c3..dd6377b0aa 100644 --- a/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts +++ b/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts @@ -1,6 +1,7 @@ import { BunPackageManager, NpmPackageManager, + NubPackageManager, PnpmPackageManager, YarnPackageManager, } from "@cloudflare/workers-utils"; @@ -57,6 +58,17 @@ describe("detectFramework() / package manager detection", () => { expect(result.packageManager).toStrictEqual(BunPackageManager); }); + it("detects nub when nub.lock is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "nub.lock": "", + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.packageManager).toStrictEqual(NubPackageManager); + }); + it("falls back to npm when no package manager lock file is present", async ({ expect, }) => { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index c5ee7d9615..045cc21d5b 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,23 @@ # @cloudflare/cli +## 0.1.16 + +### Patch Changes + +- Updated dependencies [[`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`a6c214f`](https://github.com/cloudflare/workers-sdk/commit/a6c214fb311215b1ed09b273171b7995033fb7d7)]: + - @cloudflare/workers-utils@0.28.0 + +## 0.1.15 + +### Patch Changes + +- [#14499](https://github.com/cloudflare/workers-sdk/pull/14499) [`8cd805d`](https://github.com/cloudflare/workers-sdk/commit/8cd805db2f9901cba52d574b385577bafd595cb5) Thanks [@colinhacks](https://github.com/colinhacks)! - Detect the `nub` package manager + + C3 resolves the invoking package manager with `which-pm-runs`, which already returns `nub`, but `detectPackageManager` had no `nub` case in its switch, so it fell through to the npm default and produced npm commands. `detectPackageManager` now maps `nub` to its `nub`/`nubx` executables, and `@cloudflare/cli-shared-helpers`'s package-install helpers accept `nub` as a package manager. + +- Updated dependencies []: + - @cloudflare/workers-utils@0.27.0 + ## 0.1.14 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index fef38dd3d1..6723de8c8c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/cli-shared-helpers", - "version": "0.1.14", + "version": "0.1.16", "description": "Internal shared CLI helpers for workers-sdk. Not intended for external use — APIs may change without notice.", "keywords": [ "cli", diff --git a/packages/codemod/src/index.ts b/packages/codemod/src/index.ts index d6b896467e..65fcaeddf2 100644 --- a/packages/codemod/src/index.ts +++ b/packages/codemod/src/index.ts @@ -75,7 +75,8 @@ export function transformFile(filePath: string, methods: recast.types.Visitor) { /** * merges provided properties into a given object (updating the object itself), deeply merging them in case - * some properties are object themselves + * some properties are objects themselves and concatenating them (de-duplicating string entries) in case + * some properties are arrays * * @param sourceObject the object into which merge the new properties * @param newProperties the new properties to add/merge @@ -111,10 +112,48 @@ export function mergeObjectProperties( return; } + if ( + existing.type === "ObjectProperty" && + existing.value.type === "ArrayExpression" && + newProp.value.type === "ArrayExpression" + ) { + mergeArrayElements(existing.value, newProp.value); + return; + } + sourceObject.properties[indexOfExisting] = newProp; }); } +/** + * concatenates the elements of one array expression onto another (updating the target array itself), + * skipping any string literal that is already present so that existing entries are preserved rather + * than overwritten + * + * @param existingArray the array expression to merge new elements into + * @param newArray the array expression whose elements should be appended + */ +function mergeArrayElements( + existingArray: recast.types.namedTypes.ArrayExpression, + newArray: recast.types.namedTypes.ArrayExpression +): void { + const existingStringValues = new Set( + existingArray.elements + .filter((el) => el?.type === "StringLiteral") + .map((el) => el.value) + ); + + newArray.elements.forEach((el) => { + if (el?.type === "StringLiteral") { + if (existingStringValues.has(el.value)) { + return; + } + existingStringValues.add(el.value); + } + existingArray.elements.push(el); + }); +} + function getPropertyName(newProp: recast.types.namedTypes.ObjectProperty) { return newProp.key.type === "Identifier" ? newProp.key.name diff --git a/packages/codemod/tests/index.test.ts b/packages/codemod/tests/index.test.ts index 23b8adb316..dbd62f209a 100644 --- a/packages/codemod/tests/index.test.ts +++ b/packages/codemod/tests/index.test.ts @@ -85,6 +85,44 @@ describe("mergeObjectProperties", () => { }, }, }, + { + testName: "concatenates array properties, preserving existing entries", + sourcePropertiesObject: { + modules: ["@nuxt/ui", "@nuxt/eslint"], + }, + newPropertiesObject: { + modules: ["nitro-cloudflare-dev"], + }, + expectedPropertiesObject: { + modules: ["@nuxt/ui", "@nuxt/eslint", "nitro-cloudflare-dev"], + }, + }, + { + testName: "de-duplicates string entries when concatenating arrays", + sourcePropertiesObject: { + modules: ["a"], + }, + newPropertiesObject: { + modules: ["a", "b"], + }, + expectedPropertiesObject: { + modules: ["a", "b"], + }, + }, + { + testName: "merges array and object properties in the same call", + sourcePropertiesObject: { + modules: ["@nuxt/ui", "@nuxt/eslint"], + }, + newPropertiesObject: { + modules: ["nitro-cloudflare-dev"], + nitro: { preset: "cloudflare_module" }, + }, + expectedPropertiesObject: { + modules: ["@nuxt/ui", "@nuxt/eslint", "nitro-cloudflare-dev"], + nitro: { preset: "cloudflare_module" }, + }, + }, ] satisfies { testName: string; sourcePropertiesObject: Record; diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index f67e8080eb..2c8ddf87a4 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,5 +1,37 @@ # @cloudflare/config +## 0.3.0 + +### Minor Changes + +- [#14724](https://github.com/cloudflare/workers-sdk/pull/14724) [`a50f73a`](https://github.com/cloudflare/workers-sdk/commit/a50f73a06bb7b078268ce9cebb4d1c16f79a3144) Thanks [@jamesopstad](https://github.com/jamesopstad)! - Add a `settings` export to the experimental `cloudflare.config.ts` config + + Account-level settings (`accountId`, `complianceRegion`) now live in a dedicated, named `settings` export authored via `defineSettings`, rather than on the Worker config. A `cloudflare.config.ts` can export at most one `settings` object; the Worker itself is the `default` export. + + ```ts + // cloudflare.config.ts + import { defineSettings, defineWorker } from "wrangler/experimental-config"; + import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; + + export const settings = defineSettings({ + accountId: "", + }); + + export default defineWorker({ + name: "my-worker", + entrypoint, + compatibilityDate: "2026-05-18", + }); + ``` + + This is only used behind the experimental new-config path (`wrangler --experimental-new-config` and the `@cloudflare/vite-plugin` `experimental.newConfig` option). + +## 0.2.1 + +### Patch Changes + +- [#14707](https://github.com/cloudflare/workers-sdk/pull/14707) [`b38f494`](https://github.com/cloudflare/workers-sdk/commit/b38f494204e5e08e561b8f198ef928188e554868) Thanks [@emily-shen](https://github.com/emily-shen)! - Update zod to v4 + ## 0.2.0 ### Minor Changes diff --git a/packages/config/package.json b/packages/config/package.json index 199c37a0a6..509fc0ca35 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/config", - "version": "0.2.0", + "version": "0.3.0", "description": "Definitions and utilities for configuring Cloudflare Workers and related products. This is not yet stable enough for external use — APIs may change without notice.", "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/config#readme", "bugs": { @@ -35,7 +35,7 @@ "test:watch": "vitest" }, "dependencies": { - "zod": "4.4.3" + "zod": "catalog:default" }, "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 5f908fefb0..2af0071a51 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -1,16 +1,21 @@ import { describe, it } from "vitest"; +import { bindings } from "../bindings"; import { convertToWranglerConfig } from "../convert"; import { exports as exportConfig } from "../exports"; -const baseConfig = { name: "worker", compatibilityDate: "2026-06-01" } as const; +const baseConfig = { + type: "worker", + name: "my-worker", + compatibilityDate: "2026-06-01", +} as const; describe("convertToWranglerConfig", () => { describe("top-level fields", () => { it("maps primitive top-level fields", ({ expect }) => { const result = convertToWranglerConfig({ + type: "worker", name: "my-worker", entrypoint: "./src/index.ts", - accountId: "acc-123", compatibilityDate: "2026-01-01", compatibilityFlags: ["nodejs_compat"], workersDev: true, @@ -21,7 +26,6 @@ describe("convertToWranglerConfig", () => { expect(result).toEqual({ name: "my-worker", main: "./src/index.ts", - account_id: "acc-123", compatibility_date: "2026-01-01", compatibility_flags: ["nodejs_compat"], workers_dev: true, @@ -31,24 +35,6 @@ describe("convertToWranglerConfig", () => { }); }); - it("maps complianceRegion: 'fedramp-high' to 'fedramp_high'", ({ - expect, - }) => { - const result = convertToWranglerConfig({ - ...baseConfig, - complianceRegion: "fedramp-high", - }); - expect(result.compliance_region).toBe("fedramp_high"); - }); - - it("passes complianceRegion: 'public' through unchanged", ({ expect }) => { - const result = convertToWranglerConfig({ - ...baseConfig, - complianceRegion: "public", - }); - expect(result.compliance_region).toBe("public"); - }); - it("passes placement through unchanged", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, @@ -207,6 +193,23 @@ describe("convertToWranglerConfig", () => { }); }); + it("creates draft provisionable bindings with the binding factories", ({ + expect, + }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { + QUEUE: bindings.queue(), + DISPATCH: bindings.dispatchNamespace(), + FLAGS: bindings.flagship(), + }, + }); + + expect(result.queues?.producers).toEqual([{ binding: "QUEUE" }]); + expect(result.dispatch_namespaces).toEqual([{ binding: "DISPATCH" }]); + expect(result.flagship).toEqual([{ binding: "FLAGS" }]); + }); + describe("array bindings", () => { it("maps kv with id", ({ expect }) => { const result = convertToWranglerConfig({ @@ -316,6 +319,14 @@ describe("convertToWranglerConfig", () => { expect(result.flagship).toEqual([{ binding: "F", app_id: "app-1" }]); }); + it("preserves a draft flagship binding", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { F: { type: "flagship" } }, + }); + expect(result.flagship).toEqual([{ binding: "F" }]); + }); + it("maps ai-search.name to instance_name", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, @@ -400,6 +411,14 @@ describe("convertToWranglerConfig", () => { ]); }); + it("preserves a draft dispatch namespace binding", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { DN: { type: "dispatch-namespace" } }, + }); + expect(result.dispatch_namespaces).toEqual([{ binding: "DN" }]); + }); + it("maps secrets-store-secret to store_id + secret_name", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, @@ -534,6 +553,14 @@ describe("convertToWranglerConfig", () => { }); }); + it("preserves a draft queue binding", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + env: { Q: { type: "queue" } }, + }); + expect(result.queues).toEqual({ producers: [{ binding: "Q" }] }); + }); + it("maps durable-object binding to durable_objects.bindings", ({ expect, }) => { @@ -818,6 +845,14 @@ describe("convertToWranglerConfig", () => { ]); }); + it("preserves empty email trigger addresses", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + triggers: [{ type: "email", addresses: [] }], + }); + expect(result.addresses).toEqual([]); + }); + it("maps scheduled triggers to triggers.crons", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, @@ -829,6 +864,7 @@ describe("convertToWranglerConfig", () => { expect(result.triggers).toEqual({ crons: ["0 * * * *", "*/5 * * * *"], }); + expect(result.addresses).toBeUndefined(); }); it("maps fetch trigger with dot-zone to zone_name", ({ expect }) => { @@ -1021,4 +1057,46 @@ describe("convertToWranglerConfig", () => { expect(result.streaming_tail_consumers).toEqual([{ service: "b" }]); }); }); + + describe("settings", () => { + it("maps accountId to account_id", ({ expect }) => { + const result = convertToWranglerConfig(baseConfig, { + type: "settings", + accountId: "acc-123", + }); + expect(result.account_id).toBe("acc-123"); + }); + + it("maps complianceRegion: 'fedramp-high' to 'fedramp_high'", ({ + expect, + }) => { + const result = convertToWranglerConfig(baseConfig, { + type: "settings", + complianceRegion: "fedramp-high", + }); + expect(result.compliance_region).toBe("fedramp_high"); + }); + + it("passes complianceRegion: 'public' through unchanged", ({ expect }) => { + const result = convertToWranglerConfig(baseConfig, { + type: "settings", + complianceRegion: "public", + }); + expect(result.compliance_region).toBe("public"); + }); + + it("sets no settings fields when none are provided", ({ expect }) => { + const result = convertToWranglerConfig(baseConfig, { type: "settings" }); + expect(result.account_id).toBeUndefined(); + expect(result.compliance_region).toBeUndefined(); + }); + + it("sets no settings fields when settings config is omitted", ({ + expect, + }) => { + const result = convertToWranglerConfig(baseConfig); + expect(result.account_id).toBeUndefined(); + expect(result.compliance_region).toBeUndefined(); + }); + }); }); diff --git a/packages/config/src/__tests__/load.test.ts b/packages/config/src/__tests__/load.test.ts index e7407179e7..d20d39734d 100644 --- a/packages/config/src/__tests__/load.test.ts +++ b/packages/config/src/__tests__/load.test.ts @@ -10,19 +10,28 @@ import { InputWorkerSchema } from "../schema"; // inside a test directly. Instead, we run a small Node program in a // subprocess that calls `loadConfig`, serialises the result as JSON, and // prints it to stdout for the test to consume. -function runLoadConfigInSubprocess(args: { cwd: string; configPath: string }): { +function runLoadConfigInSubprocess(args: { + cwd: string; + configPath: string; + include?: string[]; +}): { config: unknown; + exports: Record; dependencies: string[]; } { // Use a file:// URL rather than a raw filesystem path so the embedded // `import` specifier is valid on Windows (where absolute paths like // `C:\...` are not accepted as ESM specifiers). const sourceEntry = pathToFileURL(path.resolve(__dirname, "../load.ts")).href; + const options = args.include + ? `, { include: ${JSON.stringify(args.include)} }` + : ""; const script = ` import { loadConfig } from ${JSON.stringify(sourceEntry)}; - const result = await loadConfig(${JSON.stringify(args.configPath)}); + const result = await loadConfig(${JSON.stringify(args.configPath)}${options}); const serialisable = { - config: result.config, + config: result.exports.default, + exports: result.exports, dependencies: [...result.dependencies], }; process.stdout.write(JSON.stringify(serialisable, (_, v) => { @@ -65,6 +74,43 @@ describe("loadConfig", () => { expect(result.config).toEqual({ name: "my-worker" }); }); + it("returns all named exports keyed by name", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "w" }; + export const settings = { type: "settings", accountId: "acc-123" }; + `, + }); + + const result = runLoadConfigInSubprocess({ + cwd: process.cwd(), + configPath: "./cloudflare.config.ts", + }); + + expect(result.exports.default).toEqual({ type: "worker", name: "w" }); + expect(result.exports.settings).toEqual({ + type: "settings", + accountId: "acc-123", + }); + }); + + it("filters exports by `include` before resolution", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "w" }; + export const settings = { type: "settings" }; + `, + }); + + const result = runLoadConfigInSubprocess({ + cwd: process.cwd(), + configPath: "./cloudflare.config.ts", + include: ["settings"], + }); + + expect(Object.keys(result.exports)).toEqual(["settings"]); + }); + it("anchors relative cf-worker specifiers to an absolute path without executing them", async ({ expect, }) => { @@ -152,7 +198,7 @@ describe("loadConfig", () => { "src/index.ts": `// not executed`, "cloudflare.config.ts": ` import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; - export default { name: "worker", compatibilityDate: "2026-06-01", entrypoint }; + export default { type: "worker", name: "worker", compatibilityDate: "2026-06-01", entrypoint }; `, }); @@ -182,8 +228,8 @@ describe("loadConfig", () => { writeFileSync("./cloudflare.config.ts", 'export default { name: "second" };'); const second = await loadConfig("./cloudflare.config.ts"); process.stdout.write(JSON.stringify({ - first: first.config, - second: second.config, + first: first.exports.default, + second: second.exports.default, })); `; const sub = spawnSync( diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index 5b5fc0a2b8..a1c9798d90 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -1,7 +1,16 @@ import { describe, it } from "vitest"; -import { InputWorkerSchema, OutputWorkerSchema } from "../schema"; - -const baseConfig = { name: "worker", compatibilityDate: "2026-06-01" } as const; +import { + ConfigExportsSchema, + InputWorkerSchema, + OutputWorkerSchema, + SettingsSchema, +} from "../schema"; + +const baseConfig = { + type: "worker", + name: "my-worker", + compatibilityDate: "2026-06-01", +} as const; describe("InputWorkerSchema", () => { describe("env singleton bindings", () => { @@ -589,3 +598,129 @@ describe("OutputWorkerSchema", () => { expect(result.success).toBe(false); }); }); + +describe("InputWorkerSchema type discriminant", () => { + it("requires type: 'worker'", ({ expect }) => { + const { type: _type, ...withoutType } = baseConfig; + const result = InputWorkerSchema.safeParse(withoutType); + + expect(result.success).toBe(false); + }); +}); + +describe("SettingsSchema", () => { + it("accepts a minimal settings config", ({ expect }) => { + const result = SettingsSchema.safeParse({ type: "settings" }); + + expect(result.success).toBe(true); + }); + + it("accepts accountId and complianceRegion", ({ expect }) => { + const result = SettingsSchema.safeParse({ + type: "settings", + accountId: "acc-123", + complianceRegion: "fedramp-high", + }); + + expect(result.success).toBe(true); + }); + + it("rejects unknown fields", ({ expect }) => { + const result = SettingsSchema.safeParse({ + type: "settings", + name: "my-worker", + }); + + expect(result.success).toBe(false); + }); +}); + +describe("ConfigExportsSchema", () => { + it("discriminates worker and settings exports by type", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + settings: { type: "settings", accountId: "acc-123" }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.default?.type).toBe("worker"); + expect(result.data.settings?.type).toBe("settings"); + } + }); + + it("reports an invalid-discriminator issue keyed by export name", ({ + expect, + }) => { + const result = ConfigExportsSchema.safeParse({ + default: { name: "my-worker", compatibilityDate: "2026-06-01" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["default", "type"]); + } + }); + + it("rejects a settings config on a non-`settings` export", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + settings: { type: "settings" }, + extraSettings: { type: "settings" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes( + "A `settings` config is only allowed on the `settings` export" + ) + ); + expect(issue?.path).toEqual(["extraSettings"]); + } + }); + + it("rejects a settings config on the `default` export", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: { type: "settings" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes( + "A `settings` config is only allowed on the `settings` export" + ) + ); + expect(issue?.path).toEqual(["default"]); + } + }); + + it("rejects a worker config on the reserved `settings` export", ({ + expect, + }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + settings: { ...baseConfig, name: "settings" }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes( + "The `settings` export is reserved for a `settings` config" + ) + ); + expect(issue?.path).toEqual(["settings"]); + } + }); + + it("allows multiple worker exports", ({ expect }) => { + const result = ConfigExportsSchema.safeParse({ + default: baseConfig, + api: { ...baseConfig, name: "api" }, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index b7021e4fe6..8881ddd635 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -150,7 +150,7 @@ export interface D1Binding extends D1BindingOptions { interface DispatchNamespaceBindingOptions { /** The namespace to bind to. */ - namespace: string; + namespace?: string; /** Details about the outbound Worker which will handle outbound requests from your namespace. */ outbound?: { /** Name of the Worker handling the outbound requests. */ @@ -206,7 +206,7 @@ export interface TypedDurableObjectBinding< interface FlagshipBindingOptions { /** The Flagship app ID to bind to. */ - id: string; + id?: string; /** Set to `true` to suppress the remote binding warning in local dev. Flagship bindings are always remote. */ remote?: boolean; } @@ -344,7 +344,7 @@ export interface TypedPipelineBinding< interface QueueBindingOptions { /** The name of this Queue. */ - name: string; + name?: string; /** The number of seconds to wait before delivering a message. */ deliveryDelay?: number; /** Whether the Queue producer should be remote or not in local development. */ @@ -703,7 +703,7 @@ export interface Bindings { * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms */ dispatchNamespace( - options: DispatchNamespaceBindingOptions + options?: DispatchNamespaceBindingOptions ): DispatchNamespaceBinding; /** * Binding to a Durable Object class. `workerName` is the name of the Worker @@ -713,7 +713,7 @@ export interface Bindings { */ durableObject(options: DurableObjectBindingOptions): DurableObjectBinding; /** Binding to a Flagship feature-flag service. */ - flagship(options: FlagshipBindingOptions): FlagshipBinding; + flagship(options?: FlagshipBindingOptions): FlagshipBinding; /** * Binding to a Hyperdrive configuration. * @@ -761,7 +761,7 @@ export interface Bindings { * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#queues */ queue( - options: QueueBindingOptions + options?: QueueBindingOptions ): TypedQueueBinding; /** * Binding to an R2 bucket. diff --git a/packages/config/src/build-output.ts b/packages/config/src/build-output.ts index 6ab391a8d2..4593b4d47e 100644 --- a/packages/config/src/build-output.ts +++ b/packages/config/src/build-output.ts @@ -4,6 +4,7 @@ import { removeDir } from "@cloudflare/workers-utils"; import type { ParsedInputWorkerConfig, ParsedOutputWorkerConfig, + ParsedSettingsConfig, } from "./schema"; /** @@ -18,6 +19,11 @@ export const BUILD_OUTPUT_VERSION = "v0"; */ export const BUILD_OUTPUT_ROOT = ".cloudflare/output"; +/** + * Filename of the top-level (root) config holding shared settings. + */ +export const ROOT_CONFIG_FILENAME = "config.json"; + /** * Filename of the per-Worker config. */ @@ -30,6 +36,17 @@ function getBuildOutputDir(root: string): string { return path.resolve(root, BUILD_OUTPUT_ROOT); } +/** + * Absolute path to the top-level `config.json` for the current project. + */ +export function getRootConfigPath(root: string): string { + return path.join( + getBuildOutputDir(root), + BUILD_OUTPUT_VERSION, + ROOT_CONFIG_FILENAME + ); +} + /** * Clean the build output directory. */ @@ -84,3 +101,16 @@ export async function writeOutputWorkerConfig( const configPath = getWorkerConfigPath(root, outputConfig.name); await fsp.writeFile(configPath, JSON.stringify(outputConfig)); } + +/** + * Write the top-level `config.json` holding shared settings to the Build + * Output API tree. + */ +export async function writeRootOutputConfig( + root: string, + settings: ParsedSettingsConfig +): Promise { + const configPath = getRootConfigPath(root); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, JSON.stringify(settings)); +} diff --git a/packages/config/src/config-loader.ts b/packages/config/src/config-loader.ts new file mode 100644 index 0000000000..3bcebbcbd1 --- /dev/null +++ b/packages/config/src/config-loader.ts @@ -0,0 +1,36 @@ +import { resolveExportDefinition } from "./definition"; +import { loadConfig } from "./load"; +import { ConfigExportsSchema } from "./schema"; +import type { ConfigContext } from "./definition"; +import type { ParsedConfigExports } from "./schema"; +import type * as z from "zod"; + +export interface LoadAndValidateConfigResult { + /** + * Zod result for the validated exports record, keyed by JS export name. + * Consumers format `result.error` themselves. + */ + result: z.ZodSafeParseResult; + /** Transitive deps imported while resolving the config (node_modules excluded). */ + dependencies: Set; +} + +/** + * Load a `cloudflare.config.ts`, resolve all exports, and validate against {@link ConfigExportsSchema}. + */ +export async function loadAndValidateConfig( + configPath: string, + ctx: ConfigContext, + options?: { include?: string[] } +): Promise { + const { exports, dependencies } = await loadConfig(configPath, options); + + const resolved: Record = {}; + for (const [name, value] of Object.entries(exports)) { + resolved[name] = await resolveExportDefinition(value, ctx); + } + + const result = ConfigExportsSchema.safeParse(resolved); + + return { result, dependencies }; +} diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index bedd29115b..6e44eead87 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -4,7 +4,7 @@ import { type Exports, } from "@cloudflare/workers-utils"; import { isParsedUnsafeBinding } from "./schema"; -import type { ParsedInputWorkerConfig } from "./schema"; +import type { ParsedInputWorkerConfig, ParsedSettingsConfig } from "./schema"; import type { Json } from "./utils"; /** @@ -13,24 +13,48 @@ import type { Json } from "./utils"; * The caller is responsible for unwrapping any function/promise wrapper around * the config and validating it against `InputWorkerSchema` before passing it in. * - * @param config The parsed (post-validation) config. + * @param workerConfig The parsed (post-validation) Worker config. + * @param settingsConfig The optional parsed settings config, whose fields + * are merged onto the result. * @returns The corresponding Wrangler `RawConfig`. */ export function convertToWranglerConfig( - config: ParsedInputWorkerConfig + workerConfig: ParsedInputWorkerConfig, + settingsConfig?: ParsedSettingsConfig ): RawConfig { const result: RawConfig = {}; - convertTopLevel(config, result); - convertBindingsAndAssets(config, result); - convertExports(config, result); - convertDomains(config, result); - convertTriggers(config, result); - convertTailConsumers(config, result); + convertTopLevel(workerConfig, result); + convertBindingsAndAssets(workerConfig, result); + convertExports(workerConfig, result); + convertDomains(workerConfig, result); + convertTriggers(workerConfig, result); + convertTailConsumers(workerConfig, result); + + if (settingsConfig !== undefined) { + convertSettings(settingsConfig, result); + } return result; } +/** + * Merge a parsed settings config's fields (`account_id`, `compliance_region`) + * onto an existing Wrangler `RawConfig`. + */ +function convertSettings( + settings: ParsedSettingsConfig, + result: RawConfig +): void { + if (settings.accountId !== undefined) { + result.account_id = settings.accountId; + } + if (settings.complianceRegion !== undefined) { + result.compliance_region = + settings.complianceRegion === "fedramp-high" ? "fedramp_high" : "public"; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // TOP-LEVEL FIELDS // ═══════════════════════════════════════════════════════════════════════════ @@ -45,9 +69,6 @@ function convertTopLevel( if (typeof config.entrypoint === "string") { result.main = config.entrypoint; } - if (config.accountId !== undefined) { - result.account_id = config.accountId; - } if (config.compatibilityDate !== undefined) { result.compatibility_date = config.compatibilityDate; } @@ -63,10 +84,6 @@ function convertTopLevel( if (config.logpush !== undefined) { result.logpush = config.logpush; } - if (config.complianceRegion !== undefined) { - result.compliance_region = - config.complianceRegion === "fedramp-high" ? "fedramp_high" : "public"; - } if (config.firstPartyWorker !== undefined) { result.first_party_worker = config.firstPartyWorker; } @@ -768,11 +785,12 @@ function convertTriggers( const queueConsumers: NonNullable< NonNullable["consumers"] > = result.queues?.consumers ? [...result.queues.consumers] : []; - const addresses: string[] = result.addresses ? [...result.addresses] : []; + let addresses: string[] | undefined; for (const trigger of triggers) { switch (trigger.type) { case "email": { + addresses ??= []; addresses.push(...trigger.addresses); break; } @@ -817,7 +835,8 @@ function convertTriggers( if (queueConsumers.length) { result.queues = { ...(result.queues ?? {}), consumers: queueConsumers }; } - if (addresses.length) { + // An empty array removes managed addresses; undefined means no email trigger. + if (addresses !== undefined) { result.addresses = addresses; } } diff --git a/packages/config/src/definition.ts b/packages/config/src/definition.ts new file mode 100644 index 0000000000..4f505722c5 --- /dev/null +++ b/packages/config/src/definition.ts @@ -0,0 +1,56 @@ +export interface ConfigContext { + /** + * The mode the config is being evaluated in. + * Set via the `--mode` CLI flag. + * In Vite the mode defaults to `development` in `vite dev` and `production` in `vite build` ([more info](https://vite.dev/guide/env-and-mode.html#modes)). + * In Wrangler the mode defaults to `undefined`. + */ + mode: string | undefined; +} + +// We currently use Symbol.for rather than Symbol so that the symbol matches if duplicated across bundles +// This wouldn't be necessary if @cloudflare/config was published and included as a dependency +export const DEFINITION = Symbol.for("@cloudflare/config:definition"); + +/** + * The authored config in any of its supported shapes: a plain value, a promise, + * or a function of {@link ConfigContext}. + */ +export type ConfigInput = + | T + | Promise + | ((ctx: ConfigContext) => T | Promise); + +/** + * Unwrap an authored config from its value / promise / function shape, awaiting + * the result. A function config is invoked with {@link ConfigContext}. + */ +async function unwrap(config: unknown, ctx: ConfigContext): Promise { + return typeof config === "function" + ? await (config as (ctx: ConfigContext) => unknown)(ctx) + : await config; +} + +/** + * Resolve any `cloudflare.config.ts` export to its plain config value. + * + * A `define*` helper stores its authored config plus `type` under the + * {@link DEFINITION} symbol; here we unwrap the config and stamp `type` back on. + * Every other export — a raw object/promise/function — is unwrapped as-is and + * already carries its own `type`. Discrimination happens afterwards via `type`. + */ +export async function resolveExportDefinition( + def: unknown, + ctx: ConfigContext +): Promise { + if (typeof def === "object" && def !== null && DEFINITION in def) { + const { config, type } = (def as Record)[DEFINITION] as { + config: unknown; + type: string; + }; + const resolved = await unwrap(config, ctx); + return { ...(resolved as object), type }; + } + + return await unwrap(def, ctx); +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1b9b48cf7e..3e51a432f9 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -3,25 +3,34 @@ export { BUILD_OUTPUT_ROOT, BUILD_OUTPUT_VERSION, cleanBuildOutputDir, + getRootConfigPath, getWorkerAssetsDir, getWorkerBundleDir, getWorkerConfigPath, getWorkersDir, + ROOT_CONFIG_FILENAME, WORKER_CONFIG_FILENAME, writeOutputWorkerConfig, + writeRootOutputConfig, } from "./build-output"; export { + ConfigExportsSchema, InputWorkerSchema, OutputWorkerSchema, ModuleTypeSchema, + SettingsSchema, } from "./schema"; export { generateTypes } from "./generate"; export { convertToWranglerConfig } from "./convert"; export { loadConfig, registerConfigHooks } from "./load"; -export { resolveWorkerDefinition } from "./worker-definition"; +export { loadAndValidateConfig } from "./config-loader"; +export { resolveExportDefinition } from "./definition"; export type { LoadConfigResult } from "./load"; +export type { LoadAndValidateConfigResult } from "./config-loader"; export type { + ParsedConfigExports, ParsedInputWorkerConfig, ParsedOutputWorkerConfig, + ParsedSettingsConfig, ModuleType, } from "./schema"; diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index 7f990c3c85..7068ed2cf2 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -11,7 +11,7 @@ import type { TypedWorkerBinding, TypedWorkflowBinding, } from "./bindings"; -import type { UserConfigExport, WorkerDefinition } from "./worker-definition"; +import type { WorkerConfigExport, WorkerDefinition } from "./worker-definition"; import type { Pipeline } from "cloudflare:pipelines"; // ═══════════════════════════════════════════════════════════════════════════ @@ -248,7 +248,7 @@ export type InferWorkerEntrypointExports = Exclude< export type UnwrapConfig = TConfig extends WorkerDefinition ? TUnwrappedConfig - : TConfig extends UserConfigExport + : TConfig extends WorkerConfigExport ? TUnwrappedConfig : never; diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 30f8208cb7..56ac6754f3 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -25,16 +25,20 @@ const depsStore = new AsyncLocalStorage>(); /** * Dynamically import a worker config file from disk. * - * Returns the module's default export, plus the set of file paths - * imported during resolution. Callers are responsible for unwrapping - * function/promise wrappers around the returned value and validating it - * against `InputWorkerSchema`. + * Returns all of the module's exports (keyed by export name, including + * `default`), plus the set of file paths imported during resolution. Callers + * are responsible for unwrapping function/promise/definition wrappers around + * each export and validating them. * * @param configPath Filesystem path to the config file. Relative paths are * resolved against `process.cwd()`. + * @param options.include When provided, only exports whose names are listed + * survive into the returned `exports` map. Filtering happens before any + * resolution or validation. */ export async function loadConfig( - configPath: string + configPath: string, + options?: { include?: string[] } ): Promise { registerConfigHooks(); const url = pathToFileURL(configPath).href; @@ -43,12 +47,24 @@ export async function loadConfig( dependencies, () => import(url, { with: { [CF_ATTR]: CF_NO_CACHE_VALUE } }) ); - return { config: mod.default, dependencies }; + + const exports: Record = {}; + for (const [name, value] of Object.entries(mod as Record)) { + if (options?.include && !options.include.includes(name)) { + continue; + } + exports[name] = value; + } + + return { exports, dependencies }; } export interface LoadConfigResult { - /** The default export of the config module */ - config: unknown; + /** + * All exports of the config module, keyed by export name (including + * `default`). Filtered by `options.include` when provided. + */ + exports: Record; /** * Absolute file paths imported while resolving the config. * `cf-worker` entrypoints are NOT included — they are diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index 777a939b29..e5cab52dcf 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -76,10 +76,13 @@ export type { InferMainModule, UnwrapConfig, } from "./inference"; -export type { UserConfig } from "./types"; +export type { ConfigContext } from "./definition"; +export type { SettingsConfig, WorkerConfig } from "./types"; export type { - ConfigContext, TypedWorkerDefinition, - UserConfigExport, + WorkerConfigExport, + WorkerConfigInput, } from "./worker-definition"; export { defineWorker } from "./worker-definition"; +export type { SettingsConfigInput } from "./settings-definition"; +export { defineSettings } from "./settings-definition"; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index f05ab60a6e..541ff6e644 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -1,5 +1,5 @@ import * as z from "zod"; -import type { UserConfig } from "./types"; +import type { SettingsConfig, WorkerConfig } from "./types"; const AssetsSchema = z.strictObject({ htmlHandling: z @@ -55,7 +55,7 @@ const KnownBindingSchema = z.discriminatedUnion("type", [ }), z.strictObject({ type: z.literal("dispatch-namespace"), - namespace: z.string(), + namespace: z.string().optional(), outbound: z .strictObject({ workerName: z.string(), @@ -71,7 +71,7 @@ const KnownBindingSchema = z.discriminatedUnion("type", [ }), z.strictObject({ type: z.literal("flagship"), - id: z.string(), + id: z.string().optional(), remote: z.boolean().optional(), }), z.strictObject({ @@ -108,7 +108,7 @@ const KnownBindingSchema = z.discriminatedUnion("type", [ }), z.strictObject({ type: z.literal("queue"), - name: z.string(), + name: z.string().optional(), deliveryDelay: z.number().optional(), remote: z.boolean().optional(), }), @@ -430,8 +430,8 @@ const UnsafeSchema = z.strictObject({ * (user-authored) and output (on-disk) Worker configs. */ const BaseWorkerSchema = z.strictObject({ + type: z.literal("worker"), name: z.string(), - accountId: z.string().optional(), compatibilityDate: z.string(), compatibilityFlags: z.array(z.string()).optional(), assets: AssetsSchema.optional(), @@ -445,7 +445,6 @@ const BaseWorkerSchema = z.strictObject({ observability: ObservabilitySchema.optional(), workersDev: z.boolean().optional(), previewUrls: z.boolean().optional(), - complianceRegion: z.enum(["public", "fedramp-high"]).optional(), firstPartyWorker: z.boolean().optional(), unsafe: UnsafeSchema.optional(), // TODO: support previews @@ -467,6 +466,61 @@ export const InputWorkerSchema = BaseWorkerSchema.extend({ export type ParsedInputWorkerConfig = z.output; +/** + * Settings schema — validates the named `settings` export of a + * `cloudflare.config.ts`. Holds account/deployment settings shared by the other exports. + */ +export const SettingsSchema = z.strictObject({ + type: z.literal("settings"), + accountId: z.string().optional(), + complianceRegion: z.enum(["public", "fedramp-high"]).optional(), +}); + +export type ParsedSettingsConfig = z.output; + +/** + * Discriminated union of the config kinds a single export may resolve to. + */ +const ConfigExportSchema = z.discriminatedUnion("type", [ + InputWorkerSchema, + SettingsSchema, +]); + +const SETTINGS_EXPORT_NAME = "settings"; + +/** + * Schema for the resolved config exports, keyed by export + * name. Each value is discriminated on its `type` field. Reserves the + * `settings` export name exclusively for settings configs: a `settings` + * config must live on the `settings` export, and the `settings` export + * may only hold a `settings` config. + */ +export const ConfigExportsSchema = z + .record(z.string(), ConfigExportSchema) + .check((ctx) => { + for (const [key, value] of Object.entries(ctx.value)) { + const isSettingsName = key === SETTINGS_EXPORT_NAME; + const isSettingsType = value.type === "settings"; + if (isSettingsType && !isSettingsName) { + ctx.issues.push({ + code: "custom", + input: value, + path: [key], + message: `A \`settings\` config is only allowed on the \`${SETTINGS_EXPORT_NAME}\` export; found one on the \`${key}\` export.`, + }); + } else if (isSettingsName && !isSettingsType) { + ctx.issues.push({ + code: "custom", + input: value, + path: [key], + message: `The \`${SETTINGS_EXPORT_NAME}\` export is reserved for a \`settings\` config; found a \`${value.type}\` config.`, + }); + } + } + }); + +export type ParsedConfigExports = z.output; + export const ModuleTypeSchema = z.enum([ "esm", "cjs", @@ -499,7 +553,7 @@ export type ParsedOutputWorkerConfig = z.output; /** * Bidirectional drift check between {@link InputWorkerSchema} and the - * public {@link UserConfig} interface. Excludes `entrypoint` and `env`, + * public {@link WorkerConfig} interface. Excludes `entrypoint` and `env`, * which deliberately differ: * * - `entrypoint`: the public type accepts a `WorkerModule` namespace @@ -512,16 +566,16 @@ type _ComparableInput = Omit< z.input, "entrypoint" | "env" >; -type _ComparableUserConfig = Omit; -type _AssertSchemaMatchesUserConfig = [ - _ComparableInput extends _ComparableUserConfig ? true : false, - _ComparableUserConfig extends _ComparableInput ? true : false, +type _ComparableWorkerConfig = Omit; +type _AssertSchemaMatchesWorkerConfig = [ + _ComparableInput extends _ComparableWorkerConfig ? true : false, + _ComparableWorkerConfig extends _ComparableInput ? true : false, ]; -const _assertSchemaMatchesUserConfig: _AssertSchemaMatchesUserConfig = [ +const _assertSchemaMatchesWorkerConfig: _AssertSchemaMatchesWorkerConfig = [ true, true, ]; -void _assertSchemaMatchesUserConfig; +void _assertSchemaMatchesWorkerConfig; /** * Unidirectional drift check for `env`. The public binding return types @@ -529,17 +583,31 @@ void _assertSchemaMatchesUserConfig; * inference helpers that the schema does not (and cannot) validate at * runtime, so a bidirectional check would always fail in that direction. * - * We therefore only assert that `UserConfig['env']` is assignable to + * We therefore only assert that `WorkerConfig['env']` is assignable to * `z.input['env']` — i.e. every binding shape * the public type accepts is something the schema is willing to parse. * This catches drift where the public type drops a field the schema * still requires, renames a field, changes a field's type to one the * schema rejects, or adds a binding the schema doesn't know about. */ -type _AssertUserConfigEnvExtendsSchema = UserConfig["env"] extends z.input< +type _AssertWorkerConfigEnvExtendsSchema = WorkerConfig["env"] extends z.input< typeof InputWorkerSchema >["env"] ? true : false; -const _assertUserConfigEnvExtendsSchema: _AssertUserConfigEnvExtendsSchema = true; -void _assertUserConfigEnvExtendsSchema; +const _assertWorkerConfigEnvExtendsSchema: _AssertWorkerConfigEnvExtendsSchema = true; +void _assertWorkerConfigEnvExtendsSchema; + +/** + * Bidirectional drift check between {@link SettingsSchema} and the public + * {@link SettingsConfig} interface. + */ +type _AssertSettingsSchemaMatchesConfig = [ + z.input extends SettingsConfig ? true : false, + SettingsConfig extends z.input ? true : false, +]; +const _assertSettingsSchemaMatchesConfig: _AssertSettingsSchemaMatchesConfig = [ + true, + true, +]; +void _assertSettingsSchemaMatchesConfig; diff --git a/packages/config/src/settings-definition.ts b/packages/config/src/settings-definition.ts new file mode 100644 index 0000000000..ed59c99ef3 --- /dev/null +++ b/packages/config/src/settings-definition.ts @@ -0,0 +1,17 @@ +import { DEFINITION } from "./definition"; +import type { ConfigInput } from "./definition"; +import type { SettingsConfig } from "./types"; + +/** + * Authored settings config shape — {@link SettingsConfig} without the `type` + * discriminant, which `defineSettings` injects. + */ +export type SettingsConfigInput = Omit; + +/** + * Declare shared settings. + * Authored as a named `settings` export. + */ +export function defineSettings(config: ConfigInput) { + return { [DEFINITION]: { config, type: "settings" } }; +} diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 9922a14f6d..8417d04ff1 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -135,20 +135,19 @@ type Export = * Fields are validated at runtime by `InputWorkerSchema` and normalised before * being passed to downstream tooling. */ -export interface UserConfig { +export interface WorkerConfig { /** - * The name of your Worker. + * Discriminates this config as a Worker config. + * + * Injected automatically by `defineWorker`; only needs to be written by + * hand when authoring a raw config object without the helper. */ - name: string; + type: "worker"; /** - * This is the ID of the account associated with your zone. - * You might have more than one account, so make sure to use - * the ID of the account associated with the zone/route you - * provide, if you provide one. It can also be specified through - * the CLOUDFLARE_ACCOUNT_ID environment variable. + * The name of your Worker. */ - accountId?: string; + name: string; /** * A date in the form yyyy-mm-dd, which will be used to determine @@ -350,14 +349,6 @@ export interface UserConfig { */ previewUrls?: boolean; - /** - * Specify the compliance region mode of the Worker. - * - * Although if the user does not specify a compliance region, the default is `public`, - * it can be set to `undefined` in configuration to delegate to the CLOUDFLARE_COMPLIANCE_REGION environment variable. - */ - complianceRegion?: "public" | "fedramp-high"; - /** * Designates this Worker as an internal-only "first-party" Worker. * @@ -414,3 +405,35 @@ export interface UserConfig { */ exports?: Record; } + +/** + * Settings shared by the other exports. + * Authored as a named `settings` export via + * `defineSettings`. + */ +export interface SettingsConfig { + /** + * Discriminates this config as a settings config. + * + * Injected automatically by `defineSettings`; only needs to be written by + * hand when authoring a raw config object without the helper. + */ + type: "settings"; + + /** + * This is the ID of the account associated with your zone. + * You might have more than one account, so make sure to use + * the ID of the account associated with the zone/route you + * provide, if you provide one. It can also be specified through + * the CLOUDFLARE_ACCOUNT_ID environment variable. + */ + accountId?: string; + + /** + * Specify the compliance region mode of the Worker. + * + * Although if the user does not specify a compliance region, the default is `public`, + * it can be set to `undefined` in configuration to delegate to the CLOUDFLARE_COMPLIANCE_REGION environment variable. + */ + complianceRegion?: "public" | "fedramp-high"; +} diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index ac982ecf6c..5b046939bc 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -1,48 +1,35 @@ +import { DEFINITION } from "./definition"; import type { Bindings, TypedDurableObjectBinding, TypedWorkerBinding, } from "./bindings"; +import type { ConfigContext, ConfigInput } from "./definition"; import type { InferDurableNamespaces, InferWorkerName, InferWorkerEntrypointExports, } from "./inference"; -import type { UserConfig } from "./types"; - -// TODO: Use declaration merging in the consuming package once this package is published -export interface ConfigContext { - /** - * The mode the config is being evaluated in. - * Set via the `--mode` CLI flag. - * In Vite the mode defaults to `development` in `vite dev` and `production` in `vite build` ([more info](https://vite.dev/guide/env-and-mode.html#modes)). - * In Wrangler the mode defaults to `undefined`. - */ - mode: string | undefined; -} - -// We currently use Symbol.for rather than Symbol so that the symbol matches if duplicated across bundles -// This wouldn't be necessary if @cloudflare/config was published and included as a dependency -const CONFIG = Symbol.for("@cloudflare/config:worker-config"); +import type { WorkerConfig } from "./types"; /** - * Base shape of a Worker definition. Carries the resolved config and the - * untyped cross-worker binding helpers. + * Base shape of a Worker definition. Carries the authored config (under + * {@link DEFINITION}) and the untyped cross-worker binding helpers. */ export interface WorkerDefinition< - TConfig extends UserConfig = UserConfig, + TConfig extends WorkerConfig = WorkerConfig, > extends Pick { - [CONFIG]: - | TConfig - | Promise - | ((ctx: ConfigContext) => TConfig | Promise); + // The authored config is stored without its `type` discriminant (the helper + // omits it); `type` sits alongside it and is stamped back on during + // resolution. `TConfig` is still referenced so `UnwrapConfig` can recover it. + [DEFINITION]: { config: ConfigInput>; type: "worker" }; } /** * Worker definition with typed cross-worker binding helpers. */ export interface TypedWorkerDefinition< - TConfig extends UserConfig, + TConfig extends WorkerConfig, TWorkerName extends string = InferWorkerName, > extends WorkerDefinition { /** @@ -84,20 +71,32 @@ export interface TypedWorkerDefinition< // }): TypedWorkflowBinding; } -export type UserConfigExport = - | T - | Promise - | ((ctx: ConfigContext) => T | Promise); +export type WorkerConfigExport = + ConfigInput; -export function defineWorker( - config: (ctx: ConfigContext) => (UserConfig & T) | Promise -): TypedWorkerDefinition; -export function defineWorker( - config: (UserConfig & T) | Promise -): TypedWorkerDefinition; -export function defineWorker(config: UserConfigExport): WorkerDefinition { +/** + * Authored Worker config shape — {@link WorkerConfig} without the `type` + * discriminant, which `defineWorker` injects. + */ +export type WorkerConfigInput = Omit; + +export type WorkerConfigInputExport< + T extends WorkerConfigInput = WorkerConfigInput, +> = ConfigInput; + +export function defineWorker( + config: ( + ctx: ConfigContext + ) => (WorkerConfigInput & T) | Promise +): TypedWorkerDefinition; +export function defineWorker( + config: (WorkerConfigInput & T) | Promise +): TypedWorkerDefinition; +export function defineWorker( + config: WorkerConfigInputExport +): WorkerDefinition { return { - [CONFIG]: config, + [DEFINITION]: { config, type: "worker" }, durableObject(options) { return { type: "durable-object", ...options }; }, @@ -110,20 +109,3 @@ export function defineWorker(config: UserConfigExport): WorkerDefinition { // }, }; } - -export async function resolveWorkerDefinition( - def: unknown, - ctx: ConfigContext -): Promise { - const raw = - typeof def === "object" && def !== null && CONFIG in def - ? (def as Record)[CONFIG] - : def; - - const value = - typeof raw === "function" - ? (raw as (ctx: ConfigContext) => unknown)(ctx) - : raw; - - return await value; -} diff --git a/packages/create-cloudflare/CHANGELOG.md b/packages/create-cloudflare/CHANGELOG.md index ceaf6b890d..d8520273fe 100644 --- a/packages/create-cloudflare/CHANGELOG.md +++ b/packages/create-cloudflare/CHANGELOG.md @@ -1,5 +1,61 @@ # create-cloudflare +## 2.70.13 + +### Patch Changes + +- [#14659](https://github.com/cloudflare/workers-sdk/pull/14659) [`5cee1d4`](https://github.com/cloudflare/workers-sdk/commit/5cee1d47c3c17e005b990fd51f710f7782005168) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "create-cloudflare" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ----------------- | ------ | ------ | + | create-docusaurus | 3.10.1 | 3.10.2 | + +- [#14759](https://github.com/cloudflare/workers-sdk/pull/14759) [`947ad34`](https://github.com/cloudflare/workers-sdk/commit/947ad345a550a6bde8a670c855b1e24b48cff211) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "create-cloudflare" + + The following dependency versions have been updated: + + | Dependency | From | To | + | --------------- | ------ | ------ | + | @angular/create | 22.0.6 | 22.0.7 | + +- [#14760](https://github.com/cloudflare/workers-sdk/pull/14760) [`9ed6dc2`](https://github.com/cloudflare/workers-sdk/commit/9ed6dc208d09ad3e2e0994d0df1a34cdc9ffb9de) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "create-cloudflare" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ---------- | ------ | ------ | + | sv | 0.16.2 | 0.16.3 | + +- [#14534](https://github.com/cloudflare/workers-sdk/pull/14534) [`a330170`](https://github.com/cloudflare/workers-sdk/commit/a330170e8dfbe481a99597b3e07c1438e20f5ebb) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Preserve existing Nuxt `modules` when adding Cloudflare configuration + + Scaffolding a Nuxt application whose `nuxt.config.ts` already declares a `modules` array (for example the `ui` starter, which registers `@nuxt/ui` and `@nuxt/eslint`) previously overwrote that array when adding `nitro-cloudflare-dev`, dropping the existing modules and breaking the build. Existing entries are now retained and the Cloudflare module is appended instead. + +- [#14790](https://github.com/cloudflare/workers-sdk/pull/14790) [`03ce063`](https://github.com/cloudflare/workers-sdk/commit/03ce063a66e11d14e06e0dba0863cccacad150a0) Thanks [@emily-shen](https://github.com/emily-shen)! - Update dependencies of "create-cloudflare" + + The following dependency versions have been updated: + + | Dependency | From | To | + | --------------- | ------- | ------- | + | create-next-app | 16.2.10 | 16.2.11 | + +## 2.70.12 + +### Patch Changes + +- [#14661](https://github.com/cloudflare/workers-sdk/pull/14661) [`414ce87`](https://github.com/cloudflare/workers-sdk/commit/414ce87c63f5b014067866364aa42ffe13f4d1f8) 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 | 8.1.0 | 8.2.0 | + +- [#14499](https://github.com/cloudflare/workers-sdk/pull/14499) [`8cd805d`](https://github.com/cloudflare/workers-sdk/commit/8cd805db2f9901cba52d574b385577bafd595cb5) Thanks [@colinhacks](https://github.com/colinhacks)! - Detect the `nub` package manager + + C3 resolves the invoking package manager with `which-pm-runs`, which already returns `nub`, but `detectPackageManager` had no `nub` case in its switch, so it fell through to the npm default and produced npm commands. `detectPackageManager` now maps `nub` to its `nub`/`nubx` executables, and `@cloudflare/cli-shared-helpers`'s package-install helpers accept `nub` as a package manager. + ## 2.70.11 ### Patch Changes diff --git a/packages/create-cloudflare/e2e/helpers/spawn.ts b/packages/create-cloudflare/e2e/helpers/spawn.ts index 16415c2aeb..33dc188c7a 100644 --- a/packages/create-cloudflare/e2e/helpers/spawn.ts +++ b/packages/create-cloudflare/e2e/helpers/spawn.ts @@ -129,6 +129,9 @@ export const testEnv = { YARN_ENABLE_GLOBAL_CACHE: "false", PNPM_HOME: "./.pnpm", npm_config_cache: "./.npm/cache", + // Scaffolded projects intentionally test the latest framework releases. + npm_config_minimum_release_age: "0", + pnpm_config_minimum_release_age: "0", // unset the VITEST env variable as this causes e2e issues with some frameworks VITEST: undefined, }; diff --git a/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts b/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts index 809623bf4d..b6a4ab8822 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/frameworks.test.ts @@ -51,8 +51,14 @@ describe envInterfaceName: "Env", ...getFrameworkConfig(testConfig.name), }; + // A third `:`-segment in the test name (e.g. `nuxt:pages:minimal`) + // is a variant label that disambiguates tests sharing the same + // framework id + platform. getFrameworkConfig ignores it. + const variantLabel = testConfig.name.split(":")[2]; test.runIf(shouldRunTest(testConfig))( - `${frameworkConfig.id} (${frameworkConfig.platform ?? "pages"})`, + `${frameworkConfig.id} (${frameworkConfig.platform ?? "pages"})${ + variantLabel ? ` [${variantLabel}]` : "" + }`, { retry: testRetries, timeout: testConfig.timeout || TEST_TIMEOUT, diff --git a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts index f75efee352..cc79047221 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts @@ -361,6 +361,7 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { }, { name: "nuxt:pages", + quarantine: true, promptHandlers: [ { matcher: /Would you like to .* install .*modules\?/, @@ -372,9 +373,71 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { timeout: LONG_TIMEOUT, unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 unsupportedOSs: ["win32"], + // The Nuxt `ui` template pins `packageManager: pnpm@11.9.0`, so run + // it only on pnpm 11+: under pnpm 10 the cross-version self-provision + // recurses and fails with ENAMETOOLONG. + unsupportedPmRanges: { pnpm: "<11.0.0" }, + verifyDeploy: { + route: "/", + expectedText: "Nuxt UI - Starter", + }, + nodeCompat: false, + verifyPreview: { + previewArgs: ["--inspector-port=0"], + route: "/test", + expectedText: "C3_TEST", + }, + flags: ["--template", "ui"], + }, + { + name: "nuxt:workers", + quarantine: true, + promptHandlers: [ + { + matcher: /Would you like to .* install .*modules\?/, + input: [keys.enter], + }, + ], + argv: ["--platform", "workers"], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 + unsupportedOSs: ["win32"], + // See note on nuxt:pages above. + unsupportedPmRanges: { pnpm: "<11.0.0" }, + verifyDeploy: { + route: "/", + expectedText: "Nuxt UI - Starter", + }, + verifyPreview: { + previewArgs: ["--inspector-port=0"], + route: "/test", + expectedText: "C3_TEST", + }, + nodeCompat: false, + flags: ["--template", "ui"], + }, + { + name: "nuxt:pages:minimal", + promptHandlers: [ + { + matcher: /Would you like to .* install .*modules\?/, + input: [keys.enter], + }, + ], + argv: ["--platform", "pages"], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + // yarn: nitro requires youch which expects Node 20+, and yarn fails + // hard since we run on Node 18. npm: the `nuxt:pages` (ui) test + // already covers npm, so skip it here to avoid running Nuxt twice. + unsupportedPms: ["yarn", "npm"], + unsupportedOSs: ["win32"], // Nuxt's deps trip `ERR_PNPM_IGNORED_BUILDS` on pnpm 11; the e2e // harness has closed stdin by the time C3's recovery prompt fires - // (real-TTY users are unaffected). + // (real-TTY users are unaffected). The `nuxt:pages` (ui) test covers + // pnpm 11+, so this `minimal` variant runs on pnpm 10 to keep the + // pre-pnpm-11 install path (and the default template) under test. unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", @@ -389,7 +452,7 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { flags: ["--template", "minimal"], }, { - name: "nuxt:workers", + name: "nuxt:workers:minimal", promptHandlers: [ { matcher: /Would you like to .* install .*modules\?/, @@ -399,9 +462,9 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { argv: ["--platform", "workers"], testCommitMessage: true, timeout: LONG_TIMEOUT, - unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 + // See notes on nuxt:pages:minimal above. + unsupportedPms: ["yarn", "npm"], unsupportedOSs: ["win32"], - // See note on nuxt:pages above. unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", @@ -820,8 +883,39 @@ function getExperimentalFrameworkTestConfig( argv: ["--platform", "workers"], testCommitMessage: true, timeout: LONG_TIMEOUT, + unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 unsupportedOSs: ["win32"], // See note on nuxt:pages above. + unsupportedPmRanges: { pnpm: "<11.0.0" }, + verifyDeploy: { + route: "/", + expectedText: "Nuxt UI - Starter", + }, + verifyPreview: { + previewArgs: ["--inspector-port=0"], + route: "/test", + expectedText: "C3_TEST", + }, + nodeCompat: false, + verifyTypes: false, + flags: ["--template", "ui"], + }, + { + name: "nuxt:workers:minimal", + promptHandlers: [ + { + matcher: /Would you like to .* install .*modules\?/, + input: [keys.enter], + }, + ], + argv: ["--platform", "workers"], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + // See notes on nuxt:pages:minimal in getFrameworkTestConfig. + unsupportedPms: ["yarn", "npm"], + unsupportedOSs: ["win32"], + // The ui variant covers pnpm 11+ (see nuxt:pages:minimal in + // getFrameworkTestConfig); this variant runs on pnpm 10. unsupportedPmRanges: { pnpm: ">=11.0.0" }, verifyDeploy: { route: "/", @@ -1071,7 +1165,10 @@ function getExperimentalFrameworkTestConfig( * * @param options - An object containing the following properties: * - isExperimentalMode: A boolean indicating if experimental mode is enabled. - * - FrameworkTestFilter: A string that can be used to filter the tests by "name" or "name:(pages|workers)". + * - FrameworkTestFilter: A string that can be used to filter the tests by + * "name" (e.g. "nuxt"), "name:(pages|workers)" (e.g. "nuxt:pages"), or a + * full variant name (e.g. "nuxt:pages:minimal"). A "name:platform" filter + * also matches variant tests of the form "name:platform:variant". */ export function getFrameworksTests(): NamedFrameworkTestConfig[] { const packageManager = detectPackageManager(); @@ -1083,7 +1180,12 @@ export function getFrameworksTests(): NamedFrameworkTestConfig[] { return true; } if (frameworkToTestFilter.includes(":")) { - return testConfig.name === frameworkToTestFilter; + // Match the exact name, and also treat a "name:platform" filter as a + // prefix so it includes variant tests like "name:platform:minimal". + return ( + testConfig.name === frameworkToTestFilter || + testConfig.name.startsWith(`${frameworkToTestFilter}:`) + ); } return testConfig.name.split(":")[0] === frameworkToTestFilter; }); diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index f5da742aa4..acc9a0a6b9 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "create-cloudflare", - "version": "2.70.11", + "version": "2.70.13", "description": "A CLI for creating and deploying new applications to Cloudflare.", "keywords": [ "cloudflare", diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index 6b47b815f9..0b0be6dc68 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -1,13 +1,13 @@ { "name": "frameworks_clis_info", "dependencies": { - "@angular/create": "22.0.6", + "@angular/create": "22.0.7", "@tanstack/cli": "0.69.5", "create-analog": "2.6.3", "create-astro": "5.2.2", - "create-docusaurus": "3.10.1", + "create-docusaurus": "3.10.2", "create-hono": "0.19.4", - "create-next-app": "16.2.10", + "create-next-app": "16.2.11", "create-qwik": "1.20.0", "create-react-router": "8.2.0", "create-rwsdk": "3.1.3", @@ -18,7 +18,7 @@ "create-waku": "0.12.5-1.0.0-alpha.10-0", "gatsby": "5.16.1", "nuxi": "3.36.1", - "sv": "0.16.2" + "sv": "0.16.3" }, "info": [ "This package.json is only used to keep track of the frameworks cli dependencies", diff --git a/packages/deploy-helpers/CHANGELOG.md b/packages/deploy-helpers/CHANGELOG.md index 390b803ea6..1601f215a0 100644 --- a/packages/deploy-helpers/CHANGELOG.md +++ b/packages/deploy-helpers/CHANGELOG.md @@ -1,5 +1,33 @@ # @cloudflare/deploy-helpers +## 0.6.0 + +### Minor Changes + +- [#14471](https://github.com/cloudflare/workers-sdk/pull/14471) [`f03b108`](https://github.com/cloudflare/workers-sdk/commit/f03b10854d983c353fd4f3d6621b5ed716379ba3) Thanks [@DiogoSantoss](https://github.com/DiogoSantoss)! - Apply Email Routing `addresses` during Worker trigger deployment + + Worker trigger deployment now reconciles the Worker's Email Routing rules with the top-level `addresses` config. This runs for `wrangler deploy`, `wrangler triggers deploy`, and clients of `@cloudflare/deploy-helpers`. After the Worker uploads, or when `wrangler triggers deploy` runs after a version promotion, the deploy helper asks the Email Routing API for a plan, renders the changes grouped by zone (`+` added, `~` updated, `-` deleted, `!` conflict), prompts once for destructive changes in interactive mode, and applies accepted changes through the per-zone rule endpoints. Purely additive plans apply without a prompt, while non-interactive destructive plans fail without modifying rules. + +### Patch Changes + +- [#14744](https://github.com/cloudflare/workers-sdk/pull/14744) [`a0a091b`](https://github.com/cloudflare/workers-sdk/commit/a0a091b9246c5e10408f57342b3275659c9655e3) Thanks [@penalosa](https://github.com/penalosa)! - Drop the "Experimental:" prefix from the resource provisioning header now that automatic provisioning is generally available. The deploy output now reads `The following bindings need to be provisioned:`. + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`a6c214f`](https://github.com/cloudflare/workers-sdk/commit/a6c214fb311215b1ed09b273171b7995033fb7d7), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9)]: + - miniflare@4.20260721.0 + - @cloudflare/workers-utils@0.28.0 + - @cloudflare/cli-shared-helpers@0.1.16 + +## 0.5.1 + +### Patch Changes + +- [#14707](https://github.com/cloudflare/workers-sdk/pull/14707) [`b38f494`](https://github.com/cloudflare/workers-sdk/commit/b38f494204e5e08e561b8f198ef928188e554868) Thanks [@emily-shen](https://github.com/emily-shen)! - Update zod to v4 + +- Updated dependencies [[`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983), [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce), [`8cd805d`](https://github.com/cloudflare/workers-sdk/commit/8cd805db2f9901cba52d574b385577bafd595cb5), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d), [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c), [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8), [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645)]: + - miniflare@4.20260714.0 + - @cloudflare/cli-shared-helpers@0.1.15 + - @cloudflare/workers-utils@0.27.0 + ## 0.5.0 ### Minor Changes diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index 9db9cc636b..8e3438f658 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/deploy-helpers", - "version": "0.5.0", + "version": "0.6.0", "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": { @@ -25,6 +25,10 @@ "./context": { "import": "./dist/context.mjs", "types": "./dist/context.d.mts" + }, + "./create-worker-upload-form": { + "import": "./dist/create-worker-upload-form.mjs", + "types": "./dist/create-worker-upload-form.d.mts" } }, "scripts": { @@ -63,16 +67,7 @@ "ts-dedent": "^2.2.0", "tsup": "8.3.0", "typescript": "catalog:default", - "vitest": "catalog:default", - "zod": "^4.4.3" - }, - "peerDependencies": { - "zod": "^4.4.3" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } + "vitest": "catalog:default" }, "volta": { "extends": "../../package.json" diff --git a/packages/deploy-helpers/scripts/deps.ts b/packages/deploy-helpers/scripts/deps.ts index 125466ebbd..aba9685ddd 100644 --- a/packages/deploy-helpers/scripts/deps.ts +++ b/packages/deploy-helpers/scripts/deps.ts @@ -20,9 +20,4 @@ export const EXTERNAL_DEPENDENCIES = [ "p-queue", "pretty-bytes", "undici", - - // Externalized so wrangler bundles a single shared zod copy instead of - // inlining one via this package. Declared as a peerDependency (the - // consumer provides zod). - "zod", ]; diff --git a/packages/deploy-helpers/src/deploy/deploy.ts b/packages/deploy-helpers/src/deploy/deploy.ts index c4a4851fbc..f43d1d1500 100644 --- a/packages/deploy-helpers/src/deploy/deploy.ts +++ b/packages/deploy-helpers/src/deploy/deploy.ts @@ -328,7 +328,10 @@ export default async function deploy( cache: config.cache, package_dependencies: config.dependencies_instrumentation?.enabled !== false && projectRoot - ? await collectPackageDependencies(projectRoot) + ? await collectPackageDependencies( + projectRoot, + config.dependencies_instrumentation?.exclude_packages + ) : undefined, }; @@ -747,6 +750,7 @@ export default async function deploy( config, accountId, scriptName, + workerTag, env: props.env, crons: props.triggers, firstDeploy: !workerExists, diff --git a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts index 911680c705..5c1943c278 100644 --- a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts +++ b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts @@ -262,13 +262,29 @@ export function createWorkerUploadForm( }); queues.forEach(({ binding, queue_name, delivery_delay, raw }) => { - metadataBindings.push({ - type: "queue", - name: binding, - queue_name, - delivery_delay, - raw, - }); + if (options?.dryRun) { + queue_name ??= INHERIT_SYMBOL; + } + if (queue_name === undefined) { + throw new UserError( + `${binding} bindings must have a "queue" field. Add a "queue" field to the producer in your Worker configuration specifying the name of the Queue to bind to.`, + { + telemetryMessage: "queue binding missing name", + } + ); + } + + if (queue_name === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + type: "queue", + name: binding, + queue_name, + delivery_delay, + raw, + }); + } }); r2_buckets.forEach(({ binding, bucket_name, jurisdiction, raw }) => { @@ -277,7 +293,7 @@ export function createWorkerUploadForm( } if (bucket_name === undefined) { throw new UserError( - `${binding} bindings must have a "bucket_name" field`, + `${binding} bindings must have a "bucket_name" field. Add a "bucket_name" field to the binding in your Worker configuration specifying the name of the R2 bucket to bind to.`, { telemetryMessage: "r2 bucket binding missing bucket_name" } ); } @@ -305,7 +321,7 @@ export function createWorkerUploadForm( } if (database_id === undefined) { throw new UserError( - `${binding} bindings must have a "database_id" field`, + `${binding} bindings must have a "database_id" field. Add a "database_id" field to the binding in your Worker configuration specifying the ID of the D1 database to bind to.`, { telemetryMessage: "d1 database binding missing database_id" } ); } @@ -341,9 +357,12 @@ export function createWorkerUploadForm( namespace ??= INHERIT_SYMBOL; } if (namespace === undefined) { - throw new UserError(`${binding} bindings must have a "namespace" field`, { - telemetryMessage: "ai search namespace binding missing namespace", - }); + throw new UserError( + `${binding} bindings must have a "namespace" field. Add a "namespace" field to the binding in your Worker configuration specifying the AI Search namespace to bind to.`, + { + telemetryMessage: "ai search namespace binding missing namespace", + } + ); } if (namespace === INHERIT_SYMBOL) { @@ -380,9 +399,12 @@ export function createWorkerUploadForm( namespace ??= INHERIT_SYMBOL; } if (namespace === undefined) { - throw new UserError(`${binding} bindings must have a "namespace" field`, { - telemetryMessage: false, - }); + throw new UserError( + `${binding} bindings must have a "namespace" field. Add a "namespace" field to the binding in your Worker configuration specifying the namespace to bind to.`, + { + telemetryMessage: false, + } + ); } if (namespace === INHERIT_SYMBOL) { @@ -433,11 +455,23 @@ export function createWorkerUploadForm( }); flagship.forEach(({ binding, app_id }) => { - metadataBindings.push({ - name: binding, - type: "flagship", - app_id, - }); + if (options?.dryRun) { + app_id ??= INHERIT_SYMBOL; + } + if (app_id === undefined) { + throw new UserError( + `${binding} bindings must have an "app_id" field. Add an "app_id" field to the binding in your Worker configuration specifying the Flagship app to bind to.`, + { + telemetryMessage: "flagship binding missing app id", + } + ); + } + + metadataBindings.push( + app_id === INHERIT_SYMBOL + ? { name: binding, type: "inherit" } + : { name: binding, type: "flagship", app_id } + ); }); ratelimits.forEach(({ name, namespace_id, simple }) => { @@ -495,20 +529,36 @@ export function createWorkerUploadForm( }); dispatch_namespaces.forEach(({ binding, namespace, outbound }) => { - metadataBindings.push({ - name: binding, - type: "dispatch_namespace", - namespace, - ...(outbound && { - outbound: { - worker: { - service: outbound.service, - environment: outbound.environment, + if (options?.dryRun) { + namespace ??= INHERIT_SYMBOL; + } + if (namespace === undefined) { + throw new UserError( + `${binding} bindings must have a "namespace" field. Add a "namespace" field to the binding in your Worker configuration specifying the dispatch namespace to bind to.`, + { + telemetryMessage: "dispatch namespace binding missing namespace", + } + ); + } + + if (namespace === INHERIT_SYMBOL) { + metadataBindings.push({ name: binding, type: "inherit" }); + } else { + metadataBindings.push({ + name: binding, + type: "dispatch_namespace", + namespace, + ...(outbound && { + outbound: { + worker: { + service: outbound.service, + environment: outbound.environment, + }, + params: outbound.parameters?.map((p) => ({ name: p })), }, - params: outbound.parameters?.map((p) => ({ name: p })), - }, - }), - }); + }), + }); + } }); mtls_certificates.forEach(({ binding, certificate_id }) => { diff --git a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts index c15cc73acd..b8fdd310a4 100644 --- a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts +++ b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts @@ -26,6 +26,34 @@ export type PackageDependency = { */ const MAX_PACKAGE_DEPENDENCIES = 200; +/** + * Converts a glob pattern (supporting `*` wildcards) to a regular expression. + * + * The `*` character matches any sequence of characters. All other regex-special + * characters in the pattern are escaped. + * + * @param pattern - A glob-style pattern, e.g. `"@internal/*"` or `"*-utils"` + * @returns A `RegExp` that matches strings against the given pattern + */ +function globPatternToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + const regexStr = escaped.replace(/\*/g, ".*"); + return new RegExp(`^${regexStr}$`); +} + +/** + * Tests whether a package name matches any of the given exclusion patterns. + * + * @param name - The package name to test + * @param excludePatterns - An array of glob patterns to match against + * @returns `true` if the name matches at least one pattern + */ +function isExcludedPackage(name: string, excludePatterns: string[]): boolean { + return excludePatterns.some((pattern) => + globPatternToRegExp(pattern).test(name) + ); +} + /** * Collects npm package dependency metadata from the project's package.json. * @@ -35,15 +63,19 @@ const MAX_PACKAGE_DEPENDENCIES = 200; * - Pnpm catalog packages (version prefixed with `catalog:`) * - Local packages (version prefixed with `file:` or `link:`) * - Packages whose installed version cannot be resolved + * - Packages matching any pattern in `excludePackages` * * The result is capped at {@link MAX_PACKAGE_DEPENDENCIES} entries. * * @param projectPath - Path to the project directory (where package.json is located) + * @param excludePackages - Optional list of package name patterns (glob-style with + * `*` wildcards) to exclude from the collected dependencies * @returns An array of package dependency entries, or `undefined` if package.json * cannot be read or no valid dependencies are found */ export async function collectPackageDependencies( - projectPath: string + projectPath: string, + excludePackages?: string[] ): Promise { const packageJsonPath = path.join(projectPath, "package.json"); @@ -80,6 +112,13 @@ export async function collectPackageDependencies( continue; } + if ( + excludePackages?.length && + isExcludedPackage(dependencyName, excludePackages) + ) { + continue; + } + const installedVersion = getInstalledPackageVersion( dependencyName, projectPath diff --git a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts index 69a85d7480..38adb86a46 100644 --- a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts +++ b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts @@ -771,9 +771,12 @@ export function printBindings( return { name: binding, type: getBindingTypeFriendlyName("dispatch_namespace"), - value: outbound - ? `${namespace} (outbound -> ${outbound.service})` - : namespace, + value: + typeof namespace === "symbol" + ? namespace + : outbound + ? `${namespace} (outbound -> ${outbound.service})` + : namespace, mode: getMode({ isSimulatedLocally: remote && !context.remoteBindingsDisabled ? false : undefined, @@ -821,7 +824,7 @@ export function printBindings( } else { let title: string; if (context.provisioning) { - title = `${chalk.red("Experimental:")} The following bindings need to be provisioned:`; + title = "The following bindings need to be provisioned:"; } else if (context.name && isMultiWorker) { title = `${chalk.blue(context.name)} has access to the following bindings:`; } else { diff --git a/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts index 18a3a8eef9..5617acfeb9 100644 --- a/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts +++ b/packages/deploy-helpers/src/deploy/helpers/provision-bindings.ts @@ -8,13 +8,23 @@ import { PatchConfigError, UserError, } from "@cloudflare/workers-utils"; -import { fetchResult, logger, prompt, select } from "../../shared/context"; +import { + fetchListResult, + fetchPagedListResult, + fetchResult, + logger, + prompt, + select, +} from "../../shared/context"; import { printBindings } from "./print-bindings"; +import type { QueueResponse } from "../../triggers/queue-consumers"; import type { Binding, CfAgentMemory, CfAISearchNamespace, CfD1Database, + CfDispatchNamespace, + CfFlagship, CfKvNamespace, CfR2Bucket, ComplianceConfig, @@ -68,6 +78,20 @@ type KVNamespaceInfo = { supports_url_encoding?: boolean; }; +type DispatchNamespaceInfo = { + namespace_id: string; + namespace_name: string; +}; + +type FlagshipAppInfo = { + id: string; + name: string; +}; + +type QueueProducer = NonNullable< + NonNullable["producers"] +>[number]; + abstract class ProvisionResourceHandler< T extends WorkerMetadataBinding["type"], B extends ProvisionableBinding, @@ -349,6 +373,200 @@ class AgentMemoryNamespaceHandler extends ProvisionResourceHandler< } } +class QueueHandler extends ProvisionResourceHandler< + "queue", + Extract +> { + get name(): string | undefined { + return this.binding.queue_name as string; + } + + async create(name: string) { + await fetchResult( + this.complianceConfig, + `/accounts/${this.accountId}/queues`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ queue_name: name }), + } + ); + return name; + } + + constructor( + bindingName: string, + binding: Extract, + complianceConfig: ComplianceConfig, + accountId: string + ) { + super( + "queue", + bindingName, + binding, + "queue_name", + complianceConfig, + accountId + ); + } + + canInherit(settings: Settings | undefined): boolean { + const existing = settings?.bindings.find( + (candidate) => + candidate.type === this.type && + candidate.name === this.bindingName && + (this.binding.queue_name + ? this.binding.queue_name === candidate.queue_name + : true) + ); + if (existing?.type !== this.type) { + return false; + } + if ( + this.binding.delivery_delay !== undefined || + this.binding.raw !== undefined + ) { + this.connect(existing.queue_name); + return false; + } + return true; + } + + async isConnectedToExistingResource(): Promise { + assert(typeof this.binding.queue_name !== "symbol"); + if (!this.binding.queue_name) { + return false; + } + const queues = await fetchPagedListResult( + this.complianceConfig, + `/accounts/${this.accountId}/queues`, + {}, + new URLSearchParams({ name: this.binding.queue_name }) + ); + return queues.some((queue) => queue.queue_name === this.binding.queue_name); + } +} + +class DispatchNamespaceHandler extends ProvisionResourceHandler< + "dispatch_namespace", + Extract +> { + get name(): string | undefined { + return this.binding.namespace as string; + } + + async create(name: string) { + await fetchResult( + this.complianceConfig, + `/accounts/${this.accountId}/workers/dispatch/namespaces`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + } + ); + return name; + } + + constructor( + bindingName: string, + binding: Extract, + complianceConfig: ComplianceConfig, + accountId: string + ) { + super( + "dispatch_namespace", + bindingName, + binding, + "namespace", + complianceConfig, + accountId + ); + } + + canInherit(settings: Settings | undefined): boolean { + const existing = settings?.bindings.find( + (candidate) => + candidate.type === this.type && + candidate.name === this.bindingName && + (this.binding.namespace + ? this.binding.namespace === candidate.namespace + : true) + ); + if (existing?.type !== this.type) { + return false; + } + if (this.binding.outbound !== undefined) { + this.connect(existing.namespace); + return false; + } + return true; + } + + async isConnectedToExistingResource(): Promise { + assert(typeof this.binding.namespace !== "symbol"); + if (!this.binding.namespace) { + return false; + } + const namespaces = await listDispatchNamespaces( + this.complianceConfig, + this.accountId + ); + return namespaces.some( + (namespace) => namespace.namespace_name === this.binding.namespace + ); + } +} + +class FlagshipHandler extends ProvisionResourceHandler< + "flagship", + Extract +> { + get name(): undefined { + return undefined; + } + + async create(name: string) { + const app = await fetchResult( + this.complianceConfig, + `/accounts/${this.accountId}/flagship/apps`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + } + ); + return app.id; + } + + constructor( + bindingName: string, + binding: Extract, + complianceConfig: ComplianceConfig, + accountId: string + ) { + super( + "flagship", + bindingName, + binding, + "app_id", + complianceConfig, + accountId + ); + } + + canInherit(settings: Settings | undefined): boolean { + return !!settings?.bindings.find( + (existing) => + existing.type === this.type && existing.name === this.bindingName + ); + } + + isFullySpecified(): boolean { + return !!this.binding.app_id; + } +} + class KVHandler extends ProvisionResourceHandler< "kv_namespace", Extract @@ -476,7 +694,10 @@ type ProvisionableBinding = | Extract | Extract | Extract - | Extract; + | Extract + | Extract + | Extract + | Extract; const HANDLERS = { kv_namespace: { @@ -605,8 +826,114 @@ const HANDLERS = { }; }, }, + queue: { + Handler: QueueHandler, + sort: 5, + name: "Queue", + keyDescription: "name", + configField: "queues" as const, + load: async (complianceConfig: ComplianceConfig, accountId: string) => { + const queues = await fetchPagedListResult( + complianceConfig, + `/accounts/${accountId}/queues` + ); + return queues.map((queue) => ({ + title: queue.queue_name, + value: queue.queue_name, + })); + }, + toConfig: ( + bindingName: string, + binding: Extract + ): QueueProducer => ({ + binding: bindingName, + queue: + typeof binding.queue_name === "string" ? binding.queue_name : undefined, + delivery_delay: binding.delivery_delay, + remote: binding.remote, + }), + }, + dispatch_namespace: { + Handler: DispatchNamespaceHandler, + sort: 6, + name: "Dispatch Namespace", + keyDescription: "name", + configField: "dispatch_namespaces" as const, + load: async (complianceConfig: ComplianceConfig, accountId: string) => { + const namespaces = await listDispatchNamespaces( + complianceConfig, + accountId + ); + return namespaces.map((namespace) => ({ + title: namespace.namespace_name, + value: namespace.namespace_name, + })); + }, + toConfig: ( + bindingName: string, + binding: Extract + ): CfDispatchNamespace => { + const { type: _, ...rest } = binding; + return { ...rest, binding: bindingName }; + }, + }, + flagship: { + Handler: FlagshipHandler, + sort: 7, + name: "Flagship App", + keyDescription: "name or id", + configField: "flagship" as const, + load: async (complianceConfig: ComplianceConfig, accountId: string) => { + const apps = await listFlagshipApps(complianceConfig, accountId); + return apps.map((app) => ({ title: app.name, value: app.id })); + }, + toConfig: ( + bindingName: string, + binding: Extract + ): CfFlagship => { + const { type: _, ...rest } = binding; + return { ...rest, binding: bindingName }; + }, + }, }; +function getRawConfigBindings( + config: RawConfig, + resourceType: keyof typeof HANDLERS +): Array<{ binding: string }> { + if (resourceType === "queue") { + return config.queues?.producers ?? []; + } + + const configField = HANDLERS[resourceType].configField; + return (config[configField] ?? []) as Array<{ binding: string }>; +} + +function addBindingToPatch( + patch: RawConfig, + resourceType: ProvisionableBinding["type"], + binding: ReturnType +): void { + const serialisableBinding = Object.fromEntries( + Object.entries(binding).filter( + ([_, value]) => value !== undefined && typeof value !== "symbol" + ) + ); + + if (resourceType === "queue") { + patch.queues ??= {}; + patch.queues.producers ??= []; + patch.queues.producers.push(serialisableBinding as QueueProducer); + return; + } + + const configField = HANDLERS[resourceType].configField; + patch[configField] ??= []; + (patch[configField] as unknown as Array>).push( + serialisableBinding + ); +} + type PendingResource = { binding: string; resourceType: @@ -614,13 +941,19 @@ type PendingResource = { | "d1" | "r2_bucket" | "ai_search_namespace" - | "agent_memory"; + | "agent_memory" + | "queue" + | "dispatch_namespace" + | "flagship"; handler: | KVHandler | D1Handler | R2Handler | AISearchNamespaceHandler - | AgentMemoryNamespaceHandler; + | AgentMemoryNamespaceHandler + | QueueHandler + | DispatchNamespaceHandler + | FlagshipHandler; }; function isProvisionableBinding( @@ -639,7 +972,10 @@ function createHandler( | D1Handler | R2Handler | AISearchNamespaceHandler - | AgentMemoryNamespaceHandler { + | AgentMemoryNamespaceHandler + | QueueHandler + | DispatchNamespaceHandler + | FlagshipHandler { switch (binding.type) { case "kv_namespace": return new KVHandler(bindingName, binding, complianceConfig, accountId); @@ -661,6 +997,27 @@ function createHandler( complianceConfig, accountId ); + case "queue": + return new QueueHandler( + bindingName, + binding, + complianceConfig, + accountId + ); + case "dispatch_namespace": + return new DispatchNamespaceHandler( + bindingName, + binding, + complianceConfig, + accountId + ); + case "flagship": + return new FlagshipHandler( + bindingName, + binding, + complianceConfig, + accountId + ); } } @@ -672,7 +1029,10 @@ function toConfigBinding( | CfR2Bucket | CfD1Database | CfAISearchNamespace - | CfAgentMemory { + | CfAgentMemory + | QueueProducer + | CfDispatchNamespace + | CfFlagship { switch (binding.type) { case "kv_namespace": return HANDLERS.kv_namespace.toConfig(bindingName, binding); @@ -684,6 +1044,12 @@ function toConfigBinding( return HANDLERS.ai_search_namespace.toConfig(bindingName, binding); case "agent_memory": return HANDLERS.agent_memory.toConfig(bindingName, binding); + case "queue": + return HANDLERS.queue.toConfig(bindingName, binding); + case "dispatch_namespace": + return HANDLERS.dispatch_namespace.toConfig(bindingName, binding); + case "flagship": + return HANDLERS.flagship.toConfig(bindingName, binding); } } @@ -785,9 +1151,20 @@ export async function provisionBindings( ); } + for (const [bindingName, binding] of Object.entries(bindings ?? {})) { + if (binding.type === "queue" && typeof binding.queue_name === "string") { + const producer = config.queues.producers?.find( + (candidate) => candidate.binding === bindingName + ); + if (producer) { + producer.queue = binding.queue_name; + } + } + } + const patch: RawConfig = {}; - const existingBindingNames = new Set(); + const existingBindingNames = new Map>(); const isUsingRedirectedConfig = config.userConfigPath && config.userConfigPath !== config.configPath; @@ -804,10 +1181,14 @@ export async function provisionBindings( for (const resourceType of Object.keys( HANDLERS ) as (keyof typeof HANDLERS)[]) { - const configField = HANDLERS[resourceType].configField; - for (const binding of unredirectedConfig[configField] ?? []) { - existingBindingNames.add(binding.binding); + const bindingNames = new Set(); + for (const binding of getRawConfigBindings( + unredirectedConfig, + resourceType + )) { + bindingNames.add(binding.binding); } + existingBindingNames.set(resourceType, bindingNames); } } @@ -817,25 +1198,15 @@ export async function provisionBindings( } // See above for why we skip writing back some bindings to the config file. - if (isUsingRedirectedConfig && !existingBindingNames.has(bindingName)) { + if ( + isUsingRedirectedConfig && + !existingBindingNames.get(binding.type)?.has(bindingName) + ) { continue; } - const resourceType = HANDLERS[binding.type].configField; - - patch[resourceType] ??= []; - const bindingToWrite = toConfigBinding(bindingName, binding); - - (patch[resourceType] as unknown as Array>).push( - Object.fromEntries( - Object.entries(bindingToWrite).filter( - // Make sure all the values are JSON serialisable. - // Otherwise we end up with "undefined" in the config. - ([_, value]) => typeof value === "string" - ) - ) - ); + addBindingToPatch(patch, binding.type, bindingToWrite); } // If the user is performing an interactive deploy, write the provisioned IDs back to the config file. @@ -1040,6 +1411,26 @@ async function listKVNamespaces( return results; } +async function listDispatchNamespaces( + complianceConfig: ComplianceConfig, + accountId: string +): Promise { + return await fetchPagedListResult( + complianceConfig, + `/accounts/${accountId}/workers/dispatch/namespaces` + ); +} + +async function listFlagshipApps( + complianceConfig: ComplianceConfig, + accountId: string +): Promise { + return await fetchListResult( + complianceConfig, + `/accounts/${accountId}/flagship/apps` + ); +} + async function createD1Database( complianceConfig: ComplianceConfig, accountId: string, diff --git a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts index 490a7cf198..ee48a53d71 100644 --- a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts +++ b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts @@ -302,7 +302,12 @@ export async function preUploadApiChecks( } } - await ensureQueuesExistByConfig(config, accountId); + await ensureQueuesExistByConfig( + config, + accountId, + !props.resourcesProvision, + name + ); // Resolve whether this deploy will actually publish to workers.dev, using // the same logic as the triggers phase (`getSubdomainValues`): workers_dev diff --git a/packages/deploy-helpers/src/deploy/versions-upload.ts b/packages/deploy-helpers/src/deploy/versions-upload.ts index 70c78bedd1..ebce780fd4 100644 --- a/packages/deploy-helpers/src/deploy/versions-upload.ts +++ b/packages/deploy-helpers/src/deploy/versions-upload.ts @@ -192,7 +192,10 @@ export default async function versionsUpload( cache: config.cache, // cache is a versioned setting package_dependencies: config.dependencies_instrumentation?.enabled !== false && projectRoot - ? await collectPackageDependencies(projectRoot) + ? await collectPackageDependencies( + projectRoot, + config.dependencies_instrumentation?.exclude_packages + ) : undefined, }; diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index 679687188f..e8fcd21684 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -161,6 +161,7 @@ export type TriggerProps = { config: Config; accountId: string; scriptName: string; + workerTag?: string | null; env: string | undefined; crons: string[] | undefined; routes: Route[]; diff --git a/packages/deploy-helpers/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts index ecd5ae7464..7fe08fe34a 100644 --- a/packages/deploy-helpers/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -10,6 +10,7 @@ import chalk from "chalk"; import PQueue from "p-queue"; import { WORKFLOW_CRON_REQUIRES_PAID_PLAN_CODE } from "../deploy/helpers/error-codes"; import { fetchListResult, fetchResult, logger } from "../shared/context"; +import { applyEmailRoutingAddresses } from "./email-routing"; import { publishCustomDomains, publishRoutes, @@ -221,7 +222,9 @@ export async function triggersDeploy( if (config.queues.producers && config.queues.producers.length) { deployments.push( ...config.queues.producers.map((producer) => - Promise.resolve({ targets: [`Producer for ${producer.queue}`] }) + Promise.resolve({ + targets: [`Producer for ${producer.queue ?? producer.binding}`], + }) ) ); } @@ -339,6 +342,20 @@ export async function triggersDeploy( .map((deployment) => deployment.error) .filter((error): error is Error => error !== undefined); + try { + await applyEmailRoutingAddresses({ + config, + accountId, + scriptName, + workerTag: props.workerTag, + }); + } catch (error) { + if (errors.length === 0) { + throw error; + } + errors.push(error instanceof Error ? error : new Error(String(error))); + } + if (errors.length > 0) { throw new UserError( `Some triggers failed to deploy for ${workerName}:\n` + diff --git a/packages/deploy-helpers/src/triggers/email-routing-plan.ts b/packages/deploy-helpers/src/triggers/email-routing-plan.ts new file mode 100644 index 0000000000..1d8f867241 --- /dev/null +++ b/packages/deploy-helpers/src/triggers/email-routing-plan.ts @@ -0,0 +1,236 @@ +/** + * Pure plan logic for the `addresses` config field: build the plan request and + * render the plan response. No network access. + */ + +interface EmailRoutingAction { + type: string; + value?: string[]; +} + +interface EmailRoutingMatcher { + type: string; + field?: string; + value?: string; +} + +/** A `*@domain` catch-all target (e.g. `"*@example.com"`), not a literal recipient. */ +export function isCatchAllAddress(address: string): boolean { + return address.startsWith("*@") && address.length > "*@".length; +} + +export interface EmailRoutingPlanRule { + matchers: EmailRoutingMatcher[]; + actions: EmailRoutingAction[]; +} + +export interface EmailRoutingPlanCatchAll { + /** The `*@domain` target this catch-all rule applies to. */ + target: string; + rule: EmailRoutingPlanRule; +} + +export interface EmailRoutingPlanRequest { + owner_worker_tag: string; + rules: EmailRoutingPlanRule[]; + catch_all_rules: EmailRoutingPlanCatchAll[]; +} + +export type PlanChangeType = "added" | "updated" | "deleted" | "conflict"; + +export interface PlanRemoteRule { + id?: string; + matchers: EmailRoutingMatcher[]; + actions: EmailRoutingAction[]; + source?: "api" | "wrangler"; + owner_worker_name?: string; +} + +export interface EmailRoutingPlanChange { + type: PlanChangeType; + /** Recipient address or `*@domain` catch-all target this change applies to. */ + target: string; + /** Present for updates, deletes, and conflicts. */ + remote?: PlanRemoteRule; +} + +export interface EmailRoutingPlanZone { + zone_id: string; + zone_name?: string; + changes: EmailRoutingPlanChange[]; +} + +export interface EmailRoutingPlanResponse { + zones: EmailRoutingPlanZone[]; +} + +/** + * The matchers + actions routing one address to `workerName`. Single source of + * truth shared by the plan request and the apply bodies. + */ +export function ruleShapeForTarget( + target: string, + workerName: string +): EmailRoutingPlanRule { + const actions: EmailRoutingAction[] = [ + { type: "worker", value: [workerName] }, + ]; + if (isCatchAllAddress(target)) { + return { matchers: [{ type: "all" }], actions }; + } + return { + matchers: [{ type: "literal", field: "to", value: target }], + actions, + }; +} + +/** + * Compile the `addresses` config into a plan request: literal recipients become + * normal rules, `*@domain` entries become catch-all rules carrying the target. + */ +export function buildEmailRoutingPlanRequest( + addresses: string[], + workerName: string, + ownerWorkerTag: string +): EmailRoutingPlanRequest { + const rules: EmailRoutingPlanRule[] = []; + const catchAllRules: EmailRoutingPlanCatchAll[] = []; + + for (const address of addresses) { + const rule = ruleShapeForTarget(address, workerName); + if (isCatchAllAddress(address)) { + catchAllRules.push({ target: address, rule }); + } else { + rules.push(rule); + } + } + + return { + owner_worker_tag: ownerWorkerTag, + rules, + catch_all_rules: catchAllRules, + }; +} + +/** Changes that need user confirmation: deletes (incl. catch-all resets) and conflicts. */ +export function isDestructiveChange(change: EmailRoutingPlanChange): boolean { + return change.type === "deleted" || change.type === "conflict"; +} + +export function planHasChanges(plan: EmailRoutingPlanResponse): boolean { + return plan.zones.some((zone) => zone.changes.length > 0); +} + +export function planHasDestructiveChanges( + plan: EmailRoutingPlanResponse +): boolean { + return plan.zones.some((zone) => zone.changes.some(isDestructiveChange)); +} + +const CHANGE_MARKERS: Record = { + added: "+", + updated: "~", + deleted: "-", + conflict: "!", +}; + +/** Human description of what a remote rule currently does, for conflict lines. */ +function describeRemoteAction(remote: PlanRemoteRule): string { + const action = remote.actions[0]; + if (!action) { + return "no action"; + } + const value = action.value?.join(", "); + switch (action.type) { + case "forward": + return value ? `forward to ${value}` : "forward"; + case "worker": + return value ? `worker ${value}` : "worker"; + case "drop": + return "drop"; + default: + return action.type; + } +} + +/** Human description of who owns a conflicting remote rule. */ +function describeConflictOwner(remote: PlanRemoteRule): string { + if (remote.source === "wrangler") { + return remote.owner_worker_name + ? `owned by worker "${remote.owner_worker_name}"` + : "owned by another Worker"; + } + return "managed outside Wrangler"; +} + +/** + * Render the plan grouped by zone, one change per line (`+ ~ - !`) plus a + * summary. Returns lines so callers can log and tests can assert. + */ +export function renderEmailRoutingPlan( + plan: EmailRoutingPlanResponse, + workerName: string +): string[] { + const lines: string[] = []; + const counts: Record = { + added: 0, + updated: 0, + deleted: 0, + conflict: 0, + }; + let zonesWithChanges = 0; + + for (const zone of plan.zones) { + if (zone.changes.length === 0) { + continue; + } + zonesWithChanges++; + lines.push(zone.zone_name ?? zone.zone_id); + + for (const change of zone.changes) { + counts[change.type]++; + const marker = CHANGE_MARKERS[change.type]; + switch (change.type) { + case "added": + case "updated": + lines.push(` ${marker} ${change.target} -> worker (${workerName})`); + break; + case "deleted": + lines.push(` ${marker} ${change.target} (removed from config)`); + break; + case "conflict": { + const remote = change.remote; + const detail = remote + ? `conflict: ${describeConflictOwner(remote)} (${describeRemoteAction(remote)})` + : "conflict"; + lines.push(` ${marker} ${change.target} -> ${detail}`); + break; + } + } + } + } + + const total = + counts.added + counts.updated + counts.deleted + counts.conflict; + const parts: string[] = []; + if (counts.added) { + parts.push(`${counts.added} added`); + } + if (counts.updated) { + parts.push(`${counts.updated} updated`); + } + if (counts.deleted) { + parts.push(`${counts.deleted} deleted`); + } + if (counts.conflict) { + parts.push(`${counts.conflict} conflict`); + } + + lines.push( + `${total} ${total === 1 ? "change" : "changes"} across ${zonesWithChanges} ${ + zonesWithChanges === 1 ? "zone" : "zones" + }${parts.length ? ` (${parts.join(", ")})` : ""}` + ); + + return lines; +} diff --git a/packages/deploy-helpers/src/triggers/email-routing.ts b/packages/deploy-helpers/src/triggers/email-routing.ts new file mode 100644 index 0000000000..5d4d36f5ab --- /dev/null +++ b/packages/deploy-helpers/src/triggers/email-routing.ts @@ -0,0 +1,341 @@ +import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; +import { + APIError, + isNonInteractiveOrCI, + UserError, +} from "@cloudflare/workers-utils"; +import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; +import { confirm, fetchResult, logger } from "../shared/context"; +import { + buildEmailRoutingPlanRequest, + isCatchAllAddress, + planHasChanges, + planHasDestructiveChanges, + renderEmailRoutingPlan, + ruleShapeForTarget, +} from "./email-routing-plan"; +import type { + EmailRoutingPlanChange, + EmailRoutingPlanRequest, + EmailRoutingPlanResponse, +} from "./email-routing-plan"; +import type { Config } from "@cloudflare/workers-utils"; + +/** + * Network side of the `addresses` config field: plan the change set via the + * account-level endpoint, then apply it through the per-zone rule endpoints. + */ + +const PLAN_RETRY_WORKER_NOT_FOUND_CODE = 2016; +const PLAN_RETRY_TIMEOUT_MS = 30_000; +const PLAN_RETRY_DELAY_MS = 3_000; +const NON_INTERACTIVE_PROGRESS_INTERVAL = 10; + +function applyProgressMessage(done: number, total: number): string { + return `Applying Email Routing changes (${done}/${total}, ${Math.floor( + (done * 100) / total + )}%)`; +} + +function startApplyProgress(total: number): { + update(done: number): void; + stop(): void; +} { + if (!isNonInteractiveOrCI()) { + const progress = spinner(); + progress.start(applyProgressMessage(0, total)); + + return { + update: (done) => progress.update(applyProgressMessage(done, total)), + stop: () => progress.stop(), + }; + } + + logger.log(applyProgressMessage(0, total)); + + return { + update(done) { + if (done === total || done % NON_INTERACTIVE_PROGRESS_INTERVAL === 0) { + logger.log(applyProgressMessage(done, total)); + } + }, + stop() {}, + }; +} + +function postJson(body: unknown): RequestInit { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +/** The desired rule body for a target owned by this Worker (create/update). */ +function desiredRuleBody( + target: string, + workerName: string, + ownerWorkerTag: string +) { + return { + ...ruleShapeForTarget(target, workerName), + enabled: true, + source: "wrangler", + owner_worker_tag: ownerWorkerTag, + }; +} + +/** Generated default catch-all (drop, disabled), with ownership cleared. */ +const RESET_CATCH_ALL_BODY = { + matchers: [{ type: "all" }], + actions: [{ type: "drop" }], + enabled: false, + name: "", + source: "api", +}; + +/** Resolve the deployed Worker's stable public tag (used as owner_worker_tag). */ +async function resolveOwnerWorkerTag( + config: Config, + accountId: string, + scriptName: string +): Promise { + const deadline = Date.now() + PLAN_RETRY_TIMEOUT_MS; + for (;;) { + try { + const { default_environment } = await fetchResult<{ + default_environment: { script: { tag: string } }; + }>(config, `/accounts/${accountId}/workers/services/${scriptName}`); + return default_environment.script.tag; + } catch (error) { + if (!isWorkerNotFoundError(error)) { + throw error; + } + if (Date.now() + PLAN_RETRY_DELAY_MS >= deadline) { + throw new UserError( + "Email Routing could not find the deployed Worker yet. Re-run the deployment.", + { telemetryMessage: "email routing worker metadata not found" } + ); + } + await new Promise((resolve) => setTimeout(resolve, PLAN_RETRY_DELAY_MS)); + } + } +} + +/** + * Call the account-level plan endpoint. The Worker was just uploaded, so retry + * on "worker not found" (2016) until it propagates or we hit the ~30s cap. + */ +async function fetchEmailRoutingPlan( + config: Config, + accountId: string, + request: EmailRoutingPlanRequest +): Promise { + const deadline = Date.now() + PLAN_RETRY_TIMEOUT_MS; + for (;;) { + try { + return await fetchResult( + config, + `/accounts/${accountId}/email/routing/rules/plan`, + postJson(request) + ); + } catch (e) { + const isWorkerNotFound = + e instanceof APIError && e.code === PLAN_RETRY_WORKER_NOT_FOUND_CODE; + if (!isWorkerNotFound) { + throw e; + } + if (Date.now() + PLAN_RETRY_DELAY_MS >= deadline) { + throw new UserError( + "Email Routing could not find the deployed Worker yet. Re-run the deployment.", + { telemetryMessage: "email routing plan worker not found" } + ); + } + await new Promise((resolve) => setTimeout(resolve, PLAN_RETRY_DELAY_MS)); + } + } +} + +function putJson( + config: Config, + path: string, + body: unknown +): Promise { + return fetchResult(config, path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Apply a single plan change through the per-zone rule endpoints. */ +async function applyChange( + config: Config, + zoneId: string, + change: EmailRoutingPlanChange, + workerName: string, + ownerWorkerTag: string +): Promise { + if (isCatchAllAddress(change.target)) { + // Catch-all has no DELETE endpoint: a delete is a reset to the default. + await putJson( + config, + `/zones/${zoneId}/email/routing/rules/catch_all`, + change.type === "deleted" + ? RESET_CATCH_ALL_BODY + : desiredRuleBody(change.target, workerName, ownerWorkerTag) + ); + return; + } + + switch (change.type) { + case "added": + await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules`, + postJson(desiredRuleBody(change.target, workerName, ownerWorkerTag)) + ); + return; + // `conflict` only reaches here once the user accepted the takeover; it is + // applied the same way as an update: overwrite the existing rule. + case "updated": + case "conflict": { + const id = change.remote?.id; + if (!id) { + throw new UserError( + `Email Routing plan was missing the rule id for "${change.target}"; re-run the deployment.`, + { telemetryMessage: "email routing plan missing rule id" } + ); + } + await putJson( + config, + `/zones/${zoneId}/email/routing/rules/${id}`, + desiredRuleBody(change.target, workerName, ownerWorkerTag) + ); + return; + } + case "deleted": { + const id = change.remote?.id; + if (!id) { + throw new UserError( + `Email Routing plan was missing the rule id for "${change.target}"; re-run the deployment.`, + { telemetryMessage: "email routing plan missing rule id" } + ); + } + await fetchResult(config, `/zones/${zoneId}/email/routing/rules/${id}`, { + method: "DELETE", + }); + return; + } + } +} + +/** + * Reconcile the Worker's Email Routing rules with the `addresses` config after + * its triggers deploy. No-op when `addresses` is absent. Prompts once for + * destructive changes and hard-fails non-interactively. + */ +export async function applyEmailRoutingAddresses({ + config, + accountId, + scriptName, + workerTag, +}: { + config: Config; + accountId: string; + scriptName: string; + workerTag?: string | null; +}): Promise { + const { addresses } = config; + if (addresses === undefined) { + return; + } + + const ownerWorkerTag = + workerTag ?? (await resolveOwnerWorkerTag(config, accountId, scriptName)); + const request = buildEmailRoutingPlanRequest( + addresses, + scriptName, + ownerWorkerTag + ); + const plan = await fetchEmailRoutingPlan(config, accountId, request); + + if (!planHasChanges(plan)) { + logger.log("Email Routing rules are up to date."); + return; + } + + logger.log( + ["Email Routing plan:", ...renderEmailRoutingPlan(plan, scriptName)].join( + "\n" + ) + ); + + if (planHasDestructiveChanges(plan)) { + if (isNonInteractiveOrCI()) { + throw new UserError( + "The Worker is deployed, but Email Routing has destructive changes (deletes or takeover conflicts) that need confirmation. " + + "Re-run the deployment interactively, or remove the conflicting entries from `addresses`.", + { + telemetryMessage: + "email routing destructive changes need confirmation", + } + ); + } + const accepted = await confirm( + "Apply these Email Routing changes (including the destructive ones above)?", + { defaultValue: false } + ); + if (!accepted) { + throw new UserError( + "The Worker is deployed, but the Email Routing changes were declined; no rules were modified.", + { telemetryMessage: "email routing changes declined" } + ); + } + } + + const failures: string[] = []; + const totalChanges = plan.zones.reduce( + (total, zone) => total + zone.changes.length, + 0 + ); + const progress = startApplyProgress(totalChanges); + let completedChanges = 0; + + try { + for (const zone of plan.zones) { + for (const change of zone.changes) { + try { + await applyChange( + config, + zone.zone_id, + change, + scriptName, + ownerWorkerTag + ); + } catch (e) { + failures.push( + `${change.target}: ${e instanceof Error ? e.message : String(e)}` + ); + } finally { + completedChanges++; + progress.update(completedChanges); + } + } + } + } finally { + progress.stop(); + } + + if (failures.length > 0) { + for (const failure of failures) { + logger.error(`Email Routing change failed: ${failure}`); + } + throw new UserError( + `The Worker is deployed, but Email Routing was not fully applied (${failures.length} change(s) failed). Re-run the deployment to retry.`, + { telemetryMessage: "email routing apply partial failure" } + ); + } + + logger.log("Email Routing addresses applied."); +} diff --git a/packages/deploy-helpers/src/triggers/queue-consumers.ts b/packages/deploy-helpers/src/triggers/queue-consumers.ts index 8968a90236..0db3ec551d 100644 --- a/packages/deploy-helpers/src/triggers/queue-consumers.ts +++ b/packages/deploy-helpers/src/triggers/queue-consumers.ts @@ -407,14 +407,26 @@ const queuesUrl = (accountId: string, queueId?: string): string => { export async function ensureQueuesExistByConfig( config: Config, - accountId: string + accountId: string, + includeProducers = true, + scriptName?: string ) { - const producers = (config.queues.producers || []).map( - (producer) => producer.queue - ); - const consumers = (config.queues.consumers || []).map( - (consumer) => consumer.queue - ); + const producerNames = (config.queues.producers || []).flatMap((producer) => { + if (producer.queue) { + return [producer.queue]; + } + if (!includeProducers && scriptName) { + return [ + `${scriptName}-${producer.binding.toLowerCase().replaceAll("_", "-")}`, + ]; + } + return []; + }); + const producers = includeProducers ? producerNames : []; + const provisionableQueues = new Set(producerNames); + const consumers = (config.queues.consumers || []) + .map((consumer) => consumer.queue) + .filter((queue) => includeProducers || !provisionableQueues.has(queue)); const queueNames = producers.concat(consumers); if (queueNames.length > 0) { diff --git a/packages/deploy-helpers/tests/email-routing-apply.test.ts b/packages/deploy-helpers/tests/email-routing-apply.test.ts new file mode 100644 index 0000000000..39c6ecedcf --- /dev/null +++ b/packages/deploy-helpers/tests/email-routing-apply.test.ts @@ -0,0 +1,395 @@ +import { APIError } from "@cloudflare/workers-utils"; +import { beforeEach, describe, it, vi } from "vitest"; +import { initDeployHelpersContext } from "../src/shared/context"; +import { applyEmailRoutingAddresses } from "../src/triggers/email-routing"; +import type { EmailRoutingPlanResponse } from "../src/triggers/email-routing-plan"; +import type { Config } from "@cloudflare/workers-utils"; + +const { isNonInteractiveOrCI } = vi.hoisted(() => ({ + isNonInteractiveOrCI: vi.fn(() => true), +})); + +vi.mock("@cloudflare/workers-utils", async (importOriginal) => ({ + ...(await importOriginal()), + isNonInteractiveOrCI, +})); + +const ACCOUNT_ID = "some-account-id"; +const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; +const WORKER_NAME = "test-name"; +const PLAN_RETRY_DELAY_MS = 3_000; + +interface RuleWrites { + posts: unknown[]; + puts: { id: string; body: unknown }[]; + deletes: string[]; + catchAlls: unknown[]; +} + +function testConfig(addresses: string[]): Config { + return { addresses } as unknown as Config; +} + +describe("applyEmailRoutingAddresses", () => { + let plan: EmailRoutingPlanResponse; + let writes: RuleWrites; + let logs: string[]; + let errors: string[]; + let confirmResult: boolean; + let confirmRequests: number; + let nonInteractive: boolean; + let failTarget: string | undefined; + let metadataRequests: number; + let metadataFailures: number[]; + let planFailures: APIError[]; + let planBody: unknown; + + beforeEach(() => { + plan = { zones: [] }; + writes = { posts: [], puts: [], deletes: [], catchAlls: [] }; + logs = []; + errors = []; + confirmResult = true; + confirmRequests = 0; + nonInteractive = true; + isNonInteractiveOrCI.mockImplementation(() => nonInteractive); + failTarget = undefined; + metadataRequests = 0; + metadataFailures = []; + planFailures = []; + planBody = undefined; + + initDeployHelpersContext({ + logger: { + debug() {}, + info() {}, + warn() {}, + log: (...args) => logs.push(args.join(" ")), + error: (...args) => errors.push(args.join(" ")), + }, + fetchResult: fetchResult as never, + fetchListResult: (() => {}) as never, + fetchPagedListResult: (() => {}) as never, + fetchKVGetValue: (() => {}) as never, + confirm: async () => { + confirmRequests++; + return confirmResult; + }, + prompt: (() => {}) as never, + select: (() => {}) as never, + }); + }); + + async function fetchResult( + _config: Config, + path: string, + init?: RequestInit + ): Promise { + const body = + typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + + if (path.endsWith(`/workers/services/${WORKER_NAME}`)) { + metadataRequests++; + const code = metadataFailures.shift(); + if (code !== undefined) { + throw { code }; + } + return { default_environment: { script: { tag: WORKER_TAG } } }; + } + if (path.endsWith("/email/routing/rules/plan")) { + const error = planFailures.shift(); + if (error) { + throw error; + } + planBody = body; + return plan; + } + if (path.endsWith("/email/routing/rules/catch_all")) { + writes.catchAlls.push(body); + return {}; + } + if (init?.method === "POST" && path.endsWith("/email/routing/rules")) { + const target = (body as { matchers: { value?: string }[] }).matchers[0] + ?.value; + if (target === failTarget) { + throw new Error("duplicate rule"); + } + writes.posts.push(body); + return {}; + } + const ruleId = path.split("/").at(-1); + if (init?.method === "PUT" && ruleId) { + writes.puts.push({ id: ruleId, body }); + return {}; + } + if (init?.method === "DELETE" && ruleId) { + writes.deletes.push(ruleId); + return {}; + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${path}`); + } + + function apply(addresses: string[], workerTag: string | null = WORKER_TAG) { + return applyEmailRoutingAddresses({ + config: testConfig(addresses), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag, + }); + } + + it("skips reconciliation when addresses are absent", async ({ expect }) => { + await applyEmailRoutingAddresses({ + config: {} as Config, + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + }); + + expect(planBody).toBeUndefined(); + }); + + it("applies an additive plan with ownership and progress", async ({ + expect, + }) => { + plan = { + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [{ type: "added", target: "support@example.com" }], + }, + ], + }; + + await apply(["support@example.com"]); + + expect(planBody).toMatchObject({ owner_worker_tag: WORKER_TAG }); + expect(writes.posts).toHaveLength(1); + expect(writes.posts[0]).toMatchObject({ + source: "wrangler", + owner_worker_tag: WORKER_TAG, + matchers: [ + { type: "literal", field: "to", value: "support@example.com" }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }); + expect(logs.join("\n")).toContain( + "Applying Email Routing changes (1/1, 100%)" + ); + expect(logs.join("\n")).toContain("Email Routing addresses applied."); + expect(confirmRequests).toBe(0); + }); + + it("resolves the Worker tag when standalone trigger deployment omits it", async ({ + expect, + }) => { + await apply(["support@example.com"], null); + + expect(metadataRequests).toBe(1); + expect(planBody).toMatchObject({ owner_worker_tag: WORKER_TAG }); + expect(logs.join("\n")).toContain("Email Routing rules are up to date."); + }); + + it("retries Worker metadata while a new Worker propagates", async ({ + expect, + }) => { + vi.useFakeTimers(); + metadataFailures = [10007]; + + try { + const applying = apply(["support@example.com"], null); + await vi.advanceTimersByTimeAsync(PLAN_RETRY_DELAY_MS); + await applying; + expect(metadataRequests).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("retries the plan while the Worker propagates to Email Routing", async ({ + expect, + }) => { + vi.useFakeTimers(); + const error = new APIError({ + status: 404, + text: "worker not found", + telemetryMessage: false, + }); + error.code = 2016; + planFailures = [error]; + + try { + const applying = apply(["support@example.com"]); + await vi.advanceTimersByTimeAsync(PLAN_RETRY_DELAY_MS); + await applying; + expect(planBody).toMatchObject({ owner_worker_tag: WORKER_TAG }); + } finally { + vi.useRealTimers(); + } + }); + + it("applies a destructive takeover after confirmation", async ({ + expect, + }) => { + nonInteractive = false; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { + type: "conflict", + target: "support@example.com", + remote: { + id: "existing-rule-id", + matchers: [], + actions: [{ type: "forward", value: ["a@b.com"] }], + }, + }, + ], + }, + { + zone_id: "zone2", + changes: [ + { + type: "deleted", + target: "old@example.net", + remote: { id: "old-rule-id", matchers: [], actions: [] }, + }, + ], + }, + ], + }; + + await apply(["support@example.com"]); + + expect(writes.puts).toEqual([ + expect.objectContaining({ + id: "existing-rule-id", + body: expect.objectContaining({ owner_worker_tag: WORKER_TAG }), + }), + ]); + expect(writes.deletes).toEqual(["old-rule-id"]); + expect(confirmRequests).toBe(1); + }); + + it("does not write rules when destructive changes are declined", async ({ + expect, + }) => { + nonInteractive = false; + confirmResult = false; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { + type: "deleted", + target: "old@example.com", + remote: { id: "old-rule-id", matchers: [], actions: [] }, + }, + ], + }, + ], + }; + + await expect(apply([])).rejects.toThrow(/changes were declined/); + expect(confirmRequests).toBe(1); + expect(writes).toEqual({ + posts: [], + puts: [], + deletes: [], + catchAlls: [], + }); + }); + + it("rejects destructive changes non-interactively", async ({ expect }) => { + plan = { + zones: [ + { + zone_id: "zone1", + changes: [{ type: "deleted", target: "old@example.com" }], + }, + ], + }; + + await expect(apply([])).rejects.toThrow( + /destructive changes .* need confirmation/ + ); + expect(writes.deletes).toEqual([]); + expect(confirmRequests).toBe(0); + }); + + it("resets a deleted catch-all to the disabled default", async ({ + expect, + }) => { + nonInteractive = false; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [{ type: "deleted", target: "*@example.com" }], + }, + ], + }; + + await apply([]); + + expect(writes.catchAlls).toEqual([ + expect.objectContaining({ + source: "api", + enabled: false, + matchers: [{ type: "all" }], + actions: [{ type: "drop" }], + }), + ]); + expect(writes.deletes).toEqual([]); + }); + + it("continues applying changes and reports partial failure", async ({ + expect, + }) => { + failTarget = "bad@example.com"; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { type: "added", target: "bad@example.com" }, + { type: "added", target: "ok@example.com" }, + ], + }, + ], + }; + + await expect(apply(["bad@example.com", "ok@example.com"])).rejects.toThrow( + /Email Routing was not fully applied/ + ); + expect(writes.posts).toHaveLength(1); + expect(errors.join("\n")).toContain("bad@example.com: duplicate rule"); + }); + + it("reports a missing remote rule id as an apply failure", async ({ + expect, + }) => { + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { + type: "updated", + target: "support@example.com", + remote: { matchers: [], actions: [] }, + }, + ], + }, + ], + }; + + await expect(apply(["support@example.com"])).rejects.toThrow( + /Email Routing was not fully applied/ + ); + expect(errors.join("\n")).toContain("missing the rule id"); + }); +}); diff --git a/packages/deploy-helpers/tests/email-routing-plan.test.ts b/packages/deploy-helpers/tests/email-routing-plan.test.ts new file mode 100644 index 0000000000..b850ed3bb0 --- /dev/null +++ b/packages/deploy-helpers/tests/email-routing-plan.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "vitest"; +import { + buildEmailRoutingPlanRequest, + isDestructiveChange, + planHasChanges, + planHasDestructiveChanges, + renderEmailRoutingPlan, +} from "../src/triggers/email-routing-plan"; +import type { EmailRoutingPlanResponse } from "../src/triggers/email-routing-plan"; + +describe("buildEmailRoutingPlanRequest", () => { + it("compiles literal addresses into normal rules targeting the worker", ({ + expect, + }) => { + const req = buildEmailRoutingPlanRequest( + ["support@example.com"], + "my-worker", + "a7e6fb77503c41d8a7f3113c6918f10c" + ); + expect(req.owner_worker_tag).toBe("a7e6fb77503c41d8a7f3113c6918f10c"); + expect(req.catch_all_rules).toEqual([]); + expect(req.rules).toEqual([ + { + matchers: [ + { type: "literal", field: "to", value: "support@example.com" }, + ], + actions: [{ type: "worker", value: ["my-worker"] }], + }, + ]); + }); + + it("compiles *@domain entries into catch-all rules with a target", ({ + expect, + }) => { + const req = buildEmailRoutingPlanRequest( + ["*@example.com"], + "my-worker", + "tag" + ); + expect(req.rules).toEqual([]); + expect(req.catch_all_rules).toEqual([ + { + target: "*@example.com", + rule: { + matchers: [{ type: "all" }], + actions: [{ type: "worker", value: ["my-worker"] }], + }, + }, + ]); + }); +}); + +describe("renderEmailRoutingPlan", () => { + const plan: EmailRoutingPlanResponse = { + zones: [ + { + zone_id: "zonetag1", + zone_name: "example.com", + changes: [ + { type: "added", target: "support@example.com" }, + { + type: "conflict", + target: "billing@example.com", + remote: { + id: "r1", + source: "api", + matchers: [ + { type: "literal", field: "to", value: "billing@example.com" }, + ], + actions: [ + { type: "forward", value: ["billing-team@example.net"] }, + ], + }, + }, + ], + }, + { + zone_id: "zonetag2", + zone_name: "example.net", + changes: [ + { type: "deleted", target: "old@example.net" }, + { + type: "conflict", + target: "*@example.net", + remote: { + id: "r2", + source: "wrangler", + owner_worker_name: "other-worker", + matchers: [{ type: "all" }], + actions: [{ type: "worker", value: ["other-worker"] }], + }, + }, + ], + }, + ], + }; + + it("groups by zone with +/~/-/! markers and a summary line", ({ expect }) => { + const lines = renderEmailRoutingPlan(plan, "my-worker"); + expect(lines).toEqual([ + "example.com", + " + support@example.com -> worker (my-worker)", + ` ! billing@example.com -> conflict: managed outside Wrangler (forward to billing-team@example.net)`, + "example.net", + " - old@example.net (removed from config)", + ` ! *@example.net -> conflict: owned by worker "other-worker" (worker other-worker)`, + "4 changes across 2 zones (1 added, 1 deleted, 2 conflict)", + ]); + }); + + it("omits zones with no changes", ({ expect }) => { + const lines = renderEmailRoutingPlan( + { + zones: [ + { zone_id: "z", zone_name: "noop.com", changes: [] }, + { + zone_id: "z2", + zone_name: "a.com", + changes: [{ type: "added", target: "x@a.com" }], + }, + ], + }, + "my-worker" + ); + expect(lines).toEqual([ + "a.com", + " + x@a.com -> worker (my-worker)", + "1 change across 1 zone (1 added)", + ]); + }); +}); + +describe("destructive-change helpers", () => { + it("treats deletes and conflicts as destructive; added/updated are not", ({ + expect, + }) => { + expect(isDestructiveChange({ type: "added", target: "a" })).toBe(false); + expect(isDestructiveChange({ type: "updated", target: "a" })).toBe(false); + expect(isDestructiveChange({ type: "deleted", target: "a" })).toBe(true); + expect(isDestructiveChange({ type: "conflict", target: "a" })).toBe(true); + }); + + it("detects presence of changes / destructive changes across zones", ({ + expect, + }) => { + const additive: EmailRoutingPlanResponse = { + zones: [{ zone_id: "z", changes: [{ type: "added", target: "a" }] }], + }; + expect(planHasChanges(additive)).toBe(true); + expect(planHasDestructiveChanges(additive)).toBe(false); + + const withConflict: EmailRoutingPlanResponse = { + zones: [{ zone_id: "z", changes: [{ type: "conflict", target: "a" }] }], + }; + expect(planHasDestructiveChanges(withConflict)).toBe(true); + + expect(planHasChanges({ zones: [{ zone_id: "z", changes: [] }] })).toBe( + false + ); + }); +}); diff --git a/packages/deploy-helpers/tests/package-dependencies.test.ts b/packages/deploy-helpers/tests/package-dependencies.test.ts index 0d8e953a3a..eb14306b80 100644 --- a/packages/deploy-helpers/tests/package-dependencies.test.ts +++ b/packages/deploy-helpers/tests/package-dependencies.test.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { describe, it } from "vitest"; +import { assert, describe, it } from "vitest"; import { collectPackageDependencies } from "../src/deploy/helpers/package-dependencies"; describe("collectPackageDependencies", () => { @@ -299,6 +299,326 @@ describe("collectPackageDependencies", () => { expect(result).toHaveLength(200); }); + describe("exclude_packages", () => { + it("should exclude packages matching an exact name", async ({ expect }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + + for (const pkgName of ["keep-me", "exclude-me"]) { + const pkgPath = path.join(nodeModulesPath, pkgName); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: pkgName, version: "1.0.0" }, null, 2) + ); + } + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "keep-me": "^1.0.0", + "exclude-me": "^1.0.0", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), [ + "exclude-me", + ]); + + expect(result).toEqual([ + { + name: "keep-me", + packageJsonVersion: "^1.0.0", + installedVersion: "1.0.0", + }, + ]); + }); + + it("should exclude packages matching a glob pattern with wildcard prefix", async ({ + expect, + }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + + for (const pkgName of ["my-utils", "other-utils", "keep-pkg"]) { + const pkgPath = path.join(nodeModulesPath, pkgName); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: pkgName, version: "1.0.0" }, null, 2) + ); + } + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "my-utils": "^1.0.0", + "other-utils": "^1.0.0", + "keep-pkg": "^1.0.0", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), [ + "*-utils", + ]); + + expect(result).toEqual([ + { + name: "keep-pkg", + packageJsonVersion: "^1.0.0", + installedVersion: "1.0.0", + }, + ]); + }); + + it("should exclude scoped packages matching a glob pattern", async ({ + expect, + }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + + // Create scoped packages + const scopedPkgs = ["@internal/foo", "@internal/bar", "@public/baz"]; + for (const pkgName of scopedPkgs) { + const pkgPath = path.join(nodeModulesPath, pkgName); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: pkgName, version: "2.0.0" }, null, 2) + ); + } + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "@internal/foo": "^2.0.0", + "@internal/bar": "^2.0.0", + "@public/baz": "^2.0.0", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), [ + "@internal/*", + ]); + + expect(result).toEqual([ + { + name: "@public/baz", + packageJsonVersion: "^2.0.0", + installedVersion: "2.0.0", + }, + ]); + }); + + it("should not exclude anything when excludePackages is an empty array", async ({ + expect, + }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + + const pkgPath = path.join(nodeModulesPath, "some-pkg"); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: "some-pkg", version: "1.0.0" }, null, 2) + ); + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "some-pkg": "^1.0.0", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), []); + + expect(result).toEqual([ + { + name: "some-pkg", + packageJsonVersion: "^1.0.0", + installedVersion: "1.0.0", + }, + ]); + }); + + it("should not exclude anything when no pattern matches", async ({ + expect, + }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + + const pkgPath = path.join(nodeModulesPath, "some-pkg"); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: "some-pkg", version: "1.0.0" }, null, 2) + ); + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "some-pkg": "^1.0.0", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), [ + "@other/*", + "unrelated-*", + ]); + + expect(result).toEqual([ + { + name: "some-pkg", + packageJsonVersion: "^1.0.0", + installedVersion: "1.0.0", + }, + ]); + }); + + it("should not count excluded packages toward the 200 cap", async ({ + expect, + }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + const dependencies: Record = {}; + + // Create 201 packages: 1 excluded + 200 kept + const excludedPkg = "excluded-pkg"; + const excludedPath = path.join(nodeModulesPath, excludedPkg); + fs.mkdirSync(excludedPath, { recursive: true }); + fs.writeFileSync( + path.join(excludedPath, "index.js"), + "module.exports = {}" + ); + fs.writeFileSync( + path.join(excludedPath, "package.json"), + JSON.stringify({ name: excludedPkg, version: "1.0.0" }, null, 2) + ); + dependencies[excludedPkg] = "^1.0.0"; + + for (let i = 0; i < 200; i++) { + const pkgName = `pkg-${String(i).padStart(3, "0")}`; + const pkgPath = path.join(nodeModulesPath, pkgName); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: pkgName, version: `1.0.${i}` }, null, 2) + ); + dependencies[pkgName] = `^1.0.${i}`; + } + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), [ + "excluded-pkg", + ]); + + assert(result); + expect(result).toHaveLength(200); + expect(result.find((d) => d.name === "excluded-pkg")).toBeUndefined(); + }); + + it("should support multiple exclusion patterns", async ({ expect }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + + const pkgNames = [ + "@internal/core", + "lodash", + "secret-tool", + "public-lib", + ]; + for (const pkgName of pkgNames) { + const pkgPath = path.join(nodeModulesPath, pkgName); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name: pkgName, version: "1.0.0" }, null, 2) + ); + } + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "@internal/core": "^1.0.0", + lodash: "^1.0.0", + "secret-tool": "^1.0.0", + "public-lib": "^1.0.0", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd(), [ + "@internal/*", + "secret-*", + ]); + + expect(result).toEqual([ + { + name: "lodash", + packageJsonVersion: "^1.0.0", + installedVersion: "1.0.0", + }, + { + name: "public-lib", + packageJsonVersion: "^1.0.0", + installedVersion: "1.0.0", + }, + ]); + }); + }); + it("should use devDependencies version when duplicate exists in dependencies", async ({ expect, }) => { diff --git a/packages/deploy-helpers/tests/triggers-email-routing.test.ts b/packages/deploy-helpers/tests/triggers-email-routing.test.ts new file mode 100644 index 0000000000..25634d233b --- /dev/null +++ b/packages/deploy-helpers/tests/triggers-email-routing.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, it, vi } from "vitest"; +import { initDeployHelpersContext } from "../src/shared/context"; +import { triggersDeploy } from "../src/triggers/deploy"; +import type { Config } from "@cloudflare/workers-utils"; + +vi.mock("@cloudflare/workers-utils", async (importOriginal) => ({ + ...(await importOriginal()), + isNonInteractiveOrCI: () => true, +})); + +const ACCOUNT_ID = "some-account-id"; +const WORKER_NAME = "test-name"; +const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; + +describe("triggersDeploy Email Routing integration", () => { + let metadataRequests: number; + let planRequests: number; + + beforeEach(() => { + metadataRequests = 0; + planRequests = 0; + + initDeployHelpersContext({ + logger: { + debug() {}, + info() {}, + warn() {}, + log() {}, + error() {}, + }, + fetchResult: (async ( + _config: Config, + path: string, + init?: RequestInit + ) => { + if (path.endsWith("/subdomain")) { + return { enabled: false, previews_enabled: false }; + } + if (path.endsWith(`/workers/services/${WORKER_NAME}`)) { + metadataRequests++; + return { default_environment: { script: { tag: WORKER_TAG } } }; + } + if (path.endsWith("/schedules")) { + throw new Error("trigger deployment failed"); + } + if ( + init?.method === "POST" && + path.endsWith("/email/routing/rules/plan") + ) { + planRequests++; + return { zones: [] }; + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${path}`); + }) as never, + fetchListResult: (() => {}) as never, + fetchPagedListResult: (() => {}) as never, + fetchKVGetValue: (() => {}) as never, + confirm: (() => {}) as never, + prompt: (() => {}) as never, + select: (() => {}) as never, + }); + }); + + function config(): Config { + return { + addresses: ["support@example.com"], + workers_dev: false, + preview_urls: false, + queues: { producers: [], consumers: [] }, + workflows: [], + } as unknown as Config; + } + + it("reconciles once with the tag supplied by normal deploy", async ({ + expect, + }) => { + await triggersDeploy({ + config: config(), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag: WORKER_TAG, + env: undefined, + crons: undefined, + routes: [], + firstDeploy: false, + }); + + expect(planRequests).toBe(1); + expect(metadataRequests).toBe(0); + }); + + it("reconciles once and resolves the tag for standalone trigger deploy", async ({ + expect, + }) => { + await triggersDeploy({ + config: config(), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + env: undefined, + crons: undefined, + routes: [], + firstDeploy: false, + }); + + expect(planRequests).toBe(1); + expect(metadataRequests).toBe(1); + }); + + it("reconciles when another trigger deployment fails", async ({ expect }) => { + await expect( + triggersDeploy({ + config: config(), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag: WORKER_TAG, + env: undefined, + crons: ["* * * * *"], + routes: [], + firstDeploy: false, + }) + ).rejects.toThrow("trigger deployment failed"); + + expect(planRequests).toBe(1); + }); +}); diff --git a/packages/deploy-helpers/tsup.config.ts b/packages/deploy-helpers/tsup.config.ts index 7e928f8fbd..59c6ec376a 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -11,6 +11,8 @@ export default defineConfig(() => [ entry: { index: "src/index.ts", context: "src/shared/context.ts", + "create-worker-upload-form": + "src/deploy/helpers/create-worker-upload-form.ts", }, platform: "node", format: "esm", @@ -34,9 +36,6 @@ export default defineConfig(() => [ "dotenv", "command-exists", "esbuild", - // Keep zod external so wrangler (the only consumer) bundles a single - // shared copy rather than inlining one here. - /^zod(\/.*)?$/, ], }, ]); diff --git a/packages/edge-preview-authenticated-proxy/src/index.ts b/packages/edge-preview-authenticated-proxy/src/index.ts index f8af9c3343..a38371a6d7 100644 --- a/packages/edge-preview-authenticated-proxy/src/index.ts +++ b/packages/edge-preview-authenticated-proxy/src/index.ts @@ -2,6 +2,26 @@ import { MetricsRegistry } from "@cloudflare/workers-utils/prometheus-metrics"; import cookie from "cookie"; import { Toucan } from "toucan-js"; +async function pushMetrics(env: Env, metrics: string) { + try { + const response = await env.WSHIM_SOCKET.fetch( + "https://workers-logging.cfdata.org/prometheus", + { + method: "POST", + headers: { + Authorization: `Bearer ${env.PROMETHEUS_TOKEN}`, + }, + body: metrics, + } + ); + if (!response.ok) { + console.error(`Failed to push metrics: ${response.status}`); + } + } catch (error) { + console.error("Failed to push metrics", error); + } +} + class HttpError extends Error { constructor( message: string, @@ -439,15 +459,7 @@ export default { ); } } finally { - ctx.waitUntil( - fetch("https://workers-logging.cfdata.org/prometheus", { - method: "POST", - headers: { - Authorization: `Bearer ${env.PROMETHEUS_TOKEN}`, - }, - body: registry.metrics(), - }) - ); + ctx.waitUntil(pushMetrics(env, registry.metrics())); } }, }; diff --git a/packages/edge-preview-authenticated-proxy/worker-configuration.d.ts b/packages/edge-preview-authenticated-proxy/worker-configuration.d.ts index 8f809dea16..8b6a9c91b4 100644 --- a/packages/edge-preview-authenticated-proxy/worker-configuration.d.ts +++ b/packages/edge-preview-authenticated-proxy/worker-configuration.d.ts @@ -1,14 +1,14 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --include-runtime=false ./worker-configuration.d.ts` (hash: c8a899098b01428f56aa03eeb3586f5d) +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: b6821e7d8f071060b7d4e5243e26a121) interface __BaseEnv_Env { TOKEN_LOOKUP: KVNamespace; SENTRY_DSN: "https://1ff9df95733c4e7d9c31dc13ab05d44a@sentry10.cfdata.org/891"; PREVIEW: "preview.cloudflarepreviews.com"; RAW_HTTP: "rawhttp.cloudflarepreviews.com"; - // Secrets PROMETHEUS_TOKEN: string; SENTRY_ACCESS_CLIENT_SECRET: string; SENTRY_ACCESS_CLIENT_ID: string; + WSHIM_SOCKET: any; } declare namespace Cloudflare { interface GlobalProps { diff --git a/packages/edge-preview-authenticated-proxy/wrangler.jsonc b/packages/edge-preview-authenticated-proxy/wrangler.jsonc index d78d8e198c..d30564b3c5 100644 --- a/packages/edge-preview-authenticated-proxy/wrangler.jsonc +++ b/packages/edge-preview-authenticated-proxy/wrangler.jsonc @@ -14,6 +14,13 @@ "PREVIEW": "preview.cloudflarepreviews.com", "RAW_HTTP": "rawhttp.cloudflarepreviews.com", }, + "secrets": { + "required": [ + "PROMETHEUS_TOKEN", + "SENTRY_ACCESS_CLIENT_SECRET", + "SENTRY_ACCESS_CLIENT_ID", + ], + }, "dev": { "host": "preview.cloudflarepreviews.com", }, @@ -24,6 +31,15 @@ "preview_id": "76afd4161617490b9d2addc59b1e22e4", }, ], + "unsafe": { + "bindings": [ + { + "name": "WSHIM_SOCKET", + "type": "internal_service_http", + "service": "wshim", + }, + ], + }, "observability": { "enabled": true, }, diff --git a/packages/format-errors/CHANGELOG.md b/packages/format-errors/CHANGELOG.md index 74ad1a1b6a..f8bb93fa71 100644 --- a/packages/format-errors/CHANGELOG.md +++ b/packages/format-errors/CHANGELOG.md @@ -1,5 +1,11 @@ # format-errors +## 0.0.9 + +### Patch Changes + +- [#14707](https://github.com/cloudflare/workers-sdk/pull/14707) [`b38f494`](https://github.com/cloudflare/workers-sdk/commit/b38f494204e5e08e561b8f198ef928188e554868) Thanks [@emily-shen](https://github.com/emily-shen)! - Update zod to v4 + ## 0.0.8 ### Patch Changes diff --git a/packages/format-errors/package.json b/packages/format-errors/package.json index 618e60457c..4489d7887e 100644 --- a/packages/format-errors/package.json +++ b/packages/format-errors/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/format-errors", - "version": "0.0.8", + "version": "0.0.9", "private": true, "scripts": { "build": "wrangler build", @@ -17,7 +17,7 @@ "tsconfig": "*", "vitest": "catalog:default", "wrangler": "workspace:*", - "zod": "^3.22.3" + "zod": "catalog:default" }, "volta": { "extends": "../../package.json" diff --git a/packages/format-errors/src/index.ts b/packages/format-errors/src/index.ts index 33abfdef43..42c051ea24 100644 --- a/packages/format-errors/src/index.ts +++ b/packages/format-errors/src/index.ts @@ -8,6 +8,7 @@ export interface Env { SENTRY_ACCESS_CLIENT_SECRET: string; SENTRY_ACCESS_CLIENT_ID: string; SENTRY_DSN: string; + WSHIM_SOCKET: Fetcher; } export interface JsonError { message?: string; @@ -21,6 +22,26 @@ export interface Payload { headers?: Record; error?: JsonError; } + +async function pushMetrics(env: Env, metrics: string) { + try { + const response = await env.WSHIM_SOCKET.fetch( + "https://workers-logging.cfdata.org/prometheus", + { + method: "POST", + headers: { + Authorization: `Bearer ${env.PROMETHEUS_TOKEN}`, + }, + body: metrics, + } + ); + if (!response.ok) { + console.error(`Failed to push metrics: ${response.status}`); + } + } catch (error) { + console.error("Failed to push metrics", error); + } +} export const JsonErrorSchema: z.ZodType = z.lazy(() => z.object({ message: z.string().optional(), @@ -33,7 +54,7 @@ export const JsonErrorSchema: z.ZodType = z.lazy(() => export const PayloadSchema = z.object({ url: z.string().optional(), method: z.string().optional(), - headers: z.record(z.string()), + headers: z.record(z.string(), z.string()), error: JsonErrorSchema.optional(), }); @@ -185,15 +206,7 @@ export default { } ); } finally { - ctx.waitUntil( - fetch("https://workers-logging.cfdata.org/prometheus", { - method: "POST", - headers: { - Authorization: `Bearer ${env.PROMETHEUS_TOKEN}`, - }, - body: registry.metrics(), - }) - ); + ctx.waitUntil(pushMetrics(env, registry.metrics())); } }, }; diff --git a/packages/format-errors/wrangler.jsonc b/packages/format-errors/wrangler.jsonc index 36a66834c9..8a762d061c 100644 --- a/packages/format-errors/wrangler.jsonc +++ b/packages/format-errors/wrangler.jsonc @@ -20,6 +20,22 @@ "vars": { "SENTRY_DSN": "https://1ff9df95733c4e7d9c31dc13ab05d44a@sentry10.cfdata.org/891", }, + "secrets": { + "required": [ + "PROMETHEUS_TOKEN", + "SENTRY_ACCESS_CLIENT_SECRET", + "SENTRY_ACCESS_CLIENT_ID", + ], + }, + "unsafe": { + "bindings": [ + { + "name": "WSHIM_SOCKET", + "type": "internal_service_http", + "service": "wshim", + }, + ], + }, "observability": { "enabled": true, }, diff --git a/packages/local-explorer-ui/CHANGELOG.md b/packages/local-explorer-ui/CHANGELOG.md index 06728860ee..ea21f5182a 100644 --- a/packages/local-explorer-ui/CHANGELOG.md +++ b/packages/local-explorer-ui/CHANGELOG.md @@ -1,5 +1,13 @@ # @cloudflare/local-explorer-ui +## 0.14.2 + +### Patch Changes + +- [#14668](https://github.com/cloudflare/workers-sdk/pull/14668) [`e93d27e`](https://github.com/cloudflare/workers-sdk/commit/e93d27eab1c54b08c5576cb50887429c1f45c40f) Thanks [@DebadityaHait](https://github.com/DebadityaHait)! - Make long worker selector lists scrollable + + Workers beyond the visible selector limit can now be reached with a mouse, trackpad, or keyboard without scrolling the Local Explorer page. + ## 0.14.1 ### Patch Changes diff --git a/packages/local-explorer-ui/package.json b/packages/local-explorer-ui/package.json index 8f839d5792..8223e2d152 100644 --- a/packages/local-explorer-ui/package.json +++ b/packages/local-explorer-ui/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/local-explorer-ui", - "version": "0.14.1", + "version": "0.14.2", "private": true, "type": "module", "scripts": { diff --git a/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts b/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts new file mode 100644 index 0000000000..f473ff80dc --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, test } from "vitest"; +import { page, viteUrl } from "./utils"; + +const WORKERS_ROUTE = "**/cdn-cgi/explorer/api/local/workers"; + +function createWorkers(count: number) { + return Array.from({ length: count }, (_, index) => ({ + isSelf: index === 0, + name: `worker-${index + 1}`, + })); +} + +async function loadWorkers(count: number): Promise { + await page.route(WORKERS_ROUTE, async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: [], + result: createWorkers(count), + success: true, + }), + }); + }); + + await page.goto(viteUrl); +} + +function waitForWorkersResponse() { + return page.waitForResponse((response) => + response.url().endsWith("/cdn-cgi/explorer/api/local/workers") + ); +} + +afterEach(async () => { + await page.unroute(WORKERS_ROUTE); +}); + +describe("worker selector", () => { + test("stays hidden when there is only one worker", async ({ expect }) => { + await loadWorkers(1); + + expect(await page.getByRole("combobox").count()).toBe(0); + }); + + test("keeps nine workers fully visible", async ({ expect }) => { + await loadWorkers(9); + + await page.getByRole("combobox").click(); + const listBounds = await page.getByRole("listbox").boundingBox(); + const lastOptionBounds = await page + .getByRole("option", { name: "worker-9" }) + .boundingBox(); + + expect(listBounds).not.toBeNull(); + expect(lastOptionBounds).not.toBeNull(); + if (listBounds && lastOptionBounds) { + expect(lastOptionBounds.y + lastOptionBounds.height).toBeLessThanOrEqual( + listBounds.y + listBounds.height + ); + } + }); + + test("scrolls to and selects workers beyond the visible limit", async ({ + expect, + }) => { + await page.setViewportSize({ height: 720, width: 1280 }); + await loadWorkers(12); + + await page.getByRole("combobox").click(); + const list = page.getByRole("listbox"); + + const dimensions = await list.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(dimensions.scrollHeight).toBeGreaterThan(dimensions.clientHeight); + + const main = page.locator("main"); + const pageScrollTop = await main.evaluate((element) => element.scrollTop); + await list.hover(); + await page.mouse.wheel(0, dimensions.scrollHeight); + await expect + .poll(async () => await list.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + expect(await main.evaluate((element) => element.scrollTop)).toBe( + pageScrollTop + ); + + const workersResponse = waitForWorkersResponse(); + await page.getByRole("option", { name: "worker-12" }).click(); + await workersResponse; + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-12"); + await page.getByRole("combobox").getByText("worker-12").waitFor(); + await page.waitForLoadState("networkidle"); + }); + + test("reaches later workers with the keyboard in a narrow viewport", async ({ + expect, + }) => { + await loadWorkers(12); + await page.setViewportSize({ height: 640, width: 800 }); + + await page.getByRole("combobox").click({ timeout: 5_000 }); + const list = page.getByRole("listbox"); + const lastOption = page.getByRole("option", { name: "worker-12" }); + let reachedLastOption = false; + for (let index = 0; index < 12; index++) { + await page.keyboard.press("ArrowDown"); + reachedLastOption = + (await lastOption.getAttribute("data-highlighted")) !== null; + if (reachedLastOption) { + break; + } + } + expect(reachedLastOption).toBe(true); + expect(await list.evaluate((element) => element.scrollTop)).toBeGreaterThan( + 0 + ); + const workersResponse = waitForWorkersResponse(); + await page.keyboard.press("Enter"); + await workersResponse; + + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-12"); + await page.getByRole("combobox").getByText("worker-12").waitFor(); + await page.waitForLoadState("networkidle"); + }); +}); diff --git a/packages/local-explorer-ui/src/components/WorkerSelector.tsx b/packages/local-explorer-ui/src/components/WorkerSelector.tsx index cffe06020c..6a2619d363 100644 --- a/packages/local-explorer-ui/src/components/WorkerSelector.tsx +++ b/packages/local-explorer-ui/src/components/WorkerSelector.tsx @@ -113,7 +113,7 @@ export function WorkerSelector({ sideOffset={sidebar.open ? 4 : 8} > - + {workers.map((worker) => { const isSelected = selectedWorker === worker.name; const Icon = isSelected ? CheckIcon : TerminalIcon; diff --git a/packages/miniflare/CHANGELOG.md b/packages/miniflare/CHANGELOG.md index 4c39bb462e..726a9cd7ae 100644 --- a/packages/miniflare/CHANGELOG.md +++ b/packages/miniflare/CHANGELOG.md @@ -1,5 +1,95 @@ # miniflare +## 4.20260721.0 + +### Minor Changes + +- [#14742](https://github.com/cloudflare/workers-sdk/pull/14742) [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9) Thanks [@pombosilva](https://github.com/pombosilva)! - Add support for redacting sensitive Workflows step output in local dev. + + Steps configured with `sensitive: "output"` now have their output redacted to `[REDACTED]` in step logs and step-output responses when running Workflows locally, matching production behavior. The real value is still passed to downstream steps, and step errors are never redacted. + +### Patch Changes + +- [#14715](https://github.com/cloudflare/workers-sdk/pull/14715) [`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ------------------------- | ------------- | ------------- | + | @cloudflare/workers-types | ^5.20260714.1 | ^5.20260721.1 | + | workerd | 1.20260714.1 | 1.20260721.1 | + +- [#14766](https://github.com/cloudflare/workers-sdk/pull/14766) [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d) Thanks [@gianghungtien](https://github.com/gianghungtien)! - Report the Worker's error for `HEAD` requests instead of an internal JSON parse error + + A Worker that threw on a `HEAD` request (for example `curl -I`) logged `SyntaxError: Unexpected end of JSON input` from miniflare's internals rather than the actual error, and `dispatchFetch()` rejected with that same misleading error. `workerd` drops response bodies for `HEAD` requests, so the serialised error never reached the code that revives it. + + The error is now also carried in a header, which survives `HEAD`, so the original message and source-mapped stack are reported for every method. When no payload is available the reporting degrades to a plain error rather than surfacing a parse failure. + +## 4.20260714.0 + +### Minor Changes + +- [#14562](https://github.com/cloudflare/workers-sdk/pull/14562) [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981) Thanks [@martijnwalraven](https://github.com/martijnwalraven)! - Add a `handleUncaughtError` shared option that receives uncaught Worker exceptions + + The runtime catches handler exceptions to build the 500 response, so they never reach the inspector — the one place an uncaught exception exists as a structured value in Node is the pretty-error path, where the error report from the Worker is revived into a source-mapped `Error`. Embedders can now pass `handleUncaughtError: (error: Error) => void` to observe that revived error programmatically; logging behavior is unchanged. + + The hook fires only where the pretty-error path does: requests reaching the Worker through the entry socket (a browser or another HTTP client against the dev server). `dispatchFetch()` is unaffected — it always sets `MF-Disable-Pretty-Error`, and the entry worker then propagates the exception by rejecting the returned promise instead, so `dispatchFetch()` callers already receive the error directly and the hook is not invoked. + +- [#14706](https://github.com/cloudflare/workers-sdk/pull/14706) [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c) Thanks [@edmundhung](https://github.com/edmundhung)! - Add Durable Object storage access to `createTestHarness()` + + You can now execute SQL against a SQLite-backed Durable Object to seed or assert the storage state. + + ```ts + const server = createTestHarness({ + workers: [{ configPath: "./wrangler.json" }], + }); + await server.listen(); + + const worker = server.getWorker(); + const storage = await worker.getDurableObjectStorage("COUNTER", { + name: "user-123", + }); + + await worker.fetch("/counter/user-123"); + + const rows = await storage.exec( + "SELECT value FROM counters WHERE id = ?", + "user-123" + ); + expect(rows).toEqual([{ value: 1 }]); + ``` + +### Patch Changes + +- [#14417](https://github.com/cloudflare/workers-sdk/pull/14417) [`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Improve asset serving performance by removing an unnecessary internal dispatch hop + + Asset requests and RPC calls now avoid an extra internal forwarding layer, reducing latency. The forwarding infrastructure is preserved for future use by cohort-based deployments. + +- [#14682](https://github.com/cloudflare/workers-sdk/pull/14682) [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ------------------------- | ------------- | ------------- | + | @cloudflare/workers-types | ^5.20260710.1 | ^5.20260714.1 | + | workerd | 1.20260710.1 | 1.20260714.1 | + +- [#14562](https://github.com/cloudflare/workers-sdk/pull/14562) [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981) Thanks [@martijnwalraven](https://github.com/martijnwalraven)! - Keep reporting uncaught Worker errors when a stack frame's file URL has no local path + + `fileURLToPath` throws on `file://` URLs that cannot be represented as a local path (a non-local host; on Windows, any drive-less path — which is every `file:///...` URL reported by a POSIX-built bundle). Both the source-mapping machinery and `youch`'s error-page frame parsing convert stack-frame specifiers this way, so one such frame previously failed the whole pretty-error request: the error page was replaced by a raw Node stack, the error was not logged, and `handleUncaughtError` did not fire. Source mapping now degrades to the unmapped stack and the pretty page falls back to a plain stack response instead. + +- [#14418](https://github.com/cloudflare/workers-sdk/pull/14418) [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Improve routing performance for Workers with assets + + Reduce request handling latency by streamlining the router Worker's request path. The loopback infrastructure remains available for future use. + +- [#14727](https://github.com/cloudflare/workers-sdk/pull/14727) [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8) Thanks [@ascorbic](https://github.com/ascorbic)! - Prevent local Browser Rendering teardown from hanging when Chrome does not exit + + Miniflare now bounds graceful Chrome shutdown and forcefully terminates the browser process tree when needed, preventing disposal from waiting indefinitely. + +- [#14723](https://github.com/cloudflare/workers-sdk/pull/14723) [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645) Thanks [@ascorbic](https://github.com/ascorbic)! - Prevent concurrent Miniflare instances from deleting each other's temporary email sessions + + Email session cleanup now removes only the current instance's session directory and leaves the shared parent intact, avoiding startup failures when multiple local runtimes use the same project. + ## 4.20260710.0 ### Minor Changes diff --git a/packages/miniflare/package.json b/packages/miniflare/package.json index edea978e71..4ddfc9130f 100644 --- a/packages/miniflare/package.json +++ b/packages/miniflare/package.json @@ -1,6 +1,6 @@ { "name": "miniflare", - "version": "4.20260710.0", + "version": "4.20260721.0", "description": "Fun, full-featured, fully-local simulator for Cloudflare Workers", "keywords": [ "cloudflare", diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 57c3ea581b..42717a15a0 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -67,6 +67,7 @@ import { } from "./plugins"; import { RPC_PROXY_SERVICE_NAME } from "./plugins/assets/constants"; import { BROWSER_VERSION } from "./plugins/browser-rendering/browser-version"; +import { closeBrowserProcess } from "./plugins/browser-rendering/process"; import { CUSTOM_SERVICE_KNOWN_OUTBOUND, CustomServiceKind, @@ -98,6 +99,7 @@ import { parseWithRootPath, stripAnsi, } from "./shared"; +import { createDurableObjectStorageHandle } from "./shared/dev-control"; import { DevRegistry, getWorkerRegistry } from "./shared/dev-registry"; import { getOutboundDoProxyClassName, @@ -109,6 +111,7 @@ import { CoreBindings, CoreHeaders, CorePaths, + decodeErrorPayload, LogLevel, Mutex, SharedHeaders, @@ -151,6 +154,8 @@ import type { Log } from "./shared"; import type { DevControl, DurableObjectEvictionOptions, + DurableObjectStorageHandle, + DurableObjectStorageOptions, } from "./shared/dev-control"; import type { WorkerDefinition } from "./shared/dev-registry-types"; import type { @@ -984,8 +989,11 @@ export class Miniflare { */ #externalPlugins: Map> = new Map(); - // key is the browser session ID, value is the browser process - #browserProcesses: Map = new Map(); + // key is the browser session ID, value identifies the launched browser + #browserProcesses: Map< + string, + { browserProcess: Process; wsEndpoint: string } + > = new Map(); readonly #runtime?: Runtime; readonly #removeExitHook?: () => void; @@ -1161,17 +1169,6 @@ export class Miniflare { `Unable to remove email session directory: ${String(e)}` ); } - // Check if parent directory is now empty and remove it - try { - const entries = fs.readdirSync(emailPaths.parentDir); - if (entries.length === 0) { - removeDirSync(emailPaths.parentDir); - } - } catch (e) { - this.#log.debug( - `Unable to check/remove email parent directory: ${String(e)}` - ); - } } // Unregister all workers from the dev registry. Note that dispose() // does synchronous cleanup (unregistering workers) then returns a @@ -1661,22 +1658,28 @@ export class Miniflare { browserProcess.nodeProcess.on("exit", () => { this.#browserProcesses.delete(sessionId); }); - this.#browserProcesses.set(sessionId, browserProcess); + this.#browserProcesses.set(sessionId, { + browserProcess, + wsEndpoint, + }); response = Response.json({ wsEndpoint, sessionId, startTime }); } else if (url.pathname === "/browser/status") { const sessionId = url.searchParams.get("sessionId"); assert(sessionId !== null, "Missing sessionId query parameter"); - const process = this.#browserProcesses.get(sessionId); - response = new Response(null, { status: process ? 200 : 410 }); + const browser = this.#browserProcesses.get(sessionId); + response = new Response(null, { status: browser ? 200 : 410 }); } else if (url.pathname === "/browser/close") { const sessionId = url.searchParams.get("sessionId"); assert(sessionId !== null, "Missing sessionId query parameter"); - const browserProcess = this.#browserProcesses.get(sessionId); - if (!browserProcess) { + const browser = this.#browserProcesses.get(sessionId); + if (!browser) { response = new Response("Session not found", { status: 404 }); } else { this.#browserProcesses.delete(sessionId); - await browserProcess.close().catch(() => { + await closeBrowserProcess( + browser.browserProcess, + browser.wsEndpoint + ).catch(() => { // oh well, process might already be dead }); response = new Response(null, { status: 200 }); @@ -2674,15 +2677,13 @@ export class Miniflare { } async #closeBrowserProcesses() { + const browsers = Array.from(this.#browserProcesses.values()); + this.#browserProcesses.clear(); await Promise.all( - Array.from(this.#browserProcesses.values()).map((process) => - process.close() + browsers.map(({ browserProcess, wsEndpoint }) => + closeBrowserProcess(browserProcess, wsEndpoint) ) ); - assert( - this.#browserProcesses.size === 0, - "Not all browser processes were closed" - ); } async #waitForReady(disposing = false) { @@ -2929,7 +2930,14 @@ export class Miniflare { // If the Worker threw an uncaught exception, propagate it to the caller const stack = response.headers.get(CoreHeaders.ERROR_STACK); if (response.status === 500 && stack !== null) { - const caught = JsonErrorSchema.parse(await response.json()); + // `workerd` drops response bodies for `HEAD` requests, so fall back to + // the header copy of the serialised error + const serialised = + (await response.text()) || decodeErrorPayload(response); + if (serialised === null) { + throw new Error("Worker threw an uncaught exception"); + } + const caught = JsonErrorSchema.parse(JSON.parse(serialised)); throw reviveError(this.#workerSrcOpts, caught); } @@ -3081,6 +3089,34 @@ export class Miniflare { } return proxy as T; } + + /** + * Returns remote storage access for a Durable Object instance. + * + * Calling `exec()` runs SQL inside the target Durable Object and returns all + * rows. It may start the object if it is not already active. + */ + async unsafeGetDurableObjectStorage( + scriptName: string, + className: string, + options: DurableObjectStorageOptions + ): Promise { + if (!this.#sharedOpts.core.unsafeInspectDurableObjects) { + throw new TypeError( + "Durable Object storage inspection requires the `unsafeInspectDurableObjects` option." + ); + } + + const control = await this.#getDevControl(); + const handle = createDurableObjectStorageHandle( + control, + scriptName, + className, + options + ); + + return handle; + } // TODO(someday): would be nice to define these in plugins async getCaches(): Promise> { const proxyClient = await this._getProxyClient(); @@ -3400,7 +3436,6 @@ export class Miniflare { this.#tmpPath ); if (emailPaths) { - // Remove session directory and wait for completion before checking parent try { await removeDir(emailPaths.sessionDir); } catch (e) { @@ -3408,18 +3443,6 @@ export class Miniflare { `Unable to remove email session directory: ${String(e)}` ); } - // Check if parent directory is now empty and remove it - try { - const entries = await fs.promises.readdir(emailPaths.parentDir); - if (entries.length === 0) { - await removeDir(emailPaths.parentDir); - } - } catch (e) { - // Parent directory doesn't exist or can't be read, ignore - this.#log.debug( - `Unable to check/remove email parent directory: ${String(e)}` - ); - } } // Close the inspector proxy server if there is one @@ -3458,6 +3481,8 @@ export * from "./zod-format"; export type { DurableObjectIdentifier, DurableObjectEvictionOptions, + DurableObjectStorageOptions, + DurableObjectStorageHandle, } from "./shared/dev-control"; export type { WorkerRegistry, diff --git a/packages/miniflare/src/plugins/agent-memory/index.ts b/packages/miniflare/src/plugins/agent-memory/index.ts index b9630e26a4..5a04bab021 100644 --- a/packages/miniflare/src/plugins/agent-memory/index.ts +++ b/packages/miniflare/src/plugins/agent-memory/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -20,6 +20,7 @@ export const AgentMemoryOptionsSchema = z.object({ export const AGENT_MEMORY_PLUGIN_NAME = "agent-memory"; const AGENT_MEMORY_SCOPE = "agent-memory"; +const AGENT_MEMORY_REMOTE_SERVICE_NAME = `${AGENT_MEMORY_SCOPE}:remote`; export const AGENT_MEMORY_PLUGIN: Plugin = { options: AgentMemoryOptionsSchema, @@ -32,10 +33,10 @@ export const AGENT_MEMORY_PLUGIN: Plugin = { return Object.entries(options.agentMemory).map(([bindingName, entry]) => ({ name: bindingName, service: { - name: getUserBindingServiceName( - AGENT_MEMORY_SCOPE, - bindingName, - entry.remoteProxyConnectionString + name: AGENT_MEMORY_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + entry.remoteProxyConnectionString, + bindingName ), }, })); @@ -53,20 +54,15 @@ export const AGENT_MEMORY_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.agentMemory) { + if (!options.agentMemory || Object.keys(options.agentMemory).length === 0) { return []; } - return Object.entries(options.agentMemory).map(([bindingName, entry]) => ({ - name: getUserBindingServiceName( - AGENT_MEMORY_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), - })); + return [ + { + name: AGENT_MEMORY_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/ai-search/index.ts b/packages/miniflare/src/plugins/ai-search/index.ts index 27ed2d69be..0c40ab1352 100644 --- a/packages/miniflare/src/plugins/ai-search/index.ts +++ b/packages/miniflare/src/plugins/ai-search/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -21,10 +21,8 @@ export const AISearchOptionsSchema = z.object({ export const AI_SEARCH_PLUGIN_NAME = "ai-search"; -// Distinct scopes for service name generation to avoid collisions -// between namespace and instance bindings with the same binding name. -const AI_SEARCH_NS_SCOPE = "ai-search-ns"; -const AI_SEARCH_INST_SCOPE = "ai-search-inst"; +// One shared remote-proxy service for all AI Search bindings (config via props). +const AI_SEARCH_REMOTE_SERVICE_NAME = `${AI_SEARCH_PLUGIN_NAME}:remote`; export const AI_SEARCH_PLUGIN: Plugin = { options: AISearchOptionsSchema, @@ -32,34 +30,20 @@ export const AI_SEARCH_PLUGIN: Plugin = { async getBindings(options) { const bindings: { name: string; - service: { name: string }; + service: { name: string; props?: { json: string } }; }[] = []; - for (const [bindingName, entry] of Object.entries( - options.aiSearchNamespaces ?? {} - )) { + for (const [bindingName, entry] of [ + ...Object.entries(options.aiSearchNamespaces ?? {}), + ...Object.entries(options.aiSearchInstances ?? {}), + ]) { bindings.push({ name: bindingName, service: { - name: getUserBindingServiceName( - AI_SEARCH_NS_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - }, - }); - } - - for (const [bindingName, entry] of Object.entries( - options.aiSearchInstances ?? {} - )) { - bindings.push({ - name: bindingName, - service: { - name: getUserBindingServiceName( - AI_SEARCH_INST_SCOPE, - bindingName, - entry.remoteProxyConnectionString + name: AI_SEARCH_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + entry.remoteProxyConnectionString, + bindingName ), }, }); @@ -85,35 +69,13 @@ export const AI_SEARCH_PLUGIN: Plugin = { worker: ReturnType; }[] = []; - for (const [bindingName, entry] of Object.entries( - options.aiSearchNamespaces ?? {} - )) { - services.push({ - name: getUserBindingServiceName( - AI_SEARCH_NS_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), - }); - } - - for (const [bindingName, entry] of Object.entries( - options.aiSearchInstances ?? {} - )) { + const hasAny = + Object.keys(options.aiSearchNamespaces ?? {}).length > 0 || + Object.keys(options.aiSearchInstances ?? {}).length > 0; + if (hasAny) { services.push({ - name: getUserBindingServiceName( - AI_SEARCH_INST_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), + name: AI_SEARCH_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/plugins/ai/index.ts b/packages/miniflare/src/plugins/ai/index.ts index 649d943583..b24616f49f 100644 --- a/packages/miniflare/src/plugins/ai/index.ts +++ b/packages/miniflare/src/plugins/ai/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const AIOptionsSchema = z.object({ }); export const AI_PLUGIN_NAME = "ai"; +const AI_REMOTE_SERVICE_NAME = `${AI_PLUGIN_NAME}:remote`; export const AI_PLUGIN: Plugin = { options: AIOptionsSchema, @@ -36,10 +37,10 @@ export const AI_PLUGIN: Plugin = { { name: "fetcher", service: { - name: getUserBindingServiceName( - AI_PLUGIN_NAME, - options.ai.binding, - options.ai.remoteProxyConnectionString + name: AI_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.ai.remoteProxyConnectionString, + options.ai.binding ), }, }, @@ -63,15 +64,8 @@ export const AI_PLUGIN: Plugin = { return [ { - name: getUserBindingServiceName( - AI_PLUGIN_NAME, - options.ai.binding, - options.ai.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - options.ai.remoteProxyConnectionString, - options.ai.binding - ), + name: AI_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; }, diff --git a/packages/miniflare/src/plugins/artifacts/index.ts b/packages/miniflare/src/plugins/artifacts/index.ts index 881ddfd217..79231159fa 100644 --- a/packages/miniflare/src/plugins/artifacts/index.ts +++ b/packages/miniflare/src/plugins/artifacts/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,9 @@ export const ArtifactsOptionsSchema = z.object({ }); export const ARTIFACTS_PLUGIN_NAME = "artifacts"; +// One shared remote-proxy service for every artifacts binding; per-binding +// config travels via props. +const ARTIFACTS_REMOTE_SERVICE_NAME = `${ARTIFACTS_PLUGIN_NAME}:remote`; export const ARTIFACTS_PLUGIN: Plugin = { options: ArtifactsOptionsSchema, @@ -30,11 +33,8 @@ export const ARTIFACTS_PLUGIN: Plugin = { return Object.entries(options.artifacts).map(([name, config]) => ({ name, service: { - name: getUserBindingServiceName( - ARTIFACTS_PLUGIN_NAME, - name, - config.remoteProxyConnectionString - ), + name: ARTIFACTS_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(config.remoteProxyConnectionString, name), }, })); }, @@ -50,19 +50,15 @@ export const ARTIFACTS_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.artifacts) { + if (!options.artifacts || Object.keys(options.artifacts).length === 0) { return []; } - return Object.entries(options.artifacts).map( - ([name, { remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - ARTIFACTS_PLUGIN_NAME, - name, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }) - ); + return [ + { + name: ARTIFACTS_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/browser-rendering/index.ts b/packages/miniflare/src/plugins/browser-rendering/index.ts index 3bd0fff475..8ac011b0b2 100644 --- a/packages/miniflare/src/plugins/browser-rendering/index.ts +++ b/packages/miniflare/src/plugins/browser-rendering/index.ts @@ -18,6 +18,7 @@ import BROWSER_RENDERING_WORKER from "worker:browser-rendering/binding"; import { z } from "zod"; import { kVoid } from "../../runtime"; import { + buildRemoteProxyProps, getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, @@ -40,6 +41,7 @@ export const BrowserRenderingOptionsSchema = z.object({ }); export const BROWSER_RENDERING_PLUGIN_NAME = "browser-rendering"; +const BROWSER_RENDERING_REMOTE_SERVICE_NAME = `${BROWSER_RENDERING_PLUGIN_NAME}:remote`; export const BROWSER_RENDERING_PLUGIN: Plugin< typeof BrowserRenderingOptionsSchema @@ -54,13 +56,20 @@ export const BROWSER_RENDERING_PLUGIN: Plugin< return [ { name: options.browserRendering.binding, - service: { - name: getUserBindingServiceName( - BROWSER_RENDERING_PLUGIN_NAME, - "service", - options.browserRendering.remoteProxyConnectionString - ), - }, + service: options.browserRendering.remoteProxyConnectionString + ? { + name: BROWSER_RENDERING_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.browserRendering.remoteProxyConnectionString, + options.browserRendering.binding + ), + } + : { + name: getUserBindingServiceName( + BROWSER_RENDERING_PLUGIN_NAME, + "service" + ), + }, }, ]; }, @@ -77,44 +86,47 @@ export const BROWSER_RENDERING_PLUGIN: Plugin< return []; } + if (options.browserRendering.remoteProxyConnectionString) { + return [ + { + name: BROWSER_RENDERING_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; + } + return [ { name: getUserBindingServiceName( BROWSER_RENDERING_PLUGIN_NAME, - "service", - options.browserRendering.remoteProxyConnectionString + "service" ), - worker: options.browserRendering.remoteProxyConnectionString - ? remoteProxyClientWorker( - options.browserRendering.remoteProxyConnectionString, - options.browserRendering.binding - ) - : { - compatibilityDate: "2025-05-01", - compatibilityFlags: ["nodejs_compat"], - modules: [ - { - name: "index.worker.js", - esModule: BROWSER_RENDERING_WORKER(), - }, - ], - bindings: [ - WORKER_BINDING_SERVICE_LOOPBACK, - { - name: "BrowserSession", - durableObjectNamespace: { - className: "BrowserSession", - }, - }, - ], - durableObjectNamespaces: [ - { - className: "BrowserSession", - uniqueKey: "miniflare-BrowserSession", - }, - ], - durableObjectStorage: { inMemory: kVoid }, + worker: { + compatibilityDate: "2025-05-01", + compatibilityFlags: ["nodejs_compat"], + modules: [ + { + name: "index.worker.js", + esModule: BROWSER_RENDERING_WORKER(), + }, + ], + bindings: [ + WORKER_BINDING_SERVICE_LOOPBACK, + { + name: "BrowserSession", + durableObjectNamespace: { + className: "BrowserSession", + }, }, + ], + durableObjectNamespaces: [ + { + className: "BrowserSession", + uniqueKey: "miniflare-BrowserSession", + }, + ], + durableObjectStorage: { inMemory: kVoid }, + }, }, ]; }, diff --git a/packages/miniflare/src/plugins/browser-rendering/process.ts b/packages/miniflare/src/plugins/browser-rendering/process.ts new file mode 100644 index 0000000000..18296335f9 --- /dev/null +++ b/packages/miniflare/src/plugins/browser-rendering/process.ts @@ -0,0 +1,67 @@ +import WebSocket from "ws"; +import type { Process } from "@puppeteer/browsers"; + +const BROWSER_CLOSE_TIMEOUT = 5_000; + +type BrowserProcess = Pick; + +function waitForGracefulClose( + browserProcess: BrowserProcess, + wsEndpoint: string, + timeoutMs: number +): Promise { + return new Promise((resolve) => { + const socket = new WebSocket(wsEndpoint); + const timeout = setTimeout(() => finish(false), timeoutMs); + let finished = false; + + function finish(closed: boolean) { + if (finished) return; + finished = true; + clearTimeout(timeout); + socket.terminate(); + resolve(closed); + } + + void browserProcess.hasClosed().then( + () => finish(true), + () => finish(false) + ); + socket.once("open", () => { + socket.send( + JSON.stringify({ id: 1, method: "Browser.close" }), + (error) => { + if (error) finish(false); + } + ); + }); + socket.once("error", () => finish(false)); + }); +} + +async function waitForExit( + browserProcess: BrowserProcess, + timeoutMs: number +): Promise { + let timeout: NodeJS.Timeout | undefined; + await Promise.race([ + browserProcess.hasClosed().catch(() => {}), + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs); + }), + ]); + clearTimeout(timeout); +} + +export async function closeBrowserProcess( + browserProcess: BrowserProcess, + wsEndpoint: string, + timeoutMs = BROWSER_CLOSE_TIMEOUT +): Promise { + if (await waitForGracefulClose(browserProcess, wsEndpoint, timeoutMs)) return; + + // Process.kill() terminates the whole process tree with taskkill on Windows + // and SIGKILL on the detached process group on POSIX systems. + browserProcess.kill(); + await waitForExit(browserProcess, timeoutMs); +} diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index 022e3ca69f..6025927588 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -7,7 +7,7 @@ import { type Worker_Binding, type Worker_Module, } from "../../runtime"; -import { CoreBindings } from "../../workers"; +import { CoreBindings, SharedBindings } from "../../workers"; import { normaliseDurableObject } from "../do"; import { namespaceEntries, @@ -177,10 +177,18 @@ export function constructExplorerBindingMap( const databaseId = innerBinding.service?.name?.replace(/^d1:db:/, ""); assert(databaseId); - IDToBindingName.d1[databaseId] = binding.name; + // Remote databases share one proxy service ("d1:db:remote"). Remote + // resources aren't surfaced in the explorer, so skip them — otherwise + // they'd all collide under the literal id "remote". + if (databaseId !== "remote") { + IDToBindingName.d1[databaseId] = binding.name; + } } - // KV bindings: name = "MINIFLARE_PROXY:kv:worker:BINDING", kvNamespace.name = "kv:ns:ID" + // KV bindings: name = "MINIFLARE_PROXY:kv:worker:BINDING". + // Local namespaces share one entry service ("kv:ns:entry") and carry their + // id in props; remote namespaces share one proxy service ("kv:ns:remote") + // and aren't surfaced in the explorer. if ( binding.name?.startsWith( `${CoreBindings.DURABLE_OBJECT_NAMESPACE_PROXY}:kv:` @@ -188,9 +196,24 @@ export function constructExplorerBindingMap( "kvNamespace" in binding && binding.kvNamespace?.name?.startsWith("kv:ns:") ) { - // Extract ID from service name "kv:ns:ID" - const namespaceId = binding.kvNamespace.name.replace(/^kv:ns:/, ""); - IDToBindingName.kv[namespaceId] = binding.name; + let namespaceId: string | undefined; + const propsJson = binding.kvNamespace.props?.json; + if (propsJson !== undefined) { + try { + namespaceId = JSON.parse(propsJson)[SharedBindings.TEXT_NAMESPACE]; + } catch { + // fall through to service-name parsing + } + } + if (namespaceId === undefined) { + namespaceId = binding.kvNamespace.name.replace(/^kv:ns:/, ""); + } + // Remote namespaces share one proxy service ("kv:ns:remote"). Remote + // resources aren't surfaced in the explorer, so skip them — otherwise + // they'd all collide under the literal id "remote". + if (namespaceId !== "remote") { + IDToBindingName.kv[namespaceId] = binding.name; + } } // R2 bindings: name = "MINIFLARE_PROXY:r2:worker:BINDING", r2Bucket.name = "r2:bucket:ID" @@ -203,7 +226,12 @@ export function constructExplorerBindingMap( ) { // Extract bucket name from service name "r2:bucket:BUCKET_NAME" const bucketName = binding.r2Bucket.name.replace(/^r2:bucket:/, ""); - IDToBindingName.r2[bucketName] = binding.name; + // Remote buckets share one proxy service ("r2:bucket:remote"). Remote + // resources aren't surfaced in the explorer, so skip them — otherwise + // they'd all collide under the literal id "remote". + if (bucketName !== "remote") { + IDToBindingName.r2[bucketName] = binding.name; + } } } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 5eb0115512..0c8d608b7e 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -35,6 +35,7 @@ import { import { IMAGES_PLUGIN_NAME } from "../images"; import { getR2PublicService, R2_PUBLIC_SERVICE_NAME } from "../r2"; import { + buildRemoteProxyProps, getUserBindingServiceName, parseRoutes, ProxyNodeBinding, @@ -335,6 +336,8 @@ export const CoreSharedOptionsSchema = z unsafeRuntimeEnv: z.record(z.string()).optional(), // Enable the local explorer at /cdn-cgi/explorer unsafeLocalExplorer: z.boolean().optional(), + // Enable RPC-based Durable Object introspection APIs + unsafeInspectDurableObjects: z.boolean().optional(), // Enable logging requests logRequests: z.boolean().default(true), @@ -434,6 +437,8 @@ function getCustomServiceDesignator( } else if ("remoteProxyConnectionString" in service) { assert("name" in service && typeof service.name === "string"); serviceName = `${CORE_PLUGIN_NAME}:remote-proxy-service:${workerIndex}:${name}`; + // Per-binding remote config travels via props to a generic proxy worker. + props = buildRemoteProxyProps(service.remoteProxyConnectionString, name); } // Worker with entrypoint else if ("name" in service) { @@ -522,10 +527,7 @@ function maybeGetCustomServiceService( return { name: `${CORE_PLUGIN_NAME}:remote-proxy-service:${workerIndex}:${name}`, - worker: remoteProxyClientWorker( - service.remoteProxyConnectionString, - name - ), + worker: remoteProxyClientWorker(), }; } } @@ -832,7 +834,8 @@ export const CORE_PLUGIN: Plugin< ([, { enableSql }]) => enableSql ); if ( - sharedOptions.unsafeLocalExplorer && + (sharedOptions.unsafeLocalExplorer || + sharedOptions.unsafeInspectDurableObjects) && // service-format workers are not supported "modules" in workerScript && sqliteClasses.length > 0 && diff --git a/packages/miniflare/src/plugins/d1/index.ts b/packages/miniflare/src/plugins/d1/index.ts index 3dee2a65c0..007f73d68e 100644 --- a/packages/miniflare/src/plugins/d1/index.ts +++ b/packages/miniflare/src/plugins/d1/index.ts @@ -4,6 +4,7 @@ import SCRIPT_D1_DATABASE_OBJECT from "worker:d1/database"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -48,6 +49,8 @@ export const D1SharedOptionsSchema = z.object({ export const D1_PLUGIN_NAME = "d1"; const D1_STORAGE_SERVICE_NAME = `${D1_PLUGIN_NAME}:storage`; const D1_DATABASE_SERVICE_PREFIX = `${D1_PLUGIN_NAME}:db`; +// One shared remote-proxy service for all remote D1 databases (config via props). +const D1_REMOTE_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:remote`; const D1_DATABASE_OBJECT_CLASS_NAME = "D1DatabaseObject"; const D1_DATABASE_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { serviceName: D1_DATABASE_SERVICE_PREFIX, @@ -70,18 +73,21 @@ export const D1_PLUGIN: Plugin< "Alpha D1 Databases cannot run remotely" ); - const serviceName = getUserBindingServiceName( - D1_DATABASE_SERVICE_PREFIX, - id, - remoteProxyConnectionString - ); + // Remote databases share one proxy service (config via props); + // local databases keep their per-id entry service. + const serviceDesignator = remoteProxyConnectionString + ? { + name: D1_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + } + : { + name: getUserBindingServiceName(D1_DATABASE_SERVICE_PREFIX, id), + }; const binding = name.startsWith("__D1_BETA__") ? // Used before Wrangler 3.3 { - service: { - name: serviceName, - }, + service: serviceDesignator, } : // Used after Wrangler 3.3 { @@ -90,9 +96,7 @@ export const D1_PLUGIN: Plugin< innerBindings: [ { name: "fetcher", - service: { - name: serviceName, - }, + service: serviceDesignator, }, ], }, @@ -118,20 +122,28 @@ export const D1_PLUGIN: Plugin< }) { const persist = sharedOptions.d1Persist; const databases = namespaceEntries(options.d1Databases); - const services = databases.map( - ([name, { id, remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - D1_DATABASE_SERVICE_PREFIX, - id, - remoteProxyConnectionString - ), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : objectEntryWorker(D1_DATABASE_OBJECT, id), - }) - ); - if (databases.length > 0) { + const services: Service[] = []; + let hasRemote = false; + for (const [, { id, remoteProxyConnectionString }] of databases) { + if (remoteProxyConnectionString) { + hasRemote = true; + } else { + services.push({ + name: getUserBindingServiceName(D1_DATABASE_SERVICE_PREFIX, id), + worker: objectEntryWorker(D1_DATABASE_OBJECT, id), + }); + } + } + if (hasRemote) { + services.push({ + name: D1_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }); + } + + const hasLocal = services.some((s) => s.name !== D1_REMOTE_SERVICE_NAME); + if (hasLocal) { const uniqueKey = `miniflare-${D1_DATABASE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( D1_PLUGIN_NAME, @@ -180,8 +192,11 @@ export const D1_PLUGIN: Plugin< }; services.push(storageService, objectService); - for (const database of databases) { - await migrateDatabase(log, uniqueKey, persistPath, database[1].id); + for (const [, database] of databases) { + if (database.remoteProxyConnectionString) { + continue; + } + await migrateDatabase(log, uniqueKey, persistPath, database.id); } } diff --git a/packages/miniflare/src/plugins/dispatch-namespace/index.ts b/packages/miniflare/src/plugins/dispatch-namespace/index.ts index 3f387ab7b3..11e4c77631 100644 --- a/packages/miniflare/src/plugins/dispatch-namespace/index.ts +++ b/packages/miniflare/src/plugins/dispatch-namespace/index.ts @@ -2,7 +2,7 @@ import SCRIPT_DISPATCH_NAMESPACE from "worker:dispatch-namespace/dispatch-namesp import SCRIPT_DISPATCH_NAMESPACE_PROXY from "worker:dispatch-namespace/dispatch-namespace-proxy"; import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -24,17 +24,8 @@ export const DispatchNamespaceOptionsSchema = z.object({ export const DISPATCH_NAMESPACE_PLUGIN_NAME = "dispatch-namespace"; -/** Service name for the proxy client worker backing a dispatch namespace. */ -function getProxyServiceName( - name: string, - remoteProxyConnectionString?: RemoteProxyConnectionString -): string { - return getUserBindingServiceName( - `${DISPATCH_NAMESPACE_PLUGIN_NAME}-proxy`, - name, - remoteProxyConnectionString - ); -} +// One shared proxy client service for all dispatch namespaces (config via props). +const DISPATCH_NAMESPACE_REMOTE_SERVICE_NAME = `${DISPATCH_NAMESPACE_PLUGIN_NAME}-proxy:remote`; export const DISPATCH_NAMESPACE_PLUGIN: Plugin< typeof DispatchNamespaceOptionsSchema @@ -57,9 +48,10 @@ export const DISPATCH_NAMESPACE_PLUGIN: Plugin< { name: "proxyClient", service: { - name: getProxyServiceName( - name, - config.remoteProxyConnectionString + name: DISPATCH_NAMESPACE_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + config.remoteProxyConnectionString, + name ), }, }, @@ -81,18 +73,19 @@ export const DISPATCH_NAMESPACE_PLUGIN: Plugin< ); }, async getServices({ options }) { - if (!options.dispatchNamespaces) { + if ( + !options.dispatchNamespaces || + Object.keys(options.dispatchNamespaces).length === 0 + ) { return []; } - return Object.entries(options.dispatchNamespaces).map(([name, config]) => ({ - name: getProxyServiceName(name, config.remoteProxyConnectionString), - worker: remoteProxyClientWorker( - config.remoteProxyConnectionString, - name, - SCRIPT_DISPATCH_NAMESPACE_PROXY - ), - })); + return [ + { + name: DISPATCH_NAMESPACE_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(SCRIPT_DISPATCH_NAMESPACE_PROXY), + }, + ]; }, getExtensions({ options }) { if (!options.some((o) => o.dispatchNamespaces)) { diff --git a/packages/miniflare/src/plugins/email/index.ts b/packages/miniflare/src/plugins/email/index.ts index 908d19c44e..d46d5c8024 100644 --- a/packages/miniflare/src/plugins/email/index.ts +++ b/packages/miniflare/src/plugins/email/index.ts @@ -4,6 +4,7 @@ import EMAIL_MESSAGE from "worker:email/email"; import SEND_EMAIL_BINDING from "worker:email/send_email"; import { z } from "zod"; import { + buildRemoteProxyProps, getUserBindingServiceName, remoteProxyClientWorker, ProxyNodeBinding, @@ -43,6 +44,7 @@ export const EmailOptionsSchema = z.object({ export const EMAIL_PLUGIN_NAME = "email"; const SERVICE_SEND_EMAIL_WORKER_PREFIX = `SEND-EMAIL-WORKER`; +const EMAIL_REMOTE_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:remote`; // Disk service name and binding name for writing temporary files to system temp directory const EMAIL_DISK_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:disk`; const EMAIL_DISK_BINDING_NAME = "MINIFLARE_EMAIL_DISK"; @@ -112,12 +114,18 @@ export const EMAIL_PLUGIN: Plugin = { return sendEmailBindings.map(({ name, remoteProxyConnectionString }) => ({ name, - service: { - entrypoint: remoteProxyConnectionString - ? undefined - : "SendEmailBinding", - name: getUserBindingServiceName(SERVICE_SEND_EMAIL_WORKER_PREFIX, name), - }, + service: remoteProxyConnectionString + ? { + name: EMAIL_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + } + : { + entrypoint: "SendEmailBinding", + name: getUserBindingServiceName( + SERVICE_SEND_EMAIL_WORKER_PREFIX, + name + ), + }, })); }, getNodeBindings(options) { @@ -179,32 +187,42 @@ export const EMAIL_PLUGIN: Plugin = { }, })); + let hasRemote = false; for (const { name, remoteProxyConnectionString, ...config } of args.options .email?.send_email ?? []) { + if (remoteProxyConnectionString) { + hasRemote = true; + continue; + } services.push({ name: getUserBindingServiceName(SERVICE_SEND_EMAIL_WORKER_PREFIX, name), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : { - compatibilityDate: "2025-03-17", - modules: [ - { - name: "send_email.mjs", - esModule: SEND_EMAIL_BINDING(), - }, - ], - bindings: [ - ...buildJsonBindings(config), - ...diskServices.map(({ bindingName, serviceName }) => ({ - name: bindingName, - service: { name: serviceName }, - })), - { - name: "email_disk_services", - json: JSON.stringify(diskServices), - }, - ], + worker: { + compatibilityDate: "2025-03-17", + modules: [ + { + name: "send_email.mjs", + esModule: SEND_EMAIL_BINDING(), + }, + ], + bindings: [ + ...buildJsonBindings(config), + ...diskServices.map(({ bindingName, serviceName }) => ({ + name: bindingName, + service: { name: serviceName }, + })), + { + name: "email_disk_services", + json: JSON.stringify(diskServices), }, + ], + }, + }); + } + + if (hasRemote) { + services.push({ + name: EMAIL_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 136eb45f31..8c30d56aa3 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -19,6 +19,7 @@ export const FlagshipOptionsSchema = z.object({ }); export const FLAGSHIP_PLUGIN_NAME = "flagship"; +const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:remote`; export const FLAGSHIP_PLUGIN: Plugin = { options: FlagshipOptionsSchema, @@ -32,10 +33,10 @@ export const FLAGSHIP_PLUGIN: Plugin = { ([name, config]) => ({ name, service: { - name: getUserBindingServiceName( - FLAGSHIP_PLUGIN_NAME, - name, - config.remoteProxyConnectionString + name: FLAGSHIP_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + config.remoteProxyConnectionString, + name ), }, }) @@ -53,21 +54,15 @@ export const FLAGSHIP_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.flagship) { + if (!options.flagship || Object.keys(options.flagship).length === 0) { return []; } - return Object.entries(options.flagship).map( - ([name, { remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - FLAGSHIP_PLUGIN_NAME, - name, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: FLAGSHIP_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/images/fetcher.ts b/packages/miniflare/src/plugins/images/fetcher.ts index bfcf099aba..554fe125f8 100644 --- a/packages/miniflare/src/plugins/images/fetcher.ts +++ b/packages/miniflare/src/plugins/images/fetcher.ts @@ -11,6 +11,9 @@ type Transform = { rotate?: number; width?: number; height?: number; + fit?: RequestInitCfPropertiesImage["fit"]; + gravity?: RequestInitCfPropertiesImage["gravity"]; + background?: string; }; function validateTransforms(inputTransforms: unknown): Transform[] | null { @@ -173,8 +176,16 @@ async function runTransform( } if (transform.width !== undefined || transform.height !== undefined) { + const { fit, withoutEnlargement } = resolveImagesBindingFit( + transform.fit + ); transformer.resize(transform.width || null, transform.height || null, { - fit: "contain", + fit, + withoutEnlargement, + position: resolveGravity(transform.gravity), + background: + transform.background ?? + (transform.fit === "pad" ? "#ffffff" : undefined), }); } } @@ -210,7 +221,13 @@ async function runTransform( break; } - return new Response(transformer, { + // Buffer explicitly rather than passing the raw Sharp Duplex stream + // straight into Response() - the latter produced incomplete/corrupted + // output in some environments, only caught once pixel-level regression + // tests started decoding the response. Matches cfImageLocalFetcher's + // (working) pattern below. + const output = await transformer.toBuffer(); + return new Response(output, { headers: { "content-type": outputFormat, }, @@ -249,6 +266,35 @@ function resolveQuality( return undefined; } +// Fit resolution for the Images binding (`env.IMAGES.transform()`). +// Unlike cf.image, an explicit fit:"contain" pads to the exact requested +// box (letterbox), matching production Images binding behavior. Default +// (unspecified) and "scale-down" must NOT pad - see transform.spec.ts. +function resolveImagesBindingFit(fit: RequestInitCfPropertiesImage["fit"]): { + fit: keyof FitEnum; + withoutEnlargement?: boolean; +} { + switch (fit) { + case "contain": + return { fit: "contain" }; + case "cover": + return { fit: "cover" }; + case "crop": + return { fit: "cover", withoutEnlargement: true }; + case "pad": + return { fit: "contain" }; + case "squeeze": + return { fit: "fill" }; + case "scale-down": + default: + return { fit: "inside", withoutEnlargement: true }; + } +} + +// Fit resolution for cf.image (`fetch(url, { cf: { image } })`). Here +// "contain" does NOT pad - it shrinks to fit within the box preserving +// aspect ratio, same as "scale-down" but allowed to enlarge. See +// cf-image.spec.ts "fit:contain preserves aspect ratio". function resolveFit(fit: RequestInitCfPropertiesImage["fit"]): { fit: keyof FitEnum; withoutEnlargement?: boolean; diff --git a/packages/miniflare/src/plugins/images/index.ts b/packages/miniflare/src/plugins/images/index.ts index c729aa38e7..7df446df28 100644 --- a/packages/miniflare/src/plugins/images/index.ts +++ b/packages/miniflare/src/plugins/images/index.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { SharedBindings } from "../../workers"; import { KV_NAMESPACE_OBJECT_CLASS_NAME } from "../kv"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -34,6 +35,7 @@ export const ImagesSharedOptionsSchema = z.object({ }); export const IMAGES_PLUGIN_NAME = "images"; +const IMAGES_REMOTE_SERVICE_NAME = `${IMAGES_PLUGIN_NAME}:remote`; export const IMAGES_PLUGIN: Plugin< typeof ImagesOptionsSchema, @@ -55,13 +57,20 @@ export const IMAGES_PLUGIN: Plugin< innerBindings: [ { name: "fetcher", - service: { - name: getUserBindingServiceName( - IMAGES_PLUGIN_NAME, - options.images.binding, - options.images.remoteProxyConnectionString - ), - }, + service: options.images.remoteProxyConnectionString + ? { + name: IMAGES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.images.remoteProxyConnectionString, + options.images.binding + ), + } + : { + name: getUserBindingServiceName( + IMAGES_PLUGIN_NAME, + options.images.binding + ), + }, }, ], }, @@ -87,24 +96,20 @@ export const IMAGES_PLUGIN: Plugin< return []; } - const serviceName = getUserBindingServiceName( - IMAGES_PLUGIN_NAME, - options.images.binding, - options.images.remoteProxyConnectionString - ); - if (options.images.remoteProxyConnectionString) { return [ { - name: serviceName, - worker: remoteProxyClientWorker( - options.images.remoteProxyConnectionString, - options.images.binding - ), + name: IMAGES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; } + const serviceName = getUserBindingServiceName( + IMAGES_PLUGIN_NAME, + options.images.binding + ); + const persistPath = getPersistPath( IMAGES_PLUGIN_NAME, tmpPath, diff --git a/packages/miniflare/src/plugins/kv/index.ts b/packages/miniflare/src/plugins/kv/index.ts index ffcbd0d74b..ff79ca4100 100644 --- a/packages/miniflare/src/plugins/kv/index.ts +++ b/packages/miniflare/src/plugins/kv/index.ts @@ -4,9 +4,9 @@ import { z } from "zod"; import { PathSchema } from "../../shared"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, - getUserBindingServiceName, migrateDatabase, namespaceEntries, namespaceKeys, @@ -58,6 +58,11 @@ export const KVSharedOptionsSchema = z.object({ }); const SERVICE_NAMESPACE_PREFIX = `${KV_PLUGIN_NAME}:ns`; +// A single entry service shared by every *local* namespace. Each namespace's id +// is supplied per-binding via `ctx.props`, so one service serves all of them. +const KV_LOCAL_ENTRY_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:entry`; +// One shared remote-proxy service for all remote namespaces (config via props). +const KV_REMOTE_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:remote`; const KV_STORAGE_SERVICE_NAME = `${KV_PLUGIN_NAME}:storage`; export const KV_NAMESPACE_OBJECT_CLASS_NAME = "KVNamespaceObject"; const KV_NAMESPACE_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { @@ -80,16 +85,35 @@ export const KV_PLUGIN: Plugin< bindingTypeDescription: "KV namespace", async getBindings(options) { const namespaces = namespaceEntries(options.kvNamespaces); - const bindings = namespaces.map(([name, namespace]) => ({ - name, - kvNamespace: { - name: getUserBindingServiceName( - SERVICE_NAMESPACE_PREFIX, - namespace.id, - namespace.remoteProxyConnectionString - ), - }, - })); + const bindings = namespaces.map(([name, namespace]) => { + // Remote (mixed-mode) namespaces share one proxy service; per-binding + // config (connection string) travels via props. + if (namespace.remoteProxyConnectionString) { + return { + name, + kvNamespace: { + name: KV_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + namespace.remoteProxyConnectionString, + name + ), + }, + }; + } + // Local namespaces all share one entry service; the namespace id is + // passed at runtime via props (read in object-entry.worker.ts). + return { + name, + kvNamespace: { + name: KV_LOCAL_ENTRY_SERVICE_NAME, + props: { + json: JSON.stringify({ + [SharedBindings.TEXT_NAMESPACE]: namespace.id, + }), + }, + }, + }; + }); if (isWorkersSitesEnabled(options)) { bindings.push(...(await getSitesBindings(options))); @@ -121,20 +145,32 @@ export const KV_PLUGIN: Plugin< }) { const persist = sharedOptions.kvPersist; const namespaces = namespaceEntries(options.kvNamespaces); - const services = namespaces.map( - ([name, { id, remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - SERVICE_NAMESPACE_PREFIX, - id, - remoteProxyConnectionString - ), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : objectEntryWorker(KV_NAMESPACE_OBJECT, id), - }) + + const services: Service[] = []; + + // One shared entry service for all local namespaces (id supplied via props). + const hasLocalNamespace = namespaces.some( + ([, ns]) => !ns.remoteProxyConnectionString ); + if (hasLocalNamespace) { + services.push({ + name: KV_LOCAL_ENTRY_SERVICE_NAME, + worker: objectEntryWorker(KV_NAMESPACE_OBJECT), + }); + } + + // One shared proxy service for all remote (mixed-mode) namespaces. + const hasRemoteNamespace = namespaces.some( + ([, ns]) => ns.remoteProxyConnectionString + ); + if (hasRemoteNamespace) { + services.push({ + name: KV_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }); + } - if (services.length > 0) { + if (hasLocalNamespace) { const uniqueKey = `miniflare-${KV_NAMESPACE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( KV_PLUGIN_NAME, @@ -184,8 +220,11 @@ export const KV_PLUGIN: Plugin< // another breaking change to the persistence location, migrate SQLite // databases from the old location to the new location. Blobs are still // stored in the same location. - for (const namespace of namespaces) { - await migrateDatabase(log, uniqueKey, persistPath, namespace[1].id); + for (const [, namespace] of namespaces) { + if (namespace.remoteProxyConnectionString) { + continue; + } + await migrateDatabase(log, uniqueKey, persistPath, namespace.id); } } diff --git a/packages/miniflare/src/plugins/media/index.ts b/packages/miniflare/src/plugins/media/index.ts index 7c0583290a..5ac1fda46a 100644 --- a/packages/miniflare/src/plugins/media/index.ts +++ b/packages/miniflare/src/plugins/media/index.ts @@ -1,12 +1,13 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; export const MEDIA_PLUGIN_NAME = "media"; +const MEDIA_REMOTE_SERVICE_NAME = `${MEDIA_PLUGIN_NAME}:remote`; const MediaSchema = z.object({ binding: z.string(), @@ -31,10 +32,10 @@ export const MEDIA_PLUGIN: Plugin = { { name: options.media.binding, service: { - name: getUserBindingServiceName( - MEDIA_PLUGIN_NAME, - options.media.binding, - options.media.remoteProxyConnectionString + name: MEDIA_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.media.remoteProxyConnectionString, + options.media.binding ), }, }, @@ -55,15 +56,8 @@ export const MEDIA_PLUGIN: Plugin = { return [ { - name: getUserBindingServiceName( - MEDIA_PLUGIN_NAME, - options.media.binding, - options.media.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - options.media.remoteProxyConnectionString, - options.media.binding - ), + name: MEDIA_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; }, diff --git a/packages/miniflare/src/plugins/mtls/index.ts b/packages/miniflare/src/plugins/mtls/index.ts index dafd818094..5977b4dde7 100644 --- a/packages/miniflare/src/plugins/mtls/index.ts +++ b/packages/miniflare/src/plugins/mtls/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const MtlsOptionsSchema = z.object({ }); export const MTLS_PLUGIN_NAME = "mtls"; +const MTLS_REMOTE_SERVICE_NAME = `${MTLS_PLUGIN_NAME}:remote`; export const MTLS_PLUGIN: Plugin = { options: MtlsOptionsSchema, @@ -28,16 +29,13 @@ export const MTLS_PLUGIN: Plugin = { } return Object.entries(options.mtlsCertificates).map( - ([name, { certificate_id, remoteProxyConnectionString }]) => { + ([name, { remoteProxyConnectionString }]) => { return { name, service: { - name: getUserBindingServiceName( - MTLS_PLUGIN_NAME, - certificate_id, - remoteProxyConnectionString - ), + name: MTLS_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), }, }; } @@ -55,21 +53,18 @@ export const MTLS_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.mtlsCertificates) { + if ( + !options.mtlsCertificates || + Object.keys(options.mtlsCertificates).length === 0 + ) { return []; } - return Object.entries(options.mtlsCertificates).map( - ([name, { certificate_id, remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - MTLS_PLUGIN_NAME, - certificate_id, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: MTLS_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/pipelines/index.ts b/packages/miniflare/src/plugins/pipelines/index.ts index a3c0d649c5..c65fc7f5e3 100644 --- a/packages/miniflare/src/plugins/pipelines/index.ts +++ b/packages/miniflare/src/plugins/pipelines/index.ts @@ -1,6 +1,7 @@ import SCRIPT_PIPELINE_OBJECT from "worker:pipelines/pipeline"; import { z } from "zod"; import { + buildRemoteProxyProps, namespaceKeys, ProxyNodeBinding, remoteProxyClientWorker, @@ -36,16 +37,24 @@ export const PipelineOptionsSchema = z.object({ export const PIPELINES_PLUGIN_NAME = "pipelines"; const SERVICE_PIPELINE_PREFIX = `${PIPELINES_PLUGIN_NAME}:pipeline`; +const PIPELINES_REMOTE_SERVICE_NAME = `${PIPELINES_PLUGIN_NAME}:pipeline:remote`; export const PIPELINE_PLUGIN: Plugin = { options: PipelineOptionsSchema, bindingTypeDescription: "Pipeline", getBindings(options) { const pipelines = bindingEntries(options.pipelines); - return pipelines.map(([name, { id }]) => ({ - name, - service: { name: `${SERVICE_PIPELINE_PREFIX}:${id}` }, - })); + return pipelines.map( + ([name, { id, remoteProxyConnectionString }]) => ({ + name, + service: remoteProxyConnectionString + ? { + name: PIPELINES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + } + : { name: `${SERVICE_PIPELINE_PREFIX}:${id}` }, + }) + ); }, getNodeBindings(options) { const buckets = namespaceKeys(options.pipelines); @@ -56,24 +65,31 @@ export const PIPELINE_PLUGIN: Plugin = { async getServices({ options }) { const pipelines = bindingEntries(options.pipelines); - const services = []; - for (const [bindingName, pipeline] of pipelines) { + const services: Service[] = []; + let hasRemote = false; + for (const [, pipeline] of pipelines) { + if (pipeline.remoteProxyConnectionString) { + hasRemote = true; + continue; + } services.push({ name: `${SERVICE_PIPELINE_PREFIX}:${pipeline.id}`, - worker: pipeline.remoteProxyConnectionString - ? remoteProxyClientWorker( - pipeline.remoteProxyConnectionString, - bindingName - ) - : { - compatibilityDate: "2024-12-30", - modules: [ - { - name: "pipeline.worker.js", - esModule: SCRIPT_PIPELINE_OBJECT(), - }, - ], + worker: { + compatibilityDate: "2024-12-30", + modules: [ + { + name: "pipeline.worker.js", + esModule: SCRIPT_PIPELINE_OBJECT(), }, + ], + }, + }); + } + + if (hasRemote) { + services.push({ + name: PIPELINES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index f089c2917f..59543f5d40 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -4,6 +4,7 @@ import SCRIPT_R2_PUBLIC from "worker:r2/public"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -48,6 +49,8 @@ export const R2SharedOptionsSchema = z.object({ export const R2_PLUGIN_NAME = "r2"; const R2_STORAGE_SERVICE_NAME = `${R2_PLUGIN_NAME}:storage`; const R2_BUCKET_SERVICE_PREFIX = `${R2_PLUGIN_NAME}:bucket`; +// One shared remote-proxy service for all remote R2 buckets (config via props). +const R2_REMOTE_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:remote`; export const R2_PUBLIC_SERVICE_NAME = `${R2_PLUGIN_NAME}:public`; const R2_BUCKET_OBJECT_CLASS_NAME = "R2BucketObject"; const R2_BUCKET_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { @@ -97,13 +100,20 @@ export const R2_PLUGIN: Plugin< const buckets = namespaceEntries(options.r2Buckets); return buckets.map(([name, bucket]) => ({ name, - r2Bucket: { - name: getUserBindingServiceName( - R2_BUCKET_SERVICE_PREFIX, - bucket.id, - bucket.remoteProxyConnectionString - ), - }, + r2Bucket: bucket.remoteProxyConnectionString + ? { + name: R2_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + bucket.remoteProxyConnectionString, + name + ), + } + : { + name: getUserBindingServiceName( + R2_BUCKET_SERVICE_PREFIX, + bucket.id + ), + }, })); }, getNodeBindings(options) { @@ -122,20 +132,28 @@ export const R2_PLUGIN: Plugin< }) { const persist = sharedOptions.r2Persist; const buckets = namespaceEntries(options.r2Buckets); - const services = buckets.map( - ([name, { id, remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - R2_BUCKET_SERVICE_PREFIX, - id, - remoteProxyConnectionString - ), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : objectEntryWorker(R2_BUCKET_OBJECT, id), - }) - ); - if (buckets.length > 0) { + const services: Service[] = []; + let hasRemote = false; + for (const [, { id, remoteProxyConnectionString }] of buckets) { + if (remoteProxyConnectionString) { + hasRemote = true; + } else { + services.push({ + name: getUserBindingServiceName(R2_BUCKET_SERVICE_PREFIX, id), + worker: objectEntryWorker(R2_BUCKET_OBJECT, id), + }); + } + } + if (hasRemote) { + services.push({ + name: R2_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }); + } + + const hasLocal = services.some((s) => s.name !== R2_REMOTE_SERVICE_NAME); + if (hasLocal) { const uniqueKey = `miniflare-${R2_BUCKET_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( R2_PLUGIN_NAME, @@ -183,8 +201,11 @@ export const R2_PLUGIN: Plugin< }; services.push(storageService, objectService); - for (const bucket of buckets) { - await migrateDatabase(log, uniqueKey, persistPath, bucket[1].id); + for (const [, bucket] of buckets) { + if (bucket.remoteProxyConnectionString) { + continue; + } + await migrateDatabase(log, uniqueKey, persistPath, bucket.id); } } diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index b26fcc06a4..02c5c2ef60 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -61,7 +61,11 @@ export function _enableControlEndpoints() { export function objectEntryWorker( durableObjectNamespace: Worker_Binding_DurableObjectNamespaceDesignator, - namespace: string + // When provided, the namespace is baked into the worker as a static binding + // (the original per-resource model). When omitted, the namespace is supplied + // per-request via `ctx.props` (the props-based model that lets a single entry + // service serve any number of namespaces). + namespace?: string ): Worker { return { compatibilityDate: "2023-07-24", @@ -69,7 +73,9 @@ export function objectEntryWorker( { name: "object-entry.worker.js", esModule: SCRIPT_OBJECT_ENTRY() }, ], bindings: [ - { name: SharedBindings.TEXT_NAMESPACE, text: namespace }, + ...(namespace !== undefined + ? [{ name: SharedBindings.TEXT_NAMESPACE, text: namespace }] + : []), { name: SharedBindings.DURABLE_OBJECT_NAMESPACE_OBJECT, durableObjectNamespace, @@ -78,12 +84,13 @@ export function objectEntryWorker( }; } -export function remoteProxyClientWorker( - remoteProxyConnectionString: RemoteProxyConnectionString | undefined, - binding: string, - script?: () => string -) { - const cfTraceId = process.env.CF_TRACE_ID; +// A single remote-proxy client service can serve any number of remote bindings: +// the per-binding data (connection string, binding name, trace id) is supplied +// at runtime via `ctx.props` (see `buildRemoteProxyProps`), rather than baked +// into a per-binding service. The only static, non-props-able binding is the +// loopback service (used to surface diagnostics back to the Miniflare host, +// e.g. a Cloudflare Access block detected on the remote proxy response). +export function remoteProxyClientWorker(script?: () => string) { return { compatibilityDate: "2025-01-01", modules: [ @@ -92,32 +99,22 @@ export function remoteProxyClientWorker( esModule: (script ?? SCRIPT_REMOTE_PROXY_CLIENT)(), }, ], - bindings: [ - ...(remoteProxyConnectionString?.href - ? [ - { - name: "remoteProxyConnectionString", - text: remoteProxyConnectionString.href, - }, - ] - : []), - { - name: "binding", - text: binding, - }, - ...(cfTraceId - ? [ - { - name: "cfTraceId", - text: cfTraceId, - }, - ] - : []), - // Loopback binding so the proxy client can report diagnostics - // (e.g. a Cloudflare Access block on the remote proxy server) - // back to the Miniflare host for a single, actionable warning. - WORKER_BINDING_SERVICE_LOOPBACK, - ], + bindings: [WORKER_BINDING_SERVICE_LOOPBACK], + }; +} + +// Builds the `props` value for a binding that points at a shared remote-proxy +// client service. Read back in `remote-proxy-client.worker.ts` via `ctx.props`. +export function buildRemoteProxyProps( + remoteProxyConnectionString: RemoteProxyConnectionString | undefined, + binding: string +): { json: string } { + return { + json: JSON.stringify({ + remoteProxyConnectionString: remoteProxyConnectionString?.href, + binding, + cfTraceId: process.env.CF_TRACE_ID, + }), }; } diff --git a/packages/miniflare/src/plugins/stream/index.ts b/packages/miniflare/src/plugins/stream/index.ts index 9c1b446f64..fe75ffbc36 100644 --- a/packages/miniflare/src/plugins/stream/index.ts +++ b/packages/miniflare/src/plugins/stream/index.ts @@ -4,6 +4,7 @@ import OBJECT_SCRIPT from "worker:stream/object"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -31,6 +32,7 @@ export const StreamSharedOptionsSchema = z.object({ }); export const STREAM_PLUGIN_NAME = "stream"; +const STREAM_REMOTE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:remote`; const STREAM_STORAGE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:storage`; const STREAM_OBJECT_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:object`; export const STREAM_OBJECT_CLASS_NAME = "StreamObject"; @@ -52,16 +54,18 @@ export const STREAM_PLUGIN: Plugin< return [ { name: options.stream.binding, - service: { - name: getUserBindingServiceName( - STREAM_PLUGIN_NAME, - "service", - options.stream.remoteProxyConnectionString - ), - entrypoint: options.stream.remoteProxyConnectionString - ? undefined - : "StreamBinding", - }, + service: options.stream.remoteProxyConnectionString + ? { + name: STREAM_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.stream.remoteProxyConnectionString, + options.stream.binding + ), + } + : { + name: getUserBindingServiceName(STREAM_PLUGIN_NAME, "service"), + entrypoint: "StreamBinding", + }, }, ]; }, @@ -85,19 +89,10 @@ export const STREAM_PLUGIN: Plugin< } if (options.stream.remoteProxyConnectionString) { - const serviceName = getUserBindingServiceName( - STREAM_PLUGIN_NAME, - "service", - options.stream.remoteProxyConnectionString - ); - return [ { - name: serviceName, - worker: remoteProxyClientWorker( - options.stream.remoteProxyConnectionString, - options.stream.binding - ), + name: STREAM_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; } diff --git a/packages/miniflare/src/plugins/vectorize/index.ts b/packages/miniflare/src/plugins/vectorize/index.ts index ce9a0be971..b9e4f58528 100644 --- a/packages/miniflare/src/plugins/vectorize/index.ts +++ b/packages/miniflare/src/plugins/vectorize/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const VectorizeOptionsSchema = z.object({ }); export const VECTORIZE_PLUGIN_NAME = "vectorize"; +const VECTORIZE_REMOTE_SERVICE_NAME = `${VECTORIZE_PLUGIN_NAME}:remote`; export const VECTORIZE_PLUGIN: Plugin = { options: VectorizeOptionsSchema, @@ -37,10 +38,10 @@ export const VECTORIZE_PLUGIN: Plugin = { { name: "fetcher", service: { - name: getUserBindingServiceName( - VECTORIZE_PLUGIN_NAME, - name, - remoteProxyConnectionString + name: VECTORIZE_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + remoteProxyConnectionString, + name ), }, }, @@ -74,21 +75,15 @@ export const VECTORIZE_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.vectorize) { + if (!options.vectorize || Object.keys(options.vectorize).length === 0) { return []; } - return Object.entries(options.vectorize).map( - ([name, { remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - VECTORIZE_PLUGIN_NAME, - name, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: VECTORIZE_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/vpc-networks/index.ts b/packages/miniflare/src/plugins/vpc-networks/index.ts index 9314fb1609..bc363f2bba 100644 --- a/packages/miniflare/src/plugins/vpc-networks/index.ts +++ b/packages/miniflare/src/plugins/vpc-networks/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -26,6 +26,7 @@ export const VpcNetworksOptionsSchema = z.object({ }); export const VPC_NETWORKS_PLUGIN_NAME = "vpc-networks"; +const VPC_NETWORKS_REMOTE_SERVICE_NAME = `${VPC_NETWORKS_PLUGIN_NAME}:remote`; export const VPC_NETWORKS_PLUGIN: Plugin = { options: VpcNetworksOptionsSchema, @@ -36,16 +37,14 @@ export const VPC_NETWORKS_PLUGIN: Plugin = { } return Object.entries(options.vpcNetworks).map(([name, binding]) => { - const identifier = - "tunnel_id" in binding ? binding.tunnel_id : binding.network_id; return { name, service: { - name: getUserBindingServiceName( - VPC_NETWORKS_PLUGIN_NAME, - identifier, - binding.remoteProxyConnectionString + name: VPC_NETWORKS_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + binding.remoteProxyConnectionString, + name ), }, }; @@ -63,24 +62,15 @@ export const VPC_NETWORKS_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.vpcNetworks) { + if (!options.vpcNetworks || Object.keys(options.vpcNetworks).length === 0) { return []; } - return Object.entries(options.vpcNetworks).map(([name, binding]) => { - const identifier = - "tunnel_id" in binding ? binding.tunnel_id : binding.network_id; - return { - name: getUserBindingServiceName( - VPC_NETWORKS_PLUGIN_NAME, - identifier, - binding.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - binding.remoteProxyConnectionString, - name - ), - }; - }); + return [ + { + name: VPC_NETWORKS_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/vpc-services/index.ts b/packages/miniflare/src/plugins/vpc-services/index.ts index 0bdd14be54..597155d4f6 100644 --- a/packages/miniflare/src/plugins/vpc-services/index.ts +++ b/packages/miniflare/src/plugins/vpc-services/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const VpcServicesOptionsSchema = z.object({ }); export const VPC_SERVICES_PLUGIN_NAME = "vpc-services"; +const VPC_SERVICES_REMOTE_SERVICE_NAME = `${VPC_SERVICES_PLUGIN_NAME}:remote`; export const VPC_SERVICES_PLUGIN: Plugin = { options: VpcServicesOptionsSchema, @@ -28,16 +29,13 @@ export const VPC_SERVICES_PLUGIN: Plugin = { } return Object.entries(options.vpcServices).map( - ([name, { service_id, remoteProxyConnectionString }]) => { + ([name, { remoteProxyConnectionString }]) => { return { name, service: { - name: getUserBindingServiceName( - VPC_SERVICES_PLUGIN_NAME, - service_id, - remoteProxyConnectionString - ), + name: VPC_SERVICES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), }, }; } @@ -55,21 +53,15 @@ export const VPC_SERVICES_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.vpcServices) { + if (!options.vpcServices || Object.keys(options.vpcServices).length === 0) { return []; } - return Object.entries(options.vpcServices).map( - ([name, { service_id, remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - VPC_SERVICES_PLUGIN_NAME, - service_id, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: VPC_SERVICES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/websearch/index.ts b/packages/miniflare/src/plugins/websearch/index.ts index a8271680ca..70e29380cf 100644 --- a/packages/miniflare/src/plugins/websearch/index.ts +++ b/packages/miniflare/src/plugins/websearch/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -19,6 +19,7 @@ export const WebsearchOptionsSchema = z.object({ export const WEBSEARCH_PLUGIN_NAME = "websearch"; const WEBSEARCH_SCOPE = "websearch"; +const WEBSEARCH_REMOTE_SERVICE_NAME = `${WEBSEARCH_SCOPE}:remote`; export const WEBSEARCH_PLUGIN: Plugin = { options: WebsearchOptionsSchema, @@ -26,7 +27,7 @@ export const WEBSEARCH_PLUGIN: Plugin = { async getBindings(options) { const bindings: { name: string; - service: { name: string }; + service: { name: string; props?: { json: string } }; }[] = []; for (const [bindingName, entry] of Object.entries( @@ -35,10 +36,10 @@ export const WEBSEARCH_PLUGIN: Plugin = { bindings.push({ name: bindingName, service: { - name: getUserBindingServiceName( - WEBSEARCH_SCOPE, - bindingName, - entry.remoteProxyConnectionString + name: WEBSEARCH_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + entry.remoteProxyConnectionString, + bindingName ), }, }); @@ -61,19 +62,10 @@ export const WEBSEARCH_PLUGIN: Plugin = { worker: ReturnType; }[] = []; - for (const [bindingName, entry] of Object.entries( - options.websearch ?? {} - )) { + if (Object.keys(options.websearch ?? {}).length > 0) { services.push({ - name: getUserBindingServiceName( - WEBSEARCH_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), + name: WEBSEARCH_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/shared/dev-control.ts b/packages/miniflare/src/shared/dev-control.ts index d52c67df1d..da53498f13 100644 --- a/packages/miniflare/src/shared/dev-control.ts +++ b/packages/miniflare/src/shared/dev-control.ts @@ -1,3 +1,5 @@ +import type { SqlStorage, SqlStorageValue } from "@cloudflare/workers-types"; + export type DurableObjectIdentifier = | { name: string; id?: never } | { id: string; name?: never }; @@ -6,12 +8,52 @@ export type DurableObjectEvictionOptions = DurableObjectIdentifier & { webSockets?: "close" | "hibernate"; }; +export type DurableObjectStorageOptions = DurableObjectIdentifier; + +export type DurableObjectStorageHandle = { + exec< + Row extends Record = Record< + string, + SqlStorageValue + >, + >( + ...args: Parameters + ): Promise; +}; + +export type DurableObjectStorageOperationOptions = DurableObjectIdentifier & { + query: string; + bindings: unknown[]; +}; + export interface DevControl { evictDurableObject( scriptName: string, className: string, options: DurableObjectEvictionOptions ): Promise; + execDurableObjectSql>( + scriptName: string, + className: string, + options: DurableObjectStorageOperationOptions + ): Promise; +} + +export function createDurableObjectStorageHandle( + control: DevControl, + scriptName: string, + className: string, + options: DurableObjectStorageOptions +): DurableObjectStorageHandle { + return { + async exec(query, ...bindings) { + return control.execDurableObjectSql(scriptName, className, { + ...options, + query, + bindings, + }); + }, + }; } export function getDevControlDurableObjectBindingName( diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index ddb02e5d8a..79a397917c 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -39,6 +39,13 @@ export const CoreHeaders = { PROXY_SHARED_SECRET: "MF-Proxy-Shared-Secret", DISABLE_PRETTY_ERROR: "MF-Disable-Pretty-Error", ERROR_STACK: "MF-Experimental-Error-Stack", + /** + * The serialised error, URI-encoded. `workerd` drops response bodies for + * `HEAD` requests, so the body alone cannot carry the error out of the user + * Worker. Producers set this in addition to the body; consumers fall back to + * it whenever the body is unavailable. + */ + ERROR_STACK_PAYLOAD: "MF-Experimental-Error-Stack-Payload", ROUTE_OVERRIDE: "MF-Route-Override", CF_BLOB: "MF-CF-Blob", /** Used by the Vite plugin to pass through the original `sec-fetch-mode` header */ @@ -109,6 +116,26 @@ export const ProxyAddresses = { USER_START: 2, } as const; +/** + * Recovers the serialised error a Worker put in `ERROR_STACK_PAYLOAD`, for the + * cases where the response body carrying it has been dropped (`HEAD` requests). + * Returns `null` when the header is absent or malformed, so callers can fall + * back rather than surfacing a decoding failure as the Worker's error. + */ +export function decodeErrorPayload(response: { + headers: { get(name: string): string | null }; +}): string | null { + const payload = response.headers.get(CoreHeaders.ERROR_STACK_PAYLOAD); + if (payload === null) { + return null; + } + try { + return decodeURIComponent(payload); + } catch { + return null; + } +} + // ### Proxy Special Cases // The proxy supports serialising `Request`/`Response`s for the Cache API. It // doesn't support serialising `WebSocket`s though. Rather than attempting this, diff --git a/packages/miniflare/src/workers/core/dev-control.worker.ts b/packages/miniflare/src/workers/core/dev-control.worker.ts index d4cee67c65..5a069988a8 100644 --- a/packages/miniflare/src/workers/core/dev-control.worker.ts +++ b/packages/miniflare/src/workers/core/dev-control.worker.ts @@ -1,31 +1,63 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import workerdUnsafe from "workerd:unsafe"; -import { - type DevControl as DevControlInterface, - type DurableObjectEvictionOptions, - getDevControlDurableObjectBindingName, +import { INTROSPECT_SQLITE_METHOD } from "../../plugins/core/constants"; +import { getDevControlDurableObjectBindingName } from "../../shared/dev-control"; +import type { IntrospectSqliteMethod } from "../../plugins/core/constants"; +import type { + DevControl as DevControlInterface, + DurableObjectIdentifier, + DurableObjectEvictionOptions, + DurableObjectStorageOperationOptions, } from "../../shared/dev-control"; -import type { DurableObjectNamespace } from "@cloudflare/workers-types/experimental"; +import type { + DurableObjectNamespace, + DurableObjectStub, + SqlStorageValue, +} from "@cloudflare/workers-types/experimental"; -function getDurableObjectNamespace( +type IntrospectableDurableObjectStub = DurableObjectStub & { + [INTROSPECT_SQLITE_METHOD]: IntrospectSqliteMethod; +}; + +function isDurableObjectNamespace( + binding: unknown +): binding is DurableObjectNamespace { + return ( + typeof binding === "object" && + binding !== null && + "idFromName" in binding && + typeof binding.idFromName === "function" && + "idFromString" in binding && + typeof binding.idFromString === "function" && + "get" in binding && + typeof binding.get === "function" + ); +} + +function getDurableObjectStub( env: Record, - bindingName: string -): DurableObjectNamespace | null { + scriptName: string, + className: string, + identifier: DurableObjectIdentifier +): IntrospectableDurableObjectStub { + const bindingName = getDevControlDurableObjectBindingName( + scriptName, + className + ); const namespace = env[bindingName]; - if ( - !(typeof namespace === "object" && namespace !== null) || - !("idFromName" in namespace) || - typeof namespace.idFromName !== "function" || - !("idFromString" in namespace) || - typeof namespace.idFromString !== "function" || - !("get" in namespace) || - typeof namespace.get !== "function" - ) { - return null; + if (!isDurableObjectNamespace(namespace)) { + throw new TypeError( + `Expected Durable Object namespace binding for ${scriptName}:${className}` + ); } - return namespace as DurableObjectNamespace; + const id = + identifier.id === undefined + ? namespace.idFromName(identifier.name) + : namespace.idFromString(identifier.id); + + return namespace.get(id) as IntrospectableDurableObjectStub; } export default class DevControl @@ -37,25 +69,36 @@ export default class DevControl className: string, options: DurableObjectEvictionOptions ): Promise { - const doNamespace = getDurableObjectNamespace( - this.env, - getDevControlDurableObjectBindingName(scriptName, className) - ); - - if (!doNamespace) { - throw new TypeError( - `Expected Durable Object namespace binding for ${scriptName}:${className}` - ); - } - - const id = - options.id === undefined - ? doNamespace.idFromName(options.name) - : doNamespace.idFromString(options.id); - const stub = doNamespace.get(id); + const stub = getDurableObjectStub(this.env, scriptName, className, options); await workerdUnsafe.evict(stub, { webSockets: options.webSockets, }); } + + async execDurableObjectSql>( + scriptName: string, + className: string, + options: DurableObjectStorageOperationOptions + ): Promise { + const stub = getDurableObjectStub(this.env, scriptName, className, options); + + const [result] = await stub[INTROSPECT_SQLITE_METHOD]([ + { sql: options.query, params: options.bindings }, + ]); + + if (result === undefined) { + throw new Error("Durable Object SQLite query did not return a result."); + } + + const columns = result.columns ?? []; + const rawRows = result.rows ?? []; + const rows = rawRows.map((rawRow) => { + return Object.fromEntries( + columns.map((column, index) => [column, rawRow[index]]) + ) as Row; + }); + + return rows; + } } diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index 013de035eb..bcea7b8207 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -1,7 +1,12 @@ import { blue, bold, green, grey, red, reset, yellow } from "kleur/colors"; import { HttpError, LogLevel, SharedHeaders } from "miniflare:shared"; import { isCompressedByCloudflareFL } from "../../shared/mime-types"; -import { CoreBindings, CoreHeaders, CorePaths } from "./constants"; +import { + CoreBindings, + CoreHeaders, + CorePaths, + decodeErrorPayload, +} from "./constants"; import { handleEmail } from "./email"; import { STATUS_CODES } from "./http"; import { matchRoutes } from "./routing"; @@ -269,12 +274,21 @@ function maybePrettifyError(request: Request, response: Response, env: Env) { return response; } + // `workerd` drops response bodies for `HEAD` requests, so fall back to the + // header copy of the serialised error. Without a payload there is nothing to + // prettify, and POSTing an empty body would surface a JSON parse error from + // miniflare's internals instead of the user's error. + const body = response.body ?? decodeErrorPayload(response); + if (body === null) { + return response; + } + return env[CoreBindings.SERVICE_LOOPBACK].fetch( "http://localhost/core/error", { method: "POST", headers: request.headers, - body: response.body, + body, cf: { prettyErrorOriginalUrl: request.url }, } ); diff --git a/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts b/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts index 80fdc81db8..fa5faeb250 100644 --- a/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts +++ b/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts @@ -4,21 +4,27 @@ import { makeRemoteProxyStub, throwRemoteRequired, } from "../shared/remote-bindings-utils"; -import type { RemoteBindingEnv } from "../shared/remote-bindings-utils"; +import type { + RemoteBindingEnv, + RemoteBindingProps, +} from "../shared/remote-bindings-utils"; /** Proxy client for dispatch namespace bindings. */ -export default class DispatchNamespaceProxy extends WorkerEntrypoint { +export default class DispatchNamespaceProxy extends WorkerEntrypoint< + RemoteBindingEnv, + RemoteBindingProps +> { get( name: string, args?: { [key: string]: unknown }, options?: DynamicDispatchOptions ): Fetcher { - if (!this.env.remoteProxyConnectionString) { - throwRemoteRequired(this.env.binding); + if (!this.ctx.props.remoteProxyConnectionString) { + throwRemoteRequired(this.ctx.props.binding); } return makeRemoteProxyStub( - this.env.remoteProxyConnectionString, - this.env.binding, + this.ctx.props.remoteProxyConnectionString, + this.ctx.props.binding, { "MF-Dispatch-Namespace-Options": JSON.stringify({ name, @@ -26,7 +32,7 @@ export default class DispatchNamespaceProxy extends WorkerEntrypoint>{ - async fetch(request, env) { - const name = env[SharedBindings.TEXT_NAMESPACE]; +export default >{ + async fetch(request, env, ctx) { + // Prefer the namespace passed at runtime via `ctx.props` (props-based + // model: one entry service serves many namespaces). Fall back to the + // static binding for callers that still bake the namespace in. + const name = + ctx.props[SharedBindings.TEXT_NAMESPACE] ?? + env[SharedBindings.TEXT_NAMESPACE]; + if (name === undefined) { + throw new Error( + "object-entry worker: no namespace provided via props or binding" + ); + } const objectNamespace = env[SharedBindings.DURABLE_OBJECT_NAMESPACE_OBJECT]; const id = objectNamespace.idFromName(name); const stub = objectNamespace.get(id); diff --git a/packages/miniflare/src/workers/shared/remote-bindings-utils.ts b/packages/miniflare/src/workers/shared/remote-bindings-utils.ts index 7034bb3be3..fcf19a22d8 100644 --- a/packages/miniflare/src/workers/shared/remote-bindings-utils.ts +++ b/packages/miniflare/src/workers/shared/remote-bindings-utils.ts @@ -2,18 +2,27 @@ import { newWebSocketRpcSession } from "capnweb"; import type { SharedBindings } from "./constants"; /** - * Common environment type for remote binding workers. + * Common environment type for remote binding workers. The loopback service is + * the only binding still passed via env (services can't travel through props); + * the per-binding fields now arrive via `ctx.props` (see `RemoteBindingProps`). */ export type RemoteBindingEnv = { - remoteProxyConnectionString?: string; - binding: string; - cfTraceId?: string; // Optional loopback service used to surface diagnostics back to the // Miniflare host (e.g. a Cloudflare Access block detected on the response // from the remote-bindings proxy server). [SharedBindings.MAYBE_SERVICE_LOOPBACK]?: Fetcher; }; +/** + * Per-binding configuration supplied at runtime via `ctx.props`. This is what + * lets a single remote-proxy client service serve many bindings. + */ +export type RemoteBindingProps = { + remoteProxyConnectionString?: string; + binding: string; + cfTraceId?: string; +}; + /** Headers sent alongside proxy requests to provide additional context. */ export type ProxyMetadata = { "MF-Dispatch-Namespace-Options"?: string; diff --git a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts index 2a7dbb67ff..a4090913d8 100644 --- a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts +++ b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts @@ -5,29 +5,38 @@ import { makeRemoteProxyStub, throwRemoteRequired, } from "./remote-bindings-utils"; -import type { RemoteBindingEnv } from "./remote-bindings-utils"; +import type { + RemoteBindingEnv, + RemoteBindingProps, +} from "./remote-bindings-utils"; /** Generic remote proxy client for bindings. */ -export default class Client extends WorkerEntrypoint { +export default class Client extends WorkerEntrypoint< + RemoteBindingEnv, + RemoteBindingProps +> { fetch(request: Request): Promise { return makeFetch( - this.env.remoteProxyConnectionString, - this.env.binding, + this.ctx.props.remoteProxyConnectionString, + this.ctx.props.binding, undefined, - this.env.cfTraceId, + this.ctx.props.cfTraceId, this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK] )(request); } - constructor(ctx: ExecutionContext, env: RemoteBindingEnv) { + constructor( + ctx: ExecutionContext, + env: RemoteBindingEnv + ) { super(ctx, env); - const stub = env.remoteProxyConnectionString + const stub = ctx.props.remoteProxyConnectionString ? makeRemoteProxyStub( - env.remoteProxyConnectionString, - env.binding, + ctx.props.remoteProxyConnectionString, + ctx.props.binding, undefined, - env.cfTraceId, + ctx.props.cfTraceId, env[SharedBindings.MAYBE_SERVICE_LOOPBACK] ) : undefined; @@ -38,7 +47,7 @@ export default class Client extends WorkerEntrypoint { return Reflect.get(target, prop); } if (!stub) { - throwRemoteRequired(env.binding); + throwRemoteRequired(ctx.props.binding); } return Reflect.get(stub, prop); }, diff --git a/packages/miniflare/test/plugins/browser/process.spec.ts b/packages/miniflare/test/plugins/browser/process.spec.ts new file mode 100644 index 0000000000..bd69bd3b1f --- /dev/null +++ b/packages/miniflare/test/plugins/browser/process.spec.ts @@ -0,0 +1,63 @@ +import { once } from "node:events"; +import { onTestFinished, test, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import { closeBrowserProcess } from "../../../src/plugins/browser-rendering/process"; + +test("gracefully closes Chrome over CDP", async ({ expect }) => { + const closed = Promise.withResolvers(); + const browserProcess = { + hasClosed: vi.fn(() => closed.promise), + kill: vi.fn(), + }; + const server = new WebSocketServer({ port: 0 }); + onTestFinished( + () => new Promise((resolve) => server.close(() => resolve())) + ); + await once(server, "listening"); + const address = server.address(); + if (typeof address === "string" || address === null) { + throw new Error("Expected WebSocket server to listen on a TCP port"); + } + server.on("connection", (socket) => { + socket.once("message", (data) => { + expect(JSON.parse(data.toString())).toEqual({ + id: 1, + method: "Browser.close", + }); + closed.resolve(); + }); + }); + + await closeBrowserProcess( + browserProcess, + `ws://127.0.0.1:${address.port}`, + 100 + ); + + expect(browserProcess.kill).not.toHaveBeenCalled(); +}); + +test("force kills Chrome when graceful close times out", async ({ expect }) => { + const closed = Promise.withResolvers(); + const browserProcess = { + hasClosed: vi.fn(() => closed.promise), + kill: vi.fn(), + }; + const server = new WebSocketServer({ port: 0 }); + onTestFinished( + () => new Promise((resolve) => server.close(() => resolve())) + ); + await once(server, "listening"); + const address = server.address(); + if (typeof address === "string" || address === null) { + throw new Error("Expected WebSocket server to listen on a TCP port"); + } + + await closeBrowserProcess( + browserProcess, + `ws://127.0.0.1:${address.port}`, + 10 + ); + + expect(browserProcess.kill).toHaveBeenCalledOnce(); +}); diff --git a/packages/miniflare/test/plugins/core/errors/index.spec.ts b/packages/miniflare/test/plugins/core/errors/index.spec.ts index 7064276c4a..d988fd586f 100644 --- a/packages/miniflare/test/plugins/core/errors/index.spec.ts +++ b/packages/miniflare/test/plugins/core/errors/index.spec.ts @@ -496,9 +496,14 @@ export default { try { throw new Error("Unusual oops!"); } catch (e) { - return Response.json(reduceError(e), { + const body = JSON.stringify(reduceError(e)); + return new Response(body, { status: 500, - headers: { "MF-Experimental-Error-Stack": "true" }, + headers: { + "Content-Type": "application/json", + "MF-Experimental-Error-Stack": "true", + "MF-Experimental-Error-Stack-Payload": encodeURIComponent(body), + }, }); } }, @@ -527,6 +532,77 @@ test("invokes handleUncaughtError with the revived error", async ({ expect(errors[0].stack).toMatch(/Object\.fetch/); }); +// `workerd` drops response bodies for `HEAD` requests, so the serialised error +// cannot be recovered from the body alone — it is carried in a header too. +test("reports the revived error for HEAD requests", async ({ expect }) => { + const log = new CustomLog(); + const errors: Error[] = []; + const mf = new Miniflare({ + log, + modules: true, + handleUncaughtError(error) { + errors.push(error); + }, + script: JSON_ERROR_SCRIPT, + }); + useDispose(mf); + + const res = await fetch(await mf.ready, { method: "HEAD" }); + expect(res.status).toBe(500); + expect(errors.length).toBe(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toBe("Unusual oops!"); + expect(errors[0].stack).toMatch(/Object\.fetch/); +}); + +test("rejects HEAD dispatchFetch with the user error, not a parse error", async ({ + expect, +}) => { + const mf = new Miniflare({ modules: true, script: JSON_ERROR_SCRIPT }); + useDispose(mf); + + await expect( + mf.dispatchFetch(await mf.ready, { method: "HEAD" }) + ).rejects.toThrow("Unusual oops!"); +}); + +// A stack too large to fit in a header is sent without the payload copy, so a +// `HEAD` request has no way to recover it. Reporting must still degrade to a +// plain error rather than leaking a JSON parse failure from miniflare. +const NO_PAYLOAD_HEADER_SCRIPT = ` +export default { + async fetch() { + return new Response(JSON.stringify({ name: "Error", message: "Unusual oops!" }), { + status: 500, + headers: { + "Content-Type": "application/json", + "MF-Experimental-Error-Stack": "true", + }, + }); + }, +}`; + +test("degrades without leaking a parse error when HEAD has no payload header", async ({ + expect, +}) => { + const mf = new Miniflare({ + modules: true, + script: NO_PAYLOAD_HEADER_SCRIPT, + }); + useDispose(mf); + const url = await mf.ready; + + const res = await fetch(url, { method: "HEAD" }); + expect(res.status).toBe(500); + + await expect(mf.dispatchFetch(url, { method: "HEAD" })).rejects.toThrow( + "Worker threw an uncaught exception" + ); + + // The GET path still recovers the error from the body + await expect(mf.dispatchFetch(url)).rejects.toThrow("Unusual oops!"); +}); + // A stack frame may name a `file://` URL that `fileURLToPath` cannot convert // to a local path. No single URL is invalid everywhere — a drive-less // `file:///...` (every frame a POSIX-built bundle reports) throws on Windows diff --git a/packages/miniflare/test/plugins/do/index.spec.ts b/packages/miniflare/test/plugins/do/index.spec.ts index bd60eab368..049b06e7d9 100644 --- a/packages/miniflare/test/plugins/do/index.spec.ts +++ b/packages/miniflare/test/plugins/do/index.spec.ts @@ -470,6 +470,96 @@ test("SQLite is available in SQLite backed Durable Objects", async ({ expect(await res.text()).toBe("4096"); }); +test("gets SQLite storage for Durable Objects", async ({ expect }) => { + const mf = new Miniflare({ + unsafeInspectDurableObjects: true, + modules: true, + name: "worker", + script: ` + import { DurableObject } from "cloudflare:workers"; + + export class TestObject extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + this.ctx.storage.sql.exec( + "CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, value TEXT)" + ); + } + + fetch(request) { + const url = new URL(request.url); + const sql = this.ctx.storage.sql; + + if (url.pathname === "/write") { + sql.exec( + "INSERT OR REPLACE INTO entries (id, value) VALUES ('key', ?)", + url.searchParams.get("value") + ); + return new Response("ok"); + } + + const row = sql.exec("SELECT value FROM entries WHERE id = 'key'").one(); + return new Response(row?.value ?? "missing"); + } + } + + export default { + fetch(request, env) { + const name = new URL(request.url).searchParams.get("name") ?? "by-name"; + const id = env.OBJECT.idFromName(name); + return env.OBJECT.get(id).fetch(request); + } + } + `, + durableObjects: { + OBJECT: { + className: "TestObject", + useSQLite: true, + }, + }, + }); + useDispose(mf); + + const storageByName = await mf.unsafeGetDurableObjectStorage( + "worker", + "TestObject", + { + name: "by-name", + } + ); + await storageByName.exec( + "INSERT INTO entries (id, value) VALUES ('key', ?)", + "seeded" + ); + + const response1 = await mf.dispatchFetch("http://localhost/read"); + expect(await response1.text()).toBe("seeded"); + + const response2 = await mf.dispatchFetch("http://localhost/write?value=app"); + expect(await response2.text()).toBe("ok"); + + const rows = await storageByName.exec<{ value: string }>( + "SELECT value FROM entries WHERE id = 'key'" + ); + expect(rows).toEqual([{ value: "app" }]); + + const namespace = await mf.getDurableObjectNamespace("OBJECT"); + const storageById = await mf.unsafeGetDurableObjectStorage( + "worker", + "TestObject", + { + id: namespace.idFromName("by-id").toString(), + } + ); + await storageById.exec( + "INSERT INTO entries (id, value) VALUES ('key', ?)", + "seeded-by-id" + ); + + const response3 = await mf.dispatchFetch("http://localhost/read?name=by-id"); + expect(await response3.text()).toBe("seeded-by-id"); +}); + test("SQLite is not available in default Durable Objects", async ({ expect, }) => { diff --git a/packages/miniflare/test/plugins/email/index.spec.ts b/packages/miniflare/test/plugins/email/index.spec.ts index 0e3b7672c5..e89eca93e3 100644 --- a/packages/miniflare/test/plugins/email/index.spec.ts +++ b/packages/miniflare/test/plugins/email/index.spec.ts @@ -1,5 +1,5 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import fs, { existsSync } from "node:fs"; +import { mkdir, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { EMAIL_PLUGIN, @@ -2073,6 +2073,43 @@ test("send_email binding is available from getBindings", async ({ expect }) => { }); }); +test("disposing does not remove a concurrent email session", async ({ + expect, +}) => { + const projectTmpPath = await useProjectTmpPath(); + const mf = new Miniflare({ + modules: true, + script: "", + email: { + send_email: [{ name: "SEND_EMAIL" }], + }, + defaultProjectTmpPath: projectTmpPath, + compatibilityDate: "2025-03-17", + }); + + await mf.getBindings(); + + const emailParentPath = path.join(projectTmpPath, "email"); + const [sessionName] = await readdir(emailParentPath); + if (sessionName === undefined) { + throw new Error("Expected an email session directory"); + } + const concurrentSessionPath = path.join( + emailParentPath, + "concurrent-session" + ); + await mkdir(concurrentSessionPath); + + // A separate emptiness check reintroduces the race. Return a stale result so + // regressing to read-then-remove would delete the concurrent session. + const readdirSpy = vi.spyOn(fs.promises, "readdir").mockResolvedValueOnce([]); + + await mf.dispose(); + + expect(readdirSpy).not.toHaveBeenCalled(); + expect(existsSync(concurrentSessionPath)).toBe(true); +}); + describe("EMAIL_PLUGIN.getServices", () => { test("creates disk services for system temp and project directories", async ({ expect, diff --git a/packages/miniflare/test/plugins/images/transform.spec.ts b/packages/miniflare/test/plugins/images/transform.spec.ts new file mode 100644 index 0000000000..baf16404a0 --- /dev/null +++ b/packages/miniflare/test/plugins/images/transform.spec.ts @@ -0,0 +1,183 @@ +import { Miniflare } from "miniflare"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import type { MiniflareOptions } from "miniflare"; + +// A worker that runs env.IMAGES.input(...).transform(...).output(...) with +// the transform/format supplied via query params, mirroring the production +// Images binding transform pattern. +const WORKER_SCRIPT = ` +export default { + async fetch(request, env) { + const url = new URL(request.url); + const transform = JSON.parse(url.searchParams.get("transform") || "{}"); + const format = url.searchParams.get("format") || "image/png"; + const result = await env.IMAGES.input(request.body) + .transform(transform) + .output({ format }); + return result.response(); + }, +}; +`; + +describe("Images binding local transforms", () => { + let mf: Miniflare; + // 200x100, top half white, bottom half red - lets gravity-dependent crops + // be distinguished by sampling a corner pixel. + let sourcePng: Buffer; + + beforeAll(async () => { + const top = await sharp({ + create: { + width: 200, + height: 50, + channels: 3, + background: { r: 255, g: 255, b: 255 }, + }, + }) + .png() + .toBuffer(); + const bottom = await sharp({ + create: { + width: 200, + height: 50, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + sourcePng = await sharp({ + create: { width: 200, height: 100, channels: 3, background: "#000000" }, + }) + .composite([ + { input: top, top: 0, left: 0 }, + { input: bottom, top: 50, left: 0 }, + ]) + .png() + .toBuffer(); + + mf = new Miniflare({ + compatibilityDate: "2025-04-01", + modules: true, + script: WORKER_SCRIPT, + images: { binding: "IMAGES" }, + imagesPersist: false, + } satisfies MiniflareOptions); + }); + + afterAll(() => mf.dispose()); + + async function transform( + transformOpts: Record, + format = "image/png" + ) { + const params = new URLSearchParams({ + transform: JSON.stringify(transformOpts), + format, + }); + const res = await mf.dispatchFetch(`http://localhost/?${params}`, { + method: "POST", + body: sourcePng, + }); + const body = Buffer.from(await res.arrayBuffer()); + return { res, body }; + } + + async function pixelAt(body: Buffer, x: number, y: number) { + const { data, info } = await sharp(body) + .raw() + .toBuffer({ resolveWithObject: true }); + const offset = (y * info.width + x) * info.channels; + return { + r: data[offset], + g: data[offset + 1], + b: data[offset + 2], + }; + } + + test("fit:cover resizes to exact dimensions", async ({ expect }) => { + const { body } = await transform({ width: 50, height: 50, fit: "cover" }); + const meta = await sharp(body).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(50); + }); + + test("default fit (unspecified) does not letterbox with black bars", async ({ + expect, + }) => { + // Before the fix this hardcoded sharp's fit:"contain" (letterbox to the + // exact requested box), padding the extra space with black. With a + // production-matching default the output instead shrinks to fit within + // the box, preserving aspect ratio, with no padded dimensions at all. + const { body } = await transform({ width: 50, height: 50 }); + const meta = await sharp(body).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(25); + }); + + test("gravity:top crops from the top of the image", async ({ expect }) => { + // 200x20 (10:1, scale=1 - a pure crop with no resampling) keeps rows + // 0-19 of the source, solidly inside the white top half. 50x25 (same + // 2:1 aspect as the source) needs no crop at all and makes gravity a + // no-op; 100x25 crops but lands the sampled row exactly on the + // white/red seam, blurring the two colors together via resampling. + const { body } = await transform({ + width: 200, + height: 20, + fit: "cover", + gravity: "top", + }); + const { r, g, b } = await pixelAt(body, 0, 0); + // Top half of the source is white. + expect([r, g, b]).toEqual([255, 255, 255]); + }); + + test("gravity:bottom crops from the bottom of the image", async ({ + expect, + }) => { + // See gravity:top above - 200x20 keeps rows 80-99, solidly inside + // the red bottom half, avoiding both the no-op (50x25) and the + // seam-blur (100x25) problems. + const { body } = await transform({ + width: 200, + height: 20, + fit: "cover", + gravity: "bottom", + }); + const { r, g, b } = await pixelAt(body, 0, 0); + // Bottom half of the source is red. + expect([r, g, b]).toEqual([255, 0, 0]); + }); + + test("fit:pad fills the padded area with the requested background", async ({ + expect, + }) => { + const { body } = await transform({ + width: 100, + height: 100, + fit: "pad", + background: "#00ff00", + }); + const meta = await sharp(body).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(100); + // Top-left corner falls in the padded area for a 200x100 source + // padded out to a 100x100 square - should be green, not black. + const { r, g, b } = await pixelAt(body, 0, 0); + expect([r, g, b]).toEqual([0, 255, 0]); + }); + + test("fit:pad without an explicit background defaults to white", async ({ + expect, + }) => { + const { body } = await transform({ + width: 100, + height: 100, + fit: "pad", + }); + const { r, g, b } = await pixelAt(body, 0, 0); + expect([r, g, b]).toEqual([255, 255, 255]); + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/binding-map.spec.ts b/packages/miniflare/test/plugins/local-explorer/binding-map.spec.ts new file mode 100644 index 0000000000..1ebf8b24f5 --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/binding-map.spec.ts @@ -0,0 +1,89 @@ +import { Miniflare } from "miniflare"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { CorePaths } from "../../../src/workers/core/constants"; +import { disposeWithRetry } from "../../test-shared"; +import type { RemoteProxyConnectionString } from "miniflare"; + +const BASE_URL = `http://localhost${CorePaths.EXPLORER}/api`; + +// A dummy remote-proxy endpoint. Listing resources reads only the binding map, +// so this is never actually contacted. +const remoteProxyConnectionString = new URL( + "http://127.0.0.1:9999/" +) as unknown as RemoteProxyConnectionString; + +// Remote KV/R2/D1 bindings all share one proxy service (`kv:ns:remote`, +// `r2:bucket:remote`, `d1:db:remote`). Remote resources aren't supported in the +// local explorer, so they must be skipped rather than collapse to the literal id +// "remote" (a collision that would also leak a bogus "remote" entry). Local +// resources alongside them must still be surfaced. +describe("Local Explorer remote binding skipping", () => { + let mf: Miniflare; + + beforeAll(async () => { + mf = new Miniflare({ + inspectorPort: 0, + compatibilityDate: "2025-01-01", + modules: true, + script: `export default { fetch() { return new Response("user worker"); } }`, + unsafeLocalExplorer: true, + kvNamespaces: { + LOCAL_KV: "kv-local", + REMOTE_KV_A: { id: "kv-a", remoteProxyConnectionString }, + REMOTE_KV_B: { id: "kv-b", remoteProxyConnectionString }, + }, + r2Buckets: { + LOCAL_R2: "r2-local", + REMOTE_R2_A: { id: "r2-a", remoteProxyConnectionString }, + REMOTE_R2_B: { id: "r2-b", remoteProxyConnectionString }, + }, + d1Databases: { + LOCAL_D1: "d1-local", + REMOTE_D1_A: { id: "d1-a", remoteProxyConnectionString }, + REMOTE_D1_B: { id: "d1-b", remoteProxyConnectionString }, + }, + }); + }); + + afterAll(async () => { + await disposeWithRetry(mf); + }); + + test("skips remote KV namespaces, surfacing only local ones", async ({ + expect, + }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/storage/kv/namespaces` + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: Array<{ id: string }>; + }; + const ids = body.result.map((ns) => ns.id).sort(); + expect(ids).toEqual(["kv-local"]); + }); + + test("skips remote R2 buckets, surfacing only local ones", async ({ + expect, + }) => { + const response = await mf.dispatchFetch(`${BASE_URL}/r2/buckets`); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: { buckets: Array<{ name: string }> }; + }; + const names = body.result.buckets.map((b) => b.name).sort(); + expect(names).toEqual(["r2-local"]); + }); + + test("skips remote D1 databases, surfacing only local ones", async ({ + expect, + }) => { + const response = await mf.dispatchFetch(`${BASE_URL}/d1/database`); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: Array<{ uuid: string }>; + }; + const uuids = body.result.map((db) => db.uuid).sort(); + expect(uuids).toEqual(["d1-local"]); + }); +}); diff --git a/packages/pages-shared/CHANGELOG.md b/packages/pages-shared/CHANGELOG.md index e41b1e3905..29663bdf8e 100644 --- a/packages/pages-shared/CHANGELOG.md +++ b/packages/pages-shared/CHANGELOG.md @@ -1,5 +1,19 @@ # @cloudflare/pages-shared +## 0.13.159 + +### Patch Changes + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9)]: + - miniflare@4.20260721.0 + +## 0.13.158 + +### Patch Changes + +- Updated dependencies [[`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983), [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d), [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c), [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8), [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645)]: + - miniflare@4.20260714.0 + ## 0.13.157 ### Patch Changes diff --git a/packages/pages-shared/package.json b/packages/pages-shared/package.json index bb1e0b5f42..7a8cf66ff6 100644 --- a/packages/pages-shared/package.json +++ b/packages/pages-shared/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/pages-shared", - "version": "0.13.157", + "version": "0.13.159", "repository": { "type": "git", "url": "https://github.com/cloudflare/workers-sdk.git", diff --git a/packages/playground-preview-worker/CHANGELOG.md b/packages/playground-preview-worker/CHANGELOG.md index c057e69a0d..f85cbbb44e 100644 --- a/packages/playground-preview-worker/CHANGELOG.md +++ b/packages/playground-preview-worker/CHANGELOG.md @@ -1,5 +1,11 @@ # playground-preview-worker +## 0.3.3 + +### Patch Changes + +- [#14707](https://github.com/cloudflare/workers-sdk/pull/14707) [`b38f494`](https://github.com/cloudflare/workers-sdk/commit/b38f494204e5e08e561b8f198ef928188e554868) Thanks [@emily-shen](https://github.com/emily-shen)! - Update zod to v4 + ## 0.3.2 ### Patch Changes diff --git a/packages/playground-preview-worker/package.json b/packages/playground-preview-worker/package.json index 7f244151ca..6c7f87a787 100644 --- a/packages/playground-preview-worker/package.json +++ b/packages/playground-preview-worker/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/playground-preview-worker", - "version": "0.3.2", + "version": "0.3.3", "private": true, "scripts": { "build-middleware": "pnpm run build-middleware:common && pnpm run build-middleware:loader", @@ -14,7 +14,7 @@ }, "dependencies": { "hono": "^4.12.5", - "zod": "^3.22.3" + "zod": "catalog:default" }, "devDependencies": { "@cloudflare/workers-types": "catalog:default", diff --git a/packages/playground-preview-worker/src/errors.ts b/packages/playground-preview-worker/src/errors.ts index baa1507d2d..bcca2d08a6 100644 --- a/packages/playground-preview-worker/src/errors.ts +++ b/packages/playground-preview-worker/src/errors.ts @@ -1,4 +1,4 @@ -import type { ZodIssue } from "zod"; +import type { z } from "zod"; export class HttpError extends Error { constructor( @@ -55,7 +55,7 @@ export class ServiceWorkerNotSupported extends HttpError { } export class ZodSchemaError extends HttpError { name = "ZodSchemaError"; - constructor(private issues: ZodIssue[]) { + constructor(private issues: z.core.$ZodIssue[]) { super("Something went wrong", 500, true); } diff --git a/packages/playground-preview-worker/src/index.ts b/packages/playground-preview-worker/src/index.ts index f063c03a4e..171238fddd 100644 --- a/packages/playground-preview-worker/src/index.ts +++ b/packages/playground-preview-worker/src/index.ts @@ -12,6 +12,29 @@ import { import { handleException, setupSentry } from "./sentry"; import type { Toucan } from "toucan-js"; +declare const ROOT: string; +declare const PREVIEW: string; + +async function pushMetrics(env: Env, metrics: string) { + try { + const response = await env.WSHIM_SOCKET.fetch( + "https://workers-logging.cfdata.org/prometheus", + { + method: "POST", + headers: { + Authorization: `Bearer ${env.PROMETHEUS_TOKEN}`, + }, + body: metrics, + } + ); + if (!response.ok) { + console.error(`Failed to push metrics: ${response.status}`); + } + } catch (error) { + console.error("Failed to push metrics", error); + } +} + function maybeParseUrl(url: string | undefined) { if (!url) { return undefined; @@ -128,15 +151,7 @@ app.use("*", async (c, next) => { try { return await next(); } finally { - c.executionCtx.waitUntil( - fetch("https://workers-logging.cfdata.org/prometheus", { - method: "POST", - headers: { - Authorization: `Bearer ${c.env.PROMETHEUS_TOKEN}`, - }, - body: registry.metrics(), - }) - ); + c.executionCtx.waitUntil(pushMetrics(c.env, registry.metrics())); } }); diff --git a/packages/playground-preview-worker/src/realish.ts b/packages/playground-preview-worker/src/realish.ts index e7b9d46e3b..6b5cf02ad0 100644 --- a/packages/playground-preview-worker/src/realish.ts +++ b/packages/playground-preview-worker/src/realish.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { PreviewError } from "./errors"; -const APIResponse = (resultSchema: T) => +const APIResponse = (resultSchema: T) => z.union([ z.object({ success: z.literal(true), diff --git a/packages/playground-preview-worker/src/user.do.ts b/packages/playground-preview-worker/src/user.do.ts index 18922ecf18..b7656e36cf 100644 --- a/packages/playground-preview-worker/src/user.do.ts +++ b/packages/playground-preview-worker/src/user.do.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import z from "zod"; +import { z } from "zod"; import { BadUpload, ServiceWorkerNotSupported, WorkerTimeout } from "./errors"; import { constructMiddleware } from "./inject-middleware"; import { doUpload, setupTokens } from "./realish"; @@ -16,9 +16,9 @@ function switchRemote(url: URL, remote: string) { } const UploadedMetadata = z.object({ - body_part: z.ostring(), - main_module: z.ostring(), - compatibility_date: z.ostring(), + body_part: z.string().optional(), + main_module: z.string().optional(), + compatibility_date: z.string().optional(), compatibility_flags: z.array(z.string()).optional(), }); diff --git a/packages/playground-preview-worker/worker-configuration.d.ts b/packages/playground-preview-worker/worker-configuration.d.ts index d2798b85ed..59e0129852 100644 --- a/packages/playground-preview-worker/worker-configuration.d.ts +++ b/packages/playground-preview-worker/worker-configuration.d.ts @@ -1,20 +1,36 @@ -// Generated by Wrangler on Wed Jun 28 2023 20:56:33 GMT+0100 (British Summer Time) -type Env = { - SENTRY_DSN: string; - UserSession: DurableObjectNamespace; - ACCOUNT_ID: string; - workersDev: string; - // Secrets +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 9985955de831c8659f102de3a745d0f2) +interface __BaseEnv_Env { + ACCOUNT_ID: "f2ba8b5dd703a7dca481f2ea46e19296"; + SENTRY_DSN: "https://0ff0cac6ca8b492d8b551626a7b9670c@sentry10.cfdata.org/978"; + workersDev: "workers-playground-users.workers.dev"; API_TOKEN: string; PROMETHEUS_TOKEN: string; SENTRY_ACCESS_CLIENT_SECRET: string; SENTRY_ACCESS_CLIENT_ID: string; -}; - + UserSession: DurableObjectNamespace; + WSHIM_SOCKET: any; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + durableNamespaces: "UserSession"; + } + interface TestingEnv { + ACCOUNT_ID: "f2ba8b5dd703a7dca481f2ea46e19296"; + SENTRY_DSN: "https://0ff0cac6ca8b492d8b551626a7b9670c@sentry10.cfdata.org/978"; + workersDev: "workers-playground-users.workers.dev"; + API_TOKEN: string; + PROMETHEUS_TOKEN: string; + SENTRY_ACCESS_CLIENT_SECRET: string; + SENTRY_ACCESS_CLIENT_ID: string; + UserSession: DurableObjectNamespace; + WSHIM_SOCKET: any; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} declare module "*.template" { const value: string; export default value; -} - -declare const ROOT: string; -declare const PREVIEW: string; + } \ No newline at end of file diff --git a/packages/playground-preview-worker/wrangler.json b/packages/playground-preview-worker/wrangler.json index 346e07bfff..5baf5d2d93 100644 --- a/packages/playground-preview-worker/wrangler.json +++ b/packages/playground-preview-worker/wrangler.json @@ -14,6 +14,14 @@ "SENTRY_DSN": "https://0ff0cac6ca8b492d8b551626a7b9670c@sentry10.cfdata.org/978", "workersDev": "workers-playground-users.workers.dev" }, + "secrets": { + "required": [ + "API_TOKEN", + "PROMETHEUS_TOKEN", + "SENTRY_ACCESS_CLIENT_SECRET", + "SENTRY_ACCESS_CLIENT_ID" + ] + }, "define": { "ROOT": "'playground/devprod/cloudflare/dev'", @@ -41,6 +49,15 @@ "new_classes": ["UserSession"] } ], + "unsafe": { + "bindings": [ + { + "name": "WSHIM_SOCKET", + "type": "internal_service_http", + "service": "wshim" + } + ] + }, "observability": { "enabled": true @@ -53,6 +70,14 @@ "SENTRY_DSN": "https://0ff0cac6ca8b492d8b551626a7b9670c@sentry10.cfdata.org/978", "workersDev": "workers-playground-users.workers.dev" }, + "secrets": { + "required": [ + "API_TOKEN", + "PROMETHEUS_TOKEN", + "SENTRY_ACCESS_CLIENT_SECRET", + "SENTRY_ACCESS_CLIENT_ID" + ] + }, "define": { "ROOT": "'playground-testing/devprod/cloudflare/dev'", @@ -79,7 +104,16 @@ "tag": "v1", "new_classes": ["UserSession"] } - ] + ], + "unsafe": { + "bindings": [ + { + "name": "WSHIM_SOCKET", + "type": "internal_service_http", + "service": "wshim" + } + ] + } } } } diff --git a/packages/remote-bindings/CHANGELOG.md b/packages/remote-bindings/CHANGELOG.md new file mode 100644 index 0000000000..5e7d2e82b1 --- /dev/null +++ b/packages/remote-bindings/CHANGELOG.md @@ -0,0 +1,12 @@ +# @cloudflare/remote-bindings + +## 0.0.1 + +### Patch Changes + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`a0a091b`](https://github.com/cloudflare/workers-sdk/commit/a0a091b9246c5e10408f57342b3275659c9655e3), [`f03b108`](https://github.com/cloudflare/workers-sdk/commit/f03b10854d983c353fd4f3d6621b5ed716379ba3), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`a6c214f`](https://github.com/cloudflare/workers-sdk/commit/a6c214fb311215b1ed09b273171b7995033fb7d7), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9)]: + - miniflare@4.20260715.0 + - @cloudflare/deploy-helpers@0.6.0 + - @cloudflare/workers-utils@0.28.0 + - @cloudflare/cli-shared-helpers@0.1.16 + - @cloudflare/workers-auth@0.5.1 diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json new file mode 100644 index 0000000000..f70024f2d2 --- /dev/null +++ b/packages/remote-bindings/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cloudflare/remote-bindings", + "version": "0.0.1", + "private": true, + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/remote-bindings#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/remote-bindings" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc", + "dev": "tsdown --watch", + "test:ci": "vitest run --passWithNoTests", + "test:watch": "vitest" + }, + "dependencies": { + "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/deploy-helpers": "workspace:*", + "@cloudflare/workers-auth": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", + "chalk": "catalog:default", + "miniflare": "workspace:*", + "undici": "catalog:default", + "ws": "catalog:default" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/ws": "^8.5.13", + "capnweb": "catalog:default", + "esbuild": "catalog:default", + "tsdown": "0.16.3", + "typescript": "catalog:default", + "vitest": "catalog:default" + } +} diff --git a/packages/remote-bindings/scripts/embed-workers.ts b/packages/remote-bindings/scripts/embed-workers.ts new file mode 100644 index 0000000000..9dbc1d747e --- /dev/null +++ b/packages/remote-bindings/scripts/embed-workers.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { build } from "esbuild"; + +const WORKER_PREFIX = "\0worker:"; +const templatesDir = path.resolve(import.meta.dirname, "../templates"); + +export function embedWorkersPlugin() { + return { + name: "embed-workers", + resolveId(id: string) { + if (!id.startsWith("worker:")) { + return; + } + return `${WORKER_PREFIX}${id.slice("worker:".length)}`; + }, + async load(id: string) { + if (!id.startsWith(WORKER_PREFIX)) { + return; + } + const result = await build({ + entryPoints: [ + path.resolve(templatesDir, `${id.slice(WORKER_PREFIX.length)}.ts`), + ], + platform: "node", + conditions: ["workerd", "worker", "browser"], + format: "esm", + target: "esnext", + bundle: true, + write: false, + external: ["cloudflare:email", "cloudflare:workers"], + }); + const source = result.outputFiles[0]?.text; + if (source === undefined) { + throw new Error(`Failed to bundle Worker ${id}`); + } + return `export default ${JSON.stringify(source)};`; + }, + }; +} diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts new file mode 100644 index 0000000000..f4c5f6a075 --- /dev/null +++ b/packages/remote-bindings/src/auth.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { createRemoteBindingsAuth, getRemoteBindingsAuthHook } from "./auth"; +import type { RemoteBindingsLogger } from "./logger"; +import type { CfAccount } from "@cloudflare/workers-utils"; + +const mocks = vi.hoisted(() => ({ + cfAuth: { + source: "cf", + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("selected-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + }, + wranglerAuth: { + source: "wrangler", + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("selected-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + }, + createCfAuth: vi.fn(), + createWranglerAuth: vi.fn(), + cfProfileStore: { resolve: vi.fn().mockReturnValue({ name: "cf-profile" }) }, + wranglerProfileStore: { + resolve: vi.fn().mockReturnValue({ name: "wrangler-profile" }), + }, + createCfProfileStore: vi.fn(), + createWranglerProfileStore: vi.fn(), +})); + +vi.mock("@cloudflare/workers-auth/cf", () => ({ + createCfAuth: mocks.createCfAuth.mockReturnValue(mocks.cfAuth), + createCfProfileStore: mocks.createCfProfileStore.mockReturnValue( + mocks.cfProfileStore + ), +})); + +vi.mock("@cloudflare/workers-auth/wrangler", () => ({ + createWranglerAuth: mocks.createWranglerAuth.mockReturnValue( + mocks.wranglerAuth + ), + createWranglerProfileStore: mocks.createWranglerProfileStore.mockReturnValue( + mocks.wranglerProfileStore + ), +})); + +const originalCfAuth = process.env.CLOUDFLARE_CF_AUTH; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "log", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + }; +} + +beforeEach(() => { + delete process.env.CLOUDFLARE_CF_AUTH; +}); + +afterEach(() => { + vi.clearAllMocks(); + if (originalCfAuth === undefined) { + delete process.env.CLOUDFLARE_CF_AUTH; + } else { + process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; + } +}); + +describe("createRemoteBindingsAuth", () => { + it("uses Wrangler auth by default", ({ expect }) => { + const result = createRemoteBindingsAuth(createTestLogger()); + + expect(result).toEqual({ auth: mocks.wranglerAuth, useCfAuth: false }); + expect(mocks.createWranglerAuth).toHaveBeenCalledOnce(); + expect(mocks.createCfAuth).not.toHaveBeenCalled(); + }); + + it("uses CF auth when CLOUDFLARE_CF_AUTH is present", ({ expect }) => { + process.env.CLOUDFLARE_CF_AUTH = ""; + + const result = createRemoteBindingsAuth(createTestLogger()); + + expect(result).toEqual({ auth: mocks.cfAuth, useCfAuth: true }); + expect(mocks.createCfAuth).toHaveBeenCalledOnce(); + expect(mocks.createWranglerAuth).not.toHaveBeenCalled(); + }); +}); + +describe("getRemoteBindingsAuthHook", () => { + it("uses provided auth without resolving a profile", ({ expect }) => { + const auth: CfAccount = { + accountId: "provided-account-id", + apiToken: { apiToken: "provided-token" }, + }; + + const result = getRemoteBindingsAuthHook( + auth, + undefined, + undefined, + createTestLogger() + ); + + expect(result).toBe(auth); + expect(mocks.createWranglerProfileStore).not.toHaveBeenCalled(); + }); + + it("allows auth to select an account when none is configured", async ({ + expect, + }) => { + const hook = getRemoteBindingsAuthHook( + undefined, + undefined, + undefined, + createTestLogger() + ); + assert(typeof hook === "function"); + + await expect(hook()).resolves.toEqual({ + accountId: "selected-account-id", + apiToken: { apiToken: "test-token" }, + }); + expect(mocks.wranglerAuth.requireAuth).toHaveBeenCalledWith({}); + }); + + it("uses the configured account when provided", async ({ expect }) => { + const hook = getRemoteBindingsAuthHook( + undefined, + "configured-account-id", + undefined, + createTestLogger() + ); + assert(typeof hook === "function"); + + await hook(); + expect(mocks.wranglerAuth.requireAuth).toHaveBeenCalledWith({ + account_id: "configured-account-id", + }); + }); +}); diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts new file mode 100644 index 0000000000..8e0cb85538 --- /dev/null +++ b/packages/remote-bindings/src/auth.ts @@ -0,0 +1,92 @@ +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { + createCfAuth, + createCfProfileStore, +} from "@cloudflare/workers-auth/cf"; +import { + createWranglerAuth, + createWranglerProfileStore, +} from "@cloudflare/workers-auth/wrangler"; +import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; +import { version as packageVersion } from "../package.json"; +import type { RemoteBindingsLogger } from "./logger"; +import type { AsyncHook, CfAccount, Config } from "@cloudflare/workers-utils"; + +class NoDefaultValueProvided extends UserError { + constructor() { + super("This command cannot be run in a non-interactive context", { + telemetryMessage: "remote bindings prompt default missing", + }); + } +} + +export function createRemoteBindingsAuth(logger: RemoteBindingsLogger) { + const context = { + logger, + userAgent: `remote-bindings/${packageVersion}`, + async prompt(question: string) { + if (isNonInteractiveOrCI()) { + throw new NoDefaultValueProvided(); + } + return inputPrompt({ + type: "text", + question, + label: "Answer", + throwOnError: true, + }); + }, + async select( + question: string, + options: { choices: { title: string; value: string }[] } + ) { + if (isNonInteractiveOrCI()) { + throw new NoDefaultValueProvided(); + } + return inputPrompt({ + type: "select", + question, + label: "Account", + options: options.choices.map((choice) => ({ + label: choice.title, + value: choice.value, + })), + throwOnError: true, + }); + }, + isNoDefaultValueProvidedError: (error: unknown) => + error instanceof NoDefaultValueProvided, + }; + const useCfAuth = "CLOUDFLARE_CF_AUTH" in process.env; + return { + auth: useCfAuth ? createCfAuth(context) : createWranglerAuth(context), + useCfAuth, + }; +} + +export function getRemoteBindingsAuthHook( + auth: AsyncHook | undefined, + accountId: Config["account_id"] | undefined, + profileDir: string | undefined, + logger: RemoteBindingsLogger +): AsyncHook { + if (auth) { + return auth; + } + + const { auth: remoteBindingsAuth, useCfAuth } = + createRemoteBindingsAuth(logger); + const profileStore = useCfAuth + ? createCfProfileStore({ logger }) + : createWranglerProfileStore({ logger }); + const profile = profileStore.resolve({ + cwd: profileDir ?? process.cwd(), + }); + remoteBindingsAuth.setProfile(profile); + + return async () => ({ + accountId: await remoteBindingsAuth.requireAuth( + accountId ? { account_id: accountId } : {} + ), + apiToken: remoteBindingsAuth.requireApiToken(), + }); +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts new file mode 100644 index 0000000000..df418124af --- /dev/null +++ b/packages/remote-bindings/src/index.ts @@ -0,0 +1,15 @@ +export { + maybeStartOrUpdateRemoteProxySession, + pickRemoteBindings, +} from "./maybe-start-or-update-session"; +export type { + RemoteBindingsContext, + RemoteProxySessionData, + WorkerConfigObject, +} from "./maybe-start-or-update-session"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { RemoteBindingsLogger } from "./logger"; +export type { + RemoteProxySession, + StartRemoteProxySessionOptions, +} from "./start-remote-proxy-session"; diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts new file mode 100644 index 0000000000..d24891633b --- /dev/null +++ b/packages/remote-bindings/src/logger.ts @@ -0,0 +1,12 @@ +import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; + +export type RemoteBindingsLogger = Logger & { + loggerLevel: LoggerLevel; + console: NonNullable; +}; + +export let logger: RemoteBindingsLogger; + +export function initLogger(value: RemoteBindingsLogger): void { + logger = value; +} diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts new file mode 100644 index 0000000000..8a51a6b06d --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -0,0 +1,73 @@ +import { describe, it, vi } from "vitest"; +import { maybeStartOrUpdateRemoteProxySession } from "./maybe-start-or-update-session"; +import type { RemoteBindingsLogger } from "./logger"; +import type { RemoteProxySessionData } from "./maybe-start-or-update-session"; +import type { startRemoteProxySession } from "./start-remote-proxy-session"; +import type { RemoteProxyConnectionString } from "miniflare"; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "none", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + }; +} + +describe("maybeStartOrUpdateRemoteProxySession", () => { + it("updates an existing session when all remote bindings are removed", async ({ + expect, + }) => { + const dispose = vi.fn(); + const updateBindings = vi.fn(); + const startSession = vi.fn(); + const existingSession: RemoteProxySessionData = { + session: { + ready: Promise.resolve(), + dispose, + updateBindings, + remoteProxyConnectionString: new URL( + "http://localhost:8787" + ) as RemoteProxyConnectionString, + }, + remoteBindings: { + SERVICE: { + type: "service", + service: "worker", + remote: true, + }, + }, + }; + + const result = await maybeStartOrUpdateRemoteProxySession( + { bindings: {} }, + existingSession, + undefined, + { logger: createTestLogger() }, + startSession + ); + + expect(result?.session).toBe(existingSession.session); + expect(updateBindings).toHaveBeenCalledWith({}); + expect(dispose).not.toHaveBeenCalled(); + expect(startSession).not.toHaveBeenCalled(); + }); + + it("does not start a session without remote bindings", async ({ expect }) => { + const startSession = vi.fn(); + + const result = await maybeStartOrUpdateRemoteProxySession( + { bindings: {} }, + undefined, + undefined, + { logger: createTestLogger() }, + startSession + ); + + expect(result).toBeNull(); + expect(startSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts new file mode 100644 index 0000000000..a06232f492 --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -0,0 +1,136 @@ +import assert from "node:assert"; +import { getBindingLocalSupport } from "@cloudflare/workers-utils"; +import { getRemoteBindingsAuthHook } from "./auth"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; +import type { RemoteBindingsLogger } from "./logger"; +import type { RemoteProxySession } from "./start-remote-proxy-session"; +import type { + AsyncHook, + Binding, + CfAccount, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +export function pickRemoteBindings( + bindings: Record +): Record { + return Object.fromEntries( + Object.entries(bindings ?? {}).filter(([, binding]) => { + if ( + getBindingLocalSupport(binding.type) === + "DO-NOT-USE-this-resource-will-never-have-a-local-simulator" + ) { + return true; + } + return "remote" in binding && binding.remote; + }) + ); +} + +export type WorkerConfigObject = { + /** The name of the worker. */ + name?: string; + /** The Worker's bindings. */ + bindings: NonNullable; + /** If running in a non-public compliance region, set this here. */ + complianceRegion?: Config["compliance_region"]; + /** ID of the account owning the worker. */ + account_id?: Config["account_id"]; + /** Directory used to resolve the auth profile from directory bindings. */ + profileDir?: string; +}; + +export type RemoteProxySessionData = { + session: RemoteProxySession; + remoteBindings: Record; + auth?: AsyncHook; +}; + +export type RemoteBindingsContext = { + logger: RemoteBindingsLogger; +}; + +/** Potentially starts or updates a remote proxy session. */ +export async function maybeStartOrUpdateRemoteProxySession( + workerConfigObject: WorkerConfigObject, + preExistingRemoteProxySessionData: RemoteProxySessionData | null | undefined, + auth: AsyncHook | undefined, + context: RemoteBindingsContext, + startSession: typeof startRemoteProxySession = startRemoteProxySession +): Promise { + const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); + if ( + Object.keys(remoteBindings).length === 0 && + !preExistingRemoteProxySessionData?.session + ) { + return null; + } + const authSameAsBefore = deepStrictEqual( + auth, + preExistingRemoteProxySessionData?.auth + ); + let remoteProxySession = preExistingRemoteProxySessionData?.session; + + if (!authSameAsBefore) { + if (preExistingRemoteProxySessionData?.session) { + await preExistingRemoteProxySessionData.session.dispose(); + } + + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getRemoteBindingsAuthHook( + auth, + workerConfigObject.account_id, + workerConfigObject.profileDir, + context.logger + ), + logger: context.logger, + }); + } else { + const remoteBindingsAreSameAsBefore = deepStrictEqual( + remoteBindings, + preExistingRemoteProxySessionData?.remoteBindings + ); + + if (!remoteBindingsAreSameAsBefore) { + if (!remoteProxySession) { + if (Object.keys(remoteBindings).length > 0) { + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getRemoteBindingsAuthHook( + auth, + workerConfigObject.account_id, + workerConfigObject.profileDir, + context.logger + ), + logger: context.logger, + }); + } + } else { + await remoteProxySession.updateBindings(remoteBindings); + } + } + } + + await remoteProxySession?.ready; + if (!remoteProxySession) { + return null; + } + return { + session: remoteProxySession, + remoteBindings, + auth, + }; +} + +function deepStrictEqual(source: unknown, target: unknown): boolean { + try { + assert.deepStrictEqual(source, target); + return true; + } catch { + return false; + } +} diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts new file mode 100644 index 0000000000..3aa5beef1f --- /dev/null +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -0,0 +1,139 @@ +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { describe, it, vi } from "vitest"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; +import { RemoteSessionAuthenticationError } from "./utils/remote"; +import type { RemoteBindingsLogger } from "./logger"; +import type { ErrorEvent } from "./startDevWorker/events"; + +// The error that the mocked `DevEnv` emits asynchronously from `start()`. +const mockDevEnv = vi.hoisted(() => ({ error: undefined as unknown })); + +// Replace `DevEnv` with a stub that never becomes ready and instead emits a +// configurable error event just after `start()`, so we can exercise the async +// error-handling path in `startRemoteProxySession` without any real Miniflare. +vi.mock("./startDevWorker/DevEnv", async () => { + const { EventEmitter } = await import("node:events"); + class DevEnv extends EventEmitter { + proxy = { + // Never resolves — the startup race is always settled by the error event. + localServerReady: { promise: new Promise(() => {}) }, + ready: { promise: new Promise(() => {}) }, + runtimeMessageMutex: { drained: async () => {} }, + }; + constructor() { + super(); + // Avoid Node's throw-on-unhandled-"error" behaviour. + this.on("error", () => {}); + } + start() { + queueMicrotask(() => this.emit("error", mockDevEnv.error)); + } + update() {} + async teardown() {} + } + return { DevEnv }; +}); + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "none", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + }; +} + +function makeErrorEvent(cause: ErrorEvent["cause"]): ErrorEvent { + return { + type: "error", + reason: "Failed to start ProxyWorker", + cause, + source: "RemoteRuntimeController", + data: undefined, + }; +} + +describe("startRemoteProxySession error handling", () => { + // Regression: a `UserError` emitted asynchronously (e.g. the Miniflare + // user-error case in `DevEnv.handleErrorEvent`) used to fall through to being + // wrapped in a generic `Error`, which makes Wrangler treat it as an + // unexpected internal error (bug prompt + Sentry report) rather than a clean + // user-facing error. + it("re-throws a UserError emitted asynchronously without wrapping it", async ({ + expect, + }) => { + const userError = new UserError("nodejs_compat is required", { + telemetryMessage: "test user error", + }); + mockDevEnv.error = userError; + + await expect( + startRemoteProxySession({}, { logger: createTestLogger() }) + ).rejects.toBe(userError); + }); + + it("unwraps a UserError nested in an error event's cause", async ({ + expect, + }) => { + const authError = new RemoteSessionAuthenticationError(new Error("401")); + mockDevEnv.error = makeErrorEvent(authError); + + const thrown = await startRemoteProxySession( + {}, + { logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBe(authError); + expect(thrown).toBeInstanceOf(UserError); + }); + + it("wraps a non-user error in a generic Error, preserving the message", async ({ + expect, + }) => { + mockDevEnv.error = makeErrorEvent(new Error("boom")); + + const thrown = await startRemoteProxySession( + {}, + { logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(UserError); + expect((thrown as Error).message).toContain( + "Failed to start the remote proxy session" + ); + expect((thrown as Error).message).toContain("boom"); + }); + + // `APIError` extends `UserError` (via `ParseError`), but a generic API + // failure during preview-token creation must still be wrapped in the + // "Failed to start the remote proxy session" envelope — not re-thrown raw — + // so only `RemoteSessionAuthenticationError` is unwrapped from the cause + // chain. Regression guard for the too-broad `UserError` walk. + it("wraps a generic APIError from the cause chain rather than re-throwing it", async ({ + expect, + }) => { + mockDevEnv.error = makeErrorEvent( + new APIError({ + text: "The remote worker preview failed.", + telemetryMessage: "test api error", + }) + ); + + const thrown = await startRemoteProxySession( + {}, + { logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).not.toBeInstanceOf(APIError); + expect((thrown as Error).message).toContain( + "Failed to start the remote proxy session" + ); + expect((thrown as Error).message).toContain( + "The remote worker preview failed." + ); + }); +}); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts new file mode 100644 index 0000000000..28f6efb1be --- /dev/null +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -0,0 +1,242 @@ +import { randomUUID } from "node:crypto"; +import events from "node:events"; +import { UserError } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { DeferredPromise } from "miniflare"; +import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker"; +import { getRemoteBindingsAuthHook } from "./auth"; +import { initLogger } from "./logger"; +import { DevEnv } from "./startDevWorker/DevEnv"; +import { RemoteSessionAuthenticationError } from "./utils/remote"; +import type { RemoteBindingsLogger } from "./logger"; +import type { + AsyncHook, + CfAccount, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +type ErrorEvent = { + type: "error"; + reason: string; + cause: unknown; +}; + +export type StartRemoteProxySessionOptions = { + workerName?: string; + auth?: AsyncHook; + /** If running in a non-public compliance region, set this here. */ + complianceRegion?: Config["compliance_region"]; + logger: RemoteBindingsLogger; +}; + +function isErrorEvent(error: unknown): error is ErrorEvent { + return ( + typeof error === "object" && + error !== null && + "type" in error && + error.type === "error" && + "reason" in error && + "cause" in error + ); +} + +function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return getErrorMessage(error.cause) ?? error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error !== null) { + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === "string") { + const maybeCause = (error as { cause?: unknown }).cause; + return getErrorMessage(maybeCause) ?? maybeMessage; + } + } + return undefined; +} + +function formatRemoteProxySessionError(error: unknown): string | undefined { + if (isErrorEvent(error)) { + const causeMessage = getErrorMessage(error.cause); + return causeMessage ? `${error.reason}: ${causeMessage}` : error.reason; + } + return getErrorMessage(error); +} + +/** + * Walk an error's cause chain (including through error events) for a + * {@link RemoteSessionAuthenticationError}, mirroring the original Wrangler + * `findRemoteSessionAuthError` walk. Only this specific user error is surfaced + * verbatim from the chain — generic API errors (which also extend + * {@link UserError} via {@link APIError}) fall through to the wrapping logic. + */ +function findRemoteSessionAuthError( + error: unknown +): RemoteSessionAuthenticationError | undefined { + if (error instanceof RemoteSessionAuthenticationError) { + return error; + } + if (isErrorEvent(error)) { + return findRemoteSessionAuthError(error.cause); + } + if (error instanceof Error) { + return findRemoteSessionAuthError(error.cause); + } + return undefined; +} + +export async function startRemoteProxySession( + bindings: StartDevWorkerInput["bindings"], + options: StartRemoteProxySessionOptions +): Promise { + options.logger.log(chalk.dim("⎔ Establishing remote connection...")); + initLogger(getInternalLogger(options.logger)); + const rawBindings = toRawBindings(bindings); + const workerConfig = { + name: options.workerName ?? randomUUID(), + entrypointSource: remoteBindingsWorkerSource, + compatibilityDate: "2025-04-28", + compatibilityFlags: [], + complianceRegion: options.complianceRegion, + bindings: rawBindings, + auth: getRemoteBindingsAuthHook( + options.auth, + undefined, + undefined, + options.logger + ), + server: { port: 0, secure: false }, + }; + + let devEnv: DevEnv | undefined; + try { + devEnv = new DevEnv(workerConfig); + devEnv.start(); + } catch (startWorkerError: unknown) { + await devEnv?.teardown(); + if (startWorkerError instanceof UserError) { + throw startWorkerError; + } + let errorMessage = startWorkerError; + if (startWorkerError instanceof Error) { + errorMessage = + startWorkerError.cause instanceof Error + ? startWorkerError.cause.message + : startWorkerError.message; + } + throw new Error( + `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` + ); + } + + const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); + const onStartupError = (error: unknown) => { + maybeErrorPromise.resolve({ error }); + }; + devEnv.addListener("error", onStartupError); + let remoteProxyConnectionString: RemoteProxyConnectionString; + try { + const maybeError = await Promise.race([ + maybeErrorPromise, + devEnv.proxy.localServerReady.promise, + ]); + + if (maybeError && maybeError.error) { + // A UserError emitted directly (e.g. the Miniflare user-error case that + // DevEnv re-emits), and a RemoteSessionAuthenticationError found in the + // error event's cause chain, are surfaced verbatim so callers can still + // branch on `instanceof UserError` and the user sees a single actionable + // message. Everything else — including generic API errors — falls through + // to the wrapping below. + if (maybeError.error instanceof UserError) { + throw maybeError.error; + } + const authError = findRemoteSessionAuthError(maybeError.error); + if (authError) { + throw authError; + } + const details = formatRemoteProxySessionError(maybeError.error); + throw new Error( + details + ? `Failed to start the remote proxy session. ${details}` + : "Failed to start the remote proxy session. There is likely additional logging output above.", + { cause: maybeError.error } + ); + } + + remoteProxyConnectionString = (await devEnv.proxy.ready.promise) + .url as RemoteProxyConnectionString; + } catch (error) { + await devEnv.teardown(); + throw error; + } finally { + devEnv.removeListener("error", onStartupError); + } + const updateBindings = async ( + newBindings: StartDevWorkerInput["bindings"] + ) => { + const reloadComplete = events.once(devEnv, "reloadComplete"); + devEnv.update({ + ...workerConfig, + bindings: toRawBindings(newBindings), + }); + try { + await reloadComplete; + } catch (errorOrEvent) { + throw errorOrEvent instanceof Error + ? errorOrEvent + : new Error( + `RemoteProxySession.updateBindings failed during reload: ${ + (errorOrEvent as { reason?: string })?.reason ?? "unknown" + }`, + { cause: errorOrEvent } + ); + } + await devEnv.proxy.runtimeMessageMutex.drained(); + }; + + return { + ready: Promise.resolve(), + remoteProxyConnectionString, + updateBindings, + dispose: () => devEnv.teardown(), + }; +} + +export type RemoteProxySession = { + ready: Promise; + dispose(): Promise; + updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; + remoteProxyConnectionString: RemoteProxyConnectionString; +}; + +function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { + return Object.fromEntries( + Object.entries(bindings ?? {}).map(([key, binding]) => [ + key, + { ...binding, raw: true }, + ]) + ); +} + +function getInternalLogger(logger: RemoteBindingsLogger): RemoteBindingsLogger { + if (logger.loggerLevel === "debug") { + return logger; + } + + const disabled = () => {}; + const loggerLevel = logger.loggerLevel === "none" ? "none" : "error"; + return { + loggerLevel, + debug: disabled, + log: disabled, + info: disabled, + warn: disabled, + error: loggerLevel === "none" ? disabled : logger.error.bind(logger), + console: disabled, + }; +} diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts new file mode 100644 index 0000000000..183c75102d --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts @@ -0,0 +1,48 @@ +import { describe, it, vi } from "vitest"; +import { DevEnv } from "./DevEnv"; +import type { ProxyController } from "./ProxyController"; +import type { RemoteRuntimeController } from "./RemoteRuntimeController"; +import type { StartDevWorkerOptions } from "./types"; + +const config: StartDevWorkerOptions = { + name: "remote-bindings-proxy", + entrypointSource: "export default {};", + bindings: {}, + compatibilityDate: "2026-07-17", + compatibilityFlags: [], + complianceRegion: undefined, + auth: () => ({ + accountId: "account-id", + apiToken: { apiToken: "api-token" }, + }), + server: { port: 0, secure: false }, +}; + +describe("DevEnv", () => { + it("changes the uploaded source on every update", ({ expect }) => { + const devEnv = new DevEnv(config); + const onBundleComplete = + vi.fn(); + devEnv.proxy = { pause: vi.fn() } as unknown as ProxyController; + devEnv.runtime = { + onUpdateStart: vi.fn(), + onBundleComplete, + } as unknown as RemoteRuntimeController; + + devEnv.update(config); + devEnv.update(config); + + const [firstCall, secondCall] = onBundleComplete.mock.calls; + if (!firstCall || !secondCall) { + throw new Error("Expected two bundle updates"); + } + const firstBundle = firstCall[0].bundle; + const secondBundle = secondCall[0].bundle; + expect(firstBundle.entrypointSource).toBe( + "export default {};\n// remote-bindings-update:1" + ); + expect(secondBundle.entrypointSource).toBe( + "export default {};\n// remote-bindings-update:2" + ); + }); +}); diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts new file mode 100644 index 0000000000..00c66ad563 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from "node:events"; +import { UserError } from "@cloudflare/workers-utils"; +import { MiniflareCoreError } from "miniflare"; +import { logger } from "../logger"; +import { ProxyController } from "./ProxyController"; +import { RemoteRuntimeController } from "./RemoteRuntimeController"; +import type { ErrorEvent, ReloadCompleteEvent } from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; + +export class DevEnv extends EventEmitter { + runtime: RemoteRuntimeController; + proxy: ProxyController; + #bundle: Bundle; + #bundleVersion = 0; + #config: StartDevWorkerOptions; + + start() { + this.proxy.start(this.#config); + this.update(this.#config); + } + + update(config: StartDevWorkerOptions) { + this.#config = config; + this.proxy.pause(config); + this.runtime.onUpdateStart(); + this.runtime.onBundleComplete({ + type: "bundleComplete", + config, + bundle: { + ...this.#bundle, + // Ensure binding-only updates cannot reuse the previous edge-preview artifact. + entrypointSource: `${this.#bundle.entrypointSource}\n// remote-bindings-update:${++this.#bundleVersion}`, + }, + }); + } + + constructor(config: StartDevWorkerOptions) { + super(); + + this.#config = config; + this.#bundle = { + path: "proxy-worker.js", + entrypointSource: config.entrypointSource, + type: "esm", + modules: [], + }; + this.proxy = new ProxyController( + (event) => this.handleErrorEvent(event), + () => this.runtime.onPreviewTokenExpired() + ); + this.runtime = new RemoteRuntimeController( + (event) => this.handleErrorEvent(event), + (event) => this.handleReloadComplete(event) + ); + + this.on("error", (event: ErrorEvent) => { + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + }); + } + + private handleReloadComplete(event: ReloadCompleteEvent) { + this.proxy.play(event); + this.emit("reloadComplete", event); + } + + private handleErrorEvent(event: ErrorEvent): void { + if ( + event.cause instanceof MiniflareCoreError && + event.cause.isUserError() + ) { + this.emit( + "error", + new UserError(event.cause.message, { + telemetryMessage: "api dev miniflare user error", + }) + ); + } else if ( + event.source === "ProxyController" && + event.reason.startsWith("Failed to send message to") + ) { + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + } + // if other knowable + recoverable errors occur, handle them here + else { + // otherwise, re-emit the unknowable errors to the top-level + this.emit("error", event); + } + } + + async teardown() { + logger.debug("DevEnv teardown beginning..."); + + await Promise.all([this.runtime.teardown(), this.proxy.teardown()]); + + logger.debug("DevEnv teardown complete"); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts new file mode 100644 index 0000000000..aca10d24d1 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -0,0 +1,275 @@ +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { assertNever } from "@cloudflare/workers-utils"; +import { Log, LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import proxyWorkerSource from "worker:startDevWorker/ProxyWorker"; +import { logger } from "../logger"; +import { castLogLevel, handleStructuredLogs } from "../utils/miniflare"; +import { castErrorCause } from "./events"; +import { createDeferred } from "./utils"; +import type { + ErrorEvent, + ProxyWorkerIncomingRequestBody, + ProxyWorkerOutgoingRequestBody, + ReadyEvent, + ReloadCompleteEvent, + SerializedError, +} from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { LogOptions, MiniflareOptions } from "miniflare"; + +export class ProxyController { + public ready = createDeferred(); + + public localServerReady = createDeferred(); + + public proxyWorker?: Miniflare; + + protected latestConfig?: StartDevWorkerOptions; + protected latestBundle?: Bundle; + + secret = randomUUID(); + + constructor( + private onError: (event: ErrorEvent) => void, + private onPreviewTokenExpired: () => void + ) {} + + protected createProxyWorker() { + if (this._torndown || this.proxyWorker) { + return; + } + assert(this.latestConfig !== undefined); + + const proxyWorkerOptions: MiniflareOptions = { + host: this.latestConfig.server.hostname, + port: this.latestConfig.server.port, + https: this.latestConfig.server.secure, + stripDisablePrettyError: false, + unsafeLocalExplorer: false, + workers: [ + { + name: "ProxyWorker", + compatibilityDate: "2023-12-18", + compatibilityFlags: ["nodejs_compat"], + modules: [ + { + type: "ESModule", + path: "dev-proxy-worker.mjs", + contents: proxyWorkerSource, + }, + ], + durableObjects: { + DURABLE_OBJECT: { + className: "ProxyWorker", + unsafePreventEviction: true, + }, + }, + // Miniflare will strip CF-Connecting-IP from outgoing fetches from a Worker (to fix https://github.com/cloudflare/workers-sdk/issues/7924) + // However, the proxy worker only makes outgoing requests to the user Worker Miniflare instance, which _should_ receive CF-Connecting-IP + stripCfConnectingIp: false, + serviceBindings: { + PROXY_CONTROLLER: async (req): Promise => { + const message = + (await req.json()) as ProxyWorkerOutgoingRequestBody; + + this.onProxyWorkerMessage(message); + + return new Response(null, { status: 204 }); + }, + }, + bindings: { + PROXY_CONTROLLER_AUTH_SECRET: this.secret, + }, + + // no need to use file-system, so don't + cache: false, + unsafeEphemeralDurableObjects: true, + }, + ], + + verbose: logger.loggerLevel === "debug", + + // log requests into the ProxyWorker (for local + remote mode) + log: new ProxyControllerLogger( + castLogLevel(logger.loggerLevel), + { + prefix: + // if debugging, log requests with specic ProxyWorker prefix + logger.loggerLevel === "debug" + ? "remote-bindings-ProxyWorker" + : "remote-bindings", + }, + this.localServerReady.promise + ), + handleStructuredLogs, + }; + + const proxyWorker = new Miniflare(proxyWorkerOptions); + this.proxyWorker = proxyWorker; + + void proxyWorker.ready + .then((url) => { + assert(url); + this.emitReadyEvent(proxyWorker, url); + }) + .catch((error) => { + if (this._torndown) { + return; + } + this.emitErrorEvent("Failed to start ProxyWorker", error); + }); + } + + runtimeMessageMutex = new Mutex(); + async sendMessageToProxyWorker( + message: ProxyWorkerIncomingRequestBody, + retries = 3 + ): Promise { + if (this._torndown) { + return; + } + + // Don't do any async work here. Enqueue the message with the mutex immediately. + + try { + await this.runtimeMessageMutex.runWith(async () => { + const { proxyWorker } = await this.ready.promise; + + const ready = await proxyWorker.ready.catch(() => undefined); + if (!ready) { + return; + } + + return proxyWorker.dispatchFetch( + `http://dummy/cdn-cgi/ProxyWorker/${message.type}`, + { + headers: { Authorization: this.secret }, + cf: { hostMetadata: message }, + } + ); + }); + } catch (cause) { + if (this._torndown) { + return; + } + + const error = castErrorCause(cause); + + if (retries > 0) { + return this.sendMessageToProxyWorker(message, retries - 1); + } + + this.emitErrorEvent( + `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, + error + ); + } + } + start(config: StartDevWorkerOptions) { + this.latestConfig = config; + this.createProxyWorker(); + } + pause(config: StartDevWorkerOptions) { + this.latestConfig = config; + void this.sendMessageToProxyWorker({ type: "pause" }); + } + play(data: ReloadCompleteEvent) { + this.localServerReady.resolve(); + + this.latestConfig = data.config; + this.latestBundle = data.bundle; + + void this.sendMessageToProxyWorker({ + type: "play", + proxyData: data.proxyData, + }); + } + onProxyWorkerMessage(message: ProxyWorkerOutgoingRequestBody) { + switch (message.type) { + case "previewTokenExpired": + this.onPreviewTokenExpired(); + + break; + case "error": + this.emitErrorEvent("Error inside ProxyWorker", message.error); + + break; + default: + assertNever(message); + } + } + _torndown = false; + async teardown() { + logger.debug("ProxyController teardown beginning..."); + this._torndown = true; + + const { proxyWorker } = this; + this.proxyWorker = undefined; + + await proxyWorker?.dispose(); + + logger.debug("ProxyController teardown complete"); + } + + // ********************* + // Event Dispatchers + // ********************* + + emitReadyEvent(proxyWorker: Miniflare, url: URL) { + const data: ReadyEvent = { + type: "ready", + proxyWorker, + url, + }; + + this.ready.resolve(data); + } + emitErrorEvent(data: ErrorEvent): void; + emitErrorEvent(reason: string, cause?: Error | SerializedError): void; + emitErrorEvent(data: string | ErrorEvent, cause?: Error | SerializedError) { + if (typeof data === "string") { + data = { + type: "error", + source: "ProxyController", + cause: castErrorCause(cause), + reason: data, + data: { + config: this.latestConfig, + bundle: this.latestBundle, + }, + }; + } + if (this._torndown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${data.source}: ${data.reason}\n`, data.cause); + logger.debug("=> Error contextual data:", data.data); + return; + } + this.onError(data); + } +} + +class ProxyControllerLogger extends Log { + constructor( + level: LogLevel, + opts: LogOptions, + private localServerReady: Promise + ) { + super(level, opts); + } + + override logReady(message: string): void { + this.localServerReady.then(() => super.logReady(message)).catch(() => {}); + } + + override log(message: string) { + // filter out request logs being handled by the ProxyWorker + // the requests log remaining are handled by the UserWorker + // keep the ProxyWorker request logs if we're in debug mode + if (message.includes("/cdn-cgi/") && this.level < LogLevel.DEBUG) { + return; + } + super.log(message); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts new file mode 100644 index 0000000000..a582b8e9cd --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -0,0 +1,386 @@ +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { retryOnAPIFailure } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { Mutex } from "miniflare"; +import { WebSocket } from "ws"; +import { version as packageVersion } from "../../package.json"; +import { logger } from "../logger"; +import { TRACE_VERSION } from "../utils/constants"; +import { + createPreviewSession, + createWorkerPreview, +} from "../utils/create-worker-preview"; +import { realishPrintLogs } from "../utils/printing"; +import { + createRemoteWorkerInit, + handlePreviewSessionCreationError, + handlePreviewSessionUploadError, +} from "../utils/remote"; +import { castErrorCause } from "./events"; +import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; +import type { + CfAccount, + CfPreviewSession, + CfPreviewToken, +} from "../utils/create-worker-preview"; +import type { + BundleCompleteEvent, + ErrorEvent, + ProxyData, + ReloadCompleteEvent, +} from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; + +type CreateRemoteWorkerInitProps = Parameters[0]; + +export class RemoteRuntimeController { + #abortController = new AbortController(); + + #currentBundleId = 0; + #mutex = new Mutex(); + + #session?: CfPreviewSession; + + #activeTail?: WebSocket; + + #latestConfig?: StartDevWorkerOptions; + #latestBundle?: Bundle; + #latestProxyData?: ProxyData; + + // Timer for proactive token refresh before the 1-hour expiry + #refreshTimer?: ReturnType; + #tearingDown = false; + + constructor( + private onError: (event: ErrorEvent) => void, + private onReloadComplete: (event: ReloadCompleteEvent) => void + ) {} + + async #previewSession( + props: CfAccount & { + complianceConfig: ComplianceConfig; + name: string; + } + ): Promise { + try { + return await retryOnAPIFailure( + () => + createPreviewSession( + props.complianceConfig, + props, + this.#abortController.signal, + props.name + ), + logger, + undefined, + undefined, + this.#abortController.signal + ); + } catch (err: unknown) { + if (err instanceof Error && err.name == "AbortError") { + return; // ignore + } + + handlePreviewSessionCreationError(err, props.accountId); + + throw err; + } + } + + async #previewToken( + props: CreateRemoteWorkerInitProps & + CfAccount & { + complianceConfig: ComplianceConfig; + bundleId: number; + } + ): Promise { + if (!this.#session) { + return; + } + // Capture session in a local variable so TypeScript can narrow + // the type inside the retryOnAPIFailure closure below. + const session = this.#session; + + try { + // If we received a new `bundleComplete` event before we were able to + // dispatch a `reloadComplete` for this bundle, ignore this bundle. + if (props.bundleId !== this.#currentBundleId) { + return; + } + // Suppress errors from terminating a WebSocket that hasn't connected yet + this.#activeTail?.removeAllListeners("error"); + this.#activeTail?.on("error", () => {}); + this.#activeTail?.terminate(); + const init = createRemoteWorkerInit({ + bundle: props.bundle, + name: props.name, + bindings: props.bindings, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + }); + + const workerPreviewToken = await retryOnAPIFailure( + () => + createWorkerPreview( + props.complianceConfig, + init, + props, + session, + this.#abortController.signal + ), + logger, + undefined, + undefined, + this.#abortController.signal + ); + + if (workerPreviewToken.tailUrl) { + this.#activeTail = new WebSocket( + workerPreviewToken.tailUrl, + TRACE_VERSION, + { + headers: { + "Sec-WebSocket-Protocol": TRACE_VERSION, // needs to be `trace-v1` to be accepted + "User-Agent": `remote-bindings/${packageVersion}`, + }, + signal: this.#abortController.signal, + } + ); + + this.#activeTail.on("message", realishPrintLogs); + // Best-effort log streaming: ignore errors instead of letting them + // propagate as unhandled exceptions. The signal we pass to the `ws` + // constructor is shared with update cancellation, which destroys + // the underlying upgrade request with `AbortError` every time a new + // bundle starts. The existing `terminate` paths in `#previewToken` + // and `teardown()` re-install no-op listeners before shutting the + // tail down — this listener covers the window between WebSocket + // construction and the next terminate, plus any transient network + // errors during normal operation. + this.#activeTail.on("error", (err) => { + logger.debug("Active tail WebSocket error (ignored):", err); + }); + } + return workerPreviewToken; + } catch (err: unknown) { + if (err instanceof Error && err.name == "AbortError") { + return; // ignore + } + + const shouldRestartSession = handlePreviewSessionUploadError( + err, + props.accountId + ); + if (shouldRestartSession) { + this.#session = await this.#previewSession(props); + return this.#previewToken(props); + } + + this.emitErrorEvent({ + type: "error", + reason: "Failed to obtain a preview token", + cause: castErrorCause(err), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + + #getPreviewSession(config: StartDevWorkerOptions, auth: CfAccount) { + return this.#previewSession({ + complianceConfig: { compliance_region: config.complianceRegion }, + accountId: auth.accountId, + apiToken: auth.apiToken, + name: config.name, + }); + } + + async #updatePreviewToken( + config: StartDevWorkerOptions, + bundle: Bundle, + auth: CfAccount, + bundleId: number + ): Promise { + // If we received a new `bundleComplete` event before we were able to + // dispatch a `reloadComplete` for this bundle, ignore this bundle. + if (bundleId !== this.#currentBundleId) { + return false; + } + + const token = await this.#previewToken({ + bundle, + accountId: auth.accountId, + apiToken: auth.apiToken, + complianceConfig: { compliance_region: config.complianceRegion }, + name: config.name, + bindings: config.bindings, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + bundleId, + }); + // If we received a new `bundleComplete` event before we were able to + // dispatch a `reloadComplete` for this bundle, ignore this bundle. + // If `token` is undefined, we've surfaced a relevant error to the user above, so ignore this bundle + if (bundleId !== this.#currentBundleId || !token) { + return false; + } + + const accessHeaders = await getAccessHeaders(token.host, { + logger, + }); + + const proxyData: ProxyData = { + userWorkerUrl: { + protocol: "https:", + hostname: token.host, + port: "443", + }, + headers: { + "cf-workers-preview-token": token.value, + ...accessHeaders, + "cf-connecting-ip": "", + }, + }; + + this.#latestProxyData = proxyData; + + this.onReloadComplete({ + type: "reloadComplete", + bundle, + config, + proxyData, + }); + + this.#scheduleRefresh(PREVIEW_TOKEN_REFRESH_INTERVAL); + return true; + } + + #scheduleRefresh(interval: number) { + clearTimeout(this.#refreshTimer); + this.#refreshTimer = setTimeout(() => { + if (this.#latestProxyData) { + this.onPreviewTokenExpired(); + } + }, interval); + } + + async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { + // A newer bundle has already been queued — skip this stale one. + if (id !== this.#currentBundleId) { + return; + } + + logger.log(chalk.dim("⎔ Starting remote preview...")); + + try { + const auth = await unwrapHook(config.auth); + + this.#latestConfig = config; + this.#latestBundle = bundle; + + if (this.#session) { + logger.log(chalk.dim("⎔ Detected changes, restarted server.")); + } + + this.#session ??= await this.#getPreviewSession(config, auth); + await this.#updatePreviewToken(config, bundle, auth, id); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error reloading remote server", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + + async #refreshPreviewToken() { + if (!this.#latestConfig || !this.#latestBundle) { + logger.warn( + "Cannot refresh preview token: missing config or bundle data" + ); + return; + } + + try { + const auth = await unwrapHook(this.#latestConfig.auth); + + this.#session = await this.#getPreviewSession(this.#latestConfig, auth); + + const refreshed = await this.#updatePreviewToken( + this.#latestConfig, + this.#latestBundle, + auth, + this.#currentBundleId + ); + + if (refreshed) { + logger.log(chalk.green("✔ Preview token refreshed successfully")); + } + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error refreshing preview token", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + + // ****************** + // Event Handlers + // ****************** + + onUpdateStart() { + // Abort any previous operations when a new bundle is started + this.#abortController.abort(); + this.#abortController = new AbortController(); + clearTimeout(this.#refreshTimer); + } + onBundleComplete(ev: BundleCompleteEvent) { + const id = ++this.#currentBundleId; + + void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); + } + onPreviewTokenExpired(): void { + logger.log(chalk.dim("⎔ Refreshing preview token...")); + void this.#mutex.runWith(() => this.#refreshPreviewToken()); + } + + async teardown() { + this.#tearingDown = true; + if (this.#session) { + logger.log(chalk.dim("⎔ Shutting down remote preview...")); + } + logger.debug("RemoteRuntimeController teardown beginning..."); + this.#session = undefined; + clearTimeout(this.#refreshTimer); + this.#abortController.abort(); + // Suppress errors from terminating a WebSocket that hasn't connected yet + this.#activeTail?.removeAllListeners("error"); + this.#activeTail?.on("error", () => {}); + this.#activeTail?.terminate(); + logger.debug("RemoteRuntimeController teardown complete"); + } + + private emitErrorEvent(event: ErrorEvent) { + if (this.#tearingDown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + return; + } + this.onError(event); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts new file mode 100644 index 0000000000..533a260eea --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -0,0 +1,69 @@ +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { Miniflare } from "miniflare"; + +export type ErrorEvent = + | BaseErrorEvent<"RemoteRuntimeController"> + | BaseErrorEvent< + "ProxyController", + { config?: StartDevWorkerOptions; bundle?: Bundle } + >; +type BaseErrorEvent = { + type: "error"; + reason: string; + cause: Error | SerializedError; + source: Source; + data: Data; +}; + +export function castErrorCause(cause: unknown) { + if (cause instanceof Error) { + return cause; + } + + const error = new Error(); + error.cause = cause; + + return error; +} + +export type BundleCompleteEvent = { + type: "bundleComplete"; + + config: StartDevWorkerOptions; + bundle: Bundle; +}; + +export type ReloadCompleteEvent = { + type: "reloadComplete"; + + config: StartDevWorkerOptions; + bundle: Bundle; + proxyData: ProxyData; +}; +export type ReadyEvent = { + type: "ready"; + proxyWorker: Miniflare; + url: URL; +}; + +// ProxyWorker +export type ProxyWorkerIncomingRequestBody = + | { type: "play"; proxyData: ProxyData } + | { type: "pause" }; +export type ProxyWorkerOutgoingRequestBody = + | { type: "error"; error: SerializedError } + | { type: "previewTokenExpired"; proxyData: ProxyData }; + +export type SerializedError = { + message: string; + name?: string; + stack?: string | undefined; + cause?: unknown; +}; +export type UrlOriginParts = Pick; + +export type ProxyData = { + userWorkerUrl: UrlOriginParts; + userWorkerInnerUrlOverrides?: Partial; + headers: Record; +}; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts new file mode 100644 index 0000000000..68a33a27b0 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -0,0 +1,30 @@ +import type { + AsyncHook, + CfAccount, + CfModule, + CfModuleType, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +export type StartDevWorkerOptions = { + name: string; + entrypointSource: string; + bindings: NonNullable; + compatibilityDate: StartDevWorkerInput["compatibilityDate"]; + compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; + complianceRegion: Config["compliance_region"]; + auth: AsyncHook; + server: { + hostname?: string; + port: number; + secure: boolean; + }; +}; + +export type Bundle = { + path: string; + entrypointSource: string; + type: CfModuleType; + modules: CfModule[]; +}; diff --git a/packages/remote-bindings/src/startDevWorker/utils.ts b/packages/remote-bindings/src/startDevWorker/utils.ts new file mode 100644 index 0000000000..1bc9704c3d --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/utils.ts @@ -0,0 +1,101 @@ +import assert from "node:assert"; +import type { Hook, HookValues } from "@cloudflare/workers-utils"; + +/** + * When to proactively refresh the preview token. + * + * Preview tokens expire after 1 hour (hardcoded in the Workers control plane), so we retry after 50 mins. + */ +export const PREVIEW_TOKEN_REFRESH_INTERVAL = 50 * 60 * 1000; + +export type MaybePromise = T | Promise; +export type DeferredPromise = { + promise: Promise; + resolve: (_: MaybePromise) => void; + reject: (_: Error) => void; +}; + +export function createDeferred( + previousDeferred?: DeferredPromise +): DeferredPromise { + let resolve, reject; + const newPromise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + assert(resolve); + assert(reject); + + // if passed a previousDeferred, ensure it is resolved with the newDeferred + // so that await-ers of previousDeferred are now await-ing newDeferred + previousDeferred?.resolve(newPromise); + + return { + promise: newPromise, + resolve, + reject, + }; +} + +export function urlFromParts( + parts: Partial, + base = "http://localhost" +): URL { + const url = new URL(base); + + Object.assign(url, parts); + + return url; +} + +/** + * Rewrites the absolute URLs inside a single header value (e.g. `Location`, + * `Origin`, `Access-Control-Allow-Origin`), mapping only those whose host + * that match the target host. + * + * This function ensures that the host is replaced in a robust manner avoiding + * corruptions (that can happen for example if a naive replacement was used). + * + * @param value - The raw header value string that may contain absolute URLs to rewrite. + * @param from - The URL whose host should be matched against URLs found in the header value. + * @param to - The URL whose origin will replace the matched origin in the header value. + * @returns The header value string with all matching absolute URLs rewritten to use the target origin. + */ +export function rewriteUrlInHeaderValue( + value: string, + from: URL, + to: URL +): string { + // Split each absolute URL into its `scheme://authority` origin and the + // literal remainder (path/query/fragment). Keeping the remainder verbatim + // avoids URL normalization such as appending a trailing slash to a bare + // origin (which would corrupt an `Origin` header), and leaves host-like + // substrings inside query strings untouched. + return value.replace( + /(https?:\/\/[^/?#\s,;"']+)([^\s,;"']*)/gi, + (match, origin: string, rest: string) => { + let url: URL; + try { + url = new URL(origin); + } catch { + return match; + } + if (url.host !== from.host) { + return match; + } + return to.origin + rest; + } + ); +} + +type UnwrapHook< + T extends HookValues | Promise, + Args extends unknown[], +> = Hook; + +export function unwrapHook< + T extends HookValues | Promise, + Args extends unknown[], +>(hook: UnwrapHook, ...args: Args): T { + return typeof hook === "function" ? hook(...args) : hook; +} diff --git a/packages/remote-bindings/src/utils/constants.ts b/packages/remote-bindings/src/utils/constants.ts new file mode 100644 index 0000000000..57896ef36c --- /dev/null +++ b/packages/remote-bindings/src/utils/constants.ts @@ -0,0 +1 @@ +export const TRACE_VERSION = "trace-v1"; diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts new file mode 100644 index 0000000000..d65417feba --- /dev/null +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -0,0 +1,285 @@ +import crypto from "node:crypto"; +import { URL } from "node:url"; +import { createWorkerUploadForm } from "@cloudflare/deploy-helpers/create-worker-upload-form"; +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { + APIError, + fetchResultBase, + getComplianceRegionSubdomain, + ParseError, + parseJSON, + UserError, +} from "@cloudflare/workers-utils"; +import { fetch } from "undici"; +import { version as packageVersion } from "../../package.json"; +import { logger } from "../logger"; +import type { CfWorkerInitWithName } from "./remote"; +import type { + ApiCredentials, + ComplianceConfig, +} from "@cloudflare/workers-utils"; +import type { HeadersInit, RequestInit } from "undici"; + +/** + * Maximum time (ms) to wait for an individual preview API request before + * treating it as a timeout. Without this, a hung API response blocks the + * entire dev-session reload indefinitely. + */ +const PREVIEW_API_TIMEOUT_MS = 30_000; + +function fetchResult( + complianceConfig: ComplianceConfig, + account: CfAccount, + resource: string, + init: RequestInit = {}, + abortSignal?: AbortSignal +): Promise { + return fetchResultBase( + complianceConfig, + resource, + init, + `remote-bindings/${packageVersion}`, + logger, + undefined, + abortSignal, + account.apiToken + ); +} + +/** + * Combine the caller's abort signal with a per-request timeout so that a + * hung Cloudflare API response doesn't block forever. + */ +function withTimeout(signal: AbortSignal): AbortSignal { + return AbortSignal.any([signal, AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS)]); +} + +async function getOrRegisterWorkersDevSubdomain( + complianceConfig: ComplianceConfig, + account: CfAccount, + abortSignal: AbortSignal +): Promise { + const resource = `/accounts/${account.accountId}/workers/subdomain`; + try { + const { subdomain } = await fetchResult<{ subdomain: string }>( + complianceConfig, + account, + resource, + undefined, + abortSignal + ); + return subdomain; + } catch (error) { + if (!(error instanceof APIError) || error.code !== 10007) { + throw error; + } + } + + const subdomain = crypto.randomBytes(4).toString("hex"); + const result = await fetchResult<{ subdomain: string }>( + complianceConfig, + account, + resource, + { + method: "PUT", + body: JSON.stringify({ subdomain }), + }, + abortSignal + ); + return result.subdomain; +} + +/** + * A Cloudflare account. + */ + +export interface CfAccount { + /** + * An API token. + * + * @link https://api.cloudflare.com/#user-api-tokens-properties + */ + apiToken: ApiCredentials; + /** + * An account ID. + */ + accountId: string; +} + +/** + * A Preview Session on the edge + */ +export interface CfPreviewSession { + /** + * A value to use when creating a worker preview under a session + */ + value: string; + /** + * The host where the session is available. + */ + host: string; +} + +/** + * A preview token. + */ +export interface CfPreviewToken { + /** + * The header value required to trigger a preview. + * + * @example + * const headers = { 'cf-workers-preview-token': value } + * const response = await fetch('https://' + host, { headers }) + */ + value: string; + /** + * The host where the preview is available. + */ + host: string; + /** + * A URL that when fetched starts a tail. Essentially, `wrangler tail` for realish previews. + * + * https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/tail/methods/create/ + */ + tailUrl?: string; +} + +/** + * Try and get a re-encoded token from the edge. Returns null if the exchange + * fails for any reason (expected with particular zone settings). + * Rethrows AbortError so callers can handle cancellation. + */ +async function tryExpandToken( + exchangeUrl: string, + abortSignal: AbortSignal +): Promise { + try { + const switchedExchangeUrl = new URL(exchangeUrl); + + const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname, { + logger, + }); + const headers: HeadersInit = { ...accessHeaders }; + + logger.debug("-- START EXCHANGE API REQUEST:"); + + logger.debug("-- END EXCHANGE API REQUEST"); + const exchangeResponse = await fetch(switchedExchangeUrl, { + signal: abortSignal, + headers, + }); + const bodyText = await exchangeResponse.text(); + logger.debug( + "-- START EXCHANGE API RESPONSE:", + exchangeResponse.statusText, + exchangeResponse.status + ); + logger.debug("HEADERS:", JSON.stringify(exchangeResponse.headers, null, 2)); + + logger.debug("-- END EXCHANGE API RESPONSE"); + + if (!exchangeResponse.ok) { + return null; + } + + const body = parseJSON(bodyText) as { + token?: string; + }; + if (typeof body?.token !== "string") { + return null; + } + return body.token; + } catch (e) { + if (e instanceof Error && e.name === "AbortError") { + throw e; + } + return null; + } +} +/** + * Generates a preview session token. + */ +export async function createPreviewSession( + complianceConfig: ComplianceConfig, + account: CfAccount, + abortSignal: AbortSignal, + name: string +): Promise { + const { accountId } = account; + const initUrl = `/accounts/${accountId}/workers/subdomain/edge-preview`; + + const { token, exchange_url } = await fetchResult<{ + token: string; + exchange_url?: string; + }>(complianceConfig, account, initUrl, undefined, withTimeout(abortSignal)); + + const previewSessionToken = exchange_url + ? ((await tryExpandToken(exchange_url, withTimeout(abortSignal))) ?? token) + : token; + + try { + const subdomain = await getOrRegisterWorkersDevSubdomain( + complianceConfig, + account, + withTimeout(abortSignal) + ); + const host = `${name}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; + return { + value: previewSessionToken, + host, + }; + } catch (e) { + if (!(e instanceof ParseError)) { + throw e; + } else { + throw new UserError( + "Could not create remote preview session on your account.", + { telemetryMessage: "remote preview session creation failed" } + ); + } + } +} + +/** + * Creates a preview token. + */ +export async function createWorkerPreview( + complianceConfig: ComplianceConfig, + worker: CfWorkerInitWithName, + account: CfAccount, + session: CfPreviewSession, + abortSignal: AbortSignal +): Promise { + const { value, host } = session; + const { accountId } = account; + const url = `/accounts/${accountId}/workers/scripts/${worker.name}/edge-preview`; + + const formData = createWorkerUploadForm(worker, worker.bindings); + formData.set( + "wrangler-session-config", + JSON.stringify({ workers_dev: true, minimal_mode: true }) + ); + + const { preview_token, tail_url } = await fetchResult<{ + preview_token: string; + tail_url: string; + }>( + complianceConfig, + account, + url, + { + method: "POST", + body: formData, + headers: { + "cf-preview-upload-config-token": value, + }, + }, + withTimeout(abortSignal) + ); + + return { + value: preview_token, + host, + tailUrl: tail_url, + }; +} diff --git a/packages/remote-bindings/src/utils/isAbortError.ts b/packages/remote-bindings/src/utils/isAbortError.ts new file mode 100644 index 0000000000..73c89857a6 --- /dev/null +++ b/packages/remote-bindings/src/utils/isAbortError.ts @@ -0,0 +1,14 @@ +/** + * Checking whether an error is an AbortError has changed. + * There is a legacy use of `.code` + * and a (mdn status: experimental) use of `.name` + * + * See MDN for more information: + * https://developer.mozilla.org/en-US/docs/Web/API/DOMException#aborterror + */ +export function isAbortError(err: unknown) { + const legacyAbortErroCheck = (err as { code: string }).code == "ABORT_ERR"; + const abortErrorCheck = err instanceof Error && err.name == "AbortError"; + + return legacyAbortErroCheck || abortErrorCheck; +} diff --git a/packages/remote-bindings/src/utils/miniflare.ts b/packages/remote-bindings/src/utils/miniflare.ts new file mode 100644 index 0000000000..b054613916 --- /dev/null +++ b/packages/remote-bindings/src/utils/miniflare.ts @@ -0,0 +1,35 @@ +import { LogLevel } from "miniflare"; +import { logger } from "../logger"; +import type { LoggerLevel } from "@cloudflare/workers-utils"; +import type { WorkerdStructuredLog } from "miniflare"; + +export function castLogLevel(level: LoggerLevel): LogLevel { + let key = level.toUpperCase() as Uppercase; + if (key === "LOG") { + key = "INFO"; + } + + return LogLevel[key]; +} + +export function handleStructuredLogs({ + level, + message, +}: WorkerdStructuredLog): void { + if (level === "warn") { + logger.warn(message); + return; + } + + if (level === "info" || level === "debug") { + logger.info(message); + return; + } + + if (level === "error") { + logger.error(message); + return; + } + + logger.log(message); +} diff --git a/packages/remote-bindings/src/utils/printing.ts b/packages/remote-bindings/src/utils/printing.ts new file mode 100644 index 0000000000..3727487d7b --- /dev/null +++ b/packages/remote-bindings/src/utils/printing.ts @@ -0,0 +1,82 @@ +import { inspect } from "node:util"; +import { logger } from "../logger"; +import type WebSocket from "ws"; + +/** + * Everything captured by the trace worker and sent to us via + * `wrangler tail` is structured JSON that deserializes to this type. + */ +export type TailEventMessage = { + /** + * The name of the script we're tailing + */ + scriptName?: string; + + /** + * The name of the entrypoint invoked by the Worker + */ + entrypoint?: string; + + /** + * Any exceptions raised by the worker + */ + exceptions: { + /** + * The name of the exception. + */ + name: string; + + /** + * The error message + */ + message: unknown; + + /** + * When the exception was raised/thrown + */ + timestamp: number; + + /** + * The stack trace of the exception, sourcemaps are already resolved. + */ + stack?: string; + }[]; + + /** + * Any logs sent out by the worker + */ + logs: { + message: unknown[]; + level: "debug" | "info" | "log" | "warn" | "error"; + timestamp: number; + }[]; + + /** + * When the event was triggered + */ + eventTimestamp: number; +}; + +/** + * Pretty-Print a Tail message from a realish-preview attached tail worker + * This is a simplified version of `prettyPrintLogs` that: + * - Only prints logs from HTTP triggers, since realish previews don't receive any other types of trigger. + * - Doesn't print the request log line (e.g. GET https://example.com/ - Ok) since in the realish + * context this is printed by Wrangler's proxy controller. + */ +export function realishPrintLogs(data: WebSocket.RawData): void { + const eventMessage: TailEventMessage = JSON.parse(data.toString()); + + if (eventMessage.logs.length > 0) { + eventMessage.logs.forEach(({ level, message }) => { + logger.console(level, ...message); + }); + } + + if (eventMessage.exceptions.length > 0) { + eventMessage.exceptions.forEach(({ name, message, stack }) => { + const errorLine = `${name}: ${typeof message === "string" ? message : inspect(message)}`; + logger.error(`${errorLine}${stack ? `\n${stack}` : ""}`); + }); + } +} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts new file mode 100644 index 0000000000..97cef16bf7 --- /dev/null +++ b/packages/remote-bindings/src/utils/remote.ts @@ -0,0 +1,193 @@ +import assert from "node:assert"; +import path from "node:path"; +import { getAuthFromEnv } from "@cloudflare/workers-auth"; +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { logger } from "../logger"; +import { isAbortError } from "./isAbortError"; +import type { Bundle } from "../startDevWorker/types"; +import type { + CfWorkerInit, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +/** + * Error thrown when a remote dev session fails due to an authentication + * problem. The error message is a user-friendly description with actionable + * guidance, tailored to the caller's authentication method (environment + * variable token vs. OAuth). The original API error is preserved as the + * error's {@link Error.cause | cause}. + * + * Consumers that catch this error can display {@link Error.message | message} + * directly — no additional logging helper is needed. + */ +export class RemoteSessionAuthenticationError extends UserError { + /** + * @param cause - The original error that triggered the authentication + * failure (e.g. an {@link APIError} with code 9106 or 10000). + */ + constructor(cause: unknown) { + const envAuth = getAuthFromEnv(); + + let errorMessage = + "Failed to establish remote session due to an authentication issue.\n"; + if (envAuth !== undefined) { + // The user is authenticating via an environment variable + const method = + "apiToken" in envAuth + ? "a custom API token (`CLOUDFLARE_API_TOKEN`)" + : "a Global API Key (`CLOUDFLARE_API_KEY`)"; + + errorMessage += + `It looks like you are authenticating via ${method} set in an environment variable.\n` + + "The token may be invalid or lack the required permissions for this operation.\n\n" + + "To fix this, verify that your token is valid and has the correct permissions.\n" + + "You can also run `wrangler whoami` to check your current authentication status."; + } else { + // The user is authenticating via OAuth (wrangler login) + errorMessage += + "Your credentials may have expired or been revoked.\n\n" + + "To fix this, try to:\n" + + " - Run `wrangler whoami` to check your current authentication status.\n" + + " - Run `wrangler logout` and then `wrangler login` to re-authenticate."; + } + + super(errorMessage, { + cause, + telemetryMessage: "remote dev authentication error", + }); + } +} + +export function handlePreviewSessionUploadError( + err: unknown, + accountId: string +): boolean { + assert(err && typeof err === "object"); + // we want to log the error, but not end the process + // since it could recover after the developer fixes whatever's wrong + // instead of logging the raw API error to the user, + // give them friendly instructions + if (!isAbortError(err)) { + // code 10049 happens when the preview token expires + if ("code" in err && err.code === 10049) { + logger.log("Preview token expired, fetching a new one"); + + // since we want a new preview token when this happens, + // lets increment the counter, and trigger a rerun of + // the useEffect above + return true; + } else if (!handleUserFriendlyError(err, accountId)) { + logger.error("Error on remote worker:", err); + } + } + return false; +} + +export function handlePreviewSessionCreationError( + err: unknown, + accountId: string +) { + assert(err && typeof err === "object"); + if (handleUserFriendlyError(err, accountId)) { + return; + } + if ( + "cause" in err && + (err.cause as { code: string; hostname: string })?.code === "ENOTFOUND" + ) { + logger.error( + `Could not access \`${(err.cause as { code: string; hostname: string }).hostname}\`. 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` + ); + } else if (err instanceof UserError) { + logger.error(err.message); + } + // we want to log the error, but not end the process + // since it could recover after the developer fixes whatever's wrong + else if (!isAbortError(err)) { + logger.error("Error while creating remote dev session:", err); + } +} + +export type CfWorkerInitWithName = Required> & + Omit & { + bindings: StartDevWorkerInput["bindings"]; + }; + +/** + * Create remote worker init from StartDevWorkerInput["bindings"] format + * (flat Record). + */ +export function createRemoteWorkerInit(props: { + bundle: Bundle; + name: string; + bindings: StartDevWorkerInput["bindings"]; + compatibilityDate: string | undefined; + compatibilityFlags: string[] | undefined; +}) { + const bindings = { ...props.bindings }; + + const init: CfWorkerInitWithName = { + name: props.name, + main: { + name: path.basename(props.bundle.path), + filePath: props.bundle.path, + type: props.bundle.type, + content: props.bundle.entrypointSource, + }, + modules: props.bundle.modules, + bindings, + migrations: undefined, // no migrations in dev + exports: undefined, + compatibility_date: props.compatibilityDate, + compatibility_flags: props.compatibilityFlags, + keepVars: true, + keepSecrets: true, + logpush: false, + sourceMaps: undefined, + containers: undefined, // Containers are not supported in remote dev mode + assets: undefined, + placement: undefined, // no placement in dev + tail_consumers: undefined, + streaming_tail_consumers: undefined, + limits: undefined, // no limits in preview - not supported yet but can be added + observability: undefined, // no observability in dev, + cache: undefined, // no cache in dev + }; + + return init; +} + +/** + * A switch for handling thrown error mappings to user friendly + * messages, does not perform any logic other than logging errors. + * @returns if the error was handled or not + */ +function handleUserFriendlyError(error: unknown, accountId?: string) { + if (error instanceof APIError) { + switch (error.code) { + // code 9106 and 10000 are authentication errors + case 9106: + case 10000: { + throw new RemoteSessionAuthenticationError(error); + } + + // for error 10063 (workers.dev subdomain required) + case 10063: { + const onboardingLink = accountId + ? `https://dash.cloudflare.com/${accountId}/workers/onboarding` + : "https://dash.cloudflare.com/?to=/:account/workers/onboarding"; + + logger.error( + `You need to register a workers.dev subdomain before running the dev command in remote mode. You can either enable local mode by pressing l, or register a workers.dev subdomain here: ${onboardingLink}` + ); + + return true; + } + + default: { + logger.error(error); + return true; + } + } + } +} diff --git a/packages/remote-bindings/src/worker.d.ts b/packages/remote-bindings/src/worker.d.ts new file mode 100644 index 0000000000..2f7abc72cd --- /dev/null +++ b/packages/remote-bindings/src/worker.d.ts @@ -0,0 +1,4 @@ +declare module "worker:*" { + const source: string; + export default source; +} diff --git a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts similarity index 91% rename from packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts rename to packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 4a43f4518c..0ca9c5b8a6 100644 --- a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -1,7 +1,15 @@ import { newWorkersRpcResponse } from "capnweb"; import { EmailMessage } from "cloudflare:email"; -interface Env extends Record {} +type Env = Record; + +type SendEmailInput = + | Parameters[0] + | { + from: string; + to: string; + "EmailMessage::raw": ReadableStream; + }; class BindingNotFoundError extends Error { constructor(name?: string) { @@ -42,7 +50,7 @@ function getExposedJSRPCBinding(request: Request, env: Env) { if (targetBinding.constructor.name === "SendEmail") { return { - async send(e: any) { + async send(e: SendEmailInput) { // Check if this is an EmailMessage (has EmailMessage::raw property) or MessageBuilder if ("EmailMessage::raw" in e) { // EmailMessage API - reconstruct the EmailMessage object @@ -60,10 +68,11 @@ function getExposedJSRPCBinding(request: Request, env: Env) { }; } - if (url.searchParams.has("MF-Dispatch-Namespace-Options")) { - const { name, args, options } = JSON.parse( - url.searchParams.get("MF-Dispatch-Namespace-Options")! - ); + const dispatchNamespaceOptions = url.searchParams.get( + "MF-Dispatch-Namespace-Options" + ); + if (dispatchNamespaceOptions) { + const { name, args, options } = JSON.parse(dispatchNamespaceOptions); return (targetBinding as DispatchNamespace).get(name, args, options); } diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts new file mode 100644 index 0000000000..fb89a87794 --- /dev/null +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -0,0 +1,271 @@ +import { + createDeferred, + type DeferredPromise, + rewriteUrlInHeaderValue, + urlFromParts, +} from "../../src/startDevWorker/utils"; +import type { + ProxyData, + ProxyWorkerIncomingRequestBody, + ProxyWorkerOutgoingRequestBody, +} from "../../src/startDevWorker/events"; + +interface Env { + PROXY_CONTROLLER: Fetcher; + PROXY_CONTROLLER_AUTH_SECRET: string; + DURABLE_OBJECT: DurableObjectNamespace; +} + +// request.cf.hostMetadata is verbose to type using the workers-types Request -- this allows us to have Request correctly typed in this scope +type Request = Parameters< + NonNullable< + ExportedHandler["fetch"] + > +>[0]; + +export default { + fetch(req, env) { + const singleton = env.DURABLE_OBJECT.idFromName(""); + const inspectorProxy = env.DURABLE_OBJECT.get(singleton); + + return inspectorProxy.fetch(req); + }, +} as ExportedHandler; + +export class ProxyWorker implements DurableObject { + constructor( + _state: DurableObjectState, + readonly env: Env + ) {} + + proxyData?: ProxyData; + requestQueue = new Map>(); + requestRetryQueue = new Map>(); + + fetch(request: Request) { + if (isRequestFromProxyController(request, this.env)) { + // requests from ProxyController + + return this.processProxyControllerRequest(request); + } + + // regular requests to be proxied + const deferred = createDeferred(); + + this.requestQueue.set(request, deferred); + this.processQueue(); + + return deferred.promise; + } + + processProxyControllerRequest(request: Request) { + const event = request.cf?.hostMetadata; + switch (event?.type) { + case "pause": + this.proxyData = undefined; + break; + + case "play": + this.proxyData = event.proxyData; + this.processQueue(); + + break; + } + + return new Response(null, { status: 204 }); + } + + /** + * Process requests that are being retried first, then process newer requests. + * Requests that are being retried are, by definition, older than requests which haven't been processed yet. + * We don't need to be more accurate than this re ordering, since the requests are being fired off synchronously. + */ + *getOrderedQueue() { + yield* this.requestRetryQueue; + yield* this.requestQueue; + } + + processQueue() { + const { proxyData } = this; // store proxyData at the moment this function was called + if (proxyData === undefined) { + return; + } + + for (const [request, deferredResponse] of this.getOrderedQueue()) { + this.requestRetryQueue.delete(request); + this.requestQueue.delete(request); + + const outerUrl = new URL(request.url); + const headers = new Headers(request.headers); + + // override url parts for proxying + const userWorkerUrl = new URL(request.url); + Object.assign(userWorkerUrl, proxyData.userWorkerUrl); + + // set request.url in the UserWorker + const innerUrl = urlFromParts( + proxyData.userWorkerInnerUrlOverrides ?? {}, + request.url + ); + + // Preserve client `Accept-Encoding`, rather than using Worker's default + // of `Accept-Encoding: br, gzip` + const encoding = request.cf?.clientAcceptEncoding; + if (encoding !== undefined) { + headers.set("Accept-Encoding", encoding); + } + + rewriteUrlRelatedHeaders(headers, outerUrl, innerUrl); + + // Set after `rewriteUrlRelatedHeaders` so that any occurrences of the + // outer host inside the URL's query string (e.g. `?redirect_uri=`) + // are preserved in `request.url` inside the user worker. + headers.set("MF-Original-URL", innerUrl.href); + + // merge proxyData headers with the request headers + for (const [key, value] of Object.entries(proxyData.headers ?? {})) { + if (value === undefined) { + continue; + } + + if (key.toLowerCase() === "cookie") { + const existing = request.headers.get("cookie") ?? ""; + headers.set("cookie", `${existing};${value}`); + } else { + headers.set(key, value); + } + } + + // explicitly NOT await-ing this promise, we are in a loop and want to process the whole queue quickly + synchronously + void fetch(userWorkerUrl, new Request(request, { headers })) + .then(async (res) => { + res = new Response(res.body, res); + rewriteUrlRelatedHeaders(res.headers, innerUrl, outerUrl); + + await checkForPreviewTokenError(res, this.env, proxyData); + + deferredResponse.resolve(res); + }) + .catch((error: Error) => { + // errors here are network errors or from response post-processing + // to catch only network errors, use the 2nd param of the fetch.then() + + // we have crossed an async boundary, so proxyData may have changed + // if proxyData.userWorkerUrl has changed, it means there is a new downstream UserWorker + // and that this error is stale since it was for a request to the old UserWorker + // so here we construct a newUserWorkerUrl so we can compare it to the (old) userWorkerUrl + const newUserWorkerUrl = + this.proxyData && urlFromParts(this.proxyData.userWorkerUrl); + + // only report errors if the downstream proxy has NOT changed + if (userWorkerUrl.href === newUserWorkerUrl?.href) { + void sendMessageToProxyController(this.env, { + type: "error", + error: { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + }, + }); + + deferredResponse.reject(error); + } + + // if the request can be retried (subset of idempotent requests which have no body), requeue it + else if (request.method === "GET" || request.method === "HEAD") { + this.requestRetryQueue.set(request, deferredResponse); + // we would only end up here if the downstream UserWorker is chang*ing* + // i.e. we are in a `pause`d state and expecting a `play` message soon + // this request will be processed (retried) when the `play` message arrives + // for that reason, we do not need to call `this.processQueue` here + // (but, also, it can't hurt to call it since it bails when + // in a `pause`d state i.e. `this.proxyData` is undefined) + } + + // if the request cannot be retried, respond with 503 Service Unavailable + // important to note, this is not an (unexpected) error -- it is an acceptable flow of local development + // it would be incorrect to retry non-idempotent requests + // and would require cloning all body streams to avoid stream reuse (which is inefficient but not out of the question in the future) + // this is a good enough UX for now since it solves the most common GET use-case + else { + deferredResponse.resolve( + new Response( + "Your worker restarted mid-request. Please try sending the request again. Only GET or HEAD requests are retried automatically.", + { + status: 503, + headers: { "Retry-After": "0" }, + } + ) + ); + } + }); + } + } +} + +function isRequestFromProxyController(req: Request, env: Env): boolean { + return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; +} +function sendMessageToProxyController( + env: Env, + message: ProxyWorkerOutgoingRequestBody +) { + return env.PROXY_CONTROLLER.fetch("http://dummy", { + method: "POST", + body: JSON.stringify(message), + }); +} + +async function checkForPreviewTokenError( + response: Response, + env: Env, + proxyData: ProxyData +) { + if (response.status !== 400) { + return; + } + + // At this point HTMLRewriter tries to parse the compressed stream, + // so we clone and read the text instead. + const clone = response.clone(); + const text = await clone.text(); + // Naive string match should be good enough when combined with status code check. + // "Invalid Workers Preview configuration" is the HTML error returned when the + // preview token has expired. "error code: 1031" is a text/plain error returned + // by remote bindings (e.g. Workers AI) when their underlying session has timed out. + // Both indicate the preview session needs to be refreshed. + if ( + text.includes("Invalid Workers Preview configuration") || + text.includes("error code: 1031") + ) { + void sendMessageToProxyController(env, { + type: "previewTokenExpired", + proxyData, + }); + } +} +/** + * Rewrite references to URLs in request/response headers. + * + * This function is used to map the URLs in headers like Origin and Access-Control-Allow-Origin + * so that this proxy is transparent to the Client Browser and User Worker. + */ +function rewriteUrlRelatedHeaders(headers: Headers, from: URL, to: URL) { + const setCookie = headers.getAll("Set-Cookie"); + headers.delete("Set-Cookie"); + headers.forEach((value, key) => { + if (typeof value === "string" && value.includes(from.host)) { + headers.set(key, rewriteUrlInHeaderValue(value, from, to)); + } + }); + for (const cookie of setCookie) { + headers.append( + "Set-Cookie", + cookie.replace( + new RegExp(`Domain=${from.hostname}($|;|,)`), + `Domain=${to.hostname}$1` + ) + ); + } +} diff --git a/packages/remote-bindings/tsconfig.json b/packages/remote-bindings/tsconfig.json new file mode 100644 index 0000000000..9f7ff72405 --- /dev/null +++ b/packages/remote-bindings/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types/experimental", "@types/node"] + }, + "include": ["src", "templates"] +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts new file mode 100644 index 0000000000..d11d1f8cd2 --- /dev/null +++ b/packages/remote-bindings/tsdown.config.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import { defineConfig } from "tsdown"; +import { embedWorkersPlugin } from "./scripts/embed-workers.ts"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + external: (id) => { + const unprefixedId = id.charCodeAt(0) === 0 ? id.slice(1) : id; + return ( + !unprefixedId.startsWith("worker:") && + !unprefixedId.startsWith(".") && + !path.isAbsolute(unprefixedId) + ); + }, + plugins: [embedWorkersPlugin()], +}); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json new file mode 100644 index 0000000000..e1ecb71251 --- /dev/null +++ b/packages/remote-bindings/turbo.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "test:ci": { + "env": ["CLOUDFLARE_CF_AUTH"] + } + } +} diff --git a/packages/remote-bindings/vitest.config.mts b/packages/remote-bindings/vitest.config.mts new file mode 100644 index 0000000000..0c3c424439 --- /dev/null +++ b/packages/remote-bindings/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; +import { embedWorkersPlugin } from "./scripts/embed-workers"; + +export default mergeConfig( + configShared, + defineConfig({ + plugins: [embedWorkersPlugin()], + }) +); diff --git a/packages/runtime-types/CHANGELOG.md b/packages/runtime-types/CHANGELOG.md index e190f42b46..a5cfe928ac 100644 --- a/packages/runtime-types/CHANGELOG.md +++ b/packages/runtime-types/CHANGELOG.md @@ -1,5 +1,19 @@ # @cloudflare/runtime-types +## 0.0.4 + +### Patch Changes + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9)]: + - miniflare@4.20260721.0 + +## 0.0.3 + +### Patch Changes + +- Updated dependencies [[`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983), [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d), [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c), [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8), [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645)]: + - miniflare@4.20260714.0 + ## 0.0.2 ### Patch Changes diff --git a/packages/runtime-types/package.json b/packages/runtime-types/package.json index cd408de0a6..a8f8891a4b 100644 --- a/packages/runtime-types/package.json +++ b/packages/runtime-types/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/runtime-types", - "version": "0.0.2", + "version": "0.0.4", "private": true, "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/runtime-types#readme", "bugs": { diff --git a/packages/turbo-r2-archive/CHANGELOG.md b/packages/turbo-r2-archive/CHANGELOG.md index f6c0f2191d..4829f0c8b9 100644 --- a/packages/turbo-r2-archive/CHANGELOG.md +++ b/packages/turbo-r2-archive/CHANGELOG.md @@ -1,5 +1,11 @@ # turbo-r2-archive +## 0.0.5 + +### Patch Changes + +- [#14707](https://github.com/cloudflare/workers-sdk/pull/14707) [`b38f494`](https://github.com/cloudflare/workers-sdk/commit/b38f494204e5e08e561b8f198ef928188e554868) Thanks [@emily-shen](https://github.com/emily-shen)! - Update zod to v4 + ## 0.0.4 ### Patch Changes diff --git a/packages/turbo-r2-archive/package.json b/packages/turbo-r2-archive/package.json index a07d618d43..d6822aa6da 100644 --- a/packages/turbo-r2-archive/package.json +++ b/packages/turbo-r2-archive/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/turbo-r2-archive", - "version": "0.0.4", + "version": "0.0.5", "private": true, "description": "TurboRepo API Compliant Remote Caching w/ Cloudflare Workers using R2", "keywords": [ @@ -17,9 +17,9 @@ "type:check": "tsc" }, "dependencies": { - "@hono/zod-validator": "^0.4.2", + "@hono/zod-validator": "^0.8.0", "hono": "^4.12.5", - "zod": "^3.22.3" + "zod": "catalog:default" }, "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", diff --git a/packages/vite-plugin-cloudflare/CHANGELOG.md b/packages/vite-plugin-cloudflare/CHANGELOG.md index 8e4925a560..299dcd3f0e 100644 --- a/packages/vite-plugin-cloudflare/CHANGELOG.md +++ b/packages/vite-plugin-cloudflare/CHANGELOG.md @@ -1,5 +1,55 @@ # @cloudflare/vite-plugin +## 1.46.0 + +### Minor Changes + +- [#14724](https://github.com/cloudflare/workers-sdk/pull/14724) [`a50f73a`](https://github.com/cloudflare/workers-sdk/commit/a50f73a06bb7b078268ce9cebb4d1c16f79a3144) Thanks [@jamesopstad](https://github.com/jamesopstad)! - Add a `settings` export to the experimental `cloudflare.config.ts` config + + Account-level settings (`accountId`, `complianceRegion`) now live in a dedicated, named `settings` export authored via `defineSettings`, rather than on the Worker config. A `cloudflare.config.ts` can export at most one `settings` object; the Worker itself is the `default` export. + + ```ts + // cloudflare.config.ts + import { defineSettings, defineWorker } from "wrangler/experimental-config"; + import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; + + export const settings = defineSettings({ + accountId: "", + }); + + export default defineWorker({ + name: "my-worker", + entrypoint, + compatibilityDate: "2026-05-18", + }); + ``` + + This is only used behind the experimental new-config path (`wrangler --experimental-new-config` and the `@cloudflare/vite-plugin` `experimental.newConfig` option). + +### Patch Changes + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`a0a091b`](https://github.com/cloudflare/workers-sdk/commit/a0a091b9246c5e10408f57342b3275659c9655e3), [`f03b108`](https://github.com/cloudflare/workers-sdk/commit/f03b10854d983c353fd4f3d6621b5ed716379ba3), [`deae171`](https://github.com/cloudflare/workers-sdk/commit/deae1719b276b9ce2bb67a36671b5cf806ef3801), [`0df3d43`](https://github.com/cloudflare/workers-sdk/commit/0df3d432353f39b6a90c340c268c83a7ac0b7d5c), [`d83a476`](https://github.com/cloudflare/workers-sdk/commit/d83a476bab53f0266a67790242f855aab6e0468c), [`4e92e32`](https://github.com/cloudflare/workers-sdk/commit/4e92e32e1f1c27dcd463bcf38ed79e0d1b046679), [`d1d6945`](https://github.com/cloudflare/workers-sdk/commit/d1d69450decfb319a2bbf61e4c042b0511ab2618), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`a0c8bb1`](https://github.com/cloudflare/workers-sdk/commit/a0c8bb118e04eebba870a6fbe9f5041095b04637), [`a50f73a`](https://github.com/cloudflare/workers-sdk/commit/a50f73a06bb7b078268ce9cebb4d1c16f79a3144), [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`c82d96b`](https://github.com/cloudflare/workers-sdk/commit/c82d96ba63a3b343b520e781a070889251868d9a), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9), [`f75ae5d`](https://github.com/cloudflare/workers-sdk/commit/f75ae5d02576d82aad4723b9e17ccb26277b69ab)]: + - miniflare@4.20260721.0 + - wrangler@4.113.0 + +## 1.45.1 + +### Patch Changes + +- [#14610](https://github.com/cloudflare/workers-sdk/pull/14610) [`e727842`](https://github.com/cloudflare/workers-sdk/commit/e727842b2c7f64de9e06623f1df32df5488681e4) Thanks [@martijnwalraven](https://github.com/martijnwalraven)! - Keep watching config changes after a failed dev server restart + + Previously, when a config change made the dev server restart fail — for example because the updated Worker config was invalid — the plugin stopped watching config changes entirely: the change handler (covering the Worker config files, local dev vars, and the assets configuration) removed itself before restarting, and only a successfully created server would register a fresh one. Since Vite keeps the current server running when a restart fails, every subsequent config change (including the one that fixes the config) was silently ignored for the rest of the session. + + The handler now stays registered and guards against re-entrant restarts instead, so fixing the config restarts the dev server as expected. + +- [#14418](https://github.com/cloudflare/workers-sdk/pull/14418) [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Improve routing performance for Workers with assets + + Reduce request handling latency by streamlining the router Worker's request path. The loopback infrastructure remains available for future use. + +- Updated dependencies [[`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983), [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce), [`3de70df`](https://github.com/cloudflare/workers-sdk/commit/3de70dfd32f823677a9d20311ee087fd7e69d51a), [`c79504f`](https://github.com/cloudflare/workers-sdk/commit/c79504f90956405f5fab59448ba53dcf44b8d3a2), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d), [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c), [`c7dbe1a`](https://github.com/cloudflare/workers-sdk/commit/c7dbe1a3d527d534d4069080c56e364d33d6a455), [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8), [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645), [`4e1a7a7`](https://github.com/cloudflare/workers-sdk/commit/4e1a7a7fe566774dca376c5d569cab56b14f34e3), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981)]: + - miniflare@4.20260714.0 + - wrangler@4.112.0 + ## 1.45.0 ### Minor Changes diff --git a/packages/vite-plugin-cloudflare/README.md b/packages/vite-plugin-cloudflare/README.md index 1db2a73938..360fe7af6f 100644 --- a/packages/vite-plugin-cloudflare/README.md +++ b/packages/vite-plugin-cloudflare/README.md @@ -23,14 +23,14 @@ Full documentation can be found [here](https://developers.cloudflare.com/workers - Uses the Vite [Environment API](https://vite.dev/guide/api-environment) to integrate Vite with the Workers runtime - Provides direct access to [Workers runtime APIs](https://developers.cloudflare.com/workers/runtime-apis/) and [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/) - Builds your front-end assets for deployment to Cloudflare, enabling you to build static sites, SPAs, and full-stack applications -- Official support for [TanStack Start](https://tanstack.com/start/) and [React Router v7](https://reactrouter.com/) with server-side rendering +- Official support for [TanStack Start](https://tanstack.com/start/) and [React Router v8](https://reactrouter.com/) with server-side rendering - Leverages Vite's hot module replacement for consistently fast updates - Supports `vite preview` for previewing your build output in the Workers runtime prior to deployment ## Use cases - [TanStack Start](https://tanstack.com/start/) -- [React Router v7](https://reactrouter.com/) +- [React Router v8](https://reactrouter.com/) - Support for more full-stack frameworks is coming soon - Static sites, such as single-page applications, with or without an integrated backend API - Standalone Workers diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index be7c089765..7f8221b130 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.45.0", + "version": "1.46.0", "description": "Cloudflare plugin for Vite", "keywords": [ "cloudflare", @@ -64,6 +64,7 @@ "@cloudflare/config": "workspace:*", "@cloudflare/containers-shared": "workspace:*", "@cloudflare/mock-npm-registry": "workspace:*", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/runtime-types": "workspace:*", "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", diff --git a/packages/vite-plugin-cloudflare/playground/vitest-setup.ts b/packages/vite-plugin-cloudflare/playground/vitest-setup.ts index 9fa8d0e86d..fe9311d6e9 100644 --- a/packages/vite-plugin-cloudflare/playground/vitest-setup.ts +++ b/packages/vite-plugin-cloudflare/playground/vitest-setup.ts @@ -18,6 +18,7 @@ import type { PluginOption, PreviewServer, ResolvedConfig, + Rolldown, Rollup, UserConfig, ViteDevServer, @@ -74,7 +75,7 @@ export let resolvedConfig: ResolvedConfig = export let page: Page = undefined as unknown as Page; export let browser: Browser = undefined as unknown as Browser; export let viteTestUrl: string = ""; -export const watcher: Rollup.RollupWatcher | undefined = undefined; +export const watcher: Rolldown.RolldownWatcher | undefined = undefined; export function setViteUrl(url: string): void { viteTestUrl = url; @@ -350,10 +351,10 @@ export async function startDefaultServe(): Promise< * Send the rebuild complete message in build watch */ export async function notifyRebuildComplete( - rollupWatcher: Rollup.RollupWatcher -): Promise { + rollupWatcher: Rolldown.RolldownWatcher +): Promise { let resolveFn: undefined | (() => void); - const callback = (event: Rollup.RollupWatcherEvent): void => { + const callback = (event: Rolldown.RolldownWatcherEvent): void => { if (event.code === "END") { resolveFn?.(); } diff --git a/packages/vite-plugin-cloudflare/src/build-output-preview.ts b/packages/vite-plugin-cloudflare/src/build-output-preview.ts index 8b6e9960e8..2d82bb2ac6 100644 --- a/packages/vite-plugin-cloudflare/src/build-output-preview.ts +++ b/packages/vite-plugin-cloudflare/src/build-output-preview.ts @@ -2,11 +2,13 @@ import assert from "node:assert"; import * as fs from "node:fs"; import { convertToWranglerConfig, + getRootConfigPath, getWorkerAssetsDir, getWorkerBundleDir, getWorkerConfigPath, getWorkersDir, OutputWorkerSchema, + SettingsSchema, WORKER_CONFIG_FILENAME, } from "@cloudflare/config"; import { normalizeAndValidateConfig } from "@cloudflare/workers-utils"; @@ -49,6 +51,13 @@ export async function readBuildOutputWorkers( ); } + // Read the optional top-level `config.json` holding project-level + // settings (`account_id`, `compliance_region`) shared by every Worker. + const rootConfigPath = getRootConfigPath(root); + const settings = fs.existsSync(rootConfigPath) + ? SettingsSchema.parse(JSON.parse(fs.readFileSync(rootConfigPath, "utf-8"))) + : undefined; + return workerNames.map((workerName) => { const configPath = getWorkerConfigPath(root, workerName); assert( @@ -59,7 +68,7 @@ export async function readBuildOutputWorkers( JSON.parse(fs.readFileSync(configPath, "utf-8")) ); const { manifest, ...inputShape } = outputConfig; - const rawConfig = convertToWranglerConfig(inputShape); + const rawConfig = convertToWranglerConfig(inputShape, settings); const { config, diagnostics } = normalizeAndValidateConfig( rawConfig, diff --git a/packages/vite-plugin-cloudflare/src/build.ts b/packages/vite-plugin-cloudflare/src/build.ts index 9575605cea..1e7e28ee86 100644 --- a/packages/vite-plugin-cloudflare/src/build.ts +++ b/packages/vite-plugin-cloudflare/src/build.ts @@ -22,6 +22,9 @@ export function createBuildApp( assert(clientEnvironment, `No "client" environment`); const defaultHtmlPath = path.resolve(builder.config.root, "index.html"); const hasClientEntry = + /* eslint-disable-next-line @typescript-eslint/no-deprecated -- + We use `rollupOptions` for backward compatibility with Vite 6 and 7, where `rolldownOptions` does not exist. + In Vite 8, `rollupOptions` is aliased to `rolldownOptions` so this works across all supported versions. */ clientEnvironment.config.build.rollupOptions.input || fs.existsSync(defaultHtmlPath); @@ -155,6 +158,9 @@ async function fallbackBuild( builder: vite.ViteBuilder, environment: vite.BuildEnvironment ): Promise { + /* eslint-disable-next-line @typescript-eslint/no-deprecated -- + We use `rollupOptions` for backward compatibility with Vite 6 and 7, where `rolldownOptions` does not exist. + In Vite 8, `rollupOptions` is aliased to `rolldownOptions` so this works across all supported versions. */ environment.config.build.rollupOptions = { input: VIRTUAL_CLIENT_FALLBACK_ENTRY, logLevel: "silent", diff --git a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts index c99db53ffe..f02facc43a 100644 --- a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts +++ b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts @@ -222,13 +222,13 @@ export function createCloudflareEnvironmentOptions({ isParentEnvironment: boolean; hasNodeJsCompat: boolean; }): vite.EnvironmentOptions { - const rollupOptions: vite.Rollup.RollupOptions = isParentEnvironment + const rollupOptions = isParentEnvironment ? { input: { [MAIN_ENTRY_NAME]: VIRTUAL_WORKER_ENTRY, }, // workerd checks the types of the exports so we need to ensure that additional exports are not added to the entry module - preserveEntrySignatures: "strict", + preserveEntrySignatures: "strict" as const, } : {}; const define = getProcessEnvReplacements(hasNodeJsCompat, mode); diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 6c5d02bc1e..e2f07eb6c6 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -210,7 +210,7 @@ export class PluginContext { getWorkerNewConfig( environmentName: string ): ParsedInputWorkerConfig | undefined { - return this.#getWorker(environmentName)?.parsedNewConfig; + return this.#getWorker(environmentName)?.parsedNewWorkerConfig; } get allWorkerConfigs(): Unstable_Config[] { diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 5b9eef7644..ee8551ac8c 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -3,10 +3,12 @@ import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { format } from "node:util"; import { generateContainerBuildId, resolveDockerHost, } from "@cloudflare/containers-shared"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; import { getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, @@ -46,6 +48,10 @@ import type { } from "./context"; import type { PersistState } from "./plugin-config"; import type { ModuleType } from "@cloudflare/config"; +import type { + RemoteBindingsLogger, + RemoteProxySessionData, +} from "@cloudflare/remote-bindings"; import type { MiniflareOptions, ModuleRuleType, @@ -53,11 +59,7 @@ import type { WorkerOptions, } from "miniflare"; import type * as vite from "vite"; -import type { - Binding, - RemoteProxySession, - SourcelessWorkerOptions, -} from "wrangler"; +import type { SourcelessWorkerOptions } from "wrangler"; const INTERNAL_WORKERS_COMPATIBILITY_DATE = "2024-10-04"; // Used to mark HTML assets as being in the public directory so that they can be resolved from their root relative paths @@ -101,12 +103,34 @@ const WRAPPER_PATH = "__VITE_WORKER_ENTRY__"; /** Map that maps worker configPaths to their existing remote proxy session data (if any) */ const remoteProxySessionsDataMap = new Map< string, - { - session: RemoteProxySession; - remoteBindings: Record; - } | null + RemoteProxySessionData | null >(); +function createRemoteBindingsLogger(logger: vite.Logger): RemoteBindingsLogger { + const write = ( + level: "info" | "warn" | "error", + args: Parameters + ) => logger[level](format(...args)); + + return { + loggerLevel: "log", + debug() {}, + log: (...args) => write("info", args), + info: (...args) => write("info", args), + warn: (...args) => write("warn", args), + error: (...args) => write("error", args), + console(method, ...args) { + if (method === "error") { + write("error", args); + } else if (method === "warn") { + write("warn", args); + } else if (method !== "debug" && method !== "trace") { + write("info", args); + } + }, + }; +} + export async function getDevMiniflareOptions( ctx: AssetsOnlyPluginContext | WorkersPluginContext, viteDevServer: vite.ViteDevServer @@ -270,14 +294,21 @@ export async function getDevMiniflareOptions( !resolvedPluginConfig.remoteBindings ? // if remote bindings are not enabled then the proxy session can simply be null null - : await wrangler.maybeStartOrUpdateRemoteProxySession( + : await maybeStartOrUpdateRemoteProxySession( { name: worker.config.name, bindings: bindings ?? {}, + complianceRegion: worker.config.compliance_region, account_id: worker.config.account_id, profileDir: resolvedViteConfig.root, }, - preExistingRemoteProxySession ?? null + preExistingRemoteProxySession ?? null, + undefined, + { + logger: createRemoteBindingsLogger( + viteDevServer.config.logger + ), + } ); if (worker.config.configPath && remoteProxySessionData) { @@ -665,14 +696,21 @@ export async function getPreviewMiniflareOptions( const remoteProxySessionData = !resolvedPluginConfig.remoteBindings ? // if remote bindings are not enabled then the proxy session can simply be null null - : await wrangler.maybeStartOrUpdateRemoteProxySession( + : await maybeStartOrUpdateRemoteProxySession( { name: workerConfig.name, bindings: bindings ?? {}, + complianceRegion: workerConfig.compliance_region, account_id: workerConfig.account_id, profileDir: resolvedViteConfig.root, }, - preExistingRemoteProxySessionData ?? null + preExistingRemoteProxySessionData ?? null, + undefined, + { + logger: createRemoteBindingsLogger( + vitePreviewServer.config.logger + ), + } ); if (workerConfig.configPath && remoteProxySessionData) { diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts index 59602b0d45..34b0162c27 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -4,9 +4,7 @@ import * as path from "node:path"; import { convertToWranglerConfig, generateTypes, - InputWorkerSchema, - loadConfig, - resolveWorkerDefinition, + loadAndValidateConfig, } from "@cloudflare/config"; import { generateRuntimeTypes, @@ -37,7 +35,10 @@ import type { WorkerResolvedConfig, WorkerWithServerLogicResolvedConfig, } from "./workers-configs"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { + ParsedConfigExports, + ParsedInputWorkerConfig, +} from "@cloudflare/config"; import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { Unstable_Config } from "wrangler"; @@ -223,7 +224,7 @@ export interface Worker { config: ResolvedWorkerConfig; nodeJsCompat: NodeJsCompat | undefined; devOnly: DevOnly | undefined; - parsedNewConfig: ParsedInputWorkerConfig | undefined; + parsedNewWorkerConfig: ParsedInputWorkerConfig | undefined; } interface BaseResolvedConfig { @@ -242,12 +243,15 @@ interface NonPreviewResolvedConfig extends BaseResolvedConfig { environmentNameToWorkerMap: Map; environmentNameToChildEnvironmentNamesMap: Map; prerenderWorkerEnvironmentName: string | undefined; + // The full parsed `cloudflare.config.ts` exports (every worker export plus + // the optional `settings` export), keyed by export name. Undefined when + // new-config is not in use. + parsedNewConfig: ParsedConfigExports | undefined; } export interface AssetsOnlyResolvedConfig extends NonPreviewResolvedConfig { type: "assets-only"; config: ResolvedAssetsOnlyConfig; - parsedNewConfig: ParsedInputWorkerConfig | undefined; rawConfigs: { entryWorker: AssetsOnlyWorkerResolvedConfig; }; @@ -483,7 +487,7 @@ export async function resolvePluginConfig( let configPath: string | undefined; let rawConfigOverride: RawConfig | undefined; - let parsedNewConfig: ParsedInputWorkerConfig | undefined; + let parsedNewConfig: ParsedConfigExports | undefined; if (resolvedNewConfig) { if (pluginConfig.configPath) { @@ -637,12 +641,17 @@ export async function resolvePluginConfig( validateAndAddEnvironmentName(entryWorkerEnvironmentName); + const entryWorkerNewConfig = + parsedNewConfig?.default?.type === "worker" + ? parsedNewConfig.default + : undefined; + environmentNameToWorkerMap.set( entryWorkerEnvironmentName, resolveWorker( entryWorkerResolvedConfig.config, pluginConfig.assetsOnly, - parsedNewConfig + entryWorkerNewConfig ) ); @@ -723,6 +732,7 @@ export async function resolvePluginConfig( environmentNameToWorkerMap, environmentNameToChildEnvironmentNamesMap, prerenderWorkerEnvironmentName, + parsedNewConfig, entryWorkerEnvironmentName, staticRouting, remoteBindings, @@ -769,7 +779,7 @@ export function resolveDevOnly(devOnly: DevOnly | undefined): boolean { function resolveWorker( workerConfig: ResolvedWorkerConfig, devOnly: DevOnly | undefined, - parsedNewConfig?: ParsedInputWorkerConfig + parsedNewWorkerConfig?: ParsedInputWorkerConfig ): Worker { return { config: workerConfig, @@ -777,7 +787,7 @@ function resolveWorker( ? new NodeJsCompat(workerConfig) : undefined, devOnly, - parsedNewConfig, + parsedNewWorkerConfig, }; } @@ -803,7 +813,7 @@ async function loadNewConfig(options: { types: { generate: boolean; includeRuntime: boolean }; }): Promise<{ rawConfig: RawConfig; - parsedConfig: ParsedInputWorkerConfig; + parsedConfig: ParsedConfigExports; configPath: string; dependencies: Set; }> { @@ -815,34 +825,45 @@ async function loadNewConfig(options: { ); } - const { config: rawExport, dependencies } = await loadConfig(configPath); - - const resolved = await resolveWorkerDefinition(rawExport, { + const { result, dependencies } = await loadAndValidateConfig(configPath, { mode: options.mode, }); - const parsed = InputWorkerSchema.safeParse(resolved); - if (!parsed.success) { + if (!result.success) { throw new Error( - `Invalid \`${NEW_CONFIG_FILENAME}\`:\n${parsed.error.message}` + `Invalid \`${NEW_CONFIG_FILENAME}\`:\n${result.error.message}` ); } - const rawConfig = convertToWranglerConfig(parsed.data); + const worker = + result.data.default?.type === "worker" ? result.data.default : undefined; + + if (worker === undefined) { + throw new Error( + `\`${NEW_CONFIG_FILENAME}\` must have a default worker export.` + ); + } + + const settings = + result.data.settings?.type === "settings" + ? result.data.settings + : undefined; + + const rawConfig: RawConfig = convertToWranglerConfig(worker, settings); if (options.command === "serve" && options.types.generate) { await writeWorkerConfigurationDts({ root: options.root, configPath, includeRuntime: options.types.includeRuntime, - compatibilityDate: parsed.data.compatibilityDate, - compatibilityFlags: parsed.data.compatibilityFlags ?? [], + compatibilityDate: worker.compatibilityDate, + compatibilityFlags: worker.compatibilityFlags ?? [], }); } return { rawConfig, - parsedConfig: parsed.data, + parsedConfig: result.data, configPath, dependencies, }; diff --git a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts index aa6f8a9fe5..0ec1aaec7a 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts @@ -1,6 +1,9 @@ import assert from "node:assert"; import * as path from "node:path"; -import { writeOutputWorkerConfig } from "@cloudflare/config"; +import { + writeOutputWorkerConfig, + writeRootOutputConfig, +} from "@cloudflare/config"; import { MAIN_ENTRY_NAME } from "../cloudflare-environment"; import { createPlugin } from "../utils"; import type { ModuleType } from "@cloudflare/config"; @@ -20,15 +23,18 @@ export const buildOutputPlugin = createPlugin("build-output", (ctx) => { ctx.resolvedPluginConfig.type === "assets-only" && this.environment.name === "client" ) { - const workerNewConfig = ctx.resolvedPluginConfig.parsedNewConfig; + const defaultExport = ctx.resolvedPluginConfig.parsedNewConfig?.default; + const workerNewConfig = + defaultExport?.type === "worker" ? defaultExport : undefined; assert( workerNewConfig, - "Expected parsedNewConfig on assets-only resolved config" + "Expected a default worker export on assets-only resolved config" ); await writeOutputWorkerConfig( ctx.resolvedViteConfig.root, workerNewConfig ); + await writeRootSettings(); return; } @@ -77,8 +83,22 @@ export const buildOutputPlugin = createPlugin("build-output", (ctx) => { modules, } ); + await writeRootSettings(); }, }; + + async function writeRootSettings(): Promise { + if (ctx.resolvedPluginConfig.type === "preview") { + return; + } + const settingsExport = ctx.resolvedPluginConfig.parsedNewConfig?.settings; + const settings = + settingsExport?.type === "settings" ? settingsExport : undefined; + if (!settings) { + return; + } + await writeRootOutputConfig(ctx.resolvedViteConfig.root, settings); + } }); /** diff --git a/packages/vite-plugin-cloudflare/src/plugins/config.ts b/packages/vite-plugin-cloudflare/src/plugins/config.ts index 91f1ed8ff9..59c53941a0 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/config.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/config.ts @@ -382,10 +382,13 @@ function forceBuildOutputDirs( if (!environment) { continue; } - assert(worker.parsedNewConfig, "Expected parsedNewConfig to be defined"); + assert( + worker.parsedNewWorkerConfig, + "Expected parsedNewWorkerConfig to be defined" + ); environment.build.outDir = getWorkerBundleDir( root, - worker.parsedNewConfig.name + worker.parsedNewWorkerConfig.name ); } @@ -394,16 +397,21 @@ function forceBuildOutputDirs( resolvedPluginConfig.environmentNameToWorkerMap.get(entryName); assert(entryWorker, `Expected entry worker for environment "${entryName}"`); assert( - entryWorker.parsedNewConfig, - "Expected parsedNewConfig to be defined" + entryWorker.parsedNewWorkerConfig, + "Expected parsedNewWorkerConfig to be defined" ); - clientWorkerName = entryWorker.parsedNewConfig.name; + clientWorkerName = entryWorker.parsedNewWorkerConfig.name; } else { assert( resolvedPluginConfig.parsedNewConfig, "Expected parsedNewConfig to be defined" ); - clientWorkerName = resolvedPluginConfig.parsedNewConfig.name; + const defaultExport = resolvedPluginConfig.parsedNewConfig.default; + assert( + defaultExport?.type === "worker", + "Expected a default worker export" + ); + clientWorkerName = defaultExport.name; } const clientEnvironment = resolvedViteConfig.environments.client; diff --git a/packages/vitest-pool-workers/CHANGELOG.md b/packages/vitest-pool-workers/CHANGELOG.md index b7cda3b36f..cb1227256b 100644 --- a/packages/vitest-pool-workers/CHANGELOG.md +++ b/packages/vitest-pool-workers/CHANGELOG.md @@ -1,5 +1,37 @@ # @cloudflare/vitest-pool-workers +## 0.18.7 + +### Patch Changes + +- [#14713](https://github.com/cloudflare/workers-sdk/pull/14713) [`de34449`](https://github.com/cloudflare/workers-sdk/commit/de344496160da4c2b9aacc34e8a2fc25684437f8) Thanks [@allocsys](https://github.com/allocsys)! - Fix a non-ASCII path failure during the Miniflare WebSocket handshake: the `MF-Vitest-Worker-Data` header embedded the raw `process.cwd()` value, which threw a Latin-1/ASCII header encoding error when the workspace path contained non-ASCII characters (e.g. CJK characters) on Windows. The value is now percent-encoded on write and decoded on read, matching the fix applied to the module fallback redirect response. + +- [#14713](https://github.com/cloudflare/workers-sdk/pull/14713) [`de34449`](https://github.com/cloudflare/workers-sdk/commit/de344496160da4c2b9aacc34e8a2fc25684437f8) Thanks [@allocsys](https://github.com/allocsys)! - Fix a runtime start-up failure ("No such module \"cloudflare:test-internal\"") when the project workspace path contains non-ASCII characters (e.g. CJK characters) on Windows. The module fallback service's redirect response set the target file path directly as an HTTP `Location` header value, but headers are restricted to the Latin-1/ASCII byte range, so any non-ASCII byte in the path caused header construction to throw. Such paths are now percent-encoded (and tagged with a sentinel prefix) before being used as a header value, and decoded again only for the values we encoded, so the round-trip is unambiguous and a workspace path containing a literal `%` is left untouched instead of being mis-decoded. + +- [#14739](https://github.com/cloudflare/workers-sdk/pull/14739) [`5eac99e`](https://github.com/cloudflare/workers-sdk/commit/5eac99ec34858f3943f71bf132aa1ba2f5ea3415) Thanks [@Ankcorn](https://github.com/Ankcorn)! - Support testing Streaming Tail Workers in Vitest Pool Workers. + +- [#14763](https://github.com/cloudflare/workers-sdk/pull/14763) [`538e867`](https://github.com/cloudflare/workers-sdk/commit/538e8678b32aee68636f60b7a84f35f3375a501b) Thanks [@gianghungtien](https://github.com/gianghungtien)! - Treat `webSocketMessage()`, `webSocketClose()` and `webSocketError()` as optional Durable Object handlers + + The pool wraps each Durable Object class and installs a prototype method for every default handler before user code is loaded, so `workerd` always sees a handler and always dispatches. When the wrapped class didn't actually define one, the wrapper threw `` exported by does not define a `webSocketClose()` method ``, even though deployed Workers silently ignore these events for classes that omit them. A hibernatable Durable Object defining only `webSocketMessage()` would log an uncaught `TypeError` on every close. + + These three handlers now no-op when absent, matching deployed behaviour. `alarm()` is unchanged and still reports a missing handler, since `workerd` rejects `setAlarm()` up front on a class without one. + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`a0a091b`](https://github.com/cloudflare/workers-sdk/commit/a0a091b9246c5e10408f57342b3275659c9655e3), [`f03b108`](https://github.com/cloudflare/workers-sdk/commit/f03b10854d983c353fd4f3d6621b5ed716379ba3), [`deae171`](https://github.com/cloudflare/workers-sdk/commit/deae1719b276b9ce2bb67a36671b5cf806ef3801), [`0df3d43`](https://github.com/cloudflare/workers-sdk/commit/0df3d432353f39b6a90c340c268c83a7ac0b7d5c), [`d83a476`](https://github.com/cloudflare/workers-sdk/commit/d83a476bab53f0266a67790242f855aab6e0468c), [`4e92e32`](https://github.com/cloudflare/workers-sdk/commit/4e92e32e1f1c27dcd463bcf38ed79e0d1b046679), [`d1d6945`](https://github.com/cloudflare/workers-sdk/commit/d1d69450decfb319a2bbf61e4c042b0511ab2618), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`a0c8bb1`](https://github.com/cloudflare/workers-sdk/commit/a0c8bb118e04eebba870a6fbe9f5041095b04637), [`a50f73a`](https://github.com/cloudflare/workers-sdk/commit/a50f73a06bb7b078268ce9cebb4d1c16f79a3144), [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`c82d96b`](https://github.com/cloudflare/workers-sdk/commit/c82d96ba63a3b343b520e781a070889251868d9a), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9), [`f75ae5d`](https://github.com/cloudflare/workers-sdk/commit/f75ae5d02576d82aad4723b9e17ccb26277b69ab)]: + - miniflare@4.20260721.0 + - wrangler@4.113.0 + +## 0.18.6 + +### Patch Changes + +- [#14678](https://github.com/cloudflare/workers-sdk/pull/14678) [`4e62bba`](https://github.com/cloudflare/workers-sdk/commit/4e62bba52d8c540328bc9b3399bba4dcb7cde799) Thanks [@apeacock1991](https://github.com/apeacock1991)! - Fix test runs hanging after a Durable Object logs and rejects `blockConcurrencyWhile()` + + Console messages emitted from another Durable Object are now buffered until execution returns to the test runner, avoiding I/O that cannot complete after the object's input gate breaks. + +- Updated dependencies [[`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983), [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce), [`3de70df`](https://github.com/cloudflare/workers-sdk/commit/3de70dfd32f823677a9d20311ee087fd7e69d51a), [`c79504f`](https://github.com/cloudflare/workers-sdk/commit/c79504f90956405f5fab59448ba53dcf44b8d3a2), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d), [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c), [`c7dbe1a`](https://github.com/cloudflare/workers-sdk/commit/c7dbe1a3d527d534d4069080c56e364d33d6a455), [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8), [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645), [`4e1a7a7`](https://github.com/cloudflare/workers-sdk/commit/4e1a7a7fe566774dca376c5d569cab56b14f34e3), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981)]: + - miniflare@4.20260714.0 + - wrangler@4.112.0 + ## 0.18.5 ### Patch Changes diff --git a/packages/vitest-pool-workers/package.json b/packages/vitest-pool-workers/package.json index 61566dca87..d2a614deeb 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.18.5", + "version": "0.18.7", "description": "Workers Vitest integration for writing Vitest unit and integration tests that run inside the Workers runtime", "keywords": [ "cloudflare", @@ -61,6 +61,7 @@ }, "devDependencies": { "@cloudflare/mock-npm-registry": "workspace:*", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", "@cloudflare/workers-utils": "workspace:*", diff --git a/packages/vitest-pool-workers/src/pool/config.ts b/packages/vitest-pool-workers/src/pool/config.ts index a6ee42ca97..9ce9f0ab7c 100644 --- a/packages/vitest-pool-workers/src/pool/config.ts +++ b/packages/vitest-pool-workers/src/pool/config.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; +import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { formatZodError, getRootPath, @@ -14,9 +16,12 @@ import { getRelativeProjectConfigPath, getRelativeProjectPath, } from "./helpers"; +import type { + RemoteBindingsLogger, + RemoteProxySessionData, +} from "@cloudflare/remote-bindings"; import type { ModuleRule, WorkerOptions } from "miniflare"; import type { TestProject } from "vitest/node"; -import type { Binding, RemoteProxySession } from "wrangler"; import type { ParseParams, ZodError } from "zod"; export interface WorkersConfigPluginAPI { @@ -151,6 +156,18 @@ function parseWorkerOptions( const log = new Log(LogLevel.WARN, { prefix: "vpw" }); +const remoteBindingsLogger: RemoteBindingsLogger = { + loggerLevel: "log", + debug: console.debug, + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + console(method, ...args) { + Reflect.apply(console[method], console, args); + }, +}; + function filterTails( tails: WorkerOptions["tails"], userWorkers?: { name?: string }[] @@ -186,10 +203,7 @@ function filterTails( /** Map that maps worker configPaths to their existing remote proxy session data (if any) */ export const remoteProxySessionsDataMap = new Map< string, - { - session: RemoteProxySession; - remoteBindings: Record; - } | null + RemoteProxySessionData | null >(); async function parseCustomPoolOptions( @@ -267,12 +281,20 @@ async function parseCustomPoolOptions( : undefined; const remoteProxySessionData = options.remoteBindings - ? await wrangler.maybeStartOrUpdateRemoteProxySession( + ? await maybeStartOrUpdateRemoteProxySession( { - path: options.wrangler.configPath, - environment: options.wrangler.environment, + name: wranglerConfig.name ?? "worker", + bindings: + wrangler.unstable_convertConfigBindingsToStartWorkerBindings( + wranglerConfig + ) ?? {}, + complianceRegion: getCloudflareComplianceRegion(wranglerConfig), + account_id: wranglerConfig.account_id, + profileDir: path.dirname(configPath), }, - preExistingRemoteProxySessionData ?? null + preExistingRemoteProxySessionData ?? null, + undefined, + { logger: remoteBindingsLogger } ) : null; diff --git a/packages/vitest-pool-workers/src/pool/index.ts b/packages/vitest-pool-workers/src/pool/index.ts index 5bbc624f5c..26edf2485d 100644 --- a/packages/vitest-pool-workers/src/pool/index.ts +++ b/packages/vitest-pool-workers/src/pool/index.ts @@ -283,6 +283,27 @@ const SELF_SERVICE_BINDING = "__VITEST_POOL_WORKERS_SELF_SERVICE"; const LOOPBACK_SERVICE_BINDING = "__VITEST_POOL_WORKERS_LOOPBACK_SERVICE"; const RUNNER_OBJECT_BINDING = "__VITEST_POOL_WORKERS_RUNNER_OBJECT"; +function rewriteStreamingTailSelfReferences( + worker: WorkerOptions, + wranglerWorkerName: string, + runnerWorkerName: string +) { + worker.streamingTails = worker.streamingTails?.map((tail) => { + if (tail === wranglerWorkerName) { + return runnerWorkerName; + } + if ( + typeof tail === "object" && + tail !== null && + "name" in tail && + tail.name === wranglerWorkerName + ) { + return { ...tail, name: runnerWorkerName }; + } + return tail; + }); +} + async function buildProjectWorkerOptions( project: TestProject, customOptions: WorkersPoolOptionsWithDefines, @@ -585,7 +606,15 @@ async function buildProjectWorkerOptions( } // Miniflare will validate these options - workers.push(worker as WorkerOptions); + const workerOptions = worker as WorkerOptions; + if (wranglerWorkerName) { + rewriteStreamingTailSelfReferences( + workerOptions, + wranglerWorkerName, + runnerWorker.name + ); + } + workers.push(workerOptions); } delete runnerWorker.workers; } @@ -725,9 +754,15 @@ export async function connectToMiniflareSocket( const res = await stub.fetch("http://placeholder", { headers: { Upgrade: "websocket", - "MF-Vitest-Worker-Data": structuredSerializableStringify({ - cwd: process.cwd(), - }), + // Headers are restricted to the Latin-1/ASCII byte range, but `process.cwd()` + // may contain non-ASCII characters (e.g. a workspace path with CJK + // characters on Windows), so percent-encode the whole serialized value. + // See https://github.com/cloudflare/workers-sdk/issues/14655 + "MF-Vitest-Worker-Data": encodeURIComponent( + structuredSerializableStringify({ + cwd: process.cwd(), + }) + ), }, }); diff --git a/packages/vitest-pool-workers/src/pool/module-fallback.ts b/packages/vitest-pool-workers/src/pool/module-fallback.ts index 5531453381..a58a378e95 100644 --- a/packages/vitest-pool-workers/src/pool/module-fallback.ts +++ b/packages/vitest-pool-workers/src/pool/module-fallback.ts @@ -358,10 +358,77 @@ function ensureRootedPath(filePath: string) { return isWindows && filePath[0] !== "/" ? `/${filePath}` : filePath; } +// Sentinel prepended to a redirect `Location` value whenever we had to +// percent-encode it (see `encodeRedirectLocation()`). It lets us know +// *deterministically* — rather than guessing — that a specifier/referrer +// `workerd` later hands back to us is one of our own encoded values and must +// be decoded again (see `decodeEncodedSpecifier()`). +// +// It is a *leading, rooted* segment on purpose. `workerd` derives the specifier +// for a relative import by joining it onto the referring module's directory +// (`kj::Path::eval`; see `ensureRootedPath()`), which drops the final path +// segment but keeps the leading ones. A trailing marker would therefore be lost +// for those derived imports, whereas a leading one propagates to every +// descendant of an encoded module. The name is deliberately unlikely to collide +// with a real path segment. +// See https://github.com/cloudflare/workers-sdk/issues/14655 +export const ENCODED_PATH_PREFIX = "/__mf_vitest_encoded__"; + +// Non-printable-ASCII detector. Anything outside the printable ASCII range +// (`0x20`–`0x7E`) can't be represented in the Latin-1/ASCII byte range an HTTP +// header value is restricted to, so it must be percent-encoded before being +// used as a `Location`. The `u` flag makes the class match by code point, so +// astral characters (e.g. emoji, CJK extension chars) are handled as a unit +// rather than as lone surrogates. +const nonHeaderSafeRegExp = /[^\x20-\x7E]/u; + +/** + * Percent-encodes a redirect target so it's safe to use as a `Location` header + * value, but *only* when it actually contains bytes outside the printable ASCII + * range. Pure-ASCII paths — the overwhelmingly common case, including paths + * containing a literal `%` or spaces — are returned unchanged and never gain the + * sentinel prefix, so this is a no-op for them. + * + * When encoding is required, literal `%` is escaped first (to `%25`) so the + * transform is losslessly reversible by `decodeURIComponent()` even for real + * paths that already contain percent sequences (e.g. `…/開発/50%off/…`). `/` and + * `:` are left intact so Windows drive-letter paths like `/C:/a/b/c` still + * round-trip. `encodeURI()`/`decodeURI()` can't be used here because they don't + * escape a literal `%`, so a path mixing non-ASCII and `%` wouldn't round-trip. + * See https://github.com/cloudflare/workers-sdk/issues/14655 + */ +export function encodeRedirectLocation(filePath: string): string { + if (!nonHeaderSafeRegExp.test(filePath)) { + return filePath; + } + const encoded = filePath + .replace(/%/g, "%25") + .replace(/[^\x20-\x7E]/gu, (char) => encodeURIComponent(char)); + return `${ENCODED_PATH_PREFIX}${encoded}`; +} + +/** + * Inverts `encodeRedirectLocation()`. `workerd` echoes a redirect's `Location` + * value back to us verbatim as the next request's `specifier`/`referrer`, so + * this recovers the real filesystem path — but only for values we actually + * encoded, identified unambiguously by the sentinel prefix. Everything else + * (bare `cloudflare:*`/`node:*` specifiers, original file paths, and paths + * containing a literal `%` we never touched such as `50%off`) is returned + * untouched. Because we only ever decode our own output, `decodeURIComponent()` + * can't throw here and can't silently corrupt a real `%` in a workspace path. + * See https://github.com/cloudflare/workers-sdk/issues/14655 + */ +export function decodeEncodedSpecifier(value: string): string { + if (!value.startsWith(ENCODED_PATH_PREFIX)) { + return value; + } + return decodeURIComponent(value.slice(ENCODED_PATH_PREFIX.length)); +} + function buildRedirectResponse(filePath: string) { return new Response(null, { status: 301, - headers: { Location: ensureRootedPath(filePath) }, + headers: { Location: encodeRedirectLocation(ensureRootedPath(filePath)) }, }); } @@ -406,10 +473,13 @@ function maybeGetForceTypeModuleContents( } } } -function buildModuleResponse(target: string, contents: ModuleContents) { - let name = target; +// `name` must exactly match the literal `specifier` string `workerd` sent for +// this request (see the `rawTarget` comment in `handleModuleFallbackRequest()` +// for why this can differ from the decoded `target` used for filesystem +// resolution elsewhere in this file). +function buildModuleResponse(name: string, contents: ModuleContents) { if (!isWindows) { - name = posixPath.relative("/", target); + name = posixPath.relative("/", name); } assert(name[0] !== "/"); const result: Record = { name }; @@ -426,6 +496,7 @@ async function load( logBase: string, method: ResolveMethod, target: string, + rawTarget: string, specifier: string, filePath: string ): Promise { @@ -440,7 +511,7 @@ async function load( ) { const wrapper = `module.exports = { default: require(${JSON.stringify(ensureRootedPath(filePath))}) };`; debuglog(logBase, "wasm-module-wrapper:", filePath); - return buildModuleResponse(target, { commonJsModule: wrapper }); + return buildModuleResponse(rawTarget, { commonJsModule: wrapper }); } if (target !== filePath) { @@ -468,7 +539,7 @@ async function load( const maybeContents = maybeGetForceTypeModuleContents(filePath); if (maybeContents !== undefined) { debuglog(logBase, "forced:", filePath); - return buildModuleResponse(target, maybeContents); + return buildModuleResponse(rawTarget, maybeContents); } // If we're importing from a shim module, don't shim again @@ -488,7 +559,7 @@ async function load( if (filePath.endsWith(".json")) { const json = fs.readFileSync(filePath, "utf8"); debuglog(logBase, "json:", filePath); - return buildModuleResponse(target, { json }); + return buildModuleResponse(rawTarget, { json }); } let contents = fs.readFileSync(filePath, "utf8"); @@ -499,7 +570,7 @@ async function load( // Respond with ES module contents = withImportMetaUrl(contents, targetUrl); debuglog(logBase, "esm:", filePath); - return buildModuleResponse(target, { esModule: contents }); + return buildModuleResponse(rawTarget, { esModule: contents }); } // Respond with CommonJS module @@ -517,13 +588,13 @@ async function load( esModule += ` export const ${name} = mod.${name};`; } debuglog(logBase, "cjs-esm-shim:", filePath); - return buildModuleResponse(target, { esModule }); + return buildModuleResponse(rawTarget, { esModule }); } // Otherwise, if we're `require`ing a non-`node:*` module, just return a // CommonJS debuglog(logBase, "cjs:", filePath); - return buildModuleResponse(target, { commonJsModule: contents }); + return buildModuleResponse(rawTarget, { commonJsModule: contents }); } export async function handleModuleFallbackRequest( @@ -533,10 +604,32 @@ export async function handleModuleFallbackRequest( const method = request.headers.get("X-Resolve-Method"); assert(method === "import" || method === "require"); const url = new URL(request.url); - let target = url.searchParams.get("specifier"); + const rawSpecifierParam = url.searchParams.get("specifier"); let referrer = url.searchParams.get("referrer"); - assert(target !== null, "Expected specifier search param"); + assert(rawSpecifierParam !== null, "Expected specifier search param"); assert(referrer !== null, "Expected referrer search param"); + // `workerd` carries a previous redirect response's `Location` header value + // through verbatim as this request's `specifier`, rather than URI-decoding + // it. `buildRedirectResponse()` percent-encodes paths that aren't header-safe + // (required, since headers are restricted to the Latin-1/ASCII byte range) and + // tags them with a sentinel prefix, so `decodeEncodedSpecifier()` recovers the + // real filesystem path for exactly those values and leaves everything else + // (bare `cloudflare:*` specifiers, untouched original paths, paths with a + // literal `%`) alone. + // See https://github.com/cloudflare/workers-sdk/issues/14655 + let target = decodeEncodedSpecifier(rawSpecifierParam); + // `workerd` also tracks this in-flight module request by the exact literal + // `specifier` string above (before decoding), and rejects our response if the + // JSON module's `name` field doesn't match that literal string exactly. So we + // keep this raw (still percent-encoded where applicable) value around + // separately, and use it only when building the response's `name` field, + // while `target` (decoded) is used for all actual filesystem resolution. + let rawTarget = rawSpecifierParam; + // Since a module's `name` (above) must stay raw/encoded, that encoded value + // becomes the referrer workerd sends for every import statement inside that + // module, propagating the encoding forward indefinitely. Decode it the same + // way as `target` so filesystem resolution keeps working for those imports. + referrer = decodeEncodedSpecifier(referrer); const referrerDir = posixPath.dirname(referrer); let specifier = getApproximateSpecifier(target, referrerDir); @@ -565,6 +658,9 @@ export async function handleModuleFallbackRequest( if (target[0] === "/") { target = target.substring(1); } + if (rawTarget[0] === "/") { + rawTarget = rawTarget.substring(1); + } if (referrer[0] === "/") { referrer = referrer.substring(1); } @@ -576,7 +672,15 @@ export async function handleModuleFallbackRequest( try { const filePath = await resolve(vite, method, target, specifier, referrer); - return await load(vite, logBase, method, target, specifier, filePath); + return await load( + vite, + logBase, + method, + target, + rawTarget, + specifier, + filePath + ); } catch (e) { debuglog(logBase, "error:", e); console.error( diff --git a/packages/vitest-pool-workers/src/worker/entrypoints.ts b/packages/vitest-pool-workers/src/worker/entrypoints.ts index 6fa52e4cc4..4824c44319 100644 --- a/packages/vitest-pool-workers/src/worker/entrypoints.ts +++ b/packages/vitest-pool-workers/src/worker/entrypoints.ts @@ -247,6 +247,17 @@ const DURABLE_OBJECT_KEYS = [ "webSocketClose", "webSocketError", ] as const; +// Handlers `workerd` treats as optional: when a Durable Object doesn't define +// one, the corresponding event is silently dropped. We can't leave them off the +// wrapper's prototype to get that behaviour for free — the prototype is built +// before user code is loaded, so `workerd` always sees a handler and always +// dispatches. The no-op has to be reproduced here instead. +// +// `alarm()` deliberately isn't in this set: `workerd` rejects `setAlarm()` up +// front on a class with no `alarm()` handler, so a missing one is a real error. +const OPTIONAL_DURABLE_OBJECT_KEYS: ReadonlySet< + (typeof DURABLE_OBJECT_KEYS)[number] +> = new Set(["webSocketMessage", "webSocketClose", "webSocketError"] as const); // This type will grab the keys from T and remove "branded" keys type UnbrandedKeys = Exclude; @@ -555,6 +566,8 @@ export function createDurableObjectWrapper( const maybeFn = instance[key]; if (typeof maybeFn === "function") { return (maybeFn as (...a: unknown[]) => void).apply(instance, args); + } else if (OPTIONAL_DURABLE_OBJECT_KEYS.has(key)) { + return; } else { const message = `${className} exported by ${mainPath} does not define a \`${key}()\` method`; throw new TypeError(message); diff --git a/packages/vitest-pool-workers/src/worker/index.ts b/packages/vitest-pool-workers/src/worker/index.ts index 967d65901b..5efe67ec07 100644 --- a/packages/vitest-pool-workers/src/worker/index.ts +++ b/packages/vitest-pool-workers/src/worker/index.ts @@ -194,7 +194,12 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject const workerDataHeader = request.headers.get("MF-Vitest-Worker-Data"); assert(workerDataHeader); - const wd = structuredSerializableParse(workerDataHeader); + // Undo the `encodeURIComponent()` applied on the pool side, since this + // header can contain a non-ASCII cwd. + // See https://github.com/cloudflare/workers-sdk/issues/14655 + const wd = structuredSerializableParse( + decodeURIComponent(workerDataHeader) + ); assert( wd && typeof wd === "object" && "cwd" in wd && typeof wd.cwd === "string" ); diff --git a/packages/vitest-pool-workers/test/bindings.test.ts b/packages/vitest-pool-workers/test/bindings.test.ts index 7fc5417a9a..72e2f4345a 100644 --- a/packages/vitest-pool-workers/test/bindings.test.ts +++ b/packages/vitest-pool-workers/test/bindings.test.ts @@ -52,6 +52,103 @@ test("hello_world support", async ({ expect, seed, vitestRun }) => { await expect(result.exitCode).resolves.toBe(0); }); +test("Durable Objects may omit optional WebSocket handlers", async ({ + expect, + seed, + vitestRun, +}) => { + await seed({ + "vitest.config.mts": vitestConfig({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + "wrangler.jsonc": dedent` + { + "name": "test-worker", + "main": "./index.ts", + "compatibility_date": "2025-12-02", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "OPTIONAL_WS", "class_name": "OptionalWebSocketHandlers" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["OptionalWebSocketHandlers"] } + ] + } + `, + "index.ts": dedent /* javascript */ ` + import { DurableObject } from "cloudflare:workers"; + + // Defines webSocketMessage() but deliberately omits webSocketClose() + // and webSocketError(), which workerd treats as optional handlers. + export class OptionalWebSocketHandlers extends DurableObject { + fetch(request) { + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("ok"); + } + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + + webSocketMessage(ws, message) { + ws.send("echo:" + message); + } + + socketCount() { + return this.ctx.getWebSockets().length; + } + } + + export default { + async fetch() { return new Response("ok"); }, + }; + `, + "index.test.ts": dedent /* javascript */ ` + import { env } from "cloudflare:workers"; + import { it, vi } from "vitest"; + + it("echoes a message and closes without a webSocketClose() handler", async ({ expect }) => { + const stub = env.OPTIONAL_WS.get(env.OPTIONAL_WS.idFromName("ws")); + const response = await stub.fetch("https://example.com", { + headers: { Upgrade: "websocket" }, + }); + const socket = response.webSocket; + if (!socket) { throw new Error("Expected WebSocket response"); } + + const message = new Promise((resolve) => { + socket.addEventListener("message", (event) => resolve(event.data)); + }); + socket.accept(); + socket.send("hello"); + expect(await message).toBe("echo:hello"); + + // Dispatches webSocketClose() on the Durable Object, which doesn't define it + socket.close(1000, "done"); + + // Wait until the runtime has actually processed the close, so the + // dispatch has definitely happened before the run ends + await vi.waitFor( + async () => { + expect(await stub.socketCount()).toBe(0); + }, + { timeout: 5_000, interval: 100 } + ); + }); + `, + }); + + const result = await vitestRun(); + + await expect(result.exitCode).resolves.toBe(0); + // Dispatching to the absent handlers must be a no-op. Previously the wrapper + // threw " exported by does not define a `webSocketClose()` + // method", surfacing as an uncaught exception from the Durable Object. + expect(result.stderr).not.toMatch("does not define"); + expect(result.stdout).not.toMatch("does not define"); +}); + test("adminSecretsStore seeds and reads secrets", async ({ expect, seed, diff --git a/packages/vitest-pool-workers/test/module-fallback.test.ts b/packages/vitest-pool-workers/test/module-fallback.test.ts new file mode 100644 index 0000000000..7760a66c0d --- /dev/null +++ b/packages/vitest-pool-workers/test/module-fallback.test.ts @@ -0,0 +1,258 @@ +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { Request } from "miniflare"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { + decodeEncodedSpecifier, + ENCODED_PATH_PREFIX, + encodeRedirectLocation, + handleModuleFallbackRequest, +} from "../src/pool/module-fallback"; +import type { Vite } from "vitest/node"; + +// The fallback handler only reads `vite.pluginContainer.resolveId`, and only +// when a specifier can't be resolved directly from the filesystem. Returning +// `null` mimics Vite failing to resolve, exercising the 404 fall-through. +function fakeVite(): Vite.ViteDevServer { + return { + pluginContainer: { + resolveId: async () => null, + }, + } as unknown as Vite.ViteDevServer; +} + +function moduleFallbackRequest(options: { + method: "import" | "require"; + specifier: string; + referrer: string; + rawSpecifier?: string; +}): Request { + const url = new URL("http://localhost/"); + // `URLSearchParams` handles transport encoding, so the handler reads back the + // exact `specifier`/`referrer` string — just as it would from `workerd`. + url.searchParams.set("specifier", options.specifier); + url.searchParams.set("referrer", options.referrer); + if (options.rawSpecifier !== undefined) { + url.searchParams.set("rawSpecifier", options.rawSpecifier); + } + return new Request(url.href, { + headers: { "X-Resolve-Method": options.method }, + }); +} + +// `workerd` hands the fallback service POSIX-style, root-anchored specifiers, +// even on Windows: a real path like `C:\a\b` arrives as `/C:/a/b`. The handler +// relies on that shape (it runs `posixPath.dirname()` on the referrer and only +// strips a *leading* slash to recover a Windows `fs` path). Building request +// inputs with `path.join()` would send backslash, un-rooted paths on Windows +// and diverge from production, so mirror workerd's transform here. +function toWorkerdSpecifier(realPath: string): string { + const posix = realPath.replaceAll("\\", "/"); + return posix.startsWith("/") ? posix : `/${posix}`; +} + +describe("encodeRedirectLocation / decodeEncodedSpecifier", () => { + it("leaves pure-ASCII paths untouched (no sentinel)", ({ expect }) => { + const p = "/a/b/c.js"; + expect(encodeRedirectLocation(p)).toBe(p); + expect(decodeEncodedSpecifier(p)).toBe(p); + }); + + it("leaves an ASCII path containing a literal % untouched", ({ expect }) => { + // Regression guard: the previous `safeDecodeURI()` approach would decode + // this `%20` to a space and silently resolve the wrong path. A value we + // never encoded has no sentinel, so it must be passed through verbatim. + const p = "/a/build%20output/c.js"; + expect(encodeRedirectLocation(p)).toBe(p); + expect(decodeEncodedSpecifier(p)).toBe(p); + }); + + it("round-trips a non-ASCII path via the sentinel", ({ expect }) => { + const p = "/a/開発/c.js"; + const encoded = encodeRedirectLocation(p); + expect(encoded.startsWith(ENCODED_PATH_PREFIX)).toBe(true); + expect(encoded).toContain("%E9%96%8B%E7%99%BA"); + expect(encoded).not.toContain("開発"); + expect(decodeEncodedSpecifier(encoded)).toBe(p); + }); + + it("preserves / and : so Windows drive-letter paths round-trip", ({ + expect, + }) => { + const p = "/C:/開発/c.js"; + const encoded = encodeRedirectLocation(p); + expect(encoded).toBe(`${ENCODED_PATH_PREFIX}/C:/%E9%96%8B%E7%99%BA/c.js`); + expect(decodeEncodedSpecifier(encoded)).toBe(p); + }); + + it("round-trips a path mixing non-ASCII and a literal %", ({ expect }) => { + // `encodeURI()`/`decodeURI()` can't do this: `decodeURI` throws on the bare + // `%of`. Escaping `%` first (to `%25`) makes the transform reversible. + const p = "/a/開発/50%off/c.js"; + const encoded = encodeRedirectLocation(p); + expect(encoded).toContain("50%25off"); + expect(decodeEncodedSpecifier(encoded)).toBe(p); + }); + + it("round-trips astral characters (surrogate pairs)", ({ expect }) => { + const p = "/a/😀/c.js"; + const encoded = encodeRedirectLocation(p); + expect(encoded.startsWith(ENCODED_PATH_PREFIX)).toBe(true); + expect(decodeEncodedSpecifier(encoded)).toBe(p); + }); + + it("leaves bare module specifiers untouched", ({ expect }) => { + expect(decodeEncodedSpecifier("cloudflare:test-internal")).toBe( + "cloudflare:test-internal" + ); + expect(decodeEncodedSpecifier("node:assert")).toBe("node:assert"); + }); + + it("does not throw on an un-encoded value with an invalid % escape", ({ + expect, + }) => { + // The exact input that made the old blind `decodeURI()` throw `URIError`. + // Without a sentinel we never decode, so there is nothing to throw. + const p = "/a/50%off/c.js"; + expect(() => decodeEncodedSpecifier(p)).not.toThrow(); + expect(decodeEncodedSpecifier(p)).toBe(p); + }); +}); + +describe("handleModuleFallbackRequest non-ASCII paths", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "mf-fallback-")) + ); + }); + + afterEach(() => { + removeDirSync(tmp); + }); + + it("percent-encodes a non-ASCII redirect Location without throwing", async ({ + expect, + }) => { + // Requiring a directory redirects to its resolved `index.js`; the resolved + // path contains CJK characters, which previously threw when set as the + // `Location` header ("Cannot convert argument to a ByteString..."). + const pkgDir = path.join(tmp, "開発", "pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "index.js"), + "module.exports = { ok: true };" + ); + + const specifier = toWorkerdSpecifier(pkgDir); + const res = await handleModuleFallbackRequest( + fakeVite(), + moduleFallbackRequest({ + method: "require", + specifier, + referrer: toWorkerdSpecifier(path.join(tmp, "entry.js")), + }) + ); + + expect(res.status).toBe(301); + const location = res.headers.get("Location"); + assert(location !== null, "expected a Location header"); + expect(location.startsWith(ENCODED_PATH_PREFIX)).toBe(true); + // It must round-trip back to the real (decoded) index path + shim suffix. + expect(decodeEncodedSpecifier(location)).toBe( + `${specifier}/index.js?mf_vitest_no_cjs_esm_shim` + ); + }); + + it("decodes an echoed sentinel specifier back to the real non-ASCII file", async ({ + expect, + }) => { + const pkgDir = path.join(tmp, "開発", "pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + // A `.cjs` fixture is unambiguously CommonJS. A `.js` file would instead be + // treated as ESM whenever an ancestor `package.json` declares + // `"type": "module"` (true under some OS temp roots), yielding an + // `esModule` field — but the module *type* is irrelevant to what this test + // checks (that the sentinel specifier is decoded to the real file). + fs.writeFileSync( + path.join(pkgDir, "index.cjs"), + "module.exports = { ok: 123 };" + ); + + // Simulate `workerd` echoing our previous redirect `Location` verbatim. + const echoed = encodeRedirectLocation( + `${toWorkerdSpecifier(pkgDir)}/index.cjs?mf_vitest_no_cjs_esm_shim` + ); + const res = await handleModuleFallbackRequest( + fakeVite(), + moduleFallbackRequest({ + method: "require", + specifier: echoed, + referrer: toWorkerdSpecifier(path.join(tmp, "entry.js")), + }) + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + name: string; + commonJsModule?: string; + }; + // `name` must match the exact (still-encoded) specifier `workerd` sent, + // minus the leading slash (the response name is posix-relative to root). + expect(body.name).toBe(echoed.replace(/^\//, "")); + expect(body.commonJsModule).toContain("ok: 123"); + }); + + it("resolves an original path containing a literal % (never decoded)", async ({ + expect, + }) => { + // The previous `safeDecodeURI()` would decode `%20` → space here and 404. + // `.cjs` keeps the module unambiguously CommonJS (see the sibling test); + // this test is about the literal `%` never being decoded, not module type. + const dir = path.join(tmp, "build%20output"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "dep.cjs"), + "module.exports = { pct: true };" + ); + + const res = await handleModuleFallbackRequest( + fakeVite(), + moduleFallbackRequest({ + method: "require", + specifier: toWorkerdSpecifier(path.join(dir, "dep.cjs")), + referrer: toWorkerdSpecifier(path.join(tmp, "entry.js")), + }) + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + name: string; + commonJsModule?: string; + }; + expect(body.commonJsModule).toContain("pct: true"); + }); + + it("falls through to a 404 for an unresolvable specifier", async ({ + expect, + }) => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const res = await handleModuleFallbackRequest( + fakeVite(), + moduleFallbackRequest({ + method: "import", + specifier: "totally-nonexistent-package", + referrer: toWorkerdSpecifier(path.join(tmp, "entry.js")), + }) + ); + expect(res.status).toBe(404); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/packages/vitest-pool-workers/test/streaming-tails.test.ts b/packages/vitest-pool-workers/test/streaming-tails.test.ts new file mode 100644 index 0000000000..596254db3b --- /dev/null +++ b/packages/vitest-pool-workers/test/streaming-tails.test.ts @@ -0,0 +1,84 @@ +import dedent from "ts-dedent"; +import { test, vitestConfig } from "./helpers"; + +test( + "auxiliary workers can stream tails to the main worker", + { timeout: 30_000 }, + async ({ expect, seed, vitestRun }) => { + await seed({ + "wrangler.jsonc": JSON.stringify({ + name: "main-worker", + main: "worker.mjs", + compatibility_date: "2025-12-02", + }), + "worker.mjs": dedent /* javascript */ ` + import { WorkerEntrypoint } from "cloudflare:workers"; + + const events = []; + + export default class extends WorkerEntrypoint { + tailStream(onset) { + events.push(onset.event.type); + return { + log(event) { + events.push(event.event.type); + }, + outcome(event) { + events.push(event.event.type); + }, + }; + } + + getEvents() { + return events; + } + } + `, + "target.mjs": dedent /* javascript */ ` + export default { + fetch() { + console.log("target log"); + return new Response("ok"); + }, + }; + `, + "vitest.config.mts": vitestConfig({ + wrangler: { configPath: "wrangler.jsonc" }, + miniflare: { + compatibilityDate: "2025-12-02", + compatibilityFlags: ["nodejs_compat"], + serviceBindings: { TARGET: "target" }, + workers: [ + { + name: "target", + modules: true, + scriptPath: "target.mjs", + streamingTails: ["main-worker"], + }, + ], + }, + }), + "streaming-tails.test.ts": dedent /* javascript */ ` + import { env, exports } from "cloudflare:workers"; + import { expect, test, vi } from "vitest"; + + test("streams tails to the main worker", async () => { + const response = await env.TARGET.fetch("https://example.com"); + expect(await response.text()).toBe("ok"); + + await vi.waitFor(async () => { + expect(await exports.default.getEvents()).toEqual([ + "onset", + "log", + "outcome", + ]); + }); + }); + `, + }); + + const result = await vitestRun(); + expect(result.stderr).toBe(""); + expect(await result.exitCode).toBe(0); + } +); diff --git a/packages/vitest-pool-workers/vitest.config.mts b/packages/vitest-pool-workers/vitest.config.mts index 418219b98f..575f4ab1e6 100644 --- a/packages/vitest-pool-workers/vitest.config.mts +++ b/packages/vitest-pool-workers/vitest.config.mts @@ -1,6 +1,14 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ + // `VITEST_POOL_WORKERS_DEFINE_BUILTIN_MODULES` is injected at build time by + // `tsdown` (see `tsdown.config.ts`). Unit tests that import pool source + // directly (e.g. `test/module-fallback.test.ts`) don't go through that build, + // so provide an empty stub here to satisfy the reference. These tests don't + // depend on the concrete built-in module list. + define: { + VITEST_POOL_WORKERS_DEFINE_BUILTIN_MODULES: "[]", + }, test: { reporters: ["default"], globalSetup: ["./test/global-setup.ts"], diff --git a/packages/workers-auth/CHANGELOG.md b/packages/workers-auth/CHANGELOG.md index 292e842406..3583c158e0 100644 --- a/packages/workers-auth/CHANGELOG.md +++ b/packages/workers-auth/CHANGELOG.md @@ -1,5 +1,12 @@ # @cloudflare/workers-auth +## 0.5.1 + +### Patch Changes + +- Updated dependencies [[`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924), [`a6c214f`](https://github.com/cloudflare/workers-sdk/commit/a6c214fb311215b1ed09b273171b7995033fb7d7)]: + - @cloudflare/workers-utils@0.28.0 + ## 0.5.0 ### Minor Changes diff --git a/packages/workers-auth/package.json b/packages/workers-auth/package.json index eb29d59279..bfd1832950 100644 --- a/packages/workers-auth/package.json +++ b/packages/workers-auth/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-auth", - "version": "0.5.0", + "version": "0.5.1", "description": "Internal OAuth 2.0 + PKCE flow for Cloudflare CLIs. Not intended for external use — APIs may change without notice.", "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/workers-auth#readme", "bugs": { diff --git a/packages/workers-auth/src/access.ts b/packages/workers-auth/src/access.ts index 2856f0f5ed..5888e66480 100644 --- a/packages/workers-auth/src/access.ts +++ b/packages/workers-auth/src/access.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { UserError } from "@cloudflare/workers-utils"; +import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; import { fetch } from "undici"; import { getAccessClientIdFromEnv, @@ -89,11 +89,10 @@ export async function getAccessHeaders( domain: string, options: { logger: OAuthFlowLogger; - isNonInteractiveOrCI: () => boolean; + isNonInteractiveOrCI?: () => boolean; } ): Promise> { const logger = options.logger; - const isNonInteractiveOrCI = options.isNonInteractiveOrCI; // 1. If Access Service Token credentials are provided, use them directly. // @@ -139,7 +138,7 @@ export async function getAccessHeaders( } // 2. If non-interactive (CI), error with actionable message - if (isNonInteractiveOrCI()) { + if ((options.isNonInteractiveOrCI ?? isNonInteractiveOrCI)()) { throw new UserError( `The domain "${domain}" is behind Cloudflare Access, but no Access Service Token credentials were found ` + `and the current environment is non-interactive.\n` + diff --git a/packages/workers-auth/src/context.ts b/packages/workers-auth/src/context.ts index 71c5edf351..3c76fb4b45 100644 --- a/packages/workers-auth/src/context.ts +++ b/packages/workers-auth/src/context.ts @@ -2,6 +2,7 @@ import type { AuthConfigStorage } from "./config-file/auth"; import type { TemporaryAccountStorage } from "./config-file/temporary"; import type { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; +import type { Logger } from "@cloudflare/workers-utils"; /** * The dependencies the OAuth flow needs to mint/reuse a short-lived "temporary @@ -36,13 +37,7 @@ export interface OAuthConsentPages { * Subset of the wrangler `logger` singleton used by the OAuth flow. * Consumers pass in an implementation that maps to their own logging surface. */ -export interface OAuthFlowLogger { - debug(...args: unknown[]): void; - info(...args: unknown[]): void; - log(...args: unknown[]): void; - warn(...args: unknown[]): void; - error(...args: unknown[]): void; -} +export type OAuthFlowLogger = Logger; /** * Dependency-injection surface for {@link createOAuthFlow}. diff --git a/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts b/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts index 61333ed16b..17169822cc 100644 --- a/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts +++ b/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts @@ -258,13 +258,6 @@ interface KeyringModule { Entry: new (service: string, account: string) => KeyringEntry; } -// `createRequire` is used to load the lazy-installed binding from an -// absolute path computed at runtime, defeating esbuild's static analysis -// of `require(...)`. The anchor `__filename` is provided in both the -// bundled CJS output and the source-loaded test environment. -// eslint-disable-next-line no-restricted-globals -- runtime resolution requires a CJS anchor -const dynamicRequire = createRequire(__filename); - /** * Resolve the active keyring entry factory, loading the lazy-installed * binding from `installDir` when no test override is registered. @@ -281,6 +274,6 @@ export function resolveKeyringEntryFactory( "`@napi-rs/keyring` binding not found. Call `installKeyringBindingSync()` first." ); } - const mod = dynamicRequire(bindingPath) as KeyringModule; + const mod = createRequire(bindingPath)(bindingPath) as KeyringModule; return (service, account) => new mod.Entry(service, account); } diff --git a/packages/workers-shared/CHANGELOG.md b/packages/workers-shared/CHANGELOG.md index 17dc451399..69dba4c23b 100644 --- a/packages/workers-shared/CHANGELOG.md +++ b/packages/workers-shared/CHANGELOG.md @@ -1,5 +1,21 @@ # @cloudflare/workers-shared +## 0.19.9 + +### Patch Changes + +- [#14417](https://github.com/cloudflare/workers-sdk/pull/14417) [`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Improve asset serving performance by removing an unnecessary internal dispatch hop + + Asset requests and RPC calls now avoid an extra internal forwarding layer, reducing latency. The forwarding infrastructure is preserved for future use by cohort-based deployments. + +- [#14705](https://github.com/cloudflare/workers-sdk/pull/14705) [`00f41d6`](https://github.com/cloudflare/workers-sdk/commit/00f41d6dbdf7ba4943f2c4b5d338ae9c9d8fc9a0) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Retry asset reads from KV when they fail + + The asset worker reads static assets from KV, and a read can occasionally fail with a transient error. It previously retried only once before giving up. It now retries a few times with exponential backoff, which reduces the chance of serving an error. A missing asset is not treated as a failure and is not retried. + +- [#14418](https://github.com/cloudflare/workers-sdk/pull/14418) [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Improve routing performance for Workers with assets + + Reduce request handling latency by streamlining the router Worker's request path. The loopback infrastructure remains available for future use. + ## 0.19.8 ### Patch Changes diff --git a/packages/workers-shared/package.json b/packages/workers-shared/package.json index 57b30362e1..0f1e837fdc 100644 --- a/packages/workers-shared/package.json +++ b/packages/workers-shared/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-shared", - "version": "0.19.8", + "version": "0.19.9", "private": true, "description": "Package that is used at Cloudflare to power some internal features of Cloudflare Workers.", "keywords": [ diff --git a/packages/workers-utils/CHANGELOG.md b/packages/workers-utils/CHANGELOG.md index 70da09015d..11bf62f29e 100644 --- a/packages/workers-utils/CHANGELOG.md +++ b/packages/workers-utils/CHANGELOG.md @@ -1,5 +1,19 @@ # @cloudflare/workers-utils +## 0.28.0 + +### Minor Changes + +- [#14595](https://github.com/cloudflare/workers-sdk/pull/14595) [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924) Thanks [@colinhacks](https://github.com/colinhacks)! - Recognise nub as a package manager + + wrangler now detects nub — from its `npm_config_user_agent` and an installed `nub` binary — and autoconfig detects nub projects by their `nub.lock`, alongside npm, pnpm, yarn, and bun. + +### Patch Changes + +- [#14746](https://github.com/cloudflare/workers-sdk/pull/14746) [`a6c214f`](https://github.com/cloudflare/workers-sdk/commit/a6c214fb311215b1ed09b273171b7995033fb7d7) Thanks [@samarth70](https://github.com/samarth70)! - Return a clear error when `observability` is set to `null` + + `validateObservability` guarded only against `undefined`, so a `null` value (valid in JSON/JSONC config) passed the `typeof value === "object"` check and then threw `TypeError: Cannot read properties of null (reading 'enabled')` while validating the config. It now rejects `null` with the same `"observability" should be an object but got null.` diagnostic that the sibling `cache` validator already produces. + ## 0.27.0 ### Minor Changes diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index d012ef4548..be2dcf4ba5 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-utils", - "version": "0.27.0", + "version": "0.28.0", "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/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index ed0a6c75a5..a8ae511040 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -82,6 +82,15 @@ export interface ConfigFields { | { /** Whether dependency instrumentation is enabled. Defaults to `true`. */ enabled: boolean; + /** + * An optional list of package name patterns to exclude from the + * collected dependency metadata. + * + * Each entry can be an exact package name (e.g. `"lodash"`) or a glob + * pattern using `*` as a wildcard (e.g. `"@internal/*"` to exclude all + * packages under the `@internal` scope). + */ + exclude_packages?: string[]; } | undefined; diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index a9dbc8da57..f11298c460 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1,7 +1,7 @@ /** * Wrangler configuration types. The JSDoc on these fields is also the source * of truth for the equivalent fields in `@cloudflare/config` - * (`packages/config/src/types.ts` — `UserConfig` — and the binding option + * (`packages/config/src/types.ts` — `WorkerConfig` — and the binding option * interfaces in `packages/config/src/config.ts`). When editing prose here, * mirror the changes there. */ @@ -983,7 +983,7 @@ export interface EnvironmentNonInheritable { binding: string; /** The name of this Queue. */ - queue: string; + queue?: string; /** The number of seconds to wait before delivering a message */ delivery_delay?: number; @@ -1461,7 +1461,7 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the bound service. */ binding: string; /** The namespace to bind to. */ - namespace: string; + namespace?: string; /** Details about the outbound Worker which will handle outbound requests from your namespace */ outbound?: DispatchNamespaceOutbound; /** Whether the Dispatch Namespace should be remote or not in local development */ @@ -1563,7 +1563,7 @@ export interface EnvironmentNonInheritable { binding: string; /** The Flagship app ID to bind to. */ - app_id: string; + app_id?: string; /** Set to `true` to suppress the remote binding warning in local dev. Flagship bindings are always remote. */ remote?: boolean; diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index a65c96e70c..1f7824ee31 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -322,13 +322,20 @@ export function normalizeAndValidateConfig( "boolean" ); + validateOptionalTypedArray( + diagnostics, + "dependencies_instrumentation.exclude_packages", + rawConfig.dependencies_instrumentation.exclude_packages, + "string" + ); + validateAdditionalProperties( diagnostics, "dependencies_instrumentation", Object.keys( rawConfig.dependencies_instrumentation as Record ), - ["enabled"] + ["enabled", "exclude_packages"] ); } } @@ -3990,7 +3997,7 @@ const validateQueueBinding: ValidatorFn = (diagnostics, field, value) => { return false; } - // Queue bindings must have a binding and queue. + // Queue bindings must have a binding. The queue can be provisioned at deploy time. let isValid = true; if (!isRequiredProperty(value, "binding", "string")) { diagnostics.errors.push( @@ -4002,11 +4009,11 @@ const validateQueueBinding: ValidatorFn = (diagnostics, field, value) => { } if ( - !isRequiredProperty(value, "queue", "string") || - (value as { queue: string }).queue.length === 0 + !isOptionalProperty(value, "queue", "string") || + (hasProperty(value, "queue") && value.queue === "") ) { diagnostics.errors.push( - `"${field}" bindings should have a string "queue" field but got ${JSON.stringify( + `"${field}" bindings should optionally have a non-empty string "queue" field but got ${JSON.stringify( value )}.` ); @@ -4779,7 +4786,7 @@ const validateWorkerNamespaceBinding: ValidatorFn = ( return false; } let isValid = true; - // Worker namespace bindings must have a binding, and a namespace. + // Worker namespace bindings must have a binding. The namespace can be provisioned at deploy time. if (!isRequiredProperty(value, "binding", "string")) { diagnostics.errors.push( `"${field}" should have a string "binding" field but got ${JSON.stringify( @@ -4788,9 +4795,9 @@ const validateWorkerNamespaceBinding: ValidatorFn = ( ); isValid = false; } - if (!isRequiredProperty(value, "namespace", "string")) { + if (!isOptionalProperty(value, "namespace", "string")) { diagnostics.errors.push( - `"${field}" should have a string "namespace" field but got ${JSON.stringify( + `"${field}" should optionally have a string "namespace" field but got ${JSON.stringify( value )}.` ); @@ -5248,9 +5255,9 @@ const validateFlagshipBinding: ValidatorFn = (diagnostics, field, value) => { ); isValid = false; } - if (!isRequiredProperty(value, "app_id", "string")) { + if (!isOptionalProperty(value, "app_id", "string")) { diagnostics.errors.push( - `"${field}" bindings must have a string "app_id" field but got ${JSON.stringify( + `"${field}" bindings may have a string "app_id" field but got ${JSON.stringify( value )}.` ); @@ -6184,7 +6191,7 @@ const validateObservability: ValidatorFn = (diagnostics, field, value) => { return true; } - if (typeof value !== "object") { + if (typeof value !== "object" || value === null) { diagnostics.errors.push( `"${field}" should be an object but got ${JSON.stringify(value)}.` ); diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index d5c0987d54..833b0cd0ae 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -162,6 +162,7 @@ export { PnpmPackageManager, YarnPackageManager, BunPackageManager, + NubPackageManager, } from "./package-manager"; export { diff --git a/packages/workers-utils/src/logger.ts b/packages/workers-utils/src/logger.ts index 42bc17ab79..1e398cd083 100644 --- a/packages/workers-utils/src/logger.ts +++ b/packages/workers-utils/src/logger.ts @@ -23,4 +23,8 @@ export type Logger = { warn: typeof console.warn; error: typeof console.error; }; + console?>( + method: M, + ...args: Parameters + ): void; }; diff --git a/packages/workers-utils/src/package-manager.ts b/packages/workers-utils/src/package-manager.ts index b924b2c93f..3c58204eae 100644 --- a/packages/workers-utils/src/package-manager.ts +++ b/packages/workers-utils/src/package-manager.ts @@ -4,7 +4,7 @@ */ export interface PackageManager { /** The package manager identifier. */ - type: "npm" | "yarn" | "pnpm" | "bun"; + type: "npm" | "yarn" | "pnpm" | "bun" | "nub"; /** The command used to execute packages (e.g. `npx`, `pnpm`, `bunx`). */ npx: string; /** The command segments used to download and execute packages (e.g. `["npx"]`, `["pnpm", "dlx"]`). */ @@ -52,3 +52,13 @@ export const BunPackageManager = { dlx: ["bunx"], lockFiles: ["bun.lockb", "bun.lock"], } as const satisfies PackageManager; + +/** + * Manage packages using nub. + */ +export const NubPackageManager = { + type: "nub", + npx: "nubx", + dlx: ["nubx"], + lockFiles: ["nub.lock"], +} as const satisfies PackageManager; diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 57011f7b9f..4ec07328f3 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -202,7 +202,7 @@ export interface CfWorkflow { export interface CfQueue { binding: string; - queue_name: string; + queue_name?: string | typeof INHERIT_SYMBOL; delivery_delay?: number; remote?: boolean; raw?: boolean; @@ -279,7 +279,7 @@ export interface CfHelloWorld { export interface CfFlagship { binding: string; - app_id: string; + app_id?: string | typeof INHERIT_SYMBOL; remote?: boolean; } @@ -353,7 +353,7 @@ export interface CfAnalyticsEngineDataset { export interface CfDispatchNamespace { binding: string; - namespace: string; + namespace?: string | typeof INHERIT_SYMBOL; outbound?: { service: string; environment?: string; 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 970d7cc0e9..8b71f2e51f 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 @@ -214,6 +214,89 @@ describe("normalizeAndValidateConfig()", () => { `); }); + it("should accept a valid dependencies_instrumentation with exclude_packages", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + dependencies_instrumentation: { + enabled: true, + exclude_packages: ["@internal/*", "secret-pkg"], + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + }); + + it("should error on invalid dependencies_instrumentation.exclude_packages type", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + dependencies_instrumentation: { + exclude_packages: "not-an-array" as unknown, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - Expected "dependencies_instrumentation.exclude_packages" to be an array of strings but got "not-an-array"" + `); + }); + + it("should error on non-string entries in dependencies_instrumentation.exclude_packages", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + dependencies_instrumentation: { + exclude_packages: ["valid", 123] as unknown, + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - Expected "dependencies_instrumentation.exclude_packages.[1]" to be of type string but got 123." + `); + }); + + it("should warn on unknown properties in dependencies_instrumentation", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + dependencies_instrumentation: { + enabled: true, + unknown_field: "bad", + }, + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - Unexpected fields found in dependencies_instrumentation field: "unknown_field"" + `); + }); + it("should error if the deprecated `legacy_env` field is present", ({ expect, }) => { @@ -4488,11 +4571,9 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "queues.producers[0]" bindings should have a string "binding" field but got {}. - - "queues.producers[0]" bindings should have a string "queue" field but got {}. - - "queues.producers[1]" bindings should have a string "queue" field but got {"binding":"QUEUE_BINDING_1"}. - "queues.producers[2]" bindings should have a string "binding" field but got {"binding":2333,"queue":2444}. - - "queues.producers[2]" bindings should have a string "queue" field but got {"binding":2333,"queue":2444}. - - "queues.producers[3]" bindings should have a string "queue" field but got {"binding":"QUEUE_BINDING_3","queue":""}." + - "queues.producers[2]" bindings should optionally have a non-empty string "queue" field but got {"binding":2333,"queue":2444}. + - "queues.producers[3]" bindings should optionally have a non-empty string "queue" field but got {"binding":"QUEUE_BINDING_3","queue":""}." `); }); @@ -5157,11 +5238,10 @@ describe("normalizeAndValidateConfig()", () => { - "dispatch_namespaces[0]" binding should be objects, but got "a string" - "dispatch_namespaces[1]" binding should be objects, but got 123 - "dispatch_namespaces[2]" should have a string "binding" field but got {"binding":123,"namespace":456}. - - "dispatch_namespaces[2]" should have a string "namespace" field but got {"binding":123,"namespace":456}. - - "dispatch_namespaces[3]" should have a string "namespace" field but got {"binding":"DISPATCH_NAMESPACE_BINDING_1","namespace":456}. + - "dispatch_namespaces[2]" should optionally have a string "namespace" field but got {"binding":123,"namespace":456}. + - "dispatch_namespaces[3]" should optionally have a string "namespace" field but got {"binding":"DISPATCH_NAMESPACE_BINDING_1","namespace":456}. - "dispatch_namespaces[5]" should have a string "binding" field but got {"binding":123,"namespace":"DISPATCH_NAMESPACE_BINDING_SERVICE_1"}. - - "dispatch_namespaces[6]" should have a string "binding" field but got {"binding":123,"service":456}. - - "dispatch_namespaces[6]" should have a string "namespace" field but got {"binding":123,"service":456}." + - "dispatch_namespaces[6]" should have a string "binding" field but got {"binding":123,"service":456}." `); }); @@ -5987,7 +6067,6 @@ describe("normalizeAndValidateConfig()", () => { flagship: [ // @ts-expect-error purposely using an invalid value {}, - // @ts-expect-error purposely using an invalid value { binding: "VALID" }, // @ts-expect-error purposely using an invalid value { binding: 2000, app_id: 2111 }, @@ -6006,10 +6085,8 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - "flagship[0]" bindings must have a string "binding" field but got {}. - - "flagship[0]" bindings must have a string "app_id" field but got {}. - - "flagship[1]" bindings must have a string "app_id" field but got {"binding":"VALID"}. - "flagship[2]" bindings must have a string "binding" field but got {"binding":2000,"app_id":2111}. - - "flagship[2]" bindings must have a string "app_id" field but got {"binding":2000,"app_id":2111}." + - "flagship[2]" bindings may have a string "app_id" field but got {"binding":2000,"app_id":2111}." `); }); @@ -9834,6 +9911,22 @@ describe("normalizeAndValidateConfig()", () => { `); }); + it("should error if observability is null", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { observability: null } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "observability" should be an object but got null." + `); + }); + it("should not warn on full observability config", ({ expect }) => { const { diagnostics } = normalizeAndValidateConfig( { diff --git a/packages/workers-utils/tests/package-manager.test.ts b/packages/workers-utils/tests/package-manager.test.ts new file mode 100644 index 0000000000..f8f370402c --- /dev/null +++ b/packages/workers-utils/tests/package-manager.test.ts @@ -0,0 +1,61 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it } from "vitest"; +import { + BunPackageManager, + NpmPackageManager, + NubPackageManager, + PnpmPackageManager, + YarnPackageManager, +} from "../src/package-manager"; +import { runInTempDir, seed } from "../src/test-helpers"; +import type { PackageManager } from "../src/package-manager"; + +const packageManagers: PackageManager[] = [ + NpmPackageManager, + PnpmPackageManager, + YarnPackageManager, + BunPackageManager, + NubPackageManager, +]; + +describe("package managers", () => { + it("describes nub", ({ expect }) => { + expect(NubPackageManager).toEqual({ + type: "nub", + npx: "nubx", + dlx: ["nubx"], + lockFiles: ["nub.lock"], + }); + }); + + describe("lock file detection", () => { + runInTempDir(); + + // Detection is lock-file-based: a project is managed by the package + // manager whose lock file is present, matching how consumers resolve it. + const findByLockFile = (dir: string) => + packageManagers.find((pm) => + pm.lockFiles.some((lockFile) => existsSync(join(dir, lockFile))) + ); + + it("detects nub from nub.lock", async ({ expect }) => { + await seed({ "nub.lock": "" }); + expect(findByLockFile(process.cwd())).toBe(NubPackageManager); + }); + + it("does not detect nub when nub.lock is absent", async ({ expect }) => { + await seed({ + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + expect(findByLockFile(process.cwd())).toBe(NpmPackageManager); + }); + + it("still detects nub from a malformed nub.lock", async ({ expect }) => { + // Detection is presence-based and never parses lock file contents, so a + // corrupt or truncated nub.lock resolves to nub just like a valid one. + await seed({ "nub.lock": "\0not-a-valid-lockfile\0" }); + expect(findByLockFile(process.cwd())).toBe(NubPackageManager); + }); + }); +}); diff --git a/packages/workflows-shared/CHANGELOG.md b/packages/workflows-shared/CHANGELOG.md index 1746b8ec22..5678562c39 100644 --- a/packages/workflows-shared/CHANGELOG.md +++ b/packages/workflows-shared/CHANGELOG.md @@ -1,5 +1,11 @@ # @cloudflare/workflows-shared +## 0.12.1 + +### Patch Changes + +- [#14707](https://github.com/cloudflare/workers-sdk/pull/14707) [`b38f494`](https://github.com/cloudflare/workers-sdk/commit/b38f494204e5e08e561b8f198ef928188e554868) Thanks [@emily-shen](https://github.com/emily-shen)! - Update zod to v4 + ## 0.12.0 ### Minor Changes diff --git a/packages/workflows-shared/package.json b/packages/workflows-shared/package.json index bc07575484..691fd5c1c9 100644 --- a/packages/workflows-shared/package.json +++ b/packages/workflows-shared/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workflows-shared", - "version": "0.12.0", + "version": "0.12.1", "private": true, "description": "Package that is used at Cloudflare to power some internal features of Cloudflare Workflows.", "keywords": [ @@ -36,7 +36,7 @@ "heap-js": "^2.5.0", "itty-time": "^2.0.2", "mime": "^3.0.0", - "zod": "^3.22.3" + "zod": "catalog:default" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "catalog:default", diff --git a/packages/workflows-shared/src/context.ts b/packages/workflows-shared/src/context.ts index b75a561ce3..0da09e61a5 100644 --- a/packages/workflows-shared/src/context.ts +++ b/packages/workflows-shared/src/context.ts @@ -39,6 +39,7 @@ import { isValidStepConfig, isValidStepName, MAX_STEP_NAME_LENGTH, + SENSITIVE_STEP_OUTPUT, } from "./lib/validators"; import { MODIFIER_KEYS } from "./modifier"; import type { Engine } from "./engine"; @@ -68,6 +69,8 @@ export type Event = { const SERIALIZABLE_DELAY_MARKER = "[dynamic]"; type SerializableDelayMarker = typeof SERIALIZABLE_DELAY_MARKER; +export const REDACTED_STEP_OUTPUT = "[REDACTED]"; + // The persisted, fully-merged config. A dynamic delay is stored as the marker. export type ResolvedStepConfig = { retries: { @@ -1257,12 +1260,18 @@ export class Context extends RpcTarget { } } + const redactOutput = config.sensitive === SENSITIVE_STEP_OUTPUT; this.#engine.writeLog(events.success, cacheKey, stepNameWithCounter, { // TODO (WOR-86): Add limits, figure out serialization - result: lastStreamMeta ? undefined : result, - ...(lastStreamMeta && { - streamOutput: { cacheKey, meta: lastStreamMeta }, - }), + result: redactOutput + ? REDACTED_STEP_OUTPUT + : lastStreamMeta + ? undefined + : result, + ...(!redactOutput && + lastStreamMeta && { + streamOutput: { cacheKey, meta: lastStreamMeta }, + }), ...(!isRollback && rollbackFn ? { hasRollback: true } : {}), }); this.#registerRollback({ diff --git a/packages/workflows-shared/src/lib/validators.ts b/packages/workflows-shared/src/lib/validators.ts index dd9ee26bb8..6818581abb 100644 --- a/packages/workflows-shared/src/lib/validators.ts +++ b/packages/workflows-shared/src/lib/validators.ts @@ -1,6 +1,8 @@ import { ms } from "itty-time"; import { z } from "zod"; +export const SENSITIVE_STEP_OUTPUT = "output"; + export const MAX_WORKFLOW_NAME_LENGTH = 64; export const MAX_WORKFLOW_INSTANCE_ID_LENGTH = 100; @@ -58,6 +60,7 @@ const STEP_CONFIG_SCHEMA = z .strict() .optional(), timeout: z.number().gte(0).or(z.string()).optional(), + sensitive: z.literal(SENSITIVE_STEP_OUTPUT).optional(), }) .strict(); diff --git a/packages/workflows-shared/tests/context.test.ts b/packages/workflows-shared/tests/context.test.ts index 7b8b1cf698..bbc785f09e 100644 --- a/packages/workflows-shared/tests/context.test.ts +++ b/packages/workflows-shared/tests/context.test.ts @@ -1,8 +1,10 @@ import { runInDurableObject } from "cloudflare:test"; import { env } from "cloudflare:workers"; +import { NonRetryableError } from "cloudflare:workflows"; import { afterEach, describe, it, vi } from "vitest"; import workerdUnsafe from "workerd:unsafe"; import { InstanceEvent } from "../src"; +import { REDACTED_STEP_OUTPUT } from "../src/context"; import { computeHash } from "../src/lib/cache"; import { InvalidStepReadableStreamError, @@ -1452,3 +1454,160 @@ describe("Context - typed-array step outputs (issue #14101)", () => { ).toBe(false); }); }); + +describe("Sensitive step output", () => { + it("should redact a sensitive step's output in logs while passing the real value downstream", async ({ + expect, + }) => { + let downstreamValue: unknown; + + const engineStub = await runWorkflowAndAwait( + "SENSITIVE-STEP-OUTPUT", + async (_event, step) => { + const secret = await step.do( + "sensitive step", + { sensitive: "output" }, + async () => { + return { token: "super-secret" }; + } + ); + await step.do("downstream step", async () => { + downstreamValue = secret; + return "ok"; + }); + } + ); + + await vi.waitUntil( + async () => { + const logs = (await engineStub.readLogs()) as EngineLogs; + return logs.logs.some( + (val) => val.event === InstanceEvent.WORKFLOW_SUCCESS + ); + }, + { timeout: 5000 } + ); + + const logs = (await engineStub.readLogs()) as EngineLogs; + const stepLog = logs.logs.find( + (val) => + val.event === InstanceEvent.STEP_SUCCESS && + val.target === "sensitive step-1" + ); + expect(stepLog?.metadata.result).toBe(REDACTED_STEP_OUTPUT); + + // The real value is still cached and handed to the running workflow. + expect(downstreamValue).toEqual({ token: "super-secret" }); + }); + + it("should redact a sensitive step's output from waitForStepResult", async ({ + expect, + }) => { + const engineStub = await runWorkflowAndAwait( + "SENSITIVE-STEP-WAIT-RESULT", + async (_event, step) => { + await step.do( + "sensitive step", + { sensitive: "output" }, + async () => "super-secret" + ); + } + ); + + await vi.waitUntil( + async () => { + const logs = (await engineStub.readLogs()) as EngineLogs; + return logs.logs.some( + (val) => val.event === InstanceEvent.WORKFLOW_SUCCESS + ); + }, + { timeout: 5000 } + ); + + const result = await engineStub.waitForStepResult("sensitive step"); + expect(result).toBe(REDACTED_STEP_OUTPUT); + }); + + it("should not redact a sensitive step's error", async ({ expect }) => { + const engineStub = await runWorkflowAndAwait( + "SENSITIVE-STEP-ERROR", + async (_event, step) => { + try { + await step.do( + "sensitive failing step", + { sensitive: "output", retries: { limit: 0, delay: 0 } }, + async () => { + throw new NonRetryableError("boom with secret context"); + } + ); + } catch {} + } + ); + + await vi.waitUntil( + async () => { + const logs = (await engineStub.readLogs()) as EngineLogs; + return logs.logs.some( + (val) => val.event === InstanceEvent.WORKFLOW_SUCCESS + ); + }, + { timeout: 5000 } + ); + + const logs = (await engineStub.readLogs()) as EngineLogs; + const attemptFailure = logs.logs.find( + (val) => val.event === InstanceEvent.ATTEMPT_FAILURE + ); + const error = attemptFailure?.metadata.error as + | { message: string } + | undefined; + expect(error?.message).toContain("boom with secret context"); + }); + + it("should redact a sensitive streaming step output in readLogs", async ({ + expect, + }) => { + const payload = "streamed secret"; + const payloadBytes = encodeUtf8(payload); + + const engineStub = await runWorkflowAndAwait( + "SENSITIVE-STREAM-OUTPUT", + async (_event, step) => { + const stream = await step.do( + "sensitive stream step", + { sensitive: "output" }, + async () => { + return new ReadableStream({ + start(controller) { + controller.enqueue(payloadBytes); + controller.close(); + }, + }); + } + ); + const bytes = await readStreamBytes( + stream as ReadableStream + ); + return decodeUtf8(bytes); + } + ); + + await vi.waitUntil( + async () => { + const logs = (await engineStub.readLogs()) as EngineLogs; + return logs.logs.some( + (val) => val.event === InstanceEvent.WORKFLOW_SUCCESS + ); + }, + { timeout: 5000 } + ); + + const logs = (await engineStub.readLogs()) as EngineLogs; + const stepLog = logs.logs.find( + (val) => + val.event === InstanceEvent.STEP_SUCCESS && + val.target === "sensitive stream step-1" + ); + expect(stepLog?.metadata.result).toBe(REDACTED_STEP_OUTPUT); + }); +}); diff --git a/packages/workflows-shared/tests/validators.test.ts b/packages/workflows-shared/tests/validators.test.ts index 342a86126a..deb6ac736c 100644 --- a/packages/workflows-shared/tests/validators.test.ts +++ b/packages/workflows-shared/tests/validators.test.ts @@ -96,6 +96,8 @@ describe("Workflow step config validation", () => { retries: { limit: 3, delay: 10, backoff: "constant" }, timeout: 0, }, + { timeout: "5 minutes", sensitive: "all" }, + { timeout: "5 minutes", sensitive: true }, ])("should reject invalid step configs", (value, { expect }) => { expect(isValidStepConfig(value)).toBe(false); }); @@ -117,6 +119,11 @@ describe("Workflow step config validation", () => { retries: { limit: 5, delay: 0, backoff: "constant" }, timeout: "2 minutes", }, + { + retries: { limit: 3, delay: 10, backoff: "constant" }, + timeout: "2 minutes", + sensitive: "output", + }, ])("should accept valid step configs", (value, { expect }) => { expect(isValidStepConfig(value)).toBe(true); }); diff --git a/packages/wrangler/CHANGELOG.md b/packages/wrangler/CHANGELOG.md index 8aac62bdb8..8df545f956 100644 --- a/packages/wrangler/CHANGELOG.md +++ b/packages/wrangler/CHANGELOG.md @@ -1,5 +1,175 @@ # wrangler +## 4.113.0 + +### Minor Changes + +- [#14471](https://github.com/cloudflare/workers-sdk/pull/14471) [`f03b108`](https://github.com/cloudflare/workers-sdk/commit/f03b10854d983c353fd4f3d6621b5ed716379ba3) Thanks [@DiogoSantoss](https://github.com/DiogoSantoss)! - Apply Email Routing `addresses` during Worker trigger deployment + + Worker trigger deployment now reconciles the Worker's Email Routing rules with the top-level `addresses` config. This runs for `wrangler deploy`, `wrangler triggers deploy`, and clients of `@cloudflare/deploy-helpers`. After the Worker uploads, or when `wrangler triggers deploy` runs after a version promotion, the deploy helper asks the Email Routing API for a plan, renders the changes grouped by zone (`+` added, `~` updated, `-` deleted, `!` conflict), prompts once for destructive changes in interactive mode, and applies accepted changes through the per-zone rule endpoints. Purely additive plans apply without a prompt, while non-interactive destructive plans fail without modifying rules. + +- [#14679](https://github.com/cloudflare/workers-sdk/pull/14679) [`deae171`](https://github.com/cloudflare/workers-sdk/commit/deae1719b276b9ce2bb67a36671b5cf806ef3801) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add `exclude_packages` option to `dependencies_instrumentation` configuration + + The `dependencies_instrumentation` config object now accepts an optional `exclude_packages` field — an array of package name patterns (with glob-style `*` wildcards) to exclude from the dependency metadata collected during deploy and version uploads. + + ```jsonc + // wrangler.json + { + "dependencies_instrumentation": { + "exclude_packages": ["@internal/*", "secret-tool"] + } + } + ``` + +- [#14721](https://github.com/cloudflare/workers-sdk/pull/14721) [`4e92e32`](https://github.com/cloudflare/workers-sdk/commit/4e92e32e1f1c27dcd463bcf38ed79e0d1b046679) Thanks [@dmmulroy](https://github.com/dmmulroy)! - Support Artifacts sources when creating Queue event subscriptions + + `wrangler queues subscription create` now accepts the `artifacts` and `artifacts.repo` source types supported by the Cloudflare API. + +- [#13352](https://github.com/cloudflare/workers-sdk/pull/13352) [`d1d6945`](https://github.com/cloudflare/workers-sdk/commit/d1d69450decfb319a2bbf61e4c042b0511ab2618) Thanks [@penalosa](https://github.com/penalosa)! - Expand automatic resource provisioning to Queue, Dispatch Namespace, and Flagship bindings + + Deployments can now omit the resource name or ID for these bindings. Wrangler will inherit the existing binding on subsequent deploys, create a deterministically named resource automatically, or offer existing resources during an interactive deploy with automatic creation disabled. + +- [#14688](https://github.com/cloudflare/workers-sdk/pull/14688) [`a0c8bb1`](https://github.com/cloudflare/workers-sdk/commit/a0c8bb118e04eebba870a6fbe9f5041095b04637) Thanks [@NuroDev](https://github.com/NuroDev)! - Print Local Explorer API details for headless agent-driven `wrangler dev` sessions + + When `wrangler dev` is started in a headless AI agent environment, Wrangler now prints the Local Explorer API URL and basic resource routes so agents can inspect local Workers and bindings without relying on the interactive UI. + +- [#14724](https://github.com/cloudflare/workers-sdk/pull/14724) [`a50f73a`](https://github.com/cloudflare/workers-sdk/commit/a50f73a06bb7b078268ce9cebb4d1c16f79a3144) Thanks [@jamesopstad](https://github.com/jamesopstad)! - Add a `settings` export to the experimental `cloudflare.config.ts` config + + Account-level settings (`accountId`, `complianceRegion`) now live in a dedicated, named `settings` export authored via `defineSettings`, rather than on the Worker config. A `cloudflare.config.ts` can export at most one `settings` object; the Worker itself is the `default` export. + + ```ts + // cloudflare.config.ts + import { defineSettings, defineWorker } from "wrangler/experimental-config"; + import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; + + export const settings = defineSettings({ + accountId: "", + }); + + export default defineWorker({ + name: "my-worker", + entrypoint, + compatibilityDate: "2026-05-18", + }); + ``` + + This is only used behind the experimental new-config path (`wrangler --experimental-new-config` and the `@cloudflare/vite-plugin` `experimental.newConfig` option). + +- [#14595](https://github.com/cloudflare/workers-sdk/pull/14595) [`2b390d7`](https://github.com/cloudflare/workers-sdk/commit/2b390d7831ff27aa13cdf05aa8e11e4c0086f924) Thanks [@colinhacks](https://github.com/colinhacks)! - Recognise nub as a package manager + + wrangler now detects nub — from its `npm_config_user_agent` and an installed `nub` binary — and autoconfig detects nub projects by their `nub.lock`, alongside npm, pnpm, yarn, and bun. + +- [#14742](https://github.com/cloudflare/workers-sdk/pull/14742) [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9) Thanks [@pombosilva](https://github.com/pombosilva)! - Add support for redacting sensitive Workflows step output in local dev. + + Steps configured with `sensitive: "output"` now have their output redacted to `[REDACTED]` in step logs and step-output responses when running Workflows locally, matching production behavior. The real value is still passed to downstream steps, and step errors are never redacted. + +### Patch Changes + +- [#14715](https://github.com/cloudflare/workers-sdk/pull/14715) [`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ------------------------- | ------------- | ------------- | + | @cloudflare/workers-types | ^5.20260714.1 | ^5.20260721.1 | + | workerd | 1.20260714.1 | 1.20260721.1 | + +- [#14744](https://github.com/cloudflare/workers-sdk/pull/14744) [`a0a091b`](https://github.com/cloudflare/workers-sdk/commit/a0a091b9246c5e10408f57342b3275659c9655e3) Thanks [@penalosa](https://github.com/penalosa)! - Drop the "Experimental:" prefix from the resource provisioning header now that automatic provisioning is generally available. The deploy output now reads `The following bindings need to be provisioned:`. + +- [#14720](https://github.com/cloudflare/workers-sdk/pull/14720) [`0df3d43`](https://github.com/cloudflare/workers-sdk/commit/0df3d432353f39b6a90c340c268c83a7ac0b7d5c) Thanks [@penalosa](https://github.com/penalosa)! - Fix remote binding previews for accounts without a workers.dev subdomain + + Wrangler now automatically registers a workers.dev subdomain when one is required to start a remote binding preview. + +- [#14773](https://github.com/cloudflare/workers-sdk/pull/14773) [`d83a476`](https://github.com/cloudflare/workers-sdk/commit/d83a476bab53f0266a67790242f855aab6e0468c) Thanks [@chinesepowered](https://github.com/chinesepowered)! - Fix stray characters in the Workers Sites asset-key-too-long error + + The error thrown when an asset path key exceeds the 512-character limit ended with a stray `",` copy-paste artifact, so the message printed to users terminated with `...#kv-limits",` and the trailing documentation URL was malformed. The message now ends cleanly at the URL. + +- [#14766](https://github.com/cloudflare/workers-sdk/pull/14766) [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d) Thanks [@gianghungtien](https://github.com/gianghungtien)! - Report the Worker's error for `HEAD` requests instead of an internal JSON parse error + + A Worker that threw on a `HEAD` request (for example `curl -I`) logged `SyntaxError: Unexpected end of JSON input` from miniflare's internals rather than the actual error, and `dispatchFetch()` rejected with that same misleading error. `workerd` drops response bodies for `HEAD` requests, so the serialised error never reached the code that revives it. + + The error is now also carried in a header, which survives `HEAD`, so the original message and source-mapped stack are reported for every method. When no payload is available the reporting degrades to a plain error rather than surfacing a parse failure. + +- [#14448](https://github.com/cloudflare/workers-sdk/pull/14448) [`c82d96b`](https://github.com/cloudflare/workers-sdk/commit/c82d96ba63a3b343b520e781a070889251868d9a) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Use the new PATCH APIs for versioned secret commands + + Wrangler now updates versioned Worker secrets by patching the latest Worker version instead of downloading the latest version contents and uploading a full replacement version. This avoids reconstructing Worker configuration in Wrangler, which should reduce bugs when Workers use less common features. For example, this avoids regressions like the previous placement preservation bug fixed in [#13843](https://github.com/cloudflare/workers-sdk/pull/13843). + +- [#14617](https://github.com/cloudflare/workers-sdk/pull/14617) [`f75ae5d`](https://github.com/cloudflare/workers-sdk/commit/f75ae5d02576d82aad4723b9e17ccb26277b69ab) Thanks [@martijnwalraven](https://github.com/martijnwalraven)! - Derive `nodejsCompatMode` from the effective compatibility inputs in `unstable_startWorker()` + + The CLI computes the node-compat mode from the effective compatibility date and flags (`args.* ?? parsedConfig.*`), but the programmatic path used `input.build.nodejsCompatMode` raw — leaving it unset meant a worker's `nodejs_compat` flag (from its config file or from input-level `compatibilityFlags`) was silently ignored, so bundling failed to resolve node builtins that `wrangler dev` handles. `startWorker` now derives the mode the same way when the caller does not provide one: input-level `compatibilityDate`/`compatibilityFlags` first, then the resolved config, with no-bundle taken from the resolved `build.bundle` semantics. Passing an explicit `null` still disables it. + +- Updated dependencies [[`42af66d`](https://github.com/cloudflare/workers-sdk/commit/42af66d00b255945989726387acf46409b4c5eb3), [`4815711`](https://github.com/cloudflare/workers-sdk/commit/4815711fb5f896a5aa9221b6bddb9ef78c3f288d), [`34430b3`](https://github.com/cloudflare/workers-sdk/commit/34430b34f468825775377689621e451d730ab0c9)]: + - miniflare@4.20260721.0 + +## 4.112.0 + +### Minor Changes + +- [#14470](https://github.com/cloudflare/workers-sdk/pull/14470) [`3de70df`](https://github.com/cloudflare/workers-sdk/commit/3de70dfd32f823677a9d20311ee087fd7e69d51a) Thanks [@DiogoSantoss](https://github.com/DiogoSantoss)! - Add a top-level `addresses` field to Wrangler configuration for Email Routing + + You can now declare the inbound email addresses handled by your Worker directly in `wrangler.json`: + + ```json + { + "name": "my-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-21", + "addresses": ["support@example.com", "*@example.com"] + } + ``` + +- [#14706](https://github.com/cloudflare/workers-sdk/pull/14706) [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c) Thanks [@edmundhung](https://github.com/edmundhung)! - Add Durable Object storage access to `createTestHarness()` + + You can now execute SQL against a SQLite-backed Durable Object to seed or assert the storage state. + + ```ts + const server = createTestHarness({ + workers: [{ configPath: "./wrangler.json" }], + }); + await server.listen(); + + const worker = server.getWorker(); + const storage = await worker.getDurableObjectStorage("COUNTER", { + name: "user-123", + }); + + await worker.fetch("/counter/user-123"); + + const rows = await storage.exec( + "SELECT value FROM counters WHERE id = ?", + "user-123" + ); + expect(rows).toEqual([{ value: 1 }]); + ``` + +- [#14562](https://github.com/cloudflare/workers-sdk/pull/14562) [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981) Thanks [@martijnwalraven](https://github.com/martijnwalraven)! - Emit a typed `runtimeError` event on the `unstable_startWorker` DevEnv for uncaught Worker exceptions + + Uncaught Worker exceptions were only source-mapped and printed, so programmatic consumers had to scrape terminal output to observe them. The DevEnv now re-emits a `RuntimeErrorEvent` (like `reloadComplete`) carrying the exception text and source-mapped stack — fed from Miniflare's pretty-error seam via the new `handleUncaughtError` option for exceptions the runtime catches, and from the inspector for those it does not. + +### Patch Changes + +- [#14682](https://github.com/cloudflare/workers-sdk/pull/14682) [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler" + + The following dependency versions have been updated: + + | Dependency | From | To | + | ------------------------- | ------------- | ------------- | + | @cloudflare/workers-types | ^5.20260710.1 | ^5.20260714.1 | + | workerd | 1.20260710.1 | 1.20260714.1 | + +- [#14725](https://github.com/cloudflare/workers-sdk/pull/14725) [`c79504f`](https://github.com/cloudflare/workers-sdk/commit/c79504f90956405f5fab59448ba53dcf44b8d3a2) Thanks [@edmundhung](https://github.com/edmundhung)! - Support containers in `createTestHarness()` + + Workers configured with containers can now be tested using `createTestHarness()`. The harness builds configured images and makes container-backed Durable Objects available during integration tests. + +- [#14696](https://github.com/cloudflare/workers-sdk/pull/14696) [`c7dbe1a`](https://github.com/cloudflare/workers-sdk/commit/c7dbe1a3d527d534d4069080c56e364d33d6a455) Thanks [@martijnwalraven](https://github.com/martijnwalraven)! - Type `unstable_startWorker`, `DevEnv.startWorker`, and `ConfigController.set`/`patch` against `WranglerStartDevWorkerInput`, so the wrangler-specific `dev.structuredLogsHandler` field the runtime already honors is expressible through the public API. Previously the public signatures took the base `StartDevWorkerInput`, and callers passing the handler needed a cast while internal callers (the test harness) routed the wider type around the signature. + +- [#14494](https://github.com/cloudflare/workers-sdk/pull/14494) [`4e1a7a7`](https://github.com/cloudflare/workers-sdk/commit/4e1a7a7fe566774dca376c5d569cab56b14f34e3) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Register a workers.dev subdomain before uploading a new Worker + + Deploying a Worker for the first time on an account that has no workers.dev subdomain failed with an opaque API error raised by the upload request itself (code 10063, "You need a workers.dev subdomain in order to proceed"). Wrangler now checks for a workers.dev subdomain before uploading a brand-new Worker that publishes to workers.dev and prompts you to register one, so you get a clear, actionable message instead of a cryptic API failure. The check is skipped for deploys that don't target workers.dev (routes-only deploys, or `workers_dev: false`) and for existing Workers, since their account already has a subdomain. + +- Updated dependencies [[`34e696d`](https://github.com/cloudflare/workers-sdk/commit/34e696dc60dcd7ea04cdab8a6267d255efab9983), [`d39ae01`](https://github.com/cloudflare/workers-sdk/commit/d39ae0131018088f8b4c31ba3f5506e224796cce), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`9f04a7e`](https://github.com/cloudflare/workers-sdk/commit/9f04a7e96bffe42a5a53d7396624da5374bff981), [`cb30df3`](https://github.com/cloudflare/workers-sdk/commit/cb30df3a9f19e15535349643c1089e90ba16a80d), [`cb6c3f9`](https://github.com/cloudflare/workers-sdk/commit/cb6c3f9a5c6d67804cd0cb447cc0837a9f75848c), [`3f3afbb`](https://github.com/cloudflare/workers-sdk/commit/3f3afbbf136c404d26ee39d187a44adb06c1b6e8), [`e6fbc4e`](https://github.com/cloudflare/workers-sdk/commit/e6fbc4e67f76f9b78da3d9a2dd27c6e9786d2645)]: + - miniflare@4.20260714.0 + ## 4.111.0 ### Minor Changes diff --git a/packages/wrangler/e2e/assets-multiworker.test.ts b/packages/wrangler/e2e/assets-multiworker.test.ts index 7bebe0b05f..c04a7447f5 100644 --- a/packages/wrangler/e2e/assets-multiworker.test.ts +++ b/packages/wrangler/e2e/assets-multiworker.test.ts @@ -18,12 +18,12 @@ async function startWorkersDevRegistry( const workerA = helper.runLongLived(wranglerDev, { cwd: regularWorkerFirst ? assetWorker : regularWorker, }); - await workerA.waitForReady(5_000); + await workerA.waitForReady(); const workerB = helper.runLongLived(wranglerDev, { cwd: regularWorkerFirst ? regularWorker : assetWorker, }); - const { url } = await workerB.waitForReady(5_000); + const { url } = await workerB.waitForReady(); return url; } @@ -39,7 +39,7 @@ async function startWorkersMultiworker( `${wranglerDev} -c wrangler.toml -c ${regularWorkerFirst ? assetWorker : regularWorker}/wrangler.toml`, { cwd: regularWorkerFirst ? regularWorker : assetWorker } ); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); return url; } diff --git a/packages/wrangler/e2e/createTestHarness.test.ts b/packages/wrangler/e2e/createTestHarness.test.ts index 685ea6674e..d103a41b89 100644 --- a/packages/wrangler/e2e/createTestHarness.test.ts +++ b/packages/wrangler/e2e/createTestHarness.test.ts @@ -458,6 +458,118 @@ describe("createTestHarness", () => { }); }); + it("exposes Durable Object storage", async ({ expect, onTestFailed }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "do-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "durable_objects": { + "bindings": [ + { "name": "OBJECT", "class_name": "TestObject" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["TestObject"] } + ] + } + `, + "src/index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + + export class TestObject extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + this.ctx.storage.sql.exec( + "CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, value TEXT)" + ); + } + + fetch(request) { + const url = new URL(request.url); + const sql = this.ctx.storage.sql; + + if (url.pathname === "/write") { + sql.exec( + "INSERT OR REPLACE INTO entries (id, value) VALUES ('key', ?)", + url.searchParams.get("value") + ); + return new Response("ok"); + } + + const row = sql.exec("SELECT value FROM entries WHERE id = 'key'").one(); + return new Response(row?.value ?? "missing"); + } + } + + export default { + fetch(request, env) { + const url = new URL(request.url); + const id = env.OBJECT.idFromName(url.searchParams.get("name") ?? "user-123"); + return env.OBJECT.get(id).fetch(request); + } + } + `, + }); + + const server = createTestHarness({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + onTestFailed(server.debug); + + await server.listen(); + + const worker = server.getWorker< + { OBJECT: DurableObjectNamespace }, + { + default: ExportedHandler<{ OBJECT: DurableObjectNamespace }>; + TestObject: new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Match runtime class exports by instance type. + ...args: any[] + ) => CloudflareWorkersModule.DurableObject; + } + >(); + + const storageByClassName = await worker.getDurableObjectStorage( + "TestObject", + { + name: "by-class", + } + ); + await storageByClassName.exec( + "INSERT INTO entries (id, value) VALUES ('key', ?)", + "seeded-by-class" + ); + + const response3 = await worker.fetch("/read?name=by-class"); + await expect(response3.text()).resolves.toBe("seeded-by-class"); + + const storageByBindingName = await worker.getDurableObjectStorage( + "OBJECT", + { + name: "user-123", + } + ); + await storageByBindingName.exec( + "INSERT INTO entries (id, value) VALUES ('key', ?)", + "seeded" + ); + + const response1 = await worker.fetch("/read?name=user-123"); + await expect(response1.text()).resolves.toBe("seeded"); + + const response2 = await worker.fetch("/write?name=user-123&value=app"); + await expect(response2.text()).resolves.toBe("ok"); + + const rows = await storageByBindingName.exec<{ value: string }>( + "SELECT value FROM entries WHERE id = 'key'" + ); + expect(rows).toEqual([{ value: "app" }]); + }); + it("introspects Workflow instances by binding name", async ({ expect }) => { await helper.seed({ "wrangler.jsonc": dedent` diff --git a/packages/wrangler/e2e/deployments.test.ts b/packages/wrangler/e2e/deployments.test.ts index fe2b73540e..335890b82a 100644 --- a/packages/wrangler/e2e/deployments.test.ts +++ b/packages/wrangler/e2e/deployments.test.ts @@ -17,8 +17,9 @@ import { generateResourceName } from "./helpers/generate-resource-name"; import { normalizeOutput, validateAssetUploadLogs } from "./helpers/normalize"; import { retry } from "./helpers/retry"; import { waitForLong } from "./helpers/wait-for"; +import { waitForWorkersDev } from "./helpers/wait-for-workers-dev"; -const TIMEOUT = 50_000; +const TIMEOUT = 90_000; describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( "deployments", @@ -61,13 +62,11 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( const deployedUrl = getDeployedUrl(output); - await waitForLong( - async () => { - const response = await fetch(deployedUrl); - expect(await response.text()).toEqual("Hello World!"); - }, - { timeout: 30_000 } + const response = await waitForWorkersDev( + deployedUrl, + async (candidate) => (await candidate.clone().text()) === "Hello World!" ); + expect(await response.text()).toEqual("Hello World!"); }); it("lists 1 deployment", async ({ expect }) => { @@ -98,13 +97,12 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( const deployedUrl = getDeployedUrl(output); - await waitForLong( - async () => { - const response = await fetch(deployedUrl); - expect(await response.text()).toEqual("Updated Worker!"); - }, - { timeout: 30_000 } + const response = await waitForWorkersDev( + deployedUrl, + async (candidate) => + (await candidate.clone().text()) === "Updated Worker!" ); + expect(await response.text()).toEqual("Updated Worker!"); }); it("lists 2 deployments", async ({ expect }) => { diff --git a/packages/wrangler/e2e/dev-registry.test.ts b/packages/wrangler/e2e/dev-registry.test.ts index 143cf2a771..79e7188284 100644 --- a/packages/wrangler/e2e/dev-registry.test.ts +++ b/packages/wrangler/e2e/dev-registry.test.ts @@ -141,7 +141,7 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch b", async ({ expect }) => { const worker = helper.runLongLived(cmd, { cwd: b }); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect(fetch(url).then((r) => r.text())).resolves.toBe( "hello world" @@ -151,10 +151,10 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch b through a (start b, start a)", async ({ expect }) => { const workerB = helper.runLongLived(cmd, { cwd: b }); // We don't need b's URL, but ensure that b starts up before a - await workerB.waitForReady(5_000); + await workerB.waitForReady(); const workerA = helper.runLongLived(cmd, { cwd: a }); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong(() => expect(fetchText(url)).resolves.toBe("hello world") @@ -169,10 +169,10 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch b through a (start a, start b)", async ({ expect }) => { const workerA = helper.runLongLived(cmd, { cwd: a }); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); const workerB = helper.runLongLived(cmd, { cwd: b }); - await workerB.waitForReady(5_000); + await workerB.waitForReady(); await waitForLong(() => expect(fetchText(url)).resolves.toBe("hello world") @@ -200,11 +200,11 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { }) => { const workerC = helper.runLongLived(cmd, { cwd: c }); // We don't need c's URL, but ensure that c starts up before a - await workerC.waitForReady(5_000); + await workerC.waitForReady(); const workerA = helper.runLongLived(cmd, { cwd: a }); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong(() => expect(fetchText(`${url}/service`)).resolves.toBe( @@ -218,11 +218,11 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { "can fetch service worker c through a (start a, start c)", async ({ expect }) => { const workerA = helper.runLongLived(cmd, { cwd: a }); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); const workerC = helper.runLongLived(cmd, { cwd: c }); - await workerC.waitForReady(5_000); + await workerC.waitForReady(); await waitForLong(() => expect(fetchText(`${url}/service`)).resolves.toBe( @@ -273,14 +273,14 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch a without b running", async ({ expect }) => { const workerA = helper.runLongLived(cmd, { cwd: a }); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await expect(fetchText(`${url}`)).resolves.toBe("hello from a"); }); it("tail event sent to b", async ({ expect }) => { const workerA = helper.runLongLived(cmd, { cwd: a }); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); const workerB = helper.runLongLived(cmd, { cwd: b }); @@ -321,7 +321,7 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch DO through a", async ({ expect }) => { const worker = helper.runLongLived(cmd, { cwd: a }); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect( fetchJson(`${url}/do`, { @@ -336,11 +336,11 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { "can fetch remote DO attached to a through b (start b, start a)", async ({ expect }) => { const workerB = helper.runLongLived(cmd, { cwd: b }); - const { url } = await workerB.waitForReady(5_000); + const { url } = await workerB.waitForReady(); const workerA = helper.runLongLived(cmd, { cwd: a }); - await workerA.waitForReady(5_000); + await workerA.waitForReady(); await expect( fetchJson(`${url}/do`, { @@ -356,11 +356,11 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { expect, }) => { const workerA = helper.runLongLived(cmd, { cwd: a }); - await workerA.waitForReady(5_000); + await workerA.waitForReady(); const workerB = helper.runLongLived(cmd, { cwd: b }); - const { url } = await workerB.waitForReady(5_000); + const { url } = await workerB.waitForReady(); await waitForLong(() => expect( @@ -400,7 +400,7 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch b", async ({ expect }) => { const worker = helper.runLongLived(cmd, { cwd: b }); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect(fetch(url).then((r) => r.text())).resolves.toBe( "hello world" @@ -413,7 +413,7 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { { cwd: a } ); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect(fetch(url).then((r) => r.text())).resolves.toBe( "Hello from Pages" @@ -423,13 +423,13 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { it("can fetch b through a (start b, start a)", async ({ expect }) => { const workerB = helper.runLongLived(cmd, { cwd: b }); // We don't need b's URL, but ensure that b starts up before a - await workerB.waitForReady(5_000); + await workerB.waitForReady(); const workerA = helper.runLongLived( `${cmd.replace("wrangler dev", "wrangler pages dev")}`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong(() => expect(fetchText(`${url}/service`)).resolves.toBe("hello world") @@ -447,10 +447,10 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { `${cmd.replace("wrangler dev", "wrangler pages dev")}`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); const workerB = helper.runLongLived(cmd, { cwd: b }); - await workerB.waitForReady(5_000); + await workerB.waitForReady(); await waitForLong(() => expect(fetchText(`${url}/service`)).resolves.toBe("hello world") @@ -468,10 +468,10 @@ describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { `${cmd.replace("wrangler dev", "wrangler pages dev")} dist --service BEE=${workerName2}`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); const workerB = helper.runLongLived(cmd, { cwd: b }); - await workerB.waitForReady(5_000); + await workerB.waitForReady(); await waitForLong(() => expect(fetchText(`${url}/service`)).resolves.toBe("hello world") diff --git a/packages/wrangler/e2e/dev.test.ts b/packages/wrangler/e2e/dev.test.ts index 6634feb201..35b1477a3b 100644 --- a/packages/wrangler/e2e/dev.test.ts +++ b/packages/wrangler/e2e/dev.test.ts @@ -504,10 +504,11 @@ it.runIf(process.platform !== "win32")( expect(exitCode).not.toBe(0); - const endProcesses = getStartedWorkerdProcesses(helper.tmpPath); - expect(beginProcesses.length).toBe(0); - expect(endProcesses.length).toBe(0); + // workerd shutdown can lag briefly after wrangler exits + await waitFor(() => { + expect(getStartedWorkerdProcesses(helper.tmpPath).length).toBe(0); + }); } ); diff --git a/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts b/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts index c110466143..876aaff441 100644 --- a/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts +++ b/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts @@ -1,7 +1,6 @@ import assert from "node:assert"; import crypto from "node:crypto"; import { cp } from "node:fs/promises"; -import { setTimeout } from "node:timers/promises"; import { fetch } from "undici"; import { onTestFinished } from "vitest"; import { E2E_ACCOUNT_WORKERS_DEV_DOMAIN } from "./account-id"; @@ -12,7 +11,7 @@ import { } from "./cert"; import { generateResourceName } from "./generate-resource-name"; import { makeRoot, removeFiles, seed } from "./setup"; -import { waitForLong } from "./wait-for"; +import { waitForWorkersDev } from "./wait-for-workers-dev"; import { MINIFLARE_IMPORT, runWrangler, @@ -299,13 +298,11 @@ export class WranglerE2ETestHelper { await this.run( `wrangler deploy ${entryPoint} --name ${workerName} ${configOption} --compatibility-date 2025-01-01` ); - await waitForLong(async () => { - const response = await fetch(deployedUrl); - assert( - response.status === 200, - `Expected status 200 but got ${response.status}` - ); - }); + const response = await waitForWorkersDev(deployedUrl); + assert( + response.status === 200, + `Expected status 200 but got ${response.status}` + ); } /** @@ -362,19 +359,6 @@ export class WranglerE2ETestHelper { const deployedUrl = stdout.match(urlMatcher)?.groups?.url; assert(deployedUrl, `Cannot find URL in ${JSON.stringify(stdout)}`); - // Wait a couple of seconds before we start blasting the worker with requests - // to allow it to complete deployment. - await setTimeout(2_000); - - // Wait for the worker to become available - await waitForLong(async () => { - const response = await fetch(deployedUrl); - assert( - response.status === 200, - `Expected status 200 but got ${response.status}` - ); - }); - const cleanup = async () => { await this.bestEffortRun(`wrangler delete --name ${workerName} --force`); }; @@ -383,15 +367,30 @@ export class WranglerE2ETestHelper { try { this.onTeardown(cleanup, 15_000); } catch (e) { + await cleanup(); throw new Error( "Failed to register cleanup for worker.\nPerhaps you called this outside an `it` block?\nIf so, pass `cleanOnTestFinished: false` and then use the returned `cleanup` helper yourself", { cause: e } ); } - return { deployedUrl, stdout }; - } else { - return { deployedUrl, stdout, cleanup }; } + + try { + const response = await waitForWorkersDev(deployedUrl); + assert( + response.status === 200, + `Expected status 200 but got ${response.status}` + ); + } catch (error) { + if (!cleanOnTestFinished) { + await cleanup(); + } + throw error; + } + + return cleanOnTestFinished + ? { deployedUrl, stdout } + : { deployedUrl, stdout, cleanup }; } /** Create an AI Search instance (backed by an R2 bucket) in the default namespace and clean both up during tear-down. */ diff --git a/packages/wrangler/e2e/helpers/wait-for-workers-dev.test.ts b/packages/wrangler/e2e/helpers/wait-for-workers-dev.test.ts new file mode 100644 index 0000000000..2b11429b45 --- /dev/null +++ b/packages/wrangler/e2e/helpers/wait-for-workers-dev.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert"; +import { createServer } from "node:http"; +import { afterEach, it } from "vitest"; +import { waitForWorkersDev } from "./wait-for-workers-dev"; +import type { RequestListener, Server } from "node:http"; + +let server: Server | undefined; + +afterEach(async () => { + if (server) { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server?.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; + } +}); + +it("retries workers.dev 404 responses", async ({ expect }) => { + let requestCount = 0; + const url = await listen((_request, response) => { + requestCount++; + response.writeHead(requestCount === 1 ? 404 : 200).end(); + }); + + const response = await waitForWorkersDev(url); + + expect(response.status).toBe(200); + expect(requestCount).toBe(2); +}); + +it("retries workers.dev 5xx responses", async ({ expect }) => { + let requestCount = 0; + const url = await listen((_request, response) => { + requestCount++; + response.writeHead(requestCount === 1 ? 503 : 200).end(); + }); + + const response = await waitForWorkersDev(url); + + expect(response.status).toBe(200); + expect(requestCount).toBe(2); +}); + +it("returns non-retryable application failures", async ({ expect }) => { + let requestCount = 0; + const url = await listen((_request, response) => { + requestCount++; + response.writeHead(400).end(); + }); + + const response = await waitForWorkersDev(url); + + expect(response.status).toBe(400); + expect(requestCount).toBe(1); +}); + +it("retries until the response satisfies the readiness predicate", async ({ + expect, +}) => { + let requestCount = 0; + const url = await listen((_request, response) => { + requestCount++; + response.end(requestCount === 1 ? "old" : "new"); + }); + + const response = await waitForWorkersDev( + url, + async (candidate) => (await candidate.clone().text()) === "new" + ); + + expect(await response.text()).toBe("new"); + expect(requestCount).toBe(2); +}); + +async function listen(handler: RequestListener): Promise { + server = createServer(handler); + await new Promise((resolve, reject) => { + server?.once("error", reject); + server?.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + return `http://127.0.0.1:${address.port}`; +} diff --git a/packages/wrangler/e2e/helpers/wait-for-workers-dev.ts b/packages/wrangler/e2e/helpers/wait-for-workers-dev.ts new file mode 100644 index 0000000000..23cb04f529 --- /dev/null +++ b/packages/wrangler/e2e/helpers/wait-for-workers-dev.ts @@ -0,0 +1,46 @@ +import { setTimeout } from "node:timers/promises"; +import { fetch, type Response } from "undici"; + +const WORKERS_DEV_PROPAGATION_TIMEOUT = 60_000; +const INITIAL_RETRY_DELAY = 500; +const MAX_RETRY_DELAY = 5_000; + +export async function waitForWorkersDev( + url: string, + isReady: (response: Response) => boolean | Promise = () => true +) { + const deadline = Date.now() + WORKERS_DEV_PROPAGATION_TIMEOUT; + let retryDelay = INITIAL_RETRY_DELAY; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(deadline - Date.now()), + }); + if ( + response.status !== 404 && + response.status < 500 && + (await isReady(response)) + ) { + return response; + } + await response.body?.cancel(); + lastError = new Error( + `Workers.dev returned ${response.status} before it was ready for ${url}` + ); + } catch (error) { + lastError = error; + } + + const remaining = deadline - Date.now(); + if (remaining > 0) { + await setTimeout(Math.min(retryDelay, remaining)); + retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY); + } + } + + throw new Error(`Timed out waiting for ${url} to propagate`, { + cause: lastError, + }); +} diff --git a/packages/wrangler/e2e/multiworker-dev.test.ts b/packages/wrangler/e2e/multiworker-dev.test.ts index 5fb3d1b9f9..4a44c13774 100644 --- a/packages/wrangler/e2e/multiworker-dev.test.ts +++ b/packages/wrangler/e2e/multiworker-dev.test.ts @@ -189,7 +189,7 @@ describe("multiworker", () => { it("can fetch b", async ({ expect }) => { const worker = helper.runLongLived(`wrangler dev`, { cwd: b }); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect(fetch(url).then((r) => r.text())).resolves.toBe( "hello world" @@ -201,7 +201,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${b}/wrangler.toml`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong( async () => await expect(fetchText(url)).resolves.toBe("hello world") @@ -215,7 +215,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${b}/wrangler.toml`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong( async () => await expect(fetchText(`${url}/count`)).resolves.toBe("6") @@ -227,7 +227,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${b}/wrangler.toml`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong(async () => { const response = await fetch(`${url}/props`); @@ -260,7 +260,7 @@ describe("multiworker", () => { { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong( async () => @@ -291,7 +291,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${c}/wrangler.toml`, { cwd: a } ); - const { url } = await workerA.waitForReady(5_000); + const { url } = await workerA.waitForReady(); await waitForLong( async () => @@ -328,7 +328,7 @@ describe("multiworker", () => { it("can fetch DO through a", async ({ expect }) => { const worker = helper.runLongLived(`wrangler dev`, { cwd: a }); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect( fetchJson(`${url}/do`, { @@ -344,7 +344,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${a}/wrangler.toml`, { cwd: b } ); - const { url } = await workerB.waitForReady(5_000); + const { url } = await workerB.waitForReady(); await expect( fetchJson(`${url}/do`, { @@ -362,7 +362,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${a}/wrangler.toml`, { cwd: b } ); - const { url } = await workerB.waitForReady(5_000); + const { url } = await workerB.waitForReady(); await waitForLong( async () => @@ -414,7 +414,7 @@ describe("multiworker", () => { it("can fetch a without b running", async ({ expect }) => { const worker = helper.runLongLived(`wrangler dev`, { cwd: a }); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect(fetchText(`${url}`)).resolves.toBe("hello from a"); }); @@ -424,7 +424,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${b}/wrangler.toml`, { cwd: a } ); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await expect(fetchText(`${url}`)).resolves.toBe("hello from a"); @@ -484,7 +484,7 @@ describe("multiworker", () => { `wrangler dev -c wrangler.toml -c ${b}/wrangler.toml`, { cwd: a } ); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); await waitForLong(() => expect(fetchText(`${url}`)).resolves.toBe("hello from a") @@ -528,7 +528,7 @@ describe("multiworker", () => { `wrangler pages dev -c wrangler.toml -c ${b}/wrangler.toml -c ${c}/wrangler.toml`, { cwd: a } ); - const { url } = await pages.waitForReady(5_000); + const { url } = await pages.waitForReady(); await waitForLong( async () => @@ -543,7 +543,7 @@ describe("multiworker", () => { `wrangler pages dev -c wrangler.toml -c ${b}/wrangler.toml -c ${c}/wrangler.toml`, { cwd: a } ); - const { url } = await pages.waitForReady(5_000); + const { url } = await pages.waitForReady(); await waitForLong( async () => @@ -558,7 +558,7 @@ describe("multiworker", () => { `wrangler pages dev -c wrangler.toml -c ${b}/wrangler.toml -c ${c}/wrangler.toml`, { cwd: a } ); - const { url } = await pages.waitForReady(5_000); + const { url } = await pages.waitForReady(); await waitForLong( async () => @@ -634,7 +634,7 @@ describe("multiworker", () => { { cwd: a } ); - const { url } = await worker.waitForReady(5_000); + const { url } = await worker.waitForReady(); const { hostname, port } = new URL(url); await waitFor(() => { diff --git a/packages/wrangler/e2e/provision.test.ts b/packages/wrangler/e2e/provision.test.ts index 00d8d46f00..a05f2b7e6f 100644 --- a/packages/wrangler/e2e/provision.test.ts +++ b/packages/wrangler/e2e/provision.test.ts @@ -6,7 +6,7 @@ import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test"; import { fetchText } from "./helpers/fetch-text"; import { generateResourceName } from "./helpers/generate-resource-name"; import { normalizeOutput } from "./helpers/normalize"; -import { waitForLong } from "./helpers/wait-for"; +import { waitForWorkersDev } from "./helpers/wait-for-workers-dev"; const TIMEOUT = 500_000; const normalize = (str: string) => { @@ -80,7 +80,7 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( const output = await worker.output; expect(normalize(output)).toMatchInlineSnapshot(` "Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV KV Namespace env.D1 D1 Database @@ -122,13 +122,11 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( assert(d1Match?.groups); d1Id = d1Match.groups.d1; - await waitForLong( - async () => { - const response = await fetch(deployedUrl); - expect(await response.text()).toEqual("Hello World!"); - }, - { timeout: 30_000 } + const response = await waitForWorkersDev( + deployedUrl, + async (candidate) => (await candidate.clone().text()) === "Hello World!" ); + expect(await response.text()).toEqual("Hello World!"); }); it("can inherit bindings on re-deploy and won't re-provision", async ({ @@ -151,13 +149,11 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( Current Version ID: 00000000-0000-0000-0000-000000000000" `); - await waitForLong( - async () => { - const response = await fetch(deployedUrl); - expect(await response.text()).toEqual("Hello World!"); - }, - { timeout: 30_000 } + const response = await waitForWorkersDev( + deployedUrl, + async (candidate) => (await candidate.clone().text()) === "Hello World!" ); + expect(await response.text()).toEqual("Hello World!"); }); it("can inspect current bindings", async ({ expect }) => { const versionsRaw = await helper.run(`wrangler versions list --json`); @@ -208,7 +204,7 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( const output = await worker.output; expect(normalize(output)).toMatchInlineSnapshot(` "Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV2 KV Namespace Provisioning KV2 (KV Namespace)... diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 06a939575b..db2f149b30 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -1,6 +1,6 @@ { "name": "wrangler", - "version": "4.111.0", + "version": "4.113.0", "description": "Command-line interface for all things Cloudflare Workers", "keywords": [ "assembly", @@ -96,6 +96,7 @@ "@cloudflare/containers-shared": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/pages-shared": "workspace:^", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/runtime-types": "workspace:*", "@cloudflare/types": "6.18.4", "@cloudflare/workers-auth": "workspace:*", @@ -182,7 +183,7 @@ "xxhash-wasm": "^1.0.1", "yaml": "^2.8.1", "yargs": "^17.7.2", - "zod": "^4.4.3" + "zod": "catalog:default" }, "peerDependencies": { "@cloudflare/workers-types": "catalog:default" diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts index f8d26e4a92..f6d82810d6 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts @@ -191,6 +191,61 @@ describe("ConfigController", () => { expect(config.dev?.structuredLogsHandler).toBe(structuredLogsHandler); }); + it("should derive nodejsCompatMode from the config like the CLI", async ({ + expect, + }) => { + await seed({ + "src/index.ts": dedent /* javascript */ ` + export default { + fetch(request, env, ctx) { + return new Response("hello world") + } + } satisfies ExportedHandler + `, + "wrangler.toml": dedent /* toml */ ` + name = "nodejs-compat-worker" + main = "src/index.ts" + compatibility_date = "2026-06-01" + compatibility_flags = ["nodejs_compat"] + `, + }); + + // Unset: derived from the resolved config's date + flags. + const derived = bus.waitFor("configUpdate"); + await controller.set({ config: "./wrangler.toml" }); + await expect(derived).resolves.toMatchObject({ + config: { build: { nodejsCompatMode: "v2" } }, + }); + + // Input-level overrides win over the config file, like the CLI's + // `args.* ?? parsedConfig.*`: a programmatic worker passing the + // flag without a config-file entry still gets the mode. + await seed({ + "wrangler-no-flag.toml": dedent /* toml */ ` + name = "nodejs-compat-worker" + main = "src/index.ts" + compatibility_date = "2026-06-01" + `, + }); + const overridden = bus.waitFor("configUpdate"); + await controller.set({ + config: "./wrangler-no-flag.toml", + compatibilityFlags: ["nodejs_compat"], + }); + await expect(overridden).resolves.toMatchObject({ + config: { build: { nodejsCompatMode: "v2" } }, + }); + + // Explicit null still disables (callers owning the mode keep it). + const disabled = bus.waitFor("configUpdate"); + await controller.set({ + config: "./wrangler.toml", + build: { nodejsCompatMode: null }, + }); + const disabledEvent = await disabled; + expect(disabledEvent.config.build.nodejsCompatMode).toBeNull(); + }); + it("should apply module root to parent if main is nested from base_dir", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/build-output.test.ts b/packages/wrangler/src/__tests__/build-output.test.ts index 2a1f9494fd..000788c19c 100644 --- a/packages/wrangler/src/__tests__/build-output.test.ts +++ b/packages/wrangler/src/__tests__/build-output.test.ts @@ -5,34 +5,9 @@ import { describe, it, vi } from "vitest"; import { mockConsoleMethods } from "./helpers/mock-console"; import { runWrangler } from "./helpers/run-wrangler"; -// ───────────────────────────────────────────────────────────────────────────── -// Mock `@cloudflare/config`'s `loadConfig` -// ───────────────────────────────────────────────────────────────────────────── -// -// `loadNewConfig` calls into `@cloudflare/config`'s `loadConfig`, which uses -// `module.registerHooks` to register hooks for `.ts` files. That mechanism -// does not run inside vitest's module runner, so we mock the loader and -// `import()` the seeded file via a `data:` URL to keep ESM semantics. -// ───────────────────────────────────────────────────────────────────────────── - vi.mock("@cloudflare/config", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - - async function loadConfig(configPath: string) { - const source = await fs.promises.readFile(configPath, "utf8"); - const mod = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` - )) as { default: unknown }; - return { - config: mod.default, - dependencies: new Set([path.resolve(configPath)]), - }; - } - - return { - ...actual, - loadConfig, - }; + const { createConfigMock } = await import("./helpers/mock-new-config"); + return createConfigMock(importOriginal); }); const WORKER_NAME = "build-output-test-worker"; @@ -74,6 +49,7 @@ describe("wrangler build --experimental-cf-build-output", () => { it("emits the Build Output API tree for a Worker", async ({ expect }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -107,6 +83,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.ts", @@ -136,6 +113,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -174,6 +152,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -208,6 +187,7 @@ describe("wrangler build --experimental-cf-build-output", () => { it("copies the assets directory", async ({ expect }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -247,6 +227,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", };`, @@ -273,6 +254,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -301,6 +283,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", @@ -320,6 +303,7 @@ describe("wrangler build --experimental-cf-build-output", () => { }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "${WORKER_NAME}", compatibilityDate: "2026-05-18", };`, diff --git a/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts b/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts index 04ce890036..dc465eb53d 100644 --- a/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts +++ b/packages/wrangler/src/__tests__/cf-wrangler/build.test.ts @@ -6,23 +6,8 @@ import { runCfWranglerBuild } from "../../cf-wrangler/build"; import { mockConsoleMethods } from "../helpers/mock-console"; vi.mock("@cloudflare/config", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - - async function loadConfig(configPath: string) { - const source = await fs.promises.readFile(configPath, "utf8"); - const mod = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` - )) as { default: unknown }; - return { - config: mod.default, - dependencies: new Set([path.resolve(configPath)]), - }; - } - - return { - ...actual, - loadConfig, - }; + const { createConfigMock } = await import("../helpers/mock-new-config"); + return createConfigMock(importOriginal); }); describe("cf-wrangler build", () => { @@ -32,6 +17,7 @@ describe("cf-wrangler build", () => { it("emits the Build Output API tree", async ({ expect }) => { await seed({ "cloudflare.config.ts": `export default { + type: "worker", name: "cf-wrangler-build-worker", compatibilityDate: "2026-05-18", entrypoint: "./src/index.js", diff --git a/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts b/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts index 467678df15..4e2e0140b4 100644 --- a/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts +++ b/packages/wrangler/src/__tests__/create-worker-upload-form/bindings.test.ts @@ -592,6 +592,29 @@ describe("createWorkerUploadForm — bindings", () => { }); }); + describe("provisionable name-only bindings", () => { + it.for([ + { label: "Queue", input: { type: "queue" } as const }, + { + label: "Dispatch Namespace", + input: { type: "dispatch_namespace" } as const, + }, + { label: "Flagship", input: { type: "flagship" } as const }, + ])( + "should inherit a draft $label binding during dry run", + ({ input }, { expect }) => { + const bindings: StartDevWorkerInput["bindings"] = { RESOURCE: input }; + const form = createWorkerUploadForm(createEsmWorker(), bindings, { + dryRun: true, + }); + expect(getBindings(form)).toContainEqual({ + name: "RESOURCE", + type: "inherit", + }); + } + ); + }); + describe("multiple binding types together", () => { it("should handle a worker with many different binding types", ({ expect, diff --git a/packages/wrangler/src/__tests__/deploy/bindings.test.ts b/packages/wrangler/src/__tests__/deploy/bindings.test.ts index 8f9bb65875..cf6292f079 100644 --- a/packages/wrangler/src/__tests__/deploy/bindings.test.ts +++ b/packages/wrangler/src/__tests__/deploy/bindings.test.ts @@ -81,7 +81,17 @@ describe("deploy", () => { msw.use( http.get("*/accounts/:accountId/r2/buckets/:bucketName", async () => { return HttpResponse.json(createFetchResult({})); - }) + }), + http.get("*/accounts/:accountId/workers/dispatch/namespaces", () => + HttpResponse.json( + createFetchResult( + ["Foo", "Bar"].map((namespace_name) => ({ + namespace_id: `${namespace_name}-id`, + namespace_name, + })) + ) + ) + ) ); // Pretend all Agent Memory namespaces exist for the same reason. msw.use( diff --git a/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts b/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts index 185d39f3db..a68848396e 100644 --- a/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts +++ b/packages/wrangler/src/__tests__/deploy/legacy-assets.test.ts @@ -1111,7 +1111,7 @@ describe("deploy", () => { await expect( runWrangler("deploy") ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: The asset path key "folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/file.3da0d0cd12.txt" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits",]` + `[Error: The asset path key "folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/file.3da0d0cd12.txt" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits]` ); expect(std.info).toMatchInlineSnapshot(` @@ -1125,7 +1125,7 @@ describe("deploy", () => { " `); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] The asset path key "folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/file.3da0d0cd12.txt" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits", + "X [ERROR] The asset path key "folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/folder/file.3da0d0cd12.txt" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits " `); diff --git a/packages/wrangler/src/__tests__/deploy/queues.test.ts b/packages/wrangler/src/__tests__/deploy/queues.test.ts index ed29afd9b5..35212376a3 100644 --- a/packages/wrangler/src/__tests__/deploy/queues.test.ts +++ b/packages/wrangler/src/__tests__/deploy/queues.test.ts @@ -677,7 +677,7 @@ describe("deploy", () => { mockGetQueueByName(queueName, null); await expect( - runWrangler("deploy index.js") + runWrangler("deploy index.js --no-experimental-provision") ).rejects.toMatchInlineSnapshot( `[Error: Queue "queue1" does not exist. To create it, run: wrangler queues create queue1]` ); @@ -698,7 +698,7 @@ describe("deploy", () => { mockGetQueueByName(queueName, null); await expect( - runWrangler("deploy index.js") + runWrangler("deploy index.js --no-experimental-provision") ).rejects.toMatchInlineSnapshot( `[Error: Queue "queue1" does not exist. To create it, run: wrangler queues create queue1]` ); diff --git a/packages/wrangler/src/__tests__/dev.test.ts b/packages/wrangler/src/__tests__/dev.test.ts index 3498d14335..b256a4a4d8 100644 --- a/packages/wrangler/src/__tests__/dev.test.ts +++ b/packages/wrangler/src/__tests__/dev.test.ts @@ -20,6 +20,7 @@ import { logger } from "../logger"; import { sniffUserAgent } from "../package-manager"; import { DEFAULT_WORKERS_TYPES_FILE_PATH } from "../type-generation/helpers"; import * as generateRuntime from "../type-generation/runtime"; +import { detectAgent } from "../utils/detect-agent"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { useMockIsTTY } from "./helpers/mock-istty"; @@ -36,15 +37,30 @@ import type { StartDevWorkerOptions, Trigger, } from "../api"; +import type { StartDevOptions } from "../dev"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { ExpectStatic } from "vitest"; import type { Mock, MockInstance } from "vitest"; +const startDevMock = vi.hoisted(() => ({ + calls: [] as StartDevOptions[], +})); + vi.mock("../api/startDevWorker/ConfigController", (importOriginal) => importOriginal() ); vi.mock("node:child_process"); vi.mock("../dev/hotkeys"); +vi.mock("../dev/start-dev", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startDev: vi.fn((args: StartDevOptions) => { + startDevMock.calls.push(args); + return actual.startDev(args); + }), + }; +}); // Don't memoize in tests. If we did, it would memoize across test runs, which causes problems vi.mock("../utils/memoizeGetPort", () => { @@ -125,6 +141,7 @@ describe.sequential("wrangler dev", () => { beforeEach(() => { setIsTTY(true); + startDevMock.calls = []; setSpy = vi.spyOn(ConfigController.prototype, "set"); spy = vi .spyOn(ConfigController.prototype, "emitConfigUpdateEvent") @@ -166,6 +183,62 @@ describe.sequential("wrangler dev", () => { return { ...spy.mock.calls[0][0], input: setSpy.mock.calls[0][0] }; } + function getLatestStartDevArgs(): StartDevOptions { + const args = startDevMock.calls.at(-1); + assert(args); + return args; + } + + describe("Local Explorer agent hint", () => { + beforeEach(() => { + writeWranglerConfig({ + name: "test-worker", + main: "index.js", + compatibility_date: "2024-01-01", + }); + fs.writeFileSync("index.js", `export default {};`); + vi.mocked(detectAgent).mockReturnValue({ isAgent: false, id: null }); + }); + + it("asks startDev to print the hint for headless agent sessions", async ({ + expect, + }) => { + setIsTTY(false); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + + await runWranglerUntilConfig("dev"); + + expect(getLatestStartDevArgs().showLocalExplorerAgentHint).toBe(true); + }); + + it("does not ask startDev to print the hint for interactive agent sessions", async ({ + expect, + }) => { + setIsTTY(true); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + + await runWranglerUntilConfig("dev"); + + expect(getLatestStartDevArgs().showLocalExplorerAgentHint).toBe(false); + }); + + it("does not ask startDev to print the hint for non-agent sessions", async ({ + expect, + }) => { + setIsTTY(false); + + await runWranglerUntilConfig("dev"); + + expect(getLatestStartDevArgs().showLocalExplorerAgentHint).toBe(false); + }); + }); + describe("config file support", () => { it("should support wrangler.toml", async ({ expect }) => { writeWranglerConfig({ diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts index 919391c220..eaadda4e53 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts @@ -1,17 +1,10 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { assert, beforeEach, describe, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { assert, beforeEach, describe, it } from "vitest"; import { startRemoteProxySession } from "../../api"; -import { - createPreviewSession, - createWorkerPreview, -} from "../../dev/create-worker-preview"; import { mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; -import { msw, mswSuccessUserHandlers } from "../helpers/msw"; -vi.mock("../../dev/create-worker-preview", () => ({ - createPreviewSession: vi.fn(), - createWorkerPreview: vi.fn(), -})); +import { createFetchResult, msw, mswSuccessUserHandlers } from "../helpers/msw"; mockConsoleMethods(); @@ -55,15 +48,26 @@ describe("errors during dev with remote bindings", () => { it("errors triggered when establishing the remote proxy session (after it has been created) are surfaced", async ({ expect, }) => { - vi.mocked(createPreviewSession).mockResolvedValue({ - value: "test-session-value", - host: "test.workers.dev", - name: "test", - }); - - vi.mocked(createWorkerPreview).mockImplementation(async () => { - throw new Error("The remote worker preview failed."); - }); + msw.use( + http.get( + "*/accounts/test-account-id/workers/subdomain/edge-preview", + () => + HttpResponse.json(createFetchResult({ token: "test-session-value" })) + ), + http.get("*/accounts/test-account-id/workers/subdomain", () => + HttpResponse.json(createFetchResult({ subdomain: "test" })) + ), + http.post( + "*/accounts/test-account-id/workers/scripts/:scriptName/edge-preview", + () => + HttpResponse.json( + createFetchResult({}, false, [ + { code: 1000, message: "The remote worker preview failed." }, + ]), + { status: 400 } + ) + ) + ); let thrownError: Error | undefined; @@ -84,18 +88,12 @@ describe("errors during dev with remote bindings", () => { assert(thrownError); - expect(thrownError).toMatchInlineSnapshot( - `[Error: Failed to start the remote proxy session. Failed to obtain a preview token: The remote worker preview failed.]` + expect(thrownError.message).toContain( + "Failed to start the remote proxy session. Failed to obtain a preview token" ); - - expect(thrownError.cause).toMatchInlineSnapshot(` - { - "cause": [Error: The remote worker preview failed.], - "data": undefined, - "reason": "Failed to obtain a preview token", - "source": "RemoteRuntimeController", - "type": "error", - } - `); + expect(thrownError.cause).toMatchObject({ + reason: "Failed to obtain a preview token", + type: "error", + }); }); }); diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 1b19e833d3..a92bda092a 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -21,8 +21,9 @@ import { mswZoneHandlers, } from "../helpers/msw"; import { runWrangler } from "../helpers/run-wrangler"; -import type { Binding, StartRemoteProxySessionOptions } from "../../api"; +import type { Binding } from "../../api"; import type { StartDevOptions } from "../../dev"; +import type { StartRemoteProxySessionOptions } from "@cloudflare/remote-bindings"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { RemoteProxyConnectionString, WorkerOptions } from "miniflare"; @@ -783,13 +784,13 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); expect(sessionOptions).toBeDefined(); assert(sessionOptions); - const { auth, ...rest1 } = sessionOptions; + const { auth, logger: _logger, ...rest1 } = sessionOptions; expect(rest1).toEqual({ complianceRegion: undefined, workerName: "worker", }); assert(auth); - expect(await unwrapHook(auth, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth)).toEqual({ accountId: "some-account-id", apiToken: { apiToken: "some-api-token" }, }); @@ -827,13 +828,13 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { expect(sessionOptions).toBeDefined(); assert(sessionOptions); - const { auth: auth2, ...rest2 } = sessionOptions; + const { auth: auth2, logger: _logger, ...rest2 } = sessionOptions; expect(rest2).toEqual({ complianceRegion: undefined, workerName: "worker", }); assert(auth2); - expect(await unwrapHook(auth2, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth2)).toEqual({ accountId: "mock-account-id", apiToken: { apiToken: "some-api-token" }, }); diff --git a/packages/wrangler/src/__tests__/dev/start-dev.test.ts b/packages/wrangler/src/__tests__/dev/start-dev.test.ts index cf0bfaecde..1c266423e6 100644 --- a/packages/wrangler/src/__tests__/dev/start-dev.test.ts +++ b/packages/wrangler/src/__tests__/dev/start-dev.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert"; import { beforeEach, describe, it, vi } from "vitest"; import registerDevHotKeys from "../../dev/hotkeys"; import { startDev } from "../../dev/start-dev"; +import { logger } from "../../logger"; import { requireAuth } from "../../user"; +import { mockConsoleMethods } from "../helpers/mock-console"; import type { StartDevWorkerInput } from "../../api"; import type { StartDevOptions } from "../../dev"; @@ -42,10 +44,14 @@ vi.mock("../../user", () => ({ requireAuth: vi.fn(async () => "test-account-id"), })); +const std = mockConsoleMethods(); + describe("startDev", () => { beforeEach(() => { vi.clearAllMocks(); + logger.clearHistory(); mocks.configSet.mockResolvedValue(undefined); + mocks.fakeDevEnv.proxy.ready.promise = new Promise(() => {}); }); it("unregisters the latest hotkey registration after auth re-registers hotkeys", async ({ @@ -78,4 +84,47 @@ describe("startDev", () => { expect(unregisterHotKeys[0]).toHaveBeenCalledOnce(); expect(unregisterHotKeys[1]).toHaveBeenCalledOnce(); }); + + it("prints the Local Explorer API hint when the caller asks for it", async ({ + expect, + }) => { + const readyPromise = Promise.resolve({ + url: new URL("http://127.0.0.1:8787"), + }); + mocks.fakeDevEnv.proxy.ready.promise = readyPromise; + + await startDev({ + disableDevRegistry: true, + showLocalExplorerAgentHint: true, + } as StartDevOptions); + await readyPromise; + await Promise.resolve(); + + expect(std.out).toContain( + "Wrangler detected this dev session is running in an AI agent." + ); + expect(std.out).toContain( + "The Local Explorer API is available at http://127.0.0.1:8787/cdn-cgi/explorer/api" + ); + expect(std.out).toContain( + "GET http://127.0.0.1:8787/cdn-cgi/explorer/api/local/workers - local Workers and bindings" + ); + }); + + it("does not print the Local Explorer API hint when the caller has not opted in", async ({ + expect, + }) => { + const readyPromise = Promise.resolve({ + url: new URL("http://127.0.0.1:8787"), + }); + mocks.fakeDevEnv.proxy.ready.promise = readyPromise; + + await startDev({ + disableDevRegistry: true, + } as StartDevOptions); + await readyPromise; + await Promise.resolve(); + + expect(std.out).not.toContain("The Local Explorer API is available"); + }); }); diff --git a/packages/wrangler/src/__tests__/experimental-config/load.test.ts b/packages/wrangler/src/__tests__/experimental-config/load.test.ts index 714db81527..bc2acacf28 100644 --- a/packages/wrangler/src/__tests__/experimental-config/load.test.ts +++ b/packages/wrangler/src/__tests__/experimental-config/load.test.ts @@ -1,43 +1,11 @@ -import * as fs from "node:fs"; import * as path from "node:path"; import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { describe, it, vi } from "vitest"; import { loadNewConfig } from "../../experimental-config/load"; -// ───────────────────────────────────────────────────────────────────────────── -// Mock `@cloudflare/config` -// ───────────────────────────────────────────────────────────────────────────── -// -// `loadNewConfig` calls into `@cloudflare/config`'s `loadConfig`, which uses -// `module.registerHooks` to register hooks for `.ts` files and `cf-worker` -// import attributes. That mechanism does not run inside vitest's module -// runner, so we cannot invoke the real loader here. -// -// Mocking `loadConfig` lets us "load" arbitrary seeded files inside the temp -// dir without going through Node's module hooks. We use `import("data:...")` -// to evaluate the file as ESM so the function-form configs work naturally. -// ───────────────────────────────────────────────────────────────────────────── - vi.mock("@cloudflare/config", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - - async function loadConfig(configPath: string) { - const source = await fs.promises.readFile(configPath, "utf8"); - // Evaluate via a data URL — preserves ESM semantics (top-level await, - // default export, etc.) without triggering Node's `.ts` hooks. - const mod = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` - )) as { default: unknown }; - return { - config: mod.default, - dependencies: new Set([path.resolve(configPath)]), - }; - } - - return { - ...actual, - loadConfig, - }; + const { createConfigMock } = await import("../helpers/mock-new-config"); + return createConfigMock(importOriginal); }); describe("loadNewConfig", () => { @@ -62,7 +30,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "my-worker", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "my-worker", compatibilityDate: "2026-05-18" };', }); const result = await loadNewConfig({ @@ -89,7 +57,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "merged-worker", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "merged-worker", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": 'export default { minify: true, assetsDirectory: "./public" };', }); @@ -118,6 +86,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default (ctx) => ({ + type: "worker", name: \`worker-\${ctx.mode}\`, compatibilityDate: "2026-05-18", }); @@ -139,6 +108,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default (ctx) => ({ + type: "worker", name: \`worker-\${ctx.mode}\`, compatibilityDate: "2026-05-18", }); @@ -159,6 +129,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default (ctx) => ({ + type: "worker", name: \`worker[\${ctx.mode}]\`, compatibilityDate: "2026-05-18", }); @@ -178,7 +149,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": ` export default (ctx) => ({ assetsDirectory: \`./\${ctx.mode}-public\`, @@ -197,6 +168,87 @@ describe("loadNewConfig", () => { }); }); + describe("settings export", () => { + it("threads accountId and complianceRegion from the settings export", async ({ + expect, + }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" }; + export const settings = { type: "settings", accountId: "acc-123", complianceRegion: "fedramp-high" }; + `, + }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.rawConfig.account_id).toBe("acc-123"); + expect(result.rawConfig.compliance_region).toBe("fedramp_high"); + expect(result.parsedSettingsConfig).toEqual({ + type: "settings", + accountId: "acc-123", + complianceRegion: "fedramp-high", + }); + }); + + it("leaves settings undefined when there is no settings export", async ({ + expect, + }) => { + await seed({ + "cloudflare.config.ts": + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', + }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.parsedSettingsConfig).toBeUndefined(); + expect(result.rawConfig.account_id).toBeUndefined(); + }); + }); + + describe("default worker selection", () => { + it("consumes the default export and validates-then-ignores other workers", async ({ + expect, + }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "primary", compatibilityDate: "2026-05-18" }; + export const other = { type: "worker", name: "other", compatibilityDate: "2026-05-18" }; + `, + }); + + const result = await loadNewConfig({ cwd: process.cwd(), args: {} }); + + expect(result.rawConfig.name).toBe("primary"); + expect(result.parsedWorkerConfig.name).toBe("primary"); + }); + + it("still validates non-default worker exports", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { type: "worker", name: "primary", compatibilityDate: "2026-05-18" }; + export const other = { type: "worker", name: 42, compatibilityDate: "2026-05-18" }; + `, + }); + + await expect( + loadNewConfig({ cwd: process.cwd(), args: {} }) + ).rejects.toThrow(/other\.name/); + }); + + it("throws when there is no default worker export", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": 'export const settings = { type: "settings" };', + }); + + await expect( + loadNewConfig({ cwd: process.cwd(), args: {} }) + ).rejects.toMatchObject({ + message: expect.stringContaining("must have a default worker export"), + telemetryMessage: "new-config worker default export missing", + }); + }); + }); + describe("worker schema validation", () => { it("throws when cloudflare.config.ts has invalid types", async ({ expect, @@ -204,7 +256,7 @@ describe("loadNewConfig", () => { // `compatibilityDate` must be a string — number triggers a Zod error. await seed({ "cloudflare.config.ts": - 'export default { name: "bad", compatibilityDate: 12345 };', + 'export default { type: "worker", name: "bad", compatibilityDate: 12345 };', }); await expect( @@ -220,12 +272,12 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: 42, compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: 42, compatibilityDate: "2026-05-18" };', }); await expect( loadNewConfig({ cwd: process.cwd(), args: {} }) - ).rejects.toThrow(/\s*•\s+name:/); + ).rejects.toThrow(/\s*•\s+default\.name:/); }); }); @@ -235,7 +287,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', // `name` is a worker-runtime field, not a tooling field; the // `WORKER_CONFIG_FIELD_HINTS` set turns this into a hint. "wrangler.config.ts": @@ -257,7 +309,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { bogusField: true };", }); @@ -271,7 +323,7 @@ describe("loadNewConfig", () => { it("rejects wrong types for tooling fields", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": 'export default { minify: "yes-please" };', }); @@ -291,6 +343,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default { + type: "worker", name: "w", compatibilityDate: "2026-05-18", env: { ASSETS: { type: "assets" } }, @@ -322,6 +375,7 @@ describe("loadNewConfig", () => { await seed({ "cloudflare.config.ts": ` export default { + type: "worker", name: "email-worker", compatibilityDate: "2026-05-18", triggers: [ @@ -352,7 +406,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', }); const result = await loadNewConfig({ @@ -368,7 +422,7 @@ describe("loadNewConfig", () => { }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { minify: true };", }); @@ -383,7 +437,7 @@ describe("loadNewConfig", () => { it("honors `dev.types.generate: false`", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { dev: { types: { generate: false } } };", }); @@ -399,7 +453,7 @@ describe("loadNewConfig", () => { it("honors `dev.types.includeRuntime: false`", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { dev: { types: { includeRuntime: false } } };", }); @@ -415,7 +469,7 @@ describe("loadNewConfig", () => { it("is not threaded into the merged rawConfig.dev", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { dev: { types: { generate: false }, port: 1234 } };", }); @@ -437,7 +491,7 @@ describe("loadNewConfig", () => { it("is the union of dependencies from both files", async ({ expect }) => { await seed({ "cloudflare.config.ts": - 'export default { name: "w", compatibilityDate: "2026-05-18" };', + 'export default { type: "worker", name: "w", compatibilityDate: "2026-05-18" };', "wrangler.config.ts": "export default { minify: true };", }); diff --git a/packages/wrangler/src/__tests__/helpers/mock-new-config.ts b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts new file mode 100644 index 0000000000..614c1512a3 --- /dev/null +++ b/packages/wrangler/src/__tests__/helpers/mock-new-config.ts @@ -0,0 +1,58 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared `@cloudflare/config` mock +// ───────────────────────────────────────────────────────────────────────────── +// +// `loadNewConfig` calls into `@cloudflare/config`'s `loadConfig`, which uses +// `module.registerHooks` to register hooks for `.ts` files and `cf-worker` +// import attributes. That mechanism does not run inside vitest's module +// runner, so we cannot invoke the real loader here. +// +// Mocking `loadConfig` lets us "load" arbitrary seeded files inside the temp +// dir without going through Node's module hooks. We `import("data:...")` to +// evaluate the file as ESM so the function-form configs work naturally. +// ───────────────────────────────────────────────────────────────────────────── + +export async function createConfigMock(importOriginal: () => Promise) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock plumbing + const actual = (await importOriginal()) as Record; + + async function importSeeded( + configPath: string + ): Promise> { + const source = await fs.promises.readFile(configPath, "utf8"); + // Evaluate via a data URL — preserves ESM semantics (top-level await, + // default export, etc.) without triggering Node's `.ts` hooks. + return (await import( + `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` + )) as Record; + } + + async function loadConfig(configPath: string) { + const exports = await importSeeded(configPath); + return { + exports, + dependencies: new Set([path.resolve(configPath)]), + }; + } + + async function loadAndValidateConfig(configPath: string, ctx: unknown) { + const { exports } = await loadConfig(configPath); + const resolved: Record = {}; + for (const [name, value] of Object.entries(exports)) { + resolved[name] = await actual.resolveExportDefinition(value, ctx); + } + return { + result: actual.ConfigExportsSchema.safeParse(resolved), + dependencies: new Set([path.resolve(configPath)]), + }; + } + + return { + ...actual, + loadConfig, + loadAndValidateConfig, + }; +} diff --git a/packages/wrangler/src/__tests__/package-manager.test.ts b/packages/wrangler/src/__tests__/package-manager.test.ts index 1e4ebe4950..de7cde9b95 100644 --- a/packages/wrangler/src/__tests__/package-manager.test.ts +++ b/packages/wrangler/src/__tests__/package-manager.test.ts @@ -104,7 +104,7 @@ describe("getPackageManager()", () => { await expect(() => getPackageManager() ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Unable to find a package manager. Supported managers are: npm, yarn, pnpm, and bun.]` + `[Error: Unable to find a package manager. Supported managers are: npm, yarn, pnpm, bun, and nub.]` ); }); }); diff --git a/packages/wrangler/src/__tests__/provision.test.ts b/packages/wrangler/src/__tests__/provision.test.ts index ac3c4c44d8..e050311a3a 100644 --- a/packages/wrangler/src/__tests__/provision.test.ts +++ b/packages/wrangler/src/__tests__/provision.test.ts @@ -122,6 +122,322 @@ describe("resource provisioning", () => { expect(std.warn).toMatchInlineSnapshot(`""`); }); + it("auto-provisions Queue, Dispatch Namespace, and Flagship bindings", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + queues: { producers: [{ binding: "QUEUE" }] }, + dispatch_namespaces: [{ binding: "DISPATCH" }], + flagship: [{ binding: "FLAGS" }], + }); + mockGetSettings(); + msw.use( + http.get("*/accounts/:accountId/queues", () => + HttpResponse.json(createFetchResult([])) + ), + http.post("*/accounts/:accountId/queues", async ({ request }) => { + expect(await request.json()).toEqual({ queue_name: "test-name-queue" }); + return HttpResponse.json( + createFetchResult({ queue_name: "test-name-queue" }) + ); + }), + http.get("*/accounts/:accountId/workers/dispatch/namespaces", () => + HttpResponse.json(createFetchResult([])) + ), + http.post( + "*/accounts/:accountId/workers/dispatch/namespaces", + async ({ request }) => { + expect(await request.json()).toEqual({ name: "test-name-dispatch" }); + return HttpResponse.json( + createFetchResult({ + namespace_id: "dispatch-id", + namespace_name: "test-name-dispatch", + }) + ); + } + ), + http.get("*/accounts/:accountId/flagship/apps", () => + HttpResponse.json(createFetchResult([])) + ), + http.post("*/accounts/:accountId/flagship/apps", async ({ request }) => { + expect(await request.json()).toEqual({ name: "test-name-flags" }); + return HttpResponse.json( + createFetchResult({ id: "flagship-id", name: "test-name-flags" }) + ); + }) + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { name: "QUEUE", type: "queue", queue_name: "test-name-queue" }, + { + name: "DISPATCH", + type: "dispatch_namespace", + namespace: "test-name-dispatch", + }, + { name: "FLAGS", type: "flagship", app_id: "flagship-id" }, + ], + }); + + await runWrangler("deploy"); + + expect(std.err).toBe(""); + }); + + it("provisions a Queue used by both a producer and consumer", async ({ + expect, + }) => { + const queueName = "test-name-queue"; + let queueCreated = false; + writeWranglerConfig({ + main: "index.js", + queues: { + producers: [{ binding: "QUEUE" }], + consumers: [{ queue: queueName }], + }, + }); + mockGetSettings(); + msw.use( + http.get("*/accounts/:accountId/queues", () => + HttpResponse.json( + createFetchResult( + queueCreated + ? [ + { + queue_id: "queue-id", + queue_name: queueName, + producers: [], + consumers: [], + producers_total_count: 0, + consumers_total_count: 0, + created_on: "", + modified_on: "", + }, + ] + : [] + ) + ) + ), + http.post("*/accounts/:accountId/queues", () => { + queueCreated = true; + return HttpResponse.json( + createFetchResult({ queue_id: "queue-id", queue_name: queueName }) + ); + }), + http.post("*/accounts/:accountId/queues/:queueId/consumers", () => + HttpResponse.json(createFetchResult({ consumer_id: "consumer-id" })) + ) + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { name: "QUEUE", type: "queue", queue_name: queueName }, + ], + }); + + await runWrangler("deploy"); + + expect(queueCreated).toBe(true); + expect(std.out).toContain("Producer for test-name-queue"); + expect(std.out).not.toContain("Producer for undefined"); + expect(std.err).toBe(""); + }); + + it("can select Queue, Dispatch Namespace, and Flagship resources from later pages", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + queues: { producers: [{ binding: "QUEUE" }] }, + dispatch_namespaces: [{ binding: "DISPATCH" }], + flagship: [{ binding: "FLAGS" }], + }); + mockGetSettings(); + msw.use( + http.get("*/accounts/:accountId/queues", ({ request }) => { + const page = new URL(request.url).searchParams.get("page"); + return HttpResponse.json( + createFetchResult( + [ + { + queue_name: page === "2" ? "second-queue" : "first-queue", + }, + ], + true, + [], + [], + { page: Number(page), per_page: 1, count: 1, total_count: 2 } + ) + ); + }), + http.get( + "*/accounts/:accountId/workers/dispatch/namespaces", + ({ request }) => { + const page = new URL(request.url).searchParams.get("page"); + const name = page === "2" ? "second-dispatch" : "first-dispatch"; + return HttpResponse.json( + createFetchResult( + [{ namespace_id: `${name}-id`, namespace_name: name }], + true, + [], + [], + { page: Number(page), per_page: 1, count: 1, total_count: 2 } + ) + ); + } + ), + http.get("*/accounts/:accountId/flagship/apps", ({ request }) => { + const cursor = new URL(request.url).searchParams.get("cursor"); + const app = cursor + ? { id: "second-app-id", name: "second-app" } + : { id: "first-app-id", name: "first-app" }; + return HttpResponse.json( + createFetchResult( + [app], + true, + [], + [], + cursor ? { count: 1 } : { count: 1, cursor: "next" } + ) + ); + }) + ); + mockSelect( + { + text: "Would you like to connect an existing Queue or create a new one?", + result: "second-queue", + }, + { + text: "Would you like to connect an existing Dispatch Namespace or create a new one?", + result: "second-dispatch", + }, + { + text: "Would you like to connect an existing Flagship App or create a new one?", + result: "second-app-id", + } + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { name: "QUEUE", type: "queue", queue_name: "second-queue" }, + { + name: "DISPATCH", + type: "dispatch_namespace", + namespace: "second-dispatch", + }, + { name: "FLAGS", type: "flagship", app_id: "second-app-id" }, + ], + }); + + await runWrangler("deploy --x-auto-create=false"); + + expect(std.err).toBe(""); + }); + + it("inherits Queue, Dispatch Namespace, and Flagship bindings", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + queues: { producers: [{ binding: "QUEUE" }] }, + dispatch_namespaces: [{ binding: "DISPATCH" }], + flagship: [{ binding: "FLAGS" }], + }); + mockGetSettings({ + result: { + bindings: [ + { type: "queue", name: "QUEUE", queue_name: "existing-queue" }, + { + type: "dispatch_namespace", + name: "DISPATCH", + namespace: "existing-dispatch", + }, + { type: "flagship", name: "FLAGS", app_id: "existing-app" }, + ], + }, + }); + mockUploadWorkerRequest({ + expectedBindings: [ + { name: "QUEUE", type: "inherit" }, + { name: "DISPATCH", type: "inherit" }, + { name: "FLAGS", type: "inherit" }, + ], + }); + + await runWrangler("deploy"); + + expect(std.out).not.toContain("Producer for undefined"); + expect(std.out).toContain("Producer for QUEUE"); + expect(std.err).toBe(""); + }); + + it("preserves Queue and Dispatch Namespace options when reusing deployed resources", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + queues: { + producers: [{ binding: "QUEUE", delivery_delay: 5 }], + }, + dispatch_namespaces: [ + { + binding: "DISPATCH", + outbound: { service: "outbound-worker", parameters: ["context"] }, + }, + ], + }); + mockGetSettings({ + result: { + bindings: [ + { type: "queue", name: "QUEUE", queue_name: "existing-queue" }, + { + type: "dispatch_namespace", + name: "DISPATCH", + namespace: "existing-dispatch", + }, + ], + }, + }); + msw.use( + http.get("*/accounts/:accountId/queues", () => + HttpResponse.json(createFetchResult([{ queue_name: "existing-queue" }])) + ), + http.get("*/accounts/:accountId/workers/dispatch/namespaces", () => + HttpResponse.json( + createFetchResult([ + { + namespace_id: "dispatch-id", + namespace_name: "existing-dispatch", + }, + ]) + ) + ) + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "QUEUE", + type: "queue", + queue_name: "existing-queue", + delivery_delay: 5, + }, + { + name: "DISPATCH", + type: "dispatch_namespace", + namespace: "existing-dispatch", + outbound: { + worker: { + service: "outbound-worker", + }, + params: [{ name: "context" }], + }, + }, + ], + }); + + await runWrangler("deploy"); + + expect(std.err).toBe(""); + }); + describe("provisions KV, R2 and D1 bindings if not found in worker settings", () => { it("can provision KV, R2 and D1 bindings with existing resources", async ({ expect, @@ -196,7 +512,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV KV Namespace env.D1 D1 Database @@ -319,7 +635,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV KV Namespace env.D1 D1 Database @@ -452,7 +768,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV KV Namespace env.D1 D1 Database @@ -615,7 +931,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV KV Namespace env.D1 D1 Database @@ -765,7 +1081,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.KV KV Namespace env.PLATFORM_KV KV Namespace @@ -831,6 +1147,53 @@ describe("resource provisioning", () => { rmSync(".wrangler/deploy/config.json"); }); + it("does not write an injected binding with a cross-type name collision back to redirected config", async ({ + expect, + }) => { + writeRedirectedWranglerConfig({ + main: "../index.js", + kv_namespaces: [], + r2_buckets: [{ binding: "R2" }], + d1_databases: [{ binding: "D1" }], + queues: { producers: [{ binding: "KV" }] }, + }); + mockGetSettings({ + result: { + bindings: [ + { type: "r2_bucket", name: "R2", bucket_name: "r2-name" }, + { type: "d1", name: "D1", id: "d1-id" }, + ], + }, + }); + msw.use( + http.get("*/accounts/:accountId/queues", () => + HttpResponse.json(createFetchResult([])) + ), + http.post("*/accounts/:accountId/queues", () => + HttpResponse.json( + createFetchResult({ + queue_id: "queue-id", + queue_name: "test-name-kv", + }) + ) + ) + ); + mockUploadWorkerRequest({ + expectedBindings: [ + { name: "KV", type: "queue", queue_name: "test-name-kv" }, + { name: "R2", type: "inherit" }, + { name: "D1", type: "inherit" }, + ], + }); + + await runWrangler("deploy"); + + const userConfig = await readFile("wrangler.toml", "utf-8"); + expect(userConfig).not.toContain("[queues]"); + expect(userConfig).not.toContain('binding = "KV"\nqueue'); + rmSync(".wrangler/deploy/config.json"); + }); + it("can prefill d1 database name from config file if provided", async ({ expect, }) => { @@ -877,7 +1240,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.D1 D1 Database @@ -1010,7 +1373,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.D1 D1 Database @@ -1091,7 +1454,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.BUCKET R2 Bucket @@ -1291,7 +1654,7 @@ describe("resource provisioning", () => { ────────────────── Total Upload: xx KiB / gzip: xx KiB - Experimental: The following bindings need to be provisioned: + The following bindings need to be provisioned: Binding Resource env.BUCKET R2 Bucket @@ -1319,6 +1682,36 @@ describe("resource provisioning", () => { }); }); + describe("provisions ai_search_namespace bindings", () => { + it("should create an AI Search namespace if it does not exist", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + ai_search_namespaces: [ + { binding: "AI_SEARCH", namespace: "my-ai-search-namespace" }, + ], + }); + mockGetSettings(); + mockGetAISearchNamespace(expect, "my-ai-search-namespace", true); + mockCreateAISearchNamespace(expect, "my-ai-search-namespace"); + mockUploadWorkerRequest({ + expectedBindings: [ + { + name: "AI_SEARCH", + type: "ai_search_namespace", + namespace: "my-ai-search-namespace", + }, + ], + }); + + await runWrangler("deploy"); + + expect(std.out).toContain("env.AI_SEARCH"); + expect(std.err).toBe(""); + }); + }); + describe("provisions agent_memory bindings", () => { beforeEach(() => { writeWranglerConfig({ @@ -1532,6 +1925,47 @@ function mockGetAgentMemoryNamespace( ); } +function mockGetAISearchNamespace( + expect: ExpectStatic, + namespaceName: string, + missing: boolean = false +) { + msw.use( + http.get( + "*/accounts/:accountId/ai-search/namespaces/:namespaceName", + ({ params }) => { + expect(params.namespaceName).toEqual(namespaceName); + if (missing) { + return HttpResponse.json( + createFetchResult(null, false, [ + { code: 10006, message: "namespace not found" }, + ]), + { status: 404 } + ); + } + return HttpResponse.json(createFetchResult({ name: namespaceName })); + }, + { once: true } + ) + ); +} + +function mockCreateAISearchNamespace( + expect: ExpectStatic, + namespaceName: string +) { + msw.use( + http.post( + "*/accounts/:accountId/ai-search/namespaces", + async ({ request }) => { + expect(await request.json()).toEqual({ name: namespaceName }); + return HttpResponse.json(createFetchResult({ name: namespaceName })); + }, + { once: true } + ) + ); +} + function mockCreateAgentMemoryNamespace( expect: ExpectStatic, options: { assertName?: string } = {} diff --git a/packages/wrangler/src/__tests__/queues/queues-subscription.test.ts b/packages/wrangler/src/__tests__/queues/queues-subscription.test.ts index 33afc31f64..5b0c182397 100644 --- a/packages/wrangler/src/__tests__/queues/queues-subscription.test.ts +++ b/packages/wrangler/src/__tests__/queues/queues-subscription.test.ts @@ -84,7 +84,7 @@ describe("queues subscription", () => { -v, --version Show version number [boolean] OPTIONS - --source The event source type [string] [required] [choices: "images", "kv", "r2", "superSlurper", "vectorize", "workersAi.model", "workersBuilds.worker", "workflows.workflow"] + --source The event source type [string] [required] [choices: "artifacts", "artifacts.repo", "images", "kv", "r2", "superSlurper", "vectorize", "workersAi.model", "workersBuilds.worker", "workflows.workflow"] --events Comma-separated list of event types to subscribe to [string] [required] --name Name for the subscription (auto-generated if not provided) [string] --enabled Whether the subscription should be active [boolean] [default: true] @@ -187,6 +187,76 @@ describe("queues subscription", () => { `); }); + it("should create a subscription for an Artifacts account source", async ({ + expect, + }) => { + const queueNameResolveRequest = mockGetQueueByNameRequest( + expectedQueueName, + { + queue_id: expectedQueueId, + queue_name: expectedQueueName, + created_on: "", + producers: [], + consumers: [], + producers_total_count: 0, + consumers_total_count: 0, + modified_on: "", + } + ); + + const createRequest = mockCreateSubscriptionRequest( + { + name: "testQueue artifacts", + enabled: true, + source: { type: EventSourceType.ARTIFACTS }, + events: ["repo.created"], + }, + expectedQueueId + ); + + await runWrangler( + "queues subscription create testQueue --source artifacts --events repo.created" + ); + + expect(queueNameResolveRequest.count).toEqual(1); + expect(createRequest.count).toEqual(1); + }); + + it("should create a subscription for an Artifacts repository source", async ({ + expect, + }) => { + const queueNameResolveRequest = mockGetQueueByNameRequest( + expectedQueueName, + { + queue_id: expectedQueueId, + queue_name: expectedQueueName, + created_on: "", + producers: [], + consumers: [], + producers_total_count: 0, + consumers_total_count: 0, + modified_on: "", + } + ); + + const createRequest = mockCreateSubscriptionRequest( + { + name: "testQueue artifacts.repo", + enabled: true, + source: { type: EventSourceType.ARTIFACTS_REPO }, + events: ["pushed"], + }, + expectedQueueId + ); + + await runWrangler( + "queues subscription create testQueue --source artifacts.repo --events pushed" + ); + + expect(queueNameResolveRequest.count).toEqual(1); + expect(createRequest.count).toEqual(1); + }); + it("should create subscription with custom name and disabled state", async ({ expect, }) => { @@ -254,7 +324,7 @@ describe("queues subscription", () => { ) ).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: Invalid values: - Argument: source, Given: "invalid", Choices: "images", "kv", "r2", "superSlurper", "vectorize", "workersAi.model", "workersBuilds.worker", "workflows.workflow"] + Argument: source, Given: "invalid", Choices: "artifacts", "artifacts.repo", "images", "kv", "r2", "superSlurper", "vectorize", "workersAi.model", "workersBuilds.worker", "workflows.workflow"] `); }); diff --git a/packages/wrangler/src/__tests__/utils/retry.test.ts b/packages/wrangler/src/__tests__/utils/retry.test.ts index bd1738e580..6cd652ead9 100644 --- a/packages/wrangler/src/__tests__/utils/retry.test.ts +++ b/packages/wrangler/src/__tests__/utils/retry.test.ts @@ -1,7 +1,9 @@ -import { APIError } from "@cloudflare/workers-utils"; +import { + APIError, + retryOnAPIFailure as retryOnAPIFailureWithLogger, +} from "@cloudflare/workers-utils"; import { beforeEach, describe, it } from "vitest"; import { logger } from "../../logger"; -import { retryOnAPIFailure } from "../../utils/retry"; import { mockConsoleMethods } from "../helpers/mock-console"; describe("retryOnAPIFailure", () => { @@ -231,3 +233,18 @@ function getRetryAndErrorLogs(debugOutput: string): string[] { .split("\n") .filter((line) => line.includes("Retrying") || line.includes("APIError")); } + +function retryOnAPIFailure( + action: () => T | Promise, + backoff?: number, + attempts?: number, + abortSignal?: AbortSignal +): Promise { + return retryOnAPIFailureWithLogger( + action, + logger, + backoff, + attempts, + abortSignal + ); +} diff --git a/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts b/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts index 7287bdd838..c1b3b2a256 100644 --- a/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts +++ b/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts @@ -9,9 +9,11 @@ import { mockAccountId, mockApiToken } from "../../helpers/mock-account-id"; import { mockConsoleMethods } from "../../helpers/mock-console"; import { clearDialogs } from "../../helpers/mock-dialogs"; import { runWrangler } from "../../helpers/run-wrangler"; -import { mockGetVersion, mockPostVersion, mockSetupApiCalls } from "./utils"; -import type { VersionDetails } from "../../../versions/secrets"; -import type { CfPlacement } from "@cloudflare/workers-utils"; +import { + expectSecretPatch, + mockPatchLatestVersion, + mockPatchLatestVersionNoVersions, +} from "./utils"; import type { Interface } from "node:readline"; function mockReadlineInput(input: string) { @@ -63,19 +65,12 @@ describe("versions secret bulk", () => { { encoding: "utf8" } ); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET_1", text: "secret-1" }, - { type: "secret_text", name: "SECRET_2", text: "secret-2" }, - { type: "secret_text", name: "SECRET_3", text: "secret-3" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { + SECRET_1: "secret-1", + SECRET_2: "secret-2", + SECRET_3: "secret-3", + }); }); await runWrangler(`versions secret bulk secrets.json --name script-name`); @@ -100,8 +95,13 @@ describe("versions secret bulk", () => { ".env", "SECRET_1=secret-1\nSECRET_2=secret-2\nSECRET_3=secret-3" ); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { + SECRET_1: "secret-1", + SECRET_2: "secret-2", + SECRET_3: "secret-3", + }); + }); await runWrangler(`versions secret bulk .env --name script-name`); expect(std.out).toMatchInlineSnapshot( ` @@ -122,8 +122,9 @@ describe("versions secret bulk", () => { test("no wrangler configuration warnings shown", async ({ expect }) => { await writeFile("secrets.json", JSON.stringify({ SECRET_1: "secret-1" })); await writeFile("wrangler.json", JSON.stringify({ invalid_field: true })); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET_1: "secret-1" }); + }); await runWrangler(`versions secret bulk secrets.json --name script-name`); expect(std.warn).toMatchInlineSnapshot(`""`); expect(std.err).toMatchInlineSnapshot(`""`); @@ -138,19 +139,12 @@ describe("versions secret bulk", () => { }) ); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET_1", text: "secret-1" }, - { type: "secret_text", name: "SECRET_2", text: "secret-2" }, - { type: "secret_text", name: "SECRET_3", text: "secret-3" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { + SECRET_1: "secret-1", + SECRET_2: "secret-2", + SECRET_3: "secret-3", + }); }); await runWrangler(`versions secret bulk --name script-name`); @@ -175,19 +169,12 @@ describe("versions secret bulk", () => { "SECRET_1=secret-1\nSECRET_2=secret-2\nSECRET_3=secret-3" ); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET_1", text: "secret-1" }, - { type: "secret_text", name: "SECRET_2", text: "secret-2" }, - { type: "secret_text", name: "SECRET_3", text: "secret-3" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { + SECRET_1: "secret-1", + SECRET_2: "secret-2", + SECRET_3: "secret-3", + }); }); await runWrangler(`versions secret bulk --name script-name`); @@ -220,21 +207,6 @@ describe("versions secret bulk", () => { test("should error on invalid json stdin", async ({ expect }) => { mockReadlineInput("hello world"); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET_1", text: "secret-1" }, - { type: "secret_text", name: "SECRET_2", text: "secret-2" }, - { type: "secret_text", name: "SECRET_3", text: "secret-3" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - }); - await runWrangler(`versions secret bulk --name script-name`); expect(std.out).toMatchInlineSnapshot( ` @@ -267,103 +239,20 @@ describe("versions secret bulk", () => { ); }); - test("unsafe metadata is provided", async ({ expect }) => { - writeWranglerConfig({ - name: "script-name", - unsafe: { metadata: { build_options: { stable_id: "foo/bar" } } }, - }); - - await writeFile( - "secrets.json", - JSON.stringify({ - SECRET_1: "secret-1", - SECRET_2: "secret-2", - SECRET_3: "secret-3", - }), - { encoding: "utf8" } - ); - - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET_1", text: "secret-1" }, - { type: "secret_text", name: "SECRET_2", text: "secret-2" }, - { type: "secret_text", name: "SECRET_3", text: "secret-3" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - expect(metadata["build_options"]).toStrictEqual({ stable_id: "foo/bar" }); - }); - - await runWrangler(`versions secret bulk secrets.json --name script-name`); - expect(std.out).toMatchInlineSnapshot( - ` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secrets for the Worker "script-name" - ✨ Successfully created secret for key: SECRET_1 - ✨ Successfully created secret for key: SECRET_2 - ✨ Successfully created secret for key: SECRET_3 - ✨ Success! Created version id with 3 secrets. - ➡️ To deploy this version to production traffic use the command "wrangler versions deploy"." - ` - ); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("unsafe metadata not included if not in wrangler.toml", async ({ + test("shows a nice error message when the Worker has no versions", async ({ expect, }) => { - writeWranglerConfig({ - name: "script-name", + await writeFile("secrets.json", JSON.stringify({ SECRET_1: "secret-1" }), { + encoding: "utf8", }); - await writeFile( - "secrets.json", - JSON.stringify({ - SECRET_1: "secret-1", - SECRET_2: "secret-2", - SECRET_3: "secret-3", - }), - { encoding: "utf8" } - ); - - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET_1", text: "secret-1" }, - { type: "secret_text", name: "SECRET_2", text: "secret-2" }, - { type: "secret_text", name: "SECRET_3", text: "secret-3" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - expect(metadata["build_options"]).toBeUndefined(); - }); + mockPatchLatestVersionNoVersions(expect); - await runWrangler(`versions secret bulk secrets.json --name script-name`); - expect(std.out).toMatchInlineSnapshot( - ` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secrets for the Worker "script-name" - ✨ Successfully created secret for key: SECRET_1 - ✨ Successfully created secret for key: SECRET_2 - ✨ Successfully created secret for key: SECRET_3 - ✨ Success! Created version id with 3 secrets. - ➡️ To deploy this version to production traffic use the command "wrangler versions deploy"." - ` + await expect( + runWrangler(`versions secret bulk secrets.json --name script-name`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: There are currently no uploaded versions of this Worker. Please upload a version before modifying a secret.]` ); - expect(std.err).toMatchInlineSnapshot(`""`); }); describe("multi-env warning", () => { @@ -377,8 +266,9 @@ describe("versions secret bulk", () => { ); writeWranglerConfig({ env: { test: {} } }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET_1: "secret-1" }); + }); await runWrangler(`versions secret bulk --name script-name`); expect(std.warn).toMatchInlineSnapshot(` @@ -403,8 +293,9 @@ describe("versions secret bulk", () => { ); writeWranglerConfig({ env: { test: {} } }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET_1: "secret-1" }); + }); await runWrangler(`versions secret bulk --name script-name --env test`); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -420,8 +311,9 @@ describe("versions secret bulk", () => { ); writeWranglerConfig(); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET_1: "secret-1" }); + }); await runWrangler(`versions secret bulk --name script-name`); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -438,8 +330,9 @@ describe("versions secret bulk", () => { ); writeWranglerConfig({ env: { test: {} } }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET_1: "secret-1" }); + }); await runWrangler(`versions secret bulk --name script-name`); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -455,97 +348,12 @@ describe("versions secret bulk", () => { ); writeWranglerConfig({ env: { test: {} } }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET_1: "secret-1" }); + }); await runWrangler(`versions secret bulk --name script-name --env=""`); expect(std.warn).toMatchInlineSnapshot(`""`); }); }); - - describe("placement", () => { - function buildVersionInfo(placement: CfPlacement): VersionDetails { - return { - id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", - metadata: {} as VersionDetails["metadata"], - number: 2, - resources: { - bindings: [], - script: { - etag: "etag", - handlers: ["fetch"], - last_deployed_from: "api", - placement, - }, - script_runtime: { - usage_model: "standard", - limits: {}, - }, - }, - }; - } - - async function runBulk() { - await writeFile( - "secrets.json", - JSON.stringify({ SECRET_1: "secret-1" }), - { encoding: "utf8" } - ); - await runWrangler(`versions secret bulk secrets.json --name script-name`); - } - - test("preserves smart placement on the new version", async ({ expect }) => { - const placement: CfPlacement = { mode: "smart" }; - mockSetupApiCalls(expect); - mockGetVersion(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runBulk(); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("preserves targeted placement with service targets on the new version", async ({ - expect, - }) => { - const placement = { - mode: "targeted", - target: [{ hostname: "example.com", id: 410, type: "http" }], - } as unknown as CfPlacement; - mockSetupApiCalls(expect); - mockGetVersion(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runBulk(); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("preserves targeted placement with region targets on the new version", async ({ - expect, - }) => { - const placement = { - mode: "targeted", - target: [{ id: 12, region: "aws:ap-northeast-1", type: "region" }], - } as unknown as CfPlacement; - mockSetupApiCalls(expect); - mockGetVersion(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runBulk(); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("omits placement when the existing version has none", async ({ - expect, - }) => { - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toBeUndefined(); - }); - await runBulk(); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - }); }); diff --git a/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts b/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts index 758010a0ba..4ff1a3d5d0 100644 --- a/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts +++ b/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts @@ -9,9 +9,11 @@ import { mockConsoleMethods } from "../../helpers/mock-console"; import { clearDialogs, mockConfirm } from "../../helpers/mock-dialogs"; import { useMockIsTTY } from "../../helpers/mock-istty"; import { runWrangler } from "../../helpers/run-wrangler"; -import { mockGetVersion, mockPostVersion, mockSetupApiCalls } from "./utils"; -import type { VersionDetails } from "../../../versions/secrets"; -import type { CfPlacement } from "@cloudflare/workers-utils"; +import { + expectSecretPatch, + mockPatchLatestVersion, + mockPatchLatestVersionNoVersions, +} from "./utils"; describe("versions secret delete", () => { const std = mockConsoleMethods(); @@ -31,17 +33,8 @@ describe("versions secret delete", () => { result: true, }); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect, (metadata) => { - // We should have all secrets except the one being deleted - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "inherit", name: "ANOTHER_SECRET" }, - { type: "inherit", name: "YET_ANOTHER_SECRET" }, - ]); - // We will not be inherting secret_text as that would bring back SECRET - expect(metadata.keep_bindings).toStrictEqual(["secret_key"]); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); }); await runWrangler("versions secret delete SECRET --name script-name"); @@ -59,16 +52,8 @@ describe("versions secret delete", () => { test("can delete a secret (non-interactive)", async ({ expect }) => { setIsTTY(false); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "inherit", name: "ANOTHER_SECRET" }, - { type: "inherit", name: "YET_ANOTHER_SECRET" }, - ]); - // We will not be inherting secret_text as that would bring back SECRET - expect(metadata.keep_bindings).toStrictEqual(["secret_key"]); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); }); await runWrangler("versions secret delete SECRET --name script-name"); @@ -92,16 +77,8 @@ describe("versions secret delete", () => { writeWranglerConfig({ name: "script-name" }); setIsTTY(false); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "inherit", name: "ANOTHER_SECRET" }, - { type: "inherit", name: "YET_ANOTHER_SECRET" }, - ]); - // We will not be inherting secret_text as that would bring back SECRET - expect(metadata.keep_bindings).toStrictEqual(["secret_key"]); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); }); await runWrangler("versions secret delete SECRET"); @@ -123,9 +100,9 @@ describe("versions secret delete", () => { await writeFile("wrangler.json", JSON.stringify({ invalid_field: true })); setIsTTY(false); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); + }); await runWrangler("versions secret delete SECRET --name script-name"); @@ -133,6 +110,19 @@ describe("versions secret delete", () => { expect(std.err).toMatchInlineSnapshot(`""`); }); + test("shows a nice error message when the Worker has no versions", async ({ + expect, + }) => { + setIsTTY(false); + mockPatchLatestVersionNoVersions(expect); + + await expect( + runWrangler("versions secret delete SECRET --name script-name") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: There are currently no uploaded versions of this Worker. Please upload a version before modifying a secret.]` + ); + }); + describe("multi-env warning", () => { it("should warn if the wrangler config contains environments but none was specified in the command", async ({ expect, @@ -142,9 +132,9 @@ describe("versions secret delete", () => { writeWranglerConfig({ env: { test: {} }, }); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); + }); await runWrangler("versions secret delete SECRET --name script-name"); @@ -168,9 +158,9 @@ describe("versions secret delete", () => { writeWranglerConfig({ env: { test: {} }, }); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); + }); await runWrangler( "versions secret delete SECRET --name script-name -e test" @@ -185,9 +175,9 @@ describe("versions secret delete", () => { setIsTTY(false); writeWranglerConfig(); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); + }); await runWrangler("versions secret delete SECRET --name script-name"); @@ -203,9 +193,9 @@ describe("versions secret delete", () => { writeWranglerConfig({ env: { test: {} }, }); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); + }); await runWrangler("versions secret delete SECRET --name script-name"); @@ -220,9 +210,9 @@ describe("versions secret delete", () => { writeWranglerConfig({ env: { test: {} }, }); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { SECRET: null }); + }); await runWrangler( 'versions secret delete SECRET --name script-name --env=""' @@ -231,98 +221,4 @@ describe("versions secret delete", () => { expect(std.warn).toMatchInlineSnapshot(`""`); }); }); - - describe("placement", () => { - function buildVersionInfo(placement: CfPlacement): VersionDetails { - return { - id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", - metadata: {} as VersionDetails["metadata"], - number: 2, - resources: { - bindings: [ - { type: "secret_text", name: "SECRET", text: "Secret shhh" }, - ], - script: { - etag: "etag", - handlers: ["fetch"], - last_deployed_from: "api", - placement, - }, - script_runtime: { - usage_model: "standard", - limits: {}, - }, - }, - }; - } - - // `versions secret delete` fetches the version twice (once directly, once - // inside copyWorkerVersionWithNewSecrets), so register the override twice. - function mockGetVersionTwice( - expect: Parameters[0], - versionInfo: VersionDetails - ) { - mockGetVersion(expect, versionInfo); - mockGetVersion(expect, versionInfo); - } - - test("preserves smart placement on the new version", async ({ expect }) => { - setIsTTY(false); - const placement: CfPlacement = { mode: "smart" }; - mockSetupApiCalls(expect); - mockGetVersionTwice(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runWrangler("versions secret delete SECRET --name script-name"); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("preserves targeted placement with service targets on the new version", async ({ - expect, - }) => { - setIsTTY(false); - const placement = { - mode: "targeted", - target: [{ hostname: "example.com", id: 410, type: "http" }], - } as unknown as CfPlacement; - mockSetupApiCalls(expect); - mockGetVersionTwice(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runWrangler("versions secret delete SECRET --name script-name"); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("preserves targeted placement with region targets on the new version", async ({ - expect, - }) => { - setIsTTY(false); - const placement = { - mode: "targeted", - target: [{ id: 12, region: "aws:ap-northeast-1", type: "region" }], - } as unknown as CfPlacement; - mockSetupApiCalls(expect); - mockGetVersionTwice(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runWrangler("versions secret delete SECRET --name script-name"); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("omits placement when the existing version has none", async ({ - expect, - }) => { - setIsTTY(false); - mockSetupApiCalls(expect); - mockGetVersion(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toBeUndefined(); - }); - await runWrangler("versions secret delete SECRET --name script-name"); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - }); }); diff --git a/packages/wrangler/src/__tests__/versions/secrets/put.test.ts b/packages/wrangler/src/__tests__/versions/secrets/put.test.ts index b3f8b32d27..7cac0f7781 100644 --- a/packages/wrangler/src/__tests__/versions/secrets/put.test.ts +++ b/packages/wrangler/src/__tests__/versions/secrets/put.test.ts @@ -3,19 +3,18 @@ import { runInTempDir, writeWranglerConfig, } from "@cloudflare/workers-utils/test-helpers"; -import { http, HttpResponse } from "msw"; -import { FormData } from "undici"; import { afterEach, describe, it, test, vi } from "vitest"; import { mockAccountId, mockApiToken } from "../../helpers/mock-account-id"; import { mockConsoleMethods } from "../../helpers/mock-console"; import { clearDialogs, mockPrompt } from "../../helpers/mock-dialogs"; import { useMockIsTTY } from "../../helpers/mock-istty"; import { useMockStdin } from "../../helpers/mock-stdin"; -import { msw } from "../../helpers/msw"; import { runWrangler } from "../../helpers/run-wrangler"; -import { mockGetVersion, mockPostVersion, mockSetupApiCalls } from "./utils"; -import type { VersionDetails } from "../../../versions/secrets"; -import type { CfPlacement } from "@cloudflare/workers-utils"; +import { + expectSecretPatch, + mockPatchLatestVersion, + mockPatchLatestVersionNoVersions, +} from "./utils"; describe("versions secret put", () => { const std = mockConsoleMethods(); @@ -36,17 +35,8 @@ describe("versions secret put", () => { result: "the-secret", }); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); }); await runWrangler("versions secret put NEW_SECRET --name script-name"); @@ -61,12 +51,8 @@ describe("versions secret put", () => { expect(std.err).toMatchInlineSnapshot(`""`); }); - test("unsafe metadata is provided", async ({ expect }) => { - writeWranglerConfig({ - name: "script-name", - unsafe: { metadata: { build_options: { stable_id: "foo/bar" } } }, - }); - + test("no wrangler configuration warnings shown", async ({ expect }) => { + await writeFile("wrangler.json", JSON.stringify({ invalid_field: true })); setIsTTY(true); mockPrompt({ @@ -75,30 +61,17 @@ describe("versions secret put", () => { result: "the-secret", }); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata["build_options"]).toStrictEqual({ stable_id: "foo/bar" }); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); }); await runWrangler("versions secret put NEW_SECRET --name script-name"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secret for the Worker "script-name" - ✨ Success! Created version id with secret NEW_SECRET. - ➡️ To deploy this version with secret NEW_SECRET to production traffic use the command "wrangler versions deploy"." - `); + expect(std.warn).toMatchInlineSnapshot(`""`); expect(std.err).toMatchInlineSnapshot(`""`); }); - test("unsafe metadata not included if not in wrangler.toml", async ({ + test("shows a nice error message when the Worker has no versions", async ({ expect, }) => { - writeWranglerConfig({ - name: "script-name", - }); - setIsTTY(true); mockPrompt({ @@ -107,62 +80,20 @@ describe("versions secret put", () => { result: "the-secret", }); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata["build_options"]).toBeUndefined(); - }); - await runWrangler("versions secret put NEW_SECRET --name script-name"); + mockPatchLatestVersionNoVersions(expect); - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secret for the Worker "script-name" - ✨ Success! Created version id with secret NEW_SECRET. - ➡️ To deploy this version with secret NEW_SECRET to production traffic use the command "wrangler versions deploy"." - `); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("no wrangler configuration warnings shown", async ({ expect }) => { - await writeFile("wrangler.json", JSON.stringify({ invalid_field: true })); - setIsTTY(true); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - mockSetupApiCalls(expect); - mockPostVersion(expect); - await runWrangler("versions secret put NEW_SECRET --name script-name"); - expect(std.warn).toMatchInlineSnapshot(`""`); - expect(std.err).toMatchInlineSnapshot(`""`); + await expect( + runWrangler("versions secret put NEW_SECRET --name script-name") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: There are currently no uploaded versions of this Worker. Please upload a version before modifying a secret.]` + ); }); describe("(non-interactive)", () => { const mockStdIn = useMockStdin({ isTTY: false }); test("can add a new secret (non-interactive)", async ({ expect }) => { - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); }); mockStdIn.send( @@ -198,17 +129,8 @@ describe("versions secret put", () => { result: "the-secret", }); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); }); await runWrangler("versions secret put NEW_SECRET"); @@ -232,21 +154,12 @@ describe("versions secret put", () => { result: "the-secret", }); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - - expect(metadata.annotations).not.toBeUndefined(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + + expect(patch.annotations).not.toBeUndefined(); expect( - (metadata.annotations as Record)["workers/message"] + (patch.annotations as Record)["workers/message"] ).toBe("Deploy a new secret"); }); await runWrangler( @@ -273,25 +186,16 @@ describe("versions secret put", () => { result: "the-secret", }); - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - - expect(metadata.annotations).not.toBeUndefined(); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + + expect(patch.annotations).not.toBeUndefined(); expect( - (metadata.annotations as Record)["workers/message"] + (patch.annotations as Record)["workers/message"] ).toBe("Deploy a new secret"); - expect( - (metadata.annotations as Record)["workers/tag"] - ).toBe("v1"); + expect((patch.annotations as Record)["workers/tag"]).toBe( + "v1" + ); }); await runWrangler( "versions secret put NEW_SECRET --name script-name --message 'Deploy a new secret' --tag v1" @@ -308,161 +212,6 @@ describe("versions secret put", () => { expect(std.err).toMatchInlineSnapshot(`""`); }); - test("all non-secret bindings are inherited", async ({ expect }) => { - setIsTTY(true); - - mockSetupApiCalls(expect); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.annotations).not.toBeUndefined(); - }); - await runWrangler("versions secret put SECRET --name script-name"); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secret for the Worker "script-name" - ✨ Success! Created version id with secret SECRET. - ➡️ To deploy this version with secret SECRET to production traffic use the command "wrangler versions deploy"." - `); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("can update an existing secret", async ({ expect }) => { - setIsTTY(true); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - - expect(metadata.annotations).not.toBeUndefined(); - expect( - (metadata.annotations as Record)["workers/message"] - ).toBe("Deploy a new secret"); - }); - await runWrangler( - "versions secret put SECRET --name script-name --message 'Deploy a new secret'" - ); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secret for the Worker "script-name" - ✨ Success! Created version id with secret SECRET. - ➡️ To deploy this version with secret SECRET to production traffic use the command "wrangler versions deploy"." - `); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("can add secret on wasm worker", async ({ expect }) => { - setIsTTY(true); - - mockSetupApiCalls(expect); - // Mock content call to have wasm - msw.use( - http.get( - `*/accounts/:accountId/workers/scripts/:scriptName/content/v2?version=ce15c78b-cc43-4f60-b5a9-15ce4f298c2a`, - async ({ params }) => { - expect(params.accountId).toEqual("some-account-id"); - expect(params.scriptName).toEqual("script-name"); - - const formData = new FormData(); - formData.set( - "index.js", - new File(["export default {}"], "index.js", { - type: "application/javascript+module", - }), - "index.js" - ); - formData.set( - "module.wasm", - new File( - [Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])], - "module.wasm", - { - type: "application/wasm", - } - ), - "module.wasm" - ); - return HttpResponse.formData(formData, { - headers: { "cf-entrypoint": "index.js" }, - }); - }, - { once: true } - ) - ); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - mockPostVersion(expect, (metadata, formData) => { - expect(formData.get("module.wasm")).not.toBeNull(); - expect((formData.get("module.wasm") as File).size).equal(10); - - expect(metadata.bindings).toStrictEqual([ - { type: "inherit", name: "do-binding" }, - { type: "secret_text", name: "SECRET", text: "the-secret" }, - ]); - expect(metadata.keep_bindings).toStrictEqual([ - "secret_key", - "secret_text", - ]); - expect(metadata.keep_assets).toBeTruthy(); - - expect(metadata.annotations).not.toBeUndefined(); - expect( - (metadata.annotations as Record)["workers/message"] - ).toBe("Deploy a new secret"); - }); - await runWrangler( - "versions secret put SECRET --name script-name --message 'Deploy a new secret'" - ); - - expect(std.out).toMatchInlineSnapshot(` - " - ⛅️ wrangler x.x.x - ────────────────── - 🌀 Creating the secret for the Worker "script-name" - ✨ Success! Created version id with secret SECRET. - ➡️ To deploy this version with secret SECRET to production traffic use the command "wrangler versions deploy"." - `); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - describe("multi-env warning", () => { const mockStdIn = useMockStdin({ isTTY: false }); @@ -473,8 +222,9 @@ describe("versions secret put", () => { name: "script-name", env: { test: {} }, }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + }); mockStdIn.send( `the`, @@ -503,8 +253,9 @@ describe("versions secret put", () => { name: "script-name", env: { test: {} }, }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + }); mockStdIn.send( `the`, @@ -523,8 +274,9 @@ describe("versions secret put", () => { writeWranglerConfig({ name: "script-name", }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + }); mockStdIn.send( `the`, @@ -545,8 +297,9 @@ describe("versions secret put", () => { name: "script-name", env: { test: {} }, }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + }); mockStdIn.send( `the`, @@ -566,8 +319,9 @@ describe("versions secret put", () => { name: "script-name", env: { test: {} }, }); - mockSetupApiCalls(expect); - mockPostVersion(expect); + mockPatchLatestVersion(expect, (patch) => { + expectSecretPatch(expect, patch, { NEW_SECRET: "the-secret" }); + }); mockStdIn.send( `the`, @@ -580,117 +334,4 @@ describe("versions secret put", () => { expect(std.warn).toMatchInlineSnapshot(`""`); }); }); - - describe("placement", () => { - function buildVersionInfo(placement: CfPlacement): VersionDetails { - return { - id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", - metadata: {} as VersionDetails["metadata"], - number: 2, - resources: { - bindings: [], - script: { - etag: "etag", - handlers: ["fetch"], - last_deployed_from: "api", - placement, - }, - script_runtime: { - usage_model: "standard", - limits: {}, - }, - }, - }; - } - - test("preserves smart placement on the new version", async ({ expect }) => { - setIsTTY(true); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - const placement: CfPlacement = { mode: "smart" }; - mockSetupApiCalls(expect); - mockGetVersion(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runWrangler("versions secret put NEW_SECRET --name script-name"); - - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("preserves targeted placement with service targets on the new version", async ({ - expect, - }) => { - setIsTTY(true); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - const placement = { - mode: "targeted", - target: [{ hostname: "example.com", id: 410, type: "http" }], - } as unknown as CfPlacement; - mockSetupApiCalls(expect); - mockGetVersion(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runWrangler("versions secret put NEW_SECRET --name script-name"); - - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("preserves targeted placement with region targets on the new version", async ({ - expect, - }) => { - setIsTTY(true); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - const placement = { - mode: "targeted", - target: [{ id: 12, region: "aws:ap-northeast-1", type: "region" }], - } as unknown as CfPlacement; - mockSetupApiCalls(expect); - mockGetVersion(expect, buildVersionInfo(placement)); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toStrictEqual(placement); - }); - await runWrangler("versions secret put NEW_SECRET --name script-name"); - - expect(std.err).toMatchInlineSnapshot(`""`); - }); - - test("omits placement when the existing version has none", async ({ - expect, - }) => { - setIsTTY(true); - - mockPrompt({ - text: "Enter a secret value:", - options: { isSecret: true }, - result: "the-secret", - }); - - mockSetupApiCalls(expect); - mockPostVersion(expect, (metadata) => { - expect(metadata.placement).toBeUndefined(); - }); - await runWrangler("versions secret put NEW_SECRET --name script-name"); - - expect(std.err).toMatchInlineSnapshot(`""`); - }); - }); }); diff --git a/packages/wrangler/src/__tests__/versions/secrets/utils.ts b/packages/wrangler/src/__tests__/versions/secrets/utils.ts index 6886547c5c..4ce278367c 100644 --- a/packages/wrangler/src/__tests__/versions/secrets/utils.ts +++ b/packages/wrangler/src/__tests__/versions/secrets/utils.ts @@ -1,135 +1,63 @@ import { http, HttpResponse } from "msw"; -import { FormData } from "undici"; import { createFetchResult, msw } from "../../helpers/msw"; -import type { VersionDetails, WorkerVersion } from "../../../versions/secrets"; -import type { WorkerMetadata } from "@cloudflare/workers-utils"; import type { ExpectStatic } from "vitest"; -function mockGetVersions(expect: ExpectStatic) { - msw.use( - http.get( - `*/accounts/:accountId/workers/scripts/:scriptName/versions`, - async ({ params }) => { - expect(params.accountId).toEqual("some-account-id"); - expect(params.scriptName).toMatch(/script-name(-test)?/); +type PatchLatestVersionEnvBinding = { type: "secret_text"; text: string }; - return HttpResponse.json( - createFetchResult({ - items: [ - { - id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", - number: 2, - }, - { - id: "ec5ea4f9-fe32-4301-bd73-6a86006ae8d4", - number: 1, - }, - ] as WorkerVersion[], - }) - ); - }, - { once: true } - ) - ); +export interface PatchLatestVersionPatch { + annotations?: Record; + env?: Record; + + // These fields should be inherited by PATCH/latest rather than resent. + build_options?: never; + keep_assets?: never; + keep_bindings?: never; + placement?: never; } -export function mockGetVersion( +export function expectSecretPatch( expect: ExpectStatic, - versionInfo?: VersionDetails + patch: PatchLatestVersionPatch, + secrets: Record ) { - msw.use( - http.get( - `*/accounts/:accountId/workers/scripts/:scriptName/versions/ce15c78b-cc43-4f60-b5a9-15ce4f298c2a`, - async ({ params }) => { - expect(params.accountId).toEqual("some-account-id"); - expect(params.scriptName).toMatch(/script-name(-test)?/); - - return HttpResponse.json( - createFetchResult( - versionInfo ?? { - id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", - metadata: {}, - number: 2, - resources: { - bindings: [ - { type: "secret_text", name: "SECRET", text: "Secret shhh" }, - { - type: "secret_text", - name: "ANOTHER_SECRET", - text: "Another secret shhhh", - }, - { - type: "secret_text", - name: "YET_ANOTHER_SECRET", - text: "Yet another secret shhhhh", - }, - { - name: "do-binding", - type: "durable_object_namespace", - namespace_id: "some-namespace-id", - }, - ], - script: { - etag: "etag", - handlers: ["fetch"], - last_deployed_from: "api", - }, - script_runtime: { - usage_model: "standard", - limits: {}, - }, - }, - } - ) - ); - }, - { once: true } - ) - ); + expect(patch).toMatchObject({ + env: Object.fromEntries( + Object.entries(secrets).map(([name, value]) => [ + name, + value === null ? null : { type: "secret_text", text: value }, + ]) + ), + }); + expect(patch.keep_bindings).toBeUndefined(); + expect(patch.keep_assets).toBeUndefined(); + expect(patch.placement).toBeUndefined(); + expect(patch).not.toHaveProperty("build_options"); } -function mockGetVersionContent(expect: ExpectStatic) { +export function mockPatchLatestVersion( + expect: ExpectStatic, + validate?: (patch: PatchLatestVersionPatch) => void +) { msw.use( - http.get( - `*/accounts/:accountId/workers/scripts/:scriptName/content/v2`, - async ({ params, request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("version")).toEqual( - "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a" - ); + http.patch( + `*/accounts/:accountId/workers/workers/:scriptName/versions/latest`, + async ({ request, params }) => { expect(params.accountId).toEqual("some-account-id"); expect(params.scriptName).toMatch(/script-name(-test)?/); - - const formData = new FormData(); - formData.set( - "index.js", - new File(["export default {}"], "index.js", { - type: "application/javascript+module", - }), - "index.js" + expect(request.headers.get("content-type")).toEqual( + "application/merge-patch+json" ); + const patch = (await request.json()) as PatchLatestVersionPatch; - return HttpResponse.formData(formData, { - headers: { "cf-entrypoint": "index.js" }, - }); - }, - { once: true } - ) - ); -} - -function mockGetWorkerSettings(expect: ExpectStatic) { - msw.use( - http.get( - `*/accounts/:accountId/workers/scripts/:scriptName/script-settings`, - async ({ params }) => { - expect(params.accountId).toEqual("some-account-id"); - expect(params.scriptName).toMatch(/script-name(-test)?/); + if (validate) { + validate(patch); + } return HttpResponse.json( createFetchResult({ - logpush: false, - tail_consumers: null, + id: "id", + etag: "etag", + deployment_id: "version-id", }) ); }, @@ -138,43 +66,29 @@ function mockGetWorkerSettings(expect: ExpectStatic) { ); } -export function mockPostVersion( - expect: ExpectStatic, - validate?: (metadata: WorkerMetadata, formData: FormData) => void -) { +export function mockPatchLatestVersionNoVersions(expect: ExpectStatic) { msw.use( - http.post( - `*/accounts/:accountId/workers/scripts/:scriptName/versions`, - async ({ request, params }) => { + http.patch( + `*/accounts/:accountId/workers/workers/:scriptName/versions/latest`, + ({ request, params }) => { expect(params.accountId).toEqual("some-account-id"); expect(params.scriptName).toMatch(/script-name(-test)?/); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- formData() is the standard Web API; only deprecated on undici's server-side types - const formData = await request.formData(); - const metadata = JSON.parse( - formData.get("metadata") as string - ) as WorkerMetadata; - - if (validate) { - validate(metadata, formData); - } + expect(request.headers.get("content-type")).toEqual( + "application/merge-patch+json" + ); return HttpResponse.json( - createFetchResult({ - id: "id", - etag: "etag", - deployment_id: "version-id", - }) + createFetchResult(null, false, [ + { + code: 10222, + message: + "This Worker has no versions, which means this Worker has no content or versioned settings.", + }, + ]), + { status: 404 } ); }, { once: true } ) ); } - -export function mockSetupApiCalls(expect: ExpectStatic) { - mockGetVersions(expect); - mockGetVersion(expect); - mockGetVersionContent(expect); - mockGetWorkerSettings(expect); -} diff --git a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts index ad840d69ce..cf50397e59 100644 --- a/packages/wrangler/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.upload.test.ts @@ -1851,7 +1851,17 @@ describe("versions upload", () => { }), http.get("*/accounts/:accountId/r2/buckets/:bucketName", () => { return HttpResponse.json(createFetchResult({ name: "my-bucket" })); - }) + }), + http.get("*/accounts/:accountId/workers/dispatch/namespaces", () => + HttpResponse.json( + createFetchResult([ + { + namespace_id: "namespace-id", + namespace_name: "my-namespace", + }, + ]) + ) + ) ); writeWranglerConfig({ @@ -2648,6 +2658,58 @@ describe("versions upload", () => { const metadata = await getMetadata(requests[0]); expect(metadata.package_dependencies).toBeUndefined(); }); + + test("should exclude packages matching exclude_packages patterns from upload metadata", async ({ + expect, + }) => { + mockGetScript(); + const requests = mockUploadVersion(false, 0); + + // Create two resolvable packages in node_modules + for (const [name, version] of [ + ["@internal/secret", "1.0.0"], + ["public-lib", "2.0.0"], + ] as const) { + const pkgPath = path.join(process.cwd(), "node_modules", name); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify({ name, version }) + ); + } + + writeWranglerConfig({ + name: "test-name", + main: "./index.js", + dependencies_instrumentation: { + enabled: true, + exclude_packages: ["@internal/*"], + }, + }); + fs.writeFileSync( + "package.json", + JSON.stringify({ + name: "test-project", + dependencies: { + "@internal/secret": "^1.0.0", + "public-lib": "^2.0.0", + }, + }) + ); + writeWorkerSource(); + + await runWrangler("versions upload"); + + const metadata = await getMetadata(requests[0]); + expect(metadata.package_dependencies).toEqual([ + { + name: "public-lib", + packageJsonVersion: "^2.0.0", + installedVersion: "2.0.0", + }, + ]); + }); }); }); diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index 1322bb88ff..68abb047c0 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -1,221 +1,47 @@ -import assert from "node:assert"; -import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; -import { - getBindingLocalSupport, - getCloudflareComplianceRegion, -} from "@cloudflare/workers-utils"; +import { maybeStartOrUpdateRemoteProxySession as maybeStartOrUpdateRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; +import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { readConfig } from "../../config"; import { logger } from "../../logger"; -import { requireApiToken, requireAuth, setProfile } from "../../user"; import { convertConfigBindingsToStartWorkerBindings } from "../startDevWorker"; import { startRemoteProxySession } from "./start-remote-proxy-session"; -import type { CfAccount } from "../../dev/create-worker-preview"; import type { - AsyncHook, - Binding, - StartDevWorkerInput, -} from "../startDevWorker/types"; -import type { RemoteProxySession } from "./start-remote-proxy-session"; -import type { Config } from "@cloudflare/workers-utils"; + RemoteProxySessionData, + WorkerConfigObject, +} from "@cloudflare/remote-bindings"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; -export * from "./start-remote-proxy-session"; - -export function pickRemoteBindings( - bindings: Record -): Record { - return Object.fromEntries( - Object.entries(bindings ?? {}).filter(([, binding]) => { - if ( - getBindingLocalSupport(binding.type) === - "DO-NOT-USE-this-resource-will-never-have-a-local-simulator" - ) { - return true; - } - return "remote" in binding && binding["remote"]; - }) - ); -} +export * from "@cloudflare/remote-bindings"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { StartRemoteProxySessionOptions } from "./start-remote-proxy-session"; type WranglerConfigObject = { - /** The path to the wrangler config file */ path: string; - /** The target environment */ environment?: string; }; -type WorkerConfigObject = { - /** The name of the worker */ - name?: string; - /** The Worker's bindings */ - bindings: NonNullable; - /** If running in a non-public compliance region, set this here. */ - complianceRegion?: Config["compliance_region"]; - /** Id of the account owning the worker */ - account_id?: Config["account_id"]; - /** - * directory used to resolve the auth profile from directory bindings. - * Falls back to `process.cwd()` when not provided. - */ - profileDir?: string; -}; - -/** - * Utility for potentially starting or updating a remote proxy session. - * - * @param wranglerOrWorkerConfigObject either a file path to a wrangler configuration file or an object containing the name of - * the target worker alongside its bindings. - * @param preExistingRemoteProxySessionData the optional data of a pre-existing remote proxy session if there was one, this - * argument can be omitted or set to null if there is no pre-existing remote proxy session - * @param auth the authentication information for establishing the remote proxy connection - * @returns null if no existing remote proxy session was provided and one should not be created (because the worker is not - * defining any remote bindings), the data associated to the created/updated remote proxy session otherwise. - */ -export async function maybeStartOrUpdateRemoteProxySession( +export function maybeStartOrUpdateRemoteProxySession( wranglerOrWorkerConfigObject: WranglerConfigObject | WorkerConfigObject, - preExistingRemoteProxySessionData?: { - session: RemoteProxySession; - remoteBindings: Record; - auth?: AsyncHook | undefined; - } | null, - auth?: AsyncHook | undefined -): Promise<{ - session: RemoteProxySession; - remoteBindings: Record; - auth?: AsyncHook | undefined; -} | null> { - let config: Config | undefined; + preExistingRemoteProxySessionData?: RemoteProxySessionData | null, + auth?: AsyncHook +) { if ("path" in wranglerOrWorkerConfigObject) { - const wranglerConfigObject = wranglerOrWorkerConfigObject; - config = readConfig({ - config: wranglerConfigObject.path, - env: wranglerConfigObject.environment, + const config = readConfig({ + config: wranglerOrWorkerConfigObject.path, + env: wranglerOrWorkerConfigObject.environment, }); - wranglerOrWorkerConfigObject = { name: config.name ?? "worker", - complianceRegion: getCloudflareComplianceRegion(config), bindings: convertConfigBindingsToStartWorkerBindings(config) ?? {}, + complianceRegion: getCloudflareComplianceRegion(config), + account_id: config.account_id, }; } - const workerConfigObject = wranglerOrWorkerConfigObject; - - const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); - - const authSameAsBefore = deepStrictEqual( + return maybeStartOrUpdateRemoteProxySessionFromPackage( + wranglerOrWorkerConfigObject, + preExistingRemoteProxySessionData, auth, - preExistingRemoteProxySessionData?.auth + { logger }, + startRemoteProxySession ); - - let remoteProxySession = preExistingRemoteProxySessionData?.session; - - if (!authSameAsBefore) { - // The auth values have changed so we do need to restart a new remote proxy session - - if (preExistingRemoteProxySessionData?.session) { - await preExistingRemoteProxySessionData.session.dispose(); - } - - remoteProxySession = await startRemoteProxySession(remoteBindings, { - workerName: workerConfigObject.name, - complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( - auth, - workerConfigObject.account_id - ? { - account_id: workerConfigObject.account_id, - } - : config, - workerConfigObject.profileDir - ), - }); - } else { - // The auth values haven't changed so we can reuse the pre-existing session - - const remoteBindingsAreSameAsBefore = deepStrictEqual( - remoteBindings, - preExistingRemoteProxySessionData?.remoteBindings - ); - - // We only want to perform updates on the remote proxy session if the session's remote bindings have changed - if (!remoteBindingsAreSameAsBefore) { - if (!remoteProxySession) { - if (Object.keys(remoteBindings).length > 0) { - remoteProxySession = await startRemoteProxySession(remoteBindings, { - workerName: workerConfigObject.name, - complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( - auth, - workerConfigObject.account_id - ? { - account_id: workerConfigObject.account_id, - } - : config, - workerConfigObject.profileDir - ), - }); - } - } else { - // Note: we always call updateBindings even when there are zero remote bindings, in these - // cases we could terminate the remote session if we wanted, that's probably - // something to consider down the line - await remoteProxySession.updateBindings(remoteBindings); - } - } - } - - await remoteProxySession?.ready; - if (!remoteProxySession) { - return null; - } - return { - session: remoteProxySession, - remoteBindings, - auth, - }; -} - -/** - * Gets the auth hook to use for the remote proxy session, this is either the user provided auth - * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler - * config file otherwise. - * - * @param auth the auth hook provided by the user if any - * @param config the user's wrangler config if any - * @param profileDir working directory used to resolve the auth profile from directory bindings, - * falls back to `process.cwd()` when not provided - * @returns the auth hook to pass to the startRemoteProxy session function if any - */ -function getAuthHook( - auth: AsyncHook | undefined, - config: Pick | undefined, - profileDir: string | undefined -): AsyncHook | undefined { - const profile = createWranglerProfileStore({ logger }).resolve({ - cwd: profileDir ?? process.cwd(), - }); - setProfile(profile); - if (auth) { - return auth; - } - - if (config?.account_id) { - return async () => { - return { - accountId: await requireAuth(config), - apiToken: requireApiToken(), - }; - }; - } - - return undefined; -} - -function deepStrictEqual(source: unknown, target: unknown): boolean { - try { - assert.deepStrictEqual(source, target); - return true; - } catch { - return false; - } } diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index c0444802e3..cca2750567 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1,247 +1,19 @@ -import events from "node:events"; -import path from "node:path"; -import { UserError } from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { DeferredPromise } from "miniflare"; -import remoteBindingsWorkerPath from "worker:remoteBindings/ProxyServerWorker"; -import { RemoteSessionAuthenticationError } from "../../dev/remote"; +import { startRemoteProxySession as startRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; import { logger } from "../../logger"; -import { getBasePath } from "../../paths"; -import { startWorker } from "../startDevWorker"; -import type { LoggerLevel } from "../../logger"; -import type { StartDevWorkerInput, Worker } from "../startDevWorker"; -import type { ErrorEvent } from "../startDevWorker/events"; -import type { Config } from "@cloudflare/workers-utils"; -import type { RemoteProxyConnectionString } from "miniflare"; - -export type StartRemoteProxySessionOptions = { - workerName?: string; - auth?: NonNullable["auth"]; - /** If running in a non-public compliance region, set this here. */ - complianceRegion?: Config["compliance_region"]; -}; - -function isErrorEvent(error: unknown): error is ErrorEvent { - return ( - typeof error === "object" && - error !== null && - "type" in error && - (error as { type?: string }).type === "error" && - "reason" in error && - "cause" in error - ); -} - -function getErrorMessage(error: unknown): string | undefined { - if (error instanceof Error) { - return getErrorMessage(error.cause) ?? error.message; - } - - if (typeof error === "string") { - return error; - } - - if (typeof error === "object" && error !== null) { - const maybeMessage = (error as { message?: unknown }).message; - if (typeof maybeMessage === "string") { - const maybeCause = (error as { cause?: unknown }).cause; - return getErrorMessage(maybeCause) ?? maybeMessage; - } - } - - return undefined; -} - -/** - * Walks the cause chain of an error (including {@link ErrorEvent} wrappers) - * looking for a {@link RemoteSessionAuthenticationError}. - * - * @param error - the error or ErrorEvent to inspect - * @returns the first {@link RemoteSessionAuthenticationError} found, or - * `undefined` if none exists in the chain - */ -function findRemoteSessionAuthError( - error: unknown -): RemoteSessionAuthenticationError | undefined { - if (error instanceof RemoteSessionAuthenticationError) { - return error; - } - - if (isErrorEvent(error) || (error instanceof Error && error.cause)) { - return findRemoteSessionAuthError(error.cause); - } - - return undefined; -} - -function formatRemoteProxySessionError(error: unknown): string | undefined { - if (isErrorEvent(error)) { - const causeMessage = getErrorMessage(error.cause); - return causeMessage ? `${error.reason}: ${causeMessage}` : error.reason; - } - - return getErrorMessage(error); -} - -export async function startRemoteProxySession( +import type { + StartRemoteProxySessionOptions as PackageStartRemoteProxySessionOptions, + RemoteProxySession, +} from "@cloudflare/remote-bindings"; +import type { StartDevWorkerInput } from "@cloudflare/workers-utils"; + +export type StartRemoteProxySessionOptions = Omit< + PackageStartRemoteProxySessionOptions, + "logger" +>; + +export function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], - options?: StartRemoteProxySessionOptions + options: StartRemoteProxySessionOptions = {} ): Promise { - logger.log(chalk.dim("⎔ Establishing remote connection...")); - // Transform all bindings to use "raw" mode - const rawBindings = Object.fromEntries( - Object.entries(bindings ?? {}).map(([key, binding]) => [ - key, - { ...binding, raw: true }, - ]) - ); - - const proxyServerWorkerWranglerConfig = path.resolve( - getBasePath(), - "templates/remoteBindings/wrangler.jsonc" - ); - - const worker = await startWorker({ - name: options?.workerName, - entrypoint: remoteBindingsWorkerPath, - config: proxyServerWorkerWranglerConfig, - compatibilityDate: "2025-04-28", - dev: { - remote: "minimal", - auth: options?.auth, - server: { - port: 0, - }, - inspector: false, - logLevel: getStartWorkerLogLevel(logger.loggerLevel), - }, - bindings: rawBindings, - }).catch((startWorkerError) => { - // If the error is already a UserError (e.g. an auth failure from - // ConfigController), re-throw it directly so the top-level error - // handler can display the original, actionable message without - // wrapping it in a generic "Failed to start" envelope. - if (startWorkerError instanceof UserError) { - throw startWorkerError; - } - let errorMessage = startWorkerError; - if (startWorkerError instanceof Error) { - if (startWorkerError.cause instanceof Error) { - errorMessage = startWorkerError.cause.message; - } else { - errorMessage = startWorkerError.message; - } - } - throw new Error( - `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` - ); - }); - - const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); - - worker.raw.addListener("error", (e) => - maybeErrorPromise.resolve({ error: e }) - ); - - const maybeError = await Promise.race([ - maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, - ]); - - if (maybeError && maybeError.error) { - const authError = findRemoteSessionAuthError(maybeError.error); - if (authError) { - throw authError; - } - - const details = formatRemoteProxySessionError(maybeError.error); - throw new Error( - details - ? `Failed to start the remote proxy session. ${details}` - : "Failed to start the remote proxy session. There is likely additional logging output above.", - { - cause: maybeError.error, - } - ); - } - - const remoteProxyConnectionString = - (await worker.url) as RemoteProxyConnectionString; - - const updateBindings = async ( - newBindings: StartDevWorkerInput["bindings"] - ) => { - // Transform all new bindings to use "raw" mode - const rawNewBindings = Object.fromEntries( - Object.entries(newBindings ?? {}).map(([key, binding]) => [ - key, - { ...binding, raw: true }, - ]) - ); - - // `worker.patchConfig` returns as soon as the config update is dispatched - // — long before the remote worker has actually been re-uploaded with the - // new bindings and the local proxy worker has unpaused. If we returned - // here, callers issuing requests immediately afterwards would race the - // reload window, often surfacing as "WebSocket connection failed" for - // JSRPC bindings. - // - // Subscribe BEFORE patchConfig so we don't miss either event. - // `events.once()` resolves on `reloadComplete` and rejects if `error` - // is emitted first (with the event payload as the rejection value). - const reloadComplete = events.once(worker.raw, "reloadComplete"); - await worker.patchConfig({ bindings: rawNewBindings }); - try { - await reloadComplete; - } catch (errOrEvent) { - throw errOrEvent instanceof Error - ? errOrEvent - : new Error( - `RemoteProxySession.updateBindings failed during reload: ${ - (errOrEvent as { reason?: string })?.reason ?? "unknown" - }`, - { cause: errOrEvent } - ); - } - // The "play" message that resumes the local proxy worker is enqueued on - // this mutex during onReloadComplete. Wait for it to drain so the proxy - // actually unpauses before we return — matches what `worker.fetch` does. - await worker.raw.proxy.runtimeMessageMutex.drained(); - }; - - return { - ready: worker.ready, - remoteProxyConnectionString, - updateBindings, - dispose: worker.dispose, - }; -} - -export type RemoteProxySession = Pick & { - updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; - remoteProxyConnectionString: RemoteProxyConnectionString; -}; - -/** - * Gets the log level to use for the remote worker. - * - * @param wranglerLogLevel The log level set for the Wrangler process. - * @returns The log level to use for the remove worker. - */ -function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { - switch (wranglerLogLevel) { - case "debug": - // If the `logLevel` is "debug" it means that the user is likely trying to debug some issue, - // so we should respect that here as well for the remote proxy session. - return "debug"; - - case "none": - // If the `logLevel` is "none" it means that the user is trying to silence all output, - // so we should respect that here as well for the remote proxy session. - return "none"; - - default: - // In any other case we want to default to "error" to avoid noisy logs - return "error"; - } + return startRemoteProxySessionFromPackage(bindings, { ...options, logger }); } diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 66569565f5..a482c5e409 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -18,6 +18,7 @@ import { readConfig, readNewConfig } from "../../config"; import { containersScope } from "../../containers"; import { getNormalizedContainerOptions } from "../../containers/config"; import { getEntry } from "../../deployment-bundle/entry"; +import { validateNodeCompatMode } from "../../deployment-bundle/node-compat"; import { getBindings, getHostAndRoutes, getInferredHost } from "../../dev"; import { getDurableObjectClassNameToUseSQLiteMap } from "../../dev/class-names-sqlite"; import { getLocalPersistencePath } from "../../dev/get-local-persistence-path"; @@ -365,7 +366,21 @@ async function resolveConfig( "dev" ); - const nodejsCompatMode = unwrapHook(input.build?.nodejsCompatMode, config); + // Mirror the CLI: when the caller does not provide a mode (or a hook), + // derive it from the same effective values the CLI feeds + // validateNodeCompatMode — input-level overrides first, then the + // resolved config (the CLI passes `args.* ?? parsedConfig.*`; the + // programmatic spelling of no-bundle is `build.bundle: false`). + // Otherwise a dev worker's own `nodejs_compat` compatibility flag is + // silently ignored. An explicit null still disables. + const nodejsCompatMode = + input.build?.nodejsCompatMode === undefined + ? validateNodeCompatMode( + input.compatibilityDate ?? config.compatibility_date, + input.compatibilityFlags ?? config.compatibility_flags ?? [], + { noBundle: !(input.build?.bundle ?? !config.no_bundle) } + ) + : unwrapHook(input.build.nodejsCompatMode, config); const { bindings, unsafe, printCurrentBindings } = await resolveBindings( config, diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 5fae00af09..ef80312ec4 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -1,5 +1,8 @@ import assert from "node:assert"; -import { MissingConfigError } from "@cloudflare/workers-utils"; +import { + MissingConfigError, + retryOnAPIFailure, +} from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex, type Miniflare } from "miniflare"; import { WebSocket } from "ws"; @@ -18,7 +21,6 @@ import { logger } from "../../logger"; import { TRACE_VERSION } from "../../tail/createTail"; import { realishPrintLogs } from "../../tail/printing"; import { getAccessHeaders } from "../../user/access"; -import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; @@ -76,6 +78,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#abortController.signal, props.name ), + logger, undefined, undefined, this.#abortController.signal @@ -175,6 +178,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#abortController.signal, props.minimal_mode ), + logger, undefined, undefined, this.#abortController.signal diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index b1ff6606d0..5e1c0f2cd2 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { generateContainerBuildId } from "@cloudflare/containers-shared"; import { convertConfigToBindings } from "@cloudflare/deploy-helpers"; import { normalizeAndValidateConfig, @@ -50,6 +51,8 @@ import type { WorkflowIntrospector, } from "@cloudflare/workflows-shared/src/types"; import type { + DurableObjectStorageHandle, + DurableObjectStorageOptions, DispatchFetch, Json, Miniflare, @@ -197,6 +200,30 @@ export type WorkerHandle< * ``` */ applyD1Migrations(bindingName: BindingName): Promise; + /** + * Returns remote storage access for a Durable Object instance. + * Pass an exported Durable Object class name, or a Durable Object binding name. + * Class names are resolved before binding names. + * + * Use this to seed state before sending requests to the object, or to inspect + * state after awaited requests. Calling `exec()` runs SQL inside the target + * Durable Object and returns all rows. It may start the object if it is not + * already active. + * + * @example + * ```ts + * const sql = await worker.getDurableObjectStorage("COUNTER", { + * name: "user-123" + * }); + * const rows = await sql.exec("SELECT count FROM counters WHERE id = ?", "user-123"); + * ``` + */ + getDurableObjectStorage( + classNameOrBindingName: + | ExportName + | BindingName, + options: DurableObjectStorageOptions + ): Promise; /** * Creates an introspector for a specific Workflow instance. */ @@ -480,6 +507,7 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { bindings, dev: { auth: serverAuthHook, + containerBuildId: generateContainerBuildId(), server: { hostname: "127.0.0.1", port: 0 }, logLevel: "none", watch: false, @@ -1113,6 +1141,22 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { evictionOptions ); }, + async getDurableObjectStorage(classNameOrBindingName, storageOptions) { + const session = await resolveSession(); + const miniflare = await getRuntimeMiniflare(session); + const workerName = resolveWorkerName(session, name); + const { scriptName, className } = resolveDurableObjectTarget( + session, + workerName, + classNameOrBindingName + ); + + return miniflare.unsafeGetDurableObjectStorage( + scriptName, + className, + storageOptions + ); + }, async getEnv() { const session = await resolveSession(); const miniflare = await getRuntimeMiniflare(session); diff --git a/packages/wrangler/src/build/run-build-output.ts b/packages/wrangler/src/build/run-build-output.ts index 60d88cd358..b782825ace 100644 --- a/packages/wrangler/src/build/run-build-output.ts +++ b/packages/wrangler/src/build/run-build-output.ts @@ -15,9 +15,10 @@ import type { WorkerBuildResult } from "@cloudflare/deploy-helpers"; export async function runBuildOutput(buildArgs: { env?: string; }): Promise { - const { config, parsedWorkerConfig } = await readNewConfig({ - env: buildArgs.env, - }); + const { config, parsedWorkerConfig, parsedSettingsConfig } = + await readNewConfig({ + env: buildArgs.env, + }); const { buildProps, assetsOptions } = await mergeBuildOutputProps(config); const root = process.cwd(); @@ -30,6 +31,7 @@ export async function runBuildOutput(buildArgs: { await writeBuildOutput({ root, parsedWorkerConfig, + parsedSettingsConfig, buildResult, assetsOptions, }); diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index 2bfea9d54b..72e94aea16 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -16,7 +16,10 @@ import { logger } from "../logger"; import { EXIT_CODE_INVALID_PAGES_CONFIG } from "../pages/errors"; import { updateCheck } from "../update-check"; import type { NormalizedTypes } from "../experimental-config/load"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { + ParsedInputWorkerConfig, + ParsedSettingsConfig, +} from "@cloudflare/config"; import type { Config, ConfigBindingOptions, @@ -72,6 +75,7 @@ async function logWarningsWithUpgradeHint( export interface NewConfig { config: Config; parsedWorkerConfig: ParsedInputWorkerConfig; + parsedSettingsConfig: ParsedSettingsConfig | undefined; dependencies: Set; types: NormalizedTypes; } @@ -128,6 +132,7 @@ export async function readNewConfig( return { config, parsedWorkerConfig: loaded.parsedWorkerConfig, + parsedSettingsConfig: loaded.parsedSettingsConfig, dependencies: loaded.dependencies, types: loaded.types, }; diff --git a/packages/wrangler/src/deployment-bundle/build-output.ts b/packages/wrangler/src/deployment-bundle/build-output.ts index c7466edf90..97fd1e22e8 100644 --- a/packages/wrangler/src/deployment-bundle/build-output.ts +++ b/packages/wrangler/src/deployment-bundle/build-output.ts @@ -5,12 +5,14 @@ import { getWorkerAssetsDir, getWorkerBundleDir, writeOutputWorkerConfig, + writeRootOutputConfig, } from "@cloudflare/config"; import { UserError } from "@cloudflare/workers-utils"; import type { ModuleType, ParsedInputWorkerConfig, ParsedOutputWorkerConfig, + ParsedSettingsConfig, } from "@cloudflare/config"; import type { WorkerBuildResult } from "@cloudflare/deploy-helpers"; import type { AssetsOptions, CfModuleType } from "@cloudflare/workers-utils"; @@ -18,6 +20,7 @@ import type { AssetsOptions, CfModuleType } from "@cloudflare/workers-utils"; interface WriteBuildOutputArgs { root: string; parsedWorkerConfig: ParsedInputWorkerConfig; + parsedSettingsConfig: ParsedSettingsConfig | undefined; buildResult: WorkerBuildResult | undefined; assetsOptions: AssetsOptions | undefined; } @@ -29,6 +32,7 @@ interface WriteBuildOutputArgs { export async function writeBuildOutput({ root, parsedWorkerConfig, + parsedSettingsConfig, buildResult, assetsOptions, }: WriteBuildOutputArgs): Promise { @@ -58,6 +62,10 @@ export async function writeBuildOutput({ ]); await writeOutputWorkerConfig(root, parsedWorkerConfig, manifest); + + if (parsedSettingsConfig !== undefined) { + await writeRootOutputConfig(root, parsedSettingsConfig); + } } async function writeBundle({ diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 042dfb872b..3a47a5bac8 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -4,6 +4,8 @@ import { convertConfigToBindings } from "@cloudflare/deploy-helpers"; import { configFileName, formatConfigSnippet, + getLocalExplorerEnabledFromEnv, + isInteractive, UserError, } from "@cloudflare/workers-utils"; import { getHostFromRoute } from "@cloudflare/workers-utils"; @@ -15,6 +17,7 @@ import { getVarsForDev } from "./dev/dev-vars"; import { startDev } from "./dev/start-dev"; import { experimentalNewConfigArg } from "./experimental-config/cli-flag"; import { logger } from "./logger"; +import { detectAgent } from "./utils/detect-agent"; import type { StartDevWorkerInput, Trigger } from "./api"; import type { EnablePagesAssetsServiceBindingOptions } from "./miniflare-cli/types"; import type { @@ -299,7 +302,17 @@ export const dev = createCommand({ } }, async handler(args) { - const devInstance = await startDev(args); + const interactiveDevSession = + isInteractive() && args.showInteractiveDevSession !== false; + const showLocalExplorerAgentHint = + !interactiveDevSession && + !args.remote && + getLocalExplorerEnabledFromEnv() && + detectAgent().isAgent; + const devInstance = await startDev({ + ...args, + showLocalExplorerAgentHint, + }); assert(devInstance.devEnv !== undefined); await events.once(devInstance.devEnv, "teardown"); await Promise.all(devInstance.secondary.map((d) => d.teardown())); @@ -358,6 +371,7 @@ export type AdditionalDevProps = { moduleRoot?: string; rules?: Rule[]; showInteractiveDevSession?: boolean; + showLocalExplorerAgentHint?: boolean; }; type DevArguments = Omit<(typeof dev)["args"], "installSkills" | "profile">; diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 923a1cb722..ff4be0e2ac 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -283,11 +283,19 @@ function queueProducerEntry( remoteProxyConnectionString?: RemoteProxyConnectionString; }, ] { + const concreteQueueName = getRemoteId(queueName) ?? binding; if (!remoteProxyConnectionString || !remote) { - return [binding, { queueName, deliveryDelay }]; + return [binding, { queueName: concreteQueueName, deliveryDelay }]; } - return [binding, { queueName, deliveryDelay, remoteProxyConnectionString }]; + return [ + binding, + { + queueName: concreteQueueName, + deliveryDelay, + remoteProxyConnectionString, + }, + ]; } function pipelineEntry( { binding, stream, pipeline, remote }: CfPipeline, @@ -404,10 +412,14 @@ function dispatchNamespaceEntry( remoteProxyConnectionString?: RemoteProxyConnectionString; }, ] { + const concreteNamespace = getRemoteId(namespace) ?? binding; if (!remoteProxyConnectionString || !remote) { - return [binding, { namespace }]; + return [binding, { namespace: concreteNamespace }]; } - return [binding, { namespace, remoteProxyConnectionString }]; + return [ + binding, + { namespace: concreteNamespace, remoteProxyConnectionString }, + ]; } function ratelimitEntry( ratelimit: T @@ -916,7 +928,7 @@ export function buildMiniflareBindingOptions( flagshipBindings.map((binding) => [ binding.binding, { - app_id: binding.app_id, + app_id: getRemoteId(binding.app_id) ?? binding.binding, remoteProxyConnectionString, }, ]) @@ -1164,6 +1176,7 @@ export async function buildMiniflareOptions( unsafeProxySharedSecret: proxyToUserWorkerAuthenticationSecret, unsafeTriggerHandlers: true, unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), + unsafeInspectDurableObjects: true, telemetry: getMetricsConfig({ sendMetrics: config.sendMetrics }), // The way we run Miniflare instances with wrangler dev is that there are two: // - one holding the proxy worker, diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 320188495f..f7a27d6187 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -2,8 +2,8 @@ import assert from "node:assert"; import path from "node:path"; import { bold, green } from "@cloudflare/cli-shared-helpers/colors"; import { generateContainerBuildId } from "@cloudflare/containers-shared"; -import { getRegistryPath } from "@cloudflare/workers-utils"; -import { isInteractive } from "@cloudflare/workers-utils"; +import { getRegistryPath, isInteractive } from "@cloudflare/workers-utils"; +import { CorePaths } from "miniflare"; import dedent from "ts-dedent"; import { DevEnv } from "../api"; import { convertStartDevOptionsToBindings } from "../api/startDevWorker/binding-utils"; @@ -123,7 +123,10 @@ export async function startDev(args: StartDevOptions) { tunnelManager?.getTunnel()?.dispose(); }); - if (isInteractive() && args.showInteractiveDevSession !== false) { + const interactiveDevSession = + isInteractive() && args.showInteractiveDevSession !== false; + + if (interactiveDevSession) { unregisterHotKeys = registerDevHotKeys(devEnvs, args, { tunnelManager }); } @@ -155,6 +158,9 @@ export async function startDev(args: StartDevOptions) { false ); maybePrintScheduledWorkerWarning(hasCrons, !!args.testScheduled, url); + if (args.showLocalExplorerAgentHint) { + printLocalExplorerAgentHint(url); + } }); // Start tunnel early, before the proxy is ready. @@ -361,6 +367,23 @@ function maybePrintScheduledWorkerWarning( ); } +function printLocalExplorerAgentHint(url: URL): void { + const displayUrl = new URL(url.href); + displayUrl.hostname = formatHostname(url.hostname); + const explorerApiUrl = new URL(`${CorePaths.EXPLORER}/api`, displayUrl).href; + logger.once.log(dedent` + Wrangler detected this dev session is running in an AI agent. + The Local Explorer API is available at ${explorerApiUrl} + Useful routes: + GET ${explorerApiUrl} - OpenAPI schema + GET ${explorerApiUrl}/d1/database - D1 databases + GET ${explorerApiUrl}/local/workers - local Workers and bindings + GET ${explorerApiUrl}/r2/buckets - R2 buckets + GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces + GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces + GET ${explorerApiUrl}/workflows - Workflows`); +} + export function formatHostname(hostname: string): string { if (hostname === "0.0.0.0" || hostname === "::" || hostname === "*") { return "localhost"; diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index c455e4b008..2292e8ac00 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -1,7 +1,7 @@ -import { UserError } from "@cloudflare/workers-utils"; +import { retryOnAPIFailure, UserError } from "@cloudflare/workers-utils"; import { fetchListResult, fetchResult } from "../cfetch"; +import { logger } from "../logger"; import { requireAuth } from "../user"; -import { retryOnAPIFailure } from "../utils/retry"; import { listEmailSendingSubdomains } from "./client"; import type { EmailSendingSubdomain } from "./index"; import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; @@ -29,16 +29,18 @@ async function getZoneIdByDomain( domain: string, accountId: string ): Promise { - const zones = await retryOnAPIFailure(() => - fetchListResult<{ id: string }>( - complianceConfig, - `/zones`, - {}, - new URLSearchParams({ - name: domain, - "account.id": accountId, - }) - ) + const zones = await retryOnAPIFailure( + () => + fetchListResult<{ id: string }>( + complianceConfig, + `/zones`, + {}, + new URLSearchParams({ + name: domain, + "account.id": accountId, + }) + ), + logger ); const zoneId = zones[0]?.id; @@ -67,8 +69,10 @@ export async function resolveDomain( // If zone ID is provided directly, fetch the zone name to determine subdomain status if (zoneId) { await requireAuth(config); - const zone = await retryOnAPIFailure(() => - fetchResult<{ id: string; name: string }>(config, `/zones/${zoneId}`) + const zone = await retryOnAPIFailure( + () => + fetchResult<{ id: string; name: string }>(config, `/zones/${zoneId}`), + logger ); return { zoneId, @@ -84,16 +88,18 @@ export async function resolveDomain( const labels = domain.split("."); for (let i = 0; i <= labels.length - 2; i++) { const candidate = labels.slice(i).join("."); - const zones = await retryOnAPIFailure(() => - fetchListResult<{ id: string; name: string }>( - config, - `/zones`, - {}, - new URLSearchParams({ - name: candidate, - "account.id": accountId, - }) - ) + const zones = await retryOnAPIFailure( + () => + fetchListResult<{ id: string; name: string }>( + config, + `/zones`, + {}, + new URLSearchParams({ + name: candidate, + "account.id": accountId, + }) + ), + logger ); if (zones[0]) { return { diff --git a/packages/wrangler/src/experimental-config/load.ts b/packages/wrangler/src/experimental-config/load.ts index 45768e472e..7125a07250 100644 --- a/packages/wrangler/src/experimental-config/load.ts +++ b/packages/wrangler/src/experimental-config/load.ts @@ -1,10 +1,9 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { - InputWorkerSchema, convertToWranglerConfig, + loadAndValidateConfig, loadConfig, - resolveWorkerDefinition, } from "@cloudflare/config"; import { getCloudflareEnv, UserError } from "@cloudflare/workers-utils"; import { convertToolingConfig } from "./convert"; @@ -15,7 +14,10 @@ import { } from "./schema"; import { resolveWranglerConfig } from "./wrangler-definition"; import type { ParsedWranglerConfig } from "./schema"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { + ParsedInputWorkerConfig, + ParsedSettingsConfig, +} from "@cloudflare/config"; import type { RawConfig } from "@cloudflare/workers-utils"; export const CLOUDFLARE_CONFIG_FILENAME = "cloudflare.config.ts"; @@ -29,8 +31,10 @@ export interface NormalizedTypes { export interface LoadNewConfigResult { /** Merged result: `cloudflare.config.ts` runtime + `wrangler.config.ts` tooling. */ rawConfig: Omit; - /** The validated `cloudflare.config.ts` shape. */ + /** The validated `cloudflare.config.ts` worker shape (default export). */ parsedWorkerConfig: ParsedInputWorkerConfig; + /** The validated `settings` export, if present. */ + parsedSettingsConfig: ParsedSettingsConfig | undefined; /** Resolved absolute path to `cloudflare.config.ts`. */ cloudflareConfigPath: string; /** Resolved absolute path to `wrangler.config.ts`, if present. */ @@ -70,25 +74,38 @@ export async function loadNewConfig(options: { const mode = options.args.env ?? getCloudflareEnv(); - // ── Worker config ─────────────────────────────────────────────────── - const workerConfigResult = await loadConfig(cloudflareConfigPath); - - const resolvedWorkerConfig = await resolveWorkerDefinition( - workerConfigResult.config, - { mode } - ); + // ── Worker + settings config ──────────────────────────────────────── + const workerConfigResult = await loadAndValidateConfig(cloudflareConfigPath, { + mode, + }); - const parsedWorkerConfig = InputWorkerSchema.safeParse(resolvedWorkerConfig); - if (!parsedWorkerConfig.success) { + if (!workerConfigResult.result.success) { throw new UserError( - `Invalid \`${CLOUDFLARE_CONFIG_FILENAME}\`:\n${formatZodError(parsedWorkerConfig.error)}`, + `Invalid \`${CLOUDFLARE_CONFIG_FILENAME}\`:\n${formatZodError(workerConfigResult.result.error)}`, { telemetryMessage: "new-config worker validation failed" } ); } + const worker = + workerConfigResult.result.data.default?.type === "worker" + ? workerConfigResult.result.data.default + : undefined; + + if (worker === undefined) { + throw new UserError( + `\`${CLOUDFLARE_CONFIG_FILENAME}\` must have a default worker export.`, + { telemetryMessage: "new-config worker default export missing" } + ); + } + + const settings = + workerConfigResult.result.data.settings?.type === "settings" + ? workerConfigResult.result.data.settings + : undefined; + // ── Wrangler (tooling) config ─────────────────────────────────────── let wranglerConfigResult: - | { config: unknown; dependencies: Set } + | { exports: Record; dependencies: Set } | undefined; let parsedWranglerConfig: { data: ParsedWranglerConfig } | undefined; @@ -96,7 +113,7 @@ export async function loadNewConfig(options: { wranglerConfigResult = await loadConfig(wranglerConfigPath); const resolvedWranglerConfig = await resolveWranglerConfig( - wranglerConfigResult.config, + wranglerConfigResult.exports.default, { mode } ); @@ -111,7 +128,7 @@ export async function loadNewConfig(options: { } // ── Conversion + merge ────────────────────────────────────────────── - const rawWorkerConfig = convertToWranglerConfig(parsedWorkerConfig.data); + const rawWorkerConfig: RawConfig = convertToWranglerConfig(worker, settings); const rawWranglerConfig = convertToolingConfig( parsedWranglerConfig?.data ?? {} @@ -136,7 +153,8 @@ export async function loadNewConfig(options: { return { rawConfig, - parsedWorkerConfig: parsedWorkerConfig.data, + parsedWorkerConfig: worker, + parsedSettingsConfig: settings, cloudflareConfigPath, wranglerConfigPath, dependencies, diff --git a/packages/wrangler/src/package-manager.ts b/packages/wrangler/src/package-manager.ts index 1c9c7955d7..1afbb187ef 100644 --- a/packages/wrangler/src/package-manager.ts +++ b/packages/wrangler/src/package-manager.ts @@ -10,6 +10,7 @@ export { PnpmPackageManager, YarnPackageManager, BunPackageManager, + NubPackageManager, } from "@cloudflare/workers-utils"; import { @@ -17,15 +18,17 @@ import { PnpmPackageManager, YarnPackageManager, BunPackageManager, + NubPackageManager, } from "@cloudflare/workers-utils"; import type { PackageManager } from "@cloudflare/workers-utils"; export async function getPackageManager(): Promise { - const [hasYarn, hasNpm, hasPnpm, hasBun] = await Promise.all([ + const [hasYarn, hasNpm, hasPnpm, hasBun, hasNub] = await Promise.all([ supportsYarn(), supportsNpm(), supportsPnpm(), supportsBun(), + supportsNub(), ]); const userAgent = sniffUserAgent(); @@ -43,6 +46,9 @@ export async function getPackageManager(): Promise { } else if (userAgent === "bun" && hasBun) { logger.debug("Using bun as package manager."); return { ...BunPackageManager }; + } else if (userAgent === "nub" && hasNub) { + logger.debug("Using nub as package manager."); + return { ...NubPackageManager }; } // lastly, check what's installed @@ -58,9 +64,12 @@ export async function getPackageManager(): Promise { } else if (hasBun) { logger.debug("Using bun as package manager."); return { ...BunPackageManager }; + } else if (hasNub) { + logger.debug("Using nub as package manager."); + return { ...NubPackageManager }; } else { throw new UserError( - "Unable to find a package manager. Supported managers are: npm, yarn, pnpm, and bun.", + "Unable to find a package manager. Supported managers are: npm, yarn, pnpm, bun, and nub.", { telemetryMessage: "package manager detection missing manager", } @@ -100,6 +109,10 @@ function supportsBun(): Promise { return supports("bun"); } +function supportsNub(): Promise { + return supports("nub"); +} + /** * The environment variable `npm_config_user_agent` can be used to * guess the package manager that was used to execute wrangler. @@ -111,7 +124,13 @@ function supportsBun(): Promise { * - [yarn](https://yarnpkg.com/advanced/lifecycle-scripts#environment-variables) * - [bun](https://github.com/oven-sh/bun/blob/550522e99b303d8172b7b16c5750d458cb056434/src/Global.zig#L205) */ -export function sniffUserAgent(): "npm" | "pnpm" | "yarn" | "bun" | undefined { +export function sniffUserAgent(): + | "npm" + | "pnpm" + | "yarn" + | "bun" + | "nub" + | undefined { const userAgent = env.npm_config_user_agent; if (userAgent === undefined) { return undefined; @@ -129,6 +148,11 @@ export function sniffUserAgent(): "npm" | "pnpm" | "yarn" | "bun" | undefined { return "bun"; } + // nub's user agent contains "npm" (e.g. `nub/0.4.5 npm/? …`), so check it before npm. + if (userAgent.includes("nub")) { + return "nub"; + } + // npm should come last as it is included in the user agent strings of other package managers if (userAgent.includes("npm")) { return "npm"; diff --git a/packages/wrangler/src/queues/cli/commands/subscription/create.ts b/packages/wrangler/src/queues/cli/commands/subscription/create.ts index c3beb3cae1..23d18b99a8 100644 --- a/packages/wrangler/src/queues/cli/commands/subscription/create.ts +++ b/packages/wrangler/src/queues/cli/commands/subscription/create.ts @@ -20,6 +20,12 @@ function parseSourceArgument( } ): EventSource { switch (source as EventSourceType) { + case EventSourceType.ARTIFACTS: + return { type: EventSourceType.ARTIFACTS }; + + case EventSourceType.ARTIFACTS_REPO: + return { type: EventSourceType.ARTIFACTS_REPO }; + case EventSourceType.IMAGES: return { type: EventSourceType.IMAGES }; diff --git a/packages/wrangler/src/queues/client.ts b/packages/wrangler/src/queues/client.ts index c18ccd46b6..f2a9d722d7 100644 --- a/packages/wrangler/src/queues/client.ts +++ b/packages/wrangler/src/queues/client.ts @@ -124,8 +124,8 @@ export async function getQueue( } export async function ensureQueuesExistByConfig(config: Config) { - const producers = (config.queues.producers || []).map( - (producer) => producer.queue + const producers = (config.queues.producers || []).flatMap((producer) => + producer.queue ? [producer.queue] : [] ); const consumers = (config.queues.consumers || []).map( (consumer) => consumer.queue diff --git a/packages/wrangler/src/queues/subscription-types.ts b/packages/wrangler/src/queues/subscription-types.ts index bca8648b9e..8e53eab710 100644 --- a/packages/wrangler/src/queues/subscription-types.ts +++ b/packages/wrangler/src/queues/subscription-types.ts @@ -15,6 +15,8 @@ export interface EventDestination { } export enum EventSourceType { + ARTIFACTS = "artifacts", + ARTIFACTS_REPO = "artifacts.repo", IMAGES = "images", KV = "kv", R2 = "r2", @@ -28,6 +30,8 @@ export enum EventSourceType { export const EVENT_SOURCE_TYPES = Object.values(EventSourceType); export type EventSource = + | ArtifactsEventSource + | ArtifactsRepoEventSource | ImagesEventSource | KvEventSource | R2EventSource @@ -37,6 +41,14 @@ export type EventSource = | WorkersBuildsWorkerEventSource | WorkflowsWorkflowEventSource; +export interface ArtifactsEventSource { + type: EventSourceType.ARTIFACTS; +} + +export interface ArtifactsRepoEventSource { + type: EventSourceType.ARTIFACTS_REPO; +} + export interface ImagesEventSource { type: EventSourceType.IMAGES; } diff --git a/packages/wrangler/src/sites.ts b/packages/wrangler/src/sites.ts index c109fdd7a0..1811072853 100644 --- a/packages/wrangler/src/sites.ts +++ b/packages/wrangler/src/sites.ts @@ -430,7 +430,7 @@ async function validateAssetSize( function validateAssetKey(assetKey: string) { if (assetKey.length > 512) { throw new UserError( - `The asset path key "${assetKey}" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits",`, + `The asset path key "${assetKey}" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits`, { telemetryMessage: "sites asset key too long" } ); } diff --git a/packages/wrangler/src/utils/retry.ts b/packages/wrangler/src/utils/retry.ts deleted file mode 100644 index dfa2e1637a..0000000000 --- a/packages/wrangler/src/utils/retry.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { setTimeout } from "node:timers/promises"; -import { APIError } from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { logger } from "../logger"; - -const MAX_ATTEMPTS = 3; -/** - * Wrap around calls to the Cloudflare API to automatically retry - * calls that result in a 5xx error code, indicating an API failure. - * - * Retries will back off at a rate of 1000ms per retry, with a 0ms delay for the first retry - * - * Note: this will not retry 4xx or other failures, as those are - * likely legitimate user error. - */ -export async function retryOnAPIFailure( - action: () => T | Promise, - 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(chalk.dim(`Retrying API call after error...`)); - logger.debug(err); - - if (attempts <= 1) { - throw err; - } - - await setTimeout(backoff, undefined, { signal: abortSignal }); - return retryOnAPIFailure(action, backoff + 1000, attempts - 1, abortSignal); - } -} diff --git a/packages/wrangler/src/versions/secrets/bulk.ts b/packages/wrangler/src/versions/secrets/bulk.ts index acdec8180f..e992b0b88d 100644 --- a/packages/wrangler/src/versions/secrets/bulk.ts +++ b/packages/wrangler/src/versions/secrets/bulk.ts @@ -1,13 +1,11 @@ import { configFileName, UserError } from "@cloudflare/workers-utils"; -import { fetchResult } from "../../cfetch"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import * as metrics from "../../metrics"; import { parseBulkInputToObject } from "../../secret"; import { requireAuth } from "../../user"; import { getLegacyScriptName } from "../../utils/getLegacyScriptName"; -import { copyWorkerVersionWithNewSecrets } from "./index"; -import type { WorkerVersion } from "./index"; +import { patchLatestWorkerVersionWithSecrets } from "./index"; export const versionsSecretBulkCommand = createCommand({ metadata: { @@ -64,42 +62,24 @@ export const versionsSecretBulkCommand = createCommand({ return logger.error(`No content found in file or piped input.`); } - const { content, secretSource, secretFormat } = result; + const { content: secrets, secretSource, secretFormat } = result; - const secrets = Object.entries(content).map(([key, value]) => ({ - name: key, - value, - })); + const secretEntries = Object.entries(secrets); - // Grab the latest version - const versions = ( - await fetchResult<{ items: WorkerVersion[] }>( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions` - ) - ).items; - if (versions.length === 0) { - throw new UserError( - "There are currently no uploaded versions of this Worker - please upload a version before uploading a secret.", - { telemetryMessage: "versions secrets bulk no uploaded versions" } - ); - } - const latestVersion = versions[0]; - - const newVersion = await copyWorkerVersionWithNewSecrets({ + const newVersion = await patchLatestWorkerVersionWithSecrets({ config, accountId, scriptName, - versionId: latestVersion.id, secrets, - versionMessage: args.message ?? `Bulk updated ${secrets.length} secrets`, + versionMessage: + args.message ?? `Bulk updated ${secretEntries.length} secrets`, versionTag: args.tag, sendMetrics: config.send_metrics, - unsafeMetadata: config.unsafe.metadata, + noVersionsTelemetryMessage: "versions secrets bulk no uploaded versions", }); - for (const secret of secrets) { - logger.log(`✨ Successfully created secret for key: ${secret.name}`); + for (const [name] of secretEntries) { + logger.log(`✨ Successfully created secret for key: ${name}`); } metrics.sendMetricsEvent( @@ -116,7 +96,7 @@ export const versionsSecretBulkCommand = createCommand({ ); logger.log( - `✨ Success! Created version ${newVersion.id} with ${secrets.length} secrets.` + + `✨ Success! Created version ${newVersion.id} with ${secretEntries.length} secrets.` + `\n➡️ To deploy this version to production traffic use the command "wrangler versions deploy".` ); }, diff --git a/packages/wrangler/src/versions/secrets/delete.ts b/packages/wrangler/src/versions/secrets/delete.ts index 70b69daa50..da542083bb 100644 --- a/packages/wrangler/src/versions/secrets/delete.ts +++ b/packages/wrangler/src/versions/secrets/delete.ts @@ -1,12 +1,10 @@ import { configFileName, UserError } from "@cloudflare/workers-utils"; -import { fetchResult } from "../../cfetch"; import { createCommand } from "../../core/create-command"; import { confirm } from "../../dialogs"; import { logger } from "../../logger"; import { requireAuth } from "../../user"; import { getLegacyScriptName } from "../../utils/getLegacyScriptName"; -import { copyWorkerVersionWithNewSecrets } from "./index"; -import type { VersionDetails, WorkerVersion } from "./index"; +import { patchLatestWorkerVersionWithSecrets } from "./index"; export const versionsSecretDeleteCommand = createCommand({ metadata: { @@ -63,57 +61,27 @@ export const versionsSecretDeleteCommand = createCommand({ if ( await confirm( - `Are you sure you want to permanently delete the secret ${args.key} on the Worker ${scriptName}?` + `Are you sure you want to permanently delete the secret ${ + args.key + } on the Worker ${scriptName}${args.env ? ` (${args.env})` : ""}?` ) ) { logger.log( - `🌀 Deleting the secret ${args.key} on the Worker ${scriptName}` + `🌀 Deleting the secret ${args.key} on the Worker ${scriptName}${ + args.env ? ` (${args.env})` : "" + }` ); - // Grab the latest version - const versions = ( - await fetchResult<{ items: WorkerVersion[] }>( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions` - ) - ).items; - if (versions.length === 0) { - throw new UserError( - "There are currently no uploaded versions of this Worker - please upload a version before uploading a secret.", - { - telemetryMessage: "versions secrets delete no uploaded versions", - } - ); - } - const latestVersion = versions[0]; - - const versionInfo = await fetchResult( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions/${latestVersion.id}` - ); - - // Go through all - const newSecrets = versionInfo.resources.bindings - .filter( - (binding) => - binding.type === "secret_text" && binding.name !== args.key - ) - .map((binding) => ({ - name: binding.name, - value: "", - inherit: true, - })); - - const newVersion = await copyWorkerVersionWithNewSecrets({ + const newVersion = await patchLatestWorkerVersionWithSecrets({ config, accountId, scriptName, - versionId: latestVersion.id, - secrets: newSecrets, + secrets: { [args.key]: null }, versionMessage: args.message ?? `Deleted secret "${args.key}"`, versionTag: args.tag, sendMetrics: config.send_metrics, - overrideAllSecrets: true, + noVersionsTelemetryMessage: + "versions secrets delete no uploaded versions", }); logger.log( diff --git a/packages/wrangler/src/versions/secrets/index.ts b/packages/wrangler/src/versions/secrets/index.ts index 4c02e0a996..b347ab52e5 100644 --- a/packages/wrangler/src/versions/secrets/index.ts +++ b/packages/wrangler/src/versions/secrets/index.ts @@ -1,26 +1,13 @@ -import { FatalError, UserError } from "@cloudflare/workers-utils"; +import { APIError, UserError } from "@cloudflare/workers-utils"; import { fetchResult } from "../../cfetch"; -import { performApiFetch } from "../../cfetch/internal"; import { createNamespace } from "../../core/create-command"; -import { - createWorkerUploadForm, - fromMimeType, -} from "../../deployment-bundle/create-worker-upload-form"; import { getMetricsUsageHeaders } from "../../metrics"; -import type { StartDevWorkerOptions } from "../../api"; import type { - CfModule, CfPlacement, - CfTailConsumer, CfUserLimits, - CfWorkerInit, - WorkerMetadata as CfWorkerMetadata, - CfWorkerSourceMap, Config, - Observability, WorkerMetadataBinding, } from "@cloudflare/workers-utils"; -import type { SpecIterableIterator } from "undici"; export const versionsSecretNamespace = createNamespace({ metadata: { @@ -75,259 +62,67 @@ export interface VersionDetails { cache_options?: { enabled: boolean }; } -interface ScriptSettings { - logpush: boolean; - tail_consumers: CfTailConsumer[] | null; - observability: Observability; -} - -type CfUnsafeMetadata = Record; +const NO_VERSIONS_ERR_CODE = 10222; +const NO_VERSIONS_MESSAGE = + "There are currently no uploaded versions of this Worker. Please upload a version before modifying a secret."; -interface CopyLatestWorkerVersionArgs { +interface PatchLatestWorkerVersionWithSecretsArgs { config: Config; accountId: string; scriptName: string; - versionId: string; - secrets: { name: string; value: string; inherit?: boolean }[]; + secrets: Record; versionMessage?: string; versionTag?: string; sendMetrics?: boolean; - overrideAllSecrets?: boolean; // Used for delete - this will make sure we do not inherit any - unsafeMetadata?: CfUnsafeMetadata | undefined; + noVersionsTelemetryMessage: string; } -// TODO: This is a naive implementation, replace later -export async function copyWorkerVersionWithNewSecrets({ +export async function patchLatestWorkerVersionWithSecrets({ config, accountId, scriptName, - versionId, secrets, versionMessage, versionTag, sendMetrics, - unsafeMetadata, - overrideAllSecrets, -}: CopyLatestWorkerVersionArgs) { - // Grab the specific version info - const versionInfo = await fetchResult( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions/${versionId}` - ); - - // Naive implementation ahead, don't worry too much about it -- we will replace it - const { mainModule, modules, sourceMaps } = await parseModules( - config, - accountId, - scriptName, - versionId - ); - - // Grab the script settings - const scriptSettings = await fetchResult( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/script-settings` - ); - - const bindings: StartDevWorkerOptions["bindings"] = Object.fromEntries( - versionInfo.resources.bindings - // Filter out secrets because they're handled separately - .filter((binding) => binding.type !== "secret_text") - .map((binding) => { - return [ - binding.name, - { - // Inherit all of the existing bindings. This will be sent to the API as "type": "inherit" - // We inherit rather than just sending the actual bindings to reduce the risk of - // something going wrong in the round trip from the API to Wrangler and back - type: "inherit", - }, - ]; - }) - ); - - // Add the new secrets - for (const secret of secrets) { - if (secret.inherit) { - bindings[secret.name] = { - type: "inherit", - }; - } else { - bindings[secret.name] = { - type: "secret_text", - value: secret.value, - }; - } - } - - // We don't ever want to remove secret_key - const keepBindings: CfWorkerMetadata["keep_bindings"] = ["secret_key"]; - // If we aren't overriding all secrets then inherit them - if (!overrideAllSecrets) { - keepBindings.push("secret_text"); - } - - const worker: Omit = { - name: scriptName, - main: mainModule, - modules, - containers: config.containers, - sourceMaps: sourceMaps, - migrations: undefined, - exports: undefined, - compatibility_date: versionInfo.resources.script_runtime.compatibility_date, - compatibility_flags: - versionInfo.resources.script_runtime.compatibility_flags, - keepVars: false, // we're re-uploading everything - keepSecrets: false, // handled in keepBindings - keepBindings, - logpush: scriptSettings.logpush, - placement: - versionInfo.resources.script.placement ?? - (versionInfo.resources.script.placement_mode === "smart" - ? { mode: "smart" } - : undefined), - tail_consumers: scriptSettings.tail_consumers ?? undefined, - limits: versionInfo.resources.script_runtime.limits, - annotations: { - "workers/message": versionMessage, - "workers/tag": versionTag, - }, - keep_assets: true, - assets: undefined, - observability: scriptSettings.observability, - cache: versionInfo.cache_options, - }; - - const body = createWorkerUploadForm(worker, bindings, { - unsafe: { metadata: unsafeMetadata }, - }); - const result = await fetchResult<{ - available_on_subdomain: boolean; - id: string | null; - etag: string | null; - deployment_id: string | null; - }>( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions`, - { - method: "POST", - body, - headers: await getMetricsUsageHeaders(sendMetrics), - }, - new URLSearchParams({ - include_subdomain_availability: "true", - // pass excludeScript so the whole body of the - // script doesn't get included in the response - excludeScript: "true", - }) - ); - - return result; -} - -async function parseModules( - config: Config, - accountId: string, - scriptName: string, - versionId: string -): Promise<{ - mainModule: CfModule; - modules: CfModule[]; - sourceMaps: CfWorkerSourceMap[]; -}> { - // Pull the Worker content - https://developers.cloudflare.com/api/operations/worker-script-get-content - const contentRes = await performApiFetch( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/content/v2?version=${versionId}` - ); - if ( - contentRes.headers.get("content-type")?.startsWith("multipart/form-data") - ) { - // eslint-disable-next-line @typescript-eslint/no-deprecated -- formData() is the standard Web API; only deprecated on undici's server-side types - const formData = await contentRes.formData(); - - // Workers Sites is not supported - if (formData.get("__STATIC_CONTENT_MANIFEST") !== null) { - throw new UserError( - "Workers Sites does not support updating secrets through `wrangler versions secret put`. You must use `wrangler secret put` instead.", - { telemetryMessage: "versions secrets sites unsupported" } - ); - } - - // Load the main module and any additionals - const entrypoint = contentRes.headers.get("cf-entrypoint"); - if (entrypoint === null) { - throw new FatalError("Got modules without cf-entrypoint header", { - telemetryMessage: "versions secrets modules missing entrypoint header", - }); - } - - const entrypointPart = formData.get(entrypoint) as File | null; - if (entrypointPart === null) { - throw new FatalError("Could not find entrypoint in form-data", { - telemetryMessage: "versions secrets modules missing entrypoint part", - }); - } - - const mainModule: CfModule = { - name: entrypointPart.name, - filePath: "", - content: Buffer.from(await entrypointPart.arrayBuffer()), - type: fromMimeType(entrypointPart.type), - }; - - // Load all modules that are not the entrypoint or sourcemaps - const modules = await Promise.all( - Array.from(formData.entries() as SpecIterableIterator<[string, File]>) - .filter( - ([name, file]) => - name !== entrypoint && file.type !== "application/source-map" - ) - .map( - async ([name, file]) => - ({ + noVersionsTelemetryMessage, +}: PatchLatestWorkerVersionWithSecretsArgs) { + try { + return await fetchResult<{ id: string | null }>( + config, + `/accounts/${accountId}/workers/workers/${scriptName}/versions/latest`, + { + method: "PATCH", + body: JSON.stringify({ + env: Object.fromEntries( + Object.entries(secrets).map(([name, value]) => [ name, - filePath: "", - content: Buffer.from(await file.arrayBuffer()), - type: fromMimeType(file.type), - }) as CfModule - ) - ); - - // Load sourcemaps - const sourceMaps = await Promise.all( - Array.from(formData.entries() as SpecIterableIterator<[string, File]>) - .filter(([_, file]) => file.type === "application/source-map") - .map( - async ([name, file]) => - ({ - name, - content: await file.text(), - }) as CfWorkerSourceMap - ) + value === null + ? null + : { + type: "secret_text", + text: value, + }, + ]) + ), + annotations: { + "workers/message": versionMessage, + "workers/tag": versionTag, + }, + }), + headers: { + ...(await getMetricsUsageHeaders(sendMetrics)), + "Content-Type": "application/merge-patch+json", + }, + } ); - - return { mainModule, modules, sourceMaps }; - } else { - const contentType = contentRes.headers.get("content-type"); - if (contentType === null) { - throw new FatalError( - "No content-type header was provided for non-module Worker content", - { telemetryMessage: "versions secrets content missing content type" } - ); + } catch (e) { + if (e instanceof APIError && e.code === NO_VERSIONS_ERR_CODE) { + throw new UserError(NO_VERSIONS_MESSAGE, { + telemetryMessage: noVersionsTelemetryMessage, + }); } - // good old Service Worker with no additional modules - const content = await contentRes.text(); - - const mainModule: CfModule = { - name: "index.js", - filePath: "", - content, - type: fromMimeType(contentType), - }; - - return { mainModule, modules: [], sourceMaps: [] }; + throw e; } } diff --git a/packages/wrangler/src/versions/secrets/put.ts b/packages/wrangler/src/versions/secrets/put.ts index aa2177393f..3085fd633c 100644 --- a/packages/wrangler/src/versions/secrets/put.ts +++ b/packages/wrangler/src/versions/secrets/put.ts @@ -1,5 +1,4 @@ import { configFileName, UserError } from "@cloudflare/workers-utils"; -import { fetchResult } from "../../cfetch"; import { createCommand } from "../../core/create-command"; import { prompt } from "../../dialogs"; import { logger } from "../../logger"; @@ -7,8 +6,7 @@ import * as metrics from "../../metrics"; import { requireAuth } from "../../user"; import { getLegacyScriptName } from "../../utils/getLegacyScriptName"; import { readFromStdin, trimTrailingWhitespace } from "../../utils/std"; -import { copyWorkerVersionWithNewSecrets } from "./index"; -import type { WorkerVersion } from "./index"; +import { patchLatestWorkerVersionWithSecrets } from "./index"; export const versionsSecretPutCommand = createCommand({ metadata: { @@ -74,31 +72,15 @@ export const versionsSecretPutCommand = createCommand({ `🌀 Creating the secret for the Worker "${scriptName}" ${args.env ? `(${args.env})` : ""}` ); - // Grab the latest version - const versions = ( - await fetchResult<{ items: WorkerVersion[] }>( - config, - `/accounts/${accountId}/workers/scripts/${scriptName}/versions` - ) - ).items; - if (versions.length === 0) { - throw new UserError( - "There are currently no uploaded versions of this Worker. Please upload a version before uploading a secret.", - { telemetryMessage: "versions secrets put no uploaded versions" } - ); - } - const latestVersion = versions[0]; - - const newVersion = await copyWorkerVersionWithNewSecrets({ + const newVersion = await patchLatestWorkerVersionWithSecrets({ config, accountId, scriptName, - versionId: latestVersion.id, - secrets: [{ name: args.key, value: secretValue }], + secrets: { [args.key]: secretValue }, versionMessage: args.message ?? `Updated secret "${args.key}"`, versionTag: args.tag, sendMetrics: config.send_metrics, - unsafeMetadata: config.unsafe.metadata, + noVersionsTelemetryMessage: "versions secrets put no uploaded versions", }); metrics.sendMetricsEvent( diff --git a/packages/wrangler/templates/middleware/middleware-miniflare3-json-error.ts b/packages/wrangler/templates/middleware/middleware-miniflare3-json-error.ts index b1850f814f..99596bec28 100644 --- a/packages/wrangler/templates/middleware/middleware-miniflare3-json-error.ts +++ b/packages/wrangler/templates/middleware/middleware-miniflare3-json-error.ts @@ -22,10 +22,20 @@ const jsonError: Middleware = async (request, env, _ctx, middlewareCtx) => { return await middlewareCtx.next(request, env); } catch (e: any) { const error = reduceError(e); - return Response.json(error, { - status: 500, - headers: { "MF-Experimental-Error-Stack": "true" }, - }); + const body = JSON.stringify(error); + const headers: Record = { + "Content-Type": "application/json", + "MF-Experimental-Error-Stack": "true", + }; + // `workerd` drops response bodies for `HEAD` requests, so also carry the + // serialised error in a header. Past roughly 16KB of encoded header the + // runtime drops the whole response, so stay well under that; the body + // remains the primary channel for every method that keeps one. + const encoded = encodeURIComponent(body); + if (encoded.length <= 8192) { + headers["MF-Experimental-Error-Stack-Payload"] = encoded; + } + return new Response(body, { status: 500, headers }); } }; diff --git a/packages/wrangler/templates/remoteBindings/wrangler.jsonc b/packages/wrangler/templates/remoteBindings/wrangler.jsonc deleted file mode 100644 index a9a5b5c8af..0000000000 --- a/packages/wrangler/templates/remoteBindings/wrangler.jsonc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "main": "./ProxyServerWorker.ts", - "compatibility_date": "2025-04-28", -} diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index fc28268c00..6e90b222fe 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -1,6 +1,6 @@ import { createDeferred, - DeferredPromise, + type DeferredPromise, rewriteUrlInHeaderValue, urlFromParts, } from "../../src/api/startDevWorker/utils"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff93b9b078..3753a1583c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,8 +10,8 @@ catalogs: specifier: 0.13.3 version: 0.13.3 '@cloudflare/workers-types': - specifier: ^5.20260714.1 - version: 5.20260714.1 + specifier: ^5.20260721.1 + version: 5.20260721.1 '@hey-api/openapi-ts': specifier: 0.94.0 version: 0.94.0 @@ -85,11 +85,14 @@ catalogs: specifier: 4.1.0 version: 4.1.0 workerd: - specifier: 1.20260714.1 - version: 1.20260714.1 + specifier: 1.20260721.1 + version: 1.20260721.1 ws: specifier: 8.21.0 version: 8.21.0 + zod: + specifier: 4.4.3 + version: 4.4.3 overrides: '@types/react-dom@18>@types/react': ^18 @@ -188,7 +191,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@fixture/shared': specifier: workspace:* version: link:../shared @@ -236,13 +239,19 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 + '@types/node': + specifier: 22.15.17 + version: 22.15.17 ts-dedent: specifier: ^2.2.0 version: 2.2.0 typescript: specifier: catalog:default version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) wrangler: specifier: workspace:* version: link:../../packages/wrangler @@ -257,7 +266,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@playwright/test': specifier: catalog:default version: 1.60.0 @@ -287,7 +296,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -308,7 +317,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -332,7 +341,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -368,7 +377,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 vitest: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.9.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) @@ -383,7 +392,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 undici: specifier: catalog:default version: 7.28.0 @@ -398,7 +407,7 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/mimetext': specifier: ^2.0.4 version: 2.0.4 @@ -437,7 +446,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@fixture/shared': specifier: workspace:* version: link:../shared @@ -461,7 +470,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/jest-image-snapshot': specifier: ^6.4.0 version: 6.4.0 @@ -488,7 +497,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 miniflare: specifier: workspace:* version: link:../../packages/miniflare @@ -558,7 +567,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/is-even': specifier: ^1.0.2 version: 1.0.2 @@ -576,11 +585,11 @@ importers: dependencies: '@sentry/cloudflare': specifier: ^10 - version: 10.50.0(@cloudflare/workers-types@5.20260714.1) + version: 10.50.0(@cloudflare/workers-types@5.20260721.1) devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 vitest: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.9.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) @@ -611,7 +620,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -639,7 +648,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/node': specifier: 22.15.17 version: 22.15.17 @@ -669,7 +678,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 undici: specifier: catalog:default version: 7.28.0 @@ -687,7 +696,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/debug': specifier: 4.1.12 version: 4.1.12 @@ -720,7 +729,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -745,7 +754,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@fixture/pages-plugin': specifier: workspace:* version: link:../pages-plugin-example @@ -769,7 +778,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -808,7 +817,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -829,7 +838,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -850,7 +859,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -868,7 +877,7 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 is-odd: specifier: ^3.0.1 version: 3.0.1 @@ -887,7 +896,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@fixture/pages-plugin': specifier: workspace:* version: link:../pages-plugin-example @@ -947,7 +956,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -968,7 +977,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1124,19 +1133,19 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 fixtures/rules-app: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 fixtures/secrets-store: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 wrangler: specifier: workspace:* version: link:../../packages/wrangler @@ -1163,7 +1172,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/is-even': specifier: ^1.0.2 version: 1.0.2 @@ -1187,7 +1196,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 vitest: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.9.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) @@ -1202,7 +1211,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 esbuild: specifier: catalog:default version: 0.28.1 @@ -1213,14 +1222,14 @@ importers: specifier: ^3.12.8 version: 3.12.10 zod: - specifier: 3.22.3 - version: 3.22.3 + specifier: catalog:default + version: 4.4.3 fixtures/vitest-pool-workers-examples: devDependencies: '@better-auth/stripe': specifier: ^1.4.6 - version: 1.5.4(f660eebfb07999a7aa04507f74bf93ad) + version: 1.5.4(6a5abfdb3bce0994f2cec622068dd503) '@cloudflare/containers': specifier: ^0.2.2 version: 0.2.2 @@ -1229,7 +1238,7 @@ importers: version: link:../../packages/vitest-pool-workers '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@microlabs/otel-cf-workers': specifier: 1.0.0-rc.45 version: 1.0.0-rc.45(@opentelemetry/api@1.9.1) @@ -1244,7 +1253,7 @@ importers: version: 3.2.6 better-auth: specifier: ^1.4.6 - version: 1.5.4(@cloudflare/workers-types@5.20260714.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))(mongodb@7.1.0)(mysql2@3.15.3)(pg@8.16.3)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0) + version: 1.5.4(@cloudflare/workers-types@5.20260721.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))(mongodb@7.1.0)(mysql2@3.15.3)(pg@8.16.3)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0) cjs-wasm-module-dep: specifier: file:./module-resolution/vendor/cjs-wasm-module-dep version: file:fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep @@ -1318,7 +1327,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@fixture/shared': specifier: workspace:* version: link:../shared @@ -1373,7 +1382,7 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 wrangler: specifier: workspace:* version: link:../../packages/wrangler @@ -1385,7 +1394,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 miniflare: specifier: workspace:* version: link:../../packages/miniflare @@ -1433,7 +1442,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 run-script-os: specifier: ^1.1.6 version: 1.1.6 @@ -1457,7 +1466,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1478,7 +1487,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1499,7 +1508,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1520,7 +1529,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1541,7 +1550,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/jest-image-snapshot': specifier: ^6.4.0 version: 6.4.0 @@ -1574,7 +1583,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/node': specifier: 22.15.17 version: 22.15.17 @@ -1601,7 +1610,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1619,7 +1628,7 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -1758,7 +1767,7 @@ importers: packages/config: dependencies: zod: - specifier: 4.4.3 + specifier: catalog:default version: 4.4.3 devDependencies: '@cloudflare/workers-tsconfig': @@ -1766,7 +1775,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -1829,7 +1838,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2032,15 +2041,12 @@ importers: vitest: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) - zod: - specifier: ^4.4.3 - version: 4.4.3 packages/devprod-status-bot: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@octokit/types': specifier: ^13.8.0 version: 13.8.0 @@ -2061,7 +2067,7 @@ importers: version: link:../vitest-pool-workers '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2085,7 +2091,7 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2105,17 +2111,17 @@ importers: specifier: workspace:* version: link:../wrangler zod: - specifier: ^3.22.3 - version: 3.22.3 + specifier: catalog:default + version: 4.4.3 packages/kv-asset-handler: devDependencies: '@cloudflare/vitest-pool-workers': specifier: catalog:default - version: 0.13.3(@cloudflare/workers-types@5.20260714.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) + version: 0.13.3(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/mime': specifier: ^3.0.4 version: 3.0.4 @@ -2266,7 +2272,7 @@ importers: version: 7.28.0 workerd: specifier: catalog:default - version: 1.20260714.1 + version: 1.20260721.1 ws: specifier: catalog:default version: 8.21.0 @@ -2291,7 +2297,7 @@ importers: version: link:../workers-shared '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2454,7 +2460,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: catalog:default - version: 0.13.3(@cloudflare/workers-types@5.20260714.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) + version: 0.13.3(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) '@cloudflare/workers-shared': specifier: workspace:* version: link:../workers-shared @@ -2463,7 +2469,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -2486,12 +2492,12 @@ importers: specifier: ^4.12.5 version: 4.12.5 zod: - specifier: ^3.22.3 - version: 3.22.3 + specifier: catalog:default + version: 4.4.3 devDependencies: '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2525,7 +2531,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/node': specifier: 22.15.17 version: 22.15.17 @@ -2549,7 +2555,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 esbuild: specifier: catalog:default version: 0.28.1 @@ -2557,6 +2563,58 @@ importers: specifier: ^3.5.0 version: 3.5.0(esbuild@0.28.1) + packages/remote-bindings: + dependencies: + '@cloudflare/cli-shared-helpers': + specifier: workspace:* + version: link:../cli + '@cloudflare/deploy-helpers': + specifier: workspace:* + version: link:../deploy-helpers + '@cloudflare/workers-auth': + specifier: workspace:* + version: link:../workers-auth + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + chalk: + specifier: catalog:default + version: 5.3.0 + miniflare: + specifier: workspace:* + version: link:../miniflare + undici: + specifier: catalog:default + version: 7.28.0 + ws: + specifier: catalog:default + version: 8.21.0 + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 5.20260721.1 + '@types/ws': + specifier: ^8.5.13 + version: 8.5.13 + capnweb: + specifier: catalog:default + version: 0.5.0 + esbuild: + specifier: catalog:default + version: 0.28.1 + tsdown: + specifier: 0.16.3 + version: 0.16.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/runtime-types: dependencies: miniflare: @@ -2564,7 +2622,7 @@ importers: version: link:../miniflare workerd: specifier: catalog:default - version: 1.20260714.1 + version: 1.20260721.1 devDependencies: '@cloudflare/workers-tsconfig': specifier: workspace:* @@ -2590,14 +2648,14 @@ importers: packages/turbo-r2-archive: dependencies: '@hono/zod-validator': - specifier: ^0.4.2 - version: 0.4.2(hono@4.12.5)(zod@3.22.3) + specifier: ^0.8.0 + version: 0.8.0(hono@4.12.5)(zod@4.4.3) hono: specifier: ^4.12.5 version: 4.12.5 zod: - specifier: ^3.22.3 - version: 3.22.3 + specifier: catalog:default + version: 4.4.3 devDependencies: '@cloudflare/workers-tsconfig': specifier: workspace:* @@ -2638,7 +2696,7 @@ importers: version: 2.0.0-rc.24 workerd: specifier: catalog:default - version: 1.20260714.1 + version: 1.20260721.1 wrangler: specifier: workspace:* version: link:../wrangler @@ -2655,6 +2713,9 @@ importers: '@cloudflare/mock-npm-registry': specifier: workspace:* version: link:../mock-npm-registry + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/runtime-types': specifier: workspace:* version: link:../runtime-types @@ -2666,7 +2727,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -2762,7 +2823,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2783,7 +2844,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2804,7 +2865,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2825,7 +2886,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2846,7 +2907,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2867,7 +2928,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2888,7 +2949,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2909,7 +2970,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2930,7 +2991,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2951,7 +3012,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2972,7 +3033,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -2993,7 +3054,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3014,7 +3075,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3035,7 +3096,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3056,7 +3117,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3077,7 +3138,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3098,7 +3159,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/mimetext': specifier: ^2.0.4 version: 2.0.4 @@ -3131,7 +3192,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3152,7 +3213,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3173,7 +3234,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3194,7 +3255,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3215,7 +3276,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3236,7 +3297,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3257,7 +3318,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3278,7 +3339,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3299,7 +3360,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@playground/main-resolution-package': specifier: file:./package version: file:packages/vite-plugin-cloudflare/playground/main-resolution/package @@ -3323,7 +3384,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/express': specifier: ^5.0.1 version: 5.0.1 @@ -3350,7 +3411,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@playground/module-resolution-excludes': specifier: file:./packages/excludes version: file:packages/vite-plugin-cloudflare/playground/module-resolution/packages/excludes @@ -3362,7 +3423,7 @@ importers: version: file:packages/vite-plugin-cloudflare/playground/module-resolution/packages/requires '@remix-run/cloudflare': specifier: 2.12.0 - version: 2.12.0(@cloudflare/workers-types@5.20260714.1)(typescript@5.8.3) + version: 2.12.0(@cloudflare/workers-types@5.20260721.1)(typescript@5.8.3) '@types/react': specifier: ^18.3.11 version: 18.3.18 @@ -3395,7 +3456,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3416,7 +3477,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@fixture/shared': specifier: workspace:* version: link:../../../../fixtures/shared @@ -3468,7 +3529,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/react': specifier: 19.1.0 version: 19.1.0 @@ -3489,7 +3550,7 @@ importers: dependencies: partyserver: specifier: ^0.3.3 - version: 0.3.3(@cloudflare/workers-types@5.20260714.1) + version: 0.3.3(@cloudflare/workers-types@5.20260721.1) partysocket: specifier: ^1.1.16 version: 1.1.16(react@19.2.1) @@ -3508,7 +3569,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@tailwindcss/vite': specifier: ^4.2.1 version: 4.2.2(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) @@ -3544,7 +3605,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3565,7 +3626,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../../../workers-utils @@ -3605,7 +3666,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/react': specifier: 19.1.0 version: 19.1.0 @@ -3635,7 +3696,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3656,7 +3717,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3684,7 +3745,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/react': specifier: 19.1.0 version: 19.1.0 @@ -3717,7 +3778,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3738,7 +3799,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3759,7 +3820,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3780,7 +3841,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@vitejs/plugin-basic-ssl': specifier: ^2.2.0 version: 2.2.0(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) @@ -3804,7 +3865,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3825,7 +3886,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3846,7 +3907,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3867,7 +3928,7 @@ importers: version: link:../../../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 typescript: specifier: catalog:default version: 5.8.3 @@ -3899,12 +3960,15 @@ importers: '@cloudflare/mock-npm-registry': specifier: workspace:* version: link:../mock-npm-registry + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -4162,13 +4226,13 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: catalog:default - version: 0.13.3(@cloudflare/workers-types@5.20260714.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) + version: 0.13.3(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@sentry/cli': specifier: ^2.37.0 version: 2.41.1(encoding@0.1.13) @@ -4284,18 +4348,18 @@ importers: specifier: ^3.0.0 version: 3.0.0 zod: - specifier: ^3.22.3 - version: 3.22.3 + specifier: catalog:default + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: catalog:default - version: 0.13.3(@cloudflare/workers-types@5.20260714.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) + version: 0.13.3(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0) '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@types/mime': specifier: ^3.0.4 version: 3.0.4 @@ -4334,7 +4398,7 @@ importers: version: 2.0.0-rc.24 workerd: specifier: catalog:default - version: 1.20260714.1 + version: 1.20260721.1 devDependencies: '@aws-sdk/client-s3': specifier: ^3.721.0 @@ -4363,6 +4427,9 @@ importers: '@cloudflare/pages-shared': specifier: workspace:^ version: link:../pages-shared + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/runtime-types': specifier: workspace:* version: link:../runtime-types @@ -4380,7 +4447,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260714.1 + version: 5.20260721.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils @@ -4622,7 +4689,7 @@ importers: specifier: ^17.7.2 version: 17.7.2 zod: - specifier: ^4.4.3 + specifier: catalog:default version: 4.4.3 optionalDependencies: fsevents: @@ -5546,8 +5613,8 @@ packages: cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-64@1.20260714.1': - resolution: {integrity: sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==} + '@cloudflare/workerd-darwin-64@1.20260721.1': + resolution: {integrity: sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] @@ -5564,8 +5631,8 @@ packages: cpu: [arm64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260714.1': - resolution: {integrity: sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==} + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + resolution: {integrity: sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] @@ -5582,8 +5649,8 @@ packages: cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-64@1.20260714.1': - resolution: {integrity: sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==} + '@cloudflare/workerd-linux-64@1.20260721.1': + resolution: {integrity: sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] @@ -5600,8 +5667,8 @@ packages: cpu: [arm64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260714.1': - resolution: {integrity: sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==} + '@cloudflare/workerd-linux-arm64@1.20260721.1': + resolution: {integrity: sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] @@ -5618,8 +5685,8 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workerd-windows-64@1.20260714.1': - resolution: {integrity: sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==} + '@cloudflare/workerd-windows-64@1.20260721.1': + resolution: {integrity: sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -5635,8 +5702,8 @@ packages: '@cloudflare/workers-types@4.20260702.1': resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} - '@cloudflare/workers-types@5.20260714.1': - resolution: {integrity: sha512-HGCTQVIQwzqAMLrZBCgLDrWKMgOdi6yn+S0Do1rF6z7t8tVvNprQfa53V6EDRRwsVO92uN5gviX2SxASDINZfA==} + '@cloudflare/workers-types@5.20260721.1': + resolution: {integrity: sha512-J6HZRuQOP3gVe9G5rxHQVS8kQRjo7NIaF+Lz4kO2lVmaCkQ1E78APOOthaI7KyCQzp+A2NbXBQI6QLRIswdWbA==} '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} @@ -6731,11 +6798,11 @@ packages: peerDependencies: hono: ^4 - '@hono/zod-validator@0.4.2': - resolution: {integrity: sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g==} + '@hono/zod-validator@0.8.0': + resolution: {integrity: sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w==} peerDependencies: - hono: '>=3.9.0' - zod: ^3.19.1 + hono: '>=4.10.0' + zod: ^3.25.0 || ^4.0.0 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -12711,8 +12778,8 @@ packages: resolution: {integrity: sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==} engines: {node: '>=18.0.0'} - mongodb-connection-string-url@7.0.1: - resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + mongodb-connection-string-url@7.0.2: + resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==} engines: {node: '>=20.19.0'} mongodb@7.1.0: @@ -15511,8 +15578,8 @@ packages: engines: {node: '>=16'} hasBin: true - workerd@1.20260714.1: - resolution: {integrity: sha512-oIbQzfdyl9UQUnG6XLegcSq0Mgt/7WKDbFOoqGgOWCS+/fhyGB460uKEgdAQQ9RHCO/ttcNCX/KiMIQzdoeu3Q==} + workerd@1.20260721.1: + resolution: {integrity: sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==} engines: {node: '>=16'} hasBin: true @@ -15644,15 +15711,9 @@ packages: zeptomatch@2.0.2: resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} - zod@3.22.3: - resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -16539,7 +16600,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.13 - '@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1)': + '@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -16550,9 +16611,9 @@ snapshots: nanostores: 1.1.1 zod: 4.4.3 optionalDependencies: - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 - '@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)': + '@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -16563,50 +16624,50 @@ snapshots: nanostores: 1.1.1 zod: 4.4.3 optionalDependencies: - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 - '@better-auth/drizzle-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))': + '@better-auth/drizzle-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - drizzle-orm: 0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)) + drizzle-orm: 0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)) - '@better-auth/kysely-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': + '@better-auth/kysely-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 kysely: 0.28.11 - '@better-auth/memory-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)': + '@better-auth/mongo-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 mongodb: 7.1.0 - '@better-auth/prisma-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))': + '@better-auth/prisma-adapter@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@prisma/client': 7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3) prisma: 7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3) - '@better-auth/stripe@1.5.4(f660eebfb07999a7aa04507f74bf93ad)': + '@better-auth/stripe@1.5.4(6a5abfdb3bce0994f2cec622068dd503)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) - better-auth: 1.5.4(@cloudflare/workers-types@5.20260714.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))(mongodb@7.1.0)(mysql2@3.15.3)(pg@8.16.3)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + better-auth: 1.5.4(@cloudflare/workers-types@5.20260721.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))(mongodb@7.1.0)(mysql2@3.15.3)(pg@8.16.3)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0) better-call: 1.3.2(zod@4.4.3) defu: 6.1.4 stripe: 20.4.1(@types/node@22.15.17) - zod: 4.3.6 + zod: 4.4.3 - '@better-auth/telemetry@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))': + '@better-auth/telemetry@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -17171,7 +17232,7 @@ snapshots: lodash.memoize: 4.1.2 marked: 0.3.19 - '@cloudflare/vitest-pool-workers@0.13.3(@cloudflare/workers-types@5.20260714.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0)': + '@cloudflare/vitest-pool-workers@0.13.3(@cloudflare/workers-types@5.20260721.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@4.1.0)': dependencies: '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -17179,7 +17240,7 @@ snapshots: esbuild: 0.27.3 miniflare: 4.20260317.1 vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.9.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) - wrangler: 4.76.0(@cloudflare/workers-types@5.20260714.1) + wrangler: 4.76.0(@cloudflare/workers-types@5.20260721.1) zod: 3.25.76 transitivePeerDependencies: - '@cloudflare/workers-types' @@ -17192,7 +17253,7 @@ snapshots: '@cloudflare/workerd-darwin-64@1.20260423.1': optional: true - '@cloudflare/workerd-darwin-64@1.20260714.1': + '@cloudflare/workerd-darwin-64@1.20260721.1': optional: true '@cloudflare/workerd-darwin-arm64@1.20260317.1': @@ -17201,7 +17262,7 @@ snapshots: '@cloudflare/workerd-darwin-arm64@1.20260423.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260714.1': + '@cloudflare/workerd-darwin-arm64@1.20260721.1': optional: true '@cloudflare/workerd-linux-64@1.20260317.1': @@ -17210,7 +17271,7 @@ snapshots: '@cloudflare/workerd-linux-64@1.20260423.1': optional: true - '@cloudflare/workerd-linux-64@1.20260714.1': + '@cloudflare/workerd-linux-64@1.20260721.1': optional: true '@cloudflare/workerd-linux-arm64@1.20260317.1': @@ -17219,7 +17280,7 @@ snapshots: '@cloudflare/workerd-linux-arm64@1.20260423.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260714.1': + '@cloudflare/workerd-linux-arm64@1.20260721.1': optional: true '@cloudflare/workerd-windows-64@1.20260317.1': @@ -17228,7 +17289,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260423.1': optional: true - '@cloudflare/workerd-windows-64@1.20260714.1': + '@cloudflare/workerd-windows-64@1.20260721.1': optional: true '@cloudflare/workers-editor-shared@0.1.1(@cloudflare/style-const@6.1.3(react@19.2.4))(@cloudflare/style-container@7.12.2(@cloudflare/style-const@6.1.3(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -17241,7 +17302,7 @@ snapshots: '@cloudflare/workers-types@4.20260702.1': {} - '@cloudflare/workers-types@5.20260714.1': {} + '@cloudflare/workers-types@5.20260721.1': {} '@codemirror/autocomplete@6.20.0': dependencies: @@ -17970,10 +18031,10 @@ snapshots: dependencies: hono: 4.7.10 - '@hono/zod-validator@0.4.2(hono@4.12.5)(zod@3.22.3)': + '@hono/zod-validator@0.8.0(hono@4.12.5)(zod@4.4.3)': dependencies: hono: 4.12.5 - zod: 3.22.3 + zod: 4.4.3 '@humanfs/core@0.19.1': {} @@ -18937,10 +18998,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 - '@remix-run/cloudflare@2.12.0(@cloudflare/workers-types@5.20260714.1)(typescript@5.8.3)': + '@remix-run/cloudflare@2.12.0(@cloudflare/workers-types@5.20260721.1)(typescript@5.8.3)': dependencies: '@cloudflare/kv-asset-handler': 0.1.3 - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 '@remix-run/server-runtime': 2.12.0(typescript@5.8.3) optionalDependencies: typescript: 5.8.3 @@ -19476,12 +19537,12 @@ snapshots: - encoding - supports-color - '@sentry/cloudflare@10.50.0(@cloudflare/workers-types@5.20260714.1)': + '@sentry/cloudflare@10.50.0(@cloudflare/workers-types@5.20260721.1)': dependencies: '@opentelemetry/api': 1.9.1 '@sentry/core': 10.50.0 optionalDependencies: - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 '@sentry/core@10.50.0': {} @@ -21128,15 +21189,15 @@ snapshots: before-after-hook@2.2.3: {} - better-auth@1.5.4(@cloudflare/workers-types@5.20260714.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))(mongodb@7.1.0)(mysql2@3.15.3)(pg@8.16.3)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0): + better-auth@1.5.4(@cloudflare/workers-types@5.20260721.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)))(mongodb@7.1.0)(mysql2@3.15.3)(pg@8.16.3)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0): dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))) - '@better-auth/kysely-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) - '@better-auth/memory-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0) - '@better-auth/prisma-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)) - '@better-auth/telemetry': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260714.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1)) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))) + '@better-auth/kysely-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) + '@better-auth/memory-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0) + '@better-auth/prisma-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)) + '@better-auth/telemetry': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260721.1)(better-call@1.3.2(zod@4.4.3))(jose@5.9.3)(kysely@0.28.11)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -21149,7 +21210,7 @@ snapshots: zod: 4.4.3 optionalDependencies: '@prisma/client': 7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3) - drizzle-orm: 0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)) + drizzle-orm: 0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)) mongodb: 7.1.0 mysql2: 3.15.3 pg: 8.16.3 @@ -21962,9 +22023,9 @@ snapshots: dependencies: wordwrap: 1.0.0 - drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260714.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)): + drizzle-orm@0.45.1(@cloudflare/workers-types@5.20260721.1)(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.1)(@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3))(@types/pg@8.15.4)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3)): optionalDependencies: - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 '@electric-sql/pglite': 0.3.2 '@opentelemetry/api': 1.9.1 '@prisma/client': 7.0.1(prisma@7.0.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3))(typescript@5.8.3) @@ -23988,7 +24049,7 @@ snapshots: modern-tar@0.7.6: {} - mongodb-connection-string-url@7.0.1: + mongodb-connection-string-url@7.0.2: dependencies: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 @@ -23997,7 +24058,7 @@ snapshots: dependencies: '@mongodb-js/saslprep': 1.4.12 bson: 7.3.1 - mongodb-connection-string-url: 7.0.1 + mongodb-connection-string-url: 7.0.2 motion-dom@12.38.0: dependencies: @@ -24448,9 +24509,9 @@ snapshots: parseurl@1.3.3: {} - partyserver@0.3.3(@cloudflare/workers-types@5.20260714.1): + partyserver@0.3.3(@cloudflare/workers-types@5.20260721.1): dependencies: - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 nanoid: 5.1.7 partysocket@1.1.16(react@19.2.1): @@ -27321,15 +27382,15 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260423.1 '@cloudflare/workerd-windows-64': 1.20260423.1 - workerd@1.20260714.1: + workerd@1.20260721.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260714.1 - '@cloudflare/workerd-darwin-arm64': 1.20260714.1 - '@cloudflare/workerd-linux-64': 1.20260714.1 - '@cloudflare/workerd-linux-arm64': 1.20260714.1 - '@cloudflare/workerd-windows-64': 1.20260714.1 + '@cloudflare/workerd-darwin-64': 1.20260721.1 + '@cloudflare/workerd-darwin-arm64': 1.20260721.1 + '@cloudflare/workerd-linux-64': 1.20260721.1 + '@cloudflare/workerd-linux-arm64': 1.20260721.1 + '@cloudflare/workerd-windows-64': 1.20260721.1 - wrangler@4.76.0(@cloudflare/workers-types@5.20260714.1): + wrangler@4.76.0(@cloudflare/workers-types@5.20260721.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1) @@ -27340,7 +27401,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260317.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260714.1 + '@cloudflare/workers-types': 5.20260721.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -27452,12 +27513,8 @@ snapshots: dependencies: grammex: 3.1.11 - zod@3.22.3: {} - zod@3.25.76: {} - zod@4.3.6: {} - zod@4.4.3: {} zrender@6.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cbe15d4e0e..30e26e98bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -119,8 +119,8 @@ catalog: esbuild: "0.28.1" "@playwright/test": "1.60.0" playwright-chromium: "1.60.0" - "@cloudflare/workers-types": "^5.20260714.1" - workerd: "1.20260714.1" + "@cloudflare/workers-types": "^5.20260721.1" + workerd: "1.20260721.1" jsonc-parser: "3.2.0" smol-toml: "1.5.2" msw: 2.12.4 @@ -128,6 +128,7 @@ catalog: "tree-kill": "1.2.2" "capnp-es": "0.0.14" "capnweb": "0.5.0" + zod: "4.4.3" "ci-info": "4.4.0" "open": "11.0.0" "signal-exit": "4.1.0" diff --git a/tools/deployments/validate-catalog-usage.ts b/tools/deployments/validate-catalog-usage.ts index 11f7540353..992a78d3d4 100644 --- a/tools/deployments/validate-catalog-usage.ts +++ b/tools/deployments/validate-catalog-usage.ts @@ -12,7 +12,12 @@ const ROOT = resolve(__dirname, "../.."); // Deps that are deliberately pinned outside the catalog (e.g. workerd is // bumped in coordinated PRs with its own automation). -const IGNORED_DEPS = new Set(["workerd"]); +const IGNORED_DEPS = new Set([ + "workerd", + // miniflare, vitest-pool-workers, and workers-shared still use zod v3 + // and cannot adopt the v4 catalog version yet. + "zod", +]); /** * Parses the `catalog:` block of a pnpm-workspace.yaml into a map of