From 541ba50e29080ef83d769e8d7484919554dac78c Mon Sep 17 00:00:00 2001 From: Evert Heylen Date: Thu, 10 Sep 2026 14:29:02 +0200 Subject: [PATCH] fix: use synchronous module hooks Signed-off-by: Evert Heylen --- .github/workflows/ci.yml | 16 +++++-- README.md | 9 ++-- esbuild.config.mjs | 18 ++++++- src/register/register-strip.mjs | 9 +++- src/register/register-transform.mjs | 9 +++- src/strip-loader.ts | 65 +++++++++++++++---------- src/transform-loader.ts | 74 +++++++++++++++++------------ test/loader.test.js | 6 +-- 8 files changed, 133 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 923328aa9..fa6dac7b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,21 +33,27 @@ jobs: fail-fast: false matrix: node: - - 22 - - 24 + - "22.x" + - "24.x" platform: - ubuntu-latest - macos-latest - windows-latest - name: "${{matrix.platform}} w/ Node.js ${{matrix.node}}.x" + include: + - node: "22.14.0" + platform: ubuntu-latest + node_options: "--experimental-strip-types" + name: "${{matrix.platform}} w/ Node.js ${{matrix.node}}" runs-on: ${{matrix.platform}} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: "Use Node.js ${{matrix.node}}.x" + - name: "Use Node.js ${{matrix.node}}" uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ${{matrix.node}}.x + node-version: ${{matrix.node}} - run: npm install - run: npm run typecheck - run: npm run build - run: npm test + env: + NODE_OPTIONS: ${{matrix.node_options}} diff --git a/README.md b/README.md index 44c44c4dc..72201fde1 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,14 @@ 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 loading -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. +If you want TypeScript to "just work" in an existing codebase without passing `--import` every time, create a small bootstrap file and import Amaro before loading your TS entrypoint. ```mjs // bootstrap.mjs -import { register } from "node:module"; +import "amaro/strip"; -register("amaro/strip", import.meta.url); await import("./src/index.ts"); ``` @@ -65,7 +64,7 @@ Then start your app through the bootstrap file: node --watch ./bootstrap.mjs ``` -For transform mode, swap `amaro/strip` with `amaro/transform` and run Node with `--enable-source-maps`. +For transform mode, import `amaro/transform` instead and run Node with `--enable-source-maps`. ### Monorepo usage diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 1b1b62953..9416e0c0b 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -2,7 +2,12 @@ import { build } from "esbuild"; import { copy } from "esbuild-plugin-copy"; await build({ - entryPoints: ["src/*.ts"], + entryPoints: [ + "src/errors.ts", + "src/index.ts", + "src/nodejs.ts", + "src/transform.ts", + ], bundle: false, outdir: "dist", outbase: "src", @@ -24,3 +29,14 @@ await build({ }), ], }); + +await build({ + entryPoints: ["src/*-loader.ts"], + bundle: false, + outdir: "dist", + outbase: "src", + outExtension: { ".js": ".mjs" }, + platform: "node", + format: "esm", + target: "node22", +}); diff --git a/src/register/register-strip.mjs b/src/register/register-strip.mjs index 3a6134ba6..b3f9861eb 100644 --- a/src/register/register-strip.mjs +++ b/src/register/register-strip.mjs @@ -1,3 +1,8 @@ -import { register } from "node:module"; +import * as module from "node:module"; -register("./strip-loader.js", import.meta.url); +if (typeof module.registerHooks === "function") { + const { loadSync } = await import("./strip-loader.mjs"); + module.registerHooks({ load: loadSync }); +} else { + module.register("./strip-loader.mjs", import.meta.url); +} diff --git a/src/register/register-transform.mjs b/src/register/register-transform.mjs index f244052b2..bf209f227 100644 --- a/src/register/register-transform.mjs +++ b/src/register/register-transform.mjs @@ -1,4 +1,4 @@ -import { register } from "node:module"; +import * as module from "node:module"; import { emitWarning, env, execArgv } from "node:process"; const hasSourceMaps = @@ -9,4 +9,9 @@ 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 { loadSync } = await import("./transform-loader.mjs"); + module.registerHooks({ load: loadSync }); +} else { + module.register("./transform-loader.mjs", import.meta.url); +} diff --git a/src/strip-loader.ts b/src/strip-loader.ts index e76be2605..bec368eeb 100644 --- a/src/strip-loader.ts +++ b/src/strip-loader.ts @@ -1,35 +1,33 @@ -import type { LoadFnOutput, LoadHookContext } from "node:module"; +import type { LoadFnOutput, LoadHook, LoadHookSync } from "node:module"; import { fileURLToPath } from "node:url"; import { isSwcError, wrapAndReThrowSwcError } from "./errors.js"; import { transformSync } from "./index.js"; -export async function load( +function stripTypeScript( url: string, - context: LoadHookContext, - nextLoad: ( - url: string, - context?: LoadHookContext, - ) => LoadFnOutput | Promise, -) { + format: string, + source: LoadFnOutput["source"], +): LoadFnOutput { + // 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}`, + }; +} + +export const load: LoadHook = async (url, context, nextLoad) => { 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}`, - }; + const { source } = await nextLoad(url, { ...context, format }); + return stripTypeScript(url, format, source); } catch (error: unknown) { if (isSwcError(error)) { wrapAndReThrowSwcError(error); @@ -39,4 +37,21 @@ export async function load( } } return nextLoad(url, context); -} +}; + +export const loadSync: LoadHookSync = (url, context, nextLoad) => { + const { format } = context; + if (format?.endsWith("-typescript")) { + try { + const { source } = nextLoad(url, { ...context, format }); + return stripTypeScript(url, format, source); + } catch (error: unknown) { + if (isSwcError(error)) { + wrapAndReThrowSwcError(error); + } + // If the error is not an SwcError, rethrow it + throw error; + } + } + return nextLoad(url, context); +}; diff --git a/src/transform-loader.ts b/src/transform-loader.ts index ff9fa9231..58f9078af 100644 --- a/src/transform-loader.ts +++ b/src/transform-loader.ts @@ -1,43 +1,57 @@ -import type { LoadFnOutput, LoadHookContext } from "node:module"; +import type { LoadFnOutput, LoadHook, LoadHookSync } from "node:module"; import { fileURLToPath } from "node:url"; import type { Options } from "../lib/wasm"; import { isSwcError, wrapAndReThrowSwcError } from "./errors.js"; import { transformSync } from "./index.js"; -export async function load( +function transformTypeScript( url: string, - context: LoadHookContext, - nextLoad: ( - url: string, - context?: LoadHookContext, - ) => LoadFnOutput | Promise, -) { - const { format } = context; - if (format?.endsWith("-typescript")) { - try { - const { source } = await nextLoad(url, { - ...context, - format, - }); + format: string, + source: LoadFnOutput["source"], +): LoadFnOutput { + // 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); - // 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; - 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}`, + }; +} - if (map) { - const base64SourceMap = Buffer.from(map).toString("base64"); - output = `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`; +export const load: LoadHook = async (url, context, nextLoad) => { + const { format } = context; + if (format?.endsWith("-typescript")) { + try { + const { source } = await nextLoad(url, { ...context, format }); + return transformTypeScript(url, format, source); + } catch (error) { + if (isSwcError(error)) { + wrapAndReThrowSwcError(error); } + // If the error is not an SwcError, rethrow it + throw error; + } + } + return nextLoad(url, context); +}; - return { - format: format.replace("-typescript", ""), - source: `${output}\n\n//# sourceURL=${url}`, - }; +export const loadSync: LoadHookSync = (url, context, nextLoad) => { + const { format } = context; + if (format?.endsWith("-typescript")) { + try { + const { source } = nextLoad(url, { ...context, format }); + return transformTypeScript(url, format, source); } catch (error) { if (isSwcError(error)) { wrapAndReThrowSwcError(error); @@ -47,4 +61,4 @@ export async function load( } } return nextLoad(url, context); -} +}; diff --git a/test/loader.test.js b/test/loader.test.js index b0257792f..d1250227d 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -2,9 +2,8 @@ const { spawnPromisified, fixturesPath } = require("./util/util.js"); const { test } = require("node:test"); const { match, doesNotMatch, strictEqual } = require("node:assert"); -test("should work as a loader", async () => { +test("should work as a loader without warnings", async () => { const result = await spawnPromisified(process.execPath, [ - "--no-warnings", "--import=./dist/register-strip.mjs", fixturesPath("hello.ts"), ]); @@ -29,7 +28,7 @@ test("should not work with enums", async (t) => { test("should work with enums", async () => { const result = await spawnPromisified(process.execPath, [ - "--no-warnings", + "--enable-source-maps", "--import=./dist/register-transform.mjs", fixturesPath("enum.ts"), ]); @@ -47,6 +46,7 @@ test("should warn and inaccurate stracktrace", async () => { strictEqual(result.stdout, ""); match(result.stderr, /Source maps are disabled/); + doesNotMatch(result.stderr, /DEP0205/); match(result.stderr, /stacktrace.ts:7:11/); // inaccurate strictEqual(result.code, 1); });