@tinvar/react 0.0.0-stage → 0.4.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
@@ -0,0 +1,851 @@
1
+ // Client telemetry: how the page loaded, and what went wrong in its script,
2
+ // reported to the server's /_vitals and /_errors (Tinvar\Observability
3
+ // \TelemetryEndpoint).
4
+ //
5
+ // tinvar-spec.md, "Endpoints": "`/_vitals`, `/_errors` ... | `throttle:telemetry`,
6
+ // CSRF | 16 KB bodies; route names checked against the manifest". So each
7
+ // report is a same-origin POST carrying the CSRF header every other one
8
+ // carries, under 16 KB, naming its page by route name -- which the server
9
+ // holds to its route registry, and refuses when it is not one.
10
+ //
11
+ // The document's page is named by its page metadata's `route`: its route's
12
+ // name, or the view of a page with none (ADR 0129). Failing that, by its
13
+ // route token, whose payload is plain base64url JSON with the route name in
14
+ // its `r`. A page the router mounts is named by its route token, or its view
15
+ // where it has none. All of it is read without verifying, as the router
16
+ // reads the session binding beside it (bindingOf), since nothing here
17
+ // trusts it -- the server checks the name again. A page with no name at all
18
+ // reports nothing.
19
+ //
20
+ // A visitor with no session -- a static page's, which no PHP rendered --
21
+ // has no CSRF token, and reports with no cookie at all: the server takes a
22
+ // report that rides no session without one, since there is no session for
23
+ // a forged one to ride (ADR 0129).
24
+ //
25
+ // Vitals are measured with PerformanceObserver alone: LCP, FCP and TTFB as
26
+ // the document loaded, CLS as its largest burst of layout shifts, INP from
27
+ // event timing. Each page the reader is shown has its own: the document's,
28
+ // and then each page a client navigation mounts, whose CLS and INP are
29
+ // measured from its mount, and whose NAV is how long the navigation took,
30
+ // from the visit's start to the page's mount -- LCP, FCP and TTFB exist
31
+ // only for the document's load. A page's vitals are sent once, when it is
32
+ // left for the next, or the first time the tab is hidden, filed under that
33
+ // page's route and as a `document` or a `client` navigation.
34
+ //
35
+ // A static page carries its Content Security Policy in a meta element,
36
+ // which can name nowhere to report to: a browser ignores report-uri and
37
+ // report-to there. Its violations still reach the page as
38
+ // `securitypolicyviolation` events, and those of the meta element's policy
39
+ // are sent to /_reports, as the browser sends a header's, with no cookie.
40
+ //
41
+ // Errors come from the window's `error` and `unhandledrejection` events and
42
+ // from the hooks an application hands React's root. Each is reduced to a
43
+ // message, a digest, its kind, the same-origin script and line it came from
44
+ // and a few frames of its stack -- never a cookie's or storage's contents,
45
+ // nor the URL's query -- deduplicated, capped per page, filed under the page
46
+ // on screen, and sent in a batch a moment later or when the page is hidden.
47
+ //
48
+ // A report that fails is dropped: telemetry about telemetry would be sent
49
+ // through the thing that just failed.
50
+
51
+ import { csrfHeaders } from './csrf.js';
52
+ import { readPageMetadata } from './router.js';
53
+
54
+ export const VITALS_PATH = '/_vitals';
55
+ export const ERRORS_PATH = '/_errors';
56
+ export const REPORTS_PATH = '/_reports';
57
+
58
+ /** The server's cap on one report, in bytes. */
59
+ export const MAX_BODY_BYTES = 16384;
60
+
61
+ /** Errors reported for one page, at most; the rest are dropped. */
62
+ export const MAX_ERRORS_PER_PAGE = 10;
63
+
64
+ /** Errors in one report: the server takes no more. */
65
+ export const MAX_ERRORS_PER_REPORT = 10;
66
+
67
+ /** How long errors are gathered before they are sent, in milliseconds. */
68
+ export const ERROR_DELAY_MS = 1000;
69
+
70
+ /**
71
+ * The largest value of each vital the server believes: milliseconds, and a
72
+ * score for CLS. A vital past its ceiling is left out of the report, which
73
+ * the server would otherwise refuse whole.
74
+ */
75
+ export const VITAL_CEILINGS = Object.freeze({
76
+ LCP: 120000,
77
+ INP: 120000,
78
+ CLS: 10,
79
+ FCP: 120000,
80
+ TTFB: 120000,
81
+ NAV: 120000,
82
+ });
83
+
84
+ /** Policy violations sent for one document, at most; the rest are dropped. */
85
+ export const MAX_VIOLATIONS = 10;
86
+
87
+ /** Where an error was seen. */
88
+ const KINDS = new Set(['error', 'rejection', 'caught', 'uncaught', 'recoverable']);
89
+
90
+ /** What is kept of an error: a message, and a few short frames. */
91
+ const MAX_MESSAGE = 300;
92
+ const MAX_STACK_LINES = 10;
93
+ const MAX_STACK_LINE = 200;
94
+
95
+ /** A route name, as the server allows one. */
96
+ const ROUTE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
97
+
98
+ /** What a page's reports may be filed under: a route name, or a view. */
99
+ const ROUTE_LABEL = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]{0,127}$/;
100
+
101
+ /** The label as given, when it is one; null otherwise. */
102
+ const labelOf = (/** @type {unknown} */ value) =>
103
+ typeof value === 'string' && ROUTE_LABEL.test(value) ? value : null;
104
+
105
+ /** A digest a server failure is shown under: the framework's, or React's. */
106
+ const DIGEST = /^[A-Za-z0-9_-]{1,64}$/;
107
+
108
+ /** A stack frame's script and line: `at f (https://host/a.js:1:2)`, `f@https://host/a.js:1:2`. */
109
+ const FRAME = /([a-z][a-z0-9+.-]*:\/\/[^\s()]+?|\/[^\s()]+?):(\d+):(\d+)\)?$/i;
110
+
111
+ /** A browser's own word for an error in a script from another origin: nothing to go on. */
112
+ const OPAQUE = 'Script error.';
113
+
114
+ /**
115
+ * The route name a route token names, or null. Read without verifying: the
116
+ * server holds the name to its registry whatever this says.
117
+ *
118
+ * @param {string | null | undefined} token
119
+ * @returns {string | null}
120
+ */
121
+ export function routeNameOf(token) {
122
+ if (typeof token !== 'string') return null;
123
+ const body = token.split('.')[1];
124
+ if (!body) return null;
125
+
126
+ try {
127
+ const route = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')))?.r;
128
+ return typeof route === 'string' && route.length <= 128 && ROUTE_NAME.test(route) ? route : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @typedef {{
136
+ * router?: { routeTokens(): string[], currentView?(): string | null, on(name: string, fn: (event: CustomEvent) => void): () => void } | null,
137
+ * window?: Window,
138
+ * fetch?: typeof fetch,
139
+ * vitals?: boolean,
140
+ * errors?: boolean,
141
+ * violations?: boolean,
142
+ * }} TelemetryOptions
143
+ *
144
+ * `router` is the page's client router: the page on screen is read from it,
145
+ * and an error is counted against the page it happened on. Without one, the
146
+ * document's own page metadata says which page this is.
147
+ */
148
+
149
+ /** @param {TelemetryOptions} [options] */
150
+ export function createTelemetry(options = {}) {
151
+ const win = options.window ?? globalThis.window;
152
+ const doc = win?.document;
153
+ const router = options.router ?? null;
154
+ const doFetch = options.fetch ?? ((url, init) => win.fetch(url, init));
155
+
156
+ let started = false;
157
+ let stopped = false;
158
+
159
+ /** Set once the session has ended on this client: nothing more is sent for it. */
160
+ let revoked = false;
161
+
162
+ /** @type {Array<() => void>} */
163
+ const teardown = [];
164
+
165
+ // ---------------------------------------------------------------- the page
166
+
167
+ /**
168
+ * The page on screen: what its reports are filed under, how it came to be
169
+ * shown -- the document's load, or a client navigation -- how long that
170
+ * navigation took, and whether its vitals have gone.
171
+ *
172
+ * @type {{ route: string | null, navigation: 'document' | 'client', nav: number | null, sent: boolean, interactionsBefore: number }}
173
+ */
174
+ let page = { route: null, navigation: 'document', nav: null, sent: false, interactionsBefore: 0 };
175
+
176
+ /** When the visit to the next page began, by the router's `start`. */
177
+ let visitStarted = /** @type {number | null} */ (null);
178
+
179
+ /** The route of the page on screen. */
180
+ function currentRoute() {
181
+ return page.route;
182
+ }
183
+
184
+ /** The route of a page the router has just mounted: its token's name, or its view. */
185
+ function mountedRoute() {
186
+ if (router === null) return null;
187
+ const [token] = router.routeTokens();
188
+ return routeNameOf(token) ?? labelOf(router.currentView?.() ?? null);
189
+ }
190
+
191
+ const now = () => win?.performance?.now?.() ?? 0;
192
+
193
+ // ------------------------------------------------------------------ sending
194
+
195
+ /**
196
+ * POSTs one report as every other request from script does -- with the
197
+ * session's CSRF token -- and outlives the page with `keepalive`, which
198
+ * sendBeacon cannot do with a header. A page with no token has no session
199
+ * to send it under, and sends it with no cookie at all, which the server
200
+ * takes without one (ADR 0129).
201
+ *
202
+ * @param {string} path
203
+ * @param {unknown} body
204
+ * @param {string} [type]
205
+ */
206
+ function post(path, body, type = 'application/json') {
207
+ if (revoked) return;
208
+ const headers = path === REPORTS_PATH ? {} : csrfHeaders(doc);
209
+ const sessionless = Object.keys(headers).length === 0;
210
+
211
+ try {
212
+ const sent = doFetch(path, {
213
+ method: 'POST',
214
+ keepalive: true,
215
+ credentials: sessionless ? 'omit' : 'same-origin',
216
+ headers: { 'Content-Type': type, ...headers },
217
+ body: JSON.stringify(body),
218
+ });
219
+ sent?.catch?.(() => {});
220
+ } catch {
221
+ // Dropped; see the top of this file.
222
+ }
223
+ }
224
+
225
+ // ------------------------------------------------------------------- vitals
226
+
227
+ /** What has been measured: raw entry times, worked into vitals at send. */
228
+ const measured = {
229
+ activationStart: 0,
230
+ ttfb: /** @type {number | null} */ (null),
231
+ fcp: /** @type {number | null} */ (null),
232
+ lcp: /** @type {number | null} */ (null),
233
+ cls: /** @type {number | null} */ (null),
234
+ clsWindow: 0,
235
+ clsWindowStart: 0,
236
+ clsLast: 0,
237
+ interactions: /** @type {Map<number, number>} */ (new Map()),
238
+ interactionCount: 0,
239
+ lastInteraction: 0,
240
+ };
241
+
242
+ /** When the page was first hidden: a paint after it was not one the reader saw load. */
243
+ let firstHidden = Infinity;
244
+
245
+ /** @type {Array<[PerformanceObserver, (entries: PerformanceEntryList) => void]>} */
246
+ const observers = [];
247
+
248
+ /** Whether layout shifts are measured here: a page's CLS starts at 0 where they are. */
249
+ let shiftsObserved = false;
250
+
251
+ /** The interactions the browser had counted when the page on screen was mounted. */
252
+ const interactionTotal = () => {
253
+ const count = /** @type {{ interactionCount?: number }} */ (win?.performance ?? {}).interactionCount;
254
+ return typeof count === 'number' && count > 0 ? count : 0;
255
+ };
256
+
257
+ /**
258
+ * @param {string} type
259
+ * @param {(entries: PerformanceEntryList) => void} handle
260
+ * @param {Record<string, unknown>} [more]
261
+ */
262
+ function observe(type, handle, more = {}) {
263
+ const Observer = win.PerformanceObserver;
264
+ if (typeof Observer !== 'function' || !Observer.supportedEntryTypes?.includes(type)) return false;
265
+ try {
266
+ const observer = new Observer((list) => handle(list.getEntries()));
267
+ observer.observe({ type, buffered: true, ...more });
268
+ observers.push([observer, handle]);
269
+ return true;
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ function observeVitals() {
276
+ firstHidden = doc?.visibilityState === 'hidden' ? 0 : Infinity;
277
+
278
+ observe('navigation', (entries) => {
279
+ const navigation = /** @type {PerformanceNavigationTiming | undefined} */ (entries[0]);
280
+ if (!navigation) return;
281
+ measured.activationStart =
282
+ /** @type {{ activationStart?: number }} */ (navigation).activationStart ?? 0;
283
+ if (navigation.responseStart > 0) measured.ttfb = navigation.responseStart;
284
+ });
285
+
286
+ observe('paint', (entries) => {
287
+ for (const entry of entries) {
288
+ if (entry.name === 'first-contentful-paint' && entry.startTime < firstHidden)
289
+ measured.fcp = entry.startTime;
290
+ }
291
+ });
292
+
293
+ // The last candidate is the largest; the browser stops naming them once
294
+ // the reader interacts.
295
+ observe('largest-contentful-paint', (entries) => {
296
+ const last = entries[entries.length - 1];
297
+ if (last && last.startTime < firstHidden) measured.lcp = last.startTime;
298
+ });
299
+
300
+ // The largest session window of shifts: shifts less than a second apart,
301
+ // over five seconds at most, none just after the reader's own input.
302
+ shiftsObserved = observe('layout-shift', (entries) => {
303
+ for (const entry of /** @type {Array<PerformanceEntry & { value: number, hadRecentInput: boolean }>} */ (
304
+ entries
305
+ )) {
306
+ if (entry.hadRecentInput) continue;
307
+ const joins =
308
+ measured.clsWindow > 0 &&
309
+ entry.startTime - measured.clsLast < 1000 &&
310
+ entry.startTime - measured.clsWindowStart < 5000;
311
+ measured.clsWindow = joins ? measured.clsWindow + entry.value : entry.value;
312
+ if (!joins) measured.clsWindowStart = entry.startTime;
313
+ measured.clsLast = entry.startTime;
314
+ measured.cls = Math.max(measured.cls ?? 0, measured.clsWindow);
315
+ }
316
+ });
317
+ if (shiftsObserved) measured.cls ??= 0;
318
+
319
+ // Each interaction's latency is its slowest event; INP is the slowest
320
+ // interaction, setting one aside for every fifty -- the 98th percentile.
321
+ /** @param {PerformanceEntryList} entries */
322
+ const interactions = (entries) => {
323
+ for (const entry of /** @type {Array<PerformanceEntry & { interactionId?: number }>} */ (entries)) {
324
+ const id = entry.interactionId;
325
+ if (!id) continue;
326
+ if (!measured.interactions.has(id) && id > measured.lastInteraction) measured.interactionCount++;
327
+ measured.lastInteraction = Math.max(measured.lastInteraction, id);
328
+ measured.interactions.set(id, Math.max(measured.interactions.get(id) ?? 0, entry.duration));
329
+ // Only the longest few can be the answer; the rest are let go.
330
+ if (measured.interactions.size > 50) {
331
+ const kept = [...measured.interactions].sort((a, b) => b[1] - a[1]).slice(0, 10);
332
+ measured.interactions = new Map(kept);
333
+ }
334
+ }
335
+ };
336
+ observe('event', interactions, { durationThreshold: 40 });
337
+ observe('first-input', interactions);
338
+ }
339
+
340
+ /**
341
+ * The vitals of the page on screen as they stand: milliseconds, and a
342
+ * score for CLS. A vital past its ceiling, or not measured, is absent. A
343
+ * page a client navigation mounted has no LCP, FCP or TTFB of its own.
344
+ *
345
+ * @returns {Partial<Record<'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB' | 'NAV', number>>}
346
+ */
347
+ function vitals() {
348
+ const since = (time) => (time === null ? null : Math.max(time - measured.activationStart, 0));
349
+ const latencies = [...measured.interactions.values()].sort((a, b) => b - a);
350
+ const counted = interactionTotal() - page.interactionsBefore;
351
+ const total = counted > 0 ? counted : measured.interactionCount;
352
+ const inp =
353
+ latencies.length === 0 ? null : latencies[Math.min(latencies.length - 1, Math.floor(total / 50))];
354
+
355
+ /** @type {Record<string, number | null>} */
356
+ const values =
357
+ page.navigation === 'document'
358
+ ? {
359
+ LCP: since(measured.lcp),
360
+ INP: inp ?? null,
361
+ CLS: measured.cls,
362
+ FCP: since(measured.fcp),
363
+ TTFB: since(measured.ttfb),
364
+ }
365
+ : { INP: inp ?? null, CLS: measured.cls, NAV: page.nav };
366
+
367
+ /** @type {Record<string, number>} */
368
+ const kept = {};
369
+ for (const [name, value] of Object.entries(values)) {
370
+ const ceiling = VITAL_CEILINGS[/** @type {keyof typeof VITAL_CEILINGS} */ (name)];
371
+ if (value === null || !Number.isFinite(value) || value < 0 || value > ceiling) continue;
372
+ kept[name] = name === 'CLS' ? Math.round(value * 10000) / 10000 : Math.round(value * 10) / 10;
373
+ }
374
+ return kept;
375
+ }
376
+
377
+ /**
378
+ * The page on screen's vitals, once: whatever the observers still hold is
379
+ * read first, as this page's. They go on observing, for the next page.
380
+ */
381
+ function sendVitals() {
382
+ if (page.sent || observers.length === 0) return;
383
+ page.sent = true;
384
+
385
+ for (const [observer, handle] of observers) {
386
+ try {
387
+ handle(observer.takeRecords());
388
+ } catch {
389
+ // An observer that cannot be read has nothing more to say.
390
+ }
391
+ }
392
+
393
+ const metrics = vitals();
394
+ if (page.route !== null && Object.keys(metrics).length > 0) {
395
+ post(VITALS_PATH, { route: page.route, navigation: page.navigation, metrics });
396
+ }
397
+ }
398
+
399
+ /**
400
+ * A client navigation mounted a page: the one it replaced has its vitals
401
+ * sent, and this one's are measured from now -- its layout shifts and its
402
+ * interactions, and how long the visit took.
403
+ */
404
+ function mounted() {
405
+ sendVitals();
406
+ const at = now();
407
+ page = {
408
+ route: mountedRoute(),
409
+ navigation: 'client',
410
+ nav: visitStarted === null ? null : Math.max(at - visitStarted, 0),
411
+ sent: false,
412
+ interactionsBefore: interactionTotal(),
413
+ };
414
+ visitStarted = null;
415
+ measured.cls = shiftsObserved ? 0 : null;
416
+ measured.clsWindow = 0;
417
+ measured.clsWindowStart = 0;
418
+ measured.clsLast = 0;
419
+ measured.interactions = new Map();
420
+ measured.interactionCount = 0;
421
+ }
422
+
423
+ // ---------------------------------------------------------- violations
424
+
425
+ /** The policy the document's meta element holds, read at start(): null where it has none. */
426
+ let metaPolicy = /** @type {string | null} */ (null);
427
+
428
+ /** Violations waiting to be sent, and what was sent already, by what it said. */
429
+ /** @type {Array<Record<string, unknown>>} */
430
+ let violations = [];
431
+ const violated = new Set();
432
+ /** @type {ReturnType<typeof setTimeout> | null} */
433
+ let violationTimer = null;
434
+
435
+ /**
436
+ * A URL as the server keeps it: no query, no fragment, no credentials; a
437
+ * keyword (`inline`, `eval`) as itself.
438
+ *
439
+ * @param {unknown} value
440
+ * @returns {string | undefined}
441
+ */
442
+ function bareUrl(value) {
443
+ if (typeof value !== 'string' || value === '') return undefined;
444
+ if (/^[a-z][a-z-]{0,31}$/.test(value)) return value;
445
+ try {
446
+ const url = new URL(value, win.location.href);
447
+ return `${url.protocol}//${url.host}${url.pathname}`.slice(0, 512);
448
+ } catch {
449
+ return undefined;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * A violation of the meta element's policy, which no report-uri can name:
455
+ * sent to /_reports as a Reporting API report would be. Not one of a
456
+ * header's policy -- the browser reports those itself -- and none of its
457
+ * sample, which can hold the script it blocked.
458
+ *
459
+ * @param {SecurityPolicyViolationEvent} event
460
+ */
461
+ function violation(event) {
462
+ if (stopped || metaPolicy === null || String(event.originalPolicy).trim() !== metaPolicy) return;
463
+ const directive = typeof event.effectiveDirective === 'string' ? event.effectiveDirective : '';
464
+ const body = {
465
+ documentURL: bareUrl(event.documentURI),
466
+ blockedURL: bareUrl(event.blockedURI),
467
+ effectiveDirective: directive,
468
+ disposition: event.disposition === 'report' ? 'report' : 'enforce',
469
+ sourceFile: bareUrl(event.sourceFile),
470
+ lineNumber: Number.isInteger(event.lineNumber) ? event.lineNumber : undefined,
471
+ columnNumber: Number.isInteger(event.columnNumber) ? event.columnNumber : undefined,
472
+ };
473
+ const key = [directive, body.blockedURL, body.sourceFile, body.lineNumber].join('\u0000');
474
+ if (violated.has(key) || violated.size >= MAX_VIOLATIONS) return;
475
+ violated.add(key);
476
+
477
+ violations.push({ type: 'csp-violation', body });
478
+ violationTimer ??= win.setTimeout(flushViolations, ERROR_DELAY_MS);
479
+ }
480
+
481
+ function flushViolations() {
482
+ if (violationTimer !== null) win.clearTimeout(violationTimer);
483
+ violationTimer = null;
484
+ if (violations.length === 0) return;
485
+ post(REPORTS_PATH, violations.splice(0), 'application/reports+json');
486
+ }
487
+
488
+ // ------------------------------------------------------------------- errors
489
+
490
+ /** What has been reported for the page on screen, by what it said; and how many. */
491
+ let seen = new Set();
492
+ let reportedHere = 0;
493
+
494
+ /** Errors waiting to be sent, each with the route it happened on. */
495
+ /** @type {Array<{ route: string, error: Record<string, unknown> }>} */
496
+ let pending = [];
497
+ /** @type {ReturnType<typeof setTimeout> | null} */
498
+ let timer = null;
499
+
500
+ /** Error objects already taken, by whichever way they came first. */
501
+ const taken = new WeakSet();
502
+
503
+ /** The value a hook is handing on to the browser's own reporting, not to be taken twice. */
504
+ let handingOn;
505
+
506
+ /**
507
+ * Reports an error for the page on screen. Nothing for a page with no
508
+ * route name, nothing past the page's cap, and nothing it has reported
509
+ * already.
510
+ *
511
+ * @param {unknown} error
512
+ * @param {string} [kind]
513
+ * @param {{ message?: unknown, filename?: unknown, lineno?: unknown }} [event]
514
+ */
515
+ function reportError(error, kind = 'error', event = {}) {
516
+ if (stopped || revoked || !KINDS.has(kind)) return;
517
+ if (error !== null && (typeof error === 'object' || typeof error === 'function')) {
518
+ if (taken.has(error)) return;
519
+ taken.add(error);
520
+ }
521
+
522
+ const route = currentRoute();
523
+ if (route === null || reportedHere >= MAX_ERRORS_PER_PAGE) return;
524
+
525
+ const described = describe(error, kind, event);
526
+ if (described === null) return;
527
+
528
+ const key = [kind, described.message, described.file ?? '', described.line ?? ''].join('\u0000');
529
+ if (seen.has(key)) return;
530
+ seen.add(key);
531
+ reportedHere++;
532
+
533
+ pending.push({ route, error: described });
534
+ timer ??= win.setTimeout(flushErrors, ERROR_DELAY_MS);
535
+ }
536
+
537
+ /**
538
+ * An error as it is reported: its message, the digest a server failure
539
+ * was shown under, the script and line it came from when that is this
540
+ * origin's, and a few frames.
541
+ *
542
+ * @param {unknown} error
543
+ * @param {string} kind
544
+ * @param {{ message?: unknown, filename?: unknown, lineno?: unknown }} event
545
+ * @returns {Record<string, unknown> | null}
546
+ */
547
+ function describe(error, kind, event) {
548
+ const message = messageOf(error, event.message);
549
+ if (message === null) return null;
550
+
551
+ const stack = typeof (/** @type {{ stack?: unknown }} */ (error)?.stack) === 'string' ? error.stack : '';
552
+ const where = typeof event.filename === 'string' && event.filename !== '' ? event : frameOf(stack);
553
+
554
+ /** @type {Record<string, unknown>} */
555
+ const described = { kind, message: withoutQueries(scrub(message)).slice(0, MAX_MESSAGE) };
556
+
557
+ const digest = /** @type {{ digest?: unknown }} */ (error)?.digest;
558
+ if (typeof digest === 'string' && DIGEST.test(digest)) described.digest = digest;
559
+
560
+ const file = sameOriginPath(where?.filename);
561
+ if (file !== null) {
562
+ described.file = file;
563
+ const line = Number(where.lineno);
564
+ if (Number.isInteger(line) && line >= 1 && line <= 10_000_000) described.line = line;
565
+ }
566
+
567
+ const frames = framesOf(stack);
568
+ if (frames !== '') described.stack = frames;
569
+
570
+ return described;
571
+ }
572
+
573
+ /**
574
+ * What an error says. Never a value's contents when it is not an error or
575
+ * a string: a rejected response, a record, whatever was thrown -- only
576
+ * what kind of thing it was.
577
+ *
578
+ * @param {unknown} error
579
+ * @param {unknown} fallback the event's own message
580
+ * @returns {string | null}
581
+ */
582
+ function messageOf(error, fallback) {
583
+ if (error instanceof Error || (typeof error === 'object' && error !== null && 'message' in error)) {
584
+ const { name, message } = /** @type {{ name?: unknown, message?: unknown }} */ (error);
585
+ const text = typeof message === 'string' ? message : '';
586
+ return typeof name === 'string' && name !== '' && name !== 'Error' ? `${name}: ${text}` : text;
587
+ }
588
+ if (typeof error === 'string') return error;
589
+ if (typeof fallback === 'string' && fallback !== '') return fallback === OPAQUE ? null : fallback;
590
+ return `A value that is not an error: ${error === null ? 'null' : typeof error}`;
591
+ }
592
+
593
+ /**
594
+ * The script and line a stack's first frame names.
595
+ *
596
+ * @param {string} stack
597
+ * @returns {{ filename: string, lineno: number } | null}
598
+ */
599
+ function frameOf(stack) {
600
+ for (const line of stack.split('\n')) {
601
+ const match = FRAME.exec(line.trim());
602
+ if (match) return { filename: match[1], lineno: Number(match[2]) };
603
+ }
604
+ return null;
605
+ }
606
+
607
+ /**
608
+ * A script's path when it is this origin's -- no query, no fragment --
609
+ * and null for any other: a third party's script, an extension's.
610
+ *
611
+ * @param {unknown} file
612
+ * @returns {string | null}
613
+ */
614
+ function sameOriginPath(file) {
615
+ if (typeof file !== 'string' || file === '') return null;
616
+ try {
617
+ const url = new URL(file, win.location.href);
618
+ return url.origin === win.location.origin && url.pathname.length <= 512 ? url.pathname : null;
619
+ } catch {
620
+ return null;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * A few short frames: this origin left off its own URLs, every URL's
626
+ * query and fragment cut, and what a cookie holds taken out.
627
+ *
628
+ * @param {string} stack
629
+ */
630
+ function framesOf(stack) {
631
+ const origin = win.location.origin;
632
+ const lines = [];
633
+ for (const raw of stack.split('\n')) {
634
+ const line = withoutQueries(scrub(raw.trim()).split(origin).join(''));
635
+ if (line === '') continue;
636
+ lines.push(line.slice(0, MAX_STACK_LINE));
637
+ if (lines.length === MAX_STACK_LINES) break;
638
+ }
639
+ return lines.join('\n');
640
+ }
641
+
642
+ /**
643
+ * $text with every query and fragment cut, where a token in an address
644
+ * would be: an absolute URL's, and a relative or path-only one's --
645
+ * `/reset?token=...`, `./x#access_token=...` -- alike. A `?` or `#` in
646
+ * prose goes with them, which costs a message little.
647
+ *
648
+ * @param {string} text
649
+ */
650
+ function withoutQueries(text) {
651
+ return text.replace(/[?#][^\s():'"]*/g, '');
652
+ }
653
+
654
+ /**
655
+ * $text without anything a cookie script can read holds: a CSRF token a
656
+ * failed request's message quoted, say. Storage is never read at all.
657
+ *
658
+ * @param {string} text
659
+ */
660
+ function scrub(text) {
661
+ const cookies = typeof doc?.cookie === 'string' ? doc.cookie : '';
662
+ let scrubbed = text;
663
+ for (const pair of cookies.split(';')) {
664
+ const value = pair.slice(pair.indexOf('=') + 1).trim();
665
+ if (pair.includes('=') && value.length >= 8) scrubbed = scrubbed.split(value).join('[redacted]');
666
+ }
667
+ return scrubbed;
668
+ }
669
+
670
+ /** Sends what is waiting: a report per route, each within the server's caps. */
671
+ function flushErrors() {
672
+ if (timer !== null) win.clearTimeout(timer);
673
+ timer = null;
674
+ if (pending.length === 0) return;
675
+
676
+ /** @type {Map<string, Array<Record<string, unknown>>>} */
677
+ const byRoute = new Map();
678
+ for (const { route, error } of pending.splice(0)) {
679
+ if (!byRoute.has(route)) byRoute.set(route, []);
680
+ byRoute.get(route)?.push(error);
681
+ }
682
+
683
+ for (const [route, errors] of byRoute) {
684
+ for (const body of reports(route, errors)) post(ERRORS_PATH, body);
685
+ }
686
+ }
687
+
688
+ /**
689
+ * $errors as reports of at most MAX_ERRORS_PER_REPORT, each under
690
+ * MAX_BODY_BYTES as sent.
691
+ *
692
+ * @param {string} route
693
+ * @param {Array<Record<string, unknown>>} errors
694
+ */
695
+ function reports(route, errors) {
696
+ const bytes = (body) => new TextEncoder().encode(JSON.stringify(body)).length;
697
+ const bodies = [];
698
+ let current = [];
699
+ for (const error of errors) {
700
+ const next = [...current, error];
701
+ if (
702
+ current.length > 0 &&
703
+ (next.length > MAX_ERRORS_PER_REPORT || bytes({ route, errors: next }) > MAX_BODY_BYTES)
704
+ ) {
705
+ bodies.push({ route, errors: current });
706
+ current = [error];
707
+ } else {
708
+ current = next;
709
+ }
710
+ }
711
+ if (current.length > 0) bodies.push({ route, errors: current });
712
+ return bodies.filter((body) => bytes(body) <= MAX_BODY_BYTES);
713
+ }
714
+
715
+ /**
716
+ * A hook for React's root: reports, then hands the error on as React's
717
+ * own default would -- to the console for one a boundary caught, to the
718
+ * browser's error reporting otherwise -- without it being taken twice.
719
+ *
720
+ * @param {string} kind
721
+ * @param {(error: unknown) => void} handOn
722
+ */
723
+ function hook(kind, handOn) {
724
+ return (/** @type {unknown} */ error) => {
725
+ reportError(error, kind);
726
+ handingOn = error;
727
+ try {
728
+ handOn(error);
729
+ } finally {
730
+ handingOn = undefined;
731
+ }
732
+ };
733
+ }
734
+
735
+ const toConsole = (error) => console.error(error);
736
+ const toBrowser = (error) =>
737
+ typeof win?.reportError === 'function' ? win.reportError(error) : console.error(error);
738
+
739
+ // ---------------------------------------------------------------- lifecycle
740
+
741
+ /** @param {Window} target @param {string} name @param {(event: any) => void} fn */
742
+ function listen(target, name, fn) {
743
+ target.addEventListener(name, fn);
744
+ teardown.push(() => target.removeEventListener(name, fn));
745
+ }
746
+
747
+ function hidden() {
748
+ if (doc?.visibilityState === 'hidden') firstHidden = Math.min(firstHidden, win.performance?.now?.() ?? 0);
749
+ sendVitals();
750
+ flushErrors();
751
+ flushViolations();
752
+ }
753
+
754
+ const telemetry = {
755
+ /** Starts measuring and listening. Once; and never again after stop(). */
756
+ start() {
757
+ if (started || stopped || win === undefined || doc === undefined) return telemetry;
758
+ started = true;
759
+
760
+ const [token] = router?.routeTokens() ?? [];
761
+ const metadata = readPageMetadata(doc);
762
+ page.route = labelOf(metadata?.route) ?? routeNameOf(metadata?.routeToken ?? token);
763
+ page.interactionsBefore = 0;
764
+
765
+ if (options.vitals !== false) observeVitals();
766
+
767
+ if (options.violations !== false) {
768
+ const meta = doc.querySelector?.('meta[http-equiv="Content-Security-Policy" i]');
769
+ const content = meta?.getAttribute?.('content');
770
+ metaPolicy = typeof content === 'string' && content.trim() !== '' ? content.trim() : null;
771
+ if (metaPolicy !== null) listen(doc, 'securitypolicyviolation', violation);
772
+ }
773
+
774
+ if (options.errors !== false) {
775
+ listen(win, 'error', (event) => {
776
+ if (event.error !== undefined && event.error === handingOn) return;
777
+ reportError(event.error ?? null, 'error', event);
778
+ });
779
+ listen(win, 'unhandledrejection', (event) => reportError(event.reason, 'rejection'));
780
+ }
781
+
782
+ listen(doc, 'visibilitychange', () => {
783
+ if (doc.visibilityState === 'hidden') hidden();
784
+ });
785
+ listen(win, 'pagehide', hidden);
786
+
787
+ if (router !== null) {
788
+ // When the visit to the next page began, for its NAV.
789
+ teardown.push(
790
+ router.on('start', (event) => {
791
+ if (!event.detail?.background) visitStarted = now();
792
+ }),
793
+ );
794
+ // A page the router mounts is a page of its own: its errors are
795
+ // counted and told apart afresh, and its vitals are its own. A poll
796
+ // or a partial reload is the same page.
797
+ teardown.push(
798
+ router.on('success', (event) => {
799
+ if (event.detail?.background || event.detail?.partial) return;
800
+ seen = new Set();
801
+ reportedHere = 0;
802
+ if (options.vitals !== false && !stopped) mounted();
803
+ else page = { ...page, route: mountedRoute() };
804
+ }),
805
+ );
806
+ // The session has ended here: nothing more is sent under it.
807
+ teardown.push(
808
+ router.on('revoke', () => {
809
+ revoked = true;
810
+ pending = [];
811
+ violations = [];
812
+ }),
813
+ );
814
+ }
815
+
816
+ return telemetry;
817
+ },
818
+
819
+ /** Stops listening and measuring, sending nothing more. */
820
+ stop() {
821
+ stopped = true;
822
+ for (const undo of teardown.splice(0)) undo();
823
+ for (const [observer] of observers.splice(0)) observer.disconnect();
824
+ if (timer !== null) win.clearTimeout(timer);
825
+ timer = null;
826
+ pending = [];
827
+ if (violationTimer !== null) win.clearTimeout(violationTimer);
828
+ violationTimer = null;
829
+ violations = [];
830
+ },
831
+
832
+ reportError: (/** @type {unknown} */ error, /** @type {string} */ kind = 'error') =>
833
+ reportError(error, kind),
834
+
835
+ /** For hydrateRoot()'s options: an error a boundary caught. */
836
+ onCaughtError: hook('caught', toConsole),
837
+
838
+ /** For hydrateRoot()'s options: an error no boundary caught. */
839
+ onUncaughtError: hook('uncaught', toBrowser),
840
+
841
+ /** For hydrateRoot()'s options: an error React recovered from, such as a hydration mismatch. */
842
+ onRecoverableError: hook('recoverable', toBrowser),
843
+
844
+ /** Sends the errors waiting now, rather than in a moment. */
845
+ flush: flushErrors,
846
+
847
+ vitals,
848
+ };
849
+
850
+ return telemetry;
851
+ }