Created
October 12, 2025 19:04
-
-
Save itsjavi/cdaf6bc3e4e16432599257ed5515797c to your computer and use it in GitHub Desktop.
elysiajs-static-node.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Elysia, NotFoundError } from 'elysia' | |
| import { lookup } from 'mime-types' | |
| import Cache from 'node-cache' | |
| import type { Stats } from 'node:fs' | |
| import { createReadStream } from 'node:fs' | |
| import { readFile, readdir, stat } from 'node:fs/promises' | |
| import { join, resolve, resolve as resolveFn, sep } from 'node:path' | |
| import { Readable } from 'node:stream' | |
| // Adapted from @elysiajs/static to work with NodeJS + some optimizations | |
| export async function isCached(headers: Record<string, string | undefined>, etag: string, filePath: string) { | |
| // Always return stale when Cache-Control: no-cache | |
| // to support end-to-end reload requests | |
| // https://tools.ietf.org/html/rfc2616#section-14.9.4 | |
| if (headers['cache-control'] && headers['cache-control'].indexOf('no-cache') !== -1) return false | |
| // if-none-match | |
| if ('if-none-match' in headers) { | |
| const ifNoneMatch = headers['if-none-match'] | |
| if (ifNoneMatch === '*') return true | |
| if (ifNoneMatch === null) return false | |
| if (typeof etag !== 'string') return false | |
| const isMatching = ifNoneMatch === etag | |
| if (isMatching) return true | |
| /** | |
| * A recipient MUST ignore If-Modified-Since if the request contains an | |
| * If-None-Match header field; the condition in If-None-Match is considered | |
| * to be a more accurate replacement for the condition in If-Modified-Since, | |
| * and the two are only combined for the sake of interoperating with older | |
| * intermediaries that might not implement If-None-Match. | |
| * | |
| * @see RFC 9110 section 13.1.3 | |
| */ | |
| return false | |
| } | |
| // if-modified-since | |
| if (headers['if-modified-since']) { | |
| const ifModifiedSince = headers['if-modified-since'] | |
| let lastModified: Date | undefined | |
| try { | |
| lastModified = (await stat(filePath)).mtime | |
| } catch { | |
| /* empty */ | |
| } | |
| if (lastModified !== undefined && lastModified.getTime() <= Date.parse(ifModifiedSince)) return true | |
| } | |
| return false | |
| } | |
| /** | |
| * Generate ETag from file stats (mtime + size) instead of content hashing. | |
| * This is much faster as it only requires a stat() call, not reading the entire file. | |
| * The ETag will change whenever the file is modified or its size changes. | |
| */ | |
| export function generateETagFromStats(stats: Stats): string { | |
| const mtime = stats.mtime.getTime().toString(36) | |
| const size = stats.size.toString(36) | |
| return `"${mtime}-${size}"` | |
| } | |
| export async function generateETag(filePath: string): Promise<string> { | |
| const stats = await stat(filePath) | |
| return generateETagFromStats(stats) | |
| } | |
| const URL_PATH_SEP = '/' | |
| const fileExists = (path: string) => | |
| stat(path).then( | |
| () => true, | |
| () => false, | |
| ) | |
| interface CachedFile { | |
| content: Uint8Array | |
| mimeType: string | |
| size: number | |
| } | |
| const MAX_CACHEABLE_FILE_SIZE = 100 * 1024 // 100KB - cache small files in memory | |
| const MAX_MEMORY_FILE_SIZE = 1024 * 1024 // 1MB - load medium files into memory, stream larger files | |
| /** | |
| * Convert Node.js Readable stream to Web ReadableStream | |
| */ | |
| function nodeStreamToWebStream(nodeStream: Readable): ReadableStream { | |
| return new ReadableStream({ | |
| start(controller) { | |
| nodeStream.on('data', (chunk: Buffer) => { | |
| controller.enqueue(new Uint8Array(chunk)) | |
| }) | |
| nodeStream.on('end', () => { | |
| controller.close() | |
| }) | |
| nodeStream.on('error', (err) => { | |
| controller.error(err) | |
| }) | |
| }, | |
| cancel() { | |
| nodeStream.destroy() | |
| }, | |
| }) | |
| } | |
| /** | |
| * Get cached file content or read from disk. | |
| * Only caches small files (< 100KB) to avoid excessive memory usage. | |
| */ | |
| async function getCachedFileContent(filePath: string, fileSize: number): Promise<CachedFile> { | |
| // Check if file is cacheable | |
| if (fileSize > MAX_CACHEABLE_FILE_SIZE) { | |
| // Don't cache medium files (100KB - 1MB), just read and return | |
| const content = await readFile(filePath) | |
| const mimeType = lookup(filePath) || 'application/octet-stream' | |
| return { | |
| content: new Uint8Array(content), | |
| mimeType, | |
| size: fileSize, | |
| } | |
| } | |
| // Try to get from cache | |
| const cached = contentCache.get<CachedFile>(filePath) | |
| if (cached && cached.size === fileSize) { | |
| return cached | |
| } | |
| // Not in cache or size changed, read from disk | |
| const content = await readFile(filePath) | |
| const mimeType = lookup(filePath) || 'application/octet-stream' | |
| const cachedFile: CachedFile = { | |
| content: new Uint8Array(content), | |
| mimeType, | |
| size: fileSize, | |
| } | |
| // Store in cache | |
| contentCache.set(filePath, cachedFile) | |
| return cachedFile | |
| } | |
| async function createFileResponse( | |
| filePath: string, | |
| headers: Record<string, string> = {}, | |
| fileSize?: number, | |
| ): Promise<Response> { | |
| const mimeType = lookup(filePath) || 'application/octet-stream' | |
| // If we have file size, decide on strategy based on size | |
| if (fileSize !== undefined) { | |
| // For large files (>1MB), use streaming to avoid loading into memory | |
| if (fileSize > MAX_MEMORY_FILE_SIZE) { | |
| const stream = createReadStream(filePath) | |
| const webStream = nodeStreamToWebStream(stream) | |
| return new Response(webStream, { | |
| headers: { | |
| 'Content-Type': mimeType, | |
| 'Content-Length': fileSize.toString(), | |
| ...headers, | |
| }, | |
| }) | |
| } | |
| // For small/medium files (<=1MB), use cached/memory approach | |
| const file = await getCachedFileContent(filePath, fileSize) | |
| return new Response(file.content as BodyInit, { | |
| headers: { | |
| 'Content-Type': file.mimeType, | |
| 'Content-Length': file.size.toString(), | |
| ...headers, | |
| }, | |
| }) | |
| } | |
| // Fallback: read directly (shouldn't happen in normal flow) | |
| const content = await readFile(filePath) | |
| return new Response(new Uint8Array(content) as BodyInit, { | |
| headers: { | |
| 'Content-Type': mimeType, | |
| ...headers, | |
| }, | |
| }) | |
| } | |
| // Cache for file stats - lightweight, can cache many files | |
| const statCache = new Cache({ | |
| useClones: false, | |
| checkperiod: 5 * 60, | |
| stdTTL: 3 * 60 * 60, | |
| maxKeys: 1000, | |
| }) | |
| // Cache for resolved file paths (e.g., directory -> index.html) | |
| const filePathCache = new Cache({ | |
| useClones: false, | |
| checkperiod: 5 * 60, | |
| stdTTL: 3 * 60 * 60, | |
| maxKeys: 500, | |
| }) | |
| // Cache for checking if index.html exists in directories | |
| const htmlExistsCache = new Cache({ | |
| useClones: false, | |
| checkperiod: 5 * 60, | |
| stdTTL: 3 * 60 * 60, | |
| maxKeys: 250, | |
| }) | |
| // Cache for file contents - only small files to avoid excessive memory usage | |
| const contentCache = new Cache({ | |
| useClones: false, | |
| checkperiod: 5 * 60, | |
| stdTTL: 3 * 60 * 60, | |
| maxKeys: 200, | |
| }) | |
| const listFiles = async (dir: string): Promise<string[]> => { | |
| const files = await readdir(dir) | |
| const all = await Promise.all( | |
| files.map(async (name) => { | |
| const file = dir + sep + name | |
| const stats = await stat(file) | |
| return stats && stats.isDirectory() ? await listFiles(file) : [resolve(dir, file)] | |
| }), | |
| ) | |
| return all.flat() | |
| } | |
| export const staticPlugin = async <Prefix extends string = '/prefix'>( | |
| { | |
| assets = 'public', | |
| prefix = '/public' as Prefix, | |
| staticLimit = 1024, | |
| alwaysStatic = process.env.NODE_ENV === 'production', | |
| ignorePatterns = ['.DS_Store', '.git', '.env'], | |
| noExtension = false, | |
| enableDecodeURI = false, | |
| resolve = resolveFn, | |
| headers = {}, | |
| noCache = false, | |
| maxAge = 86400, | |
| directive = 'public', | |
| indexHTML = true, | |
| }: { | |
| /** | |
| * @default "public" | |
| * | |
| * Asset path to expose as public path | |
| */ | |
| assets?: string | |
| /** | |
| * @default '/public' | |
| * | |
| * Path prefix to create virtual mount path for the static directory | |
| */ | |
| prefix?: Prefix | |
| /** | |
| * @default 1024 | |
| * | |
| * If total files exceed this number, | |
| * file will be handled via wildcard instead of static route | |
| * to reduce memory usage | |
| */ | |
| staticLimit?: number | |
| /** | |
| * @default false unless `NODE_ENV` is 'production' | |
| * | |
| * Should file always be served statically | |
| */ | |
| alwaysStatic?: boolean | |
| /** | |
| * @default [] `Array<string | RegExp>` | |
| * | |
| * Array of file to ignore publication. | |
| * If one of the patters is matched, | |
| * file will not be exposed. | |
| */ | |
| ignorePatterns?: Array<string | RegExp> | |
| /** | |
| * Indicate if file extension is required | |
| * | |
| * Only works if `alwaysStatic` is set to true | |
| */ | |
| noExtension?: boolean | |
| /** | |
| * | |
| * When url needs to be decoded | |
| * | |
| * Only works if `alwaysStatic` is set to false | |
| */ | |
| enableDecodeURI?: boolean | |
| /** | |
| * Nodejs resolve function | |
| */ | |
| resolve?: (...pathSegments: string[]) => string | |
| /** | |
| * Set headers | |
| */ | |
| headers?: Record<string, string> | undefined | |
| /** | |
| * @default false | |
| * | |
| * If set to true, browser caching will be disabled | |
| */ | |
| noCache?: boolean | |
| /** | |
| * @default public | |
| * | |
| * directive for Cache-Control header | |
| * | |
| * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives | |
| */ | |
| directive?: | |
| | 'public' | |
| | 'private' | |
| | 'must-revalidate' | |
| | 'no-cache' | |
| | 'no-store' | |
| | 'no-transform' | |
| | 'proxy-revalidate' | |
| | 'immutable' | |
| /** | |
| * @default 86400 | |
| * | |
| * Specifies the maximum amount of time in seconds, a resource will be considered fresh. | |
| * This freshness lifetime is calculated relative to the time of the request. | |
| * This setting helps control browser caching behavior. | |
| * A `maxAge` of 0 will prevent caching, requiring requests to validate with the server before use. | |
| */ | |
| maxAge?: number | null | |
| /** | |
| * | |
| */ | |
| /** | |
| * @default true | |
| * | |
| * Enable serving of index.html as default / route | |
| */ | |
| indexHTML?: boolean | |
| } = { | |
| assets: 'public', | |
| prefix: '/public' as Prefix, | |
| staticLimit: 1024, | |
| alwaysStatic: process.env.NODE_ENV === 'production', | |
| ignorePatterns: [], | |
| noExtension: false, | |
| enableDecodeURI: false, | |
| resolve: resolveFn, | |
| headers: {}, | |
| noCache: false, | |
| indexHTML: true, | |
| }, | |
| ) => { | |
| const files = await listFiles(resolveFn(assets)) | |
| const isFSSepUnsafe = sep !== URL_PATH_SEP | |
| if (prefix === URL_PATH_SEP) prefix = '' as Prefix | |
| const shouldIgnore = (file: string) => { | |
| if (!ignorePatterns.length) return false | |
| return ignorePatterns.find((pattern) => { | |
| if (typeof pattern === 'string') return pattern.includes(file) | |
| else return pattern.test(file) | |
| }) | |
| } | |
| const app = new Elysia({ | |
| name: 'static', | |
| seed: { | |
| assets, | |
| prefix, | |
| staticLimit, | |
| alwaysStatic, | |
| ignorePatterns, | |
| noExtension, | |
| enableDecodeURI, | |
| resolve: resolve.toString(), | |
| headers, | |
| noCache, | |
| maxAge, | |
| directive, | |
| indexHTML, | |
| }, | |
| }) | |
| const assetsDir = assets[0] === sep ? assets : resolve() + sep + assets | |
| if (alwaysStatic || (process.env.ENV === 'production' && files.length <= staticLimit)) | |
| for (const absolutePath of files) { | |
| if (!absolutePath || shouldIgnore(absolutePath)) continue | |
| let relativePath = absolutePath.replace(assetsDir, '') | |
| if (noExtension) { | |
| const temp = relativePath.split('.') | |
| temp.splice(-1) | |
| relativePath = temp.join('.') | |
| } | |
| // Get file stats once for both ETag and size | |
| const fileStats = await stat(absolutePath) | |
| const etag = generateETagFromStats(fileStats) | |
| const fileSize = fileStats.size | |
| const pathName = isFSSepUnsafe ? prefix + relativePath.split(sep).join(URL_PATH_SEP) : join(prefix, relativePath) | |
| app.get( | |
| pathName, | |
| noCache | |
| ? async () => createFileResponse(absolutePath, headers, fileSize) | |
| : async ({ headers: reqHeaders }) => { | |
| if (await isCached(reqHeaders, etag, absolutePath)) { | |
| return new Response(null, { | |
| status: 304, | |
| headers, | |
| }) | |
| } | |
| const responseHeaders = { | |
| ...headers, | |
| Etag: etag, | |
| 'Cache-Control': maxAge !== null ? `${directive}, max-age=${maxAge}` : directive, | |
| } | |
| return createFileResponse(absolutePath, responseHeaders, fileSize) | |
| }, | |
| ) | |
| if (indexHTML && pathName.endsWith('/index.html')) | |
| app.get( | |
| pathName.replace('/index.html', ''), | |
| noCache | |
| ? async () => createFileResponse(absolutePath, headers, fileSize) | |
| : async ({ headers: reqHeaders }) => { | |
| if (await isCached(reqHeaders, etag, absolutePath)) { | |
| return new Response(null, { | |
| status: 304, | |
| headers, | |
| }) | |
| } | |
| const responseHeaders = { | |
| ...headers, | |
| Etag: etag, | |
| 'Cache-Control': maxAge !== null ? `${directive}, max-age=${maxAge}` : directive, | |
| } | |
| return createFileResponse(absolutePath, responseHeaders, fileSize) | |
| }, | |
| ) | |
| } | |
| else { | |
| if (!app.router.history.find(({ method, path }) => path === `${prefix}/*` && method === 'GET')) | |
| app | |
| .onError(() => {}) | |
| .get(`${prefix}/*`, async ({ params, headers: reqHeaders }) => { | |
| let path = enableDecodeURI | |
| ? // @ts-ignore | |
| decodeURI(`${assets}/${decodeURI(params['*'])}`) | |
| : // @ts-ignore | |
| `${assets}/${params['*']}` | |
| // Handle varying filepath separators | |
| if (isFSSepUnsafe) { | |
| path = path.replace(URL_PATH_SEP, sep) | |
| } | |
| // Note that path must match the system separator | |
| if (shouldIgnore(path)) throw new NotFoundError() | |
| try { | |
| // Get or cache file stats | |
| let fileStats = statCache.get<Stats>(path) | |
| if (!fileStats) { | |
| fileStats = await stat(path) | |
| statCache.set(path, fileStats) | |
| } | |
| if (!indexHTML && fileStats.isDirectory()) throw new NotFoundError() | |
| // Resolve the actual file path (handle directories -> index.html) | |
| let filePath = filePathCache.get<string>(path) | |
| let resolvedStats = fileStats | |
| if (!filePath) { | |
| if (fileStats.isDirectory()) { | |
| const indexPath = `${path}${sep}index.html` | |
| let hasIndex = htmlExistsCache.get<boolean>(indexPath) | |
| if (hasIndex === undefined) { | |
| hasIndex = await fileExists(indexPath) | |
| htmlExistsCache.set(indexPath, hasIndex) | |
| } | |
| if (indexHTML && hasIndex) { | |
| filePath = indexPath | |
| // Get stats for the actual index.html file | |
| resolvedStats = statCache.get<Stats>(indexPath) || (await stat(indexPath)) | |
| if (!statCache.get<Stats>(indexPath)) { | |
| statCache.set(indexPath, resolvedStats) | |
| } | |
| } else { | |
| throw new NotFoundError() | |
| } | |
| } else { | |
| filePath = path | |
| } | |
| filePathCache.set(path, filePath) | |
| } | |
| // If filePath is different from path, get its stats | |
| if (filePath !== path && resolvedStats === fileStats) { | |
| resolvedStats = statCache.get<Stats>(filePath) || (await stat(filePath)) | |
| if (!statCache.get<Stats>(filePath)) { | |
| statCache.set(filePath, resolvedStats) | |
| } | |
| } | |
| if (noCache) return createFileResponse(filePath, headers, resolvedStats.size) | |
| // Generate ETag from stats (no file read needed!) | |
| const etag = generateETagFromStats(resolvedStats) | |
| if (await isCached(reqHeaders, etag, filePath)) | |
| return new Response(null, { | |
| status: 304, | |
| headers, | |
| }) | |
| const responseHeaders = { | |
| ...headers, | |
| Etag: etag, | |
| 'Cache-Control': maxAge !== null ? `${directive}, max-age=${maxAge}` : directive, | |
| } | |
| return createFileResponse(filePath, responseHeaders, resolvedStats.size) | |
| } catch (error) { | |
| throw new NotFoundError() | |
| } | |
| }) | |
| } | |
| return app | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment