@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,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
|
+
}
|
package/src/rescue.d.ts
ADDED
|
@@ -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
|
+
}
|