@rust-js/build 0.0.1

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/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # Build hosts
2
+
3
+ `build.js` prepares and invokes the native compiler. Vite delegates to it; other
4
+ hosts can use it without importing the Vite plugin.
5
+
6
+ Distributed tooling supports Node.js and Bun using standard Node.js APIs. Bun
7
+ is used to develop, test, and create packages in this repository; users do not
8
+ need it to run installed packages. Binding preparation and the native launcher
9
+ use the host's current JavaScript runtime.
10
+
11
+ ```js
12
+ import { createNativeBuilder } from "@rust-js/build/build";
13
+
14
+ const builder = createNativeBuilder({
15
+ root: "/absolute/app",
16
+ rustJs: "/absolute/toolchain/rust-js",
17
+ resources: "/absolute/rust-js-resources",
18
+ cacheDir: "/absolute/app/.cache/rust-js",
19
+ bindings: [],
20
+ externs: { web: "/absolute/metadata/libwebapi.rmeta" },
21
+ rustcFlags: [],
22
+ });
23
+ await builder.compile({
24
+ crate: "/absolute/app/src/lib.rs",
25
+ output: "/absolute/app/generated/lib.js",
26
+ manifest: "/absolute/app/.cache/rust-js/manifest.json",
27
+ });
28
+ ```
29
+
30
+ `@rust-js/build` exposes package entry points: `/build` for native compiler
31
+ preparation, `/manifest` for build-result validation, and `/publish` for WASI
32
+ artifact publication. Vite and the playground declare this package dependency;
33
+ neither imports tooling through a path outside its own package.
34
+
35
+ `/patch`, and its command, `rust-js-patch [app-dir]`, tell an app's Cargo where
36
+ the crates its npm packages have are: each package with `"rust-js": { "crate" }`,
37
+ as `@rust-js/builtins` and `@rust-js/webapi` have, found as Node finds it, in
38
+ npm's, bun's or pnpm's layout, is a `[patch.crates-io]` entry in the app's
39
+ `.cargo/config.toml`, between `# rust-js: begin` and `# rust-js: end`. An app
40
+ runs it as its `postinstall`, so Cargo and its editor have them once it's
41
+ installed. A crate installed twice, which would be two of each of its types, is
42
+ refused, with who asked for each.
43
+
44
+ Both host packages are on npm, released with the compiler (ADR 0120). Local
45
+ tarballs can be made with `bun pm pack` from `tooling/` and `vite-plugin/`.
46
+ The package test installs these tarballs with Bun into an independent application
47
+ and compiles through the plugin.
48
+ Compiler discovery first uses the supplied `rustJs` path, then the application's
49
+ `@rust-js/native` package, then the development checkout. Resource discovery uses the supplied `resources`
50
+ path, then `@rust-js/resources` resolved from the application's dependencies,
51
+ then the development checkout;
52
+ compiler binaries and binding resources are not included in these host packages.
53
+
54
+ Build and package a native compiler for the current macOS or Linux host:
55
+
56
+ ```sh
57
+ cargo build --release --locked
58
+ bun run pack:compiler target/release/rust-js /absolute/artifacts/native.tgz
59
+ ```
60
+
61
+ The private `@rust-js/native` package contains the binary and a JavaScript launcher,
62
+ with OS/architecture restrictions in its package manifest. Install the pinned
63
+ Rust toolchain, its minimal profile and the `wasm32-unknown-unknown` target, on
64
+ the destination machine first. The launcher
65
+ asks that toolchain for its sysroot and sets the dynamic-library search path
66
+ before forwarding arguments and exit status to the compiler. It does not install
67
+ toolchains or modify global configuration. The adapter discovers this package
68
+ automatically. To override it, set `rustJs` to an absolute compiler or launcher
69
+ path, including its installed `.bin` link.
70
+ The host hashes and watches both the launcher and its compiler binary.
71
+
72
+ This is a local packaging path, tested on the development host. It still depends
73
+ on Node.js or Bun, rustup, and compatible native system libraries. Clean-machine testing,
74
+ Linux/macOS version qualification and signing remain open;
75
+ the archive is not a standalone portable compiler distribution.
76
+
77
+ Create a separate resource tarball from the repository root:
78
+
79
+ ```sh
80
+ bun run pack:resources /absolute/artifacts/resources.tgz
81
+ ```
82
+
83
+ This stages `@rust-js/resources` with the compiler's version, root toolchain pin,
84
+ React/web binding sources and build scripts, and the locked Serde manifest and
85
+ source. The resource package and metadata cache use the same input inventory.
86
+ Install it as `@rust-js/resources`, or unpack it and set `resources` to the
87
+ directory containing its `package.json` and `rust-toolchain.toml`.
88
+ Build outputs go to the configured cache, outside the
89
+ resource directory. The resource tarball contains source inputs, not prebuilt
90
+ metadata, compiler binaries, or a Rust sysroot. Building still requires Node.js or Bun,
91
+ Bash, and the pinned Rust toolchain; Cargo also needs its locked dependencies
92
+ available locally or through its configured registry.
93
+
94
+ To assemble all four packages together, use a new output directory:
95
+
96
+ ```sh
97
+ bun run pack:distribution target/release/rust-js /absolute/artifacts
98
+ cd /absolute/artifacts
99
+ shasum -a 256 -c SHA256SUMS
100
+ ```
101
+
102
+ On Linux, `sha256sum -c SHA256SUMS` also works. The command checks package versions
103
+ against the compiler, stages every tarball, writes `distribution.json` with the
104
+ compiler identity, host platform/architecture and archive hashes, then renames
105
+ the completed directory into place. Existing destinations are rejected and a
106
+ failed build removes its staging directory. `SHA256SUMS` covers all four archives
107
+ and the distribution manifest. These hashes detect corruption; they do not
108
+ authenticate the publisher. The command does not publish or install anything.
109
+
110
+ For a local installation, add the four tarballs to the application's
111
+ `package.json` using paths relative to that file, then install with the application's
112
+ package manager. The local tarball setup below is tested with `bun install`;
113
+ running the installed packages does not depend on that choice:
114
+
115
+ ```json
116
+ {
117
+ "devDependencies": {
118
+ "vite": "8.3.0",
119
+ "@rust-js/vite-plugin": "./artifacts/vite-plugin.tgz",
120
+ "@rust-js/build": "./artifacts/build.tgz",
121
+ "@rust-js/native": "./artifacts/native.tgz",
122
+ "@rust-js/resources": "./artifacts/resources.tgz"
123
+ },
124
+ "overrides": {
125
+ "@rust-js/build": "./artifacts/build.tgz"
126
+ }
127
+ }
128
+ ```
129
+
130
+ The override routes the plugin's versioned dependency to the local tarball while
131
+ the package is unpublished. Select `bindings: ["react", "serde"]`; neither a
132
+ compiler path nor a resource path is needed for this installation:
133
+
134
+ ```js
135
+ rustJs({ crates: ["src/App.rs"], bindings: ["react", "serde"] })
136
+ ```
137
+
138
+ The isolated package test installs offline with lifecycle scripts disabled,
139
+ repeats installation with a frozen lockfile, and exercises React/JSX and Serde
140
+ using automatic resource discovery. It omits Vite's peer for its direct hook
141
+ test; the separate Vite suite exercises the real bundler and Fast Refresh.
142
+ The installed launcher, host, and binding preparation are exercised under both
143
+ Node.js and Bun, with a failing stub for the other runtime on `PATH` so a hidden
144
+ dependency fails the test. The CLI uses a Node.js shebang; Bun-only users can run
145
+ `bun node_modules/.bin/rust-js`. The build adapter invokes it with its own runtime.
146
+ Direct use of `react/build.sh` defaults to Node.js; set `RUST_JS_JS_RUNTIME` to a
147
+ Bun executable to run it with Bun. Hosts supply this automatically.
148
+ Clean-machine compiler installation, release authentication, platform qualification,
149
+ and published package installation remain separate distribution work.
150
+
151
+ For packaged resources, the adapter queries `rust-js --version-json` before
152
+ preparing metadata. The response contains `version`, `toolchain`, and `abi`,
153
+ matching the compiler identity in emitted manifests. The adapter requires the
154
+ resource package's version and Rust pin to match, and accepts only ABI 1.
155
+ Mismatch errors report both identities and leave existing output untouched.
156
+ `rust-js --version` remains the human-readable form. Source-checkout resources
157
+ without the `@rust-js/resources` package identity retain the development workflow.
158
+ Matching version fields are a compatibility check, not proof of artifact
159
+ provenance; release checksums and qualification are still needed.
160
+
161
+ Use absolute compiler, resource, cache and extern paths. Crate and output paths
162
+ may also be relative to `root`. `watchFiles` lists toolchain and binding inputs;
163
+ add the validated manifest's `sources` for the application's watch set.
164
+
165
+ `bindings` defaults to `["react"]`. This builds the web and React metadata for
166
+ the application's installed React version. With no installed React, it uses the
167
+ binding resource's default. The compiler bytes, binding source inputs, resource
168
+ root, React version and compiler options identify the metadata cache directory.
169
+ A build failure leaves no completion marker. Deleting the cache is safe.
170
+
171
+ Use `bindings: ["react", "serde"]` for a React application that also uses
172
+ `serde` derives and `serde_json`, or `["serde"]` for a non-React application.
173
+ The adapter builds the locked Serde dependency set with the resource bundle's
174
+ pinned toolchain and obtains artifact paths from Cargo's JSON output. Cargo
175
+ checks freshness on every preparation; changes to the manifest, lockfile,
176
+ binding source, compiler or options select a new cache directory. Paths with
177
+ spaces are supported. Vite accepts the same `bindings` option:
178
+
179
+ ```js
180
+ rustJs({ crates: ["src/App.rs"], bindings: ["react", "serde"] })
181
+ ```
182
+
183
+ React and Serde are the built-in preparation recipes. Supply other matching metadata
184
+ through `externs`; this does not make arbitrary dependency implementations
185
+ available to the JavaScript backend. General Cargo dependency resolution and
186
+ cross-crate JavaScript linking remain unsupported. The resource bundle currently
187
+ uses the repository's binding build scripts and source layout; a standalone
188
+ installable distribution is not yet provided.
189
+
190
+ ## Share model source with native Rust
191
+
192
+ For now, compile shared source as a module in each target. For example, keep
193
+ `shared/model.rs` beside `client/lib.rs` and `server/main.rs`, and include it
194
+ from both entry points:
195
+
196
+ ```rust
197
+ #[path = "../shared/model.rs"]
198
+ mod model;
199
+ ```
200
+
201
+ The shared module can contain supported Serde models and portable validation
202
+ functions. Keep native I/O in the server entry point and browser APIs in the
203
+ client. Configure the client builder with `bindings: ["serde"]` (plus `"react"`
204
+ for React). The native server uses ordinary Serde dependencies; use matching
205
+ versions and derive features when comparing JSON contracts.
206
+
207
+ The compiler manifest lists the loaded shared file in `sources`. Watch that
208
+ list and rebuild the client when shared source changes; rebuild the native
209
+ server separately. Arbitrary Cargo dependencies, build scripts, features and
210
+ procedural macros are not automatically prepared by this adapter. Its Serde
211
+ recipe supplies the bundled dependency versions and derive support.
212
+
213
+ [`test/shared-code.test.ts`](../test/shared-code.test.ts) creates an app outside
214
+ the repository, compiles the same models and validation for both targets, and
215
+ passes client-produced JSON through a native executable and back to the client.
216
+ It checks malformed requests and rebuilds both targets after changing a shared
217
+ validation rule. The test uses a subprocess for transport; HTTP, a browser UI,
218
+ and a deployed full-stack pilot remain separate integration work.
219
+
220
+ `manifest.js` validates the compiler's version-1 build result and remaps structured
221
+ path fields. `publish.js` commits a completed WASI result to the host filesystem.
222
+ Callers supply only a successful compiler result and serialize builds targeting
223
+ the same outputs. Publication preserves unchanged files, checks stale-file
224
+ ownership, and rolls back ordinary I/O errors. It does not guarantee crash-atomic
225
+ multi-file replacement.
226
+
227
+ ## Build a Cargo workspace
228
+
229
+ Cargo builds a workspace's libraries with rust-js as its workspace wrapper
230
+ ([ADR 0101](../docs/decisions/0101-cargo-workspace-wrapper.md)), each to JS of
231
+ its own ([ADR 0100](../docs/decisions/0100-separate-crates.md)):
232
+
233
+ ```js
234
+ import { checkCargo } from "@rust-js/build/cargo";
235
+
236
+ const { js, crates } = await checkCargo({
237
+ manifestPath: "Cargo.toml", toolchain: "<pinned release>", compiler: "/path/to/rust-js",
238
+ packageName: "frontend", features: [],
239
+ });
240
+ ```
241
+
242
+ It runs `RUSTC_WORKSPACE_WRAPPER=<compiler> cargo check --target
243
+ wasm32-unknown-unknown`. Each library of the workspace that `frontend` uses,
244
+ and `frontend` itself, is JS in Cargo's target directory, beside the metadata
245
+ of that build of it, importing the others; `js` is `frontend`'s, and `crates`
246
+ each crate's JS and manifest. A feature set built before is the JS it was.
247
+ Each file a crate's manifest lists is checked, of a build Cargo has as done
248
+ too: one gone or edited is refused, and `cargo clean -p <package> --target
249
+ wasm32-unknown-unknown` builds it again.
250
+ Registry crates, build scripts and procedural macros are built by rustc, as
251
+ Cargo asks; of registry crates, only serde's are known to rust-js at run time.
252
+ `cargo build` is refused.
253
+
254
+ A crate using React depends on the bindings in rust-js, and `checkCargo`'s
255
+ `react` is the release it's checked for (ADR 0043), the latest otherwise:
256
+
257
+ ```toml
258
+ [dependencies]
259
+ react = { package = "rust-js-react", path = "/path/to/rust-js/react" }
260
+ ```
261
+
262
+ In Vite, the plugin's `cargo` option builds the workspace with `checkCargo`, for
263
+ the React the app has installed, and the app imports the package's JS as
264
+ `rust-js:<package>`:
265
+
266
+ ```js
267
+ // vite.config.js, in the workspace's web/
268
+ plugins: [rustJs({ cargo: { package: "frontend", manifestPath: "../Cargo.toml" } }), react()]
269
+ ```
270
+
271
+ ```js
272
+ import { App } from "rust-js:frontend";
273
+ ```
274
+
275
+ An edit to any crate of the workspace is one `cargo check`, and the JS it
276
+ changes is a Fast Refresh. Each module's JS is written beside its Rust, to be
277
+ committed (ADR 0041): `frontend/src/api.rs` is `frontend/src/api.js`. Without
278
+ rust-js, Vite builds from those. `cargo: { inSource: false }` leaves the JS in
279
+ Cargo's target directory only. Cargo's target directory must be one Vite serves
280
+ from: its workspace root, or another of `server.fs.allow`.
281
+
282
+ ## Experimental Cargo planning
283
+
284
+ `@rust-js/build/cargo` exports `planCargoLibraries({ manifestPath, toolchain, target,
285
+ packageName?, features?, noDefaultFeatures? })`. Supply an exact toolchain pin and
286
+ an explicit target triple. `packageName` selects a member of a virtual workspace.
287
+
288
+ The planner invokes Cargo metadata with `--frozen`: create and commit the lockfile
289
+ first. It resolves offline without changing that lockfile, and returns local
290
+ library packages in dependency order, preserving dependency aliases, source paths,
291
+ editions and Cargo-resolved features. Development dependencies are excluded.
292
+ Registry/git packages, build scripts, procedural macros and packages without an
293
+ ordinary library target are rejected when reachable from the selected library.
294
+
295
+ This is dependency discovery, not a Cargo compilation entry point. It does not
296
+ produce rustc invocations, compile dependencies to JS, establish a cross-crate ABI
297
+ or cache compilation artifacts. Metadata feature sets are Cargo resolution data,
298
+ not a substitute for Cargo compiler-unit/build-script information. The native
299
+ builder and Vite do not consume this experimental plan yet.
300
+
301
+ The compiler also has an experimental scalar linkage proof, described in
302
+ [ADR 0085](../docs/decisions/0085-scalar-library-linkage.md). A producer uses
303
+ `rust-js shared.rs -o shared.js --library --manifest shared.json`; a consumer
304
+ passes `--dependency shared.json` plus real rustc `--extern` metadata after `--`.
305
+ This supports only the documented scalar free-function ABI. The caller must build
306
+ matching metadata and JS and rebuild dependencies after source edits. The Cargo
307
+ planner, native build adapter and Vite do not orchestrate these steps yet.
package/build.js ADDED
@@ -0,0 +1,193 @@
1
+ // Native build preparation. Hosts provide scheduling and consume manifests.
2
+ import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { cargoWorkspace, checkCargo } from "./cargo.js";
10
+ import { resourceInputs } from "./resources.js";
11
+ import { parseCompilerIdentity } from "./manifest.js";
12
+
13
+ export const defaultResources = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14
+ export const defaultCompiler = join(defaultResources, "target/debug/rust-js");
15
+
16
+ export function findCompiler(root) {
17
+ const require = createRequire(join(root, "package.json"));
18
+ try { return join(dirname(require.resolve("@rust-js/native/package.json")), "bin/rust-js"); }
19
+ catch (error) {
20
+ if (error.code !== "MODULE_NOT_FOUND") throw error;
21
+ return defaultCompiler;
22
+ }
23
+ }
24
+
25
+ function installedResources(root) {
26
+ const require = createRequire(join(root, "package.json"));
27
+ try { return dirname(require.resolve("@rust-js/resources/package.json")); }
28
+ catch (error) {
29
+ if (error.code !== "MODULE_NOT_FOUND") throw error;
30
+ return defaultResources;
31
+ }
32
+ }
33
+
34
+ /** The React the project has installed, whose API the react crate is built
35
+ * with (ADR 0043): what a later React added doesn't compile. `null` without
36
+ * one, which gets the latest's. */
37
+ function installedReact(root) {
38
+ try {
39
+ const require = createRequire(join(root, "package.json"));
40
+ return JSON.parse(readFileSync(require.resolve("react/package.json"), "utf8")).version;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * `@rust-js/runtime` as the project installed it, of `version`, the
48
+ * compiler's: the helpers the JS it writes imports are its release's (ADR
49
+ * 0103). Throws with what to install otherwise.
50
+ */
51
+ function checkRuntime(root, version) {
52
+ // Where Node finds a package, from the app up: looked for each time, as
53
+ // it may be installed while a dev server runs.
54
+ let found;
55
+ for (let dir = resolve(root); !found; dir = dirname(dir)) {
56
+ const candidate = join(dir, "node_modules", "@rust-js", "runtime", "package.json");
57
+ if (existsSync(candidate)) found = candidate;
58
+ else if (dirname(dir) === dir) break;
59
+ }
60
+ if (!found) throw new Error(`the JS rust-js ${version} writes imports @rust-js/runtime ${version}: install it`);
61
+ const installed = JSON.parse(readFileSync(found, "utf8")).version;
62
+ if (installed !== version) throw new Error(`rust-js ${version} needs @rust-js/runtime ${version}, not the installed ${installed}`);
63
+ }
64
+
65
+ /** `command`'s stdout; what it says on stderr as it succeeds, its warnings,
66
+ * go to `warn`, if given. */
67
+ function run(command, args, cwd, env = {}, warn) {
68
+ return new Promise((resolve, reject) => {
69
+ const child = spawn(command, args, { cwd, env: { ...process.env, RUST_JS_JS_RUNTIME: process.execPath, ...env }, stdio: ["ignore", "pipe", "pipe"] });
70
+ let output = "";
71
+ let errors = "";
72
+ child.stdout.setEncoding("utf8").on("data", chunk => { output += chunk; });
73
+ child.stderr.setEncoding("utf8").on("data", chunk => { errors += chunk; });
74
+ child.on("error", reject);
75
+ child.on("close", code => {
76
+ if (code !== 0) return reject(new Error(errors || `${command} exited with ${code}`));
77
+ if (warn && errors) warn(errors);
78
+ resolve(output);
79
+ });
80
+ });
81
+ }
82
+
83
+ export function createNativeBuilder({ root, rustJs = findCompiler(root), resources = installedResources(root), cacheDir = join(root, "node_modules/.cache/rust-js"), rustcFlags = [], bindings = ["react"], externs = {} }) {
84
+ const repo = resources;
85
+ const compilerPath = existsSync(rustJs) ? realpathSync(rustJs) : rustJs;
86
+ const compilerInputs = [...new Set([rustJs, compilerPath])];
87
+ const nativePackage = join(dirname(compilerPath), "../package.json");
88
+ const packaged = existsSync(nativePackage) && JSON.parse(readFileSync(nativePackage, "utf8")).name === "@rust-js/native";
89
+ if (packaged) {
90
+ compilerInputs.push(nativePackage, join(dirname(compilerPath), "compiler"));
91
+ }
92
+ const compilerCommand = packaged ? process.execPath : rustJs;
93
+ const compilerArgs = packaged ? [compilerPath] : [];
94
+ const metadataInputs = resourceInputs(bindings).map(p => join(repo, p));
95
+ const pinned = () => readFileSync(join(repo, "rust-toolchain.toml"), "utf8").match(/^channel\s*=\s*"([^"]+)"/m)?.[1];
96
+ // What the compiler says it is, once: `--version-json`.
97
+ let identity;
98
+ const compilerIdentity = async () => (identity ??= parseCompilerIdentity(await run(compilerCommand, [...compilerArgs, "--version-json"], root)));
99
+
100
+ // Each recipe uses the pinned resources and a content-keyed cache directory.
101
+ async function prepare() {
102
+ if (!existsSync(rustJs)) throw new Error(`no rust-js at ${rustJs}: configure rustJs with an installed compiler or build it with cargo build`);
103
+ checkRuntime(root, (await compilerIdentity()).version);
104
+ const react = bindings.includes("react") ? installedReact(root) : null;
105
+ if (!bindings.length) return { flags: [], react };
106
+ const packagePath = join(repo, "package.json");
107
+ if (existsSync(packagePath)) {
108
+ const resourcePackage = JSON.parse(await readFile(packagePath, "utf8"));
109
+ if (resourcePackage.name === "@rust-js/resources") {
110
+ const identity = await compilerIdentity();
111
+ const pin = (await readFile(join(repo, "rust-toolchain.toml"), "utf8")).match(/^channel\s*=\s*"([^"]+)"/m)?.[1];
112
+ if (resourcePackage.version !== identity.version || pin !== identity.toolchain) {
113
+ throw new Error(`Incompatible rust-js resources: compiler ${identity.version} (${identity.toolchain}), resources ${resourcePackage.version} (${pin ?? "missing Rust pin"}). Install matching compiler and resources.`);
114
+ }
115
+ }
116
+ }
117
+ const hash = createHash("sha256").update(JSON.stringify({ resources: resolve(resources), react, bindings, rustcFlags }));
118
+ for (const path of compilerInputs) hash.update(await readFile(path));
119
+ for (const path of metadataInputs) hash.update(await readFile(path));
120
+ const key = hash.digest("hex");
121
+ const flags = [];
122
+ if (bindings.includes("react")) {
123
+ const metadata = join(cacheDir, "react", react ?? "latest", key);
124
+ const stamp = join(metadata, "complete");
125
+ if (!existsSync(stamp) || ["libreact.rmeta", "libwebapi.rmeta", "libjs.rmeta"].some(file => !existsSync(join(metadata, file)))) {
126
+ await mkdir(metadata, { recursive: true });
127
+ // The binding crates are this compiler's to compile (ADR 0112).
128
+ await run(join(repo, "react/build.sh"), ["-o", join(metadata, "libreact.rmeta"), ...(react ? ["--react", react] : [])], repo, { RUST_JS_COMPILER: compilerPath });
129
+ await writeFile(stamp, key);
130
+ }
131
+ // React's crates, each a program's to name (ADR 0102): `use js::spawn`.
132
+ for (const name of ["react", "webapi", "js"]) flags.push("--extern", `${name}=${join(metadata, `lib${name}.rmeta`)}`);
133
+ flags.push("-L", metadata);
134
+ }
135
+ if (bindings.includes("serde")) {
136
+ const toolchain = readFileSync(join(repo, "rust-toolchain.toml"), "utf8").match(/^channel\s*=\s*"([^"]+)"/m)?.[1];
137
+ if (!toolchain) throw new Error("Binding resources must declare a pinned Rust toolchain");
138
+ // Cargo's structured output handles hashed filenames and paths with spaces.
139
+ // Run Cargo even on reuse: it checks that every dependency is still fresh.
140
+ // For rust-js's target (ADR 0090); serde_derive, a procedural macro, runs on the host.
141
+ const output = await run("cargo", [`+${toolchain}`, "build", "--locked", "--message-format=json",
142
+ "--target", "wasm32-unknown-unknown",
143
+ "--manifest-path", join(repo, "serde/Cargo.toml"), "--target-dir", join(cacheDir, "serde", key)], repo);
144
+ const artifacts = output.split("\n").filter(Boolean).map(line => JSON.parse(line))
145
+ .filter(message => message.reason === "compiler-artifact");
146
+ // Where the target's libraries are, and where the host's macro is.
147
+ const directories = new Set(artifacts
148
+ .filter(message => message.target.kind.some(kind => kind === "lib" || kind === "rlib" || kind === "proc-macro"))
149
+ .flatMap(message => message.filenames.map(file => dirname(file))));
150
+ for (const name of ["serde", "serde_json"]) {
151
+ const artifact = artifacts.find(message => message.target.name === name && message.target.kind.includes("lib"));
152
+ const file = artifact?.filenames.find(file => file.endsWith(".rmeta"))
153
+ ?? artifact?.filenames.find(file => file.endsWith(".rlib"));
154
+ if (!file || !existsSync(file)) throw new Error(`Cargo produced no ${name} metadata`);
155
+ flags.push("--extern", `${name}=${file}`);
156
+ }
157
+ // Sorted: Cargo reports what it builds as each finishes, and one build's
158
+ // flags must be the next's.
159
+ for (const directory of [...directories].sort()) flags.push("-L", `dependency=${directory}`);
160
+ }
161
+ return { flags, react };
162
+ }
163
+
164
+ return {
165
+ /** A Cargo workspace's check (ADR 0101), with the pinned toolchain, this
166
+ * compiler, and the React the project has installed. */
167
+ async checkCargo(options) {
168
+ checkRuntime(root, (await compilerIdentity()).version);
169
+ return checkCargo({ ...options, toolchain: pinned(), compiler: rustJs, react: installedReact(root) ?? undefined });
170
+ },
171
+ /** The workspace of a Cargo manifest, and its target directory. */
172
+ cargoWorkspace(options) {
173
+ return cargoWorkspace({ ...options, toolchain: pinned() });
174
+ },
175
+ watchFiles: [...metadataInputs, ...(bindings.length ? [join(repo, "package.json")] : []), ...compilerInputs, ...Object.values(externs)],
176
+ prepare,
177
+ /** `save` runs the crate's checks a save runs, not those only for a
178
+ * build (ADR 0117); `warn` is given what rust-js warns of as it succeeds. */
179
+ async compile({ crate, output, manifest, save = false, warn = undefined }) {
180
+ const { flags, react } = await prepare();
181
+ try {
182
+ await run(compilerCommand, [...compilerArgs, crate, "-o", output, "--manifest", manifest, "--hooks", save ? "save" : "build",
183
+ "--", ...flags,
184
+ ...Object.entries(externs).flatMap(([name, file]) => ["--extern", `${name}=${file}`, "-L", dirname(file)]), ...rustcFlags], root, {}, warn);
185
+ } catch (error) {
186
+ if (react && error.message.includes("configured out")) {
187
+ error.message += `\nnote: this project has React ${react}; an item gated \`react = "X.Y"\` needs React X.Y or later\n`;
188
+ }
189
+ throw error;
190
+ }
191
+ },
192
+ };
193
+ }
package/cargo.js ADDED
@@ -0,0 +1,249 @@
1
+ // Cargo's builds of rust-js crates (ADR 0101), and the experimental
2
+ // local-library planning before them (ADR 0085). Cargo owns resolution; this
3
+ // adapter does not infer dependencies from source files or run build scripts.
4
+ import { execFile } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join, relative, resolve, sep } from "node:path";
9
+ import { promisify } from "node:util";
10
+
11
+ import { fingerprint } from "./publish.js";
12
+
13
+ const execute = promisify(execFile);
14
+
15
+ /** Cargo's rustc, for every crate it compiles: rust-js's `--rustc`, rustc
16
+ * with rust-js's tool known (ADR 0112). The binding crates need it, which
17
+ * the workspace's wrapper runs for only when they're its members, and a
18
+ * plain rustc doesn't know the tool. The same pinned rustc, for the rest. */
19
+ function rustcShim(compiler) {
20
+ const dir = join(tmpdir(), "rust-js", createHash("sha256").update(compiler).digest("hex").slice(0, 16));
21
+ const shim = join(dir, "rustc");
22
+ mkdirSync(dir, { recursive: true });
23
+ writeFileSync(shim, `#!/bin/sh\nexec ${JSON.stringify(compiler)} --rustc "$@"\n`, { mode: 0o755 });
24
+ return shim;
25
+ }
26
+
27
+ /** One exact toolchain: a release, `1.98.1`, or a dated nightly (ADR 0109). */
28
+ const exactToolchain = toolchain => /^(\d+\.\d+\.\d+|nightly-\d{4}-\d{2}-\d{2})$/.test(toolchain ?? "");
29
+
30
+ /** @param {{ manifestPath: string, toolchain: string, target: string, packageName?: string, features?: string[], noDefaultFeatures?: boolean }} options */
31
+ export async function planCargoLibraries({ manifestPath, toolchain, target, packageName, features = [], noDefaultFeatures = false }) {
32
+ if (!exactToolchain(toolchain)) throw new Error("Cargo planning requires an exact toolchain pin");
33
+ if (typeof target !== "string" || !target) throw new Error("Cargo planning requires an explicit target triple");
34
+ const manifest = resolve(manifestPath);
35
+ const args = [`+${toolchain}`, "metadata", "--format-version=1", "--frozen", "--manifest-path", manifest, "--filter-platform", target];
36
+ if (features.length) args.push("--features", features.join(","));
37
+ if (noDefaultFeatures) args.push("--no-default-features");
38
+ const { stdout } = await execute("cargo", args, { cwd: dirname(manifest), maxBuffer: 64 * 1024 * 1024 });
39
+ const metadata = JSON.parse(stdout);
40
+ const packages = new Map(metadata.packages.map(pkg => [pkg.id, pkg]));
41
+ const nodes = new Map(metadata.resolve.nodes.map(node => [node.id, node]));
42
+ const selected = packageName
43
+ ? metadata.workspace_members.filter(id => packages.get(id).name === packageName)
44
+ : metadata.resolve.root ? [metadata.resolve.root] : [];
45
+ if (selected.length !== 1) throw new Error("Select one Cargo workspace library with packageName");
46
+ const ordered = [];
47
+ const visited = new Set();
48
+ const visiting = new Set();
49
+ const pending = [{ id: selected[0] }];
50
+ while (pending.length) {
51
+ const { id, library: completed } = pending.pop();
52
+ if (completed) {
53
+ visiting.delete(id);
54
+ visited.add(id);
55
+ ordered.push(completed);
56
+ continue;
57
+ }
58
+ if (visited.has(id)) continue;
59
+ if (visiting.has(id)) throw new Error(`Cyclic Cargo library dependency: ${id}`);
60
+ const pkg = packages.get(id), node = nodes.get(id);
61
+ if (!pkg || !node) throw new Error(`Cargo metadata is missing package ${id}`);
62
+ if (pkg.source !== null) throw new Error(`Cargo JS planning currently supports only local path libraries: ${pkg.name}`);
63
+ if (pkg.targets.some(t => t.kind.includes("custom-build"))) throw new Error(`Cargo JS planning does not support build scripts: ${pkg.name}`);
64
+ if (pkg.targets.some(t => t.kind.includes("proc-macro"))) throw new Error(`Cargo JS planning does not support procedural macros: ${pkg.name}`);
65
+ const libraries = pkg.targets.filter(t => t.kind.includes("lib"));
66
+ if (libraries.length !== 1) throw new Error(`Cargo JS planning requires one ordinary library target: ${pkg.name}`);
67
+ const library = libraries[0];
68
+ const dependencies = node.deps.filter(dep => dep.dep_kinds.some(kind => kind.kind === null))
69
+ .map(dep => ({ name: dep.name, packageId: dep.pkg })).sort((a, b) => a.name.localeCompare(b.name));
70
+ visiting.add(id);
71
+ pending.push({ id, library: {
72
+ id, name: pkg.name, crateName: library.name, edition: library.edition,
73
+ manifestPath: pkg.manifest_path, sourcePath: library.src_path,
74
+ features: [...node.features].sort(), dependencies,
75
+ } });
76
+ for (const dependency of [...dependencies].reverse()) pending.push({ id: dependency.packageId });
77
+ }
78
+ return { root: selected[0], toolchain, target, workspaceRoot: metadata.workspace_root, libraries: ordered };
79
+ }
80
+
81
+ /**
82
+ * `cargo check` of a workspace for rust-js's target, with rust-js as Cargo's
83
+ * workspace wrapper (ADR 0101), and where each crate rust-js compiled has its
84
+ * JS: beside the metadata Cargo keeps for that build of it, so a feature set
85
+ * built before is the JS it was. Cargo reports each crate it built or found
86
+ * fresh; the `.rust-js` beside its metadata says where its manifest is. `js` is
87
+ * the selected package's, or the manifest's own package's. `react` is the
88
+ * React release the react crate is checked for (ADR 0043), or its latest.
89
+ * With `inSource`, each module's JS is written beside its Rust too, as a
90
+ * project commits it (`writeInSource`), and `js` and `crates` are those.
91
+ * `files` is every module's JS, where it's served from.
92
+ * @param {{ manifestPath: string, toolchain: string, compiler: string, packageName?: string, features?: string[], noDefaultFeatures?: boolean, offline?: boolean, react?: string, inSource?: boolean }} options
93
+ * @returns {Promise<{ js: string, crates: Map<string, { js: string, manifest: string }>, files: string[] }>}
94
+ */
95
+ export async function checkCargo({ manifestPath, toolchain, compiler, packageName, features = [], noDefaultFeatures = false, offline = false, react, inSource = false }) {
96
+ if (!exactToolchain(toolchain)) throw new Error("Cargo builds require an exact toolchain pin");
97
+ const manifest = resolve(manifestPath);
98
+ const args = [`+${toolchain}`, "check", "--message-format=json", "--target", "wasm32-unknown-unknown", "--manifest-path", manifest];
99
+ if (packageName) args.push("-p", packageName);
100
+ if (features.length) args.push("--features", features.join(","));
101
+ if (noDefaultFeatures) args.push("--no-default-features");
102
+ if (offline) args.push("--offline");
103
+ const env = { ...process.env, RUSTC_WORKSPACE_WRAPPER: resolve(compiler), RUSTC: rustcShim(resolve(compiler)) };
104
+ if (react) env.RUST_JS_REACT = react;
105
+ else delete env.RUST_JS_REACT;
106
+ const { stdout } = await execute("cargo", args, { cwd: dirname(manifest), env, maxBuffer: 64 * 1024 * 1024 }).catch((error) => {
107
+ const messages = String(error.stdout ?? "").split("\n").filter(Boolean).map(line => JSON.parse(line));
108
+ const rendered = messages.filter(m => m.reason === "compiler-message").map(m => m.message.rendered).join("");
109
+ throw new Error(`cargo check failed:\n${rendered}${error.stderr ?? ""}`);
110
+ });
111
+ const crates = new Map();
112
+ let js;
113
+ for (const line of stdout.split("\n").filter(Boolean)) {
114
+ const message = JSON.parse(line);
115
+ if (message.reason !== "compiler-artifact") continue;
116
+ const metadata = message.filenames.find(file => file.endsWith(".rmeta"));
117
+ const marker = metadata?.replace(/\.rmeta$/, ".rust-js");
118
+ if (!marker || !existsSync(marker)) continue;
119
+ // Its own manifest, then those of the libraries it was compiled with.
120
+ const library = readFileSync(marker, "utf8").split("\n")[0];
121
+ const built = existsSync(library) ? JSON.parse(readFileSync(library, "utf8")) : { artifacts: [{ file: library }] };
122
+ // What it published, as it published it, for a build Cargo has as done
123
+ // too: Cargo checks its own outputs, not rust-js's.
124
+ const changed = built.artifacts.find(({ file, hash }) => !existsSync(file) || fingerprint(readFileSync(file)) !== hash);
125
+ if (changed) {
126
+ const name = packageNameOf(message.package_id);
127
+ throw new Error(`${changed.file} isn't what rust-js wrote for Cargo's build of ${name}, which has it as done: `
128
+ + `\`cargo clean -p ${name} --target wasm32-unknown-unknown\` to build it again`);
129
+ }
130
+ // The crate root's module: `lib.jsx` when it has JSX (ADR 0075).
131
+ const entry = { js: built.modules.find(module => module.module.length === 0).file, manifest: library };
132
+ crates.set(message.target.name, entry);
133
+ if (packageName ? packageNameOf(message.package_id) === packageName : message.manifest_path === manifest) js = entry.js;
134
+ }
135
+ if (!js) throw new Error(`rust-js compiled no library of ${packageName ?? manifest}`);
136
+ const manifests = [...crates.values()].map(({ manifest }) => JSON.parse(readFileSync(manifest, "utf8")));
137
+ if (!inSource) return { js, crates, files: manifests.flatMap(({ modules }) => modules.map((module) => module.file)) };
138
+ const moved = writeInSource(manifests);
139
+ for (const entry of crates.values()) entry.js = moved.get(entry.js);
140
+ return { js: moved.get(js), crates, files: [...moved.values()] };
141
+ }
142
+
143
+ /** What rust-js writes first in a module's JS, and nowhere else. */
144
+ const GENERATED = "// Generated by rust-js from ";
145
+
146
+ /**
147
+ * Each module's JS, and its map, beside its Rust, as ReScript writes it in
148
+ * source and a project commits it (ADR 0041): `src/api.rs`'s is
149
+ * `src/api.js`, or `.jsx`. A module that isn't a file of its own, an
150
+ * inline `mod inner { .. }`, is where its file would be: `src/inner.js`
151
+ * of `src/lib.rs`'s, `src/api/inner.js` of `src/api.rs`'s, and it's an
152
+ * error if that's another module's, before anything's written. An import of another crate's module is of its
153
+ * copy, where it is; any other stays as written. A file is written only if
154
+ * it changed, and a module's JS rust-js wrote before, a `.js` or `.jsx`
155
+ * beside a crate's Rust that's no module's now, goes. Where each JS went.
156
+ * @param {{ library: { name: string }, modules: { file: string, map?: string, source: string, module: string[] }[] }[]} manifests
157
+ * @returns {Map<string, string>}
158
+ */
159
+ export function writeInSource(manifests) {
160
+ const moved = new Map();
161
+ // `mod root { .. }` of a `[lib] path = "src/root.rs"` is where the root
162
+ // is, and two crates' inline `mod helper` of `sources/alpha.rs` and
163
+ // `sources/beta.rs` are in one place: none is written over another, as
164
+ // rust-js's own output refuses `mod lib` of `lib.rs`.
165
+ const byDestination = new Map();
166
+ const named = ({ crate, module }, other) => {
167
+ const own = module.length === 0 ? "the crate root" : `module \`${module.join("::")}\``;
168
+ if (crate === other.crate) return own;
169
+ return `crate \`${crate}\`'s ${module.length === 0 ? "root" : own}`;
170
+ };
171
+ for (const { library, modules } of manifests) {
172
+ const crateRoot = modules.find((m) => m.module.length === 0).source;
173
+ for (const { file, source, module } of modules) {
174
+ const extension = file.endsWith(".jsx") ? ".jsx" : ".js";
175
+ // The module whose file `source` is, by where it is: `src/api.rs`
176
+ // and `src/api/mod.rs` are `api`'s.
177
+ const own = source === crateRoot ? [] : relative(dirname(crateRoot), source).replace(/\.rs$/, "").split(sep);
178
+ if (own.at(-1) === "mod") own.pop();
179
+ const written = own.length === module.length && own.every((name, i) => name === module[i]);
180
+ moved.set(file, written ? source.replace(/\.rs$/, extension) : join(dirname(crateRoot), ...module) + extension);
181
+ }
182
+ for (const { file, module } of [...modules].sort((a, b) => a.module.length - b.module.length)) {
183
+ const to = moved.get(file);
184
+ const here = { crate: library.name, module };
185
+ const other = byDestination.get(to);
186
+ if (other) throw new Error(`rust-js can't write the JS in source: ${named(other, here)} and ${named(here, other)} would both be ${to}; rename the module`);
187
+ byDestination.set(to, here);
188
+ }
189
+ }
190
+ const write = (path, text) => {
191
+ if (existsSync(path) && readFileSync(path, "utf8") === text) return;
192
+ mkdirSync(dirname(path), { recursive: true });
193
+ writeFileSync(path, text);
194
+ };
195
+ for (const { modules } of manifests) {
196
+ for (const { file, map } of modules) {
197
+ const to = moved.get(file);
198
+ const text = readFileSync(file, "utf8").replace(/((?:from|import)\s*\(?\s*)"(\.{1,2}\/[^"]+)"/g, (whole, lead, spec) => {
199
+ const target = moved.get(resolve(dirname(file), spec));
200
+ if (!target) return whole;
201
+ const path = relative(dirname(to), target);
202
+ return `${lead}"${path.startsWith(".") ? path : `./${path}`}"`;
203
+ });
204
+ write(to, text);
205
+ if (map && existsSync(map)) {
206
+ const json = JSON.parse(readFileSync(map, "utf8"));
207
+ json.sources = (json.sources ?? []).map((path) => relative(dirname(to), resolve(dirname(map), path)));
208
+ write(`${to}.map`, JSON.stringify(json));
209
+ }
210
+ }
211
+ }
212
+ // What rust-js wrote before beside a crate's Rust, and isn't a module's now.
213
+ const written = new Set(moved.values());
214
+ for (const { modules } of manifests) {
215
+ const root = dirname(modules.find((module) => module.module.length === 0).source);
216
+ for (const name of readdirSync(root, { recursive: true }).map(String)) {
217
+ const path = join(root, name);
218
+ if (!/\.jsx?$/.test(path) || written.has(path)) continue;
219
+ if (!readFileSync(path, "utf8").startsWith(GENERATED)) continue;
220
+ rmSync(path);
221
+ rmSync(`${path}.map`, { force: true });
222
+ }
223
+ }
224
+ return moved;
225
+ }
226
+
227
+ /**
228
+ * The workspace a Cargo manifest is of, a member's or the root's, and where
229
+ * Cargo builds it: what Cargo reads, and what it writes (ADR 0101), and where each of its
230
+ * packages is.
231
+ * @param {{ manifestPath: string, toolchain: string, offline?: boolean }} options
232
+ * @returns {Promise<{ root: string, target: string, packages: Map<string, string> }>}
233
+ */
234
+ export async function cargoWorkspace({ manifestPath, toolchain, offline = false }) {
235
+ const manifest = resolve(manifestPath);
236
+ const args = [`+${toolchain}`, "metadata", "--no-deps", "--format-version=1", "--manifest-path", manifest, ...(offline ? ["--offline"] : [])];
237
+ const { stdout } = await execute("cargo", args, { cwd: dirname(manifest), maxBuffer: 64 * 1024 * 1024 }).catch((error) => {
238
+ throw new Error(`cargo metadata failed:\n${error.stderr ?? error.message}`);
239
+ });
240
+ const metadata = JSON.parse(stdout);
241
+ const packages = new Map(metadata.packages.map((pkg) => [pkg.name, dirname(pkg.manifest_path)]));
242
+ return { root: metadata.workspace_root, target: metadata.target_directory, packages };
243
+ }
244
+
245
+ /** The package's name in a Cargo package ID: `path+file:///dir#name@1.0`, or `path+file:///dir/name#1.0`. */
246
+ function packageNameOf(id) {
247
+ const [url, fragment = ""] = id.split("#");
248
+ return fragment.includes("@") ? fragment.slice(0, fragment.lastIndexOf("@")) : url.slice(url.lastIndexOf("/") + 1);
249
+ }
package/manifest.js ADDED
@@ -0,0 +1,79 @@
1
+ // The compiler's versioned build result. Shared by native and WASI hosts.
2
+ function validCompiler(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value)
4
+ && typeof value.version === "string" && value.version.length > 0
5
+ && typeof value.toolchain === "string" && value.toolchain.length > 0
6
+ && value.abi === 1;
7
+ }
8
+
9
+ export function parseCompilerIdentity(text) {
10
+ const value = JSON.parse(text);
11
+ if (!validCompiler(value)) throw new Error("Unsupported rust-js compiler identity or ABI; expected ABI 1");
12
+ return value;
13
+ }
14
+
15
+ export function parseManifest(text) {
16
+ let result;
17
+ try { result = JSON.parse(text); }
18
+ catch (error) { throw new Error(`Invalid rust-js manifest JSON: ${error.message}`); }
19
+ const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
20
+ const strings = value => Array.isArray(value) && value.every(item => typeof item === "string");
21
+ const path = value => typeof value === "string" && (/^\//.test(value) || /^[A-Za-z]:[\\/]/.test(value));
22
+ const paths = value => strings(value) && value.every(path);
23
+ const fingerprint = value => object(value) && path(value.file)
24
+ && typeof value.hash === "string" && /^[0-9a-f]{16}$/.test(value.hash);
25
+ if (!object(result) || result.version !== 1) {
26
+ throw new Error(`Unsupported rust-js manifest version ${result?.version}; expected 1`);
27
+ }
28
+ if (result.compiler !== undefined && !validCompiler(result.compiler)) {
29
+ throw new Error("Unsupported rust-js compiler identity or ABI; expected ABI 1");
30
+ }
31
+ if (result.library !== undefined) {
32
+ const library = result.library;
33
+ // ADR 0100: each item another crate can reach, by rustc's key for it.
34
+ if (!object(library) || library.version !== 2 || typeof library.name !== "string" || !library.name
35
+ || typeof library.crate_hash !== "string" || !library.crate_hash
36
+ || !Array.isArray(library.inputs) || !library.inputs.every(fingerprint)
37
+ || !strings(library.impls) || !strings(library.libraries)
38
+ || !Array.isArray(library.items) || !library.items.every(item => object(item)
39
+ && typeof item.key === "string" && typeof item.rust_path === "string" && strings(item.module)
40
+ && typeof item.export === "string" && (item.member === null || typeof item.member === "string")
41
+ && Array.isArray(item.drops) && item.drops.every(Number.isInteger))) {
42
+ throw new Error("Invalid rust-js library contract; expected library ABI 2");
43
+ }
44
+ }
45
+ if (!path(result.input) || !path(result.output) || !paths(result.sources)
46
+ || !Array.isArray(result.modules) || !result.modules.every(module => object(module)
47
+ && strings(module.module) && path(module.file) && path(module.map)
48
+ && (module.source === null || path(module.source)) && paths(module.imports))
49
+ || !Array.isArray(result.artifacts) || !result.artifacts.every(artifact => object(artifact)
50
+ && path(artifact.file) && typeof artifact.hash === "string" && /^[0-9a-f]{16}$/.test(artifact.hash))) {
51
+ throw new Error("Invalid rust-js manifest: expected absolute paths, modules and fingerprinted artifacts");
52
+ }
53
+ const artifacts = new Set(result.artifacts.map(artifact => artifact.file));
54
+ const modules = new Set(result.modules.map(module => module.file));
55
+ if (artifacts.size !== result.artifacts.length
56
+ || modules.size !== result.modules.length
57
+ || result.modules.some(module => !artifacts.has(module.file) || !artifacts.has(module.map)
58
+ || module.imports.some(file => !modules.has(file)))) {
59
+ throw new Error("Invalid rust-js manifest: inconsistent module artifacts or imports");
60
+ }
61
+ return result;
62
+ }
63
+
64
+ // Remap only paths. Names, fingerprints and future unrelated fields stay intact.
65
+ export function mapManifestPaths(manifest, map) {
66
+ return {
67
+ ...manifest,
68
+ input: map(manifest.input), output: map(manifest.output),
69
+ sources: manifest.sources.map(map),
70
+ modules: manifest.modules.map(module => ({
71
+ ...module, file: map(module.file), map: map(module.map),
72
+ source: module.source === null ? null : map(module.source), imports: module.imports.map(map),
73
+ })),
74
+ artifacts: manifest.artifacts.map(artifact => ({ ...artifact, file: map(artifact.file) })),
75
+ ...(manifest.library === undefined ? {} : { library: {
76
+ ...manifest.library, inputs: manifest.library.inputs.map(input => ({ ...input, file: map(input.file) })),
77
+ } }),
78
+ };
79
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@rust-js/build",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Build preparation, manifests, and publication for rust-js hosts.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rust-js-lang/rust-js.git",
10
+ "directory": "tooling"
11
+ },
12
+ "bin": {
13
+ "rust-js-patch": "./patch.js"
14
+ },
15
+ "exports": {
16
+ "./build": "./build.js",
17
+ "./manifest": "./manifest.js",
18
+ "./publish": "./publish.js",
19
+ "./cargo": "./cargo.js",
20
+ "./patch": "./patch.js"
21
+ },
22
+ "files": [
23
+ "build.js",
24
+ "resources.js",
25
+ "manifest.js",
26
+ "publish.js",
27
+ "README.md",
28
+ "cargo.js",
29
+ "patch.js"
30
+ ]
31
+ }
package/patch.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // The patch an app's Cargo needs to find the crates its npm packages have:
3
+ // each package with `"rust-js": { "crate": ".." }`, found as Node finds a
4
+ // package, from the app and from each such package, where its package
5
+ // manager put it, all in one `node_modules`, npm's and bun's way, or in a
6
+ // store, and linked, pnpm's. Cargo, and an editor's rust-analyzer, are told
7
+ // where each is by `[patch.crates-io]`, in the app's `.cargo/config.toml`,
8
+ // between rust-js's lines, which an install's `postinstall` writes again.
9
+ //
10
+ // rust-js-patch [app-dir]
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+
15
+ const BEGIN = "# rust-js: begin";
16
+ const END = "# rust-js: end";
17
+
18
+ /**
19
+ * Write the patch of the app at `app`, and return the crates it names, each
20
+ * with where it is, from the app. Throws, writing nothing, if a crate is
21
+ * installed twice, which would be two of each of its types, or a package's
22
+ * crate is another version than it is.
23
+ * @param {string} app
24
+ * @returns {{ crate: string, path: string }[]}
25
+ */
26
+ export function writePatch(app) {
27
+ const root = realpathSync(app);
28
+ const crates = installedCrates(root);
29
+ const lines = [...crates].sort(([x], [y]) => x.localeCompare(y)).map(([crate, path]) => `${crate} = { path = ${JSON.stringify(path)} }`);
30
+ const config = join(root, ".cargo", "config.toml");
31
+ const rest = existsSync(config) ? withoutBlock(readFileSync(config, "utf8")) : "";
32
+ if (/^\s*\[\s*patch\s*\.\s*("crates-io"|crates-io)\s*\]/m.test(rest)) {
33
+ throw new Error(`.cargo/config.toml has a [patch.crates-io] of its own, and TOML has one of a table, which rust-js writes: move its entries to Cargo.toml's [patch.crates-io], which Cargo takes with it`);
34
+ }
35
+ if (lines.length === 0) {
36
+ if (rest.trim()) writeFileSync(config, rest);
37
+ else rmSync(config, { force: true });
38
+ return [];
39
+ }
40
+ const block = [
41
+ BEGIN,
42
+ "# Where Cargo finds each crate an npm package has: written by rust-js from",
43
+ "# what's installed, as each install ends. Edits here are written over.",
44
+ "[patch.crates-io]",
45
+ ...lines,
46
+ END,
47
+ "",
48
+ ].join("\n");
49
+ mkdirSync(dirname(config), { recursive: true });
50
+ writeFileSync(config, rest.trim() ? `${rest.trimEnd()}\n\n${block}` : block);
51
+ return [...crates].map(([crate, path]) => ({ crate, path }));
52
+ }
53
+
54
+ /** `text` without rust-js's block, and the blank line rust-js put before it. */
55
+ function withoutBlock(text) {
56
+ const start = text.indexOf(BEGIN);
57
+ if (start < 0) return text;
58
+ const end = text.indexOf(END, start);
59
+ const after = end < 0 ? text.length : text.indexOf("\n", end) < 0 ? text.length : text.indexOf("\n", end) + 1;
60
+ return text.slice(0, start).replace(/\n\n$/, "\n") + text.slice(after);
61
+ }
62
+
63
+ /**
64
+ * Each crate the app's packages have, by its name, and where it is, from
65
+ * the app: one, or an error naming each place and who asked for it.
66
+ * @param {string} root the app's directory, its real path
67
+ * @returns {Map<string, string>}
68
+ */
69
+ function installedCrates(root) {
70
+ /** @type {Map<string, Map<string, { version: string, askers: Set<string> }>>} */
71
+ const found = new Map();
72
+ const seen = new Set();
73
+ /** @type {{ from: string, name: string, asker: string }[]} */
74
+ const queue = names(readJson(join(root, "package.json"))).map(name => ({ from: root, name, asker: "the app" }));
75
+ while (queue.length) {
76
+ const { from, name, asker } = /** @type {{ from: string, name: string, asker: string }} */ (queue.shift());
77
+ const dir = lookup(from, name);
78
+ if (!dir) continue;
79
+ const pkg = readJson(join(dir, "package.json"));
80
+ const crate = pkg["rust-js"]?.crate;
81
+ if (typeof crate !== "string") continue;
82
+ const at = found.get(crate) ?? new Map();
83
+ found.set(crate, at);
84
+ const place = at.get(dir) ?? { version: pkg.version, askers: new Set() };
85
+ place.askers.add(asker);
86
+ at.set(dir, place);
87
+ if (seen.has(dir)) continue;
88
+ seen.add(dir);
89
+ checkCrate(dir, pkg, crate);
90
+ for (const next of names(pkg, true)) queue.push({ from: dir, name: next, asker: pkg.name });
91
+ }
92
+ const crates = new Map();
93
+ for (const [crate, at] of found) {
94
+ const places = [...at].map(([dir, { version, askers }]) => `${version} at ${shown(root, dir)}, for ${[...askers].join(", ")}`);
95
+ if (at.size > 1) {
96
+ throw new Error(`${crate} is installed twice, which would be two of each of its types: ${places.join("; ")}. Install one version, which each package asks for`);
97
+ }
98
+ crates.set(crate, shown(root, [...at.keys()][0]));
99
+ }
100
+ return crates;
101
+ }
102
+
103
+ /** The packages `pkg` depends on: its own, and, for a package, its peers. */
104
+ function names(pkg, peers = false) {
105
+ const fields = ["dependencies", "devDependencies", "optionalDependencies", ...(peers ? ["peerDependencies"] : [])];
106
+ return [...new Set(fields.flatMap(field => Object.keys(pkg[field] ?? {})))];
107
+ }
108
+
109
+ /** Where Node finds package `name` from `from`, as its real path. */
110
+ function lookup(from, name) {
111
+ for (let dir = from; ; dir = dirname(dir)) {
112
+ if (basename(dir) !== "node_modules") {
113
+ const candidate = join(dir, "node_modules", name, "package.json");
114
+ if (existsSync(candidate)) return realpathSync(dirname(candidate));
115
+ }
116
+ if (dirname(dir) === dir) return undefined;
117
+ }
118
+ }
119
+
120
+ /** A package's crate is the one it names, at the version it is. */
121
+ function checkCrate(dir, pkg, crate) {
122
+ const manifest = join(dir, "Cargo.toml");
123
+ if (!existsSync(manifest)) throw new Error(`${pkg.name} names a crate, ${crate}, and has no Cargo.toml`);
124
+ const table = /^\[package\][^\S\n]*\n([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(readFileSync(manifest, "utf8"))?.[1] ?? "";
125
+ const field = key => new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m").exec(table)?.[1];
126
+ if (field("name") !== crate) throw new Error(`${pkg.name} names its crate ${crate}, and its Cargo.toml ${field("name")}`);
127
+ if (field("version") !== pkg.version) {
128
+ throw new Error(`${pkg.name} is ${pkg.version}, and its crate, ${crate}, ${field("version")}: they're to be one version`);
129
+ }
130
+ }
131
+
132
+ /** `dir`, as the app's config names it: from the app, with `/`. */
133
+ function shown(root, dir) {
134
+ return relative(root, dir).split(sep).join("/");
135
+ }
136
+
137
+ function readJson(path) {
138
+ return JSON.parse(readFileSync(path, "utf8"));
139
+ }
140
+
141
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
142
+ const app = resolve(process.argv[2] ?? ".");
143
+ try {
144
+ const crates = writePatch(app);
145
+ if (crates.length) console.log(`rust-js: Cargo finds ${crates.map(c => c.crate).join(", ")} in node_modules`);
146
+ } catch (error) {
147
+ console.error(`rust-js: ${error instanceof Error ? error.message : error}`);
148
+ process.exitCode = 1;
149
+ }
150
+ }
package/publish.js ADDED
@@ -0,0 +1,95 @@
1
+ // Host publication for a successful WASI build. The manifest is committed last.
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { parseManifest } from "./manifest.js";
6
+
7
+ export function fingerprint(bytes) {
8
+ let hash = 0xcbf29ce484222325n;
9
+ for (const byte of bytes) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n);
10
+ return hash.toString(16).padStart(16, "0");
11
+ }
12
+
13
+ // A path with its symlinks resolved, as the native compiler's `absolute`
14
+ // resolves them, so aliases of one file are one path; one that doesn't
15
+ // exist yet, through the part of it that does.
16
+ function real(path) {
17
+ if (existsSync(path)) return realpathSync(path);
18
+ const parent = dirname(resolve(path));
19
+ return parent === resolve(path) ? parent : join(real(parent), basename(path));
20
+ }
21
+
22
+ // What an older build wrote that this one may remove, by the native
23
+ // compiler's rules (src/output.rs): a generated file, in the output's
24
+ // directory, that this build neither writes nor reads, still as it was
25
+ // written. Anything else, a person's file or a source, is left alone.
26
+ function staleArtifacts(previous, manifest, writes) {
27
+ const outputDir = real(dirname(manifest.output));
28
+ const kept = new Set([manifest.input, ...manifest.sources, ...writes.keys()].map(real));
29
+ return (previous?.artifacts ?? []).filter(({ file, hash }) => {
30
+ if (!existsSync(file)) return false;
31
+ const path = real(file);
32
+ return [".js", ".jsx", ".map"].includes(extname(path))
33
+ && path.startsWith(outputDir + sep)
34
+ && !kept.has(path)
35
+ && fingerprint(readFileSync(path)) === hash;
36
+ }).map(({ file }) => ({ file: real(file) }));
37
+ }
38
+
39
+ export function publishArtifacts(manifestPath, manifest, files) {
40
+ parseManifest(JSON.stringify(manifest));
41
+ for (const artifact of manifest.artifacts) {
42
+ const bytes = files.get(artifact.file);
43
+ if (!bytes || fingerprint(bytes) !== artifact.hash) throw new Error(`Missing or inconsistent artifact: ${artifact.file}`);
44
+ }
45
+ if (files.size !== manifest.artifacts.length) throw new Error("Unexpected artifacts outside manifest");
46
+ const previous = existsSync(manifestPath) ? parseManifest(readFileSync(manifestPath, "utf8")) : undefined;
47
+ if (previous && (previous.input !== manifest.input || previous.output !== manifest.output)) {
48
+ throw new Error("Manifest belongs to a different compilation");
49
+ }
50
+ const writes = new Map(files);
51
+ writes.set(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + "\n"));
52
+ const stale = staleArtifacts(previous, manifest, writes);
53
+ const id = randomUUID();
54
+ const staged = [];
55
+ const changed = [];
56
+ try {
57
+ // Stage every byte before replacing any output. Unchanged files keep mtimes.
58
+ for (const [path, data] of writes) {
59
+ if (existsSync(path) && readFileSync(path).equals(Buffer.from(data))) continue;
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ const stage = `${path}.${id}.stage`;
62
+ staged.push(stage);
63
+ writeFileSync(stage, data, { flag: "wx" });
64
+ changed.push({ path, stage, backup: `${path}.${id}.backup`, saved: false, installed: false });
65
+ }
66
+ // Stale files are backed up too, so an ordinary I/O failure can roll back.
67
+ const manifestWrite = changed.find(change => change.path === manifestPath);
68
+ const commit = [...changed.filter(change => change !== manifestWrite),
69
+ ...stale.map(({ file }) => ({ path: file, backup: `${file}.${id}.backup`, saved: false, installed: false })),
70
+ ...(manifestWrite ? [manifestWrite] : [])];
71
+ changed.splice(0, changed.length, ...commit);
72
+ for (const change of changed) {
73
+ if (existsSync(change.path)) {
74
+ renameSync(change.path, change.backup);
75
+ change.saved = true;
76
+ }
77
+ if (change.stage) {
78
+ renameSync(change.stage, change.path);
79
+ change.installed = true;
80
+ }
81
+ }
82
+ } catch (error) {
83
+ const recovery = [];
84
+ for (const change of [...changed].reverse()) {
85
+ try {
86
+ if (change.installed) rmSync(change.path);
87
+ if (change.saved) renameSync(change.backup, change.path);
88
+ } catch (failure) { recovery.push(`${change.path}: ${failure}; backup: ${change.backup}`); }
89
+ }
90
+ throw new Error(`${error}${recovery.length ? `\nRecovery failures:\n${recovery.join("\n")}` : ""}`);
91
+ } finally {
92
+ for (const stage of staged) rmSync(stage, { force: true });
93
+ }
94
+ for (const change of changed) if (change.saved) rmSync(change.backup);
95
+ }
package/resources.js ADDED
@@ -0,0 +1,22 @@
1
+ // Shared by cache invalidation and resource packaging: every prepared input
2
+ // must travel with the bundle and participate in the metadata cache key.
3
+ export const bindingInputs = {
4
+ react: [
5
+ "react/build.sh", "react/cfg.js", "react/versions.json",
6
+ "react/src/lib.rs", "react/src/event.rs", "react/src/dom.rs", "react/src/elements.rs",
7
+ "react/Cargo.toml", "react/build.rs",
8
+ "webapi/build.sh", "webapi/src/lib.rs", "webapi/Cargo.toml",
9
+ "builtins/build.sh", "builtins/src/lib.rs", "builtins/Cargo.toml",
10
+ ],
11
+ serde: ["serde/Cargo.toml", "serde/Cargo.lock", "serde/src/lib.rs"],
12
+ };
13
+
14
+ export function resourceInputs(bindings) {
15
+ return [...new Set([
16
+ ...(bindings.length ? ["rust-toolchain.toml"] : []),
17
+ ...bindings.flatMap(name => {
18
+ if (!Object.hasOwn(bindingInputs, name)) throw new Error(`Unsupported built-in binding: ${name}; supply explicit externs instead`);
19
+ return bindingInputs[name];
20
+ }),
21
+ ])];
22
+ }