Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
5 changes: 5 additions & 0 deletions .changeset/nodejs-support-wasm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
swc_core: patch
---

feat(wasm): Add `@swc/nodejs-support-wasm` for Node.js integrations
2 changes: 2 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:

- run: cargo clippy --all --all-targets -- -D warnings

- run: cargo clippy -p binding_typescript_wasm --all-targets --features nodejs-support -- -D warnings

cargo-deny:
name: Check license of dependencies
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish-npm-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,11 @@ jobs:
npm: "@swc\\/wasm-typescript-esm"
build: "./scripts/esm.sh"
out: "esm"
- package: "core"
crate: "binding_typescript_wasm"
npm: "@swc\\/nodejs-support-wasm"
build: "./scripts/build-nodejs-support.sh"
out: "nodejs-support"
- package: "html"
crate: "binding_html_wasm"
npm: "@swc\\/html-wasm"
Expand Down
8 changes: 6 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion bindings/binding_typescript_wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ bench = false
crate-type = ["cdylib"]

[features]
nightly = ["swc_ts_fast_strip/nightly"]
nightly = ["swc_ts_fast_strip/nightly"]
nodejs-support = [
"dep:serde_json",
"dep:swc_ecma_ast",
"dep:swc_ecma_parser",
"dep:swc_ecma_visit",
]

