@gentleduck/upload 0.2.5 → 0.2.6
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/README.md +320 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
- package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +8 -47
- package/dist/react/index.js +1 -1
- package/dist/strategies/index.d.ts +5 -5
- package/package.json +1 -1
- package/src/__test__/effect-pool.test.ts +5 -0
- package/src/__test__/emitter.test.ts +85 -0
- package/src/__test__/fingerprint.test.ts +49 -0
- package/src/__test__/guards.test.ts +30 -0
- package/src/__test__/id.test.ts +14 -0
- package/src/__test__/persistence.test.ts +156 -0
- package/src/__test__/store-integration.test.ts +5 -0
- package/src/__test__/store-libs.test.ts +138 -0
- package/src/__test__/transport-libs.test.ts +42 -0
- package/src/__test__/transport.test.ts +182 -0
- package/src/core/client/client.ts +50 -0
- package/src/core/client/client.types.ts +184 -0
- package/src/core/contracts/contracts.types.ts +179 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -32
- package/src/core/contracts/transport/transport.ts +5 -7
- package/src/core/engine/engine.types.ts +364 -0
- package/src/core/engine/internal-events.types.ts +3 -23
- package/src/core/engine/reducer.ts +5 -11
- package/src/core/engine/store/handlers/add-file.ts +6 -21
- package/src/core/engine/store/handlers/run-upload.ts +7 -7
- package/src/core/engine/store/handlers.ts +819 -0
- package/src/core/engine/store/store.libs.ts +17 -57
- package/src/core/engine/store/store.runtime.ts +10 -20
- package/src/core/engine/validation/file.ts +1 -1
- package/src/core/engine/validation.ts +216 -0
- package/src/core/utils/emitter/emitter.ts +143 -0
- package/src/react/upload-provider.tsx +7 -58
- package/src/react/uploader.types.ts +83 -0
- package/src/react/use-uploader.tsx +2 -5
- package/src/strategies/multipart/index.ts +9 -12
- package/src/strategies/post/index.ts +6 -46
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { createXHRTransport } from '../core/contracts/transport'
|
|
3
|
+
import { UploadAbortError } from '../core/contracts/transport/transport.libs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal `XMLHttpRequest` mock. Captures method/url/headers/body and exposes
|
|
7
|
+
* hooks to simulate `onload` / `onerror` / `onabort` and per-progress events.
|
|
8
|
+
*/
|
|
9
|
+
class MockXHR {
|
|
10
|
+
static instances: MockXHR[] = []
|
|
11
|
+
|
|
12
|
+
method = ''
|
|
13
|
+
url = ''
|
|
14
|
+
body: unknown = null
|
|
15
|
+
headers: Record<string, string> = {}
|
|
16
|
+
status = 0
|
|
17
|
+
upload = { onprogress: null as ((e: ProgressEvent) => void) | null }
|
|
18
|
+
onload: ((e: Event) => void) | null = null
|
|
19
|
+
onerror: ((e: Event) => void) | null = null
|
|
20
|
+
onabort: ((e: Event) => void) | null = null
|
|
21
|
+
responseHeadersRaw = ''
|
|
22
|
+
responseHeaders: Record<string, string> = {}
|
|
23
|
+
|
|
24
|
+
constructor() {
|
|
25
|
+
MockXHR.instances.push(this)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
open(method: string, url: string) {
|
|
29
|
+
this.method = method
|
|
30
|
+
this.url = url
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setRequestHeader(name: string, value: string) {
|
|
34
|
+
this.headers[name.toLowerCase()] = value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
send(body: unknown) {
|
|
38
|
+
this.body = body
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
abort() {
|
|
42
|
+
this.onabort?.(new Event('abort'))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getResponseHeader(name: string): string | null {
|
|
46
|
+
return this.responseHeaders[name.toLowerCase()] ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getAllResponseHeaders(): string {
|
|
50
|
+
return this.responseHeadersRaw
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Test helpers
|
|
54
|
+
fireSuccess(status: number, headers: Record<string, string> = {}) {
|
|
55
|
+
this.status = status
|
|
56
|
+
this.responseHeaders = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
|
|
57
|
+
this.responseHeadersRaw = Object.entries(headers)
|
|
58
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
59
|
+
.join('\r\n')
|
|
60
|
+
this.onload?.(new Event('load'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fireError(status = 0) {
|
|
64
|
+
this.status = status
|
|
65
|
+
this.onerror?.(new Event('error'))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const originalXHR = (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
MockXHR.instances = []
|
|
73
|
+
;(globalThis as unknown as { XMLHttpRequest: typeof MockXHR }).XMLHttpRequest = MockXHR
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
if (originalXHR) (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = originalXHR
|
|
78
|
+
else delete (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('createXHRTransport.put', () => {
|
|
82
|
+
test('resolves on 2xx and parses ETag', async () => {
|
|
83
|
+
const t = createXHRTransport()
|
|
84
|
+
const ctl = new AbortController()
|
|
85
|
+
const promise = t.put({
|
|
86
|
+
url: 'https://example.com/part',
|
|
87
|
+
body: new Blob(['x']),
|
|
88
|
+
signal: ctl.signal,
|
|
89
|
+
headers: { 'x-foo': 'bar' },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const xhr = MockXHR.instances[0]
|
|
93
|
+
expect(xhr.method).toBe('PUT')
|
|
94
|
+
expect(xhr.url).toBe('https://example.com/part')
|
|
95
|
+
expect(xhr.headers['x-foo']).toBe('bar')
|
|
96
|
+
|
|
97
|
+
xhr.fireSuccess(200, { ETag: '"abc"' })
|
|
98
|
+
const result = await promise
|
|
99
|
+
expect(result.etag).toBe('abc')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('rejects with a status-tagged Error on non-2xx status', async () => {
|
|
103
|
+
const t = createXHRTransport()
|
|
104
|
+
const ctl = new AbortController()
|
|
105
|
+
const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
|
|
106
|
+
|
|
107
|
+
const xhr = MockXHR.instances[0]
|
|
108
|
+
xhr.fireSuccess(503)
|
|
109
|
+
await expect(promise).rejects.toThrow(/status 503/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('rejects with a CORS-tagged Error when status=0', async () => {
|
|
113
|
+
const t = createXHRTransport()
|
|
114
|
+
const ctl = new AbortController()
|
|
115
|
+
const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
|
|
116
|
+
|
|
117
|
+
const xhr = MockXHR.instances[0]
|
|
118
|
+
xhr.fireError(0)
|
|
119
|
+
await expect(promise).rejects.toThrow(/CORS/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('rejects with UploadAbortError when the signal aborts', async () => {
|
|
123
|
+
const t = createXHRTransport()
|
|
124
|
+
const ctl = new AbortController()
|
|
125
|
+
const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
|
|
126
|
+
|
|
127
|
+
ctl.abort('cancel')
|
|
128
|
+
await expect(promise).rejects.toBeInstanceOf(UploadAbortError)
|
|
129
|
+
await expect(promise).rejects.toMatchObject({ code: 'aborted', reason: 'cancel' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('rejects non-absolute URLs', async () => {
|
|
133
|
+
const t = createXHRTransport()
|
|
134
|
+
const ctl = new AbortController()
|
|
135
|
+
await expect(t.put({ url: '/relative/path', body: new Blob(['x']), signal: ctl.signal })).rejects.toThrow(
|
|
136
|
+
/absolute URL/,
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('forwards upload progress events', async () => {
|
|
141
|
+
const t = createXHRTransport()
|
|
142
|
+
const ctl = new AbortController()
|
|
143
|
+
const onProgress = vi.fn()
|
|
144
|
+
const promise = t.put({
|
|
145
|
+
url: 'https://example.com/part',
|
|
146
|
+
body: new Blob(['x']),
|
|
147
|
+
signal: ctl.signal,
|
|
148
|
+
onProgress,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const xhr = MockXHR.instances[0]
|
|
152
|
+
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 5, total: 10 } as ProgressEvent)
|
|
153
|
+
expect(onProgress).toHaveBeenCalledWith(5, 10)
|
|
154
|
+
|
|
155
|
+
xhr.fireSuccess(200)
|
|
156
|
+
await promise
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('createXHRTransport.postForm', () => {
|
|
161
|
+
test('builds FormData with fields + file and POSTs', async () => {
|
|
162
|
+
const t = createXHRTransport()
|
|
163
|
+
const ctl = new AbortController()
|
|
164
|
+
const file = new File(['payload'], 'a.txt')
|
|
165
|
+
const promise = t.postForm({
|
|
166
|
+
url: 'https://example.com/upload',
|
|
167
|
+
fields: { key: 'avatar/a.txt', acl: 'private' },
|
|
168
|
+
file,
|
|
169
|
+
signal: ctl.signal,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const xhr = MockXHR.instances[0]
|
|
173
|
+
expect(xhr.method).toBe('POST')
|
|
174
|
+
expect(xhr.body).toBeInstanceOf(FormData)
|
|
175
|
+
const form = xhr.body as FormData
|
|
176
|
+
expect(form.get('key')).toBe('avatar/a.txt')
|
|
177
|
+
expect(form.get('acl')).toBe('private')
|
|
178
|
+
|
|
179
|
+
xhr.fireSuccess(204)
|
|
180
|
+
await promise
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Defaults } from '../utils/constants'
|
|
2
|
+
import type { Client } from './client.types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply defaults to a partial {@link Client.IUploadConfig}.
|
|
6
|
+
*
|
|
7
|
+
* @param input Partial config from the caller. Nested groups
|
|
8
|
+
* (e.g. `effectQueueCompaction`) accept their own partial shape.
|
|
9
|
+
* @returns Fully populated config with each numeric field clamped to a sane
|
|
10
|
+
* minimum.
|
|
11
|
+
* @template P Purpose string union.
|
|
12
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
13
|
+
*/
|
|
14
|
+
export function resolveUploadConfig<P extends string>(input?: Client.UploadConfigInput<P>): Client.IUploadConfig<P> {
|
|
15
|
+
return {
|
|
16
|
+
maxConcurrentUploads: Math.max(1, input?.maxConcurrentUploads ?? Defaults.MAX_CONCURRENT_UPLOADS),
|
|
17
|
+
autoStart: input?.autoStart,
|
|
18
|
+
progressThrottleMs: Math.max(0, input?.progressThrottleMs ?? Defaults.PROGRESS_THROTTLE_MS),
|
|
19
|
+
validation: input?.validation ?? {},
|
|
20
|
+
maxAttempts: Math.max(1, input?.maxAttempts ?? Defaults.MAX_ATTEMPTS),
|
|
21
|
+
retryPolicy: input?.retryPolicy,
|
|
22
|
+
maxItems: input?.maxItems === null ? null : Math.max(1, input?.maxItems ?? Defaults.MAX_ITEMS),
|
|
23
|
+
completedItemTTL: input?.completedItemTTL,
|
|
24
|
+
effectConcurrency: Math.max(1, input?.effectConcurrency ?? Defaults.EFFECT_CONCURRENCY),
|
|
25
|
+
effectTimeoutMs: Math.max(0, input?.effectTimeoutMs ?? Defaults.EFFECT_TIMEOUT_MS),
|
|
26
|
+
errorContextInMessage: input?.errorContextInMessage ?? false,
|
|
27
|
+
keepRawCause: input?.keepRawCause ?? false,
|
|
28
|
+
effectQueueCompaction: {
|
|
29
|
+
minHead: Math.max(1, input?.effectQueueCompaction?.minHead ?? 64),
|
|
30
|
+
ratioDenom: Math.max(2, input?.effectQueueCompaction?.ratioDenom ?? 2),
|
|
31
|
+
maxQueueLength:
|
|
32
|
+
input?.effectQueueCompaction?.maxQueueLength === null
|
|
33
|
+
? null
|
|
34
|
+
: Math.max(1, input?.effectQueueCompaction?.maxQueueLength ?? 10_000),
|
|
35
|
+
},
|
|
36
|
+
checksumMaxSize:
|
|
37
|
+
input?.checksumMaxSize === undefined
|
|
38
|
+
? null
|
|
39
|
+
: input.checksumMaxSize === null
|
|
40
|
+
? null
|
|
41
|
+
: Math.max(0, input.checksumMaxSize),
|
|
42
|
+
checksumChunkBytes:
|
|
43
|
+
input?.checksumChunkBytes === undefined
|
|
44
|
+
? null
|
|
45
|
+
: input.checksumChunkBytes === null
|
|
46
|
+
? null
|
|
47
|
+
: Math.max(1, input.checksumChunkBytes),
|
|
48
|
+
strictRebindType: input?.strictRebindType ?? false,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Contracts } from '../contracts'
|
|
2
|
+
import type { Engine } from '../engine/engine.types'
|
|
3
|
+
import type { Reducer } from '../engine/reducer'
|
|
4
|
+
import type { Store } from '../engine/store'
|
|
5
|
+
import type { PersistenceError } from '../persistence/persistence.types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Client-facing configuration, hooks, plugins, and retry policy types.
|
|
9
|
+
*
|
|
10
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
11
|
+
*/
|
|
12
|
+
export namespace Client {
|
|
13
|
+
// -------------------- Retry --------------------
|
|
14
|
+
|
|
15
|
+
/** Output of the retry policy. `delayMs` is a backoff before re-attempt. */
|
|
16
|
+
export type RetryDecision = { retryable: false } | { retryable: true; delayMs: number }
|
|
17
|
+
|
|
18
|
+
// -------------------- Config --------------------
|
|
19
|
+
|
|
20
|
+
/** Effect-queue compaction tuning knobs. */
|
|
21
|
+
export interface IEffectQueueCompaction {
|
|
22
|
+
/** Absolute head-count floor before compaction. Default 64. */
|
|
23
|
+
minHead: number
|
|
24
|
+
/** Triggers compaction when `head * denom > length`. Default 2. */
|
|
25
|
+
ratioDenom: number
|
|
26
|
+
/**
|
|
27
|
+
* Hard upper bound on queue length. New effects past this cap are
|
|
28
|
+
* dropped + logged. Defends against a pathological dispatch storm that
|
|
29
|
+
* grows the queue faster than compaction drains. `null` disables.
|
|
30
|
+
* Default `10_000`.
|
|
31
|
+
*/
|
|
32
|
+
maxQueueLength: number | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Global upload engine configuration. */
|
|
36
|
+
export interface IUploadConfig<P extends string> {
|
|
37
|
+
/** Maximum concurrent in-flight uploads. Default `3`. */
|
|
38
|
+
maxConcurrentUploads: number
|
|
39
|
+
/** Auto-start `ready` items. */
|
|
40
|
+
autoStart?: readonly P[] | ((purpose: P) => boolean)
|
|
41
|
+
/** Throttle for `upload.progress` events in ms. Default `100`. */
|
|
42
|
+
progressThrottleMs: number
|
|
43
|
+
/** Per-purpose validation rules. */
|
|
44
|
+
validation: Partial<Record<P, Contracts.IValidationRules>>
|
|
45
|
+
/** Maximum attempts per phase before a failure is final. Default `3`. */
|
|
46
|
+
maxAttempts: number
|
|
47
|
+
/** Override the default retry policy. */
|
|
48
|
+
retryPolicy?: (ctx: {
|
|
49
|
+
phase: 'intent' | 'upload' | 'complete'
|
|
50
|
+
attempt: number
|
|
51
|
+
error: Contracts.Error
|
|
52
|
+
}) => RetryDecision
|
|
53
|
+
/** Cap on items kept in state. Set to `null` for no cap. Default `100`. */
|
|
54
|
+
maxItems: number | null
|
|
55
|
+
/** Auto-remove `completed`/`canceled` items after this many ms. */
|
|
56
|
+
completedItemTTL?: number
|
|
57
|
+
/** Concurrency cap for side-effects. Default `8`. */
|
|
58
|
+
effectConcurrency: number
|
|
59
|
+
/** Per-effect watchdog timeout in ms. `0` disables. Default `60000`. */
|
|
60
|
+
effectTimeoutMs: number
|
|
61
|
+
/** Include file context in persisted error messages. Default `false` (PII). */
|
|
62
|
+
errorContextInMessage: boolean
|
|
63
|
+
/** Keep raw thrown value as `error.cause`. Default `false` (PII). */
|
|
64
|
+
keepRawCause: boolean
|
|
65
|
+
/** Effect-queue compaction tuning. */
|
|
66
|
+
effectQueueCompaction: IEffectQueueCompaction
|
|
67
|
+
/**
|
|
68
|
+
* Skip `crypto.subtle.digest` (and the dedupe lookup that depends on it)
|
|
69
|
+
* for files larger than this byte threshold. The browser has no streaming
|
|
70
|
+
* SHA-256, so digesting a 10GB file allocates 10GB of heap; the cap lets
|
|
71
|
+
* the consumer trade dedupe coverage for memory. Default `null` (no cap).
|
|
72
|
+
*/
|
|
73
|
+
checksumMaxSize: number | null
|
|
74
|
+
/**
|
|
75
|
+
* Hash only the first N bytes of each file. Yields a fast approximate
|
|
76
|
+
* fingerprint at the cost of strict uniqueness: files that share the
|
|
77
|
+
* leading window collide. Combine with `size + lastModified + chunk
|
|
78
|
+
* checksum` on the backend to form a unique dedupe key. Default
|
|
79
|
+
* `null` (full-file hash, exact match).
|
|
80
|
+
*
|
|
81
|
+
* @remarks Set to a small value (e.g. `1024 * 64`) for fast
|
|
82
|
+
* checksums on very large files where exact dedupe is not
|
|
83
|
+
* required.
|
|
84
|
+
*/
|
|
85
|
+
checksumChunkBytes: number | null
|
|
86
|
+
/**
|
|
87
|
+
* When `true`, `rebind` requires the rebound `File.type` to match the
|
|
88
|
+
* paused item's fingerprint type. Default `false` because browsers
|
|
89
|
+
* disagree on MIME detection (Safari often omits MIME types Chrome
|
|
90
|
+
* attaches). Enable when both sessions are guaranteed to run in the
|
|
91
|
+
* same browser/runtime.
|
|
92
|
+
*/
|
|
93
|
+
strictRebindType: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Partial form accepted by `createUploadStore`. Nested config groups
|
|
98
|
+
* (e.g. `effectQueueCompaction`) accept their own partial shape so
|
|
99
|
+
* callers can override one knob without restating the rest.
|
|
100
|
+
*/
|
|
101
|
+
export type UploadConfigInput<P extends string> = Partial<Omit<IUploadConfig<P>, 'effectQueueCompaction'>> & {
|
|
102
|
+
effectQueueCompaction?: Partial<IEffectQueueCompaction>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -------------------- Hooks --------------------
|
|
106
|
+
|
|
107
|
+
/** Optional observability hooks. Diagnostics / analytics / devtools / Sentry. */
|
|
108
|
+
export interface IUploadHooks<
|
|
109
|
+
M extends Contracts.IntentMap,
|
|
110
|
+
C extends Contracts.CursorMap<M>,
|
|
111
|
+
P extends string,
|
|
112
|
+
R extends Contracts.IResultBase = Contracts.IResultBase,
|
|
113
|
+
> {
|
|
114
|
+
/** Fires after every internal event applied by the reducer. */
|
|
115
|
+
onInternalEvent?: (event: Engine.InternalEvent<M, C, P, R>, state: Reducer.IState<M, C, P, R>) => void
|
|
116
|
+
/** Fires when a persistence adapter operation fails. */
|
|
117
|
+
onPersistenceError?: (error: PersistenceError) => void
|
|
118
|
+
/** Fires on listener / plugin / emitter / batch throws. */
|
|
119
|
+
onListenerError?: (
|
|
120
|
+
error: unknown,
|
|
121
|
+
context: { kind: 'subscriber' | 'plugin-setup' | 'emitter' | 'batch'; name?: string },
|
|
122
|
+
) => void
|
|
123
|
+
/**
|
|
124
|
+
* Fires when the effect queue rejects a new effect because it is at
|
|
125
|
+
* `effectQueueCompaction.maxQueueLength`. Production telemetry hook so
|
|
126
|
+
* dropped effects are not silently swallowed. Passes the current queue
|
|
127
|
+
* length (`queueLength`), active in-flight effect count (`active`), the
|
|
128
|
+
* hard cap (`cap`), and the optional `localId` of the upload that
|
|
129
|
+
* triggered the enqueue (when the caller threaded it through).
|
|
130
|
+
*/
|
|
131
|
+
onEffectDropped?: (context: { queueLength: number; active: number; cap: number; localId?: string }) => void
|
|
132
|
+
/**
|
|
133
|
+
* Fires when `scheduleWork`'s re-entrant pass loop hits the soft cap
|
|
134
|
+
* (default 100). Production telemetry hook so a runaway feedback
|
|
135
|
+
* loop is visible even when dev-mode `console.error` is gated out.
|
|
136
|
+
*/
|
|
137
|
+
onScheduleStarved?: (context: { passes: number }) => void
|
|
138
|
+
/**
|
|
139
|
+
* Fires when `flushPersistence`'s trailing-flush loop hits the soft
|
|
140
|
+
* cap. A pathological feedback loop (e.g. `onPersistenceError`
|
|
141
|
+
* dispatching state changes that schedule another flush) would
|
|
142
|
+
* otherwise spin forever. Defaults to 100 trailing iterations.
|
|
143
|
+
*/
|
|
144
|
+
onPersistenceLoopStarved?: (context: { passes: number }) => void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -------------------- Plugin --------------------
|
|
148
|
+
|
|
149
|
+
/** Store plugin. Subscribed once at construction with a narrow store view. */
|
|
150
|
+
export interface IUploadPlugin<
|
|
151
|
+
M extends Contracts.IntentMap,
|
|
152
|
+
C extends Contracts.CursorMap<M>,
|
|
153
|
+
P extends string,
|
|
154
|
+
R extends Contracts.IResultBase = Contracts.IResultBase,
|
|
155
|
+
> {
|
|
156
|
+
/** Plugin name (used in debugging output + `onListenerError` context). */
|
|
157
|
+
name: string
|
|
158
|
+
/**
|
|
159
|
+
* Called once at construction. May return a `Promise` to delay
|
|
160
|
+
* `store.ready` until the plugin finishes initializing (e.g. fetching
|
|
161
|
+
* remote feature flags). Sync throws and async rejections are both
|
|
162
|
+
* routed through `onListenerError`.
|
|
163
|
+
*
|
|
164
|
+
* Wider surface than R23: plugins now also see `flush` / `cleanup` /
|
|
165
|
+
* `purge` / `has` / `waitFor` / `subscribe` so a plugin can await
|
|
166
|
+
* durability, do bulk operations, or subscribe via the public
|
|
167
|
+
* mechanism instead of holding raw `rt` references.
|
|
168
|
+
*/
|
|
169
|
+
setup: (
|
|
170
|
+
ctx: Pick<
|
|
171
|
+
Store.IUploadStore<M, C, P, R>,
|
|
172
|
+
'on' | 'off' | 'dispatch' | 'getSnapshot' | 'subscribe' | 'waitFor' | 'flush' | 'cleanup' | 'purge' | 'has'
|
|
173
|
+
>,
|
|
174
|
+
) => void | Promise<void>
|
|
175
|
+
/**
|
|
176
|
+
* Called once during `store.destroy()` so the plugin can release its
|
|
177
|
+
* own resources (timers, external subscriptions, network handles).
|
|
178
|
+
* May return a `Promise` -- `destroy()` awaits all plugin disposes
|
|
179
|
+
* before resolving. Sync throws and async rejections route through
|
|
180
|
+
* `onListenerError` and never reject `destroy()`.
|
|
181
|
+
*/
|
|
182
|
+
dispose?: () => void | Promise<void>
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public contract types grouped under the `Contracts` namespace.
|
|
3
|
+
*
|
|
4
|
+
* Each sub-namespace owns one concern (Intent, Cursor, Result, Fingerprint,
|
|
5
|
+
* Errors, Validation, Strategy, BackendApi).
|
|
6
|
+
*
|
|
7
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
8
|
+
*/
|
|
9
|
+
export namespace Contracts {
|
|
10
|
+
// -------------------- Intent --------------------
|
|
11
|
+
|
|
12
|
+
/** Base shape for all upload intents. */
|
|
13
|
+
export interface IIntentBase<K extends string = string> {
|
|
14
|
+
/** Strategy name (discriminant). */
|
|
15
|
+
strategy: K
|
|
16
|
+
/** Unique backend file identifier. */
|
|
17
|
+
fileId: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Registry of intent types mapped by strategy name. */
|
|
21
|
+
export type IntentMap = Record<string, IIntentBase<string>>
|
|
22
|
+
|
|
23
|
+
/** Union of strategy keys for an intent map. */
|
|
24
|
+
export type StrategyKey<M extends IntentMap> = keyof M & string
|
|
25
|
+
|
|
26
|
+
/** Union of all intent variants for an intent map. */
|
|
27
|
+
export type AnyIntent<M extends IntentMap> = M[StrategyKey<M>]
|
|
28
|
+
|
|
29
|
+
// -------------------- Cursor --------------------
|
|
30
|
+
|
|
31
|
+
/** Registry of cursor payload types mapped by strategy name. */
|
|
32
|
+
export type CursorMap<M extends IntentMap> = Partial<Record<StrategyKey<M>, unknown>>
|
|
33
|
+
|
|
34
|
+
/** Discriminated union of all cursor variants. */
|
|
35
|
+
export type AnyCursor<C extends Record<string, unknown>> = {
|
|
36
|
+
[K in keyof C & string]: { strategy: K; value?: C[K] }
|
|
37
|
+
}[keyof C & string]
|
|
38
|
+
|
|
39
|
+
// -------------------- Result --------------------
|
|
40
|
+
|
|
41
|
+
/** Base shape for upload completion results returned by your backend. */
|
|
42
|
+
export interface IResultBase {
|
|
43
|
+
/** Backend file identifier. */
|
|
44
|
+
fileId: string
|
|
45
|
+
/** Storage key or path used for signed URLs. */
|
|
46
|
+
key: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// -------------------- Fingerprint --------------------
|
|
50
|
+
|
|
51
|
+
/** Deterministic client-side identity for a file. */
|
|
52
|
+
export interface IFileFingerprint {
|
|
53
|
+
/** File name. */
|
|
54
|
+
name: string
|
|
55
|
+
/** File size in bytes. */
|
|
56
|
+
size: number
|
|
57
|
+
/** File MIME type. */
|
|
58
|
+
type: string
|
|
59
|
+
/** File lastModified timestamp (ms). */
|
|
60
|
+
lastModified: number
|
|
61
|
+
/** Optional checksum (e.g. SHA-256 hex). */
|
|
62
|
+
checksum?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// -------------------- Errors --------------------
|
|
66
|
+
|
|
67
|
+
/** File rejection reasons produced by client-side validation. */
|
|
68
|
+
export type RejectReason =
|
|
69
|
+
| { code: 'empty_file' }
|
|
70
|
+
| { code: 'file_too_large'; maxBytes: number; size: number }
|
|
71
|
+
| { code: 'type_not_allowed'; allowed: string[]; got: string }
|
|
72
|
+
| { code: 'too_many_files'; max: number }
|
|
73
|
+
|
|
74
|
+
/** Base shape for custom errors. */
|
|
75
|
+
export interface IErrorBase {
|
|
76
|
+
/** Stable error code. */
|
|
77
|
+
code: string
|
|
78
|
+
/** Human-friendly message (safe for UI). */
|
|
79
|
+
message: string
|
|
80
|
+
/** Optional cause (original error, response, etc.). */
|
|
81
|
+
cause?: unknown
|
|
82
|
+
/** Whether the error should be retried. */
|
|
83
|
+
retryable?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Built-in engine errors. */
|
|
87
|
+
export type BuiltInError =
|
|
88
|
+
| { code: 'validation_failed'; message: string; reason: RejectReason; retryable?: false }
|
|
89
|
+
| { code: 'strategy_missing'; message: string; strategy: string; retryable?: false }
|
|
90
|
+
| { code: 'aborted'; message: string; reason: 'pause' | 'cancel' | 'unknown'; cause?: unknown; retryable?: false }
|
|
91
|
+
| { code: 'network'; message: string; cause?: unknown; retryable?: boolean }
|
|
92
|
+
| { code: 'http'; message: string; status: number; statusText?: string; cause?: unknown; retryable?: boolean }
|
|
93
|
+
| { code: 'timeout'; message: string; cause?: unknown; retryable?: boolean }
|
|
94
|
+
| { code: 'auth'; message: string; cause?: unknown; retryable?: false }
|
|
95
|
+
| { code: 'rate_limit'; message: string; retryAfterMs?: number; cause?: unknown; retryable?: boolean }
|
|
96
|
+
| { code: 'server'; message: string; serverCode?: string; cause?: unknown; retryable?: boolean }
|
|
97
|
+
| { code: 'unknown'; message: string; cause?: unknown; retryable?: boolean }
|
|
98
|
+
|
|
99
|
+
/** The error type used everywhere in the engine. */
|
|
100
|
+
export type Error = BuiltInError | (IErrorBase & Record<string, unknown>)
|
|
101
|
+
|
|
102
|
+
// -------------------- Validation --------------------
|
|
103
|
+
|
|
104
|
+
/** Validation rules applied on `addFiles`. */
|
|
105
|
+
export interface IValidationRules {
|
|
106
|
+
/** Maximum number of files allowed. */
|
|
107
|
+
maxFiles?: number
|
|
108
|
+
/** Maximum file size in bytes. */
|
|
109
|
+
maxSizeBytes?: number
|
|
110
|
+
/** Minimum file size in bytes. */
|
|
111
|
+
minSizeBytes?: number
|
|
112
|
+
/** Allowed MIME types. */
|
|
113
|
+
allowedTypes?: string[]
|
|
114
|
+
/** Allowed file extensions. */
|
|
115
|
+
allowedExtensions?: string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -------------------- Backend API --------------------
|
|
119
|
+
|
|
120
|
+
/** Common options object for backend calls. */
|
|
121
|
+
export type WithSignal = { signal?: AbortSignal }
|
|
122
|
+
|
|
123
|
+
/** Backend adapter the engine calls into. */
|
|
124
|
+
export interface IUploadApi<M extends IntentMap, P extends string, R extends IResultBase = IResultBase> {
|
|
125
|
+
/**
|
|
126
|
+
* Create an upload intent. The backend decides the strategy and any
|
|
127
|
+
* presigned URLs / form fields.
|
|
128
|
+
*/
|
|
129
|
+
createIntent(
|
|
130
|
+
args: { purpose: P; contentType: string; size: number; filename: string; checksum?: string },
|
|
131
|
+
opts?: WithSignal,
|
|
132
|
+
): Promise<M[keyof M]>
|
|
133
|
+
|
|
134
|
+
/** Finalize an upload after all bytes are on the server. */
|
|
135
|
+
complete(args: { fileId: string }, opts?: WithSignal): Promise<R>
|
|
136
|
+
|
|
137
|
+
/** Optional helper to mint a signed preview URL for a finished upload. */
|
|
138
|
+
getSignedPreviewUrl?(args: { fileId: string; key: string; purpose: P }, opts?: WithSignal): Promise<string>
|
|
139
|
+
|
|
140
|
+
/** Optional checksum-based dedupe lookup. */
|
|
141
|
+
findByChecksum?(args: { checksum: string; purpose: P }, opts?: WithSignal): Promise<R | null>
|
|
142
|
+
|
|
143
|
+
/** Multipart-strategy operations. Required when using the multipart strategy. */
|
|
144
|
+
multipart?: {
|
|
145
|
+
/** Sign one part for upload. */
|
|
146
|
+
signPart(
|
|
147
|
+
args: { fileId: string; uploadId: string; partNumber: number; checksum?: string },
|
|
148
|
+
opts?: WithSignal,
|
|
149
|
+
): Promise<{ url: string; headers?: Record<string, string> }>
|
|
150
|
+
|
|
151
|
+
/** Tell the backend to assemble the parts into the final object. */
|
|
152
|
+
completeMultipart(
|
|
153
|
+
args: { fileId: string; uploadId: string; parts: Array<{ partNumber: number; etag: string }> },
|
|
154
|
+
opts?: WithSignal,
|
|
155
|
+
): Promise<unknown>
|
|
156
|
+
|
|
157
|
+
/** Optional: list already-uploaded parts (used by resume diagnostics). */
|
|
158
|
+
listParts?(
|
|
159
|
+
args: { fileId: string; uploadId: string },
|
|
160
|
+
opts?: WithSignal,
|
|
161
|
+
): Promise<Array<{ partNumber: number; etag?: string; size?: number }>>
|
|
162
|
+
|
|
163
|
+
/** Optional: abort an in-progress multipart session on the backend. */
|
|
164
|
+
abort?(args: { fileId: string; uploadId: string }, opts?: WithSignal): Promise<void>
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** TUS-strategy operations. */
|
|
168
|
+
tus?: {
|
|
169
|
+
/** Create a TUS upload session, returning its URL. */
|
|
170
|
+
create(
|
|
171
|
+
args: { fileId: string; size: number; filename: string; contentType: string },
|
|
172
|
+
opts?: WithSignal,
|
|
173
|
+
): Promise<{ uploadUrl: string }>
|
|
174
|
+
|
|
175
|
+
/** Read the current TUS upload offset (used for resume). */
|
|
176
|
+
getOffset(args: { uploadUrl: string }, opts?: WithSignal): Promise<{ offset: number }>
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
import { isRecord } from '../../utils/guards'
|
|
2
2
|
|
|
3
|
-
// ============================================================================
|
|
4
|
-
// ABORT HANDLING UTILITIES
|
|
5
|
-
// ============================================================================
|
|
6
|
-
|
|
7
3
|
export function abortReason(signal?: AbortSignal): unknown {
|
|
8
4
|
return signal?.reason
|
|
9
5
|
}
|
|
10
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Map `controller.abort(reason)` payloads to `'pause' | 'cancel'`. Accepts a
|
|
9
|
+
* bare string, `{ reason }`, or `{ kind }` shape.
|
|
10
|
+
*/
|
|
11
11
|
export function normalizeAbortReason(r: unknown): 'pause' | 'cancel' | 'unknown' {
|
|
12
12
|
if (!r) return 'unknown'
|
|
13
13
|
|
|
14
|
-
// Direct string match
|
|
15
14
|
if (typeof r === 'string') {
|
|
16
15
|
if (r === 'pause' || r === 'cancel') return r
|
|
17
16
|
return 'unknown'
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
// Object with 'reason' property (from controller.abort({ reason: 'pause' }))
|
|
21
19
|
if (isRecord(r) && 'reason' in r) {
|
|
22
20
|
const v = r.reason
|
|
23
21
|
if (v === 'pause' || v === 'cancel') return v
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
// Object with 'kind' property (alternative format)
|
|
27
24
|
if (isRecord(r) && 'kind' in r) {
|
|
28
25
|
const v = r.kind
|
|
29
26
|
if (v === 'pause' || v === 'cancel') return v
|
|
@@ -46,20 +43,9 @@ export function makeAbortError(reason: unknown): UploadAbortError {
|
|
|
46
43
|
return new UploadAbortError(reason)
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
// ERROR HANDLING HELPERS
|
|
51
|
-
// ============================================================================
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Creates an error for network failures, including CORS detection.
|
|
55
|
-
*
|
|
56
|
-
* @param xhr - XMLHttpRequest instance to check status
|
|
57
|
-
* @param defaultMessage - Default error message if status is non-zero
|
|
58
|
-
*
|
|
59
|
-
* @returns {Error} Appropriate error message based on XHR status
|
|
60
|
-
*/
|
|
46
|
+
/** XHR network error with `status === 0` heuristic for CORS detection. */
|
|
61
47
|
export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string): Error {
|
|
62
|
-
//
|
|
48
|
+
// status 0 → CORS failure or transport-level network error.
|
|
63
49
|
if (xhr.status === 0) {
|
|
64
50
|
return new Error(
|
|
65
51
|
'CORS error: The upload server does not allow requests from this origin. Please check CORS configuration.',
|
|
@@ -68,18 +54,7 @@ export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string):
|
|
|
68
54
|
return new Error(`${defaultMessage} (status: ${xhr.status})`)
|
|
69
55
|
}
|
|
70
56
|
|
|
71
|
-
|
|
72
|
-
// RESPONSE PARSING
|
|
73
|
-
// ============================================================================
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Parses XHR response headers into a key-value object.
|
|
77
|
-
* Headers are normalized to lowercase keys.
|
|
78
|
-
*
|
|
79
|
-
* @param xhr - XMLHttpRequest instance
|
|
80
|
-
*
|
|
81
|
-
* @returns {Record<string, string>} Parsed headers object
|
|
82
|
-
*/
|
|
57
|
+
/** Parse XHR headers into a lowercase-keyed record. */
|
|
83
58
|
export function parseHeaders(xhr: XMLHttpRequest): Record<string, string> {
|
|
84
59
|
const headers: Record<string, string> = {}
|
|
85
60
|
const headerStr = xhr.getAllResponseHeaders()
|