@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/middleware.js CHANGED
@@ -27,10 +27,38 @@
27
27
  // that could only observe would not be able to reject, and one that had to
28
28
  // answer could not be a logger.
29
29
  //
30
- // There is no `next()` and no way to rewrite the request. Rewriting needs a
31
- // spelling — a returned `Request`, or a `next(request)` argument — and picking
32
- // one badly is harder to undo than not having it, so it is not spelled here
33
- // yet. What exists answers or continues, and says so.
30
+ // There is no `next()`. A third answer is `rewrite(destination)`: serve another
31
+ // route of this application at the address the visitor asked for.
32
+ //
33
+ // // app/$middleware.js
34
+ // import { rewrite } from "@uniflowed/router/middleware";
35
+ //
36
+ // export default function middleware(request: Request) {
37
+ // if (cookies().get("beta") != null) return rewrite("/beta" + new URL(request.url).pathname);
38
+ // }
39
+ //
40
+ // A returned value rather than a returned `Request`, which was the other
41
+ // spelling on the table. A `Request` could change the method and the headers
42
+ // too, and `headers()` reads the request the host began — so a middleware that
43
+ // added a header would have handed the page one set of headers and `headers()`
44
+ // another. A rewrite changes the path, the query when it names one, and
45
+ // nothing else.
46
+ //
47
+ // # A rewrite runs the destination's middleware
48
+ //
49
+ // The chain starts again from the root over the middleware that has not run
50
+ // yet, against the new path. So a rewrite into `/admin` passes the guard on
51
+ // `/admin` exactly as a request for it would, and is never an unguarded way to
52
+ // a guarded page; a middleware that already ran for this request does not run
53
+ // a second time, which is also what makes the loop finite.
54
+ //
55
+ // # A payload request is its document
56
+ //
57
+ // A browser navigating a React Server Components application asks for
58
+ // `/pricing/__uf.flight` rather than `/pricing`. The chain is matched against,
59
+ // and every middleware is handed, the document's URL — so the check a
60
+ // middleware writes against `/pricing` holds for a client navigation too, and
61
+ // a rewrite of the document becomes a rewrite of its payload.
34
62
  //
35
63
  // # The request it runs inside
36
64
  //
@@ -68,6 +96,7 @@
68
96
  // `routesModuleSource` keeps the middleware table in an export the client
69
97
  // never imports, for the same reason it does that with route handlers.
70
98
 
99
+ import { documentPathOf, flightUrl } from "./internal/flight.js";
71
100
  import { requireRequest } from "./internal/request.js";
72
101
  import type { RouteParams } from "./internal/runtime.js";
73
102
 
@@ -79,11 +108,39 @@ export type MiddlewareContext = {|
79
108
  readonly searchParams: URLSearchParams,
80
109
  |};
81
110
 
