This repository was archived by the owner on Oct 24, 2026. It is now read-only.
Repository navigation
fix: serve demo videos with byte ranges so Safari can play them - #759
Merged
Merged
Conversation
Cloudflare Pages answers a Range request for a static asset with the whole file and a 200. Safari and other AVFoundation players won't play an MP4 without 206 partial content, so the videos added in #758 fail there. - Add a /videos/* Pages Function that slices the asset and returns 206 (416 when unsatisfiable, whole file for multi-range or a stale If-Range). Range is ignored on HEAD, per RFC 9110. - Drop _routes.json so video requests reach the Function again, and put mp4 back in the middleware's SKIP_PATTERN so content negotiation stays out of the way.
- Match the range unit case-insensitively and allow spaces around '='. - Ignore a reversed range (last-pos < first-pos) instead of 416. - Compare If-Range strongly against the ETag, or against Last-Modified for a date. - Ask the asset layer for an unencoded response, so slices line up with Content-Range. - Keep HEAD as HEAD when fetching the asset. - Match .mp4 case-insensitively, like the middleware. - Exclude the posters (/videos/*.jpg) from Functions via _routes.json; only the MP4s need range handling.
AbhishekDoshi26
commented
Oct 23, 2026
AbhishekDoshi26
left a comment
Member
Author
There was a problem hiding this comment.
Review round 1 (fresh reviewer). Inline findings below.
AbhishekDoshi26
commented
Oct 23, 2026
- Stream the requested slice through a FixedLengthStream when the asset declares its size, and only buffer when it doesn't. - Return 416 as uncacheable text/plain instead of reusing the video's public Cache-Control, ETag and type. - Drop the date form of If-Range: Pages assets send no Last-Modified, so only an exact ETag match counts. - Read Range after the method/path guard.
AbhishekDoshi26
commented
Oct 23, 2026
AbhishekDoshi26
left a comment
Member
Author
There was a problem hiding this comment.
Review round 2 (fresh reviewer). Inline findings below. It also repeated the round 1 cost point (a Function per video request instead of R2/Stream), which is settled in that thread.
- Chain the slice through FixedLengthStream with pipeThrough, so a stream error reaches the response instead of being swallowed in waitUntil. - Return buffered slices as a zero-copy view. - Send Accept-Ranges on 416 and drop its empty Content-Type. - Never honor If-Range with a weak entity tag.
AbhishekDoshi26
commented
Oct 23, 2026
AbhishekDoshi26
left a comment
Member
Author
There was a problem hiding this comment.
Review round 3 (fresh reviewer). Five inline findings below. It checked production: the ETag is strong and every MP4 has moov before mdat.
AbhishekDoshi26
commented
Oct 23, 2026
AbhishekDoshi26
left a comment
Member
Author
There was a problem hiding this comment.
Review round 4 (fresh reviewer). It found no correctness issues and confirmed the parsing, If-Range, 416, HEAD and pass-through paths. Its points, and why none changes the PR:
- Buffering when the asset sends no
Content-Length: production's static responses do carryContent-Length, so this is expected to take the streaming path there. Buffering is only a fallback. After merge I'll confirm on prod that slices come back correct. - Function quota per range request, and moving to R2 or Workers Static Assets: settled in round 1. Keeping the videos on the docs origin was the chosen tradeoff.
- Weak asset ETags and a truncated body under a declared
Content-Length: production ETags are strong (checked in round 3). A body shorter than its declared length is a broken asset that no handler can fix, and the stream errors visibly instead of silently. - Merge the buffered and streamed paths: round 2 asked for streaming. Keeping the buffer only as a fallback is what makes local dev (no
Content-Length) work. - Route only
.mp4through the Function instead of excluding.jpg:_routes.jsonexclusions take priority over includes, so/videos/*can't be excluded with*.mp4added back. Excluding the one other file type in use is the narrowest working rule. - 416 drops the ETag and Content-Type, and re-sets
Accept-Ranges: round 2 asked for the 416 to drop the video's cacheable headers, and round 3 forAccept-Ranges. It's a three-header response kept on purpose.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dependencies
Follows #758 (merged).
Problem
#758 serves the demo videos as static files from Cloudflare Pages. Pages ignores
Rangeand answers with the whole file and a200:Safari, and anything else built on AVFoundation, needs
206partial content to play an MP4. AVPlayer fails on the live URLs with "Operation Stopped", but plays the same file from disk and plays a range-capable control URL. Chrome and Firefox are fine with a200, so this mainly breaks Safari and iOS.Fix
functions/videos/[[path]].tsserves byte ranges for/videos/*.mp4:206withContent-Rangeforbytes=a-b,bytes=a-andbytes=-n, and an uncacheable416when the range is unsatisfiable. A multi-range request, a reversed range, or anIf-Rangethat doesn't match the ETag gets the whole file.Rangeis ignored on HEAD (RFC 9110 only defines it for GET). Non-200asset responses (404, 304) pass through unchanged. The slice is streamed when the asset declares aContent-Length; otherwise the file is buffered once to learn its size, which Pages' 25 MiB per-file limit bounds.public/_routes.jsonnow excludes only/videos/*.jpg(it was/videos/*), so the MP4s reach the Function and the posters stay plain static files.mp4back in the middleware'sSKIP_PATTERN, so content negotiation leaves videos alone and passes them to the new route.Testing
Under
wrangler pages dev dist, for both the buffered path (local assets send noContent-Length) and the streaming path (forced for the test):bytes=0-1023,bytes=1000-1999,bytes=-100andbytes=3000000-return206with the rightContent-Range, and the bytes match the file.bytes=9999999-andbytes=-0return416withContent-Range: bytes */3489158andCache-Control: no-store.Range, multi-range, reversed range, weak or dateIf-Range, and HEAD (with or withoutRange) return200. A matchingIf-Rangereturns206, andIf-None-Matchreturns304.Range: Bytes = 0-9(mixed case, spaces) returns206, andAccept-Encoding: gzip, brgives an unencoded slice._routes.json, a mid-path wildcard works: excluding/videos/*.mp4turns MP4 responses back into plain200s, and excluding/videos/*.jpgkeeps MP4s on the Function.Cache-Control: public, max-age=604800from_headersis kept, and a missing MP4 still returns404.Link/Varyheaders.create-api-key.mp4andgetting-started.mp4from the local server on both paths. It fails on production today.npm run buildandtsc -p functionspass.