@uniflowed/router 0.1.0 → 0.3.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/middleware.js CHANGED
@@ -60,6 +60,32 @@
60
60
  // middleware writes against `/pricing` holds for a client navigation too, and
61
61
  // a rewrite of the document becomes a rewrite of its payload.
62
62
  //
63
+ // # A payload that renders over another page runs that page's guards too
64
+ //
65
+ // An intercepted navigation — a modal slot opening over the page it was clicked
66
+ // from — asks for the payload of the URL it opens, and names the page it
67
+ // renders over in `uf-intercepted-from`. The answer is *both* pages: the one
68
+ // the URL names, in the slot, and the one the header names, underneath it,
69
+ // with that page's loader run and its data in the payload.
70
+ //
71
+ // So the chain matched against the URL alone would be the wrong chain. A guard
72
+ // on `/feed` holds for a request *for* `/feed`, and a payload for `/photo/1`
73
+ // rendered over `/feed` is `/feed` as much as it is `/photo/1`. After the
74
+ // ordinary chain has admitted the request, every middleware guarding the page
75
+ // underneath runs as well — against a `GET` for that page, with its params and
76
+ // query — including one that already ran for the URL, because a middleware
77
+ // that decides by reading the pathname (one root `$middleware.js` guarding
78
+ // several sections is a common shape) has only been asked about the other
79
+ // path. If every one of them declines, the interception stands. If any
80
+ // answers or rewrites, the page underneath is not this request's to render:
81
+ // the header is taken off, and what renders is the page the URL names on its
82
+ // own — exactly what a reader who could not open the page underneath would
83
+ // see after a reload. The guard's own answer is not sent: the request was for
84
+ // `/photo/1`, which that guard does not cover.
85
+ //
86
+ // The header is taken off whenever it is not a path on this origin, too, so
87
+ // the renderer is never handed a value this module has not judged.
88
+ //
63
89
  // # The request it runs inside
64
90
  //
65
91
  // The runner does not establish one. The host does — `beginRequest` in
@@ -96,7 +122,7 @@
96
122
  // `routesModuleSource` keeps the middleware table in an export the client
97
123
  // never imports, for the same reason it does that with route handlers.
98
124
 
99
- import { documentPathOf, flightUrl } from "./internal/flight.js";
125
+ import { INTERCEPTED_FROM_HEADER, documentPathOf, flightUrl } from "./internal/flight.js";
100
126
  import { requireRequest } from "./internal/request.js";
101
127
  import type { RouteParams } from "./internal/runtime.js";
102
128
 
@@ -160,7 +186,10 @@ export type MiddlewareRecord = {|
160
186
  * caller's signal to carry on to the handler or the page. Returns a `Request`
161
187
  * when one of them rewrote: the same request at the destination, which the
162
188
  * caller carries on with instead — and which has already been past the
163
- * destination's middleware.
189
+ * destination's middleware. It also returns a `Request` when a payload request
190
+ * names a page to render over and that page's guards did not all admit it: the
191
+ * same request without `uf-intercepted-from`. The host must carry on with that
192
+ * request and read the header off nothing else, or the decision is undone.
164
193
  *
165
194
  * The runner is called once per request, above both the dispatcher and the
166
195
  * renderer, rather than from inside each of them. Putting the call inside
@@ -242,15 +271,99 @@ export function createMiddlewareRunner(options: {|
242
271
  return result;
243
272
  }
244
273
 
245
- if (!rewritten) {
246
- return null;
274
+ const admitted: Request | null = !rewritten
275
+ ? null
276
+ : document == null
277
+ ? seen
278
+ : requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
279
+
280
+ // A payload rendered over another page: that page's guards have their say
281
+ // too. See "A payload that renders over another page" above.
282
+ if (document != null && request.headers.has(INTERCEPTED_FROM_HEADER)) {
283
+ const carried = admitted ?? request;
284
+ const underneath = interceptionBase(request.headers.get(INTERCEPTED_FROM_HEADER), url);
285
+ if (underneath == null || !(await guardsAdmit(table, carried, underneath))) {
286
+ return withoutInterception(carried);
287
+ }
247
288
  }
248
- return document == null
249
- ? seen
250
- : requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
289
+ return admitted;
251
290
  };
252
291
  }
253
292
 
293
+ /**
294
+ * The page an intercepted payload names in its header, as a URL on `base`'s
295
+ * origin, or `null` when the value is not a path on this origin.
296
+ *
297
+ * The same shape the renderer accepts (`usableInterceptionBase` in `./rsc.js`,
298
+ * `interceptedFrom` in `@uniflowed/server`): a path, not a network-path
299
+ * reference, with any fragment dropped. Parsed rather than compared as text,
300
+ * so the pathname the guards are matched against is the one the renderer's
301
+ * route matching will read.
302
+ */
303
+ function interceptionBase(header: string | null, base: URL): URL | null {
304
+ if (header == null || !header.startsWith("/") || header.startsWith("//")) {
305
+ return null;
306
+ }
307
+ let parsed: URL;
308
+ try {
309
+ parsed = new URL(header, base);
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (parsed.origin !== base.origin) {
314
+ return null;
315
+ }
316
+ parsed.hash = "";
317
+ return parsed;
318
+ }
319
+
320
+ /**
321
+ * Whether every middleware guarding `underneath` lets this request see it.
322
+ *
323
+ * Each one is asked exactly as it would be asked by a request for that page: a
324
+ * `GET` at its URL carrying this request's headers, with the params of the
325
+ * directory it guards and the page's query. All of them, root first, whether
326
+ * or not they already ran for the URL the request names — a guard that reads
327
+ * the pathname has so far only been asked about the other one.
328
+ *
329
+ * A `Response` is a refusal and so is a `rewrite()`: the page a guard would
330
+ * serve instead is not the page the header names, and the renderer has no way
331
+ * to render one under the other. Nothing either returns is sent; the caller
332
+ * only learns that the page underneath is not this request's to render.
333
+ */
334
+ async function guardsAdmit(
335
+ table: $ReadOnlyArray<MiddlewareRecord>,
336
+ request: Request,
337
+ underneath: URL,
338
+ ): Promise<boolean> {
339
+ const asked = new Request(underneath.href, { method: "GET", headers: request.headers });
340
+ for (const record of table) {
341
+ const params = matchPrefix(record.path, underneath.pathname);
342
+ if (params == null) {
343
+ continue;
344
+ }
345
+ const middleware = pick(await record.load(), record.file);
346
+ const result = await middleware(asked, { params, searchParams: underneath.searchParams });
347
+ if (result != null) {
348
+ return false;
349
+ }
350
+ }
351
+ return true;
352
+ }
353
+
354
+ /**
355
+ * `request` with its `uf-intercepted-from` header taken off, so the renderer
356
+ * answers with the page its URL names and nothing underneath it.
357
+ *
358
+ * Only a payload request reaches here, and a payload is a `GET` or a `HEAD`,
359
+ * so there is no body to hand on.
360
+ */
361
+ function withoutInterception(request: Request): Request {
362
+ const headers = new Headers(request.headers);
363
+ headers.delete(INTERCEPTED_FROM_HEADER);
364
+ return new Request(request.url, { method: request.method, headers, signal: request.signal });
365
+ }
366
+
254
367
  /**
255
368
  * Where a rewrite goes, resolved against the URL the middleware was handed.
256
369
  *
@@ -282,12 +395,28 @@ function withPathname(url: URL, pathname: string): URL {
282
395
  /**
283
396
  * `request` at another URL: same method, headers, signal and body.
284
397
  *
285
- * A `Request` is a valid `RequestInit`, so a streamed body is handed on rather
286
- * than read.
398
+ * Every member of `RequestInit` that a `Request` carries, named — what passing
399
+ * the request itself as the init would read, spelled out so that the checker
400
+ * can see it. A streamed body is handed on rather than read, which is why a
401
+ * body comes with `duplex: "half"`, the one mode a streamed request body has.
287
402
  */
288
403
  function requestAt(request: Request, url: URL): Request {
289
- // $FlowFixMe[incompatible-call] - a `Request` is read as the `RequestInit` it satisfies.
290
- return new Request(url.href, request);
404
+ const body = request.body;
405
+ return new Request(url.href, {
406
+ method: request.method,
407
+ headers: request.headers,
408
+ body,
409
+ ...(body == null ? {} : { duplex: "half" }),
410
+ signal: request.signal,
411
+ cache: request.cache,
412
+ credentials: request.credentials,
413
+ integrity: request.integrity,
414
+ keepalive: request.keepalive,
415
+ mode: request.mode,
416
+ redirect: request.redirect,
417
+ referrer: request.referrer,
418
+ referrerPolicy: request.referrerPolicy,
419
+ });
291
420
  }
292
421
 
293
422
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,8 @@
28
28
  "./middleware": "./middleware.js",
29
29
  "./native-navigation": "./native-navigation.js",
30
30
  "./http-client": "./http-client.js",
31
- "./instrumentation": "./instrumentation.js"
31
+ "./instrumentation": "./instrumentation.js",
32
+ "./testing": "./testing.js"
32
33
  },
33
34
  "files": [
34
35
  "action.js",
@@ -47,6 +48,7 @@
47
48
  "native-navigation.js",
48
49
  "http-client.js",
49
50
  "instrumentation.js",
51
+ "testing.js",
50
52
  "!*.test.js"
51
53
  ],
52
54
  "peerDependencies": {
@@ -71,7 +73,7 @@
71
73
  }
72
74
  },
73
75
  "dependencies": {
74
- "@uniflowed/hooks": "0.1.0",
75
- "@uniflowed/server": "0.1.0"
76
+ "@uniflowed/hooks": "0.3.0",
77
+ "@uniflowed/server": "0.3.0"
76
78
  }
77
79
  }
package/rsc-client.js CHANGED
@@ -41,6 +41,8 @@ import {
41
41
  installRouting,
42
42
  installStaleTime,
43
43
  } from "./internal/runtime.js";
44
+ import { hydrationOptions } from "./internal/hydrate-options.js";
45
+ import { readFormState } from "./internal/form-action.js";
44
46
 
45
47
  /**
46
48
  * Hydrate a document React Server Components rendered.
@@ -106,7 +108,7 @@ export async function hydrateFlight(options: {|
106
108
  hydrateRoot(
107
109
  container,
108
110
  options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
109
- recovery == null ? undefined : { onRecoverableError: recovery },
111
+ hydrationOptions(recovery, readFormState(document)),
110
112
  );
111
113
  if (restoreDevHead != null) {
112
114
  setTimeout(restoreDevHead, 250);
package/rsc-ssr.js CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  runPartialPrerender,
41
41
  } from "@uniflowed/server/host";
42
42
 
43
+ import type { FormState } from "./internal/form-action.js";
43
44
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
44
45
  import {
45
46
  type DocumentBody,
@@ -113,6 +114,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
113
114
  readonly onError: (error: mixed) => void,
114
115
  readonly transformHead?: (html: string) => Promise<string>,
115
116
  readonly onStream?: (record: StreamRecord) => void,
117
+ readonly formState?: FormState,
116
118
  |},
117
119
  ): Promise<DocumentBody> {
118
120
  const [forHtml, forBrowser] = stream.tee();
@@ -123,12 +125,13 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
123
125
  const nonce = currentNonce();
124
126
  try {
125
127
  return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
126
- shell: shellFor(assets, nonce),
128
+ shell: shellFor(assets, nonce, settings.formState),
127
129
  onError: settings.onError,
128
130
  transformHead: settings.transformHead,
129
131
  onStream: settings.onStream,
130
132
  payload: forBrowser,
131
133
  nonce,
134
+ formState: settings.formState,
132
135
  });
133
136
  } catch (error) {
134
137
  void forBrowser.cancel();
@@ -141,7 +144,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
141
144
  assets: RenderAssets,
142
145
  settings?: RenderOptions,
143
146
  ): Promise<RenderResult> {
144
- const report = settings?.onError ?? (() => {});
147
+ const report = settings?.onError ?? ((_error: mixed) => {});
145
148
  const send = settings?.onStream;
146
149
  const onStream = send == null ? undefined : streamReporter(url, send);
147
150
  const transformHead = settings?.transformHead;
@@ -189,6 +192,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
189
192
  },
190
193
  transformHead,
191
194
  onStream,
195
+ formState: settings?.formState,
192
196
  });
193
197
  streaming = true;
194
198
  for (const error of held) {
@@ -244,7 +248,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
244
248
  assets: RenderAssets,
245
249
  settings?: RenderOptions,
246
250
  ): Promise<PrerenderResult> {
247
- const report = settings?.onError ?? (() => {});
251
+ const report = settings?.onError ?? ((_error: mixed) => {});
248
252
  const ledger = createErrorLedger();
249
253
  // A read of the request, while the build may leave it for the request, is
250
254
  // not an exception to report: it is how the build finds the holes. Its row
@@ -401,7 +405,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
401
405
  shell: PrerenderedShell,
402
406
  settings?: RenderOptions,
403
407
  ): Promise<RenderResult> {
404
- const report = settings?.onError ?? (() => {});
408
+ const report = settings?.onError ?? ((_error: mixed) => {});
405
409
  const ledger = createErrorLedger();
406
410
  const stop = new AbortController();
407
411
  const rendered = await renderFlight(url, {
@@ -473,7 +477,16 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
473
477
  }
474
478
  return {
475
479
  status: rendered.status,
476
- headers: { "content-type": FLIGHT_CONTENT_TYPE, vary: INTERCEPTED_FROM_HEADER },
480
+ headers: {
481
+ "content-type": FLIGHT_CONTENT_TYPE,
482
+ vary: INTERCEPTED_FROM_HEADER,
483
+ // The payload carries the page's strings as they were rendered — a
484
+ // comment body with `<img onerror>` in it included — and its URL is one
485
+ // anybody can open as a document. `text/x-component` is not a type a
486
+ // browser sniffs into HTML today; `nosniff` makes that a promise rather
487
+ // than a property of today's browsers.
488
+ "x-content-type-options": "nosniff",
489
+ },
477
490
  stream: rendered.stream,
478
491
  error: rendered.failure,
479
492
  };
@@ -597,7 +610,7 @@ function digestOf(error: mixed): string | null {
597
610
  if (error == null || typeof error !== "object") {
598
611
  return null;
599
612
  }
600
- const tagged: { +digest?: mixed, ... } = (error: $FlowFixMe);
613
+ const tagged: { readonly digest?: mixed, ... } = error as $FlowFixMe;
601
614
  return typeof tagged.digest === "string" ? tagged.digest : null;
602
615
  }
603
616
 
package/rsc.js CHANGED
@@ -59,17 +59,28 @@ import { BoundaryReporter } from "./internal/boundaries.js";
59
59
  import { routeBoundaries } from "./internal/boundary-data.js";
60
60
  import { composeRoute, pageComponent } from "./internal/compose.js";
61
61
  import { ErrorRoutePage } from "./internal/error-view.js";
62
- import { type FlightRoot, routeState } from "./internal/flight.js";
62
+ import { type FlightRoot, crossableRouteError, routeState } from "./internal/flight.js";
63
63
  import { requireServerComponentsReact } from "./internal/react-version.js";
64
- import type { ErrorModule, PageModule, ResolvedRoute, ResolvedSlot } from "./internal/resolve.js";
64
+ import type {
65
+ ErrorModule,
66
+ PageModule,
67
+ ResolvedRoute,
68
+ ResolvedSlot,
69
+ RouteTable,
70
+ } from "./internal/resolve.js";
65
71
  import { resolveFailure, resolveInterception, resolveMatch } from "./internal/resolve.js";
66
- import type { RouteParams, RouteTable, SearchParams } from "./internal/routing.js";
72
+ import type { RouteParams, SearchParams } from "./internal/routing.js";
67
73
  import { RedirectError, nearestBoundary } from "./internal/routing.js";
68
74
  import { withServerRoute } from "./internal/server-route.js";
69
75
 
70
76
  export type { FlightRoot, RouteState } from "./internal/flight.js";
71
77
  // For `virtual:uf/rsc`, so a Server Component's `basePath()` is the project's.
72
78
  export { installRouting } from "./internal/base-path.js";
79
+ // For `virtual:uf/rsc` too: the server-action endpoint is built in this graph,
80
+ // beside the pages, so an action and the page that shows what it wrote run the
81
+ // same instance of every module they share (ubugeeei-prod/uf#1469). The host
82
+ // reaches it through the bridge, as it reaches `renderFlight`.
83
+ export { createActionDispatcher } from "./internal/action-endpoint.js";
73
84
 
74
85
  /** What a host may tell the renderer about one render. */
