Skip to content
This repository was archived by the owner on Oct 24, 2026. It is now read-only.

fix: serve demo videos with byte ranges so Safari can play them - #759

Merged
AbhishekDoshi26 merged 4 commits into
mainfrom
fix/video-range-requests
Oct 23, 2026
Merged

AbhishekDoshi26 merged 4 commits into
mainfrom
fix/video-range-requests

Conversation

@AbhishekDoshi26

@AbhishekDoshi26 AbhishekDoshi26 commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Dependencies

Follows #758 (merged).

Problem

#758 serves the demo videos as static files from Cloudflare Pages. Pages ignores Range and answers with the whole file and a 200:

$ curl -sI -X GET -H 'Range: bytes=0-1023' https://docs.shorebird.dev/videos/create-api-key.mp4
HTTP/2 200
content-length: 3489158

Safari, and anything else built on AVFoundation, needs 206 partial 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 a 200, so this mainly breaks Safari and iOS.

Fix

  • New functions/videos/[[path]].ts serves byte ranges for /videos/*.mp4: 206 with Content-Range for bytes=a-b, bytes=a- and bytes=-n, and an uncacheable 416 when the range is unsatisfiable. A multi-range request, a reversed range, or an If-Range that doesn't match the ETag gets the whole file. Range is ignored on HEAD (RFC 9110 only defines it for GET). Non-200 asset responses (404, 304) pass through unchanged. The slice is streamed when the asset declares a Content-Length; otherwise the file is buffered once to learn its size, which Pages' 25 MiB per-file limit bounds.
  • public/_routes.json now excludes only /videos/*.jpg (it was /videos/*), so the MP4s reach the Function and the posters stay plain static files.
  • Put mp4 back in the middleware's SKIP_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 no Content-Length) and the streaming path (forced for the test):

  • bytes=0-1023, bytes=1000-1999, bytes=-100 and bytes=3000000- return 206 with the right Content-Range, and the bytes match the file.
  • bytes=9999999- and bytes=-0 return 416 with Content-Range: bytes */3489158 and Cache-Control: no-store.
  • No Range, multi-range, reversed range, weak or date If-Range, and HEAD (with or without Range) return 200. A matching If-Range returns 206, and If-None-Match returns 304.
  • Range: Bytes = 0-9 (mixed case, spaces) returns 206, and Accept-Encoding: gzip, br gives an unencoded slice.
  • In _routes.json, a mid-path wildcard works: excluding /videos/*.mp4 turns MP4 responses back into plain 200s, and excluding /videos/*.jpg keeps MP4s on the Function.
  • Cache-Control: public, max-age=604800 from _headers is kept, and a missing MP4 still returns 404.
  • Docs pages still negotiate Markdown and send the Link/Vary headers.
  • AVPlayer plays create-api-key.mp4 and getting-started.mp4 from the local server on both paths. It fails on production today.
  • npm run build and tsc -p functions pass.

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 AbhishekDoshi26 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review round 1 (fresh reviewer). Inline findings below.

Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts Outdated
Comment thread public/_routes.json
- 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 AbhishekDoshi26 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts Outdated
Comment thread public/_routes.json
- 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 AbhishekDoshi26 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review round 3 (fresh reviewer). Five inline findings below. It checked production: the ETag is strong and every MP4 has moov before mdat.

Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts Outdated
Comment thread functions/videos/[[path]].ts
Comment thread functions/videos/[[path]].ts Outdated

@AbhishekDoshi26 AbhishekDoshi26 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 carry Content-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 .mp4 through the Function instead of excluding .jpg: _routes.json exclusions take priority over includes, so /videos/* can't be excluded with *.mp4 added 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 for Accept-Ranges. It's a three-header response kept on purpose.

@AbhishekDoshi26
AbhishekDoshi26 merged commit 94479bc into main Oct 23, 2026
4 checks passed
@AbhishekDoshi26
AbhishekDoshi26 deleted the fix/video-range-requests branch October 23, 2026 12:43
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant