From 3a68ef631c4d7f49d2de4937305d1545b26b6eb5 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2026 09:48:05 +1000 Subject: [PATCH 1/7] Attempt 1 --- src/commonjs-compatibility.ts | 394 ++++++++++++++++++++++++++++++++++ src/compile.spec.ts | 279 +++++++++++++++++++++++- src/compile.ts | 35 ++- 3 files changed, 681 insertions(+), 27 deletions(-) create mode 100644 src/commonjs-compatibility.ts diff --git a/src/commonjs-compatibility.ts b/src/commonjs-compatibility.ts new file mode 100644 index 0000000..8058979 --- /dev/null +++ b/src/commonjs-compatibility.ts @@ -0,0 +1,394 @@ +// commonjs-compatibility.ts + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import typescript from 'typescript'; +import type { Loader, OnLoadArgs, PluginBuild } from 'esbuild'; + +export const commonJsCompatibilityInject = + '@checkdigit/typescript-config/commonjs-compatibility-inject'; +export const emptySourceMap = + '//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJtYXBwaW5ncyI6IkEifQ=='; + +export function isNodeModulePath(fileName: string): boolean { + return fileName.replaceAll('\\', '/').split('/').includes('node_modules'); +} + +const commonJsCompatibilityNamespace = 'commonjs-compatibility-inject'; +const base36Radix = 36; +const commonJsRequireAliases = Array.from( + { length: 100 }, + (_, index) => `$cjsR${index.toString(base36Radix).padStart(2, '0')}`, +); +const commonJsCompatibilityFilter = + // eslint-disable-next-line require-unicode-regexp + /^@checkdigit\/typescript-config\/commonjs-compatibility-inject$/; +// eslint-disable-next-line require-unicode-regexp +const javascriptFileFilter = /\.[cm]?[jt]sx?$/; + +function scriptKind(fileName: string): typescript.ScriptKind { + if (fileName.endsWith('.tsx')) { + return typescript.ScriptKind.TSX; + } + if ( + fileName.endsWith('.ts') || + fileName.endsWith('.mts') || + fileName.endsWith('.cts') + ) { + return typescript.ScriptKind.TS; + } + if (fileName.endsWith('.jsx')) { + return typescript.ScriptKind.JSX; + } + return typescript.ScriptKind.JS; +} + +function loader(fileName: string): Loader { + switch (scriptKind(fileName)) { + case typescript.ScriptKind.JSX: + return 'jsx'; + case typescript.ScriptKind.TS: + return 'ts'; + case typescript.ScriptKind.TSX: + return 'tsx'; + default: + return 'js'; + } +} + +function isNonReferenceIdentifier(identifier: typescript.Identifier): boolean { + const { parent } = identifier; + if (typescript.isShorthandPropertyAssignment(parent)) { + return false; + } + return ( + (Reflect.get(parent, 'name') as typescript.Node | undefined) === + identifier || + (typescript.isBindingElement(parent) && + parent.propertyName === identifier) || + (typescript.isImportSpecifier(parent) && + parent.propertyName === identifier) || + (typescript.isExportSpecifier(parent) && + parent.propertyName === identifier) || + (typescript.isQualifiedName(parent) && parent.right === identifier) || + (typescript.isLabeledStatement(parent) && parent.label === identifier) || + (typescript.isBreakOrContinueStatement(parent) && + parent.label === identifier) || + (typescript.isJsxAttribute(parent) && parent.name === identifier) + ); +} + +function isAmbientDeclaration(declaration: typescript.Declaration): boolean { + let current: typescript.Node = declaration; + while (!typescript.isSourceFile(current)) { + if ( + typescript.canHaveModifiers(current) && + typescript + .getModifiers(current) + ?.some( + (modifier) => modifier.kind === typescript.SyntaxKind.DeclareKeyword, + ) === true + ) { + return true; + } + current = current.parent; + } + return current.isDeclarationFile; +} + +function isRuntimeDeclaration(declaration: typescript.Declaration): boolean { + if ( + isAmbientDeclaration(declaration) || + typescript.isTypeOnlyImportOrExportDeclaration(declaration) + ) { + return false; + } + return ( + typescript.isBindingElement(declaration) || + typescript.isClassDeclaration(declaration) || + typescript.isClassExpression(declaration) || + typescript.isEnumDeclaration(declaration) || + typescript.isFunctionDeclaration(declaration) || + typescript.isFunctionExpression(declaration) || + typescript.isImportClause(declaration) || + typescript.isImportEqualsDeclaration(declaration) || + typescript.isImportSpecifier(declaration) || + typescript.isModuleDeclaration(declaration) || + typescript.isNamespaceImport(declaration) || + typescript.isParameter(declaration) || + typescript.isVariableDeclaration(declaration) + ); +} + +function hasRuntimeBinding(symbol: typescript.Symbol | undefined): boolean { + return symbol?.declarations?.some(isRuntimeDeclaration) === true; +} + +interface RequireIdentifier { + identifier: typescript.Identifier; + sourceFile: typescript.SourceFile; +} + +function freeRequireIdentifiers( + contents: string, + fileName: string, +): RequireIdentifier[] { + const sourceFile = typescript.createSourceFile( + fileName, + contents, + typescript.ScriptTarget.Latest, + true, + scriptKind(fileName), + ); + const compilerOptions = { + allowJs: true, + noLib: true, + noResolve: true, + } satisfies typescript.CompilerOptions; + const compilerHost = typescript.createCompilerHost(compilerOptions, true); + compilerHost.fileExists = (candidate) => candidate === fileName; + compilerHost.getSourceFile = (candidate) => + candidate === fileName ? sourceFile : undefined; + compilerHost.readFile = (candidate) => + candidate === fileName ? contents : undefined; + + const checker = typescript + .createProgram([fileName], compilerOptions, compilerHost) + .getTypeChecker(); + const identifiers: typescript.Identifier[] = []; + const visit = (node: typescript.Node): void => { + if ( + typescript.isIdentifier(node) && + node.text === 'require' && + !isNonReferenceIdentifier(node) && + !hasRuntimeBinding(checker.getSymbolAtLocation(node)) + ) { + identifiers.push(node); + } + typescript.forEachChild(node, visit); + }; + visit(sourceFile); + return identifiers.map((identifier) => ({ identifier, sourceFile })); +} + +function directRequireCall( + identifier: typescript.Identifier, +): typescript.CallExpression | undefined { + if ( + typescript.isCallExpression(identifier.parent) && + identifier.parent.expression === identifier + ) { + return identifier.parent; + } + return undefined; +} + +function isRequireResolveCall(identifier: typescript.Identifier): boolean { + return ( + typescript.isPropertyAccessExpression(identifier.parent) && + identifier.parent.expression === identifier && + identifier.parent.name.text === 'resolve' && + typescript.isCallExpression(identifier.parent.parent) && + identifier.parent.parent.expression === identifier.parent + ); +} + +function literalRequireSpecifier( + call: typescript.CallExpression, +): string | undefined { + const argument = call.arguments[0]; + if (call.arguments.length !== 1 || argument === undefined) { + return undefined; + } + if (!typescript.isStringLiteralLike(argument)) { + return undefined; + } + return argument.text; +} + +function isRelativePath(value: string): boolean { + return value.startsWith('./') || value.startsWith('../'); +} + +function globPrefix(expression: typescript.Expression): string | undefined { + if (typescript.isStringLiteralLike(expression)) { + return expression.text; + } + if (typescript.isTemplateExpression(expression)) { + return expression.head.text; + } + if ( + typescript.isBinaryExpression(expression) && + expression.operatorToken.kind === typescript.SyntaxKind.PlusToken + ) { + return globPrefix(expression.left); + } + return undefined; +} + +function isRelativeGlobRequire(call: typescript.CallExpression): boolean { + const argument = call.arguments[0]; + if (call.arguments.length !== 1 || argument === undefined) { + return false; + } + return ( + (typescript.isTemplateExpression(argument) || + (typescript.isBinaryExpression(argument) && + argument.operatorToken.kind === typescript.SyntaxKind.PlusToken)) && + isRelativePath(globPrefix(argument) ?? '') + ); +} + +function commonJsCompatibilitySource(): string { + return ` +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const commonJsRequire = createRequire(import.meta.url); +export { + dirname as __dirname, + filename as __filename, + commonJsRequire as 'import.meta.require', + ${commonJsRequireAliases.map((alias) => `commonJsRequire as ${alias}`).join(',\n ')} +};`; +} + +interface Replacement { + end: number; + start: number; + text: string; +} + +type ResolutionCache = Map>; + +function isExternalRequire( + specifier: string, + args: OnLoadArgs, + pluginBuild: PluginBuild, + resolutionCache: ResolutionCache, +): Promise { + const key = `${args.namespace}\0${args.path}\0${specifier}`; + const cached = resolutionCache.get(key); + if (cached !== undefined) { + return cached; + } + const resolution = pluginBuild + .resolve(specifier, { + importer: args.path, + kind: 'require-call', + namespace: args.namespace, + resolveDir: path.dirname(args.path), + }) + .then((result) => result.external); + resolutionCache.set(key, resolution); + return resolution; +} + +async function runtimeRequireReplacement( + identifier: typescript.Identifier, + sourceFile: typescript.SourceFile, + args: OnLoadArgs, + pluginBuild: PluginBuild, + requireAlias: string, + resolutionCache: ResolutionCache, +): Promise { + if (!isRequireResolveCall(identifier)) { + const call = directRequireCall(identifier); + if (call !== undefined && isRelativeGlobRequire(call)) { + return undefined; + } + const literal = + call === undefined ? undefined : literalRequireSpecifier(call); + if ( + literal !== undefined && + !(await isExternalRequire(literal, args, pluginBuild, resolutionCache)) + ) { + return undefined; + } + } + return { + start: identifier.getStart(sourceFile), + end: identifier.getEnd(), + text: typescript.isShorthandPropertyAssignment(identifier.parent) + ? `require: ${requireAlias}` + : requireAlias, + }; +} + +function isReplacement(value: Replacement | undefined): value is Replacement { + return value !== undefined; +} + +async function rewriteRuntimeRequires( + args: OnLoadArgs, + pluginBuild: PluginBuild, + resolutionCache: ResolutionCache, +) { + const contents = await fs.readFile(args.path, 'utf8'); + if (!contents.includes('require')) { + return undefined; + } + const requireAlias = + commonJsRequireAliases.find((alias) => !contents.includes(alias)) ?? + 'import.meta.require'; + const replacements = ( + await Promise.all( + freeRequireIdentifiers(contents, args.path).map( + async ({ identifier, sourceFile }) => + runtimeRequireReplacement( + identifier, + sourceFile, + args, + pluginBuild, + requireAlias, + resolutionCache, + ), + ), + ) + ).filter(isReplacement); + if (replacements.length === 0) { + return undefined; + } + let rewritten = contents; + for (const { start, end, text } of replacements.reverse()) { + rewritten = `${rewritten.slice(0, start)}${text}${rewritten.slice(end)}`; + } + if ( + isNodeModulePath(args.path) && + (args.path.endsWith('.js') || args.path.endsWith('.mjs')) + ) { + rewritten += `\n${emptySourceMap}`; + } + return { contents: rewritten, loader: loader(args.path) }; +} + +export default function commonJsCompatibility(): ( + pluginBuild: PluginBuild, +) => void { + return (pluginBuild: PluginBuild) => { + const resolutionCache: ResolutionCache = new Map(); + pluginBuild.onResolve({ filter: commonJsCompatibilityFilter }, (args) => + args.kind === 'entry-point' + ? { + path: commonJsCompatibilityInject, + namespace: commonJsCompatibilityNamespace, + } + : undefined, + ); + pluginBuild.onLoad( + { + filter: commonJsCompatibilityFilter, + namespace: commonJsCompatibilityNamespace, + }, + () => ({ contents: commonJsCompatibilitySource(), loader: 'js' }), + ); + pluginBuild.onLoad( + { filter: javascriptFileFilter, namespace: 'file' }, + async (args) => + rewriteRuntimeRequires(args, pluginBuild, resolutionCache), + ); + }; +} diff --git a/src/compile.spec.ts b/src/compile.spec.ts index 2fcbdda..bb12f28 100644 --- a/src/compile.spec.ts +++ b/src/compile.spec.ts @@ -1,19 +1,25 @@ // compile.spec.ts import { strict as assert } from 'node:assert'; +import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; import compile from './compile.ts'; +import { isNodeModulePath } from './commonjs-compatibility.ts'; -const commonJsCompatabilityBanner = `import { createRequire as __createRequire } from "node:module"; -import { fileURLToPath as __fileURLToPath } from "node:url"; -import { default as __path } from "node:path"; -const __filename = __fileURLToPath(import.meta.url); -const __dirname = __path.dirname(__filename); -const require = __createRequire(import.meta.url);`; +const execFileAsync = promisify(execFile); + +const commonJsCompatibilityInject = `import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +var filename = fileURLToPath(import.meta.url); +var dirname = path.dirname(filename); +var commonJsRequire = createRequire(import.meta.url);`; const singleModule = { [`index.ts`]: `export const hello = 'world';`, @@ -119,6 +125,97 @@ async function writeOutput({ ); } +async function writeRequireFixture( + inDir: string, + outDir: string, + value: string, +): Promise { + await writeInput(inDir, { + 'index.ts': ` +declare const require: NodeJS.Require; +const runtimeModule: string = './runtime.cjs'; +const bundledModule = require('./bundled.cjs') as { value: string }; +const concatName: string = 'first'; +const templateName: string = 'second'; +const indirectRequire = { require }.require; +export const bundledValue: string = bundledModule.value; +export const globValues: string[] = [ + (require('./glob-concat-' + concatName + '.cjs') as { value: string }).value, + (require(\`./glob-template-\${templateName}.cjs\`) as { value: string }).value, +]; +export const propertyValues: string[] = [ + ({ require: () => 'property' }).require(), + ({ require: () => 'explicit' }).require(), +]; +export function loadRuntime(): { value: string } { + return require(runtimeModule) as { value: string }; +} +export function loadIndirect(): { value: string } { + return indirectRequire(runtimeModule) as { value: string }; +} +export function loadExternal(): { value: string } { + return require('./external.cjs') as { value: string }; +} +const resolveDynamic: string = './resolve-dynamic.cjs'; +export function resolveTargets(): string[] { + return [ + require.resolve('./resolve-internal.cjs'), + require.resolve('./resolve-external.cjs'), + require.resolve(resolveDynamic), + require.resolve('./resolve-options.cjs', { paths: [__dirname] }), + ]; +} +interface Dependency { external(): { value: string }; load(): { value: string } } +const cjsDependency = require('./dependency.cjs') as Dependency; +const jsDependency = require('./dependency.js') as Dependency; +const mjsDependency = require('./dependency.mjs') as Dependency; +export function dependencyValues(): string[] { + return [ + cjsDependency.load().value, cjsDependency.external().value, + jsDependency.load().value, jsDependency.external().value, + mjsDependency.load().value, mjsDependency.external().value, + ]; +} +`, + 'bundled.cjs': `module.exports = { value: '${value} bundled' };`, + 'glob-concat-first.cjs': `module.exports = { value: '${value} concat glob' };`, + 'glob-template-second.cjs': `module.exports = { value: '${value} template glob' };`, + 'dependency.cjs': `const name = './dependency-cjs-runtime.cjs'; module.exports = { load: () => require(name), external: () => require('./dependency-cjs-external.cjs') };`, + 'dependency.js': `const name = './dependency-js-runtime.cjs'; module.exports = { load: () => require(name), external: () => require('./dependency-js-external.cjs') };`, + 'dependency.mjs': `const name = './dependency-mjs-runtime.cjs'; export const load = () => require(name); export const external = () => require('./dependency-mjs-external.cjs');`, + }); + await writeOutput( + await compile({ + type: 'module', + entryPoint: 'index.ts', + outFile: 'index.mjs', + inDir, + outDir, + external: [ + './external.cjs', + './resolve-external.cjs', + './dependency-cjs-external.cjs', + './dependency-js-external.cjs', + './dependency-mjs-external.cjs', + ], + }), + ); + await writeInput(outDir, { + 'runtime.cjs': `module.exports = { value: '${value} runtime' };`, + 'external.cjs': `module.exports = { value: '${value} external' };`, + 'resolve-internal.cjs': `module.exports = {};`, + 'resolve-external.cjs': `module.exports = {};`, + 'resolve-dynamic.cjs': `module.exports = {};`, + 'resolve-options.cjs': `module.exports = {};`, + 'dependency-cjs-runtime.cjs': `module.exports = { value: '${value} cjs dependency' };`, + 'dependency-js-runtime.cjs': `module.exports = { value: '${value} js dependency' };`, + 'dependency-mjs-runtime.cjs': `module.exports = { value: '${value} mjs dependency' };`, + 'dependency-cjs-external.cjs': `module.exports = { value: '${value} cjs external' };`, + 'dependency-js-external.cjs': `module.exports = { value: '${value} js external' };`, + 'dependency-mjs-external.cjs': `module.exports = { value: '${value} mjs external' };`, + }); +} + function convert(outputFiles: { path: string; text: string }[]) { return Object.fromEntries( outputFiles.map((file) => [ @@ -132,6 +229,15 @@ function convert(outputFiles: { path: string; text: string }[]) { } describe('compile', () => { + it('should identify node_modules path segments on all platforms', () => { + assert.equal(isNodeModulePath('/repo/node_modules/package/index.js'), true); + assert.equal( + isNodeModulePath('C:\\repo\\node_modules\\package\\index.js'), + true, + ); + assert.equal(isNodeModulePath('/repo/node_modules-cache/index.js'), false); + }); + it('should not build bad code', async () => { const id = crypto.randomUUID(); const inDir = path.join(os.tmpdir(), `in-dir-${id}`); @@ -270,7 +376,7 @@ describe('compile', () => { ); assert.deepEqual(await read(outDir), { 'two-modules.mjs': - `${commonJsCompatabilityBanner}\n\n` + + `${commonJsCompatibilityInject}\n\n` + `var hello = "world";\n` + `\n` + `var two_modules_default = hello + "world";\n` + @@ -300,7 +406,7 @@ describe('compile', () => { ); assert.deepEqual(await read(outDir), { 'index.mjs': - `${commonJsCompatabilityBanner}\n\n` + + `${commonJsCompatibilityInject}\n\n` + `var hello = "world";\n` + `\n` + `import util from "node:util";\n` + @@ -333,7 +439,7 @@ describe('compile', () => { }); assert.deepEqual(convert(result.outputFiles), { 'index.mjs': - `${commonJsCompatabilityBanner}\n\n` + + `${commonJsCompatibilityInject}\n\n` + `import { hello as test } from "test-esm-module";\n` + `import util from "node:util";\n` + `var hello = { test, message: util.format("hello %s", "world") };\n` + @@ -342,4 +448,159 @@ describe('compile', () => { `};\n`, }); }); + + it('should inject CommonJS globals without conflicting with a local __dirname', async () => { + const id = crypto.randomUUID(); + const inDir = path.join(os.tmpdir(), `in-dir-${id}`, 'src'); + const outDir = path.join(os.tmpdir(), `out-dir-${id}`, 'build'); + await writeInput(inDir, { + 'index.ts': ` +const __dirname = 'declared in project'; +const __filename = 'declared filename'; +const require = () => 'declared require'; +export const declaredDirname: string = __dirname; +export const declaredFilename: string = __filename; +export const declaredRequire: string = require(); +export { compatibility } from './compatibility.ts'; +`, + 'compatibility.ts': ` +const requiredModule = './required.cjs'; +export const compatibility: { + dirname: string; + filename: string; + required: { value: string }; +} = { + dirname: __dirname, + filename: __filename, + required: require(requiredModule) as { value: string }, +}; +`, + }); + await writeOutput( + await compile({ + type: 'module', + entryPoint: 'index.ts', + outFile: 'index.mjs', + inDir, + outDir, + }), + ); + await fs.writeFile( + path.join(outDir, 'required.cjs'), + `module.exports = { value: 'required value' };`, + ); + + const moduleUrl = pathToFileURL(path.join(outDir, 'index.mjs')).href; + const realOutDir = await fs.realpath(outDir); + const { stdout } = await execFileAsync(process.execPath, [ + '--input-type=module', + '--eval', + `const output = await import(${JSON.stringify(moduleUrl)}); console.log(JSON.stringify({ compatibility: output.compatibility, declaredDirname: output.declaredDirname, declaredFilename: output.declaredFilename, declaredRequire: output.declaredRequire }));`, + ]); + + assert.deepEqual(JSON.parse(stdout) as unknown, { + compatibility: { + dirname: realOutDir, + filename: path.join(realOutDir, 'index.mjs'), + required: { value: 'required value' }, + }, + declaredDirname: 'declared in project', + declaredFilename: 'declared filename', + declaredRequire: 'declared require', + }); + }); + + it('should isolate runtime require resolution between bundles', async () => { + const id = crypto.randomUUID(); + const firstInDir = path.join(os.tmpdir(), `first-in-dir-${id}`, 'src'); + const firstOutDir = path.join(os.tmpdir(), `first-out-dir-${id}`, 'build'); + const secondInDir = path.join(os.tmpdir(), `second-in-dir-${id}`, 'src'); + const secondOutDir = path.join( + os.tmpdir(), + `second-out-dir-${id}`, + 'build', + ); + await writeRequireFixture(firstInDir, firstOutDir, 'first'); + await writeRequireFixture(secondInDir, secondOutDir, 'second'); + + const firstUrl = pathToFileURL(path.join(firstOutDir, 'index.mjs')).href; + const secondUrl = pathToFileURL(path.join(secondOutDir, 'index.mjs')).href; + const { stdout } = await execFileAsync(process.execPath, [ + '--input-type=module', + '--eval', + ` +const originalRequire = globalThis.require; +const existingRequire = () => ({ value: 'existing global' }); +globalThis.require = existingRequire; +const first = await import(${JSON.stringify(firstUrl)}); +const second = await import(${JSON.stringify(secondUrl)}); +console.log(JSON.stringify({ + firstBundled: first.bundledValue, + firstDependencies: first.dependencyValues(), + firstExternal: first.loadExternal().value, + firstGlobValues: first.globValues, + firstIndirect: first.loadIndirect().value, + firstProperties: first.propertyValues, + firstResolved: first.resolveTargets(), + firstRuntime: first.loadRuntime().value, + globalRequireUnchanged: globalThis.require === existingRequire, + secondBundled: second.bundledValue, + secondDependencies: second.dependencyValues(), + secondExternal: second.loadExternal().value, + secondGlobValues: second.globValues, + secondIndirect: second.loadIndirect().value, + secondProperties: second.propertyValues, + secondResolved: second.resolveTargets(), + secondRuntime: second.loadRuntime().value, +})); +globalThis.require = originalRequire; +`, + ]); + + const firstRealOutDir = await fs.realpath(firstOutDir); + const secondRealOutDir = await fs.realpath(secondOutDir); + assert.deepEqual(JSON.parse(stdout) as unknown, { + firstBundled: 'first bundled', + firstDependencies: [ + 'first cjs dependency', + 'first cjs external', + 'first js dependency', + 'first js external', + 'first mjs dependency', + 'first mjs external', + ], + firstExternal: 'first external', + firstGlobValues: ['first concat glob', 'first template glob'], + firstIndirect: 'first runtime', + firstProperties: ['property', 'explicit'], + firstResolved: [ + path.join(firstRealOutDir, 'resolve-internal.cjs'), + path.join(firstRealOutDir, 'resolve-external.cjs'), + path.join(firstRealOutDir, 'resolve-dynamic.cjs'), + path.join(firstRealOutDir, 'resolve-options.cjs'), + ], + firstRuntime: 'first runtime', + globalRequireUnchanged: true, + secondBundled: 'second bundled', + secondDependencies: [ + 'second cjs dependency', + 'second cjs external', + 'second js dependency', + 'second js external', + 'second mjs dependency', + 'second mjs external', + ], + secondExternal: 'second external', + secondGlobValues: ['second concat glob', 'second template glob'], + secondIndirect: 'second runtime', + secondProperties: ['property', 'explicit'], + secondResolved: [ + path.join(secondRealOutDir, 'resolve-internal.cjs'), + path.join(secondRealOutDir, 'resolve-external.cjs'), + path.join(secondRealOutDir, 'resolve-dynamic.cjs'), + path.join(secondRealOutDir, 'resolve-options.cjs'), + ], + secondRuntime: 'second runtime', + }); + }); }); diff --git a/src/compile.ts b/src/compile.ts index cc8ea57..dd519eb 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -10,13 +10,13 @@ import typescript from 'typescript'; import { build, type PluginBuild } from 'esbuild'; import tsConfigJson from '../tsconfig.json' with { type: 'json' }; +import commonJsCompatibility, { + commonJsCompatibilityInject, + emptySourceMap, + isNodeModulePath, +} from './commonjs-compatibility.ts'; -const commonJsCompatabilityBanner = `import { createRequire as __createRequire } from "node:module"; -import { fileURLToPath as __fileURLToPath } from "node:url"; -import { default as __path } from "node:path"; -const __filename = __fileURLToPath(import.meta.url); -const __dirname = __path.dirname(__filename); -const require = __createRequire(import.meta.url);`; +const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); export type ImportKind = | 'entry-point' @@ -142,12 +142,12 @@ function excludeSourceMaps(filter: RegExp) { return (pluginBuild: PluginBuild) => { // ignore source maps for any Javascript file that matches filter pluginBuild.onLoad({ filter }, async (args) => { - if (args.path.endsWith('.js') || args.path.endsWith('.mjs')) { + if ( + isNodeModulePath(args.path) && + (args.path.endsWith('.js') || args.path.endsWith('.mjs')) + ) { return { - contents: `${await fs.readFile( - args.path, - 'utf8', - )}\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJtYXBwaW5ncyI6IkEifQ==`, + contents: `${await fs.readFile(args.path, 'utf8')}\n${emptySourceMap}`, loader: 'default', }; } @@ -274,7 +274,7 @@ export default async function ({ tsConfigJson, typescript.sys, // @checkdigit/typescript-config package root: - path.dirname(path.dirname(fileURLToPath(import.meta.url))), + packageRoot, ).options; const program = typescript.createProgram(productionSourceFiles, { ...compilerOptions, @@ -337,12 +337,7 @@ export default async function ({ metafile: outFile !== undefined, sourcesContent: false, logLevel: 'error', - banner: - outFile === undefined - ? {} - : { - js: commonJsCompatabilityBanner, - }, + inject: outFile === undefined ? [] : [commonJsCompatibilityInject], sourcemap: sourceMap === true ? 'inline' : false, ...(outFile === undefined ? { @@ -362,6 +357,10 @@ export default async function ({ legalComments: 'none', external, plugins: [ + { + name: 'common-js-compatibility', + setup: commonJsCompatibility(), + }, { name: 'resolve-runtime-temporal-polyfill', setup: resolveRuntimeTemporalPolyfill(external), From 1d5306f9cad62e19cd8e99695f28ca8cdc8a1fd4 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2026 09:48:21 +1000 Subject: [PATCH 2/7] Revert "Attempt 1" This reverts commit 3a68ef631c4d7f49d2de4937305d1545b26b6eb5. --- src/commonjs-compatibility.ts | 394 ---------------------------------- src/compile.spec.ts | 279 +----------------------- src/compile.ts | 35 +-- 3 files changed, 27 insertions(+), 681 deletions(-) delete mode 100644 src/commonjs-compatibility.ts diff --git a/src/commonjs-compatibility.ts b/src/commonjs-compatibility.ts deleted file mode 100644 index 8058979..0000000 --- a/src/commonjs-compatibility.ts +++ /dev/null @@ -1,394 +0,0 @@ -// commonjs-compatibility.ts - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -import typescript from 'typescript'; -import type { Loader, OnLoadArgs, PluginBuild } from 'esbuild'; - -export const commonJsCompatibilityInject = - '@checkdigit/typescript-config/commonjs-compatibility-inject'; -export const emptySourceMap = - '//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJtYXBwaW5ncyI6IkEifQ=='; - -export function isNodeModulePath(fileName: string): boolean { - return fileName.replaceAll('\\', '/').split('/').includes('node_modules'); -} - -const commonJsCompatibilityNamespace = 'commonjs-compatibility-inject'; -const base36Radix = 36; -const commonJsRequireAliases = Array.from( - { length: 100 }, - (_, index) => `$cjsR${index.toString(base36Radix).padStart(2, '0')}`, -); -const commonJsCompatibilityFilter = - // eslint-disable-next-line require-unicode-regexp - /^@checkdigit\/typescript-config\/commonjs-compatibility-inject$/; -// eslint-disable-next-line require-unicode-regexp -const javascriptFileFilter = /\.[cm]?[jt]sx?$/; - -function scriptKind(fileName: string): typescript.ScriptKind { - if (fileName.endsWith('.tsx')) { - return typescript.ScriptKind.TSX; - } - if ( - fileName.endsWith('.ts') || - fileName.endsWith('.mts') || - fileName.endsWith('.cts') - ) { - return typescript.ScriptKind.TS; - } - if (fileName.endsWith('.jsx')) { - return typescript.ScriptKind.JSX; - } - return typescript.ScriptKind.JS; -} - -function loader(fileName: string): Loader { - switch (scriptKind(fileName)) { - case typescript.ScriptKind.JSX: - return 'jsx'; - case typescript.ScriptKind.TS: - return 'ts'; - case typescript.ScriptKind.TSX: - return 'tsx'; - default: - return 'js'; - } -} - -function isNonReferenceIdentifier(identifier: typescript.Identifier): boolean { - const { parent } = identifier; - if (typescript.isShorthandPropertyAssignment(parent)) { - return false; - } - return ( - (Reflect.get(parent, 'name') as typescript.Node | undefined) === - identifier || - (typescript.isBindingElement(parent) && - parent.propertyName === identifier) || - (typescript.isImportSpecifier(parent) && - parent.propertyName === identifier) || - (typescript.isExportSpecifier(parent) && - parent.propertyName === identifier) || - (typescript.isQualifiedName(parent) && parent.right === identifier) || - (typescript.isLabeledStatement(parent) && parent.label === identifier) || - (typescript.isBreakOrContinueStatement(parent) && - parent.label === identifier) || - (typescript.isJsxAttribute(parent) && parent.name === identifier) - ); -} - -function isAmbientDeclaration(declaration: typescript.Declaration): boolean { - let current: typescript.Node = declaration; - while (!typescript.isSourceFile(current)) { - if ( - typescript.canHaveModifiers(current) && - typescript - .getModifiers(current) - ?.some( - (modifier) => modifier.kind === typescript.SyntaxKind.DeclareKeyword, - ) === true - ) { - return true; - } - current = current.parent; - } - return current.isDeclarationFile; -} - -function isRuntimeDeclaration(declaration: typescript.Declaration): boolean { - if ( - isAmbientDeclaration(declaration) || - typescript.isTypeOnlyImportOrExportDeclaration(declaration) - ) { - return false; - } - return ( - typescript.isBindingElement(declaration) || - typescript.isClassDeclaration(declaration) || - typescript.isClassExpression(declaration) || - typescript.isEnumDeclaration(declaration) || - typescript.isFunctionDeclaration(declaration) || - typescript.isFunctionExpression(declaration) || - typescript.isImportClause(declaration) || - typescript.isImportEqualsDeclaration(declaration) || - typescript.isImportSpecifier(declaration) || - typescript.isModuleDeclaration(declaration) || - typescript.isNamespaceImport(declaration) || - typescript.isParameter(declaration) || - typescript.isVariableDeclaration(declaration) - ); -} - -function hasRuntimeBinding(symbol: typescript.Symbol | undefined): boolean { - return symbol?.declarations?.some(isRuntimeDeclaration) === true; -} - -interface RequireIdentifier { - identifier: typescript.Identifier; - sourceFile: typescript.SourceFile; -} - -function freeRequireIdentifiers( - contents: string, - fileName: string, -): RequireIdentifier[] { - const sourceFile = typescript.createSourceFile( - fileName, - contents, - typescript.ScriptTarget.Latest, - true, - scriptKind(fileName), - ); - const compilerOptions = { - allowJs: true, - noLib: true, - noResolve: true, - } satisfies typescript.CompilerOptions; - const compilerHost = typescript.createCompilerHost(compilerOptions, true); - compilerHost.fileExists = (candidate) => candidate === fileName; - compilerHost.getSourceFile = (candidate) => - candidate === fileName ? sourceFile : undefined; - compilerHost.readFile = (candidate) => - candidate === fileName ? contents : undefined; - - const checker = typescript - .createProgram([fileName], compilerOptions, compilerHost) - .getTypeChecker(); - const identifiers: typescript.Identifier[] = []; - const visit = (node: typescript.Node): void => { - if ( - typescript.isIdentifier(node) && - node.text === 'require' && - !isNonReferenceIdentifier(node) && - !hasRuntimeBinding(checker.getSymbolAtLocation(node)) - ) { - identifiers.push(node); - } - typescript.forEachChild(node, visit); - }; - visit(sourceFile); - return identifiers.map((identifier) => ({ identifier, sourceFile })); -} - -function directRequireCall( - identifier: typescript.Identifier, -): typescript.CallExpression | undefined { - if ( - typescript.isCallExpression(identifier.parent) && - identifier.parent.expression === identifier - ) { - return identifier.parent; - } - return undefined; -} - -function isRequireResolveCall(identifier: typescript.Identifier): boolean { - return ( - typescript.isPropertyAccessExpression(identifier.parent) && - identifier.parent.expression === identifier && - identifier.parent.name.text === 'resolve' && - typescript.isCallExpression(identifier.parent.parent) && - identifier.parent.parent.expression === identifier.parent - ); -} - -function literalRequireSpecifier( - call: typescript.CallExpression, -): string | undefined { - const argument = call.arguments[0]; - if (call.arguments.length !== 1 || argument === undefined) { - return undefined; - } - if (!typescript.isStringLiteralLike(argument)) { - return undefined; - } - return argument.text; -} - -function isRelativePath(value: string): boolean { - return value.startsWith('./') || value.startsWith('../'); -} - -function globPrefix(expression: typescript.Expression): string | undefined { - if (typescript.isStringLiteralLike(expression)) { - return expression.text; - } - if (typescript.isTemplateExpression(expression)) { - return expression.head.text; - } - if ( - typescript.isBinaryExpression(expression) && - expression.operatorToken.kind === typescript.SyntaxKind.PlusToken - ) { - return globPrefix(expression.left); - } - return undefined; -} - -function isRelativeGlobRequire(call: typescript.CallExpression): boolean { - const argument = call.arguments[0]; - if (call.arguments.length !== 1 || argument === undefined) { - return false; - } - return ( - (typescript.isTemplateExpression(argument) || - (typescript.isBinaryExpression(argument) && - argument.operatorToken.kind === typescript.SyntaxKind.PlusToken)) && - isRelativePath(globPrefix(argument) ?? '') - ); -} - -function commonJsCompatibilitySource(): string { - return ` -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); -const commonJsRequire = createRequire(import.meta.url); -export { - dirname as __dirname, - filename as __filename, - commonJsRequire as 'import.meta.require', - ${commonJsRequireAliases.map((alias) => `commonJsRequire as ${alias}`).join(',\n ')} -};`; -} - -interface Replacement { - end: number; - start: number; - text: string; -} - -type ResolutionCache = Map>; - -function isExternalRequire( - specifier: string, - args: OnLoadArgs, - pluginBuild: PluginBuild, - resolutionCache: ResolutionCache, -): Promise { - const key = `${args.namespace}\0${args.path}\0${specifier}`; - const cached = resolutionCache.get(key); - if (cached !== undefined) { - return cached; - } - const resolution = pluginBuild - .resolve(specifier, { - importer: args.path, - kind: 'require-call', - namespace: args.namespace, - resolveDir: path.dirname(args.path), - }) - .then((result) => result.external); - resolutionCache.set(key, resolution); - return resolution; -} - -async function runtimeRequireReplacement( - identifier: typescript.Identifier, - sourceFile: typescript.SourceFile, - args: OnLoadArgs, - pluginBuild: PluginBuild, - requireAlias: string, - resolutionCache: ResolutionCache, -): Promise { - if (!isRequireResolveCall(identifier)) { - const call = directRequireCall(identifier); - if (call !== undefined && isRelativeGlobRequire(call)) { - return undefined; - } - const literal = - call === undefined ? undefined : literalRequireSpecifier(call); - if ( - literal !== undefined && - !(await isExternalRequire(literal, args, pluginBuild, resolutionCache)) - ) { - return undefined; - } - } - return { - start: identifier.getStart(sourceFile), - end: identifier.getEnd(), - text: typescript.isShorthandPropertyAssignment(identifier.parent) - ? `require: ${requireAlias}` - : requireAlias, - }; -} - -function isReplacement(value: Replacement | undefined): value is Replacement { - return value !== undefined; -} - -async function rewriteRuntimeRequires( - args: OnLoadArgs, - pluginBuild: PluginBuild, - resolutionCache: ResolutionCache, -) { - const contents = await fs.readFile(args.path, 'utf8'); - if (!contents.includes('require')) { - return undefined; - } - const requireAlias = - commonJsRequireAliases.find((alias) => !contents.includes(alias)) ?? - 'import.meta.require'; - const replacements = ( - await Promise.all( - freeRequireIdentifiers(contents, args.path).map( - async ({ identifier, sourceFile }) => - runtimeRequireReplacement( - identifier, - sourceFile, - args, - pluginBuild, - requireAlias, - resolutionCache, - ), - ), - ) - ).filter(isReplacement); - if (replacements.length === 0) { - return undefined; - } - let rewritten = contents; - for (const { start, end, text } of replacements.reverse()) { - rewritten = `${rewritten.slice(0, start)}${text}${rewritten.slice(end)}`; - } - if ( - isNodeModulePath(args.path) && - (args.path.endsWith('.js') || args.path.endsWith('.mjs')) - ) { - rewritten += `\n${emptySourceMap}`; - } - return { contents: rewritten, loader: loader(args.path) }; -} - -export default function commonJsCompatibility(): ( - pluginBuild: PluginBuild, -) => void { - return (pluginBuild: PluginBuild) => { - const resolutionCache: ResolutionCache = new Map(); - pluginBuild.onResolve({ filter: commonJsCompatibilityFilter }, (args) => - args.kind === 'entry-point' - ? { - path: commonJsCompatibilityInject, - namespace: commonJsCompatibilityNamespace, - } - : undefined, - ); - pluginBuild.onLoad( - { - filter: commonJsCompatibilityFilter, - namespace: commonJsCompatibilityNamespace, - }, - () => ({ contents: commonJsCompatibilitySource(), loader: 'js' }), - ); - pluginBuild.onLoad( - { filter: javascriptFileFilter, namespace: 'file' }, - async (args) => - rewriteRuntimeRequires(args, pluginBuild, resolutionCache), - ); - }; -} diff --git a/src/compile.spec.ts b/src/compile.spec.ts index bb12f28..2fcbdda 100644 --- a/src/compile.spec.ts +++ b/src/compile.spec.ts @@ -1,25 +1,19 @@ // compile.spec.ts import { strict as assert } from 'node:assert'; -import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, it } from 'node:test'; -import { pathToFileURL } from 'node:url'; -import { promisify } from 'node:util'; import compile from './compile.ts'; -import { isNodeModulePath } from './commonjs-compatibility.ts'; -const execFileAsync = promisify(execFile); - -const commonJsCompatibilityInject = `import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -var filename = fileURLToPath(import.meta.url); -var dirname = path.dirname(filename); -var commonJsRequire = createRequire(import.meta.url);`; +const commonJsCompatabilityBanner = `import { createRequire as __createRequire } from "node:module"; +import { fileURLToPath as __fileURLToPath } from "node:url"; +import { default as __path } from "node:path"; +const __filename = __fileURLToPath(import.meta.url); +const __dirname = __path.dirname(__filename); +const require = __createRequire(import.meta.url);`; const singleModule = { [`index.ts`]: `export const hello = 'world';`, @@ -125,97 +119,6 @@ async function writeOutput({ ); } -async function writeRequireFixture( - inDir: string, - outDir: string, - value: string, -): Promise { - await writeInput(inDir, { - 'index.ts': ` -declare const require: NodeJS.Require; -const runtimeModule: string = './runtime.cjs'; -const bundledModule = require('./bundled.cjs') as { value: string }; -const concatName: string = 'first'; -const templateName: string = 'second'; -const indirectRequire = { require }.require; -export const bundledValue: string = bundledModule.value; -export const globValues: string[] = [ - (require('./glob-concat-' + concatName + '.cjs') as { value: string }).value, - (require(\`./glob-template-\${templateName}.cjs\`) as { value: string }).value, -]; -export const propertyValues: string[] = [ - ({ require: () => 'property' }).require(), - ({ require: () => 'explicit' }).require(), -]; -export function loadRuntime(): { value: string } { - return require(runtimeModule) as { value: string }; -} -export function loadIndirect(): { value: string } { - return indirectRequire(runtimeModule) as { value: string }; -} -export function loadExternal(): { value: string } { - return require('./external.cjs') as { value: string }; -} -const resolveDynamic: string = './resolve-dynamic.cjs'; -export function resolveTargets(): string[] { - return [ - require.resolve('./resolve-internal.cjs'), - require.resolve('./resolve-external.cjs'), - require.resolve(resolveDynamic), - require.resolve('./resolve-options.cjs', { paths: [__dirname] }), - ]; -} -interface Dependency { external(): { value: string }; load(): { value: string } } -const cjsDependency = require('./dependency.cjs') as Dependency; -const jsDependency = require('./dependency.js') as Dependency; -const mjsDependency = require('./dependency.mjs') as Dependency; -export function dependencyValues(): string[] { - return [ - cjsDependency.load().value, cjsDependency.external().value, - jsDependency.load().value, jsDependency.external().value, - mjsDependency.load().value, mjsDependency.external().value, - ]; -} -`, - 'bundled.cjs': `module.exports = { value: '${value} bundled' };`, - 'glob-concat-first.cjs': `module.exports = { value: '${value} concat glob' };`, - 'glob-template-second.cjs': `module.exports = { value: '${value} template glob' };`, - 'dependency.cjs': `const name = './dependency-cjs-runtime.cjs'; module.exports = { load: () => require(name), external: () => require('./dependency-cjs-external.cjs') };`, - 'dependency.js': `const name = './dependency-js-runtime.cjs'; module.exports = { load: () => require(name), external: () => require('./dependency-js-external.cjs') };`, - 'dependency.mjs': `const name = './dependency-mjs-runtime.cjs'; export const load = () => require(name); export const external = () => require('./dependency-mjs-external.cjs');`, - }); - await writeOutput( - await compile({ - type: 'module', - entryPoint: 'index.ts', - outFile: 'index.mjs', - inDir, - outDir, - external: [ - './external.cjs', - './resolve-external.cjs', - './dependency-cjs-external.cjs', - './dependency-js-external.cjs', - './dependency-mjs-external.cjs', - ], - }), - ); - await writeInput(outDir, { - 'runtime.cjs': `module.exports = { value: '${value} runtime' };`, - 'external.cjs': `module.exports = { value: '${value} external' };`, - 'resolve-internal.cjs': `module.exports = {};`, - 'resolve-external.cjs': `module.exports = {};`, - 'resolve-dynamic.cjs': `module.exports = {};`, - 'resolve-options.cjs': `module.exports = {};`, - 'dependency-cjs-runtime.cjs': `module.exports = { value: '${value} cjs dependency' };`, - 'dependency-js-runtime.cjs': `module.exports = { value: '${value} js dependency' };`, - 'dependency-mjs-runtime.cjs': `module.exports = { value: '${value} mjs dependency' };`, - 'dependency-cjs-external.cjs': `module.exports = { value: '${value} cjs external' };`, - 'dependency-js-external.cjs': `module.exports = { value: '${value} js external' };`, - 'dependency-mjs-external.cjs': `module.exports = { value: '${value} mjs external' };`, - }); -} - function convert(outputFiles: { path: string; text: string }[]) { return Object.fromEntries( outputFiles.map((file) => [ @@ -229,15 +132,6 @@ function convert(outputFiles: { path: string; text: string }[]) { } describe('compile', () => { - it('should identify node_modules path segments on all platforms', () => { - assert.equal(isNodeModulePath('/repo/node_modules/package/index.js'), true); - assert.equal( - isNodeModulePath('C:\\repo\\node_modules\\package\\index.js'), - true, - ); - assert.equal(isNodeModulePath('/repo/node_modules-cache/index.js'), false); - }); - it('should not build bad code', async () => { const id = crypto.randomUUID(); const inDir = path.join(os.tmpdir(), `in-dir-${id}`); @@ -376,7 +270,7 @@ describe('compile', () => { ); assert.deepEqual(await read(outDir), { 'two-modules.mjs': - `${commonJsCompatibilityInject}\n\n` + + `${commonJsCompatabilityBanner}\n\n` + `var hello = "world";\n` + `\n` + `var two_modules_default = hello + "world";\n` + @@ -406,7 +300,7 @@ describe('compile', () => { ); assert.deepEqual(await read(outDir), { 'index.mjs': - `${commonJsCompatibilityInject}\n\n` + + `${commonJsCompatabilityBanner}\n\n` + `var hello = "world";\n` + `\n` + `import util from "node:util";\n` + @@ -439,7 +333,7 @@ describe('compile', () => { }); assert.deepEqual(convert(result.outputFiles), { 'index.mjs': - `${commonJsCompatibilityInject}\n\n` + + `${commonJsCompatabilityBanner}\n\n` + `import { hello as test } from "test-esm-module";\n` + `import util from "node:util";\n` + `var hello = { test, message: util.format("hello %s", "world") };\n` + @@ -448,159 +342,4 @@ describe('compile', () => { `};\n`, }); }); - - it('should inject CommonJS globals without conflicting with a local __dirname', async () => { - const id = crypto.randomUUID(); - const inDir = path.join(os.tmpdir(), `in-dir-${id}`, 'src'); - const outDir = path.join(os.tmpdir(), `out-dir-${id}`, 'build'); - await writeInput(inDir, { - 'index.ts': ` -const __dirname = 'declared in project'; -const __filename = 'declared filename'; -const require = () => 'declared require'; -export const declaredDirname: string = __dirname; -export const declaredFilename: string = __filename; -export const declaredRequire: string = require(); -export { compatibility } from './compatibility.ts'; -`, - 'compatibility.ts': ` -const requiredModule = './required.cjs'; -export const compatibility: { - dirname: string; - filename: string; - required: { value: string }; -} = { - dirname: __dirname, - filename: __filename, - required: require(requiredModule) as { value: string }, -}; -`, - }); - await writeOutput( - await compile({ - type: 'module', - entryPoint: 'index.ts', - outFile: 'index.mjs', - inDir, - outDir, - }), - ); - await fs.writeFile( - path.join(outDir, 'required.cjs'), - `module.exports = { value: 'required value' };`, - ); - - const moduleUrl = pathToFileURL(path.join(outDir, 'index.mjs')).href; - const realOutDir = await fs.realpath(outDir); - const { stdout } = await execFileAsync(process.execPath, [ - '--input-type=module', - '--eval', - `const output = await import(${JSON.stringify(moduleUrl)}); console.log(JSON.stringify({ compatibility: output.compatibility, declaredDirname: output.declaredDirname, declaredFilename: output.declaredFilename, declaredRequire: output.declaredRequire }));`, - ]); - - assert.deepEqual(JSON.parse(stdout) as unknown, { - compatibility: { - dirname: realOutDir, - filename: path.join(realOutDir, 'index.mjs'), - required: { value: 'required value' }, - }, - declaredDirname: 'declared in project', - declaredFilename: 'declared filename', - declaredRequire: 'declared require', - }); - }); - - it('should isolate runtime require resolution between bundles', async () => { - const id = crypto.randomUUID(); - const firstInDir = path.join(os.tmpdir(), `first-in-dir-${id}`, 'src'); - const firstOutDir = path.join(os.tmpdir(), `first-out-dir-${id}`, 'build'); - const secondInDir = path.join(os.tmpdir(), `second-in-dir-${id}`, 'src'); - const secondOutDir = path.join( - os.tmpdir(), - `second-out-dir-${id}`, - 'build', - ); - await writeRequireFixture(firstInDir, firstOutDir, 'first'); - await writeRequireFixture(secondInDir, secondOutDir, 'second'); - - const firstUrl = pathToFileURL(path.join(firstOutDir, 'index.mjs')).href; - const secondUrl = pathToFileURL(path.join(secondOutDir, 'index.mjs')).href; - const { stdout } = await execFileAsync(process.execPath, [ - '--input-type=module', - '--eval', - ` -const originalRequire = globalThis.require; -const existingRequire = () => ({ value: 'existing global' }); -globalThis.require = existingRequire; -const first = await import(${JSON.stringify(firstUrl)}); -const second = await import(${JSON.stringify(secondUrl)}); -console.log(JSON.stringify({ - firstBundled: first.bundledValue, - firstDependencies: first.dependencyValues(), - firstExternal: first.loadExternal().value, - firstGlobValues: first.globValues, - firstIndirect: first.loadIndirect().value, - firstProperties: first.propertyValues, - firstResolved: first.resolveTargets(), - firstRuntime: first.loadRuntime().value, - globalRequireUnchanged: globalThis.require === existingRequire, - secondBundled: second.bundledValue, - secondDependencies: second.dependencyValues(), - secondExternal: second.loadExternal().value, - secondGlobValues: second.globValues, - secondIndirect: second.loadIndirect().value, - secondProperties: second.propertyValues, - secondResolved: second.resolveTargets(), - secondRuntime: second.loadRuntime().value, -})); -globalThis.require = originalRequire; -`, - ]); - - const firstRealOutDir = await fs.realpath(firstOutDir); - const secondRealOutDir = await fs.realpath(secondOutDir); - assert.deepEqual(JSON.parse(stdout) as unknown, { - firstBundled: 'first bundled', - firstDependencies: [ - 'first cjs dependency', - 'first cjs external', - 'first js dependency', - 'first js external', - 'first mjs dependency', - 'first mjs external', - ], - firstExternal: 'first external', - firstGlobValues: ['first concat glob', 'first template glob'], - firstIndirect: 'first runtime', - firstProperties: ['property', 'explicit'], - firstResolved: [ - path.join(firstRealOutDir, 'resolve-internal.cjs'), - path.join(firstRealOutDir, 'resolve-external.cjs'), - path.join(firstRealOutDir, 'resolve-dynamic.cjs'), - path.join(firstRealOutDir, 'resolve-options.cjs'), - ], - firstRuntime: 'first runtime', - globalRequireUnchanged: true, - secondBundled: 'second bundled', - secondDependencies: [ - 'second cjs dependency', - 'second cjs external', - 'second js dependency', - 'second js external', - 'second mjs dependency', - 'second mjs external', - ], - secondExternal: 'second external', - secondGlobValues: ['second concat glob', 'second template glob'], - secondIndirect: 'second runtime', - secondProperties: ['property', 'explicit'], - secondResolved: [ - path.join(secondRealOutDir, 'resolve-internal.cjs'), - path.join(secondRealOutDir, 'resolve-external.cjs'), - path.join(secondRealOutDir, 'resolve-dynamic.cjs'), - path.join(secondRealOutDir, 'resolve-options.cjs'), - ], - secondRuntime: 'second runtime', - }); - }); }); diff --git a/src/compile.ts b/src/compile.ts index dd519eb..cc8ea57 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -10,13 +10,13 @@ import typescript from 'typescript'; import { build, type PluginBuild } from 'esbuild'; import tsConfigJson from '../tsconfig.json' with { type: 'json' }; -import commonJsCompatibility, { - commonJsCompatibilityInject, - emptySourceMap, - isNodeModulePath, -} from './commonjs-compatibility.ts'; -const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const commonJsCompatabilityBanner = `import { createRequire as __createRequire } from "node:module"; +import { fileURLToPath as __fileURLToPath } from "node:url"; +import { default as __path } from "node:path"; +const __filename = __fileURLToPath(import.meta.url); +const __dirname = __path.dirname(__filename); +const require = __createRequire(import.meta.url);`; export type ImportKind = | 'entry-point' @@ -142,12 +142,12 @@ function excludeSourceMaps(filter: RegExp) { return (pluginBuild: PluginBuild) => { // ignore source maps for any Javascript file that matches filter pluginBuild.onLoad({ filter }, async (args) => { - if ( - isNodeModulePath(args.path) && - (args.path.endsWith('.js') || args.path.endsWith('.mjs')) - ) { + if (args.path.endsWith('.js') || args.path.endsWith('.mjs')) { return { - contents: `${await fs.readFile(args.path, 'utf8')}\n${emptySourceMap}`, + contents: `${await fs.readFile( + args.path, + 'utf8', + )}\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIiJdLCJtYXBwaW5ncyI6IkEifQ==`, loader: 'default', }; } @@ -274,7 +274,7 @@ export default async function ({ tsConfigJson, typescript.sys, // @checkdigit/typescript-config package root: - packageRoot, + path.dirname(path.dirname(fileURLToPath(import.meta.url))), ).options; const program = typescript.createProgram(productionSourceFiles, { ...compilerOptions, @@ -337,7 +337,12 @@ export default async function ({ metafile: outFile !== undefined, sourcesContent: false, logLevel: 'error', - inject: outFile === undefined ? [] : [commonJsCompatibilityInject], + banner: + outFile === undefined + ? {} + : { + js: commonJsCompatabilityBanner, + }, sourcemap: sourceMap === true ? 'inline' : false, ...(outFile === undefined ? { @@ -357,10 +362,6 @@ export default async function ({ legalComments: 'none', external, plugins: [ - { - name: 'common-js-compatibility', - setup: commonJsCompatibility(), - }, { name: 'resolve-runtime-temporal-polyfill', setup: resolveRuntimeTemporalPolyfill(external), From 11d54d07895c00bc44002eaac6b4bca54abacabb Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2026 10:59:51 +1000 Subject: [PATCH 3/7] Attempt 2 --- src/compile.spec.ts | 44 ++++++++++++++++++++++++++++++++++++++++-- src/compile.ts | 8 +++++--- src/dirname-inject.mjs | 4 ++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 src/dirname-inject.mjs diff --git a/src/compile.spec.ts b/src/compile.spec.ts index 2fcbdda..2af5242 100644 --- a/src/compile.spec.ts +++ b/src/compile.spec.ts @@ -10,11 +10,13 @@ import compile from './compile.ts'; const commonJsCompatabilityBanner = `import { createRequire as __createRequire } from "node:module"; import { fileURLToPath as __fileURLToPath } from "node:url"; -import { default as __path } from "node:path"; const __filename = __fileURLToPath(import.meta.url); -const __dirname = __path.dirname(__filename); const require = __createRequire(import.meta.url);`; +const dirnameInject = `import path from "node:path"; +import { fileURLToPath } from "node:url"; +var __dirname = path.dirname(fileURLToPath(import.meta.url));`; + const singleModule = { [`index.ts`]: `export const hello = 'world';`, }; @@ -271,6 +273,7 @@ describe('compile', () => { assert.deepEqual(await read(outDir), { 'two-modules.mjs': `${commonJsCompatabilityBanner}\n\n` + + `${dirnameInject}\n\n` + `var hello = "world";\n` + `\n` + `var two_modules_default = hello + "world";\n` + @@ -282,6 +285,41 @@ describe('compile', () => { assert.equal(output.default, 'worldworld'); }); + it('should bundle an ESM module that declares __dirname', async () => { + const id = crypto.randomUUID(); + const inDir = path.join(os.tmpdir(), `in-dir-${id}`, 'src'); + const outDir = path.join(os.tmpdir(), `out-dir-${id}`, 'build'); + await writeInput(inDir, { + 'index.ts': ` +const __dirname = 'declared in project'; +export const localDirname: string = __dirname; +export { injectedDirname } from './dependency.ts'; +`, + 'dependency.ts': `export const injectedDirname: string = __dirname;`, + }); + await writeOutput( + await compile({ + type: 'module', + entryPoint: 'index.ts', + outFile: 'index.mjs', + inDir, + outDir, + }), + ); + + const output = await import(path.join(outDir, 'index.mjs')); + assert.deepEqual( + { + injectedDirname: output.injectedDirname as unknown, + localDirname: output.localDirname as unknown, + }, + { + injectedDirname: await fs.realpath(outDir), + localDirname: 'declared in project', + }, + ); + }); + it('should bundle an ESM module that imports external modules', async () => { const id = crypto.randomUUID(); const moduleDir = path.join(os.tmpdir(), `in-dir-${id}`); @@ -301,6 +339,7 @@ describe('compile', () => { assert.deepEqual(await read(outDir), { 'index.mjs': `${commonJsCompatabilityBanner}\n\n` + + `${dirnameInject}\n\n` + `var hello = "world";\n` + `\n` + `import util from "node:util";\n` + @@ -334,6 +373,7 @@ describe('compile', () => { assert.deepEqual(convert(result.outputFiles), { 'index.mjs': `${commonJsCompatabilityBanner}\n\n` + + `${dirnameInject}\n\n` + `import { hello as test } from "test-esm-module";\n` + `import util from "node:util";\n` + `var hello = { test, message: util.format("hello %s", "world") };\n` + diff --git a/src/compile.ts b/src/compile.ts index cc8ea57..198e60f 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -13,11 +13,12 @@ import tsConfigJson from '../tsconfig.json' with { type: 'json' }; const commonJsCompatabilityBanner = `import { createRequire as __createRequire } from "node:module"; import { fileURLToPath as __fileURLToPath } from "node:url"; -import { default as __path } from "node:path"; const __filename = __fileURLToPath(import.meta.url); -const __dirname = __path.dirname(__filename); const require = __createRequire(import.meta.url);`; +const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const dirnameInject = path.join(packageRoot, 'src', 'dirname-inject.mjs'); + export type ImportKind = | 'entry-point' | 'import-statement' @@ -274,7 +275,7 @@ export default async function ({ tsConfigJson, typescript.sys, // @checkdigit/typescript-config package root: - path.dirname(path.dirname(fileURLToPath(import.meta.url))), + packageRoot, ).options; const program = typescript.createProgram(productionSourceFiles, { ...compilerOptions, @@ -337,6 +338,7 @@ export default async function ({ metafile: outFile !== undefined, sourcesContent: false, logLevel: 'error', + inject: outFile === undefined ? [] : [dirnameInject], banner: outFile === undefined ? {} diff --git a/src/dirname-inject.mjs b/src/dirname-inject.mjs new file mode 100644 index 0000000..e802ab5 --- /dev/null +++ b/src/dirname-inject.mjs @@ -0,0 +1,4 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const __dirname = path.dirname(fileURLToPath(import.meta.url)); From 64f21d21102a78510be8914df2761edd8baf3b71 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2026 11:28:07 +1000 Subject: [PATCH 4/7] Cleanups --- src/compile.spec.ts | 3 +-- src/compile.ts | 6 ++++-- src/dirname-inject.mjs | 4 ---- 3 files changed, 5 insertions(+), 8 deletions(-) delete mode 100644 src/dirname-inject.mjs diff --git a/src/compile.spec.ts b/src/compile.spec.ts index 2af5242..fedfe1d 100644 --- a/src/compile.spec.ts +++ b/src/compile.spec.ts @@ -14,8 +14,7 @@ const __filename = __fileURLToPath(import.meta.url); const require = __createRequire(import.meta.url);`; const dirnameInject = `import path from "node:path"; -import { fileURLToPath } from "node:url"; -var __dirname = path.dirname(fileURLToPath(import.meta.url));`; +var __dirname = path.dirname(__filename);`; const singleModule = { [`index.ts`]: `export const hello = 'world';`, diff --git a/src/compile.ts b/src/compile.ts index 198e60f..fea3ae4 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -17,7 +17,9 @@ const __filename = __fileURLToPath(import.meta.url); const require = __createRequire(import.meta.url);`; const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const dirnameInject = path.join(packageRoot, 'src', 'dirname-inject.mjs'); +const dirNameJsCompatabilityInjection = `import path from 'node:path'; +export const __dirname = path.dirname(__filename);`; // relies on __filename from commonJsCompatabilityBanner +const dirNameJsCompatabilityInjectionURL = `data:text/javascript,${encodeURIComponent(dirNameJsCompatabilityInjection)}`; export type ImportKind = | 'entry-point' @@ -338,7 +340,7 @@ export default async function ({ metafile: outFile !== undefined, sourcesContent: false, logLevel: 'error', - inject: outFile === undefined ? [] : [dirnameInject], + inject: outFile === undefined ? [] : [dirNameJsCompatabilityInjectionURL], banner: outFile === undefined ? {} diff --git a/src/dirname-inject.mjs b/src/dirname-inject.mjs deleted file mode 100644 index e802ab5..0000000 --- a/src/dirname-inject.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -export const __dirname = path.dirname(fileURLToPath(import.meta.url)); From d221f306b105957c7789660595dce2d1d8d20819 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2026 12:15:57 +1000 Subject: [PATCH 5/7] Set version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c055f5..aaaeb0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@checkdigit/typescript-config", - "version": "10.1.1", + "version": "10.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@checkdigit/typescript-config", - "version": "10.1.1", + "version": "10.2.0", "license": "MIT", "bin": { "builder": "bin/builder.mjs" diff --git a/package.json b/package.json index c990cbc..18282f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@checkdigit/typescript-config", - "version": "10.1.1", + "version": "10.2.0", "description": "Check Digit standard Typescript configuration", "homepage": "https://github.com/checkdigit/typescript-config#readme", "bugs": { From 9e670f3501b161d9ae3d277b152eefc97cbf868b Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2026 12:36:42 +1000 Subject: [PATCH 6/7] Fix spelling --- src/compile.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/compile.ts b/src/compile.ts index fea3ae4..2ed4520 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -17,9 +17,9 @@ const __filename = __fileURLToPath(import.meta.url); const require = __createRequire(import.meta.url);`; const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const dirNameJsCompatabilityInjection = `import path from 'node:path'; +const dirNameJsCompatibilityInjection = `import path from 'node:path'; export const __dirname = path.dirname(__filename);`; // relies on __filename from commonJsCompatabilityBanner -const dirNameJsCompatabilityInjectionURL = `data:text/javascript,${encodeURIComponent(dirNameJsCompatabilityInjection)}`; +const dirNameJsCompatibilityInjectionURL = `data:text/javascript,${encodeURIComponent(dirNameJsCompatibilityInjection)}`; export type ImportKind = | 'entry-point' @@ -340,7 +340,7 @@ export default async function ({ metafile: outFile !== undefined, sourcesContent: false, logLevel: 'error', - inject: outFile === undefined ? [] : [dirNameJsCompatabilityInjectionURL], + inject: outFile === undefined ? [] : [dirNameJsCompatibilityInjectionURL], banner: outFile === undefined ? {} From 903235bab56c87083736280e1af3d3690a5e7011 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 27 Jul 2026 15:14:10 +1000 Subject: [PATCH 7/7] Review items --- src/compile.spec.ts | 12 ++---------- src/compile.ts | 3 +-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/compile.spec.ts b/src/compile.spec.ts index fedfe1d..5bcc8c5 100644 --- a/src/compile.spec.ts +++ b/src/compile.spec.ts @@ -307,16 +307,8 @@ export { injectedDirname } from './dependency.ts'; ); const output = await import(path.join(outDir, 'index.mjs')); - assert.deepEqual( - { - injectedDirname: output.injectedDirname as unknown, - localDirname: output.localDirname as unknown, - }, - { - injectedDirname: await fs.realpath(outDir), - localDirname: 'declared in project', - }, - ); + assert.equal(output.injectedDirname, await fs.realpath(outDir)); + assert.equal(output.localDirname, 'declared in project'); }); it('should bundle an ESM module that imports external modules', async () => { diff --git a/src/compile.ts b/src/compile.ts index 2ed4520..faf3f77 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -16,7 +16,6 @@ import { fileURLToPath as __fileURLToPath } from "node:url"; const __filename = __fileURLToPath(import.meta.url); const require = __createRequire(import.meta.url);`; -const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const dirNameJsCompatibilityInjection = `import path from 'node:path'; export const __dirname = path.dirname(__filename);`; // relies on __filename from commonJsCompatabilityBanner const dirNameJsCompatibilityInjectionURL = `data:text/javascript,${encodeURIComponent(dirNameJsCompatibilityInjection)}`; @@ -277,7 +276,7 @@ export default async function ({ tsConfigJson, typescript.sys, // @checkdigit/typescript-config package root: - packageRoot, + path.dirname(path.dirname(fileURLToPath(import.meta.url))), ).options; const program = typescript.createProgram(productionSourceFiles, { ...compilerOptions,