fillPlugin.ts•7.25 kB
import crypto from 'crypto'
import * as esbuild from 'esbuild'
import os from 'os'
import path from 'path'
import resolve from 'resolve'
type LoadCache = { [K in string]: string }
type Fillers = {
[k in string]: {
imports?: string
globals?: string
contents?: string
define?: string
}
}
type FillPluginOptions = {
fillerOverrides: Fillers
defaultFillers?: boolean
triggerPredicate?: (options: esbuild.BuildOptions) => boolean
}
/**
* Bundle a polyfill with all its dependencies. We use paths to files in /tmp
* instead of direct contents so that esbuild can include things once only.
* @param cache to serve from
* @param module to be compiled
* @returns the path to the bundle
*/
const loader = (cache: LoadCache) => (module: string) => {
if (cache[module]) return cache[module]
const modulePkg = `${module}/package.json`
const resolveOpt = { includeCoreModules: false }
const modulePath = path.dirname(resolve.sync(modulePkg, resolveOpt))
const filename = `${module}${crypto.randomBytes(4).toString('hex')}.js`
const outfile = path.join(os.tmpdir(), 'esbuild', filename)
esbuild.buildSync({
format: 'cjs',
platform: 'node',
outfile: outfile,
entryPoints: [modulePath],
absWorkingDir: modulePath,
mainFields: ['browser', 'main'],
bundle: true,
minify: true,
})
return (cache[module] = outfile)
}
/**
* Creates a RegExp for filtering injections
* @param fillers to be filtered
* @returns
*/
function createImportFilter(fillers: Fillers) {
const fillerNames = Object.keys(fillers)
return new RegExp(`^${fillerNames.join('\\/?$|^')}\\/?$`)
}
/**
* Looks through the fillers and applies their `define` or `inject` (if they
* have such a field to the esbuild `options` that we passed.
* @param options from esbuild
* @param fillers to be scanned
*/
function setInjectionsAndDefinitions(fillers: Fillers, options: esbuild.BuildOptions) {
const fillerNames = Object.keys(fillers)
// we make sure that it is not empty
options.define = options.define ?? {}
options.inject = options.inject ?? []
// we scan through fillers and apply
for (const fillerName of fillerNames) {
const filler = fillers[fillerName]
if (filler.define) {
options.define[fillerName] = filler.define
}
if (filler.globals) {
options.inject.push(filler.globals)
}
}
}
/**
* Handles the resolution step where esbuild resolves the imports before
* bundling them. This allows us to inject a filler via its `path` if it was
* provided. If not, we proceed to the next `onLoad` step.
* @param fillers to use the path from
* @param args from esbuild
* @returns
*/
function onResolve(fillers: Fillers, args: esbuild.OnResolveArgs, namespace: string): esbuild.OnResolveResult {
// removes trailing slashes in imports paths
const path = args.path.replace(/\/$/, '')
const item = fillers[path]
// if a path is provided, we just replace it
if (item.imports !== undefined) {
return { path: item.imports }
}
// if not, we defer action to the loaders cb
return {
namespace,
path: path,
pluginData: args.importer,
}
}
/**
* Handles the load step where esbuild loads the contents of the imports before
* bundling them. This allows us to inject a filler via its `contents` if it was
* provided. If not, the polyfill is empty and we display an error.
* @param fillers to use the contents from
* @param args from esbuild
*/
function onLoad(fillers: Fillers, args: esbuild.OnLoadArgs): esbuild.OnLoadResult {
// display useful info if no shim has been found
if (fillers[args.path].contents === undefined) {
throw `no shim for "${args.path}" imported by "${args.pluginData}"`
}
return fillers[args.path] // inject the contents
}
export const load = loader({})
const defaultFillersConfig: Fillers = {
// enabled
events: { imports: path.join(__dirname, 'fillers', 'events.ts') },
path: { imports: path.join(__dirname, 'fillers', 'path.ts') },
tty: { imports: path.join(__dirname, 'fillers', 'tty.ts') },
util: { imports: path.join(__dirname, 'fillers', 'util.ts') },
crypto: { imports: path.join(__dirname, 'fillers', 'crypto.ts') },
'node:crypto': { imports: path.join(__dirname, 'fillers', 'crypto.ts') },
// disabled
constants: { contents: '' },
domain: { contents: '' },
http: { contents: '' },
https: { contents: '' },
inherits: { contents: '' },
os: { contents: '' },
punycode: { contents: '' },
querystring: { contents: '' },
stream: { contents: '' },
string_decoder: { contents: '' },
sys: { contents: '' },
timers: { contents: '' },
url: { contents: '' },
vm: { contents: '' },
zlib: { contents: '' },
// no shims
async_hooks: { contents: '' },
child_process: { contents: '' },
cluster: { contents: '' },
dns: { contents: '' },
dgram: { contents: '' },
fs: { imports: path.join(__dirname, 'fillers', 'fs.ts') },
http2: { contents: '' },
module: { contents: '' },
net: { contents: '' },
perf_hooks: { imports: path.join(__dirname, 'fillers', 'perf_hooks.ts') },
readline: { contents: '' },
repl: { contents: '' },
tls: { contents: '' },
// globals
buffer: {
imports: load('buffer'),
globals: path.join(__dirname, 'fillers', 'buffer.ts'),
},
process: {
globals: path.join(__dirname, 'fillers', 'process.ts'),
imports: path.join(__dirname, 'fillers', 'process.ts'),
},
performance: {
globals: path.join(__dirname, 'fillers', 'perf_hooks.ts'),
},
__dirname: { define: '"/"' },
__filename: { define: '"index.js"' },
global: {
define: 'globalThis',
},
}
export const smallBuffer = {
buffer: {
imports: path.join(__dirname, 'fillers', 'buffer-small.ts'),
globals: path.join(__dirname, 'fillers', 'buffer-small.ts'),
},
}
export const smallDecimal = {
'decimal.js': {
imports: path.join(__dirname, 'fillers', 'decimal-small.ts'),
globals: path.join(__dirname, 'fillers', 'decimal-small.ts'),
},
}
/**
* Provides a simple way to use esbuild's injection capabilities while providing
* sensible defaults for node polyfills.
* @see https://v2.parceljs.org/features/node-emulation/
* @see https://github.com/Richienb/node-polyfill-webpack-plugin/blob/master/index.js
* @param fillerOverrides override default fillers
* @returns
*/
const fillPlugin = ({ fillerOverrides, defaultFillers = true }: FillPluginOptions): esbuild.Plugin => ({
name: 'fillPlugin',
setup(build) {
const uid = Math.random().toString(36).substring(7) + ''
const namespace = `fill-plugin-${uid}`
// overrides
const fillers = {
...(defaultFillers ? defaultFillersConfig : {}),
...fillerOverrides,
}
// our first step is to update options with basic injections
setInjectionsAndDefinitions(fillers, build.initialOptions)
// allows us to change the path of a filtered import by another
build.onResolve({ filter: createImportFilter(fillers) }, (args) => {
return onResolve(fillers, args, namespace)
})
// if no path was provided it defers to virtual nsp `fill-plugin`
build.onLoad({ filter: /.*/, namespace }, (args) => {
return onLoad(fillers, args)
})
},
})
export { fillPlugin }