@uniflowed/router 0.0.0-alpha.11 → 0.0.0-alpha.13

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/action.js ADDED
@@ -0,0 +1,198 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/router/action`: a server action, as the browser holds it.
4
+ //
5
+ // A `"use client"` module writes an ordinary import and an ordinary call:
6
+ //
7
+ // // app/counter/_components/Counter.js
8
+ // "use client";
9
+ // import { recordClick } from "../_actions/clicks.js";
10
+ // …
11
+ // <button onClick={() => { void recordClick(count); }}>
12
+ //
13
+ // On the server that import is the function. In the browser it is a reference
14
+ // built here: `@uniflowed/vite` replaces the `"use server"` module, in the
15
+ // client graph only, with one `createServerReference` per callable export, so
16
+ // what crosses the import is an id and a `fetch` — and the module's body, its
17
+ // imports and every secret they reached stay where they were written.
18
+ //
19
+ // # Why this module is where it is
20
+ //
21
+ // It is the browser's half, so it may not live in `@uniflowed/server`: that
22
+ // package is in `SERVER_ONLY_PACKAGES` and importing it from a client
23
+ // component is the error the RSC graph exists to produce. It holds no
24
+ // platform API beyond `fetch` and `location`, and it imports one internal
25
+ // module, which is the grammar the server applies to the same bytes.
26
+ //
27
+ // # The call
28
+ //
29
+ // `POST` to the page's own URL, `uf-action: <id>`, `application/json`, and
30
+ // `{"args":[…]}`. The URL is the page rather than a path uf reserves so that
31
+ // the middleware guarding that path runs above the call exactly as it does
32
+ // above the page — and it is not a substitute for the action authorizing
33
+ // itself; `internal/action-endpoint.js` says why in the paragraph that matters.
34
+ //
35
+ // # What Flow checks, and where
36
+ //
37
+ // Flow reads `app/_actions/clicks.js`, not the reference the bundler
38
+ // substitutes for it, so `recordClick("nine")` against
39
+ // `recordClick(count: number)` is a `uf check` error rather than a `400`. That
40
+ // is the half that needs nothing from this module.
41
+ //
42
+ // What a declaration alone does not say is whether those arguments and that
43
+ // result can cross a wire at all, and `ActionArguments` and `ActionResult` are
44
+ // that half: `uf prepare` writes one instantiation of each into
45
+ // `server-actions.js`, over every action in the project at once, so an action
46
+ // taking a callback or returning a `Map` is a `uf check` error naming the
47
+ // offending type. Without them the same mistake is a request that arrives with
48
+ // `{}` where an object was passed — the kind of bug found in production by a
49
+ // column that stopped being written.
50
+
51
+ import {
52
+ ACTION_CONTENT_TYPE,
53
+ ACTION_HEADER,
54
+ type ActionValue,
55
+ decodeActionResult,
56
+ encodeActionArguments,
57
+ } from "./internal/action-wire.js";
58
+
59
+ export type { ActionValue } from "./internal/action-wire.js";
60
+ export {
61
+ ACTION_CONTENT_TYPE,
62
+ ACTION_HEADER,
63
+ ActionValueError,
64
+ MAX_ACTION_ARGUMENTS,
65
+ MAX_ACTION_BODY_BYTES,
66
+ MAX_ACTION_DEPTH,
67
+ MAX_ACTION_VALUES,
68
+ } from "./internal/action-wire.js";
69
+
70
+ /**
71
+ * A function that can be a server action.
72
+ *
73
+ * Both halves of the signature are the wire grammar: an action is called with
74
+ * values that can cross and answers with one that can. `void` is a result and
75
+ * not an argument, because JSON has no `undefined` and an action declared to
76
+ * take one would be taking something the caller cannot send.
77
+ */
78
+ export type ServerActionFunction = (...args: Array<ActionValue>) => Promise<ActionValue | void>;
79
+
80
+ /**
81
+ * An action's arguments, held against what a wire can carry.
82
+ *
83
+ * A bound on a tuple rather than a bound on the function, and the difference
84
+ * is the whole reason this is two types instead of one. A function type puts
85
+ * its parameters in a contravariant position: `F extends (…args:
86
+ * Array<ActionValue>) => …` asks whether `F` accepts *every* `ActionValue`,
87
+ * which `createUser(name: string)` does not and should not. `Parameters<F>` is
88
+ * the same list read covariantly, where the question is the one worth asking —
89
+ * is each argument something that can cross?
90
+ *
91
+ * `uf prepare` writes one instantiation over the whole project:
92
+ *
93
+ * export type ServerActionArgsFitTheWire =
94
+ * ActionArguments<ServerActionArgs<ServerActionName>>;
95
+ *
96
+ * `ServerActionName` is every action's name, so `ServerActionArgs` of it is
97
+ * every action's argument list, and one line checks all of them.
98
+ */
99
+ export type ActionArguments<TArgs extends $ReadOnlyArray<ActionValue>> = TArgs;
100
+
101
+ /**
102
+ * An action's result, held against the same grammar.
103
+ *
104
+ * A promise, because a server action is always async — the RSC graph rejects
105
+ * one that is not — and `void` is allowed because an action that returns
106
+ * nothing is ordinary. `Promise` is covariant in Flow, so the bound reaches
107
+ * the resolved type without any of the contortion the arguments needed.
108
+ */
109
+ export type ActionResult<TResult extends Promise<ActionValue | void>> = TResult;
110
+
111
+ /**
112
+ * A server action that did not answer.
113
+ *
114
+ * Carries the status and the action's build-time name and nothing else,
115
+ * because nothing else came back: the endpoint answers every refusal with a
116
+ * fixed body, so there is no message from the server to relay. What went wrong
117
+ * is in the server's log, which is where an application's internals belong.
118
+ */
119
+ export class ServerActionError extends Error {
120
+ /** The HTTP status the endpoint answered with. */
121
+ status: number;
122
+ /** `module#export` of the action that was called. */
123
+ action: string;
124
+
125
+ constructor(action: string, status: number) {
126
+ super(
127
+ `@uniflowed/router: the server action \`${action}\` answered ${String(status)}. ` +
128
+ "The endpoint reports every failure the same way; the reason is in the server's log.",
129
+ );
130
+ this.name = "ServerActionError";
131
+ this.status = status;
132
+ this.action = action;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The reference the client bundle holds in place of one server action.
138
+ *
139
+ * Generated, never written by hand: `@uniflowed/vite` emits one call per
140
+ * callable export of a `"use server"` module, with the id
141
+ * `crates/uf_rsc/src/action.rs` derived for it and the `module#export` name
142
+ * that only ever appears in an error.
143
+ *
144
+ * The returned function is `async` and refuses before it sends: an argument
145
+ * outside the wire grammar throws an `ActionValueError` naming the argument's
146
+ * position, at the call site, rather than becoming a `400` with nothing in it.
147
+ */
148
+ export function createServerReference(id: string, name: string): ServerActionFunction {
149
+ return async function callServerAction(...args: Array<ActionValue>): Promise<ActionValue | void> {
150
+ const body = encodeActionArguments(args);
151
+ // Built rather than written as a literal, because the header's name is a
152
+ // constant and a computed key in an object literal is a shape Flow
153
+ // declines to track.
154
+ const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
155
+ headers[ACTION_HEADER] = id;
156
+ const response = await fetch(currentUrl(), {
157
+ method: "POST",
158
+ // Stated rather than left to the default, because the default is what a
159
+ // reader has to look up and because this one is load-bearing: the
160
+ // endpoint's `Origin` check is only meaningful for a request that
161
+ // carries the visitor's cookies in the first place.
162
+ credentials: "same-origin",
163
+ // Never a cached answer, and never one written to a cache: an action is
164
+ // a side effect, and `POST` responses are outside HTTP caching by
165
+ // default only until something decides otherwise.
166
+ cache: "no-store",
167
+ headers,
168
+ body,
169
+ });
170
+ if (!response.ok) {
171
+ throw new ServerActionError(name, response.status);
172
+ }
173
+ return decodeActionResult(await response.text());
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The URL an action call is sent to: the page the browser is on.
179
+ *
180
+ * Path and query, not the whole URL, so the request is same-origin by
181
+ * construction rather than by a comparison somebody could get wrong. The hash
182
+ * is left off because it never reaches a server.
183
+ *
184
+ * A reference only exists in the client bundle — the server imports the real
185
+ * module — so there is no `location` here only if something has imported the
186
+ * browser's half into a server, and saying so is better than posting to a
187
+ * relative path that means nothing there.
188
+ */
189
+ function currentUrl(): string {
190
+ const location = globalThis.location;
191
+ if (location == null) {
192
+ throw new Error(
193
+ "@uniflowed/router: a server action reference was called where there is no `location`. " +
194
+ "A reference is the browser's half of an action; the server imports the module itself.",
195
+ );
196
+ }
197
+ return `${location.pathname}${location.search}`;
198
+ }
package/client.js CHANGED
@@ -7,6 +7,17 @@
7
7
  // *before* `hydrateRoot`, so the first client render is synchronous and
8
8
  // matches the server's markup exactly.
9
9
  //
10
+ // # A hydration that fails says what differed
11
+ //
12
+ // React reports a mismatch with one sentence and a list of the six things that
13
+ // usually cause it, and leaves the reader to find which node of the two
14
+ // thousand on the page was the one. This module is the only place that can do
15
+ // better, because it is the only place that runs between the parser finishing
16
+ // and React starting: `internal/hydration.js` takes a copy of the server's
17
+ // markup here, and compares it against the repaired tree when React reports.
18
+ // Development only, and dynamically imported so a production bundle has no path
19
+ // to it. See ubugeeei-prod/uf#508.
20
+ //
10
21
  // # A route can decline to be hydrated
11
22
  //
12
23
  // uf's server-component analysis decides which routes have a `"use client"`
@@ -65,7 +76,30 @@ export async function hydrate(options: {|
65
76
 
66
77
  const { App } = options;
67
78
  const container = document.getElementById(ROOT_ID) ?? document;
79
+
80
+ // The server's markup, and the reporter that will read it, in development
81
+ // only. Both have to be in place *before* `hydrateRoot`: React repairs a
82
+ // mismatched subtree by rendering over it, so the bytes the server sent exist
83
+ // for exactly the moment between the parser finishing and this line.
84
+ //
85
+ // `import.meta.hot` is the gate because it is the one signal that is right in
86
+ // all three places this module is evaluated. Vite defines it while serving
87
+ // and replaces it with `undefined` in a build, so the branch is statically
88
+ // dead there; Node leaves it undefined, so `tests/library/rsc-split.test.js`
89
+ // imports this file without a bundler and gets the production path. The
90
+ // import is dynamic so that the overlay is not merely shaken out of a
91
+ // production bundle but never reachable from one.
92
+ let recovery = null;
93
+ if (import.meta.hot != null) {
94
+ const { captureServerMarkup, hydrationErrorHandler } = await import("./internal/hydration.js");
95
+ recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
96
+ }
97
+
68
98
  startTransition(() => {
69
- hydrateRoot(container, <App url={url} initial={resolved} />);
99
+ hydrateRoot(
100
+ container,
101
+ <App url={url} initial={resolved} />,
102
+ recovery == null ? undefined : { onRecoverableError: recovery },
103
+ );
70
104
  });
71
105
  }
package/handler.js CHANGED
@@ -26,6 +26,41 @@
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
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
+ //
29
64
  // It also does not establish the request a handler is inside. The host does,
30
65
  // around the whole of it, so a handler and the guard above it share one
31
66
  // context; see the same section in `./middleware.js`. This module used to
@@ -34,6 +69,8 @@
34
69
  // `after()` promises. A handler that streams its body has not sent a byte at
35
70
  // that point. See ubugeeei-prod/uf#389.
36
71
 
72
+ import { noteRoute } from "@uniflowed/server/host";
73
+
37
74
  import { requireRequest } from "./internal/request.js";
38
75
  import type { RouteParams } from "./internal/runtime.js";
39
76
 
@@ -66,8 +103,18 @@ export type HandlerRecord = {|
66
103
  * — and a module that exports a helper would then answer requests with it.
67
104
  * `HEAD` falls back to `GET` with the body dropped, which is what a client
68
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.
69
116
  */
70
- const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
117
+ const METHODS = ["GET", "HEAD", "QUERY", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
71
118
 
72
119
  /**
73
120
  * Match a request against the handler table and run it.
@@ -94,6 +141,12 @@ export function createDispatcher(options: {|
94
141
  continue;
95
142
  }
96
143
 
144
+ // Before the module is loaded and before the method is checked, because
145
+ // this is the answer to "what was this request" and a `405` is as much
146
+ // this route's answer as a `200` is. A log of `/api/users/:id 405` is
147
+ // actionable; the same line with the path in it is a million lines.
148
+ noteRoute(record.path);
149
+
97
150
  const module = await record.load();
98
151
  const method = request.method.toUpperCase();
99
152
  const handler = pick(module, method);
@@ -126,8 +179,18 @@ export function createDispatcher(options: {|
126
179
  };
127
180
  }
128
181
 
129
- /** The function for a method, falling back to `GET` for `HEAD`. */
182
+ /**
183
+ * The function for a method, falling back to `GET` for `HEAD`.
184
+ *
185
+ * The method is checked against `METHODS` first, which is what makes that list
186
+ * closed rather than decorative: without it a request could name any export,
187
+ * and a module's `PURGE` — or its `DEFAULT`, or a name a bundler added — would
188
+ * answer one.
189
+ */
130
190
  function pick(module: HandlerModule, method: string): Handler | null {
191
+ if (!METHODS.includes(method)) {
192
+ return null;
193
+ }
131
194
  const own = module[method];
132
195
  if (typeof own === "function") {
133
196
  return own as $FlowFixMe;
package/index.js CHANGED
@@ -9,7 +9,12 @@
9
9
  //
10
10
  // `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
11
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.
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
+ // `_uf.template.js` is a layout that remounts on every navigation, for the
17
+ // cases where a layout's persistence is the wrong default.
13
18
 
14
19
  import * as React from "react";
15
20
 
@@ -19,6 +24,7 @@ export type {
19
24
  AppProps,
20
25
  ErrorBoundary,
21
26
  ErrorModule,
27
+ JsonLd,
22
28
  LayoutModule,
23
29
  LinkPrefetch,
24
30
  LoaderArgs,
@@ -28,6 +34,7 @@ export type {
28
34
  NotFoundBoundary,
29
35
  PageModule,
30
36
  ResolvedRoute,
37
+ Robots,
31
38
  RouteError,
32
39
  RouteInfo,
33
40
  RouteMatch,
@@ -37,6 +44,7 @@ export type {
37
44
  RouteTable,
38
45
  Router,
39
46
  SearchParams,
47
+ TemplateModule,
40
48
  TwitterCard,
41
49
  } from "./internal/runtime.js";
42
50
 
@@ -65,6 +73,7 @@ export {
65
73
  useLoaderData,
66
74
  useRoute,
67
75
  useRouter,
76
+ useSeo,
68
77
  } from "./internal/runtime.js";
69
78
 
70
79
  /** Props a page receives. */