@tinvar/react 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/upload.js
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Uploads: a file sent to /_upload under the ticket a page issued, and the
|
|
4
|
+
// reference that comes back put into the form, for the action to be called
|
|
5
|
+
// with.
|
|
6
|
+
//
|
|
7
|
+
// tinvar-spec.md: "Uploaded files are referenced only by a server-issued
|
|
8
|
+
// upload ticket bound to user, tenant, target model, key prefix and expiry.
|
|
9
|
+
// Storage keys are server-generated; client filenames are metadata only."
|
|
10
|
+
// So the file never goes with the form. The page's loader hands the form a
|
|
11
|
+
// ticket for one action parameter; choosing a file sends it to /_upload at
|
|
12
|
+
// once, with the ticket and the CSRF header, reporting progress; and what
|
|
13
|
+
// /_upload answers -- an opaque reference -- is what the form posts under
|
|
14
|
+
// the parameter's name. The action is held to everything the ticket bound
|
|
15
|
+
// the upload to before its body sees it.
|
|
16
|
+
//
|
|
17
|
+
// <UploadField> renders the hidden input that carries the reference and
|
|
18
|
+
// draws nothing else: the app draws the control, from the state it is
|
|
19
|
+
// handed, in its own design system. useUpload() is the same state for a
|
|
20
|
+
// form whose data React holds (useForm), where the app puts the reference
|
|
21
|
+
// into its data itself. <UploadsField> and useUploads() are the same for a
|
|
22
|
+
// parameter that takes several files (an UploadList): each file goes to
|
|
23
|
+
// /_upload under the one ticket, and the form posts every reference.
|
|
24
|
+
//
|
|
25
|
+
// A page left open past its ticket's hour has the ticket issued again
|
|
26
|
+
// (/_upload/refresh) the first time an upload under it is refused, and the
|
|
27
|
+
// upload sent once more -- for the same session and user, and never past a
|
|
28
|
+
// day from when the page first had it.
|
|
29
|
+
//
|
|
30
|
+
// `resumable` sends the file in pieces the server holds as they arrive
|
|
31
|
+
// (resumable.js, ADR 0143): a dropped network is waited out and the upload
|
|
32
|
+
// goes on by itself; and after a refresh, a closed tab or a crash the field
|
|
33
|
+
// offers to go on from where the server is -- the person chooses the file
|
|
34
|
+
// again, or, where the browser lets a file be reopened (the File System
|
|
35
|
+
// Access API), only grants permission to read it.
|
|
36
|
+
|
|
37
|
+
import { createElement, Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
|
38
|
+
import { useFormSession, useOptionalRouter } from './form.js';
|
|
39
|
+
import { assignLocation, endSession } from './router-hooks.js';
|
|
40
|
+
import { send } from './transport.js';
|
|
41
|
+
import { defaultResumeStore } from './resume-store.js';
|
|
42
|
+
import {
|
|
43
|
+
abortUpload,
|
|
44
|
+
askOffset,
|
|
45
|
+
fingerprintFile,
|
|
46
|
+
putToStore,
|
|
47
|
+
uploadResumable,
|
|
48
|
+
uploadScope,
|
|
49
|
+
} from './resumable.js';
|
|
50
|
+
|
|
51
|
+
export { abortUpload } from './resumable.js';
|
|
52
|
+
export {
|
|
53
|
+
UPLOAD_ABORT_PATH,
|
|
54
|
+
UPLOAD_CHUNK_PATH,
|
|
55
|
+
UPLOAD_OFFSET_PATH,
|
|
56
|
+
UPLOAD_PARTS_PATH,
|
|
57
|
+
UPLOAD_SESSION_COMPLETE_PATH,
|
|
58
|
+
UPLOAD_SESSION_PATH,
|
|
59
|
+
} from './resumable.js';
|
|
60
|
+
export { indexedDbResumeStore, memoryResumeStore } from './resume-store.js';
|
|
61
|
+
|
|
62
|
+
/** Where files go: the framework's upload endpoint. */
|
|
63
|
+
export const UPLOAD_PATH = '/_upload';
|
|
64
|
+
|
|
65
|
+
/** Where a ticket a page has outlived is issued again. */
|
|
66
|
+
export const UPLOAD_REFRESH_PATH = '/_upload/refresh';
|
|
67
|
+
|
|
68
|
+
/** Where a file to go straight to the object store is announced, for a URL to send it to. */
|
|
69
|
+
export const UPLOAD_DIRECT_PATH = '/_upload/direct';
|
|
70
|
+
|
|
71
|
+
/** Where a file sent straight to the store is said to be there, for PHP to check and keep. */
|
|
72
|
+
export const UPLOAD_COMPLETE_PATH = '/_upload/complete';
|
|
73
|
+
|
|
74
|
+
/** What /_upload calls the reference it answers with: the id it drew, as hex. */
|
|
75
|
+
const REFERENCE = /^[a-f0-9]{32}$/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {import('./transport.js').Progress} Progress
|
|
79
|
+
*
|
|
80
|
+
* @typedef {{ upload: string, name: string, size: number, type: string }} Uploaded
|
|
81
|
+
*
|
|
82
|
+
* @typedef {'too_large' | 'type' | 'invalid' | 'refused' | 'signed_out' | 'throttled' | 'quota' | 'infected' | 'unavailable' | 'expired' | 'network' | 'failed'} UploadErrorKind
|
|
83
|
+
*
|
|
84
|
+
* @typedef {{
|
|
85
|
+
* kind: UploadErrorKind,
|
|
86
|
+
* status: number,
|
|
87
|
+
* code: string | null,
|
|
88
|
+
* reference: string | null,
|
|
89
|
+
* retryAfter: number | null,
|
|
90
|
+
* }} UploadError
|
|
91
|
+
*
|
|
92
|
+
* @typedef {(
|
|
93
|
+
* | { type: 'uploaded', file: Uploaded }
|
|
94
|
+
* | { type: 'error', error: UploadError }
|
|
95
|
+
* | { type: 'cancelled' }
|
|
96
|
+
* )} UploadOutcome
|
|
97
|
+
*
|
|
98
|
+
* @typedef {{
|
|
99
|
+
* ticket: string,
|
|
100
|
+
* file: Blob,
|
|
101
|
+
* url?: string,
|
|
102
|
+
* maxBytes?: number,
|
|
103
|
+
* signal?: AbortSignal,
|
|
104
|
+
* onProgress?: (progress: Progress) => void,
|
|
105
|
+
* refresh?: boolean,
|
|
106
|
+
* refreshUrl?: string,
|
|
107
|
+
* onTicket?: (ticket: string) => void,
|
|
108
|
+
* direct?: boolean,
|
|
109
|
+
* resumable?: boolean,
|
|
110
|
+
* onState?: (state: 'uploading' | 'paused' | 'resuming') => void,
|
|
111
|
+
* onSession?: (session: import('./resumable.js').ResumableSession) => void,
|
|
112
|
+
* handle?: unknown,
|
|
113
|
+
* resumeStore?: import('./resume-store.js').ResumeStore,
|
|
114
|
+
* backoff?: number[],
|
|
115
|
+
* }} UploadOptions
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sends one file to /_upload under a ticket.
|
|
120
|
+
*
|
|
121
|
+
* Never throws for an outcome. Each refusal has a kind the app can say
|
|
122
|
+
* something specific about -- too large, not a type the field takes, a
|
|
123
|
+
* ticket that has run out -- and no kind ever carries the server's message,
|
|
124
|
+
* which is for its log.
|
|
125
|
+
*
|
|
126
|
+
* `maxBytes`, when given, refuses a file larger than it without sending
|
|
127
|
+
* it: the server holds the real limit, and this only spares the customer a
|
|
128
|
+
* wait for an answer that can only be no.
|
|
129
|
+
*
|
|
130
|
+
* `direct` sends the file straight to the application's object store
|
|
131
|
+
* rather than through PHP: /_upload/direct answers with a URL presigned for
|
|
132
|
+
* its size and the headers to send, the file is PUT there with progress,
|
|
133
|
+
* and /_upload/complete has PHP fetch it back and keep it as /_upload
|
|
134
|
+
* would -- or refuse it. The outcome is the same either way. The bucket
|
|
135
|
+
* must allow this origin's PUT (CORS).
|
|
136
|
+
*
|
|
137
|
+
* `resumable` sends it as a resumable upload (ADR 0143): in chunks through
|
|
138
|
+
* PHP, or -- with `direct` -- in parts straight to the store, each piece
|
|
139
|
+
* only from where the server says it holds. A network that drops is waited
|
|
140
|
+
* out, `onState` told `paused` and then `resuming`; and the same file chosen
|
|
141
|
+
* again, after a refresh or a crash, goes on from where the server is. A
|
|
142
|
+
* server that takes no resumable upload of the kind asked has the file sent
|
|
143
|
+
* whole, as without it. `onSession` is told the session once it is known:
|
|
144
|
+
* a cancel through `signal` keeps it on the server, and a caller that gives
|
|
145
|
+
* the upload up for good gives the session up too (abortUpload()).
|
|
146
|
+
*
|
|
147
|
+
* @param {UploadOptions} options
|
|
148
|
+
* @returns {Promise<UploadOutcome>}
|
|
149
|
+
*/
|
|
150
|
+
export async function uploadFile({
|
|
151
|
+
ticket,
|
|
152
|
+
file,
|
|
153
|
+
url = UPLOAD_PATH,
|
|
154
|
+
maxBytes,
|
|
155
|
+
signal,
|
|
156
|
+
onProgress,
|
|
157
|
+
refresh = false,
|
|
158
|
+
refreshUrl = UPLOAD_REFRESH_PATH,
|
|
159
|
+
onTicket,
|
|
160
|
+
direct = false,
|
|
161
|
+
resumable = false,
|
|
162
|
+
onState,
|
|
163
|
+
onSession,
|
|
164
|
+
handle,
|
|
165
|
+
resumeStore,
|
|
166
|
+
backoff,
|
|
167
|
+
}) {
|
|
168
|
+
if (typeof maxBytes === 'number' && file.size > maxBytes) {
|
|
169
|
+
return { type: 'error', error: failure('too_large', 413) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sendWhole = (/** @type {string} */ under) =>
|
|
173
|
+
direct
|
|
174
|
+
? sendDirect(under, file, signal, onProgress)
|
|
175
|
+
: send({ url, data: { ticket: under, file }, signal, onUploadProgress: onProgress });
|
|
176
|
+
const sendUnder = async (/** @type {string} */ under) => {
|
|
177
|
+
if (!resumable) return sendWhole(under);
|
|
178
|
+
const sent = await uploadResumable({
|
|
179
|
+
ticket: under,
|
|
180
|
+
file,
|
|
181
|
+
direct,
|
|
182
|
+
signal,
|
|
183
|
+
onProgress,
|
|
184
|
+
onState,
|
|
185
|
+
onSession,
|
|
186
|
+
handle,
|
|
187
|
+
store: resumeStore,
|
|
188
|
+
...(backoff === undefined ? {} : { backoff }),
|
|
189
|
+
});
|
|
190
|
+
return sent.type === 'unsupported' ? sendWhole(under) : sent;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let outcome = await sendUnder(ticket);
|
|
194
|
+
|
|
195
|
+
// Refused: most likely a ticket the page has outlived. Asked for again
|
|
196
|
+
// once, and the file sent once more under it; a refresh refused is the
|
|
197
|
+
// first refusal, and the page has to be loaded again.
|
|
198
|
+
if (refresh && outcome.type === 'http' && outcome.status === 403) {
|
|
199
|
+
const renewed = await refreshTicket({ ticket, url: refreshUrl, signal });
|
|
200
|
+
if (renewed.type === 'refreshed') {
|
|
201
|
+
onTicket?.(renewed.ticket);
|
|
202
|
+
outcome = await sendUnder(renewed.ticket);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return outcomeOf(outcome);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* What an upload's last answer means for the app: the file, or a refusal of
|
|
211
|
+
* a kind it can say something about.
|
|
212
|
+
*
|
|
213
|
+
* @param {import('./transport.js').Outcome} outcome
|
|
214
|
+
* @returns {UploadOutcome}
|
|
215
|
+
*/
|
|
216
|
+
function outcomeOf(outcome) {
|
|
217
|
+
switch (outcome.type) {
|
|
218
|
+
case 'success': {
|
|
219
|
+
const body = outcome.body;
|
|
220
|
+
const uploaded =
|
|
221
|
+
body !== null &&
|
|
222
|
+
typeof body === 'object' &&
|
|
223
|
+
typeof body.upload === 'string' &&
|
|
224
|
+
REFERENCE.test(body.upload) &&
|
|
225
|
+
typeof body.name === 'string' &&
|
|
226
|
+
typeof body.size === 'number' &&
|
|
227
|
+
typeof body.type === 'string';
|
|
228
|
+
return uploaded
|
|
229
|
+
? {
|
|
230
|
+
type: 'uploaded',
|
|
231
|
+
file: { upload: body.upload, name: body.name, size: body.size, type: body.type },
|
|
232
|
+
}
|
|
233
|
+
: { type: 'error', error: failure('failed', outcome.status) };
|
|
234
|
+
}
|
|
235
|
+
case 'validation':
|
|
236
|
+
// A scanner's find is a 422 too, told apart by its code.
|
|
237
|
+
return outcome.body?.error?.code === 'upload_infected'
|
|
238
|
+
? { type: 'error', error: failure('infected', 422, 'upload_infected') }
|
|
239
|
+
: { type: 'error', error: failure('invalid', 422) };
|
|
240
|
+
case 'http':
|
|
241
|
+
return {
|
|
242
|
+
type: 'error',
|
|
243
|
+
error: failure(
|
|
244
|
+
kindOf(outcome.status),
|
|
245
|
+
outcome.status,
|
|
246
|
+
outcome.code,
|
|
247
|
+
outcome.reference,
|
|
248
|
+
outcome.retryAfter,
|
|
249
|
+
),
|
|
250
|
+
};
|
|
251
|
+
case 'expired':
|
|
252
|
+
return { type: 'error', error: failure('expired', 419) };
|
|
253
|
+
case 'network':
|
|
254
|
+
return { type: 'error', error: failure('network', 0) };
|
|
255
|
+
case 'cancelled':
|
|
256
|
+
return { type: 'cancelled' };
|
|
257
|
+
default:
|
|
258
|
+
// A redirect, which /_upload never answers with.
|
|
259
|
+
return { type: 'error', error: failure('failed', 0) };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A file sent straight to the object store, in three steps, answered as
|
|
265
|
+
* /_upload answers: the intent and the presigned URL from /_upload/direct;
|
|
266
|
+
* the PUT, with the headers it was signed with and nothing of this origin's
|
|
267
|
+
* -- no cookie, no CSRF header; and /_upload/complete, which answers with
|
|
268
|
+
* the reference, or the refusal /_upload would have given.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} ticket
|
|
271
|
+
* @param {Blob} file
|
|
272
|
+
* @param {AbortSignal | undefined} signal
|
|
273
|
+
* @param {((progress: Progress) => void) | undefined} onProgress
|
|
274
|
+
* @returns {Promise<import('./transport.js').Outcome>}
|
|
275
|
+
*/
|
|
276
|
+
async function sendDirect(ticket, file, signal, onProgress) {
|
|
277
|
+
const name = typeof (/** @type {any} */ (file).name) === 'string' ? /** @type {any} */ (file).name : '';
|
|
278
|
+
const begun = await send({ url: UPLOAD_DIRECT_PATH, data: { ticket, size: file.size, name }, signal });
|
|
279
|
+
if (begun.type !== 'success') return begun;
|
|
280
|
+
|
|
281
|
+
const body = begun.body;
|
|
282
|
+
if (
|
|
283
|
+
body === null ||
|
|
284
|
+
typeof body !== 'object' ||
|
|
285
|
+
typeof body.intent !== 'string' ||
|
|
286
|
+
!REFERENCE.test(body.intent) ||
|
|
287
|
+
typeof body.url !== 'string' ||
|
|
288
|
+
body.headers === null ||
|
|
289
|
+
typeof body.headers !== 'object'
|
|
290
|
+
) {
|
|
291
|
+
return {
|
|
292
|
+
type: 'http',
|
|
293
|
+
status: 0,
|
|
294
|
+
body: null,
|
|
295
|
+
headers: new Headers(),
|
|
296
|
+
code: null,
|
|
297
|
+
reference: null,
|
|
298
|
+
retryAfter: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const put = await putToStore(body.url, body.headers, file, signal, onProgress);
|
|
303
|
+
if (put.type !== 'sent') return put;
|
|
304
|
+
|
|
305
|
+
return send({ url: UPLOAD_COMPLETE_PATH, data: { ticket, intent: body.intent }, signal });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Asks for a ticket the page holds to be issued again: for the same
|
|
310
|
+
* session, user, action and record, for another hour, and never past a day
|
|
311
|
+
* from its first issue. Never throws for an outcome.
|
|
312
|
+
*
|
|
313
|
+
* @param {{ ticket: string, url?: string, signal?: AbortSignal }} options
|
|
314
|
+
* @returns {Promise<{ type: 'refreshed', ticket: string, expiresAt: number } | { type: 'error', error: UploadError } | { type: 'cancelled' }>}
|
|
315
|
+
*/
|
|
316
|
+
export async function refreshTicket({ ticket, url = UPLOAD_REFRESH_PATH, signal }) {
|
|
317
|
+
const outcome = await send({ url, data: { ticket }, signal });
|
|
318
|
+
if (outcome.type === 'cancelled') return { type: 'cancelled' };
|
|
319
|
+
if (outcome.type === 'success') {
|
|
320
|
+
const body = outcome.body;
|
|
321
|
+
if (
|
|
322
|
+
body !== null &&
|
|
323
|
+
typeof body === 'object' &&
|
|
324
|
+
typeof body.ticket === 'string' &&
|
|
325
|
+
typeof body.expiresAt === 'number'
|
|
326
|
+
) {
|
|
327
|
+
return { type: 'refreshed', ticket: body.ticket, expiresAt: body.expiresAt };
|
|
328
|
+
}
|
|
329
|
+
return { type: 'error', error: failure('failed', outcome.status) };
|
|
330
|
+
}
|
|
331
|
+
if (outcome.type === 'http') {
|
|
332
|
+
return {
|
|
333
|
+
type: 'error',
|
|
334
|
+
error: failure(
|
|
335
|
+
kindOf(outcome.status),
|
|
336
|
+
outcome.status,
|
|
337
|
+
outcome.code,
|
|
338
|
+
outcome.reference,
|
|
339
|
+
outcome.retryAfter,
|
|
340
|
+
),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return { type: 'error', error: failure(outcome.type === 'network' ? 'network' : 'failed', 0) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @param {number} status @returns {UploadErrorKind} */
|
|
347
|
+
function kindOf(status) {
|
|
348
|
+
switch (status) {
|
|
349
|
+
case 401:
|
|
350
|
+
return 'signed_out';
|
|
351
|
+
case 403:
|
|
352
|
+
return 'refused';
|
|
353
|
+
case 413:
|
|
354
|
+
return 'too_large';
|
|
355
|
+
case 415:
|
|
356
|
+
return 'type';
|
|
357
|
+
case 429:
|
|
358
|
+
return 'throttled';
|
|
359
|
+
case 507:
|
|
360
|
+
return 'quota';
|
|
361
|
+
case 503:
|
|
362
|
+
return 'unavailable';
|
|
363
|
+
default:
|
|
364
|
+
return 'failed';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @param {UploadErrorKind} kind
|
|
370
|
+
* @param {number} status
|
|
371
|
+
* @returns {UploadError}
|
|
372
|
+
*/
|
|
373
|
+
function failure(kind, status, code = null, reference = null, retryAfter = null) {
|
|
374
|
+
return { kind, status, code, reference, retryAfter };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @typedef {'idle' | 'uploading' | 'paused' | 'resuming' | 'uploaded' | 'error'} UploadStatus
|
|
379
|
+
*
|
|
380
|
+
* @typedef {{
|
|
381
|
+
* ticket: string,
|
|
382
|
+
* url?: string,
|
|
383
|
+
* maxBytes?: number,
|
|
384
|
+
* onUploaded?: (file: Uploaded) => void,
|
|
385
|
+
* onError?: (error: UploadError) => void,
|
|
386
|
+
* direct?: boolean,
|
|
387
|
+
* resumable?: boolean,
|
|
388
|
+
* resumeStore?: import('./resume-store.js').ResumeStore,
|
|
389
|
+
* backoff?: number[],
|
|
390
|
+
* }} UseUploadOptions
|
|
391
|
+
*
|
|
392
|
+
* @typedef {{
|
|
393
|
+
* name: string,
|
|
394
|
+
* size: number,
|
|
395
|
+
* offset: number,
|
|
396
|
+
* percentage: number,
|
|
397
|
+
* reopenable: boolean,
|
|
398
|
+
* }} PendingUpload
|
|
399
|
+
*/
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* One upload's state, for a field that takes one file.
|
|
403
|
+
*
|
|
404
|
+
* A second file chosen while the first is still going cancels the first:
|
|
405
|
+
* the reference that counts is the one for the file chosen last. Leaving
|
|
406
|
+
* the page cancels it too.
|
|
407
|
+
*
|
|
408
|
+
* `onUploaded` runs once the reference is in state -- after the render that
|
|
409
|
+
* puts it there -- so a form submitted from it sends the new reference,
|
|
410
|
+
* not the one before.
|
|
411
|
+
*
|
|
412
|
+
* A 401 means the session is over: what the page holds belongs to it, and
|
|
413
|
+
* the page is loaded again, as every form's 401 does.
|
|
414
|
+
*
|
|
415
|
+
* With `resumable`, `status` is also `paused` while a dropped network is
|
|
416
|
+
* waited out, and `resuming` while the server is asked where to go on; and
|
|
417
|
+
* once the field is drawn, `pending` is an upload of this field the server
|
|
418
|
+
* still holds part of -- begun before a refresh, a closed tab or a crash --
|
|
419
|
+
* for the app to offer to go on with (`resume()`) or give up
|
|
420
|
+
* (`discardPending()`). A browser cannot read a file again without the
|
|
421
|
+
* person: `resume()` reopens it where the File System Access API kept a
|
|
422
|
+
* handle to it, asking only for permission, and otherwise answers null, for
|
|
423
|
+
* the person to choose it again -- the same file goes on from where the
|
|
424
|
+
* server is; another starts over.
|
|
425
|
+
*
|
|
426
|
+
* @param {UseUploadOptions} options
|
|
427
|
+
*/
|
|
428
|
+
export function useUpload({
|
|
429
|
+
ticket,
|
|
430
|
+
url,
|
|
431
|
+
maxBytes,
|
|
432
|
+
onUploaded,
|
|
433
|
+
onError,
|
|
434
|
+
direct,
|
|
435
|
+
resumable = false,
|
|
436
|
+
resumeStore,
|
|
437
|
+
backoff,
|
|
438
|
+
}) {
|
|
439
|
+
const router = useOptionalRouter();
|
|
440
|
+
const session = useFormSession();
|
|
441
|
+
const [status, setStatus] = useState(/** @type {UploadStatus} */ ('idle'));
|
|
442
|
+
const [progress, setProgress] = useState(/** @type {Progress | null} */ (null));
|
|
443
|
+
const [file, setFile] = useState(/** @type {Uploaded | null} */ (null));
|
|
444
|
+
const [error, setError] = useState(/** @type {UploadError | null} */ (null));
|
|
445
|
+
const [pending, setPending] = useState(/** @type {PendingUpload | null} */ (null));
|
|
446
|
+
const pendingRecord = useRef(/** @type {import('./resume-store.js').ResumeRecord | null} */ (null));
|
|
447
|
+
const inFlight = useRef(/** @type {Job | null} */ (null));
|
|
448
|
+
// The last upload that ended keeping its session on the server -- given
|
|
449
|
+
// up on the network, say -- for a reset or another file to give up.
|
|
450
|
+
const kept = useRef(/** @type {Job | null} */ (null));
|
|
451
|
+
// How many files have been chosen here: one chosen while the server was
|
|
452
|
+
// asked about a waiting upload is what the field holds, not that one.
|
|
453
|
+
const choices = useRef(0);
|
|
454
|
+
|
|
455
|
+
// The latest callbacks and options, for an upload started under older ones.
|
|
456
|
+
const latest = useRef({
|
|
457
|
+
ticket,
|
|
458
|
+
url,
|
|
459
|
+
maxBytes,
|
|
460
|
+
onUploaded,
|
|
461
|
+
onError,
|
|
462
|
+
direct,
|
|
463
|
+
resumable,
|
|
464
|
+
resumeStore,
|
|
465
|
+
backoff,
|
|
466
|
+
});
|
|
467
|
+
latest.current = { ticket, url, maxBytes, onUploaded, onError, direct, resumable, resumeStore, backoff };
|
|
468
|
+
const renewed = useRenewedTicket(ticket);
|
|
469
|
+
|
|
470
|
+
// Leaving the page cancels the upload but keeps its session: the next
|
|
471
|
+
// page offers to go on with it.
|
|
472
|
+
useEffect(() => () => inFlight.current?.controller.abort(), []);
|
|
473
|
+
|
|
474
|
+
// An upload of this field the server still holds part of, offered once
|
|
475
|
+
// the field is drawn: the newest one for what this ticket is for, asked
|
|
476
|
+
// of the server first -- one it has let go of, or that is another
|
|
477
|
+
// session's or user's, is forgotten without a word.
|
|
478
|
+
const scope = resumable ? uploadScope(ticket) : null;
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (scope === null) return undefined;
|
|
481
|
+
let live = true;
|
|
482
|
+
const store = latest.current.resumeStore ?? defaultResumeStore();
|
|
483
|
+
const choicesBefore = choices.current;
|
|
484
|
+
void (async () => {
|
|
485
|
+
let records = [];
|
|
486
|
+
try {
|
|
487
|
+
records = await store.inScope(scope);
|
|
488
|
+
} catch {
|
|
489
|
+
records = [];
|
|
490
|
+
}
|
|
491
|
+
for (const record of records) {
|
|
492
|
+
const asked = await askOffset(record.session);
|
|
493
|
+
if (!live || choices.current !== choicesBefore) return;
|
|
494
|
+
if (asked.type === 'gone') {
|
|
495
|
+
await store.delete(record.id).catch(() => {});
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const offset =
|
|
499
|
+
asked.type === 'open' ? asked.offset : asked.type === 'completed' ? record.size : record.offset;
|
|
500
|
+
pendingRecord.current = record;
|
|
501
|
+
setPending({
|
|
502
|
+
name: record.name,
|
|
503
|
+
size: record.size,
|
|
504
|
+
offset,
|
|
505
|
+
percentage: record.size > 0 ? Math.floor((offset / record.size) * 100) : 0,
|
|
506
|
+
reopenable: typeof (/** @type {any} */ (record.handle)?.getFile) === 'function',
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
})();
|
|
511
|
+
return () => {
|
|
512
|
+
live = false;
|
|
513
|
+
};
|
|
514
|
+
}, [scope]);
|
|
515
|
+
|
|
516
|
+
// Told once per upload, after it has rendered.
|
|
517
|
+
const told = useRef(/** @type {Uploaded | null} */ (null));
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
if (file !== null && told.current !== file) {
|
|
520
|
+
told.current = file;
|
|
521
|
+
latest.current.onUploaded?.(file);
|
|
522
|
+
}
|
|
523
|
+
}, [file]);
|
|
524
|
+
|
|
525
|
+
const upload = useCallback(
|
|
526
|
+
async (/** @type {Blob} */ chosen, /** @type {{ handle?: unknown }} */ { handle } = {}) => {
|
|
527
|
+
choices.current++;
|
|
528
|
+
const options = latest.current;
|
|
529
|
+
const fingerprint = options.resumable ? fingerprintFile(chosen) : null;
|
|
530
|
+
// This field takes one file: the one it replaces is given up, its
|
|
531
|
+
// session too -- unless it is the same file, which goes on in it.
|
|
532
|
+
const replaced = inFlight.current ?? kept.current;
|
|
533
|
+
kept.current = null;
|
|
534
|
+
if (replaced !== null) {
|
|
535
|
+
giveUp(
|
|
536
|
+
replaced,
|
|
537
|
+
(async () =>
|
|
538
|
+
replaced.fingerprint === null || (await replaced.fingerprint) !== (await fingerprint))(),
|
|
539
|
+
options.resumeStore,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
/** @type {Job} */
|
|
544
|
+
const job = { controller, fingerprint, session: null, discard: false, done: false };
|
|
545
|
+
inFlight.current = job;
|
|
546
|
+
const current = () => inFlight.current === job;
|
|
547
|
+
|
|
548
|
+
setStatus('uploading');
|
|
549
|
+
setProgress(null);
|
|
550
|
+
setFile(null);
|
|
551
|
+
setError(null);
|
|
552
|
+
|
|
553
|
+
// Another file than the one waiting to be gone on with: this field
|
|
554
|
+
// takes one, so the one waiting is given up, and its room with it.
|
|
555
|
+
const waiting = pendingRecord.current;
|
|
556
|
+
if (waiting !== null) {
|
|
557
|
+
pendingRecord.current = null;
|
|
558
|
+
setPending(null);
|
|
559
|
+
if (options.resumable && (await fingerprint) !== waiting.fingerprint) {
|
|
560
|
+
void discardRecord(waiting, options.resumeStore);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const outcome = await uploadFile({
|
|
565
|
+
ticket: renewed.current.ticket,
|
|
566
|
+
file: chosen,
|
|
567
|
+
url: options.url,
|
|
568
|
+
maxBytes: options.maxBytes,
|
|
569
|
+
direct: options.direct,
|
|
570
|
+
resumable: options.resumable,
|
|
571
|
+
resumeStore: options.resumeStore,
|
|
572
|
+
handle,
|
|
573
|
+
...(options.backoff === undefined ? {} : { backoff: options.backoff }),
|
|
574
|
+
signal: controller.signal,
|
|
575
|
+
onProgress: (p) => {
|
|
576
|
+
if (current()) setProgress(p);
|
|
577
|
+
},
|
|
578
|
+
onState: (state) => {
|
|
579
|
+
if (current()) setStatus(state);
|
|
580
|
+
},
|
|
581
|
+
onSession: (held) => {
|
|
582
|
+
job.session = held;
|
|
583
|
+
},
|
|
584
|
+
refresh: true,
|
|
585
|
+
onTicket: (next) => {
|
|
586
|
+
renewed.current.ticket = next;
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Cancelled for good -- not by the field going away -- so the server
|
|
591
|
+
// need hold nothing more of it.
|
|
592
|
+
job.done = true;
|
|
593
|
+
if (outcome.type === 'cancelled' && job.session !== null && (await job.discard)) {
|
|
594
|
+
void discardRecord(job.session, options.resumeStore);
|
|
595
|
+
job.session = null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Superseded, or the field went away: the later upload owns the state.
|
|
599
|
+
if (!current()) return outcome;
|
|
600
|
+
inFlight.current = null;
|
|
601
|
+
// Ended with its session still on the server: the next file, or a reset, gives it up.
|
|
602
|
+
if (outcome.type === 'error' && job.session !== null) kept.current = job;
|
|
603
|
+
setProgress(null);
|
|
604
|
+
|
|
605
|
+
if (outcome.type === 'uploaded') {
|
|
606
|
+
setFile(outcome.file);
|
|
607
|
+
setStatus('uploaded');
|
|
608
|
+
} else if (outcome.type === 'error') {
|
|
609
|
+
setError(outcome.error);
|
|
610
|
+
setStatus('error');
|
|
611
|
+
latest.current.onError?.(outcome.error);
|
|
612
|
+
if (outcome.error.kind === 'signed_out') endSession(router, session?.hardNavigate ?? assignLocation);
|
|
613
|
+
} else {
|
|
614
|
+
setStatus('idle');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return outcome;
|
|
618
|
+
},
|
|
619
|
+
[router, session, renewed],
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
const cancel = useCallback(() => {
|
|
623
|
+
if (inFlight.current !== null) giveUp(inFlight.current, true, latest.current.resumeStore);
|
|
624
|
+
}, []);
|
|
625
|
+
|
|
626
|
+
const reset = useCallback(() => {
|
|
627
|
+
const job = inFlight.current ?? kept.current;
|
|
628
|
+
if (job !== null) giveUp(job, true, latest.current.resumeStore);
|
|
629
|
+
inFlight.current = null;
|
|
630
|
+
kept.current = null;
|
|
631
|
+
setStatus('idle');
|
|
632
|
+
setProgress(null);
|
|
633
|
+
setFile(null);
|
|
634
|
+
setError(null);
|
|
635
|
+
}, []);
|
|
636
|
+
|
|
637
|
+
const resume = useCallback(async () => {
|
|
638
|
+
const record = pendingRecord.current;
|
|
639
|
+
if (record === null) return null;
|
|
640
|
+
|
|
641
|
+
// Whole on the server already -- the last piece went in as the page
|
|
642
|
+
// went away -- so there is nothing to send, and no file to ask for.
|
|
643
|
+
if (pending !== null && pending.offset >= pending.size) {
|
|
644
|
+
const asked = await askOffset(record.session);
|
|
645
|
+
if (asked.type === 'completed') {
|
|
646
|
+
pendingRecord.current = null;
|
|
647
|
+
setPending(null);
|
|
648
|
+
await (latest.current.resumeStore ?? defaultResumeStore()).delete(record.id).catch(() => {});
|
|
649
|
+
const outcome = outcomeOf(asked.outcome);
|
|
650
|
+
if (outcome.type === 'uploaded') {
|
|
651
|
+
setFile(outcome.file);
|
|
652
|
+
setStatus('uploaded');
|
|
653
|
+
}
|
|
654
|
+
return outcome;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// A file the browser kept a handle to: read again once the person
|
|
659
|
+
// grants it, which needs this to run from their click.
|
|
660
|
+
const handle = /** @type {any} */ (record.handle);
|
|
661
|
+
if (handle && typeof handle.getFile === 'function') {
|
|
662
|
+
try {
|
|
663
|
+
let permission =
|
|
664
|
+
typeof handle.queryPermission === 'function'
|
|
665
|
+
? await handle.queryPermission({ mode: 'read' })
|
|
666
|
+
: 'granted';
|
|
667
|
+
if (permission !== 'granted' && typeof handle.requestPermission === 'function') {
|
|
668
|
+
permission = await handle.requestPermission({ mode: 'read' });
|
|
669
|
+
}
|
|
670
|
+
if (permission === 'granted') return upload(await handle.getFile(), { handle });
|
|
671
|
+
} catch {
|
|
672
|
+
// Moved, deleted or refused: the person chooses it again.
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}, [pending, upload]);
|
|
677
|
+
|
|
678
|
+
const discardPending = useCallback(async () => {
|
|
679
|
+
const record = pendingRecord.current;
|
|
680
|
+
pendingRecord.current = null;
|
|
681
|
+
setPending(null);
|
|
682
|
+
if (record !== null) await discardRecord(record, latest.current.resumeStore);
|
|
683
|
+
}, []);
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
status,
|
|
687
|
+
uploading: status === 'uploading' || status === 'paused' || status === 'resuming',
|
|
688
|
+
paused: status === 'paused',
|
|
689
|
+
resuming: status === 'resuming',
|
|
690
|
+
progress,
|
|
691
|
+
file,
|
|
692
|
+
error,
|
|
693
|
+
/** What the form sends under the parameter's name: the reference, or null before one. */
|
|
694
|
+
reference: file?.upload ?? null,
|
|
695
|
+
pending,
|
|
696
|
+
upload,
|
|
697
|
+
resume,
|
|
698
|
+
discardPending,
|
|
699
|
+
cancel,
|
|
700
|
+
reset,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* One upload of a single-file field: what cancels it, the fingerprint of
|
|
706
|
+
* its file (resumable only), the session it goes on in once known, and
|
|
707
|
+
* whether that session is to be given up once it has stopped -- false when
|
|
708
|
+
* the field merely went away.
|
|
709
|
+
*
|
|
710
|
+
* @typedef {{
|
|
711
|
+
* controller: AbortController,
|
|
712
|
+
* fingerprint: Promise<string | null> | null,
|
|
713
|
+
* session: import('./resumable.js').ResumableSession | null,
|
|
714
|
+
* discard: boolean | Promise<boolean>,
|
|
715
|
+
* done: boolean,
|
|
716
|
+
* }} Job
|
|
717
|
+
*/
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Gives an upload up: cancelled if it is still going -- its session given up
|
|
721
|
+
* once it has stopped, when $discard says so -- or, ended keeping its
|
|
722
|
+
* session, that session given up now.
|
|
723
|
+
*
|
|
724
|
+
* @param {Job} job
|
|
725
|
+
* @param {boolean | Promise<boolean>} discard
|
|
726
|
+
* @param {import('./resume-store.js').ResumeStore | undefined} store
|
|
727
|
+
*/
|
|
728
|
+
function giveUp(job, discard, store) {
|
|
729
|
+
job.discard = discard;
|
|
730
|
+
if (!job.done) {
|
|
731
|
+
job.controller.abort();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const session = job.session;
|
|
735
|
+
job.session = null;
|
|
736
|
+
if (session !== null) {
|
|
737
|
+
void (async () => {
|
|
738
|
+
if (await discard) await discardRecord(session, store);
|
|
739
|
+
})();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Gives up an upload a record remembers: on the server, its bytes removed
|
|
745
|
+
* and its room given back, and the record forgotten. Never throws.
|
|
746
|
+
*
|
|
747
|
+
* @param {{ session: string, id: string | null }} record
|
|
748
|
+
* @param {import('./resume-store.js').ResumeStore | undefined} store
|
|
749
|
+
*/
|
|
750
|
+
async function discardRecord(record, store) {
|
|
751
|
+
try {
|
|
752
|
+
await abortUpload(record.session);
|
|
753
|
+
} catch {
|
|
754
|
+
// Pruned once it runs out, whatever happens here.
|
|
755
|
+
}
|
|
756
|
+
if (record.id === null) return;
|
|
757
|
+
try {
|
|
758
|
+
await (store ?? defaultResumeStore()).delete(record.id);
|
|
759
|
+
} catch {
|
|
760
|
+
// Run out, it is not offered again.
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* A form field that uploads the file chosen in it, and posts the reference.
|
|
766
|
+
*
|
|
767
|
+
* <Form action={updateAvatar}>
|
|
768
|
+
* <UploadField name="avatar" ticket={data.avatarTicket}>
|
|
769
|
+
* {(field) => (
|
|
770
|
+
* <>
|
|
771
|
+
* <input {...field.input} hidden />
|
|
772
|
+
* <button type="button" onClick={field.choose}>Choose a picture</button>
|
|
773
|
+
* {field.uploading && <progress value={field.progress?.percentage ?? 0} max={100} />}
|
|
774
|
+
* </>
|
|
775
|
+
* )}
|
|
776
|
+
* </UploadField>
|
|
777
|
+
* </Form>
|
|
778
|
+
*
|
|
779
|
+
* It renders a hidden input under `name`, holding the reference once the
|
|
780
|
+
* upload is in, and hands its children the upload's state with `input` --
|
|
781
|
+
* the props for a file input, which has no name, so the file itself is
|
|
782
|
+
* never part of the form -- and `choose()`, which opens it.
|
|
783
|
+
*
|
|
784
|
+
* Without script -- before it has loaded, or with none -- the file input
|
|
785
|
+
* carries `name` and the ticket rides beside it in `_upload_tickets[name]`,
|
|
786
|
+
* so a form with `encType="multipart/form-data"` posts the file to the
|
|
787
|
+
* action itself, which takes it as /_upload would (ADR 0127). Once script
|
|
788
|
+
* runs the input loses its name, and the file goes to /_upload first.
|
|
789
|
+
*
|
|
790
|
+
* With `resumable`, choosing a file opens the browser's own file picker
|
|
791
|
+
* where it has one that keeps a handle to the file (the File System Access
|
|
792
|
+
* API), so an upload interrupted by a refresh or a crash can go on at the
|
|
793
|
+
* cost of a permission prompt; `resume()` goes on with `pending`, opening
|
|
794
|
+
* the file input for the person to choose the file again where there is no
|
|
795
|
+
* handle.
|
|
796
|
+
*
|
|
797
|
+
* @param {UseUploadOptions & {
|
|
798
|
+
* name: string,
|
|
799
|
+
* accept?: string,
|
|
800
|
+
* children?: (field: any) => unknown,
|
|
801
|
+
* }} props
|
|
802
|
+
*/
|
|
803
|
+
export function UploadField({
|
|
804
|
+
name,
|
|
805
|
+
ticket,
|
|
806
|
+
url,
|
|
807
|
+
maxBytes,
|
|
808
|
+
accept,
|
|
809
|
+
onUploaded,
|
|
810
|
+
onError,
|
|
811
|
+
direct,
|
|
812
|
+
resumable,
|
|
813
|
+
resumeStore,
|
|
814
|
+
backoff,
|
|
815
|
+
children,
|
|
816
|
+
}) {
|
|
817
|
+
const state = useUpload({
|
|
818
|
+
ticket,
|
|
819
|
+
url,
|
|
820
|
+
maxBytes,
|
|
821
|
+
onUploaded,
|
|
822
|
+
onError,
|
|
823
|
+
direct,
|
|
824
|
+
resumable,
|
|
825
|
+
resumeStore,
|
|
826
|
+
backoff,
|
|
827
|
+
});
|
|
828
|
+
const [hydrated, setHydrated] = useState(false);
|
|
829
|
+
useEffect(() => setHydrated(true), []);
|
|
830
|
+
const inputRef = useRef(/** @type {HTMLInputElement | null} */ (null));
|
|
831
|
+
const { upload, reset: resetUpload, resume: resumeUpload } = state;
|
|
832
|
+
|
|
833
|
+
const onChange = useCallback(
|
|
834
|
+
(/** @type {{ currentTarget: HTMLInputElement }} */ event) => {
|
|
835
|
+
const chosen = event.currentTarget.files?.[0];
|
|
836
|
+
if (chosen) void upload(chosen);
|
|
837
|
+
},
|
|
838
|
+
[upload],
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
const choose = useCallback(() => {
|
|
842
|
+
const picker = resumable ? openFilePicker(accept) : null;
|
|
843
|
+
if (picker === null) {
|
|
844
|
+
inputRef.current?.click();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
void picker.then((picked) => {
|
|
848
|
+
if (picked === 'unavailable') inputRef.current?.click();
|
|
849
|
+
else if (picked !== null) void upload(picked.file, { handle: picked.handle });
|
|
850
|
+
});
|
|
851
|
+
}, [resumable, accept, upload]);
|
|
852
|
+
|
|
853
|
+
const resume = useCallback(async () => {
|
|
854
|
+
const outcome = await resumeUpload();
|
|
855
|
+
if (outcome === null) choose();
|
|
856
|
+
return outcome;
|
|
857
|
+
}, [resumeUpload, choose]);
|
|
858
|
+
|
|
859
|
+
const reset = useCallback(() => {
|
|
860
|
+
resetUpload();
|
|
861
|
+
if (inputRef.current) inputRef.current.value = '';
|
|
862
|
+
}, [resetUpload]);
|
|
863
|
+
|
|
864
|
+
const field = {
|
|
865
|
+
...state,
|
|
866
|
+
reset,
|
|
867
|
+
choose,
|
|
868
|
+
resume,
|
|
869
|
+
// Whether script runs: until it does, the app shows the file input
|
|
870
|
+
// itself and a button that submits the form, which carries the file.
|
|
871
|
+
scripted: hydrated,
|
|
872
|
+
// Named only until script runs: posted by the browser itself, the file
|
|
873
|
+
// goes with the form; posted by script, never.
|
|
874
|
+
input: { ref: inputRef, type: 'file', accept, onChange, name: hydrated ? undefined : name },
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
return createElement(
|
|
878
|
+
Fragment,
|
|
879
|
+
null,
|
|
880
|
+
createElement('input', { type: 'hidden', name, value: state.reference ?? '' }),
|
|
881
|
+
// Only until script runs, as the input's name: script sends the reference alone.
|
|
882
|
+
hydrated
|
|
883
|
+
? null
|
|
884
|
+
: createElement('input', { type: 'hidden', name: `_upload_tickets[${name}]`, value: ticket }),
|
|
885
|
+
typeof children === 'function' ? children(field) : null,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The browser's own file picker, where it has one that hands back a handle
|
|
891
|
+
* to the file (the File System Access API): the file and its handle, null
|
|
892
|
+
* when the person closed it, or `unavailable` for the file input instead.
|
|
893
|
+
* Null at once where there is no such picker.
|
|
894
|
+
*
|
|
895
|
+
* @param {string | undefined} accept
|
|
896
|
+
* @returns {Promise<{ file: File, handle: unknown } | null | 'unavailable'> | null}
|
|
897
|
+
*/
|
|
898
|
+
function openFilePicker(accept) {
|
|
899
|
+
const show = /** @type {any} */ (globalThis).showOpenFilePicker;
|
|
900
|
+
if (typeof show !== 'function') return null;
|
|
901
|
+
const types = (accept ?? '')
|
|
902
|
+
.split(',')
|
|
903
|
+
.map((type) => type.trim())
|
|
904
|
+
.filter((type) => /^[a-z]+\/[a-z0-9.+-]+$/i.test(type));
|
|
905
|
+
return (async () => {
|
|
906
|
+
try {
|
|
907
|
+
const [handle] = await show({
|
|
908
|
+
multiple: false,
|
|
909
|
+
...(types.length === 0
|
|
910
|
+
? {}
|
|
911
|
+
: {
|
|
912
|
+
types: [
|
|
913
|
+
{
|
|
914
|
+
description: 'Files',
|
|
915
|
+
accept: Object.fromEntries(types.map((t) => [t, EXTENSIONS[t] ?? []])),
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
}),
|
|
919
|
+
});
|
|
920
|
+
return { file: await handle.getFile(), handle };
|
|
921
|
+
} catch (error) {
|
|
922
|
+
return /** @type {any} */ (error)?.name === 'AbortError' ? null : 'unavailable';
|
|
923
|
+
}
|
|
924
|
+
})();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** @type {Record<string, string[]>} */
|
|
928
|
+
const EXTENSIONS = {
|
|
929
|
+
'image/png': ['.png'],
|
|
930
|
+
'image/jpeg': ['.jpg', '.jpeg'],
|
|
931
|
+
'image/webp': ['.webp'],
|
|
932
|
+
'image/avif': ['.avif'],
|
|
933
|
+
'image/gif': ['.gif'],
|
|
934
|
+
'application/pdf': ['.pdf'],
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The ticket an upload goes under: the page's, until a refresh issues it
|
|
939
|
+
* again -- and the page's once more if the page hands a new one.
|
|
940
|
+
*
|
|
941
|
+
* @param {string} ticket
|
|
942
|
+
*/
|
|
943
|
+
function useRenewedTicket(ticket) {
|
|
944
|
+
const held = useRef({ given: ticket, ticket });
|
|
945
|
+
if (held.current.given !== ticket) held.current = { given: ticket, ticket };
|
|
946
|
+
return held;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* @typedef {{
|
|
951
|
+
* key: number,
|
|
952
|
+
* status: UploadStatus,
|
|
953
|
+
* progress: Progress | null,
|
|
954
|
+
* file: Uploaded | null,
|
|
955
|
+
* error: UploadError | null,
|
|
956
|
+
* }} UploadEntry
|
|
957
|
+
*/
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* The state of a parameter that takes several files (an UploadList): each
|
|
961
|
+
* file chosen is sent to /_upload under the one ticket, one after another,
|
|
962
|
+
* and `references` is every one that went in, in the order chosen.
|
|
963
|
+
*
|
|
964
|
+
* `maxFiles` refuses files past it without sending them, as `maxBytes`
|
|
965
|
+
* does a large one: the server holds the real limit.
|
|
966
|
+
*
|
|
967
|
+
* `remove()` cancels a file still going, and a file still queued is never
|
|
968
|
+
* sent; `reset()`, or the field going away, cancels the one going and sends
|
|
969
|
+
* none of those queued behind it. With `resumable`, `remove()` and
|
|
970
|
+
* `reset()` give up on the server the session of a file they cancel, or of
|
|
971
|
+
* one that ended keeping it; the field going away keeps it, so the same file
|
|
972
|
+
* chosen again goes on from where the server is.
|
|
973
|
+
*
|
|
974
|
+
* @param {UseUploadOptions & { maxFiles?: number }} options
|
|
975
|
+
*/
|
|
976
|
+
export function useUploads({
|
|
977
|
+
ticket,
|
|
978
|
+
url,
|
|
979
|
+
maxBytes,
|
|
980
|
+
maxFiles,
|
|
981
|
+
onUploaded,
|
|
982
|
+
onError,
|
|
983
|
+
direct,
|
|
984
|
+
resumable,
|
|
985
|
+
resumeStore,
|
|
986
|
+
backoff,
|
|
987
|
+
}) {
|
|
988
|
+
const router = useOptionalRouter();
|
|
989
|
+
const session = useFormSession();
|
|
990
|
+
const [entries, setEntries] = useState(/** @type {UploadEntry[]} */ ([]));
|
|
991
|
+
const renewed = useRenewedTicket(ticket);
|
|
992
|
+
const controllers = useRef(/** @type {Map<number, AbortController>} */ (new Map()));
|
|
993
|
+
const nextKey = useRef(0);
|
|
994
|
+
const latest = useRef({
|
|
995
|
+
url,
|
|
996
|
+
maxBytes,
|
|
997
|
+
maxFiles,
|
|
998
|
+
onUploaded,
|
|
999
|
+
onError,
|
|
1000
|
+
direct,
|
|
1001
|
+
resumable,
|
|
1002
|
+
resumeStore,
|
|
1003
|
+
backoff,
|
|
1004
|
+
});
|
|
1005
|
+
latest.current = { url, maxBytes, maxFiles, onUploaded, onError, direct, resumable, resumeStore, backoff };
|
|
1006
|
+
const count = useRef(0);
|
|
1007
|
+
count.current = entries.length;
|
|
1008
|
+
// The keys still wanted: a file removed, or every file on a reset, is
|
|
1009
|
+
// dropped from here, and a batch sends none it no longer finds.
|
|
1010
|
+
const wanted = useRef(/** @type {Set<number>} */ (new Set()));
|
|
1011
|
+
// One per batch still going: a reset or the field going away stops each
|
|
1012
|
+
// from sending the files it has queued.
|
|
1013
|
+
const batches = useRef(/** @type {Set<AbortController>} */ (new Set()));
|
|
1014
|
+
// The session each resumable file goes on in, while it goes, and after it
|
|
1015
|
+
// ends in an error that keeps it on the server; and the files given up
|
|
1016
|
+
// for good while they went, whose sessions go once they have stopped.
|
|
1017
|
+
const sessions = useRef(/** @type {Map<number, import('./resumable.js').ResumableSession>} */ (new Map()));
|
|
1018
|
+
const discarding = useRef(/** @type {Set<number>} */ (new Set()));
|
|
1019
|
+
|
|
1020
|
+
useEffect(
|
|
1021
|
+
() => () => {
|
|
1022
|
+
batches.current.forEach((b) => b.abort());
|
|
1023
|
+
controllers.current.forEach((c) => c.abort());
|
|
1024
|
+
},
|
|
1025
|
+
[],
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
const patch = useCallback((/** @type {number} */ key, /** @type {Partial<UploadEntry>} */ change) => {
|
|
1029
|
+
setEntries((all) => all.map((e) => (e.key === key ? { ...e, ...change } : e)));
|
|
1030
|
+
}, []);
|
|
1031
|
+
|
|
1032
|
+
const upload = useCallback(
|
|
1033
|
+
async (/** @type {Iterable<Blob>} */ chosen) => {
|
|
1034
|
+
const files = Array.from(chosen);
|
|
1035
|
+
const room =
|
|
1036
|
+
typeof latest.current.maxFiles === 'number'
|
|
1037
|
+
? Math.max(0, latest.current.maxFiles - count.current)
|
|
1038
|
+
: files.length;
|
|
1039
|
+
const taken = files.slice(0, room);
|
|
1040
|
+
const added = taken.map(() => ({
|
|
1041
|
+
key: nextKey.current++,
|
|
1042
|
+
status: /** @type {UploadStatus} */ ('uploading'),
|
|
1043
|
+
progress: null,
|
|
1044
|
+
file: null,
|
|
1045
|
+
error: null,
|
|
1046
|
+
}));
|
|
1047
|
+
count.current += added.length;
|
|
1048
|
+
for (const { key } of added) wanted.current.add(key);
|
|
1049
|
+
setEntries((all) => [...all, ...added]);
|
|
1050
|
+
const batch = new AbortController();
|
|
1051
|
+
batches.current.add(batch);
|
|
1052
|
+
|
|
1053
|
+
/** @type {UploadOutcome[]} */
|
|
1054
|
+
const outcomes = [];
|
|
1055
|
+
for (let i = 0; i < taken.length; i++) {
|
|
1056
|
+
const { key } = added[i];
|
|
1057
|
+
// Reset, or the field gone: the rest are never sent. Removed: this one is not.
|
|
1058
|
+
if (batch.signal.aborted || !wanted.current.has(key)) {
|
|
1059
|
+
outcomes.push({ type: 'cancelled' });
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
const controller = new AbortController();
|
|
1063
|
+
controllers.current.set(key, controller);
|
|
1064
|
+
const outcome = await uploadFile({
|
|
1065
|
+
ticket: renewed.current.ticket,
|
|
1066
|
+
file: taken[i],
|
|
1067
|
+
url: latest.current.url,
|
|
1068
|
+
maxBytes: latest.current.maxBytes,
|
|
1069
|
+
direct: latest.current.direct,
|
|
1070
|
+
resumable: latest.current.resumable,
|
|
1071
|
+
resumeStore: latest.current.resumeStore,
|
|
1072
|
+
...(latest.current.backoff === undefined ? {} : { backoff: latest.current.backoff }),
|
|
1073
|
+
signal: controller.signal,
|
|
1074
|
+
onProgress: (p) => patch(key, { progress: p }),
|
|
1075
|
+
onState: (state) => patch(key, { status: state }),
|
|
1076
|
+
onSession: (held) => {
|
|
1077
|
+
sessions.current.set(key, held);
|
|
1078
|
+
},
|
|
1079
|
+
refresh: true,
|
|
1080
|
+
onTicket: (next) => {
|
|
1081
|
+
renewed.current.ticket = next;
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
controllers.current.delete(key);
|
|
1085
|
+
outcomes.push(outcome);
|
|
1086
|
+
|
|
1087
|
+
// Removed or reset while it went -- not the field going away, which
|
|
1088
|
+
// keeps it for the next page -- so the server need hold nothing more.
|
|
1089
|
+
const held = sessions.current.get(key);
|
|
1090
|
+
if (outcome.type === 'cancelled' && held !== undefined && discarding.current.has(key)) {
|
|
1091
|
+
void discardRecord(held, latest.current.resumeStore);
|
|
1092
|
+
}
|
|
1093
|
+
discarding.current.delete(key);
|
|
1094
|
+
if (outcome.type !== 'error' || !wanted.current.has(key)) sessions.current.delete(key);
|
|
1095
|
+
|
|
1096
|
+
// Removed or reset while it went: its entry is gone, and nothing is told of it.
|
|
1097
|
+
if (!wanted.current.has(key)) continue;
|
|
1098
|
+
if (outcome.type !== 'uploaded' && outcome.type !== 'error') wanted.current.delete(key);
|
|
1099
|
+
|
|
1100
|
+
if (outcome.type === 'uploaded') {
|
|
1101
|
+
patch(key, { status: 'uploaded', progress: null, file: outcome.file });
|
|
1102
|
+
latest.current.onUploaded?.(outcome.file);
|
|
1103
|
+
} else if (outcome.type === 'error') {
|
|
1104
|
+
patch(key, { status: 'error', progress: null, error: outcome.error });
|
|
1105
|
+
latest.current.onError?.(outcome.error);
|
|
1106
|
+
if (outcome.error.kind === 'signed_out') {
|
|
1107
|
+
endSession(router, session?.hardNavigate ?? assignLocation);
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
} else {
|
|
1111
|
+
setEntries((all) => all.filter((e) => e.key !== key));
|
|
1112
|
+
count.current -= 1;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
batches.current.delete(batch);
|
|
1117
|
+
return outcomes;
|
|
1118
|
+
},
|
|
1119
|
+
[router, session, renewed, patch],
|
|
1120
|
+
);
|
|
1121
|
+
|
|
1122
|
+
// A file given up for good: cancelled if it is still going, its session
|
|
1123
|
+
// given up once it has stopped; or, ended keeping one, that one given up now.
|
|
1124
|
+
const giveUpFile = useCallback((/** @type {number} */ key) => {
|
|
1125
|
+
wanted.current.delete(key);
|
|
1126
|
+
const controller = controllers.current.get(key);
|
|
1127
|
+
if (controller !== undefined) {
|
|
1128
|
+
discarding.current.add(key);
|
|
1129
|
+
controller.abort();
|
|
1130
|
+
controllers.current.delete(key);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const held = sessions.current.get(key);
|
|
1134
|
+
sessions.current.delete(key);
|
|
1135
|
+
if (held !== undefined) void discardRecord(held, latest.current.resumeStore);
|
|
1136
|
+
}, []);
|
|
1137
|
+
|
|
1138
|
+
const remove = useCallback(
|
|
1139
|
+
(/** @type {number} */ key) => {
|
|
1140
|
+
giveUpFile(key);
|
|
1141
|
+
setEntries((all) => all.filter((e) => e.key !== key));
|
|
1142
|
+
},
|
|
1143
|
+
[giveUpFile],
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
const reset = useCallback(() => {
|
|
1147
|
+
batches.current.forEach((b) => b.abort());
|
|
1148
|
+
batches.current.clear();
|
|
1149
|
+
for (const key of new Set([
|
|
1150
|
+
...wanted.current,
|
|
1151
|
+
...controllers.current.keys(),
|
|
1152
|
+
...sessions.current.keys(),
|
|
1153
|
+
])) {
|
|
1154
|
+
giveUpFile(key);
|
|
1155
|
+
}
|
|
1156
|
+
wanted.current.clear();
|
|
1157
|
+
setEntries([]);
|
|
1158
|
+
}, [giveUpFile]);
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
entries,
|
|
1162
|
+
uploading: entries.some(
|
|
1163
|
+
(e) => e.status === 'uploading' || e.status === 'paused' || e.status === 'resuming',
|
|
1164
|
+
),
|
|
1165
|
+
/** What the form sends under the parameter's name: a reference for each file that went in. */
|
|
1166
|
+
references: entries.flatMap((e) => (e.file === null ? [] : [e.file.upload])),
|
|
1167
|
+
upload,
|
|
1168
|
+
remove,
|
|
1169
|
+
reset,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* A form field for a parameter that takes several files: it uploads each
|
|
1175
|
+
* file chosen in it and posts every reference under `name[]`, drawing
|
|
1176
|
+
* nothing but the hidden inputs.
|
|
1177
|
+
*
|
|
1178
|
+
* @param {UseUploadOptions & {
|
|
1179
|
+
* name: string,
|
|
1180
|
+
* accept?: string,
|
|
1181
|
+
* maxFiles?: number,
|
|
1182
|
+
* children?: (field: any) => unknown,
|
|
1183
|
+
* }} props
|
|
1184
|
+
*/
|
|
1185
|
+
export function UploadsField({
|
|
1186
|
+
name,
|
|
1187
|
+
ticket,
|
|
1188
|
+
url,
|
|
1189
|
+
maxBytes,
|
|
1190
|
+
maxFiles,
|
|
1191
|
+
accept,
|
|
1192
|
+
onUploaded,
|
|
1193
|
+
onError,
|
|
1194
|
+
direct,
|
|
1195
|
+
resumable,
|
|
1196
|
+
resumeStore,
|
|
1197
|
+
backoff,
|
|
1198
|
+
children,
|
|
1199
|
+
}) {
|
|
1200
|
+
const state = useUploads({
|
|
1201
|
+
ticket,
|
|
1202
|
+
url,
|
|
1203
|
+
maxBytes,
|
|
1204
|
+
maxFiles,
|
|
1205
|
+
onUploaded,
|
|
1206
|
+
onError,
|
|
1207
|
+
direct,
|
|
1208
|
+
resumable,
|
|
1209
|
+
resumeStore,
|
|
1210
|
+
backoff,
|
|
1211
|
+
});
|
|
1212
|
+
const inputRef = useRef(/** @type {HTMLInputElement | null} */ (null));
|
|
1213
|
+
const { upload } = state;
|
|
1214
|
+
|
|
1215
|
+
const onChange = useCallback(
|
|
1216
|
+
(/** @type {{ currentTarget: HTMLInputElement }} */ event) => {
|
|
1217
|
+
const chosen = event.currentTarget.files;
|
|
1218
|
+
if (chosen && chosen.length > 0) void upload(Array.from(chosen));
|
|
1219
|
+
event.currentTarget.value = '';
|
|
1220
|
+
},
|
|
1221
|
+
[upload],
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
const choose = useCallback(() => inputRef.current?.click(), []);
|
|
1225
|
+
|
|
1226
|
+
const field = {
|
|
1227
|
+
...state,
|
|
1228
|
+
choose,
|
|
1229
|
+
input: { ref: inputRef, type: 'file', multiple: true, accept, onChange },
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
return createElement(
|
|
1233
|
+
Fragment,
|
|
1234
|
+
null,
|
|
1235
|
+
...state.references.map((reference) =>
|
|
1236
|
+
createElement('input', { key: reference, type: 'hidden', name: `${name}[]`, value: reference }),
|
|
1237
|
+
),
|
|
1238
|
+
typeof children === 'function' ? children(field) : null,
|
|
1239
|
+
);
|
|
1240
|
+
}
|