Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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");
```

Expand Down
10 changes: 8 additions & 2 deletions src/register/register-strip.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 8 additions & 2 deletions src/register/register-transform.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { register } from "node:module";
import module from "node:module";
import { emitWarning, env, execArgv } from "node:process";

const hasSourceMaps =
Expand All @@ -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);
}
72 changes: 47 additions & 25 deletions src/strip-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
89 changes: 57 additions & 32 deletions src/transform-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions test/fixtures/bootstrap-strip.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import "../../dist/register-strip.mjs";

await import("./esm/esm.ts");
11 changes: 11 additions & 0 deletions test/fixtures/other-hook.mjs
Original file line number Diff line number Diff line change
@@ -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;
},
});
3 changes: 3 additions & 0 deletions test/fixtures/require-ts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { greet } = require("./commonjs.ts");

greet("required");
101 changes: 101 additions & 0 deletions test/register-hooks.test.js
Original file line number Diff line number Diff line change
@@ -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);
},
);
Loading