From 9865a5d190bd401fd9638797820456db7a5499e5 Mon Sep 17 00:00:00 2001 From: Abhishek Doshi Date: Fri, 23 Oct 2026 18:01:29 +0530 Subject: [PATCH 1/4] fix: serve demo videos with byte ranges so Safari can play them 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. --- functions/_middleware.ts | 2 +- functions/videos/[[path]].ts | 86 ++++++++++++++++++++++++++++++++++++ public/_routes.json | 5 --- 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 functions/videos/[[path]].ts delete mode 100644 public/_routes.json diff --git a/functions/_middleware.ts b/functions/_middleware.ts index 205ce27f..874d811d 100644 --- a/functions/_middleware.ts +++ b/functions/_middleware.ts @@ -27,7 +27,7 @@ interface Env { // since `negotiate()` only knows about text/markdown, text/html, and // application/json. const SKIP_PATTERN = - /\.(css|js|mjs|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|pdf|xml|txt|map)$/i; + /\.(css|js|mjs|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|pdf|xml|txt|map|mp4)$/i; interface AcceptEntry { type: string; diff --git a/functions/videos/[[path]].ts b/functions/videos/[[path]].ts new file mode 100644 index 00000000..bfdbf275 --- /dev/null +++ b/functions/videos/[[path]].ts @@ -0,0 +1,86 @@ +/// + +// Byte-range support for the demo videos. Cloudflare Pages answers a Range +// request for a static asset with the whole file and a 200, and Safari (and +// anything else built on AVFoundation) won't play an MP4 from a server that +// doesn't return 206 partial content. This serves the asset and slices it. +// The videos are a few MB each, so buffering one is fine. + +interface Env { + ASSETS: Fetcher; +} + +// `bytes=start-end`, `bytes=start-` or `bytes=-suffixLength`. Multi-range +// requests aren't used by media players, so they get the whole file. +function parseRange( + header: string, + size: number, +): { start: number; end: number } | 'unsatisfiable' | null { + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (!match) return null; + const [, first, last] = match; + if (first === '' && last === '') return null; + let start: number; + let end: number; + if (first === '') { + const suffix = Number(last); + if (suffix === 0) return 'unsatisfiable'; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + start = Number(first); + end = last === '' ? size - 1 : Math.min(Number(last), size - 1); + } + if (start >= size || start > end) return 'unsatisfiable'; + return { start, end }; +} + +export const onRequest: PagesFunction = async (context) => { + const { request } = context; + const range = request.headers.get('Range'); + if ( + (request.method !== 'GET' && request.method !== 'HEAD') || + !new URL(request.url).pathname.endsWith('.mp4') + ) { + return context.next(); + } + + const assetHeaders = new Headers(request.headers); + assetHeaders.delete('Range'); + const asset = await context.env.ASSETS.fetch( + new Request(request.url, { method: 'GET', headers: assetHeaders }), + ); + if (asset.status !== 200) return asset; + const headers = new Headers(asset.headers); + headers.set('Accept-Ranges', 'bytes'); + + // Range only applies to GET (RFC 9110 section 14.2). A stale If-Range + // means the client's partial copy is out of date, so it gets the whole new + // file instead of a slice of it. + const ifRange = request.headers.get('If-Range'); + const useRange = + request.method === 'GET' && + range !== null && + (!ifRange || ifRange === asset.headers.get('ETag')); + + if (!useRange) { + return new Response(request.method === 'HEAD' ? null : asset.body, { + status: asset.status, + headers, + }); + } + + const body = await asset.arrayBuffer(); + const parsed = parseRange(range, body.byteLength); + if (parsed === null) return new Response(body, { status: 200, headers }); + if (parsed === 'unsatisfiable') { + headers.set('Content-Range', `bytes */${body.byteLength}`); + headers.delete('Content-Length'); + return new Response(null, { status: 416, headers }); + } + + const { start, end } = parsed; + headers.set('Content-Range', `bytes ${start}-${end}/${body.byteLength}`); + headers.set('Content-Length', String(end - start + 1)); + return new Response(body.slice(start, end + 1), { status: 206, headers }); +}; diff --git a/public/_routes.json b/public/_routes.json deleted file mode 100644 index 635ad384..00000000 --- a/public/_routes.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "include": ["/*"], - "exclude": ["/videos/*"] -} From 14af74e5f9683fca84f404a3d350d5dabbaebe9c Mon Sep 17 00:00:00 2001 From: Abhishek Doshi Date: Fri, 23 Oct 2026 18:04:12 +0530 Subject: [PATCH 2/4] fix: tighten video range handling per RFC 9110 - 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. --- functions/videos/[[path]].ts | 23 ++++++++++++++++------- public/_routes.json | 5 +++++ 2 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 public/_routes.json diff --git a/functions/videos/[[path]].ts b/functions/videos/[[path]].ts index bfdbf275..9b782525 100644 --- a/functions/videos/[[path]].ts +++ b/functions/videos/[[path]].ts @@ -16,7 +16,7 @@ function parseRange( header: string, size: number, ): { start: number; end: number } | 'unsatisfiable' | null { - const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + const match = /^bytes\s*=\s*(\d*)-(\d*)$/i.exec(header.trim()); if (!match) return null; const [, first, last] = match; if (first === '' && last === '') return null; @@ -29,9 +29,12 @@ function parseRange( end = size - 1; } else { start = Number(first); + // A last-pos before first-pos makes the range invalid, so it's ignored + // rather than unsatisfiable (RFC 9110 section 14.1.1). + if (last !== '' && Number(last) < start) return null; end = last === '' ? size - 1 : Math.min(Number(last), size - 1); } - if (start >= size || start > end) return 'unsatisfiable'; + if (start >= size) return 'unsatisfiable'; return { start, end }; } @@ -40,15 +43,17 @@ export const onRequest: PagesFunction = async (context) => { const range = request.headers.get('Range'); if ( (request.method !== 'GET' && request.method !== 'HEAD') || - !new URL(request.url).pathname.endsWith('.mp4') + !/\.mp4$/i.test(new URL(request.url).pathname) ) { return context.next(); } + // Slices are cut from the decoded bytes, so ask for an unencoded asset. const assetHeaders = new Headers(request.headers); assetHeaders.delete('Range'); + assetHeaders.delete('Accept-Encoding'); const asset = await context.env.ASSETS.fetch( - new Request(request.url, { method: 'GET', headers: assetHeaders }), + new Request(request.url, { method: request.method, headers: assetHeaders }), ); if (asset.status !== 200) return asset; const headers = new Headers(asset.headers); @@ -56,12 +61,16 @@ export const onRequest: PagesFunction = async (context) => { // Range only applies to GET (RFC 9110 section 14.2). A stale If-Range // means the client's partial copy is out of date, so it gets the whole new - // file instead of a slice of it. - const ifRange = request.headers.get('If-Range'); + // file instead of a slice of it. If-Range holds either an entity tag + // (strong comparison, so a weak one never matches) or a date. + const ifRange = request.headers.get('If-Range')?.trim(); + const validator = ifRange?.startsWith('"') + ? asset.headers.get('ETag') + : asset.headers.get('Last-Modified'); const useRange = request.method === 'GET' && range !== null && - (!ifRange || ifRange === asset.headers.get('ETag')); + (!ifRange || ifRange === validator); if (!useRange) { return new Response(request.method === 'HEAD' ? null : asset.body, { diff --git a/public/_routes.json b/public/_routes.json new file mode 100644 index 00000000..5cb303b6 --- /dev/null +++ b/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/*"], + "exclude": ["/videos/*.jpg"] +} From 39113090eb8b6d1ccd6433a75de8330897e1e5e8 Mon Sep 17 00:00:00 2001 From: Abhishek Doshi Date: Fri, 23 Oct 2026 18:07:06 +0530 Subject: [PATCH 3/4] fix: stream video range slices and keep 416 out of caches - 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. --- functions/videos/[[path]].ts | 92 +++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/functions/videos/[[path]].ts b/functions/videos/[[path]].ts index 9b782525..909310d3 100644 --- a/functions/videos/[[path]].ts +++ b/functions/videos/[[path]].ts @@ -4,7 +4,6 @@ // request for a static asset with the whole file and a 200, and Safari (and // anything else built on AVFoundation) won't play an MP4 from a server that // doesn't return 206 partial content. This serves the asset and slices it. -// The videos are a few MB each, so buffering one is fine. interface Env { ASSETS: Fetcher; @@ -38,9 +37,25 @@ function parseRange( return { start, end }; } +// Passes through only bytes start..end (inclusive) of a stream. +function sliceStream( + start: number, + end: number, +): TransformStream { + let offset = 0; + return new TransformStream({ + transform(chunk, controller) { + const from = Math.max(start - offset, 0); + const to = Math.min(end + 1 - offset, chunk.byteLength); + if (from < to) controller.enqueue(chunk.subarray(from, to)); + offset += chunk.byteLength; + if (offset > end) controller.terminate(); + }, + }); +} + export const onRequest: PagesFunction = async (context) => { const { request } = context; - const range = request.headers.get('Range'); if ( (request.method !== 'GET' && request.method !== 'HEAD') || !/\.mp4$/i.test(new URL(request.url).pathname) @@ -48,7 +63,7 @@ export const onRequest: PagesFunction = async (context) => { return context.next(); } - // Slices are cut from the decoded bytes, so ask for an unencoded asset. + // Slices are cut from the raw bytes, so ask for an unencoded asset. const assetHeaders = new Headers(request.headers); assetHeaders.delete('Range'); assetHeaders.delete('Accept-Encoding'); @@ -61,35 +76,58 @@ export const onRequest: PagesFunction = async (context) => { // Range only applies to GET (RFC 9110 section 14.2). A stale If-Range // means the client's partial copy is out of date, so it gets the whole new - // file instead of a slice of it. If-Range holds either an entity tag - // (strong comparison, so a weak one never matches) or a date. + // file. Pages assets have a strong ETag and no Last-Modified, so only an + // exact entity-tag match counts. + const range = request.headers.get('Range'); const ifRange = request.headers.get('If-Range')?.trim(); - const validator = ifRange?.startsWith('"') - ? asset.headers.get('ETag') - : asset.headers.get('Last-Modified'); - const useRange = - request.method === 'GET' && - range !== null && - (!ifRange || ifRange === validator); - - if (!useRange) { - return new Response(request.method === 'HEAD' ? null : asset.body, { - status: asset.status, - headers, - }); + if ( + request.method !== 'GET' || + range === null || + (ifRange !== undefined && ifRange !== asset.headers.get('ETag')) || + !asset.body + ) { + return new Response(asset.body, { status: 200, headers }); } - const body = await asset.arrayBuffer(); - const parsed = parseRange(range, body.byteLength); - if (parsed === null) return new Response(body, { status: 200, headers }); + // Stream the slice when the asset's size is known up front; otherwise read + // it once to learn the size. + const declaredSize = Number(asset.headers.get('Content-Length')); + const buffered = declaredSize > 0 ? null : await asset.arrayBuffer(); + const size = buffered ? buffered.byteLength : declaredSize; + const whole = buffered ?? asset.body; + + const parsed = parseRange(range, size); + if (parsed === null) return new Response(whole, { status: 200, headers }); if (parsed === 'unsatisfiable') { - headers.set('Content-Range', `bytes */${body.byteLength}`); - headers.delete('Content-Length'); - return new Response(null, { status: 416, headers }); + await asset.body.cancel().catch(() => {}); + return new Response(null, { + status: 416, + headers: { + 'Content-Range': `bytes */${size}`, + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); } const { start, end } = parsed; - headers.set('Content-Range', `bytes ${start}-${end}/${body.byteLength}`); - headers.set('Content-Length', String(end - start + 1)); - return new Response(body.slice(start, end + 1), { status: 206, headers }); + const length = end - start + 1; + headers.set('Content-Range', `bytes ${start}-${end}/${size}`); + headers.set('Content-Length', String(length)); + if (buffered) { + return new Response(buffered.slice(start, end + 1), { + status: 206, + headers, + }); + } + const { readable, writable } = new FixedLengthStream(length); + // terminate() in sliceStream cancels the rest of the asset stream, which + // rejects the pipe; that's the expected end, not an error. + context.waitUntil( + asset.body + .pipeThrough(sliceStream(start, end)) + .pipeTo(writable) + .catch(() => {}), + ); + return new Response(readable, { status: 206, headers }); }; From 2ad0e7cb09b97fee6648d39f6e2e649d1570e5d8 Mon Sep 17 00:00:00 2001 From: Abhishek Doshi Date: Fri, 23 Oct 2026 18:10:49 +0530 Subject: [PATCH 4/4] fix: simplify the streamed video slice and tidy range responses - 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. --- functions/videos/[[path]].ts | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/functions/videos/[[path]].ts b/functions/videos/[[path]].ts index 909310d3..667f8b90 100644 --- a/functions/videos/[[path]].ts +++ b/functions/videos/[[path]].ts @@ -77,20 +77,24 @@ export const onRequest: PagesFunction = async (context) => { // Range only applies to GET (RFC 9110 section 14.2). A stale If-Range // means the client's partial copy is out of date, so it gets the whole new // file. Pages assets have a strong ETag and no Last-Modified, so only an - // exact entity-tag match counts. + // exact match on a strong entity tag counts (RFC 9110 section 13.1.5). const range = request.headers.get('Range'); const ifRange = request.headers.get('If-Range')?.trim(); + const ifRangeMatches = + ifRange === undefined || + (!ifRange.startsWith('W/') && ifRange === asset.headers.get('ETag')); if ( request.method !== 'GET' || range === null || - (ifRange !== undefined && ifRange !== asset.headers.get('ETag')) || + !ifRangeMatches || !asset.body ) { return new Response(asset.body, { status: 200, headers }); } // Stream the slice when the asset's size is known up front; otherwise read - // it once to learn the size. + // it once to learn the size. Pages caps a file at 25 MiB, which bounds the + // buffer. const declaredSize = Number(asset.headers.get('Content-Length')); const buffered = declaredSize > 0 ? null : await asset.arrayBuffer(); const size = buffered ? buffered.byteLength : declaredSize; @@ -99,12 +103,12 @@ export const onRequest: PagesFunction = async (context) => { const parsed = parseRange(range, size); if (parsed === null) return new Response(whole, { status: 200, headers }); if (parsed === 'unsatisfiable') { - await asset.body.cancel().catch(() => {}); + if (!buffered) await asset.body.cancel(); return new Response(null, { status: 416, headers: { + 'Accept-Ranges': 'bytes', 'Content-Range': `bytes */${size}`, - 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', }, }); @@ -114,20 +118,10 @@ export const onRequest: PagesFunction = async (context) => { const length = end - start + 1; headers.set('Content-Range', `bytes ${start}-${end}/${size}`); headers.set('Content-Length', String(length)); - if (buffered) { - return new Response(buffered.slice(start, end + 1), { - status: 206, - headers, - }); - } - const { readable, writable } = new FixedLengthStream(length); - // terminate() in sliceStream cancels the rest of the asset stream, which - // rejects the pipe; that's the expected end, not an error. - context.waitUntil( - asset.body - .pipeThrough(sliceStream(start, end)) - .pipeTo(writable) - .catch(() => {}), - ); - return new Response(readable, { status: 206, headers }); + const body = buffered + ? new Uint8Array(buffered, start, length) + : asset.body + .pipeThrough(sliceStream(start, end)) + .pipeThrough(new FixedLengthStream(length)); + return new Response(body, { status: 206, headers }); };