@uniflowed/router 0.0.0-alpha.12 → 0.0.0-alpha.14
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 +198 -0
- package/client.js +35 -1
- package/handler.js +74 -6
- package/index.js +10 -1
- package/internal/action-endpoint.js +422 -0
- package/internal/action-wire.js +382 -0
- package/internal/diagnostics.js +169 -0
- package/internal/hydration.js +892 -0
- package/internal/runtime.js +1038 -120
- package/internal/stream.js +122 -12
- package/middleware.js +14 -5
- package/package.json +5 -2
- package/server.js +68 -39
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: what a server action's arguments and its
|
|
4
|
+
// result are allowed to be.
|
|
5
|
+
//
|
|
6
|
+
// A `"use server"` export is a public HTTP endpoint from the moment it exists.
|
|
7
|
+
// The one decision that makes it a feature rather than a remote-code-execution
|
|
8
|
+
// surface is what the bytes on the wire are permitted to become, and this
|
|
9
|
+
// module is that decision, written once and applied on both sides of it.
|
|
10
|
+
//
|
|
11
|
+
// # The boundary
|
|
12
|
+
//
|
|
13
|
+
// **A server action's arguments are plain JSON data and nothing else.**
|
|
14
|
+
//
|
|
15
|
+
// One JSON object, `{"args": [...]}`, at most `MAX_ACTION_BODY_BYTES` of valid
|
|
16
|
+
// UTF-8, holding at most `MAX_ACTION_ARGUMENTS` values, nested at most
|
|
17
|
+
// `MAX_ACTION_DEPTH` deep, with at most `MAX_ACTION_VALUES` values in total.
|
|
18
|
+
// Each of those values is `null`, a boolean, a finite number, a string, an
|
|
19
|
+
// array of them, or a plain object whose keys are ordinary strings. The result
|
|
20
|
+
// travels back under exactly the same grammar, plus `undefined` for an action
|
|
21
|
+
// that returns nothing.
|
|
22
|
+
//
|
|
23
|
+
// Nothing in a payload can name a function, a module, a class, a prototype, a
|
|
24
|
+
// React element, an id or a reference, and nothing in it is revived into an
|
|
25
|
+
// object the sender chose. `decodeActionArguments` produces values a
|
|
26
|
+
// `JSON.parse` already produced; what this adds is the refusal of everything
|
|
27
|
+
// `JSON.parse` would have let through — which is the whole of what a decoder
|
|
28
|
+
// has to get right.
|
|
29
|
+
//
|
|
30
|
+
// # What is deliberately absent, and why
|
|
31
|
+
//
|
|
32
|
+
// * **A reference format.** React's Flight payload can carry a reference to a
|
|
33
|
+
// client module, a promise, or an element, and a decoder that reconstructs
|
|
34
|
+
// those is a decoder that constructs attacker-chosen objects. uf has no such
|
|
35
|
+
// payload (ubugeeei-prod/uf#252) and this grammar is not the place to grow
|
|
36
|
+
// one quietly.
|
|
37
|
+
// * **Class instances, `Map`, `Set`, `Date`, `RegExp`, typed arrays.** Each
|
|
38
|
+
// would need a tag in the payload saying which constructor to call, and a
|
|
39
|
+
// tag naming a constructor is the oracle every deserialisation CVE is made
|
|
40
|
+
// of. An action that wants a date takes an ISO string and parses it, where
|
|
41
|
+
// the parse is the application's and is checked.
|
|
42
|
+
// * **`FormData` and `<form action={fn}>`.** Multipart parsing is its own
|
|
43
|
+
// attack surface with its own bounds, and a form post is a *simple* CORS
|
|
44
|
+
// request — it reaches a server with the visitor's cookies and no preflight.
|
|
45
|
+
// Both are worth having and neither is worth having by accident; see the
|
|
46
|
+
// security note in `./action-endpoint.js`.
|
|
47
|
+
// * **Cycles and shared references.** A value that refers to itself is
|
|
48
|
+
// rejected rather than encoded, because the alternative is a marker in the
|
|
49
|
+
// payload that says "this is the object you saw earlier", which is a
|
|
50
|
+
// reference format by another name.
|
|
51
|
+
//
|
|
52
|
+
// # Why the same module runs on both sides
|
|
53
|
+
//
|
|
54
|
+
// The browser refuses to *send* what the server would refuse to receive, so a
|
|
55
|
+
// value that cannot cross is a mistake at the call site with the argument's
|
|
56
|
+
// position in the message, rather than a 400 with nothing in it. Two
|
|
57
|
+
// implementations of one grammar is how the two come to disagree, and the
|
|
58
|
+
// side that is lenient is always the server.
|
|
59
|
+
//
|
|
60
|
+
// Pure: no imports, no platform APIs beyond `JSON`, so the browser half of
|
|
61
|
+
// `@uniflowed/router` can reach it without reaching anything server-only.
|
|
62
|
+
|
|
63
|
+
/** The request header carrying the action id. */
|
|
64
|
+
export const ACTION_HEADER: string = "uf-action";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The only content type an action call may be sent with.
|
|
68
|
+
*
|
|
69
|
+
* Not decoration. `application/json` is not one of the three types a form can
|
|
70
|
+
* produce, so a cross-origin `<form>` — which is sent with the visitor's
|
|
71
|
+
* cookies and no preflight — cannot reach the decoder at all. It is the second
|
|
72
|
+
* of the three independent things standing between this endpoint and a CSRF,
|
|
73
|
+
* the others being the `Origin` check and `ACTION_HEADER` itself, which is a
|
|
74
|
+
* header no simple request may carry.
|
|
75
|
+
*/
|
|
76
|
+
export const ACTION_CONTENT_TYPE: string = "application/json";
|
|
77
|
+
|
|
78
|
+
/** Largest request body the endpoint will read, in bytes. */
|
|
79
|
+
export const MAX_ACTION_BODY_BYTES: number = 1024 * 1024;
|
|
80
|
+
|
|
81
|
+
/** Most positional arguments an action may be called with. */
|
|
82
|
+
export const MAX_ACTION_ARGUMENTS: number = 16;
|
|
83
|
+
|
|
84
|
+
/** Deepest nesting a payload may have. */
|
|
85
|
+
export const MAX_ACTION_DEPTH: number = 24;
|
|
86
|
+
|
|
87
|
+
/** Most values, of any kind, one payload may hold. */
|
|
88
|
+
export const MAX_ACTION_VALUES: number = 10000;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Everything that may cross the wire.
|
|
92
|
+
*
|
|
93
|
+
* Recursive on purpose, and closed on purpose: this type is what
|
|
94
|
+
* `ServerActionBoundary` in `../action.js` holds every action's parameters and
|
|
95
|
+
* return value against, so an action that takes a `Map` is a `uf check` error
|
|
96
|
+
* rather than a request that arrives with an empty object in it.
|
|
97
|
+
*/
|
|
98
|
+
export type ActionValue =
|
|
99
|
+
| null
|
|
100
|
+
| boolean
|
|
101
|
+
| number
|
|
102
|
+
| string
|
|
103
|
+
| $ReadOnlyArray<ActionValue>
|
|
104
|
+
| { readonly [string]: ActionValue };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A value that is outside the grammar, and where in the payload it was.
|
|
108
|
+
*
|
|
109
|
+
* Thrown on the browser side, where the path is the argument the caller
|
|
110
|
+
* passed. On the server side it is caught and becomes a `400` with none of
|
|
111
|
+
* this in it: the sender does not get told which part of what they sent was
|
|
112
|
+
* the part that was refused.
|
|
113
|
+
*/
|
|
114
|
+
export class ActionValueError extends Error {
|
|
115
|
+
/** Where in the payload the offending value sat, e.g. `argument 2.name`. */
|
|
116
|
+
path: string;
|
|
117
|
+
|
|
118
|
+
constructor(path: string, reason: string) {
|
|
119
|
+
super(`@uniflowed/router: ${path} ${reason}.`);
|
|
120
|
+
this.name = "ActionValueError";
|
|
121
|
+
this.path = path;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Keys that are never a property in this grammar.
|
|
127
|
+
*
|
|
128
|
+
* `JSON.parse` gives `__proto__` an *own* data property rather than changing
|
|
129
|
+
* the prototype, so a payload carrying one is not itself pollution — it
|
|
130
|
+
* becomes pollution in the first line of application code that spreads or
|
|
131
|
+
* merges it. Refused here, where there is one place to refuse it, rather than
|
|
132
|
+
* left for every action to remember. `uf_pm::detect` draws the same line for
|
|
133
|
+
* manifest JSON.
|
|
134
|
+
*/
|
|
135
|
+
const FORBIDDEN_KEYS: $ReadOnlyArray<string> = ["__proto__", "constructor", "prototype"];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether an object is one this grammar carries.
|
|
139
|
+
*
|
|
140
|
+
* The prototype is compared, not `instanceof` and not a duck-type: a class
|
|
141
|
+
* instance, a `Map`, a `Date` and a React element all pass every structural
|
|
142
|
+
* test somebody might reach for, and each of them loses its meaning on the
|
|
143
|
+
* wire. `Object.create(null)` is accepted because it is what a careful caller
|
|
144
|
+
* builds and what this module's own decoder could produce.
|
|
145
|
+
*/
|
|
146
|
+
function isPlainObject(value: interface {}): boolean {
|
|
147
|
+
const prototype: mixed = Object.getPrototypeOf(value);
|
|
148
|
+
return prototype === PLAIN_PROTOTYPE || prototype === null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The prototype every object literal has, captured rather than named.
|
|
153
|
+
*
|
|
154
|
+
* `Object.prototype` is not on Flow's `Object` statics, and the usual
|
|
155
|
+
* substitute — asking whether the prototype's own prototype is `null` — also
|
|
156
|
+
* accepts `Object.create(Object.create(null))`, which is a different set of
|
|
157
|
+
* values. One `{}` at module scope is the exact answer and costs nothing.
|
|
158
|
+
*/
|
|
159
|
+
const PLAIN_PROTOTYPE: mixed = Object.getPrototypeOf({});
|
|
160
|
+
|
|
161
|
+
/** One entry of the explicit walk stack. */
|
|
162
|
+
type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a value that cannot cross the wire, naming where it was.
|
|
166
|
+
*
|
|
167
|
+
* An explicit stack rather than recursion, for the reason the RSC graph walk
|
|
168
|
+
* gives: a payload arrives from the network, its nesting is the sender's
|
|
169
|
+
* choice, and a recursive walk over it is a stack overflow with an attacker's
|
|
170
|
+
* hand on the depth. Every bound is checked as the walk runs rather than
|
|
171
|
+
* afterwards, so a payload that busts one is refused before the rest of it is
|
|
172
|
+
* visited.
|
|
173
|
+
*/
|
|
174
|
+
export function checkActionValue(root: mixed, label: string): void {
|
|
175
|
+
const stack: Array<Pending> = [{ value: root, path: label, depth: 0 }];
|
|
176
|
+
const seen = new Set<mixed>();
|
|
177
|
+
let remaining = MAX_ACTION_VALUES;
|
|
178
|
+
|
|
179
|
+
while (stack.length > 0) {
|
|
180
|
+
const pending = stack.pop();
|
|
181
|
+
if (pending == null) {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
const { value, path, depth } = pending;
|
|
185
|
+
|
|
186
|
+
remaining -= 1;
|
|
187
|
+
if (remaining < 0) {
|
|
188
|
+
throw new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
|
|
189
|
+
}
|
|
190
|
+
if (depth > MAX_ACTION_DEPTH) {
|
|
191
|
+
throw new ActionValueError(path, `is nested deeper than ${String(MAX_ACTION_DEPTH)}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (value === null) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const kind = typeof value;
|
|
198
|
+
if (kind === "boolean" || kind === "string") {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (kind === "number") {
|
|
202
|
+
// `NaN` and the infinities have no JSON spelling; `JSON.stringify` writes
|
|
203
|
+
// `null` for each, so accepting one here would mean an action was called
|
|
204
|
+
// with a different number from the one the caller passed.
|
|
205
|
+
if (!Number.isFinite(value)) {
|
|
206
|
+
throw new ActionValueError(path, "is not a finite number");
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (kind !== "object") {
|
|
211
|
+
// `undefined`, a function, a symbol, a bigint. Named rather than
|
|
212
|
+
// lumped together, because "a function cannot cross" is the sentence
|
|
213
|
+
// that tells a caller they passed a callback to a server action.
|
|
214
|
+
throw new ActionValueError(path, `is a ${kind}, which cannot cross to a server action`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const object: interface {} = value as $FlowFixMe;
|
|
218
|
+
if (seen.has(object)) {
|
|
219
|
+
throw new ActionValueError(path, "refers to a value that already appeared in the payload");
|
|
220
|
+
}
|
|
221
|
+
seen.add(object);
|
|
222
|
+
|
|
223
|
+
if (Array.isArray(object)) {
|
|
224
|
+
const items: $ReadOnlyArray<mixed> = object as $FlowFixMe;
|
|
225
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
226
|
+
stack.push({ value: items[index], path: `${path}[${String(index)}]`, depth: depth + 1 });
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!isPlainObject(object)) {
|
|
232
|
+
throw new ActionValueError(
|
|
233
|
+
path,
|
|
234
|
+
"is a class instance, a Map, a Set, a Date, a React element or another object with a " +
|
|
235
|
+
"prototype, and only arrays and plain objects cross to a server action",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Symbol keys are silently dropped by `JSON.stringify`, so a payload built
|
|
240
|
+
// with one would arrive missing a property nobody could see was missing.
|
|
241
|
+
if (Object.getOwnPropertySymbols(object).length > 0) {
|
|
242
|
+
throw new ActionValueError(path, "has a symbol key, which has no spelling on the wire");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const record: { readonly [string]: mixed } = object as $FlowFixMe;
|
|
246
|
+
for (const key of Object.getOwnPropertyNames(object)) {
|
|
247
|
+
if (FORBIDDEN_KEYS.includes(key)) {
|
|
248
|
+
throw new ActionValueError(`${path}.${key}`, "is a key this grammar never carries");
|
|
249
|
+
}
|
|
250
|
+
stack.push({ value: record[key], path: `${path}.${key}`, depth: depth + 1 });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The request body for a call, or a named failure.
|
|
257
|
+
*
|
|
258
|
+
* The browser's half. Refusing here is what turns "the server answered 400"
|
|
259
|
+
* into "argument 2.createdAt is a class instance", at the call site, with a
|
|
260
|
+
* stack that reaches the component.
|
|
261
|
+
*/
|
|
262
|
+
export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
|
|
263
|
+
if (args.length > MAX_ACTION_ARGUMENTS) {
|
|
264
|
+
throw new ActionValueError(
|
|
265
|
+
"the call",
|
|
266
|
+
`passes ${String(args.length)} arguments, and an action takes at most ` +
|
|
267
|
+
String(MAX_ACTION_ARGUMENTS),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
271
|
+
checkActionValue(args[index], `argument ${String(index + 1)}`);
|
|
272
|
+
}
|
|
273
|
+
return JSON.stringify({ args });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The arguments a request body holds, or a named failure.
|
|
278
|
+
*
|
|
279
|
+
* The server's half, and the one that faces the network. The shape is exact —
|
|
280
|
+
* one object, one key — because a payload with a key nobody reads is a payload
|
|
281
|
+
* whose sender believed something about it that is not true, and because the
|
|
282
|
+
* only way to keep this closed as it grows is to refuse anything that is not
|
|
283
|
+
* this today.
|
|
284
|
+
*/
|
|
285
|
+
export function decodeActionArguments(text: string): Array<ActionValue> {
|
|
286
|
+
let parsed: mixed;
|
|
287
|
+
try {
|
|
288
|
+
parsed = JSON.parse(text);
|
|
289
|
+
} catch {
|
|
290
|
+
throw new ActionValueError("the body", "is not JSON");
|
|
291
|
+
}
|
|
292
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
293
|
+
throw new ActionValueError("the body", "is not a JSON object");
|
|
294
|
+
}
|
|
295
|
+
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
|
|
296
|
+
if (keys.length !== 1 || keys[0] !== "args") {
|
|
297
|
+
throw new ActionValueError("the body", 'has keys other than "args"');
|
|
298
|
+
}
|
|
299
|
+
const args: mixed = parsed.args;
|
|
300
|
+
if (!Array.isArray(args)) {
|
|
301
|
+
throw new ActionValueError("the body", 'has an "args" that is not an array');
|
|
302
|
+
}
|
|
303
|
+
if (args.length > MAX_ACTION_ARGUMENTS) {
|
|
304
|
+
throw new ActionValueError(
|
|
305
|
+
"the body",
|
|
306
|
+
`passes more than ${String(MAX_ACTION_ARGUMENTS)} arguments`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
310
|
+
checkActionValue(args[index], `argument ${String(index + 1)}`);
|
|
311
|
+
}
|
|
312
|
+
return args as $FlowFixMe;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The response body for a result, or a named failure.
|
|
317
|
+
*
|
|
318
|
+
* An action that returns nothing answers `{}` rather than `{"value":null}`:
|
|
319
|
+
* JSON has no `undefined`, and turning one into `null` would make an action
|
|
320
|
+
* declared `Promise<void>` resolve to something on the browser side.
|
|
321
|
+
*/
|
|
322
|
+
export function encodeActionResult(value: mixed): string {
|
|
323
|
+
if (value === undefined) {
|
|
324
|
+
return "{}";
|
|
325
|
+
}
|
|
326
|
+
checkActionValue(value, "the result");
|
|
327
|
+
return JSON.stringify({ value });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The result a response body holds, or a named failure.
|
|
332
|
+
*
|
|
333
|
+
* The same grammar in the other direction, and checked rather than trusted:
|
|
334
|
+
* the browser is talking to whatever answered, which on a compromised network
|
|
335
|
+
* is not the server. What it can be handed is therefore data and never an
|
|
336
|
+
* object of somebody's choosing, exactly as on the way out.
|
|
337
|
+
*/
|
|
338
|
+
export function decodeActionResult(text: string): ActionValue | void {
|
|
339
|
+
let parsed: mixed;
|
|
340
|
+
try {
|
|
341
|
+
parsed = JSON.parse(text);
|
|
342
|
+
} catch {
|
|
343
|
+
throw new ActionValueError("the answer", "is not JSON");
|
|
344
|
+
}
|
|
345
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
346
|
+
throw new ActionValueError("the answer", "is not a JSON object");
|
|
347
|
+
}
|
|
348
|
+
const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
|
|
349
|
+
if (keys.length === 0) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
if (keys.length !== 1 || keys[0] !== "value") {
|
|
353
|
+
throw new ActionValueError("the answer", 'has keys other than "value"');
|
|
354
|
+
}
|
|
355
|
+
const value: mixed = parsed.value;
|
|
356
|
+
checkActionValue(value, "the result");
|
|
357
|
+
return value as $FlowFixMe;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Whether `text` is the canonical spelling of an action id.
|
|
362
|
+
*
|
|
363
|
+
* Sixty-four lowercase hexadecimal characters, which is what
|
|
364
|
+
* `uf_rsc::ActionId::to_hex` writes. Checked before the id reaches the table
|
|
365
|
+
* so an oversized or repeated header cannot make the endpoint do work, and
|
|
366
|
+
* hand-written rather than a regular expression because this reads a header a
|
|
367
|
+
* client chose (`docs/security.md`, rule 5).
|
|
368
|
+
*/
|
|
369
|
+
export function isActionId(text: string): boolean {
|
|
370
|
+
if (text.length !== 64) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
374
|
+
const code = text.charCodeAt(index);
|
|
375
|
+
const digit = code >= 0x30 && code <= 0x39;
|
|
376
|
+
const lower = code >= 0x61 && code <= 0x66;
|
|
377
|
+
if (!digit && !lower) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the way a diagnostic the browser produced
|
|
4
|
+
// reaches the terminal.
|
|
5
|
+
//
|
|
6
|
+
// Every other uf diagnostic — a type error, a lint finding, a failing test, a
|
|
7
|
+
// page that rendered its error boundary — arrives in the terminal the
|
|
8
|
+
// developer already has open. One produced *in the browser* had nowhere to go:
|
|
9
|
+
// the page is the only process that knows about it, and `uf dev`'s diagnostics
|
|
10
|
+
// come up the driver's event channel from the Node process. So the hydration
|
|
11
|
+
// report added in ubugeeei-prod/uf#582 lived in a browser overlay and in the
|
|
12
|
+
// console, which has to be noticed, in a window that may not be in front, by
|
|
13
|
+
// somebody who does not know to look. See ubugeeei-prod/uf#583.
|
|
14
|
+
//
|
|
15
|
+
// This is the client half. The server half is `uf dev`, through
|
|
16
|
+
// `@uniflowed/vite`'s `internal/diagnostics.js`, which turns what arrives into
|
|
17
|
+
// the same rendering the terminal gives everything else: a severity, the page
|
|
18
|
+
// it came from, and a code frame when there is a position to draw one around.
|
|
19
|
+
//
|
|
20
|
+
// # One channel, and why the poster is not shared
|
|
21
|
+
//
|
|
22
|
+
// `/__uf/diagnostic` and `/__uf/vitals` are one channel: two paths, one payload
|
|
23
|
+
// shape, one `diagnostic` event out of the driver, one renderer. What is *not*
|
|
24
|
+
// shared is the twenty lines that post to it. `@uniflowed/web/vitals` has its
|
|
25
|
+
// own `vitalsBeacon`, and this is the router's.
|
|
26
|
+
//
|
|
27
|
+
// `@uniflowed/hmr` would have been the tidier home — it is already "the browser
|
|
28
|
+
// half of `uf dev`" — and it cannot be one. `tools/ci/publishable.sh` refuses a
|
|
29
|
+
// published package that depends on an unpublished one, because the tarball
|
|
30
|
+
// would name a version the registry does not have; `@uniflowed/router` is on
|
|
31
|
+
// npm and `@uniflowed/hmr` is a declaration package that is not. What the two
|
|
32
|
+
// posters do share is the contract, and `tests/library/dev-channel.test.js`
|
|
33
|
+
// asserts that every spelling of these paths agrees — a duplicated constant
|
|
34
|
+
// with a test on it is honest, and one without is how a browser ends up
|
|
35
|
+
// posting to a path nothing serves.
|
|
36
|
+
//
|
|
37
|
+
// # It is development only, and it is best effort
|
|
38
|
+
//
|
|
39
|
+
// `uf dev` serves this path and nothing else does: a built application has no
|
|
40
|
+
// `/__uf/` anything, so a call in production posts to a path that answers 404
|
|
41
|
+
// and the rejected promise is swallowed here. That is a fallback rather than a
|
|
42
|
+
// design — the only caller is behind `import.meta.hot` in `../client.js`, so a
|
|
43
|
+
// production bundle has no path to this module rather than merely no answer
|
|
44
|
+
// from it.
|
|
45
|
+
//
|
|
46
|
+
// Nothing here opens a connection until it is called, importing it does nothing
|
|
47
|
+
// at all, and what it sends goes to the page's own origin as a path rather than
|
|
48
|
+
// a URL, so it cannot be pointed at somebody else's server. Nothing leaves the
|
|
49
|
+
// machine.
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The path `uf dev` serves the diagnostic channel on.
|
|
53
|
+
*
|
|
54
|
+
* Under `/__uf/`, beside the update stream `@uniflowed/hmr` opens and the
|
|
55
|
+
* vitals endpoint `@uniflowed/web/vitals` posts to, for the reason that prefix
|
|
56
|
+
* exists: a directory in `app/` whose name begins with `_` is not a route, so
|
|
57
|
+
* no application can put anything here and nothing here can shadow a path a
|
|
58
|
+
* project wrote.
|
|
59
|
+
*/
|
|
60
|
+
export const DIAGNOSTIC_ENDPOINT: string = "/__uf/diagnostic";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How loudly a diagnostic reads in the terminal.
|
|
64
|
+
*
|
|
65
|
+
* `error` for something that is wrong, `warn` for something that is worth
|
|
66
|
+
* knowing, `info` for something that is only a measurement. Three rather than
|
|
67
|
+
* two because the vitals report on the same channel needs the third: a page
|
|
68
|
+
* whose numbers are all good has still reported, and printing that as a warning
|
|
69
|
+
* would teach the reader to ignore the warnings.
|
|
70
|
+
*/
|
|
71
|
+
export type DiagnosticSeverity = "error" | "warn" | "info";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One diagnostic, as the channel carries it.
|
|
75
|
+
*
|
|
76
|
+
* `message` is the headline and the only required field: one line, the thing
|
|
77
|
+
* that is wrong. `detail` is everything under it — the values that differed,
|
|
78
|
+
* the path through the tree, the advice — and is a list of lines rather than a
|
|
79
|
+
* blob so the terminal can indent them without guessing where they break.
|
|
80
|
+
*
|
|
81
|
+
* `url` is the page the browser was on, which the reporter fills in when the
|
|
82
|
+
* caller does not: a diagnostic that does not say which page produced it is a
|
|
83
|
+
* diagnostic somebody has to reproduce before they can act on it.
|
|
84
|
+
*
|
|
85
|
+
* `file`, `line` and `column` are for the rare browser-side report that knows a
|
|
86
|
+
* source position. When the file and the line are both there `uf dev` draws its
|
|
87
|
+
* ordinary code frame; when they are not it prints the headline and the detail,
|
|
88
|
+
* which is what a hydration mismatch — a fact about a DOM node rather than
|
|
89
|
+
* about a line — can honestly offer.
|
|
90
|
+
*/
|
|
91
|
+
export type BrowserDiagnostic = {
|
|
92
|
+
readonly severity: DiagnosticSeverity,
|
|
93
|
+
readonly message: string,
|
|
94
|
+
readonly detail?: $ReadOnlyArray<string>,
|
|
95
|
+
readonly url?: string,
|
|
96
|
+
readonly file?: string,
|
|
97
|
+
readonly line?: number,
|
|
98
|
+
readonly column?: number,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The part of the browser this module needs. */
|
|
102
|
+
type ReportingWindow = {
|
|
103
|
+
readonly location?: { readonly href?: string, ... },
|
|
104
|
+
readonly fetch?: (input: string, init: { ... }) => Promise<mixed>,
|
|
105
|
+
...
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** For a promise whose outcome is deliberately not looked at. */
|
|
109
|
+
function noop(): void {}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send one diagnostic to `uf dev`, if there is a `uf dev` to send it to.
|
|
113
|
+
*
|
|
114
|
+
* Returns nothing and throws nothing. A diagnostic is a thing a person reads,
|
|
115
|
+
* not a thing an application branches on, and a reporter that could fail would
|
|
116
|
+
* make every caller wrap it — from a code path that is, by construction,
|
|
117
|
+
* already handling something that went wrong.
|
|
118
|
+
*
|
|
119
|
+
* `fetch` rather than `sendBeacon`, which is the opposite of the choice
|
|
120
|
+
* `vitalsBeacon` makes and for the opposite reason: a vital is measured as the
|
|
121
|
+
* page is put away and needs a transport that outlives it, while a diagnostic
|
|
122
|
+
* is produced by a page that is still running and had better arrive in the
|
|
123
|
+
* order it happened. `keepalive` covers the case where the page goes away
|
|
124
|
+
* immediately afterwards anyway.
|
|
125
|
+
*
|
|
126
|
+
* @param diagnostic what to report
|
|
127
|
+
* @param endpoint where to post it; [`DIAGNOSTIC_ENDPOINT`] by default
|
|
128
|
+
*/
|
|
129
|
+
export function reportDiagnostic(diagnostic: BrowserDiagnostic, endpoint?: string): void {
|
|
130
|
+
const win = reportingWindow();
|
|
131
|
+
if (win == null) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const post = win.fetch;
|
|
135
|
+
if (post == null) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body = JSON.stringify({
|
|
140
|
+
...diagnostic,
|
|
141
|
+
url: diagnostic.url ?? win.location?.href ?? "",
|
|
142
|
+
});
|
|
143
|
+
// A rejected promise nobody is holding becomes an unhandled rejection, which
|
|
144
|
+
// arrives in whatever error reporter the application installed — from a line
|
|
145
|
+
// about development tooling. Losing the report is the right outcome when
|
|
146
|
+
// there is nothing listening; reporting the loss as an application error is
|
|
147
|
+
// not.
|
|
148
|
+
post(endpoint ?? DIAGNOSTIC_ENDPOINT, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
body,
|
|
151
|
+
keepalive: true,
|
|
152
|
+
headers: { "content-type": "application/json" },
|
|
153
|
+
}).then(noop, noop);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The window to report from, or `null` where there is no browser.
|
|
158
|
+
*
|
|
159
|
+
* The same reading `@uniflowed/web/vitals` makes, and for the same reason: in a
|
|
160
|
+
* browser `globalThis` *is* the window, but not where a document has been
|
|
161
|
+
* installed onto another host's global, which is every uf test process. Asking
|
|
162
|
+
* for the document first is what makes both cases work.
|
|
163
|
+
*/
|
|
164
|
+
function reportingWindow(): ReportingWindow | null {
|
|
165
|
+
if (typeof globalThis.document === "undefined") {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return globalThis.window ?? globalThis;
|
|
169
|
+
}
|