@gentleduck/upload 0.2.5 → 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 +297 -0
- 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 -90
- 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/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- 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/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- 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 +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- 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 +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- 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 +54 -39
- 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 +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- 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 +8 -4
- 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 +126 -0
- 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 +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- 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 +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- 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-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- 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 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -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
|
+
}
|
package/dist/async-DxRWu4le.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
function e(e){return new Promise(t=>setTimeout(t,e))}export{e as t};
|
package/dist/core-BNxznRif.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./async-DxRWu4le.js";const t=500,n=1e4,r=100,i=3,a=3,o=100;function s(e){return{maxConcurrentUploads:Math.max(1,e?.maxConcurrentUploads??3),autoStart:e?.autoStart,progressThrottleMs:Math.max(0,e?.progressThrottleMs??100),validation:e?.validation??{},maxAttempts:Math.max(1,e?.maxAttempts??3),retryPolicy:e?.retryPolicy,maxItems:Math.max(1,e?.maxItems??100),completedItemTTL:e?.completedItemTTL}}function c(){return`upload_${Date.now()}_${Math.random().toString(36).slice(2)}`}function l(e,t,n){let r=n.validation?.[t];if(!r)return null;if(e.size===0)return{code:`empty_file`};if(r.maxSizeBytes!==void 0&&e.size>r.maxSizeBytes)return{code:`file_too_large`,maxBytes:r.maxSizeBytes,size:e.size};if(r.minSizeBytes!==void 0&&e.size<r.minSizeBytes)return{code:`empty_file`};if(r.allowedTypes||r.allowedExtensions){let t=r.allowedTypes||[],n=r.allowedExtensions||[],i=e.name.split(`.`).pop()?.toLowerCase(),a=(e.type||``).toLowerCase(),o=t.length===0?!1:t.some(e=>{if(e.endsWith(`/*`)){let t=e.slice(0,-2).toLowerCase();return a.startsWith(`${t}/`)}return a===e.toLowerCase()}),s=i&&n.length>0?n.some(e=>e.toLowerCase()===i):!1,c=t.length>0,l=n.length>0;if(!(c&&l?o||s:(!c||o)&&(!l||s)))return{code:`type_not_allowed`,allowed:[...t,...n],got:e.type||e.name}}return null}function u(e){return!!e&&typeof e==`object`}function d(e,t){if(!u(e))return Error(`Invalid intent: must be an object`);if(typeof e.strategy!=`string`)return Error(`Invalid intent: missing or invalid strategy field`);if(e.strategy!==t)return Error(`Invalid intent: strategy mismatch (expected ${t}, got ${e.strategy})`);if(typeof e.fileId!=`string`||!e.fileId)return Error(`Invalid intent: missing or invalid fileId`);if(t===`post`){if(typeof e.url!=`string`||!e.url)return Error(`Invalid post intent: missing or invalid url`);try{let t=new URL(e.url);if(![`http:`,`https:`].includes(t.protocol))return Error(`Invalid post intent: url must use http or https protocol`)}catch{return Error(`Invalid post intent: url is not a valid URL`)}if(!e.fields||typeof e.fields!=`object`)return Error(`Invalid post intent: missing or invalid fields`)}else if(t===`multipart`){if(typeof e.uploadId!=`string`||!e.uploadId)return Error(`Invalid multipart intent: missing or invalid uploadId`);if(typeof e.partSize!=`number`||e.partSize<=0)return Error(`Invalid multipart intent: missing or invalid partSize`);if(`parts`in e&&e.parts!==void 0&&!Array.isArray(e.parts))return Error(`Invalid multipart intent: parts must be an array if provided`)}return null}function f(e,t,n,r=0){let i=n.validation?.[t],a=[],o=[],s=i?.maxFiles,c=s===void 0?1/0:Math.max(0,s-r);for(let r of e){if(c<=0){let e=s??0;o.push({file:r,reason:{code:`too_many_files`,max:e}});continue}let e=l(r,t,n);if(e){o.push({file:r,reason:e});continue}a.push(r),--c}return{valid:a,rejected:o}}function p(e){return{name:e.name,size:e.size,type:e.type,lastModified:e.lastModified}}function m(e,t){return e.name===t.name&&e.size===t.size&&e.lastModified===t.lastModified}async function h(e){let t=await e.arrayBuffer(),n=await crypto.subtle.digest(`SHA-256`,t);return Array.from(new Uint8Array(n)).map(e=>e.toString(16).padStart(2,`0`)).join(``)}function g(e,t){let n=e.config?.autoStart;return n===void 0?!1:Array.isArray(n)?n.includes(t):typeof n==`function`?n(t):!1}function _(e,t){if(t)return t(e);if(x(e))return{code:`aborted`,message:`Upload aborted`,reason:String(e.reason??`unknown`),retryable:!1};let n=u(e)&&typeof e.message==`string`?e.message:`Unknown error`;if(String(n).toLowerCase().includes(`network`)||String(n).toLowerCase().includes(`fetch`))return{code:`network`,message:String(n),cause:e,retryable:!0};if(u(e)&&(typeof e.status==`number`||typeof e.statusCode==`number`)){let t=typeof e.status==`number`?e.status:e.statusCode,r=t>=500||t===429;return{code:`http`,status:t,message:String(n),cause:e,retryable:r}}return{code:`unknown`,message:String(n),cause:e,retryable:!1}}function v(e){return`intent`in e&&!!e.intent}function y(e){return`file`in e&&!!e.file}function b(e){return`cursor`in e}function ee(e){return u(e)&&e.strategy===`multipart`&&typeof e.fileId==`string`&&typeof e.uploadId==`string`}function x(e){return u(e)&&e.code===`aborted`}function S(e,t){if(e.retryPolicy)return e.retryPolicy(t);if(t.error.code===`auth`||t.error.code===`validation_failed`||t.error.code===`strategy_missing`||t.error.code===`aborted`)return{retryable:!1};let r=e.maxAttempts;return t.attempt<r?{retryable:!0,delayMs:Math.min(n,500*2**(t.attempt-1))}:{retryable:!1}}function C(e,t,n){let r=Array.from(e.state.items.values()).filter(e=>e.purpose===n&&e.phase!==`canceled`).length,{valid:i,rejected:a}=f(t,n,e.opts.config,r);for(let{file:t,reason:n}of a)e.emitter.emit(`file.rejected`,{reason:n,file:t});let o=Date.now(),s=[];for(let t of i){let r=c(),i=(e.opts.fingerprint??p)(t);s.push({localId:r,purpose:n,file:t,fingerprint:i,createdAt:o}),e.enqueueEffect(async()=>{let a;try{a=await h(t);let n={...i,checksum:a};e.applyInternal({type:`fingerprint.updated`,localId:r,fingerprint:n})}catch(e){typeof window<`u`&&process.env.NODE_ENV===`development`&&console.warn(`[UploadEngine] Failed to calculate checksum:`,e)}if(a&&e.opts.api.findByChecksum)try{let t=await e.opts.api.findByChecksum({checksum:a,purpose:n});if(t){let n=e.state.items.get(r);if(n&&n.phase===`validating`){e.applyInternal({type:`dedupe.ok`,localId:r,result:t});return}}}catch(e){typeof window<`u`&&process.env.NODE_ENV===`development`&&console.warn(`[UploadEngine] Failed to check for existing file:`,e)}let o=e.opts.validateFile?.(t,n);if(o){e.applyInternal({type:`validation.failed`,localId:r,reason:o});return}let s=l(t,n,e.opts.config);if(s){e.applyInternal({type:`validation.failed`,localId:r,reason:s});return}e.applyInternal({type:`validation.ok`,localId:r})})}s.length>0&&e.applyInternal({type:`files.added`,items:s})}function w(e,t){let n=e.state.items.get(t),r=e.inflightIntents.get(t);r&&(r.abort({reason:`cancel`}),e.inflightIntents.delete(t));let i=e.inflightCompletes.get(t);i&&(i.abort({reason:`cancel`}),e.inflightCompletes.delete(t));let a=e.inflightUploads.get(t);if(a&&(a.mode=`cancel`,a.controller.abort({reason:`cancel`})),n&&v(n)&&n.intent.strategy===`multipart`&&e.opts.api.multipart?.abort){let t=n.intent;ee(t)&&e.enqueueEffect(async()=>{try{let n=e.opts.api.multipart?.abort;n&&await n({fileId:t.fileId,uploadId:t.uploadId})}catch{}})}}function T(e,t){let n=e.inflightUploads.get(t);if(n){n.mode=`pause`,n.controller.abort({reason:`pause`});return}}function E(e,t){if(t.type===`startAll`){let n=t.purpose;Array.from(e.state.items.values()).filter(e=>e.phase===`ready`&&(!n||e.purpose===n)).forEach(t=>{e.dispatch({type:`start`,localId:t.localId})});return}if(t.type===`pauseAll`){let n=t.purpose;Array.from(e.state.items.values()).filter(e=>e.phase===`uploading`&&(!n||e.purpose===n)).forEach(t=>{e.dispatch({type:`pause`,localId:t.localId})});return}if(t.type===`cancelAll`){let n=t.purpose;Array.from(e.state.items.values()).filter(e=>e.phase!==`completed`&&e.phase!==`canceled`&&(!n||e.purpose===n)).forEach(t=>{e.dispatch({type:`cancel`,localId:t.localId})});return}if(t.type===`addFiles`){C(e,t.files,t.purpose);return}if(t.type===`pause`){T(e,t.localId),e.applyCommand(t);return}if(t.type===`cancel`){w(e,t.localId),e.applyCommand(t);return}if(t.type===`start`){e.applyCommand(t);return}if(t.type===`resume`){e.applyCommand(t);return}e.applyCommand(t)}function D(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function O(e){return e?.reason}function te(e){if(!e)return`unknown`;if(typeof e==`string`)return e===`pause`||e===`cancel`?e:`unknown`;if(u(e)&&`reason`in e){let t=e.reason;if(t===`pause`||t===`cancel`)return t}if(u(e)&&`kind`in e){let t=e.kind;if(t===`pause`||t===`cancel`)return t}return`unknown`}var k=class extends Error{constructor(e){super(`Upload aborted`),D(this,`code`,`aborted`),D(this,`reason`,void 0),this.reason=te(e)}};function A(e){return new k(e)}function ne(e,t){return e.status===0?Error(`CORS error: The upload server does not allow requests from this origin. Please check CORS configuration.`):Error(`${t} (status: ${e.status})`)}function re(e){let t={};return e.getAllResponseHeaders().split(`\r
|
|
2
|
-
`).map(e=>e.trim()).filter(Boolean).forEach(e=>{let n=e.indexOf(`:`);if(n<=0)return;let r=e.slice(0,n).trim().toLowerCase(),i=e.slice(n+1).trim();r&&(t[r]=i)}),t}function ie(e){return/^https?:\/\//i.test(e)}function ae(e){let t=e.getResponseHeader(`ETag`)??e.getResponseHeader(`etag`);if(t)return t.replace(/"/g,``)}function j(e){return new Promise((t,n)=>{if(!e.url||typeof e.url!=`string`){n(Error(`UploadTransport: missing url`));return}if(!ie(e.url)){n(Error(`UploadTransport: expected absolute URL, got: ${String(e.url)}`));return}let r=new XMLHttpRequest,i=!1,a=e=>{i||(i=!0,s(),e())},o=()=>{try{r.abort()}finally{a(()=>n(A(O(e.signal))))}},s=()=>{e.signal&&e.signal.removeEventListener(`abort`,o),r.upload.onprogress=null,r.onload=null,r.onerror=null,r.onabort=null};if(e.signal?.aborted){a(()=>n(A(O(e.signal))));return}if(e.signal&&e.signal.addEventListener(`abort`,o,{once:!0}),e.onProgress&&(r.upload.onprogress=t=>{t.lengthComputable&&e.onProgress?.(t.loaded,t.total)}),r.onload=()=>{let e=r.status;a(e>=200&&e<300?()=>t({headers:re(r),etag:ae(r)}):()=>n(Error(`Upload failed with status ${e}`)))},r.onerror=()=>a(()=>n(ne(r,`Network error during upload`))),r.onabort=()=>a(()=>n(A(e.signal?.aborted?O(e.signal):`unknown`))),r.open(e.method,e.url),e.headers)for(let[t,n]of Object.entries(e.headers))r.setRequestHeader(t,n);r.send(e.body)})}function M(){return{async postForm(e){let t=new FormData;for(let[n,r]of Object.entries(e.fields))t.append(n,r);return t.append(`file`,e.file,e.filename??`file`),{headers:(await j({method:`POST`,url:e.url,body:t,signal:e.signal,onProgress:e.onProgress})).headers}},async put(e){let t=await j({method:`PUT`,url:e.url,body:e.body,signal:e.signal,headers:e.headers,onProgress:e.onProgress});return{headers:t.headers,etag:t.etag}},async patch(e){return{headers:(await j({method:`PATCH`,url:e.url,body:e.body,signal:e.signal,headers:e.headers,onProgress:e.onProgress})).headers}}}}const N=`snapshots`;function P(){return new Promise((e,t)=>{let n=indexedDB.open(`upload-engine`,1);n.onupgradeneeded=()=>{let e=n.result;e.objectStoreNames.contains(N)||e.createObjectStore(N)},n.onsuccess=()=>e(n.result),n.onerror=()=>t(n.error)})}function F(e){return new Promise((t,n)=>{e.onsuccess=()=>t(e.result),e.onerror=()=>n(e.error)})}function I(e){return new Promise((t,n)=>{e.oncomplete=()=>t(),e.onabort=()=>n(e.error??Error(`IndexedDB transaction aborted`)),e.onerror=()=>n(e.error??Error(`IndexedDB transaction error`))})}const L={async load(e){if(typeof indexedDB>`u`)return null;let t=(await P()).transaction(N,`readonly`),n=t.objectStore(N);try{let r=await F(n.get(e));return await I(t),r??null}catch{try{await I(t)}catch{}return null}},async save(e,t){if(typeof indexedDB>`u`)return;let n=(await P()).transaction(N,`readwrite`),r=n.objectStore(N);try{r.put(t,e),await I(n)}catch{try{n.abort()}catch{}}},async clear(e){if(typeof indexedDB>`u`)return;let t=(await P()).transaction(N,`readwrite`),n=t.objectStore(N);try{n.delete(e),await I(t)}catch{try{t.abort()}catch{}}}},R={load(e){if(typeof localStorage>`u`)return null;let t=localStorage.getItem(e);if(!t)return null;try{return JSON.parse(t)}catch{return null}},save(e,t){typeof localStorage>`u`||localStorage.setItem(e,JSON.stringify(t))},clear(e){typeof localStorage>`u`||localStorage.removeItem(e)}},z=new Map,B={load(e){return z.get(e)??null},save(e,t){z.set(e,t)},clear(e){z.delete(e)}};function V(e,t){let n={};for(let t of e.items.values()){if(!v(t)||t.phase===`completed`||t.phase===`canceled`||t.phase===`error`)continue;let e=b(t)?t.cursor:void 0,r=`progress`in t&&t.progress?{uploadedBytes:t.progress.uploadedBytes,totalBytes:t.progress.totalBytes,pct:t.progress.pct}:void 0,i={id:t.localId,purpose:t.purpose,status:t.phase,file:{name:t.fingerprint.name,size:t.fingerprint.size,type:t.fingerprint.type,lastModified:t.fingerprint.lastModified,checksum:t.fingerprint.checksum},intent:t.intent,cursor:e,progress:r};n[i.id]=i}return{version:t,createdAt:Date.now(),items:n}}function H(e,t){if(!t.isPurpose||!t.isIntent||!U(e))return null;let n=new Map;for(let r of Object.values(e.items)){let i=W(r);if(!i||!t.isPurpose(i.purpose)||!t.isIntent(i.intent))continue;let a=i.intent.strategy;if(typeof a!=`string`||!t.hasStrategy(a)||!i.cursor||!K(i.cursor,t.hasStrategy)||i.cursor.strategy!==a)continue;let o=i.progress?.totalBytes??i.file.size,s=i.progress?.uploadedBytes??0,c=typeof i.progress?.pct==`number`?i.progress.pct:o>0?Math.min(100,Math.max(0,s/o*100)):0;n.set(i.id,{phase:`paused`,localId:i.id,purpose:i.purpose,fingerprint:{name:i.file.name,size:i.file.size,type:i.file.type,lastModified:i.file.lastModified,checksum:i.file.checksum},intent:i.intent,cursor:i.cursor,progress:{uploadedBytes:s,totalBytes:o,pct:c},pausedAt:Date.now(),createdAt:e.createdAt??Date.now(),file:void 0})}return{items:n}}function U(e){return!u(e)||typeof e.version!=`number`||typeof e.createdAt!=`number`?!1:u(e.items)}function W(e){if(!u(e))return null;let t=typeof e.id==`string`?e.id:null,n=typeof e.purpose==`string`?e.purpose:null,r=typeof e.status==`string`?e.status:null,i=e.intent;if(!t||!n||!r||!u(e.file))return null;let a=typeof e.file.name==`string`?e.file.name:null,o=typeof e.file.size==`number`?e.file.size:null,s=typeof e.file.type==`string`?e.file.type:null,c=typeof e.file.lastModified==`number`?e.file.lastModified:null,l=typeof e.file.checksum==`string`?e.file.checksum:void 0;if(!a||o===null||!s||c===null)return null;let d=G(e.progress);return{id:t,purpose:n,status:r,file:{name:a,size:o,type:s,lastModified:c,checksum:l},intent:i,cursor:e.cursor,progress:d}}function G(e){if(!u(e)||typeof e.uploadedBytes!=`number`||typeof e.totalBytes!=`number`)return;let t=typeof e.pct==`number`?e.pct:void 0;return{uploadedBytes:e.uploadedBytes,totalBytes:e.totalBytes,pct:t}}function K(e,t){return!(!u(e)||typeof e.strategy!=`string`||!t(e.strategy))}function q(){let e={};return{on(t,n){let r=e[t];return r||(r=new Set,e[t]=r),r.add(n),()=>{let r=e[t];r&&(r.delete(n),r.size===0&&delete e[t])}},off(t,n){let r=e[t];return r?(r.delete(n),r.size===0&&delete e[t],()=>{}):()=>{}},emit(t,n){let r=e[t];r&&r.forEach(e=>{try{e(n)}catch(e){typeof window<`u`&&process.env.NODE_ENV===`development`&&console.error(`[UploadEngine] Error in event listener for "${t}":`,e)}})}}}function J(){return function(e,t){let n=new Map(e.items),r=(e,t)=>n.set(e,t),i=Date.now();if((e=>`type`in e&&typeof e.type==`string`&&!e.type.includes(`.`))(t)){switch(t.type){case`addFiles`:break;case`start`:{let e=n.get(t.localId);if(!e||e.phase!==`ready`)break;r(t.localId,{...e,phase:`queued`,requestedAt:i});break}case`resume`:{let e=n.get(t.localId);if(!e||e.phase!==`paused`||!e.file)break;r(t.localId,{...e,file:e.file,phase:`queued`,requestedAt:i});break}case`pause`:{let e=n.get(t.localId);if(!e)break;e.phase===`queued`&&r(t.localId,{...e,phase:`ready`});break}case`cancel`:{let e=n.get(t.localId);if(!e||e.phase===`completed`||e.phase===`canceled`)break;r(t.localId,Y(e,i));break}case`retry`:{let e=n.get(t.localId);if(!e||e.phase!==`error`||!e.retryable||!e.file)break;e.intent?e.progress&&e.progress.pct===100?r(t.localId,{...e,progress:e.progress,intent:e.intent,file:e.file,phase:`completing`,completingAt:i}):r(t.localId,{...e,file:e.file,intent:e.intent,phase:`ready`}):r(t.localId,{...e,phase:`creating_intent`,file:e.file,attempt:(e.attempt??1)+1});break}case`rebind`:{let e=n.get(t.localId);if(!e)break;e.phase===`paused`&&!e.file&&m(p(t.file),e.fingerprint)&&r(t.localId,{...e,file:t.file});break}case`remove`:n.delete(t.localId);break;case`startAll`:case`pauseAll`:case`cancelAll`:break}return{items:n}}switch(t.type){case`files.added`:{let e=t;for(let t of e.items)n.has(t.localId)||r(t.localId,{phase:`validating`,localId:t.localId,purpose:t.purpose,file:t.file,fingerprint:t.fingerprint,createdAt:t.createdAt});break}case`fingerprint.updated`:{let e=t,i=n.get(e.localId);if(!i)break;r(e.localId,{...i,fingerprint:e.fingerprint});break}case`validation.ok`:{let e=n.get(t.localId);if(!e||e.phase!==`validating`)break;r(t.localId,{...e,phase:`creating_intent`,attempt:1});break}case`validation.failed`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`validating`)break;r(e.localId,{...a,phase:`error`,error:{code:`validation_failed`,reason:e.reason,message:String(e.reason)},retryable:!1,attempt:1,failedAt:i});break}case`intent.ok`:{let e=t,i=n.get(e.localId);if(!i||i.phase!==`creating_intent`)break;r(e.localId,{...i,phase:`ready`,intent:e.intent});break}case`intent.failed`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`creating_intent`)break;r(e.localId,{...a,phase:`error`,error:e.error,retryable:e.retryable,failedAt:i});break}case`upload.begin`:{let e=t,i=n.get(e.localId);if(!i||i.phase!==`queued`)break;let a=i.file.size,o=i.progress,s=o?{...o,totalBytes:a,pct:X(o.uploadedBytes,a)}:{uploadedBytes:0,totalBytes:a,pct:0};r(e.localId,{...i,phase:`uploading`,progress:s,startedAt:e.startedAt});break}case`upload.progress`:{let e=t,i=n.get(e.localId);if(!i||i.phase!==`uploading`)break;let a={uploadedBytes:e.uploadedBytes,totalBytes:e.totalBytes,pct:X(e.uploadedBytes,e.totalBytes)};r(e.localId,{...i,progress:a});break}case`cursor.updated`:{let e=t,i=n.get(e.localId);if(!i)break;(i.phase===`uploading`||i.phase===`queued`||i.phase===`paused`)&&r(e.localId,{...i,cursor:e.cursor});break}case`upload.ok`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`uploading`)break;r(e.localId,{...a,phase:`completing`,progress:{uploadedBytes:a.file.size,totalBytes:a.file.size,pct:100},completingAt:i});break}case`upload.failed`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`uploading`)break;r(e.localId,{...a,phase:`error`,error:e.error,retryable:e.retryable,attempt:1,failedAt:i});break}case`paused`:{let e=t,i=n.get(e.localId);if(!i||i.phase!==`uploading`)break;r(e.localId,{...i,phase:`paused`,cursor:e.cursor,pausedAt:e.pausedAt});break}case`canceled`:{let e=t,i=n.get(e.localId);if(!i)break;r(e.localId,Y(i,e.canceledAt));break}case`complete.ok`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`completing`)break;r(e.localId,{...a,phase:`completed`,completedBy:`upload`,result:e.result,completedAt:i});break}case`dedupe.ok`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`validating`)break;r(e.localId,{phase:`completed`,localId:a.localId,file:a.file,purpose:a.purpose,fingerprint:a.fingerprint,result:e.result,completedBy:`dedupe`,completedAt:i,createdAt:a.createdAt});break}case`complete.failed`:{let e=t,a=n.get(e.localId);if(!a||a.phase!==`completing`)break;let o=(a.attempt??1)+1;r(e.localId,{...a,phase:`error`,error:e.error,retryable:e.retryable,attempt:o,failedAt:i});break}}return{items:n}}}function Y(e,t){return{phase:`canceled`,localId:e.localId,purpose:e.purpose,fingerprint:e.fingerprint,canceledAt:t,createdAt:e.createdAt,file:`file`in e?e.file:void 0,intent:`intent`in e?e.intent:void 0,cursor:`cursor`in e?e.cursor:void 0,progress:`progress`in e?e.progress:void 0,attempt:`attempt`in e?e.attempt:void 0}}function X(e,t){return t<=0?0:Math.min(100,e/t*100)}function Z(e,t){let n=e.config?.maxItems,r=e.config?.completedItemTTL,i=Date.now(),a=[],o=Array.from(t.items.entries());if(r!==void 0)for(let[e,t]of o)((t.phase===`completed`||t.phase===`canceled`)&&`completedAt`in t&&typeof t.completedAt==`number`&&i-t.completedAt>r||t.phase===`canceled`&&`canceledAt`in t&&typeof t.canceledAt==`number`&&i-t.canceledAt>r)&&a.push(e);let s=o.filter(([e])=>!a.includes(e));if(n!==void 0&&s.length>n){let e=s.filter(([,e])=>e.phase===`completed`||e.phase===`canceled`).sort(([,e],[,t])=>(`completedAt`in e?e.completedAt:`canceledAt`in e?e.canceledAt:0)-(`completedAt`in t?t.completedAt:`canceledAt`in t?t.canceledAt:0)),t=s.length-n;for(let n=0;n<t&&n<e.length;n++)a.push(e[n][0])}if(a.length===0)return null;let c=new Map(t.items);for(let e of a)c.delete(e);return{...t,items:c}}async function oe(t,n){let r=t.state.items.get(n);if(!r||r.phase!==`creating_intent`||t.inflightIntents.has(n))return;let i=new AbortController;t.inflightIntents.set(n,i);try{let e=await t.opts.api.createIntent({purpose:r.purpose,contentType:r.file.type||`application/octet-stream`,size:r.file.size,filename:r.file.name,checksum:r.fingerprint.checksum},{signal:i.signal});t.inflightIntents.delete(n);let a=t.state.items.get(n);if(!a||a.phase!==`creating_intent`)return;let o=d(e,e.strategy);if(o){let e={code:`validation_failed`,message:`Invalid intent from backend: ${o.message}`,cause:o,retryable:!1};t.applyInternal({type:`intent.failed`,localId:n,error:e,retryable:!1});return}t.applyInternal({type:`intent.ok`,localId:n,intent:e})}catch(a){if(t.inflightIntents.delete(n),i.signal.aborted)return;let o=_(a,t.opts.errorNormalizer),s={...o,message:`${o.message} (file: ${r.fingerprint.name}, size: ${r.file.size} bytes, purpose: ${r.purpose})`},c=S(t.opts.config,{phase:`intent`,attempt:r.attempt,error:s});t.applyInternal({type:`intent.failed`,localId:n,error:s,retryable:c.retryable}),c.retryable&&c.delayMs!==void 0&&t.enqueueEffect(async()=>{await e(c.delayMs),t.dispatch({type:`retry`,localId:n})})}}async function se(t,n){let r=t.state.items.get(n);if(!r||r.phase!==`completing`||t.inflightCompletes.has(n))return;let i=new AbortController;t.inflightCompletes.set(n,i);try{let e=await t.opts.api.complete({fileId:r.intent.fileId},{signal:i.signal});t.inflightCompletes.delete(n);let a=t.state.items.get(n);if(!a||a.phase!==`completing`)return;t.applyInternal({type:`complete.ok`,localId:n,result:e})}catch(r){if(t.inflightCompletes.delete(n),i.signal.aborted)return;let a=_(r,t.opts.errorNormalizer),o=t.state.items.get(n),s=o?{...a,message:`${a.message} (file: ${o.fingerprint.name}, fileId: ${(v(o)&&o.intent?.fileId)??`unknown`})`}:a,c=o&&`attempt`in o?o.attempt??1:1,l=S(t.opts.config,{phase:`complete`,attempt:c,error:s});t.applyInternal({type:`complete.failed`,localId:n,error:s,retryable:l.retryable}),l.retryable&&l.delayMs!==void 0&&t.enqueueEffect(async()=>{await e(l.delayMs),t.dispatch({type:`retry`,localId:n})})}}async function ce(t,n){let r=t.state.items.get(n);if(!r||r.phase!==`uploading`||!v(r)){t.inflightUploads.delete(n);return}let i=t.inflightUploads.get(n);if(i||(i={controller:new AbortController,mode:`normal`,started:!1},t.inflightUploads.set(n,i)),i.started)return;i.started=!0;let a=i.controller,o=()=>{let e=t.state.items.get(n);!e||e.phase===`canceled`||t.applyInternal({type:`canceled`,localId:n,canceledAt:Date.now()})},s=e=>{let r=t.state.items.get(n);!r||r.phase===`paused`||t.applyInternal({type:`paused`,localId:n,cursor:e,pausedAt:Date.now()})};if(a.signal.aborted){if(t.inflightUploads.delete(n),i.mode===`pause`){let e=t.state.items.get(n),i;r&&v(r)&&(i=r.intent.strategy);let a=(e&&b(e)?e.cursor:void 0)||(i?{strategy:i,value:void 0}:void 0);a&&s(a);return}if(i.mode===`cancel`){o();return}return}let c=r.intent.strategy;if(!t.opts.strategies.has(c)){t.inflightUploads.delete(n);let e={code:`strategy_missing`,message:`Strategy not found: ${c}`,strategy:String(c),retryable:!1};t.applyInternal({type:`upload.failed`,localId:n,error:e,retryable:!1});return}let l=t.opts.strategies.get(c);if(!l){t.inflightUploads.delete(n);let e={code:`strategy_missing`,message:`Strategy not found: ${r.intent.strategy}`,strategy:String(r.intent.strategy),retryable:!1};t.applyInternal({type:`upload.failed`,localId:n,error:e,retryable:!1});return}let u=0,d=t.opts.config.progressThrottleMs;try{if(await l.start({file:r.file,intent:r.intent,signal:a.signal,transport:t.opts.transport,api:t.opts.api,readCursor:()=>{let e=t.state.items.get(n);if(!(!e||!b(e)||!e.cursor)&&e.cursor.strategy===c)return e.cursor.value},persistCursor:e=>{let r={strategy:c,value:e};t.applyInternal({type:`cursor.updated`,localId:n,cursor:r})},reportProgress:e=>{let r=Date.now();r-u<d||(t.applyInternal({type:`upload.progress`,localId:n,uploadedBytes:e.uploadedBytes,totalBytes:e.totalBytes}),u=r)}}),t.inflightUploads.delete(n),i?.mode===`cancel`){o();return}if(a.signal.aborted&&i?.mode===`pause`){let e=t.state.items.get(n);s((e&&b(e)?e.cursor:void 0)||{strategy:c,value:void 0});return}t.applyInternal({type:`upload.ok`,localId:n})}catch(r){if(t.inflightUploads.delete(n),a.signal.aborted){if(i?.mode===`pause`){let e=t.state.items.get(n);s((e&&b(e)?e.cursor:void 0)||{strategy:c,value:void 0});return}if(i?.mode===`cancel`){o();return}o();return}let l=_(r,t.opts.errorNormalizer),u=t.state.items.get(n),d=u?{...l,message:`${l.message} (file: ${u.fingerprint.name}, size: ${(y(u)&&u.file?.size)??0} bytes, phase: ${u.phase})`}:l,f=S(t.opts.config,{phase:`upload`,attempt:1,error:d});t.applyInternal({type:`upload.failed`,localId:n,error:d,retryable:f.retryable}),f.retryable&&f.delayMs!==void 0&&t.enqueueEffect(async()=>{await e(f.delayMs),t.dispatch({type:`retry`,localId:n})})}}function le(e){if(!e.scheduling){e.scheduling=!0;try{ue(e),fe(e),de(e)}finally{e.scheduling=!1}}}function ue(e){for(let t of e.state.items.values())t.phase===`creating_intent`&&(e.inflightIntents.has(t.localId)||e.enqueueEffect(()=>oe(e,t.localId)))}function de(e){for(let t of e.state.items.values())t.phase===`completing`&&(e.inflightCompletes.has(t.localId)||e.enqueueEffect(()=>se(e,t.localId)))}function fe(e){let t=Math.max(1,e.opts.config.maxConcurrentUploads),n=e.inflightUploads.size;for(let t of e.state.items.values())t.phase===`ready`&&g(e.opts,t.purpose)&&e.applyCommand({type:`start`,localId:t.localId});if(n>=t)return;let r=Array.from(e.state.items.values()).filter(pe);if(r.length===0)return;let i=Math.max(0,t-n),a=r.slice(0,i);for(let t of a){if(e.inflightUploads.has(t.localId))continue;let n=new AbortController;e.inflightUploads.set(t.localId,{controller:n,mode:`normal`,started:!1}),e.applyInternal({type:`upload.begin`,localId:t.localId,startedAt:Date.now()}),ce(e,t.localId)}}function pe(e){return e.phase===`queued`}function me(e){let t=Q(e),n=t.persistence,r=null,i=Math.max(0,n?.debounceMs??200),a=async()=>{if(n)try{let e=(n.serialize??V)(s.state,n.version);if(!e.items||Object.keys(e.items).length===0){await n.adapter.clear(n.key);return}await n.adapter.save(n.key,e)}catch(e){typeof window<`u`&&process.env.NODE_ENV===`development`&&console.warn(`[UploadEngine] persistence flush failed:`,e)}},o=()=>{if(n){if(i===0){a();return}r&&clearTimeout(r),r=setTimeout(()=>void a(),i)}},s={opts:t,state:t.initialState??{items:new Map},listeners:new Set,emitter:q(),reduce:J(),inflightUploads:new Map,inflightIntents:new Map,inflightCompletes:new Map,effectQueue:[],processingEffects:!1,scheduling:!1,notify(){s.listeners.forEach(e=>{e()})},scheduleWork(){le(s)},applyInternal(e){let t=s.state;s.state=s.reduce(t,e),s.opts.hooks?.onInternalEvent?.(e,s.state),he(s,e);let n=Z(s.opts,s.state);n&&(s.state=n),o(),s.notify(),s.scheduleWork()},applyCommand(e){let t=s.state,n=s.reduce(t,e);s.state=n,ge(s,e,t,n);let r=Z(s.opts,s.state);r&&(s.state=r),o(),s.notify(),s.scheduleWork()},enqueueEffect(e){s.effectQueue.push(e),s.processingEffects||s.processEffects()},async processEffects(){for(s.processingEffects=!0;s.effectQueue.length>0;){let e=s.effectQueue.shift();if(e)try{await e()}catch(e){if(typeof window<`u`&&process.env.NODE_ENV===`development`){let t=e instanceof Error?e:Error(String(e)),n={queueLength:s.effectQueue.length,processing:s.processingEffects,timestamp:new Date().toISOString()};console.error(`[UploadEngine] Effect error:`,t,n)}}}s.processingEffects=!1},dispatch:()=>{throw Error(`[UploadEngine] dispatch not initialized`)}};if(n&&!t.initialState&&typeof window<`u`)try{let e=n.adapter.load(n.key);Promise.resolve(e).then(e=>{if(!e)return;let r=(n.deserialize??H)(e,{isPurpose:n.isPurpose,isIntent:n.isIntent,hasStrategy:t.strategies.has});if(r){if(s.state.items.size===0)s.state=r;else{let e=new Map(s.state.items);for(let[t,n]of r.items.entries())e.has(t)||e.set(t,n);s.state={...s.state,items:e}}s.notify(),s.scheduleWork()}}).catch(()=>void 0)}catch{}return s}function Q(e){return{...e,config:s(e.config),transport:e.transport??M()}}function he(e,t){switch(t.type){case`files.added`:for(let n of t.items)e.emitter.emit(`file.added`,{localId:n.localId,purpose:n.purpose,file:n.file,fingerprint:n.fingerprint});break;case`validation.ok`:e.emitter.emit(`validation.ok`,{localId:t.localId}),e.emitter.emit(`intent.creating`,{localId:t.localId});break;case`validation.failed`:e.emitter.emit(`validation.failed`,{localId:t.localId,reason:t.reason});break;case`intent.ok`:e.emitter.emit(`intent.created`,{localId:t.localId,intent:t.intent});break;case`intent.failed`:e.emitter.emit(`intent.failed`,{localId:t.localId,error:t.error,retryable:t.retryable});break;case`upload.begin`:e.emitter.emit(`upload.started`,{localId:t.localId});break;case`upload.progress`:{let n=t.totalBytes>0?Math.min(100,t.uploadedBytes/t.totalBytes*100):0;e.emitter.emit(`upload.progress`,{localId:t.localId,pct:n,uploadedBytes:t.uploadedBytes,totalBytes:t.totalBytes});break}case`cursor.updated`:e.emitter.emit(`upload.cursor`,{localId:t.localId,cursor:t.cursor});break;case`upload.ok`:e.emitter.emit(`upload.completing`,{localId:t.localId});break;case`upload.failed`:e.emitter.emit(`upload.error`,{localId:t.localId,error:t.error,retryable:t.retryable});break;case`dedupe.ok`:e.emitter.emit(`upload.completed`,{localId:t.localId,result:t.result,completedBy:`dedupe`});break;case`complete.ok`:e.emitter.emit(`upload.completed`,{localId:t.localId,result:t.result,completedBy:`upload`});break;case`complete.failed`:e.emitter.emit(`upload.error`,{localId:t.localId,error:t.error,retryable:t.retryable});break;case`paused`:e.emitter.emit(`upload.paused`,{localId:t.localId,cursor:t.cursor});break;case`canceled`:e.emitter.emit(`upload.canceled`,{localId:t.localId});break;case`fingerprint.updated`:break}}function ge(e,t,n,r){if(t.type===`start`||t.type===`resume`){let i=n.items.get(t.localId),a=r.items.get(t.localId);if(!i||!a||a.phase!==`queued`)return;i.phase===`paused`?e.emitter.emit(`upload.resumed`,{localId:t.localId}):e.emitter.emit(`upload.queued`,{localId:t.localId})}if(t.type===`cancel`){let i=n.items.get(t.localId),a=r.items.get(t.localId);if(!i||!a)return;a.phase===`canceled`&&i.phase!==`canceled`&&e.emitter.emit(`upload.canceled`,{localId:t.localId})}}function $(e){let t=me(e);t.dispatch=e=>E(t,e);let n={on:t.emitter.on.bind(t.emitter),off:t.emitter.off.bind(t.emitter),dispatch:t.dispatch,getSnapshot:()=>t.state};for(let t of e.plugins||[])try{t.setup(n)}catch(e){typeof window<`u`&&process.env.NODE_ENV===`development`&&console.error(`[UploadEngine] Plugin "${t.name}" failed to setup:`,e)}return{...n,subscribe:e=>(t.listeners.add(e),()=>t.listeners.delete(e)),waitFor(e){return _e(t,e)}}}function _e(e,t){if(t.length===0)return Promise.resolve([]);let n=new Set(t),r=new Map,i=()=>{for(let t of n){let i=e.state.items.get(t);if(!i){r.set(t,{localId:t,status:`missing`}),n.delete(t);continue}if(i.phase===`completed`){r.set(t,{localId:t,status:`completed`,completedBy:i.completedBy,result:i.result}),n.delete(t);continue}if(i.phase===`error`){r.set(t,{localId:t,status:`error`,error:i.error}),n.delete(t);continue}i.phase===`canceled`&&(r.set(t,{localId:t,status:`canceled`}),n.delete(t))}};return i(),n.size===0?Promise.resolve(t.map(e=>r.get(e)??{localId:e,status:`missing`})):new Promise(a=>{let o=()=>{i(),n.size===0&&(e.listeners.delete(o),a(t.map(e=>r.get(e)??{localId:e,status:`missing`})))};e.listeners.add(o)})}function ve(e){return $(e)}export{t as C,r as S,c as _,H as a,a as b,R as c,p as d,m as f,l as g,u as h,q as i,L as l,d as m,$ as n,V as o,f as p,J as r,B as s,ve as t,M as u,s as v,n as w,o as x,i as y};
|