[dependencies]
anyhow = { workspace = true }
Expand All @@ -22,7 +28,11 @@ miette = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde-wasm-bindgen = { workspace = true }
serde_json = { workspace = true, optional = true }
swc_common = { path = "../../crates/swc_common" }
swc_ecma_ast = { path = "../../crates/swc_ecma_ast", optional = true }
swc_ecma_parser = { path = "../../crates/swc_ecma_parser", optional = true }
swc_ecma_visit = { path = "../../crates/swc_ecma_visit", optional = true }
swc_error_reporters = { path = "../../crates/swc_error_reporters" }
swc_ts_fast_strip = { path = "../../crates/swc_ts_fast_strip", features = [
"wasm-bindgen",
Expand Down
24 changes: 24 additions & 0 deletions bindings/binding_typescript_wasm/README.nodejs-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# @swc/nodejs-support-wasm

This package provides WebAssembly helpers used by Node.js integrations.

It includes the TypeScript transform API from `@swc/wasm-typescript` and
additional helpers for module syntax transformation, assertion locations, and
syntax validation.

## API

- `transform` and `transformSync`
- `transformModuleSyntax`
- `getFirstExpression`
- `isValidSyntax`
- `isRecoverableError`

## Contributing

See [the main repository](https://github.com/swc-project/swc)'s contributing
guide.

## License

Apache 2.0
166 changes: 166 additions & 0 deletions bindings/binding_typescript_wasm/__tests__/nodejs-support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
const fs = require("node:fs");
const path = require("node:path");
const typescriptWasm = require("../pkg");
const nodejsSupport = require("../nodejs-support");

function validatedImport(specifier, exports) {
let out = `__nodeREPLDynamicImport(${JSON.stringify(specifier)})`;

if (exports.length > 0) {
out += `.then((m) => { `;
for (const exportName of exports) {
const message = `The requested module '${specifier}' does not provide an export named '${exportName}'`;
out += `if (!(${JSON.stringify(
exportName
)} in m)) throw new SyntaxError(${JSON.stringify(message)}); `;
}
out += `return m; })`;
}

return out;
}

describe("@swc/nodejs-support-wasm", () => {
it("keeps Node.js-specific helpers out of @swc/wasm-typescript", () => {
expect(typescriptWasm.nodejs).toBeUndefined();
expect(typescriptWasm.transformModuleSyntax).toBeUndefined();
expect(typescriptWasm.__nodejsTransformModuleSyntax).toBeUndefined();
});

it("exposes the TypeScript transform and Node.js helpers at the top level", () => {
expect(typeof nodejsSupport.transform).toBe("function");
expect(typeof nodejsSupport.transformSync).toBe("function");
expect(typeof nodejsSupport.transformModuleSyntax).toBe("function");
expect(typeof nodejsSupport.getFirstExpression).toBe("function");
expect(typeof nodejsSupport.isValidSyntax).toBe("function");
expect(typeof nodejsSupport.isRecoverableError).toBe("function");
expect(nodejsSupport.nodejs).toBeUndefined();
expect(nodejsSupport.__nodejsTransformModuleSyntax).toBeUndefined();
});

it("preserves the existing TypeScript transform behavior", async () => {
const source = "export const value: number = 1;";
const expected = await typescriptWasm.transform(source, {});

expect(nodejsSupport.transformSync(source, {})).toEqual(
typescriptWasm.transformSync(source, {})
);
await expect(nodejsSupport.transform(source, {})).resolves.toEqual(
expected
);

const bytes = new TextEncoder().encode(source);
expect(nodejsSupport.transformSync(bytes, {})).toEqual(
nodejsSupport.transformSync(source, {})
);
});

it("transforms module syntax without exposing an AST", () => {
expect(
nodejsSupport.transformModuleSyntax(`import fs from "node:fs";`)
).toEqual({
code: `const __nodeREPLImport0 = await ${validatedImport(
"node:fs",
["default"]
)};`,
hadModuleSyntax: true,
});

expect(
nodejsSupport.transformModuleSyntax(
`import { readFile as rf } from "node:fs";\nrf();`
)
).toEqual({
code: `const __nodeREPLImport0 = await ${validatedImport(
"node:fs",
["readFile"]
)};\n__nodeREPLImport0.readFile();`,
hadModuleSyntax: true,
});

expect(
nodejsSupport.transformModuleSyntax(`console.log(import.meta.url);`)
).toEqual({
code: `console.log((() => { throw new SyntaxError("Cannot use import.meta outside a module"); })().url);`,
hadModuleSyntax: true,
});

expect(
nodejsSupport.transformModuleSyntax(`const importName = "import";`)
).toEqual({
code: `const importName = "import";`,
hadModuleSyntax: false,
});
});

it("supports Uint8Array input", () => {
const input = new TextEncoder().encode(`import "node:fs";`);

expect(nodejsSupport.transformModuleSyntax(input)).toEqual({
code: `await __nodeREPLDynamicImport("node:fs");`,
hadModuleSyntax: true,
});
});

it("extracts the first expression for assertion locations", () => {
expect(nodejsSupport.getFirstExpression("assert.ok(value)", 9)).toBe(
"assert.ok(value)"
);
expect(nodejsSupport.getFirstExpression("assert(value)", 6)).toBe(
"assert(value)"
);
expect(
nodejsSupport.getFirstExpression("a(); assert.ok(value); b()", 13)
).toBe("assert.ok(value)");
expect(
nodejsSupport.getFirstExpression(
"assert[method + suffix](value)",
23
)
).toBe("assert[method + suffix](value)");
});

it("checks syntax validity and recoverability", () => {
expect(nodejsSupport.isValidSyntax("await value")).toBe(true);
expect(nodejsSupport.isValidSyntax("const x: number = 1")).toBe(true);
expect(nodejsSupport.isValidSyntax("function foo(")).toBe(false);
expect(nodejsSupport.isRecoverableError("function foo() {")).toBe(true);
expect(nodejsSupport.isRecoverableError("{ value: 1 }")).toBe(false);
expect(nodejsSupport.isRecoverableError("{}")).toBe(false);
expect(nodejsSupport.isRecoverableError("const x: number = 1")).toBe(
false
);
expect(nodejsSupport.isRecoverableError("2e")).toBe(false);
});

it("generates package metadata and top-level TypeScript declarations", () => {
const packageDir = path.join(__dirname, "../nodejs-support");
const packageJson = JSON.parse(
fs.readFileSync(path.join(packageDir, "package.json"), "utf8")
);
const types = fs.readFileSync(
path.join(packageDir, "wasm.d.ts"),
"utf8"
);
const legacyTypes = fs.readFileSync(
path.join(__dirname, "../pkg/wasm.d.ts"),
"utf8"
);

expect(packageJson.name).toBe("@swc/nodejs-support-wasm");
expect(packageJson.description).toBe(
"SWC WebAssembly helpers for Node.js"
);
expect(packageJson.files).not.toContain("wasm_bg.wasm");
expect(fs.existsSync(path.join(packageDir, "wasm_bg.wasm"))).toBe(
false
);
expect(types).toContain(
"export declare function transformModuleSyntax"
);
expect(types).toContain("export interface ModuleSyntaxTransformOutput");
expect(types).not.toContain("namespace nodejs");
expect(types).not.toContain("__nodejsTransformModuleSyntax");
expect(legacyTypes).not.toContain("transformModuleSyntax");
});
});
21 changes: 21 additions & 0 deletions bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -eux

export CARGO_PROFILE_RELEASE_LTO="fat"
export CARGO_PROFILE_RELEASE_OPT_LEVEL="z"

wasm-pack build \
--out-name wasm \
--out-dir nodejs-support \
--release \
--scope=swc \
--target nodejs \
--features "${WASM_FEATURES:-nodejs-support}" \
"$@"
ls -al ./nodejs-support

node ./scripts/patch.mjs \
nodejs-support \
@swc/nodejs-support-wasm \
"SWC WebAssembly helpers for Node.js"
cp ./README.nodejs-support.md ./nodejs-support/README.md
46 changes: 32 additions & 14 deletions bindings/binding_typescript_wasm/scripts/patch.mjs
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
import fs from 'node:fs/promises';
import fs from "node:fs/promises";
import path from "node:path";

const [
outDir = "pkg",
packageName = "@swc/wasm-typescript",
packageDescription,
] = process.argv.slice(2);

const rawWasmFile = await fs.readFile('pkg/wasm_bg.wasm');
const origJsFile = await fs.readFile('pkg/wasm.js', 'utf8');
const rawWasmPath = path.join(outDir, "wasm_bg.wasm");
const wasmJsPath = path.join(outDir, "wasm.js");
const packageJsonPath = path.join(outDir, "package.json");
const rawWasmFile = await fs.readFile(rawWasmPath);
const origJsFile = await fs.readFile(wasmJsPath, "utf8");

const base64 = rawWasmFile.toString('base64');
const base64 = rawWasmFile.toString("base64");

const patchedJsFile = origJsFile
.replace(`const path = require('path').join(__dirname, 'wasm_bg.wasm');`, '')
.replace(', fatal: true', '')
.replace(`const bytes = require('fs').readFileSync(path);`, `
.replace(
`const path = require('path').join(__dirname, 'wasm_bg.wasm');`,
""
)
.replace(", fatal: true", "")
.replace(
`const bytes = require('fs').readFileSync(path);`,
`
const { Buffer } = require('node:buffer');
const bytes = Buffer.from('${base64}', 'base64');`)
const bytes = Buffer.from('${base64}', 'base64');`
);

await fs.writeFile('pkg/wasm.js', patchedJsFile);
await fs.writeFile(wasmJsPath, patchedJsFile);

// Remove wasm file
await fs.unlink('pkg/wasm_bg.wasm');
await fs.unlink(rawWasmPath);

// Remove wasm from .files section of package.json
const pkgJsonFile = await fs.readFile('pkg/package.json', 'utf8');
const pkgJsonFile = await fs.readFile(packageJsonPath, "utf8");
const pkgJson = JSON.parse(pkgJsonFile);
pkgJson.name = '@swc/wasm-typescript';
pkgJson.files = pkgJson.files.filter(file => file !== 'wasm_bg.wasm');
await fs.writeFile('pkg/package.json', JSON.stringify(pkgJson, null, 2));
pkgJson.name = packageName;
if (packageDescription) {
pkgJson.description = packageDescription;
}
pkgJson.files = pkgJson.files.filter((file) => file !== "wasm_bg.wasm");
await fs.writeFile(packageJsonPath, JSON.stringify(pkgJson, null, 2));
4 changes: 3 additions & 1 deletion bindings/binding_typescript_wasm/scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
set -eu

./scripts/build.sh
./scripts/build-nodejs-support.sh
npx rstest $@

./scripts/build.sh --features nightly
npx rstest $@
WASM_FEATURES=nodejs-support,nightly ./scripts/build-nodejs-support.sh
npx rstest $@
Loading
Loading