@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 +43 -23
- package/client.js +3 -1
- package/internal/action-endpoint.js +222 -14
- package/internal/form-action.js +243 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/shell.js +9 -2
- package/internal/stream.js +12 -0
- package/middleware.js +120 -7
- package/package.json +3 -3
- package/rsc-client.js +3 -1
- package/rsc-ssr.js +5 -1
- package/server.js +11 -2
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
|
-
// **
|
|
70
|
-
// the form that submits before its JavaScript has arrived —
|
|
71
|
-
// `$$FORM_ACTION`
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// the
|
|
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
|
|
252
|
-
*
|
|
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
|
|
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`
|
|
59
|
-
// form's entries beside the values under a `form` key, bounded
|
|
60
|
-
// field-name length and holding strings only. Nothing about it
|
|
61
|
-
// and nothing about it is a content type a cross-origin `<form>`
|
|
62
|
-
// produce, so rule 4 is exactly as true of a form call as of any other.
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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(
|
|
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
|
-
|
|
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 ===
|
|
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(
|
|
83
|
-
|
|
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">`,
|
package/internal/stream.js
CHANGED
|
@@ -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
|
-
|
|
246
|
-
|
|
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
|
|
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.
|
|
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.
|
|
75
|
-
"@uniflowed/server": "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
|
|
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
|
|