diff --git a/eslint.config.mjs b/eslint.config.mjs index ed9b3f2..d9b7eb8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default defineConfig([ "**/.next/**", "**/out/**", ".docusaurus/**", + "**/*.d.rs.ts", ]), { name: "js/config", ...js.configs.recommended }, diff --git a/example/typed-imports/src/types.test.mjs b/example/typed-imports/src/types.test.mjs index ff3af7a..536da17 100644 --- a/example/typed-imports/src/types.test.mjs +++ b/example/typed-imports/src/types.test.mjs @@ -96,3 +96,21 @@ test("precise types, floor fallback, and runtime fidelity", async () => { assert.equal(built.fib10, 55); assert.equal(built.capped, "Hello"); }); + +test("the webpack build writes the sidecar when `types: true`", async () => { + // Independent of the `gen-types` CLI pretest: the loader emits the sidecar + // during a normal build, reusing the build it already runs. + fs.rmSync(SIDECAR, { force: true }); + assert.equal(fs.existsSync(SIDECAR), false); + + await buildWebpack(); + + assert.ok( + fs.existsSync(SIDECAR), + "the build with types:true must write the sidecar", + ); + assert.match( + fs.readFileSync(SIDECAR, "utf8"), + /fibonacci\(n: number\): number;/, + ); +}); diff --git a/example/typed-imports/webpack.config.js b/example/typed-imports/webpack.config.js index cd8d476..fa1d9c5 100644 --- a/example/typed-imports/webpack.config.js +++ b/example/typed-imports/webpack.config.js @@ -43,6 +43,7 @@ module.exports = { target: "node", node: { bundle: true }, logLevel: "error", + types: true, }, }, }, diff --git a/src/bun.js b/src/bun.js index 7153d9d..4d1cc43 100644 --- a/src/bun.js +++ b/src/bun.js @@ -10,6 +10,11 @@ const optionsSchema = { description: "Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)", }, + types: { + type: "boolean", + description: + "Also write the `.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)", + }, }, additionalProperties: false, }; @@ -36,6 +41,7 @@ module.exports = function bun(config) { baseFolder: process.cwd(), target: "node", logLevel: options.logLevel, + emitTypes: options.types === true, }), ); }, diff --git a/src/esbuild.js b/src/esbuild.js index c3e5954..a2b269e 100644 --- a/src/esbuild.js +++ b/src/esbuild.js @@ -10,6 +10,11 @@ const optionsSchema = { description: "Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)", }, + types: { + type: "boolean", + description: + "Also write the `.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)", + }, }, additionalProperties: false, }; @@ -40,6 +45,7 @@ module.exports = function esbuild(config) { build.initialOptions.absWorkingDir || process.cwd(), target: targetForPlatform(build.initialOptions.platform), logLevel: options.logLevel, + emitTypes: options.types === true, }), ); }, diff --git a/src/index.emitTypes.test.js b/src/index.emitTypes.test.js new file mode 100644 index 0000000..9cc9c17 --- /dev/null +++ b/src/index.emitTypes.test.js @@ -0,0 +1,91 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const rustWasmLoader = require("."); +const findWasmPack = require("./utils/findWasmPack.util"); + +const CRATE = path.join(__dirname, "..", "example", "typed-imports"); + +const skip = (() => { + try { + findWasmPack(); + return false; + } catch { + return "wasm-pack is not installed"; + } +})(); + +// An isolated copy of the example crate with a unique marker, so this test owns +// its own content-addressed build dir and sidecar. +function isolatedCrate() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-loader-types-")); + ["Cargo.toml", "Cargo.lock", "math.rs"].forEach((file) => + fs.copyFileSync(path.join(CRATE, file), path.join(dir, file)), + ); + fs.appendFileSync( + path.join(dir, "math.rs"), + `\n// ${path.basename(dir)}\n`, + ); + return dir; +} + +// Drives the loader with a thin loader context (the Turbopack-style shape: no +// `_compilation`, inline node build), the same surface the loader falls back to. +function runLoader(dir, loaderOptions) { + const source = fs.readFileSync(path.join(dir, "math.rs"), "utf8"); + return new Promise((resolve, reject) => { + rustWasmLoader.call( + { + resourcePath: path.join(dir, "math.rs"), + rootContext: dir, + target: "node", + async: () => (err, result) => + err ? reject(err) : resolve(result), + getOptions: () => loaderOptions, + emitFile: () => undefined, + }, + source, + ); + }); +} + +test( + "the `types` option drives sidecar emission, off by default", + { skip }, + async () => { + const dir = isolatedCrate(); + const sidecar = path.join(dir, "math.d.rs.ts"); + try { + await runLoader(dir, { + target: "node", + node: { bundle: true }, + logLevel: "error", + types: true, + }); + assert.ok( + fs.existsSync(sidecar), + "types:true must write the sidecar", + ); + assert.match( + fs.readFileSync(sidecar, "utf8"), + /fibonacci\(n: number\): number;/, + ); + + fs.rmSync(sidecar, { force: true }); + await runLoader(dir, { + target: "node", + node: { bundle: true }, + logLevel: "error", + }); + assert.equal( + fs.existsSync(sidecar), + false, + "the default (no `types`) must not write a sidecar", + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/src/index.js b/src/index.js index 1f7df21..c5e36da 100644 --- a/src/index.js +++ b/src/index.js @@ -56,6 +56,11 @@ const optionsSchema = { description: "Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)", }, + types: { + type: "boolean", + description: + "Also write the `.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)", + }, import: { description: 'Opt-in import-based wasm delivery. `strategy: "module"` ships the wasm as a pre-compiled WebAssembly.Module via a `?module` import, the only form the Next.js Edge runtime can instantiate.', @@ -280,6 +285,7 @@ async function rustWasmLoader(source) { wasmName, target: params.target, logLevel: options.logLevel, + emitTypes: options.types === true, web: { ...options.web, publicPath, diff --git a/src/next.emitTypes.test.js b/src/next.emitTypes.test.js new file mode 100644 index 0000000..6482a23 --- /dev/null +++ b/src/next.emitTypes.test.js @@ -0,0 +1,44 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const withRustWasm = require("./next"); + +// The loader options the helper attaches to its webpack rule for a given pass. +function webpackRuleOptions(isServer, nextRuntime, pluginOptions) { + const patched = withRustWasm({}, pluginOptions).webpack( + {}, + { isServer, nextRuntime }, + ); + return patched.module.rules.at(-1).use[0].options; +} + +// The loader options for each of the three turbopack `*.rs` rules. +function turbopackRuleOptions(pluginOptions) { + return withRustWasm({}, pluginOptions).turbopack.rules["*.rs"].map( + (rule) => rule.loaders[0].options, + ); +} + +test("threads types:true into every webpack pass when set", () => { + assert.equal( + webpackRuleOptions(false, undefined, { types: true }).types, + true, + ); + assert.equal( + webpackRuleOptions(true, undefined, { types: true }).types, + true, + ); + assert.equal(webpackRuleOptions(true, "edge", { types: true }).types, true); +}); + +test("threads types:true into every turbopack rule when set", () => { + turbopackRuleOptions({ types: true }).forEach((options) => + assert.equal(options.types, true), + ); +}); + +test("defaults types to false on every rule", () => { + assert.equal(webpackRuleOptions(false, undefined, {}).types, false); + turbopackRuleOptions({}).forEach((options) => + assert.equal(options.types, false), + ); +}); diff --git a/src/next.js b/src/next.js index 6cef8cb..03925fd 100644 --- a/src/next.js +++ b/src/next.js @@ -9,6 +9,11 @@ const optionsSchema = { description: "Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)", }, + types: { + type: "boolean", + description: + "Also write the `.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)", + }, }, additionalProperties: false, }; @@ -26,15 +31,15 @@ const rsLoaderRule = (options) => ({ // from bytes, so it takes the `module` delivery (target `web`, since Edge is a // web-like runtime), shipping a pre-compiled WebAssembly.Module via a `?module` // import that Next's internal Edge wasm loader injects. -const rsRule = (isServer, nextRuntime, logLevel) => +const rsRule = (isServer, nextRuntime, shared) => rsLoaderRule( nextRuntime === "edge" - ? { target: "web", import: { strategy: "module" }, logLevel } + ? { target: "web", import: { strategy: "module" }, ...shared } : { target: isServer ? "node" : "web", node: { bundle: true }, web: { asyncLoading: false }, - logLevel, + ...shared, }, ); @@ -46,34 +51,34 @@ const rsRule = (isServer, nextRuntime, logLevel) => // and `module` deliveries both fit that thinner context. Edge needs the `module` // delivery (it cannot instantiate wasm from bytes), and its rule is listed first // so it wins over the broader `{ not: "browser" }` condition that also matches Edge. -const turbopackLoader = (target, extraOptions, logLevel) => ({ +const turbopackLoader = (target, extraOptions, shared) => ({ loader: require.resolve("./index"), options: { target, ...extraOptions, - logLevel, + ...shared, }, }); -const turbopackRule = (condition, target, extraOptions, logLevel) => ({ +const turbopackRule = (condition, target, extraOptions, shared) => ({ condition, - loaders: [turbopackLoader(target, extraOptions, logLevel)], + loaders: [turbopackLoader(target, extraOptions, shared)], as: "*.js", }); -const turbopackRsRules = (logLevel) => [ +const turbopackRsRules = (shared) => [ turbopackRule( "edge-light", "web", { import: { strategy: "module" } }, - logLevel, + shared, ), - turbopackRule("browser", "web", { web: { asyncLoading: false } }, logLevel), + turbopackRule("browser", "web", { web: { asyncLoading: false } }, shared), turbopackRule( { not: "browser" }, "node", { node: { bundle: true } }, - logLevel, + shared, ), ]; @@ -83,7 +88,7 @@ const turbopackRsRules = (logLevel) => [ // is not a known token, so the build fails. The name never surfaces (the bytes are // inlined, and the Edge `module` delivery imports from its own cache path), so a // flat name is safe on every pass, Edge included. -const withRsRule = (config, isServer, nextRuntime, logLevel) => ({ +const withRsRule = (config, isServer, nextRuntime, shared) => ({ ...config, output: { ...config.output, @@ -93,7 +98,7 @@ const withRsRule = (config, isServer, nextRuntime, logLevel) => ({ ...config.module, rules: [ ...(config.module?.rules ?? []), - rsRule(isServer, nextRuntime, logLevel), + rsRule(isServer, nextRuntime, shared), ], }, }); @@ -120,7 +125,7 @@ const withRsRule = (config, isServer, nextRuntime, logLevel) => ({ * Loaders resolve through `require.resolve` against this package, so the helper * wires up the right files regardless of the consumer's module resolution. * @param {import("next").NextConfig} [nextConfig] - the Next.js config to extend - * @param {{ logLevel?: string }} [pluginOptions] + * @param {{ logLevel?: string, types?: boolean }} [pluginOptions] * @returns {import("next").NextConfig} */ function withRustWasm(nextConfig = {}, pluginOptions = {}) { @@ -130,13 +135,19 @@ function withRustWasm(nextConfig = {}, pluginOptions = {}) { name: "rust-wasmpack-loader", }); + // Cross-cutting loader options every pass shares; spread onto each rule. + const shared = { + logLevel: options.logLevel, + types: options.types === true, + }; + return { ...nextConfig, turbopack: { ...nextConfig.turbopack, rules: { ...nextConfig.turbopack?.rules, - "*.rs": turbopackRsRules(options.logLevel), + "*.rs": turbopackRsRules(shared), }, }, webpack(config, webpackOptions) { @@ -144,7 +155,7 @@ function withRustWasm(nextConfig = {}, pluginOptions = {}) { config, webpackOptions.isServer, webpackOptions.nextRuntime, - options.logLevel, + shared, ); return typeof nextConfig.webpack === "function" diff --git a/src/pack.emitTypes.test.js b/src/pack.emitTypes.test.js new file mode 100644 index 0000000..b5f54da --- /dev/null +++ b/src/pack.emitTypes.test.js @@ -0,0 +1,91 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const pack = require("./pack"); +const findWasmPack = require("./utils/findWasmPack.util"); + +const CRATE = path.join(__dirname, "..", "example", "typed-imports"); + +const skip = (() => { + try { + findWasmPack(); + return false; + } catch { + return "wasm-pack is not installed"; + } +})(); + +const noop = () => undefined; + +// An isolated copy of the example crate with a unique marker, so this test never +// shares the content-addressed build dir or the sidecar with another. +function isolatedCrate() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-pack-types-")); + ["Cargo.toml", "Cargo.lock", "math.rs"].forEach((file) => + fs.copyFileSync(path.join(CRATE, file), path.join(dir, file)), + ); + fs.appendFileSync( + path.join(dir, "math.rs"), + `\n// ${path.basename(dir)}\n`, + ); + return dir; +} + +// Node inline build params (bundle the wasm bytes into the JS). Both runs share +// one buildFolder so the second is a wasm-pack cache hit: the wasm binary is +// byte-for-byte the same and the only variable left is the typings flag. +function packParams(dir, emitTypes) { + return { + resourcePath: path.join(dir, "math.rs"), + baseFolder: dir, + buildFolder: path.join(dir, "build"), + wasmName: "out.wasm", + target: "node", + logLevel: "error", + web: { + asyncLoading: false, + usePublicPath: false, + publicPath: [], + wasmPathModifier: ["/"], + }, + node: { bundle: true }, + emitTypes, + }; +} + +test( + "emitTypes writes the sidecar as a pure side effect of the same build", + { skip }, + async () => { + const dir = isolatedCrate(); + const sidecar = path.join(dir, "math.d.rs.ts"); + try { + fs.mkdirSync(path.join(dir, "build"), { recursive: true }); + + const glueOff = await pack(packParams(dir, false), noop); + assert.equal( + fs.existsSync(sidecar), + false, + "emitTypes:false must not write a sidecar", + ); + + const glueOn = await pack(packParams(dir, true), noop); + assert.ok( + fs.existsSync(sidecar), + "emitTypes:true must write the sidecar", + ); + const content = fs.readFileSync(sidecar, "utf8"); + assert.match(content, /fibonacci\(n: number\): number;/); + assert.match(content, /cap\(s: string\): string;/); + assert.doesNotMatch(content, /Point|initSync|\[key: string\]/); + + // The returned module is a function of the source alone: emission is a + // side effect, never a change to what consumers import. + assert.equal(glueOn, glueOff); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/src/pack.js b/src/pack.js index 570b475..bfa3cab 100644 --- a/src/pack.js +++ b/src/pack.js @@ -2,6 +2,7 @@ const path = require("node:path"); const fs = require("node:fs"); const findNearestCargoBy = require("./utils/findNearestCargo.util"); const spawnWasmPack = require("./utils/spawnWasmPack.util"); +const writeSidecar = require("./utils/writeSidecar.util"); const constants = Object.seal({ toArrayBuffer: `function toArrayBuffer(buffer) {\n const ab = new ArrayBuffer(buffer.length);\n const view = new Uint8Array(ab);\n for (var i = 0; i < buffer.length; ++i) {\n view[i] = buffer[i];\n }\n return ab;\n}`, @@ -48,6 +49,7 @@ function logLevelSelector(level) { * @property {WebOptions} web - web options * @property {NodeOptions} node - node options * @property {ImportOptions} [import] - opt-in import-based wasm delivery (host bundler supplies the URL) + * @property {boolean} [emitTypes] - also write the `.d.rs.ts` sidecar from this build (keeps wasm-bindgen typings instead of `--no-typescript`) * */ // Shared default-export body: spreads the named Rust exports over any remaining @@ -121,6 +123,9 @@ async function doPack(params, emitFile) { cwd: params.buildFolder, outDir: wasmBuildSource, outName: params.wasmName, + // Keep wasm-bindgen's `.d.ts` only when the sidecar is requested; otherwise + // the build drops it. + typescript: !!params.emitTypes, args: [...logLevelSelector(params.logLevel)], // use `web` target because the generated file of this target modifies easily extraArgs: ["--target", "web"], @@ -320,6 +325,23 @@ async function doPack(params, emitFile) { }, ); + // Typings reuse this build: wasm-bindgen also emitted `.d.ts` (the + // companion to the glue `.js` above) when `emitTypes` is set, so transform it + // into the sidecar next to the source. The glue returned below is identical + // whether or not this runs. + if (params.emitTypes) { + writeSidecar( + params.resourcePath, + fs.readFileSync( + path.join( + wasmBuildSource, + `${params.wasmName.replace(".wasm", "")}.d.ts`, + ), + "utf8", + ), + ); + } + return delivery === "import" ? patch.import(generatedJs) : patch[params.target](generatedJs); diff --git a/src/rollup.emitTypes.test.js b/src/rollup.emitTypes.test.js new file mode 100644 index 0000000..35b3f95 --- /dev/null +++ b/src/rollup.emitTypes.test.js @@ -0,0 +1,67 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const rollup = require("./rollup"); +const findWasmPack = require("./utils/findWasmPack.util"); + +const CRATE = path.join(__dirname, "..", "example", "typed-imports"); + +const skip = (() => { + try { + findWasmPack(); + return false; + } catch { + return "wasm-pack is not installed"; + } +})(); + +// An isolated copy of the example crate with a unique marker, so this test owns +// its own content-addressed build dir and sidecar. +function isolatedCrate() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-rollup-types-")); + ["Cargo.toml", "Cargo.lock", "math.rs"].forEach((file) => + fs.copyFileSync(path.join(CRATE, file), path.join(dir, file)), + ); + fs.appendFileSync( + path.join(dir, "math.rs"), + `\n// ${path.basename(dir)}\n`, + ); + return dir; +} + +test( + "the rollup `types` option drives sidecar emission, off by default", + { skip }, + async () => { + const dir = isolatedCrate(); + const rs = path.join(dir, "math.rs"); + const sidecar = path.join(dir, "math.d.rs.ts"); + try { + await rollup({ + target: "node", + logLevel: "error", + types: true, + }).load(rs); + assert.ok( + fs.existsSync(sidecar), + "types:true must write the sidecar", + ); + assert.match( + fs.readFileSync(sidecar, "utf8"), + /fibonacci\(n: number\): number;/, + ); + + fs.rmSync(sidecar, { force: true }); + await rollup({ target: "node", logLevel: "error" }).load(rs); + assert.equal( + fs.existsSync(sidecar), + false, + "the default (no `types`) must not write a sidecar", + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/src/rollup.js b/src/rollup.js index 22924ad..e8e24f7 100644 --- a/src/rollup.js +++ b/src/rollup.js @@ -16,6 +16,11 @@ const optionsSchema = { description: "Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)", }, + types: { + type: "boolean", + description: + "Also write the `.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)", + }, }, additionalProperties: false, }; @@ -28,7 +33,7 @@ const isRustModule = (id) => id.split("?")[0].split("#")[0].endsWith(".rs"); * Builds a Rollup plugin that compiles `.rs` modules to inline-wasm JavaScript. * Exposed as a factory so adjacent integrations (such as Vite, whose plugins are * a superset of Rollup's) can wrap the returned object and add their own hooks. - * @param {{ target?: "web" | "node", logLevel?: string }} [config] + * @param {{ target?: "web" | "node", logLevel?: string, types?: boolean }} [config] * @param {Partial} [overrides] - extra plugin fields merged on top * @returns {import("rollup").Plugin} */ @@ -48,6 +53,7 @@ function createRustWasmRollupPlugin(config, overrides) { baseFolder: process.cwd(), target: options.target, logLevel: options.logLevel, + emitTypes: options.types === true, }) : null; }, diff --git a/src/shared-load.util.js b/src/shared-load.util.js index 26e0cbe..a674b47 100644 --- a/src/shared-load.util.js +++ b/src/shared-load.util.js @@ -13,6 +13,7 @@ const pack = require("./pack"); * @property {"fetch" | "fs" | "module"} [strategy] - import-delivery consumption mode (required when delivery is "import") * @property {(bytes: Buffer, wasmName: string) => string} [emitWasm] - emits the wasm as a host asset and returns the JS expression that resolves to its URL (import delivery only) * @property {string} [preamble] - source prepended to the generated module (import delivery only) + * @property {boolean} [emitTypes] - also write the `.d.rs.ts` sidecar from this build (defaults to false) */ const inlineWebOptions = { @@ -66,6 +67,7 @@ async function buildRsModule(params) { buildFolder, wasmName, logLevel: params.logLevel, + emitTypes: params.emitTypes === true, web: inlineWebOptions, node: inlineNodeOptions, }; diff --git a/src/utils/generateTypes.util.js b/src/utils/generateTypes.util.js index eb1d174..e22d6ab 100644 --- a/src/utils/generateTypes.util.js +++ b/src/utils/generateTypes.util.js @@ -4,7 +4,7 @@ const path = require("node:path"); const crypto = require("node:crypto"); const findNearestCargoBy = require("./findNearestCargo.util"); const spawnWasmPack = require("./spawnWasmPack.util"); -const dtsToSidecar = require("./dtsTransform.util"); +const writeSidecar = require("./writeSidecar.util"); const constants = Object.freeze({ CARGO_TOML: "Cargo.toml", @@ -39,13 +39,6 @@ function typedBuildFolder(resourcePath) { return buildFolder; } -// `math.rs` -> `math.d.rs.ts`, the name TS resolves to under -// `allowArbitraryExtensions`, overriding the ambient `*.rs` floor for this file. -function sidecarPathFor(resourcePath) { - const { dir, name } = path.parse(resourcePath); - return path.join(dir, `${name}.d.rs.ts`); -} - /** * Builds a `.rs` source with wasm-bindgen typings enabled, transforms the * generated `.d.ts` into a sidecar matching the loader's runtime default export, @@ -90,16 +83,11 @@ module.exports = async function generateTypes(resourcePath, options = {}) { extraArgs: ["--target", "web"], }); - const sidecarPath = sidecarPathFor(resourcePath); - fs.writeFileSync( - sidecarPath, - dtsToSidecar( - fs.readFileSync( - path.join(outDir, `${constants.OUT_NAME}.d.ts`), - "utf8", - ), + return writeSidecar( + resourcePath, + fs.readFileSync( + path.join(outDir, `${constants.OUT_NAME}.d.ts`), + "utf8", ), - { encoding: "utf8" }, ); - return sidecarPath; }; diff --git a/src/utils/writeSidecar.util.js b/src/utils/writeSidecar.util.js new file mode 100644 index 0000000..4b18d1a --- /dev/null +++ b/src/utils/writeSidecar.util.js @@ -0,0 +1,20 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const dtsToSidecar = require("./dtsTransform.util"); + +/** + * Transforms a wasm-bindgen `.d.ts` into the loader's sidecar shape and writes it + * next to the `.rs` source as `.d.rs.ts` (the name TS resolves under + * `allowArbitraryExtensions`, overriding the ambient `*.rs` floor for this file). + * @param {string} resourcePath absolute path to the `.rs` file + * @param {string} wasmBindgenDts wasm-bindgen `.d.ts` source + * @returns {string} the written sidecar path + */ +module.exports = function writeSidecar(resourcePath, wasmBindgenDts) { + const { dir, name } = path.parse(resourcePath); + const sidecarPath = path.join(dir, `${name}.d.rs.ts`); + fs.writeFileSync(sidecarPath, dtsToSidecar(wasmBindgenDts), { + encoding: "utf8", + }); + return sidecarPath; +}; diff --git a/src/utils/writeSidecar.util.test.js b/src/utils/writeSidecar.util.test.js new file mode 100644 index 0000000..0ed38e6 --- /dev/null +++ b/src/utils/writeSidecar.util.test.js @@ -0,0 +1,37 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const writeSidecar = require("./writeSidecar.util"); + +// wasm-bindgen `--target web` `.d.ts` for a crate exporting `cap`, `fibonacci`, +// and a `#[wasm_bindgen] struct Point`: the public functions plus the class, +// init, and raw-wasm noise the sidecar must drop. +const FIXTURE = [ + "export class Point {", + " private constructor();", + " x: number;", + "}", + "", + "export function cap(s: string): string;", + "", + "export function fibonacci(n: number): number;", + "", + "export function initSync(module: SyncInitInput): InitOutput;", + "", +].join("\n"); + +test("writes .d.rs.ts next to the source and returns its path", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-sidecar-")); + try { + const out = writeSidecar(path.join(dir, "math.rs"), FIXTURE); + assert.equal(out, path.join(dir, "math.d.rs.ts")); + const content = fs.readFileSync(out, "utf8"); + assert.match(content, /fibonacci\(n: number\): number;/); + assert.match(content, /cap\(s: string\): string;/); + assert.doesNotMatch(content, /Point|initSync|\[key: string\]/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/vite.js b/src/vite.js index 91dce6b..762b2d6 100644 --- a/src/vite.js +++ b/src/vite.js @@ -16,6 +16,11 @@ const optionsSchema = { description: "Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)", }, + types: { + type: "boolean", + description: + "Also write the `.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)", + }, }, additionalProperties: false, }; @@ -50,7 +55,7 @@ const resolveIsSSR = (ctx, loadOptions) => * runtime via Rollup's file-url reference). Dev, every SSR build, and bundled * SSR targets (`webworker`/edge) inline the wasm bytes, which is always correct * and sidesteps dev-server asset plumbing. - * @param {{ ssrNoExternal?: string[], logLevel?: string }} [config] + * @param {{ ssrNoExternal?: string[], logLevel?: string, types?: boolean }} [config] * @returns {import("vite").Plugin} */ function vite(config) { @@ -99,6 +104,7 @@ function vite(config) { baseFolder: process.cwd(), target, logLevel: options.logLevel, + emitTypes: options.types === true, }; return shouldEmit