@uniflowed/vite 0.0.0-alpha.12 → 0.0.0-alpha.14

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
@@ -35,7 +35,7 @@
35
35
  // route uf has positively decided needs no browser — and a manifest that is
36
36
  // missing, unreadable, or written by an older uf removes nothing at all.
37
37
 
38
- import { readFileSync } from "node:fs";
38
+ import { readFileSync, statSync } from "node:fs";
39
39
  import path from "node:path";
40
40
 
41
41
  /** Environment variable naming the manifest, set by `uf build` and `uf dev`. */
@@ -140,12 +140,267 @@ export function clientRouteFilter(manifest, root, boundaries = {}) {
140
140
  if (needed(route.page)) return true;
141
141
  if (route.layouts.some(needed)) return true;
142
142
  if ((route.loading ?? []).some((entry) => needed(entry.module))) return true;
143
+ if ((route.templates ?? []).some((entry) => needed(entry.module))) return true;
144
+ // A boundary with no module of its own is the record the scan synthesises
145
+ // at the router root, and what renders there is the framework's own page —
146
+ // already in `@uniflowed/router`, reaching nothing this project wrote. It
147
+ // is skipped rather than left to `needed`, whose answer for a value that is
148
+ // not a file is "assume it is needed": that answer is right for a path the
149
+ // manifest has never heard of and wrong for the absence of a path, and
150
+ // taking it here would have kept every page of every project in the client
151
+ // bundle. See ubugeeei-prod/uf#351.
143
152
  for (const boundary of notFound) {
153
+ if (boundary.page == null) continue;
144
154
  if (covers(boundary.path, route.path) && needed(boundary.page)) return true;
145
155
  }
146
156
  for (const boundary of errors) {
157
+ if (boundary.module == null) continue;
147
158
  if (covers(boundary.path, route.path) && needed(boundary.module)) return true;
148
159
  }
149
160
  return false;
150
161
  };
151
162
  }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Server actions
