import type { Context } from "koa"; import Router from "koa-router"; import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap"; import type { ContentContext } from "./context"; import { since_last_modified } from "./util"; import type { ZipReader } from "@zip.js/zip.js"; import type { FileHandle } from "node:fs/promises"; import { Readable } from "node:stream"; /** * zip stream cache. */ const ZipStreamCache = new Map, handle: FileHandle, refCount: number, }>(); function markUseZip(path: string) { const ret = ZipStreamCache.get(path); if (ret) { ret.refCount++; } return ret !== undefined; } async function acquireZip(path: string, marked = false) { const ret = ZipStreamCache.get(path); if (!ret) { const obj = await readZip(path); const check = ZipStreamCache.get(path); if (check) { check.refCount++; // if the cache is updated, release the previous one. releaseZip(path); return check.reader; } // if the cache is not updated, set the new one. ZipStreamCache.set(path, { reader: obj.reader, handle: obj.handle, refCount: 1, }); return obj.reader; } if (!marked) { ret.refCount++; } return ret.reader; } function releaseZip(path: string) { const obj = ZipStreamCache.get(path); if (obj === undefined) { console.warn(`warning! duplicate release at ${path}`); return; } if (obj.refCount === 1) { const { reader, handle } = obj; reader.close().then(() => { handle.close(); }); ZipStreamCache.delete(path); } else { obj.refCount--; } } async function renderZipImage(ctx: Context, path: string, page: number) { const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"]; const marked = markUseZip(path); const zip = await acquireZip(path, marked); const entries = (await entriesByNaturalOrder(zip)).filter((x) => { const ext = x.filename.split(".").pop(); return ext !== undefined && image_ext.includes(ext); }); if (0 <= page && page < entries.length) { const entry = entries[page]; const last_modified = entry.lastModDate; if (since_last_modified(ctx, last_modified)) { return; } const read_stream = await createReadableStreamFromZip(zip, entry); const nodeReadableStream = new Readable(); nodeReadableStream._read = () => { }; read_stream.pipeTo(new WritableStream({ write(chunk) { nodeReadableStream.push(chunk); }, close() { nodeReadableStream.push(null); }, })); nodeReadableStream.on("error", (err) => { console.error(err); releaseZip(path); }); nodeReadableStream.on("close", () => { releaseZip(path); }); ctx.body = nodeReadableStream; ctx.response.length = entry.uncompressedSize; // console.log(`${entry.name}'s ${page}:${entry.size}`); ctx.response.type = entry.filename.split(".").pop() as string; ctx.status = 200; ctx.set("Date", new Date().toUTCString()); ctx.set("Last-Modified", last_modified.toUTCString()); } else { ctx.status = 404; } } export class ComicRouter extends Router { constructor() { super(); this.get("/", async (ctx, next) => { await renderZipImage(ctx, ctx.state.location.path, 0); }); this.get("/:page(\\d+)", async (ctx, next) => { const page = Number.parseInt(ctx.params.page); await renderZipImage(ctx, ctx.state.location.path, page); }); this.get("/thumbnail", async (ctx, next) => { await renderZipImage(ctx, ctx.state.location.path, 0); }); } } export default ComicRouter;