Skip to content

Commit 1920164

Browse files
committed
Make things work in ESM with getBuiltinModule
1 parent 57046dd commit 1920164

File tree

19 files changed

+186
-106
lines changed

19 files changed

+186
-106
lines changed

Herebyfile.mjs

Lines changed: 39 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// @ts-check
22
import { CancelToken } from "@esfx/canceltoken";
3-
import assert from "assert";
43
import chalk from "chalk";
54
import chokidar from "chokidar";
65
import esbuild from "esbuild";
@@ -172,25 +171,22 @@ async function runDtsBundler(entrypoint, output) {
172171
* @param {BundlerTaskOptions} [taskOptions]
173172
*
174173
* @typedef BundlerTaskOptions
175-
* @property {boolean} [exportIsTsObject]
176174
* @property {boolean} [treeShaking]
177175
* @property {boolean} [usePublicAPI]
178176
* @property {() => void} [onWatchRebuild]
179177
*/
180178
function createBundler(entrypoint, outfile, taskOptions = {}) {
181179
const getOptions = memoize(async () => {
182180
const copyright = await getCopyrightHeader();
183-
const banner = taskOptions.exportIsTsObject ? "var ts = {}; ((module) => {" : "";
184-
185181
/** @type {esbuild.BuildOptions} */
186182
const options = {
187183
entryPoints: [entrypoint],
188-
banner: { js: copyright + banner },
184+
banner: { js: copyright },
189185
bundle: true,
190186
outfile,
191187
platform: "node",
192188
target: ["es2020", "node14.17"],
193-
format: "cjs",
189+
format: "esm",
194190
sourcemap: "linked",
195191
sourcesContent: false,
196192
treeShaking: taskOptions.treeShaking,
@@ -200,66 +196,17 @@ function createBundler(entrypoint, outfile, taskOptions = {}) {
200196
};
201197

202198
if (taskOptions.usePublicAPI) {
203-
options.external = ["./typescript.js"];
204199
options.plugins = options.plugins || [];
205200
options.plugins.push({
206-
name: "remap-typescript-to-require",
201+
name: "remap-typescript-to-public-api",
207202
setup(build) {
208-
build.onLoad({ filter: /src[\\/]typescript[\\/]typescript\.ts$/ }, () => {
209-
return { contents: `export * from "./typescript.js"` };
203+
build.onResolve({ filter: /^(?:\.\.[\\/])*typescript[\\/]typescript\.js$/ }, () => {
204+
return { path: "./typescript.js", external: true };
210205
});
211206
},
212207
});
213208
}
214209

215-
if (taskOptions.exportIsTsObject) {
216-
// Monaco bundles us as ESM by wrapping our code with something that defines module.exports
217-
// but then does not use it, instead using the `ts` variable. Ensure that if we think we're CJS
218-
// that we still set `ts` to the module.exports object.
219-
options.footer = { js: `})({ get exports() { return ts; }, set exports(v) { ts = v; if (typeof module !== "undefined" && module.exports) { module.exports = v; } } })` };
220-
221-
// esbuild converts calls to "require" to "__require"; this function
222-
// calls the real require if it exists, or throws if it does not (rather than
223-
// throwing an error like "require not defined"). But, since we want typescript
224-
// to be consumable by other bundlers, we need to convert these calls back to
225-
// require so our imports are visible again.
226-
//
227-
// To fix this, we redefine "require" to a name we're unlikely to use with the
228-
// same length as "require", then replace it back to "require" after bundling,
229-
// ensuring that source maps still work.
230-
//
231-
// See: https://github.com/evanw/esbuild/issues/1905
232-
const require = "require";
233-
const fakeName = "Q".repeat(require.length);
234-
const fakeNameRegExp = new RegExp(fakeName, "g");
235-
options.define = { [require]: fakeName };
236-
237-
// For historical reasons, TypeScript does not set __esModule. Hack esbuild's __toCommonJS to be a noop.
238-
// We reference `__copyProps` to ensure the final bundle doesn't have any unreferenced code.
239-
const toCommonJsRegExp = /var __toCommonJS .*/;
240-
const toCommonJsRegExpReplacement = "var __toCommonJS = (mod) => (__copyProps, mod); // Modified helper to skip setting __esModule.";
241-
242-
options.plugins = options.plugins || [];
243-
options.plugins.push(
244-
{
245-
name: "post-process",
246-
setup: build => {
247-
build.onEnd(async () => {
248-
let contents = await fs.promises.readFile(outfile, "utf-8");
249-
contents = contents.replace(fakeNameRegExp, require);
250-
let matches = 0;
251-
contents = contents.replace(toCommonJsRegExp, () => {
252-
matches++;
253-
return toCommonJsRegExpReplacement;
254-
});
255-
assert(matches === 1, "Expected exactly one match for __toCommonJS");
256-
await fs.promises.writeFile(outfile, contents);
257-
});
258-
},
259-
},
260-
);
261-
}
262-
263210
return options;
264211
});
265212

@@ -305,6 +252,7 @@ let printedWatchWarning = false;
305252
* @param {string} options.output
306253
* @param {boolean} [options.enableCompileCache]
307254
* @param {Task[]} [options.mainDeps]
255+
* @param {boolean} [options.reexportDefault]
308256
* @param {BundlerTaskOptions} [options.bundlerOptions]
309257
*/
310258
function entrypointBuildTask(options) {
@@ -329,13 +277,13 @@ function entrypointBuildTask(options) {
329277
const moduleSpecifier = path.relative(outDir, output);
330278
const lines = [
331279
`// This file is a shim which defers loading the real module until the compile cache is enabled.`,
332-
`try {`,
333-
` const { enableCompileCache } = require("node:module");`,
334-
` if (enableCompileCache) {`,
335-
` enableCompileCache();`,
336-
` }`,
337-
`} catch {}`,
338-
`module.exports = require("./${moduleSpecifier.replace(/[\\/]/g, "/")}");`,
280+
`import mod from "node:module";`,
281+
`if (mod.enableCompileCache) {`,
282+
` mod.enableCompileCache();`,
283+
`}`,
284+
`// Keep this synchronous so downstream people who required this file do not see TLA.`,
285+
`const require = mod.createRequire(import.meta.url);`,
286+
`require("./${moduleSpecifier.replace(/[\\/]/g, "/")}");`,
339287
];
340288
await fs.promises.writeFile(originalOutput, lines.join("\n") + "\n");
341289
},
@@ -355,22 +303,33 @@ function entrypointBuildTask(options) {
355303
});
356304

357305
/**
358-
* Writes a CJS module that reexports another CJS file. E.g. given
306+
* Writes a module that reexports another file. E.g. given
359307
* `options.builtEntrypoint = "./built/local/tsc/tsc.js"` and
360308
* `options.output = "./built/local/tsc.js"`, this will create a file
361309
* named "./built/local/tsc.js" containing:
362310
*
363311
* ```
364-
* module.exports = require("./tsc/tsc.js")
312+
* export * from "./tsc/tsc.js";
365313
* ```
366314
*/
367315
const shim = task({
368316
name: `shim-${options.name}`,
369317
run: async () => {
370318
const outDir = path.dirname(output);
371319
await fs.promises.mkdir(outDir, { recursive: true });
372-
const moduleSpecifier = path.relative(outDir, options.builtEntrypoint);
373-
await fs.promises.writeFile(output, `module.exports = require("./${moduleSpecifier.replace(/[\\/]/g, "/")}")`);
320+
const moduleSpecifier = path.relative(outDir, options.builtEntrypoint).replace(/[\\/]/g, "/");
321+
const lines = [
322+
`export * from "./${moduleSpecifier}";`,
323+
];
324+
325+
if (options.reexportDefault) {
326+
lines.push(
327+
`import _default from "./${moduleSpecifier}";`,
328+
`export default _default;`,
329+
);
330+
}
331+
332+
await fs.promises.writeFile(output, lines.join("\n") + "\n");
374333
},
375334
});
376335

@@ -435,7 +394,7 @@ const { main: services, build: buildServices, watch: watchServices } = entrypoin
435394
builtEntrypoint: "./built/local/typescript/typescript.js",
436395
output: "./built/local/typescript.js",
437396
mainDeps: [generateLibs],
438-
bundlerOptions: { exportIsTsObject: true },
397+
reexportDefault: true,
439398
});
440399
export { services, watchServices };
441400

@@ -477,25 +436,22 @@ export const watchMin = task({
477436
dependencies: [watchTsc, watchTsserver],
478437
});
479438

480-
// This is technically not enough to make tsserverlibrary loadable in the
481-
// browser, but it's unlikely that anyone has actually been doing that.
482439
const lsslJs = `
483-
if (typeof module !== "undefined" && module.exports) {
484-
module.exports = require("./typescript.js");
485-
}
486-
else {
487-
throw new Error("tsserverlibrary requires CommonJS; use typescript.js instead");
488-
}
440+
import ts from "./typescript.js";
441+
export * from "./typescript.js";
442+
export default ts;
489443
`;
490444

491445
const lsslDts = `
492-
import ts = require("./typescript.js");
493-
export = ts;
446+
import ts from "./typescript.js";
447+
export * from "./typescript.js";
448+
export default ts;
494449
`;
495450

496451
const lsslDtsInternal = `
497-
import ts = require("./typescript.internal.js");
498-
export = ts;
452+
import ts from "./typescript.internal.js";
453+
export * from "./typescript.internal.js";
454+
export default ts;
499455
`;
500456

501457
/**
@@ -536,7 +492,7 @@ const { main: tests, watch: watchTests } = entrypointBuildTask({
536492
description: "Builds the test infrastructure",
537493
buildDeps: [generateDiagnostics],
538494
project: "src/testRunner",
539-
srcEntrypoint: "./src/testRunner/_namespaces/Harness.ts",
495+
srcEntrypoint: "./src/testRunner/runner.ts",
540496
builtEntrypoint: "./built/local/testRunner/runner.js",
541497
output: testRunner,
542498
mainDeps: [generateLibs],

bin/tsc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
#!/usr/bin/env node
2-
require('../lib/tsc.js')
2+
import '../lib/tsc.js';

bin/tsserver

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
#!/usr/bin/env node
2-
require('../lib/tsserver.js')
2+
import '../lib/tsserver.js';

eslint.config.mjs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ const rulesDir = path.join(__dirname, "scripts", "eslint", "rules");
1717
const ext = ".cjs";
1818
const ruleFiles = fs.readdirSync(rulesDir).filter(p => p.endsWith(ext));
1919

20+
const restrictedESMGlobals = [
21+
{ name: "__filename" },
22+
{ name: "__dirname" },
23+
{ name: "require" },
24+
{ name: "module" },
25+
{ name: "exports" },
26+
];
27+
2028
export default tseslint.config(
2129
{
2230
files: ["**/*.{ts,tsx,cts,mts,js,cjs,mjs}"],
@@ -165,11 +173,7 @@ export default tseslint.config(
165173
// These globals don't exist outside of CJS files.
166174
"no-restricted-globals": [
167175
"error",
168-
{ name: "__filename" },
169-
{ name: "__dirname" },
170-
{ name: "require" },
171-
{ name: "module" },
172-
{ name: "exports" },
176+
...restrictedESMGlobals,
173177
],
174178
},
175179
},
@@ -204,14 +208,18 @@ export default tseslint.config(
204208
{ name: "setImmediate" },
205209
{ name: "clearImmediate" },
206210
{ name: "performance" },
211+
...restrictedESMGlobals,
207212
],
208213
"local/no-direct-import": "error",
209214
},
210215
},
211216
{
212217
files: ["src/harness/**", "src/testRunner/**"],
213218
rules: {
214-
"no-restricted-globals": "off",
219+
"no-restricted-globals": [
220+
"error",
221+
...restrictedESMGlobals,
222+
],
215223
"regexp/no-super-linear-backtracking": "off",
216224
"local/no-direct-import": "off",
217225
},

scripts/browserIntegrationTest.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ for (const browserType of browsers) {
2828

2929
await page.setContent(`
3030
<html>
31-
<script>${readFileSync(join("built", "local", "typescript.js"), "utf8")}</script>
31+
<script type="module">${readFileSync(join("built", "local", "typescript.js"), "utf8")}</script>
32+
<!-- TODO(jakebailey): figure out the web story -->
3233
<script>if (typeof ts.version !== "string") throw new Error("ts.version not set")</script>
3334
</html>
3435
`);

scripts/checkModuleFormat.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ console.log(`Testing ${typescript}...`);
1919
/** @type {[fn: (() => Promise<any>), shouldSucceed: boolean][]} */
2020
const fns = [
2121
[() => require(typescript).version, true],
22-
[() => require(typescript).default.version, false],
22+
[() => require(typescript).default.version, true],
2323
[() => __importDefault(require(typescript)).version, false],
2424
[() => __importDefault(require(typescript)).default.version, true],
2525
[() => __importStar(require(typescript)).version, true],

scripts/dtsBundler.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ function isSelfReference(reference, symbol) {
365365
* @param {ts.Symbol} moduleSymbol
366366
*/
367367
function emitAsNamespace(name, parent, moduleSymbol, needExportModifier) {
368+
if (name === "default") return;
369+
368370
assert(moduleSymbol.flags & ts.SymbolFlags.ValueModule, "moduleSymbol is not a module");
369371

370372
const fullName = parent ? `${parent}.${name}` : name;
@@ -482,6 +484,7 @@ function emitAsNamespace(name, parent, moduleSymbol, needExportModifier) {
482484

483485
emitAsNamespace("ts", "", moduleSymbol, /*needExportModifier*/ false);
484486

487+
// TODO(jakebailey): require(ESM) - fix this
485488
write("export = ts;", WriteTarget.Both);
486489

487490
const copyrightNotice = fs.readFileSync(path.join(__dirname, "CopyrightNotice.txt"), "utf-8");

src/compiler/core.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2588,6 +2588,5 @@ export function isNodeLikeSystem(): boolean {
25882588
// use in performanceCore.ts.
25892589
return typeof process !== "undefined"
25902590
&& !!process.nextTick
2591-
&& !(process as any).browser
2592-
&& typeof require !== "undefined";
2591+
&& !(process as any).browser;
25932592
}

src/compiler/nodeGetBuiltinModule.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export function nodeCreateRequire(path: string): (id: string) => any {
2+
/* eslint-disable no-restricted-globals */
3+
// If we're running in an environment that already has `require`, use it.
4+
// We're probably in bun or a bundler that provides `require` even within ESM.
5+
if (typeof require === "function" && typeof require.resolve === "function") {
6+
return id => {
7+
const p = require.resolve(id, { paths: [path] });
8+
return require(p);
9+
};
10+
}
11+
/* eslint-enable no-restricted-globals */
12+
13+
// Otherwise, try and build a `require` function from the `module` module.
14+
if (typeof process === "undefined" || typeof process.getBuiltinModule !== "function") {
15+
throw new Error("process.getBuiltinModule is not supported in this environment.");
16+
}
17+
18+
const mod = process.getBuiltinModule("node:module");
19+
if (!mod) throw new Error("missing node:module");
20+
return mod.createRequire(path);
21+
}

src/compiler/performanceCore.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isNodeLikeSystem } from "./_namespaces/ts.js";
2+
import { nodeCreateRequire } from "./nodeGetBuiltinModule.js";
23

34
// The following definitions provide the minimum compatible support for the Web Performance User Timings API
45
// between browsers and NodeJS:
@@ -31,7 +32,7 @@ function tryGetPerformance() {
3132
if (isNodeLikeSystem()) {
3233
try {
3334
// By default, only write native events when generating a cpu profile or using the v8 profiler.
34-
// Some environments may polyfill this module with an empty object; verify the object has the expected shape.
35+
const require = nodeCreateRequire(import.meta.url);
3536
const { performance } = require("perf_hooks") as Partial<typeof import("perf_hooks")>;
3637
if (performance) {
3738
return {

0 commit comments

Comments
 (0)