Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default defineConfig([
"**/.next/**",
"**/out/**",
".docusaurus/**",
"**/*.d.rs.ts",
]),

{ name: "js/config", ...js.configs.recommended },
Expand Down
18 changes: 18 additions & 0 deletions example/typed-imports/src/types.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;/,
);
});
1 change: 1 addition & 0 deletions example/typed-imports/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ module.exports = {
target: "node",
node: { bundle: true },
logLevel: "error",
types: true,
},
},
},
Expand Down
6 changes: 6 additions & 0 deletions src/bun.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const optionsSchema = {
description:
"Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)",
},
types: {
type: "boolean",
description:
"Also write the `<name>.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)",
},
},
additionalProperties: false,
};
Expand All @@ -36,6 +41,7 @@ module.exports = function bun(config) {
baseFolder: process.cwd(),
target: "node",
logLevel: options.logLevel,
emitTypes: options.types === true,
}),
);
},
Expand Down
6 changes: 6 additions & 0 deletions src/esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const optionsSchema = {
description:
"Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)",
},
types: {
type: "boolean",
description:
"Also write the `<name>.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)",
},
},
additionalProperties: false,
};
Expand Down Expand Up @@ -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,
}),
);
},
Expand Down
91 changes: 91 additions & 0 deletions src/index.emitTypes.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
},
);
6 changes: 6 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ const optionsSchema = {
description:
"Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)",
},
types: {
type: "boolean",
description:
"Also write the `<name>.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.',
Expand Down Expand Up @@ -280,6 +285,7 @@ async function rustWasmLoader(source) {
wasmName,
target: params.target,
logLevel: options.logLevel,
emitTypes: options.types === true,
web: {
...options.web,
publicPath,
Expand Down
44 changes: 44 additions & 0 deletions src/next.emitTypes.test.js
Original file line number Diff line number Diff line change
@@ -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),
);
});
43 changes: 27 additions & 16 deletions src/next.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ const optionsSchema = {
description:
"Log Level (`verbose`, `info`, `warn`, `error`, `quiet`)",
},
types: {
type: "boolean",
description:
"Also write the `<name>.d.rs.ts` sidecar next to each `.rs` source during the build (off by default)",
},
},
additionalProperties: false,
};
Expand All @@ -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,
},
);

Expand All @@ -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,
),
];

Expand All @@ -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,
Expand All @@ -93,7 +98,7 @@ const withRsRule = (config, isServer, nextRuntime, logLevel) => ({
...config.module,
rules: [
...(config.module?.rules ?? []),
rsRule(isServer, nextRuntime, logLevel),
rsRule(isServer, nextRuntime, shared),
],
},
});
Expand All @@ -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 = {}) {
Expand All @@ -130,21 +135,27 @@ 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) {
const patched = withRsRule(
config,
webpackOptions.isServer,
webpackOptions.nextRuntime,
options.logLevel,
shared,
);

return typeof nextConfig.webpack === "function"
Expand Down
Loading
Loading