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

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.
@@ -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.8",
4
4
  "description": "Running Flow on a Capability JS Host, with no bundler in the way.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  "exports": {
14
14
  "./bun-preload": "./bun-preload.js",
15
15
  "./internal/node-hooks.js": "./internal/node-hooks.js",
16
+ "./module-mocks": "./module-mocks.js",
16
17
  "./register": "./register.js",
17
18
  "./transform": "./transform.js",
18
19
  "./write-atomically": "./write-atomically.js"
@@ -20,7 +21,9 @@
20
21
  "files": [
21
22
  "register.js",
22
23
  "bun-preload.js",
24
+ "module-mocks.js",
23
25
  "transform.js",
26
+ "write-atomically.js",
24
27
  "internal/*.js"
25
28
  ]
26
29
  }
@@ -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
+ }