From 837c87fba4166e50039c52375103b7f930e03465 Mon Sep 17 00:00:00 2001 From: monoid Date: Wed, 1 Oct 2025 02:38:44 +0900 Subject: [PATCH] feat(server): serve static assets without bun --- packages/server/package.json | 1 - packages/server/src/server.ts | 2 +- packages/server/src/util/static.ts | 129 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 27 ------ 4 files changed, 130 insertions(+), 29 deletions(-) create mode 100644 packages/server/src/util/static.ts diff --git a/packages/server/package.json b/packages/server/package.json index d6f7a2d..06ba9f5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,7 +18,6 @@ "@elysiajs/html": "^1.3.1", "@elysiajs/node": "^1.4.1", "@elysiajs/openapi": "^1.4.11", - "@elysiajs/static": "^1.3.0", "@std/async": "npm:@jsr/std__async@^1.0.13", "@zip.js/zip.js": "^2.7.62", "better-sqlite3": "^9.6.0", diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index d51bcd3..1bcaa7a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,6 +1,6 @@ import { Elysia, t } from "elysia"; import { cors } from "@elysiajs/cors"; -import { staticPlugin } from "@elysiajs/static"; +import { staticPlugin } from "./util/static.ts"; import { html } from "@elysiajs/html"; import { connectDB } from "./database.ts"; diff --git a/packages/server/src/util/static.ts b/packages/server/src/util/static.ts new file mode 100644 index 0000000..0ef3d87 --- /dev/null +++ b/packages/server/src/util/static.ts @@ -0,0 +1,129 @@ +import { Elysia, NotFoundError, type Context } from "elysia"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +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 handleStaticRequest = async ( + rootDir: string, + headersTemplate: Record, + ctx: Context, + sendBody: boolean, +) => { + const wildcard = ctx.params?.["*"] ?? ""; + if (wildcard.length === 0) { + throw new NotFoundError(); + } + + let decoded: string; + try { + decoded = decodeURI(wildcard); + } catch { + throw new NotFoundError(); + } + + if (decoded.includes("\0")) { + throw new NotFoundError(); + } + + const absolutePath = resolve(rootDir, decoded); + if (!isPathWithinRoot(absolutePath, rootDir)) { + throw new NotFoundError(); + } + + const fileStat = await stat(absolutePath).catch(() => undefined); + if (!fileStat || fileStat.isDirectory()) { + throw new NotFoundError(); + } + + const responseHeaders: Record = { + ...headersTemplate, + "Last-Modified": fileStat.mtime.toUTCString(), + }; + + const etag = generateETag(fileStat.mtimeMs, fileStat.size); + responseHeaders.ETag = etag; + + const ifNoneMatch = ctx.request.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === etag) { + ctx.set.status = 304; + ctx.set.headers = responseHeaders; + return undefined; + } + + const ifModifiedSince = ctx.request.headers.get("if-modified-since"); + if (ifModifiedSince) { + const since = new Date(ifModifiedSince); + if (!Number.isNaN(since.getTime()) && fileStat.mtime <= since) { + ctx.set.status = 304; + ctx.set.headers = responseHeaders; + return undefined; + } + } + + responseHeaders["Content-Type"] = getMimeType(absolutePath); + responseHeaders["Content-Length"] = fileStat.size.toString(); + + ctx.set.status = 200; + ctx.set.headers = responseHeaders; + + if (!sendBody) { + return undefined; + } + + return createReadStream(absolutePath); +}; + +export const staticPlugin = ({ 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 }; + + return new Elysia({ name: "node-static" }) + .get(wildcardRoute, async (ctx) => handleStaticRequest(rootDir, headersTemplate, ctx, true)) + .head(wildcardRoute, async (ctx) => handleStaticRequest(rootDir, headersTemplate, ctx, false)); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd65b32..866e2c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,9 +181,6 @@ importers: '@elysiajs/openapi': specifier: ^1.4.11 version: 1.4.11(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)) - '@elysiajs/static': - specifier: ^1.3.0 - version: 1.3.0(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3)) '@std/async': specifier: npm:@jsr/std__async@^1.0.13 version: '@jsr/std__async@1.0.13' @@ -438,11 +435,6 @@ packages: peerDependencies: elysia: '>= 1.4.0' - '@elysiajs/static@1.3.0': - resolution: {integrity: sha512-7mWlj2U/AZvH27IfRKqpUjDP1W9ZRldF9NmdnatFEtx0AOy7YYgyk0rt5hXrH6wPcR//2gO2Qy+k5rwswpEhJA==} - peerDependencies: - elysia: '>= 1.3.0' - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1783,10 +1775,6 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} - clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2622,10 +2610,6 @@ packages: resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} engines: {node: '>=10'} - node-cache@5.1.2: - resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} - engines: {node: '>= 8.0.0'} - node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3641,11 +3625,6 @@ snapshots: dependencies: elysia: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3) - '@elysiajs/static@1.3.0(elysia@1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3))': - dependencies: - elysia: 1.4.9(@sinclair/typebox@0.34.41)(exact-mirror@0.2.0(@sinclair/typebox@0.34.41))(file-type@21.0.0)(openapi-types@12.1.3)(typescript@5.8.3) - node-cache: 5.1.2 - '@esbuild/aix-ppc64@0.21.5': optional: true @@ -4841,8 +4820,6 @@ snapshots: clone@1.0.4: {} - clone@2.1.2: {} - clsx@2.1.1: {} code-block-writer@12.0.0: {} @@ -5604,10 +5581,6 @@ snapshots: dependencies: semver: 7.7.2 - node-cache@5.1.2: - dependencies: - clone: 2.1.2 - node-domexception@1.0.0: {} node-fetch@3.3.2: