@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,486 @@
1
+ // Where remembered state is kept: per tab, per history entry, per session.
2
+ //
3
+ // tinvar-spec.md, "Client-held data": it "is bound to (user, tenant). It is
4
+ // cleared, with a full page load, on logout ... and session revocation ...
5
+ // Any mismatch between stored and current user or tenant also purges it."
6
+ //
7
+ // history.state cannot: the router can only write the entry it is on, so a
8
+ // value left in any other entry's state outlives the session in plaintext,
9
+ // for as long as the tab keeps its history -- merely unread. So the values
10
+ // live here instead, in sessionStorage -- which survives a reload and back
11
+ // and forward into the document, and dies with the tab -- under one key, as
12
+ // one map bound to one session. Entries are found by the key the router
13
+ // writes into each history entry's state; the state itself holds no values.
14
+ //
15
+ // The whole map belongs to one binding. Reading under another one wipes it,
16
+ // and so does the router on every binding change and every 401, so nothing
17
+ // remembered under a session can be read, carried over or found in storage
18
+ // once that session has ended or changed -- as far as this tab has heard.
19
+ //
20
+ // History encryption covers what it has not heard (ADR 0019). An entry whose
21
+ // page came with a history key is sealed with it -- AES-GCM, by a key the
22
+ // server derives for the session and the browser imports as one it cannot
23
+ // export -- and kept under `sealed` rather than `entries`. A tab restored
24
+ // with its storage after the session ended, a session that ended elsewhere,
25
+ // a copy of the storage taken off the disk: each finds ciphertext whose key
26
+ // the server hands to no one but that session. An entry whose page came
27
+ // with none -- not signed in, or opted out -- is kept as before, in the
28
+ // clear, under `entries`.
29
+ //
30
+ // Values are stored as JSON. One that does not survive JSON is not
31
+ // remembered faithfully; keep remembered state to plain data.
32
+
33
+ /** The sessionStorage key the map is kept under. */
34
+ export const REMEMBER_STORAGE_KEY = 'tinvar:remember';
35
+
36
+ /** How many history entries' values are kept; the oldest go first. */
37
+ export const MAX_REMEMBERED_ENTRIES = 50;
38
+
39
+ /**
40
+ * Puts `value` in a map by history entry as its newest, dropping the oldest
41
+ * past MAX_REMEMBERED_ENTRIES -- what the router keeps by entry in memory
42
+ * (scroll positions, scroll regions, list anchors) is held to the same limit
43
+ * as what it keeps here. A Map keeps insertion order, so the oldest is the
44
+ * first key.
45
+ *
46
+ * @template V
47
+ * @param {Map<string, V>} map
48
+ * @param {string} entry
49
+ * @param {V} value
50
+ */
51
+ export function keepNewest(map, entry, value) {
52
+ map.delete(entry);
53
+ map.set(entry, value);
54
+ while (map.size > MAX_REMEMBERED_ENTRIES) {
55
+ const oldest = map.keys().next();
56
+ if (oldest.done === true) break;
57
+ map.delete(oldest.value);
58
+ }
59
+ }
60
+
61
+ /** AES-GCM's nonce, in bytes: random for every seal. */
62
+ const IV_BYTES = 12;
63
+
64
+ /** A history key's length, in bytes: AES-256. */
65
+ const KEY_BYTES = 32;
66
+
67
+ /**
68
+ * @typedef {{
69
+ * binding: string,
70
+ * order: string[],
71
+ * entries: Record<string, Record<string, unknown>>,
72
+ * sealed?: Record<string, string>,
73
+ * }} StoredMap
74
+ *
75
+ * A page's history key, imported -- null when it could not be: no
76
+ * `crypto.subtle` (an insecure context), or a key that is not one.
77
+ * @typedef {Promise<CryptoKey | null>} HistoryKey
78
+ *
79
+ * @typedef {{
80
+ * read(entry: string, binding: string | null): Record<string, unknown>,
81
+ * isSealed(entry: string, binding: string | null): boolean,
82
+ * open(entry: string, binding: string | null, key: HistoryKey | null): Promise<Record<string, unknown>>,
83
+ * write(entry: string, binding: string | null, values: Record<string, unknown>, key?: HistoryKey | null): Promise<void>,
84
+ * wipe(): void,
85
+ * }} RememberStore
86
+ */
87
+
88
+ /**
89
+ * The tab's sessionStorage, or null where it cannot be used -- a sandboxed
90
+ * frame, storage switched off -- which throws on mere access.
91
+ *
92
+ * @param {Window | undefined} win
93
+ * @returns {Storage | null}
94
+ */
95
+ export function tabStorage(win) {
96
+ try {
97
+ const storage = win?.sessionStorage ?? null;
98
+ if (storage === null) return null;
99
+ // Some browsers hand out a storage that throws on the first write.
100
+ storage.setItem(`${REMEMBER_STORAGE_KEY}:probe`, '1');
101
+ storage.removeItem(`${REMEMBER_STORAGE_KEY}:probe`);
102
+ return storage;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Imports a history key as the server sends it -- 32 bytes, base64url -- for
110
+ * AES-GCM, as a key script cannot export: the raw bytes are not kept by the
111
+ * router once imported, and are never written anywhere.
112
+ *
113
+ * @param {string} encoded
114
+ * @param {Crypto | undefined} [crypto]
115
+ * @returns {HistoryKey} null without `crypto.subtle`, or for a key that is not one
116
+ */
117
+ export async function importHistoryKey(encoded, crypto = globalThis.crypto) {
118
+ const subtle = crypto?.subtle;
119
+ const bytes = fromBase64Url(encoded);
120
+ if (!subtle || bytes === null || bytes.length !== KEY_BYTES) return null;
121
+ try {
122
+ return await subtle.importKey('raw', bytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * A store over a Storage, or over memory alone when there is none -- kept
130
+ * only for this document then, which is still bound and still wiped.
131
+ *
132
+ * @param {Storage | null} storage
133
+ * @param {Crypto | undefined} [crypto] what seals and opens entries
134
+ * @returns {RememberStore}
135
+ */
136
+ export function createRememberStore(storage, crypto = globalThis.crypto) {
137
+ /** @type {StoredMap | null} */
138
+ let memory = null;
139
+
140
+ /**
141
+ * Bumped by every wipe. A seal still under way when the map is wiped
142
+ * belongs to what was wiped, and is never written after it.
143
+ */
144
+ let generation = 0;
145
+
146
+ /**
147
+ * The newest write to each entry. Sealing takes time, and a later write
148
+ * -- sealed or not -- must not be overwritten by an earlier one that
149
+ * finished after it.
150
+ *
151
+ * @type {Map<string, number>}
152
+ */
153
+ const newest = new Map();
154
+ let writes = 0;
155
+
156
+ /**
157
+ * Sealed writes, by entry: one is sealed at a time, and the newest asked
158
+ * for meanwhile waits as `pending`, replacing any that waited before it.
159
+ * A sealed write goes out at once rather than on the debounce, so each
160
+ * keystroke in a remembered field asks for one; sealed and stored each
161
+ * time, the map was encrypted and rewritten once a keystroke.
162
+ *
163
+ * @type {Map<string, { pending: { job: () => Promise<void>, resolve: () => void } | null }>}
164
+ */
165
+ const lanes = new Map();
166
+
167
+ /**
168
+ * Runs a sealed write for an entry now, or once the one being sealed for
169
+ * it settles. A write replaced while it waits settles with the one that
170
+ * replaced it.
171
+ *
172
+ * @param {string} entry
173
+ * @param {() => Promise<void>} job
174
+ * @returns {Promise<void>}
175
+ */
176
+ function inTurn(entry, job) {
177
+ const lane = lanes.get(entry);
178
+ if (lane !== undefined) {
179
+ const replaced = lane.pending;
180
+ const settled = new Promise((resolve) => {
181
+ lane.pending = { job, resolve: () => resolve(undefined) };
182
+ });
183
+ if (replaced !== null) void settled.then(replaced.resolve);
184
+ return /** @type {Promise<void>} */ (settled);
185
+ }
186
+ const created = { pending: null };
187
+ lanes.set(entry, created);
188
+ return drain(entry, created, job);
189
+ }
190
+
191
+ /**
192
+ * @param {string} entry
193
+ * @param {{ pending: { job: () => Promise<void>, resolve: () => void } | null }} lane
194
+ * @param {() => Promise<void>} job
195
+ * @returns {Promise<void>}
196
+ */
197
+ async function drain(entry, lane, job) {
198
+ try {
199
+ await job();
200
+ } catch {
201
+ // Nothing more can be done about a write that failed.
202
+ }
203
+ const next = lane.pending;
204
+ lane.pending = null;
205
+ if (next === null) {
206
+ lanes.delete(entry);
207
+ return;
208
+ }
209
+ void drain(entry, lane, next.job).then(next.resolve);
210
+ }
211
+
212
+ /** @returns {StoredMap | null} */
213
+ function load() {
214
+ if (storage === null) return memory;
215
+ let raw;
216
+ try {
217
+ raw = storage.getItem(REMEMBER_STORAGE_KEY);
218
+ } catch {
219
+ return null;
220
+ }
221
+ if (raw === null) return null;
222
+ try {
223
+ const data = JSON.parse(raw);
224
+ if (
225
+ typeof data?.binding === 'string' &&
226
+ Array.isArray(data.order) &&
227
+ isRecord(data.entries) &&
228
+ (data.sealed === undefined || isRecord(data.sealed))
229
+ ) {
230
+ return data;
231
+ }
232
+ } catch {
233
+ // Not ours, or damaged: dropped below.
234
+ }
235
+ wipe();
236
+ return null;
237
+ }
238
+
239
+ /** @param {StoredMap} data */
240
+ function save(data) {
241
+ // Absent when empty: a tab with nothing sealed keeps the map it always kept.
242
+ if (data.sealed !== undefined && Object.keys(data.sealed).length === 0) delete data.sealed;
243
+ if (storage === null) {
244
+ memory = data;
245
+ return;
246
+ }
247
+ try {
248
+ storage.setItem(REMEMBER_STORAGE_KEY, JSON.stringify(data));
249
+ } catch {
250
+ // Full, or a value JSON cannot hold: what was there stays, as of the
251
+ // last write that worked.
252
+ }
253
+ }
254
+
255
+ function wipe() {
256
+ generation++;
257
+ memory = null;
258
+ try {
259
+ storage?.removeItem(REMEMBER_STORAGE_KEY);
260
+ } catch {
261
+ // Nothing more can be done about storage that refuses.
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Puts an entry's values in the map -- in the clear, or sealed, or not at
267
+ * all when both are null -- as its newest, and drops the oldest past the
268
+ * limit.
269
+ *
270
+ * @param {StoredMap} data
271
+ * @param {string} entry
272
+ * @param {Record<string, unknown> | null} values
273
+ * @param {string | null} sealed
274
+ */
275
+ function place(data, entry, values, sealed) {
276
+ data.order = data.order.filter((key) => key !== entry);
277
+ delete data.entries[entry];
278
+ if (data.sealed !== undefined) delete data.sealed[entry];
279
+
280
+ if (sealed !== null) {
281
+ data.sealed = { ...(data.sealed ?? {}), [entry]: sealed };
282
+ data.order.push(entry);
283
+ } else if (values !== null && Object.keys(values).length > 0) {
284
+ data.entries[entry] = values;
285
+ data.order.push(entry);
286
+ }
287
+
288
+ while (data.order.length > MAX_REMEMBERED_ENTRIES) {
289
+ const oldest = data.order.shift();
290
+ if (oldest === undefined) continue;
291
+ delete data.entries[oldest];
292
+ if (data.sealed !== undefined) delete data.sealed[oldest];
293
+ }
294
+ save(data);
295
+ }
296
+
297
+ return {
298
+ read(entry, binding) {
299
+ if (binding === null) return {};
300
+ const data = load();
301
+ if (data === null) return {};
302
+ // Kept under another session: it is purged, never read.
303
+ if (data.binding !== binding) {
304
+ wipe();
305
+ return {};
306
+ }
307
+ return valuesIn(data.entries[entry]);
308
+ },
309
+
310
+ isSealed(entry, binding) {
311
+ if (binding === null) return false;
312
+ const data = load();
313
+ return data !== null && data.binding === binding && typeof data.sealed?.[entry] === 'string';
314
+ },
315
+
316
+ async open(entry, binding, key) {
317
+ if (binding === null) return {};
318
+ const data = load();
319
+ if (data === null) return {};
320
+ if (data.binding !== binding) {
321
+ wipe();
322
+ return {};
323
+ }
324
+
325
+ const sealed = data.sealed?.[entry];
326
+ if (typeof sealed !== 'string') return valuesIn(data.entries[entry]);
327
+
328
+ // A page with no key -- since opted out, say -- cannot open what an
329
+ // encrypted one sealed, and leaves it for that page's next write.
330
+ const at = generation;
331
+ const imported = key === null ? null : await key;
332
+ if (imported === null) return {};
333
+
334
+ const values = await unseal(crypto, imported, binding, entry, sealed);
335
+ // Wiped while it was being opened: the session it was kept for ended.
336
+ if (at !== generation) return {};
337
+ if (values !== null) return values;
338
+
339
+ // Sealed under another key -- another session's, or moved here from
340
+ // another entry -- so it never opens under this one: dropped, not kept
341
+ // to be tried again. Unless it has been replaced since.
342
+ const now = load();
343
+ if (now !== null && now.sealed?.[entry] === sealed) place(now, entry, null, null);
344
+ return {};
345
+ },
346
+
347
+ async write(entry, binding, values, key = null) {
348
+ // No session to bind it to: nothing is kept.
349
+ if (binding === null) return;
350
+
351
+ const mine = ++writes;
352
+ newest.set(entry, mine);
353
+
354
+ if (key === null || Object.keys(values).length === 0) {
355
+ let data = load();
356
+ if (data === null || data.binding !== binding) data = { binding, order: [], entries: {} };
357
+ place(data, entry, values, null);
358
+ return;
359
+ }
360
+
361
+ const at = generation;
362
+ // Wiped meanwhile -- the session ended, or changed -- or written again
363
+ // since: this seal is of something no longer wanted.
364
+ const unwanted = () => at !== generation || newest.get(entry) !== mine;
365
+
366
+ return inTurn(entry, async () => {
367
+ if (unwanted()) return;
368
+ let sealed = null;
369
+ try {
370
+ const imported = await key;
371
+ if (imported !== null) sealed = await seal(crypto, imported, binding, entry, values);
372
+ } catch {
373
+ // A value JSON cannot hold, or a key the browser would not use:
374
+ // what is in the map for the entry is decided below.
375
+ }
376
+ if (unwanted()) return;
377
+
378
+ let data = load();
379
+ if (data !== null && data.binding !== binding) return;
380
+ data ??= { binding, order: [], entries: {} };
381
+
382
+ // A page that is to be sealed and cannot be keeps nothing: never its
383
+ // values in the clear, nor what an earlier write left for the entry.
384
+ place(data, entry, null, sealed);
385
+ });
386
+ },
387
+
388
+ wipe,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * @param {unknown} values
394
+ * @returns {Record<string, unknown>}
395
+ */
396
+ function valuesIn(values) {
397
+ return isRecord(values) ? /** @type {Record<string, unknown>} */ (values) : {};
398
+ }
399
+
400
+ /** @param {unknown} value */
401
+ function isRecord(value) {
402
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
403
+ }
404
+
405
+ /**
406
+ * What a sealed entry is bound to besides its key: the session and the
407
+ * entry it was sealed for. Ciphertext copied into another entry, or into a
408
+ * map under another session, does not open.
409
+ *
410
+ * @param {string} binding
411
+ * @param {string} entry
412
+ */
413
+ function boundTo(binding, entry) {
414
+ return new TextEncoder().encode(JSON.stringify(['tinvar:remember', binding, entry]));
415
+ }
416
+
417
+ /**
418
+ * @param {Crypto | undefined} crypto
419
+ * @param {CryptoKey} key
420
+ * @param {string} binding
421
+ * @param {string} entry
422
+ * @param {Record<string, unknown>} values
423
+ * @returns {Promise<string>} the nonce and the ciphertext, base64url
424
+ */
425
+ async function seal(crypto, key, binding, entry, values) {
426
+ if (!crypto?.subtle) throw new Error('No crypto.subtle to seal with.');
427
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
428
+ const cipher = await crypto.subtle.encrypt(
429
+ { name: 'AES-GCM', iv, additionalData: boundTo(binding, entry) },
430
+ key,
431
+ new TextEncoder().encode(JSON.stringify(values)),
432
+ );
433
+ const out = new Uint8Array(IV_BYTES + cipher.byteLength);
434
+ out.set(iv);
435
+ out.set(new Uint8Array(cipher), IV_BYTES);
436
+ return toBase64Url(out);
437
+ }
438
+
439
+ /**
440
+ * @param {Crypto | undefined} crypto
441
+ * @param {CryptoKey} key
442
+ * @param {string} binding
443
+ * @param {string} entry
444
+ * @param {string} sealed
445
+ * @returns {Promise<Record<string, unknown> | null>} null when it does not open
446
+ */
447
+ async function unseal(crypto, key, binding, entry, sealed) {
448
+ const bytes = fromBase64Url(sealed);
449
+ if (!crypto?.subtle || bytes === null || bytes.length <= IV_BYTES) return null;
450
+ try {
451
+ const plain = await crypto.subtle.decrypt(
452
+ { name: 'AES-GCM', iv: bytes.slice(0, IV_BYTES), additionalData: boundTo(binding, entry) },
453
+ key,
454
+ bytes.slice(IV_BYTES),
455
+ );
456
+ const values = JSON.parse(new TextDecoder().decode(plain));
457
+ return isRecord(values) ? values : null;
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ /** @param {Uint8Array} bytes */
464
+ function toBase64Url(bytes) {
465
+ // A character at a time: spreading a large array into fromCharCode
466
+ // overflows the stack.
467
+ let binary = '';
468
+ for (const byte of bytes) binary += String.fromCharCode(byte);
469
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
470
+ }
471
+
472
+ /**
473
+ * @param {unknown} encoded
474
+ * @returns {Uint8Array | null}
475
+ */
476
+ function fromBase64Url(encoded) {
477
+ if (typeof encoded !== 'string' || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
478
+ try {
479
+ const binary = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'));
480
+ const bytes = new Uint8Array(binary.length);
481
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
482
+ return bytes;
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
@@ -0,0 +1,10 @@
1
+ /** What a #[Rescue] field that threw is sent as. See ADR 0017. */
2
+ export interface Rescued {
3
+ /** The digest the failure was logged under, to quote to support. */
4
+ readonly $rescued: string;
5
+ }
6
+
7
+ export declare const RESCUED_KEY: '$rescued';
8
+
9
+ /** Whether a field was rescued rather than resolved; narrows `T | Rescued` to either side. */
10
+ export declare function isRescued(value: unknown): value is Rescued;
package/src/rescue.js ADDED
@@ -0,0 +1,30 @@
1
+ // What a rescued field arrives as.
2
+ //
3
+ // tinvar-spec.md, "Rescue and reloading": "A deferred or lazy field marked
4
+ // `rescue` that throws is reported to the PHP exception handler and
5
+ // omitted." A Lazy field marked #[Rescue] whose resolver threw is sent as
6
+ // `{"$rescued": "<digest>"}`, and codegen types it `T | Rescued` (ADR 0017),
7
+ // so a component has to say what it shows instead before it can read the
8
+ // value.
9
+ //
10
+ // No React and no DOM: a page's server component reads its own data, so
11
+ // this has to import cleanly in either environment.
12
+
13
+ /** Mirrors Encoder::RESCUED_KEY. */
14
+ export const RESCUED_KEY = '$rescued';
15
+
16
+ /**
17
+ * Whether a field was rescued rather than resolved.
18
+ *
19
+ * Exactly one key: a Data object never encodes to a map with a `$` key -- no
20
+ * PHP property can be named that way -- but a rescued map field's own value
21
+ * could, by chance, carry one among others.
22
+ *
23
+ * @param {unknown} value
24
+ * @returns {boolean}
25
+ */
26
+ export function isRescued(value) {
27
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
28
+ const keys = Object.keys(value);
29
+ return keys.length === 1 && keys[0] === RESCUED_KEY && typeof value[RESCUED_KEY] === 'string';
30
+ }