diff --git a/README.md b/README.md index 12c74d473..19d1b5ad8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,21 @@ This allows the installed Amaro to override the Amaro version used by Node.js. node --experimental-strip-types --import="amaro/register" script.ts ``` +Or with the alias: + +```bash +node --experimental-strip-types --import="amaro/strip" script.ts +``` + +Enabling TypeScript feature transformation: + +```bash +node --experimental-transform-types --import="amaro/transform" script.ts +``` + +> Note that the "amaro/transform" loader should be used with `--experimental-transform-types` flag, or +> at least with `--enable-source-maps` flag, to preserve the original source maps. + ### How to update SWC To update the SWC version, run: diff --git a/esbuild.config.js b/esbuild.config.js deleted file mode 100644 index aa6a08d5d..000000000 --- a/esbuild.config.js +++ /dev/null @@ -1,24 +0,0 @@ -const { copy } = require("esbuild-plugin-copy"); -const esbuild = require("esbuild"); - -esbuild.build({ - entryPoints: ["src/index.ts"], - bundle: true, - platform: "node", - target: "node20", - outdir: "dist", - plugins: [ - copy({ - assets: { - from: ["./src/register/register.mjs"], - to: ["."], - }, - }), - copy({ - assets: { - from: ["./lib/LICENSE", "./lib/package.json"], - to: ["."], - }, - }), - ], -}); diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 000000000..281464db5 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,44 @@ +import { build } from "esbuild"; +import { copy } from "esbuild-plugin-copy"; + +const copyPlugin = copy({ + assets: [ + { + from: ["./src/register/register-strip.mjs"], + to: ["."], + }, + { + from: ["./src/register/register-transform.mjs"], + to: ["."], + }, + { + from: ["./lib/LICENSE", "./lib/package.json"], + to: ["."], + }, + ], +}); + +await build({ + entryPoints: ["src/index.ts"], + bundle: true, + platform: "node", + target: "node22", + outfile: "dist/index.js", + plugins: [copyPlugin], +}); + +await build({ + entryPoints: ["src/strip-loader.ts"], + bundle: false, + outfile: "dist/strip-loader.js", + platform: "node", + target: "node22", +}); + +await build({ + entryPoints: ["src/transform-loader.ts"], + bundle: false, + outfile: "dist/transform-loader.js", + platform: "node", + target: "node22", +}); diff --git a/package.json b/package.json index 2c2aaba75..1436909a8 100644 --- a/package.json +++ b/package.json @@ -21,14 +21,14 @@ "ci:fix": "biome check --write", "prepack": "npm run build", "postpack": "npm run clean", - "build": "node esbuild.config.js", + "build": "node esbuild.config.mjs", "typecheck": "tsc --noEmit", "test": "node --test --experimental-test-snapshots \"**/*.test.js\"", "test:regenerate": "node --test --experimental-test-snapshots --test-update-snapshots \"**/*.test.js\"" }, "devDependencies": { "@biomejs/biome": "1.8.3", - "@types/node": "^20.14.11", + "@types/node": "^22.0.0", "esbuild": "^0.23.0", "esbuild-plugin-copy": "^2.1.1", "rimraf": "^6.0.1", @@ -36,7 +36,12 @@ }, "exports": { ".": "./dist/index.js", - "./register": "./dist/register.mjs" + "./register": "./dist/register-strip.mjs", + "./strip": "./dist/register-strip.mjs", + "./transform": "./dist/register-transform.mjs" }, - "files": ["dist", "LICENSE.md"] + "files": ["dist", "LICENSE.md"], + "engines": { + "node": ">=22" + } } diff --git a/src/index.ts b/src/index.ts index 4f96007e2..5e35978f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1 @@ export { transformSync } from "./transform.ts"; -export { load } from "./loader.ts"; diff --git a/src/register/register-strip.mjs b/src/register/register-strip.mjs new file mode 100644 index 000000000..3a6134ba6 --- /dev/null +++ b/src/register/register-strip.mjs @@ -0,0 +1,3 @@ +import { register } from "node:module"; + +register("./strip-loader.js", import.meta.url); diff --git a/src/register/register-transform.mjs b/src/register/register-transform.mjs new file mode 100644 index 000000000..d8f2aad43 --- /dev/null +++ b/src/register/register-transform.mjs @@ -0,0 +1,12 @@ +import { register } from "node:module"; +import { emitWarning, env, execArgv } from "node:process"; + +const hasSourceMaps = + execArgv.includes("--enable-source-maps") || + env.NODE_OPTIONS?.includes("--enable-source-maps"); + +if (!hasSourceMaps) { + emitWarning("Source maps are disabled, stack traces will not accurate"); +} + +register("./transform-loader.js", import.meta.url); diff --git a/src/register/register.mjs b/src/register/register.mjs deleted file mode 100644 index 39e7bce12..000000000 --- a/src/register/register.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { register } from "node:module"; - -register("./index.js", import.meta.url); diff --git a/src/loader.ts b/src/strip-loader.ts similarity index 62% rename from src/loader.ts rename to src/strip-loader.ts index 1e4843dcb..a185935d3 100644 --- a/src/loader.ts +++ b/src/strip-loader.ts @@ -1,6 +1,6 @@ import type { LoadFnOutput, LoadHookContext } from "node:module"; import type { Options } from "../lib/wasm"; -import { transformSync } from "./index.ts"; +import { transformSync } from "./index.js"; type NextLoad = ( url: string, @@ -20,14 +20,16 @@ export async function load( ...context, format: "module", }); - if (source == null) - throw new Error("Source code cannot be null or undefined"); - const { code } = transformSync(source.toString(), { + // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source + const { code } = transformSync(source!.toString(), { mode: "strip-only", } as Options); return { format: format.replace("-typescript", ""), - source: code, + // 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}`, }; } return nextLoad(url, context); diff --git a/src/transform-loader.ts b/src/transform-loader.ts new file mode 100644 index 000000000..9f3b73629 --- /dev/null +++ b/src/transform-loader.ts @@ -0,0 +1,44 @@ +import type { LoadFnOutput, LoadHookContext } from "node:module"; +import type { Options } from "../lib/wasm"; +import { transformSync } from "./index.js"; + +type NextLoad = ( + url: string, + context?: LoadHookContext, +) => LoadFnOutput | Promise; + +export async function load( + url: string, + context: LoadHookContext, + nextLoad: NextLoad, +) { + const { format } = context; + if (format.endsWith("-typescript")) { + // Use format 'module' so it returns the source as-is, without stripping the types. + // Format 'commonjs' would not return the source for historical reasons. + const { source } = await nextLoad(url, { + ...context, + format: "module", + }); + + // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source + const { code, map } = transformSync(source!.toString(), { + mode: "transform", + sourceMap: true, + filename: 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}`, + }; + } + return nextLoad(url, context); +} diff --git a/test/fixtures/stacktrace.ts b/test/fixtures/stacktrace.ts new file mode 100644 index 000000000..7314a6568 --- /dev/null +++ b/test/fixtures/stacktrace.ts @@ -0,0 +1,4 @@ +enum Foo { + A = "Hello, TypeScript!", +} +throw new Error(Foo.A); diff --git a/test/loader.test.js b/test/loader.test.js index 2ee276981..42712baf6 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -1,12 +1,12 @@ const { spawnPromisified, fixturesPath } = require("./util/util.js"); const { test } = require("node:test"); -const { match, strictEqual } = require("node:assert"); +const { match, doesNotMatch, strictEqual } = require("node:assert"); test("should work as a loader", async () => { const result = await spawnPromisified(process.execPath, [ "--experimental-strip-types", "--no-warnings", - "--import=./dist/register.mjs", + "--import=./dist/register-strip.mjs", fixturesPath("hello.ts"), ]); @@ -15,11 +15,11 @@ test("should work as a loader", async () => { strictEqual(result.code, 0); }); -test("should work with enums", async () => { +test("should not work with enums", async () => { const result = await spawnPromisified(process.execPath, [ "--experimental-strip-types", "--no-warnings", - "--import=./dist/register.mjs", + "--import=./dist/register-strip.mjs", fixturesPath("enum.ts"), ]); @@ -27,3 +27,43 @@ test("should work with enums", async () => { match(result.stderr, /TypeScript enum is not supported in strip-only mode/); strictEqual(result.code, 1); }); + +test("should work with enums", async () => { + const result = await spawnPromisified(process.execPath, [ + "--experimental-strip-types", + "--no-warnings", + "--import=./dist/register-transform.mjs", + fixturesPath("enum.ts"), + ]); + + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.stderr, ""); + strictEqual(result.code, 0); +}); + +test("should warn and inaccurate stracktrace", async () => { + const result = await spawnPromisified(process.execPath, [ + "--experimental-strip-types", + "--import=./dist/register-transform.mjs", + fixturesPath("stacktrace.ts"), + ]); + + strictEqual(result.stdout, ""); + match(result.stderr, /Source maps are disabled/); + match(result.stderr, /stacktrace.ts:5:7/); // inaccurate + strictEqual(result.code, 1); +}); + +test("should not warn and accurate stracktrace", async () => { + const result = await spawnPromisified(process.execPath, [ + "--experimental-strip-types", + "--enable-source-maps", + "--import=./dist/register-transform.mjs", + fixturesPath("stacktrace.ts"), + ]); + + doesNotMatch(result.stderr, /Source maps are disabled/); + strictEqual(result.stdout, ""); + match(result.stderr, /stacktrace.ts:4:7/); // accurate + strictEqual(result.code, 1); +});