@gentleduck/upload 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -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 {
|
|
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
|
|
13
|
-
C extends
|
|
14
|
-
P extends string
|
|
15
|
-
R extends
|
|
16
|
-
>():
|
|
17
|
-
const map: Partial<{ [K in keyof M & string]:
|
|
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
|
+
}
|
package/dist/async-DxRWu4le.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
function e(e){return new Promise(t=>setTimeout(t,e))}export{e as t};
|