Skip to content

Commit 9873df0

Browse files
committed
fix(files): stream served files with byte ranges instead of buffering them under 100 MB
1 parent 30d6f67 commit 9873df0

23 files changed

Lines changed: 1100 additions & 256 deletions

‎apps/sim/app/api/files/serve/[...path]/route.test.ts‎

Lines changed: 230 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Tests for file serve API route
33
*/
44

5+
import { Readable } from 'node:stream'
56
import {
67
authMockFns,
78
hybridAuthMockFns,
@@ -41,7 +42,7 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
4142
const {
4243
mockReadFile,
4344
mockAuthenticateWorkspaceFile,
44-
mockReadWorkspaceFileContentByKey,
45+
mockStreamAuthorizedWorkspaceFile,
4546
mockReadWorkspaceFileRecordByKey,
4647
mockAuthorizeWorkspaceFileRecordByKey,
4748
mockResolveServableDocBytes,
@@ -64,7 +65,7 @@ const {
6465
mockReadOrganizationAssistantImage: vi.fn(),
6566
mockReadFile: vi.fn(),
6667
mockAuthenticateWorkspaceFile: vi.fn(),
67-
mockReadWorkspaceFileContentByKey: vi.fn(),
68+
mockStreamAuthorizedWorkspaceFile: vi.fn(),
6869
mockReadWorkspaceFileRecordByKey: vi.fn(),
6970
mockAuthorizeWorkspaceFileRecordByKey: vi.fn(async () => undefined),
7071
mockResolveServableDocBytes: vi.fn(),
@@ -117,8 +118,11 @@ vi.mock('@/lib/workspace-files/api', () => ({
117118
internalWorkspaceFileServeAuth: { authenticate: mockAuthenticateWorkspaceFile },
118119
}))
119120

120-
vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({
121-
readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey },
121+
vi.mock('@/lib/workspace-files/application/stream-authorized-workspace-file', () => ({
122+
streamAuthorizedWorkspaceFile: mockStreamAuthorizedWorkspaceFile,
123+
}))
124+
125+
vi.mock('@/lib/workspace-files/application/read-workspace-file-record-by-key', () => ({
122126
readWorkspaceFileRecordByKey: {
123127
execute: mockReadWorkspaceFileRecordByKey,
124128
authorize: mockAuthorizeWorkspaceFileRecordByKey,
@@ -129,7 +133,9 @@ vi.mock('@/lib/uploads/documents/compile', () => ({
129133
resolveServableDocBytes: mockResolveServableDocBytes,
130134
}))
131135

132-
vi.mock('@/app/api/files/utils', () => ({
136+
vi.mock('@/app/api/files/utils', async (importOriginal) => ({
137+
createStoredFileStreamResponse: (await importOriginal<typeof import('@/app/api/files/utils')>())
138+
.createStoredFileStreamResponse,
133139
FileNotFoundError,
134140
createFileResponse: mockCreateFileResponse,
135141
createConditionalFileResponse: mockCreateConditionalFileResponse,
@@ -151,6 +157,10 @@ const serveLogger = getMockLogger('FilesServeAPI')
151157
const mockDownloadCopilotFile = uploadsMockFns.mockDownloadCopilotFile
152158
const mockIsUsingCloudStorage = uploadsMockFns.mockIsUsingCloudStorage
153159

160+
function webStream(text: string): ReadableStream<Uint8Array> {
161+
return new Blob([text]).stream()
162+
}
163+
154164
describe('File Serve API Route', () => {
155165
beforeEach(() => {
156166
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
@@ -170,14 +180,7 @@ describe('File Serve API Route', () => {
170180
mockAuthenticateWorkspaceFile.mockResolvedValue(
171181
createSessionPrincipal({ userId: 'test-user-id' })
172182
)
173-
mockReadWorkspaceFileContentByKey.mockResolvedValue({
174-
file: {
175-
id: 'file-1',
176-
workspaceId: 'test-workspace-id',
177-
name: 'report.pdf',
178-
},
179-
content: Buffer.from('generated source'),
180-
})
183+
mockStreamAuthorizedWorkspaceFile.mockImplementation(async () => webStream('stored bytes'))
181184
mockReadWorkspaceFileRecordByKey.mockResolvedValue({
182185
file: {
183186
id: 'file-1',
@@ -248,11 +251,11 @@ describe('File Serve API Route', () => {
248251
mockDownloadCopilotFile.mockResolvedValue(Buffer.from('bytes'))
249252

250253
await GET(
251-
createMockRequest({ url: 'http://localhost:3000/api/files/serve/copilot/doc.txt' }),
252-
createRouteContext({ path: ['copilot', 'doc.txt'] })
254+
createMockRequest({ url: 'http://localhost:3000/api/files/serve/copilot/doc.pdf' }),
255+
createRouteContext({ path: ['copilot', 'doc.pdf'] })
253256
)
254257

255-
expect(mockDownloadCopilotFile).toHaveBeenCalledWith('copilot/doc.txt', {
258+
expect(mockDownloadCopilotFile).toHaveBeenCalledWith('copilot/doc.pdf', {
256259
maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
257260
})
258261
})
@@ -287,9 +290,9 @@ describe('File Serve API Route', () => {
287290
it('bounds the local read rather than trusting the stored size', async () => {
288291
await GET(
289292
createMockRequest({
290-
url: 'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt',
293+
url: 'http://localhost:3000/api/files/serve/workspace/ws/report.pdf',
291294
}),
292-
createRouteContext({ path: ['workspace', 'ws', 'test-file.txt'] })
295+
createRouteContext({ path: ['workspace', 'ws', 'report.pdf'] })
293296
)
294297

295298
expect(mockReadLocalFileWithinLimit).toHaveBeenCalledWith(
@@ -318,8 +321,8 @@ describe('File Serve API Route', () => {
318321
)
319322

320323
const response = await GET(
321-
createMockRequest({ url: 'http://localhost:3000/api/files/serve/workspace/ws/huge.bin' }),
322-
createRouteContext({ path: ['workspace', 'ws', 'huge.bin'] })
324+
createMockRequest({ url: 'http://localhost:3000/api/files/serve/workspace/ws/huge.pdf' }),
325+
createRouteContext({ path: ['workspace', 'ws', 'huge.pdf'] })
323326
)
324327

325328
expect(response.status).toBe(413)
@@ -334,10 +337,10 @@ describe('File Serve API Route', () => {
334337
})
335338

336339
const response = await GET(
337-
new NextRequest('http://localhost:3000/api/files/serve/workspace/ws/report.md', {
340+
new NextRequest('http://localhost:3000/api/files/serve/workspace/ws/report.pdf', {
338341
signal: controller.signal,
339342
}),
340-
createRouteContext({ path: ['workspace', 'ws', 'report.md'] })
343+
createRouteContext({ path: ['workspace', 'ws', 'report.pdf'] })
341344
)
342345

343346
expect(response.status).toBe(499)
@@ -400,36 +403,220 @@ describe('File Serve API Route', () => {
400403
const response = await serveVersionedDoc(true)
401404
expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate')
402405
})
406+
})
403407

404-
it('serves stored bytes under the type their extension names, not the recorded type', async () => {
408+
describe('streaming stored objects', () => {
409+
const principal = createSessionPrincipal({ userId: 'test-user-id' })
410+
const video = {
411+
id: 'file-1',
412+
workspaceId: 'test-workspace-id',
413+
name: 'clip.mp4',
414+
key: 'workspace/test-workspace-id/clip.mp4',
415+
type: 'application/octet-stream',
416+
size: 300_000_000,
417+
updatedAt: new Date('2026-10-31T20:28:58Z'),
418+
contentUpdatedAt: new Date('2026-10-31T20:28:58Z'),
419+
}
420+
421+
function serveWorkspaceFile(
422+
file: typeof video,
423+
headers: Record<string, string> = {},
424+
query = ''
425+
) {
405426
mockResolveStoredFileContext.mockResolvedValue('workspace')
406427
mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id')
407428
mockAuthenticateWorkspaceFile.mockResolvedValue(principal)
408-
mockReadWorkspaceFileRecordByKey.mockResolvedValue({
409-
file: {
410-
id: 'file-1',
411-
workspaceId: 'test-workspace-id',
412-
name: 'export.csv',
413-
key: 'workspace/test-workspace-id/export.csv',
414-
type: 'application/vnd.ms-excel',
415-
size: 3,
416-
contentUpdatedAt: new Date('2026-09-06'),
417-
},
418-
})
419-
fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({
420-
buffer: Buffer.from('a,b'),
421-
contentType: 'application/vnd.ms-excel',
422-
})
429+
mockReadWorkspaceFileRecordByKey.mockResolvedValue({ file })
430+
const segments = file.key.split('/')
431+
return GET(
432+
new NextRequest(`http://localhost:3000/api/files/serve/${file.key}${query}`, { headers }),
433+
createRouteContext({ path: segments })
434+
)
435+
}
423436

424-
const response = await GET(
425-
createMockRequest({
426-
url: 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/export.csv',
427-
}),
428-
createRouteContext({ path: ['workspace', 'test-workspace-id', 'export.csv'] })
437+
it('streams a file past the transfer ceiling instead of refusing it', async () => {
438+
const response = await serveWorkspaceFile(video)
439+
440+
expect(response.status).toBe(200)
441+
expect(response.headers.get('Content-Length')).toBe(String(video.size))
442+
expect(response.headers.get('Content-Type')).toBe('video/mp4')
443+
expect(response.headers.get('Accept-Ranges')).toBe('bytes')
444+
expect(await response.text()).toBe('stored bytes')
445+
expect(mockStreamAuthorizedWorkspaceFile).toHaveBeenCalledWith(
446+
expect.objectContaining({ file: video, principal, range: undefined })
447+
)
448+
expect(fileUtilsServerMockFns.mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
449+
})
450+
451+
it('rechecks access through the same use case that admitted the file', async () => {
452+
await serveWorkspaceFile(video)
453+
const [{ authorize }] = mockStreamAuthorizedWorkspaceFile.mock.calls[0]
454+
await authorize()
455+
expect(mockAuthorizeWorkspaceFileRecordByKey).toHaveBeenCalledWith(
456+
expect.objectContaining({
457+
principal,
458+
input: { key: video.key, assertedWorkspaceId: 'test-workspace-id' },
459+
})
460+
)
461+
})
462+
463+
it('answers a byte range with 206 and reads only that span', async () => {
464+
const response = await serveWorkspaceFile(video, { Range: 'bytes=100-199' })
465+
466+
expect(response.status).toBe(206)
467+
expect(response.headers.get('Content-Range')).toBe(`bytes 100-199/${video.size}`)
468+
expect(response.headers.get('Content-Length')).toBe('100')
469+
expect(mockStreamAuthorizedWorkspaceFile).toHaveBeenCalledWith(
470+
expect.objectContaining({ range: { start: 100, end: 199 } })
429471
)
472+
})
473+
474+
it('refuses a range past the end with 416 without opening storage', async () => {
475+
const response = await serveWorkspaceFile(video, { Range: `bytes=${video.size}-` })
476+
477+
expect(response.status).toBe(416)
478+
expect(response.headers.get('Content-Range')).toBe(`bytes */${video.size}`)
479+
expect(mockStreamAuthorizedWorkspaceFile).not.toHaveBeenCalled()
480+
})
481+
482+
it('answers a revalidation of unchanged bytes with 304 without opening storage', async () => {
483+
const first = await serveWorkspaceFile(video)
484+
const etag = first.headers.get('ETag')
485+
expect(etag).toBeTruthy()
486+
mockStreamAuthorizedWorkspaceFile.mockClear()
487+
488+
const response = await serveWorkspaceFile(video, { 'If-None-Match': etag ?? '' })
489+
490+
expect(response.status).toBe(304)
491+
expect(mockStreamAuthorizedWorkspaceFile).not.toHaveBeenCalled()
492+
})
493+
494+
it('stops matching once a content write moves the file to new bytes', async () => {
495+
const etag = (await serveWorkspaceFile(video)).headers.get('ETag') ?? ''
496+
const rewritten = {
497+
...video,
498+
key: 'workspace/test-workspace-id/clip-v2.mp4',
499+
contentUpdatedAt: new Date('2026-11-01T00:00:00Z'),
500+
}
501+
502+
const revalidated = await serveWorkspaceFile(rewritten, { 'If-None-Match': etag })
503+
const resumed = await serveWorkspaceFile(rewritten, { Range: 'bytes=0-9', 'If-Range': etag })
504+
505+
expect(revalidated.status).toBe(200)
506+
expect(resumed.status).toBe(200)
507+
expect(resumed.headers.get('Content-Length')).toBe(String(video.size))
508+
})
509+
510+
it('serves stored bytes under the type their extension names, not the recorded type', async () => {
511+
const response = await serveWorkspaceFile({
512+
...video,
513+
name: 'export.csv',
514+
key: 'workspace/test-workspace-id/export.csv',
515+
type: 'application/vnd.ms-excel',
516+
size: 3,
517+
})
430518

431519
expect(response.status).toBe(200)
432520
expect(response.headers.get('Content-Type')).toBe('text/csv')
433521
})
522+
523+
it('streams the raw source of a page rather than rendering it', async () => {
524+
mockGetContentType.mockReturnValue('text/html')
525+
const response = await serveWorkspaceFile(
526+
{ ...video, name: 'page.html', key: 'workspace/test-workspace-id/page.html', size: 12 },
527+
{},
528+
'?raw=1'
529+
)
530+
531+
expect(response.status).toBe(200)
532+
expect(await response.text()).toBe('stored bytes')
533+
})
534+
535+
it.each([
536+
['a generated document', { name: 'report.pdf', type: 'text/x-pdflibjs' }, ''],
537+
['a page', { name: 'page.html', type: 'text/html' }, ''],
538+
[
539+
'a preview, which may be a HEIC transcode',
540+
{ name: 'photo.jpg', type: 'image/jpeg' },
541+
'?preview=1',
542+
],
543+
])('keeps %s on the buffered path', async (_label, override, query) => {
544+
fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({
545+
buffer: Buffer.from('rendered'),
546+
contentType: 'application/octet-stream',
547+
})
548+
await serveWorkspaceFile(
549+
{ ...video, ...override, key: `workspace/test-workspace-id/${override.name}`, size: 8 },
550+
{},
551+
query
552+
)
553+
554+
expect(mockStreamAuthorizedWorkspaceFile).not.toHaveBeenCalled()
555+
expect(fileUtilsServerMockFns.mockDownloadServableFileFromStorage).toHaveBeenCalled()
556+
})
557+
558+
describe('outside the workspace-file model', () => {
559+
const key = 'workspace/ws/1790000000000-0123456789abcdef-clip.mp4'
560+
561+
function serveAttachment(headers: Record<string, string> = {}) {
562+
mockIsUsingCloudStorage.mockReturnValue(true)
563+
storageServiceMockFns.mockDownloadFileStream.mockImplementation(async () =>
564+
Readable.from([Buffer.from('attachment')])
565+
)
566+
return GET(
567+
new NextRequest(`http://localhost:3000/api/files/serve/${key}`, { headers }),
568+
createRouteContext({ path: key.split('/') })
569+
)
570+
}
571+
572+
it('streams a range of an attachment sized by storage, never buffering it', async () => {
573+
storageServiceMockFns.mockHeadObject.mockResolvedValue({
574+
size: 500_000_000,
575+
version: '"v1"',
576+
})
577+
578+
const response = await serveAttachment({ Range: 'bytes=0-1023' })
579+
580+
expect(response.status).toBe(206)
581+
expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/500000000')
582+
expect(response.headers.get('ETag')).toBeTruthy()
583+
expect(await response.text()).toBe('attachment')
584+
expect(storageServiceMockFns.mockDownloadFileStream).toHaveBeenCalledWith({
585+
key,
586+
context: 'mothership',
587+
range: { start: 0, end: 1023 },
588+
})
589+
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
590+
})
591+
592+
it('still checks access before asking storage anything', async () => {
593+
mockVerifyFileAccess.mockResolvedValue(false)
594+
595+
const response = await serveAttachment()
596+
597+
expect(response.status).toBe(404)
598+
expect(storageServiceMockFns.mockHeadObject).not.toHaveBeenCalled()
599+
expect(storageServiceMockFns.mockDownloadFileStream).not.toHaveBeenCalled()
600+
})
601+
602+
it('answers 404 for an object storage no longer has', async () => {
603+
storageServiceMockFns.mockHeadObject.mockResolvedValue(null)
604+
605+
const response = await serveAttachment()
606+
607+
expect(response.status).toBe(404)
608+
expect(storageServiceMockFns.mockDownloadFileStream).not.toHaveBeenCalled()
609+
})
610+
611+
it('sends everything when storage offers no version to resume against', async () => {
612+
storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 10 })
613+
614+
const response = await serveAttachment({ Range: 'bytes=0-4', 'If-Range': '"v1"' })
615+
616+
expect(response.status).toBe(200)
617+
expect(response.headers.get('ETag')).toBeNull()
618+
expect(response.headers.get('Content-Length')).toBe('10')
619+
})
620+
})
434621
})
435622
})

0 commit comments

Comments
 (0)