75
86
  export type FlightOptions = {|
@@ -209,9 +220,11 @@ export function createFlightRenderer(options: {|
209
220
  </>
210
221
  );
211
222
  const root: FlightRoot = { route: state, tree, deployment: options.deployment ?? null };
212
- const failure = renderFailure(route);
223
+ // From the route as it resolved, not the copy for the browser: the host is
224
+ // told what was actually thrown. See `crossableRouteError`.
225
+ const failure = renderFailure(resolved);
213
226
  if (failure != null) reportRequestError(failure, "render");
214
- const report = (error) => {
227
+ const report = (error: mixed) => {
215
228
  reportRequestError(error, "render");
216
229
  if (settings?.onError != null) return settings.onError(error);
217
230
  console.error(error);
@@ -227,7 +240,7 @@ export function createFlightRenderer(options: {|
227
240
  signal: settings?.signal,
228
241
  }),
229
242
  );
230
- return { kind: "route", status: route.status, stream, failure: renderFailure(route) };
243
+ return { kind: "route", status: route.status, stream, failure };
231
244
  };
232
245
  }
233
246
 
@@ -343,6 +356,9 @@ function forTheBrowser(resolved: ResolvedRoute, file: string | null): ResolvedRo
343
356
  const boundary = resolved.errorBoundary;
344
357
  return {
345
358
  ...resolved,
359
+ // Only an `Error` has React's production rule of carrying no message; see
360
+ // `crossableRouteError`.
361
+ error: crossableRouteError(resolved.error),
346
362
  errorBoundary: { above: boundary.above, module: clientErrorModule(boundary.module, file) },
347
363
  slots: resolved.slots.map(slotForTheBrowser),
348
364
  };
@@ -385,7 +401,7 @@ function isClientReference(value: mixed): boolean {
385
401
  if (value == null || (typeof value !== "object" && typeof value !== "function")) {
386
402
  return false;
387
403
  }
388
- const tagged: { +$$typeof?: mixed, ... } = (value: $FlowFixMe);
404
+ const tagged: { readonly $$typeof?: mixed, ... } = value as $FlowFixMe;
389
405
  return tagged.$$typeof === CLIENT_REFERENCE;
390
406
  }
391
407
 
package/server.js CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  resolveMatch,
49
49
  } from "./internal/runtime.js";
