@gentleduck/upload 0.2.6 → 0.3.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/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -1,105 +1,90 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
1
|
import * as React from 'react'
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
UploadItem,
|
|
10
|
-
UploadPhase,
|
|
11
|
-
UploadResultBase,
|
|
12
|
-
} from '../core'
|
|
13
|
-
import type { UploadStore } from '../core/engine/store'
|
|
14
|
-
import { useOptionalUploadStore, useUploadStore } from './upload-provider'
|
|
15
|
-
|
|
16
|
-
export function createUploadFactory<
|
|
17
|
-
M extends IntentMap,
|
|
18
|
-
C extends CursorMap<M>,
|
|
19
|
-
P extends string,
|
|
20
|
-
R extends UploadResultBase = UploadResultBase,
|
|
21
|
-
>(store: UploadStore<M, C, P, R>) {
|
|
22
|
-
return () => useUploader<M, C, P, R>(store)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type PickItemsByPhase<Phase extends UploadPhase> = Extract<
|
|
26
|
-
UploadItem<IntentMap, CursorMap<IntentMap>, string, UploadResultBase>,
|
|
27
|
-
{ phase: Phase }
|
|
28
|
-
>
|
|
29
|
-
|
|
30
|
-
type Uploader<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase> = {
|
|
31
|
-
items: UploadItem<M, C, P, R>[]
|
|
32
|
-
byPhase: Record<string, UploadItem<M, C, P, R>[]>
|
|
33
|
-
dispatch: (cmd: UploadCommand<P>) => void
|
|
34
|
-
on: <K extends keyof UploadEventMap<M, C, P, R> & string>(
|
|
35
|
-
type: K,
|
|
36
|
-
cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
|
|
37
|
-
) => () => void
|
|
38
|
-
off: <K extends keyof UploadEventMap<M, C, P, R> & string>(
|
|
39
|
-
type: K,
|
|
40
|
-
cb: (payload: UploadEventMap<M, C, P, R>[K]) => void,
|
|
41
|
-
) => () => void
|
|
42
|
-
uploading: UploadItem<M, C, P, R>[]
|
|
43
|
-
paused: UploadItem<M, C, P, R>[]
|
|
44
|
-
completed: UploadItem<M, C, P, R>[]
|
|
45
|
-
failed: UploadItem<M, C, P, R>[]
|
|
46
|
-
ready: UploadItem<M, C, P, R>[]
|
|
47
|
-
}
|
|
2
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
3
|
+
import type { Contracts, Engine } from '../core'
|
|
4
|
+
import type { Store } from '../core/engine/store'
|
|
5
|
+
import { useUploadStore } from './upload-provider'
|
|
6
|
+
import type { Uploader } from './uploader.types'
|
|
48
7
|
|
|
49
8
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
9
|
+
* Hook to consume the upload store state, track files, and interact with the upload engine.
|
|
10
|
+
*
|
|
11
|
+
* Exposes files, categorizes items by status phase (ready, uploading, paused, completed, failed),
|
|
12
|
+
* and registers action dispatchers.
|
|
13
|
+
*
|
|
14
|
+
* Can optionally be passed a specific store instance directly. Otherwise, it will read the store from context.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* function UploadForm() {
|
|
19
|
+
* const { items, uploading, dispatch } = useUploader();
|
|
20
|
+
*
|
|
21
|
+
* const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
22
|
+
* if (e.target.files) {
|
|
23
|
+
* dispatch({ type: 'addFiles', files: Array.from(e.target.files), purpose: 'avatar' });
|
|
24
|
+
* }
|
|
25
|
+
* };
|
|
26
|
+
*
|
|
27
|
+
* return (
|
|
28
|
+
* <div>
|
|
29
|
+
* <input type="file" onChange={onFileChange} multiple />
|
|
30
|
+
* <ul>
|
|
31
|
+
* {items.map(item => (
|
|
32
|
+
* <li key={item.localId}>{item.fingerprint.name} - {item.phase}</li>
|
|
33
|
+
* ))}
|
|
34
|
+
* </ul>
|
|
35
|
+
* </div>
|
|
36
|
+
* );
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @template M - Intent map type
|
|
41
|
+
* @template C - Cursor map type
|
|
42
|
+
* @template P - Purpose string union type
|
|
43
|
+
* @template R - Backend result shape
|
|
44
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
52
45
|
*/
|
|
53
|
-
export type UploaderActions<
|
|
54
|
-
M extends IntentMap,
|
|
55
|
-
C extends CursorMap<M>,
|
|
56
|
-
P extends string,
|
|
57
|
-
R extends UploadResultBase = UploadResultBase,
|
|
58
|
-
> = Pick<UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
|
|
59
|
-
store: UploadStore<M, C, P, R>
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function useUploader<
|
|
63
|
-
M extends IntentMap,
|
|
64
|
-
C extends CursorMap<M>,
|
|
65
|
-
P extends string,
|
|
66
|
-
R extends UploadResultBase = UploadResultBase,
|
|
67
|
-
>(): Uploader<M, C, P, R>
|
|
68
|
-
export function useUploader<
|
|
69
|
-
M extends IntentMap,
|
|
70
|
-
C extends CursorMap<M>,
|
|
71
|
-
P extends string,
|
|
72
|
-
R extends UploadResultBase = UploadResultBase,
|
|
73
|
-
>(store: UploadStore<M, C, P, R>): Uploader<M, C, P, R>
|
|
74
46
|
export function useUploader<
|
|
75
|
-
M extends
|
|
76
|
-
C extends
|
|
47
|
+
M extends Contracts.Intent.Map,
|
|
48
|
+
C extends Contracts.Cursor.Map<M>,
|
|
77
49
|
P extends string,
|
|
78
|
-
R extends
|
|
79
|
-
>(providedStore?: UploadStore<M, C, P, R> | undefined) {
|
|
80
|
-
const contextStore =
|
|
50
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
51
|
+
>(providedStore?: Store.UploadStore<M, C, P, R> | undefined): Uploader.State<M, C, P, R> {
|
|
52
|
+
const contextStore = useUploadStore<M, C, P, R>()
|
|
81
53
|
const store = providedStore ?? contextStore
|
|
54
|
+
|
|
82
55
|
if (!store) {
|
|
83
|
-
throw new Error('useUploader must be used within UploadProvider
|
|
56
|
+
throw new Error('useUploader must be used within an <UploadProvider> or passed a store instance directly.')
|
|
84
57
|
}
|
|
85
58
|
|
|
86
|
-
const
|
|
87
|
-
store.subscribe
|
|
88
|
-
store.getSnapshot
|
|
89
|
-
store.getSnapshot.
|
|
59
|
+
const state = useSyncExternalStore(
|
|
60
|
+
store.subscribe,
|
|
61
|
+
store.getSnapshot,
|
|
62
|
+
store.getSnapshot, // SSR fallback placeholder.
|
|
90
63
|
)
|
|
91
64
|
|
|
92
|
-
const items =
|
|
93
|
-
return Array.from(
|
|
94
|
-
}, [
|
|
65
|
+
const items = useMemo(() => {
|
|
66
|
+
return Array.from(state.items.values())
|
|
67
|
+
}, [state])
|
|
95
68
|
|
|
96
|
-
const byPhase =
|
|
97
|
-
const result: Record<
|
|
69
|
+
const byPhase = useMemo(() => {
|
|
70
|
+
const result: Record<Engine.Phase, Array<Engine.Item<M, C, P, R>>> = {
|
|
71
|
+
validating: [],
|
|
72
|
+
creating_intent: [],
|
|
73
|
+
ready: [],
|
|
74
|
+
queued: [],
|
|
75
|
+
uploading: [],
|
|
76
|
+
paused: [],
|
|
77
|
+
completing: [],
|
|
78
|
+
completed: [],
|
|
79
|
+
error: [],
|
|
80
|
+
canceled: [],
|
|
81
|
+
}
|
|
98
82
|
items.forEach((item) => {
|
|
99
|
-
|
|
100
|
-
|
|
83
|
+
const phase = item.phase
|
|
84
|
+
if (!result[phase]) {
|
|
85
|
+
result[phase] = []
|
|
101
86
|
}
|
|
102
|
-
result[
|
|
87
|
+
result[phase].push(item)
|
|
103
88
|
})
|
|
104
89
|
return result
|
|
105
90
|
}, [items])
|
|
@@ -118,12 +103,19 @@ export function useUploader<
|
|
|
118
103
|
}
|
|
119
104
|
}
|
|
120
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Returns the store's imperative actions (dispatch, event bindings) along with a direct
|
|
108
|
+
* reference to the store instance. Does not trigger React re-renders on upload progress or state changes.
|
|
109
|
+
*
|
|
110
|
+
* Use this when you only need to trigger commands or subscribe to events without binding UI lists.
|
|
111
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
112
|
+
*/
|
|
121
113
|
export function useUploaderActions<
|
|
122
|
-
M extends
|
|
123
|
-
C extends
|
|
114
|
+
M extends Contracts.Intent.Map,
|
|
115
|
+
C extends Contracts.Cursor.Map<M>,
|
|
124
116
|
P extends string,
|
|
125
|
-
R extends
|
|
126
|
-
>():
|
|
117
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
118
|
+
>(): Uploader.IActions<M, C, P, R> {
|
|
127
119
|
const store = useUploadStore<M, C, P, R>()
|
|
128
120
|
|
|
129
121
|
const dispatch = React.useMemo(() => store.dispatch.bind(store), [store])
|
|
@@ -131,3 +123,18 @@ export function useUploaderActions<
|
|
|
131
123
|
|
|
132
124
|
return { dispatch, on, store }
|
|
133
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a bound `useUploader` hook pre-configured for a specific store instance.
|
|
129
|
+
*
|
|
130
|
+
* Useful for building static multi-uploader widgets or context-free setups.
|
|
131
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
132
|
+
*/
|
|
133
|
+
export function createUploadFactory<
|
|
134
|
+
M extends Contracts.Intent.Map,
|
|
135
|
+
C extends Contracts.Cursor.Map<M>,
|
|
136
|
+
P extends string,
|
|
137
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
138
|
+
>(store: Store.UploadStore<M, C, P, R>) {
|
|
139
|
+
return () => useUploader<M, C, P, R>(store)
|
|
140
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { DEFAULT_MAX_RETRIES, isAbort, isRetryable, withRetry } from '../retry'
|
|
3
|
+
|
|
4
|
+
describe('isAbort', () => {
|
|
5
|
+
it('detects AbortError by name', () => {
|
|
6
|
+
const err = new Error('cancelled')
|
|
7
|
+
err.name = 'AbortError'
|
|
8
|
+
expect(isAbort(err)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('detects abort by message', () => {
|
|
12
|
+
expect(isAbort(new Error('request was aborted'))).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is false for plain errors and non-errors', () => {
|
|
16
|
+
expect(isAbort(new Error('network down'))).toBe(false)
|
|
17
|
+
expect(isAbort('abort')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('isRetryable', () => {
|
|
22
|
+
it('matches transient network wording and codes', () => {
|
|
23
|
+
expect(isRetryable(new Error('network error'))).toBe(true)
|
|
24
|
+
expect(isRetryable(new Error('socket timeout'))).toBe(true)
|
|
25
|
+
expect(isRetryable(new Error('Upload failed with status 503'))).toBe(true)
|
|
26
|
+
expect(isRetryable(new Error('read ECONNRESET'))).toBe(true)
|
|
27
|
+
expect(isRetryable(new Error('connect EHOSTUNREACH'))).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is false for non-transient errors', () => {
|
|
31
|
+
expect(isRetryable(new Error('validation failed'))).toBe(false)
|
|
32
|
+
expect(isRetryable(new Error('Upload failed with status 403'))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('withRetry', () => {
|
|
37
|
+
it('returns first-attempt result without retrying', async () => {
|
|
38
|
+
const fn = vi.fn(async () => 'ok')
|
|
39
|
+
await expect(withRetry(fn)).resolves.toBe('ok')
|
|
40
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('retries transient failures then succeeds', async () => {
|
|
44
|
+
let calls = 0
|
|
45
|
+
const fn = vi.fn(async () => {
|
|
46
|
+
calls++
|
|
47
|
+
if (calls < 3) throw new Error('network glitch')
|
|
48
|
+
return calls
|
|
49
|
+
})
|
|
50
|
+
await expect(withRetry(fn, { maxRetries: 5 })).resolves.toBe(3)
|
|
51
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('passes the zero-based attempt index to fn', async () => {
|
|
55
|
+
const seen: number[] = []
|
|
56
|
+
let calls = 0
|
|
57
|
+
await withRetry(
|
|
58
|
+
async (attempt) => {
|
|
59
|
+
seen.push(attempt)
|
|
60
|
+
calls++
|
|
61
|
+
if (calls < 3) throw new Error('timeout')
|
|
62
|
+
return null
|
|
63
|
+
},
|
|
64
|
+
{ maxRetries: 5 },
|
|
65
|
+
)
|
|
66
|
+
expect(seen).toEqual([0, 1, 2])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('gives up after maxRetries and rethrows', async () => {
|
|
70
|
+
const fn = vi.fn(async () => {
|
|
71
|
+
throw new Error('network down')
|
|
72
|
+
})
|
|
73
|
+
await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow('network down')
|
|
74
|
+
// first attempt + 2 retries
|
|
75
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does not retry non-retryable errors', async () => {
|
|
79
|
+
const fn = vi.fn(async () => {
|
|
80
|
+
throw new Error('validation failed')
|
|
81
|
+
})
|
|
82
|
+
await expect(withRetry(fn)).rejects.toThrow('validation failed')
|
|
83
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not retry aborts', async () => {
|
|
87
|
+
const err = new Error('aborted')
|
|
88
|
+
err.name = 'AbortError'
|
|
89
|
+
const fn = vi.fn(async () => {
|
|
90
|
+
throw err
|
|
91
|
+
})
|
|
92
|
+
await expect(withRetry(fn)).rejects.toThrow('aborted')
|
|
93
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('stops immediately when the signal is already aborted', async () => {
|
|
97
|
+
const controller = new AbortController()
|
|
98
|
+
controller.abort()
|
|
99
|
+
const fn = vi.fn(async () => {
|
|
100
|
+
throw new Error('network down')
|
|
101
|
+
})
|
|
102
|
+
await expect(withRetry(fn, { signal: controller.signal })).rejects.toThrow('network down')
|
|
103
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('honors a custom retryable predicate', async () => {
|
|
107
|
+
let calls = 0
|
|
108
|
+
const fn = vi.fn(async () => {
|
|
109
|
+
calls++
|
|
110
|
+
if (calls < 2) throw new Error('please retry me')
|
|
111
|
+
return 'done'
|
|
112
|
+
})
|
|
113
|
+
await expect(withRetry(fn, { retryable: (e) => /retry me/.test(String(e)) })).resolves.toBe('done')
|
|
114
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('exposes a sane default retry count', () => {
|
|
118
|
+
expect(DEFAULT_MAX_RETRIES).toBe(3)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared retry helpers used by upload strategies.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes the abort detection, transient-error classification, and
|
|
5
|
+
* exponential-backoff loop that `multipart` grew inline and that `post` and
|
|
6
|
+
* `tus` reuse.
|
|
7
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sleep } from '../../core/utils/async'
|
|
11
|
+
|
|
12
|
+
/** Default number of retries (attempts after the first) for transient failures. */
|
|
13
|
+
export const DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
/** Base backoff in ms; delay for retry `n` (0-indexed) is `2 ** n * BASE`. */
|
|
16
|
+
const BACKOFF_BASE_MS = 500
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when `err` represents an aborted request (either an `AbortError` or a
|
|
20
|
+
* message mentioning "abort"). Aborts are never retried.
|
|
21
|
+
*/
|
|
22
|
+
export function isAbort(err: unknown): boolean {
|
|
23
|
+
return err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify an error as a transient network failure worth retrying. Matches
|
|
28
|
+
* network/timeout wording, 5xx status codes, and common Node socket errnos.
|
|
29
|
+
*/
|
|
30
|
+
export function isRetryable(err: unknown): boolean {
|
|
31
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
32
|
+
return (
|
|
33
|
+
/network/i.test(msg) ||
|
|
34
|
+
/timeout/i.test(msg) ||
|
|
35
|
+
/5\d\d/.test(msg) ||
|
|
36
|
+
/ECONNRESET/i.test(msg) ||
|
|
37
|
+
/EHOSTUNREACH/i.test(msg)
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link withRetry}. */
|
|
42
|
+
export type WithRetryOptions = {
|
|
43
|
+
/** Retries after the first attempt. Defaults to {@link DEFAULT_MAX_RETRIES}. */
|
|
44
|
+
maxRetries?: number
|
|
45
|
+
/**
|
|
46
|
+
* Abort signal. When aborted (or the error is an abort) the loop stops
|
|
47
|
+
* immediately and rethrows without further retries.
|
|
48
|
+
*/
|
|
49
|
+
signal?: AbortSignal
|
|
50
|
+
/**
|
|
51
|
+
* Custom retry predicate. Defaults to {@link isRetryable}. Aborts are always
|
|
52
|
+
* terminal regardless of this predicate.
|
|
53
|
+
*/
|
|
54
|
+
retryable?: (err: unknown) => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run `fn` and retry transient failures with exponential backoff.
|
|
59
|
+
*
|
|
60
|
+
* Aborts (signal aborted, or an abort-shaped error) short-circuit and rethrow
|
|
61
|
+
* immediately. Non-retryable errors rethrow on first failure. `fn` receives the
|
|
62
|
+
* zero-based attempt index.
|
|
63
|
+
*/
|
|
64
|
+
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: WithRetryOptions = {}): Promise<T> {
|
|
65
|
+
const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_MAX_RETRIES)
|
|
66
|
+
const retryable = opts.retryable ?? isRetryable
|
|
67
|
+
|
|
68
|
+
let attempt = 0
|
|
69
|
+
for (;;) {
|
|
70
|
+
try {
|
|
71
|
+
return await fn(attempt)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (opts.signal?.aborted || isAbort(err)) throw err
|
|
74
|
+
if (attempt >= maxRetries || !retryable(err)) throw err
|
|
75
|
+
await sleep(2 ** attempt * BACKOFF_BASE_MS)
|
|
76
|
+
attempt++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/strategies/index.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* @module upload-strategies
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export * from './multipart'
|
|
14
|
-
export * from './post'
|
|
15
|
-
export * from './registry'
|
|
1
|
+
export type { MultipartStrategy } from './multipart'
|
|
2
|
+
export { multipartStrategy } from './multipart'
|
|
3
|
+
export type { PostIntent } from './post'
|
|
4
|
+
export { PostStrategy } from './post'
|
|
5
|
+
export type { PutIntent } from './put'
|
|
6
|
+
export { PutStrategy } from './put'
|
|
7
|
+
export { createStrategyRegistry } from './registry'
|
|
8
|
+
export type { TusIntent } from './tus'
|
|
9
|
+
export { TusStrategy } from './tus'
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import type { Transport } from '../../../core'
|
|
3
|
+
import { __resetMultipartWarningsForTests, multipartStrategy, validatePartUrl } from '../index'
|
|
4
|
+
|
|
5
|
+
describe('validatePartUrl', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
__resetMultipartWarningsForTests()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('rejects file:// URL', () => {
|
|
11
|
+
expect(() => validatePartUrl('file:///etc/passwd')).toThrow(/forbidden protocol/)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('rejects javascript: URL', () => {
|
|
15
|
+
expect(() => validatePartUrl('javascript:alert(1)')).toThrow(/forbidden protocol/)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('rejects malformed URL', () => {
|
|
19
|
+
expect(() => validatePartUrl('not a url')).toThrow(/malformed/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('rejects empty URL', () => {
|
|
23
|
+
expect(() => validatePartUrl('')).toThrow(/invalid URL/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('rejects URL containing ".." segment', () => {
|
|
27
|
+
expect(() => validatePartUrl('https://upload.example.com/a/../b')).toThrow(/\.\./)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('rejects http://127.0.0.1/upload when allowPrivateHosts is false', () => {
|
|
31
|
+
expect(() => validatePartUrl('http://127.0.0.1/upload')).toThrow(/private\/loopback/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects RFC1918 hosts (10/8, 172.16/12, 192.168/16)', () => {
|
|
35
|
+
expect(() => validatePartUrl('http://10.0.0.5/x')).toThrow(/private\/loopback/)
|
|
36
|
+
expect(() => validatePartUrl('http://172.16.1.1/x')).toThrow(/private\/loopback/)
|
|
37
|
+
expect(() => validatePartUrl('http://192.168.1.1/x')).toThrow(/private\/loopback/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('rejects IPv6 loopback ::1 and link-local fe80::', () => {
|
|
41
|
+
expect(() => validatePartUrl('http://[::1]/x')).toThrow(/private\/loopback/)
|
|
42
|
+
expect(() => validatePartUrl('http://[fe80::1]/x')).toThrow(/private\/loopback/)
|
|
43
|
+
expect(() => validatePartUrl('http://[fc00::1]/x')).toThrow(/private\/loopback/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('rejects IPv6 unspecified :: and multicast ff02::1', () => {
|
|
47
|
+
expect(() => validatePartUrl('http://[::]/x')).toThrow(/private\/loopback/)
|
|
48
|
+
expect(() => validatePartUrl('http://[ff02::1]/x')).toThrow(/private\/loopback/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('rejects IPv4-mapped IPv6 (dotted-quad, canonical hex, fully expanded)', () => {
|
|
52
|
+
expect(() => validatePartUrl('http://[::ffff:127.0.0.1]/')).toThrow(/private\/loopback/)
|
|
53
|
+
expect(() => validatePartUrl('http://[::ffff:7f00:1]/')).toThrow(/private\/loopback/)
|
|
54
|
+
expect(() => validatePartUrl('http://[0:0:0:0:0:ffff:7f00:1]/')).toThrow(/private\/loopback/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects 6to4 IPv6 carrying a private IPv4 (2002:7f00:1::)', () => {
|
|
58
|
+
expect(() => validatePartUrl('http://[2002:7f00:1::]/')).toThrow(/private\/loopback/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('rejects NAT64 IPv6 carrying a private IPv4 (64:ff9b::7f00:1)', () => {
|
|
62
|
+
expect(() => validatePartUrl('http://[64:ff9b::7f00:1]/')).toThrow(/private\/loopback/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('rejects AWS cloud-metadata 169.254.169.254', () => {
|
|
66
|
+
expect(() => validatePartUrl('http://169.254.169.254/latest/meta-data/')).toThrow(/private\/loopback/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('rejects CGNAT 100.64.0.0/10', () => {
|
|
70
|
+
expect(() => validatePartUrl('http://100.64.5.5/')).toThrow(/private\/loopback/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('rejects multicast 224.0.0.0/4 and broadcast 255.255.255.255', () => {
|
|
74
|
+
expect(() => validatePartUrl('http://224.0.0.1/')).toThrow(/private\/loopback/)
|
|
75
|
+
expect(() => validatePartUrl('http://255.255.255.255/')).toThrow(/private\/loopback/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('accepts http://127.0.0.1/upload when allowPrivateHosts is true', () => {
|
|
79
|
+
expect(() => validatePartUrl('http://127.0.0.1/upload', { allowPrivateHosts: true })).not.toThrow()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('accepts hardened private/embedded forms when allowPrivateHosts is true', () => {
|
|
83
|
+
const cases = [
|
|
84
|
+
'http://[::1]/',
|
|
85
|
+
'http://[::ffff:127.0.0.1]/',
|
|
86
|
+
'http://[::ffff:7f00:1]/',
|
|
87
|
+
'http://[2002:7f00:1::]/',
|
|
88
|
+
'http://[64:ff9b::7f00:1]/',
|
|
89
|
+
'http://169.254.169.254/latest/meta-data/',
|
|
90
|
+
'http://100.64.5.5/',
|
|
91
|
+
'http://224.0.0.1/',
|
|
92
|
+
'http://255.255.255.255/',
|
|
93
|
+
'http://[::]/',
|
|
94
|
+
'http://[ff02::1]/',
|
|
95
|
+
]
|
|
96
|
+
for (const u of cases) {
|
|
97
|
+
expect(() => validatePartUrl(u, { allowPrivateHosts: true })).not.toThrow()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('public URL still flows through (happy path)', () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
validatePartUrl('https://upload.example.com/parts/3', {
|
|
104
|
+
allowedHosts: ['upload.example.com'],
|
|
105
|
+
}),
|
|
106
|
+
).not.toThrow()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('allowedHosts: accepts listed host, rejects others', () => {
|
|
110
|
+
expect(() =>
|
|
111
|
+
validatePartUrl('https://upload.example.com/parts/3', {
|
|
112
|
+
allowedHosts: ['upload.example.com'],
|
|
113
|
+
}),
|
|
114
|
+
).not.toThrow()
|
|
115
|
+
|
|
116
|
+
expect(() =>
|
|
117
|
+
validatePartUrl('https://evil.example.com/parts/3', {
|
|
118
|
+
allowedHosts: ['upload.example.com'],
|
|
119
|
+
}),
|
|
120
|
+
).toThrow(/allow-list/)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('allowedHosts matches case-insensitively and is port-aware', () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
validatePartUrl('https://Upload.Example.COM:8443/p', {
|
|
126
|
+
allowedHosts: ['upload.example.com:8443'],
|
|
127
|
+
}),
|
|
128
|
+
).not.toThrow()
|
|
129
|
+
|
|
130
|
+
expect(() =>
|
|
131
|
+
validatePartUrl('https://upload.example.com:9000/p', {
|
|
132
|
+
allowedHosts: ['upload.example.com:8443'],
|
|
133
|
+
}),
|
|
134
|
+
).toThrow(/allow-list/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('warns once on omitted allowedHosts, suppresses on subsequent calls', () => {
|
|
138
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
139
|
+
try {
|
|
140
|
+
validatePartUrl('https://upload.example.com/a')
|
|
141
|
+
validatePartUrl('https://upload.example.com/b')
|
|
142
|
+
validatePartUrl('https://upload.example.com/c')
|
|
143
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
144
|
+
expect(spy.mock.calls[0]?.[0]).toMatch(/allowedHosts/)
|
|
145
|
+
} finally {
|
|
146
|
+
spy.mockRestore()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('multipartStrategy happy path', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
__resetMultipartWarningsForTests()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
vi.restoreAllMocks()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
function makeCtx(opts: { signPartUrl: string; fileSize?: number }) {
|
|
161
|
+
const size = opts.fileSize ?? 8
|
|
162
|
+
const file = new File([new Uint8Array(size)], 'a.bin')
|
|
163
|
+
const transport: Pick<Transport.Options, 'put' | 'postForm' | 'patch'> = {
|
|
164
|
+
put: vi.fn(async (_args: Parameters<Transport.Options['put']>[0]) => ({ etag: 'etag-1' })),
|
|
165
|
+
postForm: vi.fn(async () => ({})),
|
|
166
|
+
patch: vi.fn(async () => ({})),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const cursors: Array<unknown> = []
|
|
170
|
+
const ctx = {
|
|
171
|
+
file,
|
|
172
|
+
intent: {
|
|
173
|
+
strategy: 'multipart' as const,
|
|
174
|
+
fileId: 'f1',
|
|
175
|
+
uploadId: 'u1',
|
|
176
|
+
partSize: size, // one part
|
|
177
|
+
partCount: 1,
|
|
178
|
+
},
|
|
179
|
+
signal: new AbortController().signal,
|
|
180
|
+
transport: transport,
|
|
181
|
+
api: {
|
|
182
|
+
createIntent: vi.fn(),
|
|
183
|
+
complete: vi.fn(),
|
|
184
|
+
multipart: {
|
|
185
|
+
signPart: vi.fn(async () => ({ url: opts.signPartUrl, headers: undefined })),
|
|
186
|
+
completeMultipart: vi.fn(async () => undefined),
|
|
187
|
+
},
|
|
188
|
+
} as unknown as Parameters<ReturnType<typeof multipartStrategy>['start']>[0]['api'],
|
|
189
|
+
readCursor: () => undefined,
|
|
190
|
+
persistCursor: (c: unknown) => {
|
|
191
|
+
cursors.push(c)
|
|
192
|
+
},
|
|
193
|
+
reportProgress: () => {},
|
|
194
|
+
}
|
|
195
|
+
return { ctx, transport, cursors }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test('signed https://upload.example.com URL flows through to transport.put', async () => {
|
|
199
|
+
const { ctx, transport } = makeCtx({ signPartUrl: 'https://upload.example.com/parts/3' })
|
|
200
|
+
const start = multipartStrategy({ allowedHosts: ['upload.example.com'] })
|
|
201
|
+
await start.start(ctx as unknown as Parameters<typeof start.start>[0])
|
|
202
|
+
expect(transport.put).toHaveBeenCalledTimes(1)
|
|
203
|
+
const callArgs = (transport.put as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as { url: string }
|
|
204
|
+
expect(callArgs.url).toBe('https://upload.example.com/parts/3')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('file:// signed URL is rejected before reaching transport.put', async () => {
|
|
208
|
+
const { ctx, transport } = makeCtx({ signPartUrl: 'file:///etc/passwd' })
|
|
209
|
+
const start = multipartStrategy({ allowedHosts: ['upload.example.com'] })
|
|
210
|
+
await expect(start.start(ctx as unknown as Parameters<typeof start.start>[0])).rejects.toThrow(/forbidden protocol/)
|
|
211
|
+
expect(transport.put).not.toHaveBeenCalled()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('private-host signed URL is rejected when allowPrivateHosts is false', async () => {
|
|
215
|
+
const { ctx, transport } = makeCtx({ signPartUrl: 'http://127.0.0.1/upload' })
|
|
216
|
+
const start = multipartStrategy({})
|
|
217
|
+
// suppress the one-time warn
|
|
218
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
219
|
+
await expect(start.start(ctx as unknown as Parameters<typeof start.start>[0])).rejects.toThrow(/private\/loopback/)
|
|
220
|
+
expect(transport.put).not.toHaveBeenCalled()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('private-host signed URL is accepted when allowPrivateHosts is true', async () => {
|
|
224
|
+
const { ctx, transport } = makeCtx({ signPartUrl: 'http://127.0.0.1/upload' })
|
|
225
|
+
const start = multipartStrategy({ allowPrivateHosts: true, allowedHosts: ['127.0.0.1'] })
|
|
226
|
+
await start.start(ctx as unknown as Parameters<typeof start.start>[0])
|
|
227
|
+
expect(transport.put).toHaveBeenCalledTimes(1)
|
|
228
|
+
})
|
|
229
|
+
})
|