feat: add HTTP Range request support for media streaming - #69
Conversation
Enables video seeking and audio skipping by implementing HTTP/1.1 range requests: - Add parseRangeHeader() to parse bytes=start-end, bytes=start-, bytes=-suffix formats - Return 206 Partial Content with Content-Range header for valid ranges - Return 416 Range Not Satisfiable for invalid ranges - Add Accept-Ranges: bytes header to all non-container responses - Use fs.createReadStream with start/end options for efficient partial reads - Add Content-Range to CORS exposed headers and Range to allowed headers Fixes #68
There was a problem hiding this comment.
Pull request overview
This PR implements HTTP Range request support for media streaming, enabling video seeking and audio skipping functionality. The implementation follows RFC 7233 to handle byte-range requests with 206 Partial Content responses, using efficient streaming instead of loading full files into memory.
Key Changes:
- Added
parseRangeHeader()function to parse and validate Range request headers with support for three range formats (standard, open-ended, and suffix ranges) - Implemented streaming-based partial content delivery using Node.js read streams with configurable start/end byte positions
- Updated HTTP headers to advertise Range support via
Accept-Ranges: bytesand extended CORS configuration
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/handlers/resource.js | Adds Range header parsing logic and 206/416 response handling for partial content requests in the GET handler |
| src/ldp/headers.js | Advertises byte-range support via Accept-Ranges header and updates CORS headers to allow Range requests and expose Content-Range responses |
| src/storage/filesystem.js | Introduces createReadStream() function for efficient streaming of partial file content based on byte ranges |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,4 +1,5 @@ | |||
| import * as storage from '../storage/filesystem.js'; | |||
| import { createReadStream } from '../storage/filesystem.js'; | |||
There was a problem hiding this comment.
The createReadStream function is imported separately on line 2, but it's already part of the storage namespace imported on line 1. This creates a redundant import. Consider using storage.createReadStream instead and removing this duplicate import.
| import { createReadStream } from '../storage/filesystem.js'; |
| // 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; | ||
| headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled); | ||
|
|
||
| Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); | ||
|
|
||
| const streamResult = createReadStream(storagePath, { start, end }); | ||
| if (!streamResult) { | ||
| return reply.code(500).send({ error: 'Stream error' }); | ||
| } | ||
|
|
||
| return reply.code(206).send(streamResult.stream); | ||
| } else { | ||
| // Range not satisfiable | ||
| reply.header('Content-Range', `bytes */${stats.size}`); | ||
| return reply.code(416).send({ error: 'Range Not Satisfiable' }); | ||
| } | ||
| } |
There was a problem hiding this comment.
The new range request functionality lacks test coverage. Consider adding tests to verify: (1) correct parsing of all range formats (bytes=0-1023, bytes=1024-, bytes=-500), (2) proper 206 response with correct Content-Range headers, (3) 416 response for invalid ranges, (4) correct handling of ranges that exceed file size, and (5) stream error handling.
| }); | ||
| headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`; | ||
| headers['Content-Length'] = chunkSize; | ||
| headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled); |
There was a problem hiding this comment.
The Vary header on line 319 overrides the one set by getAllHeaders on line 309. The getVaryHeader function only includes 'Accept, Origin' or 'Origin', but it's missing 'Authorization' which is important for caching - responses can vary based on authorization. This could cause caching issues where authenticated and unauthenticated responses get confused. Consider not overriding the Vary header that getAllHeaders already sets correctly.
| headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled); |
| if (!streamResult) { | ||
| return reply.code(500).send({ error: 'Stream error' }); | ||
| } | ||
|
|
There was a problem hiding this comment.
Stream errors that occur after the stream is passed to reply.send() are not handled. If the file is deleted or becomes unreadable while streaming, the error will not be caught, potentially causing the response to hang or fail silently. Consider adding an error handler to the stream before sending it in the response.
| 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' }); | |
| } | |
| }); |
| if (parts.length !== 2) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
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.
|
|
||
| try { | ||
| const stream = fs.createReadStream(filePath, options); | ||
| return { stream, filePath }; | ||
| } catch { |
There was a problem hiding this comment.
The try-catch block in createReadStream will not catch file read errors because fs.createReadStream doesn't throw synchronously. Instead, it returns a stream that emits error events asynchronously. This function will always return a stream object (even for non-existent files) rather than null, which breaks the error handling in the caller at line 324 of resource.js. Consider checking if the file exists before creating the stream, or handle stream errors in the caller.
| try { | |
| const stream = fs.createReadStream(filePath, options); | |
| return { stream, filePath }; | |
| } catch { | |
| // Ensure the file exists before creating a read stream so we can | |
| // reliably return null for missing resources. | |
| if (!fs.pathExistsSync(filePath)) { | |
| return null; | |
| } | |
| try { | |
| const stream = fs.createReadStream(filePath, options); | |
| return { stream, filePath }; | |
| } catch { | |
| // Handle rare cases where createReadStream throws synchronously |
- Remove redundant createReadStream import (use storage.createReadStream) - Check file existence before creating stream (sync check) - Add stream error handler for errors during response - Ignore multi-range requests per RFC 7233 (serve full content) - Don't override Vary header (getAllHeaders sets it correctly) - Add comprehensive test suite for range requests (10 tests)
Summary
Changes
src/handlers/resource.js: AddparseRangeHeader()helper and range request handling inhandleGetsrc/ldp/headers.js: AddAccept-Ranges: bytesheader, update CORS headerssrc/storage/filesystem.js: AddcreateReadStream()for streaming partial contentSupported Range Formats
bytes=0-1023- first 1024 bytesbytes=1024-- from byte 1024 to endbytes=-500- last 500 bytesTest Plan
Fixes #68