Skip to content

Commit

Permalink
fix unenv plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
nitedani committed Aug 15, 2024
1 parent fe34d52 commit 6cf1a68
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 160 deletions.
3 changes: 2 additions & 1 deletion packages/vike-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
261 changes: 102 additions & 159 deletions packages/vike-node/src/plugin/plugins/esbuild/unenvPlugin.ts
Original file line number Diff line number Diff line change
@@ -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':
Expand All @@ -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<T>(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)
Expand All @@ -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<string, string>, external: string[]) {
function handleAliasedNodeJSPackages(build: PluginBuild, alias: Record<string, string>, external: string[]): void {
// esbuild expects alias paths to be absolute
const aliasAbsolute = Object.fromEntries(
Object.entries(alias)
Expand Down Expand Up @@ -126,141 +128,82 @@ function handleAliasedNodeJSPackages(build: PluginBuild, alias: Record<string, s
})
}

/**
* Inject node globals defined in unenv's `inject` config via virtual modules
*/
function handleNodeJSGlobals(build: PluginBuild, inject: Record<string, string | string[]>) {
const UNENV_GLOBALS_RE = /_virtual_unenv_global_polyfill-([^.]+)\.js$/

function handleNodeJSGlobals(build: PluginBuild, inject: Record<string, string | string[]>): 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())
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 6cf1a68

Please sign in to comment.