From 70e9c5ce030f0f7fe3e1ef0729602f350496f4df Mon Sep 17 00:00:00 2001 From: yeskiy Date: Mon, 29 Jun 2026 02:25:00 +0300 Subject: [PATCH 1/3] feat: add a tsserver plugin for live .rs types in the editor Ship a TypeScript Language Service plugin (`rust-wasmpack-loader/tsserver`) that gives `.rs` imports their precise wasm-bindgen types live in any tsserver-based editor, with no editor extension. It proxies the language-service host: a `.rs` file resolves as a declaration and its snapshot is the generated `.d.ts`. The generator is slow (a wasm-pack build), so synchronous calls never block - they serve the cached, on-disk, or floor types and run the build in the background, then bump the script version and refresh so the editor repaints. The plugin never writes to disk; it seeds from the M1/M2 sidecar. - src/tsserver.js + root tsserver.js re-export: the plugin - generateTypes: extract `buildTypedDts` so the plugin types without writing - ci: run the loader's src unit tests (incl. the plugin's programmatic LS proof) in the matrix; they had no CI coverage before - example/typed-imports: the `plugins` tsconfig entry + a .vscode workspace-TS nudge The live editor-refresh and per-IDE loading run only inside a real tsserver, so they are documented in the plugin header rather than CI-gated. Refs: RL-67 --- .github/workflows/ci.yml | 2 +- eslint.config.mjs | 1 + example/typed-imports/.vscode/settings.json | 4 + example/typed-imports/tsconfig.json | 3 +- package.json | 2 + src/tsserver.integration.test.js | 170 +++++++++++++++ src/tsserver.js | 219 ++++++++++++++++++++ src/tsserver.test.js | 168 +++++++++++++++ src/utils/generateTypes.util.js | 38 +++- src/utils/generateTypes.util.test.js | 21 ++ tsserver.js | 1 + 11 files changed, 616 insertions(+), 13 deletions(-) create mode 100644 example/typed-imports/.vscode/settings.json create mode 100644 src/tsserver.integration.test.js create mode 100644 src/tsserver.js create mode 100644 src/tsserver.test.js create mode 100644 tsserver.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 887ab92..ce7ae60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: os: [ ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest ] node: [ 22, 24 ] rust: [ "1.88.0", "1.93.0" ] - test: [ example/node-bun, example/node-webpack, example/typed-imports, example/esbuild, example/rspack, example/rollup, example/vite, example/next, example/next-turbopack, example/electron ] + test: [ ., example/node-bun, example/node-webpack, example/typed-imports, example/esbuild, example/rspack, example/rollup, example/vite, example/next, example/next-turbopack, example/electron ] steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 diff --git a/eslint.config.mjs b/eslint.config.mjs index d9b7eb8..29eeb7e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,6 +49,7 @@ export default defineConfig([ files: [ "src/**/*.js", "bin/**/*.js", + "tsserver.js", "*.cjs", "docs/**/*.js", "example/**/*.config.js", diff --git a/example/typed-imports/.vscode/settings.json b/example/typed-imports/.vscode/settings.json new file mode 100644 index 0000000..2946048 --- /dev/null +++ b/example/typed-imports/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/example/typed-imports/tsconfig.json b/example/typed-imports/tsconfig.json index bb57219..00535ac 100644 --- a/example/typed-imports/tsconfig.json +++ b/example/typed-imports/tsconfig.json @@ -7,7 +7,8 @@ "allowArbitraryExtensions": true, "skipLibCheck": true, "target": "es2020", - "types": [] + "types": [], + "plugins": [{ "name": "rust-wasmpack-loader/tsserver" }] }, "files": ["node_modules/rust-wasmpack-loader/types/rs.d.ts"], "include": ["src/index.ts"] diff --git a/package.json b/package.json index c2508c6..aab829d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "rust-wasmpack-loader": "bin/rust-wasmpack-loader.js" }, "scripts": { + "test": "node --test \"src/**/*.test.js\"", "release": "npx commit-and-tag-version && npm run postrelease", "postrelease": "cd ./example/node-webpack && npm i && cd ../../ && cd ./example/web-webpack && npm i && cd ../../ && cd ./example/node-bun && npm i && cd ../../ && cd ./example/esbuild && npm i && cd ../../ && cd ./example/rspack && npm i && cd ../../ && cd ./example/rollup && npm i && cd ../../ && cd ./example/vite && npm i && cd ../../ && cd ./example/next && npm i && cd ../../ && cd ./example/next-turbopack && npm i && cd ../../ && cd ./example/electron && npm i", "docs:start": "docusaurus start --config ./docs/docusaurus.config.js", @@ -164,6 +165,7 @@ "!src/**/*.test.js", "types", "bin", + "tsserver.js", "LICENSE", "README.md" ] diff --git a/src/tsserver.integration.test.js b/src/tsserver.integration.test.js new file mode 100644 index 0000000..c015c16 --- /dev/null +++ b/src/tsserver.integration.test.js @@ -0,0 +1,170 @@ +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 ts = require("typescript"); +const init = require("./tsserver"); +const { buildTypedDts } = require("./utils/generateTypes.util"); +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 COMPILER_OPTIONS = { + noEmit: true, + strict: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowArbitraryExtensions: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2020, + types: [], +}; + +const IMPORTER = [ + 'import lib from "./math.rs";', + "export const ok = lib.fibonacci(1);", + "export const bad = lib.nope();", + "", +].join("\n"); + +// An isolated copy of the example crate with a unique marker, so the test owns +// its content-addressed build dir. +function isolatedCrate() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-ls-")); + ["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; +} + +function waitFor(predicate, timeoutMs) { + const start = Date.now(); + const attempt = async () => { + if (predicate()) return true; + if (Date.now() - start > timeoutMs) return false; + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + return attempt(); + }; + return attempt(); +} + +const messagesOf = (diagnostics) => + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ); + +// A real `ts.createLanguageService` over a virtual host that holds the `.ts` +// importer and serves the rest (the `.rs` and the lib files) from disk. The +// plugin's `create` decorates it exactly as tsserver would. +function harness(dir) { + // tsserver hands the host forward-slash paths, so key in-memory files the + // same way and normalize every lookup. + const toUnix = (file) => file.replace(/\\/g, "/"); + const importer = toUnix(path.join(dir, "app.ts")); + const sources = new Map([[importer, IMPORTER]]); + const refreshed = []; + + const readDisk = (file) => + fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined; + const read = (file) => + sources.has(toUnix(file)) ? sources.get(toUnix(file)) : readDisk(file); + + const host = { + getCompilationSettings: () => COMPILER_OPTIONS, + getScriptFileNames: () => [importer], + getScriptKind: () => ts.ScriptKind.TS, + getScriptVersion: () => "1", + getScriptSnapshot: (file) => { + const text = read(file); + return text === undefined + ? undefined + : ts.ScriptSnapshot.fromString(text); + }, + getCurrentDirectory: () => toUnix(dir), + getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options), + fileExists: (file) => sources.has(toUnix(file)) || fs.existsSync(file), + readFile: read, + readDirectory: () => [], + resolveModuleNameLiterals: (literals, containingFile, _r, options) => + literals.map((literal) => + ts.resolveModuleName( + literal.text, + containingFile, + options, + host, + ), + ), + }; + + const project = { + markAsDirty: () => undefined, + refreshDiagnostics: () => refreshed.push(1), + getFileNames: () => [...sources.keys()], + projectService: { logger: { info: () => undefined } }, + }; + + const languageService = init({ typescript: ts }, { buildTypedDts }).create({ + project, + languageServiceHost: host, + languageService: {}, + serverHost: {}, + config: {}, + }); + + return { languageService, importer, refreshed }; +} + +test( + "serves the floor first, then precise types that flag a bad call", + { skip }, + async () => { + const dir = isolatedCrate(); + const { languageService, importer, refreshed } = harness(dir); + try { + // Before the build lands, the floor keeps every member callable, so + // the import (and the bad call) type-check loosely. + assert.deepEqual( + messagesOf(languageService.getSemanticDiagnostics(importer)), + [], + ); + + // The first snapshot read enqueued the background build; wait for it + // to finish and ask the project to refresh. + assert.ok( + await waitFor(() => refreshed.length > 0, 180000), + "the background build never completed", + ); + + // The precise sidecar now overrides the floor. The only error is the + // unknown export: `lib.nope()` is rejected, while the valid + // `lib.fibonacci(1)` produces none (a bad call would be a second one). + const messages = messagesOf( + languageService.getSemanticDiagnostics(importer), + ); + assert.equal( + messages.length, + 1, + `expected only the lib.nope() error, got: ${messages.join(" | ") || "(none)"}`, + ); + assert.match(messages[0], /Property 'nope' does not exist/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/src/tsserver.js b/src/tsserver.js new file mode 100644 index 0000000..ce81829 --- /dev/null +++ b/src/tsserver.js @@ -0,0 +1,219 @@ +/** + * tsserver Language Service plugin: gives `.rs` imports their precise + * wasm-bindgen types live in any tsserver-based editor (VS Code, JetBrains, + * Neovim) with no editor extension. A `.rs` file masquerades as a TypeScript + * declaration: module resolution points `import x from "./foo.rs"` at the `.rs` + * itself, `getScriptKind` reports it as TS, and `getScriptSnapshot` serves the + * generated `.d.ts`. + * + * The generator is a wasm-pack build, so it is slow. Synchronous language-service + * calls never block on it: they serve the cached, on-disk, or floor types right + * away and enqueue a background build. When the build lands, the per-file version + * counter bumps and the project is asked to refresh, so the editor repaints with + * the precise types a moment later. + * + * The plugin never writes to disk (the M1/M2 on-disk sidecar already serves + * `tsc`, ESLint, and cold start); it only reads that sidecar to warm its cache. + * + * VS Code note: the editor must run the workspace TypeScript ("TypeScript: + * Select TypeScript Version" -> "Use Workspace Version"), since plugins load from + * the active tsserver. Because the first build lags the first import, a fresh + * `.rs` shows the loose floor until the build finishes; saving the file once more + * after it lands refreshes the precise types. + */ +const path = require("node:path"); +const fs = require("node:fs"); +const crypto = require("node:crypto"); +const dtsToSidecar = require("./utils/dtsTransform.util"); +const { buildTypedDts } = require("./utils/generateTypes.util"); + +// The loose floor served for a `.rs` module before its precise types finish +// building, and the fallback if a build fails. Every export is callable, so the +// import never errors; the generated sidecar overrides it once the build lands. +const FLOOR = [ + "declare const _default: Record any>;", + "export default _default;", + "", +].join("\n"); + +const isRs = (fileName) => fileName.endsWith(".rs"); + +const hashOf = (text) => crypto.createHash("sha256").update(text).digest("hex"); + +// Reads the M1/M2 on-disk sidecar (`.d.rs.ts`) if a prior build, the CLI, +// or a bundler left one, so a cold editor shows precise types before its own +// background build finishes. +function diskSidecarOf(resourcePath) { + const { dir, name } = path.parse(resourcePath); + const sidecar = path.join(dir, `${name}.d.rs.ts`); + return fs.existsSync(sidecar) + ? fs.readFileSync(sidecar, "utf8") + : undefined; +} + +/** + * The async type cache. `versionOf`/`snapshotOf` are synchronous and never block: + * they serve the cached, on-disk, or floor types and enqueue a background build, + * deduped by content hash. On completion the per-file counter bumps (so the + * script version changes) and `onGenerated` fires so the host can refresh. + */ +function createTypeCache({ + build, + readFile, + readDiskSidecar, + onGenerated, + onError, +}) { + const cache = new Map(); // content hash -> generated sidecar `.d.ts` + const counter = new Map(); // resourcePath -> completed-build count + const inflight = new Map(); // content hash -> in-progress build promise + + const contentHash = (resourcePath) => hashOf(readFile(resourcePath) || ""); + + const versionOf = (resourcePath) => + `${contentHash(resourcePath)}-${counter.get(resourcePath) || 0}`; + + const ensureFresh = (resourcePath) => { + const hash = contentHash(resourcePath); + if (cache.has(hash) || inflight.has(hash)) { + return inflight.get(hash); + } + const job = Promise.resolve() + .then(() => build(resourcePath)) + .then((dts) => { + cache.set(hash, dtsToSidecar(dts)); + counter.set(resourcePath, (counter.get(resourcePath) || 0) + 1); + onGenerated(resourcePath); + }) + .catch((error) => onError(error, resourcePath)) + .finally(() => inflight.delete(hash)); + inflight.set(hash, job); + return job; + }; + + const snapshotOf = (resourcePath) => { + ensureFresh(resourcePath); + return ( + cache.get(contentHash(resourcePath)) || + readDiskSidecar(resourcePath) || + FLOOR + ); + }; + + const pending = () => Promise.allSettled([...inflight.values()]); + + return { versionOf, snapshotOf, ensureFresh, pending }; +} + +const rsResolution = (tsm, specifier, containingFile) => ({ + extension: tsm.Extension.Dts, + isExternalLibraryImport: false, + resolvedFileName: path.resolve(path.dirname(containingFile), specifier), +}); + +// Layers `.rs` handling over the real host: `.rs` reads come from the cache, the +// rest pass through. Only the resolution method the host already implements is +// wrapped, so `.rs` specifiers resolve to a declaration while every other import +// keeps the host's own resolution. +function buildHostOverrides({ tsm, host, cache }) { + const wrapLiterals = (literals, containingFile, resolved) => + literals.map((literal, index) => + isRs(literal.text) + ? { + resolvedModule: rsResolution( + tsm, + literal.text, + containingFile, + ), + } + : resolved[index], + ); + const wrapNames = (names, containingFile, resolved) => + names.map((name, index) => + isRs(name) + ? rsResolution(tsm, name, containingFile) + : resolved[index], + ); + + const overrides = { + getScriptKind: (fileName) => { + if (isRs(fileName)) return tsm.ScriptKind.TS; + return host.getScriptKind + ? host.getScriptKind(fileName) + : tsm.ScriptKind.Unknown; + }, + getScriptVersion: (fileName) => + isRs(fileName) + ? cache.versionOf(fileName) + : host.getScriptVersion(fileName), + getScriptSnapshot: (fileName) => + isRs(fileName) + ? tsm.ScriptSnapshot.fromString(cache.snapshotOf(fileName)) + : host.getScriptSnapshot(fileName), + }; + + if (host.resolveModuleNameLiterals) { + const base = host.resolveModuleNameLiterals.bind(host); + overrides.resolveModuleNameLiterals = ( + literals, + containingFile, + ...rest + ) => + wrapLiterals( + literals, + containingFile, + base(literals, containingFile, ...rest), + ); + } else if (host.resolveModuleNames) { + const base = host.resolveModuleNames.bind(host); + overrides.resolveModuleNames = (names, containingFile, ...rest) => + wrapNames( + names, + containingFile, + base(names, containingFile, ...rest), + ); + } + + return overrides; +} + +function create(info, { tsm, build }) { + const host = info.languageServiceHost; + const { logger } = info.project.projectService; + + const cache = createTypeCache({ + build, + readFile: (resourcePath) => host.readFile(resourcePath), + readDiskSidecar: diskSidecarOf, + onGenerated: () => { + info.project.markAsDirty(); + info.project.refreshDiagnostics(); + }, + onError: (error, resourcePath) => + logger.info( + `rust-wasmpack-loader: type build failed for ${resourcePath}: ${error.message}`, + ), + }); + + const overrides = buildHostOverrides({ tsm, host, cache }); + return tsm.createLanguageService( + new Proxy(host, { + get: (target, key) => + key in overrides ? overrides[key] : target[key], + }), + ); +} + +function init(modules, deps = {}) { + const tsm = modules.typescript; + const build = deps.buildTypedDts || buildTypedDts; + return { + create: (info) => create(info, { tsm, build }), + getExternalFiles: (project) => project.getFileNames().filter(isRs), + }; +} + +module.exports = init; +module.exports.createTypeCache = createTypeCache; +module.exports.buildHostOverrides = buildHostOverrides; +module.exports.FLOOR = FLOOR; diff --git a/src/tsserver.test.js b/src/tsserver.test.js new file mode 100644 index 0000000..c6a31fe --- /dev/null +++ b/src/tsserver.test.js @@ -0,0 +1,168 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const os = require("node:os"); +const path = require("node:path"); +const ts = require("typescript"); +const init = require("./tsserver"); + +const { createTypeCache, buildHostOverrides, FLOOR } = init; + +// Raw wasm-bindgen `--target web` `.d.ts` for a crate exporting two functions. +// The real `dtsToSidecar` turns this into the precise sidecar the cache serves. +const WBGEN_DTS = [ + "export function fibonacci(n: number): number;", + "export function cap(s: string): string;", + "", +].join("\n"); + +// A language-service host with just the methods the overrides delegate to. A +// sentinel snapshot lets a test prove non-`.rs` reads pass straight through. +const SENTINEL = ts.ScriptSnapshot.fromString("sentinel"); + +function mockHost(extra = {}) { + return { + getScriptKind: () => ts.ScriptKind.JS, + getScriptVersion: () => "host-version", + getScriptSnapshot: () => SENTINEL, + readFile: () => "rust source", + resolveModuleNameLiterals: (literals) => + literals.map(() => ({ resolvedModule: undefined })), + ...extra, + }; +} + +function makeCache(opts = {}) { + return createTypeCache({ + build: async () => WBGEN_DTS, + readFile: () => "rust source", + readDiskSidecar: () => undefined, + onGenerated: () => undefined, + onError: () => undefined, + ...opts, + }); +} + +const snapshotText = (snapshot) => snapshot.getText(0, snapshot.getLength()); + +test("getScriptKind classifies .rs as TS and delegates the rest", () => { + const overrides = buildHostOverrides({ + tsm: ts, + host: mockHost(), + cache: makeCache(), + }); + assert.equal(overrides.getScriptKind("/a/math.rs"), ts.ScriptKind.TS); + assert.equal(overrides.getScriptKind("/a/app.ts"), ts.ScriptKind.JS); +}); + +test("resolveModuleNameLiterals points .rs at a declaration, delegates others", () => { + const overrides = buildHostOverrides({ + tsm: ts, + host: mockHost(), + cache: makeCache(), + }); + const containing = path.join(os.tmpdir(), "app.ts"); + const [rs, other] = overrides.resolveModuleNameLiterals( + [{ text: "./math.rs" }, { text: "./other" }], + containing, + undefined, + {}, + {}, + undefined, + ); + assert.equal(rs.resolvedModule.extension, ts.Extension.Dts); + assert.equal(rs.resolvedModule.isExternalLibraryImport, false); + assert.equal( + rs.resolvedModule.resolvedFileName, + path.resolve(path.dirname(containing), "./math.rs"), + ); + assert.equal(other.resolvedModule, undefined); +}); + +test("getScriptSnapshot passes non-.rs reads straight through", () => { + const overrides = buildHostOverrides({ + tsm: ts, + host: mockHost(), + cache: makeCache(), + }); + assert.equal(overrides.getScriptSnapshot("/a/app.ts"), SENTINEL); + assert.equal(overrides.getScriptVersion("/a/app.ts"), "host-version"); +}); + +test("getScriptSnapshot serves the floor first, then the precise types", async () => { + const cache = makeCache(); + const overrides = buildHostOverrides({ tsm: ts, host: mockHost(), cache }); + const rs = "/x/math.rs"; + + const before = overrides.getScriptVersion(rs); + assert.equal(snapshotText(overrides.getScriptSnapshot(rs)), FLOOR); + + await cache.pending(); + + const after = overrides.getScriptSnapshot(rs); + assert.match(snapshotText(after), /fibonacci\(n: number\): number;/); + assert.doesNotMatch(snapshotText(after), /Record { + const notified = []; + const cache = makeCache({ onGenerated: (rp) => notified.push(rp) }); + const rs = "/x/math.rs"; + + const before = cache.versionOf(rs); + cache.snapshotOf(rs); + cache.snapshotOf(rs); + await cache.pending(); + + assert.deepEqual(notified, [rs]); + assert.notEqual(cache.versionOf(rs), before); +}); + +test("a failed build keeps the floor and reports the error", async () => { + const errors = []; + const cache = makeCache({ + build: async () => { + throw new Error("wasm-pack boom"); + }, + onError: (error) => errors.push(error.message), + }); + const rs = "/x/math.rs"; + + assert.equal(cache.snapshotOf(rs), FLOOR); + await cache.pending(); + assert.equal(cache.snapshotOf(rs), FLOOR); + assert.deepEqual(errors, ["wasm-pack boom"]); +}); + +test("the on-disk sidecar overrides the floor before a build lands", () => { + const cache = makeCache({ + readDiskSidecar: () => + "declare const _default: { onDisk(): void };\nexport default _default;\n", + }); + assert.match(cache.snapshotOf("/x/math.rs"), /onDisk\(\): void/); +}); + +test("changing the source content changes the script version", () => { + const sources = { value: "v1" }; + const cache = makeCache({ readFile: () => sources.value }); + const rs = "/x/math.rs"; + const first = cache.versionOf(rs); + sources.value = "v2"; + assert.notEqual(cache.versionOf(rs), first); +}); + +test("getExternalFiles returns only the project's .rs files", () => { + const plugin = init({ typescript: ts }, { buildTypedDts: async () => "" }); + const project = { + getFileNames: () => [ + "/a/app.ts", + "/a/math.rs", + "/b/lib.rs", + "/c/x.json", + ], + }; + assert.deepEqual(plugin.getExternalFiles(project), [ + "/a/math.rs", + "/b/lib.rs", + ]); +}); diff --git a/src/utils/generateTypes.util.js b/src/utils/generateTypes.util.js index e22d6ab..9e5c147 100644 --- a/src/utils/generateTypes.util.js +++ b/src/utils/generateTypes.util.js @@ -40,15 +40,16 @@ function typedBuildFolder(resourcePath) { } /** - * Builds a `.rs` source with wasm-bindgen typings enabled, transforms the - * generated `.d.ts` into a sidecar matching the loader's runtime default export, - * and writes `.d.rs.ts` next to the source. Reuses the loader's - * Cargo-discovery and wasm-pack pipeline; idempotent across runs. + * Builds a `.rs` source with wasm-bindgen typings enabled and returns the raw + * wasm-bindgen `.d.ts` source, without writing anything next to the `.rs`. + * Reuses the loader's Cargo-discovery and wasm-pack pipeline; idempotent across + * runs. The tsserver plugin uses this to type `.rs` imports live without + * touching disk; `generateTypes` layers the on-disk sidecar write on top. * @param {string} resourcePath absolute path to the `.rs` file * @param {{ baseFolder?: string, logLevel?: string }} [options] - * @returns {Promise} the written sidecar path + * @returns {Promise} the wasm-bindgen `.d.ts` source */ -module.exports = async function generateTypes(resourcePath, options = {}) { +async function buildTypedDts(resourcePath, options = {}) { const baseFolder = path.normalize(options.baseFolder || process.cwd()); const fileEntry = path.normalize(resourcePath); const { dir } = path.parse(fileEntry); @@ -83,11 +84,26 @@ module.exports = async function generateTypes(resourcePath, options = {}) { extraArgs: ["--target", "web"], }); + return fs.readFileSync( + path.join(outDir, `${constants.OUT_NAME}.d.ts`), + "utf8", + ); +} + +/** + * Builds a `.rs` source with wasm-bindgen typings enabled, transforms the + * generated `.d.ts` into a sidecar matching the loader's runtime default export, + * and writes `.d.rs.ts` next to the source. + * @param {string} resourcePath absolute path to the `.rs` file + * @param {{ baseFolder?: string, logLevel?: string }} [options] + * @returns {Promise} the written sidecar path + */ +async function generateTypes(resourcePath, options = {}) { return writeSidecar( resourcePath, - fs.readFileSync( - path.join(outDir, `${constants.OUT_NAME}.d.ts`), - "utf8", - ), + await buildTypedDts(resourcePath, options), ); -}; +} + +module.exports = generateTypes; +module.exports.buildTypedDts = buildTypedDts; diff --git a/src/utils/generateTypes.util.test.js b/src/utils/generateTypes.util.test.js index c68aa26..5850ffa 100644 --- a/src/utils/generateTypes.util.test.js +++ b/src/utils/generateTypes.util.test.js @@ -4,6 +4,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const generateTypes = require("./generateTypes.util"); +const { buildTypedDts } = require("./generateTypes.util"); const findWasmPack = require("./findWasmPack.util"); const CRATE = path.join(__dirname, "..", "..", "example", "typed-imports"); @@ -31,6 +32,26 @@ function isolatedCrate() { return dir; } +test( + "buildTypedDts returns the raw wasm-bindgen .d.ts and writes nothing", + { skip }, + async () => { + const dir = isolatedCrate(); + try { + const dts = await buildTypedDts(path.join(dir, "math.rs")); + assert.match(dts, /export function fibonacci/); + assert.match(dts, /export function cap/); + assert.equal( + fs.existsSync(path.join(dir, "math.d.rs.ts")), + false, + "buildTypedDts must not write a sidecar", + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, +); + test( "writes a sidecar with the precise function signatures", { skip }, diff --git a/tsserver.js b/tsserver.js new file mode 100644 index 0000000..097cfbf --- /dev/null +++ b/tsserver.js @@ -0,0 +1 @@ +module.exports = require("./src/tsserver"); From 0844c3be36b27147dd28a1b7d5bc35ad8d0341c9 Mon Sep 17 00:00:00 2001 From: yeskiy Date: Mon, 29 Jun 2026 03:59:06 +0300 Subject: [PATCH 2/3] test: run the src suite serially to avoid cargo contention `node --test` runs test files in parallel, and several src tests spawn a wasm-pack/cargo build; on a cold cargo cache (CI) the concurrent builds wedge on the registry lock. Run with `--test-concurrency=1` so one build runs at a time, add `--test-force-exit` against any lingering handle, and force-kill the `--watch` test child with a bounded wait so teardown cannot hang. Refs: RL-67 --- package.json | 2 +- src/cli.test.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index aab829d..d7976bb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "rust-wasmpack-loader": "bin/rust-wasmpack-loader.js" }, "scripts": { - "test": "node --test \"src/**/*.test.js\"", + "test": "node --test --test-concurrency=1 --test-force-exit \"src/**/*.test.js\"", "release": "npx commit-and-tag-version && npm run postrelease", "postrelease": "cd ./example/node-webpack && npm i && cd ../../ && cd ./example/web-webpack && npm i && cd ../../ && cd ./example/node-bun && npm i && cd ../../ && cd ./example/esbuild && npm i && cd ../../ && cd ./example/rspack && npm i && cd ../../ && cd ./example/rollup && npm i && cd ../../ && cd ./example/vite && npm i && cd ../../ && cd ./example/next && npm i && cd ../../ && cd ./example/next-turbopack && npm i && cd ../../ && cd ./example/electron && npm i", "docs:start": "docusaurus start --config ./docs/docusaurus.config.js", diff --git a/src/cli.test.js b/src/cli.test.js index 88d3d99..85e66f6 100644 --- a/src/cli.test.js +++ b/src/cli.test.js @@ -100,10 +100,15 @@ test("--watch regenerates the sidecar on source change", { skip }, async () => { `watch did not pick up the new export:\n${output()}`, ); } finally { - child.kill(); - await new Promise((resolve) => { - child.once("exit", resolve); - }); + child.kill("SIGKILL"); + await Promise.race([ + new Promise((resolve) => { + child.once("exit", resolve); + }), + new Promise((resolve) => { + setTimeout(resolve, 5000); + }), + ]); // Windows can hold the directory briefly after the child exits. await waitFor(() => { try { From 76ea3c64c52c2b9be61837b2e60c348de2700c0b Mon Sep 17 00:00:00 2001 From: yeskiy Date: Mon, 29 Jun 2026 04:27:33 +0300 Subject: [PATCH 3/3] refactor: pass the inflight iterable straight to Promise.allSettled `Promise.allSettled` accepts an iterable, so spreading the Map values into an array first is unnecessary (SonarCloud S7747). Refs: RL-67 --- src/tsserver.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tsserver.js b/src/tsserver.js index ce81829..c62810b 100644 --- a/src/tsserver.js +++ b/src/tsserver.js @@ -100,7 +100,7 @@ function createTypeCache({ ); }; - const pending = () => Promise.allSettled([...inflight.values()]); + const pending = () => Promise.allSettled(inflight.values()); return { versionOf, snapshotOf, ensureFresh, pending }; }