@uniflowed/router 0.0.0-alpha.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +344 -0
- package/client.js +263 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +646 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/form-action.js +243 -0
- package/internal/head.js +219 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +132 -0
- package/internal/stream.js +766 -21
- package/middleware.js +274 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +122 -0
- package/rsc-ssr.js +641 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +263 -106
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: what a server action's arguments and its
|
|
4
|
+
// result are allowed to be.
|
|
5
|
+
//
|
|
6
|
+
// A `"use server"` export is a public HTTP endpoint from the moment it exists.
|
|
7
|
+
// The one decision that makes it a feature rather than a remote-code-execution
|
|
8
|
+
// surface is what the bytes on the wire are permitted to become, and this
|
|
9
|
+
// module is that decision, written once and applied on both sides of it.
|
|
10
|
+
//
|
|
11
|
+
// # The boundary
|
|
12
|
+
//
|
|
13
|
+
// **A server action's arguments are plain JSON data, plus at most one form.**
|
|
14
|
+
//
|
|
15
|
+
// One JSON object, `{"args": [...]}`, at most `MAX_ACTION_BODY_BYTES` of valid
|
|
16
|
+
// UTF-8, holding at most `MAX_ACTION_ARGUMENTS` values, nested at most
|
|
17
|
+
// `MAX_ACTION_DEPTH` deep, with at most `MAX_ACTION_VALUES` values in total.
|
|
18
|
+
// Each of those values is `null`, a boolean, a finite number, a string, an
|
|
19
|
+
// array of them, or a plain object whose keys are ordinary strings. The result
|
|
20
|
+
// travels back under exactly the same grammar, plus `undefined` for an action
|
|
21
|
+
// that returns nothing — and with no form, because a form is something a
|
|
22
|
+
// browser submits and not something a server answers with.
|
|
23
|
+
//
|
|
24
|
+
// Nothing in a payload can name a function, a module, a class, a prototype, a
|
|
25
|
+
// React element, an id or a reference, and nothing in it is revived into an
|
|
26
|
+
// object the sender chose. `decodeActionArguments` produces values a
|
|
27
|
+
// `JSON.parse` already produced; what this adds is the refusal of everything
|
|
28
|
+
// `JSON.parse` would have let through — which is the whole of what a decoder
|
|
29
|
+
// has to get right.
|
|
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
|
+
//
|
|
60
|
+
// # What is deliberately absent, and why
|
|
61
|
+
//
|
|
62
|
+
// * **A reference format.** React's Flight payload can carry a reference to a
|
|
63
|
+
// client module, a promise, or an element, and a decoder that reconstructs
|
|
64
|
+
// those is a decoder that constructs attacker-chosen objects. uf's payload
|
|
65
|
+
// (`./payload.js`) now carries one of the three — a reference to a *row of
|
|
66
|
+
// itself*, which names nothing to construct — and it is a document the
|
|
67
|
+
// server writes rather than a body somebody sends. This grammar is the one
|
|
68
|
+
// an untrusted sender is decoded under, so it still has none, and it is
|
|
69
|
+
// still not the place to grow one quietly (ubugeeei-prod/uf#252).
|
|
70
|
+
// * **Class instances, `Map`, `Set`, `Date`, `RegExp`, typed arrays.** Each
|
|
71
|
+
// would need a tag in the payload saying which constructor to call, and a
|
|
72
|
+
// tag naming a constructor is the oracle every deserialisation CVE is made
|
|
73
|
+
// of. An action that wants a date takes an ISO string and parses it, where
|
|
74
|
+
// the parse is the application's and is checked.
|
|
75
|
+
// * **A file in a form.** A `File` entry is refused at the call site rather
|
|
76
|
+
// than encoded: bytes in this envelope would be base64 in a JSON string with
|
|
77
|
+
// no ceiling of their own, and an upload wants a content type, a streaming
|
|
78
|
+
// read and a size limit that are not this module's. Multipart parsing is its
|
|
79
|
+
// own attack surface and is still deliberately absent.
|
|
80
|
+
// * **Cycles and shared references.** A value that refers to itself is
|
|
81
|
+
// rejected rather than encoded, because the alternative is a marker in the
|
|
82
|
+
// payload that says "this is the object you saw earlier", which is a
|
|
83
|
+
// reference format by another name.
|
|
84
|
+
//
|
|
85
|
+
// # Why the same module runs on both sides
|
|
86
|
+
//
|
|
87
|
+
// The browser refuses to *send* what the server would refuse to receive, so a
|
|
88
|
+
// value that cannot cross is a mistake at the call site with the argument's
|
|
89
|
+
// position in the message, rather than a 400 with nothing in it. Two
|
|
90
|
+
// implementations of one grammar is how the two come to disagree, and the
|
|
91
|
+
// side that is lenient is always the server.
|
|
92
|
+
//
|
|
93
|
+
// Pure: no imports, no platform APIs beyond `JSON`, so the browser half of
|
|
94
|
+
// `@uniflowed/router` can reach it without reaching anything server-only.
|
|
95
|
+
|
|
96
|
+
/** The request header carrying the action id. */
|
|
97
|
+
export const ACTION_HEADER: string = "uf-action";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The only content type an action call may be sent with.
|
|
101
|
+
*
|
|
102
|
+
* Not decoration. `application/json` is not one of the three types a form can
|
|
103
|
+
* produce, so a cross-origin `<form>` — which is sent with the visitor's
|
|
104
|
+
* cookies and no preflight — cannot reach the decoder at all. It is the second
|
|
105
|
+
* of the three independent things standing between this endpoint and a CSRF,
|
|
106
|
+
* the others being the `Origin` check and `ACTION_HEADER` itself, which is a
|
|
107
|
+
* header no simple request may carry.
|
|
108
|
+
*/
|
|
109
|
+
export const ACTION_CONTENT_TYPE: string = "application/json";
|
|
110
|
+
|
|
111
|
+
/** Largest request body the endpoint will read, in bytes. */
|
|
112
|
+
export const MAX_ACTION_BODY_BYTES: number = 1024 * 1024;
|
|
113
|
+
|
|
114
|
+
/** Most positional arguments an action may be called with. */
|
|
115
|
+
export const MAX_ACTION_ARGUMENTS: number = 16;
|
|
116
|
+
|
|
117
|
+
/** Deepest nesting a payload may have. */
|
|
118
|
+
export const MAX_ACTION_DEPTH: number = 24;
|
|
119
|
+
|
|
120
|
+
/** Most values, of any kind, one payload may hold. */
|
|
121
|
+
export const MAX_ACTION_VALUES: number = 10000;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Most fields one submitted form may carry.
|
|
125
|
+
*
|
|
126
|
+
* A ceiling on the *count* rather than on the bytes, because the bytes already
|
|
127
|
+
* have one: the whole body is bounded by `MAX_ACTION_BODY_BYTES` before a
|
|
128
|
+
* character of it is parsed. What this bounds is the number of `append` calls
|
|
129
|
+
* a sender can make the decoder do, and the size of the multimap they build.
|
|
130
|
+
* A form with more than 256 controls is a form that wants a different shape.
|
|
131
|
+
*/
|
|
132
|
+
export const MAX_FORM_ENTRIES: number = 256;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Longest field name one form entry may have.
|
|
136
|
+
*
|
|
137
|
+
* A name is an HTML `name` attribute — `email`, `items[3][quantity]` — so this
|
|
138
|
+
* is generous by two orders of magnitude for anything a document declares, and
|
|
139
|
+
* it stops a body's whole byte budget being spent on one key.
|
|
140
|
+
*/
|
|
141
|
+
export const MAX_FORM_NAME_LENGTH: number = 128;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Everything that may cross the wire.
|
|
145
|
+
*
|
|
146
|
+
* Recursive on purpose, and closed on purpose: this type is what
|
|
147
|
+
* `ServerActionBoundary` in `../action.js` holds every action's parameters and
|
|
148
|
+
* return value against, so an action that takes a `Map` is a `uf check` error
|
|
149
|
+
* rather than a request that arrives with an empty object in it.
|
|
150
|
+
*/
|
|
151
|
+
export type ActionValue =
|
|
152
|
+
| null
|
|
153
|
+
| boolean
|
|
154
|
+
| number
|
|
155
|
+
| string
|
|
156
|
+
| $ReadOnlyArray<ActionValue>
|
|
157
|
+
| { readonly [string]: ActionValue };
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Everything an *argument* may be: a value, or the one form.
|
|
161
|
+
*
|
|
162
|
+
* Wider than [`ActionValue`] in exactly one place and deliberately not
|
|
163
|
+
* recursive: a `FormData` is something a call passes, never something inside
|
|
164
|
+
* something a call passes. `ActionArguments` in `../action.js` holds every
|
|
165
|
+
* action's parameter list against this, and `ActionResult` still holds every
|
|
166
|
+
* return type against `ActionValue` — an action receives a form and does not
|
|
167
|
+
* answer with one.
|
|
168
|
+
*/
|
|
169
|
+
export type ActionArgument = ActionValue | FormData;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A value that is outside the grammar, and where in the payload it was.
|
|
173
|
+
*
|
|
174
|
+
* Thrown on the browser side, where the path is the argument the caller
|
|
175
|
+
* passed. On the server side it is caught and becomes a `400` with none of
|
|
176
|
+
* this in it: the sender does not get told which part of what they sent was
|
|
177
|
+
* the part that was refused.
|
|
178
|
+
*/
|
|
179
|
+
export class ActionValueError extends Error {
|
|
180
|
+
/** Where in the payload the offending value sat, e.g. `argument 2.name`. */
|
|
181
|
+
path: string;
|
|
182
|
+
|
|
183
|
+
constructor(path: string, reason: string) {
|
|
184
|
+
super(`@uniflowed/router: ${path} ${reason}.`);
|
|
185
|
+
this.name = "ActionValueError";
|
|
186
|
+
this.path = path;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Keys that are never a property in this grammar.
|
|
192
|
+
*
|
|
193
|
+
* `JSON.parse` gives `__proto__` an *own* data property rather than changing
|
|
194
|
+
* the prototype, so a payload carrying one is not itself pollution — it
|
|
195
|
+
* becomes pollution in the first line of application code that spreads or
|
|
196
|
+
* merges it. Refused here, where there is one place to refuse it, rather than
|
|
197
|
+
* left for every action to remember. `uf_pm::detect` draws the same line for
|
|
198
|
+
* manifest JSON.
|
|
199
|
+
*/
|
|
200
|
+
const FORBIDDEN_KEYS: $ReadOnlyArray<string> = ["__proto__", "constructor", "prototype"];
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether an object is one this grammar carries.
|
|
204
|
+
*
|
|
205
|
+
* The prototype is compared, not `instanceof` and not a duck-type: a class
|
|
206
|
+
* instance, a `Map`, a `Date` and a React element all pass every structural
|
|
207
|
+
* test somebody might reach for, and each of them loses its meaning on the
|
|
208
|
+
* wire. `Object.create(null)` is accepted because it is what a careful caller
|
|
209
|
+
* builds and what this module's own decoder could produce.
|
|
210
|
+
*/
|
|
211
|
+
function isPlainObject(value: interface {}): boolean {
|
|
212
|
+
const prototype: mixed = Object.getPrototypeOf(value);
|
|
213
|
+
return prototype === PLAIN_PROTOTYPE || prototype === null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The prototype every object literal has, captured rather than named.
|
|
218
|
+
*
|
|
219
|
+
* `Object.prototype` is not on Flow's `Object` statics, and the usual
|
|
220
|
+
* substitute — asking whether the prototype's own prototype is `null` — also
|
|
221
|
+
* accepts `Object.create(Object.create(null))`, which is a different set of
|
|
222
|
+
* values. One `{}` at module scope is the exact answer and costs nothing.
|
|
223
|
+
*/
|
|
224
|
+
const PLAIN_PROTOTYPE: mixed = Object.getPrototypeOf({});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The value as a submitted form, or `null`.
|
|
228
|
+
*
|
|
229
|
+
* `typeof` first, because this module is imported by the browser's half and by
|
|
230
|
+
* the server's, and `FormData` is a global that a runtime is allowed not to
|
|
231
|
+
* have. `instanceof` and not a duck-type, for the reason `isPlainObject` gives
|
|
232
|
+
* about prototypes: an object with `entries` and `get` is not a form.
|
|
233
|
+
*
|
|
234
|
+
* It answers with the form rather than with a `boolean` so that the caller
|
|
235
|
+
* that goes on to read the entries has the type from the check rather than
|
|
236
|
+
* from a cast. A Flow type guard would be the direct spelling and is not
|
|
237
|
+
* available here: a guard has to refine the type away on the false branch too,
|
|
238
|
+
* and "this runtime has no `FormData` at all" is a false branch that says
|
|
239
|
+
* nothing about the value.
|
|
240
|
+
*/
|
|
241
|
+
function asFormData(value: mixed): FormData | null {
|
|
242
|
+
if (typeof FormData === "undefined" || !(value instanceof FormData)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** One entry of the explicit walk stack. */
|
|
249
|
+
type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Refuse a value that cannot cross the wire, naming where it was.
|
|
253
|
+
*
|
|
254
|
+
* An explicit stack rather than recursion, for the reason the RSC graph walk
|
|
255
|
+
* gives: a payload arrives from the network, its nesting is the sender's
|
|
256
|
+
* choice, and a recursive walk over it is a stack overflow with an attacker's
|
|
257
|
+
* hand on the depth. Every bound is checked as the walk runs rather than
|
|
258
|
+
* afterwards, so a payload that busts one is refused before the rest of it is
|
|
259
|
+
* visited.
|
|
260
|
+
*/
|
|
261
|
+
export function checkActionValue(root: mixed, label: string): void {
|
|
262
|
+
const stack: Array<Pending> = [{ value: root, path: label, depth: 0 }];
|
|
263
|
+
const seen = new Set<mixed>();
|
|
264
|
+
let remaining = MAX_ACTION_VALUES;
|
|
265
|
+
|
|
266
|
+
while (stack.length > 0) {
|
|
267
|
+
const pending = stack.pop();
|
|
268
|
+
if (pending == null) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const { value, path, depth } = pending;
|
|
272
|
+
|
|
273
|
+
remaining -= 1;
|
|
274
|
+
if (remaining < 0) {
|
|
275
|
+
throw new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
|
|
276
|
+
}
|
|
277
|
+
if (depth > MAX_ACTION_DEPTH) {
|
|
278
|
+
throw new ActionValueError(path, `is nested deeper than ${String(MAX_ACTION_DEPTH)}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (value === null) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const kind = typeof value;
|
|
285
|
+
if (kind === "boolean" || kind === "string") {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (kind === "number") {
|
|
289
|
+
// `NaN` and the infinities have no JSON spelling; `JSON.stringify` writes
|
|
290
|
+
// `null` for each, so accepting one here would mean an action was called
|
|
291
|
+
// with a different number from the one the caller passed.
|
|
292
|
+
if (!Number.isFinite(value)) {
|
|
293
|
+
throw new ActionValueError(path, "is not a finite number");
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (kind !== "object") {
|
|
298
|
+
// `undefined`, a function, a symbol, a bigint. Named rather than
|
|
299
|
+
// lumped together, because "a function cannot cross" is the sentence
|
|
300
|
+
// that tells a caller they passed a callback to a server action.
|
|
301
|
+
throw new ActionValueError(path, `is a ${kind}, which cannot cross to a server action`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const object: interface {} = value as $FlowFixMe;
|
|
305
|
+
if (seen.has(object)) {
|
|
306
|
+
throw new ActionValueError(path, "refers to a value that already appeared in the payload");
|
|
307
|
+
}
|
|
308
|
+
seen.add(object);
|
|
309
|
+
|
|
310
|
+
if (Array.isArray(object)) {
|
|
311
|
+
const items: $ReadOnlyArray<mixed> = object as $FlowFixMe;
|
|
312
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
313
|
+
stack.push({ value: items[index], path: `${path}[${String(index)}]`, depth: depth + 1 });
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!isPlainObject(object)) {
|
|
319
|
+
// A form gets its own sentence. It is the one prototype this grammar
|
|
320
|
+
// does carry, just not here — a `FormData` is an argument of a call, and
|
|
321
|
+
// this walk only ever sees the inside of one, or a result — and "is a
|
|
322
|
+
// class instance" would send the reader looking for a class they did not
|
|
323
|
+
// write.
|
|
324
|
+
if (asFormData(object) != null) {
|
|
325
|
+
throw new ActionValueError(
|
|
326
|
+
path,
|
|
327
|
+
"is a FormData, and a form may only be an argument of a call: never part of a value, " +
|
|
328
|
+
"and never a result",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
throw new ActionValueError(
|
|
332
|
+
path,
|
|
333
|
+
"is a class instance, a Map, a Set, a Date, a React element or another object with a " +
|
|
334
|
+
"prototype, and only arrays and plain objects cross to a server action",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Symbol keys are silently dropped by `JSON.stringify`, so a payload built
|
|
339
|
+
// with one would arrive missing a property nobody could see was missing.
|
|
340
|
+
if (Object.getOwnPropertySymbols(object).length > 0) {
|
|
341
|
+
throw new ActionValueError(path, "has a symbol key, which has no spelling on the wire");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const record: { readonly [string]: mixed } = object as $FlowFixMe;
|
|
345
|
+
for (const key of Object.getOwnPropertyNames(object)) {
|
|
346
|
+
if (FORBIDDEN_KEYS.includes(key)) {
|
|
347
|
+
throw new ActionValueError(`${path}.${key}`, "is a key this grammar never carries");
|
|
348
|
+
}
|
|
349
|
+
stack.push({ value: record[key], path: `${path}.${key}`, depth: depth + 1 });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One form's entries, as pairs of strings, or a named failure.
|
|
356
|
+
*
|
|
357
|
+
* The count is checked as the entries are read rather than afterwards, so an
|
|
358
|
+
* enormous form is refused before the whole of it has been copied. A `File`
|
|
359
|
+
* entry is refused by name: an upload is not in this grammar and the field
|
|
360
|
+
* that carried it is the useful half of saying so.
|
|
361
|
+
*/
|
|
362
|
+
function formEntries(form: FormData, label: string): Array<Array<string>> {
|
|
363
|
+
const entries: Array<Array<string>> = [];
|
|
364
|
+
for (const [name, value] of form.entries()) {
|
|
365
|
+
if (entries.length >= MAX_FORM_ENTRIES) {
|
|
366
|
+
throw new ActionValueError(
|
|
367
|
+
label,
|
|
368
|
+
`carries more than ${String(MAX_FORM_ENTRIES)} form fields`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (name.length > MAX_FORM_NAME_LENGTH) {
|
|
372
|
+
throw new ActionValueError(
|
|
373
|
+
label,
|
|
374
|
+
`has a form field name longer than ${String(MAX_FORM_NAME_LENGTH)} characters`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (typeof value !== "string") {
|
|
378
|
+
throw new ActionValueError(
|
|
379
|
+
`${label} field \`${name}\``,
|
|
380
|
+
"is a file, and a file cannot cross to a server action",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
entries.push([name, value]);
|
|
384
|
+
}
|
|
385
|
+
return entries;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The request body for a call, or a named failure.
|
|
390
|
+
*
|
|
391
|
+
* The browser's half. Refusing here is what turns "the server answered 400"
|
|
392
|
+
* into "argument 2.createdAt is a class instance", at the call site, with a
|
|
393
|
+
* stack that reaches the component.
|
|
394
|
+
*
|
|
395
|
+
* A `FormData` argument becomes the envelope's `form` key and leaves `null` in
|
|
396
|
+
* its own slot, so `args` stays a list of values of exactly the length the
|
|
397
|
+
* call had. Two forms is a refusal rather than a choice: React passes one, and
|
|
398
|
+
* an envelope that could carry several would need to say which is which in a
|
|
399
|
+
* place that is not `at`.
|
|
400
|
+
*/
|
|
401
|
+
export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
402
|
+
if (args.length > MAX_ACTION_ARGUMENTS) {
|
|
403
|
+
throw new ActionValueError(
|
|
404
|
+
"the call",
|
|
405
|
+
`passes ${String(args.length)} arguments, and an action takes at most ` +
|
|
406
|
+
String(MAX_ACTION_ARGUMENTS),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const values: Array<mixed> = [];
|
|
410
|
+
let form: {| readonly at: number, readonly entries: Array<Array<string>> |} | null = null;
|
|
411
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
412
|
+
const argument = args[index];
|
|
413
|
+
const label = `argument ${String(index + 1)}`;
|
|
414
|
+
const submitted = asFormData(argument);
|
|
415
|
+
if (submitted != null) {
|
|
416
|
+
if (form != null) {
|
|
417
|
+
throw new ActionValueError(label, "is a second form, and a call carries at most one");
|
|
418
|
+
}
|
|
419
|
+
form = { at: index, entries: formEntries(submitted, label) };
|
|
420
|
+
values.push(null);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
checkActionValue(argument, label);
|
|
424
|
+
values.push(argument);
|
|
425
|
+
}
|
|
426
|
+
return form == null ? JSON.stringify({ args: values }) : JSON.stringify({ args: values, form });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The arguments a request body holds, or a named failure.
|
|
431
|
+
*
|
|
432
|
+
* The server's half, and the one that faces the network. The shape is exact —
|
|
433
|
+
* one object, one key — because a payload with a key nobody reads is a payload
|
|
434
|
+
* whose sender believed something about it that is not true, and because the
|
|
435
|
+
* only way to keep this closed as it grows is to refuse anything that is not
|
|
436
|
+
* this today.
|
|
437
|
+
*/
|
|
438
|
+
export function decodeActionArguments(text: string): Array<ActionArgument> {
|
|
439
|
+
let parsed: mixed;
|
|
440
|
+
try {
|
|
441
|
+
parsed = JSON.parse(text);
|
|
442
|
+
} catch {
|
|
443
|
+
throw new ActionValueError("the body", "is not JSON");
|
|
444
|
+
}
|
|
445
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
446
|
+
throw new ActionValueError("the body", "is not a JSON object");
|
|
447
|
+
}
|
|
448
|
+
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
|
|
449
|
+
const named = keys.length === 1 ? keys[0] === "args" : keys.length === 2 && keys.includes("form");
|
|
450
|
+
if (!named || !keys.includes("args")) {
|
|
451
|
+
throw new ActionValueError("the body", 'has keys other than "args" and "form"');
|
|
452
|
+
}
|
|
453
|
+
const args: mixed = parsed.args;
|
|
454
|
+
if (!Array.isArray(args)) {
|
|
455
|
+
throw new ActionValueError("the body", 'has an "args" that is not an array');
|
|
456
|
+
}
|
|
457
|
+
if (args.length > MAX_ACTION_ARGUMENTS) {
|
|
458
|
+
throw new ActionValueError(
|
|
459
|
+
"the body",
|
|
460
|
+
`passes more than ${String(MAX_ACTION_ARGUMENTS)} arguments`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
464
|
+
checkActionValue(args[index], `argument ${String(index + 1)}`);
|
|
465
|
+
}
|
|
466
|
+
const decoded: Array<ActionArgument> = args as $FlowFixMe;
|
|
467
|
+
if (keys.length === 2) {
|
|
468
|
+
const at = decodeForm(parsed.form, decoded);
|
|
469
|
+
decoded[at.index] = at.form;
|
|
470
|
+
}
|
|
471
|
+
return decoded;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The envelope's `form`, as a `FormData` and the position it belongs at.
|
|
476
|
+
*
|
|
477
|
+
* Every field of the envelope is checked before anything is built, and the
|
|
478
|
+
* slot it names must already hold `null`: `args` and `form` are two statements
|
|
479
|
+
* about one call, and a payload that makes both about the same argument is a
|
|
480
|
+
* payload whose sender believed something that is not true. The only thing
|
|
481
|
+
* constructed is a `FormData`, from strings, and which constructor that is was
|
|
482
|
+
* decided here rather than by the bytes.
|
|
483
|
+
*/
|
|
484
|
+
function decodeForm(
|
|
485
|
+
candidate: mixed,
|
|
486
|
+
args: $ReadOnlyArray<ActionArgument>,
|
|
487
|
+
): {| readonly index: number, readonly form: FormData |} {
|
|
488
|
+
if (typeof FormData === "undefined") {
|
|
489
|
+
throw new ActionValueError("the body", "carries a form, and this runtime has no FormData");
|
|
490
|
+
}
|
|
491
|
+
if (candidate == null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
492
|
+
throw new ActionValueError("the form", "is not a JSON object");
|
|
493
|
+
}
|
|
494
|
+
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(candidate);
|
|
495
|
+
if (keys.length !== 2 || !keys.includes("at") || !keys.includes("entries")) {
|
|
496
|
+
throw new ActionValueError("the form", 'has keys other than "at" and "entries"');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const at: mixed = candidate.at;
|
|
500
|
+
if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at >= args.length) {
|
|
501
|
+
throw new ActionValueError("the form", "names no argument of this call");
|
|
502
|
+
}
|
|
503
|
+
if (args[at] !== null) {
|
|
504
|
+
throw new ActionValueError(
|
|
505
|
+
"the form",
|
|
506
|
+
`names argument ${String(at + 1)}, which the payload also gives a value`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const entries: mixed = candidate.entries;
|
|
511
|
+
if (!Array.isArray(entries)) {
|
|
512
|
+
throw new ActionValueError("the form", 'has an "entries" that is not an array');
|
|
513
|
+
}
|
|
514
|
+
if (entries.length > MAX_FORM_ENTRIES) {
|
|
515
|
+
throw new ActionValueError(
|
|
516
|
+
"the form",
|
|
517
|
+
`carries more than ${String(MAX_FORM_ENTRIES)} form fields`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const form: FormData = new FormData();
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
524
|
+
throw new ActionValueError("the form", "has an entry that is not a name and a value");
|
|
525
|
+
}
|
|
526
|
+
const [name, value] = entry;
|
|
527
|
+
if (typeof name !== "string" || typeof value !== "string") {
|
|
528
|
+
throw new ActionValueError("the form", "has an entry whose name or value is not a string");
|
|
529
|
+
}
|
|
530
|
+
if (name.length > MAX_FORM_NAME_LENGTH) {
|
|
531
|
+
throw new ActionValueError(
|
|
532
|
+
"the form",
|
|
533
|
+
`has a form field name longer than ${String(MAX_FORM_NAME_LENGTH)} characters`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
form.append(name, value);
|
|
537
|
+
}
|
|
538
|
+
return { index: at, form };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The response body for a result, or a named failure.
|
|
543
|
+
*
|
|
544
|
+
* An action that returns nothing answers `{}` rather than `{"value":null}`:
|
|
545
|
+
* JSON has no `undefined`, and turning one into `null` would make an action
|
|
546
|
+
* declared `Promise<void>` resolve to something on the browser side.
|
|
547
|
+
*/
|
|
548
|
+
export function encodeActionResult(value: mixed): string {
|
|
549
|
+
if (value === undefined) {
|
|
550
|
+
return "{}";
|
|
551
|
+
}
|
|
552
|
+
checkActionValue(value, "the result");
|
|
553
|
+
return JSON.stringify({ value });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The result a response body holds, or a named failure.
|
|
558
|
+
*
|
|
559
|
+
* The same grammar in the other direction, and checked rather than trusted:
|
|
560
|
+
* the browser is talking to whatever answered, which on a compromised network
|
|
561
|
+
* is not the server. What it can be handed is therefore data and never an
|
|
562
|
+
* object of somebody's choosing, exactly as on the way out.
|
|
563
|
+
*/
|
|
564
|
+
export function decodeActionResult(text: string): ActionValue | void {
|
|
565
|
+
let parsed: mixed;
|
|
566
|
+
try {
|
|
567
|
+
parsed = JSON.parse(text);
|
|
568
|
+
} catch {
|
|
569
|
+
throw new ActionValueError("the answer", "is not JSON");
|
|
570
|
+
}
|
|
571
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
572
|
+
throw new ActionValueError("the answer", "is not a JSON object");
|
|
573
|
+
}
|
|
574
|
+
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
|
|
575
|
+
if (keys.length === 0) {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
if (keys.length !== 1 || keys[0] !== "value") {
|
|
579
|
+
throw new ActionValueError("the answer", 'has keys other than "value"');
|
|
580
|
+
}
|
|
581
|
+
const value: mixed = parsed.value;
|
|
582
|
+
checkActionValue(value, "the result");
|
|
583
|
+
return value as $FlowFixMe;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Whether `text` is the canonical spelling of an action id.
|
|
588
|
+
*
|
|
589
|
+
* Sixty-four lowercase hexadecimal characters, which is what
|
|
590
|
+
* `uf_rsc::ActionId::to_hex` writes. Checked before the id reaches the table
|
|
591
|
+
* so an oversized or repeated header cannot make the endpoint do work, and
|
|
592
|
+
* hand-written rather than a regular expression because this reads a header a
|
|
593
|
+
* client chose (`docs/security.md`, rule 5).
|
|
594
|
+
*/
|
|
595
|
+
export function isActionId(text: string): boolean {
|
|
596
|
+
if (text.length !== 64) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
600
|
+
const code = text.charCodeAt(index);
|
|
601
|
+
const digit = code >= 0x30 && code <= 0x39;
|
|
602
|
+
const lower = code >= 0x61 && code <= 0x66;
|
|
603
|
+
if (!digit && !lower) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return true;
|
|
608
|
+
}
|