@gotcos/glasses-server 6.24.5 → 6.26.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/CHANGELOG.md +56 -0
- package/package.json +1 -1
- package/server/index.ts +45 -3
- package/server/lib/media-store.ts +241 -12
- package/server/lib/rich-media-safety.ts +206 -44
- package/server/lib/upload-session.ts +409 -0
- package/server/lib/video-compression.ts +376 -0
- package/server/routes/health.ts +20 -2
- package/server/routes/media.ts +471 -16
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Chunked upload sessions (contract §2 wire shape, §6 v1 decisions).
|
|
2
|
+
//
|
|
3
|
+
// A chunked upload is ONE staging file appended across many requests, then handed
|
|
4
|
+
// to the EXISTING ingest. This module owns only the bookkeeping — which upload,
|
|
5
|
+
// how many bytes are committed, which index comes next, when it expires. It never
|
|
6
|
+
// validates media, never writes into assets/, and never ingests: forking any of
|
|
7
|
+
// that would be a second place for the safety rules to rot (contract §6).
|
|
8
|
+
//
|
|
9
|
+
// Time and filesystem are injectable so the route tests can drive expiry and a
|
|
10
|
+
// failed write without real timers or a real disk fault. media-store.ts already
|
|
11
|
+
// uses that constructor-dependency style for compressVideoFile.
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { closeSync, ftruncateSync, openSync, statSync, writeSync } from 'node:fs'
|
|
15
|
+
import { getMediaStore, STAGED_TTL_MS, type MediaStagingFile } from './media-store.js'
|
|
16
|
+
import { MAX_CHUNKED_MEDIA_BYTES, MEDIA_CHUNK_BYTES } from './rich-media-safety.js'
|
|
17
|
+
|
|
18
|
+
/** An in-flight chunked upload IS an unsubmitted upload, so it retires on the
|
|
19
|
+
* same retention clock as one — contract §2, "expire on the existing
|
|
20
|
+
* quarantine/retention clock". Fixed from init rather than sliding: the
|
|
21
|
+
* advertised 2 GiB ceiling at the client's own 250 KiB/s floor is ~2.4 hours,
|
|
22
|
+
* so 4 hours covers a legitimate worst case, while a sliding window would let
|
|
23
|
+
* a slow drip hold that disk indefinitely. */
|
|
24
|
+
export const CHUNKED_UPLOAD_TTL_MS = STAGED_TTL_MS
|
|
25
|
+
|
|
26
|
+
/** Every live session may hold up to `chunkedMaxBytes` of staging file, so this
|
|
27
|
+
* count is a DISK bound, not a throughput one. The client sends one chunk at a
|
|
28
|
+
* time from one device (§6, "sequential and in-order"); this leaves headroom
|
|
29
|
+
* for a retry and a second device without letting a looping client reserve
|
|
30
|
+
* unbounded disk. */
|
|
31
|
+
export const MAX_CONCURRENT_CHUNKED_UPLOADS = 8
|
|
32
|
+
|
|
33
|
+
const UPLOAD_ID_PATTERN = /^u_[0-9a-f]{24}$/
|
|
34
|
+
|
|
35
|
+
/** Ids are minted here, so they are validated here. A value that cannot have
|
|
36
|
+
* come from this module is treated as unknown (404) rather than reaching the
|
|
37
|
+
* registry map — the id is a path component. */
|
|
38
|
+
export function isValidUploadId(value: unknown): value is string {
|
|
39
|
+
return typeof value === 'string' && UPLOAD_ID_PATTERN.test(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type UploadSessionErrorCode =
|
|
43
|
+
| 'upload_not_found'
|
|
44
|
+
| 'chunk_out_of_order'
|
|
45
|
+
| 'attachment_too_large'
|
|
46
|
+
| 'incomplete_upload'
|
|
47
|
+
| 'upload_size_mismatch'
|
|
48
|
+
| 'invalid_total_bytes'
|
|
49
|
+
| 'chunk_bytes_required'
|
|
50
|
+
| 'chunked_upload_unavailable'
|
|
51
|
+
| 'upload_staging_failed'
|
|
52
|
+
|
|
53
|
+
/** `detail` is merged into the JSON error body by the route, so a 409 can carry
|
|
54
|
+
* `expectedIndex` and a 400 can carry the byte counts without the route
|
|
55
|
+
* re-deriving them from state it would have to look up again. */
|
|
56
|
+
export class UploadSessionError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
readonly code: UploadSessionErrorCode,
|
|
59
|
+
message: string,
|
|
60
|
+
readonly detail: Readonly<Record<string, number>> = {},
|
|
61
|
+
) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = 'UploadSessionError'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface UploadSession {
|
|
68
|
+
uploadId: string
|
|
69
|
+
/** Absolute path inside the media store's tmp/. Boot reconcile rm -rf's that
|
|
70
|
+
* directory, which is exactly why resume is within a boot only (§6). */
|
|
71
|
+
stagingPath: string
|
|
72
|
+
/** The client's DECLARED total from init, already bounded to the ceiling. */
|
|
73
|
+
totalBytes: number
|
|
74
|
+
receivedBytes: number
|
|
75
|
+
nextIndex: number
|
|
76
|
+
mime?: string
|
|
77
|
+
label?: string
|
|
78
|
+
capturedAt?: string
|
|
79
|
+
sessionId?: string
|
|
80
|
+
createdAtMs: number
|
|
81
|
+
expiresAtMs: number
|
|
82
|
+
/** Removes the staging file. Idempotent, and a no-op once ingest has moved
|
|
83
|
+
* the file out — same handle contract as MediaStore.createStagingFile(). */
|
|
84
|
+
dispose: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The resume-probe view (contract §2 GET). `expiresAt` is ISO 8601 to match
|
|
88
|
+
* every other expiresAt on the media wire (MediaAttachmentRef.expiresAt). */
|
|
89
|
+
export interface UploadSessionProgress {
|
|
90
|
+
uploadId: string
|
|
91
|
+
totalBytes: number
|
|
92
|
+
receivedBytes: number
|
|
93
|
+
nextIndex: number
|
|
94
|
+
expiresAt: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CreateUploadInput {
|
|
98
|
+
/** Untrusted client claim. Validated here because the ceiling is this
|
|
99
|
+
* module's business. */
|
|
100
|
+
totalBytes: unknown
|
|
101
|
+
mime?: string
|
|
102
|
+
label?: string
|
|
103
|
+
capturedAt?: string
|
|
104
|
+
sessionId?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Filesystem seam. `writeAt` MUST leave the file exactly `position +
|
|
108
|
+
* bytes.length` bytes long — the truncation is load-bearing, see appendChunk. */
|
|
109
|
+
export interface UploadSessionFs {
|
|
110
|
+
create: (path: string) => void
|
|
111
|
+
writeAt: (path: string, bytes: Buffer, position: number) => void
|
|
112
|
+
sizeOf: (path: string) => number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* fs.writeSync MAY RETURN SHORT, and discarding the count is the one path that defeats
|
|
117
|
+
* finalize's assembled-size re-verification: writeAt then ftruncates to the ASSUMED
|
|
118
|
+
* length, so the file ends up exactly the expected size with a zero-filled hole where
|
|
119
|
+
* the unwritten tail belongs. The size check passes and a silently corrupt video is
|
|
120
|
+
* published.
|
|
121
|
+
*
|
|
122
|
+
* Throwing is the correct response and needs no new client handling: the session
|
|
123
|
+
* counters commit only after writeAt returns, so a throw leaves them untouched and the
|
|
124
|
+
* client's retry at the same nextIndex overwrites from the same offset.
|
|
125
|
+
*
|
|
126
|
+
* Extracted rather than inlined so it is directly testable — inline, no test could
|
|
127
|
+
* reach it without stubbing a module-level fs import, and a mutation removing it passed.
|
|
128
|
+
*/
|
|
129
|
+
export function assertFullWrite(written: number, expected: number): void {
|
|
130
|
+
if (written !== expected) {
|
|
131
|
+
throw new Error(`short chunk write: ${written} of ${expected} bytes`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The real implementation, exported so a test can DECORATE it (inject one
|
|
136
|
+
* failing write and delegate the rest) rather than re-implement it — a fake that
|
|
137
|
+
* restates writeAt would pass forever while this drifted. */
|
|
138
|
+
export const defaultUploadSessionFs: UploadSessionFs = {
|
|
139
|
+
create: (path) => {
|
|
140
|
+
// Created eagerly and synchronously so the file provably exists before init
|
|
141
|
+
// answers: an upload whose staging file appears later cannot be swept, and
|
|
142
|
+
// a resume probe would report progress against a path that is not there.
|
|
143
|
+
closeSync(openSync(path, 'w', 0o600))
|
|
144
|
+
},
|
|
145
|
+
writeAt: (path, bytes, position) => {
|
|
146
|
+
const fd = openSync(path, 'r+')
|
|
147
|
+
try {
|
|
148
|
+
// fs.writeSync MAY RETURN SHORT. Discarding the count and then truncating to
|
|
149
|
+
// the assumed length is the one path that defeats finalize's assembled-size
|
|
150
|
+
// re-verification: the file ends up exactly the expected length with a
|
|
151
|
+
// zero-filled hole where the unwritten tail should be, so the size check
|
|
152
|
+
// passes and a silently corrupt video is published. Throwing instead leaves
|
|
153
|
+
// the counters untouched (they commit only after this returns), so the client's
|
|
154
|
+
// retry at the same nextIndex overwrites from the same offset — the existing
|
|
155
|
+
// idempotency path, no new client handling required.
|
|
156
|
+
assertFullWrite(writeSync(fd, bytes, 0, bytes.length, position), bytes.length)
|
|
157
|
+
// Truncate to the exact committed length. A previous attempt at this same
|
|
158
|
+
// index may have written MORE bytes here (it is retried after a reconnect,
|
|
159
|
+
// and the last chunk is short), which would otherwise leave a tail beyond
|
|
160
|
+
// the counters and make the finalize size check fail on a healthy upload.
|
|
161
|
+
ftruncateSync(fd, position + bytes.length)
|
|
162
|
+
} finally {
|
|
163
|
+
closeSync(fd)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
sizeOf: (path) => statSync(path).size,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface UploadSessionRegistryOptions {
|
|
170
|
+
createStagingFile?: () => MediaStagingFile
|
|
171
|
+
now?: () => number
|
|
172
|
+
fs?: Partial<UploadSessionFs>
|
|
173
|
+
ttlMs?: number
|
|
174
|
+
/** The assembled ceiling — contract's `chunkedMaxBytes`. Test seam. */
|
|
175
|
+
maxBytes?: number
|
|
176
|
+
/** Per-chunk ceiling — contract's `chunkBytes`. Test seam. */
|
|
177
|
+
maxChunkBytes?: number
|
|
178
|
+
maxConcurrent?: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class UploadSessionRegistry {
|
|
182
|
+
private readonly sessions = new Map<string, UploadSession>()
|
|
183
|
+
private readonly createStaging: () => MediaStagingFile
|
|
184
|
+
private readonly now: () => number
|
|
185
|
+
private readonly fs: UploadSessionFs
|
|
186
|
+
private readonly ttlMs: number
|
|
187
|
+
private readonly maxBytes: number
|
|
188
|
+
private readonly maxChunkBytes: number
|
|
189
|
+
private readonly maxConcurrent: number
|
|
190
|
+
|
|
191
|
+
constructor(options: UploadSessionRegistryOptions = {}) {
|
|
192
|
+
this.createStaging = options.createStagingFile ?? (() => getMediaStore().createStagingFile())
|
|
193
|
+
this.now = options.now ?? Date.now
|
|
194
|
+
this.fs = { ...defaultUploadSessionFs, ...options.fs }
|
|
195
|
+
this.ttlMs = options.ttlMs ?? CHUNKED_UPLOAD_TTL_MS
|
|
196
|
+
this.maxBytes = options.maxBytes ?? MAX_CHUNKED_MEDIA_BYTES
|
|
197
|
+
this.maxChunkBytes = options.maxChunkBytes ?? MEDIA_CHUNK_BYTES
|
|
198
|
+
this.maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_CHUNKED_UPLOADS
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
create(input: CreateUploadInput): UploadSession {
|
|
202
|
+
this.sweepExpired()
|
|
203
|
+
const totalBytes = input.totalBytes
|
|
204
|
+
if (typeof totalBytes !== 'number' || !Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
|
|
205
|
+
throw new UploadSessionError('invalid_total_bytes', 'totalBytes must be a positive integer')
|
|
206
|
+
}
|
|
207
|
+
// The declared size is a CLAIM (§6). Bounding it here is the cheap refusal;
|
|
208
|
+
// appendChunk still enforces it as bytes actually arrive.
|
|
209
|
+
if (totalBytes > this.maxBytes) {
|
|
210
|
+
throw new UploadSessionError(
|
|
211
|
+
'attachment_too_large',
|
|
212
|
+
`declared size exceeds ${this.maxBytes} byte ceiling`,
|
|
213
|
+
{ maxBytes: this.maxBytes },
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
if (this.sessions.size >= this.maxConcurrent) {
|
|
217
|
+
throw new UploadSessionError('chunked_upload_unavailable', 'too many uploads in flight')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const staged = this.createStaging()
|
|
221
|
+
try {
|
|
222
|
+
this.fs.create(staged.path)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
staged.dispose()
|
|
225
|
+
throw new UploadSessionError(
|
|
226
|
+
'upload_staging_failed',
|
|
227
|
+
`staging file could not be created: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
const startedAt = this.now()
|
|
231
|
+
const session: UploadSession = {
|
|
232
|
+
uploadId: `u_${randomBytes(12).toString('hex')}`,
|
|
233
|
+
stagingPath: staged.path,
|
|
234
|
+
totalBytes,
|
|
235
|
+
receivedBytes: 0,
|
|
236
|
+
nextIndex: 0,
|
|
237
|
+
...(input.mime ? { mime: input.mime } : {}),
|
|
238
|
+
...(input.label ? { label: input.label } : {}),
|
|
239
|
+
...(input.capturedAt ? { capturedAt: input.capturedAt } : {}),
|
|
240
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
241
|
+
createdAtMs: startedAt,
|
|
242
|
+
expiresAtMs: startedAt + this.ttlMs,
|
|
243
|
+
dispose: staged.dispose,
|
|
244
|
+
}
|
|
245
|
+
this.sessions.set(session.uploadId, session)
|
|
246
|
+
return session
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Resume probe. Sweeps first, so an expired upload reads as unknown rather
|
|
250
|
+
* than reporting progress that can never be finalized. */
|
|
251
|
+
peek(uploadId: string): UploadSession | undefined {
|
|
252
|
+
this.sweepExpired()
|
|
253
|
+
if (!isValidUploadId(uploadId)) return undefined
|
|
254
|
+
return this.sessions.get(uploadId)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
appendChunk(uploadId: string, index: number, bytes: Buffer): UploadSession {
|
|
258
|
+
const session = this.require(uploadId)
|
|
259
|
+
if (bytes.length === 0) {
|
|
260
|
+
// A zero-byte chunk would advance nextIndex without progress, so a client
|
|
261
|
+
// looping on it would never finish and never fail.
|
|
262
|
+
throw new UploadSessionError('chunk_bytes_required', 'chunk body is empty')
|
|
263
|
+
}
|
|
264
|
+
// Repairable, so the session survives: the client can re-send this index at
|
|
265
|
+
// the advertised chunk size.
|
|
266
|
+
if (bytes.length > this.maxChunkBytes) {
|
|
267
|
+
throw new UploadSessionError(
|
|
268
|
+
'attachment_too_large',
|
|
269
|
+
`chunk exceeds ${this.maxChunkBytes} byte chunk size`,
|
|
270
|
+
{ maxBytes: this.maxChunkBytes, expectedIndex: session.nextIndex },
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
if (!Number.isSafeInteger(index) || index !== session.nextIndex) {
|
|
274
|
+
// Strictly in-order (§6). A re-send of an ALREADY-committed index lands
|
|
275
|
+
// here too and gets the recovery information it needs rather than a blind
|
|
276
|
+
// second append of the same bytes.
|
|
277
|
+
throw new UploadSessionError('chunk_out_of_order', `expected chunk ${session.nextIndex}`, {
|
|
278
|
+
expectedIndex: session.nextIndex,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
// "whichever is lower" (§6). create() already bounds totalBytes to maxBytes,
|
|
282
|
+
// so the DECLARED value is the operative half and the ceiling half is
|
|
283
|
+
// unreachable through the public API — measured: replacing this min() with
|
|
284
|
+
// `session.totalBytes` alone leaves the whole suite green, while replacing it
|
|
285
|
+
// with `this.maxBytes` alone fails. It stays as the contract's literal rule,
|
|
286
|
+
// and as what keeps this correct if init's bound ever loosens.
|
|
287
|
+
const limit = Math.min(session.totalBytes, this.maxBytes)
|
|
288
|
+
if (session.receivedBytes + bytes.length > limit) {
|
|
289
|
+
// Fatal, not repairable: the assembled file can no longer match what was
|
|
290
|
+
// declared, so the upload is dropped and its disk released immediately.
|
|
291
|
+
this.drop(uploadId)
|
|
292
|
+
throw new UploadSessionError('attachment_too_large', `upload exceeds ${limit} declared bytes`, {
|
|
293
|
+
maxBytes: limit,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.fs.writeAt(session.stagingPath, bytes, session.receivedBytes)
|
|
298
|
+
// Committed only after the write RETURNED. A throw above leaves the counters
|
|
299
|
+
// untouched, so the retry of this same index writes at the same offset and
|
|
300
|
+
// overwrites whatever the failed attempt left — that positional write, not
|
|
301
|
+
// an open(path,'a'), is what makes re-sending the chunk at nextIndex
|
|
302
|
+
// idempotent instead of duplicating bytes.
|
|
303
|
+
session.receivedBytes += bytes.length
|
|
304
|
+
session.nextIndex += 1
|
|
305
|
+
return session
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Completeness first (non-destructive: `incomplete_upload` means keep going),
|
|
309
|
+
* then hand the session over and forget it. The caller owns the staging file
|
|
310
|
+
* from that moment, so a duplicate finalize is a clean 404 rather than a
|
|
311
|
+
* second ingest of the same bytes. */
|
|
312
|
+
finalize(uploadId: string): UploadSession {
|
|
313
|
+
const session = this.require(uploadId)
|
|
314
|
+
if (session.receivedBytes !== session.totalBytes) {
|
|
315
|
+
throw new UploadSessionError('incomplete_upload', 'upload is not complete', {
|
|
316
|
+
receivedBytes: session.receivedBytes,
|
|
317
|
+
totalBytes: session.totalBytes,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
// §6: re-verify the ASSEMBLED size before ingest. The counters are ours and
|
|
321
|
+
// the file is the thing being ingested — when they disagree, the counters
|
|
322
|
+
// are the ones that cannot be trusted, so refuse rather than ingest a
|
|
323
|
+
// truncated or over-long body.
|
|
324
|
+
let actualBytes: number
|
|
325
|
+
try {
|
|
326
|
+
actualBytes = this.fs.sizeOf(session.stagingPath)
|
|
327
|
+
} catch (err) {
|
|
328
|
+
this.drop(uploadId)
|
|
329
|
+
throw new UploadSessionError(
|
|
330
|
+
'upload_size_mismatch',
|
|
331
|
+
`assembled upload could not be measured: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
if (actualBytes !== session.totalBytes) {
|
|
335
|
+
this.drop(uploadId)
|
|
336
|
+
throw new UploadSessionError('upload_size_mismatch', 'assembled size does not match the declared total', {
|
|
337
|
+
receivedBytes: actualBytes,
|
|
338
|
+
totalBytes: session.totalBytes,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
this.sessions.delete(uploadId)
|
|
342
|
+
return session
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Forget the session AND release its staging file. */
|
|
346
|
+
drop(uploadId: string): boolean {
|
|
347
|
+
const session = this.sessions.get(uploadId)
|
|
348
|
+
if (!session) return false
|
|
349
|
+
this.sessions.delete(uploadId)
|
|
350
|
+
try { session.dispose() } catch { /* already moved or gone */ }
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Lazy sweep on every access rather than a second interval timer: tmp/ is
|
|
355
|
+
* wiped by MediaStore boot reconcile, so the only case a timer would add is a
|
|
356
|
+
* server that never touches media again before restarting. */
|
|
357
|
+
sweepExpired(now = this.now()): number {
|
|
358
|
+
let dropped = 0
|
|
359
|
+
for (const [uploadId, session] of this.sessions) {
|
|
360
|
+
if (session.expiresAtMs <= now) {
|
|
361
|
+
this.sessions.delete(uploadId)
|
|
362
|
+
try { session.dispose() } catch { /* already moved or gone */ }
|
|
363
|
+
dropped++
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return dropped
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
progressOf(session: UploadSession): UploadSessionProgress {
|
|
370
|
+
return {
|
|
371
|
+
uploadId: session.uploadId,
|
|
372
|
+
totalBytes: session.totalBytes,
|
|
373
|
+
receivedBytes: session.receivedBytes,
|
|
374
|
+
nextIndex: session.nextIndex,
|
|
375
|
+
expiresAt: new Date(session.expiresAtMs).toISOString(),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
size(): number {
|
|
380
|
+
return this.sessions.size
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private require(uploadId: string): UploadSession {
|
|
384
|
+
const session = this.peek(uploadId)
|
|
385
|
+
// A wiped, expired, or never-existent upload is the SAME answer (§6): 404,
|
|
386
|
+
// never a partial success. A restart legitimately produces this.
|
|
387
|
+
if (!session) throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
|
|
388
|
+
return session
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Default singleton ────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
let defaultRegistry: UploadSessionRegistry | null = null
|
|
395
|
+
|
|
396
|
+
export function getUploadSessions(): UploadSessionRegistry {
|
|
397
|
+
if (!defaultRegistry) defaultRegistry = new UploadSessionRegistry()
|
|
398
|
+
return defaultRegistry
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Test hook — mirrors _setMediaStoreForTests so a route test can install a
|
|
402
|
+
* registry with a fake clock and tiny ceilings. Returns the previous one. */
|
|
403
|
+
export function _setUploadSessionsForTests(
|
|
404
|
+
registry: UploadSessionRegistry | null,
|
|
405
|
+
): UploadSessionRegistry | null {
|
|
406
|
+
const prev = defaultRegistry
|
|
407
|
+
defaultRegistry = registry
|
|
408
|
+
return prev
|
|
409
|
+
}
|