From 5dc70d5704531c560fdf064a1a61081f67d282c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Wed, 14 Aug 2024 09:17:03 +0200 Subject: [PATCH 1/5] add unenv plugin --- packages/vercel/package.json | 3 +- packages/vercel/src/build.ts | 46 +++-- packages/vercel/src/esbuild/unenvPlugin.ts | 223 +++++++++++++++++++++ packages/vercel/src/utils.ts | 16 ++ packages/vercel/tsup.config.ts | 1 + pnpm-lock.yaml | 26 +++ 6 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 packages/vercel/src/esbuild/unenvPlugin.ts diff --git a/packages/vercel/package.json b/packages/vercel/package.json index 9ea415cd..baa5dca0 100644 --- a/packages/vercel/package.json +++ b/packages/vercel/package.json @@ -62,6 +62,7 @@ "esbuild": "^0.23.0", "fast-glob": "^3.3.2", "magicast": "^0.3.4", - "zod": "^3.23.8" + "zod": "^3.23.8", + "unenv": "^1.10.0" } } diff --git a/packages/vercel/src/build.ts b/packages/vercel/src/build.ts index 42de2fba..e4488dd0 100644 --- a/packages/vercel/src/build.ts +++ b/packages/vercel/src/build.ts @@ -1,6 +1,6 @@ import { ResolvedConfig } from 'vite'; import glob from 'fast-glob'; -import { builtinModules } from 'module' +import { builtinModules } from 'module'; import path, { basename } from 'path'; import { getOutput, getRoot, pathRelativeTo } from './utils'; import { build, BuildOptions, type Plugin } from 'esbuild'; @@ -14,6 +14,7 @@ import { generateCode, loadFile, type ASTNode } from 'magicast'; import { getNodeVersion } from '@vercel/build-utils'; import { nodeFileTrace } from '@vercel/nft'; import { findRoot } from '@manypkg/find-root'; +import { unenvPlugin } from './esbuild/unenvPlugin'; export function getAdditionalEndpoints(resolvedConfig: ResolvedConfig) { return (resolvedConfig.vercel?.additionalEndpoints ?? []).map((e) => ({ @@ -92,29 +93,29 @@ const vercelOgPlugin = (ctx: { found: boolean; index: string }): Plugin => { }; }; -const standardBuildOptions: BuildOptions = { - bundle: true, - target: 'es2022', - format: 'esm', - platform: 'node', - logLevel: 'info', - logOverride: { - 'ignored-bare-import': 'verbose', - 'require-resolve-not-external': 'verbose', - }, - minify: false, - plugins: [], - define: { - 'process.env.NODE_ENV': '"production"', - 'import.meta.env.NODE_ENV': '"production"', - }, -}; - export async function buildFn( resolvedConfig: ResolvedConfig, entry: ViteVercelApiEntry, buildOptions?: BuildOptions, ) { + const standardBuildOptions: BuildOptions = { + bundle: true, + target: 'es2022', + format: 'esm', + platform: 'node', + logLevel: 'info', + logOverride: { + 'ignored-bare-import': 'verbose', + 'require-resolve-not-external': 'verbose', + }, + minify: false, + plugins: [], + define: { + 'process.env.NODE_ENV': '"production"', + 'import.meta.env.NODE_ENV': '"production"', + }, + }; + assert( entry.destination.length > 0, `Endpoint ${ @@ -156,7 +157,10 @@ export async function buildFn( if (entry.edge) { delete options.platform; - options.external = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)] + options.external = [ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), + ]; options.conditions = [ 'edge-light', 'worker', @@ -165,7 +169,7 @@ export async function buildFn( 'import', 'require', ]; - options.plugins?.push(edgeWasmPlugin); + options.plugins?.push(edgeWasmPlugin, unenvPlugin()); options.format = 'esm'; } else if (options.format === 'esm') { options.banner = { diff --git a/packages/vercel/src/esbuild/unenvPlugin.ts b/packages/vercel/src/esbuild/unenvPlugin.ts new file mode 100644 index 00000000..bc8efd14 --- /dev/null +++ b/packages/vercel/src/esbuild/unenvPlugin.ts @@ -0,0 +1,223 @@ +// taken from 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 } from 'node:module'; +import nodePath from 'node:path'; +import { env, nodeless, vercel } from 'unenv'; +import { packagePath } from '../utils'; + +const REQUIRED_NODE_BUILT_IN_NAMESPACE = 'node-built-in-modules'; + +export const unenvPlugin: () => Plugin = () => { + const { alias, inject, external } = env(nodeless, vercel); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { Buffer, ...rest } = inject; + return { + name: 'unenv', + setup(build) { + handleRequireCallsToNodeJSBuiltins(build); + handleAliasedNodeJSPackages(build, alias, external); + handleNodeJSGlobals(build, rest); + }, + }; +}; + +/** + * 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('|')})$`, + ); + build.onResolve({ filter: NODEJS_MODULES_RE }, (args) => { + if (args.kind === 'require-call') { + return { + path: args.path, + namespace: REQUIRED_NODE_BUILT_IN_NAMESPACE, + }; + } + }); + build.onLoad( + { filter: /.*/, namespace: REQUIRED_NODE_BUILT_IN_NAMESPACE }, + ({ path }) => { + return { + contents: [ + `import libDefault from '${path}';`, + 'export default libDefault;', + ].join('\n'), + loader: 'js', + }; + }, + ); +} + +function handleAliasedNodeJSPackages( + build: PluginBuild, + alias: Record, + external: string[], +) { + // esbuild expects alias paths to be absolute + const aliasAbsolute = Object.fromEntries( + Object.entries(alias) + .map(([key, value]) => { + let resolvedAliasPath; + try { + resolvedAliasPath = require.resolve(value); + } catch (e) { + // this is an alias for package that is not installed in the current app => ignore + resolvedAliasPath = ''; + } + + return [key, resolvedAliasPath.replace(/\.cjs$/, '.mjs')]; + }) + .filter((entry) => entry[1] !== ''), + ); + const UNENV_ALIAS_RE = new RegExp( + `^(${Object.keys(aliasAbsolute).join('|')})$`, + ); + + build.onResolve({ filter: UNENV_ALIAS_RE }, (args) => { + // Resolve the alias to its absolute path and potentially mark it as external + return { + path: aliasAbsolute[args.path], + external: external.includes(alias[args.path]), + }; + }); +} + +/** + * Inject node globals defined in unenv's `inject` config via virtual modules + */ +function handleNodeJSGlobals( + build: PluginBuild, + inject: Record, +) { + const UNENV_GLOBALS_RE = /_virtual_unenv_global_polyfill-([^.]+)\.js$/; + + 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`, + ), + ), + ]; + + build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path }) => ({ path })); + + build.onLoad({ filter: UNENV_GLOBALS_RE }, ({ path }) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const globalName = decodeFromLowerCase(path.match(UNENV_GLOBALS_RE)![1]); + const globalMapping = inject[globalName]; + + if (typeof globalMapping === 'string') { + const globalPolyfillSpecifier = globalMapping; + return { + contents: ` + import globalVar from "${globalPolyfillSpecifier}"; + + ${ + /* + // 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; + })(); + + export { + exportable as '${globalName}', + exportable as 'globalThis.${globalName}', + } + `, + }; + } + const [moduleName, exportName] = inject[globalName]; + + return { + contents: ` + import { ${exportName} } from "${moduleName}"; + + ${ + /* + // 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}; + })(); + + 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()}`); +} + +/** + * 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++; + out += str[i].toUpperCase(); + } else { + out += str[i]; + } + i++; + } + if (i < str.length) { + out += str[i]; + } + return out; +} diff --git a/packages/vercel/src/utils.ts b/packages/vercel/src/utils.ts index c54460a1..2841589c 100644 --- a/packages/vercel/src/utils.ts +++ b/packages/vercel/src/utils.ts @@ -1,5 +1,6 @@ import { normalizePath, ResolvedConfig, UserConfig } from 'vite'; import path from 'path'; +import { createRequire } from 'module'; export function getRoot(config: UserConfig | ResolvedConfig): string { return normalizePath(config.root || process.cwd()); @@ -30,3 +31,18 @@ export function pathRelativeTo( path.relative(normalizePath(path.join(root, rel)), filePath), ); } + +function getOwnPackagePath() { + const require_ = createRequire(import.meta.url); + // vercel/dist/index.cjs + const resolved = require_.resolve('vite-plugin-vercel'); + return path.resolve( + resolved, + // vercel/dist + '..', + // vercel + '..', + ); +} + +export const packagePath = getOwnPackagePath(); diff --git a/packages/vercel/tsup.config.ts b/packages/vercel/tsup.config.ts index c299f6cc..f3fc4781 100644 --- a/packages/vercel/tsup.config.ts +++ b/packages/vercel/tsup.config.ts @@ -10,5 +10,6 @@ export default defineConfig([ dts: { entry: './src/index.ts', }, + shims: true, }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7eddcb3..fb66748f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: magicast: specifier: ^0.3.4 version: 0.3.4 + unenv: + specifier: ^1.10.0 + version: 1.10.0 zod: specifier: ^3.23.8 version: 3.23.8 @@ -1738,6 +1741,9 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2687,6 +2693,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3523,6 +3534,9 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} + unenv@1.10.0: + resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} + unicode-trie@2.0.0: resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} @@ -5169,6 +5183,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.0.1 + defu@6.1.4: {} + delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -6426,6 +6442,8 @@ snapshots: mime@1.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -7226,6 +7244,14 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + unenv@1.10.0: + dependencies: + consola: 3.2.3 + defu: 6.1.4 + mime: 3.0.0 + node-fetch-native: 1.6.4 + pathe: 1.1.2 + unicode-trie@2.0.0: dependencies: pako: 0.2.9 From 7190daa3491db0623cc2fb4d182c00f3d233afe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Wed, 14 Aug 2024 09:19:06 +0200 Subject: [PATCH 2/5] remove external --- packages/vercel/src/build.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/vercel/src/build.ts b/packages/vercel/src/build.ts index e4488dd0..48990ff9 100644 --- a/packages/vercel/src/build.ts +++ b/packages/vercel/src/build.ts @@ -1,6 +1,5 @@ import { ResolvedConfig } from 'vite'; import glob from 'fast-glob'; -import { builtinModules } from 'module'; import path, { basename } from 'path'; import { getOutput, getRoot, pathRelativeTo } from './utils'; import { build, BuildOptions, type Plugin } from 'esbuild'; @@ -157,10 +156,6 @@ export async function buildFn( if (entry.edge) { delete options.platform; - options.external = [ - ...builtinModules, - ...builtinModules.map((m) => `node:${m}`), - ]; options.conditions = [ 'edge-light', 'worker', From f7bb590c0b26a40b16fe28c3bafbb8678351b776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Thu, 15 Aug 2024 05:09:16 +0200 Subject: [PATCH 3/5] unenv v2 --- packages/vercel/package.json | 2 +- packages/vercel/src/esbuild/unenvPlugin.ts | 314 ++++++++++----------- pnpm-lock.yaml | 34 +-- 3 files changed, 170 insertions(+), 180 deletions(-) diff --git a/packages/vercel/package.json b/packages/vercel/package.json index baa5dca0..d5c3c6cb 100644 --- a/packages/vercel/package.json +++ b/packages/vercel/package.json @@ -63,6 +63,6 @@ "fast-glob": "^3.3.2", "magicast": "^0.3.4", "zod": "^3.23.8", - "unenv": "^1.10.0" + "unenv-nightly": "2.0.0-1721914978.1a79944" } } diff --git a/packages/vercel/src/esbuild/unenvPlugin.ts b/packages/vercel/src/esbuild/unenvPlugin.ts index bc8efd14..f4c4656d 100644 --- a/packages/vercel/src/esbuild/unenvPlugin.ts +++ b/packages/vercel/src/esbuild/unenvPlugin.ts @@ -1,55 +1,93 @@ -// taken from https://github.com/cloudflare/workers-sdk/blob/e24939c53475228e12a3c5228aa652c6473a889f/packages/wrangler/src/deployment-bundle/esbuild-plugins/hybrid-nodejs-compat.ts +// 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 } from 'node:module'; -import nodePath from 'node:path'; -import { env, nodeless, vercel } from 'unenv'; +import { builtinModules, createRequire } from 'node:module'; +import path from 'node:path'; +import { env, nodeless, vercel } from 'unenv-nightly'; import { packagePath } from '../utils'; -const REQUIRED_NODE_BUILT_IN_NAMESPACE = 'node-built-in-modules'; +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 value; +} + +export function unenvPlugin(): Plugin { + const { alias, inject, external, polyfill } = replaceUnenv( + env(nodeless, vercel), + ); + + delete inject.global; + delete inject.process; + delete inject.Buffer; + delete inject.performance; -export const unenvPlugin: () => Plugin = () => { - const { alias, inject, external } = env(nodeless, vercel); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { Buffer, ...rest } = inject; return { name: 'unenv', - setup(build) { + setup(build: PluginBuild) { + handlePolyfills(build, polyfill); handleRequireCallsToNodeJSBuiltins(build); handleAliasedNodeJSPackages(build, alias, external); - handleNodeJSGlobals(build, rest); + handleNodeJSGlobals(build, inject); }, }; -}; - -/** - * 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 handlePolyfills(build: PluginBuild, polyfill: string[]): void { + if (polyfill.length === 0) return; + + const polyfillModules = polyfill.map((id) => ({ + path: require_.resolve(id), + name: path.basename(id, path.extname(id)), + })); + + build.initialOptions.inject = [ + ...(build.initialOptions.inject ?? []), + ...polyfillModules.map(({ path: modulePath }) => + path.resolve(packagePath, modulePath), + ), + ]; +} + +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', - }; - }, + { filter: /.*/, namespace: NODE_REQUIRE_NAMESPACE }, + ({ path: modulePath }) => ({ + contents: ` + import libDefault from '${modulePath}'; + export default libDefault; + `, + loader: 'js', + }), ); } @@ -57,167 +95,117 @@ function handleAliasedNodeJSPackages( build: PluginBuild, alias: Record, external: string[], -) { - // esbuild expects alias paths to be absolute - const aliasAbsolute = Object.fromEntries( - Object.entries(alias) - .map(([key, value]) => { - let resolvedAliasPath; - try { - resolvedAliasPath = require.resolve(value); - } catch (e) { - // this is an alias for package that is not installed in the current app => ignore - resolvedAliasPath = ''; - } +): void { + const aliasEntries = Object.entries(alias) + .map(([key, value]) => { + try { + const resolved = require_.resolve(value).replace(/\.cjs$/, '.mjs'); + return [key, resolved]; + } catch (e) { + console.warn(`Failed to resolve alias for ${key}: ${value}`); + return null; + } + }) + .filter((entry): entry is [string, string] => entry !== null); - return [key, resolvedAliasPath.replace(/\.cjs$/, '.mjs')]; - }) - .filter((entry) => entry[1] !== ''), - ); const UNENV_ALIAS_RE = new RegExp( - `^(${Object.keys(aliasAbsolute).join('|')})$`, + `^(${aliasEntries.map(([key]) => key).join('|')})$`, ); build.onResolve({ filter: UNENV_ALIAS_RE }, (args) => { - // Resolve the alias to its absolute path and potentially mark it as external - return { - path: aliasAbsolute[args.path], - external: external.includes(alias[args.path]), - }; + const aliasPath = aliasEntries.find(([key]) => key === args.path)?.[1]; + return aliasPath + ? { + path: aliasPath, + external: external.includes(alias[args.path]), + } + : undefined; }); } -/** - * Inject node globals defined in unenv's `inject` config via virtual modules - */ function handleNodeJSGlobals( build: PluginBuild, inject: Record, -) { - const UNENV_GLOBALS_RE = /_virtual_unenv_global_polyfill-([^.]+)\.js$/; - +): 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( + path.resolve( packagePath, - `_virtual_unenv_global_polyfill-${encodeToLowerCase(globalName)}.js`, + `_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 - 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}`); + } + + const globalName = decodeFromLowerCase(match[1]); const globalMapping = inject[globalName]; if (typeof globalMapping === 'string') { - const globalPolyfillSpecifier = globalMapping; - return { - contents: ` - import globalVar from "${globalPolyfillSpecifier}"; - - ${ - /* - // 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; - })(); - - export { - exportable as '${globalName}', - exportable as 'globalThis.${globalName}', - } - `, - }; + return handleStringGlobalMapping(globalName, globalMapping); + } else if (Array.isArray(globalMapping)) { + return handleArrayGlobalMapping(globalName, globalMapping); + } else { + throw new Error(`Invalid global mapping for ${globalName}`); } - const [moduleName, exportName] = inject[globalName]; + }); +} - return { - contents: ` - import { ${exportName} } from "${moduleName}"; - - ${ - /* - // 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}; - })(); - - export { - exportable as '${globalName}', - exportable as 'global.${globalName}', - exportable as 'globalThis.${globalName}' - } - `, - }; +function handleStringGlobalMapping( + globalName: string, + globalMapping: string, +): { contents: string } { + const possiblePaths = [globalMapping, `${globalMapping}/index`]; + const found = possiblePaths.find((path) => { + try { + return !!require_.resolve(path); + } catch (error) { + return false; + } }); + + if (!found) { + throw new Error(`Could not resolve global mapping for ${globalName}`); + } + + return { + contents: ` + import globalVar from "${found}"; + const exportable = /* @__PURE__ */ (() => globalThis.${globalName} = globalVar)(); + export { exportable as '${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()}`); + .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++; - 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 fb66748f..2b14161c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,9 +197,9 @@ importers: magicast: specifier: ^0.3.4 version: 0.3.4 - unenv: - specifier: ^1.10.0 - version: 1.10.0 + unenv-nightly: + specifier: 2.0.0-1721914978.1a79944 + version: 2.0.0-1721914978.1a79944 zod: specifier: ^3.23.8 version: 3.23.8 @@ -2693,11 +2693,6 @@ packages: engines: {node: '>=4'} hasBin: true - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2840,6 +2835,9 @@ packages: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} engines: {node: '>= 0.4'} + ohash@1.1.3: + resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3527,6 +3525,9 @@ packages: ufo@1.5.3: resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -3534,8 +3535,8 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} - unenv@1.10.0: - resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} + unenv-nightly@2.0.0-1721914978.1a79944: + resolution: {integrity: sha512-kUTkeoSeWS1/FselivRDhYeoFv5GRSB5VuCpQSw+HqWPVCE94JaHGS35GKgqrcAhWI1LUevUtSQar4+mXubOSQ==} unicode-trie@2.0.0: resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} @@ -6442,8 +6443,6 @@ snapshots: mime@1.6.0: {} - mime@3.0.0: {} - mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -6549,6 +6548,8 @@ snapshots: object-inspect@1.13.2: {} + ohash@1.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -7238,19 +7239,20 @@ snapshots: ufo@1.5.3: {} + ufo@1.5.4: {} + undici-types@5.26.5: {} undici@5.28.4: dependencies: '@fastify/busboy': 2.1.1 - unenv@1.10.0: + unenv-nightly@2.0.0-1721914978.1a79944: dependencies: - consola: 3.2.3 defu: 6.1.4 - mime: 3.0.0 - node-fetch-native: 1.6.4 + ohash: 1.1.3 pathe: 1.1.2 + ufo: 1.5.4 unicode-trie@2.0.0: dependencies: From e36e893fffb204bf8bc4f0f13356ed4fd99b303b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Thu, 15 Aug 2024 06:35:27 +0200 Subject: [PATCH 4/5] full support for tree-shaking --- packages/vercel/package.json | 3 +- packages/vercel/src/esbuild/unenvPlugin.ts | 86 ++++++++++++---------- pnpm-lock.yaml | 3 + 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/packages/vercel/package.json b/packages/vercel/package.json index d5c3c6cb..aa3e4859 100644 --- a/packages/vercel/package.json +++ b/packages/vercel/package.json @@ -63,6 +63,7 @@ "fast-glob": "^3.3.2", "magicast": "^0.3.4", "zod": "^3.23.8", - "unenv-nightly": "2.0.0-1721914978.1a79944" + "unenv-nightly": "2.0.0-1721914978.1a79944", + "resolve-from": "^5.0.0" } } diff --git a/packages/vercel/src/esbuild/unenvPlugin.ts b/packages/vercel/src/esbuild/unenvPlugin.ts index f4c4656d..1e7b07ce 100644 --- a/packages/vercel/src/esbuild/unenvPlugin.ts +++ b/packages/vercel/src/esbuild/unenvPlugin.ts @@ -1,9 +1,10 @@ -// credits +// 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 path from 'node:path'; +import resolveFrom from 'resolve-from'; import { env, nodeless, vercel } from 'unenv-nightly'; import { packagePath } from '../utils'; @@ -11,6 +12,7 @@ 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('|')})$`); @@ -37,6 +39,7 @@ export function unenvPlugin(): Plugin { env(nodeless, vercel), ); + // already included in polyfill delete inject.global; delete inject.process; delete inject.Buffer; @@ -56,15 +59,10 @@ export function unenvPlugin(): Plugin { function handlePolyfills(build: PluginBuild, polyfill: string[]): void { if (polyfill.length === 0) return; - const polyfillModules = polyfill.map((id) => ({ - path: require_.resolve(id), - name: path.basename(id, path.extname(id)), - })); - build.initialOptions.inject = [ ...(build.initialOptions.inject ?? []), - ...polyfillModules.map(({ path: modulePath }) => - path.resolve(packagePath, modulePath), + ...polyfill.map((id) => + resolveFrom(packagePath, id).replace(/\.cjs$/, '.mjs'), ), ]; } @@ -96,30 +94,32 @@ function handleAliasedNodeJSPackages( alias: Record, external: string[], ): void { - const aliasEntries = Object.entries(alias) - .map(([key, value]) => { - try { - const resolved = require_.resolve(value).replace(/\.cjs$/, '.mjs'); - return [key, resolved]; - } catch (e) { - console.warn(`Failed to resolve alias for ${key}: ${value}`); - return null; - } - }) - .filter((entry): entry is [string, string] => entry !== null); + // esbuild expects alias paths to be absolute + const aliasAbsolute = Object.fromEntries( + Object.entries(alias) + .map(([key, value]) => { + let resolvedAliasPath; + try { + resolvedAliasPath = require_.resolve(value); + } catch (e) { + // this is an alias for package that is not installed in the current app => ignore + resolvedAliasPath = ''; + } + return [key, resolvedAliasPath.replace(/\.cjs$/, '.mjs')]; + }) + .filter((entry) => entry[1] !== ''), + ); const UNENV_ALIAS_RE = new RegExp( - `^(${aliasEntries.map(([key]) => key).join('|')})$`, + `^(${Object.keys(aliasAbsolute).join('|')})$`, ); build.onResolve({ filter: UNENV_ALIAS_RE }, (args) => { - const aliasPath = aliasEntries.find(([key]) => key === args.path)?.[1]; - return aliasPath - ? { - path: aliasPath, - external: external.includes(alias[args.path]), - } - : undefined; + // Resolve the alias to its absolute path and potentially mark it as external + return { + path: aliasAbsolute[args.path], + external: external.includes(alias[args.path]), + }; }); } @@ -137,9 +137,13 @@ function handleNodeJSGlobals( ), ]; - build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => ({ - path: filePath, - })); + build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => { + console.log({ filePath }); + + return { + path: filePath, + }; + }); build.onLoad({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => { const match = filePath.match(UNENV_GLOBALS_RE); @@ -150,6 +154,8 @@ function handleNodeJSGlobals( const globalName = decodeFromLowerCase(match[1]); const globalMapping = inject[globalName]; + console.log({ globalMapping, filePath }); + if (typeof globalMapping === 'string') { return handleStringGlobalMapping(globalName, globalMapping); } else if (Array.isArray(globalMapping)) { @@ -160,18 +166,22 @@ function handleNodeJSGlobals( }); } -function handleStringGlobalMapping( - globalName: string, - globalMapping: string, -): { contents: string } { +function handleStringGlobalMapping(globalName: string, globalMapping: string) { + // workaround for wrongly published unenv const possiblePaths = [globalMapping, `${globalMapping}/index`]; - const found = possiblePaths.find((path) => { + // the absolute path of the file + let found = ''; + for (const p of possiblePaths) { try { - return !!require_.resolve(path); + // mjs to support tree-shaking + found ||= resolveFrom(packagePath, p).replace(/\.cjs$/, '.mjs'); + if (found) { + break; + } } catch (error) { - return false; + // ignore } - }); + } if (!found) { throw new Error(`Could not resolve global mapping for ${globalName}`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b14161c..eead4458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: magicast: specifier: ^0.3.4 version: 0.3.4 + resolve-from: + specifier: ^5.0.0 + version: 5.0.0 unenv-nightly: specifier: 2.0.0-1721914978.1a79944 version: 2.0.0-1721914978.1a79944 From b042b0b0b9ad5a6ce934da2fe6851e006cba0de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20D=C3=A1niel?= Date: Thu, 15 Aug 2024 06:38:13 +0200 Subject: [PATCH 5/5] remove console.log --- packages/vercel/src/esbuild/unenvPlugin.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/vercel/src/esbuild/unenvPlugin.ts b/packages/vercel/src/esbuild/unenvPlugin.ts index 1e7b07ce..a8dc87c1 100644 --- a/packages/vercel/src/esbuild/unenvPlugin.ts +++ b/packages/vercel/src/esbuild/unenvPlugin.ts @@ -137,13 +137,9 @@ function handleNodeJSGlobals( ), ]; - build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => { - console.log({ filePath }); - - return { - path: filePath, - }; - }); + build.onResolve({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => ({ + path: filePath, + })); build.onLoad({ filter: UNENV_GLOBALS_RE }, ({ path: filePath }) => { const match = filePath.match(UNENV_GLOBALS_RE); @@ -154,8 +150,6 @@ function handleNodeJSGlobals( const globalName = decodeFromLowerCase(match[1]); const globalMapping = inject[globalName]; - console.log({ globalMapping, filePath }); - if (typeof globalMapping === 'string') { return handleStringGlobalMapping(globalName, globalMapping); } else if (Array.isArray(globalMapping)) {