Skip to content

Instantly share code, notes, and snippets.

@itsjavi
Created October 12, 2025 19:04
Show Gist options
  • Select an option

  • Save itsjavi/cdaf6bc3e4e16432599257ed5515797c to your computer and use it in GitHub Desktop.

Select an option

Save itsjavi/cdaf6bc3e4e16432599257ed5515797c to your computer and use it in GitHub Desktop.
elysiajs-static-node.ts
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