@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.40

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.
Files changed (42) hide show
  1. package/action.js +301 -0
  2. package/client.js +295 -8
  3. package/handler.js +101 -17
  4. package/index.js +71 -4
  5. package/internal/action-endpoint.js +434 -0
  6. package/internal/action-wire.js +608 -0
  7. package/internal/base-path.js +175 -0
  8. package/internal/boundaries.js +481 -0
  9. package/internal/boundary-data.js +88 -0
  10. package/internal/compose.js +490 -0
  11. package/internal/devtools.js +131 -0
  12. package/internal/diagnostics.js +169 -0
  13. package/internal/error-view.js +193 -0
  14. package/internal/flight-browser.js +228 -0
  15. package/internal/flight-chunks.js +205 -0
  16. package/internal/flight-ssr.js +78 -0
  17. package/internal/flight.js +158 -0
  18. package/internal/head.js +219 -0
  19. package/internal/hydration.js +1085 -0
  20. package/internal/inspector.js +626 -0
  21. package/internal/navigation-cache.js +144 -0
  22. package/internal/payload-rows.js +270 -0
  23. package/internal/payload.js +685 -0
  24. package/internal/prepare-document.js +49 -0
  25. package/internal/react-version.js +77 -0
  26. package/internal/request.js +43 -0
  27. package/internal/resolve.js +1615 -0
  28. package/internal/resolved-summary.js +199 -0
  29. package/internal/routing.js +474 -0
  30. package/internal/runtime.js +1511 -543
  31. package/internal/server-route.js +58 -0
  32. package/internal/shell.js +115 -0
  33. package/internal/stream.js +1084 -0
  34. package/middleware.js +350 -0
  35. package/native.js +408 -0
  36. package/package.json +36 -7
  37. package/routing.js +51 -0
  38. package/rsc-client.js +120 -0
  39. package/rsc-ssr.js +440 -0
  40. package/rsc.js +334 -0
  41. package/server-components.js +159 -0
  42. package/server.js +448 -75
package/handler.js CHANGED
@@ -2,13 +2,13 @@
2
2
  //
3
3
  // Route handlers: a path that answers a request instead of rendering a page.
4
4
  //
5
- // `app/api/users/_uf.route.js` exporting `GET` and `POST` serves
5
+ // `app/api/users/$route.js` exporting `GET` and `POST` serves
6
6
  // `/api/users`. A handler takes a `Request` and returns a `Response` — the
7
7
  // platform's own types, not a framework's wrapper — because that is what runs
8
8
  // unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
9
9
  // position is that the host is a capability rather than a target.
10
10
  //
11
- // // app/api/users/[id]/_uf.route.js
11
+ // // app/api/users/[id]/$route.js
12
12
  // // @flow
13
13
  // export async function GET(request: Request, context: HandlerContext) {
14
14
  // const user = await find(context.params.id);
@@ -25,8 +25,53 @@
25
25
  // It does answer `405` itself when the path matches and the method does not,
26
26
  // with the `Allow` header the specification requires — that is not the
27
27
  // handler's business, and every handler would otherwise write it.
