@uniflowed/router 0.2.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 +58 -1
- package/client.js +27 -8
- package/internal/action-endpoint.js +102 -2
- 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/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/stream.js +29 -14
- package/middleware.js +20 -4
- package/package.json +6 -4
- package/rsc-ssr.js +14 -5
- package/rsc.js +23 -7
- package/server.js +2 -2
- package/testing.js +980 -0
package/action.js
CHANGED
|
@@ -109,6 +109,7 @@
|
|
|
109
109
|
import {
|
|
110
110
|
ACTION_CONTENT_TYPE,
|
|
111
111
|
ACTION_HEADER,
|
|
112
|
+
ACTION_OUTCOME_HEADER,
|
|
112
113
|
type ActionArgument,
|
|
113
114
|
type ActionValue,
|
|
114
115
|
decodeActionResult,
|
|
@@ -117,6 +118,7 @@ import {
|
|
|
117
118
|
import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
|
|
118
119
|
import { withFormAction } from "./internal/form-action.js";
|
|
119
120
|
import { clearNavigationCache } from "./internal/navigation-cache.js";
|
|
121
|
+
import { ForbiddenError, NotFoundError, UnauthorizedError } from "./internal/routing.js";
|
|
120
122
|
|
|
121
123
|
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
122
124
|
export {
|
|
@@ -277,6 +279,9 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
|
277
279
|
...args: Array<ActionArgument>
|
|
278
280
|
): Promise<ActionValue | void> {
|
|
279
281
|
const body = encodeActionArguments(args);
|
|
282
|
+
// The page the call is made from, kept for the answer: a relative redirect
|
|
283
|
+
// means relative to this page, whatever the visitor has done since.
|
|
284
|
+
const from = currentUrl();
|
|
280
285
|
// Built rather than written as a literal, because the header's name is a
|
|
281
286
|
// constant and a computed key in an object literal is a shape Flow
|
|
282
287
|
// declines to track.
|
|
@@ -285,7 +290,7 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
|
285
290
|
// Which build this page is, so a server on another build refuses the call
|
|
286
291
|
// rather than looking this id up in a table it was never in.
|
|
287
292
|
withDeployment(headers);
|
|
288
|
-
const response = await fetch(
|
|
293
|
+
const response = await fetch(from, {
|
|
289
294
|
method: "POST",
|
|
290
295
|
// Stated rather than left to the default, because the default is what a
|
|
291
296
|
// reader has to look up and because this one is load-bearing: the
|
|
@@ -313,6 +318,14 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
|
313
318
|
loadDocument(currentUrl());
|
|
314
319
|
return new Promise<ActionValue | void>(() => {});
|
|
315
320
|
}
|
|
321
|
+
// The action called `redirect()`, `notFound()`, `unauthorized()` or
|
|
322
|
+
// `forbidden()`. Checked before the status, because three of the four are
|
|
323
|
+
// not `ok` and none of them is a failure.
|
|
324
|
+
const outcome = response.headers.get(ACTION_OUTCOME_HEADER);
|
|
325
|
+
if (outcome != null) {
|
|
326
|
+
void response.body?.cancel();
|
|
327
|
+
return followOutcome(name, outcome, response, from);
|
|
328
|
+
}
|
|
316
329
|
if (!response.ok) {
|
|
317
330
|
throw new ServerActionError(name, response.status);
|
|
318
331
|
}
|
|
@@ -320,6 +333,50 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
|
320
333
|
};
|
|
321
334
|
}
|
|
322
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Do in the browser what the action's routing call asked for.
|
|
338
|
+
*
|
|
339
|
+
* - `redirect`: go to the answer's `Location` the way `router.push` would. The
|
|
340
|
+
* call then resolves with `undefined`, because the action returned nothing;
|
|
341
|
+
* it threw. It resolves only once the navigation has settled, so a
|
|
342
|
+
* `useActionState` on a page that is still on screen afterwards (a redirect
|
|
343
|
+
* to the same page) is not left pending.
|
|
344
|
+
* - `not-found`, `unauthorized`, `forbidden`: throw the same error the server
|
|
345
|
+
* caught. React hands an error thrown by an action to the nearest error
|
|
346
|
+
* boundary, and uf's route boundary already tells `unauthorized()` and
|
|
347
|
+
* `forbidden()` apart. `notFound()` arrives as a `NotFoundError`, which an
|
|
348
|
+
* `$error.js` can test for.
|
|
349
|
+
*
|
|
350
|
+
* Any other value is a server this reference does not understand, and it is
|
|
351
|
+
* the same `ServerActionError` as any other failure.
|
|
352
|
+
*
|
|
353
|
+
* The router is imported when a redirect happens, not at module scope. This
|
|
354
|
+
* module is also imported by the server graphs (`registerServerAction`), and
|
|
355
|
+
* the router's runtime is client-rendering code that has no place there.
|
|
356
|
+
*/
|
|
357
|
+
async function followOutcome(
|
|
358
|
+
name: string,
|
|
359
|
+
outcome: string,
|
|
360
|
+
response: Response,
|
|
361
|
+
from: string,
|
|
362
|
+
): Promise<ActionValue | void> {
|
|
363
|
+
switch (outcome) {
|
|
364
|
+
case "redirect": {
|
|
365
|
+
const { followActionRedirect } = await import("./internal/runtime.js");
|
|
366
|
+
await followActionRedirect(response.headers.get("location") ?? from, from);
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
case "not-found":
|
|
370
|
+
throw new NotFoundError();
|
|
371
|
+
case "unauthorized":
|
|
372
|
+
throw new UnauthorizedError();
|
|
373
|
+
case "forbidden":
|
|
374
|
+
throw new ForbiddenError();
|
|
375
|
+
default:
|
|
376
|
+
throw new ServerActionError(name, response.status);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
323
380
|
/**
|
|
324
381
|
* The URL an action call is sent to: the page the browser is on.
|
|
325
382
|
*
|
package/client.js
CHANGED
|
@@ -122,12 +122,26 @@ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/bas
|
|
|
122
122
|
import { hydrationOptions } from "./internal/hydrate-options.js";
|
|
123
123
|
import { readFormState } from "./internal/form-action.js";
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The React root `hydrate` and `render` mounted, as far as a caller needs it:
|
|
127
|
+
* a way to take the application down again. See `hydrate` for who needs that.
|
|
128
|
+
*/
|
|
129
|
+
export type ApplicationRoot = { readonly unmount: () => void, ... };
|
|
130
|
+
|
|
125
131
|
/**
|
|
126
132
|
* Hydrate the current document.
|
|
127
133
|
*
|
|
128
134
|
* Resolves without mounting anything when the current route ships no client
|
|
129
135
|
* page — see the header. The promise settling is not a claim that React is on
|
|
130
136
|
* the document.
|
|
137
|
+
*
|
|
138
|
+
* Resolves with the React root it created, or `null` when it mounted nothing.
|
|
139
|
+
* A page never needs it: its root lives as long as the document. It is for
|
|
140
|
+
* whoever has to take the application down again — a test that hydrates one
|
|
141
|
+
* page and then another in the same process, where an application left
|
|
142
|
+
* mounted keeps its router, its `popstate` listener and its effects running
|
|
143
|
+
* under the next one. `root.unmount()` runs every cleanup, including the one
|
|
144
|
+
* that stops a server action from navigating a router that is gone.
|
|
131
145
|
*/
|
|
132
146
|
export async function hydrate(options: {|
|
|
133
147
|
readonly App: React.ComponentType<AppProps>,
|
|
@@ -139,7 +153,7 @@ export async function hydrate(options: {|
|
|
|
139
153
|
readonly basePath?: string,
|
|
140
154
|
readonly trailingSlash?: TrailingSlash,
|
|
141
155
|
readonly staleTime?: number,
|
|
142
|
-
|}): Promise<
|
|
156
|
+
|}): Promise<ApplicationRoot | null> {
|
|
143
157
|
const table: RouteTable = {
|
|
144
158
|
routes: options.routes,
|
|
145
159
|
notFound: options.notFound,
|
|
@@ -160,7 +174,7 @@ export async function hydrate(options: {|
|
|
|
160
174
|
// would go looking for a page module that is not in this bundle.
|
|
161
175
|
const matched = matchRoute(table.routes, applicationPath);
|
|
162
176
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
163
|
-
return;
|
|
177
|
+
return null;
|
|
164
178
|
}
|
|
165
179
|
|
|
166
180
|
const url = applicationPath + window.location.search;
|
|
@@ -213,8 +227,9 @@ export async function hydrate(options: {|
|
|
|
213
227
|
// be a development-only difference without being a hydration difference.
|
|
214
228
|
const tree = <App url={url} initial={resolved} />;
|
|
215
229
|
|
|
230
|
+
let root: ApplicationRoot | null = null;
|
|
216
231
|
startTransition(() => {
|
|
217
|
-
hydrateRoot(
|
|
232
|
+
root = hydrateRoot(
|
|
218
233
|
container,
|
|
219
234
|
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
220
235
|
hydrationOptions(recovery, readFormState(document)),
|
|
@@ -235,6 +250,7 @@ export async function hydrate(options: {|
|
|
|
235
250
|
const { reportDevtools } = await import("./internal/devtools.js");
|
|
236
251
|
reportDevtools(window);
|
|
237
252
|
}
|
|
253
|
+
return root;
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
/**
|
|
@@ -265,6 +281,9 @@ export async function hydrate(options: {|
|
|
|
265
281
|
* * **A redirect is the browser's.** `redirect()` from a loader throws before
|
|
266
282
|
* anything is rendered; on a server that becomes a 307 and here it becomes
|
|
267
283
|
* `location.replace`, which is the same instruction to the same browser.
|
|
284
|
+
*
|
|
285
|
+
* Resolves with the React root, or `null` when a loader redirected and nothing
|
|
286
|
+
* was mounted — for the reason `hydrate` gives.
|
|
268
287
|
*/
|
|
269
288
|
export async function render(options: {|
|
|
270
289
|
readonly App: React.ComponentType<AppProps>,
|
|
@@ -276,7 +295,7 @@ export async function render(options: {|
|
|
|
276
295
|
readonly basePath?: string,
|
|
277
296
|
readonly trailingSlash?: TrailingSlash,
|
|
278
297
|
readonly staleTime?: number,
|
|
279
|
-
|}): Promise<
|
|
298
|
+
|}): Promise<ApplicationRoot | null> {
|
|
280
299
|
const table: RouteTable = {
|
|
281
300
|
routes: options.routes,
|
|
282
301
|
notFound: options.notFound,
|
|
@@ -296,7 +315,7 @@ export async function render(options: {|
|
|
|
296
315
|
} catch (error) {
|
|
297
316
|
if (error instanceof RedirectError) {
|
|
298
317
|
window.location.replace(addressOf(error.to));
|
|
299
|
-
return;
|
|
318
|
+
return null;
|
|
300
319
|
}
|
|
301
320
|
// The error boundary, chosen the same way the server chooses it. A throw
|
|
302
321
|
// from a loader is a page that cannot render, and rendering the boundary is
|
|
@@ -321,7 +340,7 @@ export async function render(options: {|
|
|
|
321
340
|
);
|
|
322
341
|
}
|
|
323
342
|
const tree = <App url={url} initial={resolved} />;
|
|
324
|
-
createRoot(container)
|
|
325
|
-
|
|
326
|
-
|
|
343
|
+
const root = createRoot(container);
|
|
344
|
+
root.render(options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree);
|
|
345
|
+
return root;
|
|
327
346
|
}
|
|
@@ -125,6 +125,7 @@ import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
|
|
|
125
125
|
import {
|
|
126
126
|
ACTION_CONTENT_TYPE,
|
|
127
127
|
ACTION_HEADER,
|
|
128
|
+
ACTION_OUTCOME_HEADER,
|
|
128
129
|
type ActionArgument,
|
|
129
130
|
ActionValueError,
|
|
130
131
|
MAX_ACTION_BODY_BYTES,
|
|
@@ -140,7 +141,7 @@ import {
|
|
|
140
141
|
readFormPost,
|
|
141
142
|
} from "./form-action.js";
|
|
142
143
|
import { requireRequest } from "./request.js";
|
|
143
|
-
import { RedirectError } from "./routing.js";
|
|
144
|
+
import { ForbiddenError, NotFoundError, RedirectError, UnauthorizedError } from "./routing.js";
|
|
144
145
|
|
|
145
146
|
export type { FormState } from "./form-action.js";
|
|
146
147
|
|
|
@@ -294,6 +295,13 @@ export function createActionDispatcher(options: {|
|
|
|
294
295
|
const call = action as $FlowFixMe;
|
|
295
296
|
result = await traceRequestPhase("action", () => call(...args));
|
|
296
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
|
+
}
|
|
297
305
|
report(record, error);
|
|
298
306
|
return refusal(500);
|
|
299
307
|
}
|
|
@@ -390,13 +398,23 @@ async function nativeFormPost(
|
|
|
390
398
|
if (error instanceof RedirectError) {
|
|
391
399
|
return seeOther(addressOf(error.to));
|
|
392
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
|
+
}
|
|
393
411
|
report(record, error);
|
|
394
412
|
return refusal(500);
|
|
395
413
|
}
|
|
396
414
|
if (stateKey == null || postback == null) {
|
|
397
415
|
// Post/redirect/get: the page again, by a `GET`, so a reload does not
|
|
398
416
|
// submit the form a second time.
|
|
399
|
-
return seeOther(addressOf(url.pathname + url.search));
|
|
417
|
+
return seeOther(addressOf(withOneLeadingSlash(url.pathname) + url.search));
|
|
400
418
|
}
|
|
401
419
|
try {
|
|
402
420
|
// Held to the grammar for the reason the JSON door holds a result to it:
|
|
@@ -451,6 +469,88 @@ function sameSiteFetch(request: Request): boolean {
|
|
|
451
469
|
return site == null || site === "same-origin";
|
|
452
470
|
}
|
|
453
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
|
+
|
|
454
554
|
/** A `303 See Other`, which a browser follows with a `GET`. */
|
|
455
555
|
function seeOther(location: string): Response {
|
|
456
556
|
return new Response(null, {
|
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>
|