@uniflowed/host 0.0.0-alpha.10
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/assets.js +248 -0
- package/bun-preload.js +24 -0
- package/internal/node-hooks.js +147 -0
- package/module-mocks.js +356 -0
- package/package.json +31 -0
- package/register.js +13 -0
- package/transform.js +322 -0
- package/write-atomically.js +54 -0
package/assets.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this runs in the host that runs Vite, beside
|
|
4
|
+
// `transform.js`, and reaching the Flow transform is what that file is for.
|
|
5
|
+
//
|
|
6
|
+
// The JavaScript side of the `uf assets` service.
|
|
7
|
+
//
|
|
8
|
+
// Decoding, resizing and re-encoding images lives in `crates/uf_assets` and is
|
|
9
|
+
// reached exactly the way the Flow transform is reached: one long-lived `uf`
|
|
10
|
+
// process per host process, newline-delimited JSON in, replies in request
|
|
11
|
+
// order out. The protocol is documented on the other side, in
|
|
12
|
+
// `crates/uf_cli/src/commands/assets.rs`.
|
|
13
|
+
//
|
|
14
|
+
// Why a second service rather than a second message on the transform one: the
|
|
15
|
+
// transform service runs on a thread with half a gigabyte of stack because
|
|
16
|
+
// every stage of the Flow chain recurses, and it is started for every host
|
|
17
|
+
// that touches a module — the Node loader hook and the Bun preload included,
|
|
18
|
+
// neither of which has any use for an image pipeline. A build that imports no
|
|
19
|
+
// images should not start a process that can resize them, and a `uf test` run
|
|
20
|
+
// should not start one at all.
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { createInterface } from "node:readline";
|
|
24
|
+
|
|
25
|
+
import { ufBinary, ufBinaryIdentity } from "./transform.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* File extensions the pipeline claims.
|
|
29
|
+
*
|
|
30
|
+
* `.svg` is deliberately not among them. uf could only ever copy one through —
|
|
31
|
+
* it is already resolution independent, so there is nothing to resize — and
|
|
32
|
+
* claiming it would take `.svg` away from `vite-plugin-svgr` and everything
|
|
33
|
+
* like it, which turn one into a component. A plugin that claims an extension
|
|
34
|
+
* to do nothing with it is the shape of red line 8 in `docs/red-lines.md`: if
|
|
35
|
+
* Vite can do it, a uf project can do it. So an SVG import stays Vite's, and
|
|
36
|
+
* `<Image src={url} width={…} height={…} />` is how you render one.
|
|
37
|
+
*
|
|
38
|
+
* `.gif` and `.avif` *are* claimed even though no decoder for them is compiled
|
|
39
|
+
* in, and that is the opposite decision for a reason: uf has something to say
|
|
40
|
+
* about them. The import still evaluates to a manifest, and the manifest
|
|
41
|
+
* carries a `note` saying the file was served unchanged and why — which is
|
|
42
|
+
* what tells an author their AVIF is not being resized, rather than leaving
|
|
43
|
+
* them to notice.
|
|
44
|
+
*/
|
|
45
|
+
export const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"];
|
|
46
|
+
|
|
47
|
+
/** Font file extensions the pipeline claims. */
|
|
48
|
+
export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether this import is one uf's asset pipeline handles, and as what.
|
|
52
|
+
*
|
|
53
|
+
* Returns `"image"`, `"font"`, or `null`. The query string is stripped first:
|
|
54
|
+
* `./hero.png?width=400` is an image, and the query is how an import says what
|
|
55
|
+
* it wants.
|
|
56
|
+
*/
|
|
57
|
+
export function assetKind(id) {
|
|
58
|
+
const clean = stripQuery(id);
|
|
59
|
+
const lower = clean.toLowerCase();
|
|
60
|
+
if (IMAGE_EXTENSIONS.some((extension) => lower.endsWith(extension))) return "image";
|
|
61
|
+
if (FONT_EXTENSIONS.some((extension) => lower.endsWith(extension))) return "font";
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stripQuery(id) {
|
|
66
|
+
const at = id.indexOf("?");
|
|
67
|
+
return at === -1 ? id : id.slice(0, at);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An asset the pipeline could not process. */
|
|
71
|
+
export class AssetError extends Error {
|
|
72
|
+
constructor(id, message) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "AssetError";
|
|
75
|
+
this.id = id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One `uf assets` process, with requests answered in the order they were sent.
|
|
81
|
+
*
|
|
82
|
+
* The same arrangement as `TransformService` next door, including the reason
|
|
83
|
+
* there are no correlation ids: the service replies once per request and in
|
|
84
|
+
* order, so a plain queue of resolvers pairs a reply with its caller. Any exit
|
|
85
|
+
* is final and every outstanding request is rejected at once.
|
|
86
|
+
*/
|
|
87
|
+
export class AssetService {
|
|
88
|
+
#child;
|
|
89
|
+
#pending = [];
|
|
90
|
+
#identity;
|
|
91
|
+
#failure = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} [options]
|
|
95
|
+
* @param {string} [options.command] the `uf` binary; `ufBinary()` by default
|
|
96
|
+
* @param {string} [options.root] project root, so `uf.config.js` is found
|
|
97
|
+
*/
|
|
98
|
+
constructor(options = {}) {
|
|
99
|
+
const command = options.command ?? ufBinary();
|
|
100
|
+
const root = options.root ?? process.cwd();
|
|
101
|
+
// Read before the spawn and kept, for the reason `TransformService` gives:
|
|
102
|
+
// the child goes on executing the binary it started from however many
|
|
103
|
+
// times that file is rewritten underneath it, and anything cached from an
|
|
104
|
+
// answer of this service belongs under *this* identity.
|
|
105
|
+
this.#identity = ufBinaryIdentity(command);
|
|
106
|
+
this.#child = spawn(command, ["--cwd", root, "assets"], {
|
|
107
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
createInterface({ input: this.#child.stdout }).on("line", (line) => {
|
|
111
|
+
const waiting = this.#pending.shift();
|
|
112
|
+
if (!waiting) return;
|
|
113
|
+
let reply;
|
|
114
|
+
try {
|
|
115
|
+
reply = JSON.parse(line);
|
|
116
|
+
} catch {
|
|
117
|
+
waiting.reject(new Error(`uf assets sent a malformed reply: ${line}`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (reply.error != null) {
|
|
121
|
+
waiting.reject(new AssetError(waiting.id, reply.error));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
waiting.resolve(reply);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.#child.on("error", (error) => {
|
|
128
|
+
this.#settleAll(new Error(`could not run \`${command} assets\`: ${error.message}`));
|
|
129
|
+
});
|
|
130
|
+
this.#child.on("close", (code) => {
|
|
131
|
+
this.#settleAll(new Error(`uf assets exited (${code})`));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#send(request) {
|
|
136
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
this.#pending.push({ id: request.id, resolve, reject });
|
|
139
|
+
this.#child.stdin.write(`${JSON.stringify(request)}\n`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#settleAll(error) {
|
|
144
|
+
this.#failure = error;
|
|
145
|
+
while (this.#pending.length > 0) this.#pending.shift().reject(error);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resize and re-encode one image.
|
|
150
|
+
*
|
|
151
|
+
* Resolves to the manifest the component reads: `{ width, height, format,
|
|
152
|
+
* variants, blur, declined, note }`. Every field is named here rather than
|
|
153
|
+
* passed through, which is deliberate and is the bug `transform.js` records
|
|
154
|
+
* next door: a shim that copies three of four fields drops the fourth
|
|
155
|
+
* silently, and the caller sees `undefined` rather than an error.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} id absolute path to the source image
|
|
158
|
+
* @param {object} options
|
|
159
|
+
* @param {string} options.outDir where variants are written
|
|
160
|
+
* @param {number[]} [options.widths] overriding the project's
|
|
161
|
+
* @param {number} [options.quality]
|
|
162
|
+
* @param {boolean} [options.blur]
|
|
163
|
+
*/
|
|
164
|
+
async image(id, options) {
|
|
165
|
+
const reply = await this.#send({
|
|
166
|
+
kind: "image",
|
|
167
|
+
id,
|
|
168
|
+
outDir: options.outDir,
|
|
169
|
+
widths: options.widths,
|
|
170
|
+
quality: options.quality,
|
|
171
|
+
blur: options.blur,
|
|
172
|
+
});
|
|
173
|
+
const image = reply.image;
|
|
174
|
+
if (image == null) throw new AssetError(id, "uf assets returned no image");
|
|
175
|
+
return {
|
|
176
|
+
width: image.width ?? null,
|
|
177
|
+
height: image.height ?? null,
|
|
178
|
+
format: image.format,
|
|
179
|
+
variants: image.variants ?? [],
|
|
180
|
+
blur: image.blur ?? null,
|
|
181
|
+
declined: image.declined ?? [],
|
|
182
|
+
note: image.note ?? null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Self-host one font and describe it.
|
|
188
|
+
*
|
|
189
|
+
* Resolves to `{ file, mime, bytes, family, fallbackFamily, container,
|
|
190
|
+
* metrics, fallback, fallbackDeclined, css }`.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} id absolute path to the source font
|
|
193
|
+
* @param {object} options
|
|
194
|
+
* @param {string} options.outDir where the copy is written
|
|
195
|
+
* @param {string} [options.family]
|
|
196
|
+
* @param {string} [options.weight]
|
|
197
|
+
* @param {string} [options.style]
|
|
198
|
+
* @param {string} [options.display]
|
|
199
|
+
* @param {string} [options.baseUrl] prefixed to the file name in `src: url()`
|
|
200
|
+
* @param {string | null} [options.fallback] `null` for no fallback face
|
|
201
|
+
*/
|
|
202
|
+
async font(id, options) {
|
|
203
|
+
const request = {
|
|
204
|
+
kind: "font",
|
|
205
|
+
id,
|
|
206
|
+
outDir: options.outDir,
|
|
207
|
+
family: options.family,
|
|
208
|
+
weight: options.weight,
|
|
209
|
+
style: options.style,
|
|
210
|
+
display: options.display,
|
|
211
|
+
baseUrl: options.baseUrl,
|
|
212
|
+
};
|
|
213
|
+
// Only sent when the caller had an opinion. The service distinguishes "not
|
|
214
|
+
// mentioned, use the project's" from "explicitly none", and a key that is
|
|
215
|
+
// always present collapses the two.
|
|
216
|
+
if ("fallback" in options) request.fallback = options.fallback;
|
|
217
|
+
const reply = await this.#send(request);
|
|
218
|
+
const font = reply.font;
|
|
219
|
+
if (font == null) throw new AssetError(id, "uf assets returned no font");
|
|
220
|
+
return {
|
|
221
|
+
file: font.file,
|
|
222
|
+
mime: font.mime,
|
|
223
|
+
bytes: font.bytes,
|
|
224
|
+
family: font.family,
|
|
225
|
+
fallbackFamily: font.fallbackFamily ?? null,
|
|
226
|
+
container: font.container,
|
|
227
|
+
metrics: font.metrics,
|
|
228
|
+
fallback: font.fallback ?? null,
|
|
229
|
+
fallbackDeclined: font.fallbackDeclined ?? null,
|
|
230
|
+
css: font.css,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The build of `uf` this service's child is executing, or `null`.
|
|
236
|
+
*
|
|
237
|
+
* @returns {string | null}
|
|
238
|
+
*/
|
|
239
|
+
get identity() {
|
|
240
|
+
return this.#identity;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Stop the process. Outstanding requests are rejected. */
|
|
244
|
+
close() {
|
|
245
|
+
this.#child.stdin.end();
|
|
246
|
+
this.#child.kill();
|
|
247
|
+
}
|
|
248
|
+
}
|
package/bun-preload.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this file registers the loader, so it cannot need one.
|
|
4
|
+
//
|
|
5
|
+
// `bun --preload @uniflowed/vite/bun-preload app.js` runs a Flow project on
|
|
6
|
+
// Bun without a build step, through Bun's own plugin API: every module uf is
|
|
7
|
+
// responsible for is transformed by `uf transform` as Bun loads it. It is the
|
|
8
|
+
// Bun counterpart of `./register.js`, and the policy of which files count is
|
|
9
|
+
// the same `isFlowModule`.
|
|
10
|
+
|
|
11
|
+
import { isFlowModule, transformFlow } from "./transform.js";
|
|
12
|
+
|
|
13
|
+
Bun.plugin({
|
|
14
|
+
name: "uniflowed-flow",
|
|
15
|
+
setup(build) {
|
|
16
|
+
build.onLoad({ filter: /\.(js|jsx|mjs)$/ }, async (args) => {
|
|
17
|
+
if (!isFlowModule(args.path)) return undefined;
|
|
18
|
+
const source = await Bun.file(args.path).text();
|
|
19
|
+
const out = await transformFlow(source, args.path, { development: true, sourceMap: false });
|
|
20
|
+
if (out == null) return undefined;
|
|
21
|
+
return { contents: out.code, loader: "js" };
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this *is* the loader, so it cannot be Flow.
|
|
4
|
+
//
|
|
5
|
+
// Node.js module customization hooks that transform Flow on import.
|
|
6
|
+
//
|
|
7
|
+
// Registered by `@uniflowed/host/register` (through `node:module`'s
|
|
8
|
+
// `register()`), which makes `node --import @uniflowed/host/register app.js`
|
|
9
|
+
// run a Flow project directly: every `.js` module uf is responsible for is
|
|
10
|
+
// transformed as it is loaded through `uf transform`, and everything else is
|
|
11
|
+
// left to Node.
|
|
12
|
+
//
|
|
13
|
+
// Transforms are cached on disk under `.uf/cache/transform/` keyed by a hash
|
|
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.
|
|
26
|
+
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
import { isFlowModule, sharedService, transformFlow, ufBinaryIdentity } from "../transform.js";
|
|
33
|
+
import { writeAtomically } from "../write-atomically.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bumped whenever *this file's* framing of the output changes, to retire old
|
|
37
|
+
* entries.
|
|
38
|
+
*
|
|
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.
|
|
43
|
+
*/
|
|
44
|
+
const CACHE_VERSION = "2";
|
|
45
|
+
|
|
46
|
+
let cacheDirectory = null;
|
|
47
|
+
let root = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Called once by `register()` with `{ root }`; the cache lives under it and
|
|
51
|
+
* the transform service is started there so it reads the right config.
|
|
52
|
+
*/
|
|
53
|
+
export async function initialize(data) {
|
|
54
|
+
root = data?.root ?? process.cwd();
|
|
55
|
+
cacheDirectory = path.join(root, ".uf", "cache", "transform");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The `load` hook: transform Flow modules, defer everything else.
|
|
60
|
+
*/
|
|
61
|
+
export async function load(url, context, nextLoad) {
|
|
62
|
+
if (!url.startsWith("file:")) return nextLoad(url, context);
|
|
63
|
+
const filename = fileURLToPath(url);
|
|
64
|
+
if (!isFlowModule(filename)) return nextLoad(url, context);
|
|
65
|
+
|
|
66
|
+
const source = readFileSync(filename, "utf8");
|
|
67
|
+
const code = await cachedTransform(source, filename);
|
|
68
|
+
if (code == null) return nextLoad(url, context);
|
|
69
|
+
// uf projects are ES modules. Forcing the format here means a project whose
|
|
70
|
+
// package.json forgot `"type": "module"` still runs, rather than failing on
|
|
71
|
+
// an `import` in what Node would have guessed was CommonJS.
|
|
72
|
+
return { format: "module", source: code, shortCircuit: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
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;
|
|
88
|
+
const key = createHash("sha256")
|
|
89
|
+
.update(CACHE_VERSION)
|
|
90
|
+
.update("\0")
|
|
91
|
+
.update(identity)
|
|
92
|
+
.update("\0")
|
|
93
|
+
.update(filename)
|
|
94
|
+
.update("\0")
|
|
95
|
+
.update(source)
|
|
96
|
+
.digest("hex");
|
|
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);
|
|
125
|
+
|
|
126
|
+
if (entry) {
|
|
127
|
+
try {
|
|
128
|
+
return readFileSync(entry, "utf8");
|
|
129
|
+
} catch {
|
|
130
|
+
// not cached yet
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const out = await transformFlow(source, filename, { root, development: true, sourceMap: true });
|
|
135
|
+
if (out == null) return null;
|
|
136
|
+
const output = out.map
|
|
137
|
+
? `${out.code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(out.map).toString("base64")}\n`
|
|
138
|
+
: out.code;
|
|
139
|
+
|
|
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 });
|
|
145
|
+
}
|
|
146
|
+
return output;
|
|
147
|
+
}
|
package/module-mocks.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this is part of the loader, and `bun-preload.js` imports
|
|
4
|
+
// it before any transform exists.
|
|
5
|
+
//
|
|
6
|
+
// Standing a module in for another one, at the only place that can do it: the
|
|
7
|
+
// loader. `@uniflowed/test`'s `uft.mock` is the API; this is the mechanism, and
|
|
8
|
+
// it lives here because by the time the runner sees an `import` the module has
|
|
9
|
+
// already been fetched, linked and evaluated.
|
|
10
|
+
//
|
|
11
|
+
// # Why these are *synchronous* hooks
|
|
12
|
+
//
|
|
13
|
+
// `./register.js` installs Node's asynchronous customization hooks, and those
|
|
14
|
+
// run on a loader thread of their own. That is right for the transform, which
|
|
15
|
+
// only needs the file's bytes — and useless for a mock, which is a value the
|
|
16
|
+
// test built: a spy the test holds a reference to cannot be sent to another
|
|
17
|
+
// thread, and a registry written on the main thread is not a registry the
|
|
18
|
+
// loader thread can read.
|
|
19
|
+
//
|
|
20
|
+
// So interception uses `node:module`'s `registerHooks`, which run in the thread
|
|
21
|
+
// that is doing the importing. They see this module's `mocks` map directly, and
|
|
22
|
+
// they chain into the asynchronous hooks for everything they do not claim — so
|
|
23
|
+
// a module that is not mocked is still transformed, still cached on disk, and
|
|
24
|
+
// still costs exactly what it cost before.
|
|
25
|
+
//
|
|
26
|
+
// # Why a mocked module gets a URL of its own
|
|
27
|
+
//
|
|
28
|
+
// A worker runs one file at a time and reuses the process, and Node's module
|
|
29
|
+
// registry is keyed by URL and never forgets. If a mocked module were served
|
|
30
|
+
// under its own URL, the *next* file in that worker would import the plain
|
|
31
|
+
// specifier and be handed the previous file's stand-in — a mock leaking across
|
|
32
|
+
// files, which is the one failure `docs/architecture.md` says the runner exists
|
|
33
|
+
// to prevent.
|
|
34
|
+
//
|
|
35
|
+
// So a mock does not replace a module; it redirects to a new one. Registering a
|
|
36
|
+
// mock takes the next revision number, the resolve hook appends it to the URL,
|
|
37
|
+
// and the mocked module is a different module from the real one rather than the
|
|
38
|
+
// same module with different contents. `unmock` stops appending, so the real
|
|
39
|
+
// URL — which may still hold the real module — is what the next import gets.
|
|
40
|
+
// Revisions are handed out for the life of the worker and never reused, which
|
|
41
|
+
// is what makes a cleared registry actually clear.
|
|
42
|
+
//
|
|
43
|
+
// The same parameter carries the module *epoch*. A module that imports a mocked
|
|
44
|
+
// one is standing in for it too — `consumer.js` computed its exports from
|
|
45
|
+
// whatever `client.js` gave it — so registering a mock, removing one, or
|
|
46
|
+
// calling `uft.resetModules` all start a new epoch, and every module reached by
|
|
47
|
+
// a path is evaluated again on the next import of it. Epochs are handed out for
|
|
48
|
+
// the life of the worker and never reused, for the reason revisions are not:
|
|
49
|
+
// the file after this one must not be able to land on a URL this one filled.
|
|
50
|
+
//
|
|
51
|
+
// The epoch is appended only to modules reached by a path specifier (`./x.js`,
|
|
52
|
+
// `/srv/x.js`): a bare specifier is a package, and handing a second copy of
|
|
53
|
+
// `@uniflowed/test` to a test file would give it a second registry, a second
|
|
54
|
+
// set of spies, and two of everything this package assumes there is one of. A
|
|
55
|
+
// *mocked* package still gets a URL of its own, from its revision — being
|
|
56
|
+
// stood in for is exactly the case where identity has to be given up.
|
|
57
|
+
|
|
58
|
+
import * as nodeModule from "node:module";
|
|
59
|
+
|
|
60
|
+
/** The URL parameter carrying `<module epoch>.<mock revision>`. */
|
|
61
|
+
export const REVISION_PARAM = "uf-modules";
|
|
62
|
+
|
|
63
|
+
/** The URL parameter marking an import that must reach the real module. */
|
|
64
|
+
export const ACTUAL_PARAM = "uf-actual";
|
|
65
|
+
|
|
66
|
+
/** This module's own URL, which a generated stand-in imports its values from. */
|
|
67
|
+
const SELF = import.meta.url;
|
|
68
|
+
|
|
69
|
+
/** Registered mocks, by module key. */
|
|
70
|
+
const mocks = new Map();
|
|
71
|
+
|
|
72
|
+
/** Namespaces handed to a generated module, by the exact URL it was loaded as. */
|
|
73
|
+
const served = new Map();
|
|
74
|
+
|
|
75
|
+
/** How many mocks have ever been registered in this process. */
|
|
76
|
+
let revisions = 0;
|
|
77
|
+
|
|
78
|
+
/** How many epochs have ever been started in this process. */
|
|
79
|
+
let epochs = 0;
|
|
80
|
+
|
|
81
|
+
/** The epoch this file is in; `0` until it does something that starts one. */
|
|
82
|
+
let epoch = 0;
|
|
83
|
+
|
|
84
|
+
/** The installed hooks, or `null` when interception is not installed. */
|
|
85
|
+
let handle = null;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A module's identity for the purpose of mocking: its URL with no query.
|
|
89
|
+
*
|
|
90
|
+
* The query is where every mechanism in this file writes — the worker's
|
|
91
|
+
* cache-busting `uf-run`, this file's revision, `importActual`'s marker — so
|
|
92
|
+
* two URLs that differ only there are the same module as far as a mock is
|
|
93
|
+
* concerned.
|
|
94
|
+
*/
|
|
95
|
+
export function moduleKey(url) {
|
|
96
|
+
const parsed = new URL(url);
|
|
97
|
+
parsed.search = "";
|
|
98
|
+
parsed.hash = "";
|
|
99
|
+
return parsed.href;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether this host can intercept a module before it is imported.
|
|
104
|
+
*
|
|
105
|
+
* The one requirement is synchronous, in-thread module hooks. Node has them;
|
|
106
|
+
* Bun's `node:module` has neither `register` nor `registerHooks`, and Deno has
|
|
107
|
+
* no loader in `@uniflowed/host` at all. `@uniflowed/test` turns a `false` here
|
|
108
|
+
* into an error that names the host rather than a mock that quietly does
|
|
109
|
+
* nothing.
|
|
110
|
+
*/
|
|
111
|
+
export function interceptionSupported() {
|
|
112
|
+
return typeof nodeModule.registerHooks === "function";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Install the interception hooks, once.
|
|
117
|
+
*
|
|
118
|
+
* Called on the first `uft.mock` or `uft.resetModules` rather than at import,
|
|
119
|
+
* because a resolve hook that runs for every specifier in the process is not
|
|
120
|
+
* something a suite that never mocks anything should pay for.
|
|
121
|
+
*/
|
|
122
|
+
export function installInterception() {
|
|
123
|
+
if (handle != null) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (!interceptionSupported()) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
handle = nodeModule.registerHooks({ load: loadHook, resolve: resolveHook });
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Register `namespace` as the stand-in for the module at `url`.
|
|
135
|
+
*
|
|
136
|
+
* Returns nothing: what the caller needs is that the *next* resolution of that
|
|
137
|
+
* module lands somewhere else, which the resolve hook arranges from the
|
|
138
|
+
* revision recorded here.
|
|
139
|
+
*/
|
|
140
|
+
export function defineModuleMock(url, namespace) {
|
|
141
|
+
revisions += 1;
|
|
142
|
+
mocks.set(moduleKey(url), { namespace, revision: revisions });
|
|
143
|
+
startModuleEpoch();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stop standing in for the module at `url`. */
|
|
147
|
+
export function removeModuleMock(url) {
|
|
148
|
+
if (!mocks.delete(moduleKey(url))) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
startModuleEpoch();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether the module at `url` is currently stood in for. */
|
|
156
|
+
export function isModuleMocked(url) {
|
|
157
|
+
return mocks.has(moduleKey(url));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The epoch path imports are currently loading into. */
|
|
161
|
+
export function moduleEpoch() {
|
|
162
|
+
return epoch;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Begin a new epoch, so a path import evaluates its module again.
|
|
167
|
+
*
|
|
168
|
+
* Counted process-wide rather than per file. A file that reused the number a
|
|
169
|
+
* previous file's epoch had would be handed that file's modules, mocks and all,
|
|
170
|
+
* which is the leak the whole scheme exists to prevent.
|
|
171
|
+
*/
|
|
172
|
+
export function startModuleEpoch() {
|
|
173
|
+
epochs += 1;
|
|
174
|
+
epoch = epochs;
|
|
175
|
+
return epoch;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Forget every mock and leave the epoch.
|
|
180
|
+
*
|
|
181
|
+
* Called by the worker between files. Epochs and revisions deliberately keep
|
|
182
|
+
* counting: the next file's mock of the same module must not be handed the URL
|
|
183
|
+
* this file's mock is cached under.
|
|
184
|
+
*/
|
|
185
|
+
export function resetModuleMocks() {
|
|
186
|
+
mocks.clear();
|
|
187
|
+
served.clear();
|
|
188
|
+
epoch = 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The URL an import that must reach the real module should use.
|
|
193
|
+
*
|
|
194
|
+
* A mocked module is standing at the URL an ordinary import resolves to, so
|
|
195
|
+
* reaching past it takes a URL of its own — one per epoch, so that two calls in
|
|
196
|
+
* a row hand back the same module rather than compiling it twice.
|
|
197
|
+
*
|
|
198
|
+
* A module nobody is standing in for needs no marker: the URL an ordinary
|
|
199
|
+
* import would use already holds the real thing, and taking the marked path
|
|
200
|
+
* anyway would hand back a second copy of a module the test is already holding.
|
|
201
|
+
*
|
|
202
|
+
* `pathLike` is whether the caller wrote a path rather than a package name, and
|
|
203
|
+
* it is not a detail: this is the resolve hook's rule applied by hand, and the
|
|
204
|
+
* rule is that a package keeps its identity across an epoch. Getting it wrong
|
|
205
|
+
* here would hand a test a second copy of `@uniflowed/test` — a second registry
|
|
206
|
+
* and a second set of spies — from the one call that is supposed to reach the
|
|
207
|
+
* real thing.
|
|
208
|
+
*/
|
|
209
|
+
export function actualUrl(url, pathLike) {
|
|
210
|
+
const parsed = new URL(url);
|
|
211
|
+
if (!mocks.has(moduleKey(url))) {
|
|
212
|
+
if (epoch !== 0 && pathLike) {
|
|
213
|
+
parsed.searchParams.set(REVISION_PARAM, `${epoch}.0`);
|
|
214
|
+
}
|
|
215
|
+
return parsed.href;
|
|
216
|
+
}
|
|
217
|
+
parsed.searchParams.set(ACTUAL_PARAM, String(epoch));
|
|
218
|
+
return parsed.href;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The values a generated stand-in module exports.
|
|
223
|
+
*
|
|
224
|
+
* Keyed by the exact URL the stand-in was loaded as rather than by the module
|
|
225
|
+
* key, so that a stand-in evaluated after its mock was replaced still reads the
|
|
226
|
+
* namespace its export list was written from.
|
|
227
|
+
*/
|
|
228
|
+
export function namespaceFor(url) {
|
|
229
|
+
const namespace = served.get(url);
|
|
230
|
+
if (namespace == null) {
|
|
231
|
+
throw new Error(`@uniflowed/host: no mocked namespace was recorded for ${url}`);
|
|
232
|
+
}
|
|
233
|
+
return namespace;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The source of the stand-in module for `url`, or `null` when there is none.
|
|
238
|
+
*
|
|
239
|
+
* An ES module's export names are fixed when it is compiled, so they are
|
|
240
|
+
* written out here from the keys the mock actually has. That is the whole
|
|
241
|
+
* reason this returns source rather than an object: a namespace object cannot
|
|
242
|
+
* be handed to `import`, and a module with the wrong names would fail to link
|
|
243
|
+
* with an error about the importer rather than about the mock.
|
|
244
|
+
*/
|
|
245
|
+
export function mockedSource(url) {
|
|
246
|
+
const parsed = new URL(url);
|
|
247
|
+
if (parsed.searchParams.has(ACTUAL_PARAM)) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const record = mocks.get(moduleKey(url));
|
|
251
|
+
if (record == null) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
served.set(url, record.namespace);
|
|
256
|
+
const lines = [
|
|
257
|
+
`import { namespaceFor } from ${JSON.stringify(SELF)};`,
|
|
258
|
+
`const values = namespaceFor(${JSON.stringify(url)});`,
|
|
259
|
+
];
|
|
260
|
+
const bindings = [];
|
|
261
|
+
for (const [index, name] of Object.keys(record.namespace).entries()) {
|
|
262
|
+
lines.push(`const binding${index} = values[${JSON.stringify(name)}];`);
|
|
263
|
+
bindings.push(`binding${index} as ${exportName(name)}`);
|
|
264
|
+
}
|
|
265
|
+
// `export {}` rather than nothing, so a mock with no exports is still an ES
|
|
266
|
+
// module: without an export or import declaration Node would have to guess
|
|
267
|
+
// the format from the package, and the guess is not always "module".
|
|
268
|
+
lines.push(bindings.length === 0 ? "export {};" : `export { ${bindings.join(", ")} };`);
|
|
269
|
+
return `${lines.join("\n")}\n`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** How an export is named in an `export {}` clause. */
|
|
273
|
+
function exportName(name) {
|
|
274
|
+
// Reserved words are fine — `export { x as default }` is the whole point —
|
|
275
|
+
// but anything that is not an identifier at all has to be a string, which
|
|
276
|
+
// ES2022 allows and which is how a mock keeps a name like `foo-bar`.
|
|
277
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The `load` hook: serve a stand-in, or defer to the transform.
|
|
282
|
+
*
|
|
283
|
+
* Deferring is the common case and has to stay cheap: with no mocks registered
|
|
284
|
+
* this is one map lookup on an empty map.
|
|
285
|
+
*/
|
|
286
|
+
function loadHook(url, context, nextLoad) {
|
|
287
|
+
if (mocks.size === 0 || !url.startsWith("file:")) {
|
|
288
|
+
return nextLoad(url, context);
|
|
289
|
+
}
|
|
290
|
+
const source = mockedSource(url);
|
|
291
|
+
if (source == null) {
|
|
292
|
+
return nextLoad(url, context);
|
|
293
|
+
}
|
|
294
|
+
return { format: "module", shortCircuit: true, source };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The `resolve` hook: send an import to the revision it belongs to.
|
|
299
|
+
*
|
|
300
|
+
* Runs after the rest of the chain, so what it rewrites is a fully resolved
|
|
301
|
+
* URL rather than a specifier it would have to resolve itself.
|
|
302
|
+
*/
|
|
303
|
+
function resolveHook(specifier, context, nextResolve) {
|
|
304
|
+
const resolved = nextResolve(specifier, context);
|
|
305
|
+
if (mocks.size === 0 && epoch === 0) {
|
|
306
|
+
return resolved;
|
|
307
|
+
}
|
|
308
|
+
const url = redirect(specifier, context?.parentURL, resolved?.url);
|
|
309
|
+
return url === resolved.url ? resolved : { ...resolved, url };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Where an import of `url` should actually go. */
|
|
313
|
+
function redirect(specifier, parentURL, url) {
|
|
314
|
+
if (typeof url !== "string" || !url.startsWith("file:")) {
|
|
315
|
+
return url;
|
|
316
|
+
}
|
|
317
|
+
const parsed = new URL(url);
|
|
318
|
+
// Already answered: `importActual` names the URL it wants, and a URL that
|
|
319
|
+
// carries a revision was produced by this function on the way in.
|
|
320
|
+
if (parsed.searchParams.has(ACTUAL_PARAM) || parsed.searchParams.has(REVISION_PARAM)) {
|
|
321
|
+
return url;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const revision = mocks.get(moduleKey(url))?.revision ?? 0;
|
|
325
|
+
// A package keeps its identity across an epoch; a module of this project's
|
|
326
|
+
// own does not. See the note at the top of the file.
|
|
327
|
+
const at = isPathSpecifier(specifier) ? epochOf(parentURL) : 0;
|
|
328
|
+
if (revision === 0 && at === 0) {
|
|
329
|
+
return url;
|
|
330
|
+
}
|
|
331
|
+
parsed.searchParams.set(REVISION_PARAM, `${at}.${revision}`);
|
|
332
|
+
return parsed.href;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Whether `specifier` names a file rather than a package. */
|
|
336
|
+
function isPathSpecifier(specifier) {
|
|
337
|
+
return specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The epoch an import from `parentURL` belongs to.
|
|
342
|
+
*
|
|
343
|
+
* A module loaded in epoch two loads its own dependencies in epoch two, however
|
|
344
|
+
* many mocks have been registered since: a graph half of which is one epoch and
|
|
345
|
+
* half another is two copies of a module that expects to be one.
|
|
346
|
+
*/
|
|
347
|
+
function epochOf(parentURL) {
|
|
348
|
+
if (parentURL == null || !parentURL.startsWith("file:")) {
|
|
349
|
+
return epoch;
|
|
350
|
+
}
|
|
351
|
+
const carried = new URL(parentURL).searchParams.get(REVISION_PARAM);
|
|
352
|
+
if (carried == null) {
|
|
353
|
+
return epoch;
|
|
354
|
+
}
|
|
355
|
+
return Number.parseInt(carried.split(".")[0], 10);
|
|
356
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/host",
|
|
3
|
+
"version": "0.0.0-alpha.10",
|
|
4
|
+
"description": "Running Flow on a Capability JS Host, with no bundler in the way.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/host"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
"./assets": "./assets.js",
|
|
15
|
+
"./bun-preload": "./bun-preload.js",
|
|
16
|
+
"./internal/node-hooks.js": "./internal/node-hooks.js",
|
|
17
|
+
"./module-mocks": "./module-mocks.js",
|
|
18
|
+
"./register": "./register.js",
|
|
19
|
+
"./transform": "./transform.js",
|
|
20
|
+
"./write-atomically": "./write-atomically.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"register.js",
|
|
24
|
+
"assets.js",
|
|
25
|
+
"bun-preload.js",
|
|
26
|
+
"module-mocks.js",
|
|
27
|
+
"transform.js",
|
|
28
|
+
"write-atomically.js",
|
|
29
|
+
"internal/*.js"
|
|
30
|
+
]
|
|
31
|
+
}
|
package/register.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this file registers the loader, so it cannot need one.
|
|
4
|
+
//
|
|
5
|
+
// `node --import @uniflowed/host/register app.js` runs a Flow project on
|
|
6
|
+
// Node.js without a build step. Importing this module installs the hooks in
|
|
7
|
+
// `./internal/node-hooks.js` for the rest of the process.
|
|
8
|
+
|
|
9
|
+
import { register } from "node:module";
|
|
10
|
+
|
|
11
|
+
register("./internal/node-hooks.js", import.meta.url, {
|
|
12
|
+
data: { root: process.env.UF_PROJECT_ROOT ?? process.cwd() },
|
|
13
|
+
});
|
package/transform.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: executed by the host that runs Vite, before any transform
|
|
4
|
+
// exists — this module is how the transform is reached, so it cannot be Flow.
|
|
5
|
+
//
|
|
6
|
+
// The Flow → JavaScript transform lives in `uf` itself (`crates/uf_transform`:
|
|
7
|
+
// the official Flow parser, Flow's own lowering rules, the official React
|
|
8
|
+
// Compiler, oxc for JSX and code generation). This module is the JavaScript
|
|
9
|
+
// side of the `uf transform` service: one long-lived `uf` process per host
|
|
10
|
+
// process, newline-delimited JSON in, replies in request order out.
|
|
11
|
+
//
|
|
12
|
+
// Every host that runs Flow — the Vite plugin, the Node loader hook, the Bun
|
|
13
|
+
// preload, the config loader — goes through here, which is what makes them
|
|
14
|
+
// all produce the same module from the same source.
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { createInterface } from "node:readline";
|
|
20
|
+
|
|
21
|
+
/** File extensions uf treats as Flow source. */
|
|
22
|
+
export const FLOW_EXTENSIONS = [".js", ".jsx", ".mjs", ".cjs"];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether uf is responsible for transforming this module.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors `uf_transform::is_flow_module`, and must keep mirroring it: a `uf
|
|
28
|
+
* dev` session and a `uf test` run that disagree about which files are Flow
|
|
29
|
+
* disagree about what the code is.
|
|
30
|
+
*
|
|
31
|
+
* A build tool synthesises modules of its own — ids beginning with a NUL byte,
|
|
32
|
+
* a bundler's shims — and a third-party dependency ships JavaScript that is
|
|
33
|
+
* already JavaScript; neither is Flow. `@uniflowed/*` under `node_modules` is
|
|
34
|
+
* the deliberate exception: those packages ship Flow source, because that is
|
|
35
|
+
* what uf tells everyone to write.
|
|
36
|
+
*
|
|
37
|
+
* Which build tool is deliberately not named. This loader runs Flow on a
|
|
38
|
+
* Capability JS Host and has no bundler in it; naming one would tie the answer
|
|
39
|
+
* to a tool that is not in this file's dependency graph.
|
|
40
|
+
*/
|
|
41
|
+
export function isFlowModule(id) {
|
|
42
|
+
if (id.startsWith("\0")) return false;
|
|
43
|
+
const clean = stripQuery(id);
|
|
44
|
+
if (!FLOW_EXTENSIONS.some((extension) => clean.endsWith(extension))) return false;
|
|
45
|
+
const at = clean.lastIndexOf("/node_modules/");
|
|
46
|
+
return at === -1 || clean.slice(at).startsWith("/node_modules/@uniflowed/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripQuery(id) {
|
|
50
|
+
const at = id.indexOf("?");
|
|
51
|
+
return at === -1 ? id : id.slice(0, at);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `uf` binary to talk to.
|
|
56
|
+
*
|
|
57
|
+
* `uf dev`, `uf build` and `uf test` set `UF_BINARY` to themselves when they
|
|
58
|
+
* start a host, so the host reaches exactly the binary that started it. A host
|
|
59
|
+
* started by hand finds `uf` on PATH, which is what the installer arranges.
|
|
60
|
+
*/
|
|
61
|
+
export function ufBinary() {
|
|
62
|
+
return process.env.UF_BINARY ?? "uf";
|
|
63
|
+
}
|
|
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
|
+
|
|
150
|
+
/**
|
|
151
|
+
* An error the transform reported for one module, with its position when
|
|
152
|
+
* the parser or the lowering rules gave one.
|
|
153
|
+
*/
|
|
154
|
+
export class TransformError extends Error {
|
|
155
|
+
constructor(id, message, line, column) {
|
|
156
|
+
super(message);
|
|
157
|
+
this.name = "TransformError";
|
|
158
|
+
this.id = id;
|
|
159
|
+
this.loc = line != null ? { file: id, line, column: column ?? 0 } : undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One `uf transform` process, with requests answered in the order they were
|
|
165
|
+
* sent.
|
|
166
|
+
*
|
|
167
|
+
* `uf transform` replies once per request, in order, so a plain queue of
|
|
168
|
+
* resolvers pairs a reply with its caller — no correlation ids and no map to
|
|
169
|
+
* leak. Any exit is final: a request made after the process has gone is
|
|
170
|
+
* rejected at once rather than queued against something that will never
|
|
171
|
+
* answer.
|
|
172
|
+
*/
|
|
173
|
+
export class TransformService {
|
|
174
|
+
#child;
|
|
175
|
+
#pending = [];
|
|
176
|
+
#identity;
|
|
177
|
+
#failure = null;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {object} [options]
|
|
181
|
+
* @param {string} [options.command] the `uf` binary; `ufBinary()` by default
|
|
182
|
+
* @param {string} [options.root] project root, so `uf.config.js` is found
|
|
183
|
+
*/
|
|
184
|
+
constructor(options = {}) {
|
|
185
|
+
const command = options.command ?? ufBinary();
|
|
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);
|
|
195
|
+
this.#child = spawn(command, ["--cwd", root, "transform"], {
|
|
196
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
createInterface({ input: this.#child.stdout }).on("line", (line) => {
|
|
200
|
+
const waiting = this.#pending.shift();
|
|
201
|
+
if (!waiting) return;
|
|
202
|
+
let reply;
|
|
203
|
+
try {
|
|
204
|
+
reply = JSON.parse(line);
|
|
205
|
+
} catch {
|
|
206
|
+
waiting.reject(new Error(`uf transform sent a malformed reply: ${line}`));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (reply.error != null) {
|
|
210
|
+
waiting.reject(new TransformError(waiting.id, reply.error, reply.line, reply.column));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
waiting.resolve(reply);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this.#child.on("error", (error) => {
|
|
217
|
+
this.#settleAll(new Error(`could not run \`${command} transform\`: ${error.message}`));
|
|
218
|
+
});
|
|
219
|
+
this.#child.on("close", (code) => {
|
|
220
|
+
this.#settleAll(new Error(`uf transform exited (${code})`));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#settleAll(error) {
|
|
225
|
+
this.#failure = error;
|
|
226
|
+
while (this.#pending.length > 0) this.#pending.shift().reject(error);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Transform one module.
|
|
231
|
+
*
|
|
232
|
+
* Resolves to `{ code, map, css, diagnostics }`, or to `null` when the
|
|
233
|
+
* module is not uf's to transform (see `isFlowModule`). Rejects with a
|
|
234
|
+
* `TransformError` carrying the position when the source is not valid Flow.
|
|
235
|
+
*
|
|
236
|
+
* `css` is the stylesheet the module's StyleX rules declare, and `null` when
|
|
237
|
+
* it declares none.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} id absolute path, used for the map and for errors
|
|
240
|
+
* @param {string} code the Flow source
|
|
241
|
+
* @param {object} [options]
|
|
242
|
+
* @param {boolean} [options.development] readable output, `jsxDEV`
|
|
243
|
+
* @param {boolean} [options.refresh] Fast Refresh registrations (development only)
|
|
244
|
+
* @param {boolean} [options.sourceMap] produce a source map; on by default
|
|
245
|
+
*/
|
|
246
|
+
transform(id, code, options = {}) {
|
|
247
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
this.#pending.push({
|
|
250
|
+
id,
|
|
251
|
+
reject,
|
|
252
|
+
resolve: (reply) => {
|
|
253
|
+
if (reply.code == null) {
|
|
254
|
+
resolve(null);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Named field by field rather than passed through, so a host reads
|
|
258
|
+
// the protocol rather than whatever `uf transform` happens to send —
|
|
259
|
+
// which means every field the protocol grows has to be added here,
|
|
260
|
+
// and one was not. `css` arrived with the StyleX compiler and this
|
|
261
|
+
// object did not mention it, so `out.css` was `undefined` in every
|
|
262
|
+
// host: the Vite plugin's `if (out.css != null)` never ran, no module
|
|
263
|
+
// ever imported its own stylesheet, and an application styled with
|
|
264
|
+
// `stylex.create` shipped class names and no CSS. The transform was
|
|
265
|
+
// right the whole time; the shim in front of it was returning three
|
|
266
|
+
// quarters of the answer. See ubugeeei-prod/uf#306.
|
|
267
|
+
resolve({
|
|
268
|
+
code: reply.code,
|
|
269
|
+
map: reply.map ?? null,
|
|
270
|
+
css: reply.css ?? null,
|
|
271
|
+
diagnostics: reply.diagnostics ?? [],
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
this.#child.stdin.write(`${JSON.stringify({ id, code, options })}\n`);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The build of `uf` this service's child is executing, or `null` when that
|
|
281
|
+
* could not be established.
|
|
282
|
+
*
|
|
283
|
+
* Read once, before the spawn, and never again: the child goes on executing
|
|
284
|
+
* the binary it started from however many times that file is rewritten
|
|
285
|
+
* underneath it. Anything kept from an answer of this service belongs under
|
|
286
|
+
* this identity and not under whatever the file says now.
|
|
287
|
+
*
|
|
288
|
+
* @returns {string | null}
|
|
289
|
+
*/
|
|
290
|
+
get identity() {
|
|
291
|
+
return this.#identity;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Stop the process. Outstanding requests are rejected. */
|
|
295
|
+
close() {
|
|
296
|
+
this.#child.stdin.end();
|
|
297
|
+
this.#child.kill();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let shared = null;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The process-wide service, started on first use.
|
|
305
|
+
*
|
|
306
|
+
* The loader hooks and the config loader share one process per host rather
|
|
307
|
+
* than one per module; it lives as long as the host does.
|
|
308
|
+
*/
|
|
309
|
+
export function sharedService(root) {
|
|
310
|
+
shared ??= new TransformService({ root: root ?? process.env.UF_PROJECT_ROOT ?? process.cwd() });
|
|
311
|
+
return shared;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Transform one Flow module through the shared service.
|
|
316
|
+
*
|
|
317
|
+
* Returns `{ code, map, css, diagnostics }`; a module that is not uf's to
|
|
318
|
+
* transform comes back as `null`.
|
|
319
|
+
*/
|
|
320
|
+
export function transformFlow(code, filename, options = {}) {
|
|
321
|
+
return sharedService(options.root).transform(filename, code, options);
|
|
322
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this is reached from the loader hooks, which run before
|
|
4
|
+
// any transform, and from the config loader, which runs before them.
|
|
5
|
+
//
|
|
6
|
+
// Writing a file a second process may be reading at the same time.
|
|
7
|
+
//
|
|
8
|
+
// `writeFileSync` truncates and then writes, so a reader can observe the empty
|
|
9
|
+
// file or half of one. When what is being written is a module, the reader does
|
|
10
|
+
// not get an error it can act on — it gets a module with no exports, and says
|
|
11
|
+
// so about the *source*:
|
|
12
|
+
//
|
|
13
|
+
// uf: uf.config.js must `export default defineConfig({ ... })`
|
|
14
|
+
//
|
|
15
|
+
// which is a sentence about a file that is perfectly correct. That is not
|
|
16
|
+
// hypothetical: two `uf` commands in one project, which is `uf dev` in one
|
|
17
|
+
// terminal and `uf build` in another, produced exactly it. See
|
|
18
|
+
// ubugeeei-prod/uf#240.
|
|
19
|
+
//
|
|
20
|
+
// Writing to a private name and renaming is atomic within a filesystem, so a
|
|
21
|
+
// reader sees the old file or the new one and never a part of either.
|
|
22
|
+
|
|
23
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write `contents` to `target` so a concurrent reader never sees half of it.
|
|
28
|
+
*
|
|
29
|
+
* The temporary name carries the process id and a random suffix, because two
|
|
30
|
+
* processes racing to write the *same* target is the case this exists for and
|
|
31
|
+
* they must not collide on the temporary either.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} target absolute path to write
|
|
34
|
+
* @param {string} contents what to write
|
|
35
|
+
* @param {{ tolerant?: boolean }} [options] `tolerant` swallows a failure,
|
|
36
|
+
* which is what a cache wants — a read-only checkout still runs, just
|
|
37
|
+
* without one. A config that cannot be written is a failure the caller has
|
|
38
|
+
* to see.
|
|
39
|
+
*/
|
|
40
|
+
export function writeAtomically(target, contents, options = {}) {
|
|
41
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
44
|
+
writeFileSync(temporary, contents);
|
|
45
|
+
renameSync(temporary, target);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(temporary);
|
|
49
|
+
} catch {
|
|
50
|
+
// Nothing to clean up.
|
|
51
|
+
}
|
|
52
|
+
if (options.tolerant !== true) throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|