50
50
 
51
+ import type { FormState } from "./internal/form-action.js";
51
52
  import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
52
53
 
53
54
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
@@ -198,6 +199,12 @@ export type RenderOptions = {|
198
199
  * it changes is a failing test rather than a surprise.
199
200
  */
200
201
  readonly transformHead?: (html: string) => Promise<string>,
202
+ /**
203
+ * React's `formState` for a page rendered in answer to a form posted before
204
+ * hydration; see `internal/form-action.js`. Only a host's `postback` passes
205
+ * one.
206
+ */
207
+ readonly formState?: FormState,
201
208
  /**
202
209
  * Told, in words, when a document streamed differently than it did last time.
203
210
  *
@@ -405,7 +412,7 @@ export function createRenderer(options: {|
405
412
  return redirectDocument(resolution.error);
406
413
  }
407
414
  let resolved: ResolvedRoute = resolution.route;
408
- const report = settings?.onError ?? (() => {});
415
+ const report = settings?.onError ?? ((_error: mixed) => {});
409
416
  // Built once and shared by both renders below, so a page that threw its
410
417
  // shell away and rendered its error boundary instead reports the stream the
411
418
  // browser was actually sent rather than the one that was abandoned.
@@ -436,11 +443,12 @@ export function createRenderer(options: {|
436
443
  let body: DocumentBody;
437
444
  try {
438
445
  body = await renderDocument(<App url={url} initial={resolved} />, {
439
- shell: shellFor(assets, nonce),
446
+ shell: shellFor(assets, nonce, settings?.formState),
440
447
  onError,
441
448
  transformHead: settings?.transformHead,
442
449
  onStream,
443
450
  nonce,
451
+ formState: settings?.formState,
444
452
  });
445
453
  streaming = true;
446
454
  // Recovered before the shell was ready: a `<Suspense>` boundary whose
@@ -469,11 +477,12 @@ export function createRenderer(options: {|
469
477
  // where somebody can fix it.
470
478
  streaming = true;
471
479
  body = await renderDocument(<App url={url} initial={resolved} />, {
472
- shell: shellFor(assets, nonce),
480
+ shell: shellFor(assets, nonce, settings?.formState),
473
481
  onError,
474
482
  transformHead: settings?.transformHead,
475
483
  onStream,
476
484
  nonce,
485
+ formState: settings?.formState,
477
486
  });
478
487
  }
479
488
 
@@ -496,7 +505,7 @@ export function createRenderer(options: {|
496
505
  return redirectResult(redirectDocument(resolution.error));
497
506
  }
498
507
  let resolved: ResolvedRoute = resolution.route;
499
- const report = settings?.onError ?? (() => {});
508
+ const report = settings?.onError ?? ((_error: mixed) => {});
500
509
 
501
510
  let html: string;
502
511
  try {