166
+ //
167
+ // The other half of the split, and the one the manifest was already carrying
168
+ // an answer for. `serverActions` in the manifest is every action `uf_rsc`
169
+ // decided is a *callable endpoint* — an action some module that can hand it
170
+ // across a client boundary reaches — with the keyed id
171
+ // `crates/uf_rsc/src/action.rs` derived for it. An action nothing exposes is
172
+ // tracked in the registry and never written here, so a table built out of this
173
+ // file cannot contain a row that was not meant to be dialable.
174
+ //
175
+ // Two tables come out of it, for the two graphs:
176
+ //
177
+ // * `serverActionModules` is the browser's. It says, for each `"use server"`
178
+ // file, which exports become `createServerReference` calls — and the plugin
179
+ // answers that source *instead of the file*, so the module's body never
180
+ // enters the client graph and neither does anything only it imported.
181
+ // * `serverActionTable` is the server's. It is what `virtual:uf/actions`
182
+ // emits and what `createActionDispatcher` dials into.
183
+ //
184
+ // Both are keyed on the id and nothing else. No request-derived value ever
185
+ // becomes a path, a specifier or an export name here or downstream; see the
186
+ // header of `packages/router/internal/action-endpoint.js`.
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * The request header carrying an action id, lowercased as Node delivers it.
191
+ *
192
+ * A second spelling of `ACTION_HEADER` in
193
+ * `packages/router/internal/action-wire.js`, and it has to be one: this module
194
+ * is plain JavaScript the Vite host imports before any transform, and that one
195
+ * is Flow, which Node cannot import at all. `RSC_MANIFEST_ENV` above is the
196
+ * same situation with `crates/uf_rsc/src/manifest.rs`. What keeps a second
197
+ * spelling from becoming a second answer is
198
+ * `tests/library/server-actions.test.js`, which reads both and compares them.
199
+ */
200
+ export const ACTION_HEADER = "uf-action";
201
+
202
+ /** The id an action row must carry: 64 lowercase hexadecimal characters. */
203
+ function isActionId(value) {
204
+ if (typeof value !== "string" || value.length !== 64) return false;
205
+ for (let index = 0; index < value.length; index += 1) {
206
+ const code = value.charCodeAt(index);
207
+ const digit = code >= 0x30 && code <= 0x39;
208
+ const lower = code >= 0x61 && code <= 0x66;
209
+ if (!digit && !lower) return false;
210
+ }
211
+ return true;
212
+ }
213
+
214
+ /**
215
+ * Whether a name can be written as `export const <name>`.
216
+ *
217
+ * The scanner only ever produces identifiers, so this refuses nothing a real
218
+ * project has. It is here because the alternative to refusing is emitting a
219
+ * module that does not parse, and a generated file that does not parse fails a
220
+ * build somewhere far from the module that caused it. `default` is handled by
221
+ * the caller, which writes `export default`.
222
+ */
223
+ function isExportableName(name) {
224
+ if (typeof name !== "string" || name.length === 0) return false;
225
+ const first = name.charCodeAt(0);
226
+ const startish = (code) =>
227
+ (code >= 0x41 && code <= 0x5a) ||
228
+ (code >= 0x61 && code <= 0x7a) ||
229
+ code === 0x24 ||
230
+ code === 0x5f;
231
+ if (!startish(first)) return false;
232
+ for (let index = 1; index < name.length; index += 1) {
233
+ const code = name.charCodeAt(index);
234
+ if (!startish(code) && !(code >= 0x30 && code <= 0x39)) return false;
235
+ }
236
+ return true;
237
+ }
238
+
239
+ /** Every callable action of the manifest, in the manifest's own order. */
240
+ function callableActions(manifest) {
241
+ if (manifest == null || !Array.isArray(manifest.serverActions)) return [];
242
+ return manifest.serverActions.filter(
243
+ (action) =>
244
+ action != null &&
245
+ isActionId(action.id) &&
246
+ typeof action.module === "string" &&
247
+ action.module !== "" &&
248
+ // An inline `"use server"` closure has no export name to import, so it
249
+ // has no reference in the client bundle and no row in the server's
250
+ // table. It is in the manifest, and reaching it needs the payload
251
+ // ubugeeei-prod/uf#252 is about.
252
+ action.kind === "module-export" &&
253
+ isExportableName(action.export === "default" ? "default_" : action.export),
254
+ );
255
+ }
256
+
257
+ /**
258
+ * The absolute path of a module the manifest names, or `null`.
259
+ *
260
+ * The manifest's paths are project-relative with forward slashes and were
261
+ * written by a walk that already refused anything outside the root; joined
262
+ * here and checked again, because a path that escapes the project is a path
263
+ * this plugin would otherwise hand to Rollup as a module to emit.
264
+ */
265
+ function moduleFile(root, relative) {
266
+ const joined = path.resolve(root, relative);
267
+ const inside = path.relative(root, joined);
268
+ if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) return null;
269
+ return joined;
270
+ }
271
+
272
+ /**
273
+ * Which exports of each `"use server"` file become references in the browser.
274
+ *
275
+ * Keyed by absolute path, because that is what Vite's `load` hook is given.
276
+ * A file with no callable action is absent rather than present-and-empty: the
277
+ * plugin substitutes a module only for a key it finds, and substituting an
278
+ * empty module for a file something imports would be a build error in place of
279
+ * a working import.
280
+ *
281
+ * @param {object | null} manifest from {@link readRscManifest}
282
+ * @param {string} root absolute project root
283
+ * @returns {Map<string, Array<{id: string, module: string, export: string}>>}
284
+ */
285
+ export function serverActionModules(manifest, root) {
286
+ const modules = new Map();
287
+ for (const action of callableActions(manifest)) {
288
+ const file = moduleFile(root, action.module);
289
+ if (file == null) continue;
290
+ const rows = modules.get(file);
291
+ const row = { id: action.id, module: action.module, export: action.export };
292
+ if (rows === undefined) modules.set(file, [row]);
293
+ else rows.push(row);
294
+ }
295
+ return modules;
296
+ }
297
+
298
+ /**
299
+ * Every callable action, as the server's dispatcher table.
300
+ *
301
+ * @param {object | null} manifest from {@link readRscManifest}
302
+ * @param {string} root absolute project root
303
+ * @returns {Array<{id: string, module: string, export: string, file: string}>}
304
+ */
305
+ export function serverActionTable(manifest, root) {
306
+ const rows = [];
307
+ for (const action of callableActions(manifest)) {
308
+ const file = moduleFile(root, action.module);
309
+ if (file == null) continue;
310
+ rows.push({ id: action.id, module: action.module, export: action.export, file });
311
+ }
312
+ return rows;
313
+ }
314
+
315
+ /**
316
+ * The client bundle's stand-in for one `"use server"` module.
317
+ *
318
+ * What the browser gets in place of the file: one `createServerReference` per
319
+ * callable export, an id each, and nothing the module itself imported. This is
320
+ * the whole of how a database handle reached only through an action stays on
321
+ * the server — `crates/uf_rsc/src/graph/build.rs` colours the module server for
322
+ * the same reason, so that the analysis and the bundle agree about it.
323
+ *
324
+ * @param {Array<{id: string, module: string, export: string}>} actions
325
+ */
326
+ export function actionReferenceSource(actions) {
327
+ const lines = ['import { createServerReference } from "@uniflowed/router/action";', ""];
328
+ for (const action of actions) {
329
+ const reference = `createServerReference(${JSON.stringify(action.id)}, ${JSON.stringify(
330
+ `${action.module}#${action.export}`,
331
+ )})`;
332
+ lines.push(
333
+ action.export === "default"
334
+ ? `export default ${reference};`
335
+ : `export const ${action.export} = ${reference};`,
336
+ );
337
+ }
338
+ return `${lines.join("\n")}\n`;
339
+ }
340
+
341
+ /**
342
+ * The source of `virtual:uf/actions`: the table the endpoint dials into.
343
+ *
344
+ * One `import()` thunk per file rather than one per action, so a module with
345
+ * four actions is one chunk of the server bundle and not four. Lazy for the
346
+ * reason the handler table is: an action module is loaded when an action in it
347
+ * is called, and a project's actions are not something every request should
348
+ * pay to import.
349
+ *
350
+ * With no manifest the table is empty and every action call is a `404` — the
351
+ * same answer a project driving Vite itself gets for the route split, and for
352
+ * the same reason: uf will not guess at an analysis it was not given.
353
+ *
354
+ * @param {Array<{id: string, module: string, export: string, file: string}>} actions
355
+ */
356
+ export function actionsModuleSource(actions) {
357
+ const loaders = new Map();
358
+ const declarations = [];
359
+ const loaderId = (file) => {
360
+ let id = loaders.get(file);
361
+ if (id === undefined) {
362
+ id = `load${loaders.size}`;
363
+ loaders.set(file, id);
364
+ declarations.push(`const ${id} = () => import(${JSON.stringify(file)});`);
365
+ }
366
+ return id;
367
+ };
368
+
369
+ const entries = actions.map(
370
+ (action) => ` {
371
+ id: ${JSON.stringify(action.id)},
372
+ module: ${JSON.stringify(action.module)},
373
+ export: ${JSON.stringify(action.export)},
374
+ load: ${loaderId(action.file)},
375
+ }`,
376
+ );
377
+
378
+ return `${declarations.join("\n")}
379
+ export const actions = [
380
+ ${entries.join(",\n")}
381
+ ];
382
+ export default actions;
383
+ `;
384
+ }
385
+
386
+ /**
387
+ * A cheap identity for the manifest file, so a reader can tell it has changed.
388
+ *
389
+ * The plugin's `load` hook runs for every module in the graph and cannot parse
390
+ * the manifest each time. Size and modification time together are what
391
+ * `.uf/cache/transform` already keys on for the binary that wrote it, and the
392
+ * same reasoning applies: a file that differs in neither is the file that was
393
+ * read. `uf dev` also clears the cache outright when its watcher sees the
394
+ * manifest change, so this is the build's answer rather than the only one.
395
+ *
396
+ * @param {string | undefined} file
397
+ */
398
+ export function rscManifestKey(file) {
399
+ if (file == null || file === "") return "";
400
+ try {
401
+ const stats = statSync(file);
402
+ return `${String(stats.size)}:${String(stats.mtimeMs)}`;
403
+ } catch {
404
+ return "";
405
+ }
406
+ }
package/internal/serve.js CHANGED
@@ -240,12 +240,12 @@ export function assetsFromManifest(manifest) {
240
240
  * naming rather than papering over, and it is the failure path of a request
241
241
  * that already went wrong — not the ordinary one this exists for.
242
242
  *
243
- * @param {{beginRequest: (request: Request) => {run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
243
+ * @param {{beginRequest: (request: Request) => {context: object, run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
244
244
  * @param {Request} request
245
245
  * @param {() => Promise<mixed>} body
246
246
  */
247
247
  export async function withRequest(entry, request, body) {
248
- const { run, settle } = entry.beginRequest(request);
248
+ const { run, settle } = await beginRequest(entry, request);
249
249
  try {
250
250
  return await run(body);
251
251
  } finally {
@@ -253,6 +253,44 @@ export async function withRequest(entry, request, body) {
253
253
  }
254
254
  }
255
255
 
256
+ /**
257
+ * Begin a request on this host, with what this host can do already on it.
258
+ *
259
+ * The half of [`withRequest`] that a caller which may *not* answer needs.
260
+ * `uf dev` runs the application's middleware, its action endpoint and its
261
+ * dispatcher for every request, and hands the ones none of them claimed back
262
+ * to Vite's chain — at which point the response is written somewhere this
263
+ * module cannot see, so settling has to wait for the socket rather than for a
264
+ * `finally` here. A caller that always answers should use [`withRequest`] and
265
+ * not think about it.
266
+ *
267
+ * `entry.beginRequest` and not an import: the request lives in an
268
+ * `AsyncLocalStorage` belonging to one copy of `@uniflowed/server`, and the
269
+ * copy that matters is the one inside the application bundle. See
270
+ * `serverModuleSource` in `./routes.js`.
271
+ *
272
+ * What this host can do is put on the request the way `createFetchHandler`
273
+ * puts it on the one it owns. `uf dev` and `uf build --compile` reach a route
274
+ * handler without going through that function, and a handler that streams
275
+ * events or queues work has to get the same answer from all four front doors —
276
+ * a capability that is present under `uf start` and absent under `uf dev` is
277
+ * the difference this whole seam exists to remove.
278
+ *
279
+ * `nodeCapabilities`, because both of those *are* a Node process with a
280
+ * socket: a body reaches the client as it is written, and the process is still
281
+ * there afterwards. Neither passes an upgrader or a queue, because uf defines
282
+ * both and implements neither.
283
+ *
284
+ * @param {{beginRequest: (request: Request) => {context: object, run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
285
+ * @param {Request} request
286
+ */
287
+ export async function beginRequest(entry, request) {
288
+ const lifecycle = entry.beginRequest(request);
289
+ const { nodeCapabilities } = await deployment();
290
+ lifecycle.context.capabilities ??= nodeCapabilities();
291
+ return lifecycle;
292
+ }
293
+
256
294
  /**
257
295
  * The application half: route handlers, then rendering.
258
296
  *
@@ -274,11 +312,16 @@ export async function withRequest(entry, request, body) {
274
312
  * @param {{entry: object, assets: object, cache?: object}} build
275
313
  */
276
314
  export function createApplicationHandler({ entry, assets, cache }) {
277
- const ready = deployment().then(({ createFetchHandler, createCacheStore }) =>
315
+ const ready = deployment().then(({ createFetchHandler, createCacheStore, nodeCapabilities }) =>
278
316
  createFetchHandler({
279
317
  app: entry,
280
318
  document: assets,
281
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(),
282
325
  }),
283
326
  );
284
327
  return async function handle(request) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.12",
3
+ "version": "0.0.0-alpha.14",
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",
@@ -10,6 +10,14 @@
10
10
  "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
11
  "directory": "packages/vite"
12
12
  },
13
+ "uf": {
14
+ "builder": {
15
+ "driver": "./driver.js",
16
+ "preload": {
17
+ "bun": "@uniflowed/host/bun-preload"
18
+ }
19
+ }
20
+ },
13
21
  "exports": {
14
22
  ".": "./index.js",
15
23
  "./driver": "./driver.js",
@@ -25,8 +33,8 @@
25
33
  "dependencies": {
26
34
  "@mdx-js/rollup": "^3.1.1",
27
35
  "@shikijs/rehype": "^3.23.0",
28
- "@uniflowed/host": "0.0.0-alpha.12",
29
- "@uniflowed/server": "0.0.0-alpha.12",
36
+ "@uniflowed/host": "0.0.0-alpha.14",
37
+ "@uniflowed/server": "0.0.0-alpha.14",
30
38
  "rehype-slug": "^6.0.0",
31
39
  "remark-frontmatter": "^5.0.0",
32
40
  "remark-gfm": "^4.0.1",