import type { Context } from "@oak/oak"; const CHUNK_SIZE = 64 * 1024; /** * Streams `length` bytes of an open file, from wherever its cursor stands, * and closes it once done or cancelled. The response never holds more than a * chunk of the file in memory — a seek in a 50 MB track reads what it plays. */ function fileStream( file: Deno.FsFile, length: number, ): ReadableStream { let remaining = length; let closed = false; const close = () => { if (closed) return; closed = true; file.close(); }; return new ReadableStream({ async pull(controller) { if (remaining <= 0) { close(); controller.close(); return; } const buf = new Uint8Array(Math.min(CHUNK_SIZE, remaining)); let n: number | null; try { n = await file.read(buf); } catch (err) { close(); controller.error(err); return; } if (n === null) { close(); controller.close(); return; } remaining -= n; controller.enqueue(buf.subarray(0, n)); if (remaining <= 0) { close(); controller.close(); } }, cancel() { close(); }, }); } /** Whether an If-None-Match header names `etag` (weak comparison). */ function matchesEtag(header: string | null, etag: string): boolean { if (!header) return false; if (header.trim() === "*") return true; const strip = (tag: string) => tag.trim().replace(/^W\//, ""); return header.split(",").some((tag) => strip(tag) === strip(etag)); } export interface ServeFileOptions { mime: string; cacheControl: string; /** Answer a single `Range: bytes=…` with a 206 (media files). */ ranges?: boolean; } /** * Sends a file from disk with an ETag (a 304 when the client already has it) * and, when `ranges` is set, single byte ranges. The body is streamed. * * Returns false, having sent nothing, when the file doesn't exist. */ export async function serveFile( ctx: Context, path: string, { mime, cacheControl, ranges = false }: ServeFileOptions, ): Promise { let file: Deno.FsFile; let stat: Deno.FileInfo; try { file = await Deno.open(path, { read: true }); stat = await file.stat(); } catch { return false; } const total = stat.size; const mtime = stat.mtime?.getTime() ?? 0; const etag = `W/"${total.toString(36)}-${mtime.toString(36)}"`; const headers = ctx.response.headers; headers.set("Content-Type", mime); headers.set("Cache-Control", cacheControl); headers.set("ETag", etag); if (stat.mtime) headers.set("Last-Modified", stat.mtime.toUTCString()); if (ranges) headers.set("Accept-Ranges", "bytes"); if (matchesEtag(ctx.request.headers.get("If-None-Match"), etag)) { file.close(); ctx.response.status = 304; return true; } // Only a single byte range is served as one. Anything else — several // ranges, another unit — is a header RFC 9110 lets us ignore, so the whole // file goes out with a 200 rather than a 416 for ranges that were fine. const match = ranges ? ctx.request.headers.get("Range")?.match(/^bytes=(\d*)-(\d*)$/) : null; if (!match) { // Explicit: a caller may have tried another file first and left a 404. ctx.response.status = 200; headers.set("Content-Length", String(total)); ctx.response.body = fileStream(file, total); return true; } // "bytes=a-b", "bytes=a-" (to the end) or "bytes=-n" (the last n bytes). let start: number; let end: number; if (match[1]) { start = parseInt(match[1], 10); end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1; } else { const suffix = parseInt(match[2], 10); start = Math.max(total - suffix, 0); end = total - 1; // "bytes=-" and "bytes=-0" name no bytes at all. if (!(suffix > 0)) start = total; } if (start > end || start >= total) { file.close(); ctx.response.status = 416; headers.set("Content-Range", `bytes */${total}`); return true; } const length = end - start + 1; try { await file.seek(start, Deno.SeekMode.Start); } catch (err) { file.close(); throw err; } ctx.response.status = 206; headers.set("Content-Range", `bytes ${start}-${end}/${total}`); headers.set("Content-Length", String(length)); ctx.response.body = fileStream(file, length); return true; }