@uniflowed/host 0.0.0-alpha.2 → 0.0.0-alpha.5
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/internal/node-hooks.js +69 -35
- package/package.json +4 -3
- package/transform.js +111 -0
package/internal/node-hooks.js
CHANGED
|
@@ -11,46 +11,36 @@
|
|
|
11
11
|
// left to Node.
|
|
12
12
|
//
|
|
13
13
|
// Transforms are cached on disk under `.uf/cache/transform/` keyed by a hash
|
|
14
|
-
// of the source
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// of the source *and* of the `uf` that compiled it, so a second run of the
|
|
15
|
+
// same file is a read rather than a round trip.
|
|
16
|
+
//
|
|
17
|
+
// Both halves are load-bearing. The key was the source alone at first, on the
|
|
18
|
+
// reasoning that a content-addressed cache has no invalidation to get wrong —
|
|
19
|
+
// which quietly assumed the compiler was a constant. It is not: edit
|
|
20
|
+
// `crates/uf_transform` or `crates/uf_stylex`, rebuild, run `uf test`, and
|
|
21
|
+
// every module whose *source* had not changed came back as the previous
|
|
22
|
+
// binary had compiled it. The suite then passed, or failed, for the previous
|
|
23
|
+
// build's reasons, and the only symptom was an answer that made no sense.
|
|
24
|
+
// `rm -rf .uf/cache/transform` was the cure, and finding that out cost a
|
|
25
|
+
// debugging session while `@uniflowed/stylex`'s preset was being written.
|
|
17
26
|
|
|
18
27
|
import { createHash } from "node:crypto";
|
|
19
|
-
import {
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
20
29
|
import path from "node:path";
|
|
21
30
|
import { fileURLToPath } from "node:url";
|
|
22
31
|
|
|
23
|
-
import { isFlowModule, transformFlow } from "../transform.js";
|
|
32
|
+
import { isFlowModule, sharedService, transformFlow, ufBinaryIdentity } from "../transform.js";
|
|
33
|
+
import { writeAtomically } from "../write-atomically.js";
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `uf test` runs one of these processes per core and they all import the same
|
|
29
|
-
* few modules at once, so two writers and a reader meet on the same cache
|
|
30
|
-
* entry constantly. `writeFileSync` is not atomic — a reader can observe a
|
|
31
|
-
* truncated file and report a module that "does not provide an export" — so
|
|
32
|
-
* the content goes to a private temporary name first and is then renamed,
|
|
33
|
-
* which is atomic within a filesystem.
|
|
36
|
+
* Bumped whenever *this file's* framing of the output changes, to retire old
|
|
37
|
+
* entries.
|
|
34
38
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
39
|
+
* Not the compiler's version, which is `ufBinaryIdentity` and which nobody
|
|
40
|
+
* has to remember. What is left for this to cover is what the loader adds
|
|
41
|
+
* around a transform — the appended source map, the module format it forces —
|
|
42
|
+
* and that is all it should ever be bumped for.
|
|
37
43
|
*/
|
|
38
|
-
function writeAtomically(target, contents) {
|
|
39
|
-
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
40
|
-
try {
|
|
41
|
-
mkdirSync(cacheDirectory, { recursive: true });
|
|
42
|
-
writeFileSync(temporary, contents);
|
|
43
|
-
renameSync(temporary, target);
|
|
44
|
-
} catch {
|
|
45
|
-
try {
|
|
46
|
-
unlinkSync(temporary);
|
|
47
|
-
} catch {
|
|
48
|
-
// Nothing to clean up.
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Bumped whenever the transform's output shape changes, to retire old entries. */
|
|
54
44
|
const CACHE_VERSION = "2";
|
|
55
45
|
|
|
56
46
|
let cacheDirectory = null;
|
|
@@ -82,15 +72,56 @@ export async function load(url, context, nextLoad) {
|
|
|
82
72
|
return { format: "module", source: code, shortCircuit: true };
|
|
83
73
|
}
|
|
84
74
|
|
|
85
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The file this module's compiled form belongs in under `identity`, or `null`
|
|
77
|
+
* when it must not be cached at all.
|
|
78
|
+
*
|
|
79
|
+
* `null` when there is no cache directory, and — the case worth spelling out —
|
|
80
|
+
* when the caller has no identity to give: nothing is read and nothing is
|
|
81
|
+
* written. Hashing the rest anyway would give every build of `uf` one key
|
|
82
|
+
* again, and writing under it would leave an entry for the next run to trust.
|
|
83
|
+
* A host that cannot name its compiler compiles everything, every time, which
|
|
84
|
+
* is slower and is never wrong.
|
|
85
|
+
*/
|
|
86
|
+
function cacheEntryFor(identity, source, filename) {
|
|
87
|
+
if (cacheDirectory == null || identity == null) return null;
|
|
86
88
|
const key = createHash("sha256")
|
|
87
89
|
.update(CACHE_VERSION)
|
|
88
90
|
.update("\0")
|
|
91
|
+
.update(identity)
|
|
92
|
+
.update("\0")
|
|
89
93
|
.update(filename)
|
|
90
94
|
.update("\0")
|
|
91
95
|
.update(source)
|
|
92
96
|
.digest("hex");
|
|
93
|
-
|
|
97
|
+
return path.join(cacheDirectory, `${key}.mjs`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The compiled form of one module, from disk if some build already produced
|
|
102
|
+
* it and from `uf` otherwise.
|
|
103
|
+
*
|
|
104
|
+
* The two keys are computed from two different identities on purpose.
|
|
105
|
+
*
|
|
106
|
+
* The **read** is keyed by the binary as it is *now*, stat'd per module rather
|
|
107
|
+
* than once when the hooks were installed. A rebuild between installing them
|
|
108
|
+
* and loading the first Flow module would otherwise serve the old build's
|
|
109
|
+
* output while the new one is what would run — the same staleness this key
|
|
110
|
+
* exists to remove, with a smaller window. A stat is a microsecond and a
|
|
111
|
+
* fully warm run still spawns nothing, which is the property that decided the
|
|
112
|
+
* key's shape in the first place.
|
|
113
|
+
*
|
|
114
|
+
* The **write** is keyed by the binary the compiler process is actually
|
|
115
|
+
* executing, which `sharedService` read before it spawned and which cannot
|
|
116
|
+
* change afterwards. Keying the write by the file's current state would file
|
|
117
|
+
* this build's output under the next build's name if the rebuild landed while
|
|
118
|
+
* the module was being compiled — the same lie pointing the other way.
|
|
119
|
+
*
|
|
120
|
+
* They are usually the same string. When they are not, a rebuild happened
|
|
121
|
+
* during this run, and each half is right about its own half.
|
|
122
|
+
*/
|
|
123
|
+
async function cachedTransform(source, filename) {
|
|
124
|
+
const entry = cacheEntryFor(ufBinaryIdentity(), source, filename);
|
|
94
125
|
|
|
95
126
|
if (entry) {
|
|
96
127
|
try {
|
|
@@ -106,8 +137,11 @@ async function cachedTransform(source, filename) {
|
|
|
106
137
|
? `${out.code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(out.map).toString("base64")}\n`
|
|
107
138
|
: out.code;
|
|
108
139
|
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
const written = cacheEntryFor(sharedService(root).identity, source, filename);
|
|
141
|
+
if (written) {
|
|
142
|
+
// Tolerant: a cache that cannot be written is a slower run, not a
|
|
143
|
+
// failed one — a read-only checkout still works.
|
|
144
|
+
writeAtomically(written, output, { tolerant: true });
|
|
111
145
|
}
|
|
112
146
|
return output;
|
|
113
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/host",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.5",
|
|
4
4
|
"description": "Running Flow on a Capability JS Host, with no bundler in the way.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
"directory": "packages/host"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
|
-
"./register": "./register.js",
|
|
15
14
|
"./bun-preload": "./bun-preload.js",
|
|
15
|
+
"./internal/node-hooks.js": "./internal/node-hooks.js",
|
|
16
|
+
"./register": "./register.js",
|
|
16
17
|
"./transform": "./transform.js",
|
|
17
|
-
"./
|
|
18
|
+
"./write-atomically": "./write-atomically.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"register.js",
|
package/transform.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// all produce the same module from the same source.
|
|
15
15
|
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
17
19
|
import { createInterface } from "node:readline";
|
|
18
20
|
|
|
19
21
|
/** File extensions uf treats as Flow source. */
|
|
@@ -60,6 +62,91 @@ export function ufBinary() {
|
|
|
60
62
|
return process.env.UF_BINARY ?? "uf";
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Which *build* of `uf` a host will transform through, or `null` when that
|
|
67
|
+
* cannot be established.
|
|
68
|
+
*
|
|
69
|
+
* `ufBinary()` names the compiler; this identifies it. Anything kept across
|
|
70
|
+
* runs needs the second, because the first does not change when the compiler
|
|
71
|
+
* does: `crates/uf_transform` is edited, `cargo build` writes a new binary
|
|
72
|
+
* over the old one, and every answer already on disk is now wrong while the
|
|
73
|
+
* name that produced them is unchanged. A version string is the same promise
|
|
74
|
+
* one step removed — every build between two releases shares one.
|
|
75
|
+
*
|
|
76
|
+
* So: the size and modification time of the file that will be executed. They
|
|
77
|
+
* move together on every rebuild, they are one `stat` away, and — this is the
|
|
78
|
+
* part that decided it — reading them does not require starting `uf`. A run
|
|
79
|
+
* that finds everything already compiled must not have to spawn the compiler
|
|
80
|
+
* to learn that it does not need it, which is what asking the running
|
|
81
|
+
* `uf transform` to introduce itself would have cost.
|
|
82
|
+
*
|
|
83
|
+
* The same test is applied to a path as to a bare name: a regular file with
|
|
84
|
+
* the execute bit. Size and mtime do not move when a binary loses that bit, so
|
|
85
|
+
* without the test a chmod produced the same identity as before, a warm cache
|
|
86
|
+
* went on serving, and a cold one failed to start `uf` — the answer depending
|
|
87
|
+
* on how warm the cache was, which is the class of bug this key exists to
|
|
88
|
+
* remove.
|
|
89
|
+
*
|
|
90
|
+
* `null` means the question could not be answered. It is not an invitation to
|
|
91
|
+
* hash the rest anyway: a key that leaves the compiler out is one key for
|
|
92
|
+
* every build of it, which is the whole defect.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} [command] the binary; `ufBinary()` by default
|
|
95
|
+
* @returns {string | null} an opaque identity, stable while that build is
|
|
96
|
+
*/
|
|
97
|
+
export function ufBinaryIdentity(command = ufBinary()) {
|
|
98
|
+
const binary = resolveExecutable(command);
|
|
99
|
+
if (binary == null) return null;
|
|
100
|
+
try {
|
|
101
|
+
const stats = statSync(binary);
|
|
102
|
+
if (!stats.isFile()) return null;
|
|
103
|
+
accessSync(binary, constants.X_OK);
|
|
104
|
+
return `${binary}\0${stats.size}\0${stats.mtimeMs}`;
|
|
105
|
+
} catch {
|
|
106
|
+
// Named a binary that is not there, or is not one. The caller gets `null`
|
|
107
|
+
// and stops trusting the cache, which is right: nothing can be compiled
|
|
108
|
+
// either.
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The file `spawn` will execute for `command`, or `null` when there is none.
|
|
115
|
+
*
|
|
116
|
+
* A bare name is searched along PATH the way `execvp` searches for it — the
|
|
117
|
+
* first regular, executable file wins — so that the identity above describes
|
|
118
|
+
* the binary that actually runs rather than some other `uf` further down the
|
|
119
|
+
* list. Getting this wrong is not a slow cache but a silently stale one, which
|
|
120
|
+
* is why a directory named `uf` is skipped here as `execvp` skips it, rather
|
|
121
|
+
* than being accepted because `access` says a directory is executable.
|
|
122
|
+
*
|
|
123
|
+
* Windows resolves a bare name by rules of its own — `PATHEXT`, the current
|
|
124
|
+
* directory — which this does not implement. There a bare name is `null` and
|
|
125
|
+
* the caller falls back to not caching, rather than to caching under the
|
|
126
|
+
* identity of a file that may not be the one that ran. `UF_BINARY`, which is
|
|
127
|
+
* how every uf-started host arrives here, is an absolute path on every
|
|
128
|
+
* platform and never takes this path at all.
|
|
129
|
+
*/
|
|
130
|
+
function resolveExecutable(command) {
|
|
131
|
+
// A path is taken as given — `spawn` will execute exactly it — and
|
|
132
|
+
// `ufBinaryIdentity` applies the file-and-executable test to the result
|
|
133
|
+
// either way, so a path that is a directory or is not executable is no more
|
|
134
|
+
// trusted than a bare name that resolves to one.
|
|
135
|
+
if (path.basename(command) !== command) return command;
|
|
136
|
+
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
137
|
+
if (directory === "") continue;
|
|
138
|
+
const candidate = path.join(directory, command);
|
|
139
|
+
try {
|
|
140
|
+
if (!statSync(candidate).isFile()) continue;
|
|
141
|
+
accessSync(candidate, constants.X_OK);
|
|
142
|
+
return candidate;
|
|
143
|
+
} catch {
|
|
144
|
+
// Not in this directory. Keep looking, exactly as the shell would.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
63
150
|
/**
|
|
64
151
|
* An error the transform reported for one module, with its position when
|
|
65
152
|
* the parser or the lowering rules gave one.
|
|
@@ -86,6 +173,7 @@ export class TransformError extends Error {
|
|
|
86
173
|
export class TransformService {
|
|
87
174
|
#child;
|
|
88
175
|
#pending = [];
|
|
176
|
+
#identity;
|
|
89
177
|
#failure = null;
|
|
90
178
|
|
|
91
179
|
/**
|
|
@@ -96,6 +184,14 @@ export class TransformService {
|
|
|
96
184
|
constructor(options = {}) {
|
|
97
185
|
const command = options.command ?? ufBinary();
|
|
98
186
|
const root = options.root ?? process.cwd();
|
|
187
|
+
// Read before the spawn and kept: this is the identity of the build that
|
|
188
|
+
// answers every request this service ever serves, because a child goes on
|
|
189
|
+
// executing the binary it started from however many times that file is
|
|
190
|
+
// rewritten underneath it. Anything written to disk from an answer of
|
|
191
|
+
// this service belongs under *this* identity — a caller that stat'd the
|
|
192
|
+
// binary earlier and wrote under that would file build B's output under
|
|
193
|
+
// build A's name, which is the original defect with a smaller window.
|
|
194
|
+
this.#identity = ufBinaryIdentity(command);
|
|
99
195
|
this.#child = spawn(command, ["--cwd", root, "transform"], {
|
|
100
196
|
stdio: ["pipe", "pipe", "inherit"],
|
|
101
197
|
});
|
|
@@ -166,6 +262,21 @@ export class TransformService {
|
|
|
166
262
|
});
|
|
167
263
|
}
|
|
168
264
|
|
|
265
|
+
/**
|
|
266
|
+
* The build of `uf` this service's child is executing, or `null` when that
|
|
267
|
+
* could not be established.
|
|
268
|
+
*
|
|
269
|
+
* Read once, before the spawn, and never again: the child goes on executing
|
|
270
|
+
* the binary it started from however many times that file is rewritten
|
|
271
|
+
* underneath it. Anything kept from an answer of this service belongs under
|
|
272
|
+
* this identity and not under whatever the file says now.
|
|
273
|
+
*
|
|
274
|
+
* @returns {string | null}
|
|
275
|
+
*/
|
|
276
|
+
get identity() {
|
|
277
|
+
return this.#identity;
|
|
278
|
+
}
|
|
279
|
+
|
|
169
280
|
/** Stop the process. Outstanding requests are rejected. */
|
|
170
281
|
close() {
|
|
171
282
|
this.#child.stdin.end();
|