@tinvar/react 0.0.0-stage → 0.4.1

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,510 @@
1
+ // Fetching one deferred subtree.
2
+ //
3
+ // tinvar-spec.md, "Deferred components": "On the trigger, the client requests
4
+ // `GET /_fragment/<token>` with `RSC: 1`. ... The response is RSC payload
5
+ // only, sent `private, no-store` with a refreshed token."
6
+ //
7
+ // This module owns the request and the token lifecycle, and nothing else. It
8
+ // does not know about React, triggers or the DOM, so the retry and refresh
9
+ // rules can be read -- and tested -- without mounting anything.
10
+
11
+ import { BootstrapError, bootstrapToken, isPlaceholder } from './bootstrap.js';
12
+ import { LIVE_TOKEN_HEADER } from './router.js';
13
+
14
+ export const FRAGMENT_PREFIX = '/_fragment/';
15
+ export const TOKEN_HEADER = 'x-fragment-token';
16
+
17
+ /** What the endpoint promises to answer with. Anything else is not a fragment. */
18
+ export const FRAGMENT_CONTENT_TYPE = 'text/x-component';
19
+
20
+ /** The shape a token the server issued always has. */
21
+ const TOKEN_SHAPE = /^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
22
+
23
+ /** Floor between two loads of one boundary, and the base for backoff. */
24
+ export const MIN_INTERVAL_MS = 250;
25
+
26
+ /** After this many consecutive failures a boundary stops trying. */
27
+ export const MAX_FAILURES = 5;
28
+
29
+ export class FragmentError extends Error {
30
+ /**
31
+ * @param {string} message
32
+ * @param {number} status
33
+ * @param {string | null} [digest] what the server logged a 5xx under
34
+ */
35
+ constructor(message, status, digest = null) {
36
+ super(message);
37
+ this.name = 'FragmentError';
38
+ this.status = status;
39
+ this.digest = digest;
40
+ }
41
+ }
42
+
43
+ /** What a digest the server sends looks like; anything else is not shown. */
44
+ const DIGEST_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;
45
+
46
+ /**
47
+ * Whether a failed load was the server's render throwing -- and so already
48
+ * reported, with a digest -- rather than a request that never got a proper
49
+ * answer. Only that is what a boundary's `rescue` slot is for
50
+ * (tinvar-spec.md, "Rescue and reloading"): an offline blip or a 429 is
51
+ * retried, and a 404 means the page has to be reloaded.
52
+ *
53
+ * Two shapes reach here. PHP failing -- the render channel, the renderer
54
+ * process, the route's own code -- is a 5xx from the exception handler.
55
+ * A component throwing inside the renderer is an error row React writes in
56
+ * the payload, which surfaces as an error carrying the digest the renderer
57
+ * gave it: when the payload is decoded, if it was the fragment's root, or
58
+ * when it renders, if it was further down.
59
+ *
60
+ * @param {unknown} error
61
+ * @returns {{ digest: string | null } | null}
62
+ */
63
+ export function serverFailure(error) {
64
+ if (error instanceof FragmentError) {
65
+ return error.status >= 500 ? { digest: error.digest } : null;
66
+ }
67
+ const digest = /** @type {{ digest?: unknown } | null} */ (error)?.digest;
68
+ return typeof digest === 'string' ? { digest: DIGEST_SHAPE.test(digest) ? digest : null } : null;
69
+ }
70
+
71
+ /**
72
+ * Holds one boundary's token across refetches.
73
+ *
74
+ * The token rotates: every response carries a replacement, so a boundary that
75
+ * refreshes on a timer outlives the original hour. Keeping the LATEST one is
76
+ * the whole mechanism -- reusing the first token forever would start failing
77
+ * at the expiry, which is exactly the case refresh boundaries exist for.
78
+ */
79
+ export class FragmentHandle {
80
+ #token;
81
+ #fetch;
82
+ #inFlight = null;
83
+ /** The signal the request in flight was started under. */
84
+ #inFlightSignal = undefined;
85
+ #failures = 0;
86
+ #notBefore = 0;
87
+ #terminal = null;
88
+ /** The status a terminal handle keeps answering with. */
89
+ #terminalStatus = 404;
90
+ /** What the last load's render read, signed: the boundary listens with it. */
91
+ #liveToken = null;
92
+
93
+ /**
94
+ * @param {string} token
95
+ * @param {typeof fetch} [fetchImpl]
96
+ */
97
+ constructor(token, fetchImpl) {
98
+ this.#token = token;
99
+ this.#fetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
100
+ }
101
+
102
+ get token() {
103
+ return this.#token;
104
+ }
105
+
106
+ /**
107
+ * The live token the last successful load came with -- the topics of the
108
+ * tags its render read -- or null when it read none, or the server has no
109
+ * live updates. A group's members share their one render's.
110
+ *
111
+ * @returns {string | null}
112
+ */
113
+ get liveToken() {
114
+ return this.#liveToken;
115
+ }
116
+
117
+ /** Whether this handle has given up: a 404, a 401, or too many failures. */
118
+ get terminal() {
119
+ return this.#terminal !== null;
120
+ }
121
+
122
+ /**
123
+ * Takes a token the server issued afresh for the same boundary -- a
124
+ * re-render of the page mints a new one each time. The backoff and the
125
+ * gap between requests carry over; a 404 does not, since it was this
126
+ * server's answer to the OLD token and the new one has not been tried, and
127
+ * neither does the failure count.
128
+ *
129
+ * @param {string} token
130
+ */
131
+ adopt(token) {
132
+ this.#token = token;
133
+ this.#terminal = null;
134
+ // A fresh token earns a fresh count. Kept, a handle that had reached the
135
+ // limit went terminal again after one try with the new token. The
136
+ // pacing -- the earliest next request -- is kept.
137
+ this.#failures = 0;
138
+ }
139
+
140
+ /**
141
+ * Fetches the subtree.
142
+ *
143
+ * Concurrent calls share one request. A boundary can be asked to load by a
144
+ * trigger and by a revalidation in the same tick, and two requests would
145
+ * mean two renders of the same subtree racing to mount.
146
+ *
147
+ * With `decode`, the body is decoded here and the load resolves with what
148
+ * it returns. A 200 that cannot be decoded is a failed load as much as a
149
+ * 500 is; decoded by the caller after the handle had counted the 200 as a
150
+ * success, it reset the failure count and was retried every 250 ms for
151
+ * good. Shared requests share the decoded result, so one stream is never
152
+ * read twice.
153
+ *
154
+ * `headers` are the claims the request makes: X-Have-Shared alone is sent.
155
+ *
156
+ * @template [T=ReadableStream<Uint8Array>]
157
+ * @param {{signal?: AbortSignal, decode?: (stream: ReadableStream<Uint8Array>) => T | Promise<T>, headers?: Record<string, string>}} [options]
158
+ * @returns {Promise<T>}
159
+ */
160
+ load(options = {}) {
161
+ // A boundary the server has finished with stops asking. The 404 it
162
+ // answers covers every reason -- expired, wrong session, revoked build --
163
+ // and none of them get better by retrying.
164
+ if (this.#terminal) {
165
+ return Promise.reject(new FragmentError(this.#terminal, this.#terminalStatus));
166
+ }
167
+
168
+ // Shared, unless whoever started it has since abandoned it. React's
169
+ // Strict Mode mounts a boundary, unmounts it -- aborting its load -- and
170
+ // mounts it again at once; handing the second mount the first one's
171
+ // aborted request left it reporting an abort it never asked for, with
172
+ // the fallback on screen for good.
173
+ if (this.#inFlight && !this.#inFlightSignal?.aborted) return this.#inFlight;
174
+
175
+ // `always` boundaries refetch on every intersection, and an element that
176
+ // oscillates across the rootMargin edge -- a sticky header, an animation,
177
+ // momentum scroll -- would otherwise be a request storm, each request a
178
+ // full server render. Backoff after a failure, a floor between successes.
179
+ const wait = Math.max(0, this.#notBefore - Date.now());
180
+
181
+ const attempt = (wait > 0 ? sleep(wait) : Promise.resolve())
182
+ .then(() => {
183
+ // Abandoned during the wait -- the boundary was destroyed. Nothing
184
+ // is sent: a native fetch would refuse an aborted signal, but an
185
+ // injected one need not honour it.
186
+ if (options.signal?.aborted) {
187
+ throw new DOMException('The fragment load was aborted.', 'AbortError');
188
+ }
189
+ return this.#request(options);
190
+ })
191
+ .finally(() => {
192
+ // Only its own slot: an abandoned request settling late must not
193
+ // clear the one that replaced it.
194
+ if (this.#inFlight !== attempt) return;
195
+ this.#inFlight = null;
196
+ });
197
+
198
+ // The stored promise can reject while a caller is still waiting on a
199
+ // timer before it attaches its handler, and Node reports that as an
200
+ // unhandled rejection even though the caller does handle it a tick later.
201
+ // The swallowing handler is on a derived promise, so the one returned
202
+ // still rejects for whoever asked.
203
+ attempt.catch(() => {});
204
+ this.#inFlight = attempt;
205
+ this.#inFlightSignal = options.signal;
206
+
207
+ return attempt;
208
+ }
209
+
210
+ #request(options) {
211
+ return FragmentHandle.#send([this], options);
212
+ }
213
+
214
+ /**
215
+ * Fetches the boundaries of one <Deferred group> in one request:
216
+ * `GET /_fragment/<token>/<token>/...` (ADR 0018).
217
+ *
218
+ * The handles' own rules hold for all of them at once: none is sent while
219
+ * one has given up -- the server refuses a group for any token it will
220
+ * not act on -- the wait is the longest any of them owes, and a failure is
221
+ * counted against each. The payload's root is the list of their subtrees
222
+ * in the order of the handles, which is what this resolves with.
223
+ *
224
+ * @param {FragmentHandle[]} handles two or more, from one provider
225
+ * @param {{signal?: AbortSignal, decode: (stream: ReadableStream<Uint8Array>) => unknown, headers?: Record<string, string>}} options
226
+ * @returns {Promise<unknown[]>}
227
+ */
228
+ static loadGroup(handles, options) {
229
+ const done = handles.find((handle) => handle.#terminal !== null);
230
+ if (done) {
231
+ return Promise.reject(new FragmentError(done.#terminal, done.#terminalStatus));
232
+ }
233
+
234
+ const wait = Math.max(0, ...handles.map((handle) => handle.#notBefore - Date.now()));
235
+
236
+ return (wait > 0 ? sleep(wait) : Promise.resolve()).then(() => {
237
+ if (options.signal?.aborted) {
238
+ throw new DOMException('The fragment load was aborted.', 'AbortError');
239
+ }
240
+ return FragmentHandle.#send(handles, options);
241
+ });
242
+ }
243
+
244
+ /**
245
+ * One request for one boundary or a group's, and what each handle makes
246
+ * of the answer -- one path, so a group cannot drift from the rules a lone
247
+ * boundary has had to learn.
248
+ *
249
+ * @param {FragmentHandle[]} handles
250
+ * @param {{signal?: AbortSignal, decode?: (stream: ReadableStream<Uint8Array>) => unknown, headers?: Record<string, string>}} options
251
+ * `headers` are the claims the request makes: X-Have-Shared, the
252
+ * once-shared values' holds (ADR 0074), and nothing else
253
+ */
254
+ static async #send(handles, { signal, decode, headers: claims = {} }) {
255
+ const fetchImpl = handles[0].#fetch;
256
+ if (!fetchImpl) {
257
+ throw new FragmentError('No fetch implementation available.', 0);
258
+ }
259
+
260
+ const failed = () => {
261
+ for (const handle of handles) handle.#fail();
262
+ };
263
+
264
+ // A statically built page's boundaries carry a placeholder, not a token:
265
+ // the visitor's own come from /_bootstrap, the first time each loads,
266
+ // and are kept -- and rotated -- from then on like any other.
267
+ try {
268
+ await Promise.all(
269
+ handles.map(async (handle) => {
270
+ if (isPlaceholder(handle.#token)) handle.#token = await bootstrapToken(handle.#token, fetchImpl);
271
+ }),
272
+ );
273
+ } catch (error) {
274
+ if (isAbort(error, signal)) throw error;
275
+ failed();
276
+ // The server knows no such built page: an old copy, which no retry
277
+ // makes current.
278
+ if (error instanceof BootstrapError && error.status === 404) {
279
+ for (const handle of handles) {
280
+ handle.#terminal = 'This page is out of date; reload it.';
281
+ handle.#terminalStatus = 404;
282
+ }
283
+ }
284
+ throw new FragmentError(
285
+ error instanceof Error ? error.message : 'Bootstrap failed.',
286
+ error?.status ?? 0,
287
+ );
288
+ }
289
+
290
+ const sent = handles.map((handle) => handle.#token);
291
+
292
+ let response;
293
+ try {
294
+ response = await fetchImpl(FRAGMENT_PREFIX + sent.map(encodeURIComponent).join('/'), {
295
+ method: 'GET',
296
+ // "with RSC: 1" -- the same header a page's payload request uses, so
297
+ // caches that key on it keep the two apart.
298
+ headers: { ...sharedClaims(claims), RSC: '1', accept: FRAGMENT_CONTENT_TYPE },
299
+ credentials: 'same-origin',
300
+ // credentials governs COOKIES, not origin. Without these two, a
301
+ // cross-origin redirect anywhere in front of /_fragment/* -- a global
302
+ // middleware bouncing an expired session to an external IdP is the
303
+ // realistic shape -- would be followed, and a permissive destination
304
+ // could then supply both the body and the rotated token. An RSC payload
305
+ // carries client-module rows, so a forged stream is a module-loading
306
+ // primitive: the one path in this feature that reaches script execution.
307
+ mode: 'same-origin',
308
+ redirect: 'error',
309
+ cache: 'no-store',
310
+ signal,
311
+ });
312
+ } catch (error) {
313
+ // A request that never got an answer -- offline, a reset connection,
314
+ // the redirect refused just above -- is a failure like any status. It
315
+ // skipped the count, so a boundary on screen re-armed and asked again
316
+ // at once, every frame, with no backoff and no limit. Being abandoned
317
+ // is not a failure: nothing was wrong with the fragment.
318
+ if (!isAbort(error, signal)) failed();
319
+ throw error;
320
+ }
321
+
322
+ if (!response.ok) {
323
+ // The server says one thing for every rejection -- expired, wrong
324
+ // session, revoked build, no such fragment -- so there is nothing to
325
+ // branch on and nothing worth showing a user. A 404 is final: the page
326
+ // has to be reloaded to get a token this server will act on.
327
+ failed();
328
+ for (const handle of handles) {
329
+ if (response.status === 404) {
330
+ handle.#terminal = 'This fragment is no longer available; reload the page.';
331
+ handle.#terminalStatus = 404;
332
+ }
333
+
334
+ // A 401 is final too, and about the session rather than the token:
335
+ // "session revocation, which the server signals with a 401"
336
+ // (tinvar-spec.md, "Client-held data"). Retried as an ordinary
337
+ // failure, the boundary went on asking with a session the server had
338
+ // refused. Whoever holds the handle reloads the page; see
339
+ // DeferredBoundary.
340
+ if (response.status === 401) {
341
+ handle.#terminal = 'The session has ended; reload the page.';
342
+ handle.#terminalStatus = 401;
343
+ }
344
+ }
345
+
346
+ // A 5xx is the exception handler's answer, which carries the digest
347
+ // the failure was logged under -- the reference a rescue slot shows.
348
+ const digest = response.status >= 500 ? await loggedDigest(response) : null;
349
+
350
+ throw new FragmentError(`Fragment request failed with ${response.status}.`, response.status, digest);
351
+ }
352
+
353
+ // Rotated only on success, and only if it looks like a token this server
354
+ // issued. The endpoint deliberately withholds the header on a failure, so
355
+ // a replacement arriving with one came from somewhere else -- and storing
356
+ // it bricked the boundary until reload.
357
+ // The rotation of the token this request SENT. If a fresher token was
358
+ // adopted while it was in flight -- a re-render of the page -- that one
359
+ // wins: the rotation descends from the old token and keeps its older
360
+ // absolute lifetime.
361
+ // A group's arrive comma-separated in the order they were sent, all or
362
+ // none; a list of any other length cannot be paired with its boundaries.
363
+ const refreshed = (response.headers.get(TOKEN_HEADER) ?? '').split(',');
364
+ if (refreshed.length === handles.length && refreshed.every((token) => TOKEN_SHAPE.test(token))) {
365
+ handles.forEach((handle, i) => {
366
+ if (handle.#token === sent[i]) handle.#token = refreshed[i];
367
+ });
368
+ }
369
+
370
+ // The endpoint answers one content type. Anything else is not a fragment,
371
+ // and handing it to the mount callback would feed it to the RSC reader.
372
+ const type = response.headers.get('content-type') ?? '';
373
+ if (!type.startsWith(FRAGMENT_CONTENT_TYPE)) {
374
+ failed();
375
+
376
+ throw new FragmentError(
377
+ `Fragment response was ${type || 'untyped'}, not ${FRAGMENT_CONTENT_TYPE}.`,
378
+ response.status,
379
+ );
380
+ }
381
+
382
+ if (!response.body) {
383
+ failed();
384
+
385
+ throw new FragmentError('Fragment response had no body.', response.status);
386
+ }
387
+
388
+ for (const handle of handles) handle.#notBefore = Date.now() + MIN_INTERVAL_MS;
389
+
390
+ if (!decode) {
391
+ const live = response.headers.get(LIVE_TOKEN_HEADER);
392
+ for (const handle of handles) {
393
+ handle.#failures = 0;
394
+ handle.#liveToken = live !== null && TOKEN_SHAPE.test(live) ? live : null;
395
+ }
396
+ return response.body;
397
+ }
398
+
399
+ let decoded;
400
+ try {
401
+ decoded = await decode(response.body);
402
+ } catch (error) {
403
+ if (!isAbort(error, signal)) failed();
404
+ throw error;
405
+ }
406
+
407
+ // A group's payload is the list of its subtrees, one per boundary. Any
408
+ // other root cannot be shared out, and is a failed load like any other.
409
+ if (handles.length > 1 && (!Array.isArray(decoded) || decoded.length !== handles.length)) {
410
+ failed();
411
+
412
+ throw new FragmentError('A group response did not carry one subtree per boundary.', response.status);
413
+ }
414
+
415
+ // Only now is the load a success: the count resets once there is
416
+ // something to show -- and what it read is what the boundary listens to.
417
+ const live = response.headers.get(LIVE_TOKEN_HEADER);
418
+ for (const handle of handles) {
419
+ handle.#failures = 0;
420
+ handle.#liveToken = live !== null && TOKEN_SHAPE.test(live) ? live : null;
421
+ }
422
+
423
+ return decoded;
424
+ }
425
+
426
+ #fail() {
427
+ this.#failures++;
428
+
429
+ if (this.#failures >= MAX_FAILURES) {
430
+ this.#terminal = `This fragment failed ${this.#failures} times and will not be retried.`;
431
+ this.#terminalStatus = 404;
432
+ return;
433
+ }
434
+
435
+ // Exponential, from the same floor a successful load uses.
436
+ this.#notBefore = Date.now() + MIN_INTERVAL_MS * 2 ** this.#failures;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Whether a load ended because its caller left rather than because it failed.
442
+ *
443
+ * @param {unknown} error
444
+ * @param {AbortSignal | undefined} signal
445
+ */
446
+ function isAbort(error, signal) {
447
+ return signal?.aborted === true || /** @type {{ name?: unknown } | null} */ (error)?.name === 'AbortError';
448
+ }
449
+
450
+ /**
451
+ * The digest in the exception handler's JSON body, or null for any other
452
+ * body -- a proxy's HTML page, nothing at all.
453
+ *
454
+ * @param {Response} response
455
+ * @returns {Promise<string | null>}
456
+ */
457
+ async function loggedDigest(response) {
458
+ if (!(response.headers.get('content-type') ?? '').includes('application/json')) return null;
459
+ try {
460
+ const digest = (await response.json())?.error?.digest;
461
+ return typeof digest === 'string' && DIGEST_SHAPE.test(digest) ? digest : null;
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ /** @param {number} ms */
468
+ function sleep(ms) {
469
+ return new Promise((resolve) => setTimeout(resolve, ms));
470
+ }
471
+
472
+ /**
473
+ * The claims a fragment request may carry: X-Have-Shared, the once-shared
474
+ * values' holds, and nothing else -- whatever else a caller passed is not a
475
+ * claim, and a fragment request has no business sending it.
476
+ *
477
+ * @param {Record<string, string>} claims
478
+ * @returns {Record<string, string>}
479
+ */
480
+ function sharedClaims(claims) {
481
+ for (const [name, value] of Object.entries(claims ?? {})) {
482
+ if (name.toLowerCase() === 'x-have-shared' && typeof value === 'string' && value !== '') {
483
+ return { 'X-Have-Shared': value };
484
+ }
485
+ }
486
+ return {};
487
+ }
488
+
489
+ /**
490
+ * What a fragment token is FOR, without when it was issued or expires: its
491
+ * signed claims minus `i` and `e`. Two tokens with the same identity name the
492
+ * same component, props, route and binding. Read without verifying -- the
493
+ * payload is plain base64url JSON, and nothing here trusts it; it only
494
+ * decides whether a boundary already on screen can keep what it shows.
495
+ *
496
+ * @param {string | null} token
497
+ * @returns {string | null}
498
+ */
499
+ export function tokenIdentity(token) {
500
+ if (token === null) return null;
501
+ try {
502
+ const body = token.split('.')[1] ?? '';
503
+ const json = atob(body.replace(/-/g, '+').replace(/_/g, '/'));
504
+ const { i: _issued, e: _expires, ...claims } = JSON.parse(json);
505
+ return JSON.stringify(claims);
506
+ } catch {
507
+ // Not a shape this understands: only an identical token is the same.
508
+ return token;
509
+ }
510
+ }
package/src/head.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /** Marks a tag PHP rendered for a component's to replace. */
4
+ export declare const FALLBACK_ATTRIBUTE: 'data-tinvar-head';
5
+ export declare const FALLBACK: 'fallback';
6
+ /** The key a tag is deduplicated by, on the element. */
7
+ export declare const KEY_ATTRIBUTE: 'data-tinvar-key';
8
+ /** Its depth in the route, on the element. */
9
+ export declare const DEPTH_ATTRIBUTE: 'data-tinvar-depth';
10
+ /** The key every title shares. */
11
+ export declare const TITLE_KEY: 'title';
12
+
13
+ /** The key a meta tag is deduplicated by, or null for one never deduplicated. */
14
+ export declare function metaKey(props: Record<string, unknown>): string | null;
15
+
16
+ /**
17
+ * The key a link is deduplicated by: `headKey`; canonical by its rel alone;
18
+ * alternate with an hreflang by its language; an icon by its sizes, media
19
+ * and type; anything else by its rel and href. Null for a stylesheet, which is
20
+ * React's, and for a link with nothing to key it by.
21
+ */
22
+ export declare function linkKey(props: Record<string, unknown>): string | null;
23
+
24
+ export interface HeadStore {
25
+ register(id: string, key: string, depth: number): () => void;
26
+ wins(id: string): boolean;
27
+ subscribe(listener: () => void): () => void;
28
+ }
29
+
30
+ export declare function createHeadStore(): HeadStore;
31
+
32
+ /**
33
+ * Puts the winning title first in the head -- the deepest, a raw <title> at
34
+ * the document's depth, a fallback below all -- moving it, never removing
35
+ * any. Returns the winner, or null for a head with no title.
36
+ */
37
+ export declare function arrangeTitles(head: HTMLHeadElement): Element | null;
38
+
39
+ /**
40
+ * The title template and default around the document. `titleTemplate` holds
41
+ * `%s` where a <Title>'s text goes; `defaultTitle` is the title when no
42
+ * component names one.
43
+ */
44
+ export declare function HeadProvider(props: {
45
+ titleTemplate?: string;
46
+ defaultTitle?: string;
47
+ children?: ReactNode;
48
+ }): ReactNode;
49
+
50
+ /** The document's title: one, the deepest in the route, through the template. */
51
+ export declare function Title(props: { children: string | number | readonly (string | number)[] }): ReactNode;
52
+
53
+ /** A meta tag: one per name, property or http-equiv, or per `headKey`. */
54
+ export declare function Meta(
55
+ props: {
56
+ name?: string;
57
+ property?: string;
58
+ httpEquiv?: string;
59
+ charSet?: string;
60
+ content?: string;
61
+ headKey?: string;
62
+ } & Record<string, string | undefined>,
63
+ ): ReactNode;
64
+
65
+ /**
66
+ * A link in the head: one canonical URL, one icon per size, media and type, one
67
+ * alternate per language, no link written twice -- or one per `headKey`.
68
+ * The deepest in the route wins. A stylesheet renders as React's own
69
+ * <link>, deduplicated and ordered by React.
70
+ */
71
+ export declare function HeadLink(
72
+ props: {
73
+ rel: string;
74
+ href?: string;
75
+ hrefLang?: string;
76
+ sizes?: string;
77
+ media?: string;
78
+ type?: string;
79
+ as?: string;
80
+ crossOrigin?: '' | 'anonymous' | 'use-credentials';
81
+ title?: string;
82
+ precedence?: string;
83
+ headKey?: string;
84
+ } & Record<string, string | undefined>,
85
+ ): ReactNode;