-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add HTTP Range request support for media streaming #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -30,6 +30,64 @@ function getRequestPaths(request) { | |||||||||||||||||||||||||
| return { urlPath, storagePath, resourceUrl }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Parse HTTP Range header | ||||||||||||||||||||||||||
| * @param {string} rangeHeader - The Range header value (e.g., "bytes=0-1023") | ||||||||||||||||||||||||||
| * @param {number} fileSize - Total file size in bytes | ||||||||||||||||||||||||||
| * @returns {{ start: number, end: number } | null} | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| function parseRangeHeader(rangeHeader, fileSize) { | ||||||||||||||||||||||||||
| if (!rangeHeader || !rangeHeader.startsWith('bytes=')) { | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const range = rangeHeader.slice(6); // Remove 'bytes=' | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Multi-range requests (e.g., "0-100,200-300") are not supported | ||||||||||||||||||||||||||
| // Per RFC 7233, ignore Range header and serve full content instead of 416 | ||||||||||||||||||||||||||
| if (range.includes(',')) { | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const parts = range.split('-'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (parts.length !== 2) { | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let start, end; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (parts[0] === '') { | ||||||||||||||||||||||||||
| // Suffix range: bytes=-500 (last 500 bytes) | ||||||||||||||||||||||||||
| const suffix = parseInt(parts[1], 10); | ||||||||||||||||||||||||||
| if (isNaN(suffix) || suffix <= 0) return null; | ||||||||||||||||||||||||||
| start = Math.max(0, fileSize - suffix); | ||||||||||||||||||||||||||
| end = fileSize - 1; | ||||||||||||||||||||||||||
| } else if (parts[1] === '') { | ||||||||||||||||||||||||||
| // Open-ended range: bytes=1024- (from 1024 to end) | ||||||||||||||||||||||||||
| start = parseInt(parts[0], 10); | ||||||||||||||||||||||||||
| if (isNaN(start) || start < 0) return null; | ||||||||||||||||||||||||||
| end = fileSize - 1; | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| // Normal range: bytes=0-1023 | ||||||||||||||||||||||||||
| start = parseInt(parts[0], 10); | ||||||||||||||||||||||||||
| end = parseInt(parts[1], 10); | ||||||||||||||||||||||||||
| if (isNaN(start) || isNaN(end) || start < 0 || end < start) return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Clamp end to file size | ||||||||||||||||||||||||||
| if (end >= fileSize) { | ||||||||||||||||||||||||||
| end = fileSize - 1; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Check if range is satisfiable | ||||||||||||||||||||||||||
| if (start > end || start >= fileSize) { | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { start, end }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Handle GET request | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
|
|
@@ -245,6 +303,43 @@ export async function handleGet(request, reply) { | |||||||||||||||||||||||||
| return reply.type('text/html').send(html); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Handle Range requests for media files (video, audio, etc.) | ||||||||||||||||||||||||||
| const rangeHeader = request.headers.range; | ||||||||||||||||||||||||||
| if (rangeHeader && !isRdfContentType(storedContentType)) { | ||||||||||||||||||||||||||
| const range = parseRangeHeader(rangeHeader, stats.size); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (range) { | ||||||||||||||||||||||||||
| const { start, end } = range; | ||||||||||||||||||||||||||
| const chunkSize = end - start + 1; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const headers = getAllHeaders({ | ||||||||||||||||||||||||||
| isContainer: false, | ||||||||||||||||||||||||||
| etag: stats.etag, | ||||||||||||||||||||||||||
| contentType: storedContentType, | ||||||||||||||||||||||||||
| origin, | ||||||||||||||||||||||||||
| resourceUrl, | ||||||||||||||||||||||||||
| connegEnabled | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`; | ||||||||||||||||||||||||||
| headers['Content-Length'] = chunkSize; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const streamResult = storage.createReadStream(storagePath, { start, end }); | ||||||||||||||||||||||||||
| if (!streamResult) { | ||||||||||||||||||||||||||
| return reply.code(500).send({ error: 'Stream error' }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| streamResult.stream.on('error', (err) => { | |
| // Log the error if a logger is available | |
| if (request && request.log && typeof request.log.error === 'function') { | |
| request.log.error({ err }, 'Error while streaming resource'); | |
| } | |
| // Avoid sending another response if one has already been sent | |
| if (!reply.sent) { | |
| reply.code(500).send({ error: 'Stream error' }); | |
| } | |
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| /** | ||
| * Range Request Tests | ||
| * | ||
| * Tests HTTP Range header support for partial content delivery. | ||
| */ | ||
|
|
||
| import { describe, it, before, after } from 'node:test'; | ||
| import assert from 'node:assert'; | ||
| import { | ||
| startTestServer, | ||
| stopTestServer, | ||
| request, | ||
| createTestPod, | ||
| assertStatus | ||
| } from './helpers.js'; | ||
|
|
||
| describe('Range Requests', () => { | ||
| const testContent = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; // 36 bytes | ||
|
|
||
| before(async () => { | ||
| await startTestServer(); | ||
| await createTestPod('rangetest'); | ||
|
|
||
| // Create a test file with known content | ||
| await request('/rangetest/public/test.txt', { | ||
| method: 'PUT', | ||
| headers: { 'Content-Type': 'text/plain' }, | ||
| body: testContent, | ||
| auth: 'rangetest' | ||
| }); | ||
| }); | ||
|
|
||
| after(async () => { | ||
| await stopTestServer(); | ||
| }); | ||
|
|
||
| describe('Accept-Ranges header', () => { | ||
| it('should include Accept-Ranges: bytes for files', async () => { | ||
| const res = await request('/rangetest/public/test.txt'); | ||
| assertStatus(res, 200); | ||
| assert.strictEqual(res.headers.get('Accept-Ranges'), 'bytes'); | ||
| }); | ||
|
|
||
| it('should include Accept-Ranges: none for containers', async () => { | ||
| const res = await request('/rangetest/public/'); | ||
| assertStatus(res, 200); | ||
| assert.strictEqual(res.headers.get('Accept-Ranges'), 'none'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Range header parsing', () => { | ||
| it('should return 206 for valid range bytes=0-9', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'bytes=0-9' } | ||
| }); | ||
| assertStatus(res, 206); | ||
|
|
||
| const body = await res.text(); | ||
| assert.strictEqual(body, 'ABCDEFGHIJ'); | ||
| assert.strictEqual(res.headers.get('Content-Range'), 'bytes 0-9/36'); | ||
| assert.strictEqual(res.headers.get('Content-Length'), '10'); | ||
| }); | ||
|
|
||
| it('should return 206 for open-ended range bytes=30-', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'bytes=30-' } | ||
| }); | ||
| assertStatus(res, 206); | ||
|
|
||
| const body = await res.text(); | ||
| assert.strictEqual(body, '456789'); | ||
| assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36'); | ||
| }); | ||
|
|
||
| it('should return 206 for suffix range bytes=-6', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'bytes=-6' } | ||
| }); | ||
| assertStatus(res, 206); | ||
|
|
||
| const body = await res.text(); | ||
| assert.strictEqual(body, '456789'); | ||
| assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36'); | ||
| }); | ||
|
|
||
| it('should clamp end to file size for range exceeding file', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'bytes=30-1000' } | ||
| }); | ||
| assertStatus(res, 206); | ||
|
|
||
| const body = await res.text(); | ||
| assert.strictEqual(body, '456789'); | ||
| assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Multi-range requests', () => { | ||
| it('should ignore multi-range and return 200 with full content', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'bytes=0-5,10-15' } | ||
| }); | ||
| // Multi-range is not supported, should fall back to 200 | ||
| assertStatus(res, 200); | ||
|
|
||
| const body = await res.text(); | ||
| assert.strictEqual(body, testContent); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Invalid ranges', () => { | ||
| it('should return 200 for invalid range format', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'invalid' } | ||
| }); | ||
| // Invalid format, ignore Range header | ||
| assertStatus(res, 200); | ||
| }); | ||
|
|
||
| it('should return 200 for non-bytes range unit', async () => { | ||
| const res = await request('/rangetest/public/test.txt', { | ||
| headers: { 'Range': 'chars=0-10' } | ||
| }); | ||
| assertStatus(res, 200); | ||
| }); | ||
| }); | ||
|
|
||
| describe('RDF resources', () => { | ||
| it('should ignore Range header for RDF resources', async () => { | ||
| // Create an RDF resource | ||
| await request('/rangetest/public/data.jsonld', { | ||
| method: 'PUT', | ||
| headers: { 'Content-Type': 'application/ld+json' }, | ||
| body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Test' }), | ||
| auth: 'rangetest' | ||
| }); | ||
|
|
||
| const res = await request('/rangetest/public/data.jsonld', { | ||
| headers: { 'Range': 'bytes=0-10' } | ||
| }); | ||
| // RDF resources don't support range requests | ||
| assertStatus(res, 200); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation rejects multiple range requests (e.g., 'bytes=0-100,200-300') with a 416 Range Not Satisfiable response. According to RFC 7233, if you don't support multipart ranges but at least one range is satisfiable, you should either serve the first range or ignore the Range header and return 200 with full content. Consider handling this edge case by ignoring the Range header for multipart requests instead of returning 416.