mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
f2f230914d
commit
7d07a7cadc
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user