feat: serveStatic을 사용하여 정적 자산 제공 기능 추가 및 createStaticRouter 제거

This commit is contained in:
monoid 2025-11-01 01:17:47 +09:00
parent 3424d13c11
commit eb06208f80
2 changed files with 12 additions and 146 deletions

View file

@ -1,10 +1,10 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { readFileSync } from "node:fs";
import { createInterface as createReadlineInterface } from "node:readline";
import { config } from "dotenv";
import { connectDB } from "./database.ts";
import { createDiffRouter, DiffManager } from "./diff/mod.ts";
import { get_setting, initializeSetting } from "./SettingConfig.ts";
@ -16,7 +16,6 @@ import { createSettingsRouter } from "./route/settings.ts";
import { createComicWatcher } from "./diff/watcher/comic_watcher.ts";
import { loadComicConfig } from "./diff/watcher/ComicConfig.ts";
import { getTagRounter } from "./route/tags.ts";
import { createStaticRouter } from "./util/static.ts";
config();
@ -97,15 +96,17 @@ export async function create_server() {
app.use("*", cors());
app.use("*", createUserHandler(userController));
const staticRouter = createStaticRouter({
assets: "dist/assets",
prefix: "/assets",
headers: {
"X-Content-Type-Options": "nosniff",
"Cache-Control": setting.mode === "development" ? "no-cache" : "public, max-age=3600",
},
});
app.route("/", staticRouter);
const assetCacheControl = setting.mode === "development" ? "no-cache" : "public, max-age=3600";
app.use(
"/assets/*",
serveStatic<AppEnv>({
root: "./dist",
onFound: (_path, c) => {
c.header("X-Content-Type-Options", "nosniff");
c.header("Cache-Control", assetCacheControl);
},
}),
);
app.onError((err, _c) => {
const { status, body } = mapErrorToResponse(normalizeError(err));

View file

@ -1,135 +0,0 @@
import { Hono } from "hono";
import type { Context } from "hono";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { extname, resolve } from "node:path";
const MIME_TYPES: Record<string, string> = {
html: "text/html; charset=utf-8",
css: "text/css; charset=utf-8",
js: "application/javascript; charset=utf-8",
mjs: "application/javascript; charset=utf-8",
map: "application/json; charset=utf-8",
json: "application/json; charset=utf-8",
txt: "text/plain; charset=utf-8",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
ico: "image/x-icon",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
};
const toPosix = (value: string) => value.replace(/\\/g, "/");
const isPathWithinRoot = (candidate: string, root: string) => {
const normalizedCandidate = toPosix(resolve(candidate));
const normalizedRoot = toPosix(resolve(root));
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
};
const getMimeType = (path: string) => {
const ext = extname(path).slice(1).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
};
const generateETag = (mtimeMs: number, size: number) => `W/"${size.toString(16)}-${Math.round(mtimeMs).toString(16)}"`;
export type StaticPluginOptions = {
assets: string;
prefix?: string;
headers?: Record<string, string>;
};
const buildResponse = (status: number, headers: Record<string, string>, body: BodyInit | null) =>
new Response(body, { status, headers });
const resolveWildcard = (context: Context, wildcardParam: string) => {
const wildcard = context.req.param(wildcardParam) ?? "";
if (wildcard.length === 0) {
return undefined;
}
try {
const decoded = decodeURI(wildcard);
if (decoded.includes("\0")) {
return undefined;
}
return decoded;
} catch {
return undefined;
}
};
const handleStaticRequest = async (
context: Context,
rootDir: string,
headersTemplate: Record<string, string>,
sendBody: boolean,
) => {
const pathFragment = resolveWildcard(context, "*");
if (!pathFragment) {
return buildResponse(404, {}, null);
}
const absolutePath = resolve(rootDir, pathFragment);
if (!isPathWithinRoot(absolutePath, rootDir)) {
return buildResponse(404, {}, null);
}
const fileStat = await stat(absolutePath).catch(() => undefined);
if (!fileStat || fileStat.isDirectory()) {
return buildResponse(404, {}, null);
}
const responseHeaders: Record<string, string> = {
...headersTemplate,
"Last-Modified": fileStat.mtime.toUTCString(),
};
const etag = generateETag(fileStat.mtimeMs, fileStat.size);
responseHeaders.ETag = etag;
const ifNoneMatch = context.req.header("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
return buildResponse(304, responseHeaders, null);
}
const ifModifiedSince = context.req.header("if-modified-since");
if (ifModifiedSince) {
const since = new Date(ifModifiedSince);
if (!Number.isNaN(since.getTime()) && fileStat.mtime <= since) {
return buildResponse(304, responseHeaders, null);
}
}
responseHeaders["Content-Type"] = getMimeType(absolutePath);
responseHeaders["Content-Length"] = fileStat.size.toString();
if (!sendBody) {
return buildResponse(200, responseHeaders, null);
}
const nodeStream = createReadStream(absolutePath);
const body = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
return buildResponse(200, responseHeaders, body);
};
export const createStaticRouter = ({ assets, prefix = "/public", headers = {} }: StaticPluginOptions) => {
const trimmedPrefix = prefix.endsWith("/") && prefix !== "/" ? prefix.slice(0, -1) : prefix;
const normalizedPrefix = trimmedPrefix.startsWith("/") ? trimmedPrefix : `/${trimmedPrefix}`;
const wildcardRoute = normalizedPrefix === "/" ? "/*" : `${normalizedPrefix}/*`;
const rootDir = resolve(process.cwd(), assets);
const headersTemplate = { ...headers };
const router = new Hono();
router.get(wildcardRoute, (c) => handleStaticRequest(c, rootDir, headersTemplate, true));
router.on("HEAD", wildcardRoute, (c) => handleStaticRequest(c, rootDir, headersTemplate, false));
return router;
};