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

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
@@ -12,15 +12,15 @@ import { startTransition } from "react";
12
12
  import { hydrateRoot } from "react-dom/client";
13
13
 
14
14
  import { type AppProps, type RouteTable, installRoutes, resolveMatch } from "./internal/runtime.js";
15
- import { DATA_ID, ROOT_ID } from "./server.js";
15
+ import { DATA_ID, ROOT_ID } from "./internal/document.js";
16
16
 
17
17
  /**
18
18
  * Hydrate the current document.
19
19
  */
20
20
  export async function hydrate(options: {|
21
- +App: React.ComponentType<AppProps>,
22
- +routes: RouteTable["routes"],
23
- +notFound: RouteTable["notFound"],
21
+ readonly App: React.ComponentType<AppProps>,
22
+ readonly routes: RouteTable["routes"],
23
+ readonly notFound: RouteTable["notFound"],
24
24
  |}): Promise<void> {
25
25
  const table: RouteTable = { routes: options.routes, notFound: options.notFound };
26
26
  installRoutes(table);
package/handler.js ADDED
@@ -0,0 +1,210 @@
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
+ import { contextFor, drainDeferred, runWithContext } from "@uniflowed/server/host";
30
+ import type { RouteParams } from "./internal/runtime.js";
31
+
32
+ /** What a handler is given besides the request. */
33
+ export type HandlerContext = {|
34
+ /** The `[param]` and `[...rest]` segments of the matched path. */
35
+ readonly params: RouteParams,
36
+ /** The parsed query string, for the common case of reading one value. */
37
+ readonly searchParams: URLSearchParams,
38
+ |};
39
+
40
+ /** One exported method of a handler module. */
41
+ export type Handler = (request: Request, context: HandlerContext) => Response | Promise<Response>;
42
+
43
+ /** A handler module, as the generated table loads it. */
44
+ export type HandlerModule = { readonly [method: string]: mixed };
45
+
46
+ /** One entry of the generated handler table. */
47
+ export type HandlerRecord = {|
48
+ readonly path: string,
49
+ readonly params: $ReadOnlyArray<{| readonly name: string, readonly catchAll: boolean |}>,
50
+ readonly file: string,
51
+ readonly load: () => Promise<HandlerModule>,
52
+ |};
53
+
54
+ /**
55
+ * The methods a handler may export.
56
+ *
57
+ * A closed list, because the alternative is treating every export as a method
58
+ * — and a module that exports a helper would then answer requests with it.
59
+ * `HEAD` falls back to `GET` with the body dropped, which is what a client
60
+ * asking for headers expects and what nobody remembers to write.
61
+ */
62
+ const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
63
+
64
+ /**
65
+ * Match a request against the handler table and run it.
66
+ *
67
+ * Returns `null` when no path matches, which is the caller's signal to carry
68
+ * on — a request for `/about` is a page, and the dispatcher declining is how
69
+ * it says so.
70
+ */
71
+ export function createDispatcher(options: {|
72
+ readonly handlers: $ReadOnlyArray<HandlerRecord>,
73
+ |}): (request: Request) => Promise<Response | null> {
74
+ // Longest path first, so `/api/users/new` wins over `/api/users/[id]` and a
75
+ // catch-all is the last thing tried.
76
+ const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
77
+
78
+ return async function dispatch(request: Request): Promise<Response | null> {
79
+ const url = new URL(request.url);
80
+ for (const record of table) {
81
+ const params = matchPath(record.path, url.pathname);
82
+ if (params == null) {
83
+ continue;
84
+ }
85
+
86
+ const module = await record.load();
87
+ const method = request.method.toUpperCase();
88
+ const handler = pick(module, method);
89
+ if (handler == null) {
90
+ return methodNotAllowed(module);
91
+ }
92
+
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
+ }),
102
+ );
103
+ await drainDeferred(context);
104
+
105
+ // A `HEAD` answered by `GET` must not carry the body. The test is
106
+ // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
107
+ // `GET`, so asking it whether a `HEAD` exists always said yes and the
108
+ // body went out anyway.
109
+ if (method === "HEAD" && typeof module.HEAD !== "function") {
110
+ return new Response(null, {
111
+ status: response.status,
112
+ statusText: response.statusText,
113
+ headers: response.headers,
114
+ });
115
+ }
116
+ return response;
117
+ }
118
+ return null;
119
+ };
120
+ }
121
+
122
+ /** The function for a method, falling back to `GET` for `HEAD`. */
123
+ function pick(module: HandlerModule, method: string): Handler | null {
124
+ const own = module[method];
125
+ if (typeof own === "function") {
126
+ return own as $FlowFixMe;
127
+ }
128
+ if (method === "HEAD" && typeof module.GET === "function") {
129
+ return module.GET as $FlowFixMe;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * `405`, with the `Allow` header naming what the path does accept.
136
+ *
137
+ * Required by the specification, and the reason a client can tell "you may not
138
+ * do that here" from "there is nothing here".
139
+ */
140
+ function methodNotAllowed(module: HandlerModule): Response {
141
+ const own = new Set(METHODS.filter((method) => typeof module[method] === "function"));
142
+ // A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
143
+ if (own.has("GET")) {
144
+ own.add("HEAD");
145
+ }
146
+ // Filtered through `METHODS` rather than listed in insertion order, so the
147
+ // header reads in the conventional order however the module was written.
148
+ return new Response(null, {
149
+ status: 405,
150
+ headers: { allow: METHODS.filter((method) => own.has(method)).join(", ") },
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Match one route path against a pathname, returning its parameters.
156
+ *
157
+ * `null` rather than an empty object when it does not match, so a route with
158
+ * no parameters is still distinguishable from a miss.
159
+ */
160
+ function matchPath(routePath: string, pathname: string): RouteParams | null {
161
+ const wanted = segmentsOf(routePath);
162
+ const given = segmentsOf(pathname);
163
+ const params: { [string]: string | Array<string> } = {};
164
+
165
+ for (let index = 0; index < wanted.length; index += 1) {
166
+ const segment = wanted[index];
167
+ if (segment.startsWith(":") && segment.endsWith("*")) {
168
+ // A catch-all takes the rest, and matches zero segments as well as many.
169
+ params[segment.slice(1, -1)] = given.slice(index);
170
+ return params as $FlowFixMe;
171
+ }
172
+ if (index >= given.length) {
173
+ return null;
174
+ }
175
+ if (segment.startsWith(":")) {
176
+ params[segment.slice(1)] = given[index];
177
+ continue;
178
+ }
179
+ if (segment !== given[index]) {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ return wanted.length === given.length ? (params as $FlowFixMe) : null;
185
+ }
186
+
187
+ function segmentsOf(value: string): Array<string> {
188
+ return value.split("/").filter((segment) => segment !== "");
189
+ }
190
+
191
+ /**
192
+ * How specific a path is, so the table can be tried in the right order.
193
+ *
194
+ * A literal segment is worth more than a parameter and a parameter more than a
195
+ * catch-all, and a longer path outranks a shorter one — which is what makes
196
+ * `/api/users/new` win over `/api/users/[id]`.
197
+ */
198
+ function specificity(routePath: string): number {
199
+ let score = 0;
200
+ for (const segment of segmentsOf(routePath)) {
201
+ if (segment.startsWith(":") && segment.endsWith("*")) {
202
+ score += 1;
203
+ } else if (segment.startsWith(":")) {
204
+ score += 10;
205
+ } else {
206
+ score += 100;
207
+ }
208
+ }
209
+ return score;
210
+ }
package/index.js CHANGED
@@ -49,16 +49,18 @@ export {
49
49
 
50
50
  /** Props a page receives. */
51
51
  export type PageProps<
52
- TParams: { +[string]: string | $ReadOnlyArray<string> } = {},
52
+ TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
53
53
  TData = void,
54
54
  > = {|
55
- +params: TParams,
56
- +searchParams: { +[string]: string },
57
- +data: TData,
55
+ readonly params: TParams,
56
+ readonly searchParams: { readonly [string]: string },
57
+ readonly data: TData,
58
58
  |};
59
59
 
60
60
  /** Props a layout receives. */
61
- export type LayoutProps<TParams: { +[string]: string | $ReadOnlyArray<string> } = {}> = {|
62
- +params: TParams,
63
- +children: React$Node,
61
+ export type LayoutProps<
62
+ TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
63
+ > = {|
64
+ readonly params: TParams,
65
+ readonly children: React$Node,
64
66
  |};
@@ -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";
@@ -22,101 +22,150 @@ import {
22
22
  } from "react";
23
23
 
24
24
  /** One parameter a route path captures. */
25
- export type RouteParamSpec = {| +name: string, +catchAll: boolean |};
25
+ export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
26
26
 
27
27
  /** The parameters captured from a URL. A catch-all captures the rest as a list. */
28
- export type RouteParams = { +[string]: string | $ReadOnlyArray<string> };
28
+ export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
29
29
 
30
30
  /** The query string, as a read-only map. */
31
- export type SearchParams = { +[string]: string };
31
+ export type SearchParams = { readonly [string]: string };
32
+
33
+ /**
34
+ * A component found in a route module.
35
+ *
36
+ * `React.ComponentType<empty>` is "some React component", and it is a claim
37
+ * rather than a shrug. `ComponentType` is contravariant in its props — Flow's
38
+ * library definition writes it `component(...P)` with `in P` — so `empty` is
39
+ * the *top* of the component types: every component is one, and nothing may be
40
+ * passed to one until a caller has said which props it is passing. That is
41
+ * exactly what is known here. The router finds these by dynamic import, and
42
+ * nobody has told it what a page's props are.
43
+ *
44
+ * It cannot be `React.ComponentType<PageRenderProps>`, the props the router
45
+ * actually passes, because Flow's `component` syntax gives a component *exact*
46
+ * props and a page is free to want none of them. This repository's own pages
47
+ * and layouts are `component NotFound()` and
48
+ * `component Layout(children: React.Node)`, and against the props the router
49
+ * hands them that reads:
50
+ *
51
+ * error[incompatible-type]: property `data`, property `params`, and
52
+ * property `searchParams` are extra in `PageRenderProps` but missing in
53
+ * `props of component NotFound`. Exact objects do not accept extra props.
54
+ *
55
+ * React passing a component a prop it did not declare is allowed and always
56
+ * has been. `renderable` is the one line that says so.
57
+ */
58
+ type RouteComponent = React.ComponentType<empty>;
59
+
60
+ /**
61
+ * The props `RouteView` gives the page it renders.
62
+ *
63
+ * The same three as the public `PageProps` in `../index.js`, at the arguments
64
+ * the runtime instantiates it with: the runtime knows the parameters as
65
+ * strings and the loader's data as `mixed`, and a page narrows both by
66
+ * annotating its own props.
67
+ */
68
+ type PageRenderProps = {|
69
+ readonly params: RouteParams,
70
+ readonly searchParams: SearchParams,
71
+ readonly data: mixed,
72
+ |};
73
+
74
+ /** The props `RouteView` gives each layout, outermost first. */
75
+ type LayoutRenderProps = {|
76
+ readonly params: RouteParams,
77
+ readonly children: React.Node,
78
+ |};
32
79
 
33
80
  /** What a page module may export. The component is `default` or `Page`. */
34
81
  export type PageModule = {
35
- +default?: React.ComponentType<any>,
36
- +Page?: React.ComponentType<any>,
37
- +loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
38
- +metadata?: Metadata,
39
- +generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
40
- +generateStaticParams?: () => $ReadOnlyArray<RouteParams> | Promise<$ReadOnlyArray<RouteParams>>,
41
- +frontmatter?: { +title?: string, +description?: string, ... },
82
+ readonly default?: RouteComponent,
83
+ readonly Page?: RouteComponent,
84
+ readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
85
+ readonly metadata?: Metadata,
86
+ readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
87
+ readonly generateStaticParams?: () =>
88
+ | $ReadOnlyArray<RouteParams>
89
+ | Promise<$ReadOnlyArray<RouteParams>>,
90
+ readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
42
91
  ...
43
92
  };
44
93
 
45
94
  /** What a layout module may export. The component is `default` or `Layout`. */
46
95
  export type LayoutModule = {
47
- +default?: React.ComponentType<any>,
48
- +Layout?: React.ComponentType<any>,
49
- +metadata?: Metadata,
96
+ readonly default?: RouteComponent,
97
+ readonly Layout?: RouteComponent,
98
+ readonly metadata?: Metadata,
50
99
  ...
51
100
  };
52
101
 
53
102
  /** Document metadata a page or layout declares. */
54
103
  export type Metadata = {
55
- +title?: string,
56
- +description?: string,
57
- +openGraph?: {
58
- +title?: string,
59
- +description?: string,
60
- +images?: $ReadOnlyArray<string>,
104
+ readonly title?: string,
105
+ readonly description?: string,
106
+ readonly openGraph?: {
107
+ readonly title?: string,
108
+ readonly description?: string,
109
+ readonly images?: $ReadOnlyArray<string>,
61
110
  },
62
111
  };
63
112
 
64
113
  /** Arguments a loader receives. */
65
114
  export type LoaderArgs = {|
66
- +params: RouteParams,
67
- +searchParams: SearchParams,
68
- +pathname: string,
115
+ readonly params: RouteParams,
116
+ readonly searchParams: SearchParams,
117
+ readonly pathname: string,
69
118
  |};
70
119
 
71
120
  /** Arguments `generateMetadata` receives. */
72
121
  export type MetadataArgs = {|
73
- +params: RouteParams,
74
- +searchParams: SearchParams,
75
- +data: mixed,
122
+ readonly params: RouteParams,
123
+ readonly searchParams: SearchParams,
124
+ readonly data: mixed,
76
125
  |};
77
126
 
78
127
  /** One entry of the generated route table. */
79
128
  export type RouteRecord = {|
80
- +path: string,
81
- +params: $ReadOnlyArray<RouteParamSpec>,
82
- +mdx: boolean,
83
- +file: string,
84
- +page: () => Promise<PageModule>,
85
- +layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
129
+ readonly path: string,
130
+ readonly params: $ReadOnlyArray<RouteParamSpec>,
131
+ readonly mdx: boolean,
132
+ readonly file: string,
133
+ readonly page: () => Promise<PageModule>,
134
+ readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
86
135
  |};
87
136
 
88
137
  /** The not-found page, when the app declares one. */
89
138
  export type NotFoundRecord = {|
90
- +mdx: boolean,
91
- +file: string,
92
- +page: () => Promise<PageModule>,
93
- +layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
139
+ readonly mdx: boolean,
140
+ readonly file: string,
141
+ readonly page: () => Promise<PageModule>,
142
+ readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
94
143
  |};
95
144
 
96
145
  /** A route table plus the not-found page. */
97
146
  export type RouteTable = {|
98
- +routes: $ReadOnlyArray<RouteRecord>,
99
- +notFound: ?NotFoundRecord,
147
+ readonly routes: $ReadOnlyArray<RouteRecord>,
148
+ readonly notFound: ?NotFoundRecord,
100
149
  |};
101
150
 
102
151
  /** A URL matched against the table. */
103
152
  export type RouteMatch = {|
104
- +route: RouteRecord,
105
- +params: RouteParams,
153
+ readonly route: RouteRecord,
154
+ readonly params: RouteParams,
106
155
  |};
107
156
 
108
157
  /** A match whose modules are loaded and whose loader has run. */
109
158
  export type ResolvedRoute = {|
110
- +pathname: string,
111
- +search: string,
112
- +path: string,
113
- +params: RouteParams,
114
- +searchParams: SearchParams,
115
- +page: PageModule,
116
- +layouts: $ReadOnlyArray<LayoutModule>,
117
- +data: mixed,
118
- +metadata: Metadata,
119
- +status: 200 | 404,
159
+ readonly pathname: string,
160
+ readonly search: string,
161
+ readonly path: string,
162
+ readonly params: RouteParams,
163
+ readonly searchParams: SearchParams,
164
+ readonly page: PageModule,
165
+ readonly layouts: $ReadOnlyArray<LayoutModule>,
166
+ readonly data: mixed,
167
+ readonly metadata: Metadata,
168
+ readonly status: 200 | 404,
120
169
  |};
121
170
 
122
171
  /** Thrown by `notFound()`; the renderer answers with the not-found page. */
@@ -145,9 +194,9 @@ export class RedirectError extends Error {
145
194
  // ---------------------------------------------------------------------------
146
195
 
147
196
  type Segment =
148
- | {| +kind: "static", +value: string |}
149
- | {| +kind: "param", +name: string |}
150
- | {| +kind: "catchAll", +name: string |};
197
+ | {| readonly kind: "static", readonly value: string |}
198
+ | {| readonly kind: "param", readonly name: string |}
199
+ | {| readonly kind: "catchAll", readonly name: string |};
151
200
 
152
201
  function compile(routePath: string): $ReadOnlyArray<Segment> {
153
202
  return routePath
@@ -171,35 +220,37 @@ function compile(routePath: string): $ReadOnlyArray<Segment> {
171
220
  function specificity(segments: $ReadOnlyArray<Segment>): number {
172
221
  let score = 0;
173
222
  for (const segment of segments) {
174
- score +=
175
- match (segment) {
176
- { kind: "static" } => 3,
177
- { kind: "param" } => 2,
178
- { kind: "catchAll" } => 1,
179
- };
223
+ score += match (segment) {
224
+ {kind: "static"} => 3,
225
+ {kind: "param"} => 2,
226
+ {kind: "catchAll"} => 1,
227
+ };
180
228
  }
181
229
  return score;
182
230
  }
183
231
 
184
- function matchSegments(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): ?RouteParams {
232
+ function matchSegments(
233
+ segments: $ReadOnlyArray<Segment>,
234
+ parts: $ReadOnlyArray<string>,
235
+ ): ?RouteParams {
185
236
  const params: { [string]: string | $ReadOnlyArray<string> } = {};
186
237
  let index = 0;
187
238
  for (const segment of segments) {
188
239
  match (segment) {
189
- { kind: "static", value: const value } => {
240
+ {kind: "static", value: const value} => {
190
241
  if (parts[index] !== value) {
191
242
  return null;
192
243
  }
193
244
  index += 1;
194
245
  }
195
- { kind: "param", name: const name } => {
246
+ {kind: "param", name: const name} => {
196
247
  if (index >= parts.length) {
197
248
  return null;
198
249
  }
199
250
  params[name] = decodeSegment(parts[index]);
200
251
  index += 1;
201
252
  }
202
- { kind: "catchAll", name: const name } => {
253
+ {kind: "catchAll", name: const name} => {
203
254
  params[name] = parts.slice(index).map(decodeSegment);
204
255
  index = parts.length;
205
256
  }
@@ -239,7 +290,7 @@ export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string
239
290
  }
240
291
 
241
292
  /** Split a URL into its pathname and search string. */
242
- export function splitUrl(url: string): {| +pathname: string, +search: string |} {
293
+ export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
243
294
  const hash = url.indexOf("#");
244
295
  const withoutHash = hash === -1 ? url : url.slice(0, hash);
245
296
  const question = withoutHash.indexOf("?");
@@ -295,7 +346,7 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
295
346
  export async function resolveMatch(
296
347
  table: RouteTable,
297
348
  url: string,
298
- options?: {| +data?: mixed, +skipLoader?: boolean |},
349
+ options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
299
350
  ): Promise<ResolvedRoute> {
300
351
  const { pathname, search } = splitUrl(url);
301
352
  const searchParams = parseSearch(search);
@@ -322,7 +373,11 @@ export async function resolveMatch(
322
373
  }
323
374
  }
324
375
 
325
- const metadata = await resolveMetadata(page, layouts, { params: matched.params, searchParams, data });
376
+ const metadata = await resolveMetadata(page, layouts, {
377
+ params: matched.params,
378
+ searchParams,
379
+ data,
380
+ });
326
381
  return {
327
382
  pathname,
328
383
  search,
@@ -362,7 +417,11 @@ async function resolveNotFound(
362
417
  loadOnce(record.page),
363
418
  ...record.layouts.map((layout) => loadOnce(layout)),
364
419
  ]);
365
- const metadata = await resolveMetadata(page, layouts, { params: {}, searchParams, data: undefined });
420
+ const metadata = await resolveMetadata(page, layouts, {
421
+ params: {},
422
+ searchParams,
423
+ data: undefined,
424
+ });
366
425
  return {
367
426
  pathname,
368
427
  search,
@@ -390,7 +449,11 @@ async function resolveMetadata(
390
449
  }
391
450
  if (page.frontmatter != null) {
392
451
  const { title, description } = page.frontmatter;
393
- merged = { ...merged, ...(title != null ? { title } : {}), ...(description != null ? { description } : {}) };
452
+ merged = {
453
+ ...merged,
454
+ ...(title != null ? { title } : {}),
455
+ ...(description != null ? { description } : {}),
456
+ };
394
457
  }
395
458
  if (page.metadata != null) {
396
459
  merged = { ...merged, ...page.metadata };
@@ -416,32 +479,32 @@ component DefaultNotFound() {
416
479
  // ---------------------------------------------------------------------------
417
480
 
418
481
  /** How a navigation is performed. */
419
- export type NavigateOptions = {| +replace?: boolean, +scroll?: boolean |};
482
+ export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
420
483
 
421
484
  /** What `useRouter()` returns. */
422
485
  export type Router = {|
423
- +push: (to: string, options?: NavigateOptions) => Promise<void>,
424
- +replace: (to: string) => Promise<void>,
425
- +prefetch: (to: string) => Promise<void>,
426
- +refresh: () => Promise<void>,
427
- +back: () => void,
428
- +forward: () => void,
486
+ readonly push: (to: string, options?: NavigateOptions) => Promise<void>,
487
+ readonly replace: (to: string) => Promise<void>,
488
+ readonly prefetch: (to: string) => Promise<void>,
489
+ readonly refresh: () => Promise<void>,
490
+ readonly back: () => void,
491
+ readonly forward: () => void,
429
492
  |};
430
493
 
431
494
  /** What `useRoute()` returns. */
432
495
  export type RouteInfo = {|
433
- +path: string,
434
- +pathname: string,
435
- +params: RouteParams,
436
- +searchParams: SearchParams,
437
- +data: mixed,
438
- +pending: boolean,
496
+ readonly path: string,
497
+ readonly pathname: string,
498
+ readonly params: RouteParams,
499
+ readonly searchParams: SearchParams,
500
+ readonly data: mixed,
501
+ readonly pending: boolean,
439
502
  |};
440
503
 
441
504
  type RouterState = {|
442
- +resolved: ResolvedRoute,
443
- +router: Router,
444
- +pending: boolean,
505
+ readonly resolved: ResolvedRoute,
506
+ readonly router: Router,
507
+ readonly pending: boolean,
445
508
  |};
446
509
 
447
510
  const RouterContext: React.Context<?RouterState> = createContext(null);
@@ -457,15 +520,17 @@ export function installRoutes(table: RouteTable): void {
457
520
  /** The registered table, or a clear error when the entry forgot to install it. */
458
521
  export function routeTable(): RouteTable {
459
522
  if (installedTable == null) {
460
- throw new Error("@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`");
523
+ throw new Error(
524
+ "@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`",
525
+ );
461
526
  }
462
527
  return installedTable;
463
528
  }
464
529
 
465
530
  /** Props the app root receives from the client and server entries. */
466
531
  export type AppProps = {|
467
- +url: string,
468
- +initial: ResolvedRoute,
532
+ readonly url: string,
533
+ readonly initial: ResolvedRoute,
469
534
  |};
470
535
 
471
536
  const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
@@ -547,13 +612,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
547
612
  if (matched == null) {
548
613
  return;
549
614
  }
550
- await Promise.all([loadOnce(matched.route.page), ...matched.route.layouts.map((layout) => loadOnce(layout))]);
615
+ await Promise.all([
616
+ loadOnce(matched.route.page),
617
+ ...matched.route.layouts.map((layout) => loadOnce(layout)),
618
+ ]);
551
619
  },
552
620
  refresh: async () => {
553
621
  if (!isBrowser) {
554
622
  return;
555
623
  }
556
- const nextResolved = await resolveMatch(routeTable(), window.location.pathname + window.location.search);
624
+ const nextResolved = await resolveMatch(
625
+ routeTable(),
626
+ window.location.pathname + window.location.search,
627
+ );
557
628
  startTransition(() => {
558
629
  setResolved(nextResolved);
559
630
  });
@@ -572,14 +643,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
572
643
  [navigate],
573
644
  );
574
645
 
575
- const value = useMemo<RouterState>(() => ({ resolved, router, pending }), [resolved, router, pending]);
646
+ const value = useMemo<RouterState>(
647
+ () => ({ resolved, router, pending }),
648
+ [resolved, router, pending],
649
+ );
576
650
  return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
577
651
  }
578
652
 
579
653
  hook useRouterState(): RouterState {
580
654
  const state = useContext(RouterContext);
581
655
  if (state == null) {
582
- throw new Error("@uniflowed/router: this hook must be used inside the app started by `routerView`");
656
+ throw new Error(
657
+ "@uniflowed/router: this hook must be used inside the app started by `routerView`",
658
+ );
583
659
  }
584
660
  return state;
585
661
  }
@@ -602,10 +678,28 @@ export hook useRouter(): Router {
602
678
  return useRouterState().router;
603
679
  }
604
680
 
605
- /** The current page's loader data. */
606
- export hook useLoaderData<T>(): T {
607
- // $FlowFixMe[unclear-type] loader data is typed by the page that declares the loader.
608
- return (useRouterState().resolved.data: any);
681
+ /**
682
+ * The current page's loader data.
683
+ *
684
+ * `mixed`, so the page that reads it says what it is and the checker watches
685
+ * it do so. This was `useLoaderData<T>(): T`, which looks like inference and
686
+ * is a cast a caller writes at a distance: `useLoaderData<Post>()` asserted
687
+ * that a loader three files away returned a `Post` and nothing anywhere
688
+ * checked it, so a loader that changed shape produced a `Post`-shaped
689
+ * `undefined` at the first property read rather than an error where the shape
690
+ * was decided.
691
+ *
692
+ * Narrowing is a line at the top of the page — `if (typeof data !== "object"
693
+ * || data == null) { … }`, or the page's own validator schema, which is what
694
+ * `@uniflowed/validator` is for at exactly this boundary.
695
+ *
696
+ * The type that would need no narrowing is a *generated* one: the route table
697
+ * already produces `RoutePath` and `RouteParams` from the `app/` directory
698
+ * (`crates/uf_router/src/lib.rs`), and a loader's return type belongs in the
699
+ * same file, keyed by route. Until it is there, this says what is true.
700
+ */
701
+ export hook useLoaderData(): mixed {
702
+ return useRouterState().resolved.data;
609
703
  }
610
704
 
611
705
  /**
@@ -634,21 +728,47 @@ export component RouteView() {
634
728
  * The component a page module renders: its default export, or the named
635
729
  * `Page` that `uf create` scaffolds. An MDX page always has a default export.
636
730
  */
637
- function pageComponent(module: PageModule): React.ComponentType<any> {
731
+ function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
638
732
  const component = module.default ?? module.Page;
639
733
  if (component == null) {
640
- throw new Error("@uniflowed/router: a page module must export a component as `default` or `Page`");
734
+ throw new Error(
735
+ "@uniflowed/router: a page module must export a component as `default` or `Page`",
736
+ );
641
737
  }
642
- return component;
738
+ return renderable(component);
643
739
  }
644
740
 
645
741
  /** The component a layout module renders: `default`, or the named `Layout`. */
646
- function layoutComponent(module: LayoutModule): React.ComponentType<any> {
742
+ function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
647
743
  const component = module.default ?? module.Layout;
648
744
  if (component == null) {
649
- throw new Error("@uniflowed/router: a layout module must export a component as `default` or `Layout`");
745
+ throw new Error(
746
+ "@uniflowed/router: a layout module must export a component as `default` or `Layout`",
747
+ );
650
748
  }
651
- return component;
749
+ return renderable(component);
750
+ }
751
+
752
+ /**
753
+ * A route module's component, as the router is about to render it.
754
+ *
755
+ * # The one cast in this file, and why it is here rather than in six places
756
+ *
757
+ * A `RouteComponent` is a component about whose props nothing was claimed, and
758
+ * `RouteView` is about to pass it three. React allows that — a component
759
+ * receives the props its parent wrote and ignores the ones it did not declare
760
+ * — but Flow cannot be told it: a page's props are exact, so no props type but
761
+ * that page's own is assignable, and the router does not know which page it
762
+ * has. `React.ComponentType<any>` on the module types was this same
763
+ * unsoundness spread over six declarations, where it also stopped anyone from
764
+ * checking that `RouteView` passes the props a page is documented to receive.
765
+ * Here it is one line, and everything on either side of it is checked: what a
766
+ * module may export, and what a page is handed.
767
+ */
768
+ function renderable<TProps extends { ... }>(
769
+ component: RouteComponent,
770
+ ): React.ComponentType<TProps> {
771
+ return component as any;
652
772
  }
653
773
 
654
774
  component Head(metadata: Metadata) {
@@ -658,7 +778,9 @@ component Head(metadata: Metadata) {
658
778
  {title != null ? <title>{title}</title> : null}
659
779
  {description != null ? <meta name="description" content={description} /> : null}
660
780
  {openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
661
- {openGraph?.description != null ? <meta property="og:description" content={openGraph.description} /> : null}
781
+ {openGraph?.description != null ? (
782
+ <meta property="og:description" content={openGraph.description} />
783
+ ) : null}
662
784
  {openGraph?.images != null
663
785
  ? openGraph.images.map((image) => <meta key={image} property="og:image" content={image} />)
664
786
  : null}
@@ -683,7 +805,7 @@ export component Link(
683
805
  children?: React.Node,
684
806
  className?: string,
685
807
  onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
686
- ...rest: { +[string]: mixed }
808
+ ...rest: { readonly [string]: mixed }
687
809
  ) {
688
810
  const router = useRouter();
689
811
  const prefetched = React.useRef(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.1",
3
+ "version": "0.0.0-alpha.5",
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",
@@ -14,16 +14,21 @@
14
14
  ".": "./index.js",
15
15
  "./client": "./client.js",
16
16
  "./server": "./server.js",
17
- "./package.json": "./package.json"
17
+ "./package.json": "./package.json",
18
+ "./handler": "./handler.js"
18
19
  },
19
20
  "files": [
20
- "index.js",
21
21
  "client.js",
22
- "server.js",
23
- "internal"
22
+ "handler.js",
23
+ "index.js",
24
+ "internal",
25
+ "server.js"
24
26
  ],
25
27
  "peerDependencies": {
26
28
  "react": ">=19",
27
29
  "react-dom": ">=19"
30
+ },
31
+ "dependencies": {
32
+ "@uniflowed/server": "0.0.0-alpha.5"
28
33
  }
29
34
  }
package/server.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // `uf build` renders every static route through it once. Both produce the
8
8
  // same markup from the same code, which is the point.
9
9
 
10
+ import { DATA_ID, ROOT_ID } from "./internal/document.js";
10
11
  import * as React from "react";
11
12
  import { renderToString } from "react-dom/server";
12
13
 
@@ -21,31 +22,30 @@ import {
21
22
 
22
23
  /** Asset URLs to reference from the document. */
23
24
  export type RenderAssets = {|
24
- +scripts: $ReadOnlyArray<string>,
25
- +styles: $ReadOnlyArray<string>,
26
- +preloads: $ReadOnlyArray<string>,
25
+ readonly scripts: $ReadOnlyArray<string>,
26
+ readonly styles: $ReadOnlyArray<string>,
27
+ readonly preloads: $ReadOnlyArray<string>,
27
28
  |};
28
29
 
29
30
  /** A rendered document. */
30
31
  export type RenderResult = {|
31
- +status: number,
32
- +html: string,
33
- +headers?: { +[string]: string },
32
+ readonly status: number,
33
+ readonly html: string,
34
+ readonly headers?: { readonly [string]: string },
34
35
  |};
35
36
 
36
- /** The id of the element the client hydrates when the app does not render `<html>`. */
37
- export const ROOT_ID = "uf-root";
38
-
39
- /** The id of the script carrying the loader data to the client. */
40
- export const DATA_ID = "__uf_data";
41
-
42
37
  /**
43
38
  * Build a `render(url, assets)` for one app.
44
39
  */
40
+ export { DATA_ID, ROOT_ID } from "./internal/document.js";
41
+
42
+ export type { Handler, HandlerContext, HandlerModule, HandlerRecord } from "./handler.js";
43
+ export { createDispatcher } from "./handler.js";
44
+
45
45
  export function createRenderer(options: {|
46
- +App: React.ComponentType<AppProps>,
47
- +routes: RouteTable["routes"],
48
- +notFound: RouteTable["notFound"],
46
+ readonly App: React.ComponentType<AppProps>,
47
+ readonly routes: RouteTable["routes"],
48
+ readonly notFound: RouteTable["notFound"],
49
49
  |}): (url: string, assets: RenderAssets) => Promise<RenderResult> {
50
50
  const table: RouteTable = { routes: options.routes, notFound: options.notFound };
51
51
  installRoutes(table);
@@ -93,7 +93,8 @@ function assemble(markup: string, resolved: ResolvedRoute, assets: RenderAssets)
93
93
  : markup.replace(/<html([^>]*)>/i, `<html$1><head>${head}</head>`);
94
94
  return `<!doctype html>\n${document}\n`;
95
95
  }
96
- const title = resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
96
+ const title =
97
+ resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
97
98
  return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}</head><body><div id="${ROOT_ID}">${markup}</div></body></html>\n`;
98
99
  }
99
100