28
+ //
29
+ // # `QUERY`, and what refuses it
30
+ //
31
+ // `QUERY` is a `GET` with a body: safe, idempotent, cacheable, and the method
32
+ // that a search with more parameters than a URL can hold has been faking with a
33
+ // `POST` for twenty years. A handler exports it like any other verb, and this
34
+ // dispatcher matches it like any other verb, because there is nothing special
35
+ // about it *here*. What is special about it is the path between a client and
36
+ // this function, and that is the part worth writing down rather than leaving to
37
+ // be discovered in production.
38
+ //
39
+ // Three things refuse it, and they refuse it differently:
40
+ //
41
+ // * **A client that cannot send it.** The Fetch standard forbids `CONNECT`,
42
+ // `TRACE` and `TRACK` and allows any other token, so every browser and
43
+ // every runtime uf targets can send a `QUERY` today. `XMLHttpRequest` and
44
+ // `EventSource` cannot, and neither can a `<form>`.
45
+ // * **An intermediary that will not forward it.** This is the real one. A
46
+ // proxy, a CDN or a WAF that has a list of methods answers `405` or `501`
47
+ // itself, and the request never arrives — so the failure looks exactly like
48
+ // a route that does not exist, from a server that never saw it.
49
+ // `@uniflowed/fetch` names that case in the error rather than passing the
50
+ // status through, which is the whole of what "stated rather than
51
+ // discovered" can mean from the other end of a wire.
52
+ // * **A cache that does not know it is safe.** `QUERY` is cacheable in
53
+ // principle and the key includes the body, which almost nothing implements.
54
+ // uf's own route cache is `GET`-only and stays that way; anything in front
55
+ // of the application should be told not to store a `QUERY` at all.
56
+ //
57
+ // What uf deliberately does not do about any of it is accept a method-override
58
+ // header. `X-HTTP-Method-Override: QUERY` on a `POST` is the usual workaround
59
+ // and it is the shape of CVE-2025-29927: an inbound header steering dispatch,
60
+ // which `docs/security.md` forbids in the row about that CVE and in rule 3. A
61
+ // route that must work through hostile infrastructure exports `POST` as well
62
+ // and says so in its own file, where a reader can see it.
63
+ //
64
+ // It also does not establish the request a handler is inside. The host does,
65
+ // around the whole of it, so a handler and the guard above it share one
66
+ // context; see the same section in `./middleware.js`. This module used to
67
+ // build its own and drain it the moment the handler returned, which the
68
+ // comment there called "the response is in hand" — true, and not what
69
+ // `after()` promises. A handler that streams its body has not sent a byte at
70
+ // that point. See ubugeeei-prod/uf#389.
28
71
 
29
- import { contextFor, drainDeferred, runWithContext } from "@uniflowed/server/host";
72
+ import { asResponder, noteRoute } from "@uniflowed/server/host";
73
+
74
+ import { requireRequest } from "./internal/request.js";
30
75
  import type { RouteParams } from "./internal/runtime.js";
31
76
 
32
77
  /** What a handler is given besides the request. */
@@ -58,8 +103,27 @@ export type HandlerRecord = {|
58
103
  * — and a module that exports a helper would then answer requests with it.
59
104
  * `HEAD` falls back to `GET` with the body dropped, which is what a client
60
105
  * asking for headers expects and what nobody remembers to write.
106
+ *
107
+ * It was closed in name only until `QUERY` was added. `pick` looked the method
108
+ * up on the module and this list decided nothing but the order of the `Allow`
109
+ * header, so a module exporting `PURGE` answered `PURGE` — the exact behaviour
110
+ * the paragraph above says is refused. Adding a verb was the moment to make the
111
+ * sentence true, because the alternative was adding one to a list nothing read.
112
+ *
113
+ * `QUERY` is here and `CONNECT` and `TRACE` are not, and the difference is not
114
+ * taste: the `fetch` specification forbids the last two outright, so a handler
115
+ * exporting either could never be reached by a browser.
61
116
  */
62
- const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
117
+ export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
118
+ "GET",
119
+ "HEAD",
120
+ "QUERY",
121
+ "POST",
122
+ "PUT",
123
+ "PATCH",
124
+ "DELETE",
125
+ "OPTIONS",
126
+ ]);
63
127
 
