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