diff --git a/src/server/api/__tests__/previewFs.test.ts b/src/server/api/__tests__/previewFs.test.ts
index f8d47814..bcb53224 100644
--- a/src/server/api/__tests__/previewFs.test.ts
+++ b/src/server/api/__tests__/previewFs.test.ts
@@ -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'), '
ok
')
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)
+ })
})
diff --git a/src/server/api/previewFs.ts b/src/server/api/previewFs.ts
index 9d9dfe9a..b00ee60d 100644
--- a/src/server/api/previewFs.ts
+++ b/src/server/api/previewFs.ts
@@ -23,6 +23,15 @@ const CONTENT_TYPES: Record = {
woff2: 'font/woff2',
txt: 'text/plain; charset=utf-8',
md: 'text/plain; charset=utf-8',
+ // Video — served inline via