64
128
  /**
65
129
  * Match a request against the handler table and run it.
@@ -76,6 +140,9 @@ export function createDispatcher(options: {|
76
140
  const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
77
141
 
78
142
  return async function dispatch(request: Request): Promise<Response | null> {
143
+ // The host's half of the contract, checked rather than assumed; see
144
+ // `./internal/request.js`.
145
+ requireRequest("dispatch");
79
146
  const url = new URL(request.url);
80
147
  for (const record of table) {
81
148
  const params = matchPath(record.path, url.pathname);
@@ -83,6 +150,12 @@ export function createDispatcher(options: {|
83
150
  continue;
84
151
  }
85
152
 
153
+ // Before the module is loaded and before the method is checked, because
154
+ // this is the answer to "what was this request" and a `405` is as much
155
+ // this route's answer as a `200` is. A log of `/api/users/:id 405` is
156
+ // actionable; the same line with the path in it is a million lines.
157
+ noteRoute(record.path);
158
+
86
159
  const module = await record.load();
87
160
  const method = request.method.toUpperCase();
88
161
  const handler = pick(module, method);
@@ -90,17 +163,18 @@ export function createDispatcher(options: {|
90
163
  return methodNotAllowed(module);
91
164
  }
92
165
 
93
- // Inside the request, so a handler that calls `headers()`, `cookies()`
94
- // or `after()` has something to answer about. `drainDeferred` runs after
95
- // the response is in hand, which is what `after()` means.
96
- const context = contextFor(request);
97
- const response = await runWithContext(context, () =>
98
- handler(request, {
99
- params,
100
- searchParams: url.searchParams,
101
- }),
166
+ // In the host's request, so a handler that calls `headers()`,
167
+ // `cookies()` or `after()` answers about the same one its guard did, and
168
+ // what it defers is drained once, by the host, after the bytes are out.
169
+ //
170
+ // And inside `asResponder`, which is the other half: a route handler is
171
+ // one of the two things that owns a response, so it is one of the two
172
+ // places `draftMode().enable()` is allowed — and the `Set-Cookie` it
173
+ // decided on is written onto the response below rather than left on an
174
+ // object the host is about to discard. See ubugeeei-prod/uf#282.
175
+ const response = await asResponder("a route handler", async () =>
176
+ handler(request, { params, searchParams: url.searchParams }),
102
177
  );
103
- await drainDeferred(context);
104
178
 
105
179
  // A `HEAD` answered by `GET` must not carry the body. The test is
106
180
  // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
@@ -119,8 +193,18 @@ export function createDispatcher(options: {|
119
193
  };
120
194
  }
121
195
 
122
- /** The function for a method, falling back to `GET` for `HEAD`. */
196
+ /**
197
+ * The function for a method, falling back to `GET` for `HEAD`.
198
+ *
199
+ * The method is checked against `METHODS` first, which is what makes that list
200
+ * closed rather than decorative: without it a request could name any export,
201
+ * and a module's `PURGE` — or its `DEFAULT`, or a name a bundler added — would
202
+ * answer one.
203
+ */
123
204
  function pick(module: HandlerModule, method: string): Handler | null {
205
+ if (!HANDLER_METHODS.includes(method)) {
206
+ return null;
207
+ }
124
208
  const own = module[method];
125
209
  if (typeof own === "function") {
126
210
  return own as $FlowFixMe;
@@ -138,7 +222,7 @@ function pick(module: HandlerModule, method: string): Handler | null {
138
222
  * do that here" from "there is nothing here".
139
223
  */
140
224
  function methodNotAllowed(module: HandlerModule): Response {
141
- const own = new Set(METHODS.filter((method) => typeof module[method] === "function"));
225
+ const own = new Set(HANDLER_METHODS.filter((method) => typeof module[method] === "function"));
142
226
  // A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
143
227
  if (own.has("GET")) {
144
228
  own.add("HEAD");
@@ -147,7 +231,7 @@ function methodNotAllowed(module: HandlerModule): Response {
147
231
  // header reads in the conventional order however the module was written.
148
232
  return new Response(null, {
149
233
  status: 405,
150
- headers: { allow: METHODS.filter((method) => own.has(method)).join(", ") },
234
+ headers: { allow: HANDLER_METHODS.filter((method) => own.has(method)).join(", ") },
151
235
  });
152
236
  }
153
237
 
package/index.js CHANGED
@@ -2,21 +2,54 @@
2
2
  //
3
3
  // `@uniflowed/router`: the file-system router.
4
4
  //
5
- // Pages live in `app/` as `_uf.page.js` (or `.mdx`), layouts as
6
- // `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
5
+ // Pages live in `app/` as `$page.js` (or `.mdx`), layouts as
6
+ // `$layout.js`, and `app.js` exports `routerView("./app")`. The route table
7
7
  // is generated from the directory at build time; this module is the runtime
8
8
  // that matches, loads, navigates and renders it.
9
+ //
10
+ // `$not-found.js` and `$error.js` are the two boundaries: the page for a
11
+ // path that matched nothing, and what renders in place of a subtree that threw.
12
+ // Both are segment files, resolved by the nearest one above the path — and the
13
+ // router root always has one of each, so a project that declares neither still
14
+ // answers a 404 inside its own layouts rather than beside them.
15
+ //
16
+ // `$template.js` is a layout that remounts on every navigation, for the
17
+ // cases where a layout's persistence is the wrong default.
18
+ //
19
+ // A directory named `@team` is a parallel-route slot: it contributes no URL
20
+ // segment, and the layout of the segment that holds it receives the slot as a
21
+ // `team` prop beside `children`. The slot's pages are matched against the same
22
+ // URL the page is, so one URL renders two subtrees at once, and
23
+ // `$default.js` is what a slot renders when the URL matched none of its
24
+ // routes. See ubugeeei-prod/uf#267.
25
+ //
26
+ // A directory named `(.)photo` inside a slot is an intercepting route: a client
27
+ // navigation that starts on a page the slot is on, and reaches the URL the
28
+ // directory stands in for, renders it in the slot and leaves the page
29
+ // underneath where it was. A document request for that URL — a reload, a
30
+ // shared link, a prerender — renders the ordinary page.
31
+
32
+ import * as React from "react";
33
+
34
+ import type { RouteError } from "./internal/runtime.js";
9
35
 
10
36
  export type {
11
37
  AppProps,
38
+ ErrorBoundary,
39
+ ErrorModule,
40
+ Interception,
41
+ JsonLd,
12
42
  LayoutModule,
13
43
  LinkPrefetch,
14
44
  LoaderArgs,
15
45
  Metadata,
16
46
  MetadataArgs,
17
47
  NavigateOptions,
48
+ NotFoundBoundary,
18
49
  PageModule,
19
50
  ResolvedRoute,
51
+ Robots,
52
+ RouteError,
20
53
  RouteInfo,
21
54
  RouteMatch,
22
55
  RouteParamSpec,
@@ -24,27 +57,42 @@ export type {
24
57
  RouteRecord,
25
58
  RouteTable,
26
59
  Router,
60
+ ResolvedSlot,
27
61
  SearchParams,
62
+ SlotRecord,
63
+ SlotRouteRecord,
64
+ TemplateModule,
65
+ TwitterCard,
28
66
  } from "./internal/runtime.js";
29
67
 
30
68
  export {
69
+ ForbiddenError,
31
70
  Link,
32
71
  NotFoundError,
33
72
  RedirectError,
34
73
  RouteView,
35
74
  RouterProvider,
75
+ UnauthorizedError,
76
+ basePath,
77
+ buildRoute,
78
+ forbidden,
79
+ hasClientPage,
36
80
  matchRoute,
37
81
  notFound,
38
82
  parseSearch,
39
83
  permanentRedirect,
40
84
  redirect,
85
+ resolveFailure,
41
86
  resolveMatch,
87
+ routeErrorStatus,
42
88
  routerView,
43
89
  splitUrl,
90
+ unauthorized,
44
91
  useIsServer,
45
92
  useLoaderData,
46
93
  useRoute,
47
94
  useRouter,
95
+ useSeo,
48
96
  } from "./internal/runtime.js";
49
97
 
50
98
  /** Props a page receives. */
@@ -57,10 +105,29 @@ export type PageProps<
57
105
  readonly data: TData,
58
106
  |};
59
107
 
60
- /** Props a layout receives. */
108
+ /** Props an `$error.js` component receives. */
109
+ export type ErrorProps = {|
110
+ readonly error: RouteError,
111
+ readonly reset: () => void,
112
+ |};
113
+
114
+ /**
115
+ * Props a layout receives.
116
+ *
117
+ * A layout on a segment that declares parallel-route slots receives one more
118
+ * prop per slot, named after the directory without its `@`, and this exact
119
+ * type does not describe those — the names are the project's. Declare them: a
120
+ * layout beside `@team` and `@analytics` is
121
+ *
122
+ * component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
123
+ *
124
+ * and the router passes `null` for a slot the URL addressed by neither a route
125
+ * of its own nor a `$default.js`, so `{team ?? <Empty />}` is a thing that
126
+ * can be written and relied on.
127
+ */
61
128
  export type LayoutProps<
62
129
  TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
63
130
  > = {|
64
131
  readonly params: TParams,
65
- readonly children: React$Node,
132
+ readonly children: React.Node,
66
133
  |};