@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
@@ -0,0 +1,132 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+ import { __resetPutWarningsForTests, type PutIntent, PutStrategy } from '../index'
3
+
4
+ function makeCtx(url: string, overrides: Partial<PutIntent> = {}) {
5
+ const file = new File(['hello world'], 'a.txt', { type: 'text/plain' })
6
+ return {
7
+ intent: { strategy: 'put', fileId: 'f', url, ...overrides } satisfies PutIntent,
8
+ file,
9
+ signal: new AbortController().signal,
10
+ reportProgress: vi.fn(),
11
+ transport: {
12
+ put: vi.fn(async () => ({ etag: 'abc', headers: {} })),
13
+ },
14
+ }
15
+ }
16
+
17
+ describe('PutStrategy SSRF guard', () => {
18
+ beforeEach(() => {
19
+ __resetPutWarningsForTests()
20
+ })
21
+
22
+ test('rejects file://', async () => {
23
+ const s = PutStrategy()
24
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
25
+ await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/forbidden protocol/)
26
+ })
27
+
28
+ test('rejects javascript:', async () => {
29
+ const s = PutStrategy()
30
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
31
+ await expect(s.start(makeCtx('javascript:alert(1)') as any)).rejects.toThrow(/forbidden protocol/)
32
+ })
33
+
34
+ test('rejects loopback', async () => {
35
+ const s = PutStrategy()
36
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
37
+ await expect(s.start(makeCtx('https://127.0.0.1/up') as any)).rejects.toThrow(/private/)
38
+ })
39
+
40
+ test('rejects AWS metadata IPv4', async () => {
41
+ const s = PutStrategy()
42
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
43
+ await expect(s.start(makeCtx('https://169.254.169.254/up') as any)).rejects.toThrow(/private/)
44
+ })
45
+
46
+ test('rejects host outside allowedHosts', async () => {
47
+ const s = PutStrategy({ allowedHosts: ['up.example.com'] })
48
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
49
+ await expect(s.start(makeCtx('https://evil.example.com/up') as any)).rejects.toThrow(/allow-list/)
50
+ })
51
+
52
+ test('accepts allowlisted host and PUTs the file body', async () => {
53
+ const s = PutStrategy({ allowedHosts: ['up.example.com'] })
54
+ const ctx = makeCtx('https://up.example.com/up', { headers: { 'Content-Type': 'text/plain' } })
55
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
56
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
57
+ expect(ctx.transport.put).toHaveBeenCalledTimes(1)
58
+ const arg = (ctx.transport.put.mock.calls[0] as unknown as [{ url: string; body: unknown; headers: unknown }])[0]
59
+ expect(arg.url).toBe('https://up.example.com/up')
60
+ expect(arg.body).toBe(ctx.file)
61
+ expect(arg.headers).toEqual({ 'Content-Type': 'text/plain' })
62
+ })
63
+
64
+ test('allowPrivateHosts lets loopback through (opt-in)', async () => {
65
+ const s = PutStrategy({ allowPrivateHosts: true })
66
+ const ctx = makeCtx('https://127.0.0.1/up')
67
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
68
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
69
+ })
70
+
71
+ test('warns once when allowedHosts not set', async () => {
72
+ const s = PutStrategy()
73
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
74
+ try {
75
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
76
+ await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
77
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
78
+ await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
79
+ expect(warn).toHaveBeenCalledTimes(1)
80
+ } finally {
81
+ warn.mockRestore()
82
+ }
83
+ })
84
+
85
+ test('caller label is "put.intent"', async () => {
86
+ const s = PutStrategy()
87
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
88
+ await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/put\.intent/)
89
+ })
90
+
91
+ test('throws when intent has no url', async () => {
92
+ const s = PutStrategy()
93
+ const ctx = makeCtx('https://up.example.com/up')
94
+ // biome-ignore lint/suspicious/noExplicitAny: intentionally clearing the url
95
+ ;(ctx.intent as any).url = ''
96
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
97
+ await expect(s.start(ctx as any)).rejects.toThrow(/missing url/)
98
+ })
99
+ })
100
+
101
+ describe('PutStrategy retry', () => {
102
+ beforeEach(() => {
103
+ __resetPutWarningsForTests()
104
+ })
105
+
106
+ test('retries a transient failure then succeeds', async () => {
107
+ const s = PutStrategy({ allowedHosts: ['up.example.com'], maxRetries: 2 })
108
+ const ctx = makeCtx('https://up.example.com/up')
109
+ let calls = 0
110
+ ctx.transport.put = vi.fn(async () => {
111
+ calls++
112
+ if (calls < 2) throw new Error('network error')
113
+ return { etag: 'ok', headers: {} }
114
+ })
115
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
116
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
117
+ expect(calls).toBe(2)
118
+ })
119
+
120
+ test('does not retry a non-transient failure', async () => {
121
+ const s = PutStrategy({ allowedHosts: ['up.example.com'] })
122
+ const ctx = makeCtx('https://up.example.com/up')
123
+ let calls = 0
124
+ ctx.transport.put = vi.fn(async () => {
125
+ calls++
126
+ throw new Error('Upload failed with status 403')
127
+ })
128
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
129
+ await expect(s.start(ctx as any)).rejects.toThrow(/403/)
130
+ expect(calls).toBe(1)
131
+ })
132
+ })
@@ -0,0 +1,139 @@
1
+ import type { Contracts } from '../../core'
2
+ import { UploadEngineError } from '../../core'
3
+ import { validateUploadUrl } from '../../core/utils/url-safety'
4
+ import { withRetry } from '../_shared/retry'
5
+
6
+ /**
7
+ * Types for the single-request presigned HTTP PUT upload strategy.
8
+ * @author wildduck2 <https://github.com/wildduck2>
9
+ */
10
+ export namespace PutStrategy {
11
+ /**
12
+ * Presigned HTTP PUT strategy configuration options.
13
+ */
14
+ export type Config = {
15
+ /**
16
+ * Case-insensitive allow-list of host names (with optional port). When
17
+ * set, the presigned PUT URL must match a listed host or it is rejected.
18
+ */
19
+ allowedHosts?: string[]
20
+ /**
21
+ * When `true`, allow private-network IP literals in the PUT URL.
22
+ * Defaults to `false`.
23
+ */
24
+ allowPrivateHosts?: boolean
25
+ /**
26
+ * Retries after the first attempt for transient network failures.
27
+ * Defaults to the shared retry default (3).
28
+ */
29
+ maxRetries?: number
30
+ }
31
+
32
+ /**
33
+ * Presigned HTTP PUT intent payload. The whole file body is sent in one
34
+ * request to a backend-signed URL (e.g. S3 `putObject`, GCS signed PUT).
35
+ */
36
+ export type Intent = {
37
+ /** Discriminant identifier matching strategy configuration registry keys. */
38
+ strategy: 'put'
39
+ /** Unique database identifier for the file resource. */
40
+ fileId: string
41
+ /** Presigned PUT destination URL. */
42
+ url: string
43
+ /** Optional request headers required by the signature (e.g. `Content-Type`). */
44
+ headers?: Record<string, string>
45
+ /** Optional epoch string indicating when the pre-signed credentials lapse. */
46
+ expiresAt?: string
47
+ }
48
+
49
+ /**
50
+ * Direct strategy cursor state representing that no resumable bytes exist.
51
+ */
52
+ export type Cursor = Record<string, never>
53
+ }
54
+
55
+ /** Convenience alias for {@link PutStrategy.Intent}. */
56
+ export type PutIntent = PutStrategy.Intent
57
+
58
+ let warnedMissingAllowedHosts = false
59
+
60
+ /**
61
+ * Reset the warn-once latch. Test-only.
62
+ * @internal
63
+ */
64
+ export function __resetPutWarningsForTests(): void {
65
+ warnedMissingAllowedHosts = false
66
+ }
67
+
68
+ /**
69
+ * Presigned single-request HTTP PUT strategy.
70
+ *
71
+ * Sends the entire file as the request body to a backend-signed URL in one PUT.
72
+ * The most common direct-to-storage pattern (S3 `putObject`, GCS/Azure signed
73
+ * PUT). Not resumable — use {@link multipartStrategy} or the tus strategy for
74
+ * resumable transfers.
75
+ *
76
+ * Security: the presigned URL is backend-supplied and flows straight to the
77
+ * transport, so a compromised backend or MITM could return a `file:`,
78
+ * `javascript:`, or private-network URL. Every URL is checked with
79
+ * {@link validateUploadUrl}; set {@link PutStrategy.Config.allowedHosts} to
80
+ * lock it to known hosts, or leave `allowPrivateHosts` at its default `false`
81
+ * to block loopback/private addresses.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const strategies = createStrategyRegistry([
86
+ * PutStrategy({ allowedHosts: ['uploads.example.com'] }),
87
+ * multipartStrategy(),
88
+ * ]);
89
+ * ```
90
+ * @author wildduck2 <https://github.com/wildduck2>
91
+ */
92
+ export function PutStrategy<
93
+ M extends Contracts.Intent.Map,
94
+ C extends Contracts.Cursor.Map<M>,
95
+ P extends string,
96
+ R extends Contracts.Result.Base = Contracts.Result.Base,
97
+ >(opts: PutStrategy.Config = {}): Contracts.Strategy.Me<M, C, P, R, 'put'> {
98
+ const allowedHosts = opts.allowedHosts ?? []
99
+ const allowPrivateHosts = opts.allowPrivateHosts === true
100
+
101
+ return {
102
+ id: 'put',
103
+ resumable: false,
104
+
105
+ async start(ctx) {
106
+ const intent = ctx.intent as unknown as PutStrategy.Intent
107
+
108
+ if (!intent.url) {
109
+ throw new UploadEngineError('validation_failed', { message: 'put.start: intent missing url' })
110
+ }
111
+
112
+ if (allowedHosts.length === 0 && !warnedMissingAllowedHosts) {
113
+ warnedMissingAllowedHosts = true
114
+ console.warn(
115
+ '[duck-upload] PutStrategy: no `allowedHosts` configured. Presigned PUT URLs will be host-unrestricted. ' +
116
+ 'Set PutStrategy.Config.allowedHosts to lock the upload host.',
117
+ )
118
+ }
119
+
120
+ validateUploadUrl(intent.url, 'put.intent', { allowedHosts, allowPrivateHosts })
121
+
122
+ const totalBytes = ctx.file.size
123
+
124
+ await withRetry(
125
+ () =>
126
+ ctx.transport.put({
127
+ url: intent.url,
128
+ body: ctx.file,
129
+ headers: intent.headers ?? {},
130
+ signal: ctx.signal,
131
+ onProgress(uploadedBytes, total) {
132
+ ctx.reportProgress({ uploadedBytes, totalBytes: total || totalBytes })
133
+ },
134
+ }),
135
+ { signal: ctx.signal, ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}) },
136
+ )
137
+ },
138
+ }
139
+ }
@@ -4,17 +4,20 @@
4
4
  * Core strategy types live in `core/contracts/strategy.types.ts`.
