@uniflowed/router 0.0.0-alpha.1 → 0.0.0-alpha.10

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/client.js CHANGED
@@ -6,25 +6,58 @@
6
6
  // The current route's chunks are loaded and its embedded loader data read
7
7
  // *before* `hydrateRoot`, so the first client render is synchronous and
8
8
  // matches the server's markup exactly.
9
+ //
10
+ // # A route can decline to be hydrated
11
+ //
12
+ // uf's server-component analysis decides which routes have a `"use client"`
13
+ // boundary anywhere in them, and `@uniflowed/vite` leaves the page out of the
14
+ // client route table for the ones that have none. Such a route has nothing in
15
+ // the browser to attach: the document the server wrote is the whole of it. So
16
+ // this returns without calling `hydrateRoot`, and the `<a>` elements a `Link`
17
+ // rendered stay what the server made them — real links the browser follows.
18
+ // See ubugeeei-prod/uf#350.
9
19
 
10
20
  import * as React from "react";
11
21
  import { startTransition } from "react";
12
22
  import { hydrateRoot } from "react-dom/client";
13
23
 
14
- import { type AppProps, type RouteTable, installRoutes, resolveMatch } from "./internal/runtime.js";
15
- import { DATA_ID, ROOT_ID } from "./server.js";
24
+ import {
25
+ type AppProps,
26
+ type RouteTable,
27
+ hasClientPage,
28
+ installRoutes,
29
+ matchRoute,
30
+ resolveMatch,
31
+ } from "./internal/runtime.js";
32
+ import { DATA_ID, ROOT_ID } from "./internal/document.js";
16
33
 
17
34
  /**
18
35
  * Hydrate the current document.
36
+ *
37
+ * Resolves without mounting anything when the current route ships no client
38
+ * page — see the header. The promise settling is not a claim that React is on
39
+ * the document.
19
40
  */
