ionian/packages/server/src/route/comic.ts

104 lines
2.6 KiB
TypeScript

import type { Context as ElysiaContext } from "elysia";
import { Readable } from "node:stream";
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts";
const imageExtensions = new Set(["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]);
const extensionToMime = (ext: string) => {
if (ext === "jpg") return "image/jpeg";
return `image/${ext}`;
};
type ResponseSet = Pick<ElysiaContext["set"], "status" | "headers">;
type RenderOptions = {
path: string;
page: number;
reqHeaders: Headers;
set: ResponseSet;
};
export async function renderComicPage({ path, page, reqHeaders, set }: RenderOptions) {
const zip = await readZip(path);
try {
const entries = (await entriesByNaturalOrder(zip.reader)).filter((entry) => {
const ext = entry.filename.split(".").pop()?.toLowerCase();
return ext !== undefined && imageExtensions.has(ext);
});
if (page < 0 || page >= entries.length) {
set.status = 404;
await zip.reader.close();
return null;
}
const entry = entries[page];
const lastModified = entry.lastModDate ?? new Date();
const ifModifiedSince = reqHeaders.get("if-modified-since");
const headers = (set.headers ??= {} as Record<string, string | number>);
headers["Date"] = new Date().toUTCString();
headers["Last-Modified"] = lastModified.toUTCString();
if (ifModifiedSince) {
const cachedDate = new Date(ifModifiedSince);
if (!Number.isNaN(cachedDate.valueOf()) && lastModified <= cachedDate) {
set.status = 304;
await zip.reader.close();
return null;
}
}
const readStream = await createReadableStreamFromZip(zip.reader, entry);
const nodeReadable = new Readable({
read() {
// noop
},
});
let zipClosed = false;
const closeZip = async () => {
if (!zipClosed) {
zipClosed = true;
await zip.reader.close();
}
};
readStream.pipeTo(new WritableStream({
write(chunk) {
nodeReadable.push(chunk);
},
close() {
nodeReadable.push(null);
},
abort(err) {
nodeReadable.destroy(err);
},
})).catch((err) => {
nodeReadable.destroy(err);
});
nodeReadable.on("close", () => {
closeZip().catch(console.error);
});
nodeReadable.on("error", () => {
closeZip().catch(console.error);
});
nodeReadable.on("end", () => {
closeZip().catch(console.error);
});
const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg";
headers["Content-Type"] = extensionToMime(ext);
if (typeof entry.uncompressedSize === "number") {
headers["Content-Length"] = entry.uncompressedSize.toString();
}
set.status = 200;
return nodeReadable;
} catch (error) {
await zip.reader.close();
throw error;
}
}