diff --git a/.changeset/friendly-connection-timeout.md b/.changeset/friendly-connection-timeout.md new file mode 100644 index 000000000000..ef7b617e09af --- /dev/null +++ b/.changeset/friendly-connection-timeout.md @@ -0,0 +1,11 @@ +--- +"wrangler": patch +--- + +Show helpful messages for errors outside of Wrangler's control. This prevents unnecessary Sentry reports. + +Errors now handled with user-friendly messages: + +- Connection timeouts to Cloudflare's API (`UND_ERR_CONNECT_TIMEOUT`) - typically due to slow networks or connectivity issues +- File system permission errors (`EPERM`, `EACCES`) - caused by insufficient permissions, locked files, or antivirus software +- DNS resolution failures (`ENOTFOUND`) - caused by network connectivity issues or DNS configuration problems diff --git a/.changeset/major-snails-post.md b/.changeset/major-snails-post.md new file mode 100644 index 000000000000..785a16759b0a --- /dev/null +++ b/.changeset/major-snails-post.md @@ -0,0 +1,20 @@ +--- +"@cloudflare/vite-plugin": minor +--- + +Add support for child environments. + +This is to support React Server Components via [@vitejs/plugin-rsc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) and frameworks that build on top of it. A `childEnvironments` option is now added to the plugin config to enable using multiple environments within a single Worker. The parent environment can import modules from a child environment in order to access a separate module graph. For a typical RSC use case, the plugin might be configured as in the following example: + +```ts +export default defineConfig({ + plugins: [ + cloudflare({ + viteEnvironment: { + name: "rsc", + childEnvironments: ["ssr"], + }, + }), + ], +}); +``` diff --git a/.changeset/stupid-pants-push.md b/.changeset/stupid-pants-push.md new file mode 100644 index 000000000000..c90e42c3f746 --- /dev/null +++ b/.changeset/stupid-pants-push.md @@ -0,0 +1,19 @@ +--- +"@cloudflare/vite-plugin": patch +"@cloudflare/vitest-pool-workers": patch +"@cloudflare/kv-asset-handler": patch +"miniflare": patch +--- + +Bundle more third-party dependencies to reduce supply chain risk + +Previously, several small utility packages were listed as runtime dependencies and +installed separately. These are now bundled directly into the published packages, +reducing the number of external dependencies users need to trust. + +Bundled dependencies: + +- **miniflare**: `acorn`, `acorn-walk`, `exit-hook`, `glob-to-regexp`, `stoppable` +- **kv-asset-handler**: `mime` +- **vite-plugin-cloudflare**: `@remix-run/node-fetch-server`, `defu`, `get-port`, `picocolors`, `tinyglobby` +- **vitest-pool-workers**: `birpc`, `devalue`, `get-port`, `semver` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f86022129b8d..37c2a16a9976 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -395,6 +395,49 @@ CLOUDFLARE_ACCOUNT_ID="" CLOUDFLARE_A > [!NOTE] > Workers and other resources created in the E2E tests might not always be cleaned up. Internal users with access to the "DevProd Testing" account can rely on an automated job to clean up the Workers and other resources, but if you use another account, please be aware you may want to manually delete the Workers and other resources yourself. +## Managing Package Dependencies + +Packages in this monorepo should bundle their dependencies into the distributable code rather than leaving them as runtime `dependencies` that get installed by downstream users. This prevents dependency chain poisoning where a transitive dependency could introduce unexpected or malicious code. + +### The Rule + +- **Bundle dependencies**: Most dependencies should be listed in `devDependencies` and bundled into the package output by esbuild/tsup/etc. +- **External dependencies**: Only dependencies that _cannot_ be bundled should be listed in `dependencies`. These must be explicitly declared with documentation explaining why. + +### Why This Matters + +When users install one of our packages (e.g., `wrangler`), npm/pnpm will also install everything listed in `dependencies`. If one of those dependencies has unpinned transitive dependencies, a malicious actor could publish a compromised version that gets pulled into user installations. By bundling our dependencies, we control exactly what code ships. + +### Adding a New External Dependency + +If you need to add a dependency that cannot be bundled (native binaries, WASM modules, packages that must be resolved at runtime, etc.): + +1. **Add to `dependencies`** in `package.json` with a pinned version +2. **Add to `EXTERNAL_DEPENDENCIES`** in `scripts/deps.ts` with a comment explaining why it can't be bundled +3. **Run `pnpm check:package-deps`** to verify the allowlist is correct + +Example `scripts/deps.ts`: + +```typescript +export const EXTERNAL_DEPENDENCIES = [ + // Native binary - cannot be bundled + "workerd", + + // WASM module that blows up when bundled + "blake3-wasm", + + // Must be resolved at runtime when bundling user's worker code + "esbuild", +]; +``` + +### Valid Reasons for External Dependencies + +- **Native binaries**: Packages like `workerd` or `sharp` contain platform-specific binaries +- **WASM modules**: Some WASM packages don't bundle correctly +- **Runtime resolution**: Packages like `esbuild` or `unenv` that need to be resolved when bundling user code +- **Peer dependencies**: Packages the user is expected to provide (e.g., `react`, `vite`) + ## Changesets Every non-trivial change to the project - those that should appear in the changelog - must be captured in a "changeset". diff --git a/package.json b/package.json index caee89c468bf..647625eaeed8 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "author": "wrangler@cloudflare.com", "scripts": { "build": "dotenv -- turbo build", - "check": "pnpm check:fixtures && pnpm check:private-packages && pnpm check:deployments && node lint-turbo.mjs && dotenv -- turbo check:lint check:type check:format type:tests", + "check": "pnpm check:fixtures && pnpm check:private-packages && pnpm check:package-deps && pnpm check:deployments && node lint-turbo.mjs && dotenv -- turbo check:lint check:type check:format type:tests", "check:deployments": "node -r esbuild-register tools/deployments/deploy-non-npm-packages.ts check", "check:fixtures": "node -r esbuild-register tools/deployments/validate-fixtures.ts", "check:format": "prettier . --check --ignore-unknown", "check:lint": "dotenv -- turbo check:lint", + "check:package-deps": "node -r esbuild-register tools/deployments/validate-package-dependencies.ts", "check:private-packages": "node -r esbuild-register tools/deployments/validate-private-packages.ts", "check:type": "dotenv -- turbo check:type type:tests", "dev": "dotenv -- turbo dev", diff --git a/packages/kv-asset-handler/package.json b/packages/kv-asset-handler/package.json index d3e3ac730cc9..65dd871a1509 100644 --- a/packages/kv-asset-handler/package.json +++ b/packages/kv-asset-handler/package.json @@ -36,9 +36,6 @@ "test": "vitest", "test:ci": "vitest run" }, - "dependencies": { - "mime": "^3.0.0" - }, "devDependencies": { "@cloudflare/eslint-config-shared": "workspace:*", "@cloudflare/vitest-pool-workers": "catalog:default", @@ -46,6 +43,7 @@ "@types/mime": "^3.0.4", "@types/node": "catalog:default", "eslint": "catalog:default", + "mime": "^3.0.0", "tsup": "8.3.0", "vitest": "~2.1.0" }, diff --git a/packages/miniflare/package.json b/packages/miniflare/package.json index 7f3b4959b119..788b9b406c35 100644 --- a/packages/miniflare/package.json +++ b/packages/miniflare/package.json @@ -44,12 +44,7 @@ }, "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "acorn": "8.14.0", - "acorn-walk": "8.3.2", - "exit-hook": "2.2.1", - "glob-to-regexp": "0.4.1", "sharp": "^0.34.5", - "stoppable": "1.1.0", "undici": "catalog:default", "workerd": "1.20260114.0", "ws": "catalog:default", @@ -75,6 +70,8 @@ "@types/stoppable": "^1.1.1", "@types/which": "^2.0.1", "@types/ws": "^8.5.7", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", "capnp-es": "^0.0.11", "capnweb": "^0.1.0", "chokidar": "^4.0.1", @@ -86,8 +83,10 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-es": "^4.1.0", "eslint-plugin-prettier": "^5.0.1", + "exit-hook": "2.2.1", "expect-type": "^0.15.0", "get-port": "^7.1.0", + "glob-to-regexp": "0.4.1", "heap-js": "^2.5.0", "http-cache-semantics": "^4.1.0", "kleur": "^4.1.5", @@ -96,6 +95,7 @@ "pretty-bytes": "^6.0.0", "rimraf": "catalog:default", "source-map": "^0.6.1", + "stoppable": "1.1.0", "ts-dedent": "^2.2.0", "typescript": "catalog:default", "vitest": "catalog:default", diff --git a/packages/miniflare/scripts/deps.ts b/packages/miniflare/scripts/deps.ts new file mode 100644 index 000000000000..7cf7bba930da --- /dev/null +++ b/packages/miniflare/scripts/deps.ts @@ -0,0 +1,33 @@ +/** + * Dependencies that _are not_ bundled along with miniflare. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Must be external - uses require.resolve() and require.cache manipulation + // to load fresh instances of the module at runtime (see sourcemap.ts) + "@cspotcode/source-map-support", + + // Native binary with platform-specific builds - cannot be bundled + "sharp", + + // Large HTTP client with optional native dependencies; commonly shared + // with other packages to avoid version conflicts and duplication + "undici", + + // Native binary - Cloudflare's JavaScript runtime cannot be bundled + "workerd", + + // Has optional native bindings (bufferutil, utf-8-validate) for performance; + // commonly shared with other packages to avoid duplication + "ws", + + // Must be external - dynamically required at runtime via require("youch") + // for lazy loading of pretty error pages + "youch", + + // Large validation library; commonly shared as a dependency + // to avoid version conflicts and bundle size duplication + "zod", +]; diff --git a/packages/vite-plugin-cloudflare/eslint.config.mjs b/packages/vite-plugin-cloudflare/eslint.config.mjs index 93df11a289d1..65a5b5cb96a2 100644 --- a/packages/vite-plugin-cloudflare/eslint.config.mjs +++ b/packages/vite-plugin-cloudflare/eslint.config.mjs @@ -5,6 +5,7 @@ export default defineConfig([ globalIgnores([ "**/dist", "**/e2e", + "scripts/**", "tsdown.config.ts", "vitest.config.ts", "src/__tests__/fixtures/**", diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index 90493f5547fe..cf6861ed58d7 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -44,12 +44,7 @@ }, "dependencies": { "@cloudflare/unenv-preset": "workspace:*", - "@remix-run/node-fetch-server": "^0.8.0", - "defu": "^6.1.4", - "get-port": "^7.1.0", "miniflare": "workspace:*", - "picocolors": "^1.1.1", - "tinyglobby": "^0.2.12", "unenv": "2.0.0-rc.24", "wrangler": "workspace:*", "ws": "catalog:default" @@ -61,12 +56,17 @@ "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", "@cloudflare/workers-utils": "workspace:*", + "@remix-run/node-fetch-server": "^0.8.0", "@types/node": "catalog:vite-plugin", "@types/semver": "^7.5.1", "@types/ws": "^8.5.13", + "defu": "^6.1.4", + "get-port": "^7.1.0", "magic-string": "^0.30.12", "mlly": "^1.7.4", + "picocolors": "^1.1.1", "semver": "^7.7.1", + "tinyglobby": "^0.2.12", "tree-kill": "^1.2.2", "tsdown": "0.16.3", "typescript": "catalog:default", diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts b/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts new file mode 100644 index 000000000000..5bd72537ea28 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from "vitest"; +import { getTextResponse, isBuild } from "../../__test-utils__"; + +test.runIf(!isBuild)("can import module from child environment", async () => { + const response = await getTextResponse(); + expect(response).toBe("Hello from the child environment"); +}); diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/package.json b/packages/vite-plugin-cloudflare/playground/child-environment/package.json new file mode 100644 index 000000000000..bc9f2685dd74 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/package.json @@ -0,0 +1,19 @@ +{ + "name": "@playground/child-environment", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "check:type": "tsc --build", + "dev": "vite dev", + "preview": "vite preview" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "vite": "catalog:vite-plugin", + "wrangler": "workspace:*" + } +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts b/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts new file mode 100644 index 000000000000..8d520baac4e1 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts @@ -0,0 +1,6 @@ +// @ts-expect-error - no types +import { getEnvironmentName } from "virtual:environment-name"; + +export function getMessage() { + return `Hello from the ${getEnvironmentName()} environment`; +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts b/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts new file mode 100644 index 000000000000..8d53086b3e79 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts @@ -0,0 +1,18 @@ +declare global { + // In real world usage, this is accessed by `@vitejs/plugin-rsc` + function __VITE_ENVIRONMENT_RUNNER_IMPORT__( + environmentName: string, + id: string + ): Promise; +} + +export default { + async fetch() { + const childEnvironmentModule = (await __VITE_ENVIRONMENT_RUNNER_IMPORT__( + "child", + "./src/child-environment-module" + )) as { getMessage: () => string }; + + return new Response(childEnvironmentModule.getMessage()); + }, +} satisfies ExportedHandler; diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.json b/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.json new file mode 100644 index 000000000000..b52af703bdc2 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ] +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.node.json b/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.node.json new file mode 100644 index 000000000000..773be9834af5 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.node.json @@ -0,0 +1,4 @@ +{ + "extends": ["@cloudflare/workers-tsconfig/base.json"], + "include": ["vite.config.ts", "__tests__"] +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.worker.json b/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.worker.json new file mode 100644 index 000000000000..da43778b826f --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/tsconfig.worker.json @@ -0,0 +1,4 @@ +{ + "extends": ["@cloudflare/workers-tsconfig/worker.json"], + "include": ["src"] +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/turbo.json b/packages/vite-plugin-cloudflare/playground/child-environment/turbo.json new file mode 100644 index 000000000000..6556dcf3e5e5 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts b/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts new file mode 100644 index 000000000000..6a7bc6827e7a --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts @@ -0,0 +1,28 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + inspectorPort: false, + persistState: false, + viteEnvironment: { + name: "parent", + childEnvironments: ["child"], + }, + }), + { + name: "virtual-module-plugin", + resolveId(source) { + if (source === "virtual:environment-name") { + return "\0virtual:environment-name"; + } + }, + load(id) { + if (id === "\0virtual:environment-name") { + return `export function getEnvironmentName() { return ${JSON.stringify(this.environment.name)} }`; + } + }, + }, + ], +}); diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/wrangler.jsonc b/packages/vite-plugin-cloudflare/playground/child-environment/wrangler.jsonc new file mode 100644 index 000000000000..940fd1ab07d9 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "worker", + "main": "./src/index.ts", +} diff --git a/packages/vite-plugin-cloudflare/scripts/deps.ts b/packages/vite-plugin-cloudflare/scripts/deps.ts new file mode 100644 index 000000000000..d9e5753f4db8 --- /dev/null +++ b/packages/vite-plugin-cloudflare/scripts/deps.ts @@ -0,0 +1,15 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/vite-plugin. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Must be external - resolved at runtime when bundling user's worker code + // to provide Node.js compatibility polyfills + "unenv", + + // Has optional native bindings (bufferutil, utf-8-validate) for performance; + // commonly shared with other packages to avoid duplication + "ws", +]; diff --git a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts index 35d58fe4830b..db5b18b9292c 100644 --- a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts +++ b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts @@ -3,9 +3,11 @@ import { CoreHeaders } from "miniflare"; import * as vite from "vite"; import { additionalModuleRE } from "./plugins/additional-modules"; import { + ENVIRONMENT_NAME_HEADER, GET_EXPORT_TYPES_PATH, INIT_PATH, IS_ENTRY_WORKER_HEADER, + IS_PARENT_ENVIRONMENT_HEADER, UNKNOWN_HOST, VIRTUAL_WORKER_ENTRY, WORKER_ENTRY_PATH_HEADER, @@ -97,7 +99,7 @@ export class CloudflareDevEnvironment extends vite.DevEnvironment { async initRunner( miniflare: Miniflare, workerConfig: ResolvedWorkerConfig, - isEntryWorker: boolean + options: { isEntryWorker: boolean; isParentEnvironment: boolean } ): Promise { const response = await miniflare.dispatchFetch( new URL(INIT_PATH, UNKNOWN_HOST), @@ -105,7 +107,9 @@ export class CloudflareDevEnvironment extends vite.DevEnvironment { headers: { [CoreHeaders.ROUTE_OVERRIDE]: workerConfig.name, [WORKER_ENTRY_PATH_HEADER]: encodeURIComponent(workerConfig.main), - [IS_ENTRY_WORKER_HEADER]: String(isEntryWorker), + [IS_ENTRY_WORKER_HEADER]: String(options.isEntryWorker), + [ENVIRONMENT_NAME_HEADER]: this.name, + [IS_PARENT_ENVIRONMENT_HEADER]: String(options.isParentEnvironment), upgrade: "websocket", }, } @@ -170,14 +174,6 @@ const defaultConditions = ["workerd", "worker", "module", "browser"]; // workerd uses [v8 version 14.2 as of 2025-10-17](https://developers.cloudflare.com/workers/platform/changelog/#2025-10-17) const target = "es2024"; -const rollupOptions: vite.Rollup.RollupOptions = { - 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", -}; - // TODO: consider removing in next major to use default extensions const resolveExtensions = [ ".mjs", @@ -198,6 +194,7 @@ export function createCloudflareEnvironmentOptions({ mode, environmentName, isEntryWorker, + isParentEnvironment, hasNodeJsCompat, }: { workerConfig: ResolvedWorkerConfig; @@ -205,8 +202,18 @@ export function createCloudflareEnvironmentOptions({ mode: vite.ConfigEnv["mode"]; environmentName: string; isEntryWorker: boolean; + isParentEnvironment: boolean; hasNodeJsCompat: boolean; }): vite.EnvironmentOptions { + const rollupOptions: vite.Rollup.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", + } + : {}; const define = getProcessEnvReplacements(hasNodeJsCompat, mode); return { @@ -323,19 +330,39 @@ export function initRunners( viteDevServer: vite.ViteDevServer, miniflare: Miniflare ): Promise | undefined { - return Promise.all( - [...resolvedPluginConfig.environmentNameToWorkerMap].map( - ([environmentName, worker]) => { - debuglog("Initializing worker:", worker.config.name); - const isEntryWorker = - environmentName === resolvedPluginConfig.entryWorkerEnvironmentName; - - return ( - viteDevServer.environments[ - environmentName - ] as CloudflareDevEnvironment - ).initRunner(miniflare, worker.config, isEntryWorker); - } - ) - ); + const initPromises = [ + ...resolvedPluginConfig.environmentNameToWorkerMap, + ].flatMap(([environmentName, worker]) => { + debuglog("Initializing worker:", worker.config.name); + const isEntryWorker = + environmentName === resolvedPluginConfig.entryWorkerEnvironmentName; + + const childEnvironmentNames = + resolvedPluginConfig.environmentNameToChildEnvironmentNamesMap.get( + environmentName + ) ?? []; + + const parentInit = ( + viteDevServer.environments[environmentName] as CloudflareDevEnvironment + ).initRunner(miniflare, worker.config, { + isEntryWorker, + isParentEnvironment: true, + }); + + const childInits = childEnvironmentNames.map((childEnvironmentName) => { + debuglog("Initializing child environment:", childEnvironmentName); + return ( + viteDevServer.environments[ + childEnvironmentName + ] as CloudflareDevEnvironment + ).initRunner(miniflare, worker.config, { + isEntryWorker: false, + isParentEnvironment: false, + }); + }); + + return [parentInit, ...childInits]; + }); + + return Promise.all(initPromises); } diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index fec784f6856d..9464e6af1d78 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -9,6 +9,7 @@ import type { PreviewResolvedConfig, ResolvedPluginConfig, ResolvedWorkerConfig, + Worker, WorkersResolvedConfig, } from "./plugin-config"; import type { MiniflareOptions } from "miniflare"; @@ -141,12 +142,47 @@ export class PluginContext { return this.#resolvedViteConfig; } + isChildEnvironment(environmentName: string): boolean { + if (this.resolvedPluginConfig.type !== "workers") { + return false; + } + + for (const childEnvironmentNames of this.resolvedPluginConfig.environmentNameToChildEnvironmentNamesMap.values()) { + if (childEnvironmentNames.includes(environmentName)) { + return true; + } + } + + return false; + } + + #getWorker(environmentName: string): Worker | undefined { + if (this.resolvedPluginConfig.type !== "workers") { + return undefined; + } + + const worker = + this.resolvedPluginConfig.environmentNameToWorkerMap.get(environmentName); + + if (worker) { + return worker; + } + + // Check if this is a child environment and, if so, return the parent's Worker + for (const [parentEnvironmentName, childEnvironmentNames] of this + .resolvedPluginConfig.environmentNameToChildEnvironmentNamesMap) { + if (childEnvironmentNames.includes(environmentName)) { + return this.resolvedPluginConfig.environmentNameToWorkerMap.get( + parentEnvironmentName + ); + } + } + + return undefined; + } + getWorkerConfig(environmentName: string): ResolvedWorkerConfig | undefined { - return this.resolvedPluginConfig.type === "workers" - ? this.resolvedPluginConfig.environmentNameToWorkerMap.get( - environmentName - )?.config - : undefined; + return this.#getWorker(environmentName)?.config; } get allWorkerConfigs(): Unstable_Config[] { @@ -173,11 +209,7 @@ export class PluginContext { } getNodeJsCompat(environmentName: string): NodeJsCompat | undefined { - return this.resolvedPluginConfig.type === "workers" - ? this.resolvedPluginConfig.environmentNameToWorkerMap.get( - environmentName - )?.nodeJsCompat - : undefined; + return this.#getWorker(environmentName)?.nodeJsCompat; } } diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 59f927ad9015..1405558bb157 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -26,6 +26,7 @@ import { import { getContainerOptions, getDockerPath } from "./containers"; import { getInputInspectorPort } from "./debug"; import { additionalModuleRE } from "./plugins/additional-modules"; +import { ENVIRONMENT_NAME_HEADER } from "./shared"; import { withTrailingSlash } from "./utils"; import type { CloudflareDevEnvironment } from "./cloudflare-environment"; import type { ContainerTagToOptionsMap } from "./containers"; @@ -381,10 +382,17 @@ export async function getDevMiniflareOptions( } : {}), __VITE_INVOKE_MODULE__: async (request) => { + const targetEnvironmentName = request.headers.get( + ENVIRONMENT_NAME_HEADER + ); + assert( + targetEnvironmentName, + `Expected ${ENVIRONMENT_NAME_HEADER} header` + ); const payload = (await request.json()) as vite.CustomPayload; const devEnvironment = viteDevServer.environments[ - environmentName + targetEnvironmentName ] as CloudflareDevEnvironment; const result = await devEnvironment.hot.handleInvoke(payload); diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts index 3f28cb870629..6f3fec2016aa 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -25,7 +25,7 @@ import type { Unstable_Config } from "wrangler"; export type PersistState = boolean | { path: string }; interface BaseWorkerConfig { - viteEnvironment?: { name?: string }; + viteEnvironment?: { name?: string; childEnvironments?: string[] }; } interface EntryWorkerConfig extends BaseWorkerConfig { @@ -112,6 +112,7 @@ export interface WorkersResolvedConfig extends BaseResolvedConfig { configPaths: Set; cloudflareEnv: string | undefined; environmentNameToWorkerMap: Map; + environmentNameToChildEnvironmentNamesMap: Map; entryWorkerEnvironmentName: string; staticRouting: StaticRouting | undefined; rawConfigs: { @@ -338,6 +339,18 @@ export function resolvePluginConfig( [entryWorkerEnvironmentName, resolveWorker(entryWorkerConfig)], ]); + const environmentNameToChildEnvironmentNamesMap = new Map(); + + const entryWorkerChildEnvironments = + pluginConfig.viteEnvironment?.childEnvironments; + + if (entryWorkerChildEnvironments) { + environmentNameToChildEnvironmentNamesMap.set( + entryWorkerEnvironmentName, + entryWorkerChildEnvironments + ); + } + const auxiliaryWorkersResolvedConfigs: WorkerResolvedConfig[] = []; for (const auxiliaryWorker of pluginConfig.auxiliaryWorkers ?? []) { @@ -374,6 +387,16 @@ export function resolvePluginConfig( workerEnvironmentName, resolveWorker(workerResolvedConfig.config as ResolvedWorkerConfig) ); + + const auxiliaryWorkerChildEnvironments = + auxiliaryWorker.viteEnvironment?.childEnvironments; + + if (auxiliaryWorkerChildEnvironments) { + environmentNameToChildEnvironmentNamesMap.set( + workerEnvironmentName, + auxiliaryWorkerChildEnvironments + ); + } } return { @@ -382,6 +405,7 @@ export function resolvePluginConfig( cloudflareEnv, configPaths, environmentNameToWorkerMap, + environmentNameToChildEnvironmentNamesMap, entryWorkerEnvironmentName, staticRouting, remoteBindings: pluginConfig.remoteBindings ?? true, diff --git a/packages/vite-plugin-cloudflare/src/plugins/config.ts b/packages/vite-plugin-cloudflare/src/plugins/config.ts index 3b436e29959c..9f47ce24c51b 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/config.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/config.ts @@ -11,6 +11,8 @@ import { hasLocalDevVarsFileChanged } from "../dev-vars"; import { createPlugin, debuglog, getOutputDirectory } from "../utils"; import { validateWorkerEnvironmentOptions } from "../vite-config"; import { getWarningForWorkersConfigs } from "../workers-configs"; +import type { PluginContext } from "../context"; +import type { EnvironmentOptions, UserConfig } from "vite"; /** * Plugin to handle configuration and config file watching @@ -46,43 +48,7 @@ export const configPlugin = createPlugin("config", (ctx) => { deny: [...defaultDeniedFiles, ".dev.vars", ".dev.vars.*"], }, }, - environments: - ctx.resolvedPluginConfig.type === "workers" - ? { - ...Object.fromEntries( - [...ctx.resolvedPluginConfig.environmentNameToWorkerMap].map( - ([environmentName, worker]) => { - return [ - environmentName, - createCloudflareEnvironmentOptions({ - workerConfig: worker.config, - userConfig, - mode: env.mode, - environmentName, - isEntryWorker: - ctx.resolvedPluginConfig.type === "workers" && - environmentName === - ctx.resolvedPluginConfig - .entryWorkerEnvironmentName, - hasNodeJsCompat: - ctx.getNodeJsCompat(environmentName) !== undefined, - }), - ]; - } - ) - ), - client: { - build: { - outDir: getOutputDirectory(userConfig, "client"), - }, - optimizeDeps: { - // Some frameworks allow users to mix client and server code in the same file and then extract the server code. - // As the dependency optimization may happen before the server code is extracted, we should exclude Cloudflare built-ins from client optimization. - exclude: [...cloudflareBuiltInModules], - }, - }, - } - : undefined, + environments: getEnvironmentsConfig(ctx, userConfig, env.mode), builder: { buildApp: userConfig.builder?.buildApp ?? @@ -171,3 +137,76 @@ export const configPlugin = createPlugin("config", (ctx) => { }, }; }); + +/** + * Generates the environment configuration for all Worker environments. + */ +function getEnvironmentsConfig( + ctx: PluginContext, + userConfig: UserConfig, + mode: string +): Record | undefined { + if (ctx.resolvedPluginConfig.type !== "workers") { + return undefined; + } + + const workersConfig = ctx.resolvedPluginConfig; + + const workerEnvironments = Object.fromEntries( + [...workersConfig.environmentNameToWorkerMap].flatMap( + ([environmentName, worker]) => { + const childEnvironmentNames = + workersConfig.environmentNameToChildEnvironmentNamesMap.get( + environmentName + ) ?? []; + + const sharedOptions = { + workerConfig: worker.config, + userConfig, + mode, + hasNodeJsCompat: ctx.getNodeJsCompat(environmentName) !== undefined, + }; + + const parentConfig = [ + environmentName, + createCloudflareEnvironmentOptions({ + ...sharedOptions, + environmentName, + isEntryWorker: + environmentName === workersConfig.entryWorkerEnvironmentName, + isParentEnvironment: true, + }), + ] as const; + + const childConfigs = childEnvironmentNames.map( + (childEnvironmentName) => + [ + childEnvironmentName, + createCloudflareEnvironmentOptions({ + ...sharedOptions, + environmentName: childEnvironmentName, + isEntryWorker: false, + isParentEnvironment: false, + }), + ] as const + ); + + return [parentConfig, ...childConfigs]; + } + ) + ); + + return { + ...workerEnvironments, + client: { + build: { + outDir: getOutputDirectory(userConfig, "client"), + }, + optimizeDeps: { + // Some frameworks allow users to mix client and server code in the same file and then extract the server code. + // As the dependency optimization may happen before the server code is extracted, we should exclude Cloudflare built-ins from client optimization. + exclude: [...cloudflareBuiltInModules], + }, + }, + }; +} diff --git a/packages/vite-plugin-cloudflare/src/plugins/output-config.ts b/packages/vite-plugin-cloudflare/src/plugins/output-config.ts index 9477a3c2437c..47ca92da1596 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/output-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/output-config.ts @@ -16,6 +16,11 @@ export const outputConfigPlugin = createPlugin("output-config", (ctx) => { generateBundle(_, bundle) { assertIsNotPreview(ctx); + // Child environments should not emit wrangler.json or .dev.vars files + if (ctx.isChildEnvironment(this.environment.name)) { + return; + } + let outputConfig: Unstable_RawConfig | undefined; if (ctx.resolvedPluginConfig.type === "workers") { diff --git a/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts b/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts index c091392b68ed..c0ab2d9efd7e 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts @@ -15,7 +15,10 @@ export const VIRTUAL_CLIENT_FALLBACK_ENTRY = `${virtualPrefix}client-fallback-en export const virtualModulesPlugin = createPlugin("virtual-modules", (ctx) => { return { applyToEnvironment(environment) { - return ctx.getWorkerConfig(environment.name) !== undefined; + return ( + !ctx.isChildEnvironment(environment.name) && + ctx.getWorkerConfig(environment.name) !== undefined + ); }, async resolveId(source) { if (source === VIRTUAL_WORKER_ENTRY || source === VIRTUAL_EXPORT_TYPES) { diff --git a/packages/vite-plugin-cloudflare/src/shared.ts b/packages/vite-plugin-cloudflare/src/shared.ts index c295f551848f..489d11a4da01 100644 --- a/packages/vite-plugin-cloudflare/src/shared.ts +++ b/packages/vite-plugin-cloudflare/src/shared.ts @@ -8,6 +8,8 @@ export const GET_EXPORT_TYPES_PATH = // headers export const WORKER_ENTRY_PATH_HEADER = "__VITE_WORKER_ENTRY_PATH__"; export const IS_ENTRY_WORKER_HEADER = "__VITE_IS_ENTRY_WORKER__"; +export const ENVIRONMENT_NAME_HEADER = "__VITE_ENVIRONMENT_NAME__"; +export const IS_PARENT_ENVIRONMENT_HEADER = "__VITE_IS_PARENT_ENVIRONMENT__"; // virtual modules export const virtualPrefix = "virtual:cloudflare/"; diff --git a/packages/vite-plugin-cloudflare/src/workers/runner-worker/index.ts b/packages/vite-plugin-cloudflare/src/workers/runner-worker/index.ts index 3703f9733d98..4bb4962c63a3 100644 --- a/packages/vite-plugin-cloudflare/src/workers/runner-worker/index.ts +++ b/packages/vite-plugin-cloudflare/src/workers/runner-worker/index.ts @@ -7,6 +7,7 @@ import { GET_EXPORT_TYPES_PATH, INIT_PATH, IS_ENTRY_WORKER_HEADER, + IS_PARENT_ENVIRONMENT_HEADER, WORKER_ENTRY_PATH_HEADER, } from "../../shared"; import { stripInternalEnv } from "./env"; @@ -213,29 +214,36 @@ export function createWorkerEntrypointWrapper( // Initialize the module runner if (url.pathname === INIT_PATH) { - const workerEntryPathHeader = request.headers.get( - WORKER_ENTRY_PATH_HEADER + const isParentEnvironmentHeader = request.headers.get( + IS_PARENT_ENVIRONMENT_HEADER ); - if (!workerEntryPathHeader) { - throw new Error( - `Unexpected error: "${WORKER_ENTRY_PATH_HEADER}" header not set.` + // Only set Worker variables when initializing the parent environment + if (isParentEnvironmentHeader === "true") { + const workerEntryPathHeader = request.headers.get( + WORKER_ENTRY_PATH_HEADER ); - } - const isEntryWorkerHeader = request.headers.get( - IS_ENTRY_WORKER_HEADER - ); + if (!workerEntryPathHeader) { + throw new Error( + `Unexpected error: "${WORKER_ENTRY_PATH_HEADER}" header not set.` + ); + } - if (!isEntryWorkerHeader) { - throw new Error( - `Unexpected error: "${IS_ENTRY_WORKER_HEADER}" header not set.` + const isEntryWorkerHeader = request.headers.get( + IS_ENTRY_WORKER_HEADER ); + + if (!isEntryWorkerHeader) { + throw new Error( + `Unexpected error: "${IS_ENTRY_WORKER_HEADER}" header not set.` + ); + } + + workerEntryPath = decodeURIComponent(workerEntryPathHeader); + isEntryWorker = isEntryWorkerHeader === "true"; } - // Set the Worker entry path - workerEntryPath = decodeURIComponent(workerEntryPathHeader); - isEntryWorker = isEntryWorkerHeader === "true"; const stub = this.env.__VITE_RUNNER_OBJECT__.get("singleton"); // Forward the request to the Durable Object to initialize the module runner and return the WebSocket diff --git a/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts b/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts index e0b707fb6f02..c5933aaa73cd 100644 --- a/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts +++ b/packages/vite-plugin-cloudflare/src/workers/runner-worker/module-runner.ts @@ -1,7 +1,9 @@ import { DurableObject } from "cloudflare:workers"; import { ModuleRunner, ssrModuleExportsKey } from "vite/module-runner"; import { + ENVIRONMENT_NAME_HEADER, INIT_PATH, + IS_PARENT_ENVIRONMENT_HEADER, UNKNOWN_HOST, VIRTUAL_EXPORT_TYPES, VIRTUAL_WORKER_ENTRY, @@ -14,6 +16,14 @@ import type { ModuleRunnerOptions, } from "vite/module-runner"; +declare global { + // This global variable is accessed by `@vitejs/plugin-rsc` + var __VITE_ENVIRONMENT_RUNNER_IMPORT__: ( + environmentName: string, + id: string + ) => Promise; +} + /** * Custom `ModuleRunner`. * The `cachedModule` method is overridden to ensure compatibility with the Workers runtime. @@ -21,21 +31,28 @@ import type { // @ts-expect-error: `cachedModule` is private class CustomModuleRunner extends ModuleRunner { #env: WrapperEnv; + #environmentName: string; constructor( options: ModuleRunnerOptions, evaluator: ModuleEvaluator, - env: WrapperEnv + env: WrapperEnv, + environmentName: string ) { super(options, evaluator); this.#env = env; + this.#environmentName = environmentName; } override async cachedModule( url: string, importer?: string ): Promise { const stub = this.#env.__VITE_RUNNER_OBJECT__.get("singleton"); - const moduleId = await stub.getFetchedModuleId(url, importer); + const moduleId = await stub.getFetchedModuleId( + this.#environmentName, + url, + importer + ); const module = this.evaluatedModules.getModuleById(moduleId); if (!module) { @@ -46,22 +63,26 @@ class CustomModuleRunner extends ModuleRunner { } } -/** Module runner instance */ -let moduleRunner: CustomModuleRunner | undefined; +/** Module runner instances keyed by environment name */ +const moduleRunners = new Map(); + +/** The parent environment name (set explicitly via IS_PARENT_ENVIRONMENT_HEADER) */ +let parentEnvironmentName: string | undefined; + +interface EnvironmentState { + webSocket: WebSocket; + concurrentModuleNodePromises: Map>; +} /** * Durable Object that creates the module runner and handles WebSocket communication with the Vite dev server. */ export class __VITE_RUNNER_OBJECT__ extends DurableObject { - /** WebSocket connection to the Vite dev server */ - #webSocket?: WebSocket; - #concurrentModuleNodePromises = new Map< - string, - Promise - >(); + /** Per-environment state containing WebSocket and concurrent module node promises */ + #environments = new Map(); /** - * Handles fetch requests to initialize the module runner. + * Handles fetch requests to initialize a module runner for an environment. * Creates a WebSocket pair for communication with the Vite dev server and initializes the ModuleRunner. * @param request - The incoming fetch request * @returns Response with WebSocket @@ -76,45 +97,91 @@ export class __VITE_RUNNER_OBJECT__ extends DurableObject { ); } - if (moduleRunner) { - throw new Error(`Module runner already initialized`); + const environmentName = request.headers.get(ENVIRONMENT_NAME_HEADER); + + if (!environmentName) { + throw new Error( + `__VITE_RUNNER_OBJECT__ received request without ${ENVIRONMENT_NAME_HEADER} header` + ); + } + + if (moduleRunners.has(environmentName)) { + throw new Error( + `Module runner already initialized for environment: ${environmentName}` + ); + } + + const isParentEnvironment = + request.headers.get(IS_PARENT_ENVIRONMENT_HEADER) === "true"; + + if (isParentEnvironment) { + parentEnvironmentName = environmentName; } const { 0: client, 1: server } = new WebSocketPair(); server.accept(); - this.#webSocket = server; - moduleRunner = await createModuleRunner(this.env, this.#webSocket); + + const environmentState: EnvironmentState = { + webSocket: server, + concurrentModuleNodePromises: new Map(), + }; + this.#environments.set(environmentName, environmentState); + + const moduleRunner = await createModuleRunner( + this.env, + environmentState.webSocket, + environmentName + ); + moduleRunners.set(environmentName, moduleRunner); return new Response(null, { status: 101, webSocket: client }); } /** - * Sends data to the Vite dev server via the WebSocket. + * Sends data to the Vite dev server via the WebSocket for a specific environment. + * @param environmentName - The environment name * @param data - The data to send as a string * @throws Error if the WebSocket is not initialized */ - send(data: string): void { - if (!this.#webSocket) { - throw new Error(`Module runner WebSocket not initialized`); + send(environmentName: string, data: string): void { + const environmentState = this.#environments.get(environmentName); + + if (!environmentState) { + throw new Error( + `Module runner WebSocket not initialized for environment: ${environmentName}` + ); } - this.#webSocket.send(data); + environmentState.webSocket.send(data); } /** * Based on the implementation of `cachedModule` from Vite's `ModuleRunner`. * Running this in the DO enables us to share promises across invocations. + * @param environmentName - The environment name * @param url - The module URL * @param importer - The module's importer * @returns The ID of the fetched module */ async getFetchedModuleId( + environmentName: string, url: string, importer: string | undefined ): Promise { + const moduleRunner = moduleRunners.get(environmentName); + if (!moduleRunner) { - throw new Error(`Module runner not initialized`); + throw new Error( + `Module runner not initialized for environment: ${environmentName}` + ); + } + + const environmentState = this.#environments.get(environmentName); + if (!environmentState) { + throw new Error( + `Environment state not found for environment: ${environmentName}` + ); } - let cached = this.#concurrentModuleNodePromises.get(url); + let cached = environmentState.concurrentModuleNodePromises.get(url); if (!cached) { const cachedModule = moduleRunner.evaluatedModules.getModuleByUrl(url); @@ -122,9 +189,9 @@ export class __VITE_RUNNER_OBJECT__ extends DurableObject { // @ts-expect-error: `getModuleInformation` is private .getModuleInformation(url, importer, cachedModule) .finally(() => { - this.#concurrentModuleNodePromises.delete(url); + environmentState.concurrentModuleNodePromises.delete(url); }) as Promise; - this.#concurrentModuleNodePromises.set(url, cached); + environmentState.concurrentModuleNodePromises.set(url, cached); } else { // @ts-expect-error: `debug` is private moduleRunner.debug?.("[module runner] using cached module info for", url); @@ -140,9 +207,14 @@ export class __VITE_RUNNER_OBJECT__ extends DurableObject { * Creates a new module runner instance with a WebSocket transport. * @param env - The wrapper env * @param webSocket - WebSocket connection for communication with Vite dev server + * @param environmentName - The name of the environment this runner is for * @returns Configured module runner instance */ -async function createModuleRunner(env: WrapperEnv, webSocket: WebSocket) { +async function createModuleRunner( + env: WrapperEnv, + webSocket: WebSocket, + environmentName: string +) { return new CustomModuleRunner( { sourcemapInterceptor: "prepareStackTrace", @@ -166,12 +238,15 @@ async function createModuleRunner(env: WrapperEnv, webSocket: WebSocket) { // This is because `import.meta.send` may be called within a Worker's request context. // Directly using a WebSocket created in another context would be forbidden. const stub = env.__VITE_RUNNER_OBJECT__.get("singleton"); - stub.send(JSON.stringify(data)); + stub.send(environmentName, JSON.stringify(data)); }, async invoke(data) { const response = await env.__VITE_INVOKE_MODULE__.fetch( new Request(UNKNOWN_HOST, { method: "POST", + headers: { + [ENVIRONMENT_NAME_HEADER]: environmentName, + }, body: JSON.stringify(data), }) ); @@ -207,7 +282,8 @@ async function createModuleRunner(env: WrapperEnv, webSocket: WebSocket) { return import(filepath); }, }, - env + env, + environmentName ); } @@ -222,6 +298,12 @@ export async function getWorkerEntryExport( workerEntryPath: string, exportName: string ): Promise { + if (!parentEnvironmentName) { + throw new Error(`Parent environment not initialized`); + } + + const moduleRunner = moduleRunners.get(parentEnvironmentName); + if (!moduleRunner) { throw new Error(`Module runner not initialized`); } @@ -243,6 +325,12 @@ export async function getWorkerEntryExport( } export async function getWorkerEntryExportTypes() { + if (!parentEnvironmentName) { + throw new Error(`Parent environment not initialized`); + } + + const moduleRunner = moduleRunners.get(parentEnvironmentName); + if (!moduleRunner) { throw new Error(`Module runner not initialized`); } @@ -252,3 +340,28 @@ export async function getWorkerEntryExportTypes() { return getExportTypes(module); } + +/** + * Imports a module from a specific environment's module runner. + * @param environmentName - The name of the environment to import from + * @param id - The module ID to import + * @returns The imported module + * @throws Error if the environment's module runner has not been initialized + */ +async function importFromEnvironment( + environmentName: string, + id: string +): Promise { + const moduleRunner = moduleRunners.get(environmentName); + + if (!moduleRunner) { + throw new Error( + `Module runner not initialized for environment: ${environmentName}` + ); + } + + return moduleRunner.import(id); +} + +// Register the import function globally for use from worker code +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = importFromEnvironment; diff --git a/packages/vitest-pool-workers/package.json b/packages/vitest-pool-workers/package.json index cb9edc358e35..ac8f8d7f97f5 100644 --- a/packages/vitest-pool-workers/package.json +++ b/packages/vitest-pool-workers/package.json @@ -53,13 +53,9 @@ "test:ci": "vitest run" }, "dependencies": { - "birpc": "0.2.14", "cjs-module-lexer": "^1.2.3", - "devalue": "^5.3.2", "esbuild": "catalog:default", - "get-port": "^7.1.0", "miniflare": "workspace:*", - "semver": "^7.7.1", "wrangler": "workspace:*", "zod": "^3.25.76" }, @@ -73,8 +69,12 @@ "@types/semver": "^7.5.1", "@vitest/runner": "catalog:default", "@vitest/snapshot": "catalog:default", + "birpc": "0.2.14", "capnp-es": "^0.0.11", + "devalue": "^5.3.2", "eslint": "catalog:default", + "get-port": "^7.1.0", + "semver": "^7.7.1", "ts-dedent": "^2.2.0", "typescript": "catalog:default", "undici": "catalog:default", diff --git a/packages/vitest-pool-workers/scripts/bundle.mjs b/packages/vitest-pool-workers/scripts/bundle.mjs index bd7971c6cec2..7a1122861c9b 100644 --- a/packages/vitest-pool-workers/scripts/bundle.mjs +++ b/packages/vitest-pool-workers/scripts/bundle.mjs @@ -97,19 +97,13 @@ const commonOptions = { // Virtual/runtime modules "__VITEST_POOL_WORKERS_DEFINES", "__VITEST_POOL_WORKERS_USER_OBJECT", - // All npm packages (previously handled by packages: "external") - "birpc", + // External dependencies (see scripts/deps.ts for rationale) "cjs-module-lexer", - "devalue", "esbuild", - "get-port", + "zod", + // Workspace dependencies "miniflare", - "semver", - "semver/*", "wrangler", - "zod", - "undici", - "undici/*", // Peer dependencies "vitest", "vitest/*", diff --git a/packages/vitest-pool-workers/scripts/deps.ts b/packages/vitest-pool-workers/scripts/deps.ts new file mode 100644 index 000000000000..235f167e7521 --- /dev/null +++ b/packages/vitest-pool-workers/scripts/deps.ts @@ -0,0 +1,17 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/vitest-pool-workers. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Has optional native N-API bindings for performance - may not bundle correctly + "cjs-module-lexer", + + // Native binary - cannot be bundled, used to bundle test files at runtime + "esbuild", + + // Large validation library; commonly shared as a dependency + // to avoid version conflicts and bundle size duplication + "zod", +]; diff --git a/packages/workers-editor-shared/scripts/deps.ts b/packages/workers-editor-shared/scripts/deps.ts new file mode 100644 index 000000000000..b35b587b5987 --- /dev/null +++ b/packages/workers-editor-shared/scripts/deps.ts @@ -0,0 +1,11 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/workers-editor-shared. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // React split pane component - kept external as it's a React component + // that needs to integrate with the host application's React instance + "react-split-pane", +]; diff --git a/packages/wrangler/src/__tests__/core/handle-errors.test.ts b/packages/wrangler/src/__tests__/core/handle-errors.test.ts index 5d6cbd01c1cc..ce3bbd3f50ce 100644 --- a/packages/wrangler/src/__tests__/core/handle-errors.test.ts +++ b/packages/wrangler/src/__tests__/core/handle-errors.test.ts @@ -88,4 +88,262 @@ describe("handleError", () => { expect(std.err).toContain("self-signed certificate in certificate chain"); }); }); + + describe("Cloudflare API connection timeout errors", () => { + it("should show user-friendly message for api.cloudflare.com timeouts", async () => { + const error = Object.assign( + new Error("Connect Timeout Error: https://api.cloudflare.com/endpoint"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("ConnectionTimeout"); + expect(std.err).toContain("The request to Cloudflare's API timed out"); + expect(std.err).toContain("network connectivity issues"); + expect(std.err).toContain("Please check your internet connection"); + }); + + it("should show user-friendly message for dash.cloudflare.com timeouts", async () => { + const error = Object.assign( + new Error("Connect Timeout Error: https://dash.cloudflare.com/api"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("ConnectionTimeout"); + expect(std.err).toContain("The request to Cloudflare's API timed out"); + }); + + it("should handle timeout errors in error cause", async () => { + const cause = Object.assign( + new Error("timeout connecting to api.cloudflare.com"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + const error = new Error("Request failed", { cause }); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("ConnectionTimeout"); + expect(std.err).toContain("The request to Cloudflare's API timed out"); + }); + + it("should handle timeout when Cloudflare URL is in parent message", async () => { + const cause = Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }); + const error = new Error( + "Failed to connect to https://api.cloudflare.com/client/v4/accounts", + { cause } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("ConnectionTimeout"); + expect(std.err).toContain("The request to Cloudflare's API timed out"); + }); + + it("should NOT show timeout message for non-Cloudflare URLs", async () => { + const error = Object.assign( + new Error("Connect Timeout Error: https://example.com/api"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).not.toBe("ConnectionTimeout"); + expect(std.err).not.toContain( + "The request to Cloudflare's API timed out" + ); + }); + + it("should NOT show timeout message for user's dev server timeouts", async () => { + const cause = Object.assign( + new Error("timeout connecting to localhost:8787"), + { code: "UND_ERR_CONNECT_TIMEOUT" } + ); + const error = new Error("Request failed", { cause }); + + const errorType = await handleError(error, {}, []); + + expect(errorType).not.toBe("ConnectionTimeout"); + expect(std.err).not.toContain( + "The request to Cloudflare's API timed out" + ); + }); + }); + + describe("Permission errors (EPERM, EACCES)", () => { + it("should show user-friendly message for EPERM errors with path", async () => { + const error = Object.assign( + new Error( + "EPERM: operation not permitted, open '/Users/user/.wrangler/logs/wrangler.log'" + ), + { + code: "EPERM", + errno: -1, + syscall: "open", + path: "/Users/user/.wrangler/logs/wrangler.log", + } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("PermissionError"); + expect(std.err).toContain( + "A permission error occurred while accessing the file system" + ); + expect(std.err).toContain( + "Affected path: /Users/user/.wrangler/logs/wrangler.log" + ); + expect(std.err).toContain("Insufficient file or directory permissions"); + }); + + it("should show user-friendly message for EACCES errors with path", async () => { + const error = Object.assign( + new Error( + "EACCES: permission denied, open '/Users/user/Library/Preferences/.wrangler/config/default.toml'" + ), + { + code: "EACCES", + errno: -13, + syscall: "open", + path: "/Users/user/Library/Preferences/.wrangler/config/default.toml", + } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("PermissionError"); + expect(std.err).toContain( + "A permission error occurred while accessing the file system" + ); + expect(std.err).toContain( + "Affected path: /Users/user/Library/Preferences/.wrangler/config/default.toml" + ); + expect(std.err).toContain("Insufficient file or directory permissions"); + }); + + it("should show error message when path is not available", async () => { + const error = Object.assign( + new Error("EPERM: operation not permitted, mkdir"), + { + code: "EPERM", + } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("PermissionError"); + expect(std.err).toContain( + "A permission error occurred while accessing the file system" + ); + expect(std.err).toContain("Error: EPERM: operation not permitted, mkdir"); + expect(std.err).not.toContain("Affected path:"); + }); + + it("should handle EPERM errors in error cause", async () => { + const cause = Object.assign( + new Error( + "EPERM: operation not permitted, open '/var/logs/wrangler.log'" + ), + { + code: "EPERM", + path: "/var/logs/wrangler.log", + } + ); + const error = new Error("Failed to write to log file", { cause }); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("PermissionError"); + expect(std.err).toContain( + "A permission error occurred while accessing the file system" + ); + expect(std.err).toContain("Affected path: /var/logs/wrangler.log"); + }); + + it("should NOT treat non-EPERM errors as permission errors", async () => { + const error = Object.assign(new Error("ENOENT: file not found"), { + code: "ENOENT", + }); + + const errorType = await handleError(error, {}, []); + + expect(errorType).not.toBe("PermissionError"); + expect(std.err).not.toContain( + "A permission error occurred while accessing the file system" + ); + }); + }); + + describe("DNS resolution errors (ENOTFOUND)", () => { + it("should show user-friendly message for ENOTFOUND to api.cloudflare.com", async () => { + const error = Object.assign( + new Error("getaddrinfo ENOTFOUND api.cloudflare.com"), + { + code: "ENOTFOUND", + hostname: "api.cloudflare.com", + syscall: "getaddrinfo", + } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("DNSError"); + expect(std.err).toContain("Unable to resolve Cloudflare's API hostname"); + expect(std.err).toContain("api.cloudflare.com or dash.cloudflare.com"); + expect(std.err).toContain("No internet connection"); + expect(std.err).toContain("DNS resolver not configured"); + }); + + it("should show user-friendly message for ENOTFOUND to dash.cloudflare.com", async () => { + const error = Object.assign( + new Error("getaddrinfo ENOTFOUND dash.cloudflare.com"), + { + code: "ENOTFOUND", + hostname: "dash.cloudflare.com", + } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("DNSError"); + expect(std.err).toContain("Unable to resolve Cloudflare's API hostname"); + }); + + it("should handle DNS errors in error cause", async () => { + const cause = Object.assign( + new Error("getaddrinfo ENOTFOUND api.cloudflare.com"), + { + code: "ENOTFOUND", + hostname: "api.cloudflare.com", + } + ); + const error = new Error("Request failed", { cause }); + + const errorType = await handleError(error, {}, []); + + expect(errorType).toBe("DNSError"); + expect(std.err).toContain("Unable to resolve Cloudflare's API hostname"); + }); + + it("should NOT show DNS error for non-Cloudflare hostnames", async () => { + const error = Object.assign( + new Error("getaddrinfo ENOTFOUND example.com"), + { + code: "ENOTFOUND", + hostname: "example.com", + } + ); + + const errorType = await handleError(error, {}, []); + + expect(errorType).not.toBe("DNSError"); + expect(std.err).not.toContain( + "Unable to resolve Cloudflare's API hostname" + ); + }); + }); }); diff --git a/packages/wrangler/src/core/handle-errors.ts b/packages/wrangler/src/core/handle-errors.ts index d2cb5517a03f..1e5097fa43da 100644 --- a/packages/wrangler/src/core/handle-errors.ts +++ b/packages/wrangler/src/core/handle-errors.ts @@ -11,6 +11,7 @@ import { } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Cloudflare } from "cloudflare"; +import dedent from "ts-dedent"; import { createCLIParser } from ".."; import { renderError } from "../cfetch"; import { readConfig } from "../config"; @@ -57,6 +58,149 @@ function isCertificateError(e: unknown): boolean { ); } +/** + * Permission errors (EPERM, EACCES) are caused by file system + * permissions that users need to fix outside of their code, so we present + * a helpful message instead of reporting to Sentry. + * + * @param e - The error to check + * @returns `true` if the error is a permission error, `false` otherwise + */ +function isPermissionError(e: unknown): boolean { + // Check for Node.js ErrnoException with EPERM or EACCES code + if ( + e && + typeof e === "object" && + "code" in e && + (e.code === "EPERM" || e.code === "EACCES") && + "message" in e + ) { + return true; + } + + // Check in the error cause as well + if (e instanceof Error && e.cause) { + return isPermissionError(e.cause); + } + + return false; +} + +/** + * Check if a text string contains a reference to Cloudflare's API domains. + * This is a safety precaution to only handle errors related to Cloudflare's + * infrastructure, not user endpoints. + * + * @param text - The text to check for Cloudflare API references + * @returns `true` if the text contains a Cloudflare API domain, `false` otherwise + */ +function isCloudflareAPI(text: string): boolean { + return ( + text.includes("api.cloudflare.com") || text.includes("dash.cloudflare.com") + ); +} + +/** + * DNS resolution failures (ENOTFOUND) to Cloudflare's API are + * caused by network connectivity or DNS problems, so we present + * a helpful message instead of reporting to Sentry. + * + * @param e - The error to check + * @returns `true` if the error is a DNS resolution failure to Cloudflare's API, `false` otherwise + */ +function isCloudflareAPIDNSError(e: unknown): boolean { + // Only handle DNS errors to Cloudflare APIs + + const hasDNSErrorCode = (obj: unknown): boolean => { + return ( + obj !== null && + typeof obj === "object" && + "code" in obj && + obj.code === "ENOTFOUND" + ); + }; + + if (hasDNSErrorCode(e)) { + const message = e instanceof Error ? e.message : String(e); + if (isCloudflareAPI(message)) { + return true; + } + // Also check hostname property + if ( + e && + typeof e === "object" && + "hostname" in e && + typeof e.hostname === "string" + ) { + if (isCloudflareAPI(e.hostname)) { + return true; + } + } + } + + // Errors are often wrapped, so check the cause chain as well + if (e instanceof Error && e.cause && hasDNSErrorCode(e.cause)) { + const causeMessage = + e.cause instanceof Error ? e.cause.message : String(e.cause); + const parentMessage = e.message; + if (isCloudflareAPI(causeMessage) || isCloudflareAPI(parentMessage)) { + return true; + } + // Check hostname in cause + if ( + typeof e.cause === "object" && + "hostname" in e.cause && + typeof e.cause.hostname === "string" + ) { + if (isCloudflareAPI(e.cause.hostname)) { + return true; + } + } + } + + return false; +} + +/** + * Connection timeouts to Cloudflare's API are caused by slow networks or + * connectivity problems, so we present a helpful message instead of + * reporting to Sentry. + * + * @param e - The error to check + * @returns `true` if the error is a connection timeout to Cloudflare's API, `false` otherwise + */ +function isCloudflareAPIConnectionTimeoutError(e: unknown): boolean { + // Only handle timeouts to Cloudflare APIs - timeouts to user endpoints + // (e.g., in dev server or user's own APIs) may indicate actual bugs + const hasTimeoutCode = (obj: unknown): boolean => { + return ( + obj !== null && + typeof obj === "object" && + "code" in obj && + obj.code === "UND_ERR_CONNECT_TIMEOUT" + ); + }; + + if (hasTimeoutCode(e)) { + const message = e instanceof Error ? e.message : String(e); + if (isCloudflareAPI(message)) { + return true; + } + } + + // Errors are often wrapped, so check the cause chain as well + if (e instanceof Error && e.cause && hasTimeoutCode(e.cause)) { + const causeMessage = + e.cause instanceof Error ? e.cause.message : String(e.cause); + const parentMessage = e.message; + if (isCloudflareAPI(causeMessage) || isCloudflareAPI(parentMessage)) { + return true; + } + } + + return false; +} + /** * Handles an error thrown during command execution. * @@ -82,6 +226,88 @@ export async function handleError( ); } + // Handle DNS resolution errors to Cloudflare API with a user-friendly message + if (isCloudflareAPIDNSError(e)) { + mayReport = false; + errorType = "DNSError"; + logger.error(dedent` + Unable to resolve Cloudflare's API hostname (api.cloudflare.com or dash.cloudflare.com). + + This is typically caused by: + - No internet connection or network connectivity issues + - DNS resolver not configured or not responding + - Firewall or VPN blocking DNS requests + - Corporate network with restricted DNS + + Please check your network connection and DNS settings. + `); + return errorType; + } + + // Handle permission errors with a user-friendly message + if (isPermissionError(e)) { + mayReport = false; + errorType = "PermissionError"; + + // Extract the error message and path, checking both the error and its cause + const errorMessage = e instanceof Error ? e.message : String(e); + let path: string | null = null; + + // Check main error for path + if ( + e && + typeof e === "object" && + "path" in e && + typeof e.path === "string" + ) { + path = e.path; + } + + // If no path in main error, check the cause + if ( + !path && + e instanceof Error && + e.cause && + typeof e.cause === "object" && + "path" in e.cause && + typeof e.cause.path === "string" + ) { + path = e.cause.path; + } + + // Always log the full error message in debug + logger.debug(`Permission error: ${errorMessage}`); + + // Include path in main error if available, otherwise include the error message + const errorDetails = path + ? `\nAffected path: ${path}\n` + : `\nError: ${errorMessage}\n`; + + logger.error(dedent` + A permission error occurred while accessing the file system. + ${errorDetails} + This is typically caused by: + - Insufficient file or directory permissions + - Files or directories being locked by another process + - Antivirus or security software blocking access + + Please check the file permissions and try again. + `); + return errorType; + } + + // Handle connection timeout errors to Cloudflare API with a user-friendly message + if (isCloudflareAPIConnectionTimeoutError(e)) { + mayReport = false; + errorType = "ConnectionTimeout"; + logger.error( + "The request to Cloudflare's API timed out.\n" + + "This is likely due to network connectivity issues or slow network speeds.\n" + + "Please check your internet connection and try again." + ); + return errorType; + } + if (e instanceof CommandLineArgsError) { logger.error(e.message); // We are not able to ask the `wrangler` CLI parser to show help for a subcommand programmatically. diff --git a/packages/wrangler/src/d1/execute.ts b/packages/wrangler/src/d1/execute.ts index cf377137fbe3..e5c6d2b6ed1e 100644 --- a/packages/wrangler/src/d1/execute.ts +++ b/packages/wrangler/src/d1/execute.ts @@ -41,31 +41,6 @@ export type QueryResult = { query?: string; }; -// Common SQLite Codes -// See https://www.sqlite.org/rescode.html -const SQLITE_RESULT_CODES = [ - "SQLITE_ERROR", - "SQLITE_CONSTRAINT", - "SQLITE_MISMATCH", - "SQLITE_AUTH", -]; - -function isSqliteUserError(error: unknown): error is Error { - if (!(error instanceof Error)) { - return false; - } - - const message = error.message.toUpperCase(); - - for (const code of SQLITE_RESULT_CODES) { - if (message.includes(code)) { - return true; - } - } - - return false; -} - export const d1ExecuteCommand = createCommand({ metadata: { description: "Execute a command or SQL file", @@ -338,13 +313,8 @@ async function executeLocally({ try { results = await db.batch(queries.map((query) => db.prepare(query))); } catch (e: unknown) { - const cause = (e as { cause?: unknown })?.cause ?? e; - - if (isSqliteUserError(cause)) { - throw new UserError(cause.message); - } - - throw cause; + const cause = ((e as { cause?: unknown })?.cause ?? e) as Error; + throw new UserError(cause.message); } finally { await mf.dispose(); } diff --git a/packages/wrangler/src/pages/validate.ts b/packages/wrangler/src/pages/validate.ts index 990cb3707319..055227d4864a 100644 --- a/packages/wrangler/src/pages/validate.ts +++ b/packages/wrangler/src/pages/validate.ts @@ -76,7 +76,16 @@ export const validate = async (args: { fileMap: Map = new Map(), startingDir: string = dir ) => { - const files = await readdir(dir); + let files: string[]; + try { + files = await readdir(dir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") { + // File not found exeptions should be marked as user error + throw new FatalError((e as NodeJS.ErrnoException).message); + } + throw e; + } await Promise.all( files.map(async (file) => { diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 9df7747a2bca..74268b709d94 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -496,8 +496,8 @@ class ErrorOAuth2 extends UserError { } } -// For really unknown errors. -class ErrorUnknown extends Error { +// Unclassified Oauth errors +class ErrorUnknown extends UserError { toString(): string { return "ErrorUnknown"; } @@ -626,7 +626,7 @@ function toErrorClass(rawError: string): ErrorOAuth2 | ErrorUnknown { case "invalid_token": return new ErrorInvalidToken(rawError); default: - return new ErrorUnknown(); + return new ErrorUnknown(rawError); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6880fb4a2936..c200a8bf645a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1863,10 +1863,6 @@ importers: version: 3.22.3 packages/kv-asset-handler: - dependencies: - mime: - specifier: ^3.0.0 - version: 3.0.0 devDependencies: '@cloudflare/eslint-config-shared': specifier: workspace:* @@ -1886,6 +1882,9 @@ importers: eslint: specifier: catalog:default version: 9.39.1(jiti@2.6.0) + mime: + specifier: ^3.0.0 + version: 3.0.0 tsup: specifier: 8.3.0 version: 8.3.0(@microsoft/api-extractor@7.52.8(@types/node@20.19.9))(jiti@2.6.0)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) @@ -1898,24 +1897,9 @@ importers: '@cspotcode/source-map-support': specifier: 0.8.1 version: 0.8.1 - acorn: - specifier: 8.14.0 - version: 8.14.0 - acorn-walk: - specifier: 8.3.2 - version: 8.3.2 - exit-hook: - specifier: 2.2.1 - version: 2.2.1 - glob-to-regexp: - specifier: 0.4.1 - version: 0.4.1 sharp: specifier: ^0.34.5 version: 0.34.5 - stoppable: - specifier: 1.1.0 - version: 1.1.0 undici: specifier: catalog:default version: 7.14.0 @@ -1986,6 +1970,12 @@ importers: '@types/ws': specifier: ^8.5.7 version: 8.5.10 + acorn: + specifier: 8.14.0 + version: 8.14.0 + acorn-walk: + specifier: 8.3.2 + version: 8.3.2 capnp-es: specifier: ^0.0.11 version: 0.0.11(typescript@5.8.3) @@ -2019,12 +2009,18 @@ importers: eslint-plugin-prettier: specifier: ^5.0.1 version: 5.0.1(eslint-config-prettier@9.0.0(eslint@9.39.1(jiti@2.6.0)))(eslint@9.39.1(jiti@2.6.0))(prettier@3.2.5) + exit-hook: + specifier: 2.2.1 + version: 2.2.1 expect-type: specifier: ^0.15.0 version: 0.15.0 get-port: specifier: ^7.1.0 version: 7.1.0 + glob-to-regexp: + specifier: 0.4.1 + version: 0.4.1 heap-js: specifier: ^2.5.0 version: 2.5.0 @@ -2049,6 +2045,9 @@ importers: source-map: specifier: ^0.6.1 version: 0.6.1 + stoppable: + specifier: 1.1.0 + version: 1.1.0 ts-dedent: specifier: ^2.2.0 version: 2.2.0 @@ -2284,24 +2283,9 @@ importers: '@cloudflare/unenv-preset': specifier: workspace:* version: link:../unenv-preset - '@remix-run/node-fetch-server': - specifier: ^0.8.0 - version: 0.8.0 - defu: - specifier: ^6.1.4 - version: 6.1.4 - get-port: - specifier: ^7.1.0 - version: 7.1.0 miniflare: specifier: workspace:* version: link:../miniflare - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - tinyglobby: - specifier: ^0.2.12 - version: 0.2.12 unenv: specifier: 2.0.0-rc.24 version: 2.0.0-rc.24 @@ -2330,6 +2314,9 @@ importers: '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils + '@remix-run/node-fetch-server': + specifier: ^0.8.0 + version: 0.8.0 '@types/node': specifier: ^20.19.9 version: 20.19.9 @@ -2339,15 +2326,27 @@ importers: '@types/ws': specifier: ^8.5.13 version: 8.5.13 + defu: + specifier: ^6.1.4 + version: 6.1.4 + get-port: + specifier: ^7.1.0 + version: 7.1.0 magic-string: specifier: ^0.30.12 version: 0.30.17 mlly: specifier: ^1.7.4 version: 1.7.4 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 semver: specifier: ^7.7.1 version: 7.7.3 + tinyglobby: + specifier: ^0.2.12 + version: 0.2.12 tree-kill: specifier: ^1.2.2 version: 1.2.2 @@ -2472,6 +2471,27 @@ importers: specifier: workspace:* version: link:../../../wrangler + packages/vite-plugin-cloudflare/playground/child-environment: + devDependencies: + '@cloudflare/vite-plugin': + specifier: workspace:* + version: link:../.. + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../../../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 4.20260114.0 + typescript: + specifier: catalog:default + version: 5.8.3 + vite: + specifier: catalog:vite-plugin + version: 7.1.12(@types/node@20.19.9)(jiti@2.6.0)(lightningcss@1.30.2)(yaml@2.8.1) + wrangler: + specifier: workspace:* + version: link:../../../wrangler + packages/vite-plugin-cloudflare/playground/cloudflare-env: devDependencies: '@cloudflare/vite-plugin': @@ -3405,27 +3425,15 @@ importers: packages/vitest-pool-workers: dependencies: - birpc: - specifier: 0.2.14 - version: 0.2.14 cjs-module-lexer: specifier: ^1.2.3 version: 1.2.3 - devalue: - specifier: ^5.3.2 - version: 5.3.2 esbuild: specifier: catalog:default version: 0.27.0 - get-port: - specifier: ^7.1.0 - version: 7.1.0 miniflare: specifier: workspace:* version: link:../miniflare - semver: - specifier: ^7.7.1 - version: 7.7.1 wrangler: specifier: workspace:* version: link:../wrangler @@ -3460,12 +3468,24 @@ importers: '@vitest/snapshot': specifier: catalog:default version: 3.2.3 + birpc: + specifier: 0.2.14 + version: 0.2.14 capnp-es: specifier: ^0.0.11 version: 0.0.11(typescript@5.8.3) + devalue: + specifier: ^5.3.2 + version: 5.3.2 eslint: specifier: catalog:default version: 9.39.1(jiti@2.6.0) + get-port: + specifier: ^7.1.0 + version: 7.1.0 + semver: + specifier: ^7.7.1 + version: 7.7.1 ts-dedent: specifier: ^2.2.0 version: 2.2.0 @@ -4778,7 +4798,7 @@ packages: resolution: {integrity: sha512-FNcunDuTmEfQTLRLtA6zz+buIXUHj1soPvSWzzQFBC+n2lsy+CGf/NIrR3SEPCmsVNQj70/Jx2lViCpq+09YpQ==} '@cloudflare/kv-asset-handler@0.4.1': - resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} + resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==, tarball: https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.1.tgz} engines: {node: '>=18.0.0'} '@cloudflare/playwright@0.0.10': @@ -4817,7 +4837,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/unenv-preset@2.7.13': - resolution: {integrity: sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw==} + resolution: {integrity: sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw==, tarball: https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.13.tgz} peerDependencies: unenv: 2.0.0-rc.24 workerd: ^1.20251202.0 @@ -4837,104 +4857,104 @@ packages: resolution: {integrity: sha512-H8q/Msk+9Fga6iqqmff7i4mi+kraBCQWFbMEaKIRq3+HBNN5gkpizk05DSG6iIHVxCG1M3WR1FkN9CQ0ZtK4Cw==} '@cloudflare/vitest-pool-workers@0.10.15': - resolution: {integrity: sha512-eISef+JvqC5xr6WBv2+kc6WEjxuKSrZ1MdMuIwdb4vsh8olqw7WHW5pLBL/UzAhbLVlXaAL1uH9UyxIlFkJe7w==} + resolution: {integrity: sha512-eISef+JvqC5xr6WBv2+kc6WEjxuKSrZ1MdMuIwdb4vsh8olqw7WHW5pLBL/UzAhbLVlXaAL1uH9UyxIlFkJe7w==, tarball: https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.10.15.tgz} peerDependencies: '@vitest/runner': 2.0.x - 3.2.x '@vitest/snapshot': 2.0.x - 3.2.x vitest: 2.0.x - 3.2.x '@cloudflare/workerd-darwin-64@1.20251210.0': - resolution: {integrity: sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ==} + resolution: {integrity: sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20251210.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-64@1.20260111.0': - resolution: {integrity: sha512-UGAjrGLev2/CMLZy7b+v1NIXA4Hupc/QJBFlJwMqldywMcJ/iEqvuUYYuVI2wZXuXeWkgmgFP87oFDQsg78YTQ==} + resolution: {integrity: sha512-UGAjrGLev2/CMLZy7b+v1NIXA4Hupc/QJBFlJwMqldywMcJ/iEqvuUYYuVI2wZXuXeWkgmgFP87oFDQsg78YTQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260111.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-64@1.20260114.0': - resolution: {integrity: sha512-HNlsRkfNgardCig2P/5bp/dqDECsZ4+NU5XewqArWxMseqt3C5daSuptI620s4pn7Wr0ZKg7jVLH0PDEBkA+aA==} + resolution: {integrity: sha512-HNlsRkfNgardCig2P/5bp/dqDECsZ4+NU5XewqArWxMseqt3C5daSuptI620s4pn7Wr0ZKg7jVLH0PDEBkA+aA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260114.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20251210.0': - resolution: {integrity: sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw==} + resolution: {integrity: sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20251210.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260111.0': - resolution: {integrity: sha512-YFAZwidLCQVa6rKCCaiWrhA+eh87a7MUhyd9lat3KSbLBAGpYM+ORpyTXpi2Gjm3j6Mp1e/wtzcFTSeMIy2UqA==} + resolution: {integrity: sha512-YFAZwidLCQVa6rKCCaiWrhA+eh87a7MUhyd9lat3KSbLBAGpYM+ORpyTXpi2Gjm3j6Mp1e/wtzcFTSeMIy2UqA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260111.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260114.0': - resolution: {integrity: sha512-qyE1UdFnAlxzb+uCfN/d9c8icch7XRiH49/DjoqEa+bCDihTuRS7GL1RmhVIqHJhb3pX3DzxmKgQZBDBL83Inw==} + resolution: {integrity: sha512-qyE1UdFnAlxzb+uCfN/d9c8icch7XRiH49/DjoqEa+bCDihTuRS7GL1RmhVIqHJhb3pX3DzxmKgQZBDBL83Inw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260114.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-linux-64@1.20251210.0': - resolution: {integrity: sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g==} + resolution: {integrity: sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20251210.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-64@1.20260111.0': - resolution: {integrity: sha512-zx1GW6FwfOBjCV7QUCRzGRkViUtn3Is/zaaVPmm57xyy9sjtInx6/SdeBr2Y45tx9AnOP1CnaOFFdmH1P7VIEg==} + resolution: {integrity: sha512-zx1GW6FwfOBjCV7QUCRzGRkViUtn3Is/zaaVPmm57xyy9sjtInx6/SdeBr2Y45tx9AnOP1CnaOFFdmH1P7VIEg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260111.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-64@1.20260114.0': - resolution: {integrity: sha512-Z0BLvAj/JPOabzads2ddDEfgExWTlD22pnwsuNbPwZAGTSZeQa3Y47eGUWyHk+rSGngknk++S7zHTGbKuG7RRg==} + resolution: {integrity: sha512-Z0BLvAj/JPOabzads2ddDEfgExWTlD22pnwsuNbPwZAGTSZeQa3Y47eGUWyHk+rSGngknk++S7zHTGbKuG7RRg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260114.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20251210.0': - resolution: {integrity: sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA==} + resolution: {integrity: sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20251210.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260111.0': - resolution: {integrity: sha512-wFVKxNvCyjRaAcgiSnJNJAmIos3p3Vv6Uhf4pFUZ9JIxr69GNlLWlm9SdCPvtwNFAjzSoDaKzDwjj5xqpuCS6Q==} + resolution: {integrity: sha512-wFVKxNvCyjRaAcgiSnJNJAmIos3p3Vv6Uhf4pFUZ9JIxr69GNlLWlm9SdCPvtwNFAjzSoDaKzDwjj5xqpuCS6Q==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260111.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260114.0': - resolution: {integrity: sha512-kPUmEtUxUWlr9PQ64kuhdK0qyo8idPe5IIXUgi7xCD7mDd6EOe5J7ugDpbfvfbYKEjx4DpLvN2t45izyI/Sodw==} + resolution: {integrity: sha512-kPUmEtUxUWlr9PQ64kuhdK0qyo8idPe5IIXUgi7xCD7mDd6EOe5J7ugDpbfvfbYKEjx4DpLvN2t45izyI/Sodw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260114.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20251210.0': - resolution: {integrity: sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw==} + resolution: {integrity: sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20251210.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workerd-windows-64@1.20260111.0': - resolution: {integrity: sha512-zWgd77L7OI1BxgBbG+2gybDahIMgPX5iNo6e3LqcEz1Xm3KfiqgnDyMBcxeQ7xDrj7fHUGAlc//QnKvDchuUoQ==} + resolution: {integrity: sha512-zWgd77L7OI1BxgBbG+2gybDahIMgPX5iNo6e3LqcEz1Xm3KfiqgnDyMBcxeQ7xDrj7fHUGAlc//QnKvDchuUoQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260111.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workerd-windows-64@1.20260114.0': - resolution: {integrity: sha512-MJnKgm6i1jZGyt2ZHQYCnRlpFTEZcK2rv9y7asS3KdVEXaDgGF8kOns5u6YL6/+eMogfZuHRjfDS+UqRTUYIFA==} + resolution: {integrity: sha512-MJnKgm6i1jZGyt2ZHQYCnRlpFTEZcK2rv9y7asS3KdVEXaDgGF8kOns5u6YL6/+eMogfZuHRjfDS+UqRTUYIFA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260114.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workers-types@4.20260114.0': - resolution: {integrity: sha512-Q3YG2tAPvN0Z9LueWZp8RyBIJYAppb2+knSh9WXagm/W6XERGKtYfMV0z9Ij5bJksmvvR/R9jTjjEqbK27kd8g==} + resolution: {integrity: sha512-Q3YG2tAPvN0Z9LueWZp8RyBIJYAppb2+knSh9WXagm/W6XERGKtYfMV0z9Ij5bJksmvvR/R9jTjjEqbK27kd8g==, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260114.0.tgz} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} diff --git a/tools/deployments/__tests__/validate-package-dependencies.test.ts b/tools/deployments/__tests__/validate-package-dependencies.test.ts new file mode 100644 index 000000000000..fe1e044babff --- /dev/null +++ b/tools/deployments/__tests__/validate-package-dependencies.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; +import { + getAllDependencies, + getNonWorkspaceDependencies, + getPublicPackages, + validatePackageDependencies, +} from "../validate-package-dependencies"; + +describe("getAllDependencies()", () => { + it("should return empty array for undefined", () => { + expect(getAllDependencies(undefined)).toEqual([]); + }); + + it("should return empty array for empty object", () => { + expect(getAllDependencies({})).toEqual([]); + }); + + it("should return all dependency names", () => { + expect( + getAllDependencies({ + foo: "1.0.0", + bar: "workspace:*", + baz: "^2.0.0", + }) + ).toEqual(["foo", "bar", "baz"]); + }); +}); + +describe("getNonWorkspaceDependencies()", () => { + it("should return empty array for undefined", () => { + expect(getNonWorkspaceDependencies(undefined)).toEqual([]); + }); + + it("should return empty array for empty object", () => { + expect(getNonWorkspaceDependencies({})).toEqual([]); + }); + + it("should filter out workspace dependencies", () => { + expect( + getNonWorkspaceDependencies({ + foo: "1.0.0", + bar: "workspace:*", + baz: "workspace:^", + qux: "^2.0.0", + }) + ).toEqual(["foo", "qux"]); + }); + + it("should return all deps if none are workspace deps", () => { + expect( + getNonWorkspaceDependencies({ + foo: "1.0.0", + bar: "^2.0.0", + }) + ).toEqual(["foo", "bar"]); + }); +}); + +describe("validatePackageDependencies()", () => { + it("should return no errors for package with no dependencies", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { name: "test-package" }, + null + ); + expect(errors).toEqual([]); + }); + + it("should return no errors for package with only workspace dependencies", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + "@cloudflare/foo": "workspace:*", + "@cloudflare/bar": "workspace:^", + }, + }, + null + ); + expect(errors).toEqual([]); + }); + + it("should return error when package has non-workspace deps but no allowlist", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + zod: "^3.0.0", + }, + }, + null + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Package "test-package"'); + expect(errors[0]).toContain("2 non-workspace dependencies"); + expect(errors[0]).toContain("no scripts/deps.ts file"); + expect(errors[0]).toContain("lodash, zod"); + }); + + it("should return no errors when all deps are in allowlist", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + zod: "^3.0.0", + }, + }, + ["lodash", "zod"] + ); + expect(errors).toEqual([]); + }); + + it("should return error for dependency not in allowlist", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + zod: "^3.0.0", + "new-dep": "^1.0.0", + }, + }, + ["lodash", "zod"] + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Package "test-package"'); + expect(errors[0]).toContain('"new-dep"'); + expect(errors[0]).toContain("not listed in EXTERNAL_DEPENDENCIES"); + }); + + it("should return error for stale allowlist entry", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + }, + }, + ["lodash", "removed-dep"] + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Package "test-package"'); + expect(errors[0]).toContain('"removed-dep"'); + expect(errors[0]).toContain("not in dependencies or peerDependencies"); + }); + + it("should allow workspace deps in EXTERNAL_DEPENDENCIES without error", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + miniflare: "workspace:*", + }, + }, + ["lodash", "miniflare"] + ); + expect(errors).toEqual([]); + }); + + it("should check peerDependencies for stale allowlist entries", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + }, + peerDependencies: { + vite: "^5.0.0", + }, + }, + ["lodash", "vite"] + ); + expect(errors).toEqual([]); + }); + + it("should return multiple errors for multiple issues", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + "undeclared-dep": "^1.0.0", + }, + }, + ["lodash", "stale-dep"] + ); + expect(errors).toHaveLength(2); + expect(errors[0]).toContain('"undeclared-dep"'); + expect(errors[1]).toContain('"stale-dep"'); + }); + + it("should ignore devDependencies completely", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + devDependencies: { + vitest: "^1.0.0", + typescript: "^5.0.0", + esbuild: "^0.20.0", + }, + }, + null // No allowlist needed since devDependencies are ignored + ); + expect(errors).toEqual([]); + }); + + it("should ignore devDependencies when validating against allowlist", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + }, + devDependencies: { + // These should NOT trigger "not in allowlist" errors + vitest: "^1.0.0", + typescript: "^5.0.0", + "some-dev-tool": "^1.0.0", + }, + }, + ["lodash"] // Only lodash in allowlist, devDependencies should be ignored + ); + expect(errors).toEqual([]); + }); + + it("should not require devDependencies to be in allowlist", () => { + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + zod: "^3.0.0", + }, + devDependencies: { + // Many devDependencies that are NOT in the allowlist + vitest: "^1.0.0", + typescript: "^5.0.0", + esbuild: "^0.20.0", + prettier: "^3.0.0", + eslint: "^8.0.0", + }, + }, + ["lodash", "zod"] // Only runtime deps in allowlist + ); + // Should pass - devDependencies don't need to be in allowlist + expect(errors).toEqual([]); + }); + + it("should not count devDependencies as stale allowlist entries", () => { + // If a package has something in devDependencies AND in the allowlist, + // it should be flagged as stale (since devDeps are bundled, not external) + const errors = validatePackageDependencies( + "test-package", + "test-package", + { + name: "test-package", + dependencies: { + lodash: "^4.0.0", + }, + devDependencies: { + // esbuild is in devDependencies (will be bundled) + esbuild: "^0.20.0", + }, + }, + ["lodash", "esbuild"] // esbuild in allowlist but only in devDeps = stale + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"esbuild"'); + expect(errors[0]).toContain("not in dependencies or peerDependencies"); + }); +}); + +describe("getPublicPackages()", () => { + it("should return only non-private packages", async () => { + const packages = await getPublicPackages(); + + // All returned packages should be non-private + for (const pkg of packages) { + expect(pkg.packageJson.private).not.toBe(true); + } + + // Should include known public packages + const packageNames = packages.map((p) => p.packageJson.name); + expect(packageNames).toContain("wrangler"); + expect(packageNames).toContain("miniflare"); + expect(packageNames).toContain("create-cloudflare"); + }); + + it("should not include private packages", async () => { + const packages = await getPublicPackages(); + const packageNames = packages.map((p) => p.packageJson.name); + + // These are known private packages + expect(packageNames).not.toContain("@cloudflare/workers-shared"); + expect(packageNames).not.toContain("@cloudflare/cli"); + }); +}); diff --git a/tools/deployments/validate-package-dependencies.ts b/tools/deployments/validate-package-dependencies.ts new file mode 100644 index 000000000000..3fa63d47db1c --- /dev/null +++ b/tools/deployments/validate-package-dependencies.ts @@ -0,0 +1,217 @@ +/** + * Validates that packages explicitly declare their external (non-bundled) dependencies. + * + * This prevents accidental dependency chain poisoning where a dependency of ours + * has not pinned its versions and allows users to install unexpected upstream + * transitive dependencies. + * + * Packages should bundle their dependencies into the distributable code via + * devDependencies. Any dependency that MUST remain external (native binaries, + * WASM, runtime-resolved code) should be: + * 1. Listed in `dependencies` (or `peerDependencies`) in package.json + * 2. Listed in `scripts/deps.ts` with EXTERNAL_DEPENDENCIES export + * 3. Documented with a comment explaining WHY it can't be bundled + */ + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { glob } from "glob"; + +export interface PackageJSON { + name: string; + private?: boolean; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +export interface PackageInfo { + dir: string; + packageJson: PackageJSON; +} + +if (require.main === module) { + console.log("::group::Checking package dependencies"); + checkPackageDependencies() + .then((errors) => { + if (errors.length > 0) { + console.error( + "::error::Package dependency checks:" + errors.map((e) => `\n- ${e}`) + ); + } + console.log("::endgroup::"); + process.exit(errors.length > 0 ? 1 : 0); + }) + .catch((error) => { + console.log("::endgroup::"); + console.error("An unexpected error occurred", error); + process.exit(1); + }); +} + +/** + * Gets all non-private package.json files under packages/ + */ +export async function getPublicPackages(): Promise { + const packagesDir = resolve(__dirname, "../../packages"); + const packageJsonPaths = await glob("*/package.json", { + cwd: packagesDir, + absolute: true, + }); + + return packageJsonPaths + .map((packageJsonPath) => { + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf-8") + ) as PackageJSON; + return { + dir: dirname(packageJsonPath), + packageJson, + }; + }) + .filter(({ packageJson }) => !packageJson.private); +} + +/** + * Gets all dependency names from a package.json dependencies object + */ +export function getAllDependencies( + dependencies: Record | undefined +): string[] { + if (!dependencies) { + return []; + } + return Object.keys(dependencies); +} + +/** + * Gets the non-workspace dependencies from a package.json + */ +export function getNonWorkspaceDependencies( + dependencies: Record | undefined +): string[] { + if (!dependencies) { + return []; + } + return Object.entries(dependencies) + .filter(([, version]) => !version.startsWith("workspace:")) + .map(([name]) => name); +} + +/** + * Attempts to load EXTERNAL_DEPENDENCIES from a package's scripts/deps.ts + */ +export function loadExternalDependencies(packageDir: string): string[] | null { + const depsFilePath = resolve(packageDir, "scripts/deps.ts"); + + if (!existsSync(depsFilePath)) { + return null; + } + + // Use require with esbuild-register (which is already loaded) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const depsModule = require(depsFilePath) as { + EXTERNAL_DEPENDENCIES?: string[]; + }; + + if (!Array.isArray(depsModule.EXTERNAL_DEPENDENCIES)) { + return null; + } + + return depsModule.EXTERNAL_DEPENDENCIES; +} + +/** + * Validates a single package's dependencies against its allowlist. + * Returns an array of error messages (empty if valid). + */ +export function validatePackageDependencies( + packageName: string, + relativePath: string, + packageJson: PackageJSON, + externalDeps: string[] | null +): string[] { + const errors: string[] = []; + + // Get non-workspace dependencies + const nonWorkspaceDeps = getNonWorkspaceDependencies( + packageJson.dependencies + ); + + // Skip packages with no non-workspace dependencies + if (nonWorkspaceDeps.length === 0) { + return errors; + } + + // Check if allowlist exists + if (externalDeps === null) { + errors.push( + `Package "${packageName}" has ${nonWorkspaceDeps.length} non-workspace dependencies ` + + `but no scripts/deps.ts file with EXTERNAL_DEPENDENCIES export.\n` + + ` Create packages/${relativePath}/scripts/deps.ts with an EXTERNAL_DEPENDENCIES export ` + + `listing all dependencies that cannot be bundled, with comments explaining why.\n` + + ` Dependencies: ${nonWorkspaceDeps.join(", ")}` + ); + return errors; + } + + // Check for dependencies not in the allowlist + const undeclaredDeps = nonWorkspaceDeps.filter( + (dep) => !externalDeps.includes(dep) + ); + + for (const dep of undeclaredDeps) { + errors.push( + `Package "${packageName}" has dependency "${dep}" that is not listed in ` + + `EXTERNAL_DEPENDENCIES (scripts/deps.ts). Either:\n` + + ` 1. Bundle this dependency by moving it to devDependencies, or\n` + + ` 2. Add it to EXTERNAL_DEPENDENCIES with a comment explaining why it can't be bundled` + ); + } + + // Check for stale entries in the allowlist (not in dependencies or peerDependencies) + // Note: we check against ALL dependencies here (including workspace ones) because + // EXTERNAL_DEPENDENCIES is also used by the bundler to mark dependencies as external + const allDeclaredDeps = [ + ...getAllDependencies(packageJson.dependencies), + ...getAllDependencies(packageJson.peerDependencies), + ]; + + const staleDeps = externalDeps.filter( + (dep) => !allDeclaredDeps.includes(dep) + ); + + for (const dep of staleDeps) { + errors.push( + `Package "${packageName}" has "${dep}" in EXTERNAL_DEPENDENCIES but it's not in ` + + `dependencies or peerDependencies. Remove it from scripts/deps.ts.` + ); + } + + return errors; +} + +/** + * Validates that all packages properly declare their external dependencies + */ +export async function checkPackageDependencies(): Promise { + const packages = await getPublicPackages(); + const errors: string[] = []; + + for (const { dir, packageJson } of packages) { + const packageName = packageJson.name; + const relativePath = dir.split("/packages/")[1]; + console.log(`- ${packageName}`); + + const externalDeps = loadExternalDependencies(dir); + const packageErrors = validatePackageDependencies( + packageName, + relativePath, + packageJson, + externalDeps + ); + errors.push(...packageErrors); + } + + return errors; +}