@rust-js/vite-plugin 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +290 -0
- package/package.json +25 -4
- package/README.md +0 -3
package/index.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Compile Rust to ordinary JS/JSX files; Vite and plugin-react own bundling
|
|
2
|
+
// and Fast Refresh. The compiler manifest owns dependencies and output paths.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
7
|
+
import { createNativeBuilder, findCompiler } from "@rust-js/build/build";
|
|
8
|
+
import { parseManifest } from "@rust-js/build/manifest";
|
|
9
|
+
import { writePatch } from "@rust-js/build/patch";
|
|
10
|
+
|
|
11
|
+
/** The app Vite's `root` is of: the nearest directory with a `package.json`. */
|
|
12
|
+
function appDir(root) {
|
|
13
|
+
for (let dir = root; ; dir = dirname(dir)) {
|
|
14
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
15
|
+
if (dirname(dir) === dir) return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {string[]} [options.crates] Crate roots relative to Vite's root.
|
|
22
|
+
* @param {string} [options.rustJs] Compiler binary; defaults to the app's native package, then this checkout.
|
|
23
|
+
* @param {string[]} [options.bindings] Built-in metadata recipes: react (default), serde.
|
|
24
|
+
* @param {(job: { crate: string, output: string, manifest: string }) => Promise<void>} [options.compile]
|
|
25
|
+
* Compile a crate some other way, like the playground's with rust-js.wasm:
|
|
26
|
+
* write its JS beside it and a manifest (ADR 0042) to `manifest`, all paths
|
|
27
|
+
* absolute, or throw rustc's errors. It builds the crates it needs itself.
|
|
28
|
+
* @param {{ package: string, manifestPath?: string, features?: string[], noDefaultFeatures?: boolean, offline?: boolean, inSource?: boolean }} [options.cargo]
|
|
29
|
+
* Build a Cargo workspace instead (ADR 0101): Cargo checks each crate, with
|
|
30
|
+
* rust-js as its workspace wrapper, and the app imports `package`'s JS as
|
|
31
|
+
* `rust-js:<package>`. It's beside the Rust, as a project commits it
|
|
32
|
+
* (ADR 0041), unless `inSource` is `false`: then where Cargo's build of it
|
|
33
|
+
* has it. `manifestPath` is relative to Vite's root, `Cargo.toml` by default.
|
|
34
|
+
*/
|
|
35
|
+
export default function rustJs({ crates = ["src/App.rs"], rustJs, compile: custom, resources, rustcFlags = [], cacheDir, bindings, externs, cargo } = {}) {
|
|
36
|
+
// Cargo's checks are the native compiler's (ADR 0101), not another's.
|
|
37
|
+
if (cargo && custom) throw new Error("rust-js: give `cargo` or `compile`, not both");
|
|
38
|
+
if (cargo) crates = [cargo.package];
|
|
39
|
+
let root, server, builder, logger, closed = false;
|
|
40
|
+
// A Cargo build's: the package's JS, the manifest Vite is given, and the
|
|
41
|
+
// workspace it's of, what Cargo reads, and its target, what it writes.
|
|
42
|
+
let entry, workspaceManifest, workspace, targetDir;
|
|
43
|
+
let active;
|
|
44
|
+
const pending = new Set();
|
|
45
|
+
const manifests = new Map();
|
|
46
|
+
const failures = new Map();
|
|
47
|
+
const aliases = new Map();
|
|
48
|
+
const maps = new Set();
|
|
49
|
+
// The modules Cargo's builds have, outside the app.
|
|
50
|
+
const generated = new Set();
|
|
51
|
+
// Committed files used without rust-js, whose maps may not be committed.
|
|
52
|
+
const committed = new Set();
|
|
53
|
+
const manifestPath = crate => join(cacheDir ?? join(root, "node_modules/.cache/rust-js"), "vite", createHash("sha256").update(resolve(root, crate)).digest("hex") + ".json");
|
|
54
|
+
|
|
55
|
+
// Every crate Cargo built, or found done: each is a manifest here, and
|
|
56
|
+
// every edit is one `cargo check`, which rebuilds what it changed.
|
|
57
|
+
async function compileCargo() {
|
|
58
|
+
const old = JSON.stringify([...manifests.values()].flatMap(m => m.artifacts.map(a => a.file)).sort());
|
|
59
|
+
// A member's manifest is of the workspace its siblings are of too.
|
|
60
|
+
({ root: workspace, target: targetDir } = await builder.cargoWorkspace({ manifestPath: workspaceManifest, offline: cargo.offline }));
|
|
61
|
+
server?.watcher.add(workspace);
|
|
62
|
+
const built = await builder.checkCargo({
|
|
63
|
+
manifestPath: workspaceManifest, packageName: cargo.package,
|
|
64
|
+
features: cargo.features, noDefaultFeatures: cargo.noDefaultFeatures, offline: cargo.offline,
|
|
65
|
+
inSource: cargo.inSource ?? true,
|
|
66
|
+
});
|
|
67
|
+
manifests.clear();
|
|
68
|
+
for (const [name, { manifest }] of built.crates) manifests.set(name, parseManifest(await readFile(manifest, "utf8")));
|
|
69
|
+
entry = built.js;
|
|
70
|
+
failures.clear();
|
|
71
|
+
// Its imports name each module's file, `.jsx` or `.js`: no aliases.
|
|
72
|
+
maps.clear();
|
|
73
|
+
generated.clear();
|
|
74
|
+
for (const current of manifests.values()) server?.watcher.add(current.sources);
|
|
75
|
+
// Each module's JS where it's served from: beside its Rust, or Cargo's.
|
|
76
|
+
server?.watcher.add(built.files);
|
|
77
|
+
for (const file of built.files) {
|
|
78
|
+
generated.add(file);
|
|
79
|
+
maps.add(`${file}.map`);
|
|
80
|
+
}
|
|
81
|
+
// Another build of a crate, of other features say, is another module ID.
|
|
82
|
+
const now = JSON.stringify([...manifests.values()].flatMap(m => m.artifacts.map(a => a.file)).sort());
|
|
83
|
+
if (server && old !== "[]" && old !== now) {
|
|
84
|
+
server.moduleGraph.invalidateAll();
|
|
85
|
+
server.ws.send({ type: "full-reload" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function compile(crate) {
|
|
90
|
+
if (cargo) return compileCargo();
|
|
91
|
+
const manifest = manifestPath(crate);
|
|
92
|
+
await mkdir(dirname(manifest), { recursive: true });
|
|
93
|
+
const output = crate.replace(/\.rs$/, ".js");
|
|
94
|
+
if (custom) await custom({ crate: resolve(root, crate), output: resolve(root, output), manifest });
|
|
95
|
+
// The Vite server's is a save's, whose checks are those not only for a
|
|
96
|
+
// build; what rust-js warns of, a check's or a transform's, is Vite's.
|
|
97
|
+
else await builder.compile({ crate, output, manifest, save: Boolean(server), warn: message => logger.warn(message.trimEnd(), { timestamp: true }) });
|
|
98
|
+
const result = parseManifest(await readFile(manifest, "utf8"));
|
|
99
|
+
const old = manifests.get(crate);
|
|
100
|
+
manifests.set(crate, result);
|
|
101
|
+
failures.delete(crate);
|
|
102
|
+
server?.watcher.add(result.sources);
|
|
103
|
+
aliases.clear();
|
|
104
|
+
maps.clear();
|
|
105
|
+
for (const current of manifests.values()) {
|
|
106
|
+
for (const module of current.modules) {
|
|
107
|
+
if (module.map) maps.add(resolve(root, module.map));
|
|
108
|
+
const file = resolve(root, module.file);
|
|
109
|
+
const stem = file.slice(0, -extname(file).length);
|
|
110
|
+
aliases.set(stem + ".js", file);
|
|
111
|
+
aliases.set(stem + ".jsx", file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// A suffix or module-set change invalidates old Vite module IDs. Ordinary
|
|
115
|
+
// edits retain their IDs and use plugin-react's state-preserving refresh.
|
|
116
|
+
if (server && old && JSON.stringify(old.artifacts.map(a => a.file)) !== JSON.stringify(result.artifacts.map(a => a.file))) {
|
|
117
|
+
server.moduleGraph.invalidateAll();
|
|
118
|
+
server.ws.send({ type: "full-reload" });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function drain() {
|
|
123
|
+
while (pending.size && !closed) {
|
|
124
|
+
// Let filesystem events from a single save accumulate before spawning.
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
126
|
+
const batch = [...pending];
|
|
127
|
+
pending.clear();
|
|
128
|
+
for (const crate of batch) {
|
|
129
|
+
try { await compile(crate); }
|
|
130
|
+
catch (error) { failures.set(crate, error.message); }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (server && !closed) {
|
|
134
|
+
if (failures.size) {
|
|
135
|
+
const message = [...failures.values()].join("\n");
|
|
136
|
+
server.config.logger.error(message, { timestamp: true });
|
|
137
|
+
server.ws.send({ type: "error", err: { message, stack: "", plugin: "rust-js" } });
|
|
138
|
+
} else {
|
|
139
|
+
// Vite clears its error overlay on an update, even when correcting an
|
|
140
|
+
// error reproduces identical JS and therefore triggers no file update.
|
|
141
|
+
server.ws.send({ type: "update", updates: [] });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function schedule(affected) {
|
|
147
|
+
for (const crate of affected) pending.add(crate);
|
|
148
|
+
if (!active) active = drain().finally(() => { active = undefined; });
|
|
149
|
+
return active;
|
|
150
|
+
}
|
|
151
|
+
function changed(event, path) {
|
|
152
|
+
if (closed) return;
|
|
153
|
+
const file = resolve(path);
|
|
154
|
+
const common = builder?.watchFiles.includes(file);
|
|
155
|
+
if (cargo) {
|
|
156
|
+
// What Cargo reads of the workspace, and each source rust-js read,
|
|
157
|
+
// wherever it is: a module by `#[path]` outside it. Cargo's own
|
|
158
|
+
// outputs aren't.
|
|
159
|
+
const read = /\.rs$|(^|[\\/])Cargo\.(toml|lock)$/.test(file) && file.startsWith(workspace + sep) && !(targetDir && file.startsWith(targetDir + sep));
|
|
160
|
+
const source = [...manifests.values()].some(manifest => manifest.sources.includes(file));
|
|
161
|
+
if (common || read || source) void schedule(crates);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!common && !file.endsWith(".rs")) return;
|
|
165
|
+
const affected = crates.filter(crate => {
|
|
166
|
+
if (common || manifests.get(crate)?.sources.includes(file) || resolve(root, crate) === file) return true;
|
|
167
|
+
// Newly declared or missing modules aren't in the last successful
|
|
168
|
+
// manifest. Retry roots under whose source tree the edit occurred.
|
|
169
|
+
return failures.has(crate) || ((event === "add" || !manifests.has(crate)) && file.startsWith(dirname(resolve(root, crate)) + sep));
|
|
170
|
+
});
|
|
171
|
+
void schedule(affected);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
name: "rust-js",
|
|
176
|
+
enforce: "pre",
|
|
177
|
+
configResolved(config) {
|
|
178
|
+
root = config.root;
|
|
179
|
+
logger = config.logger;
|
|
180
|
+
if (cargo) {
|
|
181
|
+
workspaceManifest = resolve(root, cargo.manifestPath ?? "Cargo.toml");
|
|
182
|
+
workspace = dirname(workspaceManifest);
|
|
183
|
+
}
|
|
184
|
+
if (!custom) {
|
|
185
|
+
rustJs = resolve(root, rustJs ?? findCompiler(root));
|
|
186
|
+
builder = createNativeBuilder({ root, rustJs, resources, rustcFlags, cacheDir, bindings, externs });
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
async buildStart() {
|
|
190
|
+
// The app's Cargo, and its editor, find the crates its npm packages
|
|
191
|
+
// have by a patch, which an install's `postinstall` writes, and Vite
|
|
192
|
+
// again as it starts: a crate installed twice stops it.
|
|
193
|
+
const app = appDir(root);
|
|
194
|
+
if (app) {
|
|
195
|
+
try {
|
|
196
|
+
writePatch(app);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.error(`rust-js: ${error instanceof Error ? error.message : error}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// The generated JS is committed, as ReScript recommends (ADR 0041), so
|
|
202
|
+
// a checkout without rust-js still builds from it. With rust-js, the
|
|
203
|
+
// Rust is always compiled, and an error is never hidden by an old file.
|
|
204
|
+
// A Cargo workspace's is beside each crate's Rust (ADR 0041).
|
|
205
|
+
if (cargo && !existsSync(rustJs)) {
|
|
206
|
+
const { packages } = await builder.cargoWorkspace({ manifestPath: workspaceManifest, offline: cargo.offline });
|
|
207
|
+
const dir = packages.get(cargo.package);
|
|
208
|
+
const file = dir && [".jsx", ".js"].map(ext => join(dir, "src", `lib${ext}`)).find(existsSync);
|
|
209
|
+
if (file) {
|
|
210
|
+
entry = file;
|
|
211
|
+
committed.add(file);
|
|
212
|
+
generated.add(file);
|
|
213
|
+
this.warn(`no rust-js at ${rustJs}: using the committed ${file}. Install rust-js or build it with cargo build to compile the Rust.`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!custom && !cargo && !existsSync(rustJs)) {
|
|
218
|
+
const files = crates.map(crate => [".jsx", ".js"].map(ext => crate.replace(/\.rs$/, ext)).find(file => existsSync(resolve(root, file))));
|
|
219
|
+
if (files.every(Boolean)) {
|
|
220
|
+
for (const file of files) committed.add(resolve(root, file));
|
|
221
|
+
this.warn(`no rust-js at ${rustJs}: using the committed ${files.join(", ")}. Install rust-js or build it with cargo build to compile the Rust.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await schedule(crates);
|
|
226
|
+
if (failures.size) {
|
|
227
|
+
const message = [...failures.values()].join("\n");
|
|
228
|
+
if (server) this.warn(message);
|
|
229
|
+
else this.error(message);
|
|
230
|
+
}
|
|
231
|
+
for (const manifest of manifests.values()) for (const file of manifest.sources) this.addWatchFile(file);
|
|
232
|
+
},
|
|
233
|
+
// A committed file names its source map, which isn't committed: without
|
|
234
|
+
// it, drop the comment rather than have Vite report a missing file.
|
|
235
|
+
async load(id) {
|
|
236
|
+
const file = id.split("?")[0];
|
|
237
|
+
if (!committed.has(file) || existsSync(`${file}.map`)) return;
|
|
238
|
+
return (await readFile(file, "utf8")).replace(/\/\/# sourceMappingURL=\S+\s*$/, "");
|
|
239
|
+
},
|
|
240
|
+
resolveId(source, importer, options) {
|
|
241
|
+
if (cargo && source === `rust-js:${cargo.package}`) return entry;
|
|
242
|
+
// A package a module Cargo built imports is the app's, where the app
|
|
243
|
+
// has it installed: resolved as from Vite's root, not the target's.
|
|
244
|
+
if (importer && generated.has(importer.split("?")[0]) && !source.startsWith(".") && !source.startsWith("/")) {
|
|
245
|
+
return this.resolve(source, join(root, "index.html"), { ...options, skipSelf: true });
|
|
246
|
+
}
|
|
247
|
+
// What a committed module imports of the crates' is theirs too: found
|
|
248
|
+
// from the entry, when there's no rust-js to say what they are.
|
|
249
|
+
if (cargo && importer && committed.has(importer.split("?")[0]) && source.startsWith(".")) {
|
|
250
|
+
const file = resolve(dirname(importer.split("?")[0]), source);
|
|
251
|
+
if (/\.jsx?$/.test(file) && existsSync(file)) {
|
|
252
|
+
committed.add(file);
|
|
253
|
+
generated.add(file);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (!importer || !source.startsWith(".")) return;
|
|
257
|
+
return aliases.get(resolve(dirname(importer.split("?")[0]), source));
|
|
258
|
+
},
|
|
259
|
+
// Neither a `.rs` file nor a source map written from one is a module: the
|
|
260
|
+
// JS compiled from them brings its own update. Pass on only what depends
|
|
261
|
+
// on a `.rs` file as a plain file, like a stylesheet holding its Tailwind
|
|
262
|
+
// classes, which then updates in place. Otherwise Tailwind reloads the
|
|
263
|
+
// page, as it does for a template file it scans.
|
|
264
|
+
hotUpdate({ file, modules }) {
|
|
265
|
+
if (maps.has(file)) return [];
|
|
266
|
+
if (!file.endsWith(".rs")) return;
|
|
267
|
+
return [...new Set(modules.flatMap(module => [...module.importers]))];
|
|
268
|
+
},
|
|
269
|
+
async handleHotUpdate() {
|
|
270
|
+
// Native publication replaces files individually. Let Vite read the
|
|
271
|
+
// completed module set, including its manifest, before sending updates.
|
|
272
|
+
await active;
|
|
273
|
+
},
|
|
274
|
+
configureServer(value) {
|
|
275
|
+
server = value;
|
|
276
|
+
server.watcher.add([...(builder?.watchFiles ?? []), ...(cargo ? [workspace] : crates.map(crate => dirname(resolve(root, crate))))]);
|
|
277
|
+
server.watcher.on("all", changed);
|
|
278
|
+
},
|
|
279
|
+
async closeBundle() {
|
|
280
|
+
if (!server) return;
|
|
281
|
+
closed = true;
|
|
282
|
+
server?.watcher.off("all", changed);
|
|
283
|
+
await active;
|
|
284
|
+
},
|
|
285
|
+
async closeWatcher() {
|
|
286
|
+
closed = true;
|
|
287
|
+
await active;
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rust-js/vite-plugin",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Compile Rust with rust-js in a Vite project, on start and on save. See docs/decisions/0041-react.md.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/rust-js-lang/rust-js.git",
|
|
10
|
+
"directory": "vite-plugin"
|
|
11
|
+
},
|
|
12
|
+
"exports": "./index.js",
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@rust-js/build": "0.0.2"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"vite": ">=8"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"vite": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/README.md
DELETED