@uniflowed/test 0.0.0-alpha.9 → 0.1.0
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/app-browser.js +6 -0
- package/app.js +175 -0
- package/browser-worker.js +334 -0
- package/browser.js +59 -0
- package/bun/index.js +305 -0
- package/in-source.js +169 -0
- package/index.js +11 -1
- package/internal/axe.js +362 -0
- package/internal/browser/cdp.js +364 -0
- package/internal/browser/node.js +375 -0
- package/internal/browser/page-transport.js +36 -0
- package/internal/browser/page.js +258 -0
- package/internal/browser/screenshots.js +102 -0
- package/internal/browser/server.js +893 -0
- package/internal/browser/transport.js +2 -0
- package/internal/expect.js +432 -80
- package/internal/isolation.js +231 -0
- package/internal/namespace.js +22 -10
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/internal/output.js +25 -7
- package/internal/registry.js +54 -3
- package/internal/run.js +130 -11
- package/internal/timers.js +2 -2
- package/package.json +41 -5
- package/worker.js +98 -10
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: the modules a page imports, over HTTP.
|
|
4
|
+
//
|
|
5
|
+
// A Node worker gets its modules from a loader hook: Node asks uf what a file
|
|
6
|
+
// is, uf answers with transformed JavaScript, and resolution stays Node's. A
|
|
7
|
+
// page has no such hook. What it has is a URL for every module and an import
|
|
8
|
+
// map for every bare specifier, and this module is those two things — the
|
|
9
|
+
// smallest server that can hand a browser a uf project.
|
|
10
|
+
//
|
|
11
|
+
// # Why a server and not a bundle
|
|
12
|
+
//
|
|
13
|
+
// Bundling the file under test would make `uf test --browser` a second build
|
|
14
|
+
// pipeline: a graph walker, a tree shaker, a chunking strategy and a source-map
|
|
15
|
+
// merge, every one of which uf already owns somewhere else and none of which
|
|
16
|
+
// belongs to a test runner. `docs/red-lines.md` is explicit that uf owns
|
|
17
|
+
// orchestration rather than implementation, and a bundler inside the test
|
|
18
|
+
// runner is the opposite of that.
|
|
19
|
+
//
|
|
20
|
+
// Serving is the cheaper half of the same job. Each module is served at the
|
|
21
|
+
// URL its path already implies, transformed by the same `uf transform` a build
|
|
22
|
+
// uses; relative specifiers resolve as URLs, with no rewriting; bare
|
|
23
|
+
// specifiers resolve through one import map the page is given before it loads
|
|
24
|
+
// anything. Nothing walks the graph, because the browser walks it.
|
|
25
|
+
//
|
|
26
|
+
// # The three things a page needs that a Node worker does not
|
|
27
|
+
//
|
|
28
|
+
// **An import map.** `import { render } from "@uniflowed/react-testing"` is a
|
|
29
|
+
// specifier no browser resolves. The map is built from *manifests* rather than
|
|
30
|
+
// from sources — every package the project depends on, and everything those
|
|
31
|
+
// depend on, with the entry points their `exports` declare — so no JavaScript
|
|
32
|
+
// is parsed to build it and a specifier that could legally be written is in it.
|
|
33
|
+
// See `importMap`.
|
|
34
|
+
//
|
|
35
|
+
// **A `browser` field that is honoured.** `@uniflowed/test` maps six `node:`
|
|
36
|
+
// builtins to a shim, `@uniflowed/host` maps a seventh, and both mappings mean
|
|
37
|
+
// something different inside each package. That is what an import map's
|
|
38
|
+
// `scopes` are: a mapping that applies to one importer's directory and not to
|
|
39
|
+
// the whole page.
|
|
40
|
+
//
|
|
41
|
+
// **CommonJS, wrapped.** React ships CommonJS, a browser imports ESM, and a
|
|
42
|
+
// component test that cannot import React is not a component test. See
|
|
43
|
+
// `wrapCommonJs` for what the wrapper does, what it refuses, and why the list
|
|
44
|
+
// of named exports is read from Node rather than lexed out of the source.
|
|
45
|
+
//
|
|
46
|
+
// # What this server will not serve
|
|
47
|
+
//
|
|
48
|
+
// Only files under a root the run named, and only after the path has been
|
|
49
|
+
// resolved and checked against those roots. The server is bound to 127.0.0.1
|
|
50
|
+
// and lives for one run, but "a local port that reads any file" is a shape
|
|
51
|
+
// worth not having at all.
|
|
52
|
+
|
|
53
|
+
import { createServer } from "node:http";
|
|
54
|
+
import { randomUUID } from "node:crypto";
|
|
55
|
+
import { createRequire } from "node:module";
|
|
56
|
+
import { readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
57
|
+
import path from "node:path";
|
|
58
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
59
|
+
|
|
60
|
+
import { isFlowModule } from "@uniflowed/host/transform";
|
|
61
|
+
|
|
62
|
+
/** Where a file on disk is served from. */
|
|
63
|
+
const FILE_PREFIX = "/@fs";
|
|
64
|
+
|
|
65
|
+
/** Where the harness page lives, so a person can open it by hand. */
|
|
66
|
+
const PAGE_PATH = "/uf-test/";
|
|
67
|
+
|
|
68
|
+
/** How many packages the manifest crawl will visit. */
|
|
69
|
+
const MAX_PACKAGES = 4_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Conditions honoured in an `exports` map, in order.
|
|
73
|
+
*
|
|
74
|
+
* `browser` first, because that is what the page is, then the ESM spellings,
|
|
75
|
+
* because a browser imports ES modules and the CommonJS wrapper below is a
|
|
76
|
+
* fallback rather than a preference. `require` is last for the same reason.
|
|
77
|
+
* `development` sits between them: `uf test` runs the development build of a
|
|
78
|
+
* library where there is a choice, because a test that fails should say why
|
|
79
|
+
* and a production build says nothing.
|
|
80
|
+
*/
|
|
81
|
+
const CONDITIONS = ["browser", "import", "module", "development", "default", "require"];
|
|
82
|
+
|
|
83
|
+
/** A `package.json`, as this module reads one. */
|
|
84
|
+
type Manifest = {
|
|
85
|
+
readonly name?: string,
|
|
86
|
+
readonly type?: string,
|
|
87
|
+
readonly main?: string,
|
|
88
|
+
readonly module?: string,
|
|
89
|
+
readonly exports?: mixed,
|
|
90
|
+
readonly browser?: mixed,
|
|
91
|
+
readonly dependencies?: { readonly [string]: string },
|
|
92
|
+
readonly peerDependencies?: { readonly [string]: string },
|
|
93
|
+
...
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** One package the crawl found. */
|
|
97
|
+
type Package = {|
|
|
98
|
+
/** The directory holding its `package.json`, real path. */
|
|
99
|
+
readonly directory: string,
|
|
100
|
+
readonly manifest: Manifest,
|
|
101
|
+
|};
|
|
102
|
+
|
|
103
|
+
/** What `create` hands back. */
|
|
104
|
+
export type ModuleServer = {|
|
|
105
|
+
/** Where the page lives. */
|
|
106
|
+
readonly url: string,
|
|
107
|
+
/** Hand the page its next file, and take the events it writes. */
|
|
108
|
+
readonly offer: (request: PageRequest | null) => void,
|
|
109
|
+
/** Called with every event the page posts. */
|
|
110
|
+
readonly onEvent: (listener: (event: { readonly [string]: mixed }) => void) => void,
|
|
111
|
+
/** Whether the page has asked for work at least once. */
|
|
112
|
+
readonly connected: () => boolean,
|
|
113
|
+
readonly close: () => Promise<void>,
|
|
114
|
+
|};
|
|
115
|
+
|
|
116
|
+
/** One file, as the page is told about it. */
|
|
117
|
+
export type PageRequest = {|
|
|
118
|
+
readonly file: string,
|
|
119
|
+
readonly filter: string | null,
|
|
120
|
+
readonly timeoutMs: number,
|
|
121
|
+
readonly generation: number,
|
|
122
|
+
|};
|
|
123
|
+
|
|
124
|
+
/** How `create` is configured. */
|
|
125
|
+
export type ServerOptions = {|
|
|
126
|
+
readonly browserCommand?: (
|
|
127
|
+
id: mixed,
|
|
128
|
+
method: string,
|
|
129
|
+
args: $ReadOnlyArray<mixed>,
|
|
130
|
+
) => Promise<mixed>,
|
|
131
|
+
/** The project root; every served file must be under this or a package. */
|
|
132
|
+
readonly root: string,
|
|
133
|
+
/** Transforms a Flow module, or answers `null` when it is not uf's. */
|
|
134
|
+
readonly transform: (id: string, code: string) => Promise<string | null>,
|
|
135
|
+
/** Where a message about a refused request goes. */
|
|
136
|
+
readonly warn: (message: string) => void,
|
|
137
|
+
|};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Start the module server on a free port of the loopback interface.
|
|
141
|
+
*
|
|
142
|
+
* Port zero, resolved after listening: a fixed port is a collision with
|
|
143
|
+
* whatever else the machine is running, and a run that fails because somebody's
|
|
144
|
+
* dev server is on 5173 would be uf's fault for having an opinion about it.
|
|
145
|
+
*/
|
|
146
|
+
export async function create(options: ServerOptions): Promise<ModuleServer> {
|
|
147
|
+
const root = realpathSync(options.root);
|
|
148
|
+
const browserToken = randomUUID();
|
|
149
|
+
const packages = crawlPackages(root);
|
|
150
|
+
const map = importMap(packages);
|
|
151
|
+
const roots = [root, ...packages.map((entry) => entry.directory)];
|
|
152
|
+
const listeners: Array<(event: { readonly [string]: mixed }) => void> = [];
|
|
153
|
+
const transformed: Map<string, Promise<string>> = new Map();
|
|
154
|
+
|
|
155
|
+
/** The request the page is waiting for, and how to hand it over. */
|
|
156
|
+
let waiting: ((body: string) => void) | null = null;
|
|
157
|
+
let pending: PageRequest | null = null;
|
|
158
|
+
let finished = false;
|
|
159
|
+
let asked = false;
|
|
160
|
+
|
|
161
|
+
function offer(request: PageRequest | null): void {
|
|
162
|
+
if (request == null) {
|
|
163
|
+
finished = true;
|
|
164
|
+
} else {
|
|
165
|
+
pending = request;
|
|
166
|
+
}
|
|
167
|
+
const answer = waiting;
|
|
168
|
+
if (answer == null) return;
|
|
169
|
+
const body = takeAnswer();
|
|
170
|
+
if (body == null) return;
|
|
171
|
+
waiting = null;
|
|
172
|
+
answer(body);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The body of a `/next` reply, or `null` when there is nothing to say yet. */
|
|
176
|
+
function takeAnswer(): string | null {
|
|
177
|
+
if (pending != null) {
|
|
178
|
+
const request = pending;
|
|
179
|
+
pending = null;
|
|
180
|
+
return JSON.stringify(request);
|
|
181
|
+
}
|
|
182
|
+
return finished ? JSON.stringify({ done: true }) : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const server = createServer((incoming, outgoing) => {
|
|
186
|
+
const url = incoming.url ?? "/";
|
|
187
|
+
const at = url.indexOf("?");
|
|
188
|
+
const route = at === -1 ? url : url.slice(0, at);
|
|
189
|
+
if (route === "/" || route === PAGE_PATH) {
|
|
190
|
+
reply(outgoing, 200, "text/html; charset=utf-8", harnessHtml(map, browserToken));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (route === "/uf-test/next") {
|
|
194
|
+
asked = true;
|
|
195
|
+
const body = takeAnswer();
|
|
196
|
+
if (body != null) {
|
|
197
|
+
reply(outgoing, 200, "application/json", body);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Held open. The page has nothing to do until `uf` says so, and a poll
|
|
201
|
+
// loop would be a busy wait against a runner that is already streaming.
|
|
202
|
+
waiting = (answer: string) => reply(outgoing, 200, "application/json", answer);
|
|
203
|
+
outgoing.on("close", () => {
|
|
204
|
+
waiting = null;
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (route === "/uf-test/browser") {
|
|
209
|
+
const origin = `http://127.0.0.1:${String(port)}`;
|
|
210
|
+
if (
|
|
211
|
+
incoming.method !== "POST" ||
|
|
212
|
+
incoming.headers.origin !== origin ||
|
|
213
|
+
incoming.headers.host !== `127.0.0.1:${String(port)}` ||
|
|
214
|
+
incoming.headers["uf-test-browser"] !== browserToken
|
|
215
|
+
) {
|
|
216
|
+
reply(
|
|
217
|
+
outgoing,
|
|
218
|
+
403,
|
|
219
|
+
"application/json",
|
|
220
|
+
JSON.stringify({ error: "invalid test browser request" }),
|
|
221
|
+
);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
readBody(incoming, (body) => {
|
|
225
|
+
void (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const { id, method, args } = JSON.parse(body);
|
|
228
|
+
if (
|
|
229
|
+
typeof method !== "string" ||
|
|
230
|
+
!Array.isArray(args) ||
|
|
231
|
+
options.browserCommand == null
|
|
232
|
+
)
|
|
233
|
+
throw new Error("invalid browser command");
|
|
234
|
+
const value = await options.browserCommand(id, method, args);
|
|
235
|
+
reply(outgoing, 200, "application/json", JSON.stringify({ value }));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
reply(
|
|
238
|
+
outgoing,
|
|
239
|
+
400,
|
|
240
|
+
"application/json",
|
|
241
|
+
JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
})();
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (route === "/uf-test/events") {
|
|
249
|
+
readBody(incoming, (body) => {
|
|
250
|
+
for (const event of parseEvents(body, options.warn)) {
|
|
251
|
+
for (const listener of listeners) listener(event);
|
|
252
|
+
}
|
|
253
|
+
reply(outgoing, 200, "application/json", "{}");
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (route.startsWith(`${FILE_PREFIX}/`)) {
|
|
258
|
+
serveFile(decodeURIComponent(route.slice(FILE_PREFIX.length)), roots, transformed, options)
|
|
259
|
+
.then((served) => reply(outgoing, 200, served.type, served.body))
|
|
260
|
+
.catch((error: Error) => {
|
|
261
|
+
// A module the page could not have is answered *as a module*, so the
|
|
262
|
+
// failure arrives as the sentence below in the test's own report
|
|
263
|
+
// rather than as a browser console line nobody is reading. A 404
|
|
264
|
+
// would surface as "Failed to fetch dynamically imported module",
|
|
265
|
+
// which names the importer and never the missing file.
|
|
266
|
+
reply(
|
|
267
|
+
outgoing,
|
|
268
|
+
200,
|
|
269
|
+
"text/javascript; charset=utf-8",
|
|
270
|
+
`throw new Error(${JSON.stringify(`uf test --browser: ${error.message}`)});\n`,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
reply(outgoing, 404, "text/plain; charset=utf-8", "not found\n");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await new Promise((resolve) => {
|
|
279
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
280
|
+
});
|
|
281
|
+
const address = server.address();
|
|
282
|
+
const port = address != null && typeof address === "object" ? address.port : 0;
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
url: `http://127.0.0.1:${String(port)}${PAGE_PATH}`,
|
|
286
|
+
offer,
|
|
287
|
+
onEvent: (listener) => {
|
|
288
|
+
listeners.push(listener);
|
|
289
|
+
},
|
|
290
|
+
connected: () => asked,
|
|
291
|
+
close: () =>
|
|
292
|
+
new Promise((resolve) => {
|
|
293
|
+
// Whatever is still held open is answered first: a page blocked on
|
|
294
|
+
// `/next` would otherwise keep the socket, and `close` waits for every
|
|
295
|
+
// connection to end.
|
|
296
|
+
offer(null);
|
|
297
|
+
server.closeAllConnections();
|
|
298
|
+
server.close(() => resolve());
|
|
299
|
+
}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Everything a request handler needs to say to say one thing. */
|
|
304
|
+
function reply(outgoing: $FlowFixMe, status: number, type: string, body: string): void {
|
|
305
|
+
outgoing.writeHead(status, {
|
|
306
|
+
"content-type": type,
|
|
307
|
+
"cache-control": "no-store",
|
|
308
|
+
});
|
|
309
|
+
outgoing.end(body);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Read a request body, bounded, and hand it over. */
|
|
313
|
+
function readBody(incoming: $FlowFixMe, done: (body: string) => void): void {
|
|
314
|
+
let body = "";
|
|
315
|
+
incoming.on("data", (chunk: mixed) => {
|
|
316
|
+
if (body.length < 8 * 1024 * 1024) body += String(chunk);
|
|
317
|
+
});
|
|
318
|
+
incoming.on("end", () => done(body));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The events in one POST.
|
|
323
|
+
*
|
|
324
|
+
* Everything a page posts is untrusted — it is whatever the test file caused to
|
|
325
|
+
* be written — so a malformed body is a warning and not an exception: the run
|
|
326
|
+
* loses those events and keeps its deadline, which is the trade `uf test` makes
|
|
327
|
+
* everywhere else it reads from a host.
|
|
328
|
+
*/
|
|
329
|
+
function parseEvents(
|
|
330
|
+
body: string,
|
|
331
|
+
warn: (message: string) => void,
|
|
332
|
+
): Array<{ readonly [string]: mixed }> {
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(body);
|
|
335
|
+
if (!Array.isArray(parsed)) return [];
|
|
336
|
+
return parsed.filter((event) => event != null && typeof event === "object");
|
|
337
|
+
} catch (error) {
|
|
338
|
+
warn(`unreadable page event: ${String(error)}`);
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A served module: its body and what to call it. */
|
|
344
|
+
type Served = {| readonly body: string, readonly type: string |};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* One file, transformed if it is uf's and wrapped if it is CommonJS.
|
|
348
|
+
*
|
|
349
|
+
* Cached by path for the life of the run rather than per page load. The page
|
|
350
|
+
* reloads between files — that is how a browser run isolates one file from the
|
|
351
|
+
* next — and re-transforming a hundred unchanged modules on every reload would
|
|
352
|
+
* make the isolation cost more than the run.
|
|
353
|
+
*/
|
|
354
|
+
async function serveFile(
|
|
355
|
+
requested: string,
|
|
356
|
+
roots: $ReadOnlyArray<string>,
|
|
357
|
+
cache: Map<string, Promise<string>>,
|
|
358
|
+
options: ServerOptions,
|
|
359
|
+
): Promise<Served> {
|
|
360
|
+
const absolute = path.resolve("/", requested);
|
|
361
|
+
let real: string;
|
|
362
|
+
try {
|
|
363
|
+
real = realpathSync(absolute);
|
|
364
|
+
} catch {
|
|
365
|
+
throw new Error(`there is no file at ${absolute}`);
|
|
366
|
+
}
|
|
367
|
+
// Resolved first, then checked. A prefix test against the requested path
|
|
368
|
+
// would be a prefix test against `..` and a symlink.
|
|
369
|
+
if (!roots.some((allowed) => real === allowed || real.startsWith(`${allowed}${path.sep}`))) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`${absolute} is outside this project and the packages it depends on, so the browser is not being handed it`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (/\.html$/.test(real))
|
|
375
|
+
return { body: readFileSync(real, "utf8"), type: "text/html; charset=utf-8" };
|
|
376
|
+
if (!/\.(?:js|jsx|mjs|cjs)$/.test(real)) {
|
|
377
|
+
// A JSON import, a stylesheet, an asset. Not this change: a page can have
|
|
378
|
+
// them and `uf test --browser` has no opinion about what they should mean
|
|
379
|
+
// yet, so saying so is better than serving bytes the page will misread.
|
|
380
|
+
throw new Error(
|
|
381
|
+
`${path.basename(real)} is not a JavaScript module, and \`uf test --browser\` serves no others yet`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
let body = cache.get(real);
|
|
385
|
+
if (body == null) {
|
|
386
|
+
body = buildModule(real, options);
|
|
387
|
+
cache.set(real, body);
|
|
388
|
+
}
|
|
389
|
+
return { body: await body, type: "text/javascript; charset=utf-8" };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A file's text, as the page should see it. */
|
|
393
|
+
async function buildModule(real: string, options: ServerOptions): Promise<string> {
|
|
394
|
+
const source = readFileSync(real, "utf8");
|
|
395
|
+
if (isCommonJs(real)) {
|
|
396
|
+
return wrapCommonJs(real, source);
|
|
397
|
+
}
|
|
398
|
+
if (!isFlowModule(real)) {
|
|
399
|
+
return source;
|
|
400
|
+
}
|
|
401
|
+
const transformed = await options.transform(real, source);
|
|
402
|
+
return transformed ?? source;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Whether Node would treat this file as CommonJS.
|
|
407
|
+
*
|
|
408
|
+
* Node's own rule rather than a look at the source: `.cjs` is CommonJS, `.mjs`
|
|
409
|
+
* is not, and a `.js` is whichever the nearest `package.json` says. Guessing
|
|
410
|
+
* from the text — "it says `require(`, so…" — gets a module that mentions the
|
|
411
|
+
* word in a comment wrong, and gets an ES module that assigns to a local called
|
|
412
|
+
* `exports` wrong in the other direction.
|
|
413
|
+
*/
|
|
414
|
+
function isCommonJs(file: string): boolean {
|
|
415
|
+
if (file.endsWith(".cjs")) return true;
|
|
416
|
+
if (file.endsWith(".mjs")) return false;
|
|
417
|
+
const manifest = nearestManifest(path.dirname(file));
|
|
418
|
+
return manifest?.type !== "module";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* A CommonJS module, as an ES module.
|
|
423
|
+
*
|
|
424
|
+
* # What this is and is not
|
|
425
|
+
*
|
|
426
|
+
* It is an adapter, not an implementation of CommonJS. React and the scheduler
|
|
427
|
+
* are the reason it exists: they ship CommonJS, `@uniflowed/react` re-exports
|
|
428
|
+
* React by name, and a browser cannot import any of it. Everything below is
|
|
429
|
+
* the smallest thing that makes those work, and each limit is a throw with a
|
|
430
|
+
* sentence rather than a silence:
|
|
431
|
+
*
|
|
432
|
+
* * `require` answers for the specifiers found in the module's own source and
|
|
433
|
+
* refuses every other. A `require` built at runtime — `require(name)` —
|
|
434
|
+
* cannot be served, because the import that would satisfy it has to be
|
|
435
|
+
* written before the page fetches the module.
|
|
436
|
+
* * There is no `require.cache`, no `require.resolve`, and no circular
|
|
437
|
+
* dependency support beyond what ESM already gives.
|
|
438
|
+
* * `process` is `{ env: { NODE_ENV: "development" } }` and nothing else, which
|
|
439
|
+
* is the whole of what a browser build of a CommonJS package reads.
|
|
440
|
+
*
|
|
441
|
+
* # Why the named exports are read from Node
|
|
442
|
+
*
|
|
443
|
+
* `export * from "react"` needs React's export *names*, statically, and a
|
|
444
|
+
* CommonJS module has none: they exist only after the body has run. Node
|
|
445
|
+
* solves this by lexing the source for assignments to `exports`; uf solves it
|
|
446
|
+
* by asking the module. The driver is a Node process, it can `require` the
|
|
447
|
+
* file, and the keys of what comes back are exactly the names — no lexer, no
|
|
448
|
+
* heuristics, and no drift between what the page sees and what the same
|
|
449
|
+
* `import` would see on a Node worker.
|
|
450
|
+
*
|
|
451
|
+
* A module that throws when Node loads it falls back to a default export
|
|
452
|
+
* alone. That is honest: the failure then arrives in the page, from the same
|
|
453
|
+
* code, with the browser's own message.
|
|
454
|
+
*/
|
|
455
|
+
function wrapCommonJs(file: string, source: string): string {
|
|
456
|
+
const specifiers = requiredSpecifiers(source);
|
|
457
|
+
const load = createRequire(pathToFileURL(file).href);
|
|
458
|
+
const imports: Array<string> = [];
|
|
459
|
+
const entries: Array<string> = [];
|
|
460
|
+
specifiers.forEach((specifier, index) => {
|
|
461
|
+
// Resolved here so the page is given a URL rather than a specifier its
|
|
462
|
+
// import map may not carry: a `require` of a relative path is not a bare
|
|
463
|
+
// specifier, and an import map has nothing to say about one.
|
|
464
|
+
let target: string;
|
|
465
|
+
try {
|
|
466
|
+
target = moduleUrl(load.resolve(specifier));
|
|
467
|
+
} catch {
|
|
468
|
+
// Left to the page. `require("node:fs")` inside a dependency is a real
|
|
469
|
+
// thing to write and a real thing to fail on, and the failure should
|
|
470
|
+
// name the specifier at the moment it is reached rather than stop the
|
|
471
|
+
// whole module from loading.
|
|
472
|
+
entries.push(
|
|
473
|
+
`${JSON.stringify(specifier)}: () => { throw new Error(${JSON.stringify(
|
|
474
|
+
`uf test --browser: a require of ${JSON.stringify(specifier)} in ${path.basename(file)} names something a page cannot be given`,
|
|
475
|
+
)}); }`,
|
|
476
|
+
);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
imports.push(`import * as __uf_${String(index)} from ${JSON.stringify(target)};`);
|
|
480
|
+
entries.push(
|
|
481
|
+
`${JSON.stringify(specifier)}: () => __uf_${String(index)}.default ?? __uf_${String(index)}`,
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const names = exportNames(load, file);
|
|
486
|
+
const reexports = names.map(
|
|
487
|
+
(name) => `export const ${name} = __uf_pick(${JSON.stringify(name)});`,
|
|
488
|
+
);
|
|
489
|
+
const unwritten = JSON.stringify(
|
|
490
|
+
`uf test --browser: ${path.basename(file)} does not contain a literal require of `,
|
|
491
|
+
);
|
|
492
|
+
const built = JSON.stringify(
|
|
493
|
+
", so no import could be made for it. A specifier built at run time cannot be served to a page.",
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
return [
|
|
497
|
+
...imports,
|
|
498
|
+
"const __uf_deps = {",
|
|
499
|
+
...entries.map((entry) => ` ${entry},`),
|
|
500
|
+
"};",
|
|
501
|
+
"const __uf_module = { exports: {} };",
|
|
502
|
+
"const __uf_require = (specifier) => {",
|
|
503
|
+
" const get = __uf_deps[specifier];",
|
|
504
|
+
" if (get == null) {",
|
|
505
|
+
` throw new Error(${unwritten} + JSON.stringify(specifier) + ${built});`,
|
|
506
|
+
" }",
|
|
507
|
+
" return get();",
|
|
508
|
+
"};",
|
|
509
|
+
'const __uf_process = globalThis.process ?? { env: { NODE_ENV: "development" }, platform: "browser", argv: [] };',
|
|
510
|
+
"(function (module, exports, require, process, __filename, __dirname) {",
|
|
511
|
+
source,
|
|
512
|
+
`})(__uf_module, __uf_module.exports, __uf_require, __uf_process, ${JSON.stringify(file)}, ${JSON.stringify(path.dirname(file))});`,
|
|
513
|
+
"const __uf_exports = __uf_module.exports;",
|
|
514
|
+
"const __uf_pick = (name) => (__uf_exports == null ? undefined : __uf_exports[name]);",
|
|
515
|
+
"export default __uf_exports;",
|
|
516
|
+
...reexports,
|
|
517
|
+
"",
|
|
518
|
+
].join("\n");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Every `require("…")` written in a source, in order, without repeats. */
|
|
522
|
+
function requiredSpecifiers(source: string): Array<string> {
|
|
523
|
+
const found: Array<string> = [];
|
|
524
|
+
// Deliberately generous. An extra entry costs one import of a module the
|
|
525
|
+
// page was going to be able to reach anyway; a missing one is a throw naming
|
|
526
|
+
// the specifier. Over-matching is the safe direction, which is why this can
|
|
527
|
+
// be a pattern rather than a parser.
|
|
528
|
+
const pattern = /\brequire\(\s*["']([^"'\n]+)["']\s*\)/g;
|
|
529
|
+
let match = pattern.exec(source);
|
|
530
|
+
while (match != null) {
|
|
531
|
+
const specifier = match[1];
|
|
532
|
+
if (!found.includes(specifier)) found.push(specifier);
|
|
533
|
+
match = pattern.exec(source);
|
|
534
|
+
}
|
|
535
|
+
return found;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The names a CommonJS module exports, as Node sees them. */
|
|
539
|
+
function exportNames(load: (specifier: string) => mixed, file: string): Array<string> {
|
|
540
|
+
let loaded: mixed;
|
|
541
|
+
try {
|
|
542
|
+
loaded = load(file);
|
|
543
|
+
} catch {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
// A CommonJS module is whatever `module.exports` ended up as, which is
|
|
547
|
+
// usually an object and is a function often enough to matter — `express` and
|
|
548
|
+
// half of npm export one. Both carry own enumerable keys and both are read
|
|
549
|
+
// here through the same indexed shape, which is the amount this function
|
|
550
|
+
// knows about either.
|
|
551
|
+
if (loaded == null || (typeof loaded !== "object" && typeof loaded !== "function")) {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
const exported: { readonly [string]: mixed } = loaded as $FlowFixMe;
|
|
555
|
+
const names = new Set<string>();
|
|
556
|
+
for (const name of Object.keys(exported)) {
|
|
557
|
+
// `default` is written separately, and a key that is not an identifier
|
|
558
|
+
// cannot be an export name. Both are dropped rather than mangled: a name
|
|
559
|
+
// nobody can import is not worth inventing a spelling for.
|
|
560
|
+
if (name !== "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !isReserved(name)) {
|
|
561
|
+
names.add(name);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return [...names];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Words that cannot be a `const`. */
|
|
568
|
+
const RESERVED: Set<string> = new Set([
|
|
569
|
+
"await",
|
|
570
|
+
"break",
|
|
571
|
+
"case",
|
|
572
|
+
"catch",
|
|
573
|
+
"class",
|
|
574
|
+
"const",
|
|
575
|
+
"continue",
|
|
576
|
+
"debugger",
|
|
577
|
+
"default",
|
|
578
|
+
"delete",
|
|
579
|
+
"do",
|
|
580
|
+
"else",
|
|
581
|
+
"enum",
|
|
582
|
+
"export",
|
|
583
|
+
"extends",
|
|
584
|
+
"false",
|
|
585
|
+
"finally",
|
|
586
|
+
"for",
|
|
587
|
+
"function",
|
|
588
|
+
"if",
|
|
589
|
+
"import",
|
|
590
|
+
"in",
|
|
591
|
+
"instanceof",
|
|
592
|
+
"new",
|
|
593
|
+
"null",
|
|
594
|
+
"return",
|
|
595
|
+
"super",
|
|
596
|
+
"switch",
|
|
597
|
+
"this",
|
|
598
|
+
"throw",
|
|
599
|
+
"true",
|
|
600
|
+
"try",
|
|
601
|
+
"typeof",
|
|
602
|
+
"var",
|
|
603
|
+
"void",
|
|
604
|
+
"while",
|
|
605
|
+
"with",
|
|
606
|
+
"yield",
|
|
607
|
+
"let",
|
|
608
|
+
"static",
|
|
609
|
+
]);
|
|
610
|
+
|
|
611
|
+
function isReserved(name: string): boolean {
|
|
612
|
+
return RESERVED.has(name);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The URL a file on disk is served at. */
|
|
616
|
+
export function moduleUrl(file: string): string {
|
|
617
|
+
return `${FILE_PREFIX}${realpathSync(file).split(path.sep).map(encodeURIComponent).join("/")}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The page, which is one import map and one module.
|
|
622
|
+
*
|
|
623
|
+
* Deliberately the smallest document that can hold a test: no stylesheet, no
|
|
624
|
+
* viewport meta, nothing that would make a measurement here disagree with a
|
|
625
|
+
* measurement in the application. A browser's defaults are the answer a
|
|
626
|
+
* component test wants, and every line added to this document is a line
|
|
627
|
+
* standing between the test and them.
|
|
628
|
+
*
|
|
629
|
+
* `<!doctype html>` is not decoration. Without it a browser is in quirks mode,
|
|
630
|
+
* where `box-sizing`, table cell heights and percentage heights all behave
|
|
631
|
+
* differently — a layout answer from a quirks-mode page would be a wrong
|
|
632
|
+
* answer wearing a real browser's authority.
|
|
633
|
+
*/
|
|
634
|
+
function harnessHtml(map: string, browserToken: string): string {
|
|
635
|
+
const page = moduleUrl(fileURLToPath(new URL("./page.js", import.meta.url).href));
|
|
636
|
+
return [
|
|
637
|
+
"<!doctype html>",
|
|
638
|
+
'<html lang="en">',
|
|
639
|
+
'<meta charset="utf-8">',
|
|
640
|
+
"<title>uf test</title>",
|
|
641
|
+
`<script>globalThis[Symbol.for("uf.test.browser.token")]=${JSON.stringify(browserToken)}</script>`,
|
|
642
|
+
`<script type="importmap">${map}</script>`,
|
|
643
|
+
`<script type="module" src="${page}"></script>`,
|
|
644
|
+
"",
|
|
645
|
+
].join("\n");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------- //
|
|
649
|
+
// The import map
|
|
650
|
+
// ---------------------------------------------------------------------- //
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Every package the project can reach, from `node_modules` rather than source.
|
|
654
|
+
*
|
|
655
|
+
* A bare specifier a file may legally write is a package installed somewhere
|
|
656
|
+
* above it, and *that* is a directory listing — no parser, no graph walk, no
|
|
657
|
+
* chance of disagreeing with what a file actually imports in a direction that
|
|
658
|
+
* matters. The map is allowed to be wider than the graph; a package nothing
|
|
659
|
+
* imports costs two lines of JSON and is never fetched.
|
|
660
|
+
*
|
|
661
|
+
* The order is Node's: the nearest `node_modules` first, climbing to the root.
|
|
662
|
+
* A workspace is covered without knowing what a workspace is, because a
|
|
663
|
+
* workspace is a symlink in `node_modules` and this reads links.
|
|
664
|
+
*
|
|
665
|
+
* # The one place this is narrower than Node
|
|
666
|
+
*
|
|
667
|
+
* Two copies of one package at two depths are one entry here and two modules
|
|
668
|
+
* on Node: an import map's `imports` is global, so the page gets whichever copy
|
|
669
|
+
* is nearest the project. Making it exact would mean a `scopes` entry per
|
|
670
|
+
* importing directory — the resolution algorithm written out as data — and the
|
|
671
|
+
* shape it would fix is a project whose test and whose component disagree about
|
|
672
|
+
* which React they mean, which is a broken install rather than a browser-mode
|
|
673
|
+
* problem.
|
|
674
|
+
*/
|
|
675
|
+
function crawlPackages(root: string): Array<Package> {
|
|
676
|
+
const found: Map<string, Package> = new Map();
|
|
677
|
+
const seen: Set<string> = new Set();
|
|
678
|
+
const queue: Array<string> = [];
|
|
679
|
+
|
|
680
|
+
// Every `node_modules` from the project up to the filesystem root, nearest
|
|
681
|
+
// first, then each installed package's own nested one.
|
|
682
|
+
let directory = root;
|
|
683
|
+
for (;;) {
|
|
684
|
+
queue.push(path.join(directory, "node_modules"));
|
|
685
|
+
const parent = path.dirname(directory);
|
|
686
|
+
if (parent === directory) break;
|
|
687
|
+
directory = parent;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
while (queue.length > 0 && found.size < MAX_PACKAGES) {
|
|
691
|
+
const modules = queue.shift();
|
|
692
|
+
if (modules == null || seen.has(modules)) continue;
|
|
693
|
+
seen.add(modules);
|
|
694
|
+
for (const name of packageNames(modules)) {
|
|
695
|
+
const linked = path.join(modules, name);
|
|
696
|
+
let real: string;
|
|
697
|
+
try {
|
|
698
|
+
real = realpathSync(linked);
|
|
699
|
+
} catch {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
// The first copy found wins, which is the nearest one; see above.
|
|
703
|
+
if (found.has(real)) continue;
|
|
704
|
+
const manifest = readManifest(path.join(real, "package.json"));
|
|
705
|
+
if (manifest == null) continue;
|
|
706
|
+
found.set(real, { directory: real, manifest });
|
|
707
|
+
queue.push(path.join(real, "node_modules"));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return [...found.values()];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The package names directly inside one `node_modules`.
|
|
715
|
+
*
|
|
716
|
+
* `@scope/name` is two directory levels and one name, which is the only thing
|
|
717
|
+
* this has to know about how npm lays a directory out.
|
|
718
|
+
*/
|
|
719
|
+
function packageNames(modules: string): Array<string> {
|
|
720
|
+
const names: Array<string> = [];
|
|
721
|
+
for (const entry of readDirectory(modules)) {
|
|
722
|
+
if (entry.startsWith(".")) continue;
|
|
723
|
+
if (entry.startsWith("@")) {
|
|
724
|
+
for (const scoped of readDirectory(path.join(modules, entry))) {
|
|
725
|
+
if (!scoped.startsWith(".")) names.push(`${entry}/${scoped}`);
|
|
726
|
+
}
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
names.push(entry);
|
|
730
|
+
}
|
|
731
|
+
return names;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** One directory's entries, or none when there is no such directory. */
|
|
735
|
+
function readDirectory(directory: string): Array<string> {
|
|
736
|
+
try {
|
|
737
|
+
return readdirSync(directory);
|
|
738
|
+
} catch {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** A manifest, or `null` when there is not one or it is not readable. */
|
|
744
|
+
function readManifest(file: string): Manifest | null {
|
|
745
|
+
try {
|
|
746
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
747
|
+
return parsed != null && typeof parsed === "object" ? parsed : null;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The nearest `package.json` at or above a directory.
|
|
755
|
+
*
|
|
756
|
+
* A `while` with a named stopping condition rather than a `for (;;)` with two
|
|
757
|
+
* `return`s in it: the checker cannot see that an unbounded loop always leaves
|
|
758
|
+
* through one of them, and a reader has to take the same thing on trust.
|
|
759
|
+
*/
|
|
760
|
+
function nearestManifest(from: string): Manifest | null {
|
|
761
|
+
let directory = from;
|
|
762
|
+
let parent = path.dirname(directory);
|
|
763
|
+
while (parent !== directory) {
|
|
764
|
+
const manifest = readManifest(path.join(directory, "package.json"));
|
|
765
|
+
if (manifest != null) return manifest;
|
|
766
|
+
directory = parent;
|
|
767
|
+
parent = path.dirname(directory);
|
|
768
|
+
}
|
|
769
|
+
return readManifest(path.join(directory, "package.json"));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** An import map, as the page's `<script type="importmap">`. */
|
|
773
|
+
export function importMap(packages: $ReadOnlyArray<Package>): string {
|
|
774
|
+
const imports: { [string]: string } = {};
|
|
775
|
+
const scopes: { [string]: { [string]: string } } = {};
|
|
776
|
+
|
|
777
|
+
for (const entry of packages) {
|
|
778
|
+
const name = typeof entry.manifest.name === "string" ? entry.manifest.name : null;
|
|
779
|
+
if (name == null) continue;
|
|
780
|
+
for (const [subpath, target] of entryPoints(entry)) {
|
|
781
|
+
imports[subpath === "." ? name : `${name}/${subpath.slice(2)}`] = target;
|
|
782
|
+
}
|
|
783
|
+
// The `browser` field, as a scope. It is the importing *package's*
|
|
784
|
+
// substitution — `@uniflowed/test` and `@uniflowed/host` map `node:module`
|
|
785
|
+
// to two different files, and both are right — so it cannot be a global
|
|
786
|
+
// entry, and a scope keyed on the package's own directory is exactly the
|
|
787
|
+
// shape of "when this package asks".
|
|
788
|
+
const substitutions = browserField(entry);
|
|
789
|
+
if (Object.keys(substitutions).length > 0) {
|
|
790
|
+
scopes[`${moduleUrl(entry.directory)}/`] = substitutions;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// A `node:` specifier written in a *test file* is deliberately not in here.
|
|
794
|
+
// Only a package that declared a `browser` substitution gets one, in its own
|
|
795
|
+
// scope; a test that imports `node:fs` and runs in a page should say so, and
|
|
796
|
+
// it does — the browser refuses the specifier by name, at the import.
|
|
797
|
+
return JSON.stringify({ imports, scopes }, null, 2);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The `subpath → URL` pairs one package publishes. */
|
|
801
|
+
function entryPoints(entry: Package): Array<[string, string]> {
|
|
802
|
+
const found: Array<[string, string]> = [];
|
|
803
|
+
const exported = entry.manifest.exports;
|
|
804
|
+
if (typeof exported === "string") {
|
|
805
|
+
found.push([".", join(entry, exported)]);
|
|
806
|
+
} else if (exported != null && typeof exported === "object" && !Array.isArray(exported)) {
|
|
807
|
+
const keys = Object.keys(exported);
|
|
808
|
+
// `{"import": …, "default": …}` with no `.` key is a conditions object for
|
|
809
|
+
// the package root rather than a subpath map, which is how React's own
|
|
810
|
+
// manifest is written.
|
|
811
|
+
if (!keys.some((key) => key === "." || key.startsWith("./"))) {
|
|
812
|
+
const target = condition(exported);
|
|
813
|
+
if (target != null) found.push([".", join(entry, target)]);
|
|
814
|
+
} else {
|
|
815
|
+
for (const key of keys) {
|
|
816
|
+
if (key !== "." && !key.startsWith("./")) continue;
|
|
817
|
+
// `./internal/*.js` cannot be an import-map key, but the prefix it
|
|
818
|
+
// stands for can: a trailing-slash key maps a whole directory, which
|
|
819
|
+
// covers every file the pattern would have matched.
|
|
820
|
+
const wildcard = key.indexOf("*");
|
|
821
|
+
const target = condition(exported[key]);
|
|
822
|
+
if (target == null) continue;
|
|
823
|
+
if (wildcard === -1) {
|
|
824
|
+
found.push([key, join(entry, target)]);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const targetWildcard = target.indexOf("*");
|
|
828
|
+
if (targetWildcard === -1) continue;
|
|
829
|
+
found.push([key.slice(0, wildcard), join(entry, target.slice(0, targetWildcard))]);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (found.length === 0) {
|
|
834
|
+
const main = entry.manifest.module ?? entry.manifest.main ?? "./index.js";
|
|
835
|
+
found.push([".", join(entry, main)]);
|
|
836
|
+
}
|
|
837
|
+
// `package.json` is importable from several of uf's packages and is never in
|
|
838
|
+
// an `exports` map's wildcards.
|
|
839
|
+
found.push(["./package.json", join(entry, "./package.json")]);
|
|
840
|
+
return found;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The first condition this page honours, following nested objects. */
|
|
844
|
+
function condition(target: mixed): string | null {
|
|
845
|
+
if (typeof target === "string") return target;
|
|
846
|
+
if (target == null || typeof target !== "object" || Array.isArray(target)) return null;
|
|
847
|
+
for (const name of CONDITIONS) {
|
|
848
|
+
if (name in target) {
|
|
849
|
+
const chosen = condition(target[name]);
|
|
850
|
+
if (chosen != null) return chosen;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* A package-relative target, as a URL.
|
|
858
|
+
*
|
|
859
|
+
* The trailing slash is carried through by hand, because `path.join` removes
|
|
860
|
+
* one and an import map is strict about it: a key ending in `/` may only map to
|
|
861
|
+
* a value ending in `/`, and a pair that breaks that rule is dropped by the
|
|
862
|
+
* browser with a console warning nobody is reading. That pair is how a
|
|
863
|
+
* wildcard subpath — `"./internal/*.js"` — reaches the page at all.
|
|
864
|
+
*/
|
|
865
|
+
function join(entry: Package, target: string): string {
|
|
866
|
+
const cleaned = target.startsWith("./") ? target.slice(2) : target;
|
|
867
|
+
const absolute = path.join(entry.directory, cleaned);
|
|
868
|
+
const slash = target.endsWith("/") && !absolute.endsWith(path.sep) ? "/" : "";
|
|
869
|
+
// Not `moduleUrl`, which stats: an `exports` map may name a file a published
|
|
870
|
+
// package has and this checkout does not, and a missing entry point should
|
|
871
|
+
// fail when something imports it rather than stop the map being built.
|
|
872
|
+
return `${FILE_PREFIX}${absolute.split(path.sep).map(encodeURIComponent).join("/")}${slash}`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** A package's `browser` substitutions, as an import map's scope body. */
|
|
876
|
+
function browserField(entry: Package): { [string]: string } {
|
|
877
|
+
const field = entry.manifest.browser;
|
|
878
|
+
const substitutions: { [string]: string } = {};
|
|
879
|
+
if (field == null || typeof field !== "object" || Array.isArray(field)) {
|
|
880
|
+
return substitutions;
|
|
881
|
+
}
|
|
882
|
+
for (const key of Object.keys(field)) {
|
|
883
|
+
const target = field[key];
|
|
884
|
+
// `false` means "replace with an empty module", which this does not do:
|
|
885
|
+
// an empty module is a silence, and silence is what the whole `browser`
|
|
886
|
+
// shim in this package exists not to be. Nothing uf ships uses it.
|
|
887
|
+
if (typeof target !== "string") continue;
|
|
888
|
+
substitutions[key.startsWith(".") ? join(entry, key) : key] = target.startsWith(".")
|
|
889
|
+
? join(entry, target)
|
|
890
|
+
: target;
|
|
891
|
+
}
|
|
892
|
+
return substitutions;
|
|
893
|
+
}
|