@uniflowed/host 0.0.0-alpha.7 → 0.0.0-alpha.9

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 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
+ }
@@ -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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/host",
3
- "version": "0.0.0-alpha.7",
3
+ "version": "0.0.0-alpha.9",
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,16 +11,21 @@
11
11
  "directory": "packages/host"
12
12
  },
13
13
  "exports": {
14
+ "./assets": "./assets.js",
14
15
  "./bun-preload": "./bun-preload.js",
15
16
  "./internal/node-hooks.js": "./internal/node-hooks.js",
17
+ "./module-mocks": "./module-mocks.js",
16
18
  "./register": "./register.js",
17
19
  "./transform": "./transform.js",
18
20
  "./write-atomically": "./write-atomically.js"
19
21
  },
20
22
  "files": [
21
23
  "register.js",
24
+ "assets.js",
22
25
  "bun-preload.js",
26
+ "module-mocks.js",
23
27
  "transform.js",
28
+ "write-atomically.js",
24
29
  "internal/*.js"
25
30
  ]
26
31
  }
@@ -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
+ }