@uniflowed/vite 0.0.0-alpha.35 → 0.0.0-alpha.37

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,459 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // `import { Switch } from "@uniflowed/ui"`, as the bundler has to see it: an
6
+ // import from `switch.js` (ubugeeei-prod/uf#1118).
7
+ //
8
+ // # Why a barrel import is rewritten at all
9
+ //
10
+ // `@uniflowed/ui` is imported through its barrel, which re-exports every one of
11
+ // its modules, and `sideEffects: false` lets a bundler drop the modules a page
12
+ // does not use. But that happens when a build tree-shakes, and two things
13
+ // happen before it:
14
+ //
15
+ // * **The rsc pass records client modules as it transforms them.**
16
+ // `clientReferencePlugin` in `./flight.js` makes every `"use client"` module
17
+ // the rsc graph *loads* an entry of the client build. A page that imported
18
+ // `{ Switch }` loaded the barrel, the barrel loaded all forty-one modules,
19
+ // and the client build of one switch was 57 files and 146 KB gzipped where
20
+ // an import of `switch.js` alone is 6 files and 88 KB.
21
+ // * **A development server evaluates what is imported.** Nothing is
22
+ // tree-shaken under `uf dev`, so a page using one part ran all of them.
23
+ //
24
+ // So a named import from the barrel becomes an import from the file that
25
+ // defines the name, before either happens: the idea behind Next.js's
26
+ // `optimizePackageImports`. `uf_rsc` draws the client boundary of such an
27
+ // import at that module too (`uf_lib::client_modules_exporting`), so the
28
+ // analysis and the bundle agree about what a name reaches.
29
+ //
30
+ // # Read off the barrel the project resolves
31
+ //
32
+ // Which file defines a name is read from the barrel itself: its static
33
+ // `import { A } from "./a.js"` and `export { A } from "./a.js"` statements, and
34
+ // the object literals it builds its namespaces from. Not a table kept here,
35
+ // because the barrel a project installed is the one whose names count.
36
+ //
37
+ // A name that reading cannot place stays an import from the barrel, and so does
38
+ // every form that binds no name — `import * as ui`, `export * from` and
39
+ // `import()` — which is the rule `uf_rsc` applies to the same forms. A barrel
40
+ // import left alone costs size, never correctness, for the next reason.
41
+ //
42
+ // # One module, however it is reached
43
+ //
44
+ // A target is the file's absolute path, not a package subpath, so the package
45
+ // can export `.` alone. It is the path the barrel's own `./switch.js` resolves
46
+ // to, and Vite gives an absolute, a relative and a bare import of one file the
47
+ // same id in every environment — the `?v=` a development server adds to a file
48
+ // in `node_modules` included. So a module still loading the barrel and a module
49
+ // rewritten past it share one `switch.js`, and one React context between their
50
+ // parts.
51
+ //
52
+ // # Namespaces
53
+ //
54
+ // `Dialog` is not an export of `dialog.js`. It is an object the barrel builds
55
+ // from `dialog.js`'s parts, and `ContextMenu`'s is built from two modules. An
56
+ // import of one is served from a view of the barrel, `index.js?uf-namespace=Dialog`:
57
+ // a module generated here that imports those parts and builds the same object.
58
+ // It is the barrel's own path with a query, so its relative imports resolve the
59
+ // way the barrel's do.
60
+
61
+ import { readFileSync, statSync } from "node:fs";
62
+ import path from "node:path";
63
+
64
+ import { normalizePath, parseAst } from "vite";
65
+
66
+ /** The packages whose barrel imports are rewritten. */
67
+ export const BARREL_PACKAGES = Object.freeze(["@uniflowed/ui"]);
68
+
69
+ /** The query that makes a barrel's path a view of one of its namespaces. */
70
+ export const NAMESPACE_QUERY = "uf-namespace";
71
+
72
+ /** The module kinds whose imports are read, once earlier plugins made them JavaScript. */
73
+ const SCRIPT = /\.(?:[cm]?[jt]sx?|mdx)$/;
74
+
75
+ /** A name the barrel exports that no rewrite can place. */
76
+ const OPAQUE = Object.freeze({ kind: "opaque" });
77
+
78
+ /**
79
+ * The plugin that rewrites named imports from a barrel to the files defining
80
+ * them, in every environment.
81
+ *
82
+ * A normal plugin, so it runs after `uf:flow` and `uf:mdx` and after Vite's own
83
+ * transforms: whatever the module was written in, it is JavaScript by the time
84
+ * its imports are read.
85
+ */
86
+ export function barrelImportsPlugin() {
87
+ /** Each barrel's reading, keyed by its file and kept while the file is unchanged. */
88
+ const readings = new Map();
89
+ /** The barrels already reported as unreadable, so each is reported once. */
90
+ const reported = new Set();
91
+
92
+ return {
93
+ name: "uf:barrel-imports",
94
+
95
+ async transform(code, id) {
96
+ if (id.startsWith("\0") || namespaceViewOf(id) != null) return null;
97
+ if (!BARREL_PACKAGES.some((name) => code.includes(name))) return null;
98
+ if (!SCRIPT.test(cleanId(id))) return null;
99
+ let program;
100
+ try {
101
+ program = parseAst(code);
102
+ } catch {
103
+ return null;
104
+ }
105
+ const statements = program.body.filter(importsFromBarrel);
106
+ if (statements.length === 0) return null;
107
+
108
+ const edits = [];
109
+ for (const name of BARREL_PACKAGES) {
110
+ const own = statements.filter((node) => node.source.value === name);
111
+ if (own.length === 0) continue;
112
+ const resolved = await this.resolve(name, id, { skipSelf: true });
113
+ // Left to whatever loads an external module, which reads the barrel
114
+ // as a whole; nothing here can change what that loads.
115
+ if (resolved == null || resolved.external) continue;
116
+ const file = cleanId(resolved.id);
117
+ const barrel = readBarrel(readings, file);
118
+ if (barrel.problem != null) {
119
+ if (!reported.has(file)) {
120
+ reported.add(file);
121
+ this.warn(
122
+ `uf: could not read which file defines each export of ${name} (${barrel.problem}), ` +
123
+ "so a module importing from it loads every module it re-exports",
124
+ );
125
+ }
126
+ continue;
127
+ }
128
+ this.addWatchFile?.(file);
129
+ for (const node of own) {
130
+ const replacement = rewriteStatement(node, barrel.exports, file);
131
+ if (replacement != null) edits.push({ node, replacement });
132
+ }
133
+ }
134
+ if (edits.length === 0) return null;
135
+ return { code: applyEdits(code, edits), map: null };
136
+ },
137
+
138
+ load(id) {
139
+ const view = namespaceViewOf(id);
140
+ if (view == null) return null;
141
+ return namespaceViewSource(readBarrel(readings, view.file).exports, view.file, view.name);
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The barrel and the namespace a view module stands for, or `null` for any
148
+ * other id.
149
+ *
150
+ * `uf:flow` asks too: a view has the barrel's path and extension, and is not
151
+ * the barrel's Flow source.
152
+ */
153
+ export function namespaceViewOf(id) {
154
+ const at = id.indexOf("?");
155
+ if (at === -1 || id.startsWith("\0")) return null;
156
+ const name = new URLSearchParams(id.slice(at + 1)).get(NAMESPACE_QUERY);
157
+ return name == null || name === "" ? null : { file: id.slice(0, at), name };
158
+ }
159
+
160
+ /**
161
+ * Where each name a barrel exports is defined, read from its source.
162
+ *
163
+ * The barrel is Flow, read with the TypeScript grammar: the only type syntax a
164
+ * barrel has is `export type { A } from` and `import { type A }`, which the two
165
+ * grammars spell alike. A barrel this cannot parse throws, and its importers
166
+ * are left as they were.
167
+ *
168
+ * Each name maps to one of three things:
169
+ *
170
+ * * `{ kind: "binding", file, name }` — an export of another module, passed
171
+ * through under this name;
172
+ * * `{ kind: "namespace", parts }` — an object literal whose every property
173
+ * is such a binding, each part `{ key, file, name }`;
174
+ * * `OPAQUE` — anything else, which stays an import from the barrel.
175
+ *
176
+ * @param {string} source
177
+ * @param {string} file the barrel's absolute path
178
+ * @returns {Map<string, object>}
179
+ */
180
+ export function barrelExports(source, file) {
181
+ const program = parseAst(source, { lang: "ts" });
182
+ const directory = path.dirname(file);
183
+ const fileOf = (node) =>
184
+ typeof node?.value === "string" && /^\.\.?\//.test(node.value)
185
+ ? normalizePath(path.join(directory, node.value))
186
+ : null;
187
+
188
+ // Every binding an import made, and every `const` object, before any export
189
+ // is read: `export { Dialog }` may come before the `const` it names.
190
+ const bindings = new Map();
191
+ const objects = new Map();
192
+ for (const node of program.body) {
193
+ if (node.type === "ImportDeclaration" && node.importKind !== "type") {
194
+ const from = fileOf(node.source);
195
+ for (const specifier of node.specifiers) {
196
+ if (specifier.importKind === "type") continue;
197
+ const imported =
198
+ specifier.type === "ImportSpecifier"
199
+ ? nameOf(specifier.imported)
200
+ : specifier.type === "ImportDefaultSpecifier"
201
+ ? "default"
202
+ : null;
203
+ bindings.set(
204
+ specifier.local.name,
205
+ from == null || imported == null ? null : { file: from, name: imported },
206
+ );
207
+ }
208
+ }
209
+ const declaration = node.type === "ExportNamedDeclaration" ? node.declaration : node;
210
+ if (declaration?.type === "VariableDeclaration" && declaration.kind === "const") {
211
+ for (const declarator of declaration.declarations) {
212
+ if (declarator.id.type === "Identifier" && declarator.init?.type === "ObjectExpression") {
213
+ objects.set(declarator.id.name, declarator.init);
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ const local = (name) => {
220
+ const binding = bindings.get(name);
221
+ if (binding != null) return { kind: "binding", ...binding };
222
+ const object = objects.get(name);
223
+ return object == null ? OPAQUE : namespaceOf(object, bindings);
224
+ };
225
+
226
+ const exports = new Map();
227
+ for (const node of program.body) {
228
+ if (node.type === "ExportDefaultDeclaration") {
229
+ exports.set("default", OPAQUE);
230
+ }
231
+ if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue;
232
+ const { declaration } = node;
233
+ if (declaration != null) {
234
+ if (declaration.type === "VariableDeclaration") {
235
+ for (const declarator of declaration.declarations) {
236
+ if (declarator.id.type === "Identifier") {
237
+ exports.set(declarator.id.name, local(declarator.id.name));
238
+ }
239
+ }
240
+ } else if (declaration.id?.type === "Identifier" && !declaration.type.startsWith("TS")) {
241
+ exports.set(declaration.id.name, OPAQUE);
242
+ }
243
+ continue;
244
+ }
245
+ const from = node.source == null ? null : fileOf(node.source);
246
+ for (const specifier of node.specifiers) {
247
+ if (specifier.exportKind === "type") continue;
248
+ const name = nameOf(specifier.local);
249
+ exports.set(
250
+ nameOf(specifier.exported),
251
+ node.source == null
252
+ ? local(name)
253
+ : from == null
254
+ ? OPAQUE
255
+ : { kind: "binding", file: from, name },
256
+ );
257
+ }
258
+ }
259
+ return exports;
260
+ }
261
+
262
+ /**
263
+ * The module a view of a barrel's namespace is: the parts, imported from their
264
+ * files, and the object the barrel builds from them.
265
+ *
266
+ * A name that is not a namespace the barrel builds — the barrel changed under a
267
+ * development server after an importer was rewritten — is re-exported from the
268
+ * barrel itself, which answers correctly, if slowly, or with the bundler's own
269
+ * error for a name that is gone.
270
+ *
271
+ * @param {Map<string, object>} exports what `barrelExports` read
272
+ * @param {string} barrel the barrel's absolute path
273
+ * @param {string} name the namespace
274
+ */
275
+ export function namespaceViewSource(exports, barrel, name) {
276
+ const target = exports.get(name);
277
+ const directory = path.dirname(barrel);
278
+ const specifierOf = (file) => {
279
+ const relative = normalizePath(path.relative(directory, file));
280
+ return JSON.stringify(relative.startsWith("../") ? relative : `./${relative}`);
281
+ };
282
+ if (target?.kind !== "namespace") {
283
+ return `export { ${printName(name)} } from ${specifierOf(barrel)};\n`;
284
+ }
285
+ const locals = new Map();
286
+ const taken = new Set();
287
+ const imports = new Map();
288
+ for (const part of target.parts) {
289
+ const key = `${part.file}\0${part.name}`;
290
+ if (locals.has(key)) continue;
291
+ let alias = IDENTIFIER.test(part.name) ? part.name : "part";
292
+ while (taken.has(alias)) alias = `${alias}$`;
293
+ taken.add(alias);
294
+ locals.set(key, alias);
295
+ const specifiers = imports.get(part.file) ?? [];
296
+ specifiers.push(alias === part.name ? alias : `${printName(part.name)} as ${alias}`);
297
+ imports.set(part.file, specifiers);
298
+ }
299
+ const lines = [...imports].map(
300
+ ([file, specifiers]) => `import { ${specifiers.join(", ")} } from ${specifierOf(file)};`,
301
+ );
302
+ const properties = target.parts.map(
303
+ (part) => ` ${printName(part.key)}: ${locals.get(`${part.file}\0${part.name}`)},`,
304
+ );
305
+ lines.push(`export const ${name} = {`, ...properties, "};");
306
+ return `${lines.join("\n")}\n`;
307
+ }
308
+
309
+ /** An import or re-export whose source is one of `BARREL_PACKAGES`. */
310
+ function importsFromBarrel(node) {
311
+ if (node.type === "ImportDeclaration") {
312
+ return node.importKind !== "type" && BARREL_PACKAGES.includes(node.source.value);
313
+ }
314
+ return (
315
+ node.type === "ExportNamedDeclaration" &&
316
+ node.source != null &&
317
+ node.exportKind !== "type" &&
318
+ BARREL_PACKAGES.includes(node.source.value)
319
+ );
320
+ }
321
+
322
+ /**
323
+ * The statements one import or re-export from a barrel becomes, as source, or
324
+ * `null` to leave it as it was.
325
+ *
326
+ * @param {object} node the statement
327
+ * @param {Map<string, object>} exports the barrel's reading
328
+ * @param {string} barrel the barrel's absolute path
329
+ */
330
+ function rewriteStatement(node, exports, barrel) {
331
+ const isImport = node.type === "ImportDeclaration";
332
+ const specifiers = node.specifiers;
333
+ // A bare import binds nothing, and the barrel declares it has no side
334
+ // effects: what it loads is every module, for nothing.
335
+ if (isImport && specifiers.length === 0) return "";
336
+ // `import * as ui` names nothing that can be placed; see the header.
337
+ if (specifiers.some((specifier) => specifier.type === "ImportNamespaceSpecifier")) return null;
338
+
339
+ const kept = [];
340
+ let keptDefault = null;
341
+ const moved = new Map();
342
+ for (const specifier of specifiers) {
343
+ if (specifier.type === "ImportDefaultSpecifier") {
344
+ keptDefault = specifier.local.name;
345
+ continue;
346
+ }
347
+ const exported = nameOf(isImport ? specifier.imported : specifier.local);
348
+ const binding = isImport ? specifier.local.name : nameOf(specifier.exported);
349
+ const printed = (name) =>
350
+ name === binding ? printName(name) : `${printName(name)} as ${printName(binding)}`;
351
+ const target = specifier.importKind === "type" ? OPAQUE : (exports.get(exported) ?? OPAQUE);
352
+ if (target.kind === "opaque") {
353
+ kept.push(printed(exported));
354
+ continue;
355
+ }
356
+ const [source, name] =
357
+ target.kind === "binding"
358
+ ? [target.file, target.name]
359
+ : [`${normalizePath(barrel)}?${NAMESPACE_QUERY}=${encodeURIComponent(exported)}`, exported];
360
+ const list = moved.get(source) ?? [];
361
+ list.push(printed(name));
362
+ moved.set(source, list);
363
+ }
364
+ if (moved.size === 0) return null;
365
+
366
+ const keyword = isImport ? "import" : "export";
367
+ const statements = [];
368
+ if (kept.length > 0 || keptDefault != null) {
369
+ const clause = [keptDefault, kept.length > 0 ? `{ ${kept.join(", ")} }` : null]
370
+ .filter(Boolean)
371
+ .join(", ");
372
+ statements.push(`${keyword} ${clause} from ${JSON.stringify(node.source.value)};`);
373
+ }
374
+ for (const [source, list] of moved) {
375
+ statements.push(`${keyword} { ${list.join(", ")} } from ${JSON.stringify(source)};`);
376
+ }
377
+ return statements.join("\n");
378
+ }
379
+
380
+ /**
381
+ * `code` with each statement replaced, and every line after it where it was.
382
+ *
383
+ * No source map: the only lines that change are the imports themselves, and
384
+ * each replacement is padded to the line count it replaced, so a position
385
+ * anywhere after them maps as it did — the reason `clientReferencePlugin`
386
+ * returns `map: null` too.
387
+ */
388
+ function applyEdits(code, edits) {
389
+ let out = "";
390
+ let at = 0;
391
+ for (const { node, replacement } of [...edits].sort((a, b) => a.node.start - b.node.start)) {
392
+ const original = code.slice(node.start, node.end);
393
+ const lines = original.split("\n").length - 1;
394
+ const written = replacement.split("\n").length - 1;
395
+ const text =
396
+ written <= lines
397
+ ? `${replacement}${"\n".repeat(lines - written)}`
398
+ : `${replacement.split("\n").join(" ")}${"\n".repeat(lines)}`;
399
+ out += code.slice(at, node.start) + text;
400
+ at = node.end;
401
+ }
402
+ return out + code.slice(at);
403
+ }
404
+
405
+ /** An object literal of imported bindings, as a namespace, or `OPAQUE`. */
406
+ function namespaceOf(object, bindings) {
407
+ const parts = [];
408
+ for (const property of object.properties) {
409
+ if (property.type !== "Property" || property.kind !== "init" || property.computed) {
410
+ return OPAQUE;
411
+ }
412
+ if (property.method || property.value.type !== "Identifier") return OPAQUE;
413
+ const key =
414
+ property.key.type === "Identifier"
415
+ ? property.key.name
416
+ : typeof property.key.value === "string"
417
+ ? property.key.value
418
+ : null;
419
+ const binding = bindings.get(property.value.name);
420
+ if (key == null || binding == null) return OPAQUE;
421
+ parts.push({ key, ...binding });
422
+ }
423
+ return { kind: "namespace", parts };
424
+ }
425
+
426
+ /** A barrel's reading, re-read only when its size or modification time changes. */
427
+ function readBarrel(readings, file) {
428
+ let stats;
429
+ try {
430
+ stats = statSync(file);
431
+ } catch (error) {
432
+ return { exports: new Map(), problem: error.message };
433
+ }
434
+ const known = readings.get(file);
435
+ if (known != null && known.mtimeMs === stats.mtimeMs && known.size === stats.size) return known;
436
+ const reading = { mtimeMs: stats.mtimeMs, size: stats.size, exports: new Map(), problem: null };
437
+ try {
438
+ reading.exports = barrelExports(readFileSync(file, "utf8"), file);
439
+ } catch (error) {
440
+ reading.problem = error.message;
441
+ }
442
+ readings.set(file, reading);
443
+ return reading;
444
+ }
445
+
446
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
447
+
448
+ function nameOf(node) {
449
+ return node.type === "Identifier" ? node.name : String(node.value);
450
+ }
451
+
452
+ function printName(name) {
453
+ return IDENTIFIER.test(name) ? name : JSON.stringify(name);
454
+ }
455
+
456
+ function cleanId(id) {
457
+ const at = id.indexOf("?");
458
+ return at === -1 ? id : id.slice(0, at);
459
+ }
@@ -198,6 +198,29 @@ export function rscEnvironment({ production, exclude }) {
198
198
  };
199
199
  }
200
200
 
201
+ /**
202
+ * What the browser's graph pre-bundles for an application React Server
203
+ * Components render: React's Flight client, spelled the way the router's
204
+ * `internal/flight-browser.js` imports it, because the optimizer finds a
205
+ * pre-bundled dependency by the specifier that imports it.
206
+ *
207
+ * Named rather than left to be discovered, because in a project that installs
208
+ * the router nothing discovers it (ubugeeei-prod/uf#1126). The package is
209
+ * CommonJS. `uf:flow` excludes every `@uniflowed/*` package from the optimizer,
210
+ * since they ship Flow, and Vite pre-bundles a dependency it first meets while
211
+ * serving only when the module importing it is outside `node_modules`. An
212
+ * installed router is inside it, so the browser was sent the CommonJS file and
213
+ * hydration stopped at "does not provide an export named 'createFromFetch'". A
214
+ * linked router, the layout of this repository, is outside it, which is why
215
+ * nothing here failed.
216
+ *
217
+ * It resolves from the project, which installs `react-server-dom-parcel` as the
218
+ * router's peer.
219
+ */
220
+ export const FLIGHT_BROWSER_DEPENDENCIES = Object.freeze([
221
+ "react-server-dom-parcel/client.browser",
222
+ ]);
223
+
201
224
  /**
202
225
  * The state the plugins and the driver share for one application.
203
226
  *
@@ -285,6 +308,70 @@ export function clientReferencePlugin(state) {
285
308
  };
286
309
  }
287
310
 
311
+ /**
312
+ * The plugin that gives a file of a `@uniflowed/*` package one URL in the
313
+ * browser under `uf dev`: its path, with no `?v=`.
314
+ *
315
+ * A client reference names the URL `devUrlOf` gives its file, and the browser
316
+ * imports that URL when a payload names it. Vite gave the same file a second
317
+ * URL when a client module imported it: a file in `node_modules` of a package
318
+ * the dependency optimizer excludes — every `@uniflowed/*` package, because
319
+ * they ship Flow — carries `?v=` and the optimizer's hash. A browser keys a
320
+ * module by its URL, so those were two modules. `Dialog.Root` rendered by a
321
+ * server component and `Dialog.Trigger` rendered by a client component held
322
+ * two `DialogContext`s, and hydration threw "Dialog.Trigger must be rendered
323
+ * inside a Dialog.Root". Only in a project that installed its packages, since a
324
+ * linked package is not in `node_modules` and gets no query: every project but
325
+ * this repository.
326
+ *
327
+ * The query only lets the browser cache the file without asking, so dropping it
328
+ * costs a revalidation. `pre`, so it can ask Vite's resolver first and drop
329
+ * what that added; only in the browser's graph, because the rsc graph records a
330
+ * client module by its path already and the ssr graph has no optimizer.
331
+ */
332
+ export function clientModuleUrlPlugin() {
333
+ return {
334
+ name: "uf:rsc-client-urls",
335
+ apply: "serve",
336
+ enforce: "pre",
337
+ applyToEnvironment(environment) {
338
+ return environment.name === "client";
339
+ },
340
+ async resolveId(id, importer, options) {
341
+ if (!reachesUniflowedPackage(id, importer)) return null;
342
+ const resolved = await this.resolve(id, importer, { ...options, skipSelf: true });
343
+ if (resolved == null) return null;
344
+ const unversioned = withoutVersion(resolved.id);
345
+ return unversioned === resolved.id ? resolved : { ...resolved, id: unversioned };
346
+ },
347
+ };
348
+ }
349
+
350
+ /** Where every `@uniflowed/*` package is, installed, whatever manages `node_modules`. */
351
+ const UNIFLOWED_FILES = "/node_modules/@uniflowed/";
352
+
353
+ /**
354
+ * Whether an import can resolve to a file of an installed `@uniflowed/*`
355
+ * package: a bare import of one, a path or URL into one, or a relative import
356
+ * from inside one. Everything else is left to Vite without a second resolution.
357
+ */
358
+ function reachesUniflowedPackage(id, importer) {
359
+ if (id.startsWith("\0")) return false;
360
+ if (id.startsWith("@uniflowed/") || id.includes(UNIFLOWED_FILES)) return true;
361
+ return /^\.\.?\//.test(id) && typeof importer === "string" && importer.includes(UNIFLOWED_FILES);
362
+ }
363
+
364
+ /** `id` without the optimizer's `v=`, for a file of an installed `@uniflowed/*` package. */
365
+ function withoutVersion(id) {
366
+ const at = id.indexOf("?");
367
+ if (at === -1 || !id.slice(0, at).includes(UNIFLOWED_FILES)) return id;
368
+ const kept = id
369
+ .slice(at + 1)
370
+ .split("&")
371
+ .filter((parameter) => !/^v=[\w.-]*$/.test(parameter));
372
+ return kept.length === 0 ? id.slice(0, at) : `${id.slice(0, at)}?${kept.join("&")}`;
373
+ }
374
+
288
375
  /** The module kinds a reference can stand in for. */
289
376
  const SCRIPT = /\.(?:[cm]?js|jsx|mdx)$/;
290
377
 
@@ -407,9 +494,14 @@ export function fsFileOf(pathname) {
407
494
  }
408
495
 
409
496
  /** `virtual:uf/rsc`: the Flight renderer over the rsc graph's route table. */
410
- export function rscEntrySource(routesId) {
411
- return `import { createFlightRenderer } from "@uniflowed/router/rsc";
497
+ export function rscEntrySource(routesId, routing = {}) {
498
+ const settings = {
499
+ basePath: routing.basePath ?? "",
500
+ trailingSlash: routing.trailingSlash ?? "ignore",
501
+ };
502
+ return `import { createFlightRenderer, installRouting } from "@uniflowed/router/rsc";
412
503
  import { routes, notFound, errors } from ${JSON.stringify(routesId)};
504
+ installRouting(${JSON.stringify(settings)});
413
505
  export { routes, notFound, errors };
414
506
  export const renderFlight = createFlightRenderer({ routes, notFound, errors });
415
507
  `;
@@ -512,15 +604,28 @@ export function loadClientModule(url) {
512
604
  *
513
605
  * No route table: the browser resolves no route and imports no page. What it
514
606
  * has is the application root and the payload the document carries, which
515
- * `hydrateFlight` reads. Strict Mode and navigation are generated constants
516
- * for the reasons `clientModuleSource` in `./routes.js` gives.
607
+ * `hydrateFlight` reads. That function comes from `@uniflowed/router/rsc/client`,
608
+ * not from `@uniflowed/router/client`, the entry an application rendered from
609
+ * its modules starts from. So only this kind of application has React's Flight
610
+ * client in its bundle: `react-server-dom-parcel` is an optional peer of the
611
+ * router, and a project on React 19.2 does not install it (ubugeeei-prod/uf#992).
612
+ * Strict Mode and navigation are generated constants for the reasons
613
+ * `clientModuleSource` in `./routes.js` gives.
517
614
  */
518
615
  export function flightClientSource(appEntry, options = {}) {
519
616
  const strictMode = options.strictMode === true ? ", strictMode: true" : "";
520
617
  const navigation = options.navigation === "document" ? ', navigation: "document"' : "";
521
- return `import { hydrateFlight } from "@uniflowed/router/client";
618
+ // `routes.js`'s `routingArgumentSource`, spelled here too: that module
619
+ // imports this one, and a default project's entry has to stay the module it
620
+ // was, so nothing is written for the root and the default policy.
621
+ const basePath = options.routing?.basePath ?? "";
622
+ const trailingSlash = options.routing?.trailingSlash ?? "ignore";
623
+ const routing =
624
+ (basePath === "" ? "" : `, basePath: ${JSON.stringify(basePath)}`) +
625
+ (trailingSlash === "ignore" ? "" : `, trailingSlash: ${JSON.stringify(trailingSlash)}`);
626
+ return `import { hydrateFlight } from "@uniflowed/router/rsc/client";
522
627
  import App from ${JSON.stringify(appEntry)};
523
- hydrateFlight({ App${strictMode}${navigation} });
628
+ hydrateFlight({ App${strictMode}${navigation}${routing} });
524
629
  `;
525
630
  }
526
631
 
@@ -529,24 +634,33 @@ hydrateFlight({ App${strictMode}${navigation} });
529
634
  *
530
635
  * The exports and their order are `serverModuleSource`'s in `./routes.js`, and
531
636
  * that comment is the argument for them. Two things differ. The renderer is
532
- * `createDocumentRenderer`, which renders the payload the rsc graph writes
533
- * rather than the route's modules, and adds `flight` for a browser that is
534
- * navigating. And `routes`, `notFound` and `errors` come through the bridge,
637
+ * `createDocumentRenderer` from `@uniflowed/router/rsc/ssr`, an entry of its own
638
+ * for the reason `flightClientSource` gives. It renders the payload the rsc graph
639
+ * writes rather than the route's modules, and adds `flight` for a browser that
640
+ * is navigating. And `routes`, `notFound` and `errors` come through the bridge,
535
641
  * because the page modules they import are the rsc graph's: the driver reads a
536
642
  * page's `generateStaticParams` from the graph that renders it.
537
643
  */
538
- export function flightServerSource(appEntry, routesId, actionsId) {
644
+ export function flightServerSource(
645
+ appEntry,
646
+ routesId,
647
+ actionsId,
648
+ routing = { redirects: [], rewrites: [], headers: [], basePath: "", trailingSlash: "ignore" },
649
+ ) {
539
650
  return `import {
540
651
  createActionDispatcher,
541
652
  createDispatcher,
542
- createDocumentRenderer,
543
653
  createMiddlewareRunner,
654
+ installRouting,
544
655
  } from "@uniflowed/router/server";
656
+ import { createDocumentRenderer } from "@uniflowed/router/rsc/ssr";
545
657
  import { handlers, middleware } from ${JSON.stringify(routesId)};
546
658
  import { actions } from ${JSON.stringify(actionsId)};
547
659
  import { renderFlight, routes, notFound, errors } from ${JSON.stringify(FLIGHT_VIRTUAL.bridge)};
548
660
  import { loadClientModule } from ${JSON.stringify(FLIGHT_VIRTUAL.references)};
549
661
  import App from ${JSON.stringify(appEntry)};
662
+ export const routing = ${JSON.stringify(routing)};
663
+ installRouting(routing);
550
664
  export { routes, handlers, middleware, notFound, errors };
551
665
  export { beginRequest } from "@uniflowed/router/server";
552
666
  const renderer = createDocumentRenderer({ App, renderFlight, loadClientModule });
package/internal/http.js CHANGED
@@ -25,13 +25,18 @@
25
25
  * that accepts an upload should not need the whole thing buffered before it
26
26
  * starts.
27
27
  *
28
+ * `path` names the path and query to build it at, when that is not the one the
29
+ * request line carried — `uf dev` passes the one Vite's base middleware has
30
+ * already taken `app.router.basePath` off.
31
+ *
28
32
  * @param {import("node:http").IncomingMessage} incoming
29
33
  * @param {{server?: {https?: unknown}} | undefined} config the resolved Vite config
34
+ * @param {string} [path]
30
35
  */
31
- export async function toRequest(incoming, config) {
36
+ export async function toRequest(incoming, config, path) {
32
37
  const host = incoming.headers.host ?? "localhost";
33
38
  const protocol = config?.server?.https == null ? "http" : "https";
34
- const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
39
+ const url = new URL(path ?? incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
35
40
 
36
41
  const headers = new Headers();
37
42
  for (const [name, value] of Object.entries(incoming.headers)) {
@@ -52,6 +57,20 @@ export async function toRequest(incoming, config) {
52
57
  return new Request(url, init);
53
58
  }
54
59
 
60
+ /**
61
+ * A Node request as a `Request` that carries its address and nothing else.
62
+ *
63
+ * For the questions asked about a URL alone — `app.router`'s redirects and
64
+ * headers, in front of Vite's own middleware — which must not wrap the body a
65
+ * later middleware turns into the `Request` the application reads.
66
+ *
67
+ * @param {import("node:http").IncomingMessage} incoming
68
+ */
69
+ export function toAddressRequest(incoming) {
70
+ const host = incoming.headers.host ?? "localhost";
71
+ return new Request(new URL(incoming.originalUrl ?? incoming.url ?? "/", `http://${host}`));
72
+ }
73
+
55
74
  /**
56
75
  * Write a `Response` to a Node response.
57
76
  *