@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,822 @@
1
+ // A resumable upload (ADR 0143): a file sent in pieces -- chunks through PHP,
2
+ // or parts straight to the object store -- each one only from where the
3
+ // server says it holds, so a refresh, a closed tab, a crash or a dropped
4
+ // network costs at most the piece that was in flight.
5
+ //
6
+ // The server is the only one who knows how much is in. After every failure
7
+ // the browser asks it (/_upload/session/offset, or the parts the store
8
+ // holds) before sending another byte; nothing is ever sent again from zero
9
+ // while the server holds some of it. A network that drops, or a server that
10
+ // answers 5xx, is tried again on its own, backing off, and at once when the
11
+ // browser says it is online again.
12
+ //
13
+ // What lets the upload go on after the page is gone is a record in
14
+ // IndexedDB (resume-store.js): the session the server signed, and the
15
+ // file's name, size and fingerprint -- never its bytes. Choosing the same
16
+ // file again finds it, by what the upload is for and the fingerprint, so a
17
+ // different file is never appended to an old session.
18
+
19
+ import { send, watchStall } from './transport.js';
20
+ import { defaultResumeStore } from './resume-store.js';
21
+
22
+ /** Where a resumable upload begins, under the ticket. */
23
+ export const UPLOAD_SESSION_PATH = '/_upload/session';
24
+ /** Where it is asked how much is in. */
25
+ export const UPLOAD_OFFSET_PATH = '/_upload/session/offset';
26
+ /** Where a chunk is appended, through PHP. */
27
+ export const UPLOAD_CHUNK_PATH = '/_upload/session/chunk';
28
+ /** Where the URLs for the next parts are asked for, for the store. */
29
+ export const UPLOAD_PARTS_PATH = '/_upload/session/parts';
30
+ /** Where an upload sent in parts is completed. */
31
+ export const UPLOAD_SESSION_COMPLETE_PATH = '/_upload/session/complete';
32
+ /** Where an upload is given up. */
33
+ export const UPLOAD_ABORT_PATH = '/_upload/session/abort';
34
+
35
+ /** Seconds between tries, after a network drop or a 5xx: about two minutes in all, then it is the person's to retry. */
36
+ export const BACKOFF = [1, 2, 4, 8, 15, 30, 30, 30];
37
+
38
+ /** Milliseconds a piece may send nothing before it is given up as a dropped network, and the server asked where it is. */
39
+ export const STALL_TIMEOUT = 30_000;
40
+
41
+ /** How much of each end of a file its fingerprint reads. */
42
+ const SAMPLE_BYTES = 65_536;
43
+
44
+ /**
45
+ * @typedef {import('./transport.js').Outcome} Outcome
46
+ * @typedef {import('./transport.js').Progress} Progress
47
+ * @typedef {import('./resume-store.js').ResumeStore} ResumeStore
48
+ * @typedef {import('./resume-store.js').ResumeRecord} ResumeRecord
49
+ * @typedef {'uploading' | 'paused' | 'resuming'} ResumableState
50
+ *
51
+ * The session an upload goes on in, and the id of the record that
52
+ * remembers it (null where nothing is remembered): what giving it up
53
+ * (abortUpload(), and the record deleted) needs.
54
+ * @typedef {{ session: string, id: string | null }} ResumableSession
55
+ *
56
+ * @typedef {{
57
+ * ticket: string,
58
+ * file: Blob,
59
+ * direct?: boolean,
60
+ * signal?: AbortSignal,
61
+ * onProgress?: (progress: Progress) => void,
62
+ * onState?: (state: ResumableState) => void,
63
+ * onSession?: (session: ResumableSession) => void,
64
+ * store?: ResumeStore,
65
+ * handle?: unknown,
66
+ * backoff?: number[],
67
+ * stallTimeout?: number,
68
+ * }} ResumableOptions
69
+ */
70
+
71
+ /**
72
+ * Sends a file as a resumable upload: going on with the session a record
73
+ * remembers for this very file, where one is and the server still holds it,
74
+ * or beginning one. Answers as send() does -- the last answer, the
75
+ * upload's on success -- or `unsupported` where the server takes no
76
+ * resumable upload of this kind, for the caller to send the file whole.
77
+ *
78
+ * `onSession` is told the session once it is known, and remembered, so a
79
+ * caller that cancels the upload for good can give it up. A cancel alone
80
+ * keeps it on the server, for a later page to go on with.
81
+ *
82
+ * Never throws for an outcome.
83
+ *
84
+ * @param {ResumableOptions} options
85
+ * @returns {Promise<Outcome | { type: 'unsupported' }>}
86
+ */
87
+ export async function uploadResumable({
88
+ ticket,
89
+ file,
90
+ direct = false,
91
+ signal,
92
+ onProgress,
93
+ onState,
94
+ onSession,
95
+ store = defaultResumeStore(),
96
+ handle,
97
+ backoff = BACKOFF,
98
+ stallTimeout = STALL_TIMEOUT,
99
+ }) {
100
+ const scope = uploadScope(ticket);
101
+ const fingerprint = scope === null ? null : await fingerprintFile(file);
102
+ const id = scope === null || fingerprint === null ? null : `${scope}#${fingerprint}`;
103
+ const report = (/** @type {number} */ loaded) =>
104
+ onProgress?.({
105
+ loaded,
106
+ total: file.size,
107
+ percentage: file.size > 0 ? Math.min(100, Math.floor((loaded / file.size) * 100)) : undefined,
108
+ });
109
+ const forget = async () => {
110
+ if (id !== null) await quietly(() => store.delete(id));
111
+ };
112
+
113
+ /** @type {{ session: string, mode: 'chunks' | 'parts', offset: number, unit: number } | null} */
114
+ let current = null;
115
+ const remembered = id === null ? null : await quietly(() => store.get(id));
116
+ if (remembered !== null && remembered !== undefined && remembered.size === file.size) {
117
+ onState?.('resuming');
118
+ const asked = await retrying(() => askOffset(remembered.session, signal), { signal, onState, backoff });
119
+ if (asked.type === 'completed') {
120
+ await forget();
121
+ report(file.size);
122
+ return asked.outcome;
123
+ }
124
+ if (asked.type === 'open') {
125
+ current = { session: remembered.session, mode: asked.mode, offset: asked.offset, unit: asked.unit };
126
+ onSession?.({ session: remembered.session, id });
127
+ } else if (asked.type === 'gone') {
128
+ // Run out, abandoned, or another session's or user's: forgotten
129
+ // without a word, and the file sent as a new upload.
130
+ await forget();
131
+ } else {
132
+ return asked;
133
+ }
134
+ }
135
+
136
+ if (current === null) {
137
+ const name = fileName(file);
138
+ const begun = await send({
139
+ url: UPLOAD_SESSION_PATH,
140
+ data: { ticket, size: file.size, name, type: typeof file.type === 'string' ? file.type : '', direct },
141
+ signal,
142
+ });
143
+ if (begun.type === 'http' && begun.status === 404) return { type: 'unsupported' };
144
+ if (begun.type !== 'success') return begun;
145
+ const body = begun.body;
146
+ const unit = body?.mode === 'parts' ? body?.partBytes : body?.chunkBytes;
147
+ if (
148
+ typeof body?.session !== 'string' ||
149
+ (body.mode !== 'chunks' && body.mode !== 'parts') ||
150
+ typeof unit !== 'number' ||
151
+ unit < 1 ||
152
+ typeof body.expiresAt !== 'number'
153
+ ) {
154
+ return malformed();
155
+ }
156
+ current = { session: body.session, mode: body.mode, offset: 0, unit };
157
+ if (id !== null && scope !== null && fingerprint !== null) {
158
+ /** @type {ResumeRecord} */
159
+ const record = {
160
+ id,
161
+ scope,
162
+ fingerprint,
163
+ session: body.session,
164
+ mode: body.mode,
165
+ name,
166
+ size: file.size,
167
+ type: typeof file.type === 'string' ? file.type : '',
168
+ lastModified: lastModifiedOf(file),
169
+ offset: 0,
170
+ createdAt: Date.now(),
171
+ expiresAt: body.expiresAt,
172
+ ...(handle === undefined ? {} : { handle }),
173
+ };
174
+ await quietly(() => store.put(record));
175
+ }
176
+ onSession?.({ session: body.session, id });
177
+ }
178
+
179
+ report(current.offset);
180
+ const remember = async (/** @type {number} */ offset) => {
181
+ if (id === null) return;
182
+ const record = await quietly(() => store.get(id));
183
+ if (record) await quietly(() => store.put({ ...record, offset }));
184
+ };
185
+ const context = { file, signal, onState, backoff, stallTimeout, report, remember, forget };
186
+
187
+ return current.mode === 'chunks' ? sendChunks(current, context) : sendParts(current, context);
188
+ }
189
+
190
+ /**
191
+ * @typedef {{
192
+ * file: Blob,
193
+ * signal: AbortSignal | undefined,
194
+ * onState: ((state: ResumableState) => void) | undefined,
195
+ * backoff: number[],
196
+ * stallTimeout: number,
197
+ * report: (loaded: number) => void,
198
+ * remember: (offset: number) => Promise<void>,
199
+ * forget: () => Promise<void>,
200
+ * }} SendContext
201
+ */
202
+
203
+ /**
204
+ * Chunk after chunk, each at the offset the server holds: a chunk refused
205
+ * as being for another offset goes on from the one the refusal names; one
206
+ * that did not arrive is tried again once the server has said how much it
207
+ * holds. The last chunk's answer is the upload's.
208
+ *
209
+ * @param {{ session: string, offset: number, unit: number }} current
210
+ * @param {SendContext} context
211
+ * @returns {Promise<Outcome>}
212
+ */
213
+ async function sendChunks(
214
+ { session, offset: from, unit },
215
+ { file, signal, onState, backoff, stallTimeout, report, remember, forget },
216
+ ) {
217
+ let offset = from;
218
+ let failures = 0;
219
+ let mismatches = 0;
220
+
221
+ for (;;) {
222
+ if (offset >= file.size && file.size > 0) {
223
+ // Every byte held, nothing kept: a keep that failed for a reason that
224
+ // passes gave the session back, and it is completed from what is held.
225
+ const done = await retrying(
226
+ () => send({ url: UPLOAD_SESSION_COMPLETE_PATH, data: { session }, signal }),
227
+ {
228
+ signal,
229
+ onState,
230
+ backoff,
231
+ },
232
+ );
233
+ if (done.type === 'http' && done.status === 409 && ++mismatches <= 3) {
234
+ const held = Number(done.headers.get('upload-offset'));
235
+ if (Number.isSafeInteger(held) && held >= 0 && held < file.size) {
236
+ offset = held;
237
+ report(offset);
238
+ continue;
239
+ }
240
+ }
241
+ // As for parts: forgotten once done, or refused for good; a server
242
+ // that failed for a while keeps the bytes, and the record with them.
243
+ if (done.type === 'success' || refusedForGood(done)) {
244
+ await forget();
245
+ }
246
+ if (done.type === 'success') report(file.size);
247
+ return done;
248
+ }
249
+ const chunk = file.slice(offset, Math.min(offset + unit, file.size));
250
+ /** @type {Record<string, string>} */
251
+ const headers = {
252
+ 'content-type': 'application/offset+octet-stream',
253
+ 'upload-session': session,
254
+ 'upload-offset': String(offset),
255
+ };
256
+ const sum = await checksum(chunk);
257
+ if (sum !== null) headers['upload-checksum'] = sum;
258
+
259
+ onState?.('uploading');
260
+ const at = offset;
261
+ const sent = await send({
262
+ url: UPLOAD_CHUNK_PATH,
263
+ body: chunk,
264
+ headers,
265
+ signal,
266
+ stallTimeout,
267
+ onUploadProgress: (p) => report(at + p.loaded),
268
+ });
269
+
270
+ if (sent.type === 'success') {
271
+ if (sent.status === 201) {
272
+ await forget();
273
+ report(file.size);
274
+ return sent;
275
+ }
276
+ const held = Number(sent.headers.get('upload-offset') ?? sent.body?.offset);
277
+ if (!Number.isSafeInteger(held) || held <= offset || held > file.size) return malformed();
278
+ offset = held;
279
+ failures = 0;
280
+ mismatches = 0;
281
+ report(offset);
282
+ await remember(offset);
283
+ continue;
284
+ }
285
+
286
+ if (sent.type === 'http' && sent.status === 409) {
287
+ // Not where the server is: on from where it says it is.
288
+ const held = Number(sent.headers.get('upload-offset'));
289
+ if (!Number.isSafeInteger(held) || held < 0 || held > file.size || ++mismatches > 3) return sent;
290
+ offset = held;
291
+ report(offset);
292
+ continue;
293
+ }
294
+
295
+ if (sent.type === 'http' && sent.status === 460) {
296
+ // Changed on its way: sent again, as it is.
297
+ if (++failures > backoff.length) return sent;
298
+ continue;
299
+ }
300
+
301
+ if (transient(sent)) {
302
+ if (++failures > backoff.length) return sent;
303
+ onState?.('paused');
304
+ if (!(await pause(delayFor(sent, backoff, failures), signal))) return { type: 'cancelled' };
305
+ onState?.('resuming');
306
+ const asked = await retrying(() => askOffset(session, signal), { signal, onState, backoff });
307
+ if (asked.type === 'completed') {
308
+ await forget();
309
+ report(file.size);
310
+ return asked.outcome;
311
+ }
312
+ if (asked.type === 'gone') {
313
+ await forget();
314
+ return asked.outcome;
315
+ }
316
+ if (asked.type !== 'open') return asked;
317
+ offset = asked.offset;
318
+ report(offset);
319
+ continue;
320
+ }
321
+
322
+ if (
323
+ sent.type === 'cancelled' ||
324
+ sent.type === 'expired' ||
325
+ (sent.type === 'http' && sent.status === 401)
326
+ ) {
327
+ // Still the server's to go on with, after a refresh or a sign-in.
328
+ return sent;
329
+ }
330
+
331
+ // Refused for good -- too large, not a type the field takes, or a
332
+ // session that is gone -- and over on the server too. Anything else (a
333
+ // keep refused while too many uploads wait, 429 upload_pending) leaves
334
+ // the bytes held, for the session to go on with later.
335
+ if (refusedForGood(sent)) await forget();
336
+ return sent;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Part after part to the store, from the first the store does not hold as
342
+ * granted: the server asks the store what it holds each time, and hands out
343
+ * URLs for the next few. Once it holds them all, the server completes it.
344
+ *
345
+ * @param {{ session: string, unit: number }} current
346
+ * @param {SendContext} context
347
+ * @returns {Promise<Outcome>}
348
+ */
349
+ async function sendParts(
350
+ { session, unit },
351
+ { file, signal, onState, backoff, stallTimeout, report, remember, forget },
352
+ ) {
353
+ let failures = 0;
354
+ // What the store held when last asked, and whether every part granted
355
+ // then was taken: asked again after that, it must hold more.
356
+ let held = -1;
357
+ let sentWhole = false;
358
+
359
+ for (;;) {
360
+ const granted = await retrying(() => send({ url: UPLOAD_PARTS_PATH, data: { session }, signal }), {
361
+ signal,
362
+ onState,
363
+ backoff,
364
+ });
365
+ if (granted.type !== 'success') {
366
+ if (
367
+ granted.type === 'http' &&
368
+ granted.status >= 400 &&
369
+ granted.status < 500 &&
370
+ ![401, 429].includes(granted.status)
371
+ ) {
372
+ await forget();
373
+ }
374
+ return granted;
375
+ }
376
+ const body = granted.body;
377
+ if (typeof body?.offset !== 'number' || !Array.isArray(body?.parts)) return malformed();
378
+ report(body.offset);
379
+ await remember(body.offset);
380
+
381
+ if (body.offset > held) {
382
+ failures = 0;
383
+ } else if (sentWhole && body.parts.length > 0) {
384
+ // A store that said it took the parts and does not hold them -- slow
385
+ // to list them, or losing them -- is waited for, and then given up
386
+ // on, rather than sent them again without end.
387
+ if (++failures > backoff.length) return malformed();
388
+ onState?.('paused');
389
+ if (!(await pause(delayFor(granted, backoff, failures), signal))) return { type: 'cancelled' };
390
+ onState?.('resuming');
391
+ }
392
+ held = body.offset;
393
+ sentWhole = false;
394
+
395
+ if (body.parts.length === 0) {
396
+ const done = await retrying(
397
+ () => send({ url: UPLOAD_SESSION_COMPLETE_PATH, data: { session }, signal }),
398
+ {
399
+ signal,
400
+ onState,
401
+ backoff,
402
+ },
403
+ );
404
+ if (done.type === 'http' && done.status === 409 && ++failures <= backoff.length) {
405
+ // A part the store does not hold after all: asked for again, after a
406
+ // pause, so a store slow to list what it holds is not asked at once.
407
+ onState?.('paused');
408
+ if (!(await pause(delayFor(done, backoff, failures), signal))) return { type: 'cancelled' };
409
+ onState?.('resuming');
410
+ continue;
411
+ }
412
+ // Forgotten once done, or refused for good; a server that failed for
413
+ // a while, a throttle or a sign-in leaves the parts to complete later.
414
+ if (done.type === 'success' || refusedForGood(done)) {
415
+ await forget();
416
+ }
417
+ if (done.type === 'success') report(file.size);
418
+ return done;
419
+ }
420
+
421
+ for (const part of body.parts) {
422
+ const start = (part.number - 1) * unit;
423
+ const blob = file.slice(start, start + part.size);
424
+ if (typeof part.url !== 'string' || blob.size !== part.size) return malformed();
425
+ onState?.('uploading');
426
+ const put = await putToStore(
427
+ part.url,
428
+ part.headers ?? {},
429
+ blob,
430
+ signal,
431
+ (p) => report(start + p.loaded),
432
+ stallTimeout,
433
+ );
434
+ if (put.type === 'sent') {
435
+ sentWhole = part === body.parts.at(-1);
436
+ continue;
437
+ }
438
+ if (put.type === 'cancelled') return put;
439
+ // A URL that ran out is asked for again; a store that did not answer
440
+ // is waited for. Either way the store is asked what it holds first.
441
+ if (++failures > backoff.length) return put;
442
+ if (transient(put)) {
443
+ onState?.('paused');
444
+ if (!(await pause(delayFor(put, backoff, failures), signal))) return { type: 'cancelled' };
445
+ onState?.('resuming');
446
+ }
447
+ break;
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Asks the server how much of an upload it holds.
454
+ *
455
+ * @param {string} session
456
+ * @param {AbortSignal | undefined} signal
457
+ * @returns {Promise<
458
+ * | { type: 'open', mode: 'chunks' | 'parts', offset: number, size: number, unit: number, name: string }
459
+ * | { type: 'completed', outcome: Outcome }
460
+ * | { type: 'gone', outcome: Outcome }
461
+ * | Outcome
462
+ * >}
463
+ */
464
+ export async function askOffset(session, signal) {
465
+ const asked = await send({ url: UPLOAD_OFFSET_PATH, data: { session }, signal });
466
+ if (asked.type === 'http' && (asked.status === 403 || asked.status === 404 || asked.status === 410)) {
467
+ return { type: 'gone', outcome: asked };
468
+ }
469
+ if (asked.type !== 'success') return asked;
470
+ const body = asked.body;
471
+ if (body?.upload && typeof body.upload === 'object') {
472
+ return { type: 'completed', outcome: { ...asked, status: 201, body: body.upload } };
473
+ }
474
+ const unit = body?.mode === 'parts' ? body?.partBytes : body?.chunkBytes;
475
+ if (
476
+ (body?.mode !== 'chunks' && body?.mode !== 'parts') ||
477
+ typeof body.offset !== 'number' ||
478
+ typeof body.size !== 'number' ||
479
+ typeof unit !== 'number'
480
+ ) {
481
+ return malformed();
482
+ }
483
+ return {
484
+ type: 'open',
485
+ mode: body.mode,
486
+ offset: body.offset,
487
+ size: body.size,
488
+ unit,
489
+ name: typeof body.name === 'string' ? body.name : '',
490
+ };
491
+ }
492
+
493
+ /** How many times an abort is sent while the server says a completion is running. */
494
+ const ABORT_TRIES = 8;
495
+
496
+ /**
497
+ * Gives an upload up on the server -- its bytes removed, its room given
498
+ * back -- whatever the server answers: a session it no longer holds is
499
+ * given up already. A 425 says another request is completing the session
500
+ * and the file may yet be kept, so the abort is sent again after the wait
501
+ * the server names, a few times; given up then, a file that was kept waits
502
+ * unclaimed and is pruned. The record that remembers it is not touched
503
+ * here; the caller deletes it from its resume store.
504
+ *
505
+ * @param {string} session
506
+ */
507
+ export async function abortUpload(session) {
508
+ for (let attempt = 1; ; attempt++) {
509
+ const outcome = await send({ url: UPLOAD_ABORT_PATH, data: { session } });
510
+ if (outcome.type !== 'http' || outcome.status !== 425 || attempt >= ABORT_TRIES) return;
511
+ if (!(await pause(delayFor(outcome, [2], attempt), undefined))) return;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * What a ticket is for -- its action and parameter, its record, tenant and
517
+ * panel, and the session and user it was issued to, as the keyed hashes it
518
+ * carries -- read from it for a key, and trusted for nothing else: the
519
+ * server holds every use to them. Null for a ticket this cannot read, and
520
+ * nothing is remembered then.
521
+ *
522
+ * Bound to the session and the user so a resume is offered only to whoever
523
+ * began it: another account signed in on this browser is never shown the
524
+ * name of the file someone else was sending.
525
+ *
526
+ * @param {string} ticket
527
+ * @returns {string | null}
528
+ */
529
+ export function uploadScope(ticket) {
530
+ try {
531
+ const [version, payload] = String(ticket).split('.');
532
+ if (version !== 'v1' || !payload) return null;
533
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
534
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
535
+ const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
536
+ const scope = ['a', 'f', 'm', 'k', 'y', 'n', 's', 'u'].map((key) => claims?.[key]);
537
+ return scope.every((part) => typeof part === 'string') ? JSON.stringify(scope) : null;
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * A file's fingerprint: its name, size and last change, and a digest of its
545
+ * first and last 64 KiB -- so a different file of the same name and size is
546
+ * never taken for it. Null where the file cannot be read.
547
+ *
548
+ * @param {Blob} file
549
+ * @returns {Promise<string | null>}
550
+ */
551
+ export async function fingerprintFile(file) {
552
+ try {
553
+ const head = await digest(file.slice(0, SAMPLE_BYTES));
554
+ const tail = await digest(file.slice(Math.max(0, file.size - SAMPLE_BYTES)));
555
+ if (head === null || tail === null) return null;
556
+ return `${fileName(file)}:${file.size}:${lastModifiedOf(file)}:${head}:${tail}`;
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * PUTs a blob to a URL the server presigned, with the headers it was signed
564
+ * with, reporting progress. Not through send(): the store is another
565
+ * origin, and nothing of this one's -- its cookies, its CSRF header -- goes
566
+ * there. Given up as a network failure once it has sent nothing for
567
+ * $stallTimeout milliseconds, where that is given.
568
+ *
569
+ * @param {string} url
570
+ * @param {Record<string, string>} headers
571
+ * @param {Blob} blob
572
+ * @param {AbortSignal | undefined} signal
573
+ * @param {((progress: Progress) => void) | undefined} onProgress
574
+ * @param {number} [stallTimeout]
575
+ * @returns {Promise<{ type: 'sent' } | Outcome>}
576
+ */
577
+ export function putToStore(url, headers, blob, signal, onProgress, stallTimeout) {
578
+ return new Promise((resolve) => {
579
+ if (signal?.aborted) {
580
+ resolve({ type: 'cancelled' });
581
+ return;
582
+ }
583
+ const xhr = new XMLHttpRequest();
584
+ xhr.open('PUT', url);
585
+ xhr.withCredentials = false;
586
+ for (const [name, value] of Object.entries(headers)) {
587
+ if (typeof value === 'string') xhr.setRequestHeader(name, value);
588
+ }
589
+ if (onProgress && xhr.upload) {
590
+ xhr.upload.addEventListener('progress', (event) => {
591
+ const total = event.lengthComputable ? event.total : undefined;
592
+ onProgress({
593
+ loaded: event.loaded,
594
+ total,
595
+ percentage: total ? Math.round((event.loaded / total) * 100) : undefined,
596
+ });
597
+ });
598
+ }
599
+ const stall = watchStall(xhr, stallTimeout);
600
+ const abort = () => xhr.abort();
601
+ signal?.addEventListener('abort', abort, { once: true });
602
+ xhr.addEventListener('load', () => {
603
+ stall.stop();
604
+ signal?.removeEventListener('abort', abort);
605
+ resolve(
606
+ xhr.status >= 200 && xhr.status < 300
607
+ ? { type: 'sent' }
608
+ : {
609
+ type: 'http',
610
+ status: xhr.status,
611
+ body: null,
612
+ headers: new Headers(),
613
+ code: null,
614
+ reference: null,
615
+ retryAfter: null,
616
+ },
617
+ );
618
+ });
619
+ xhr.addEventListener('error', () => {
620
+ stall.stop();
621
+ signal?.removeEventListener('abort', abort);
622
+ resolve({ type: 'network', error: new Error('The file did not reach the store.') });
623
+ });
624
+ xhr.addEventListener('abort', () => {
625
+ stall.stop();
626
+ signal?.removeEventListener('abort', abort);
627
+ resolve(
628
+ stall.stalled()
629
+ ? { type: 'network', error: new Error('The store took nothing for too long.') }
630
+ : { type: 'cancelled' },
631
+ );
632
+ });
633
+ xhr.send(blob);
634
+ });
635
+ }
636
+
637
+ /**
638
+ * Tries a step again while it fails as a network or a busy server does,
639
+ * backing off, paused between.
640
+ *
641
+ * @template {{ type: string }} T
642
+ * @param {() => Promise<T>} step
643
+ * @param {{ signal: AbortSignal | undefined, onState: ((state: ResumableState) => void) | undefined, backoff: number[] }} options
644
+ * @returns {Promise<T | { type: 'cancelled' }>}
645
+ */
646
+ async function retrying(step, { signal, onState, backoff }) {
647
+ for (let attempt = 0; ; attempt++) {
648
+ const outcome = await step();
649
+ if (!transient(/** @type {any} */ (outcome)) || attempt >= backoff.length) return outcome;
650
+ onState?.('paused');
651
+ if (!(await pause(delayFor(/** @type {any} */ (outcome), backoff, attempt + 1), signal)))
652
+ return { type: 'cancelled' };
653
+ onState?.('resuming');
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Whether a failure is one to wait out: no answer at all, a server error --
659
+ * a malware scan that could not run among them, the session's bytes kept
660
+ * for the next try -- a throttle, or a 425 while another request completes
661
+ * the session. Never a refusal.
662
+ *
663
+ * @param {Outcome | { type: 'sent' }} outcome
664
+ */
665
+ function transient(outcome) {
666
+ if (outcome.type === 'network') return true;
667
+ if (outcome.type !== 'http') return false;
668
+ if (outcome.status === 429) return outcome.code !== 'upload_pending';
669
+ if (outcome.status === 425) return true;
670
+ if (outcome.code === 'opaque_redirect' || outcome.code === 'malformed') return false;
671
+ return outcome.status >= 500 || outcome.status === 0;
672
+ }
673
+
674
+ /**
675
+ * Whether a refusal ends the upload on the server too -- a session gone, or
676
+ * one refused for its size, type or content -- so that nothing is left to
677
+ * go on with. A keep refused for the quota (507) or while too many uploads
678
+ * wait (429) gives the session back with its bytes (ADR 0143).
679
+ *
680
+ * @param {Outcome} outcome
681
+ */
682
+ function refusedForGood(outcome) {
683
+ if (outcome.type === 'validation') return true;
684
+ return outcome.type === 'http' && [403, 404, 410, 413, 415, 422].includes(outcome.status);
685
+ }
686
+
687
+ /**
688
+ * @param {Outcome | { type: 'sent' }} outcome
689
+ * @param {number[]} backoff
690
+ * @param {number} attempt from 1
691
+ */
692
+ function delayFor(outcome, backoff, attempt) {
693
+ const wanted =
694
+ outcome.type === 'http' && outcome.retryAfter !== null ? Math.min(60, outcome.retryAfter) : null;
695
+ const base = wanted ?? backoff[Math.min(attempt, backoff.length) - 1] ?? 1;
696
+ // A little jitter, so the tabs of a whole office back on line at once
697
+ // do not all come back in the same second.
698
+ return base * 1000 * (0.8 + Math.random() * 0.4);
699
+ }
700
+
701
+ /**
702
+ * Waits $ms, or until the browser says it is online again, or until the
703
+ * upload is cancelled. While the browser says it is offline, it waits for
704
+ * that alone.
705
+ *
706
+ * @param {number} ms
707
+ * @param {AbortSignal | undefined} signal
708
+ * @returns {Promise<boolean>} false when cancelled
709
+ */
710
+ function pause(ms, signal) {
711
+ return new Promise((resolve) => {
712
+ if (signal?.aborted) {
713
+ resolve(false);
714
+ return;
715
+ }
716
+ const offline = globalThis.navigator?.onLine === false;
717
+ const timer = offline ? null : setTimeout(() => done(true), ms);
718
+ const online = () => done(true);
719
+ const abort = () => done(false);
720
+ globalThis.addEventListener?.('online', online);
721
+ signal?.addEventListener('abort', abort, { once: true });
722
+ /** @param {boolean} value */
723
+ function done(value) {
724
+ if (timer !== null) clearTimeout(timer);
725
+ globalThis.removeEventListener?.('online', online);
726
+ signal?.removeEventListener('abort', abort);
727
+ resolve(value);
728
+ }
729
+ });
730
+ }
731
+
732
+ /**
733
+ * A chunk's SHA-256, as the server checks it (`sha256 <base64>`); null where
734
+ * the browser has no SubtleCrypto -- outside a secure context -- and the
735
+ * chunk goes without one.
736
+ *
737
+ * @param {Blob} chunk
738
+ * @returns {Promise<string | null>}
739
+ */
740
+ async function checksum(chunk) {
741
+ try {
742
+ const subtle = globalThis.crypto?.subtle;
743
+ if (!subtle) return null;
744
+ const hash = new Uint8Array(await subtle.digest('SHA-256', await chunk.arrayBuffer()));
745
+ let binary = '';
746
+ for (const byte of hash) binary += String.fromCharCode(byte);
747
+ return `sha256 ${btoa(binary)}`;
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * A digest of a piece of a file, as hex: SHA-256 where the browser has it,
755
+ * FNV-1a where it does not -- an identity, not a seal; the server checks
756
+ * everything that matters.
757
+ *
758
+ * @param {Blob} blob
759
+ * @returns {Promise<string | null>}
760
+ */
761
+ async function digest(blob) {
762
+ if (typeof blob.arrayBuffer !== 'function') return null;
763
+ const bytes = new Uint8Array(await blob.arrayBuffer());
764
+ const subtle = globalThis.crypto?.subtle;
765
+ if (subtle) {
766
+ try {
767
+ return [...new Uint8Array(await subtle.digest('SHA-256', bytes))]
768
+ .map((b) => b.toString(16).padStart(2, '0'))
769
+ .join('');
770
+ } catch {
771
+ // Below.
772
+ }
773
+ }
774
+ let a = 0x811c9dc5;
775
+ let b = 0x01000193;
776
+ for (const byte of bytes) {
777
+ a = Math.imul(a ^ byte, 0x01000193) >>> 0;
778
+ b = Math.imul(b ^ byte ^ (a & 0xff), 0x01000193) >>> 0;
779
+ }
780
+ return `fnv${a.toString(16).padStart(8, '0')}${b.toString(16).padStart(8, '0')}`;
781
+ }
782
+
783
+ /** @param {Blob} file */
784
+ function fileName(file) {
785
+ const name = /** @type {any} */ (file).name;
786
+ return typeof name === 'string' ? name : '';
787
+ }
788
+
789
+ /** @param {Blob} file */
790
+ function lastModifiedOf(file) {
791
+ const at = /** @type {any} */ (file).lastModified;
792
+ return typeof at === 'number' ? at : 0;
793
+ }
794
+
795
+ /**
796
+ * Runs a store call, never throwing: what it gives, or null.
797
+ *
798
+ * @template T
799
+ * @param {() => Promise<T>} call
800
+ * @returns {Promise<T | null>}
801
+ */
802
+ async function quietly(call) {
803
+ try {
804
+ const result = await call();
805
+ return result === undefined ? /** @type {any} */ (true) : result;
806
+ } catch {
807
+ return null;
808
+ }
809
+ }
810
+
811
+ /** An answer this code cannot read: a failure, never taken for success. @returns {Outcome} */
812
+ function malformed() {
813
+ return {
814
+ type: 'http',
815
+ status: 0,
816
+ body: null,
817
+ headers: new Headers(),
818
+ code: 'malformed',
819
+ reference: null,
820
+ retryAfter: null,
821
+ };
822
+ }