@uniflowed/router 0.1.0 → 0.3.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 +101 -24
- package/client.js +30 -9
- package/internal/action-endpoint.js +322 -14
- package/internal/action-wire.js +70 -8
- package/internal/compose.js +14 -4
- package/internal/deployment.js +3 -2
- package/internal/devtools.js +1 -1
- package/internal/error-view.js +1 -1
- package/internal/flight-browser.js +86 -10
- package/internal/flight-chunks.js +7 -7
- package/internal/flight-rows.js +7 -2
- package/internal/flight-ssr.js +10 -3
- package/internal/flight.js +32 -0
- package/internal/form-action.js +243 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +11 -6
- package/internal/navigation-cache.js +1 -1
- package/internal/payload-rows.js +5 -4
- package/internal/payload.js +18 -12
- package/internal/prepare-document.js +6 -0
- package/internal/resolve.js +17 -11
- package/internal/routing.js +77 -12
- package/internal/runtime.js +114 -6
- package/internal/shell.js +9 -2
- package/internal/stream.js +41 -14
- package/middleware.js +140 -11
- package/package.json +6 -4
- package/rsc-client.js +3 -1
- package/rsc-ssr.js +19 -6
- package/rsc.js +23 -7
- package/server.js +13 -4
- package/testing.js +980 -0
|
@@ -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
|
|
@@ -95,6 +125,7 @@ import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
|
|
|
95
125
|
import {
|
|
96
126
|
ACTION_CONTENT_TYPE,
|
|
97
127
|
ACTION_HEADER,
|
|
128
|
+
ACTION_OUTCOME_HEADER,
|
|
98
129
|
type ActionArgument,
|
|
99
130
|
ActionValueError,
|
|
100
131
|
MAX_ACTION_BODY_BYTES,
|
|
@@ -102,7 +133,30 @@ import {
|
|
|
102
133
|
encodeActionResult,
|
|
103
134
|
isActionId,
|
|
104
135
|
} from "./action-wire.js";
|
|
136
|
+
import { addressOf } from "./base-path.js";
|
|
137
|
+
import {
|
|
138
|
+
FORM_ACTION_CONTENT_TYPE,
|
|
139
|
+
type FormPost,
|
|
140
|
+
type FormState,
|
|
141
|
+
readFormPost,
|
|
142
|
+
} from "./form-action.js";
|
|
105
143
|
import { requireRequest } from "./request.js";
|
|
144
|
+
import { ForbiddenError, NotFoundError, RedirectError, UnauthorizedError } from "./routing.js";
|
|
145
|
+
|
|
146
|
+
export type { FormState } from "./form-action.js";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What a host gives the dispatcher beyond the request.
|
|
150
|
+
*
|
|
151
|
+
* `postback` renders the page the request is for, with a `useActionState`'s
|
|
152
|
+
* result as React's `formState`, and is how a form posted before hydration
|
|
153
|
+
* gets the page back with its answer in it. A host that supplies none still
|
|
154
|
+
* serves native posts: the answer is a `303` back to the page, which runs the
|
|
155
|
+
* action and loses only the state.
|
|
156
|
+
*/
|
|
157
|
+
export type ActionDispatchOptions = {|
|
|
158
|
+
readonly postback?: (formState: FormState) => Promise<Response>,
|
|
159
|
+
|};
|
|
106
160
|
|
|
107
161
|
/** A module holding server actions, as the generated table loads it. */
|
|
108
162
|
export type ActionModule = { readonly [name: string]: mixed };
|
|
@@ -146,10 +200,13 @@ const ANSWER_HEADERS: { readonly [string]: string } = {
|
|
|
146
200
|
*/
|
|
147
201
|
export function createActionDispatcher(options: {|
|
|
148
202
|
readonly actions: $ReadOnlyArray<ActionRecord>,
|
|
149
|
-
|}): (request: Request) => Promise<Response | null> {
|
|
203
|
+
|}): (request: Request, settings?: ActionDispatchOptions) => Promise<Response | null> {
|
|
150
204
|
const table = options.actions;
|
|
151
205
|
|
|
152
|
-
return async function callAction(
|
|
206
|
+
return async function callAction(
|
|
207
|
+
request: Request,
|
|
208
|
+
settings?: ActionDispatchOptions,
|
|
209
|
+
): Promise<Response | null> {
|
|
153
210
|
// The host's half of the contract, checked rather than assumed, exactly as
|
|
154
211
|
// `dispatch` and `runMiddleware` check it: an action that calls `cookies()`
|
|
155
212
|
// has to answer about the request it is inside.
|
|
@@ -157,7 +214,9 @@ export function createActionDispatcher(options: {|
|
|
|
157
214
|
|
|
158
215
|
const id = request.headers.get(ACTION_HEADER);
|
|
159
216
|
if (id == null) {
|
|
160
|
-
|
|
217
|
+
// Not a JSON call. It may still be a form posted before its page
|
|
218
|
+
// hydrated, which is the second door in the header.
|
|
219
|
+
return nativeFormPost(table, request, settings?.postback);
|
|
161
220
|
}
|
|
162
221
|
|
|
163
222
|
// Cheapest and most protective first, and all of it before a byte of the
|
|
@@ -236,6 +295,13 @@ export function createActionDispatcher(options: {|
|
|
|
236
295
|
const call = action as $FlowFixMe;
|
|
237
296
|
result = await traceRequestPhase("action", () => call(...args));
|
|
238
297
|
} catch (error) {
|
|
298
|
+
// `redirect()` and its three siblings are where the visitor goes next,
|
|
299
|
+
// not something that went wrong: answered as themselves, and never
|
|
300
|
+
// reported. See `ACTION_OUTCOME_HEADER`.
|
|
301
|
+
const outcome = routingOutcome(error);
|
|
302
|
+
if (outcome != null) {
|
|
303
|
+
return outcome;
|
|
304
|
+
}
|
|
239
305
|
report(record, error);
|
|
240
306
|
return refusal(500);
|
|
241
307
|
}
|
|
@@ -256,6 +322,243 @@ export function createActionDispatcher(options: {|
|
|
|
256
322
|
};
|
|
257
323
|
}
|
|
258
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Answer a form the browser posted natively, or decline with `null`.
|
|
327
|
+
*
|
|
328
|
+
* The header's second door. Declines everything that is not a urlencoded
|
|
329
|
+
* `POST` carrying a `$uf_ref_` field, and answers everything that is,
|
|
330
|
+
* refusals included — the same promise the JSON door makes, for the same
|
|
331
|
+
* reason.
|
|
332
|
+
*/
|
|
333
|
+
async function nativeFormPost(
|
|
334
|
+
table: $ReadOnlyArray<ActionRecord>,
|
|
335
|
+
request: Request,
|
|
336
|
+
postback: ?(formState: FormState) => Promise<Response>,
|
|
337
|
+
): Promise<Response | null> {
|
|
338
|
+
if (request.method.toUpperCase() !== "POST") {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
if (!isMedia(request.headers.get("content-type"), FORM_ACTION_CONTENT_TYPE)) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
// A copy, so that a form which turns out not to be an action post reaches the
|
|
345
|
+
// route handler with its body unread.
|
|
346
|
+
const text = await readBoundedText(request.clone(), MAX_ACTION_BODY_BYTES);
|
|
347
|
+
if (text == null) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const post = readFormPost(new URLSearchParams(text));
|
|
351
|
+
if (post == null) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// An action post from here on, and every outcome is an answer.
|
|
356
|
+
if (!sameOrigin(request) || !sameSiteFetch(request)) {
|
|
357
|
+
return refusal(403);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let args: Array<ActionArgument>;
|
|
361
|
+
let bound: number;
|
|
362
|
+
try {
|
|
363
|
+
({ args, bound } = formArguments(post));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!(error instanceof ActionValueError)) {
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
return refusal(400);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const id = post.id;
|
|
372
|
+
const record = id != null && isActionId(id) ? select(table, id) : null;
|
|
373
|
+
if (record == null) {
|
|
374
|
+
return refusal(404);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let action: mixed;
|
|
378
|
+
try {
|
|
379
|
+
const module = await record.load();
|
|
380
|
+
action = module[record.export];
|
|
381
|
+
} catch (error) {
|
|
382
|
+
report(record, error);
|
|
383
|
+
return refusal(500);
|
|
384
|
+
}
|
|
385
|
+
if (typeof action !== "function") {
|
|
386
|
+
report(record, new Error(`export \`${record.export}\` is not a function`));
|
|
387
|
+
return refusal(500);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const url = new URL(request.url);
|
|
391
|
+
const stateKey = post.stateKey;
|
|
392
|
+
return asResponder("a server action", async () => {
|
|
393
|
+
let result: mixed;
|
|
394
|
+
try {
|
|
395
|
+
const call = action as $FlowFixMe;
|
|
396
|
+
result = await traceRequestPhase("action", () => call(...args));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error instanceof RedirectError) {
|
|
399
|
+
return seeOther(addressOf(error.to));
|
|
400
|
+
}
|
|
401
|
+
// A person is looking at this answer and there is no page to render the
|
|
402
|
+
// boundary into, so it is the status with a fixed line of text — the same
|
|
403
|
+
// three statuses the JSON door answers, and nothing of the exception.
|
|
404
|
+
const status = routingStatus(error);
|
|
405
|
+
if (status != null) {
|
|
406
|
+
return new Response(`${status.text}\n`, {
|
|
407
|
+
status: status.code,
|
|
408
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
report(record, error);
|
|
412
|
+
return refusal(500);
|
|
413
|
+
}
|
|
414
|
+
if (stateKey == null || postback == null) {
|
|
415
|
+
// Post/redirect/get: the page again, by a `GET`, so a reload does not
|
|
416
|
+
// submit the form a second time.
|
|
417
|
+
return seeOther(addressOf(withOneLeadingSlash(url.pathname) + url.search));
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
// Held to the grammar for the reason the JSON door holds a result to it:
|
|
421
|
+
// this value is written into a document a browser parses.
|
|
422
|
+
encodeActionResult(result);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
report(record, error);
|
|
425
|
+
return refusal(500);
|
|
426
|
+
}
|
|
427
|
+
// `bound - 1`: `useActionState` bound the previous state itself, and
|
|
428
|
+
// React compares the count of the bindings the *action* had.
|
|
429
|
+
return postback([result, stateKey, record.id, bound - 1]);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The arguments a native post calls its action with: the bound ones, then
|
|
435
|
+
* the form.
|
|
436
|
+
*
|
|
437
|
+
* Built into the JSON call's own envelope and decoded by the JSON call's own
|
|
438
|
+
* decoder, so there is one grammar and one set of limits, not a second one for
|
|
439
|
+
* forms that could drift from the first.
|
|
440
|
+
*/
|
|
441
|
+
function formArguments(post: FormPost): {|
|
|
442
|
+
readonly args: Array<ActionArgument>,
|
|
443
|
+
readonly bound: number,
|
|
444
|
+
|} {
|
|
445
|
+
let bound: Array<ActionArgument> = [];
|
|
446
|
+
if (post.bound != null) {
|
|
447
|
+
bound = decodeActionArguments(post.bound);
|
|
448
|
+
if (bound.some((value) => value instanceof FormData)) {
|
|
449
|
+
throw new ActionValueError("the bound arguments", "carry a form");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const envelope = JSON.stringify({
|
|
453
|
+
args: [...bound, null],
|
|
454
|
+
form: { at: bound.length, entries: post.entries },
|
|
455
|
+
});
|
|
456
|
+
return { args: decodeActionArguments(envelope), bound: bound.length };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Whether the browser says the request came from this origin, when it says.
|
|
461
|
+
*
|
|
462
|
+
* `Sec-Fetch-Site` is a forbidden header name, so a page cannot set it; a
|
|
463
|
+
* browser that sends it and says anything but `same-origin` is describing a
|
|
464
|
+
* cross-site form. Absent is not a refusal on its own — older browsers do not
|
|
465
|
+
* send it, and `Origin` has already been required.
|
|
466
|
+
*/
|
|
467
|
+
function sameSiteFetch(request: Request): boolean {
|
|
468
|
+
const site = request.headers.get("sec-fetch-site");
|
|
469
|
+
return site == null || site === "same-origin";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The kind, status and text of a routing error other than a redirect. */
|
|
473
|
+
type RoutingStatus = {|
|
|
474
|
+
readonly kind: "not-found" | "unauthorized" | "forbidden",
|
|
475
|
+
readonly code: 404 | 401 | 403,
|
|
476
|
+
readonly text: string,
|
|
477
|
+
|};
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* What `notFound()`, `unauthorized()` or `forbidden()` answers with, or `null`
|
|
481
|
+
* for anything else.
|
|
482
|
+
*
|
|
483
|
+
* The statuses are the ones a page gets for the same call (`routeErrorStatus`
|
|
484
|
+
* and the not-found page), so a client that reads only the status reads the
|
|
485
|
+
* same thing a document request would have told it.
|
|
486
|
+
*/
|
|
487
|
+
function routingStatus(error: mixed): RoutingStatus | null {
|
|
488
|
+
if (error instanceof NotFoundError) {
|
|
489
|
+
return { kind: "not-found", code: 404, text: "not found" };
|
|
490
|
+
}
|
|
491
|
+
if (error instanceof UnauthorizedError) {
|
|
492
|
+
return { kind: "unauthorized", code: 401, text: "unauthorized" };
|
|
493
|
+
}
|
|
494
|
+
if (error instanceof ForbiddenError) {
|
|
495
|
+
return { kind: "forbidden", code: 403, text: "forbidden" };
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The JSON door's answer for a routing error, or `null` for any other throw.
|
|
502
|
+
*
|
|
503
|
+
* A redirect is a `204` carrying `Location`, the address `addressOf` makes of
|
|
504
|
+
* it (under the base path for a path on this application). It is not a `3xx`,
|
|
505
|
+
* because `fetch` would follow that itself and fetch the page's HTML for
|
|
506
|
+
* nothing, and the reference could not read where it pointed. The other three
|
|
507
|
+
* are their page statuses. Each one carries `ACTION_OUTCOME_HEADER`, so a `404`
|
|
508
|
+
* from `notFound()` is not mistaken for the `404` of an unknown id. The body
|
|
509
|
+
* names the kind and nothing else.
|
|
510
|
+
*/
|
|
511
|
+
function routingOutcome(error: mixed): Response | null {
|
|
512
|
+
if (error instanceof RedirectError) {
|
|
513
|
+
const headers: { [string]: string } = {
|
|
514
|
+
"cache-control": "no-store",
|
|
515
|
+
location: addressOf(error.to),
|
|
516
|
+
};
|
|
517
|
+
headers[ACTION_OUTCOME_HEADER] = "redirect";
|
|
518
|
+
return new Response(null, { status: 204, headers });
|
|
519
|
+
}
|
|
520
|
+
const status = routingStatus(error);
|
|
521
|
+
if (status == null) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
const headers: { [string]: string } = { ...ANSWER_HEADERS };
|
|
525
|
+
headers[ACTION_OUTCOME_HEADER] = status.kind;
|
|
526
|
+
return new Response(JSON.stringify({ outcome: status.kind }), {
|
|
527
|
+
status: status.code,
|
|
528
|
+
headers,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* `path` with a leading run of `/` counted down to one.
|
|
534
|
+
*
|
|
535
|
+
* The post/redirect/get above answers with the path the form was posted to,
|
|
536
|
+
* and that path is the request's: `https://app.example//evil.example/notes` is
|
|
537
|
+
* a link anybody can write, its page posts its forms back to the same address,
|
|
538
|
+
* and a URL parser reads `/\evil.example` as the same two slashes. Written
|
|
539
|
+
* into `Location` as it stands, `//evil.example/notes` is a network-path
|
|
540
|
+
* reference and the browser follows it to another host — after the person
|
|
541
|
+
* submitted a form on this one. `@uniflowed/server` makes the same repair to
|
|
542
|
+
* the trailing-slash redirect (`withOneLeadingSlash` in its `routing.js`); the
|
|
543
|
+
* router cannot import it, so it is spelled here as a counted loop, never a
|
|
544
|
+
* pattern (`docs/security.md`, rule 5).
|
|
545
|
+
*/
|
|
546
|
+
function withOneLeadingSlash(path: string): string {
|
|
547
|
+
let start = 0;
|
|
548
|
+
while (start + 1 < path.length && path.charCodeAt(start + 1) === 47) {
|
|
549
|
+
start += 1;
|
|
550
|
+
}
|
|
551
|
+
return path.charCodeAt(0) === 47 ? path.slice(start) : path;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** A `303 See Other`, which a browser follows with a `GET`. */
|
|
555
|
+
function seeOther(location: string): Response {
|
|
556
|
+
return new Response(null, {
|
|
557
|
+
status: 303,
|
|
558
|
+
headers: { location, "cache-control": "no-store" },
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
259
562
|
/**
|
|
260
563
|
* The row with this id, or `null`, without saying which by taking longer.
|
|
261
564
|
*
|
|
@@ -331,12 +634,17 @@ function sameOrigin(request: Request): boolean {
|
|
|
331
634
|
* can.
|
|
332
635
|
*/
|
|
333
636
|
function isJson(declared: string | null): boolean {
|
|
637
|
+
return isMedia(declared, ACTION_CONTENT_TYPE);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Whether `declared` is exactly the media type `expected`, parameters aside. */
|
|
641
|
+
function isMedia(declared: string | null, expected: string): boolean {
|
|
334
642
|
if (declared == null) {
|
|
335
643
|
return false;
|
|
336
644
|
}
|
|
337
645
|
const semicolon = declared.indexOf(";");
|
|
338
646
|
const media = (semicolon === -1 ? declared : declared.slice(0, semicolon)).trim().toLowerCase();
|
|
339
|
-
return media ===
|
|
647
|
+
return media === expected;
|
|
340
648
|
}
|
|
341
649
|
|
|
342
650
|
/**
|
package/internal/action-wire.js
CHANGED
|
@@ -93,6 +93,24 @@
|
|
|
93
93
|
// Pure: no imports, no platform APIs beyond `JSON`, so the browser half of
|
|
94
94
|
// `@uniflowed/router` can reach it without reaching anything server-only.
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The response header that says a server action ended in a routing outcome
|
|
98
|
+
* rather than a result: `redirect`, `not-found`, `unauthorized` or
|
|
99
|
+
* `forbidden`.
|
|
100
|
+
*
|
|
101
|
+
* `redirect()`, `notFound()`, `unauthorized()` and `forbidden()` are how a
|
|
102
|
+
* page's code says where the visitor goes next, and an action is page code:
|
|
103
|
+
* `redirect("/notes")` at the end of a mutation is the common way to finish
|
|
104
|
+
* one. They are control flow, not failures, so the endpoint does not report
|
|
105
|
+
* them or answer `500`. It answers with this header, the matching status and,
|
|
106
|
+
* for a redirect, `Location`. The reference in the browser reads the header
|
|
107
|
+
* and navigates or throws the same error for the route's boundary. Only the
|
|
108
|
+
* kind crosses, plus the address for a redirect. The exception's message and
|
|
109
|
+
* stack never do, which is the fixed-`500` rule applied to the one failure
|
|
110
|
+
* that is not a failure.
|
|
111
|
+
*/
|
|
112
|
+
export const ACTION_OUTCOME_HEADER: string = "uf-action-outcome";
|
|
113
|
+
|
|
96
114
|
/** The request header carrying the action id. */
|
|
97
115
|
export const ACTION_HEADER: string = "uf-action";
|
|
98
116
|
|
|
@@ -248,6 +266,21 @@ function asFormData(value: mixed): FormData | null {
|
|
|
248
266
|
/** One entry of the explicit walk stack. */
|
|
249
267
|
type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
|
|
250
268
|
|
|
269
|
+
/**
|
|
270
|
+
* How many more values a walk may visit before `MAX_ACTION_VALUES` is spent.
|
|
271
|
+
*
|
|
272
|
+
* An object rather than a number so that one budget can be handed to the walk
|
|
273
|
+
* of every argument of a call: the ceiling `docs/security.md` states is on the
|
|
274
|
+
* *call* — "at most 10,000 values in total" — and a budget per argument would
|
|
275
|
+
* be `MAX_ACTION_ARGUMENTS` times that.
|
|
276
|
+
*/
|
|
277
|
+
export type ValueBudget = { remaining: number };
|
|
278
|
+
|
|
279
|
+
/** A fresh budget of `MAX_ACTION_VALUES`, for one call or one result. */
|
|
280
|
+
export function valueBudget(): ValueBudget {
|
|
281
|
+
return { remaining: MAX_ACTION_VALUES };
|
|
282
|
+
}
|
|
283
|
+
|
|
251
284
|
/**
|
|
252
285
|
* Refuse a value that cannot cross the wire, naming where it was.
|
|
253
286
|
*
|
|
@@ -257,11 +290,27 @@ type Pending = {| readonly value: mixed, readonly path: string, readonly depth:
|
|
|
257
290
|
* hand on the depth. Every bound is checked as the walk runs rather than
|
|
258
291
|
* afterwards, so a payload that busts one is refused before the rest of it is
|
|
259
292
|
* visited.
|
|
293
|
+
*
|
|
294
|
+
* That includes the count, which is checked *before* an array's or an
|
|
295
|
+
* object's members are queued rather than as each is visited. One array of
|
|
296
|
+
* half a million zeros fits in a 1 MiB body, and queueing every element — each
|
|
297
|
+
* with a path string of its own — before the counter reached any of them
|
|
298
|
+
* spent tens of megabytes of heap on a payload that was always going to be
|
|
299
|
+
* refused.
|
|
300
|
+
*
|
|
301
|
+
* `budget` is spent by this walk and may be shared: `decodeActionArguments`
|
|
302
|
+
* and `encodeActionArguments` hand every argument the same one, so the ceiling
|
|
303
|
+
* is on the call. Without one, the value has a budget of its own.
|
|
260
304
|
*/
|
|
261
|
-
export function checkActionValue(
|
|
305
|
+
export function checkActionValue(
|
|
306
|
+
root: mixed,
|
|
307
|
+
label: string,
|
|
308
|
+
budget?: ValueBudget = valueBudget(),
|
|
309
|
+
): void {
|
|
262
310
|
const stack: Array<Pending> = [{ value: root, path: label, depth: 0 }];
|
|
263
311
|
const seen = new Set<mixed>();
|
|
264
|
-
|
|
312
|
+
const overBudget = () =>
|
|
313
|
+
new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
|
|
265
314
|
|
|
266
315
|
while (stack.length > 0) {
|
|
267
316
|
const pending = stack.pop();
|
|
@@ -270,9 +319,9 @@ export function checkActionValue(root: mixed, label: string): void {
|
|
|
270
319
|
}
|
|
271
320
|
const { value, path, depth } = pending;
|
|
272
321
|
|
|
273
|
-
remaining -= 1;
|
|
274
|
-
if (remaining < 0) {
|
|
275
|
-
throw
|
|
322
|
+
budget.remaining -= 1;
|
|
323
|
+
if (budget.remaining < 0) {
|
|
324
|
+
throw overBudget();
|
|
276
325
|
}
|
|
277
326
|
if (depth > MAX_ACTION_DEPTH) {
|
|
278
327
|
throw new ActionValueError(path, `is nested deeper than ${String(MAX_ACTION_DEPTH)}`);
|
|
@@ -309,6 +358,11 @@ export function checkActionValue(root: mixed, label: string): void {
|
|
|
309
358
|
|
|
310
359
|
if (Array.isArray(object)) {
|
|
311
360
|
const items: $ReadOnlyArray<mixed> = object as $FlowFixMe;
|
|
361
|
+
// Every queued value will be visited and spent, so a queue longer than
|
|
362
|
+
// what is left is a refusal now rather than after the push.
|
|
363
|
+
if (stack.length + items.length > budget.remaining) {
|
|
364
|
+
throw overBudget();
|
|
365
|
+
}
|
|
312
366
|
for (let index = 0; index < items.length; index += 1) {
|
|
313
367
|
stack.push({ value: items[index], path: `${path}[${String(index)}]`, depth: depth + 1 });
|
|
314
368
|
}
|
|
@@ -342,7 +396,11 @@ export function checkActionValue(root: mixed, label: string): void {
|
|
|
342
396
|
}
|
|
343
397
|
|
|
344
398
|
const record: { readonly [string]: mixed } = object as $FlowFixMe;
|
|
345
|
-
|
|
399
|
+
const keys = Object.getOwnPropertyNames(object);
|
|
400
|
+
if (stack.length + keys.length > budget.remaining) {
|
|
401
|
+
throw overBudget();
|
|
402
|
+
}
|
|
403
|
+
for (const key of keys) {
|
|
346
404
|
if (FORBIDDEN_KEYS.includes(key)) {
|
|
347
405
|
throw new ActionValueError(`${path}.${key}`, "is a key this grammar never carries");
|
|
348
406
|
}
|
|
@@ -407,6 +465,8 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
|
407
465
|
);
|
|
408
466
|
}
|
|
409
467
|
const values: Array<mixed> = [];
|
|
468
|
+
// One budget for the whole call; see `ValueBudget`.
|
|
469
|
+
const budget = valueBudget();
|
|
410
470
|
let form: {| readonly at: number, readonly entries: Array<Array<string>> |} | null = null;
|
|
411
471
|
for (let index = 0; index < args.length; index += 1) {
|
|
412
472
|
const argument = args[index];
|
|
@@ -420,7 +480,7 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
|
420
480
|
values.push(null);
|
|
421
481
|
continue;
|
|
422
482
|
}
|
|
423
|
-
checkActionValue(argument, label);
|
|
483
|
+
checkActionValue(argument, label, budget);
|
|
424
484
|
values.push(argument);
|
|
425
485
|
}
|
|
426
486
|
return form == null ? JSON.stringify({ args: values }) : JSON.stringify({ args: values, form });
|
|
@@ -460,8 +520,10 @@ export function decodeActionArguments(text: string): Array<ActionArgument> {
|
|
|
460
520
|
`passes more than ${String(MAX_ACTION_ARGUMENTS)} arguments`,
|
|
461
521
|
);
|
|
462
522
|
}
|
|
523
|
+
// One budget for the whole call; see `ValueBudget`.
|
|
524
|
+
const budget = valueBudget();
|
|
463
525
|
for (let index = 0; index < args.length; index += 1) {
|
|
464
|
-
checkActionValue(args[index], `argument ${String(index + 1)}
|
|
526
|
+
checkActionValue(args[index], `argument ${String(index + 1)}`, budget);
|
|
465
527
|
}
|
|
466
528
|
const decoded: Array<ActionArgument> = args as $FlowFixMe;
|
|
467
529
|
if (keys.length === 2) {
|
package/internal/compose.js
CHANGED
|
@@ -58,7 +58,7 @@ export type ComposeOptions = {|
|
|
|
58
58
|
|};
|
|
59
59
|
|
|
60
60
|
/** The route a slot renders inside, as much of it as a slot reads. */
|
|
61
|
-
type SlotRoute = {
|
|
61
|
+
type SlotRoute = { readonly pathname: string, readonly searchParams: SearchParams, ... };
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* Whether this bundle marks the boundaries it renders.
|
|
@@ -356,11 +356,11 @@ export function slotsAt(
|
|
|
356
356
|
slots: $ReadOnlyArray<ResolvedSlot>,
|
|
357
357
|
depth: number,
|
|
358
358
|
route: SlotRoute,
|
|
359
|
-
):
|
|
359
|
+
): SlotProps {
|
|
360
360
|
if (slots.length === 0) {
|
|
361
361
|
return EMPTY_SLOTS;
|
|
362
362
|
}
|
|
363
|
-
const props: { [string]: React.Node } = {};
|
|
363
|
+
const props: { key?: empty, [string]: React.Node } = {};
|
|
364
364
|
for (const slot of slots) {
|
|
365
365
|
if (slot.above === depth) {
|
|
366
366
|
// `null` rather than an element that renders nothing, and the difference
|
|
@@ -376,8 +376,18 @@ export function slotsAt(
|
|
|
376
376
|
return props;
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
/**
|
|
380
|
+
* A layout's slots, as the props they are spread into.
|
|
381
|
+
*
|
|
382
|
+
* `key` is named out of the indexer, the way `@uniflowed/ui`'s `Rest` names it:
|
|
383
|
+
* the object is spread onto an element, and a `React.Node` is not a key. No
|
|
384
|
+
* slot is called `key` — a slot is a prop the layout declares, and React
|
|
385
|
+
* never hands a component its key.
|
|
386
|
+
*/
|
|
387
|
+
export type SlotProps = { readonly key?: empty, readonly [string]: React.Node };
|
|
388
|
+
|
|
379
389
|
/** One object for every layout on a project that declares no slot. */
|
|
380
|
-
const EMPTY_SLOTS:
|
|
390
|
+
const EMPTY_SLOTS: SlotProps = Object.freeze({});
|
|
381
391
|
|
|
382
392
|
/**
|
|
383
393
|
* One slot's tree: its page, inside the layouts declared under the slot, with
|
package/internal/deployment.js
CHANGED
|
@@ -52,8 +52,9 @@ let remembered: string | null | void;
|
|
|
52
52
|
|
|
53
53
|
/** The parts of a `Document` read here. */
|
|
54
54
|
type HeadLike = interface {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// Methods, as a `Document`'s are: a method cannot be read off as a property.
|
|
56
|
+
querySelector(selector: string): ?interface {
|
|
57
|
+
getAttribute(name: string): ?string,
|
|
57
58
|
},
|
|
58
59
|
};
|
|
59
60
|
|
package/internal/devtools.js
CHANGED
|
@@ -92,7 +92,7 @@ export function devtoolsProblem(win: HookWindow): {|
|
|
|
92
92
|
// handed back. Anything else there is a hook uf did not install and DevTools
|
|
93
93
|
// did not either, and guessing at its shape would report a problem that is
|
|
94
94
|
// really this module not recognising one.
|
|
95
|
-
const renderers = (hook
|
|
95
|
+
const renderers = (hook as $FlowFixMe).renderers;
|
|
96
96
|
const count = renderers instanceof Map ? renderers.size : null;
|
|
97
97
|
if (count != null && count > 1) {
|
|
98
98
|
return {
|
package/internal/error-view.js
CHANGED
|
@@ -34,7 +34,7 @@ component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
|
34
34
|
const detail = match (error) {
|
|
35
35
|
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
36
36
|
{kind: "forbidden"} => "You do not have access to this page.",
|
|
37
|
-
{kind: "thrown"} => "This page could not be rendered.",
|
|
37
|
+
{kind: "thrown", ...} => "This page could not be rendered.",
|
|
38
38
|
};
|
|
39
39
|
return (
|
|
40
40
|
<main>
|