@uniflowed/vite 0.0.0-alpha.18 → 0.0.0-alpha.21

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/internal/rsc.js CHANGED
@@ -17,10 +17,13 @@
17
17
  //
18
18
  // Dropping a single Server Component from the client bundle is what Next.js
19
19
  // does, and it works there because the browser is handed a Flight payload
20
- // describing the tree the server rendered. uf has no such payload yet:
21
- // `packages/router/client.js` hydrates by re-rendering the matched tree from
22
- // the same modules the server rendered it from, so a module missing from the
23
- // client bundle is a module React cannot hydrate. What *can* be dropped is a
20
+ // describing the tree the server rendered. uf's payload
21
+ // (`packages/router/internal/payload.js`) carries the route's *data* and not
22
+ // its tree — that half needs a second React module graph, see
23
+ // ubugeeei-prod/uf#519 — so `packages/router/client.js` still hydrates by
24
+ // re-rendering the matched tree from the same modules the server rendered it
25
+ // from, and a module missing from the client bundle is a module React cannot
26
+ // hydrate. What *can* be dropped is a
24
27
  // route the browser never renders at all — one where no client boundary is
25
28
  // reachable from the page, its layouts, its loading fallbacks or the
26
29
  // boundaries that cover it. Nothing under it is ever re-rendered in the
@@ -44,13 +47,12 @@ export const RSC_MANIFEST_ENV = "UF_RSC_MANIFEST";
44
47
  /**
45
48
  * The manifest schema this understands.
46
49
  *
47
- * Version 1 published the client boundaries and nothing that said which
48
- * modules sat *above* one, so it cannot answer the question this module asks.
49
- * An older manifest is therefore refused rather than read optimistically: a
50
- * missing `proximity` would read as `undefined`, compare unequal to
51
- * `"reaches-boundary"`, and quietly drop every route from the client bundle.
50
+ * Version 3 lets client boundaries name package specifiers as well as project
51
+ * paths. An older manifest is therefore refused rather than read
52
+ * optimistically: it cannot know a route imports a package client module, and
53
+ * quietly dropping that route would be the worst possible answer.
52
54
  */
53
- const SUPPORTED_VERSION = 2;
55
+ const SUPPORTED_VERSION = 3;
54
56
 
