@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
package/src/resumable.js
ADDED
|
@@ -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
|
+
}
|