@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.
Files changed (51) hide show
  1. package/action.js +344 -0
  2. package/client.js +263 -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 +646 -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/form-action.js +243 -0
  23. package/internal/head.js +219 -0
  24. package/internal/hydrate-options.js +38 -0
  25. package/internal/hydration.js +1085 -0
  26. package/internal/inspector.js +626 -0
  27. package/internal/native-links.js +67 -0
  28. package/internal/native-tree.js +89 -0
  29. package/internal/navigation-cache.js +181 -0
  30. package/internal/payload-rows.js +270 -0
  31. package/internal/payload.js +685 -0
  32. package/internal/prepare-document.js +54 -0
  33. package/internal/react-version.js +77 -0
  34. package/internal/resolve.js +1617 -0
  35. package/internal/resolved-summary.js +199 -0
  36. package/internal/routing.js +478 -0
  37. package/internal/runtime.js +1593 -1341
  38. package/internal/server-instrumentation.js +12 -0
  39. package/internal/server-route.js +58 -0
  40. package/internal/shell.js +132 -0
  41. package/internal/stream.js +766 -21
  42. package/middleware.js +274 -22
  43. package/native-navigation.js +217 -0
  44. package/native.js +416 -0
  45. package/package.json +48 -7
  46. package/routing.js +51 -0
  47. package/rsc-client.js +122 -0
  48. package/rsc-ssr.js +641 -0
  49. package/rsc.js +402 -0
  50. package/server-components.js +159 -0
  51. package/server.js +263 -106
