@uniflowed/router 0.0.0-alpha.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +344 -0
- package/client.js +263 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +646 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/form-action.js +243 -0
- package/internal/head.js +219 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +132 -0
- package/internal/stream.js +766 -21
- package/middleware.js +274 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +122 -0
- package/rsc-ssr.js +641 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +263 -106
|
@@ -0,0 +1,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
|
+
}
|