@ 261027.7

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

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"); ``` 

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);+}

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<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); }

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<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

new file mode 100644@@ -0,0 +1,3 @@+import "../../dist/register-strip.mjs";++await import("./esm/esm.ts");

test/fixtures/other-hook.mjs

new file mode 100644@@ -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;+	},+});

test/fixtures/require-ts.js

new file mode 100644@@ -0,0 +1,3 @@+const { greet } = require("./commonjs.ts");++greet("required");

test/register-hooks.test.js

new file mode 100644@@ -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);+	},+);