@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.
Files changed (136) hide show
  1. package/README.md +170 -193
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -51
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /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 type {
5
- CursorMap,
6
- IntentMap,
7
- UploadCommand,
8
- UploadEventMap,
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
- * Imperative action surface. Declared explicitly (not inferred) so generated
51
- * .d.ts files do not leak internal generic names created by `store.on.bind(store)`.
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 IntentMap,
76
- C extends CursorMap<M>,
47
+ M extends Contracts.Intent.Map,
48
+ C extends Contracts.Cursor.Map<M>,
77
49
  P extends string,
78
- R extends UploadResultBase = UploadResultBase,
79
- >(providedStore?: UploadStore<M, C, P, R> | undefined) {
80
- const contextStore = useOptionalUploadStore<M, C, P, R>()
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 when no store argument is provided')
56
+ throw new Error('useUploader must be used within an <UploadProvider> or passed a store instance directly.')
84
57
  }
85
58
 
86
- const snapshot = React.useSyncExternalStore(
87
- store.subscribe.bind(store),
88
- store.getSnapshot.bind(store),
89
- store.getSnapshot.bind(store),
59
+ const state = useSyncExternalStore(
60
+ store.subscribe,
61
+ store.getSnapshot,
62
+ store.getSnapshot, // SSR fallback placeholder.
90
63
  )
91
64
 
92
- const items = React.useMemo(() => {
93
- return Array.from(snapshot.items.values())
94
- }, [snapshot.items])
65
+ const items = useMemo(() => {
66
+ return Array.from(state.items.values())
67
+ }, [state])
95
68
 
96
- const byPhase = React.useMemo(() => {
97
- const result: Record<string, UploadItem<M, C, P, R>[]> = {}
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
- if (!result[item.phase]) {
100
- result[item.phase] = []
83
+ const phase = item.phase
84
+ if (!result[phase]) {
85
+ result[phase] = []
101
86
  }
102
- result[item.phase].push(item)
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 IntentMap,
123
- C extends CursorMap<M>,
114
+ M extends Contracts.Intent.Map,
115
+ C extends Contracts.Cursor.Map<M>,
124
116
  P extends string,
125
- R extends UploadResultBase = UploadResultBase,
126
- >(): UploaderActions<M, C, P, R> {
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
+ }
@@ -1,15 +1,9 @@
1
- /**
2
- * @fileoverview Public exports for upload strategies.
3
- *
4
- * Re-exports all strategy implementations and types for convenient importing.
5
- *
6
- * Available strategies:
7
- * - PostStrategy: Simple POST uploads (presigned forms)
8
- * - multipartStrategy: Chunked multipart uploads (S3-style, resumable)
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
+ })