165 lines
4.5 KiB
TypeScript
165 lines
4.5 KiB
TypeScript
/**
|
|
* Serving files from disk with ETags and byte ranges, streamed a chunk at a
|
|
* time.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
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<Uint8Array> {
|
|
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();
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Checks 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));
|
|
}
|
|
|
|
/** How {@link serveFile} answers. */
|
|
export interface ServeFileOptions {
|
|
/** The `Content-Type` sent. */
|
|
mime: string;
|
|
/** The `Cache-Control` sent. */
|
|
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<boolean> {
|
|
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;
|
|
}
|