From eb06208f80104477297dc5eb234788e7be10e91d Mon Sep 17 00:00:00 2001 From: monoid Date: Sat, 1 Nov 2025 01:17:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20serveStatic=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=A0=95=EC=A0=81=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EC=A0=9C=EA=B3=B5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20createStaticRouter=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/server.ts | 23 ++--- packages/server/src/util/static.ts | 135 ----------------------------- 2 files changed, 12 insertions(+), 146 deletions(-) delete mode 100644 packages/server/src/util/static.ts diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 96c0585..5e07e6a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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({ + 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)); diff --git a/packages/server/src/util/static.ts b/packages/server/src/util/static.ts deleted file mode 100644 index d423c2e..0000000 --- a/packages/server/src/util/static.ts +++ /dev/null @@ -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 = { - 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; -}; - -const buildResponse = (status: number, headers: Record, 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, - 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 = { - ...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; - 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; -};