feat(server): preview-fs serves video with byte-range streaming (inline <video>)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 18:19:20 +08:00
parent f2f230914d
commit 7d07a7cadc
3 changed files with 240 additions and 10 deletions

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test'
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { contentTypeForPath, handlePreviewFs } from '../previewFs'
import { contentTypeForPath, handlePreviewFs, parseRange } from '../previewFs'
describe('contentTypeForPath', () => {
it('maps common web asset extensions', () => {
@ -13,19 +13,62 @@ describe('contentTypeForPath', () => {
expect(contentTypeForPath('/x/logo.svg')).toBe('image/svg+xml')
expect(contentTypeForPath('/x/p.png')).toBe('image/png')
})
it('maps video and audio extensions', () => {
expect(contentTypeForPath('/x/clip.mp4')).toBe('video/mp4')
expect(contentTypeForPath('/x/clip.webm')).toBe('video/webm')
expect(contentTypeForPath('/x/clip.mov')).toBe('video/quicktime')
expect(contentTypeForPath('/x/clip.m4v')).toBe('video/x-m4v')
expect(contentTypeForPath('/x/song.mp3')).toBe('audio/mpeg')
expect(contentTypeForPath('/x/song.wav')).toBe('audio/wav')
expect(contentTypeForPath('/x/song.ogg')).toBe('audio/ogg')
})
it('falls back to octet-stream for unknown', () => {
expect(contentTypeForPath('/x/file.bin')).toBe('application/octet-stream')
})
})
// Deterministic 256-byte payload (bytes 0..255) so range slices are checkable.
const VIDEO_BYTES = Uint8Array.from({ length: 256 }, (_, i) => i)
function setupWorkspace() {
const root = mkdtempSync(path.join(tmpdir(), 'pfs-'))
writeFileSync(path.join(root, 'index.html'), '<h1>ok</h1>')
mkdirSync(path.join(root, 'assets'))
writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}')
writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES)
return root
}
describe('parseRange', () => {
it('returns null when no header', () => {
expect(parseRange(null, 100)).toBeNull()
expect(parseRange(undefined, 100)).toBeNull()
})
it('parses explicit closed range', () => {
expect(parseRange('bytes=0-99', 256)).toEqual({ start: 0, end: 99 })
})
it('parses open-ended range to EOF', () => {
expect(parseRange('bytes=100-', 256)).toEqual({ start: 100, end: 255 })
})
it('parses suffix range (last N bytes)', () => {
expect(parseRange('bytes=-10', 256)).toEqual({ start: 246, end: 255 })
})
it('clamps end past EOF', () => {
expect(parseRange('bytes=0-9999', 256)).toEqual({ start: 0, end: 255 })
})
it('reports unsatisfiable when start >= size', () => {
expect(parseRange('bytes=999999-', 256)).toBe('unsatisfiable')
})
it('reports unsatisfiable when start > end', () => {
expect(parseRange('bytes=50-10', 256)).toBe('unsatisfiable')
})
it('ignores malformed headers (falls back to full response)', () => {
expect(parseRange('bytes=abc', 256)).toBeNull()
expect(parseRange('items=0-9', 256)).toBeNull()
expect(parseRange('bytes=-', 256)).toBeNull()
})
})
describe('handlePreviewFs', () => {
it('serves an in-workspace file with content-type', async () => {
const root = setupWorkspace()
@ -56,4 +99,73 @@ describe('handlePreviewFs', () => {
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/sX/index.html'), resolve)
expect(res.status).toBe(404)
})
it('serves .mp4 with video content-type and Accept-Ranges on a 200', async () => {
const root = setupWorkspace()
const resolve = async () => root
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/clip.mp4'), resolve)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('video/mp4')
expect(res.headers.get('accept-ranges')).toBe('bytes')
expect(res.headers.get('content-length')).toBe('256')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VIDEO_BYTES)
})
it('serves a closed byte-range as 206 with the requested bytes', async () => {
const root = setupWorkspace()
const resolve = async () => root
const res = await handlePreviewFs(
new URL('http://127.0.0.1/preview-fs/s1/clip.mp4'),
resolve,
new Headers({ Range: 'bytes=0-99' }),
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 0-99/256')
expect(res.headers.get('content-length')).toBe('100')
expect(res.headers.get('accept-ranges')).toBe('bytes')
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBe(100)
expect(body).toEqual(VIDEO_BYTES.slice(0, 100))
})
it('serves an open-ended byte-range to EOF as 206', async () => {
const root = setupWorkspace()
const resolve = async () => root
const res = await handlePreviewFs(
new URL('http://127.0.0.1/preview-fs/s1/clip.mp4'),
resolve,
new Headers({ Range: 'bytes=100-' }),
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 100-255/256')
expect(res.headers.get('content-length')).toBe('156')
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBe(156)
expect(body).toEqual(VIDEO_BYTES.slice(100))
})
it('returns 416 for an unsatisfiable range (start >= size)', async () => {
const root = setupWorkspace()
const resolve = async () => root
const res = await handlePreviewFs(
new URL('http://127.0.0.1/preview-fs/s1/clip.mp4'),
resolve,
new Headers({ Range: 'bytes=999999-' }),
)
expect(res.status).toBe(416)
expect(res.headers.get('content-range')).toBe('bytes */256')
})
it('returns the whole file (200) when no Range header is sent', async () => {
const root = setupWorkspace()
const resolve = async () => root
const res = await handlePreviewFs(
new URL('http://127.0.0.1/preview-fs/s1/clip.mp4'),
resolve,
new Headers(),
)
expect(res.status).toBe(200)
expect(res.headers.get('content-length')).toBe('256')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VIDEO_BYTES)
})
})

View File

@ -23,6 +23,15 @@ const CONTENT_TYPES: Record<string, string> = {
woff2: 'font/woff2',
txt: 'text/plain; charset=utf-8',
md: 'text/plain; charset=utf-8',
// Video — served inline via <video> with HTTP byte-range streaming.
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
m4v: 'video/x-m4v',
// Audio.
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
}
export function contentTypeForPath(filePath: string): string {
@ -33,7 +42,76 @@ export function contentTypeForPath(filePath: string): string {
export type ResolveWorkDir = (sessionId: string) => Promise<string | null>
const PREFIX = '/preview-fs/'
const MAX_FILE_BYTES = 50 * 1024 * 1024
/**
* Upper bound on what we'll serve. The old 50 MB cap existed because the file
* was buffered into memory via `readFileSync`; that would 413 real dubbed
* videos. We now STREAM every response through `Bun.file(...)` (including
* byte-ranges), so the in-memory pressure is gone and we can raise this a lot.
* We keep a generous-but-finite ceiling (2 GiB) purely as a sanity guard
* against pathological / runaway files not as a memory limit.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
export interface ParsedRange {
start: number
end: number
}
/**
* Parse a single HTTP `Range` header against a known file `size`.
*
* Supports the common single-range forms:
* - `bytes=start-end` (explicit closed range)
* - `bytes=start-` (open-ended to EOF)
* - `bytes=-N` (suffix last N bytes)
*
* Returns inclusive `{ start, end }` byte offsets clamped to `[0, size-1]`,
* `null` when the header is absent/unparseable (caller should fall back to a
* full 200 response), or `'unsatisfiable'` when the range cannot be satisfied
* (caller should reply 416). Multi-range requests (comma-separated) are not
* supported and fall back to a full response.
*/
export function parseRange(
rangeHeader: string | null | undefined,
size: number,
): ParsedRange | null | 'unsatisfiable' {
if (!rangeHeader) return null
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim())
if (!match) return null
const startRaw = match[1]
const endRaw = match[2]
// `bytes=-N` suffix form: the last N bytes.
if (startRaw === '') {
if (endRaw === '') return null // `bytes=-` is malformed → ignore.
const suffixLen = Number(endRaw)
if (!Number.isFinite(suffixLen) || suffixLen <= 0) return 'unsatisfiable'
if (size === 0) return 'unsatisfiable'
const start = Math.max(0, size - suffixLen)
return { start, end: size - 1 }
}
const start = Number(startRaw)
if (!Number.isFinite(start)) return null
// start beyond EOF is unsatisfiable.
if (start >= size) return 'unsatisfiable'
let end: number
if (endRaw === '') {
end = size - 1 // open-ended → EOF
} else {
end = Number(endRaw)
if (!Number.isFinite(end)) return null
if (end < start) return 'unsatisfiable'
end = Math.min(end, size - 1) // clamp to EOF
}
return { start, end }
}
/**
* Serve a single file from a session's sandboxed workspace directory.
@ -49,6 +127,7 @@ const MAX_FILE_BYTES = 50 * 1024 * 1024
export async function handlePreviewFs(
url: URL,
resolveWorkDir: ResolveWorkDir,
reqHeaders?: Headers,
): Promise<Response> {
if (!url.pathname.startsWith(PREFIX)) {
return new Response('forbidden', { status: 403 })
@ -79,12 +158,48 @@ export async function handlePreviewFs(
if (!stat.isFile()) return new Response('not a file', { status: 404 })
if (stat.size > MAX_FILE_BYTES) return new Response('too large', { status: 413 })
const data = fs.readFileSync(target)
return new Response(data, {
const size = stat.size
const contentType = contentTypeForPath(target)
// Stream straight from disk via Bun.file — never buffer whole media into
// memory. Bun's file blob is an acceptable Response body.
const file = Bun.file(target)
const range = parseRange(reqHeaders?.get('range'), size)
if (range === 'unsatisfiable') {
return new Response('range not satisfiable', {
status: 416,
headers: {
'Content-Range': `bytes */${size}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
},
})
}
if (range) {
const { start, end } = range
// `slice(start, end + 1)` — Bun's slice end is exclusive, range end is
// inclusive — streams just the requested window.
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(end - start + 1),
'Cache-Control': 'no-cache',
},
})
}
// No (or unparseable) Range header → stream the whole file as 200.
return new Response(file, {
status: 200,
headers: {
'Content-Type': contentTypeForPath(target),
'Content-Length': String(stat.size),
'Content-Type': contentType,
'Content-Length': String(size),
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
},
})

View File

@ -296,10 +296,13 @@ export function startServer(port = PORT, host = HOST) {
}
}
const response = await handlePreviewFs(url, async (sessionId) =>
conversationService.getSessionWorkDir(sessionId) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
null,
const response = await handlePreviewFs(
url,
async (sessionId) =>
conversationService.getSessionWorkDir(sessionId) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
null,
req.headers,
)
return withCors(response, cors)
}