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..667f8b90 --- /dev/null +++ b/functions/videos/[[path]].ts @@ -0,0 +1,127 @@ +/// + +// 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. + +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\s*=\s*(\d*)-(\d*)$/i.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); + // 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) return 'unsatisfiable'; + 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; + if ( + (request.method !== 'GET' && request.method !== 'HEAD') || + !/\.mp4$/i.test(new URL(request.url).pathname) + ) { + return context.next(); + } + + // 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'); + const asset = await context.env.ASSETS.fetch( + new Request(request.url, { method: request.method, 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. Pages assets have a strong ETag and no Last-Modified, so only an + // 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 || + !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. 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; + const whole = buffered ?? asset.body; + + const parsed = parseRange(range, size); + if (parsed === null) return new Response(whole, { status: 200, headers }); + if (parsed === 'unsatisfiable') { + if (!buffered) await asset.body.cancel(); + return new Response(null, { + status: 416, + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes */${size}`, + 'Cache-Control': 'no-store', + }, + }); + } + + const { start, end } = parsed; + const length = end - start + 1; + headers.set('Content-Range', `bytes ${start}-${end}/${size}`); + headers.set('Content-Length', String(length)); + 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 }); +}; diff --git a/public/_routes.json b/public/_routes.json index 635ad384..5cb303b6 100644 --- a/public/_routes.json +++ b/public/_routes.json @@ -1,5 +1,5 @@ { "version": 1, "include": ["/*"], - "exclude": ["/videos/*"] + "exclude": ["/videos/*.jpg"] }