@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.40

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 (42) hide show
  1. package/action.js +301 -0
  2. package/client.js +295 -8
  3. package/handler.js +101 -17
  4. package/index.js +71 -4
  5. package/internal/action-endpoint.js +434 -0
  6. package/internal/action-wire.js +608 -0
  7. package/internal/base-path.js +175 -0
  8. package/internal/boundaries.js +481 -0
  9. package/internal/boundary-data.js +88 -0
  10. package/internal/compose.js +490 -0
  11. package/internal/devtools.js +131 -0
  12. package/internal/diagnostics.js +169 -0
  13. package/internal/error-view.js +193 -0
  14. package/internal/flight-browser.js +228 -0
  15. package/internal/flight-chunks.js +205 -0
  16. package/internal/flight-ssr.js +78 -0
  17. package/internal/flight.js +158 -0
  18. package/internal/head.js +219 -0
  19. package/internal/hydration.js +1085 -0
  20. package/internal/inspector.js +626 -0
  21. package/internal/navigation-cache.js +144 -0
  22. package/internal/payload-rows.js +270 -0
  23. package/internal/payload.js +685 -0
  24. package/internal/prepare-document.js +49 -0
  25. package/internal/react-version.js +77 -0
  26. package/internal/request.js +43 -0
  27. package/internal/resolve.js +1615 -0
  28. package/internal/resolved-summary.js +199 -0
  29. package/internal/routing.js +474 -0
  30. package/internal/runtime.js +1511 -543
  31. package/internal/server-route.js +58 -0
  32. package/internal/shell.js +115 -0
  33. package/internal/stream.js +1084 -0
  34. package/middleware.js +350 -0
  35. package/native.js +408 -0
  36. package/package.json +36 -7
  37. package/routing.js +51 -0
  38. package/rsc-client.js +120 -0
  39. package/rsc-ssr.js +440 -0
  40. package/rsc.js +334 -0
  41. package/server-components.js +159 -0
  42. package/server.js +448 -75
