@uniflowed/router 0.1.0 → 0.2.0

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 CHANGED
@@ -66,26 +66,19 @@
66
66
  // still reads `submitNote`'s own declaration, so a form action whose first
67
67
  // parameter is not the state it was given `null` for is a `uf check` error.
68
68
  //
69
- // **This needs the page to be hydrated.** React's progressive enhancement —
70
- // the form that submits before its JavaScript has arrived — works through a
71
- // `$$FORM_ACTION` property that turns the submit into a *native* form post,
72
- // and a native form post is `multipart/form-data`: the content type this
73
- // endpoint refuses, deliberately, as one of the three things standing between
74
- // it and a cross-site call. Supporting the pre-hydration submit would mean
75
- // accepting that content type, so a reference carries no `$$FORM_ACTION`, and
76
- // React writes the form it writes for any client action —
77
- // `action="javascript:throw new Error('React form unexpectedly submitted.')"`
78
- // — so a submit before hydration throws in the page rather than posting
79
- // anywhere. Nothing reaches a server that was not meant to; what is missing is
80
- // the submit working at all. See ubugeeei-prod/uf#252.
81
- //
82
- // The refusal is why the property is withheld, not what would stop it: no
83
- // request is made, so nothing is refused. Nor would a native form post aimed
84
- // at a page by hand be refused as multipart — `createActionDispatcher` reads
85
- // the `uf-action` header before it looks at the method or the content type and
86
- // returns `null` when it is absent, so the request is not an action call at
87
- // all and falls through to the route handlers. The `415` answers a request
88
- // that claims to be an action, which is the only kind that reaches it.
69
+ // **It works before the page has hydrated, too.** React's progressive
70
+ // enhancement — the form that submits before its JavaScript has arrived — asks
71
+ // the function for `$$FORM_ACTION` while rendering to HTML, and a reference
72
+ // has one: `method="POST"`, `application/x-www-form-urlencoded`, and hidden
73
+ // fields naming the action and carrying its bound arguments. The server
74
+ // renders with the real function, which `@uniflowed/vite` gives the same
75
+ // property through `registerServerAction` below, so the markup is the same
76
+ // whichever side wrote it. A native post is not the JSON call: it is a
77
+ // separate door in `./internal/action-endpoint.js`, which accepts only that
78
+ // content type, only from the page's own origin, and only with the fields
79
+ // `./internal/form-action.js` writes. `useActionState` works there as well
80
+ // — the answer is the page rendered with the action's result as the hook's
81
+ // state. See ubugeeei-prod/uf#1358.
89
82
  //
90
83
  // # After a deploy
91
84
  //
@@ -122,6 +115,7 @@ import {
122
115
  encodeActionArguments,
123
116
  } from "./internal/action-wire.js";
124
117
  import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
118
+ import { withFormAction } from "./internal/form-action.js";
125
119
  import { clearNavigationCache } from "./internal/navigation-cache.js";
126
120
 
127
121
  export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
