@uniflowed/vite 0.0.0-alpha.8 → 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/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
@@ -64,17 +64,20 @@
64
64
  // settles it on the line after the last byte.
65
65
  //
66
66
  // It has to be the *entry's* `beginRequest` rather than one imported here: the
67
- // request lives in an `AsyncLocalStorage` belonging to one copy of
68
- // `@uniflowed/server`, and the copy that matters is the one inside the
69
- // application bundle. A host that resolved its own would begin a request the
70
- // application cannot see, and nothing would fail loudly — the guard would run,
71
- // the page would render, and every `cookies()` in it would throw as though no
72
- // host had run at all. See ubugeeei-prod/uf#389.
67
+ // request store is shared by every copy of one release of `@uniflowed/server`,
68
+ // and the release that matters is the one inside the application bundle. A
69
+ // host that resolved its own could be holding another release, would begin a
70
+ // request the application cannot see, and nothing would fail loudly — the
71
+ // guard would run, the page would render, and every `cookies()` in it would
72
+ // throw as though no host had run at all. See ubugeeei-prod/uf#389.
73
73
 
74
+ import { createHash } from "node:crypto";
74
75
  import { readFile, stat } from "node:fs/promises";
75
76
  import path from "node:path";
76
77
  import { pathToFileURL } from "node:url";
77
78
 
