@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 +307 -0
- package/build.js +193 -0
- package/cargo.js +249 -0
- package/manifest.js +79 -0
- package/package.json +31 -0
- package/patch.js +150 -0
- package/publish.js +95 -0
- package/resources.js +22 -0
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
|
+
}
|