@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.
- package/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- 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;
|