Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
6 changes: 6 additions & 0 deletions .changeset/nodejs-wasm-typescript-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@swc/wasm-typescript": minor
"@swc/wasm-typescript-esm": minor
---

feat(wasm-typescript): add Node.js-specific parser helpers
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.

4 changes: 4 additions & 0 deletions bindings/binding_typescript_wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ miette = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde-wasm-bindgen = { workspace = true }
serde_json = { workspace = true }
swc_common = { path = "../../crates/swc_common" }
swc_ecma_ast = { path = "../../crates/swc_ecma_ast" }
swc_ecma_parser = { path = "../../crates/swc_ecma_parser" }
swc_ecma_visit = { path = "../../crates/swc_ecma_visit" }
swc_error_reporters = { path = "../../crates/swc_error_reporters" }
swc_ts_fast_strip = { path = "../../crates/swc_ts_fast_strip", features = [
"wasm-bindgen",
Expand Down
103 changes: 103 additions & 0 deletions bindings/binding_typescript_wasm/__tests__/transform.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
const swc = require("../pkg");
const fs = require("node:fs");
const path = require("node:path");

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

it("properly reports error", () => {
expect(() => {
Expand Down Expand Up @@ -218,3 +235,89 @@ if(!((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((
})
});
});

describe("nodejs namespace", () => {
it("exposes Node.js-specific helpers only through the namespace", () => {
expect(swc.nodejs).toBeDefined();
expect(typeof swc.nodejs.transformModuleSyntax).toBe("function");
expect(typeof swc.__nodejsTransformModuleSyntax).toBe("undefined");
});

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

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

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

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

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

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

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

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

it("declares the public namespace in generated types", () => {
const types = fs.readFileSync(path.join(__dirname, "../pkg/wasm.d.ts"), "utf8");

expect(types).toContain("export declare namespace nodejs");
expect(types).toContain("function transformModuleSyntax");
expect(types).not.toContain("__nodejsTransformModuleSyntax");
});
});
17 changes: 17 additions & 0 deletions bindings/binding_typescript_wasm/scripts/esm.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";

const pkgJsonFile = await fs.readFile("esm/package.json", "utf8");
const wasmJsFile = await fs.readFile("esm/wasm.js", "utf8");
const pkgJson = JSON.parse(pkgJsonFile);
pkgJson.name = '@swc/wasm-typescript-esm';
pkgJson.exports = {
Expand All @@ -9,7 +10,23 @@ pkgJson.exports = {
default: "./wasm.js",
};

const patchedWasmJsFile = wasmJsFile
.replace("export function __nodejsTransformModuleSyntax(", "function __nodejsTransformModuleSyntax(")
.replace("export function __nodejsGetFirstExpression(", "function __nodejsGetFirstExpression(")
.replace("export function __nodejsIsValidSyntax(", "function __nodejsIsValidSyntax(")
.replace("export function __nodejsIsRecoverableError(", "function __nodejsIsRecoverableError(")
+ `

export const nodejs = Object.freeze({
transformModuleSyntax: __nodejsTransformModuleSyntax,
getFirstExpression: __nodejsGetFirstExpression,
isValidSyntax: __nodejsIsValidSyntax,
isRecoverableError: __nodejsIsRecoverableError,
});
`;

await Promise.all([
fs.cp("src/wasm-node.js", "esm/wasm-node.js"),
fs.writeFile("esm/wasm.js", patchedWasmJsFile),
fs.writeFile("esm/package.json", JSON.stringify(pkgJson, null, 2)),
]);
17 changes: 15 additions & 2 deletions bindings/binding_typescript_wasm/scripts/patch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,20 @@ const patchedJsFile = origJsFile
.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');`) + `

const __swcNodejs = Object.freeze({
transformModuleSyntax: module.exports.__nodejsTransformModuleSyntax,
getFirstExpression: module.exports.__nodejsGetFirstExpression,
isValidSyntax: module.exports.__nodejsIsValidSyntax,
isRecoverableError: module.exports.__nodejsIsRecoverableError,
});
delete module.exports.__nodejsTransformModuleSyntax;
delete module.exports.__nodejsGetFirstExpression;
delete module.exports.__nodejsIsValidSyntax;
delete module.exports.__nodejsIsRecoverableError;
module.exports.nodejs = __swcNodejs;
`;

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

Expand All @@ -23,4 +36,4 @@ const pkgJsonFile = await fs.readFile('pkg/package.json', '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));
await fs.writeFile('pkg/package.json', JSON.stringify(pkgJson, null, 2));
72 changes: 58 additions & 14 deletions bindings/binding_typescript_wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,25 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{future_to_promise, js_sys::Promise};

mod error_reporter;
mod nodejs;

/// Custom interface definitions for the @swc/wasm's public interface instead of
/// auto generated one, which is not reflecting most of types in detail.
#[wasm_bindgen(typescript_custom_section)]
const INTERFACE_DEFINITIONS: &'static str = r#"
export declare function transform(src: string | Uint8Array, opts?: Options): Promise<TransformOutput>;
export declare function transformSync(src: string | Uint8Array, opts?: Options): TransformOutput;
export declare namespace nodejs {
function transformModuleSyntax(src: string | Uint8Array): ModuleSyntaxTransformOutput;
function getFirstExpression(src: string | Uint8Array, startColumn: number): string;
function isValidSyntax(src: string | Uint8Array): boolean;
function isRecoverableError(src: string | Uint8Array): boolean;

interface ModuleSyntaxTransformOutput {
code: string;
hadModuleSyntax: boolean;
}
}
export type { Options, TransformOutput };
"#;

Expand All @@ -43,20 +55,7 @@ pub fn transform_sync(input: JsValue, options: JsValue) -> Result<JsValue, JsVal
serde_wasm_bindgen::from_value(options)?
};

let input = match input.as_string() {
Some(input) => input,
None => {
if input.is_instance_of::<Uint8Array>() {
let input = input.unchecked_into::<Uint8Array>();
match input.to_string().as_string() {
Some(input) => input,
None => return Err(JsValue::from_str("Input Uint8Array is not valid utf-8")),
}
} else {
return Err(JsValue::from_str("Input is not a string or Uint8Array"));
}
}
};
let input = coerce_input(input)?;

let result = GLOBALS.set(&Default::default(), || operate(input, options));

Expand All @@ -66,6 +65,51 @@ pub fn transform_sync(input: JsValue, options: JsValue) -> Result<JsValue, JsVal
}
}

#[wasm_bindgen(js_name = "__nodejsTransformModuleSyntax", skip_typescript)]
pub fn nodejs_transform_module_syntax(input: JsValue) -> Result<JsValue, JsValue> {
let input = coerce_input(input)?;
let output = nodejs::transform_module_syntax(input);

Ok(serde_wasm_bindgen::to_value(&output)?)
}

#[wasm_bindgen(js_name = "__nodejsGetFirstExpression", skip_typescript)]
pub fn nodejs_get_first_expression(input: JsValue, start_column: u32) -> Result<String, JsValue> {
let input = coerce_input(input)?;

Ok(nodejs::get_first_expression(input, start_column))
}

#[wasm_bindgen(js_name = "__nodejsIsValidSyntax", skip_typescript)]
pub fn nodejs_is_valid_syntax(input: JsValue) -> Result<bool, JsValue> {
let input = coerce_input(input)?;

Ok(nodejs::is_valid_syntax(input))
}

#[wasm_bindgen(js_name = "__nodejsIsRecoverableError", skip_typescript)]
pub fn nodejs_is_recoverable_error(input: JsValue) -> Result<bool, JsValue> {
let input = coerce_input(input)?;

Ok(nodejs::is_recoverable_error(input))
}

fn coerce_input(input: JsValue) -> Result<String, JsValue> {
match input.as_string() {
Some(input) => Ok(input),
None => {
if input.is_instance_of::<Uint8Array>() {
let input = input.unchecked_into::<Uint8Array>();

String::from_utf8(input.to_vec())
.map_err(|_| JsValue::from_str("Input Uint8Array is not valid utf-8"))
} else {
Err(JsValue::from_str("Input is not a string or Uint8Array"))
}
}
}
}

fn operate(input: String, options: Options) -> Result<TransformOutput, Vec<JsonDiagnostic>> {
let cm = Lrc::new(SourceMap::default());

Expand Down
Loading
Loading