261026.1 · b6bf196ae1e954da44a0f35532cd30b2e0969202
feat: use module.registerHooks for amaro/strip and amaro/transform
Use synchronous module.registerHooks() when available, so the loaders also apply to require() of TypeScript and avoid the loader thread. Fall back to module.register() on Node.js versions without it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
reading declarations
@@ -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"); ```
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);+}
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);+}
@@ -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<LoadHookContext>,+) => 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<LoadHookContext>, ) => LoadFnOutput | Promise<LoadFnOutput>, ) { 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); }
@@ -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<LoadHookContext>,+) => 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<LoadHookContext>, ) => LoadFnOutput | Promise<LoadFnOutput>, ) { 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); }
test/fixtures/bootstrap-strip.mjs
@@ -0,0 +1,3 @@+import "../../dist/register-strip.mjs";++await import("./esm/esm.ts");
@@ -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;+ },+});
@@ -0,0 +1,3 @@+const { greet } = require("./commonjs.ts");++greet("required");
@@ -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);+ },+);