79
+ import { localImageEndpoint, servesRemoteImages } from "./image-endpoint.js";
80
+
78
81
  /**
79
82
  * `@uniflowed/server`'s two halves, loaded once.
80
83
  *
@@ -86,10 +89,133 @@ function deployment() {
86
89
  deploymentModules ??= Promise.all([
87
90
  import("@uniflowed/server/fetch"),
88
91
  import("@uniflowed/server/node"),
89
- ]).then(([application, host]) => ({ ...application, ...host }));
92
+ import("@uniflowed/server/cache"),
93
+ ]).then(([application, host, cache]) => ({ ...application, ...host, ...cache }));
90
94
  return deploymentModules;
91
95
  }
92
96
 
97
+ /**
98
+ * The cache `rendering.cache` describes, or `undefined` for no cache at all.
99
+ *
100
+ * `undefined` rather than a store with both switches off, and the difference is
101
+ * visible from an application: a handler with no `cache` installs nothing on
102
+ * the request, so `revalidateTag()` raises "there is not one here" instead of
103
+ * reporting that it expired nothing. A project that turned the cache off should
104
+ * be told that it did, not handed a cache that quietly does nothing.
105
+ *
106
+ * One store per server process, built when the handler is. With no
107
+ * `rendering.cache.store` that is the whole of what "in memory, per process"
108
+ * means in practice: `uf preview` and `uf start` each hold one, and two of them
109
+ * running at once share nothing. With one, the two processes share whatever the
110
+ * provider is in front of — see [`providerFor`].
111
+ *
112
+ * The store's remaining options are still not configurable from `uf.config.js`
113
+ * and are still not named here: `maxEntries` and `now` are facts about one
114
+ * process's heap and one process's clock, and a parameter threaded through for
115
+ * a setting nobody can set would be the shape of configurability with none of
116
+ * the substance.
117
+ *
118
+ * @param {{route?: boolean, fetch?: boolean, data?: boolean, store?: string, storeDir?: string} | undefined} declared
119
+ * @param {(options?: object) => object} createCacheStore
120
+ * @param {{root: string, build: string | null}} where
121
+ */
122
+ async function cacheFor(declared, createCacheStore, where) {
123
+ const route = declared?.route === true;
124
+ const fetchCache = declared?.fetch === true;
125
+ const data = declared?.data === true;
126
+ if (!route && !fetchCache && !data) return undefined;
127
+ const provider = await providerFor(declared, where);
128
+ const store =
129
+ provider == null ? createCacheStore() : createCacheStore({ provider, build: where.build });
130
+ return { store, route, fetch: fetchCache, data };
131
+ }
132
+
133
+ /**
134
+ * Where `/__uf/image` keeps its variants: over the provider
135
+ * `rendering.cache.store` names, or `undefined` for the endpoint's own memory.
136
+ *
137
+ * The same provider as the route cache, because it is the one a project
138
+ * already said survives a restart and is shared between processes — a second
139
+ * setting for "where do image variants go" would be a second answer to a
140
+ * question the project answered once. Asked only when a store is named, so a
141
+ * project that names none needs no build identity for its images.
142
+ *
143
+ * @param {{store?: string, storeDir?: string} | undefined} declared
144
+ * @param {(options?: object) => object} createCacheStore
145
+ * @param {{root: string, build: string | null}} where
146
+ */
147
+ async function imageStoreFor(declared, createCacheStore, where) {
148
+ const named = declared?.store ?? "memory";
149
+ if (named === "memory") return undefined;
150
+ const provider = await providerFor(declared, where);
151
+ if (provider == null) return undefined;
152
+ const { MAX_MEMORY_VARIANTS } = await import("@uniflowed/server/image");
153
+ return createCacheStore({ provider, build: where.build, maxEntries: MAX_MEMORY_VARIANTS });
154
+ }
155
+
156
+ /**
157
+ * The durable provider `rendering.cache.store` names, or `null` for memory.
158
+ *
159
+ * Three answers, and the third is the one that matters to
160
+ * `docs/red-lines.md`'s third line. `"memory"` — the default, and what every
161
+ * project that says nothing gets — keeps the store exactly as it was.
162
+ * `"filesystem"` is uf's built-in, and it is a convenience rather than an
163
+ * architecture. Anything else is a **module specifier**, resolved from the
164
+ * project, exporting `createCacheProvider`: the same shape `builder.module`
165
+ * has, chosen for the same reason that document gives — "a provider a project
166
+ * can replace has to be a name it can write, and an enum with one variant
167
+ * cannot become one without a release of uf".
168
+ *
169
+ * So a project with a Redis, a KV namespace or an S3 bucket writes twenty lines
170
+ * against `@uniflowed/server/cache`'s `CacheProvider` type, names the module
171
+ * here, and uf never learns which of those it was.
172
+ *
173
+ * # Why a missing build identity is a refusal
174
+ *
175
+ * Because the alternatives are both worse. Falling back to memory would give a
176
+ * project that asked for a cache surviving restarts one that does not, and the
177
+ * symptom is a `MISS` on every cold request — indistinguishable from a cache
178
+ * that is simply cold. Generating an identity per process would be worse again:
179
+ * four servers would write four copies of everything into one directory and
180
+ * read none of each other's. The deployment rules say a target that cannot
181
+ * provide a durable store has to say so, and this is a host saying so.
182
+ *
183
+ * @param {{store?: string, storeDir?: string} | undefined} declared
184
+ * @param {{root: string, build: string | null}} where
185
+ */
186
+ async function providerFor(declared, { root, build, regenerates }) {
187
+ // A build that regenerates pages keeps what it regenerated on disk unless the
188
+ // project named a store. A restart that took every regenerated page back to
189
+ // the build's copy would be a server whose pages went back in time, which is
190
+ // the one thing regeneration is for not doing. `"memory"`, said out loud, is
191
+ // still memory.
192
+ const named = declared?.store ?? (regenerates === true ? "filesystem" : "memory");
193
+ if (named === "memory") return null;
194
+ if (build == null) {
195
+ throw new Error(
196
+ `uf: rendering.cache.store is ${JSON.stringify(named)}, which keeps entries between ` +
197
+ "restarts, and there is no build identity to key them by. `uf build` writes one " +
198
+ "beside the server bundle; set UF_BUILD_ID to name it yourself. Without one, a " +
199
+ "deploy would answer the new build's URLs with the previous build's documents.",
200
+ );
201
+ }
202
+ const directory = path.resolve(root, declared?.storeDir ?? path.join(".uf", "cache", "route"));
203
+ if (named === "filesystem") {
204
+ const { createFilesystemCache } = await import("@uniflowed/server/cache/filesystem");
205
+ return createFilesystemCache({ directory });
206
+ }
207
+ const provider = await import(providerSpecifier(root, named));
208
+ const create = provider.createCacheProvider ?? provider.default;
209
+ if (typeof create !== "function") {
210
+ throw new Error(
211
+ `uf: rendering.cache.store names ${JSON.stringify(named)}, which exports no ` +
212
+ "`createCacheProvider`. A durable cache provider is a module exporting that " +
213
+ "function; see @uniflowed/server/cache's CacheProvider type for what it returns.",
214
+ );
215
+ }
216
+ return create({ build, directory });
217
+ }
218
+
93
219
  /**
94
220
  * Everything a served build consists of.
95
221
  *
@@ -118,7 +244,184 @@ export async function loadBuild({ root, outDir, serverDir }) {
118
244
  const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
119
245
  const entry = await import(pathToFileURL(entryFile).href);
120
246
  await deployment();
121
- return { entry, assets: assetsFromManifest(manifest), distDir };
247
+ const build = await buildIdentity(root, serverDir);
248
+ const regeneration = await readRegeneration(path.resolve(root, serverDir));
249
+ const partial = await readPartialPrerenders(path.resolve(root, serverDir));
250
+ return {
251
+ entry,
252
+ assets: await documentAssetsFor(path.resolve(root, serverDir), manifest),
253
+ distDir,
254
+ root,
255
+ build,
256
+ regeneration,
257
+ partial,
258
+ };
259
+ }
260
+
261
+ /** What `uf build` records a document's tags in, beside the server bundle. */
262
+ export const DOCUMENT_ASSETS_FILE = "uf-document-assets.json";
263
+
264
+ /**
265
+ * The tags a served document needs: the ones `uf build` recorded, or — for a
266
+ * build from before it recorded them — the client manifest's.
267
+ *
268
+ * Recorded rather than recomputed, because the client manifest is no longer the
269
+ * whole answer. An application React Server Components render links the
270
+ * stylesheets its rsc graph emitted and the ones each client module's chunk
271
+ * carries, and neither is reachable from the client entry the manifest is walked
272
+ * from — so a server that recomputed the tags rendered every page without the
273
+ * stylesheets its prerendered pages had. `uf start`, `uf preview`, `--adapter`
274
+ * and `--compile` all read them from here.
275
+ *
276
+ * @param {string} serverDir absolute path of the server bundle's directory
277
+ * @param {object} manifest the client build's Vite manifest
278
+ */
279
+ export async function documentAssetsFor(serverDir, manifest) {
280
+ const file = path.join(serverDir, DOCUMENT_ASSETS_FILE);
281
+ let recorded;
282
+ try {
283
+ recorded = await readFile(file, "utf8");
284
+ } catch {
285
+ return assetsFromManifest(manifest);
286
+ }
287
+ try {
288
+ return JSON.parse(recorded);
289
+ } catch {
290
+ throw new Error(`uf: ${file} is not the JSON \`uf build\` writes; run \`uf build\` again`);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * The file beside the server bundle that names the pages a build regenerates.
296
+ *
297
+ * Written by `driver.js`'s build, and only for a build that has such a page.
298
+ */
299
+ export const REGENERATION_FILE = "regenerate.json";
300
+
301
+ /**
302
+ * Where a regenerated page's document goes, under the build's output directory.
303
+ *
304
+ * Somewhere no static half answers the page's own URL, which is the point: a
305
+ * file at `dist/posts/a/index.html` would be served by every host before the
306
+ * server saw the request, forever, whatever the page's lifetime said.
307
+ */
308
+ export const REGENERATED_DIRECTORY = "__uf/regenerate";
309
+
310
+ /**
311
+ * The pages this build regenerates, or `undefined` for a build that has none.
312
+ *
313
+ * `undefined` rather than an empty manifest, so a build with nothing to
314
+ * regenerate serves exactly as every build did before regeneration existed.
315
+ */
316
+ export async function readRegeneration(serverDir) {
317
+ let text;
318
+ try {
319
+ text = await readFile(path.join(serverDir, REGENERATION_FILE), "utf8");
320
+ } catch (error) {
321
+ if (error?.code === "ENOENT") return undefined;
322
+ throw error;
323
+ }
324
+ return JSON.parse(text);
325
+ }
326
+
327
+ /**
328
+ * The file beside the server bundle that holds the static shells of the pages a
329
+ * build prerendered partially.
330
+ *
331
+ * Beside the server bundle rather than under the output directory, because a
332
+ * shell is not a document anybody should be sent on its own: its holes are
333
+ * filled by the server that reads this file, and a file server would send it
334
+ * with every hole showing its fallback forever.
335
+ */
336
+ export const PARTIAL_PRERENDER_FILE = "partial-prerender.json";
337
+
338
+ /**
339
+ * The pages this build prerendered partially, or `undefined` for a build with
340
+ * none — which then serves exactly as every build did before partial
341
+ * prerendering existed.
342
+ */
343
+ export async function readPartialPrerenders(serverDir) {
344
+ let text;
345
+ try {
346
+ text = await readFile(path.join(serverDir, PARTIAL_PRERENDER_FILE), "utf8");
347
+ } catch (error) {
348
+ if (error?.code === "ENOENT") return undefined;
349
+ throw error;
350
+ }
351
+ return JSON.parse(text);
352
+ }
353
+
354
+ /**
355
+ * What `import()` should be given for a provider a project named.
356
+ *
357
+ * A relative path in `uf.config.js` is relative to *the project*, which is what
358
+ * anybody writing `"./cache/redis.js"` means and is not what `import()` from
359
+ * this module would do — it would look beside `@uniflowed/vite`, find nothing,
360
+ * and report a missing module the config file does not mention. `builder.module`
361
+ * settled the same question the same way in `uf_cli`'s `project_directory`.
362
+ *
363
+ * A bare specifier is left alone: `"@acme/uf-cache-redis"` is a package, and
364
+ * resolving it is Node's job and not this function's.
365
+ *
366
+ * @param {string} root
367
+ * @param {string} named
368
+ */
369
+ export function providerSpecifier(root, named) {
370
+ if (!named.startsWith(".") && !path.isAbsolute(named)) return named;
371
+ return pathToFileURL(path.resolve(root, named)).href;
372
+ }
373
+
374
+ /** What `uf build` writes its identity into, beside the server bundle. */
375
+ export const BUILD_ID_FILE = "uf-build-id";
376
+
377
+ /**
378
+ * The identity of the build being served, or `null`.
379
+ *
380
+ * Only a durable cache reads it, and only a durable cache needs it: an entry
381
+ * that cannot outlive the process cannot outlive the build either, so every
382
+ * command that keeps its cache in memory is entitled to `null` here and never
383
+ * looks. `packages/server/internal/cache-key.js` argues the rest.
384
+ *
385
+ * `UF_BUILD_ID` first, then the file `uf build` wrote. The environment wins for
386
+ * the reason it wins in `crates/uf_rsc`'s `BuildId::from_env_or_generate`,
387
+ * which reads the same variable for the same kind of fact: a deployment that
388
+ * needs two artefacts to *be* one build — a blue/green pair, a rebuild of a
389
+ * tagged commit — has no other way to say so.
390
+ *
391
+ * `null` rather than a generated fallback, and that is the whole point of the
392
+ * function. A per-process identity would give four servers four caches with a
393
+ * shared disk between them, which is worse than four memories: it would write
394
+ * four copies of everything and read none of them. Whoever asked for a durable
395
+ * store is told there is no build to key it by, and gets to fix it.
396
+ */
397
+ export async function buildIdentity(root, serverDir) {
398
+ const named = process.env.UF_BUILD_ID;
399
+ if (typeof named === "string" && named !== "") return named;
400
+ try {
401
+ const file = path.join(path.resolve(root, serverDir), BUILD_ID_FILE);
402
+ const value = (await readFile(file, "utf8")).trim();
403
+ return value === "" ? null : value;
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * The id a build's documents publish, derived from the build id.
411
+ *
412
+ * A browser names it on every action call and payload request, and a front
413
+ * door on another build refuses rather than answering; see
414
+ * `@uniflowed/server`'s `internal/deployment.js`. It has to be public — it is
415
+ * in every document — and the build id must not be: it is the key the action
416
+ * ids are an HMAC under whenever `UF_BUILD_ID` names both. So this is a
417
+ * digest of it, under a label of its own, and sixteen hex characters of that:
418
+ * the same for two artefacts that are one build, different for any two that
419
+ * are not, and no help to anybody guessing the key.
420
+ *
421
+ * @param {string} buildId
422
+ */
423
+ export function deploymentIdFor(buildId) {
424
+ return createHash("sha256").update("uf:deployment\0").update(buildId).digest("hex").slice(0, 16);
122
425
  }
123
426
 
124
427
  async function readable(file, message) {
@@ -153,8 +456,15 @@ async function readable(file, message) {
153
456
  * the rest moved to `@uniflowed/server`. `uf build --adapter` calls it too,
154
457
  * at build time, and bakes the answer into what it emits.
155
458
  */
156
- export function assetsFromManifest(manifest) {
157
- const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
459
+ export function assetsFromManifest(manifest, base = "") {
460
+ // `client` by name first. An application React Server Components render
461
+ // gives the client build one entry per client module as well, and the
462
+ // document's script is the application's entry, not whichever of those the
463
+ // manifest happens to list first.
464
+ const chunks = Object.values(manifest);
465
+ const entry =
466
+ chunks.find((chunk) => chunk.isEntry && chunk.name === "client") ??
467
+ chunks.find((chunk) => chunk.isEntry);
158
468
  if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
159
469
 
160
470
  const styles = new Set(entry.css ?? []);
@@ -182,10 +492,12 @@ export function assetsFromManifest(manifest) {
182
492
  };
183
493
  collectPreloads(entry);
184
494
 
495
+ // Under `app.router.basePath` when there is one: a prerendered document is
496
+ // written outside Vite's HTML transform, so nothing else would put it there.
185
497
  return {
186
- scripts: [`/${entry.file}`],
187
- styles: [...styles].map((file) => `/${file}`),
188
- preloads: [...preloads].map((file) => `/${file}`),
498
+ scripts: [`${base}/${entry.file}`],
499
+ styles: [...styles].map((file) => `${base}/${file}`),
500
+ preloads: [...preloads].map((file) => `${base}/${file}`),
189
501
  };
190
502
  }
191
503
 
@@ -198,10 +510,10 @@ export function assetsFromManifest(manifest) {
198
510
  * its callback whether the render threw or not, and `drainDeferred` already
199
511
  * reports a failing task rather than propagating it.
200
512
  *
201
- * `entry.beginRequest` and not an import: the request lives in an
202
- * `AsyncLocalStorage` belonging to one copy of `@uniflowed/server`, and the
203
- * copy that matters is the one inside the application bundle. See
204
- * `serverModuleSource` in `./routes.js`.
513
+ * `entry.beginRequest` and not an import: the request store is shared by every
514
+ * copy of one release of `@uniflowed/server`, and the release that matters is
515
+ * the one inside the application bundle. See `serverModuleSource` in
516
+ * `./routes.js`.
205
517
  *
206
518
  * The one case this cannot be exact about is a request uf hands back rather
207
519
  * than answers: a caller whose `catch` is `next(error)` gives the response to
@@ -211,12 +523,12 @@ export function assetsFromManifest(manifest) {
211
523
  * naming rather than papering over, and it is the failure path of a request
212
524
  * that already went wrong — not the ordinary one this exists for.
213
525
  *
214
- * @param {{beginRequest: (request: Request) => {run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
526
+ * @param {{beginRequest: (request: Request) => {context: object, run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
215
527
  * @param {Request} request
216
528
  * @param {() => Promise<mixed>} body
217
529
  */
218
530
  export async function withRequest(entry, request, body) {
219
- const { run, settle } = entry.beginRequest(request);
531
+ const { run, settle } = await beginRequest(entry, request);
220
532
  try {
221
533
  return await run(body);
222
534
  } finally {
@@ -224,6 +536,44 @@ export async function withRequest(entry, request, body) {
224
536
  }
225
537
  }
226
538
 
539
+ /**
540
+ * Begin a request on this host, with what this host can do already on it.
541
+ *
542
+ * The half of [`withRequest`] that a caller which may *not* answer needs.
543
+ * `uf dev` runs the application's middleware, its action endpoint and its
544
+ * dispatcher for every request, and hands the ones none of them claimed back
545
+ * to Vite's chain — at which point the response is written somewhere this
546
+ * module cannot see, so settling has to wait for the socket rather than for a
547
+ * `finally` here. A caller that always answers should use [`withRequest`] and
548
+ * not think about it.
549
+ *
550
+ * `entry.beginRequest` and not an import: the request store is shared by every
551
+ * copy of one release of `@uniflowed/server`, and the release that matters is
552
+ * the one inside the application bundle. See `serverModuleSource` in
553
+ * `./routes.js`.
554
+ *
555
+ * What this host can do is put on the request the way `createFetchHandler`
556
+ * puts it on the one it owns. `uf dev` and `uf build --compile` reach a route
557
+ * handler without going through that function, and a handler that streams
558
+ * events or queues work has to get the same answer from all four front doors —
559
+ * a capability that is present under `uf start` and absent under `uf dev` is
560
+ * the difference this whole seam exists to remove.
561
+ *
562
+ * `nodeCapabilities`, because both of those *are* a Node process with a
563
+ * socket: a body reaches the client as it is written, and the process is still
564
+ * there afterwards. Neither passes an upgrader or a queue, because uf defines
565
+ * both and implements neither.
566
+ *
567
+ * @param {{beginRequest: (request: Request) => {context: object, run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
568
+ * @param {Request} request
569
+ */
570
+ export async function beginRequest(entry, request) {
571
+ const lifecycle = entry.beginRequest(request);
572
+ const { nodeCapabilities } = await deployment();
573
+ lifecycle.context.capabilities ??= nodeCapabilities();
574
+ return lifecycle;
575
+ }
576
+
227
577
  /**
228
578
  * The application half: route handlers, then rendering.
229
579
  *
@@ -238,11 +588,67 @@ export async function withRequest(entry, request, body) {
238
588
  * forgets is not left to discover it: `entry.runMiddleware` refuses outside a
239
589
  * request and names what establishes one. See "Who owns the request" above.
240
590
  *
241
- * @param {{entry: object, assets: object}} build
591
+ * `cache` is `rendering.cache` from `uf.config.js`, straight through: this is
592
+ * the point where four switches that used to reach a JSON file and nothing else
593
+ * become a store a request can hit. See ubugeeei-prod/uf#277.
594
+ *
595
+ * `root` and `build` come with it, and only the cache reads either: `root` is
596
+ * where a `storeDir` is resolved from and `build` is what a durable entry is
597
+ * keyed by. Both are `undefined` for a caller that constructs a handler by
598
+ * hand, which is the memory-only store and needs neither.
599
+ *
600
+ * `images` is `app.builtins.images`, and it adds `/__uf/image` to what the
601
+ * handler answers when it lists remote hosts; see `./image-endpoint.js`.
602
+ *
603
+ * @param {{entry: object, assets: object, cache?: object, root?: string, build?: string | null, regeneration?: object, images?: object, partial?: object}} build
242
604
  */
243
- export function createApplicationHandler({ entry, assets }) {
244
- const ready = deployment().then(({ createFetchHandler }) =>
245
- createFetchHandler({ app: entry, document: assets }),
605
+ export function createApplicationHandler({
606
+ entry,
607
+ assets,
608
+ cache,
609
+ root,
610
+ build,
611
+ regeneration,
612
+ images,
613
+ partial,
614
+ }) {
615
+ const ready = deployment().then(
616
+ async ({ createFetchHandler, createCacheStore, nodeCapabilities }) =>
617
+ createFetchHandler({
618
+ app: entry,
619
+ document: assets,
620
+ cache: await cacheFor(cache, createCacheStore, {
621
+ root: root ?? process.cwd(),
622
+ build: build ?? null,
623
+ regenerates: regeneration != null,
624
+ }),
625
+ // `/__uf/image`, for a project that listed remote hosts. See
626
+ // `./image-endpoint.js`.
627
+ ...(servesRemoteImages(images)
628
+ ? {
629
+ images: await localImageEndpoint({
630
+ images,
631
+ root: root ?? process.cwd(),
632
+ store: await imageStoreFor(cache, createCacheStore, {
633
+ root: root ?? process.cwd(),
634
+ build: build ?? null,
635
+ }),
636
+ }),
637
+ }
638
+ : {}),
639
+ // The pages this build regenerates, from the manifest beside the server
640
+ // bundle. Absent for a build with none, which then serves exactly as it
641
+ // did before regeneration existed.
642
+ ...(regeneration == null ? {} : { regeneration }),
643
+ // And the static shells of the pages it prerendered partially, from the
644
+ // file beside the server bundle, for the same reason.
645
+ ...(partial == null ? {} : { partial }),
646
+ // `uf preview` and `uf start` are a Node process with a socket, which is
647
+ // what a deployed `--adapter node` build is too — so a route handler
648
+ // that streams events answers the same way in the preview it is checked
649
+ // in and in the deployment it ends up as. See `withRequest` above.
650
+ capabilities: nodeCapabilities(),
651
+ }),
246
652
  );
247
653
  return async function handle(request) {
248
654
  return (await ready)(request);
@@ -272,13 +678,161 @@ export function createStaticHandler({ root }) {
272
678
  * project whose handler path collides with a file in `public/` behaves one way
273
679
  * when it is checked and the other way when it is deployed.
274
680
  *
275
- * @param {{entry: object, assets: object, distDir: string}} build
681
+ * It is `@uniflowed/server/node`'s own `createServeHandler`, the one a deployed
682
+ * `server.js` runs, rather than the two halves composed a second time here.
683
+ * That one also hands the application the build's files when the static half
684
+ * has nothing, which is how a regenerated page starts from the document the
685
+ * build wrote; a composition of its own here would be a `uf start` whose
686
+ * regenerated pages rendered on their first request while a deployment's did
687
+ * not.
688
+ *
689
+ * It is handed the bundle's `routing` too, so `app.router`'s redirects answer
690
+ * before the files and its headers go on whatever answers, here exactly as in
691
+ * a deployment. `uf preview` puts the same two in front of Vite's file
692
+ * middleware as well; see [`answerRouting`].
693
+ *
694
+ * @param {{entry: object, assets: object, distDir: string, cache?: object, root?: string, build?: string | null, regeneration?: object, partial?: object}} build
276
695
  */
277
- export function createServeHandler({ entry, assets, distDir }) {
278
- const serveStatic = createStaticHandler({ root: distDir });
279
- const application = createApplicationHandler({ entry, assets });
696
+ export function createServeHandler({
697
+ entry,
698
+ assets,
699
+ distDir,
700
+ cache,
701
+ root,
702
+ build,
703
+ regeneration,
704
+ images,
705
+ partial,
706
+ }) {
707
+ const application = createApplicationHandler({
708
+ entry,
709
+ assets,
710
+ cache,
711
+ root,
712
+ build,
713
+ regeneration,
714
+ images,
715
+ partial,
716
+ });
717
+ const ready = deployment().then(({ createServeHandler: create }) =>
718
+ create({ staticDir: distDir, handle: application, routing: entry.routing }),
719
+ );
280
720
  return async function handle(request) {
281
- return (await serveStatic(request)) ?? (await application(request));
721
+ return (await ready)(request);
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Whether `routing` has anything to say in front of a file server.
727
+ *
728
+ * Redirects and headers are the two that do; a rewrite is the application's.
729
+ * Asked before a middleware is mounted at all, so a project with no rules pays
730
+ * nothing per request under `uf dev` or `uf preview`.
731
+ *
732
+ * @param {{redirects?: unknown[], headers?: unknown[]} | undefined} routing
733
+ */
734
+ export function answersInFrontOfFiles(routing) {
735
+ return (
736
+ (routing?.redirects?.length ?? 0) > 0 ||
737
+ (routing?.headers?.length ?? 0) > 0 ||
738
+ (routing?.basePath ?? "") !== "" ||
739
+ (routing?.trailingSlash ?? "ignore") !== "ignore"
740
+ );
741
+ }
742
+
743
+ /**
744
+ * `app.router`'s headers and redirects, for a door whose files Vite serves.
745
+ *
746
+ * `uf dev` and `uf preview` mount this in front of Vite's own middleware:
747
+ * the headers are pinned on the Node response, so they survive the
748
+ * `writeHead` Vite's file server writes its own with, and a redirect is
749
+ * answered before any file is looked for. `true` when it answered.
750
+ *
751
+ * @param {object} routing the bundle's `routing`
752
+ * @param {Request} request the address alone; see `toAddressRequest`
753
+ * @param {import("node:http").ServerResponse} response
754
+ */
755
+ export async function answerRouting(routing, request, response) {
756
+ const { admit, headersFor, pinHeaders, send: write } = await deployment();
757
+ pinHeaders(response, headersFor(routing, request));
758
+ // A request outside the base path, the other spelling of a path, or a
759
+ // redirect rule: answered here, before Vite's own base middleware would
760
+ // answer the first in its words rather than uf's.
761
+ const admitted = admit(routing, request);
762
+ if (admitted.kind !== "answer") return false;
763
+ await write(response, admitted.response);
764
+ return true;
765
+ }
766
+
767
+ /**
768
+ * A request [`answerRouting`] let through, spelled so Vite's own middleware
769
+ * recognises it.
770
+ *
771
+ * Vite serves under its `base` with the trailing slash, `/docs/`, and its base
772
+ * middleware answers every other path with a 404 of its own, the bare `/docs`
773
+ * included. But `/docs` is the application's root under `app.router.basePath`,
774
+ * and the only spelling of it unless the trailing-slash policy is `"always"`,
775
+ * which has already answered `/docs` with a `308` by the time this is asked.
776
+ * So a request for exactly the base goes on to Vite as `/docs/`, which Vite
777
+ * takes the base off and hands on as the root. The application is handed the
778
+ * root either way.
779
+ *
780
+ * @param {{basePath?: string} | undefined} routing the bundle's `routing`
781
+ * @param {import("node:http").IncomingMessage} request
782
+ */
783
+ export function forViteBase(routing, request) {
784
+ const base = routing?.basePath ?? "";
785
+ const url = request.url ?? "/";
786
+ if (base === "") return;
787
+ const queryAt = url.indexOf("?");
788
+ const pathname = queryAt === -1 ? url : url.slice(0, queryAt);
789
+ if (pathname === base) {
790
+ request.url = `${base}/${queryAt === -1 ? "" : url.slice(queryAt)}`;
791
+ }
792
+ }
793
+
794
+ /**
795
+ * `app.router.rewrites` for this request, for `uf dev`: the rewritten request,
796
+ * or `null`.
797
+ *
798
+ * `@uniflowed/server`'s `rewriteFor`, which `createFetchHandler` asks for every
799
+ * other front door at the same point — after the files, before the guard.
800
+ *
801
+ * @param {object | undefined} routing the bundle's `routing`
802
+ * @param {Request} request
803
+ */
804
+ export async function rewriteRouting(routing, request) {
805
+ const { rewriteFor } = await deployment();
806
+ return rewriteFor(routing, request);
807
+ }
808
+
809
+ /**
810
+ * Whether a file server may answer this request, or uf has to go first.
811
+ *
812
+ * `@uniflowed/server`'s `prerenderedMayAnswer`, reached the same way
813
+ * `createStaticHandler` is. It exists out here because of the one request
814
+ * `uf preview` may not leave to Vite: `createServeHandler` above is mounted
815
+ * *behind* Vite's static middleware, which is fine for everything except a
816
+ * request carrying the draft cookie. `createStaticHandler` declines a
817
+ * prerendered document for such a request and, under `uf preview`, never sees
818
+ * it — so draft mode appeared to be off there while it worked under `uf dev`
819
+ * and `uf start`. That is ubugeeei-prod/uf#620.
820
+ *
821
+ * `driver.js` asks this in a middleware mounted in *front* of Vite's, and
822
+ * mounts `createServeHandler` behind it for the answer, so a draft request
823
+ * goes through the same handler `uf start` uses and gets the same answer —
824
+ * including its stylesheets and chunks, which that handler still serves off
825
+ * disk. Every other request is untouched and Vite's file middleware runs as
826
+ * before.
827
+ *
828
+ * A gate rather than a second handler, because the caller has a request
829
+ * lifecycle to open and must not open one for a request it is about to hand
830
+ * on.
831
+ */
832
+ export function createPrerenderGate() {
833
+ const ready = deployment().then(({ prerenderedMayAnswer }) => prerenderedMayAnswer);
834
+ return async function prerenderedMayAnswer(cookieHeader) {
835
+ return (await ready)(cookieHeader ?? null);
282
836
  };
283
837
  }
284
838