@uniflowed/router 0.0.0-alpha.9 → 0.1.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.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1593 -1341
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -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
+ }