Skip to content

Commit

Permalink
Make things work in ESM with polyfill
Browse files Browse the repository at this point in the history
  • Loading branch information
jakebailey committed May 10, 2024
1 parent 40176ce commit 983a8e1
Show file tree
Hide file tree
Showing 34 changed files with 249 additions and 230 deletions.
108 changes: 32 additions & 76 deletions Herebyfile.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @ts-check
import { CancelToken } from "@esfx/canceltoken";
import assert from "assert";
import chalk from "chalk";
import chokidar from "chokidar";
import esbuild from "esbuild";
Expand Down Expand Up @@ -172,25 +171,22 @@ async function runDtsBundler(entrypoint, output) {
* @param {BundlerTaskOptions} [taskOptions]
*
* @typedef BundlerTaskOptions
* @property {boolean} [exportIsTsObject]
* @property {boolean} [treeShaking]
* @property {boolean} [usePublicAPI]
* @property {() => void} [onWatchRebuild]
*/
function createBundler(entrypoint, outfile, taskOptions = {}) {
const getOptions = memoize(async () => {
const copyright = await getCopyrightHeader();
const banner = taskOptions.exportIsTsObject ? "var ts = {}; ((module) => {" : "";

/** @type {esbuild.BuildOptions} */
const options = {
entryPoints: [entrypoint],
banner: { js: copyright + banner },
banner: { js: copyright },
bundle: true,
outfile,
platform: "node",
target: ["es2020", "node14.17"],
format: "cjs",
format: "esm",
sourcemap: "linked",
sourcesContent: false,
treeShaking: taskOptions.treeShaking,
Expand All @@ -200,66 +196,17 @@ function createBundler(entrypoint, outfile, taskOptions = {}) {
};

if (taskOptions.usePublicAPI) {
options.external = ["./typescript.js"];
options.plugins = options.plugins || [];
options.plugins.push({
name: "remap-typescript-to-require",
name: "remap-typescript-to-public-api",
setup(build) {
build.onLoad({ filter: /src[\\/]typescript[\\/]typescript\.ts$/ }, () => {
return { contents: `export * from "./typescript.js"` };
build.onResolve({ filter: /^(?:\.\.[\\/])*typescript[\\/]typescript\.js$/ }, () => {
return { path: "./typescript.js", external: true };
});
},
});
}

if (taskOptions.exportIsTsObject) {
// Monaco bundles us as ESM by wrapping our code with something that defines module.exports
// but then does not use it, instead using the `ts` variable. Ensure that if we think we're CJS
// that we still set `ts` to the module.exports object.
options.footer = { js: `})(typeof module !== "undefined" && module.exports ? module : { exports: ts });\nif (typeof module !== "undefined" && module.exports) { ts = module.exports; }` };

// esbuild converts calls to "require" to "__require"; this function
// calls the real require if it exists, or throws if it does not (rather than
// throwing an error like "require not defined"). But, since we want typescript
// to be consumable by other bundlers, we need to convert these calls back to
// require so our imports are visible again.
//
// To fix this, we redefine "require" to a name we're unlikely to use with the
// same length as "require", then replace it back to "require" after bundling,
// ensuring that source maps still work.
//
// See: https://github.com/evanw/esbuild/issues/1905
const require = "require";
const fakeName = "Q".repeat(require.length);
const fakeNameRegExp = new RegExp(fakeName, "g");
options.define = { [require]: fakeName };

// For historical reasons, TypeScript does not set __esModule. Hack esbuild's __toCommonJS to be a noop.
// We reference `__copyProps` to ensure the final bundle doesn't have any unreferenced code.
const toCommonJsRegExp = /var __toCommonJS .*/;
const toCommonJsRegExpReplacement = "var __toCommonJS = (mod) => (__copyProps, mod); // Modified helper to skip setting __esModule.";

options.plugins = options.plugins || [];
options.plugins.push(
{
name: "post-process",
setup: build => {
build.onEnd(async () => {
let contents = await fs.promises.readFile(outfile, "utf-8");
contents = contents.replace(fakeNameRegExp, require);
let matches = 0;
contents = contents.replace(toCommonJsRegExp, () => {
matches++;
return toCommonJsRegExpReplacement;
});
assert(matches === 1, "Expected exactly one match for __toCommonJS");
await fs.promises.writeFile(outfile, contents);
});
},
},
);
}

return options;
});

Expand Down Expand Up @@ -304,6 +251,7 @@ let printedWatchWarning = false;
* @param {string} options.builtEntrypoint
* @param {string} options.output
* @param {Task[]} [options.mainDeps]
* @param {boolean} [options.reexportDefault]
* @param {BundlerTaskOptions} [options.bundlerOptions]
*/
function entrypointBuildTask(options) {
Expand All @@ -324,22 +272,33 @@ function entrypointBuildTask(options) {
});

/**
* Writes a CJS module that reexports another CJS file. E.g. given
* Writes a module that reexports another file. E.g. given
* `options.builtEntrypoint = "./built/local/tsc/tsc.js"` and
* `options.output = "./built/local/tsc.js"`, this will create a file
* named "./built/local/tsc.js" containing:
*
* ```
* module.exports = require("./tsc/tsc.js")
* export * from "./tsc/tsc.js";
* ```
*/
const shim = task({
name: `shim-${options.name}`,
run: async () => {
const outDir = path.dirname(options.output);
await fs.promises.mkdir(outDir, { recursive: true });
const moduleSpecifier = path.relative(outDir, options.builtEntrypoint);
await fs.promises.writeFile(options.output, `module.exports = require("./${moduleSpecifier.replace(/[\\/]/g, "/")}")`);
const moduleSpecifier = path.relative(outDir, options.builtEntrypoint).replace(/[\\/]/g, "/");
const lines = [
`export * from "./${moduleSpecifier}";`,
];

if (options.reexportDefault) {
lines.push(
`import _default from "./${moduleSpecifier}";`,
`export default _default;`,
);
}

await fs.promises.writeFile(options.output, lines.join("\n") + "\n");
},
});

Expand Down Expand Up @@ -404,7 +363,7 @@ const { main: services, build: buildServices, watch: watchServices } = entrypoin
builtEntrypoint: "./built/local/typescript/typescript.js",
output: "./built/local/typescript.js",
mainDeps: [generateLibs],
bundlerOptions: { exportIsTsObject: true },
reexportDefault: true,
});
export { services, watchServices };

Expand Down Expand Up @@ -445,25 +404,22 @@ export const watchMin = task({
dependencies: [watchTsc, watchTsserver],
});

// This is technically not enough to make tsserverlibrary loadable in the
// browser, but it's unlikely that anyone has actually been doing that.
const lsslJs = `
if (typeof module !== "undefined" && module.exports) {
module.exports = require("./typescript.js");
}
else {
throw new Error("tsserverlibrary requires CommonJS; use typescript.js instead");
}
import ts from "./typescript.js";
export * from "./typescript.js";
export default ts;
`;

const lsslDts = `
import ts = require("./typescript.js");
export = ts;
import ts from "./typescript.js";
export * from "./typescript.js";
export default ts;
`;

const lsslDtsInternal = `
import ts = require("./typescript.internal.js");
export = ts;
import ts from "./typescript.internal.js";
export * from "./typescript.internal.js";
export default ts;
`;

/**
Expand Down Expand Up @@ -504,7 +460,7 @@ const { main: tests, watch: watchTests } = entrypointBuildTask({
description: "Builds the test infrastructure",
buildDeps: [generateDiagnostics],
project: "src/testRunner",
srcEntrypoint: "./src/testRunner/_namespaces/Harness.ts",
srcEntrypoint: "./src/testRunner/runner.ts",
builtEntrypoint: "./built/local/testRunner/runner.js",
output: testRunner,
mainDeps: [generateLibs],
Expand Down
2 changes: 1 addition & 1 deletion bin/tsc
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/usr/bin/env node
require('../lib/tsc.js')
import '../lib/tsc.js';
2 changes: 1 addition & 1 deletion bin/tsserver
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/usr/bin/env node
require('../lib/tsserver.js')
import '../lib/tsserver.js';
13 changes: 13 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@esfx/canceltoken": "^1.0.0",
"@octokit/rest": "^20.1.1",
"@types/chai": "^4.3.16",
"@types/diff": "^5.2.1",
"@types/microsoft__typescript-etw": "^0.1.3",
"@types/minimist": "^1.2.5",
"@types/mocha": "^10.0.6",
Expand Down
9 changes: 9 additions & 0 deletions patchProcessGetBuiltin.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const _module = require("module");

/** @type {(name: string) => any} */
function getBuiltinModule(name) {
if (!_module.isBuiltin(name)) return undefined;
return require(name);
}

process.getBuiltinModule = getBuiltinModule;
2 changes: 1 addition & 1 deletion scripts/browserIntegrationTest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ for (const browserType of browsers) {

await page.setContent(`
<html>
<script>${readFileSync(join("built", "local", "typescript.js"), "utf8")}</script>
<script type="module">${readFileSync(join("built", "local", "typescript.js"), "utf8")}</script>
</html>
`);

Expand Down
2 changes: 1 addition & 1 deletion scripts/checkModuleFormat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ console.log(`Testing ${typescript}...`);
/** @type {[fn: (() => Promise<any>), shouldSucceed: boolean][]} */
const fns = [
[() => require(typescript).version, true],
[() => require(typescript).default.version, false],
[() => require(typescript).default.version, true],
[() => __importDefault(require(typescript)).version, false],
[() => __importDefault(require(typescript)).default.version, true],
[() => __importStar(require(typescript)).version, true],
Expand Down
3 changes: 3 additions & 0 deletions scripts/dtsBundler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ function isSelfReference(reference, symbol) {
* @param {ts.Symbol} moduleSymbol
*/
function emitAsNamespace(name, parent, moduleSymbol, needExportModifier) {
if (name === "default") return;

assert(moduleSymbol.flags & ts.SymbolFlags.ValueModule, "moduleSymbol is not a module");

const fullName = parent ? `${parent}.${name}` : name;
Expand Down Expand Up @@ -465,6 +467,7 @@ function emitAsNamespace(name, parent, moduleSymbol, needExportModifier) {

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

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

const copyrightNotice = fs.readFileSync(path.join(__dirname, "CopyrightNotice.txt"), "utf-8");
Expand Down
5 changes: 4 additions & 1 deletion src/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
{ "name": "clearInterval" },
{ "name": "setImmediate" },
{ "name": "clearImmediate" },
{ "name": "performance" }
{ "name": "performance" },
{ "name": "require" },
{ "name": "__dirname" },
{ "name": "__filename" }
]
},
"overrides": [
Expand Down
3 changes: 1 addition & 2 deletions src/cancellationToken/cancellationToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function pipeExists(name: string): boolean {
return fs.existsSync(name);
}

function createCancellationToken(args: string[]): ServerCancellationToken {
export function createCancellationToken(args: string[]): ServerCancellationToken {
let cancellationPipeName: string | undefined;
for (let i = 0; i < args.length - 1; i++) {
if (args[i] === "--cancellationPipeName") {
Expand Down Expand Up @@ -66,4 +66,3 @@ function createCancellationToken(args: string[]): ServerCancellationToken {
};
}
}
export = createCancellationToken;
2 changes: 1 addition & 1 deletion src/compiler/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2730,5 +2730,5 @@ export function isNodeLikeSystem(): boolean {
return typeof process !== "undefined"
&& !!process.nextTick
&& !(process as any).browser
&& typeof require !== "undefined";
&& typeof (process as any).getBuiltinModule === "function";
}
12 changes: 12 additions & 0 deletions src/compiler/nodeGetBuiltinModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function nodeCreateRequire(path: string): NodeJS.Require {
const mod = nodeGetBuiltinModule("module") as typeof import("module") | undefined;
if (!mod) throw new Error("missing node:module");
return mod.createRequire(path);
}

function nodeGetBuiltinModule(moduleName: string): unknown {
if (typeof process === "undefined" || typeof (process as any).getBuiltinModule !== "function") {
throw new Error("process.getBuiltinModule is not supported in this environment.");
}
return (process as any).getBuiltinModule(moduleName);
}
3 changes: 3 additions & 0 deletions src/compiler/perfLogger.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { nodeCreateRequire } from "./nodeGetBuiltinModule.js";

/** @internal */
export interface PerfLogger {
logEvent(msg: string): void;
Expand Down Expand Up @@ -27,6 +29,7 @@ export interface PerfLogger {
let etwModule: typeof import("@microsoft/typescript-etw") | undefined;
try {
const etwModulePath = process.env.TS_ETW_MODULE_PATH ?? "./node_modules/@microsoft/typescript-etw";
const require = nodeCreateRequire(import.meta.url);

// require() will throw an exception if the module is not found
// It may also return undefined if not installed properly
Expand Down
4 changes: 3 additions & 1 deletion src/compiler/performanceCore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isNodeLikeSystem } from "./_namespaces/ts.js";
import { nodeCreateRequire } from "./nodeGetBuiltinModule.js";

// The following definitions provide the minimum compatible support for the Web Performance User Timings API
// between browsers and NodeJS:
Expand Down Expand Up @@ -31,7 +32,8 @@ function tryGetPerformance() {
if (isNodeLikeSystem()) {
try {
// By default, only write native events when generating a cpu profile or using the v8 profiler.
const { performance } = require("perf_hooks") as typeof import("perf_hooks");
const require = nodeCreateRequire(import.meta.url);
const { performance } = require("perf_hooks");
return {
shouldWriteNativeEvents: false,
performance,
Expand Down
9 changes: 8 additions & 1 deletion src/compiler/sys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
WatchOptions,
writeFileEnsuringDirectories,
} from "./_namespaces/ts.js";
import { nodeCreateRequire } from "./nodeGetBuiltinModule.js";

declare function setTimeout(handler: (...args: any[]) => void, timeout: number): any;
declare function clearTimeout(handle: any): void;
Expand Down Expand Up @@ -1466,9 +1467,15 @@ export let sys: System = (() => {
const byteOrderMarkIndicator = "\uFEFF";

function getNodeSystem(): System {
// TODO(jakebailey): Only use createRequire for sys.require.
const require = nodeCreateRequire(import.meta.url);
const _path: typeof import("path") = require("path");
const _url: typeof import("url") = require("url");
const __filename = _url.fileURLToPath(new URL(import.meta.url));
const __dirname = _path.dirname(__filename);

const nativePattern = /^native |^\([^)]+\)$|^(internal[\\/]|[a-zA-Z0-9_\s]+(\.js)?$)/;
const _fs: typeof import("fs") = require("fs");
const _path: typeof import("path") = require("path");
const _os = require("os");
// crypto can be absent on reduced node installations
let _crypto: typeof import("crypto") | undefined;
Expand Down
Loading

0 comments on commit 983a8e1

Please sign in to comment.