@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.
Files changed (3) hide show
  1. package/index.js +290 -0
  2. package/package.json +25 -4
  3. 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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.