20
41
  export async function hydrate(options: {|
21
- +App: React.ComponentType<AppProps>,
22
- +routes: RouteTable["routes"],
23
- +notFound: RouteTable["notFound"],
42
+ readonly App: React.ComponentType<AppProps>,
43
+ readonly routes: RouteTable["routes"],
44
+ readonly notFound: RouteTable["notFound"],
45
+ readonly errors: RouteTable["errors"],
24
46
  |}): Promise<void> {
25
- const table: RouteTable = { routes: options.routes, notFound: options.notFound };
47
+ const table: RouteTable = {
48
+ routes: options.routes,
49
+ notFound: options.notFound,
50
+ errors: options.errors,
51
+ };
26
52
  installRoutes(table);
27
53
 
54
+ // Before the loader data is read and before `resolveMatch` is called: both
55
+ // would go looking for a page module that is not in this bundle.
56
+ const matched = matchRoute(table.routes, window.location.pathname);
57
+ if (matched != null && !hasClientPage(matched.route)) {
58
+ return;
59
+ }
60
+
28
61
  const url = window.location.pathname + window.location.search;
29
62
  const embedded = document.getElementById(DATA_ID);
30
63
  const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
package/handler.js ADDED
@@ -0,0 +1,217 @@
1
+ // @flow
2
+ //
3
+ // Route handlers: a path that answers a request instead of rendering a page.
4
+ //
5
+ // `app/api/users/_uf.route.js` exporting `GET` and `POST` serves
6
+ // `/api/users`. A handler takes a `Request` and returns a `Response` — the
7
+ // platform's own types, not a framework's wrapper — because that is what runs
8
+ // unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
9
+ // position is that the host is a capability rather than a target.
10
+ //
11
+ // // app/api/users/[id]/_uf.route.js
12
+ // // @flow
13
+ // export async function GET(request: Request, context: HandlerContext) {
14
+ // const user = await find(context.params.id);
15
+ // return user == null
16
+ // ? new Response("not found", { status: 404 })
17
+ // : Response.json(user);
18
+ // }
19
+ //
20
+ // # What the dispatcher decides, and what it does not
21
+ //
22
+ // It matches a path and a method and calls a function. It does not catch the
23
+ // handler's errors, because a handler that throws is a bug the host's own
24
+ // error reporting should see, and swallowing it into a 500 here would hide it.
25
+ // It does answer `405` itself when the path matches and the method does not,
26
+ // with the `Allow` header the specification requires — that is not the
27
+ // handler's business, and every handler would otherwise write it.
28
+ //
29
+ // It also does not establish the request a handler is inside. The host does,
30
+ // around the whole of it, so a handler and the guard above it share one
31
+ // context; see the same section in `./middleware.js`. This module used to
32
+ // build its own and drain it the moment the handler returned, which the
33
+ // comment there called "the response is in hand" — true, and not what
34
+ // `after()` promises. A handler that streams its body has not sent a byte at
35
+ // that point. See ubugeeei-prod/uf#389.
36
+
37
+ import { requireRequest } from "./internal/request.js";
38
+ import type { RouteParams } from "./internal/runtime.js";
39
+
40
+ /** What a handler is given besides the request. */
41
+ export type HandlerContext = {|
42
+ /** The `[param]` and `[...rest]` segments of the matched path. */
43
+ readonly params: RouteParams,
44
+ /** The parsed query string, for the common case of reading one value. */
45
+ readonly searchParams: URLSearchParams,
46
+ |};
47
+
48
+ /** One exported method of a handler module. */
49
+ export type Handler = (request: Request, context: HandlerContext) => Response | Promise<Response>;
50
+
51
+ /** A handler module, as the generated table loads it. */
52
+ export type HandlerModule = { readonly [method: string]: mixed };
53
+
54
+ /** One entry of the generated handler table. */
55
+ export type HandlerRecord = {|
56
+ readonly path: string,
57
+ readonly params: $ReadOnlyArray<{| readonly name: string, readonly catchAll: boolean |}>,
58
+ readonly file: string,
59
+ readonly load: () => Promise<HandlerModule>,
60
+ |};
61
+
62
+ /**
63
+ * The methods a handler may export.
64
+ *
65
+ * A closed list, because the alternative is treating every export as a method
66
+ * — and a module that exports a helper would then answer requests with it.
67
+ * `HEAD` falls back to `GET` with the body dropped, which is what a client
68
+ * asking for headers expects and what nobody remembers to write.
69
+ */
70
+ const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
71
+
72
+ /**
73
+ * Match a request against the handler table and run it.
74
+ *
75
+ * Returns `null` when no path matches, which is the caller's signal to carry
76
+ * on — a request for `/about` is a page, and the dispatcher declining is how
77
+ * it says so.
78
+ */
79
+ export function createDispatcher(options: {|
80
+ readonly handlers: $ReadOnlyArray<HandlerRecord>,
81
+ |}): (request: Request) => Promise<Response | null> {
82
+ // Longest path first, so `/api/users/new` wins over `/api/users/[id]` and a
83
+ // catch-all is the last thing tried.
84
+ const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
85
+
86
+ return async function dispatch(request: Request): Promise<Response | null> {
87
+ // The host's half of the contract, checked rather than assumed; see
88
+ // `./internal/request.js`.
89
+ requireRequest("dispatch");
90
+ const url = new URL(request.url);
91
+ for (const record of table) {
92
+ const params = matchPath(record.path, url.pathname);
93
+ if (params == null) {
94
+ continue;
95
+ }
96
+
97
+ const module = await record.load();
98
+ const method = request.method.toUpperCase();
99
+ const handler = pick(module, method);
100
+ if (handler == null) {
101
+ return methodNotAllowed(module);
102
+ }
103
+
104
+ // In the host's request, so a handler that calls `headers()`,
105
+ // `cookies()` or `after()` answers about the same one its guard did, and
106
+ // what it defers is drained once, by the host, after the bytes are out.
107
+ const response = await handler(request, {
108
+ params,
109
+ searchParams: url.searchParams,
110
+ });
111
+
112
+ // A `HEAD` answered by `GET` must not carry the body. The test is
113
+ // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
114
+ // `GET`, so asking it whether a `HEAD` exists always said yes and the
115
+ // body went out anyway.
116
+ if (method === "HEAD" && typeof module.HEAD !== "function") {
117
+ return new Response(null, {
118
+ status: response.status,
119
+ statusText: response.statusText,
120
+ headers: response.headers,
121
+ });
122
+ }
123
+ return response;
124
+ }
125
+ return null;
126
+ };
127
+ }
128
+
129
+ /** The function for a method, falling back to `GET` for `HEAD`. */
130
+ function pick(module: HandlerModule, method: string): Handler | null {
131
+ const own = module[method];
132
+ if (typeof own === "function") {
133
+ return own as $FlowFixMe;
134
+ }
135
+ if (method === "HEAD" && typeof module.GET === "function") {
136
+ return module.GET as $FlowFixMe;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * `405`, with the `Allow` header naming what the path does accept.
143
+ *
144
+ * Required by the specification, and the reason a client can tell "you may not
145
+ * do that here" from "there is nothing here".
146
+ */
147
+ function methodNotAllowed(module: HandlerModule): Response {
148
+ const own = new Set(METHODS.filter((method) => typeof module[method] === "function"));
149
+ // A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
150
+ if (own.has("GET")) {
151
+ own.add("HEAD");
152
+ }
153
+ // Filtered through `METHODS` rather than listed in insertion order, so the
154
+ // header reads in the conventional order however the module was written.
155
+ return new Response(null, {
156
+ status: 405,
157
+ headers: { allow: METHODS.filter((method) => own.has(method)).join(", ") },
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Match one route path against a pathname, returning its parameters.
163
+ *
164
+ * `null` rather than an empty object when it does not match, so a route with
165
+ * no parameters is still distinguishable from a miss.
166
+ */
167
+ function matchPath(routePath: string, pathname: string): RouteParams | null {
168
+ const wanted = segmentsOf(routePath);
169
+ const given = segmentsOf(pathname);
170
+ const params: { [string]: string | Array<string> } = {};
171
+
172
+ for (let index = 0; index < wanted.length; index += 1) {
173
+ const segment = wanted[index];
174
+ if (segment.startsWith(":") && segment.endsWith("*")) {
175
+ // A catch-all takes the rest, and matches zero segments as well as many.
176
+ params[segment.slice(1, -1)] = given.slice(index);
177
+ return params as $FlowFixMe;
178
+ }
179
+ if (index >= given.length) {
180
+ return null;
181
+ }
182
+ if (segment.startsWith(":")) {
183
+ params[segment.slice(1)] = given[index];
184
+ continue;
185
+ }
186
+ if (segment !== given[index]) {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ return wanted.length === given.length ? (params as $FlowFixMe) : null;
192
+ }
193
+
194
+ function segmentsOf(value: string): Array<string> {
195
+ return value.split("/").filter((segment) => segment !== "");
196
+ }
197
+
198
+ /**
199
+ * How specific a path is, so the table can be tried in the right order.
200
+ *
201
+ * A literal segment is worth more than a parameter and a parameter more than a
202
+ * catch-all, and a longer path outranks a shorter one — which is what makes
203
+ * `/api/users/new` win over `/api/users/[id]`.
204
+ */
205
+ function specificity(routePath: string): number {
206
+ let score = 0;
207
+ for (const segment of segmentsOf(routePath)) {
208
+ if (segment.startsWith(":") && segment.endsWith("*")) {
209
+ score += 1;
210
+ } else if (segment.startsWith(":")) {
211
+ score += 10;
212
+ } else {
213
+ score += 100;
214
+ }
215
+ }
216
+ return score;
217
+ }
package/index.js CHANGED
@@ -6,17 +6,29 @@
6
6
  // `_uf.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
+ // `_uf.not-found.js` and `_uf.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.
13
+
14
+ import * as React from "react";
15
+
16
+ import type { RouteError } from "./internal/runtime.js";
9
17
 
10
18
  export type {
11
19
  AppProps,
20
+ ErrorBoundary,
21
+ ErrorModule,
12
22
  LayoutModule,
13
23
  LinkPrefetch,
14
24
  LoaderArgs,
15
25
  Metadata,
16
26
  MetadataArgs,
17
27
  NavigateOptions,
28
+ NotFoundBoundary,
18
29
  PageModule,
19
30
  ResolvedRoute,
31
+ RouteError,
20
32
  RouteInfo,
21
33
  RouteMatch,
22
34
  RouteParamSpec,
@@ -28,19 +40,26 @@ export type {
28
40
  } from "./internal/runtime.js";
29
41
 
30
42
  export {
43
+ ForbiddenError,
31
44
  Link,
32
45
  NotFoundError,
33
46
  RedirectError,
34
47
  RouteView,
35
48
  RouterProvider,
49
+ UnauthorizedError,
50
+ forbidden,
51
+ hasClientPage,
36
52
  matchRoute,
37
53
  notFound,
38
54
  parseSearch,
39
55
  permanentRedirect,
40
56
  redirect,
57
+ resolveFailure,
41
58
  resolveMatch,
59
+ routeErrorStatus,
42
60
  routerView,
43
61
  splitUrl,
62
+ unauthorized,
44
63
  useIsServer,
45
64
  useLoaderData,
46
65
  useRoute,
@@ -49,16 +68,24 @@ export {
49
68
 
50
69
  /** Props a page receives. */
51
70
  export type PageProps<
52
- TParams: { +[string]: string | $ReadOnlyArray<string> } = {},
71
+ TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
53
72
  TData = void,
54
73
  > = {|
55
- +params: TParams,
56
- +searchParams: { +[string]: string },
57
- +data: TData,
74
+ readonly params: TParams,
75
+ readonly searchParams: { readonly [string]: string },
76
+ readonly data: TData,
77
+ |};
78
+
79
+ /** Props an `_uf.error.js` component receives. */
80
+ export type ErrorProps = {|
81
+ readonly error: RouteError,
82
+ readonly reset: () => void,
58
83
  |};
59
84
 
60
85
  /** Props a layout receives. */
61
- export type LayoutProps<TParams: { +[string]: string | $ReadOnlyArray<string> } = {}> = {|
62
- +params: TParams,
63
- +children: React$Node,
86
+ export type LayoutProps<
87
+ TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
88
+ > = {|
89
+ readonly params: TParams,
90
+ readonly children: React.Node,
64
91
  |};
@@ -0,0 +1,24 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the two ids the server writes and the
4
+ // client reads.
5
+ //
6
+ // Their own module because both halves need them and neither should import the
7
+ // other. The client used to take them from `./server.js`, which meant a client
8
+ // bundle reached the request dispatcher — and through it `node:async_hooks`,
9
+ // by way of `@uniflowed/server`. Nothing was *called*, so a bundler dropped the
10
+ // code, but the import of a Node builtin survived into a browser bundle and
11
+ // Vite said so on every build.
12
+ //
13
+ // Two string constants are not worth a boundary violation.
14
+
15
+ /**
16
+ * The element the client hydrates when the app does not render `<html>`.
17
+ *
18
+ * An app whose root layout renders the whole document owns it and hydrates
19
+ * `document` instead; this is the wrapper for the ones that render content.
20
+ */
21
+ export const ROOT_ID = "uf-root";
22
+
23
+ /** The script element the server's resolved route data is written into. */
24
+ export const DATA_ID = "__uf_data";
@@ -0,0 +1,43 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the request the server half runs inside.
4
+ //
5
+ // One function, and it exists because two modules need the same refusal.
6
+ // `createMiddlewareRunner` and `createDispatcher` both used to build a request
7
+ // context of their own — `contextFor`, then `runWithContext`, then
8
+ // `drainDeferred` — which gave one request up to two contexts and ran what
9
+ // `after()` deferred before the response was written, and in the middleware's
10
+ // case before there was a response at all. See ubugeeei-prod/uf#389.
11
+ //
12
+ // Both now run inside whatever request the host established, which means both
13
+ // depend on the host having established one. That dependency is checked rather
14
+ // than assumed, for the reason `createApplicationHandler` gives about
15
+ // `entry.runMiddleware` itself: a missing half of the contract should be a
16
+ // failure on the first request, not an application that answers strangely.
17
+ //
18
+ // Server-only, like the two modules that import it. Nothing the browser loads
19
+ // reaches this.
20
+
21
+ import { insideRequest } from "@uniflowed/server/host";
22
+
23
+ /**
24
+ * Refuse to run outside a request, naming what has to establish one.
25
+ *
26
+ * The message is addressed to whoever wired the host, because that is who can
27
+ * fix it. Left to fail on its own, a mis-wired host would surface as the first
28
+ * `cookies()` in somebody's guard throwing "called outside a request … a
29
+ * static prerender, a module's top level, or a client component" — three
30
+ * places to look, none of them this one — and an application that calls no
31
+ * server function at all would sail past that and lose every `after()` instead.
32
+ */
33
+ export function requireRequest(entry: string): void {
34
+ if (insideRequest()) {
35
+ return;
36
+ }
37
+ throw new Error(
38
+ `@uniflowed/router: ${entry}() was called outside a request. A host owns the request: ` +
39
+ "begin it with `beginRequest` from `@uniflowed/server/host` (a bundled application " +
40
+ "re-exports it from `virtual:uf/server`), run the whole request inside `run`, and call " +
41
+ "`settle` once the response has been sent. See ubugeeei-prod/uf#389.",
42
+ );
43
+ }