@@ -248,11 +242,37 @@ export class ServerActionError extends Error {
248
242
  * outside the wire grammar throws an `ActionValueError` naming the argument's
249
243
  * position, at the call site, rather than becoming a `400` with nothing in it.
250
244
  *
251
- * It carries no `$$FORM_ACTION`, which is deliberate and is the header's last
252
- * section: React uses that property to make a form submit *natively* before
253
- * hydration, and a native submit is a content type this endpoint refuses.
245
+ * It carries `$$FORM_ACTION`, so a form bound to it is a real form before the
246
+ * page hydrates; see the header and `./internal/form-action.js`.
254
247
  */
255
248
  export function createServerReference(id: string, name: string): ServerActionFunction {
249
+ return withFormAction(callServerActionFor(id, name), id, []);
250
+ }
251
+
252
+ /**
253
+ * Give a server action, on the server, what its reference has in the browser.
254
+ *
255
+ * `@uniflowed/vite` calls this on every callable export of a `"use server"`
256
+ * module in the server graphs, with the id the build derived for it, so that a
257
+ * client component rendered to HTML writes a form that posts without
258
+ * JavaScript. It changes nothing about calling the function: the properties it
259
+ * defines are ones only React reads, and `bind` still binds. Anything that is
260
+ * not a function is returned untouched, because the RSC graph has already
261
+ * refused a `"use server"` export that is not one and this is not the place to
262
+ * say it twice.
263
+ */
264
+ export function registerServerAction<T>(fn: T, id: string): T {
265
+ if (typeof fn !== "function") {
266
+ return fn;
267
+ }
268
+ // `typeof` refines `T` to a function whose parameters Flow cannot name, and
269
+ // `withFormAction` changes nothing about calling it; the cast says that.
270
+ withFormAction(fn as $FlowFixMe, id, []);
271
+ return fn;
272
+ }
273
+
274
+ /** The network call one reference makes. */
275
+ function callServerActionFor(id: string, name: string): ServerActionFunction {
256
276
  return async function callServerAction(
257
277
  ...args: Array<ActionArgument>
258
278
  ): Promise<ActionValue | void> {
package/client.js CHANGED
@@ -119,6 +119,8 @@ import { decodePayload } from "./internal/payload.js";
119
119
  import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
120
120
  import { prepareDocumentForHydration } from "./internal/prepare-document.js";
121
121
  import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
122
+ import { hydrationOptions } from "./internal/hydrate-options.js";
123
+ import { readFormState } from "./internal/form-action.js";
122
124
 
123
125
  /**
124
126
  * Hydrate the current document.
@@ -215,7 +217,7 @@ export async function hydrate(options: {|
215
217
  hydrateRoot(
216
218
  container,
217
219
  options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
218
- recovery == null ? undefined : { onRecoverableError: recovery },
220
+ hydrationOptions(recovery, readFormState(document)),
219
221
  );
220
222
  if (restoreDevHead != null) {
221
223
  setTimeout(restoreDevHead, 250);
@@ -55,16 +55,46 @@
55
55
  // header for what is excluded and why.
56
56
  //
57
57
  // A submitted form is inside that boundary and does not widen it: `<form
58
- // action={fn}>` reaches here as the same `application/json` body, with the
59
- // form's entries beside the values under a `form` key, bounded in count and in
60
- // field-name length and holding strings only. Nothing about it is multipart
61
- // and nothing about it is a content type a cross-origin `<form>` could
62
- // produce, so rule 4 is exactly as true of a form call as of any other. The
63
- // cost of keeping it that way is written down where it is paid — a form that
64
- // submits before its page has hydrated throws in the page rather than posting
65
- // anywhere, because React writes `action="javascript:throw …"` for a form
66
- // whose action carries no `$$FORM_ACTION`, and giving it one would mean
67
- // accepting a native form post here.
58
+ // action={fn}>` on a hydrated page reaches here as the same `application/json`
59
+ // body, with the form's entries beside the values under a `form` key, bounded
60
+ // in count and in field-name length and holding strings only. Nothing about it
61
+ // is multipart and nothing about it is a content type a cross-origin `<form>`
62
+ // could produce, so rule 4 is exactly as true of a form call as of any other.
63
+ //
64
+ // # The second door: a form posted before the page hydrated
65
+ //
66
+ // React's progressive enhancement is a *native* form post, and a native post is
67
+ // the one request rule 4 was written to keep out. So it is not let in through
68
+ // that door. It has its own, narrower than the first, and it is worth being
69
+ // exact about what it keeps of the six rules above:
70
+ //
71
+ // - **Rules 1, 2, 3, 5 and 6 hold unchanged.** The id selects a row and is
72
+ // compared the same way; a failed lookup is the same `404`; the bound
73
+ // arguments and the form cross through `decodeActionArguments`, so the
74
+ // grammar, the counts, the depth and the field limits are the JSON call's
75
+ // own; a throw is the same fixed `500`.
76
+ // - **Rule 4 keeps one of its three guards.** A native post is a simple
77
+ // request, so there is no custom header and no JSON content type to lean on.
78
+ // What is left is `Origin`, which every browser sends on a `POST` and which
79
+ // must equal `Host` exactly as before — and `Sec-Fetch-Site`, which must say
80
+ // `same-origin` when the browser sends it. A cross-site form, a sandboxed
81
+ // frame (`Origin: null`) and a request with no `Origin` are `403`s.
82
+ // - **It accepts one content type**, `application/x-www-form-urlencoded`, which
83
+ // is what `$$FORM_ACTION` asks React to write. Not multipart: a file cannot
84
+ // cross to an action, and a multipart parser is surface uf does not open.
85
+ // - **It is recognised by its fields, read from a copy of the body.** A post
86
+ // that carries no `$uf_ref_` field is somebody else's — an ordinary form to a
87
+ // route handler — and is declined with `null` and its body untouched. So is
88
+ // one larger than an action accepts or not UTF-8, because it cannot be known
89
+ // to be an action post without reading it; it goes on to the route handlers,
90
+ // and a page answers a `POST` with a `404`.
91
+ //
92
+ // The answer is a document rather than JSON, because a person is looking at it:
93
+ // a `303` back to the page for a plain form action, the page rendered again
94
+ // with the action's result as the submitting `useActionState`'s state (the
95
+ // host supplies that render as `postback`), or a `303` to wherever
96
+ // `redirect()` pointed. See `./form-action.js` for the fields and
97
+ // ubugeeei-prod/uf#1358.
68
98
  //
69
99
  // **6. Nothing about a failure goes back.** An action that throws is a `500`
70
100
  // with a fixed body; the exception goes to the host's error reporting. A
@@ -102,7 +132,30 @@ import {
102
132
  encodeActionResult,
103
133
  isActionId,
104
134
  } from "./action-wire.js";
135
+ import { addressOf } from "./base-path.js";
136
+ import {
137
+ FORM_ACTION_CONTENT_TYPE,
138
+ type FormPost,
139
+ type FormState,
140
+ readFormPost,
141
+ } from "./form-action.js";
105
142
  import { requireRequest } from "./request.js";
143
+ import { RedirectError } from "./routing.js";
144
+
145
+ export type { FormState } from "./form-action.js";
146
+
147
+ /**
148
+ * What a host gives the dispatcher beyond the request.
149
+ *
150
+ * `postback` renders the page the request is for, with a `useActionState`'s
151
+ * result as React's `formState`, and is how a form posted before hydration
152
+ * gets the page back with its answer in it. A host that supplies none still
153
+ * serves native posts: the answer is a `303` back to the page, which runs the
154
+ * action and loses only the state.
155
+ */
156
+ export type ActionDispatchOptions = {|
157
+ readonly postback?: (formState: FormState) => Promise<Response>,
158
+ |};
106
159
 
107
160
  /** A module holding server actions, as the generated table loads it. */
108
161
  export type ActionModule = { readonly [name: string]: mixed };
@@ -146,10 +199,13 @@ const ANSWER_HEADERS: { readonly [string]: string } = {
146
199
  */
147
200
  export function createActionDispatcher(options: {|
148
201
  readonly actions: $ReadOnlyArray<ActionRecord>,
149
- |}): (request: Request) => Promise<Response | null> {
202
+ |}): (request: Request, settings?: ActionDispatchOptions) => Promise<Response | null> {
150
203
  const table = options.actions;
151
204
 
152
- return async function callAction(request: Request): Promise<Response | null> {
205
+ return async function callAction(
206
+ request: Request,
207
+ settings?: ActionDispatchOptions,
208
+ ): Promise<Response | null> {
153
209
  // The host's half of the contract, checked rather than assumed, exactly as
154
210
  // `dispatch` and `runMiddleware` check it: an action that calls `cookies()`
155
211
  // has to answer about the request it is inside.
@@ -157,7 +213,9 @@ export function createActionDispatcher(options: {|
157
213
 
158
214
  const id = request.headers.get(ACTION_HEADER);
159
215
  if (id == null) {
160
- return null;
216
+ // Not a JSON call. It may still be a form posted before its page
217
+ // hydrated, which is the second door in the header.
218
+ return nativeFormPost(table, request, settings?.postback);
161
219
  }
162
220
 
163
221
  // Cheapest and most protective first, and all of it before a byte of the
@@ -256,6 +314,151 @@ export function createActionDispatcher(options: {|
256
314
  };
257
315
  }
258
316
 
317
+ /**
318
+ * Answer a form the browser posted natively, or decline with `null`.
319
+ *
320
+ * The header's second door. Declines everything that is not a urlencoded
321
+ * `POST` carrying a `$uf_ref_` field, and answers everything that is,
322
+ * refusals included — the same promise the JSON door makes, for the same
323
+ * reason.
324
+ */
325
+ async function nativeFormPost(
326
+ table: $ReadOnlyArray<ActionRecord>,
327
+ request: Request,
328
+ postback: ?(formState: FormState) => Promise<Response>,
329
+ ): Promise<Response | null> {
330
+ if (request.method.toUpperCase() !== "POST") {
331
+ return null;
332
+ }
333
+ if (!isMedia(request.headers.get("content-type"), FORM_ACTION_CONTENT_TYPE)) {
334
+ return null;
335
+ }
336
+ // A copy, so that a form which turns out not to be an action post reaches the
337
+ // route handler with its body unread.
338
+ const text = await readBoundedText(request.clone(), MAX_ACTION_BODY_BYTES);
339
+ if (text == null) {
340
+ return null;
341
+ }
342
+ const post = readFormPost(new URLSearchParams(text));
343
+ if (post == null) {
344
+ return null;
345
+ }
346
+
347
+ // An action post from here on, and every outcome is an answer.
348
+ if (!sameOrigin(request) || !sameSiteFetch(request)) {
349
+ return refusal(403);
350
+ }
351
+
352
+ let args: Array<ActionArgument>;
353
+ let bound: number;
354
+ try {
355
+ ({ args, bound } = formArguments(post));
356
+ } catch (error) {
357
+ if (!(error instanceof ActionValueError)) {
358
+ throw error;
359
+ }
360
+ return refusal(400);
361
+ }
362
+
363
+ const id = post.id;
364
+ const record = id != null && isActionId(id) ? select(table, id) : null;
365
+ if (record == null) {
366
+ return refusal(404);
367
+ }
368
+
369
+ let action: mixed;
370
+ try {
371
+ const module = await record.load();
372
+ action = module[record.export];
373
+ } catch (error) {
374
+ report(record, error);
375
+ return refusal(500);
376
+ }
377
+ if (typeof action !== "function") {
378
+ report(record, new Error(`export \`${record.export}\` is not a function`));
379
+ return refusal(500);
380
+ }
381
+
382
+ const url = new URL(request.url);
383
+ const stateKey = post.stateKey;
384
+ return asResponder("a server action", async () => {
385
+ let result: mixed;
386
+ try {
387
+ const call = action as $FlowFixMe;
388
+ result = await traceRequestPhase("action", () => call(...args));
389
+ } catch (error) {
390
+ if (error instanceof RedirectError) {
391
+ return seeOther(addressOf(error.to));
392
+ }
393
+ report(record, error);
394
+ return refusal(500);
395
+ }
396
+ if (stateKey == null || postback == null) {
397
+ // Post/redirect/get: the page again, by a `GET`, so a reload does not
398
+ // submit the form a second time.
399
+ return seeOther(addressOf(url.pathname + url.search));
400
+ }
401
+ try {
402
+ // Held to the grammar for the reason the JSON door holds a result to it:
403
+ // this value is written into a document a browser parses.
404
+ encodeActionResult(result);
405
+ } catch (error) {
406
+ report(record, error);
407
+ return refusal(500);
408
+ }
409
+ // `bound - 1`: `useActionState` bound the previous state itself, and
410
+ // React compares the count of the bindings the *action* had.
411
+ return postback([result, stateKey, record.id, bound - 1]);
412
+ });
413
+ }
414
+
415
+ /**
416
+ * The arguments a native post calls its action with: the bound ones, then
417
+ * the form.
418
+ *
419
+ * Built into the JSON call's own envelope and decoded by the JSON call's own
420
+ * decoder, so there is one grammar and one set of limits, not a second one for
421
+ * forms that could drift from the first.
422
+ */
423
+ function formArguments(post: FormPost): {|
424
+ readonly args: Array<ActionArgument>,
425
+ readonly bound: number,
426
+ |} {
427
+ let bound: Array<ActionArgument> = [];
428
+ if (post.bound != null) {
429
+ bound = decodeActionArguments(post.bound);
430
+ if (bound.some((value) => value instanceof FormData)) {
431
+ throw new ActionValueError("the bound arguments", "carry a form");
432
+ }
433
+ }
434
+ const envelope = JSON.stringify({
435
+ args: [...bound, null],
436
+ form: { at: bound.length, entries: post.entries },
437
+ });
438
+ return { args: decodeActionArguments(envelope), bound: bound.length };
439
+ }
440
+
441
+ /**
442
+ * Whether the browser says the request came from this origin, when it says.
443
+ *
444
+ * `Sec-Fetch-Site` is a forbidden header name, so a page cannot set it; a
445
+ * browser that sends it and says anything but `same-origin` is describing a
446
+ * cross-site form. Absent is not a refusal on its own — older browsers do not
447
+ * send it, and `Origin` has already been required.
448
+ */
449
+ function sameSiteFetch(request: Request): boolean {
450
+ const site = request.headers.get("sec-fetch-site");
451
+ return site == null || site === "same-origin";
452
+ }
453
+
454
+ /** A `303 See Other`, which a browser follows with a `GET`. */
455
+ function seeOther(location: string): Response {
456
+ return new Response(null, {
457
+ status: 303,
458
+ headers: { location, "cache-control": "no-store" },
459
+ });
460
+ }
461
+
259
462
  /**
260
463
  * The row with this id, or `null`, without saying which by taking longer.
261
464
  *
@@ -331,12 +534,17 @@ function sameOrigin(request: Request): boolean {
331
534
  * can.
332
535
  */
333
536
  function isJson(declared: string | null): boolean {
537
+ return isMedia(declared, ACTION_CONTENT_TYPE);
538
+ }
539
+
540
+ /** Whether `declared` is exactly the media type `expected`, parameters aside. */
541
+ function isMedia(declared: string | null, expected: string): boolean {
334
542
  if (declared == null) {
335
543
  return false;
336
544
  }
337
545
  const semicolon = declared.indexOf(";");
338
546
  const media = (semicolon === -1 ? declared : declared.slice(0, semicolon)).trim().toLowerCase();
339
- return media === ACTION_CONTENT_TYPE;
547
+ return media === expected;
340
548
  }
341
549
 
342
550
  /**
@@ -0,0 +1,243 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: a server action as a form the browser can
4
+ // submit before any JavaScript has run.
5
+ //
6
+ // React 19's contract for `<form action={serverFunction}>` is progressive
7
+ // enhancement. While rendering to HTML, React asks the function for
8
+ // `$$FORM_ACTION(prefix)` and writes what it answers into the markup — a
9
+ // `method`, an `encType`, a hidden field named `name`, and one hidden field per
10
+ // entry of `data` — so the form is a real form that posts to the page it is on.
11
+ // A function without the property gets `action="javascript:throw …"` instead,
12
+ // which is what every uf form got before ubugeeei-prod/uf#1358.
13
+ //
14
+ // Two functions carry the property: the reference the browser holds
15
+ // (`createServerReference` in `../action.js`) and the real function the server
16
+ // renders with (`registerServerAction`, which `@uniflowed/vite` calls on every
17
+ // callable export of a `"use server"` module in the server graph). Both answer
18
+ // with the same fields, from the code below, so the markup the server writes and
19
+ // what a hydrated page would have written agree.
20
+ //
21
+ // # The fields
22
+ //
23
+ // React hands `$$FORM_ACTION` a prefix unique to the form in its render, and
24
+ // the prefix is in every field name so that two forms — or a `<form>` and a
25
+ // `<button formAction>` inside it — never read each other's fields:
26
+ //
27
+ // | Field | Value |
28
+ // | --- | --- |
29
+ // | `$uf_ref_<prefix>` | empty. The field that says *which* action this submit is for; on a `<button formAction>` it is the button's own name, so only the button that was pressed sends it |
30
+ // | `$uf_id_<prefix>` | the action's id, the same 64 hexadecimal characters the JSON call carries in `uf-action` |
31
+ // | `$uf_bound_<prefix>` | the bound arguments, as the JSON envelope `encodeActionArguments` writes, when there are any |
32
+ //
33
+ // `useActionState` adds React's own `$ACTION_KEY`, which names the hook that
34
+ // submitted, so the answer can put the action's result back into the same hook.
35
+ //
36
+ // Every field is a string and every bound argument goes through the same
37
+ // grammar as a JSON call: nothing new is allowed to cross by arriving as a form.
38
+ // `./action-endpoint.js` is the other half, and its header says what a native
39
+ // post is refused for.
40
+
41
+ import { encodeActionArguments } from "./action-wire.js";
42
+
43
+ /** The name of the field that says which action a submit is for. */
44
+ export const FORM_REF_FIELD = "$uf_ref_";
45
+ /** The action id, beside it. */
46
+ export const FORM_ID_FIELD = "$uf_id_";
47
+ /** The bound arguments, beside it. */
48
+ export const FORM_BOUND_FIELD = "$uf_bound_";
49
+ /** React's own: which `useActionState` submitted. */
50
+ export const FORM_STATE_KEY_FIELD = "$ACTION_KEY";
51
+
52
+ /**
53
+ * The only content type a native action post may have.
54
+ *
55
+ * `application/x-www-form-urlencoded` rather than `multipart/form-data`,
56
+ * because a server action refuses a file anyway and multipart parsing is a
57
+ * surface uf does not open for one (`docs/security.md`). `$$FORM_ACTION` says
58
+ * it explicitly, so React writes it on the form and on a submit button.
59
+ */
60
+ export const FORM_ACTION_CONTENT_TYPE = "application/x-www-form-urlencoded";
61
+
62
+ /** What `$$FORM_ACTION` answers, in the shape React reads. */
63
+ export type FormActionFields = {|
64
+ readonly name: string,
65
+ readonly method: "POST",
66
+ readonly encType: string,
67
+ readonly data: FormData,
68
+ |};
69
+
70
+ /**
71
+ * What a `useActionState` postback leaves for the page it renders.
72
+ *
73
+ * React's `ReactFormState`: the action's result, the hook's key, the action's
74
+ * id, and how many bound arguments it had *besides* the state
75
+ * `useActionState` bound itself.
76
+ */
77
+ export type FormState = [mixed, string, string, number];
78
+
79
+ /**
80
+ * Make `fn` a server action a form can post to without JavaScript.
81
+ *
82
+ * Defines three non-enumerable properties on `fn` and returns it:
83
+ *
84
+ * - `$$FORM_ACTION(prefix)`, the fields above;
85
+ * - `$$IS_SIGNATURE_EQUAL(id, bound)`, which React asks while rendering a
86
+ * postback's answer to decide whether a `useActionState` is the one that
87
+ * submitted — the same action, bound the same number of times;
88
+ * - `bind`, which keeps both of those on the function it returns, with the
89
+ * bound arguments recorded so they travel in the form.
90
+ *
91
+ * `call` is what the bound function runs: `fn` itself on the server, the
92
+ * network call in the browser.
93
+ */
94
+ export function withFormAction<T extends (...args: $ReadOnlyArray<empty>) => mixed>(
95
+ fn: T,
96
+ id: string,
97
+ bound: $ReadOnlyArray<mixed>,
98
+ ): T {
99
+ const define = (name: string, value: mixed) => {
100
+ Object.defineProperty(fn, name, { value, configurable: true, writable: true });
101
+ };
102
+ define("$$FORM_ACTION", (prefix: string): FormActionFields => formFields(id, bound, prefix));
103
+ define(
104
+ "$$IS_SIGNATURE_EQUAL",
105
+ (referenceId: string, boundCount: number): boolean =>
106
+ referenceId === id && boundCount === bound.length,
107
+ );
108
+ // `Function.prototype.bind` itself, reached through a cast: Flow types
109
+ // `bind` per call site and has no type for the method taken off the
110
+ // prototype and applied to an arbitrary function.
111
+ const nativeBind: $FlowFixMe = Function.prototype.bind;
112
+ define("bind", function bindAction(thisArg: mixed, ...more: Array<mixed>) {
113
+ const next: T = nativeBind.call(fn, thisArg, ...more);
114
+ return withFormAction(next, id, [...bound, ...more]);
115
+ });
116
+ return fn;
117
+ }
118
+
119
+ /**
120
+ * The fields a form bound to this action is written with.
121
+ *
122
+ * Throws `ActionValueError` when a bound argument cannot cross, which React
123
+ * catches and reports as "Failed to serialize an action for progressive
124
+ * enhancement" before writing the form it would have written without this —
125
+ * so an unencodable state costs the pre-hydration submit and nothing else.
126
+ */
127
+ export function formFields(
128
+ id: string,
129
+ bound: $ReadOnlyArray<mixed>,
130
+ prefix: string,
131
+ ): FormActionFields {
132
+ const data = new FormData();
133
+ data.append(`${FORM_ID_FIELD}${prefix}`, id);
134
+ if (bound.length > 0) {
135
+ data.append(`${FORM_BOUND_FIELD}${prefix}`, encodeActionArguments(bound));
136
+ }
137
+ return {
138
+ name: `${FORM_REF_FIELD}${prefix}`,
139
+ method: "POST",
140
+ encType: FORM_ACTION_CONTENT_TYPE,
141
+ data,
142
+ };
143
+ }
144
+
145
+ /** What a native post names, read off its fields. */
146
+ export type FormPost = {|
147
+ /** The action id, as sent; checked by the caller. */
148
+ readonly id: string | null,
149
+ /** The bound-argument envelope, as sent, or `null` when there is none. */
150
+ readonly bound: string | null,
151
+ /** `$ACTION_KEY`, or `null` when no `useActionState` submitted. */
152
+ readonly stateKey: string | null,
153
+ /** Every other field, in order: what the action is handed as its form. */
154
+ readonly entries: Array<[string, string]>,
155
+ |};
156
+
157
+ /**
158
+ * Read a native post's fields, or `null` when it names no action at all.
159
+ *
160
+ * `null` is the caller's signal to decline, exactly as a JSON request with no
161
+ * `uf-action` header is: an ordinary form posted to a route handler must reach
162
+ * that handler. The last `$uf_ref_` field wins, because the browser writes a
163
+ * pressed button's name after the fields of the form around it, and a button's
164
+ * `formAction` is meant to override the form's `action`.
165
+ *
166
+ * Every field whose name uf or React owns is kept out of `entries`, including
167
+ * another prefix's, so the action receives the form the person filled in and
168
+ * nothing that says how it was wired.
169
+ */
170
+ export function readFormPost(fields: URLSearchParams): FormPost | null {
171
+ let prefix: string | null = null;
172
+ for (const [name] of fields) {
173
+ if (name.startsWith(FORM_REF_FIELD)) {
174
+ prefix = name.slice(FORM_REF_FIELD.length);
175
+ }
176
+ }
177
+ if (prefix == null) {
178
+ return null;
179
+ }
180
+ const entries: Array<[string, string]> = [];
181
+ for (const [name, value] of fields) {
182
+ if (!isWiring(name)) {
183
+ entries.push([name, value]);
184
+ }
185
+ }
186
+ return {
187
+ id: fields.get(`${FORM_ID_FIELD}${prefix}`),
188
+ bound: fields.get(`${FORM_BOUND_FIELD}${prefix}`),
189
+ stateKey: fields.get(FORM_STATE_KEY_FIELD),
190
+ entries,
191
+ };
192
+ }
193
+
194
+ /** Whether a field is uf's or React's rather than the form's own. */
195
+ function isWiring(name: string): boolean {
196
+ return name.startsWith("$uf_") || name.startsWith("$ACTION_");
197
+ }
198
+
199
+ /** The element a postback's form state is written into the document as. */
200
+ export const FORM_STATE_ELEMENT_ID = "uf:form-state";
201
+
202
+ /**
203
+ * The `<script>` that carries a postback's form state to `hydrateRoot`.
204
+ *
205
+ * `application/json`, so it is data and never executed — no nonce, and nothing
206
+ * a Content Security Policy has to admit. The result was already held to the
207
+ * wire grammar before it got here, so it is plain JSON; `<` is escaped so
208
+ * that no string in it can close the element.
209
+ */
210
+ export function formStateScript(state: FormState): string {
211
+ const json = JSON.stringify(state).replace(/</g, "\\u003c");
212
+ return `<script type="application/json" id="${FORM_STATE_ELEMENT_ID}">${json}</script>`;
213
+ }
214
+
215
+ /**
216
+ * The form state a document carries, or `undefined`.
217
+ *
218
+ * Read by both hydrating entries before `hydrateRoot`. Anything malformed is
219
+ * `undefined` rather than an exception: the page still hydrates, and the
220
+ * `useActionState` that submitted starts from its initial state, which is what
221
+ * it would have done with JavaScript from the start.
222
+ */
223
+ export function readFormState(document: Document): FormState | void {
224
+ const element = document.getElementById(FORM_STATE_ELEMENT_ID);
225
+ if (element == null) {
226
+ return undefined;
227
+ }
228
+ try {
229
+ const parsed: mixed = JSON.parse(element.textContent);
230
+ if (
231
+ Array.isArray(parsed) &&
232
+ parsed.length === 4 &&
233
+ typeof parsed[1] === "string" &&
234
+ typeof parsed[2] === "string" &&
235
+ typeof parsed[3] === "number"
236
+ ) {
237
+ return [parsed[0], parsed[1], parsed[2], parsed[3]];
238
+ }
239
+ } catch {
240
+ // Fall through: see above.
241
+ }
242
+ return undefined;
243
+ }
@@ -0,0 +1,38 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: what both hydrating entries hand
4
+ // `hydrateRoot` besides the tree.
5
+
6
+ import type { FormState } from "./form-action.js";
7
+
8
+ /** React's `onRecoverableError`, as the development report builds one. */
9
+ type Recovery = (error: mixed, info: { componentStack?: ?string, ... }) => void;
10
+
11
+ /** What this hands `hydrateRoot`. */
12
+ type HydrationOptions = {| onRecoverableError?: Recovery, formState?: FormState |};
13
+
14
+ /**
15
+ * `hydrateRoot`'s options: the development recovery handler, and a postback's
16
+ * form state.
17
+ *
18
+ * `formState` is React's own option. A page rendered in answer to a form posted
19
+ * before hydration was rendered with it, React marked the `useActionState` that
20
+ * submitted, and passing the same value here is how the browser's copy of that
21
+ * hook starts from the action's result instead of its initial state.
22
+ */
23
+ export function hydrationOptions(
24
+ recovery: ?Recovery,
25
+ formState: FormState | void,
26
+ ): HydrationOptions | void {
27
+ if (recovery == null && formState == null) {
28
+ return undefined;
29
+ }
30
+ const options: HydrationOptions = {};
31
+ if (recovery != null) {
32
+ options.onRecoverableError = recovery;
33
+ }
34
+ if (formState != null) {
35
+ options.formState = formState;
36
+ }
37
+ return options;
38
+ }
package/internal/shell.js CHANGED
@@ -15,6 +15,7 @@ import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
15
15
  import { addressOf } from "./base-path.js";
16
16
  import { DEPLOYMENT_META } from "./deployment.js";
17
17
  import { ROOT_ID } from "./document.js";
18
+ import { type FormState, formStateScript } from "./form-action.js";
18
19
  import type { RedirectError } from "./routing.js";
19
20
  import { type DocumentShell, bodyOfText } from "./stream.js";
20
21
 
@@ -79,8 +80,14 @@ export function redirectDocument(error: RedirectError): RenderResult {
79
80
  * carried two of them, one in each place, and only one was where a browser
80
81
  * looks. Hoisting the rendered one leaves the metadata with a single source.
81
82
  */
82
- export function shellFor(assets: RenderAssets, nonce?: string | null): DocumentShell {
83
- const head = headTags(assets, nonce);
83
+ export function shellFor(
84
+ assets: RenderAssets,
85
+ nonce?: string | null,
86
+ formState?: FormState,
87
+ ): DocumentShell {
88
+ // A postback's form state goes first, before the client entry that reads it:
89
+ // data rather than a script, so it needs no nonce. See `./form-action.js`.
90
+ const head = (formState == null ? "" : formStateScript(formState)) + headTags(assets, nonce);
84
91
  return {
85
92
  head,
86
93
  open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
@@ -44,6 +44,7 @@ import * as ReactDOMServer from "react-dom/server";
44
44
  import * as ReactDOMStatic from "react-dom/static";
45
45
 
46
46
  import { createChunkEncoder } from "./flight-chunks.js";
47
+ import type { FormState } from "./form-action.js";
47
48
  import { type StreamRecord, inspected } from "./inspector.js";
48
49
 
49
50
  /**
@@ -693,6 +694,14 @@ export type RenderOptions = {|
693
694
  * `prerenderDocument` is deliberately never given one. See its own paragraph.
694
695
  */
695
696
  readonly nonce?: string | null,
697
+ /**
698
+ * React's `formState`, for a document that answers a form posted before
699
+ * hydration: the `useActionState` that submitted starts from the action's
700
+ * result, and React marks it so the browser's hydration picks the same one.
701
+ * The shell carries the same value for `hydrateRoot`; see
702
+ * `./form-action.js`. Absent for every other render.
703
+ */
704
+ readonly formState?: FormState,
696
705
  |};
697
706
 
698
707
  /**
@@ -751,6 +760,7 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
751
760
  // and the bootstrap. uf nonces the scripts it writes itself; these are
752
761
  // React's, and there is no other way to reach them.
753
762
  nonce: options.nonce ?? undefined,
763
+ formState: options.formState ?? null,
754
764
  });
755
765
  return;
756
766
  }
@@ -779,6 +789,7 @@ type ReadableStreamRenderer = (
779
789
  readonly onError: (error: mixed) => void,
780
790
  readonly signal: AbortSignal,
781
791
  readonly nonce?: string,
792
+ readonly formState?: FormState | null,
782
793
  |},
783
794
  ) => Promise<ByteSource>;
784
795
 
@@ -808,6 +819,7 @@ export function renderWithReadableStream(
808
819
  onError: options.onError,
809
820
  signal: controller.signal,
810
821
  nonce: options.nonce ?? undefined,
822
+ formState: options.formState ?? null,
811
823
  }).then((stream: ByteSource) =>
812
824
  bodyOf(
813
825
  outgoing(
package/middleware.js CHANGED
@@ -60,6 +60,32 @@
60
60
  // middleware writes against `/pricing` holds for a client navigation too, and
61
61
  // a rewrite of the document becomes a rewrite of its payload.
62
62
  //
63
+ // # A payload that renders over another page runs that page's guards too
64
+ //
65
+ // An intercepted navigation — a modal slot opening over the page it was clicked
66
+ // from — asks for the payload of the URL it opens, and names the page it
67
+ // renders over in `uf-intercepted-from`. The answer is *both* pages: the one
68
+ // the URL names, in the slot, and the one the header names, underneath it,
69
+ // with that page's loader run and its data in the payload.
70
+ //
71
+ // So the chain matched against the URL alone would be the wrong chain. A guard
72
+ // on `/feed` holds for a request *for* `/feed`, and a payload for `/photo/1`
73
+ // rendered over `/feed` is `/feed` as much as it is `/photo/1`. After the
74
+ // ordinary chain has admitted the request, every middleware guarding the page
75
+ // underneath runs as well — against a `GET` for that page, with its params and
76
+ // query — including one that already ran for the URL, because a middleware
77
+ // that decides by reading the pathname (one root `$middleware.js` guarding
78
+ // several sections is a common shape) has only been asked about the other
79
+ // path. If every one of them declines, the interception stands. If any
80
+ // answers or rewrites, the page underneath is not this request's to render:
81
+ // the header is taken off, and what renders is the page the URL names on its
82
+ // own — exactly what a reader who could not open the page underneath would
83
+ // see after a reload. The guard's own answer is not sent: the request was for
84
+ // `/photo/1`, which that guard does not cover.
85
+ //
86
+ // The header is taken off whenever it is not a path on this origin, too, so
87
+ // the renderer is never handed a value this module has not judged.
88
+ //
63
89
  // # The request it runs inside
64
90
  //
65
91
  // The runner does not establish one. The host does — `beginRequest` in
@@ -96,7 +122,7 @@
96
122
  // `routesModuleSource` keeps the middleware table in an export the client
97
123
  // never imports, for the same reason it does that with route handlers.
98
124
 
99
- import { documentPathOf, flightUrl } from "./internal/flight.js";
125
+ import { INTERCEPTED_FROM_HEADER, documentPathOf, flightUrl } from "./internal/flight.js";
100
126
  import { requireRequest } from "./internal/request.js";
101
127
  import type { RouteParams } from "./internal/runtime.js";
102
128
 
@@ -160,7 +186,10 @@ export type MiddlewareRecord = {|
160
186
  * caller's signal to carry on to the handler or the page. Returns a `Request`
161
187
  * when one of them rewrote: the same request at the destination, which the
162
188
  * caller carries on with instead — and which has already been past the
163
- * destination's middleware.
189
+ * destination's middleware. It also returns a `Request` when a payload request
190
+ * names a page to render over and that page's guards did not all admit it: the
191
+ * same request without `uf-intercepted-from`. The host must carry on with that
192
+ * request and read the header off nothing else, or the decision is undone.
164
193
  *
165
194
  * The runner is called once per request, above both the dispatcher and the
166
195
  * renderer, rather than from inside each of them. Putting the call inside
@@ -242,15 +271,99 @@ export function createMiddlewareRunner(options: {|
242
271
  return result;
243
272
  }
244
273
 
245
- if (!rewritten) {
246
- return null;
274
+ const admitted: Request | null = !rewritten
275
+ ? null
276
+ : document == null
277
+ ? seen
278
+ : requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
279
+
280
+ // A payload rendered over another page: that page's guards have their say
281
+ // too. See "A payload that renders over another page" above.
282
+ if (document != null && request.headers.has(INTERCEPTED_FROM_HEADER)) {
283
+ const carried = admitted ?? request;
284
+ const underneath = interceptionBase(request.headers.get(INTERCEPTED_FROM_HEADER), url);
285
+ if (underneath == null || !(await guardsAdmit(table, carried, underneath))) {
286
+ return withoutInterception(carried);
287
+ }
247
288
  }
248
- return document == null
249
- ? seen
250
- : requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
289
+ return admitted;
251
290
  };
252
291
  }
253
292
 
293
+ /**
294
+ * The page an intercepted payload names in its header, as a URL on `base`'s
295
+ * origin, or `null` when the value is not a path on this origin.
296
+ *
297
+ * The same shape the renderer accepts (`usableInterceptionBase` in `./rsc.js`,
298
+ * `interceptedFrom` in `@uniflowed/server`): a path, not a network-path
299
+ * reference, with any fragment dropped. Parsed rather than compared as text,
300
+ * so the pathname the guards are matched against is the one the renderer's
301
+ * route matching will read.
302
+ */
303
+ function interceptionBase(header: string | null, base: URL): URL | null {
304
+ if (header == null || !header.startsWith("/") || header.startsWith("//")) {
305
+ return null;
306
+ }
307
+ let parsed: URL;
308
+ try {
309
+ parsed = new URL(header, base);
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (parsed.origin !== base.origin) {
314
+ return null;
315
+ }
316
+ parsed.hash = "";
317
+ return parsed;
318
+ }
319
+
320
+ /**
321
+ * Whether every middleware guarding `underneath` lets this request see it.
322
+ *
323
+ * Each one is asked exactly as it would be asked by a request for that page: a
324
+ * `GET` at its URL carrying this request's headers, with the params of the
325
+ * directory it guards and the page's query. All of them, root first, whether
326
+ * or not they already ran for the URL the request names — a guard that reads
327
+ * the pathname has so far only been asked about the other one.
328
+ *
329
+ * A `Response` is a refusal and so is a `rewrite()`: the page a guard would
330
+ * serve instead is not the page the header names, and the renderer has no way
331
+ * to render one under the other. Nothing either returns is sent; the caller
332
+ * only learns that the page underneath is not this request's to render.
333
+ */
334
+ async function guardsAdmit(
335
+ table: $ReadOnlyArray<MiddlewareRecord>,
336
+ request: Request,
337
+ underneath: URL,
338
+ ): Promise<boolean> {
339
+ const asked = new Request(underneath.href, { method: "GET", headers: request.headers });
340
+ for (const record of table) {
341
+ const params = matchPrefix(record.path, underneath.pathname);
342
+ if (params == null) {
343
+ continue;
344
+ }
345
+ const middleware = pick(await record.load(), record.file);
346
+ const result = await middleware(asked, { params, searchParams: underneath.searchParams });
347
+ if (result != null) {
348
+ return false;
349
+ }
350
+ }
351
+ return true;
352
+ }
353
+
354
+ /**
355
+ * `request` with its `uf-intercepted-from` header taken off, so the renderer
356
+ * answers with the page its URL names and nothing underneath it.
357
+ *
358
+ * Only a payload request reaches here, and a payload is a `GET` or a `HEAD`,
359
+ * so there is no body to hand on.
360
+ */
361
+ function withoutInterception(request: Request): Request {
362
+ const headers = new Headers(request.headers);
363
+ headers.delete(INTERCEPTED_FROM_HEADER);
364
+ return new Request(request.url, { method: request.method, headers, signal: request.signal });
365
+ }
366
+
254
367
  /**
255
368
  * Where a rewrite goes, resolved against the URL the middleware was handed.
256
369
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -71,7 +71,7 @@
71
71
  }
72
72
  },
73
73
  "dependencies": {
74
- "@uniflowed/hooks": "0.1.0",
75
- "@uniflowed/server": "0.1.0"
74
+ "@uniflowed/hooks": "0.2.0",
75
+ "@uniflowed/server": "0.2.0"
76
76
  }
77
77
  }
package/rsc-client.js CHANGED
@@ -41,6 +41,8 @@ import {
41
41
  installRouting,
42
42
  installStaleTime,
43
43
  } from "./internal/runtime.js";
44
+ import { hydrationOptions } from "./internal/hydrate-options.js";
45
+ import { readFormState } from "./internal/form-action.js";
44
46
 
45
47
  /**
46
48
  * Hydrate a document React Server Components rendered.
@@ -106,7 +108,7 @@ export async function hydrateFlight(options: {|
106
108
  hydrateRoot(
107
109
  container,
108
110
  options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
109
- recovery == null ? undefined : { onRecoverableError: recovery },
111
+ hydrationOptions(recovery, readFormState(document)),
110
112
  );
111
113
  if (restoreDevHead != null) {
112
114
  setTimeout(restoreDevHead, 250);
package/rsc-ssr.js CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  runPartialPrerender,
41
41
  } from "@uniflowed/server/host";
42
42
 
43
+ import type { FormState } from "./internal/form-action.js";
43
44
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
44
45
  import {
45
46
  type DocumentBody,
@@ -113,6 +114,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
113
114
  readonly onError: (error: mixed) => void,
114
115
  readonly transformHead?: (html: string) => Promise<string>,
115
116
  readonly onStream?: (record: StreamRecord) => void,
117
+ readonly formState?: FormState,
116
118
  |},
117
119
  ): Promise<DocumentBody> {
118
120
  const [forHtml, forBrowser] = stream.tee();
@@ -123,12 +125,13 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
123
125
  const nonce = currentNonce();
124
126
  try {
125
127
  return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
126
- shell: shellFor(assets, nonce),
128
+ shell: shellFor(assets, nonce, settings.formState),
127
129
  onError: settings.onError,
128
130
  transformHead: settings.transformHead,
129
131
  onStream: settings.onStream,
130
132
  payload: forBrowser,
131
133
  nonce,
134
+ formState: settings.formState,
132
135
  });
133
136
  } catch (error) {
134
137
  void forBrowser.cancel();
@@ -189,6 +192,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
189
192
  },
190
193
  transformHead,
191
194
  onStream,
195
+ formState: settings?.formState,
192
196
  });
193
197
  streaming = true;
194
198
  for (const error of held) {
package/server.js CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  resolveMatch,
49
49
  } from "./internal/runtime.js";
50
50
 
51
+ import type { FormState } from "./internal/form-action.js";
51
52
  import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
52
53
 
53
54
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
@@ -198,6 +199,12 @@ export type RenderOptions = {|
198
199
  * it changes is a failing test rather than a surprise.
199
200
  */
200
201
  readonly transformHead?: (html: string) => Promise<string>,
202
+ /**
203
+ * React's `formState` for a page rendered in answer to a form posted before
204
+ * hydration; see `internal/form-action.js`. Only a host's `postback` passes
205
+ * one.
206
+ */
207
+ readonly formState?: FormState,
201
208
  /**
202
209
  * Told, in words, when a document streamed differently than it did last time.
203
210
  *
@@ -436,11 +443,12 @@ export function createRenderer(options: {|
436
443
  let body: DocumentBody;
437
444
  try {
438
445
  body = await renderDocument(<App url={url} initial={resolved} />, {
439
- shell: shellFor(assets, nonce),
446
+ shell: shellFor(assets, nonce, settings?.formState),
440
447
  onError,
441
448
  transformHead: settings?.transformHead,
442
449
  onStream,
443
450
  nonce,
451
+ formState: settings?.formState,
444
452
  });
445
453
  streaming = true;
446
454
  // Recovered before the shell was ready: a `<Suspense>` boundary whose
@@ -469,11 +477,12 @@ export function createRenderer(options: {|
469
477
  // where somebody can fix it.
470
478
  streaming = true;
471
479
  body = await renderDocument(<App url={url} initial={resolved} />, {
472
- shell: shellFor(assets, nonce),
480
+ shell: shellFor(assets, nonce, settings?.formState),
473
481
  onError,
474
482
  transformHead: settings?.transformHead,
475
483
  onStream,
476
484
  nonce,
485
+ formState: settings?.formState,
477
486
  });
478
487
  }
479
488