@@ -0,0 +1,646 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the endpoint a server action is dialled at.
4
+ //
5
+ // `createActionDispatcher` is the fourth thing `virtual:uf/server` exports and
6
+ // the third thing a host calls, between the middleware and the route handlers.
7
+ // It takes a table of `{ id, module, export, load }` built from the RSC
8
+ // manifest, and answers a request that carries an action id — or declines,
9
+ // with `null`, so that everything else about the request is somebody else's.
10
+ //
11
+ // # This is a public endpoint, and it is treated as one
12
+ //
13
+ // A `"use server"` export is reachable by anything that can make an HTTP
14
+ // request from the moment the build contains it. Every rule below is one of
15
+ // `docs/security.md`'s applied to that fact, and the table there has the row.
16
+ //
17
+ // **1. Only the build's own actions are dialable.** The table is the
18
+ // manifest's `serverActions`, which `uf_rsc` writes only for an action some
19
+ // module that can hand it across a client boundary reaches
20
+ // (`ActionExposure::CallableEndpoint`). A `"use server"` function nothing
21
+ // exposes has no row and therefore no endpoint. There is no path from a
22
+ // request to a module specifier, a file name or an export name: the id selects
23
+ // a row, and the row was decided at build time.
24
+ //
25
+ // **2. The id is not guessable.** `uf_rsc::ActionId` is
26
+ // `HMAC-SHA256(build id, module ‖ export ‖ kind)`, so the whole repository plus
27
+ // a published sourcemap is not enough to derive the id of a function the
28
+ // interface does not offer, and a rebuild changes every id.
29
+ //
30
+ // **3. One answer for every failed lookup.** `404`, with no body, for a
31
+ // malformed id, a well-formed id nobody has, and an id that names something not
32
+ // callable. `select` compares against every row whatever happens, and compares
33
+ // with `sameId`, so neither the answer nor the time taken says which. This is
34
+ // `ServerActionRegistry::resolve`'s contract, kept on the other side of the
35
+ // wire.
36
+ //
37
+ // **4. Cross-site calls cannot happen by accident, three times over.** The
38
+ // call is a `POST` carrying `uf-action`, which is not a header a simple request
39
+ // may set, so a cross-origin caller needs a preflight and nothing here answers
40
+ // one. It must be `application/json`, which is not a content type a `<form>`
41
+ // can produce. And `Origin` must be present and must equal `Host`. Any one of
42
+ // the three would do; all three are here because the cost is three `if`s and
43
+ // the failure is somebody's account.
44
+ //
45
+ // `Origin` is compared against `Host` and against nothing else. `Host` is what
46
+ // the browser was talking to, which is exactly the question being asked, and
47
+ // `X-Forwarded-Host` is a string the caller wrote (`docs/security.md`, rule 2)
48
+ // — so a proxy in front of a uf application has to preserve `Host`, and one
49
+ // that rewrites it turns every action call into a `403` rather than into a
50
+ // hole. A refusal is the right way for that to be discovered.
51
+ //
52
+ // **5. The argument boundary is `./action-wire.js` and only that.** Bounded
53
+ // bytes, bounded depth, bounded count, valid UTF-8, plain JSON data, no
54
+ // prototype keys, no constructor named by the payload. See that module's
55
+ // header for what is excluded and why.
56
+ //
57
+ // A submitted form is inside that boundary and does not widen it: `<form
58
+ // action={fn}>` on a hydrated page reaches here as the same `application/json`
59
+ // body, with the form's entries beside the values under a `form` key, bounded
60
+ // in count and in field-name length and holding strings only. Nothing about it
61
+ // is multipart and nothing about it is a content type a cross-origin `<form>`
62
+ // could produce, so rule 4 is exactly as true of a form call as of any other.
63
+ //
64
+ // # The second door: a form posted before the page hydrated
65
+ //
66
+ // React's progressive enhancement is a *native* form post, and a native post is
67
+ // the one request rule 4 was written to keep out. So it is not let in through
68
+ // that door. It has its own, narrower than the first, and it is worth being
69
+ // exact about what it keeps of the six rules above:
70
+ //
71
+ // - **Rules 1, 2, 3, 5 and 6 hold unchanged.** The id selects a row and is
72
+ // compared the same way; a failed lookup is the same `404`; the bound
73
+ // arguments and the form cross through `decodeActionArguments`, so the
74
+ // grammar, the counts, the depth and the field limits are the JSON call's
75
+ // own; a throw is the same fixed `500`.
76
+ // - **Rule 4 keeps one of its three guards.** A native post is a simple
77
+ // request, so there is no custom header and no JSON content type to lean on.
78
+ // What is left is `Origin`, which every browser sends on a `POST` and which
79
+ // must equal `Host` exactly as before — and `Sec-Fetch-Site`, which must say
80
+ // `same-origin` when the browser sends it. A cross-site form, a sandboxed
81
+ // frame (`Origin: null`) and a request with no `Origin` are `403`s.
82
+ // - **It accepts one content type**, `application/x-www-form-urlencoded`, which
83
+ // is what `$$FORM_ACTION` asks React to write. Not multipart: a file cannot
84
+ // cross to an action, and a multipart parser is surface uf does not open.
85
+ // - **It is recognised by its fields, read from a copy of the body.** A post
86
+ // that carries no `$uf_ref_` field is somebody else's — an ordinary form to a
87
+ // route handler — and is declined with `null` and its body untouched. So is
88
+ // one larger than an action accepts or not UTF-8, because it cannot be known
89
+ // to be an action post without reading it; it goes on to the route handlers,
90
+ // and a page answers a `POST` with a `404`.
91
+ //
92
+ // The answer is a document rather than JSON, because a person is looking at it:
93
+ // a `303` back to the page for a plain form action, the page rendered again
94
+ // with the action's result as the submitting `useActionState`'s state (the
95
+ // host supplies that render as `postback`), or a `303` to wherever
96
+ // `redirect()` pointed. See `./form-action.js` for the fields and
97
+ // ubugeeei-prod/uf#1358.
98
+ //
99
+ // **6. Nothing about a failure goes back.** An action that throws is a `500`
100
+ // with a fixed body; the exception goes to the host's error reporting. A
101
+ // message, a name or a stack in that response is an application's internals
102
+ // published to whoever asked for them, and the same is true in development —
103
+ // `uf dev` and `uf build` have to agree about what this endpoint answers, and
104
+ // the terminal is where a developer reads the exception anyway.
105
+ //
106
+ // # What a middleware does and does not do for an action
107
+ //
108
+ // The call is a `POST` to the page's own URL rather than to a path uf reserves,
109
+ // so the middleware that guards that path runs above it exactly as it does for
110
+ // the page — no new route to collide with a project's own, and no second
111
+ // spelling of "which guard applies here".
112
+ //
113
+ // That is a convenience and it is not a boundary, because the URL is the
114
+ // caller's to choose: a client that wants to skip the guard on `/dashboard`
115
+ // posts the same id to `/`. **A server action authorizes itself.** It is the
116
+ // unit of authorization, the way a route handler is, and a `"use server"`
117
+ // function that relies on a path guard having run is a function with a hole in
118
+ // it. Written here because this is the file somebody reads before deciding
119
+ // otherwise.
120
+
121
+ import { traceRequestPhase, reportRequestError } from "@uniflowed/server/instrumentation";
122
+
123
+ import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
124
+
125
+ import {
126
+ ACTION_CONTENT_TYPE,
127
+ ACTION_HEADER,
128
+ type ActionArgument,
129
+ ActionValueError,
130
+ MAX_ACTION_BODY_BYTES,
131
+ decodeActionArguments,
132
+ encodeActionResult,
133
+ isActionId,
134
+ } from "./action-wire.js";
135
+ import { addressOf } from "./base-path.js";
136
+ import {
137
+ FORM_ACTION_CONTENT_TYPE,
138
+ type FormPost,
139
+ type FormState,
140
+ readFormPost,
141
+ } from "./form-action.js";
142
+ import { requireRequest } from "./request.js";
143
+ import { RedirectError } from "./routing.js";
144
+
145
+ export type { FormState } from "./form-action.js";
146
+
147
+ /**
148
+ * What a host gives the dispatcher beyond the request.
149
+ *
150
+ * `postback` renders the page the request is for, with a `useActionState`'s
151
+ * result as React's `formState`, and is how a form posted before hydration
152
+ * gets the page back with its answer in it. A host that supplies none still
153
+ * serves native posts: the answer is a `303` back to the page, which runs the
154
+ * action and loses only the state.
155
+ */
156
+ export type ActionDispatchOptions = {|
157
+ readonly postback?: (formState: FormState) => Promise<Response>,
158
+ |};
159
+
160
+ /** A module holding server actions, as the generated table loads it. */
161
+ export type ActionModule = { readonly [name: string]: mixed };
162
+
163
+ /**
164
+ * One callable action, as `virtual:uf/actions` writes it.
165
+ *
166
+ * `module` and `export` are here for the error a broken build produces, never
167
+ * for dispatch: dispatch is `id` against `id`. Nothing a request carries is
168
+ * ever joined onto a path or used to name an export.
169
+ */
170
+ export type ActionRecord = {|
171
+ /** The keyed id, 64 lowercase hexadecimal characters. */
172
+ readonly id: string,
173
+ /** Declaring module, relative to the project root. */
174
+ readonly module: string,
175
+ /** The exported binding, or `default`. */
176
+ readonly export: string,
177
+ /** Import the declaring module. */
178
+ readonly load: () => Promise<ActionModule>,
179
+ |};
180
+
181
+ /** Headers every answer carries, whatever it says. */
182
+ const ANSWER_HEADERS: { readonly [string]: string } = {
183
+ "content-type": "application/json; charset=utf-8",
184
+ // An action call is a `POST` and is not cached by anything by default. Said
185
+ // anyway, because "not cached by default" is the sentence in front of every
186
+ // cache-poisoning advisory in `docs/security.md`.
187
+ "cache-control": "no-store",
188
+ };
189
+
190
+ /**
191
+ * Match a request against the action table and run what it names.
192
+ *
193
+ * Returns `null` when the request carries no action id, which is the caller's
194
+ * signal to carry on: everything that is not an action call is a page, a
195
+ * handler or a 404, and this declines all of them. Every other outcome —
196
+ * including every refusal — is a `Response`, because a request that named an
197
+ * action and did not get to call one must not fall through to something else
198
+ * that might answer it.
199
+ */
200
+ export function createActionDispatcher(options: {|
201
+ readonly actions: $ReadOnlyArray<ActionRecord>,
202
+ |}): (request: Request, settings?: ActionDispatchOptions) => Promise<Response | null> {
203
+ const table = options.actions;
204
+
205
+ return async function callAction(
206
+ request: Request,
207
+ settings?: ActionDispatchOptions,
208
+ ): Promise<Response | null> {
209
+ // The host's half of the contract, checked rather than assumed, exactly as
210
+ // `dispatch` and `runMiddleware` check it: an action that calls `cookies()`
211
+ // has to answer about the request it is inside.
212
+ requireRequest("callAction");
213
+
214
+ const id = request.headers.get(ACTION_HEADER);
215
+ if (id == null) {
216
+ // Not a JSON call. It may still be a form posted before its page
217
+ // hydrated, which is the second door in the header.
218
+ return nativeFormPost(table, request, settings?.postback);
219
+ }
220
+
221
+ // Cheapest and most protective first, and all of it before a byte of the
222
+ // body is read.
223
+ if (request.method.toUpperCase() !== "POST") {
224
+ return new Response(null, { status: 405, headers: { ...ANSWER_HEADERS, allow: "POST" } });
225
+ }
226
+ const native = request.headers.has("uf-native-action");
227
+ if (native ? !nativeActionAllowed(request) : !sameOrigin(request)) {
228
+ return refusal(403);
229
+ }
230
+ if (!isJson(request.headers.get("content-type"))) {
231
+ return refusal(415);
232
+ }
233
+
234
+ const body = await readBoundedText(request, MAX_ACTION_BODY_BYTES);
235
+ if (body == null) {
236
+ return refusal(413);
237
+ }
238
+
239
+ let args: Array<ActionArgument>;
240
+ try {
241
+ args = decodeActionArguments(body);
242
+ } catch (error) {
243
+ // The reason is the sender's own payload described back to them, which
244
+ // is a thing to write in a log and not a thing to answer with.
245
+ if (!(error instanceof ActionValueError)) {
246
+ throw error;
247
+ }
248
+ return refusal(400);
249
+ }
250
+
251
+ // Decoded before the lookup, so that a malformed payload and an unknown id
252
+ // cannot be told apart by trying one against the other.
253
+ const record = isActionId(id) ? select(table, id) : null;
254
+ if (record == null) {
255
+ return refusal(404);
256
+ }
257
+
258
+ let action: mixed;
259
+ try {
260
+ const module = await record.load();
261
+ action = module[record.export];
262
+ } catch (error) {
263
+ report(record, error);
264
+ return refusal(500);
265
+ }
266
+ if (typeof action !== "function") {
267
+ // The RSC graph rejects a `"use server"` export that is not an async
268
+ // function at build time, so reaching this means the manifest and the
269
+ // modules disagree — a stale `.uf/rsc/uf-rsc-manifest.json`, or a build
270
+ // half-written. It is uf's bug, not the caller's, so it is reported and
271
+ // answered as a `500`.
272
+ report(record, new Error(`export \`${record.export}\` is not a function`));
273
+ return refusal(500);
274
+ }
275
+
276
+ // Everything from here to the answer runs as the thing that owns this
277
+ // response, which is what makes `draftMode().enable()` legal in an action:
278
+ // a `"use server"` function is one of the two places uf lets draft mode be
279
+ // changed, and the `Set-Cookie` it decides on is written onto the response
280
+ // this returns rather than onto an object the host discards. See
281
+ // ubugeeei-prod/uf#282 and `asResponder`.
282
+ //
283
+ // The refusals stay outside it. A `403` for a cross-origin call must not
284
+ // carry a cookie the caller asked for, and a scope that covered them would
285
+ // be a scope in which nothing ran that could have asked.
286
+ return asResponder("a server action", async () => {
287
+ let result: mixed;
288
+ try {
289
+ // The build-time contract says this is `async (...ActionArgument) => …`
290
+ // (`ServerActionBoundary` in `../action.js`), and Flow cannot read that
291
+ // through a module loaded by a thunk. The arguments are the ones
292
+ // `decodeActionArguments` produced, so what is unchecked here is the
293
+ // shape of the function and not the shape of the payload.
294
+ const call = action as $FlowFixMe;
295
+ result = await traceRequestPhase("action", () => call(...args));
296
+ } catch (error) {
297
+ report(record, error);
298
+ return refusal(500);
299
+ }
300
+
301
+ let answer: string;
302
+ try {
303
+ answer = encodeActionResult(result);
304
+ } catch (error) {
305
+ // The action ran and its return value cannot cross. Flow says so at
306
+ // build time — `ServerActionBoundary` in `../action.js` holds every
307
+ // action's return type against the grammar — so this is the case where
308
+ // it was reached anyway, and half a value is worse than none.
309
+ report(record, error);
310
+ return refusal(500);
311
+ }
312
+ return new Response(answer, { status: 200, headers: { ...ANSWER_HEADERS } });
313
+ });
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Answer a form the browser posted natively, or decline with `null`.
319
+ *
320
+ * The header's second door. Declines everything that is not a urlencoded
321
+ * `POST` carrying a `$uf_ref_` field, and answers everything that is,
322
+ * refusals included — the same promise the JSON door makes, for the same
323
+ * reason.
324
+ */
325
+ async function nativeFormPost(
326
+ table: $ReadOnlyArray<ActionRecord>,
327
+ request: Request,
328
+ postback: ?(formState: FormState) => Promise<Response>,
329
+ ): Promise<Response | null> {
330
+ if (request.method.toUpperCase() !== "POST") {
331
+ return null;
332
+ }
333
+ if (!isMedia(request.headers.get("content-type"), FORM_ACTION_CONTENT_TYPE)) {
334
+ return null;
335
+ }
336
+ // A copy, so that a form which turns out not to be an action post reaches the
337
+ // route handler with its body unread.
338
+ const text = await readBoundedText(request.clone(), MAX_ACTION_BODY_BYTES);
339
+ if (text == null) {
340
+ return null;
341
+ }
342
+ const post = readFormPost(new URLSearchParams(text));
343
+ if (post == null) {
344
+ return null;
345
+ }
346
+
347
+ // An action post from here on, and every outcome is an answer.
348
+ if (!sameOrigin(request) || !sameSiteFetch(request)) {
349
+ return refusal(403);
350
+ }
351
+
352
+ let args: Array<ActionArgument>;
353
+ let bound: number;
354
+ try {
355
+ ({ args, bound } = formArguments(post));
356
+ } catch (error) {
357
+ if (!(error instanceof ActionValueError)) {
358
+ throw error;
359
+ }
360
+ return refusal(400);
361
+ }
362
+
363
+ const id = post.id;
364
+ const record = id != null && isActionId(id) ? select(table, id) : null;
365
+ if (record == null) {
366
+ return refusal(404);
367
+ }
368
+
369
+ let action: mixed;
370
+ try {
371
+ const module = await record.load();
372
+ action = module[record.export];
373
+ } catch (error) {
374
+ report(record, error);
375
+ return refusal(500);
376
+ }
377
+ if (typeof action !== "function") {
378
+ report(record, new Error(`export \`${record.export}\` is not a function`));
379
+ return refusal(500);
380
+ }
381
+
382
+ const url = new URL(request.url);
383
+ const stateKey = post.stateKey;
384
+ return asResponder("a server action", async () => {
385
+ let result: mixed;
386
+ try {
387
+ const call = action as $FlowFixMe;
388
+ result = await traceRequestPhase("action", () => call(...args));
389
+ } catch (error) {
390
+ if (error instanceof RedirectError) {
391
+ return seeOther(addressOf(error.to));
392
+ }
393
+ report(record, error);
394
+ return refusal(500);
395
+ }
396
+ if (stateKey == null || postback == null) {
397
+ // Post/redirect/get: the page again, by a `GET`, so a reload does not
398
+ // submit the form a second time.
399
+ return seeOther(addressOf(url.pathname + url.search));
400
+ }
401
+ try {
402
+ // Held to the grammar for the reason the JSON door holds a result to it:
403
+ // this value is written into a document a browser parses.
404
+ encodeActionResult(result);
405
+ } catch (error) {
406
+ report(record, error);
407
+ return refusal(500);
408
+ }
409
+ // `bound - 1`: `useActionState` bound the previous state itself, and
410
+ // React compares the count of the bindings the *action* had.
411
+ return postback([result, stateKey, record.id, bound - 1]);
412
+ });
413
+ }
414
+
415
+ /**
416
+ * The arguments a native post calls its action with: the bound ones, then
417
+ * the form.
418
+ *
419
+ * Built into the JSON call's own envelope and decoded by the JSON call's own
420
+ * decoder, so there is one grammar and one set of limits, not a second one for
421
+ * forms that could drift from the first.
422
+ */
423
+ function formArguments(post: FormPost): {|
424
+ readonly args: Array<ActionArgument>,
425
+ readonly bound: number,
426
+ |} {
427
+ let bound: Array<ActionArgument> = [];
428
+ if (post.bound != null) {
429
+ bound = decodeActionArguments(post.bound);
430
+ if (bound.some((value) => value instanceof FormData)) {
431
+ throw new ActionValueError("the bound arguments", "carry a form");
432
+ }
433
+ }
434
+ const envelope = JSON.stringify({
435
+ args: [...bound, null],
436
+ form: { at: bound.length, entries: post.entries },
437
+ });
438
+ return { args: decodeActionArguments(envelope), bound: bound.length };
439
+ }
440
+
441
+ /**
442
+ * Whether the browser says the request came from this origin, when it says.
443
+ *
444
+ * `Sec-Fetch-Site` is a forbidden header name, so a page cannot set it; a
445
+ * browser that sends it and says anything but `same-origin` is describing a
446
+ * cross-site form. Absent is not a refusal on its own — older browsers do not
447
+ * send it, and `Origin` has already been required.
448
+ */
449
+ function sameSiteFetch(request: Request): boolean {
450
+ const site = request.headers.get("sec-fetch-site");
451
+ return site == null || site === "same-origin";
452
+ }
453
+
454
+ /** A `303 See Other`, which a browser follows with a `GET`. */
455
+ function seeOther(location: string): Response {
456
+ return new Response(null, {
457
+ status: 303,
458
+ headers: { location, "cache-control": "no-store" },
459
+ });
460
+ }
461
+
462
+ /**
463
+ * The row with this id, or `null`, without saying which by taking longer.
464
+ *
465
+ * Mirrors `ServerActionRegistry::lookup`: every row is visited whatever
466
+ * happens, the comparison is over the whole id, and the selected index is
467
+ * carried in arithmetic rather than in a branch. Two rows can never share an
468
+ * id, so the last match is the only match.
469
+ */
470
+ function select(table: $ReadOnlyArray<ActionRecord>, id: string): ActionRecord | null {
471
+ let selected = 0;
472
+ let found = 0;
473
+ for (let index = 0; index < table.length; index += 1) {
474
+ const matches = sameId(table[index].id, id);
475
+ const mask = -matches;
476
+ selected = (selected & ~mask) | (index & mask);
477
+ found |= matches;
478
+ }
479
+ return found === 1 ? table[selected] : null;
480
+ }
481
+
482
+ /**
483
+ * Whether two ids are the same, in time that does not depend on how much of
484
+ * one matched.
485
+ *
486
+ * The length is compared first and that is not a leak: every action id is
487
+ * exactly 64 characters and `isActionId` has already said so about this one.
488
+ */
489
+ function sameId(left: string, right: string): number {
490
+ if (left.length !== right.length) {
491
+ return 0;
492
+ }
493
+ let difference = 0;
494
+ for (let index = 0; index < left.length; index += 1) {
495
+ difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
496
+ }
497
+ return difference === 0 ? 1 : 0;
498
+ }
499
+
500
+ /**
501
+ * Whether the request came from the page it claims to have come from.
502
+ *
503
+ * Deny by default: a call with no `Origin` is refused rather than trusted,
504
+ * because "the header was absent" and "the header matched" are not the same
505
+ * fact and only one of them is a browser saying where it was.
506
+ */
507
+ function sameOrigin(request: Request): boolean {
508
+ const origin = request.headers.get("origin");
509
+ const host = request.headers.get("host");
510
+ if (origin == null || host == null) {
511
+ return false;
512
+ }
513
+ let parsed: URL;
514
+ try {
515
+ // `Origin: null` — a sandboxed frame, a `data:` document, some redirects —
516
+ // is not a URL and is refused here rather than being special-cased into a
517
+ // value that could match something.
518
+ parsed = new URL(origin);
519
+ } catch {
520
+ return false;
521
+ }
522
+ // `host` on both sides, so the port is part of the comparison: `:5173` and
523
+ // `:5174` on one machine are two origins and a cookie tells them apart.
524
+ return parsed.host === host;
525
+ }
526
+
527
+ /**
528
+ * Whether the declared content type is the one this endpoint accepts.
529
+ *
530
+ * The parameters after `;` are ignored — `application/json; charset=utf-8` is
531
+ * the same media type — and the media type itself must match exactly. A
532
+ * `+json` suffix is not accepted: the point of the check is that a form cannot
533
+ * produce this string, and a widened match is a widened set of things that
534
+ * can.
535
+ */
536
+ function isJson(declared: string | null): boolean {
537
+ return isMedia(declared, ACTION_CONTENT_TYPE);
538
+ }
539
+
540
+ /** Whether `declared` is exactly the media type `expected`, parameters aside. */
541
+ function isMedia(declared: string | null, expected: string): boolean {
542
+ if (declared == null) {
543
+ return false;
544
+ }
545
+ const semicolon = declared.indexOf(";");
546
+ const media = (semicolon === -1 ? declared : declared.slice(0, semicolon)).trim().toLowerCase();
547
+ return media === expected;
548
+ }
549
+
550
+ /**
551
+ * The body as text, or `null` when it is larger than `limit`.
552
+ *
553
+ * Counted as it arrives rather than trusted from `Content-Length`, because a
554
+ * chunked request declares no length and a declared one is the sender's claim.
555
+ * The declared length is still read first, so an oversized request that is
556
+ * honest about it is refused before its body is transferred at all.
557
+ *
558
+ * `fatal: true` refuses input that is not valid UTF-8 instead of replacing
559
+ * each bad sequence with U+FFFD, which is `docs/security.md`'s row about
560
+ * non-UTF-8 input applied at the one place uf decodes bytes a client sent.
561
+ */
562
+ async function readBoundedText(request: Request, limit: number): Promise<string | null> {
563
+ const declared = request.headers.get("content-length");
564
+ if (declared != null) {
565
+ const size = Number(declared);
566
+ if (!Number.isInteger(size) || size < 0 || size > limit) {
567
+ return null;
568
+ }
569
+ }
570
+
571
+ // Flow's `Request` predates a body one can read as a stream, so the property
572
+ // is reached through a cast and the shape it is used at is checked below.
573
+ // `ReadableStream` is not polymorphic in the DOM declarations uf checks
574
+ // against, so the element type is asserted at the one place it is read
575
+ // rather than written here.
576
+ const body: ReadableStream | null = (request as $FlowFixMe).body;
577
+ if (body == null) {
578
+ return "";
579
+ }
580
+
581
+ const reader = body.getReader();
582
+ const chunks: Array<Uint8Array> = [];
583
+ let total = 0;
584
+ try {
585
+ for (;;) {
586
+ const step = await reader.read();
587
+ if (step.done === true) {
588
+ break;
589
+ }
590
+ const chunk: Uint8Array = step.value as $FlowFixMe;
591
+ total += chunk.byteLength;
592
+ if (total > limit) {
593
+ // Cancelled rather than dropped, so the sender stops instead of
594
+ // filling a queue nobody is reading. The reason is given because the
595
+ // declarations uf checks against require one, and "too large" is what
596
+ // a cancelled read of an oversized body is about.
597
+ await reader.cancel("the body is larger than a server action accepts");
598
+ return null;
599
+ }
600
+ chunks.push(chunk);
601
+ }
602
+ } catch {
603
+ return null;
604
+ }
605
+
606
+ const joined = new Uint8Array(total);
607
+ let at = 0;
608
+ for (const chunk of chunks) {
609
+ joined.set(chunk, at);
610
+ at += chunk.byteLength;
611
+ }
612
+ try {
613
+ return new TextDecoder("utf-8", { fatal: true }).decode(joined);
614
+ } catch {
615
+ return null;
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Every refusal, with the same body whatever it was.
621
+ *
622
+ * The status says what a caller may usefully do differently — retry with a
623
+ * smaller body, send the header, look elsewhere — and the body says nothing at
624
+ * all, because everything that could go in it is about the build.
625
+ */
626
+ function refusal(status: number): Response {
627
+ return new Response('{"error":"server action refused"}', {
628
+ status,
629
+ headers: { ...ANSWER_HEADERS },
630
+ });
631
+ }
632
+
633
+ /**
634
+ * Report an action that failed, where the host's error reporting can see it.
635
+ *
636
+ * The console, for the reason `createFetchHandler` gives about its own
637
+ * `onError`: this runs inside a worker or a serverless invocation as often as
638
+ * in a terminal, and losing the exception entirely is worse than putting it
639
+ * somewhere a platform is expected to collect. The module and export are named
640
+ * here and nowhere the caller can read.
641
+ */
642
+ function report(record: ActionRecord, error: mixed): void {
643
+ reportRequestError(error, "action");
644
+ // eslint-disable-next-line no-console
645
+ console.error(`uf: server action \`${record.export}\` in \`${record.module}\` failed`, error);
646
+ }