diff --git a/README.md b/README.md index f0c743b79..700965256 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,13 @@ node --enable-source-maps --import="amaro/transform" file.ts > Note that the `amaro/transform` loader should be used with `--enable-source-maps` > to preserve accurate source-mapped stack traces. -#### Programmatic registration with `module.register()` +#### Programmatic registration with `module.registerHooks()` -If you want TypeScript to "just work" in an existing codebase without passing `--import` every time, create a small bootstrap file and register Amaro once before loading your TS entrypoint. +Amaro's `strip` and `transform` entry points use `module.registerHooks()` to register synchronous hooks on Node.js versions that provide it, and fall back to `module.register()` on older versions. If you want TypeScript to "just work" in an existing codebase without passing `--import` every time, create a small bootstrap file that imports Amaro before loading your TS entrypoint. ```mjs // bootstrap.mjs -import { register } from "node:module"; - -register("amaro/strip", import.meta.url); +import "amaro/strip"; await import("./src/index.ts"); ``` diff --git a/src/register/register-strip.mjs b/src/register/register-strip.mjs index 3a6134ba6..7ffde8fba 100644 --- a/src/register/register-strip.mjs +++ b/src/register/register-strip.mjs @@ -1,3 +1,9 @@ -import { register } from "node:module"; +import module from "node:module"; -register("./strip-loader.js", import.meta.url); +if (typeof module.registerHooks === "function") { + const { default: loader } = await import("./strip-loader.js"); + module.registerHooks({ load: loader.loadSync }); +} else { + // Node.js versions without `module.registerHooks()`. + module.register("./strip-loader.js", import.meta.url); +} diff --git a/src/register/register-transform.mjs b/src/register/register-transform.mjs index f244052b2..54f8b0bca 100644 --- a/src/register/register-transform.mjs +++ b/src/register/register-transform.mjs @@ -1,4 +1,4 @@ -import { register } from "node:module"; +import module from "node:module"; import { emitWarning, env, execArgv } from "node:process"; const hasSourceMaps = @@ -9,4 +9,10 @@ if (!hasSourceMaps) { emitWarning("Source maps are disabled, stack traces will not be accurate"); } -register("./transform-loader.js", import.meta.url); +if (typeof module.registerHooks === "function") { + const { default: loader } = await import("./transform-loader.js"); + module.registerHooks({ load: loader.loadSync }); +} else { + // Node.js versions without `module.registerHooks()`. + module.register("./transform-loader.js", import.meta.url); +} diff --git a/src/strip-loader.ts b/src/strip-loader.ts index e76be2605..6537fffec 100644 --- a/src/strip-loader.ts +++ b/src/strip-loader.ts @@ -3,40 +3,62 @@ import { fileURLToPath } from "node:url"; import { isSwcError, wrapAndReThrowSwcError } from "./errors.js"; import { transformSync } from "./index.js"; +type NextLoad = ( + url: string, + context?: Partial, +) => LoadFnOutput; + +function strip(url: string, format: string, source: LoadFnOutput["source"]) { + try { + // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source + const { code } = transformSync(source!.toString(), { + mode: "strip-only", + filename: fileURLToPath(url), + }); + return { + format: format.replace("-typescript", ""), + // Source map is not necessary in strip-only mode. However, to map the source + // file in debuggers to the original TypeScript source, add a sourceURL magic + // comment to hint that it is a generated source. + source: `${code}\n\n//# sourceURL=${url}`, + }; + } catch (error: unknown) { + if (isSwcError(error)) { + wrapAndReThrowSwcError(error); + } + // If the error is not an SwcError, rethrow it + throw error; + } +} + +// Synchronous hook for `module.registerHooks()`. +export function loadSync( + url: string, + context: LoadHookContext, + nextLoad: NextLoad, +) { + const { format } = context; + if (format?.endsWith("-typescript")) { + const { source } = nextLoad(url, { ...context, format }); + return strip(url, format, source); + } + return nextLoad(url, context); +} + +// Asynchronous hook for `module.register()`, used on Node.js versions that +// do not provide `module.registerHooks()`. export async function load( url: string, context: LoadHookContext, nextLoad: ( url: string, - context?: LoadHookContext, + context?: Partial, ) => LoadFnOutput | Promise, ) { const { format } = context; if (format?.endsWith("-typescript")) { - try { - const { source } = await nextLoad(url, { - ...context, - format, - }); - // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source - const { code } = transformSync(source!.toString(), { - mode: "strip-only", - filename: fileURLToPath(url), - }); - return { - format: format.replace("-typescript", ""), - // Source map is not necessary in strip-only mode. However, to map the source - // file in debuggers to the original TypeScript source, add a sourceURL magic - // comment to hint that it is a generated source. - source: `${code}\n\n//# sourceURL=${url}`, - }; - } catch (error: unknown) { - if (isSwcError(error)) { - wrapAndReThrowSwcError(error); - } - // If the error is not an SwcError, rethrow it - throw error; - } + const { source } = await nextLoad(url, { ...context, format }); + return strip(url, format, source); } return nextLoad(url, context); } diff --git a/src/transform-loader.ts b/src/transform-loader.ts index ff9fa9231..1e07c449c 100644 --- a/src/transform-loader.ts +++ b/src/transform-loader.ts @@ -4,47 +4,72 @@ import type { Options } from "../lib/wasm"; import { isSwcError, wrapAndReThrowSwcError } from "./errors.js"; import { transformSync } from "./index.js"; +type NextLoad = ( + url: string, + context?: Partial, +) => LoadFnOutput; + +function transform( + url: string, + format: string, + source: LoadFnOutput["source"], +) { + try { + // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source + const { code, map } = transformSync(source!.toString(), { + mode: "transform", + sourceMap: true, + filename: fileURLToPath(url), + } as Options); + + let output = code; + + if (map) { + const base64SourceMap = Buffer.from(map).toString("base64"); + output = `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`; + } + + return { + format: format.replace("-typescript", ""), + source: `${output}\n\n//# sourceURL=${url}`, + }; + } catch (error) { + if (isSwcError(error)) { + wrapAndReThrowSwcError(error); + } + // If the error is not an SwcError, rethrow it + throw error; + } +} + +// Synchronous hook for `module.registerHooks()`. +export function loadSync( + url: string, + context: LoadHookContext, + nextLoad: NextLoad, +) { + const { format } = context; + if (format?.endsWith("-typescript")) { + const { source } = nextLoad(url, { ...context, format }); + return transform(url, format, source); + } + return nextLoad(url, context); +} + +// Asynchronous hook for `module.register()`, used on Node.js versions that +// do not provide `module.registerHooks()`. export async function load( url: string, context: LoadHookContext, nextLoad: ( url: string, - context?: LoadHookContext, + context?: Partial, ) => LoadFnOutput | Promise, ) { const { format } = context; if (format?.endsWith("-typescript")) { - try { - const { source } = await nextLoad(url, { - ...context, - format, - }); - - // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source - const { code, map } = transformSync(source!.toString(), { - mode: "transform", - sourceMap: true, - filename: fileURLToPath(url), - } as Options); - - let output = code; - - if (map) { - const base64SourceMap = Buffer.from(map).toString("base64"); - output = `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`; - } - - return { - format: format.replace("-typescript", ""), - source: `${output}\n\n//# sourceURL=${url}`, - }; - } catch (error) { - if (isSwcError(error)) { - wrapAndReThrowSwcError(error); - } - // If the error is not an SwcError, rethrow it - throw error; - } + const { source } = await nextLoad(url, { ...context, format }); + return transform(url, format, source); } return nextLoad(url, context); } diff --git a/test/fixtures/bootstrap-strip.mjs b/test/fixtures/bootstrap-strip.mjs new file mode 100644 index 000000000..aefdccefe --- /dev/null +++ b/test/fixtures/bootstrap-strip.mjs @@ -0,0 +1,3 @@ +import "../../dist/register-strip.mjs"; + +await import("./esm/esm.ts"); diff --git a/test/fixtures/other-hook.mjs b/test/fixtures/other-hook.mjs new file mode 100644 index 000000000..f6d0711d6 --- /dev/null +++ b/test/fixtures/other-hook.mjs @@ -0,0 +1,11 @@ +import { registerHooks } from "node:module"; + +registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + if (url.endsWith("/hello.js")) { + console.log("other hook saw hello.js"); + } + return result; + }, +}); diff --git a/test/fixtures/require-ts.js b/test/fixtures/require-ts.js new file mode 100644 index 000000000..4cfee8993 --- /dev/null +++ b/test/fixtures/require-ts.js @@ -0,0 +1,3 @@ +const { greet } = require("./commonjs.ts"); + +greet("required"); diff --git a/test/register-hooks.test.js b/test/register-hooks.test.js new file mode 100644 index 000000000..2e1613c27 --- /dev/null +++ b/test/register-hooks.test.js @@ -0,0 +1,101 @@ +const { spawnPromisified, fixturesPath } = require("./util/util.js"); +const { test } = require("node:test"); +const { registerHooks } = require("node:module"); +const { pathToFileURL } = require("node:url"); + +// Without registerHooks, Amaro falls back to async module.register(). +const skip = + typeof registerHooks !== "function" && "requires module.registerHooks()"; +const { match, strictEqual } = require("node:assert"); + +const otherHook = pathToFileURL(fixturesPath("other-hook.mjs")).href; + +const run = (args) => + spawnPromisified(process.execPath, ["--no-warnings", ...args]); + +for (const mode of ["strip", "transform"]) { + test(`should apply ${mode} hooks to require() of TypeScript`, async () => { + const result = await run([ + `--import=./dist/register-${mode}.mjs`, + fixturesPath("require-ts.js"), + ]); + + strictEqual(result.stderr, ""); + match(result.stdout, /Hello from CommonJS TypeScript! - required/); + strictEqual(result.code, 0); + }); + + test(`should apply ${mode} hooks to dynamic import() of TypeScript`, async () => { + const result = await run([ + `--import=./dist/register-${mode}.mjs`, + "--input-type=module", + "--eval", + `await import(${JSON.stringify(pathToFileURL(fixturesPath("esm/esm.ts")).href)});`, + ]); + + strictEqual(result.stderr, ""); + match(result.stdout, /Hello from ESM TypeScript!/); + strictEqual(result.code, 0); + }); + + test( + `should compose ${mode} hooks with other registered hooks`, + { skip }, + async () => { + const typescript = await run([ + `--import=./dist/register-${mode}.mjs`, + `--import=${otherHook}`, + fixturesPath("hello.ts"), + ]); + strictEqual(typescript.stderr, ""); + match(typescript.stdout, /Hello, TypeScript!/); + strictEqual(typescript.code, 0); + + const javascript = await run([ + `--import=${otherHook}`, + `--import=./dist/register-${mode}.mjs`, + fixturesPath("hello.js"), + ]); + strictEqual(javascript.stderr, ""); + match(javascript.stdout, /other hook saw hello\.js/); + match(javascript.stdout, /Hello, JavaScript!/); + strictEqual(javascript.code, 0); + }, + ); +} + +test("should work from a programmatic bootstrap file", async () => { + const result = await run([fixturesPath("bootstrap-strip.mjs")]); + + strictEqual(result.stderr, ""); + match(result.stdout, /Hello from ESM TypeScript!/); + strictEqual(result.code, 0); +}); + +test("should throw a syntax error from require() of invalid TypeScript", async () => { + const result = await run([ + "--import=./dist/register-strip.mjs", + "--eval", + `require(${JSON.stringify(fixturesPath("invalid-syntax.ts"))});`, + ]); + + strictEqual(result.stdout, ""); + match(result.stderr, /SyntaxError/); + strictEqual(result.code, 1); +}); + +test( + "should map transformed require() stack traces with source maps", + { skip }, + async () => { + const result = await spawnPromisified(process.execPath, [ + "--enable-source-maps", + "--import=./dist/register-transform.mjs", + "--eval", + `require(${JSON.stringify(fixturesPath("stacktrace.ts"))});`, + ]); + + match(result.stderr, /stacktrace.ts:7:9/); + strictEqual(result.code, 1); + }, +);