@@ -0,0 +1,434 @@
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}>` reaches here as the same `application/json` body, with the
59
+ // form's entries beside the values under a `form` key, bounded in count and in
60
+ // field-name length and holding strings only. Nothing about it is multipart
61
+ // and nothing about it is a content type a cross-origin `<form>` could
62
+ // produce, so rule 4 is exactly as true of a form call as of any other. The
63
+ // cost of keeping it that way is written down where it is paid — a form that
64
+ // submits before its page has hydrated throws in the page rather than posting
65
+ // anywhere, because React writes `action="javascript:throw …"` for a form
66
+ // whose action carries no `$$FORM_ACTION`, and giving it one would mean
67
+ // accepting a native form post here.
68
+ //
69
+ // **6. Nothing about a failure goes back.** An action that throws is a `500`
70
+ // with a fixed body; the exception goes to the host's error reporting. A
71
+ // message, a name or a stack in that response is an application's internals
72
+ // published to whoever asked for them, and the same is true in development —
73
+ // `uf dev` and `uf build` have to agree about what this endpoint answers, and
74
+ // the terminal is where a developer reads the exception anyway.
75
+ //
76
+ // # What a middleware does and does not do for an action
77
+ //
78
+ // The call is a `POST` to the page's own URL rather than to a path uf reserves,
79
+ // so the middleware that guards that path runs above it exactly as it does for
80
+ // the page — no new route to collide with a project's own, and no second
81
+ // spelling of "which guard applies here".
82
+ //
83
+ // That is a convenience and it is not a boundary, because the URL is the
84
+ // caller's to choose: a client that wants to skip the guard on `/dashboard`
85
+ // posts the same id to `/`. **A server action authorizes itself.** It is the
86
+ // unit of authorization, the way a route handler is, and a `"use server"`
87
+ // function that relies on a path guard having run is a function with a hole in
88
+ // it. Written here because this is the file somebody reads before deciding
89
+ // otherwise.
90
+
91
+ import { asResponder } from "@uniflowed/server/host";
92
+
93
+ import {
94
+ ACTION_CONTENT_TYPE,
95
+ ACTION_HEADER,
96
+ type ActionArgument,
97
+ ActionValueError,
98
+ MAX_ACTION_BODY_BYTES,
99
+ decodeActionArguments,
100
+ encodeActionResult,
101
+ isActionId,
102
+ } from "./action-wire.js";
103
+ import { requireRequest } from "./request.js";
104
+
105
+ /** A module holding server actions, as the generated table loads it. */
106
+ export type ActionModule = { readonly [name: string]: mixed };
107
+
108
+ /**
109
+ * One callable action, as `virtual:uf/actions` writes it.
110
+ *
111
+ * `module` and `export` are here for the error a broken build produces, never
112
+ * for dispatch: dispatch is `id` against `id`. Nothing a request carries is
113
+ * ever joined onto a path or used to name an export.
114
+ */
115
+ export type ActionRecord = {|
116
+ /** The keyed id, 64 lowercase hexadecimal characters. */
117
+ readonly id: string,
118
+ /** Declaring module, relative to the project root. */
119
+ readonly module: string,
120
+ /** The exported binding, or `default`. */
121
+ readonly export: string,
122
+ /** Import the declaring module. */
123
+ readonly load: () => Promise<ActionModule>,
124
+ |};
125
+
126
+ /** Headers every answer carries, whatever it says. */
127
+ const ANSWER_HEADERS: { readonly [string]: string } = {
128
+ "content-type": "application/json; charset=utf-8",
129
+ // An action call is a `POST` and is not cached by anything by default. Said
130
+ // anyway, because "not cached by default" is the sentence in front of every
131
+ // cache-poisoning advisory in `docs/security.md`.
132
+ "cache-control": "no-store",
133
+ };
134
+
135
+ /**
136
+ * Match a request against the action table and run what it names.
137
+ *
138
+ * Returns `null` when the request carries no action id, which is the caller's
139
+ * signal to carry on: everything that is not an action call is a page, a
140
+ * handler or a 404, and this declines all of them. Every other outcome —
141
+ * including every refusal — is a `Response`, because a request that named an
142
+ * action and did not get to call one must not fall through to something else
143
+ * that might answer it.
144
+ */
145
+ export function createActionDispatcher(options: {|
146
+ readonly actions: $ReadOnlyArray<ActionRecord>,
147
+ |}): (request: Request) => Promise<Response | null> {
148
+ const table = options.actions;
149
+
150
+ return async function callAction(request: Request): Promise<Response | null> {
151
+ // The host's half of the contract, checked rather than assumed, exactly as
152
+ // `dispatch` and `runMiddleware` check it: an action that calls `cookies()`
153
+ // has to answer about the request it is inside.
154
+ requireRequest("callAction");
155
+
156
+ const id = request.headers.get(ACTION_HEADER);
157
+ if (id == null) {
158
+ return null;
159
+ }
160
+
161
+ // Cheapest and most protective first, and all of it before a byte of the
162
+ // body is read.
163
+ if (request.method.toUpperCase() !== "POST") {
164
+ return new Response(null, { status: 405, headers: { ...ANSWER_HEADERS, allow: "POST" } });
165
+ }
166
+ if (!sameOrigin(request)) {
167
+ return refusal(403);
168
+ }
169
+ if (!isJson(request.headers.get("content-type"))) {
170
+ return refusal(415);
171
+ }
172
+
173
+ const body = await readBoundedText(request, MAX_ACTION_BODY_BYTES);
174
+ if (body == null) {
175
+ return refusal(413);
176
+ }
177
+
178
+ let args: Array<ActionArgument>;
179
+ try {
180
+ args = decodeActionArguments(body);
181
+ } catch (error) {
182
+ // The reason is the sender's own payload described back to them, which
183
+ // is a thing to write in a log and not a thing to answer with.
184
+ if (!(error instanceof ActionValueError)) {
185
+ throw error;
186
+ }
187
+ return refusal(400);
188
+ }
189
+
190
+ // Decoded before the lookup, so that a malformed payload and an unknown id
191
+ // cannot be told apart by trying one against the other.
192
+ const record = isActionId(id) ? select(table, id) : null;
193
+ if (record == null) {
194
+ return refusal(404);
195
+ }
196
+
197
+ let action: mixed;
198
+ try {
199
+ const module = await record.load();
200
+ action = module[record.export];
201
+ } catch (error) {
202
+ report(record, error);
203
+ return refusal(500);
204
+ }
205
+ if (typeof action !== "function") {
206
+ // The RSC graph rejects a `"use server"` export that is not an async
207
+ // function at build time, so reaching this means the manifest and the
208
+ // modules disagree — a stale `.uf/rsc/uf-rsc-manifest.json`, or a build
209
+ // half-written. It is uf's bug, not the caller's, so it is reported and
210
+ // answered as a `500`.
211
+ report(record, new Error(`export \`${record.export}\` is not a function`));
212
+ return refusal(500);
213
+ }
214
+
215
+ // Everything from here to the answer runs as the thing that owns this
216
+ // response, which is what makes `draftMode().enable()` legal in an action:
217
+ // a `"use server"` function is one of the two places uf lets draft mode be
218
+ // changed, and the `Set-Cookie` it decides on is written onto the response
219
+ // this returns rather than onto an object the host discards. See
220
+ // ubugeeei-prod/uf#282 and `asResponder`.
221
+ //
222
+ // The refusals stay outside it. A `403` for a cross-origin call must not
223
+ // carry a cookie the caller asked for, and a scope that covered them would
224
+ // be a scope in which nothing ran that could have asked.
225
+ return asResponder("a server action", async () => {
226
+ let result: mixed;
227
+ try {
228
+ // The build-time contract says this is `async (...ActionArgument) => …`
229
+ // (`ServerActionBoundary` in `../action.js`), and Flow cannot read that
230
+ // through a module loaded by a thunk. The arguments are the ones
231
+ // `decodeActionArguments` produced, so what is unchecked here is the
232
+ // shape of the function and not the shape of the payload.
233
+ const call = action as $FlowFixMe;
234
+ result = await call(...args);
235
+ } catch (error) {
236
+ report(record, error);
237
+ return refusal(500);
238
+ }
239
+
240
+ let answer: string;
241
+ try {
242
+ answer = encodeActionResult(result);
243
+ } catch (error) {
244
+ // The action ran and its return value cannot cross. Flow says so at
245
+ // build time — `ServerActionBoundary` in `../action.js` holds every
246
+ // action's return type against the grammar — so this is the case where
247
+ // it was reached anyway, and half a value is worse than none.
248
+ report(record, error);
249
+ return refusal(500);
250
+ }
251
+ return new Response(answer, { status: 200, headers: { ...ANSWER_HEADERS } });
252
+ });
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The row with this id, or `null`, without saying which by taking longer.
258
+ *
259
+ * Mirrors `ServerActionRegistry::lookup`: every row is visited whatever
260
+ * happens, the comparison is over the whole id, and the selected index is
261
+ * carried in arithmetic rather than in a branch. Two rows can never share an
262
+ * id, so the last match is the only match.
263
+ */
264
+ function select(table: $ReadOnlyArray<ActionRecord>, id: string): ActionRecord | null {
265
+ let selected = 0;
266
+ let found = 0;
267
+ for (let index = 0; index < table.length; index += 1) {
268
+ const matches = sameId(table[index].id, id);
269
+ const mask = -matches;
270
+ selected = (selected & ~mask) | (index & mask);
271
+ found |= matches;
272
+ }
273
+ return found === 1 ? table[selected] : null;
274
+ }
275
+
276
+ /**
277
+ * Whether two ids are the same, in time that does not depend on how much of
278
+ * one matched.
279
+ *
280
+ * The length is compared first and that is not a leak: every action id is
281
+ * exactly 64 characters and `isActionId` has already said so about this one.
282
+ */
283
+ function sameId(left: string, right: string): number {
284
+ if (left.length !== right.length) {
285
+ return 0;
286
+ }
287
+ let difference = 0;
288
+ for (let index = 0; index < left.length; index += 1) {
289
+ difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
290
+ }
291
+ return difference === 0 ? 1 : 0;
292
+ }
293
+
294
+ /**
295
+ * Whether the request came from the page it claims to have come from.
296
+ *
297
+ * Deny by default: a call with no `Origin` is refused rather than trusted,
298
+ * because "the header was absent" and "the header matched" are not the same
299
+ * fact and only one of them is a browser saying where it was.
300
+ */
301
+ function sameOrigin(request: Request): boolean {
302
+ const origin = request.headers.get("origin");
303
+ const host = request.headers.get("host");
304
+ if (origin == null || host == null) {
305
+ return false;
306
+ }
307
+ let parsed: URL;
308
+ try {
309
+ // `Origin: null` — a sandboxed frame, a `data:` document, some redirects —
310
+ // is not a URL and is refused here rather than being special-cased into a
311
+ // value that could match something.
312
+ parsed = new URL(origin);
313
+ } catch {
314
+ return false;
315
+ }
316
+ // `host` on both sides, so the port is part of the comparison: `:5173` and
317
+ // `:5174` on one machine are two origins and a cookie tells them apart.
318
+ return parsed.host === host;
319
+ }
320
+
321
+ /**
322
+ * Whether the declared content type is the one this endpoint accepts.
323
+ *
324
+ * The parameters after `;` are ignored — `application/json; charset=utf-8` is
325
+ * the same media type — and the media type itself must match exactly. A
326
+ * `+json` suffix is not accepted: the point of the check is that a form cannot
327
+ * produce this string, and a widened match is a widened set of things that
328
+ * can.
329
+ */
330
+ function isJson(declared: string | null): boolean {
331
+ if (declared == null) {
332
+ return false;
333
+ }
334
+ const semicolon = declared.indexOf(";");
335
+ const media = (semicolon === -1 ? declared : declared.slice(0, semicolon)).trim().toLowerCase();
336
+ return media === ACTION_CONTENT_TYPE;
337
+ }
338
+
339
+ /**
340
+ * The body as text, or `null` when it is larger than `limit`.
341
+ *
342
+ * Counted as it arrives rather than trusted from `Content-Length`, because a
343
+ * chunked request declares no length and a declared one is the sender's claim.
344
+ * The declared length is still read first, so an oversized request that is
345
+ * honest about it is refused before its body is transferred at all.
346
+ *
347
+ * `fatal: true` refuses input that is not valid UTF-8 instead of replacing
348
+ * each bad sequence with U+FFFD, which is `docs/security.md`'s row about
349
+ * non-UTF-8 input applied at the one place uf decodes bytes a client sent.
350
+ */
351
+ async function readBoundedText(request: Request, limit: number): Promise<string | null> {
352
+ const declared = request.headers.get("content-length");
353
+ if (declared != null) {
354
+ const size = Number(declared);
355
+ if (!Number.isInteger(size) || size < 0 || size > limit) {
356
+ return null;
357
+ }
358
+ }
359
+
360
+ // Flow's `Request` predates a body one can read as a stream, so the property
361
+ // is reached through a cast and the shape it is used at is checked below.
362
+ // `ReadableStream` is not polymorphic in the DOM declarations uf checks
363
+ // against, so the element type is asserted at the one place it is read
364
+ // rather than written here.
365
+ const body: ReadableStream | null = (request as $FlowFixMe).body;
366
+ if (body == null) {
367
+ return "";
368
+ }
369
+
370
+ const reader = body.getReader();
371
+ const chunks: Array<Uint8Array> = [];
372
+ let total = 0;
373
+ try {
374
+ for (;;) {
375
+ const step = await reader.read();
376
+ if (step.done === true) {
377
+ break;
378
+ }
379
+ const chunk: Uint8Array = step.value as $FlowFixMe;
380
+ total += chunk.byteLength;
381
+ if (total > limit) {
382
+ // Cancelled rather than dropped, so the sender stops instead of
383
+ // filling a queue nobody is reading. The reason is given because the
384
+ // declarations uf checks against require one, and "too large" is what
385
+ // a cancelled read of an oversized body is about.
386
+ await reader.cancel("the body is larger than a server action accepts");
387
+ return null;
388
+ }
389
+ chunks.push(chunk);
390
+ }
391
+ } catch {
392
+ return null;
393
+ }
394
+
395
+ const joined = new Uint8Array(total);
396
+ let at = 0;
397
+ for (const chunk of chunks) {
398
+ joined.set(chunk, at);
399
+ at += chunk.byteLength;
400
+ }
401
+ try {
402
+ return new TextDecoder("utf-8", { fatal: true }).decode(joined);
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Every refusal, with the same body whatever it was.
410
+ *
411
+ * The status says what a caller may usefully do differently — retry with a
412
+ * smaller body, send the header, look elsewhere — and the body says nothing at
413
+ * all, because everything that could go in it is about the build.
414
+ */
415
+ function refusal(status: number): Response {
416
+ return new Response('{"error":"server action refused"}', {
417
+ status,
418
+ headers: { ...ANSWER_HEADERS },
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Report an action that failed, where the host's error reporting can see it.
424
+ *
425
+ * The console, for the reason `createFetchHandler` gives about its own
426
+ * `onError`: this runs inside a worker or a serverless invocation as often as
427
+ * in a terminal, and losing the exception entirely is worse than putting it
428
+ * somewhere a platform is expected to collect. The module and export are named
429
+ * here and nowhere the caller can read.
430
+ */
431
+ function report(record: ActionRecord, error: mixed): void {
432
+ // eslint-disable-next-line no-console
433
+ console.error(`uf: server action \`${record.export}\` in \`${record.module}\` failed`, error);
434
+ }