Skip to content

Commit

Permalink
vite-plugin: update to use the new @cloudflare/unenv-preset (#7830)
Browse files Browse the repository at this point in the history
* vite-plugin: update to use the new `@cloudflare/unenv-preset`

* update to use Vite 6.1

* refactor: move nodejs-compat stuff into its own Vite plugin

* add changesets

* pin the cf unenv preset dependency to 1.1.1

* update sub-plugin name

* throw if we fail to resolve an aliased import

* Update virtual module prefix

* fix comment typo

* Error if there is no alias found for an import marked as a virtual node.js compat module

* move node.js compat plugin back into index.ts and do some code clean up

* revert to short-circuiting the config when in preview mode

* tweak comment
  • Loading branch information
petebacondarwin authored Feb 11, 2025
1 parent e8272b0 commit 99ba292
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 159 deletions.
5 changes: 5 additions & 0 deletions .changeset/eleven-lemons-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/vite-plugin": patch
---

add support for Vite 6.1
5 changes: 5 additions & 0 deletions .changeset/kind-ducks-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/vite-plugin": patch
---

implement the new Cloudflare unenv preset into the Vite plugin
3 changes: 2 additions & 1 deletion packages/vite-plugin-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
"test:ci": "vitest run"
},
"dependencies": {
"@cloudflare/unenv-preset": "1.1.1",
"@hattip/adapter-node": "^0.0.49",
"miniflare": "workspace:*",
"unenv": "catalog:vite-plugin",
"unenv": "2.0.0-rc.1",
"ws": "^8.18.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import assert from "node:assert";
import { builtinModules } from "node:module";
import * as vite from "vite";
import { getNodeCompatExternals } from "./node-js-compat";
import { INIT_PATH, UNKNOWN_HOST } from "./shared";
import { getOutputDirectory } from "./utils";
import type { ResolvedPluginConfig, WorkerConfig } from "./plugin-config";
Expand Down Expand Up @@ -156,7 +155,7 @@ export function createCloudflareEnvironmentOptions(
// dev pre-bundling crawling (were we not to set this input field we'd have to appropriately set
// optimizeDeps.entries in the dev config)
input: workerConfig.main,
external: [...cloudflareBuiltInModules, ...getNodeCompatExternals()],
external: [...cloudflareBuiltInModules],
},
},
optimizeDeps: {
Expand Down
131 changes: 89 additions & 42 deletions packages/vite-plugin-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import {
getPreviewMiniflareOptions,
} from "./miniflare-options";
import {
dealiasVirtualNodeJSImport,
getNodeCompatAliases,
getNodeCompatExternals,
injectGlobalCode,
resolveNodeCompatId,
isNodeCompat,
maybeStripNodeJsVirtualPrefix,
} from "./node-js-compat";
import { resolvePluginConfig } from "./plugin-config";
import { MODULE_PATTERN } from "./shared";
Expand Down Expand Up @@ -49,6 +52,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
name: "vite-plugin-cloudflare",
config(userConfig, env) {
if (env.isPreview) {
// Short-circuit the whole configuration if we are in preview mode
return { appType: "custom" };
}

Expand All @@ -70,9 +74,6 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {

return {
appType: "custom",
resolve: {
alias: getNodeCompatAliases(),
},
environments:
resolvedPluginConfig.type === "workers"
? {
Expand Down Expand Up @@ -142,37 +143,6 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
configResolved(config) {
resolvedViteConfig = config;
},
async resolveId(source) {
if (resolvedPluginConfig.type === "assets-only") {
return;
}

const workerConfig =
resolvedPluginConfig.workers[this.environment.name];
if (!workerConfig) {
return;
}

return resolveNodeCompatId(this.environment, workerConfig, source);
},
async transform(code, id) {
if (resolvedPluginConfig.type === "assets-only") {
return;
}

const workerConfig =
resolvedPluginConfig.workers[this.environment.name];

if (!workerConfig) {
return;
}

const resolvedId = await this.resolve(workerConfig.main);

if (id === resolvedId?.id) {
return injectGlobalCode(id, code, workerConfig);
}
},
generateBundle(_, bundle) {
let config: Unstable_RawConfig | undefined;

Expand Down Expand Up @@ -339,13 +309,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
// Otherwise the `vite:wasm-fallback` plugin prevents the `.wasm` extension being used for module imports.
enforce: "pre",
applyToEnvironment(environment) {
if (resolvedPluginConfig.type === "assets-only") {
return false;
}

return Object.keys(resolvedPluginConfig.workers).includes(
environment.name
);
// Note that this hook does not get called in preview mode.
return getWorkerConfig(environment.name) !== undefined;
},
async resolveId(source, importer) {
if (!source.endsWith(".wasm")) {
Expand Down Expand Up @@ -419,7 +384,89 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
}
},
},
// Plugin that can provide Node.js compatibility support for Vite Environments that are hosted in Cloudflare Workers.
{
name: "vite-plugin-cloudflare:nodejs-compat",
apply(_config, env) {
// Skip this whole plugin if we are in preview mode
return !env.isPreview;
},
config() {
// Configure Vite with the Node.js polyfill aliases
// We have to do this across the whole Vite config because it is not possible to do it per Environment.
// These aliases are to virtual modules that then get Environment specific handling in the resolveId hook.
return {
resolve: {
alias: getNodeCompatAliases(),
},
};
},
configEnvironment(environmentName) {
// Ignore Node.js external modules when building environments that use Node.js compat.
const workerConfig = getWorkerConfig(environmentName);
if (isNodeCompat(workerConfig)) {
return {
build: {
rollupOptions: {
external: getNodeCompatExternals(),
},
},
};
}
},
async resolveId(source, importer, options) {
// Handle the virtual modules that come from Node.js compat aliases.
const from = maybeStripNodeJsVirtualPrefix(source);
if (!from) {
return;
}

const workerConfig = getWorkerConfig(this.environment.name);
if (!isNodeCompat(workerConfig)) {
return this.resolve(from, importer, options);
}

const unresolvedAlias = dealiasVirtualNodeJSImport(from);
const resolvedAlias = await this.resolve(
unresolvedAlias,
import.meta.url
);
assert(
resolvedAlias,
"Failed to resolve aliased nodejs import: " + unresolvedAlias
);

if (this.environment.mode === "dev" && this.environment.depsOptimizer) {
// Make sure the dependency optimizer is aware of this aliased import
this.environment.depsOptimizer.registerMissingImport(
unresolvedAlias,
resolvedAlias.id
);
}

return resolvedAlias;
},
async transform(code, id) {
// Inject the Node.js compat globals into the entry module for Node.js compat environments.
const workerConfig = getWorkerConfig(this.environment.name);
if (!isNodeCompat(workerConfig)) {
return;
}

const resolvedId = await this.resolve(workerConfig.main);
if (id === resolvedId?.id) {
return injectGlobalCode(id, code);
}
},
},
];

function getWorkerConfig(environmentName: string) {
assert(resolvedPluginConfig, "Expected resolvedPluginConfig to be defined");
return resolvedPluginConfig.type !== "assets-only"
? resolvedPluginConfig.workers[environmentName]
: undefined;
}
}

/**
Expand Down
103 changes: 42 additions & 61 deletions packages/vite-plugin-cloudflare/src/node-js-compat.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { createRequire } from "node:module";
import assert from "node:assert";
import { cloudflare } from "@cloudflare/unenv-preset";
import MagicString from "magic-string";
import { getNodeCompat } from "miniflare";
import * as unenv from "unenv";
import { defineEnv } from "unenv";
import type { WorkerConfig } from "./plugin-config";
import type { Environment } from "vite";

const require = createRequire(import.meta.url);
const preset = unenv.env(unenv.nodeless, unenv.cloudflare);
const CLOUDFLARE_VIRTUAL_PREFIX = "\0cloudflare-";
const { env } = defineEnv({
nodeCompat: true,
presets: [cloudflare],
});

const CLOUDFLARE_VIRTUAL_PREFIX = "\0__CLOUDFLARE_NODEJS_COMPAT__";

/**
* Returns true if the given combination of compat dates and flags means that we need Node.js compatibility.
*/
export function isNodeCompat({
compatibility_date,
compatibility_flags,
}: WorkerConfig): boolean {
export function isNodeCompat(
workerConfig: WorkerConfig | undefined
): workerConfig is WorkerConfig {
if (workerConfig === undefined) {
return false;
}
const nodeCompatMode = getNodeCompat(
compatibility_date,
compatibility_flags ?? []
workerConfig.compatibility_date,
workerConfig.compatibility_flags ?? []
).mode;
if (nodeCompatMode === "v2") {
return true;
Expand All @@ -40,16 +45,8 @@ export function isNodeCompat({
* If the current environment needs Node.js compatibility,
* then inject the necessary global polyfills into the code.
*/
export function injectGlobalCode(
id: string,
code: string,
workerConfig: WorkerConfig
) {
if (!isNodeCompat(workerConfig)) {
return;
}

const injectedCode = Object.entries(preset.inject)
export function injectGlobalCode(id: string, code: string) {
const injectedCode = Object.entries(env.inject)
.map(([globalName, globalInject]) => {
if (typeof globalInject === "string") {
const moduleSpecifier = globalInject;
Expand Down Expand Up @@ -78,63 +75,47 @@ export function injectGlobalCode(
*/
export function getNodeCompatAliases() {
const aliases: Record<string, string> = {};
Object.keys(preset.alias).forEach((key) => {
Object.keys(env.alias).forEach((key) => {
// Don't create aliases for modules that are already marked as external
if (!preset.external.includes(key)) {
if (!env.external.includes(key)) {
aliases[key] = CLOUDFLARE_VIRTUAL_PREFIX + key;
}
});
return aliases;
}

/**
* Attempt to resolve the `id` to an unenv alias or polyfill.
* Get an array of modules that should be considered external.
*/
export function resolveNodeCompatId(
environment: Environment,
workerConfig: WorkerConfig,
id: string
) {
const aliased = resolveNodeAliases(id, workerConfig) ?? id;

if (aliased.startsWith("unenv/")) {
const resolvedDep = require.resolve(aliased).replace(/\.cjs$/, ".mjs");
if (environment.mode === "dev" && environment.depsOptimizer) {
const dep = environment.depsOptimizer.registerMissingImport(
aliased,
resolvedDep
);
return dep.id;
} else {
return resolvedDep;
}
}
export function getNodeCompatExternals(): string[] {
return env.external;
}

/**
* Get an array of modules that should be considered external.
* If the `source` module id starts with the virtual prefix then strip it and return the rest of the id.
* Otherwise return undefined.
*/
export function getNodeCompatExternals(): string[] {
return preset.external;
export function maybeStripNodeJsVirtualPrefix(
source: string
): string | undefined {
return source.startsWith(CLOUDFLARE_VIRTUAL_PREFIX)
? source.slice(CLOUDFLARE_VIRTUAL_PREFIX.length)
: undefined;
}

/**
* Convert any virtual module Id that was generated by the aliases returned from `getNodeCompatAliases()`
* back to real a module Id and whether it is an external (built-in) package or not.
*/
function resolveNodeAliases(source: string, workerConfig: WorkerConfig) {
if (
!source.startsWith(CLOUDFLARE_VIRTUAL_PREFIX) ||
!isNodeCompat(workerConfig)
) {
return;
}

const from = source.slice(CLOUDFLARE_VIRTUAL_PREFIX.length);
const alias = preset.alias[from];

if (alias && preset.external.includes(alias)) {
throw new Error(`Alias to external: ${source} -> ${alias}`);
}
export function dealiasVirtualNodeJSImport(source: string) {
const alias = env.alias[source];
assert(
alias,
`Expected "${source}" to have a Node.js compat alias, but none was found`
);
assert(
!env.external.includes(alias),
`Unexpected unenv alias to external module: ${source} -> ${alias}`
);
return alias;
}
Loading

0 comments on commit 99ba292

Please sign in to comment.