@uniflowed/router 0.0.0-alpha.14 → 0.0.0-alpha.16
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 +105 -8
- package/client.js +51 -2
- package/index.js +1 -0
- package/internal/action-endpoint.js +15 -3
- package/internal/action-wire.js +236 -13
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +3 -3
- package/internal/runtime.js +62 -0
- package/package.json +3 -3
package/action.js
CHANGED
|
@@ -32,6 +32,61 @@
|
|
|
32
32
|
// above the page — and it is not a substitute for the action authorizing
|
|
33
33
|
// itself; `internal/action-endpoint.js` says why in the paragraph that matters.
|
|
34
34
|
//
|
|
35
|
+
// # A form
|
|
36
|
+
//
|
|
37
|
+
// The same reference is what React 19's form APIs take, because a reference is
|
|
38
|
+
// an ordinary async function and that is all they ask for:
|
|
39
|
+
//
|
|
40
|
+
// // app/counter/_components/Note.js
|
|
41
|
+
// "use client";
|
|
42
|
+
// import { useActionState } from "@uniflowed/react";
|
|
43
|
+
// import { useFormStatus } from "react-dom";
|
|
44
|
+
// import { submitNote } from "../_actions/notes.js";
|
|
45
|
+
//
|
|
46
|
+
// component Save() {
|
|
47
|
+
// const { pending } = useFormStatus();
|
|
48
|
+
// return <button disabled={pending}>Save</button>;
|
|
49
|
+
// }
|
|
50
|
+
//
|
|
51
|
+
// component Note() {
|
|
52
|
+
// const [saved, save] = useActionState(submitNote, null);
|
|
53
|
+
// return (
|
|
54
|
+
// <form action={save}>
|
|
55
|
+
// <input name="note" />
|
|
56
|
+
// <Save />
|
|
57
|
+
// <output>{saved}</output>
|
|
58
|
+
// </form>
|
|
59
|
+
// );
|
|
60
|
+
// }
|
|
61
|
+
//
|
|
62
|
+
// `<form action={save}>` hands the reference a `FormData`, `useActionState`
|
|
63
|
+
// hands it the previous state and then the `FormData`, and both cross under
|
|
64
|
+
// the grammar in `internal/action-wire.js` — one form per call, beside the
|
|
65
|
+
// values rather than inside one, entries of strings and nothing else. Flow
|
|
66
|
+
// still reads `submitNote`'s own declaration, so a form action whose first
|
|
67
|
+
// parameter is not the state it was given `null` for is a `uf check` error.
|
|
68
|
+
//
|
|
69
|
+
// **This needs the page to be hydrated.** React's progressive enhancement —
|
|
70
|
+
// the form that submits before its JavaScript has arrived — works through a
|
|
71
|
+
// `$$FORM_ACTION` property that turns the submit into a *native* form post,
|
|
72
|
+
// and a native form post is `multipart/form-data`: the content type this
|
|
73
|
+
// endpoint refuses, deliberately, as one of the three things standing between
|
|
74
|
+
// it and a cross-site call. Supporting the pre-hydration submit would mean
|
|
75
|
+
// accepting that content type, so a reference carries no `$$FORM_ACTION`, and
|
|
76
|
+
// React writes the form it writes for any client action —
|
|
77
|
+
// `action="javascript:throw new Error('React form unexpectedly submitted.')"`
|
|
78
|
+
// — so a submit before hydration throws in the page rather than posting
|
|
79
|
+
// anywhere. Nothing reaches a server that was not meant to; what is missing is
|
|
80
|
+
// the submit working at all. See ubugeeei-prod/uf#252.
|
|
81
|
+
//
|
|
82
|
+
// The refusal is why the property is withheld, not what would stop it: no
|
|
83
|
+
// request is made, so nothing is refused. Nor would a native form post aimed
|
|
84
|
+
// at a page by hand be refused as multipart — `createActionDispatcher` reads
|
|
85
|
+
// the `uf-action` header before it looks at the method or the content type and
|
|
86
|
+
// returns `null` when it is absent, so the request is not an action call at
|
|
87
|
+
// all and falls through to the route handlers. The `415` answers a request
|
|
88
|
+
// that claims to be an action, which is the only kind that reaches it.
|
|
89
|
+
//
|
|
35
90
|
// # What Flow checks, and where
|
|
36
91
|
//
|
|
37
92
|
// Flow reads `app/_actions/clicks.js`, not the reference the bundler
|
|
@@ -51,12 +106,13 @@
|
|
|
51
106
|
import {
|
|
52
107
|
ACTION_CONTENT_TYPE,
|
|
53
108
|
ACTION_HEADER,
|
|
109
|
+
type ActionArgument,
|
|
54
110
|
type ActionValue,
|
|
55
111
|
decodeActionResult,
|
|
56
112
|
encodeActionArguments,
|
|
57
113
|
} from "./internal/action-wire.js";
|
|
58
114
|
|
|
59
|
-
export type { ActionValue } from "./internal/action-wire.js";
|
|
115
|
+
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
60
116
|
export {
|
|
61
117
|
ACTION_CONTENT_TYPE,
|
|
62
118
|
ACTION_HEADER,
|
|
@@ -65,17 +121,22 @@ export {
|
|
|
65
121
|
MAX_ACTION_BODY_BYTES,
|
|
66
122
|
MAX_ACTION_DEPTH,
|
|
67
123
|
MAX_ACTION_VALUES,
|
|
124
|
+
MAX_FORM_ENTRIES,
|
|
125
|
+
MAX_FORM_NAME_LENGTH,
|
|
68
126
|
} from "./internal/action-wire.js";
|
|
69
127
|
|
|
70
128
|
/**
|
|
71
129
|
* A function that can be a server action.
|
|
72
130
|
*
|
|
73
|
-
* Both halves of the signature are the wire grammar
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
131
|
+
* Both halves of the signature are the wire grammar, and they are not the same
|
|
132
|
+
* half: an action is called with values that can cross *or* the one form a
|
|
133
|
+
* submit produces, and answers with a value that can cross. `void` is a result
|
|
134
|
+
* and not an argument, because JSON has no `undefined` and an action declared
|
|
135
|
+
* to take one would be taking something the caller cannot send; a `FormData`
|
|
136
|
+
* is an argument and not a result, because a form is something a browser
|
|
137
|
+
* submits and not something a server answers with.
|
|
77
138
|
*/
|
|
78
|
-
export type ServerActionFunction = (...args: Array<
|
|
139
|
+
export type ServerActionFunction = (...args: Array<ActionArgument>) => Promise<ActionValue | void>;
|
|
79
140
|
|
|
80
141
|
/**
|
|
81
142
|
* An action's arguments, held against what a wire can carry.
|
|
@@ -95,8 +156,38 @@ export type ServerActionFunction = (...args: Array<ActionValue>) => Promise<Acti
|
|
|
95
156
|
*
|
|
96
157
|
* `ServerActionName` is every action's name, so `ServerActionArgs` of it is
|
|
97
158
|
* every action's argument list, and one line checks all of them.
|
|
159
|
+
*
|
|
160
|
+
* The bound is [`ActionArgument`] and not [`ActionValue`], which is the whole
|
|
161
|
+
* of what makes `<form action={fn}>` type-check: a form action's parameter is
|
|
162
|
+
* a `FormData`, and a `FormData` crosses as an argument and only as one.
|
|
163
|
+
*
|
|
164
|
+
* # What this does not catch, and why
|
|
165
|
+
*
|
|
166
|
+
* The bound is element-wise, so it holds each argument against the grammar and
|
|
167
|
+
* says nothing about the list as a whole. The wire has one rule that is about
|
|
168
|
+
* the list: a call carries at most one form, because the envelope names the
|
|
169
|
+
* form's position once. So `(a: FormData, b: FormData)` type-checks here and
|
|
170
|
+
* throws `ActionValueError` at `encodeActionArguments` — a rule enforced at
|
|
171
|
+
* run time that the types ought to have caught.
|
|
172
|
+
*
|
|
173
|
+
* Saying it in the type needs a walk over the tuple, and the walk is blocked by
|
|
174
|
+
* ubugeeei-prod/uf#300: a spread in a conditional type's tuple pattern binds
|
|
175
|
+
* `infer` as `unknown` and the rest as the whole array widened. The obvious
|
|
176
|
+
* recursion is not merely rejected, it quietly answers wrongly —
|
|
177
|
+
*
|
|
178
|
+
* type NoForm<T> = T extends [] ? true
|
|
179
|
+
* : T extends [infer H, ...infer R]
|
|
180
|
+
* ? (H extends FormData ? false : NoForm<R>) : true;
|
|
181
|
+
*
|
|
182
|
+
* — gives `true` for `[string, FormData]`, because the spread pattern never
|
|
183
|
+
* matches and every tuple falls through to the last branch. A constraint built
|
|
184
|
+
* on that would be worse than none: it would report every signature as fine.
|
|
185
|
+
*
|
|
186
|
+
* `tests/type-tests/server-actions.js` pins the gap, so that whoever fixes
|
|
187
|
+
* #300 is told this is waiting on it. The run-time guard and its test are in
|
|
188
|
+
* `internal/action-wire.js` and `tests/library/server-actions.test.js`.
|
|
98
189
|
*/
|
|
99
|
-
export type ActionArguments<TArgs extends $ReadOnlyArray<
|
|
190
|
+
export type ActionArguments<TArgs extends $ReadOnlyArray<ActionArgument>> = TArgs;
|
|
100
191
|
|
|
101
192
|
/**
|
|
102
193
|
* An action's result, held against the same grammar.
|
|
@@ -144,9 +235,15 @@ export class ServerActionError extends Error {
|
|
|
144
235
|
* The returned function is `async` and refuses before it sends: an argument
|
|
145
236
|
* outside the wire grammar throws an `ActionValueError` naming the argument's
|
|
146
237
|
* position, at the call site, rather than becoming a `400` with nothing in it.
|
|
238
|
+
*
|
|
239
|
+
* It carries no `$$FORM_ACTION`, which is deliberate and is the header's last
|
|
240
|
+
* section: React uses that property to make a form submit *natively* before
|
|
241
|
+
* hydration, and a native submit is a content type this endpoint refuses.
|
|
147
242
|
*/
|
|
148
243
|
export function createServerReference(id: string, name: string): ServerActionFunction {
|
|
149
|
-
return async function callServerAction(
|
|
244
|
+
return async function callServerAction(
|
|
245
|
+
...args: Array<ActionArgument>
|
|
246
|
+
): Promise<ActionValue | void> {
|
|
150
247
|
const body = encodeActionArguments(args);
|
|
151
248
|
// Built rather than written as a literal, because the header's name is a
|
|
152
249
|
// constant and a computed key in an object literal is a shape Flow
|
package/client.js
CHANGED
|
@@ -18,6 +18,37 @@
|
|
|
18
18
|
// Development only, and dynamically imported so a production bundle has no path
|
|
19
19
|
// to it. See ubugeeei-prod/uf#508.
|
|
20
20
|
//
|
|
21
|
+
// # And whether React DevTools can see the page at all
|
|
22
|
+
//
|
|
23
|
+
// The other question only this module is in a position to ask.
|
|
24
|
+
// `@uniflowed/vite` installs the hook DevTools attaches through, above every
|
|
25
|
+
// module in the document; whether that worked *on this page* is a fact about a
|
|
26
|
+
// running browser, and the line after hydration is where it can be read.
|
|
27
|
+
// `internal/devtools.js` has the two findings and sends them to the same
|
|
28
|
+
// terminal the hydration report goes to. See ubugeeei-prod/uf#503.
|
|
29
|
+
//
|
|
30
|
+
// # Strict Mode, in development, by default
|
|
31
|
+
//
|
|
32
|
+
// `uf dev` generates `strictMode: true` into `virtual:uf/client` and `uf build`
|
|
33
|
+
// does not, so a development render is doubled and a visitor's is not. That is
|
|
34
|
+
// React's own check for the thing it cannot check any other way: a component
|
|
35
|
+
// whose render is not pure, and an effect whose cleanup does not undo its
|
|
36
|
+
// setup, both behave correctly until the one production render that interleaves
|
|
37
|
+
// with something — and Strict Mode makes them behave incorrectly at once, on
|
|
38
|
+
// the machine of the person writing them.
|
|
39
|
+
//
|
|
40
|
+
// The wrapper is the argument to `hydrateRoot` rather than something inside
|
|
41
|
+
// `<App>`, and that is load-bearing rather than tidy. React decides whether to
|
|
42
|
+
// double-invoke a mount's effects at the *topmost fiber it is placing*: if that
|
|
43
|
+
// fiber is not itself in Strict Mode, React stops there and never looks inside
|
|
44
|
+
// it. A `<StrictMode>` further down still doubles the renders under it — that
|
|
45
|
+
// comes from the fiber's own mode — and doubles no effect at all, so it would
|
|
46
|
+
// have bought the half of the check that is easy to notice and silently lost
|
|
47
|
+
// the half that finds the bug. It renders no element, so the hydrated tree is
|
|
48
|
+
// unchanged and the markup comparison above is unaffected.
|
|
49
|
+
// `app.react.strictMode: false` in `uf.config.js` turns it off. See
|
|
50
|
+
// ubugeeei-prod/uf#516.
|
|
51
|
+
//
|
|
21
52
|
// # A route can decline to be hydrated
|
|
22
53
|
//
|
|
23
54
|
// uf's server-component analysis decides which routes have a `"use client"`
|
|
@@ -29,7 +60,7 @@
|
|
|
29
60
|
// See ubugeeei-prod/uf#350.
|
|
30
61
|
|
|
31
62
|
import * as React from "react";
|
|
32
|
-
import { startTransition } from "react";
|
|
63
|
+
import { StrictMode, startTransition } from "react";
|
|
33
64
|
import { hydrateRoot } from "react-dom/client";
|
|
34
65
|
|
|
35
66
|
import {
|
|
@@ -54,6 +85,7 @@ export async function hydrate(options: {|
|
|
|
54
85
|
readonly routes: RouteTable["routes"],
|
|
55
86
|
readonly notFound: RouteTable["notFound"],
|
|
56
87
|
readonly errors: RouteTable["errors"],
|
|
88
|
+
readonly strictMode?: boolean,
|
|
57
89
|
|}): Promise<void> {
|
|
58
90
|
const table: RouteTable = {
|
|
59
91
|
routes: options.routes,
|
|
@@ -95,11 +127,28 @@ export async function hydrate(options: {|
|
|
|
95
127
|
recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
// `<StrictMode>` renders no element of its own, so the tree React hydrates
|
|
131
|
+
// against the server's markup is the same tree either way and the flag can
|
|
132
|
+
// be a development-only difference without being a hydration difference.
|
|
133
|
+
const tree = <App url={url} initial={resolved} />;
|
|
134
|
+
|
|
98
135
|
startTransition(() => {
|
|
99
136
|
hydrateRoot(
|
|
100
137
|
container,
|
|
101
|
-
|
|
138
|
+
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
102
139
|
recovery == null ? undefined : { onRecoverableError: recovery },
|
|
103
140
|
);
|
|
104
141
|
});
|
|
142
|
+
|
|
143
|
+
// And, in development only, whether the panel a developer is about to open
|
|
144
|
+
// can see any of that. `react-dom` announced itself while it was being
|
|
145
|
+
// imported — long before this line — so the answer is already settled and
|
|
146
|
+
// this only reads it. Behind the same `import.meta.hot` gate as the
|
|
147
|
+
// hydration reporter, dynamically imported for the same reason: a production
|
|
148
|
+
// bundle has no path to the module rather than merely no reason to run it.
|
|
149
|
+
// See `./internal/devtools.js` and ubugeeei-prod/uf#503.
|
|
150
|
+
if (import.meta.hot != null) {
|
|
151
|
+
const { reportDevtools } = await import("./internal/devtools.js");
|
|
152
|
+
reportDevtools(window);
|
|
153
|
+
}
|
|
105
154
|
}
|
package/index.js
CHANGED
|
@@ -54,6 +54,18 @@
|
|
|
54
54
|
// prototype keys, no constructor named by the payload. See that module's
|
|
55
55
|
// header for what is excluded and why.
|
|
56
56
|
//
|
|
57
|
+
// A submitted form is inside that boundary and does not widen it: `<form
|
|
58
|
+
// action={fn}>` reaches here as the same `application/json` body, with the
|
|
59
|
+
// form's entries beside the values under a `form` key, bounded in count and in
|
|
60
|
+
// field-name length and holding strings only. Nothing about it is multipart
|
|
61
|
+
// and nothing about it is a content type a cross-origin `<form>` could
|
|
62
|
+
// produce, so rule 4 is exactly as true of a form call as of any other. The
|
|
63
|
+
// cost of keeping it that way is written down where it is paid — a form that
|
|
64
|
+
// submits before its page has hydrated throws in the page rather than posting
|
|
65
|
+
// anywhere, because React writes `action="javascript:throw …"` for a form
|
|
66
|
+
// whose action carries no `$$FORM_ACTION`, and giving it one would mean
|
|
67
|
+
// accepting a native form post here.
|
|
68
|
+
//
|
|
57
69
|
// **6. Nothing about a failure goes back.** An action that throws is a `500`
|
|
58
70
|
// with a fixed body; the exception goes to the host's error reporting. A
|
|
59
71
|
// message, a name or a stack in that response is an application's internals
|
|
@@ -81,7 +93,7 @@ import { asResponder } from "@uniflowed/server/host";
|
|
|
81
93
|
import {
|
|
82
94
|
ACTION_CONTENT_TYPE,
|
|
83
95
|
ACTION_HEADER,
|
|
84
|
-
type
|
|
96
|
+
type ActionArgument,
|
|
85
97
|
ActionValueError,
|
|
86
98
|
MAX_ACTION_BODY_BYTES,
|
|
87
99
|
decodeActionArguments,
|
|
@@ -163,7 +175,7 @@ export function createActionDispatcher(options: {|
|
|
|
163
175
|
return refusal(413);
|
|
164
176
|
}
|
|
165
177
|
|
|
166
|
-
let args: Array<
|
|
178
|
+
let args: Array<ActionArgument>;
|
|
167
179
|
try {
|
|
168
180
|
args = decodeActionArguments(body);
|
|
169
181
|
} catch (error) {
|
|
@@ -213,7 +225,7 @@ export function createActionDispatcher(options: {|
|
|
|
213
225
|
return asResponder("a server action", async () => {
|
|
214
226
|
let result: mixed;
|
|
215
227
|
try {
|
|
216
|
-
// The build-time contract says this is `async (...
|
|
228
|
+
// The build-time contract says this is `async (...ActionArgument) => …`
|
|
217
229
|
// (`ServerActionBoundary` in `../action.js`), and Flow cannot read that
|
|
218
230
|
// through a module loaded by a thunk. The arguments are the ones
|
|
219
231
|
// `decodeActionArguments` produced, so what is unchecked here is the
|
package/internal/action-wire.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// # The boundary
|
|
12
12
|
//
|
|
13
|
-
// **A server action's arguments are plain JSON data
|
|
13
|
+
// **A server action's arguments are plain JSON data, plus at most one form.**
|
|
14
14
|
//
|
|
15
15
|
// One JSON object, `{"args": [...]}`, at most `MAX_ACTION_BODY_BYTES` of valid
|
|
16
16
|
// UTF-8, holding at most `MAX_ACTION_ARGUMENTS` values, nested at most
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// Each of those values is `null`, a boolean, a finite number, a string, an
|
|
19
19
|
// array of them, or a plain object whose keys are ordinary strings. The result
|
|
20
20
|
// travels back under exactly the same grammar, plus `undefined` for an action
|
|
21
|
-
// that returns nothing
|
|
21
|
+
// that returns nothing — and with no form, because a form is something a
|
|
22
|
+
// browser submits and not something a server answers with.
|
|
22
23
|
//
|
|
23
24
|
// Nothing in a payload can name a function, a module, a class, a prototype, a
|
|
24
25
|
// React element, an id or a reference, and nothing in it is revived into an
|
|
@@ -27,6 +28,35 @@
|
|
|
27
28
|
// `JSON.parse` would have let through — which is the whole of what a decoder
|
|
28
29
|
// has to get right.
|
|
29
30
|
//
|
|
31
|
+
// # The one thing that is not a value: `<form action={fn}>`
|
|
32
|
+
//
|
|
33
|
+
// React 19 hands a form action a `FormData`, and `useActionState` hands it the
|
|
34
|
+
// previous state and then a `FormData`. So one argument of a call may be a
|
|
35
|
+
// form, and the envelope grows a second key to say which:
|
|
36
|
+
//
|
|
37
|
+
// {"args": [null, null], "form": {"at": 1, "entries": [["note", "hi"]]}}
|
|
38
|
+
//
|
|
39
|
+
// It is written *beside* the values rather than inside one, and that placement
|
|
40
|
+
// is the whole design. A tag in the value tree — `{"$formData": …}` — would be
|
|
41
|
+
// a payload saying which constructor to call, which is the shape of every
|
|
42
|
+
// deserialisation CVE and the thing the list below refuses on principle.
|
|
43
|
+
// Outside the tree there is no tag: `checkActionValue` is unchanged and still
|
|
44
|
+
// knows nothing but JSON data, `at` names a position and not a type, and the
|
|
45
|
+
// slot it names must hold `null` so a sender cannot say two things about one
|
|
46
|
+
// argument. At most one form crosses per call, it is never nested inside a
|
|
47
|
+
// value, its entries are `[name, value]` pairs of strings, and the decoder's
|
|
48
|
+
// only constructor is `FormData` — fixed here, never named by the payload.
|
|
49
|
+
//
|
|
50
|
+
// What that buys is `<form action={fn}>`, `useActionState` and `useFormStatus`
|
|
51
|
+
// against a real endpoint. What it does not buy is a form that works before
|
|
52
|
+
// hydration: React's progressive enhancement needs `$$FORM_ACTION` on the
|
|
53
|
+
// reference, which makes the submit a *native* form post, and a native form
|
|
54
|
+
// post is `multipart/form-data` — a content type this endpoint refuses on
|
|
55
|
+
// purpose (see `./action-endpoint.js`, rule 4). Without it React writes the
|
|
56
|
+
// form it writes for any client action, whose `action` is a `javascript:` URL
|
|
57
|
+
// that throws, so such a submit does nothing rather than posting somewhere it
|
|
58
|
+
// should not. That is still open; see ubugeeei-prod/uf#252.
|
|
59
|
+
//
|
|
30
60
|
// # What is deliberately absent, and why
|
|
31
61
|
//
|
|
32
62
|
// * **A reference format.** React's Flight payload can carry a reference to a
|
|
@@ -39,11 +69,11 @@
|
|
|
39
69
|
// tag naming a constructor is the oracle every deserialisation CVE is made
|
|
40
70
|
// of. An action that wants a date takes an ISO string and parses it, where
|
|
41
71
|
// the parse is the application's and is checked.
|
|
42
|
-
// *
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
72
|
+
// * **A file in a form.** A `File` entry is refused at the call site rather
|
|
73
|
+
// than encoded: bytes in this envelope would be base64 in a JSON string with
|
|
74
|
+
// no ceiling of their own, and an upload wants a content type, a streaming
|
|
75
|
+
// read and a size limit that are not this module's. Multipart parsing is its
|
|
76
|
+
// own attack surface and is still deliberately absent.
|
|
47
77
|
// * **Cycles and shared references.** A value that refers to itself is
|
|
48
78
|
// rejected rather than encoded, because the alternative is a marker in the
|
|
49
79
|
// payload that says "this is the object you saw earlier", which is a
|
|
@@ -87,6 +117,26 @@ export const MAX_ACTION_DEPTH: number = 24;
|
|
|
87
117
|
/** Most values, of any kind, one payload may hold. */
|
|
88
118
|
export const MAX_ACTION_VALUES: number = 10000;
|
|
89
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Most fields one submitted form may carry.
|
|
122
|
+
*
|
|
123
|
+
* A ceiling on the *count* rather than on the bytes, because the bytes already
|
|
124
|
+
* have one: the whole body is bounded by `MAX_ACTION_BODY_BYTES` before a
|
|
125
|
+
* character of it is parsed. What this bounds is the number of `append` calls
|
|
126
|
+
* a sender can make the decoder do, and the size of the multimap they build.
|
|
127
|
+
* A form with more than 256 controls is a form that wants a different shape.
|
|
128
|
+
*/
|
|
129
|
+
export const MAX_FORM_ENTRIES: number = 256;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Longest field name one form entry may have.
|
|
133
|
+
*
|
|
134
|
+
* A name is an HTML `name` attribute — `email`, `items[3][quantity]` — so this
|
|
135
|
+
* is generous by two orders of magnitude for anything a document declares, and
|
|
136
|
+
* it stops a body's whole byte budget being spent on one key.
|
|
137
|
+
*/
|
|
138
|
+
export const MAX_FORM_NAME_LENGTH: number = 128;
|
|
139
|
+
|
|
90
140
|
/**
|
|
91
141
|
* Everything that may cross the wire.
|
|
92
142
|
*
|
|
@@ -103,6 +153,18 @@ export type ActionValue =
|
|
|
103
153
|
| $ReadOnlyArray<ActionValue>
|
|
104
154
|
| { readonly [string]: ActionValue };
|
|
105
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Everything an *argument* may be: a value, or the one form.
|
|
158
|
+
*
|
|
159
|
+
* Wider than [`ActionValue`] in exactly one place and deliberately not
|
|
160
|
+
* recursive: a `FormData` is something a call passes, never something inside
|
|
161
|
+
* something a call passes. `ActionArguments` in `../action.js` holds every
|
|
162
|
+
* action's parameter list against this, and `ActionResult` still holds every
|
|
163
|
+
* return type against `ActionValue` — an action receives a form and does not
|
|
164
|
+
* answer with one.
|
|
165
|
+
*/
|
|
166
|
+
export type ActionArgument = ActionValue | FormData;
|
|
167
|
+
|
|
106
168
|
/**
|
|
107
169
|
* A value that is outside the grammar, and where in the payload it was.
|
|
108
170
|
*
|
|
@@ -158,6 +220,28 @@ function isPlainObject(value: interface {}): boolean {
|
|
|
158
220
|
*/
|
|
159
221
|
const PLAIN_PROTOTYPE: mixed = Object.getPrototypeOf({});
|
|
160
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The value as a submitted form, or `null`.
|
|
225
|
+
*
|
|
226
|
+
* `typeof` first, because this module is imported by the browser's half and by
|
|
227
|
+
* the server's, and `FormData` is a global that a runtime is allowed not to
|
|
228
|
+
* have. `instanceof` and not a duck-type, for the reason `isPlainObject` gives
|
|
229
|
+
* about prototypes: an object with `entries` and `get` is not a form.
|
|
230
|
+
*
|
|
231
|
+
* It answers with the form rather than with a `boolean` so that the caller
|
|
232
|
+
* that goes on to read the entries has the type from the check rather than
|
|
233
|
+
* from a cast. A Flow type guard would be the direct spelling and is not
|
|
234
|
+
* available here: a guard has to refine the type away on the false branch too,
|
|
235
|
+
* and "this runtime has no `FormData` at all" is a false branch that says
|
|
236
|
+
* nothing about the value.
|
|
237
|
+
*/
|
|
238
|
+
function asFormData(value: mixed): FormData | null {
|
|
239
|
+
if (typeof FormData === "undefined" || !(value instanceof FormData)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
161
245
|
/** One entry of the explicit walk stack. */
|
|
162
246
|
type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
|
|
163
247
|
|
|
@@ -229,6 +313,18 @@ export function checkActionValue(root: mixed, label: string): void {
|
|
|
229
313
|
}
|
|
230
314
|
|
|
231
315
|
if (!isPlainObject(object)) {
|
|
316
|
+
// A form gets its own sentence. It is the one prototype this grammar
|
|
317
|
+
// does carry, just not here — a `FormData` is an argument of a call, and
|
|
318
|
+
// this walk only ever sees the inside of one, or a result — and "is a
|
|
319
|
+
// class instance" would send the reader looking for a class they did not
|
|
320
|
+
// write.
|
|
321
|
+
if (asFormData(object) != null) {
|
|
322
|
+
throw new ActionValueError(
|
|
323
|
+
path,
|
|
324
|
+
"is a FormData, and a form may only be an argument of a call: never part of a value, " +
|
|
325
|
+
"and never a result",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
232
328
|
throw new ActionValueError(
|
|
233
329
|
path,
|
|
234
330
|
"is a class instance, a Map, a Set, a Date, a React element or another object with a " +
|
|
@@ -252,12 +348,52 @@ export function checkActionValue(root: mixed, label: string): void {
|
|
|
252
348
|
}
|
|
253
349
|
}
|
|
254
350
|
|
|
351
|
+
/**
|
|
352
|
+
* One form's entries, as pairs of strings, or a named failure.
|
|
353
|
+
*
|
|
354
|
+
* The count is checked as the entries are read rather than afterwards, so an
|
|
355
|
+
* enormous form is refused before the whole of it has been copied. A `File`
|
|
356
|
+
* entry is refused by name: an upload is not in this grammar and the field
|
|
357
|
+
* that carried it is the useful half of saying so.
|
|
358
|
+
*/
|
|
359
|
+
function formEntries(form: FormData, label: string): Array<Array<string>> {
|
|
360
|
+
const entries: Array<Array<string>> = [];
|
|
361
|
+
for (const [name, value] of form.entries()) {
|
|
362
|
+
if (entries.length >= MAX_FORM_ENTRIES) {
|
|
363
|
+
throw new ActionValueError(
|
|
364
|
+
label,
|
|
365
|
+
`carries more than ${String(MAX_FORM_ENTRIES)} form fields`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (name.length > MAX_FORM_NAME_LENGTH) {
|
|
369
|
+
throw new ActionValueError(
|
|
370
|
+
label,
|
|
371
|
+
`has a form field name longer than ${String(MAX_FORM_NAME_LENGTH)} characters`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (typeof value !== "string") {
|
|
375
|
+
throw new ActionValueError(
|
|
376
|
+
`${label} field \`${name}\``,
|
|
377
|
+
"is a file, and a file cannot cross to a server action",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
entries.push([name, value]);
|
|
381
|
+
}
|
|
382
|
+
return entries;
|
|
383
|
+
}
|
|
384
|
+
|
|
255
385
|
/**
|
|
256
386
|
* The request body for a call, or a named failure.
|
|
257
387
|
*
|
|
258
388
|
* The browser's half. Refusing here is what turns "the server answered 400"
|
|
259
389
|
* into "argument 2.createdAt is a class instance", at the call site, with a
|
|
260
390
|
* stack that reaches the component.
|
|
391
|
+
*
|
|
392
|
+
* A `FormData` argument becomes the envelope's `form` key and leaves `null` in
|
|
393
|
+
* its own slot, so `args` stays a list of values of exactly the length the
|
|
394
|
+
* call had. Two forms is a refusal rather than a choice: React passes one, and
|
|
395
|
+
* an envelope that could carry several would need to say which is which in a
|
|
396
|
+
* place that is not `at`.
|
|
261
397
|
*/
|
|
262
398
|
export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
263
399
|
if (args.length > MAX_ACTION_ARGUMENTS) {
|
|
@@ -267,10 +403,24 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
|
267
403
|
String(MAX_ACTION_ARGUMENTS),
|
|
268
404
|
);
|
|
269
405
|
}
|
|
406
|
+
const values: Array<mixed> = [];
|
|
407
|
+
let form: {| readonly at: number, readonly entries: Array<Array<string>> |} | null = null;
|
|
270
408
|
for (let index = 0; index < args.length; index += 1) {
|
|
271
|
-
|
|
409
|
+
const argument = args[index];
|
|
410
|
+
const label = `argument ${String(index + 1)}`;
|
|
411
|
+
const submitted = asFormData(argument);
|
|
412
|
+
if (submitted != null) {
|
|
413
|
+
if (form != null) {
|
|
414
|
+
throw new ActionValueError(label, "is a second form, and a call carries at most one");
|
|
415
|
+
}
|
|
416
|
+
form = { at: index, entries: formEntries(submitted, label) };
|
|
417
|
+
values.push(null);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
checkActionValue(argument, label);
|
|
421
|
+
values.push(argument);
|
|
272
422
|
}
|
|
273
|
-
return JSON.stringify({ args });
|
|
423
|
+
return form == null ? JSON.stringify({ args: values }) : JSON.stringify({ args: values, form });
|
|
274
424
|
}
|
|
275
425
|
|
|
276
426
|
/**
|
|
@@ -282,7 +432,7 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
|
282
432
|
* only way to keep this closed as it grows is to refuse anything that is not
|
|
283
433
|
* this today.
|
|
284
434
|
*/
|
|
285
|
-
export function decodeActionArguments(text: string): Array<
|
|
435
|
+
export function decodeActionArguments(text: string): Array<ActionArgument> {
|
|
286
436
|
let parsed: mixed;
|
|
287
437
|
try {
|
|
288
438
|
parsed = JSON.parse(text);
|
|
@@ -293,8 +443,9 @@ export function decodeActionArguments(text: string): Array<ActionValue> {
|
|
|
293
443
|
throw new ActionValueError("the body", "is not a JSON object");
|
|
294
444
|
}
|
|
295
445
|
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
|
|
296
|
-
|
|
297
|
-
|
|
446
|
+
const named = keys.length === 1 ? keys[0] === "args" : keys.length === 2 && keys.includes("form");
|
|
447
|
+
if (!named || !keys.includes("args")) {
|
|
448
|
+
throw new ActionValueError("the body", 'has keys other than "args" and "form"');
|
|
298
449
|
}
|
|
299
450
|
const args: mixed = parsed.args;
|
|
300
451
|
if (!Array.isArray(args)) {
|
|
@@ -309,7 +460,79 @@ export function decodeActionArguments(text: string): Array<ActionValue> {
|
|
|
309
460
|
for (let index = 0; index < args.length; index += 1) {
|
|
310
461
|
checkActionValue(args[index], `argument ${String(index + 1)}`);
|
|
311
462
|
}
|
|
312
|
-
|
|
463
|
+
const decoded: Array<ActionArgument> = args as $FlowFixMe;
|
|
464
|
+
if (keys.length === 2) {
|
|
465
|
+
const at = decodeForm(parsed.form, decoded);
|
|
466
|
+
decoded[at.index] = at.form;
|
|
467
|
+
}
|
|
468
|
+
return decoded;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The envelope's `form`, as a `FormData` and the position it belongs at.
|
|
473
|
+
*
|
|
474
|
+
* Every field of the envelope is checked before anything is built, and the
|
|
475
|
+
* slot it names must already hold `null`: `args` and `form` are two statements
|
|
476
|
+
* about one call, and a payload that makes both about the same argument is a
|
|
477
|
+
* payload whose sender believed something that is not true. The only thing
|
|
478
|
+
* constructed is a `FormData`, from strings, and which constructor that is was
|
|
479
|
+
* decided here rather than by the bytes.
|
|
480
|
+
*/
|
|
481
|
+
function decodeForm(
|
|
482
|
+
candidate: mixed,
|
|
483
|
+
args: $ReadOnlyArray<ActionArgument>,
|
|
484
|
+
): {| readonly index: number, readonly form: FormData |} {
|
|
485
|
+
if (typeof FormData === "undefined") {
|
|
486
|
+
throw new ActionValueError("the body", "carries a form, and this runtime has no FormData");
|
|
487
|
+
}
|
|
488
|
+
if (candidate == null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
489
|
+
throw new ActionValueError("the form", "is not a JSON object");
|
|
490
|
+
}
|
|
491
|
+
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(candidate);
|
|
492
|
+
if (keys.length !== 2 || !keys.includes("at") || !keys.includes("entries")) {
|
|
493
|
+
throw new ActionValueError("the form", 'has keys other than "at" and "entries"');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const at: mixed = candidate.at;
|
|
497
|
+
if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at >= args.length) {
|
|
498
|
+
throw new ActionValueError("the form", "names no argument of this call");
|
|
499
|
+
}
|
|
500
|
+
if (args[at] !== null) {
|
|
501
|
+
throw new ActionValueError(
|
|
502
|
+
"the form",
|
|
503
|
+
`names argument ${String(at + 1)}, which the payload also gives a value`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const entries: mixed = candidate.entries;
|
|
508
|
+
if (!Array.isArray(entries)) {
|
|
509
|
+
throw new ActionValueError("the form", 'has an "entries" that is not an array');
|
|
510
|
+
}
|
|
511
|
+
if (entries.length > MAX_FORM_ENTRIES) {
|
|
512
|
+
throw new ActionValueError(
|
|
513
|
+
"the form",
|
|
514
|
+
`carries more than ${String(MAX_FORM_ENTRIES)} form fields`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const form: FormData = new FormData();
|
|
519
|
+
for (const entry of entries) {
|
|
520
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
521
|
+
throw new ActionValueError("the form", "has an entry that is not a name and a value");
|
|
522
|
+
}
|
|
523
|
+
const [name, value] = entry;
|
|
524
|
+
if (typeof name !== "string" || typeof value !== "string") {
|
|
525
|
+
throw new ActionValueError("the form", "has an entry whose name or value is not a string");
|
|
526
|
+
}
|
|
527
|
+
if (name.length > MAX_FORM_NAME_LENGTH) {
|
|
528
|
+
throw new ActionValueError(
|
|
529
|
+
"the form",
|
|
530
|
+
`has a form field name longer than ${String(MAX_FORM_NAME_LENGTH)} characters`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
form.append(name, value);
|
|
534
|
+
}
|
|
535
|
+
return { index: at, form };
|
|
313
536
|
}
|
|
314
537
|
|
|
315
538
|
/**
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: whether React DevTools can actually attach
|
|
4
|
+
// to the page this browser just hydrated.
|
|
5
|
+
//
|
|
6
|
+
// `@uniflowed/vite` installs the hook DevTools attaches through, as a classic
|
|
7
|
+
// script above every module — see `packages/vite/internal/devtools.js`, which
|
|
8
|
+
// has the argument. That is uf saying what it intends. This is the half that
|
|
9
|
+
// checks it happened, and the two are not the same claim: the preamble is
|
|
10
|
+
// injected by a `transformIndexHtml` hook, into a document a project's own Vite
|
|
11
|
+
// plugins also write to, and the thing that has to be true is an *ordering* —
|
|
12
|
+
// the hook exists before `react-dom` is evaluated — which no amount of reading
|
|
13
|
+
// the injector can establish about a particular page.
|
|
14
|
+
//
|
|
15
|
+
// It runs once, after hydration, in development only, and says nothing at all
|
|
16
|
+
// when there is nothing wrong. See ubugeeei-prod/uf#503.
|
|
17
|
+
//
|
|
18
|
+
// # Why it reports rather than throws
|
|
19
|
+
//
|
|
20
|
+
// Nothing here is a reason to stop a page. DevTools not attaching costs a
|
|
21
|
+
// developer a panel, and a framework that refused to render over it would have
|
|
22
|
+
// turned a missing convenience into an outage. So the two findings go to the
|
|
23
|
+
// terminal on the same channel as every other browser-side diagnostic — see
|
|
24
|
+
// `./diagnostics.js` — and the page carries on.
|
|
25
|
+
//
|
|
26
|
+
// # The two findings, and why they are the two
|
|
27
|
+
//
|
|
28
|
+
// A third condition — React's *development* build, which is what gives the
|
|
29
|
+
// panel props, hooks and source positions — is not checked here because a
|
|
30
|
+
// browser cannot tell the difference from the outside, and because uf owns it
|
|
31
|
+
// end to end: `mode` is `development` and `uf transform` is called with
|
|
32
|
+
// `development: true`, both asserted in `tests/library/devtools.test.js`
|
|
33
|
+
// against the plugin rather than against a page. What is left is what only a
|
|
34
|
+
// running page knows.
|
|
35
|
+
//
|
|
36
|
+
// 1. **There is no hook.** Something ran before `react-dom` and there was
|
|
37
|
+
// nothing for it to register with, or the preamble did not reach this
|
|
38
|
+
// document. Either way no renderer was announced and the panel will say
|
|
39
|
+
// the page is not using React.
|
|
40
|
+
// 2. **There is more than one renderer.** Two copies of `react-dom` each
|
|
41
|
+
// injected, and DevTools shows the tree of whichever it heard from — which
|
|
42
|
+
// is the shape of the "multiple renderers concurrently rendering the same
|
|
43
|
+
// context provider" report, and a component tree that is missing half the
|
|
44
|
+
// page for a reason nothing on screen explains.
|
|
45
|
+
|
|
46
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The global React registers itself with.
|
|
50
|
+
*
|
|
51
|
+
* The same string as `DEVTOOLS_HOOK` in `@uniflowed/vite`'s
|
|
52
|
+
* `internal/devtools.js`, written out again rather than imported for the reason
|
|
53
|
+
* that file's neighbour `internal/diagnostics.js` gives about the endpoint
|
|
54
|
+
* paths: `@uniflowed/vite` is loaded by Vite before any Flow transform exists
|
|
55
|
+
* and this module is Flow, so the import cannot go either way.
|
|
56
|
+
* `tests/library/devtools.test.js` asserts the two spellings agree, which is
|
|
57
|
+
* what makes a duplicated constant honest.
|
|
58
|
+
*/
|
|
59
|
+
export const DEVTOOLS_HOOK: string = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
|
|
60
|
+
|
|
61
|
+
/** The part of a page this module reads. */
|
|
62
|
+
type HookWindow = {
|
|
63
|
+
[key: string]: mixed,
|
|
64
|
+
...
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What is wrong with this page's DevTools hook, as a diagnostic, or `null`.
|
|
69
|
+
*
|
|
70
|
+
* Separated from the reporting so that a test can ask the question without a
|
|
71
|
+
* channel to answer on, and because the wording is the part worth pinning: a
|
|
72
|
+
* reader who sees this in a terminal has to be able to act on it without
|
|
73
|
+
* reading this file.
|
|
74
|
+
*/
|
|
75
|
+
export function devtoolsProblem(win: HookWindow): {|
|
|
76
|
+
readonly message: string,
|
|
77
|
+
readonly detail: $ReadOnlyArray<string>,
|
|
78
|
+
|} | null {
|
|
79
|
+
const hook = win[DEVTOOLS_HOOK];
|
|
80
|
+
if (hook == null || typeof hook !== "object") {
|
|
81
|
+
return {
|
|
82
|
+
message: `React DevTools cannot attach: nothing installed \`${DEVTOOLS_HOOK}\` before react-dom ran`,
|
|
83
|
+
detail: [
|
|
84
|
+
"React registers itself with that global while `react-dom` is evaluated, once and never again.",
|
|
85
|
+
"`uf dev` injects the hook as a classic script at the top of the head; a plugin that replaces",
|
|
86
|
+
"`transformIndexHtml`'s output, or a document that does not go through it, takes it away.",
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `renderers` is a Map React puts its renderer in, keyed by the id `inject`
|
|
92
|
+
// handed back. Anything else there is a hook uf did not install and DevTools
|
|
93
|
+
// did not either, and guessing at its shape would report a problem that is
|
|
94
|
+
// really this module not recognising one.
|
|
95
|
+
const renderers = (hook: $FlowFixMe).renderers;
|
|
96
|
+
const count = renderers instanceof Map ? renderers.size : null;
|
|
97
|
+
if (count != null && count > 1) {
|
|
98
|
+
return {
|
|
99
|
+
message: `React DevTools has ${count} renderers on this page and will show one of them`,
|
|
100
|
+
detail: [
|
|
101
|
+
"Two copies of `react-dom` are loaded, so half the component tree is in a tree the panel cannot see.",
|
|
102
|
+
'`resolve.dedupe: ["react", "react-dom"]` is what usually prevents it; a linked package with its',
|
|
103
|
+
"own `react-dom` in `node_modules` is what usually causes it.",
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Report the problem, if there is one, to the terminal running `uf dev`.
|
|
112
|
+
*
|
|
113
|
+
* Throws nothing and returns nothing, and the guard is around the whole body
|
|
114
|
+
* rather than around the reading: this is called from the line after a
|
|
115
|
+
* successful hydration, so every failure available to it — a hook object whose
|
|
116
|
+
* property getter throws, a host with no `fetch` to report through — is a
|
|
117
|
+
* development convenience failing, and a development convenience that can take
|
|
118
|
+
* a working page down is worse than no convenience at all.
|
|
119
|
+
*/
|
|
120
|
+
export function reportDevtools(win: HookWindow): void {
|
|
121
|
+
try {
|
|
122
|
+
const problem = devtoolsProblem(win);
|
|
123
|
+
if (problem == null) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
reportDiagnostic({ severity: "warn", message: problem.message, detail: problem.detail });
|
|
127
|
+
} catch {
|
|
128
|
+
// Deliberately silent. There is no second channel to complain on, and the
|
|
129
|
+
// thing being reported was never worth interrupting anybody for.
|
|
130
|
+
}
|
|
131
|
+
}
|
package/internal/diagnostics.js
CHANGED
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
// `uf dev` serves this path and nothing else does: a built application has no
|
|
40
40
|
// `/__uf/` anything, so a call in production posts to a path that answers 404
|
|
41
41
|
// and the rejected promise is swallowed here. That is a fallback rather than a
|
|
42
|
-
// design —
|
|
43
|
-
//
|
|
44
|
-
// from it.
|
|
42
|
+
// design — both callers are behind `import.meta.hot` in `../client.js`, the
|
|
43
|
+
// hydration report and the DevTools check, so a production bundle has no path
|
|
44
|
+
// to this module rather than merely no answer from it.
|
|
45
45
|
//
|
|
46
46
|
// Nothing here opens a connection until it is called, importing it does nothing
|
|
47
47
|
// at all, and what it sends goes to the page's own origin as a path rather than
|
package/internal/runtime.js
CHANGED
|
@@ -732,6 +732,68 @@ function matchSegments(
|
|
|
732
732
|
return index === parts.length ? params : null;
|
|
733
733
|
}
|
|
734
734
|
|
|
735
|
+
/**
|
|
736
|
+
* The URL for a route pattern and the parameters it takes.
|
|
737
|
+
*
|
|
738
|
+
* The inverse of [`matchSegments`], and deliberately built out of the same
|
|
739
|
+
* [`compile`]: a builder that parsed patterns its own way would drift from the
|
|
740
|
+
* matcher, and the drift would show up as a link that 404s rather than as a
|
|
741
|
+
* failure anybody could see.
|
|
742
|
+
*
|
|
743
|
+
* The generated `router.js` is what a project calls — `route("/posts/:slug",
|
|
744
|
+
* { slug })` — and it is typed there, so the parameters are checked before this
|
|
745
|
+
* runs. This still refuses a bad call rather than building a wrong URL,
|
|
746
|
+
* because the types are only in front of the callers that have them: a value
|
|
747
|
+
* that arrived from JSON, or from a module that opted out of Flow, reaches
|
|
748
|
+
* here unchecked. A link to `/posts/undefined` is the failure this exists to
|
|
749
|
+
* turn into an error with a name on it.
|
|
750
|
+
*
|
|
751
|
+
* Each segment is `encodeURIComponent`d, which is what [`decodeSegment`]
|
|
752
|
+
* undoes on the way back — so a slug with a slash in it round-trips as one
|
|
753
|
+
* segment rather than becoming two.
|
|
754
|
+
*/
|
|
755
|
+
export function buildRoute(routePath: string, params?: RouteParams): string {
|
|
756
|
+
const values: RouteParams = params ?? {};
|
|
757
|
+
const parts: Array<string> = [];
|
|
758
|
+
for (const segment of compile(routePath)) {
|
|
759
|
+
match (segment) {
|
|
760
|
+
{kind: "static", value: const value} => {
|
|
761
|
+
parts.push(value);
|
|
762
|
+
}
|
|
763
|
+
{kind: "param", name: const name} => {
|
|
764
|
+
const value = values[name];
|
|
765
|
+
if (typeof value !== "string") {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
parts.push(encodeURIComponent(value));
|
|
771
|
+
}
|
|
772
|
+
{kind: "catchAll", name: const name} => {
|
|
773
|
+
const value = values[name];
|
|
774
|
+
if (value == null || typeof value === "string") {
|
|
775
|
+
throw new Error(
|
|
776
|
+
`route ${routePath} takes an array of segments for :${name}*, and got ` +
|
|
777
|
+
describeParam(value),
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
for (const part of value) {
|
|
781
|
+
parts.push(encodeURIComponent(part));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return parts.length === 0 ? "/" : `/${parts.join("/")}`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** What a parameter was, for the message that says it was the wrong thing. */
|
|
790
|
+
function describeParam(value: string | $ReadOnlyArray<string> | void): string {
|
|
791
|
+
if (value === undefined) {
|
|
792
|
+
return "nothing";
|
|
793
|
+
}
|
|
794
|
+
return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
|
|
795
|
+
}
|
|
796
|
+
|
|
735
797
|
function decodeSegment(segment: string): string {
|
|
736
798
|
try {
|
|
737
799
|
return decodeURIComponent(segment);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.16",
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"react-dom": ">=19"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
37
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
36
|
+
"@uniflowed/hooks": "0.0.0-alpha.16",
|
|
37
|
+
"@uniflowed/server": "0.0.0-alpha.16"
|
|
38
38
|
}
|
|
39
39
|
}
|