55
57
  /**
56
58
  * Read the RSC manifest, or `null` when there is nothing usable to read.
@@ -141,6 +143,12 @@ export function clientRouteFilter(manifest, root, boundaries = {}) {
141
143
  if (route.layouts.some(needed)) return true;
142
144
  if ((route.loading ?? []).some((entry) => needed(entry.module))) return true;
143
145
  if ((route.templates ?? []).some((entry) => needed(entry.module))) return true;
146
+ // A slot renders inside this route, so a `"use client"` anywhere in one is
147
+ // this route's reason to ship. Without this line a page whose only
148
+ // interactive part is in a slot would be dropped from the client bundle
149
+ // and served as a document — rendered correctly and never hydrated, which
150
+ // is the quietest way a feature can be half-implemented.
151
+ if (slotNeeds(route.slots ?? [], needed)) return true;
144
152
  // A boundary with no module of its own is the record the scan synthesises
145
153
  // at the router root, and what renders there is the framework's own page —
146
154
  // already in `@uniflowed/router`, reaching nothing this project wrote. It
@@ -161,6 +169,31 @@ export function clientRouteFilter(manifest, root, boundaries = {}) {
161
169
  };
162
170
  }
163
171
 
172
+ /**
173
+ * Whether anything in a slot tree has to reach the browser.
174
+ *
175
+ * Recursive because slots nest: a slot's own layout may declare slots of its
176
+ * own, and a `"use client"` at any depth is still inside the page this route
177
+ * renders.
178
+ *
179
+ * @param {ReadonlyArray<{
180
+ * defaultPage: ?string,
181
+ * routes: ReadonlyArray<{page: string, layouts: ReadonlyArray<string>, slots: ReadonlyArray<*>}>,
182
+ * }>} slots
183
+ * @param {(file: ?string) => boolean} needed
184
+ */
185
+ function slotNeeds(slots, needed) {
186
+ for (const slot of slots) {
187
+ if (slot.defaultPage != null && needed(slot.defaultPage)) return true;
188
+ for (const route of slot.routes) {
189
+ if (needed(route.page)) return true;
190
+ if (route.layouts.some(needed)) return true;
191
+ if (slotNeeds(route.slots, needed)) return true;
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+
164
197
  // ---------------------------------------------------------------------------
165
198
  // Server actions
166
199
  //
package/internal/serve.js CHANGED
@@ -32,7 +32,7 @@
32
32
  // module is the part that is genuinely Vite's: finding the build on disk and
33
33
  // reading the manifest a Vite build wrote.
34
34
  //
35
- // It moved because of `uf build --adapter`. `tests/library/serve.test.js` said
35
+ // It moved because of `uf build --adapter`. `packages/server/serve.test.js` said
36
36
  // what was wrong with the old arrangement while it was still the only one:
37
37
  // "`internal/serve.js` is the seam a deploy adapter will need, and naming it
38
38
  // in `exports` before one exists would be promising an interface nothing has
@@ -100,23 +100,88 @@ function deployment() {
100
100
  * reporting that it expired nothing. A project that turned the cache off should
101
101
  * be told that it did, not handed a cache that quietly does nothing.
102
102
  *
103
- * One store per server process, built when the handler is, which is the whole
104
- * of what "in memory, per process" means in practice: `uf preview` and
105
- * `uf start` each hold one, and two of them running at once share nothing.
103
+ * One store per server process, built when the handler is. With no
104
+ * `rendering.cache.store` that is the whole of what "in memory, per process"
105
+ * means in practice: `uf preview` and `uf start` each hold one, and two of them
106
+ * running at once share nothing. With one, the two processes share whatever the
107
+ * provider is in front of — see [`providerFor`].
106
108
  *
107
- * The store's own options are not configurable from `uf.config.js` and are not
108
- * named here: `rendering.cache` has four keys and no fifth, and a parameter
109
- * threaded through for a setting nobody can set would be the shape of
110
- * configurability with none of the substance.
109
+ * The store's remaining options are still not configurable from `uf.config.js`
110
+ * and are still not named here: `maxEntries` and `now` are facts about one
111
+ * process's heap and one process's clock, and a parameter threaded through for
112
+ * a setting nobody can set would be the shape of configurability with none of
113
+ * the substance.
111
114
  *
112
- * @param {{route?: boolean, fetch?: boolean} | undefined} declared
115
+ * @param {{route?: boolean, fetch?: boolean, store?: string, storeDir?: string} | undefined} declared
113
116
  * @param {(options?: object) => object} createCacheStore
117
+ * @param {{root: string, build: string | null}} where
114
118
  */
115
- function cacheFor(declared, createCacheStore) {
119
+ async function cacheFor(declared, createCacheStore, where) {
116
120
  const route = declared?.route === true;
117
121
  const fetchCache = declared?.fetch === true;
118
122
  if (!route && !fetchCache) return undefined;
119
- return { store: createCacheStore(), route, fetch: fetchCache };
123
+ const provider = await providerFor(declared, where);
124
+ const store =
125
+ provider == null ? createCacheStore() : createCacheStore({ provider, build: where.build });
126
+ return { store, route, fetch: fetchCache };
127
+ }
128
+
129
+ /**
130
+ * The durable provider `rendering.cache.store` names, or `null` for memory.
131
+ *
132
+ * Three answers, and the third is the one that matters to
133
+ * `docs/red-lines.md`'s third line. `"memory"` — the default, and what every
134
+ * project that says nothing gets — keeps the store exactly as it was.
135
+ * `"filesystem"` is uf's built-in, and it is a convenience rather than an
136
+ * architecture. Anything else is a **module specifier**, resolved from the
137
+ * project, exporting `createCacheProvider`: the same shape `builder.module`
138
+ * has, chosen for the same reason that document gives — "a provider a project
139
+ * can replace has to be a name it can write, and an enum with one variant
140
+ * cannot become one without a release of uf".
141
+ *
142
+ * So a project with a Redis, a KV namespace or an S3 bucket writes twenty lines
143
+ * against `@uniflowed/server/cache`'s `CacheProvider` type, names the module
144
+ * here, and uf never learns which of those it was.
145
+ *
146
+ * # Why a missing build identity is a refusal
147
+ *
148
+ * Because the alternatives are both worse. Falling back to memory would give a
149
+ * project that asked for a cache surviving restarts one that does not, and the
150
+ * symptom is a `MISS` on every cold request — indistinguishable from a cache
151
+ * that is simply cold. Generating an identity per process would be worse again:
152
+ * four servers would write four copies of everything into one directory and
153
+ * read none of each other's. The deployment rules say a target that cannot
154
+ * provide a durable store has to say so, and this is a host saying so.
155
+ *
156
+ * @param {{store?: string, storeDir?: string} | undefined} declared
157
+ * @param {{root: string, build: string | null}} where
158
+ */
159
+ async function providerFor(declared, { root, build }) {
160
+ const named = declared?.store ?? "memory";
161
+ if (named === "memory") return null;
162
+ if (build == null) {
163
+ throw new Error(
164
+ `uf: rendering.cache.store is ${JSON.stringify(named)}, which keeps entries between ` +
165
+ "restarts, and there is no build identity to key them by. `uf build` writes one " +
166
+ "beside the server bundle; set UF_BUILD_ID to name it yourself. Without one, a " +
167
+ "deploy would answer the new build's URLs with the previous build's documents.",
168
+ );
169
+ }
170
+ const directory = path.resolve(root, declared?.storeDir ?? path.join(".uf", "cache", "route"));
171
+ if (named === "filesystem") {
172
+ const { createFilesystemCache } = await import("@uniflowed/server/cache/filesystem");
173
+ return createFilesystemCache({ directory });
174
+ }
175
+ const provider = await import(providerSpecifier(root, named));
176
+ const create = provider.createCacheProvider ?? provider.default;
177
+ if (typeof create !== "function") {
178
+ throw new Error(
179
+ `uf: rendering.cache.store names ${JSON.stringify(named)}, which exports no ` +
180
+ "`createCacheProvider`. A durable cache provider is a module exporting that " +
181
+ "function; see @uniflowed/server/cache's CacheProvider type for what it returns.",
182
+ );
183
+ }
184
+ return create({ build, directory });
120
185
  }
121
186
 
122
187
  /**
@@ -147,7 +212,63 @@ export async function loadBuild({ root, outDir, serverDir }) {
147
212
  const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
148
213
  const entry = await import(pathToFileURL(entryFile).href);
149
214
  await deployment();
150
- return { entry, assets: assetsFromManifest(manifest), distDir };
215
+ const build = await buildIdentity(root, serverDir);
216
+ return { entry, assets: assetsFromManifest(manifest), distDir, root, build };
217
+ }
218
+
219
+ /**
220
+ * What `import()` should be given for a provider a project named.
221
+ *
222
+ * A relative path in `uf.config.js` is relative to *the project*, which is what
223
+ * anybody writing `"./cache/redis.js"` means and is not what `import()` from
224
+ * this module would do — it would look beside `@uniflowed/vite`, find nothing,
225
+ * and report a missing module the config file does not mention. `builder.module`
226
+ * settled the same question the same way in `uf_cli`'s `project_directory`.
227
+ *
228
+ * A bare specifier is left alone: `"@acme/uf-cache-redis"` is a package, and
229
+ * resolving it is Node's job and not this function's.
230
+ *
231
+ * @param {string} root
232
+ * @param {string} named
233
+ */
234
+ export function providerSpecifier(root, named) {
235
+ if (!named.startsWith(".") && !path.isAbsolute(named)) return named;
236
+ return pathToFileURL(path.resolve(root, named)).href;
237
+ }
238
+
239
+ /** What `uf build` writes its identity into, beside the server bundle. */
240
+ export const BUILD_ID_FILE = "uf-build-id";
241
+
242
+ /**
243
+ * The identity of the build being served, or `null`.
244
+ *
245
+ * Only a durable cache reads it, and only a durable cache needs it: an entry
246
+ * that cannot outlive the process cannot outlive the build either, so every
247
+ * command that keeps its cache in memory is entitled to `null` here and never
248
+ * looks. `packages/server/internal/cache-key.js` argues the rest.
249
+ *
250
+ * `UF_BUILD_ID` first, then the file `uf build` wrote. The environment wins for
251
+ * the reason it wins in `crates/uf_rsc`'s `BuildId::from_env_or_generate`,
252
+ * which reads the same variable for the same kind of fact: a deployment that
253
+ * needs two artefacts to *be* one build — a blue/green pair, a rebuild of a
254
+ * tagged commit — has no other way to say so.
255
+ *
256
+ * `null` rather than a generated fallback, and that is the whole point of the
257
+ * function. A per-process identity would give four servers four caches with a
258
+ * shared disk between them, which is worse than four memories: it would write
259
+ * four copies of everything and read none of them. Whoever asked for a durable
260
+ * store is told there is no build to key it by, and gets to fix it.
261
+ */
262
+ export async function buildIdentity(root, serverDir) {
263
+ const named = process.env.UF_BUILD_ID;
264
+ if (typeof named === "string" && named !== "") return named;
265
+ try {
266
+ const file = path.join(path.resolve(root, serverDir), BUILD_ID_FILE);
267
+ const value = (await readFile(file, "utf8")).trim();
268
+ return value === "" ? null : value;
269
+ } catch {
270
+ return null;
271
+ }
151
272
  }
152
273
 
153
274
  async function readable(file, message) {
@@ -309,20 +430,29 @@ export async function beginRequest(entry, request) {
309
430
  * the point where four switches that used to reach a JSON file and nothing else
310
431
  * become a store a request can hit. See ubugeeei-prod/uf#277.
311
432
  *
312
- * @param {{entry: object, assets: object, cache?: object}} build
433
+ * `root` and `build` come with it, and only the cache reads either: `root` is
434
+ * where a `storeDir` is resolved from and `build` is what a durable entry is
435
+ * keyed by. Both are `undefined` for a caller that constructs a handler by
436
+ * hand, which is the memory-only store and needs neither.
437
+ *
438
+ * @param {{entry: object, assets: object, cache?: object, root?: string, build?: string | null}} build
313
439
  */
314
- export function createApplicationHandler({ entry, assets, cache }) {
315
- const ready = deployment().then(({ createFetchHandler, createCacheStore, nodeCapabilities }) =>
316
- createFetchHandler({
317
- app: entry,
318
- document: assets,
319
- cache: cacheFor(cache, createCacheStore),
320
- // `uf preview` and `uf start` are a Node process with a socket, which is
321
- // what a deployed `--adapter node` build is too — so a route handler
322
- // that streams events answers the same way in the preview it is checked
323
- // in and in the deployment it ends up as. See `withRequest` above.
324
- capabilities: nodeCapabilities(),
325
- }),
440
+ export function createApplicationHandler({ entry, assets, cache, root, build }) {
441
+ const ready = deployment().then(
442
+ async ({ createFetchHandler, createCacheStore, nodeCapabilities }) =>
443
+ createFetchHandler({
444
+ app: entry,
445
+ document: assets,
446
+ cache: await cacheFor(cache, createCacheStore, {
447
+ root: root ?? process.cwd(),
448
+ build: build ?? null,
449
+ }),
450
+ // `uf preview` and `uf start` are a Node process with a socket, which is
451
+ // what a deployed `--adapter node` build is too — so a route handler
452
+ // that streams events answers the same way in the preview it is checked
453
+ // in and in the deployment it ends up as. See `withRequest` above.
454
+ capabilities: nodeCapabilities(),
455
+ }),
326
456
  );
327
457
  return async function handle(request) {
328
458
  return (await ready)(request);
@@ -352,11 +482,11 @@ export function createStaticHandler({ root }) {
352
482
  * project whose handler path collides with a file in `public/` behaves one way
353
483
  * when it is checked and the other way when it is deployed.
354
484
  *
355
- * @param {{entry: object, assets: object, distDir: string, cache?: object}} build
485
+ * @param {{entry: object, assets: object, distDir: string, cache?: object, root?: string, build?: string | null}} build
356
486
  */
357
- export function createServeHandler({ entry, assets, distDir, cache }) {
487
+ export function createServeHandler({ entry, assets, distDir, cache, root, build }) {
358
488
  const serveStatic = createStaticHandler({ root: distDir });
359
- const application = createApplicationHandler({ entry, assets, cache });
489
+ const application = createApplicationHandler({ entry, assets, cache, root, build });
360
490
  return async function handle(request) {
361
491
  return (await serveStatic(request)) ?? (await application(request));
362
492
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.18",
3
+ "version": "0.0.0-alpha.21",
4
4
  "description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,13 +28,16 @@
28
28
  "driver.js",
29
29
  "index.js",
30
30
  "internal",
31
- "merge.js"
31
+ "merge.js",
32
+ "!*.test.js"
32
33
  ],
33
34
  "dependencies": {
34
35
  "@mdx-js/rollup": "^3.1.1",
35
36
  "@shikijs/rehype": "^3.23.0",
36
- "@uniflowed/host": "0.0.0-alpha.18",
37
- "@uniflowed/server": "0.0.0-alpha.18",
37
+ "@uniflowed/host": "0.0.0-alpha.21",
38
+ "@uniflowed/router": "0.0.0-alpha.21",
39
+ "@uniflowed/server": "0.0.0-alpha.21",
40
+ "@uniflowed/validator": "0.0.0-alpha.21",
38
41
  "rehype-slug": "^6.0.0",
39
42
  "remark-frontmatter": "^5.0.0",
40
43
  "remark-gfm": "^4.0.1",