@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.
- package/driver.js +263 -50
- package/index.js +132 -15
- package/internal/barrel-imports.js +459 -0
- package/internal/flight.js +125 -11
- package/internal/http.js +21 -2
- package/internal/module-graph.js +134 -0
- package/internal/routes.js +55 -2
- package/internal/serve.js +158 -12
- package/internal/server-components.js +109 -0
- package/package.json +5 -5
|
@@ -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
|
+
}
|
package/internal/flight.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
516
|
-
*
|
|
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
|
-
|
|
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
|
|
533
|
-
*
|
|
534
|
-
*
|
|
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(
|
|
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
|
*
|