5
5
  */
6
6
 
7
- import type { CursorMap, IntentMap, StrategyRegistry, UploadResultBase, UploadStrategy } from '../../core/contracts'
8
-
9
- export type { StrategyCtx, StrategyRegistry, UploadStrategy } from '../../core/contracts'
7
+ import type { Contracts } from '../../core/contracts'
10
8
 
9
+ /**
10
+ * Create an empty strategy registry. Register upload strategies with `set`, then
11
+ * resolve them by id with `get` / `has`.
12
+ * @author wildduck2 <https://github.com/wildduck2>
13
+ */
11
14
  export function createStrategyRegistry<
12
- M extends IntentMap,
13
- C extends CursorMap<M>,
14
- P extends string = string,
15
- R extends UploadResultBase = UploadResultBase,
16
- >(): StrategyRegistry<M, C, P, R> {
17
- const map: Partial<{ [K in keyof M & string]: UploadStrategy<M, C, P, R, K> }> = {}
15
+ M extends Contracts.Intent.Map,
16
+ C extends Contracts.Cursor.Map<M>,
17
+ P extends string,
18
+ R extends Contracts.Result.Base = Contracts.Result.Base,
19
+ >(): Contracts.Strategy.Registry<M, C, P, R> {
20
+ const map: Partial<{ [K in keyof M & string]: Contracts.Strategy.Me<M, C, P, R, K> }> = {}
18
21
 
19
22
  return {
20
23
  get(id) {
@@ -0,0 +1,159 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+ import { __resetTusWarningsForTests, type TusIntent, TusStrategy } from '../index'
3
+
4
+ type CursorState = { offset: number } | undefined
5
+
6
+ /**
7
+ * Build a fake strategy ctx backed by an in-memory tus server that tracks its
8
+ * own offset and grows it by the size of each accepted PATCH.
9
+ */
10
+ function makeCtx(opts: {
11
+ fileBytes: number
12
+ url?: string
13
+ intentChunkSize?: number
14
+ cursor?: CursorState
15
+ headOffset?: number | null // null = HEAD omitted from transport
16
+ serverStartOffset?: number
17
+ }) {
18
+ const { fileBytes, url = 'https://tus.example.com/files/abc', cursor, headOffset = 0, serverStartOffset = 0 } = opts
19
+
20
+ const file = new File([new Uint8Array(fileBytes)], 'a.bin')
21
+ let persisted: CursorState = cursor
22
+ let serverOffset = serverStartOffset
23
+
24
+ const patch = vi.fn(async (args: { headers?: Record<string, string>; body: Blob }) => {
25
+ const sent = Number(args.headers?.['Upload-Offset'] ?? args.headers?.['upload-offset'])
26
+ // Simulate the server accepting the chunk at the declared offset.
27
+ serverOffset = sent + args.body.size
28
+ return { headers: { 'upload-offset': String(serverOffset) } }
29
+ })
30
+
31
+ const transport: Record<string, unknown> = { patch }
32
+ if (headOffset !== null) {
33
+ transport['head'] = vi.fn(async () => ({
34
+ status: 200,
35
+ headers: { 'upload-offset': String(headOffset) },
36
+ }))
37
+ }
38
+
39
+ const intent: TusIntent = { strategy: 'tus', fileId: 'f', url }
40
+ if (opts.intentChunkSize) intent.chunkSize = opts.intentChunkSize
41
+
42
+ const ctx = {
43
+ intent,
44
+ file,
45
+ signal: new AbortController().signal,
46
+ reportProgress: vi.fn(),
47
+ readCursor: () => persisted,
48
+ persistCursor: (c: CursorState) => {
49
+ persisted = c
50
+ },
51
+ transport,
52
+ get persistedOffset() {
53
+ return persisted?.offset
54
+ },
55
+ }
56
+ return {
57
+ ctx,
58
+ patch,
59
+ get server() {
60
+ return serverOffset
61
+ },
62
+ }
63
+ }
64
+
65
+ describe('TusStrategy', () => {
66
+ beforeEach(() => {
67
+ __resetTusWarningsForTests()
68
+ })
69
+
70
+ test('uploads a file in chunks and completes', async () => {
71
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'], chunkSize: 10 })
72
+ const { ctx, patch } = makeCtx({ fileBytes: 25, intentChunkSize: 10 })
73
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
74
+ await expect(s.start(ctx as any)).resolves.toBeUndefined()
75
+ // 25 bytes / 10 per chunk -> 3 PATCHes (10, 10, 5)
76
+ expect(patch).toHaveBeenCalledTimes(3)
77
+ const sizes = patch.mock.calls.map((c) => (c[0] as { body: Blob }).body.size)
78
+ expect(sizes).toEqual([10, 10, 5])
79
+ // biome-ignore lint/suspicious/noExplicitAny: reading test helper getter
80
+ expect((ctx as any).persistedOffset).toBe(25)
81
+ })
82
+
83
+ test('sends the tus protocol headers', async () => {
84
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'] })
85
+ const { ctx, patch } = makeCtx({ fileBytes: 5, intentChunkSize: 10 })
86
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
87
+ await s.start(ctx as any)
88
+ const headers = (patch.mock.calls[0]?.[0] as { headers: Record<string, string> }).headers
89
+ expect(headers['Tus-Resumable']).toBe('1.0.0')
90
+ expect(headers['Upload-Offset']).toBe('0')
91
+ expect(headers['Content-Type']).toBe('application/offset+octet-stream')
92
+ })
93
+
94
+ test('resumes from the server HEAD offset, not the start', async () => {
95
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'], chunkSize: 10 })
96
+ // Server already has 20 of 25 bytes.
97
+ const { ctx, patch } = makeCtx({
98
+ fileBytes: 25,
99
+ intentChunkSize: 10,
100
+ headOffset: 20,
101
+ serverStartOffset: 20,
102
+ })
103
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
104
+ await s.start(ctx as any)
105
+ // Only the final 5 bytes remain -> one PATCH.
106
+ expect(patch).toHaveBeenCalledTimes(1)
107
+ expect((patch.mock.calls[0]?.[0] as { body: Blob }).body.size).toBe(5)
108
+ const startHeader = (patch.mock.calls[0]?.[0] as { headers: Record<string, string> }).headers['Upload-Offset']
109
+ expect(startHeader).toBe('20')
110
+ })
111
+
112
+ test('falls back to the persisted cursor when no HEAD transport', async () => {
113
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'], chunkSize: 10 })
114
+ const { ctx, patch } = makeCtx({
115
+ fileBytes: 25,
116
+ intentChunkSize: 10,
117
+ headOffset: null, // no head() on transport
118
+ cursor: { offset: 20 },
119
+ serverStartOffset: 20,
120
+ })
121
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
122
+ await s.start(ctx as any)
123
+ expect(patch).toHaveBeenCalledTimes(1)
124
+ expect((patch.mock.calls[0]?.[0] as { body: Blob }).body.size).toBe(5)
125
+ })
126
+
127
+ test('is a no-op when already fully uploaded', async () => {
128
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'] })
129
+ const { ctx, patch } = makeCtx({ fileBytes: 25, headOffset: 25, serverStartOffset: 25 })
130
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
131
+ await s.start(ctx as any)
132
+ expect(patch).not.toHaveBeenCalled()
133
+ })
134
+
135
+ test('rejects a non-allowlisted host', async () => {
136
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'] })
137
+ const { ctx } = makeCtx({ fileBytes: 5, url: 'https://evil.example.com/f' })
138
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
139
+ await expect(s.start(ctx as any)).rejects.toThrow(/allow-list/)
140
+ })
141
+
142
+ test('rejects file:// URL', async () => {
143
+ const s = TusStrategy()
144
+ const { ctx } = makeCtx({ fileBytes: 5, url: 'file:///etc/passwd' })
145
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
146
+ await expect(s.start(ctx as any)).rejects.toThrow(/forbidden protocol/)
147
+ })
148
+
149
+ test('bails when the server never advances the offset', async () => {
150
+ const s = TusStrategy({ allowedHosts: ['tus.example.com'], chunkSize: 10 })
151
+ const { ctx } = makeCtx({ fileBytes: 25, intentChunkSize: 10 })
152
+ // Server that always reports offset 0 -> no forward progress.
153
+ ;(ctx.transport as { patch: unknown }).patch = vi.fn(async () => ({
154
+ headers: { 'upload-offset': '0' },
155
+ }))
156
+ // biome-ignore lint/suspicious/noExplicitAny: test ctx is a subset of the full strategy ctx
157
+ await expect(s.start(ctx as any)).rejects.toThrow(/did not advance/)
158
+ })
159
+ })
@@ -0,0 +1,211 @@
1
+ /**
2
+ * tus resumable upload strategy (creation-less).
3
+ *
4
+ * The backend intent hands back a ready tus upload URL (creation is done
5
+ * server-side), so this strategy implements only the transfer half of the
6
+ * protocol: HEAD to read the server's `Upload-Offset`, then a sequence of
7
+ * `PATCH` requests with `application/offset+octet-stream` bodies. The offset is
8
+ * persisted in the cursor so a resumed run continues where it left off, and
9
+ * re-confirmed against the server via HEAD when the transport supports it.
10
+ *
11
+ * @see https://tus.io/protocols/resumable-upload
12
+ */
13
+
14
+ import type { Contracts } from '../../core'
15
+ import { UploadEngineError } from '../../core'
16
+ import { validateUploadUrl } from '../../core/utils/url-safety'
17
+ import { withRetry } from '../_shared/retry'
18
+
19
+ /** tus protocol version this strategy speaks. */
20
+ const TUS_RESUMABLE = '1.0.0'
21
+
22
+ /** Default PATCH chunk size when neither intent nor config specifies one: 8 MiB. */
23
+ const DEFAULT_TUS_CHUNK_SIZE = 8 * 1024 * 1024
24
+
25
+ /**
26
+ * Types for the tus resumable upload strategy.
27
+ * @author wildduck2 <https://github.com/wildduck2>
28
+ */
29
+ export namespace TusStrategy {
30
+ /**
31
+ * Configuration options for the tus strategy.
32
+ */
33
+ export type Config = {
34
+ /** PATCH chunk size in bytes. Overridden by `intent.chunkSize`. Defaults to 8 MiB. */
35
+ chunkSize?: number
36
+ /**
37
+ * Case-insensitive allow-list of host names (with optional port). When set,
38
+ * the tus upload URL must match a listed host or it is rejected.
39
+ */
40
+ allowedHosts?: string[]
41
+ /**
42
+ * When `true`, allow private-network IP literals in the tus URL.
43
+ * Defaults to `false`.
44
+ */
45
+ allowPrivateHosts?: boolean
46
+ /** Retries after the first attempt for transient PATCH/HEAD failures. Defaults to 3. */
47
+ maxRetries?: number
48
+ }
49
+
50
+ /**
51
+ * tus intent payload. The backend has already created the upload; `url` is the
52
+ * ready upload resource to PATCH against.
53
+ */
54
+ export type Intent = {
55
+ /** Discriminant matching strategy configuration registry keys. */
56
+ strategy: 'tus'
57
+ /** Unique database identifier for the file resource. */
58
+ fileId: string
59
+ /** Ready tus upload URL (backend-created via the creation extension). */
60
+ url: string
61
+ /** Optional per-upload chunk size override in bytes. */
62
+ chunkSize?: number
63
+ /** Optional extra headers to send on every request (e.g. auth). */
64
+ headers?: Record<string, string>
65
+ }
66
+
67
+ /**
68
+ * Persisted resume state: the last acknowledged upload offset in bytes.
69
+ */
70
+ export type Cursor = {
71
+ /** Byte offset already accepted by the server. */
72
+ offset: number
73
+ }
74
+ }
75
+
76
+ /** Convenience alias for {@link TusStrategy.Intent}. */
77
+ export type TusIntent = TusStrategy.Intent
78
+
79
+ let warnedMissingAllowedHosts = false
80
+
81
+ /**
82
+ * Reset the warn-once latch. Test-only.
83
+ * @internal
84
+ */
85
+ export function __resetTusWarningsForTests(): void {
86
+ warnedMissingAllowedHosts = false
87
+ }
88
+
89
+ /** Parse a non-negative integer header value, or `null` when absent/invalid. */
90
+ function parseOffsetHeader(headers: Record<string, string> | undefined, name: string): number | null {
91
+ const raw = headers?.[name] ?? headers?.[name.toLowerCase()]
92
+ if (raw === undefined) return null
93
+ const n = Number(raw)
94
+ return Number.isInteger(n) && n >= 0 ? n : null
95
+ }
96
+
97
+ /**
98
+ * tus resumable upload strategy (creation-less).
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const registry = createStrategyRegistry([
103
+ * TusStrategy({ allowedHosts: ['tus.example.com'], chunkSize: 8 * 1024 * 1024 }),
104
+ * ]);
105
+ * ```
106
+ * @author wildduck2 <https://github.com/wildduck2>
107
+ */
108
+ export function TusStrategy<
109
+ M extends Contracts.Intent.Map & { tus: TusStrategy.Intent },
110
+ C extends Contracts.Cursor.Map<M> & { tus?: TusStrategy.Cursor },
111
+ P extends string = string,
112
+ R extends Contracts.Result.Base = Contracts.Result.Base,
113
+ >(opts: TusStrategy.Config = {}): Contracts.Strategy.Me<M, C, P, R, 'tus'> {
114
+ const allowedHosts = opts.allowedHosts ?? []
115
+ const allowPrivateHosts = opts.allowPrivateHosts === true
116
+ const configChunkSize = opts.chunkSize
117
+ const retryOpts = opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}
118
+
119
+ return {
120
+ id: 'tus',
121
+ resumable: true,
122
+
123
+ async start(ctx) {
124
+ const intent = ctx.intent
125
+ if (!intent.url) {
126
+ throw new UploadEngineError('validation_failed', { message: 'tus.start: intent missing url' })
127
+ }
128
+
129
+ if (allowedHosts.length === 0 && !warnedMissingAllowedHosts) {
130
+ warnedMissingAllowedHosts = true
131
+ console.warn(
132
+ '[duck-upload] TusStrategy: no `allowedHosts` configured. tus upload URLs will be host-unrestricted. ' +
133
+ 'Set TusStrategy.Config.allowedHosts to lock the upload host.',
134
+ )
135
+ }
136
+
137
+ validateUploadUrl(intent.url, 'tus.intent', { allowedHosts, allowPrivateHosts })
138
+
139
+ const url = intent.url
140
+ const total = ctx.file.size
141
+ const chunkSize = Math.max(1, intent.chunkSize ?? configChunkSize ?? DEFAULT_TUS_CHUNK_SIZE)
142
+ const baseHeaders: Record<string, string> = { 'Tus-Resumable': TUS_RESUMABLE, ...(intent.headers ?? {}) }
143
+
144
+ // Start from the persisted offset, then confirm against the server when
145
+ // the transport can HEAD (server offset is authoritative on resume).
146
+ let offset = ctx.readCursor()?.offset ?? 0
147
+
148
+ if (ctx.transport.head) {
149
+ const head = ctx.transport.head
150
+ try {
151
+ const res = await withRetry(() => head({ url, headers: baseHeaders, signal: ctx.signal }), {
152
+ signal: ctx.signal,
153
+ ...retryOpts,
154
+ })
155
+ const serverOffset = parseOffsetHeader(res.headers, 'upload-offset')
156
+ if (serverOffset !== null) offset = serverOffset
157
+ } catch (err) {
158
+ // Abort must propagate; other HEAD failures fall back to the cursor
159
+ // offset and let the first PATCH surface a real error.
160
+ if (ctx.signal?.aborted || (err instanceof Error && err.name === 'AbortError')) throw err
161
+ }
162
+ }
163
+
164
+ // Clamp a stale/oversized persisted offset back into range.
165
+ if (offset > total) offset = total
166
+
167
+ ctx.reportProgress({ uploadedBytes: offset, totalBytes: total })
168
+
169
+ while (offset < total) {
170
+ if (ctx.signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
171
+
172
+ const start = offset
173
+ const end = Math.min(total, start + chunkSize)
174
+ const chunk = ctx.file.slice(start, end)
175
+
176
+ const res = await withRetry(
177
+ () =>
178
+ ctx.transport.patch({
179
+ url,
180
+ body: chunk,
181
+ headers: {
182
+ ...baseHeaders,
183
+ 'Upload-Offset': String(start),
184
+ 'Content-Type': 'application/offset+octet-stream',
185
+ },
186
+ signal: ctx.signal,
187
+ onProgress: (loaded) => {
188
+ ctx.reportProgress({ uploadedBytes: start + loaded, totalBytes: total })
189
+ },
190
+ }),
191
+ { signal: ctx.signal, ...retryOpts },
192
+ )
193
+
194
+ // Trust the server's acknowledged offset; fall back to the local end.
195
+ const acked = parseOffsetHeader(res.headers, 'upload-offset')
196
+ offset = acked !== null ? acked : end
197
+
198
+ if (offset <= start) {
199
+ // No forward progress -> a broken/misconfigured server. Bail rather
200
+ // than spin forever.
201
+ throw new UploadEngineError('upload_failed', {
202
+ message: `tus: server did not advance Upload-Offset (stuck at ${offset} of ${total}).`,
203
+ })
204
+ }
205
+
206
+ ctx.persistCursor({ offset } as C['tus'])
207
+ ctx.reportProgress({ uploadedBytes: offset, totalBytes: total })
208
+ }
209
+ },
210
+ }
211
+ }
@@ -1 +0,0 @@
1
- function e(e){return new Promise(t=>setTimeout(t,e))}export{e as t};