111
+ /**
112
+ * What a middleware returns to serve another route at the requested address.
113
+ *
114
+ * Built by [`rewrite`] and read by the runner; a class so that the runner can
115
+ * tell it from a `Response` without trusting the shape of an object.
116
+ */
117
+ export class Rewrite {
118
+ readonly destination: string;
119
+
120
+ constructor(destination: string) {
121
+ this.destination = destination;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Serve `destination` — a path of this application — in place of the path the
127
+ * request named.
128
+ *
129
+ * Relative to the request, so `"/beta/pricing"` and `"../pricing"` both work.
130
+ * A destination that names no query keeps the request's; one that names a
131
+ * query replaces it. Another origin is refused when the middleware returns it:
132
+ * sending a visitor elsewhere is `Response.redirect`, and proxying to another
133
+ * server is a route handler that fetches.
134
+ */
135
+ export function rewrite(destination: string | URL): Rewrite {
136
+ return new Rewrite(typeof destination === "string" ? destination : destination.href);
137
+ }
138
+
82
139
  /** One middleware function. */
83
140
  export type Middleware = (
84
141
  request: Request,
85
142
  context: MiddlewareContext,
86
- ) => Response | void | Promise<Response | void>;
143
+ ) => Response | Rewrite | void | Promise<Response | Rewrite | void>;
87
144
 
88
145
  /** A middleware module, as the generated table loads it. */
89
146
  export type MiddlewareModule = { readonly [name: string]: mixed };
@@ -100,7 +157,10 @@ export type MiddlewareRecord = {|
100
157
  * Build the middleware runner for one application.
101
158
  *
102
159
  * Returns `null` when every middleware on the path declined, which is the
103
- * caller's signal to carry on to the handler or the page.
160
+ * caller's signal to carry on to the handler or the page. Returns a `Request`
161
+ * when one of them rewrote: the same request at the destination, which the
162
+ * caller carries on with instead — and which has already been past the
163
+ * destination's middleware.
104
164
  *
105
165
  * The runner is called once per request, above both the dispatcher and the
106
166
  * renderer, rather than from inside each of them. Putting the call inside
@@ -112,7 +172,7 @@ export type MiddlewareRecord = {|
112
172
  */
113
173
  export function createMiddlewareRunner(options: {|
114
174
  readonly middleware: $ReadOnlyArray<MiddlewareRecord>,
115
- |}): (request: Request) => Promise<Response | null> {
175
+ |}): (request: Request) => Promise<Response | Request | null> {
116
176
  // Root first, so an application-wide check runs before the one that guards a
117
177
  // section of it. A shorter path is always an ancestor of a longer one that
118
178
  // also matched, so segment count is the whole of the ordering.
@@ -120,7 +180,7 @@ export function createMiddlewareRunner(options: {|
120
180
  (a, b) => segmentsOf(a.path).length - segmentsOf(b.path).length,
121
181
  );
122
182
 
123
- return async function runMiddleware(request: Request): Promise<Response | null> {
183
+ return async function runMiddleware(request: Request): Promise<Response | Request | null> {
124
184
  // Checked rather than assumed, and checked before the table so that a host
125
185
  // is caught on its first request whether or not this project happens to
126
186
  // have a middleware. `createApplicationHandler` makes the same argument
@@ -138,13 +198,26 @@ export function createMiddlewareRunner(options: {|
138
198
  return null;
139
199
  }
140
200
 
141
- const url = new URL(request.url);
201
+ const arrived = new URL(request.url);
202
+ const document = documentPathOf(arrived.pathname);
203
+ // The URL the chain is matched against and every middleware is handed: the
204
+ // document's, for a payload request. See "A payload request is its
205
+ // document" above.
206
+ let url = document == null ? arrived : withPathname(arrived, document);
207
+ let seen = document == null ? request : requestAt(request, url);
208
+ let rewritten = false;
209
+ const ran: Set<MiddlewareRecord> = new Set();
142
210
 
143
- for (const record of table) {
211
+ for (let index = 0; index < table.length; index += 1) {
212
+ const record = table[index];
213
+ if (ran.has(record)) {
214
+ continue;
215
+ }
144
216
  const params = matchPrefix(record.path, url.pathname);
145
217
  if (params == null) {
146
218
  continue;
147
219
  }
220
+ ran.add(record);
148
221
 
149
222
  const middleware = pick(await record.load(), record.file);
150
223
  // In the host's context, not one of this module's own. Two middleware on
@@ -152,16 +225,71 @@ export function createMiddlewareRunner(options: {|
152
225
  // underneath them: `draftMode().isEnabled` is one answer for the whole
153
226
  // request, and every `after()` on the request lands in one ordered list
154
227
  // that the host drains once, after the response has gone.
155
- const result = await middleware(request, { params, searchParams: url.searchParams });
156
- if (result != null) {
157
- return result;
228
+ const result = await middleware(seen, { params, searchParams: url.searchParams });
229
+ if (result == null) {
230
+ continue;
231
+ }
232
+ if (result instanceof Rewrite) {
233
+ url = destinationOf(result.destination, url, record.file);
234
+ seen = requestAt(seen, url);
235
+ rewritten = true;
236
+ // From the root again, over what has not run: the destination's guards
237
+ // are owed their say, and the ones that already had it are not asked
238
+ // twice.
239
+ index = -1;
240
+ continue;
158
241
  }
242
+ return result;
159
243
  }
160
244
 
161
- return null;
245
+ if (!rewritten) {
246
+ return null;
247
+ }
248
+ return document == null
249
+ ? seen
250
+ : requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
162
251
  };
163
252
  }
164
253
 
254
+ /**
255
+ * Where a rewrite goes, resolved against the URL the middleware was handed.
256
+ *
257
+ * Refused by name when it leaves the origin, because the one thing a rewrite
258
+ * promises is that this application answers.
259
+ */
260
+ function destinationOf(destination: string, base: URL, file: string): URL {
261
+ const next = new URL(destination, base);
262
+ if (next.origin !== base.origin) {
263
+ throw new Error(
264
+ `${file} rewrote ${base.pathname} to ${destination}, which is another origin. A rewrite ` +
265
+ "serves another route of this application: answer with `Response.redirect` to send the " +
266
+ "visitor elsewhere, or fetch the other server from a route handler.",
267
+ );
268
+ }
269
+ if (!destination.includes("?")) {
270
+ next.search = base.search;
271
+ }
272
+ next.hash = "";
273
+ return next;
274
+ }
275
+
276
+ function withPathname(url: URL, pathname: string): URL {
277
+ const next = new URL(url.href);
278
+ next.pathname = pathname;
279
+ return next;
280
+ }
281
+
282
+ /**
283
+ * `request` at another URL: same method, headers, signal and body.
284
+ *
285
+ * A `Request` is a valid `RequestInit`, so a streamed body is handed on rather
286
+ * than read.
287
+ */
288
+ 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);
291
+ }
292
+
165
293
  /**
166
294
  * The function a middleware module exports.
167
295
  *
@@ -171,6 +299,8 @@ export function createMiddlewareRunner(options: {|
171
299
  * ignored is the bug this whole module exists to stop happening.
172
300
  */
173
301
  function pick(module: MiddlewareModule, file: string): Middleware {
302
+ // `rewrite` is an export a middleware module may well import, and is never
303
+ // the middleware itself.
174
304
  const exported = typeof module.default === "function" ? module.default : module.middleware;
175
305
  if (typeof exported !== "function") {
176
306
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.35",
3
+ "version": "0.0.0-alpha.37",
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",
@@ -19,6 +19,8 @@
19
19
  "./client": "./client.js",
20
20
  "./native": "./native.js",
21
21
  "./rsc": "./rsc.js",
22
+ "./rsc/client": "./rsc-client.js",
23
+ "./rsc/ssr": "./rsc-ssr.js",
22
24
  "./server": "./server.js",
23
25
  "./routing": "./routing.js",
24
26
  "./package.json": "./package.json",
@@ -34,23 +36,28 @@
34
36
  "middleware.js",
35
37
  "native.js",
36
38
  "routing.js",
39
+ "rsc-client.js",
40
+ "rsc-ssr.js",
37
41
  "rsc.js",
38
42
  "server-components.js",
39
43
  "server.js",
40
44
  "!*.test.js"
41
45
  ],
42
46
  "peerDependencies": {
43
- "react": ">=19.3.0",
44
- "react-dom": ">=19.3.0",
47
+ "react": ">=19.2.3",
48
+ "react-dom": ">=19.2.3",
45
49
  "react-server-dom-parcel": ">=19.3.0"
46
50
  },
47
51
  "peerDependenciesMeta": {
48
52
  "react-dom": {
49
53
  "optional": true
54
+ },
55
+ "react-server-dom-parcel": {
56
+ "optional": true
50
57
  }
51
58
  },
52
59
  "dependencies": {
53
- "@uniflowed/hooks": "0.0.0-alpha.35",
54
- "@uniflowed/server": "0.0.0-alpha.35"
60
+ "@uniflowed/hooks": "0.0.0-alpha.37",
61
+ "@uniflowed/server": "0.0.0-alpha.37"
55
62
  }
56
63
  }
package/rsc-client.js ADDED
@@ -0,0 +1,117 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/router/rsc/client`: starting, in the browser, an application that
4
+ // React Server Components rendered.
5
+ //
6
+ // `virtual:uf/client` imports this entry when routes render as Server
7
+ // Components, which is the default. It imports `@uniflowed/router/client` when
8
+ // they render from their modules (`app.rsc: false`) or into an empty shell
9
+ // (`app.rendering.modes: ["csr"]`). This is an entry of its own because it loads
10
+ // React's Flight client, `react-server-dom-parcel`. That package is an optional
11
+ // peer and needs React 19.3, while the rest of the router runs on the React
12
+ // 19.2.3 that Expo SDK 57 and React Native 0.87 ship (ubugeeei-prod/uf#992). A
13
+ // bundler resolves every import in the graph it is given, whether or not
14
+ // anything calls it, so a browser bundle that renders no Server Component
15
+ // leaves the package out only if nothing it imports names it.
16
+ // `crates/uf_lib/tests/package_surface.rs` holds the router to that.
17
+ //
18
+ // The same reason keeps the payload fetch out of `./internal/runtime.js`.
19
+ // Navigation there serves every application, so `hydrateFlight` hands it the
20
+ // fetch before the first render.
21
+
22
+ import * as React from "react";
23
+ import { StrictMode, startTransition } from "react";
24
+ import { hydrateRoot } from "react-dom/client";
25
+
26
+ import { type TrailingSlash, applicationPathOf } from "./internal/base-path.js";
27
+ import { ROOT_ID } from "./internal/document.js";
28
+ import {
29
+ fetchFlight,
30
+ installBrowserModules,
31
+ readDocumentPayload,
32
+ } from "./internal/flight-browser.js";
33
+ import { domObserver } from "./internal/payload-rows.js";
34
+ import { prepareDocumentForHydration } from "./internal/prepare-document.js";
35
+ import { requireServerComponentsReact } from "./internal/react-version.js";
36
+ import {
37
+ type AppProps,
38
+ type Navigation,
39
+ installFlightFetch,
40
+ installNavigation,
41
+ installRouting,
42
+ } from "./internal/runtime.js";
43
+
44
+ /**
45
+ * Hydrate a document React Server Components rendered.
46
+ *
47
+ * `hydrate` in `./client.js` resolves the route from its modules and renders it
48
+ * again over the server's markup. This one resolves nothing and imports no route
49
+ * module: the document carries the Flight payload its tree was rendered from,
50
+ * React's own client reads it, and the tree the browser hydrates is the tree the
51
+ * server rendered — a Server Component is markup and a reference, and a client
52
+ * component is the one kind of module this page loads. See
53
+ * ubugeeei-prod/uf#519.
54
+ *
55
+ * The payload is read while the document is still arriving. Row 0 is in the
56
+ * shell, so hydration starts as soon as the module script runs, and every row
57
+ * after it lands in a later chunk that the reader picks up as it is parsed — so
58
+ * a boundary the server completes after hydration began resolves then, with no
59
+ * second request.
60
+ *
61
+ * Everything else is `hydrate`'s, for the reasons written there: the navigation
62
+ * mode is installed before the first render, the development hydration report
63
+ * captures the server's markup before React repairs it, and Strict Mode wraps
64
+ * the root.
65
+ *
66
+ * On a React older than 19.3 it refuses before it touches the page, naming the
67
+ * version it found; see `./internal/react-version.js`.
68
+ */
69
+ export async function hydrateFlight(options: {|
70
+ readonly App: React.ComponentType<AppProps>,
71
+ readonly strictMode?: boolean,
72
+ readonly navigation?: Navigation,
73
+ readonly basePath?: string,
74
+ readonly trailingSlash?: TrailingSlash,
75
+ |}): Promise<void> {
76
+ requireServerComponentsReact("@uniflowed/router/rsc/client");
77
+ installNavigation(options.navigation ?? "client");
78
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
79
+ installFlightFetch(fetchFlight);
80
+ installBrowserModules();
81
+ const flight = readDocumentPayload(document, domObserver(document));
82
+
83
+ // The route table has no base path in it, and the address bar does.
84
+ const url =
85
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
86
+ window.location.search;
87
+ const { App } = options;
88
+ const container = document.getElementById(ROOT_ID) ?? document;
89
+ prepareDocumentForHydration(document);
90
+
91
+ let recovery = null;
92
+ let restoreDevHead = null;
93
+ if (import.meta.hot != null) {
94
+ const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
95
+ await import("./internal/hydration.js");
96
+ restoreDevHead = prepareDevHeadForHydration(document);
97
+ recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
98
+ }
99
+
100
+ const tree = <App url={url} flight={flight} />;
101
+
102
+ startTransition(() => {
103
+ hydrateRoot(
104
+ container,
105
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
106
+ recovery == null ? undefined : { onRecoverableError: recovery },
107
+ );
108
+ if (restoreDevHead != null) {
109
+ setTimeout(restoreDevHead, 250);
110
+ }
111
+ });
112
+
113
+ if (import.meta.hot != null) {
114
+ const { reportDevtools } = await import("./internal/devtools.js");
115
+ reportDevtools(window);
116
+ }
117
+ }