diff --git a/packages/vike-node/package.json b/packages/vike-node/package.json index 2f8c6ae..baf1c34 100644 --- a/packages/vike-node/package.json +++ b/packages/vike-node/package.json @@ -35,7 +35,8 @@ "@vercel/nft": "^0.26.5", "esbuild": "^0.19.12", "sirv": "^2.0.4", - "unenv-nightly": "2.0.0-1721914978.1a79944" + "unenv-nightly": "2.0.0-1721914978.1a79944", + "resolve-from": "^5.0.0" }, "peerDependencies": { "vike": "^0.4.181", diff --git a/packages/vike-node/src/plugin/plugins/esbuild/unenvPlugin.ts b/packages/vike-node/src/plugin/plugins/esbuild/unenvPlugin.ts index ef72429..a63b77b 100644 --- a/packages/vike-node/src/plugin/plugins/esbuild/unenvPlugin.ts +++ b/packages/vike-node/src/plugin/plugins/esbuild/unenvPlugin.ts @@ -1,19 +1,15 @@ -// taken from https://github.com/cloudflare/workers-sdk/blob/e24939c53475228e12a3c5228aa652c6473a889f/packages/wrangler/src/deployment-bundle/esbuild-plugins/hybrid-nodejs-compat.ts - -export { unenvPlugin } +// credits: +// https://github.com/cloudflare/workers-sdk/blob/e24939c53475228e12a3c5228aa652c6473a889f/packages/wrangler/src/deployment-bundle/esbuild-plugins/hybrid-nodejs-compat.ts import type { Plugin, PluginBuild } from 'esbuild' import { builtinModules, createRequire } from 'node:module' -import nodePath from 'node:path' +import path from 'node:path' +import resolveFrom from 'resolve-from' import { cloudflare, deno, env, node, nodeless, vercel } from 'unenv-nightly' import type { Runtime } from '../../../types.js' import { assert } from '../../../utils/assert.js' import { packagePath } from '../../utils/version.js' -const require_ = createRequire(import.meta.url) - -const REQUIRED_NODE_BUILT_IN_NAMESPACE = 'node-built-in-modules' - function getEnv(runtime: Runtime) { switch (runtime) { case 'node': @@ -31,33 +27,42 @@ function getEnv(runtime: Runtime) { } } -//@ts-ignore -function replaceUnenv(obj) { - if (typeof obj === 'string') { - return obj.replace(/\bunenv\b/g, 'unenv-nightly') - } else if (Array.isArray(obj)) { - return obj.map(replaceUnenv) - } else if (typeof obj === 'object' && obj !== null) { - const newObj = {} - for (const [key, value] of Object.entries(obj)) { - //@ts-ignore - newObj[key] = replaceUnenv(value) - } - return newObj +const require_ = createRequire(import.meta.url) + +const NODE_REQUIRE_NAMESPACE = 'node-require' +const UNENV_GLOBALS_RE = /_virtual_unenv_inject-([^.]+)\.js$/ + +const UNENV_REGEX = /\bunenv\b/g +const NODEJS_MODULES_RE = new RegExp(`^(node:)?(${builtinModules.join('|')})$`) + +function replaceUnenv(value: T): T { + if (typeof value === 'string') { + return value.replace(UNENV_REGEX, 'unenv-nightly') as T + } + + if (Array.isArray(value)) { + return value.map(replaceUnenv) as T + } + + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceUnenv(v)])) as T } - return obj + + return value } -function unenvPlugin(runtime: Runtime): Plugin { +export function unenvPlugin(runtime: Runtime): Plugin { const { alias, inject, external, polyfill } = replaceUnenv(getEnv(runtime)) + // already included in polyfill delete inject.global delete inject.process delete inject.Buffer + delete inject.performance return { name: 'unenv', - setup(build) { + setup(build: PluginBuild) { handlePolyfills(build, polyfill) handleRequireCallsToNodeJSBuiltins(build) handleAliasedNodeJSPackages(build, alias, external) @@ -66,38 +71,35 @@ function unenvPlugin(runtime: Runtime): Plugin { } } -/** - * Handle polyfills by injecting them into the bundle - */ -function handlePolyfills(build: PluginBuild, polyfill: string[]) { +function handlePolyfills(build: PluginBuild, polyfill: string[]): void { if (polyfill.length === 0) return - build.initialOptions.inject = [...(build.initialOptions.inject ?? []), ...polyfill.map((id) => require_.resolve(id))] + + build.initialOptions.inject = [ + ...(build.initialOptions.inject ?? []), + ...polyfill.map((id) => resolveFrom(packagePath, id).replace(/\.cjs$/, '.mjs')) + ] } -/** - * We must convert `require()` calls for Node.js to a virtual ES Module that can be imported avoiding the require calls. - * We do this by creating a special virtual ES module that re-exports the library in an onLoad handler. - * The onLoad handler is triggered by matching the "namespace" added to the resolve. - */ -function handleRequireCallsToNodeJSBuiltins(build: PluginBuild) { - const NODEJS_MODULES_RE = new RegExp(`^(node:)?(${builtinModules.join('|')})$`) +function handleRequireCallsToNodeJSBuiltins(build: PluginBuild): void { build.onResolve({ filter: NODEJS_MODULES_RE }, (args) => { if (args.kind === 'require-call') { return { path: args.path, - namespace: REQUIRED_NODE_BUILT_IN_NAMESPACE + namespace: NODE_REQUIRE_NAMESPACE } } }) - build.onLoad({ filter: /.*/, namespace: REQUIRED_NODE_BUILT_IN_NAMESPACE }, ({ path }) => { - return { - contents: [`import libDefault from '${path}';`, 'export default libDefault;'].join('\n'), - loader: 'js' - } - }) + + build.onLoad({ filter: /.*/, namespace: NODE_REQUIRE_NAMESPACE }, ({ path: modulePath }) => ({ + contents: ` + import libDefault from '${modulePath}'; + export default libDefault; + `, + loader: 'js' + })) } -function handleAliasedNodeJSPackages(build: PluginBuild, alias: Record, external: string[]) { +function handleAliasedNodeJSPackages(build: PluginBuild, alias: Record, external: string[]): void { // esbuild expects alias paths to be absolute const aliasAbsolute = Object.fromEntries( Object.entries(alias) @@ -126,141 +128,82 @@ function handleAliasedNodeJSPackages(build: PluginBuild, alias: Record) { - const UNENV_GLOBALS_RE = /_virtual_unenv_global_polyfill-([^.]+)\.js$/ - +function handleNodeJSGlobals(build: PluginBuild, inject: Record): void { build.initialOptions.inject = [ ...(build.initialOptions.inject ?? []), - //convert unenv's inject keys to absolute specifiers of custom virtual modules that will be provided via a custom onLoad ...Object.keys(inject).map((globalName) => - nodePath.resolve(packagePath, `_virtual_unenv_global_polyfill-${encodeToLowerCase(globalName)}.js`) + path.resolve(packagePath, `_virtual_unenv_inject-${encodeToLowerCase(globalName)}.js`) ) ] - build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path }) => ({ path })) + build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => ({ + path: filePath + })) - build.onLoad({ filter: UNENV_GLOBALS_RE }, ({ path }) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - //@ts-ignore - const globalName = decodeFromLowerCase(path.match(UNENV_GLOBALS_RE)![1]) + build.onLoad({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => { + const match = filePath.match(UNENV_GLOBALS_RE) + if (!match) { + throw new Error(`Invalid global polyfill path: ${filePath}`) + } + assert(match[1]) + const globalName = decodeFromLowerCase(match[1]) const globalMapping = inject[globalName] if (typeof globalMapping === 'string') { - const possiblePaths = [globalMapping, `${globalMapping}/index`] - const found = possiblePaths.find((path) => { - try { - const found = require_.resolve(path) - if (found) { - return true - } - } catch (error) {} - }) - - return { - contents: ` - import globalVar from "${found}"; - - ${ - /* - // ESBuild's inject doesn't actually touch globalThis, so let's do it ourselves - // by creating an exportable so that we can preserve the globalThis assignment if - // the ${globalName} was found in the app, or tree-shake it, if it wasn't - // see https://esbuild.github.io/api/#inject - */ '' - } - const exportable = - ${ - /* - // mark this as a PURE call so it can be ignored and tree-shaken by ESBuild, - // when we don't detect 'process', 'global.process', or 'globalThis.process' - // in the app code - // see https://esbuild.github.io/api/#tree-shaking-and-side-effects - */ '' - } - /* @__PURE__ */ (() => { - return globalThis.${globalName} = globalVar; - })(); + return handleStringGlobalMapping(globalName, globalMapping) + } else if (Array.isArray(globalMapping)) { + return handleArrayGlobalMapping(globalName, globalMapping) + } else { + throw new Error(`Invalid global mapping for ${globalName}`) + } + }) +} - export { - exportable as '${globalName}', - exportable as 'globalThis.${globalName}', - } - ` +function handleStringGlobalMapping(globalName: string, globalMapping: string) { + // workaround for wrongly published unenv + const possiblePaths = [globalMapping, `${globalMapping}/index`] + // the absolute path of the file + let found = '' + for (const p of possiblePaths) { + try { + // mjs to support tree-shaking + found ||= resolveFrom(packagePath, p).replace(/\.cjs$/, '.mjs') + if (found) { + break } + } catch (error) { + // ignore } - //@ts-ignore - const [moduleName, exportName] = inject[globalName] + } - return { - contents: ` - import { ${exportName} } from "${moduleName}"; + if (!found) { + throw new Error(`Could not resolve global mapping for ${globalName}`) + } - ${ - /* - // ESBuild's inject doesn't actually touch globalThis, so let's do it ourselves - // by creating an exportable so that we can preserve the globalThis assignment if - // the ${globalName} was found in the app, or tree-shake it, if it wasn't - // see https://esbuild.github.io/api/#inject - */ '' - } - const exportable = - ${ - /* - // mark this as a PURE call so it can be ignored and tree-shaken by ESBuild, - // when we don't detect 'process', 'global.process', or 'globalThis.process' - // in the app code - // see https://esbuild.github.io/api/#tree-shaking-and-side-effects - */ '' - } - /* @__PURE__ */ (() => { - return globalThis.${globalName} = ${exportName}; - })(); + return { + contents: ` + import globalVar from "${found}"; + const exportable = /* @__PURE__ */ (() => globalThis.${globalName} = globalVar)(); + export { exportable as '${globalName}', exportable as 'globalThis.${globalName}' }; + ` + } +} - export { - exportable as '${globalName}', - exportable as 'global.${globalName}', - exportable as 'globalThis.${globalName}' - } - ` - } - }) +function handleArrayGlobalMapping(globalName: string, globalMapping: string[]): { contents: string } { + const [moduleName, exportName] = globalMapping + return { + contents: ` + import { ${exportName} } from "${moduleName}"; + const exportable = /* @__PURE__ */ (() => globalThis.${globalName} = ${exportName})(); + export { exportable as '${globalName}', exportable as 'global.${globalName}', exportable as 'globalThis.${globalName}' }; + ` + } } -/** - * Encodes a case sensitive string to lowercase string by prefixing all uppercase letters - * with $ and turning them into lowercase letters. - * - * This function exists because ESBuild requires that all resolved paths are case insensitive. - * Without this transformation, ESBuild will clobber /foo/bar.js with /foo/Bar.js - * - * This is important to support `inject` config for `performance` and `Performance` introduced - * in https://github.com/unjs/unenv/pull/257 - */ export function encodeToLowerCase(str: string): string { - return str.replaceAll(/\$/g, () => '$$').replaceAll(/[A-Z]/g, (letter) => `$${letter.toLowerCase()}`) + return str.replace(/\$/g, '$$').replace(/[A-Z]/g, (letter) => `$${letter.toLowerCase()}`) } -/** - * Decodes a string lowercased using `encodeToLowerCase` to the original strings - */ export function decodeFromLowerCase(str: string): string { - let out = '' - let i = 0 - while (i < str.length - 1) { - if (str[i] === '$') { - i++ - //@ts-ignore - out += str[i].toUpperCase() - } else { - out += str[i] - } - i++ - } - if (i < str.length) { - out += str[i] - } - return out + return str.replace(/\$(.)/g, (_, letter) => letter.toUpperCase()) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4384e73..06686e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: esbuild: specifier: ^0.19.12 version: 0.19.12 + resolve-from: + specifier: ^5.0.0 + version: 5.0.0 sirv: specifier: ^2.0.4 version: 2.0.4