diff --git a/core/plugins/built-in/vite/index.ts b/core/plugins/built-in/vite/index.ts index 6948ca6a..645f9eb5 100644 --- a/core/plugins/built-in/vite/index.ts +++ b/core/plugins/built-in/vite/index.ts @@ -4,7 +4,7 @@ import { clientConfig } from '@config' import { pluginsConfig } from '@config' import { isDevelopment } from "@core/utils/helpers" import { join } from "path" -import { statSync, existsSync, readdirSync } from "fs" +import { existsSync } from "fs" type Plugin = FluxStack.Plugin @@ -21,24 +21,28 @@ const STATIC_MAX_AGE = 31536000 const HASHED_EXT = /\.[0-9a-f]{8,}\.\w+$/ /** - * Recursively collect all files under `dir` as relative paths (e.g. "/assets/app.abc123.js"). - * Runs once at startup — in production the build output never changes. + * Collect all files under `dir` using Bun.Glob (native C++ implementation). + * Returns a map of relative URL paths → absolute filesystem paths. + * + * Throws if the directory does not exist so callers get a clear signal + * instead of silently serving nothing. */ -function collectFiles(dir: string, prefix = ''): Map { +function collectFiles(dir: string): Map { + if (!existsSync(dir)) { + throw new Error( + `Static file directory "${dir}" does not exist. ` + + `Run the client build first or check your configuration.` + ) + } + const map = new Map() - try { - const entries = readdirSync(dir, { withFileTypes: true }) - for (const entry of entries) { - const rel = prefix + '/' + entry.name - if (entry.isDirectory()) { - for (const [k, v] of collectFiles(join(dir, entry.name), rel)) { - map.set(k, v) - } - } else if (entry.isFile()) { - map.set(rel, join(dir, entry.name)) - } - } - } catch {} + const glob = new Bun.Glob("**/*") + + for (const relativePath of glob.scanSync({ cwd: dir, onlyFiles: true, dot: true })) { + // scanSync returns paths like "assets/app.abc123.js" (no leading slash) + map.set('/' + relativePath, join(dir, relativePath)) + } + return map } @@ -60,6 +64,15 @@ function createStaticFallback() { // Bun.file() handle cache — avoids re-creating handles on repeated requests const fileCache = new Map>() + // Build a set of paths that have a pre-compressed .gz sibling + const gzSet = new Set() + for (const [rel] of fileMap) { + if (rel.endsWith('.gz')) { + // "/assets/app.abc123.js.gz" → "/assets/app.abc123.js" + gzSet.add(rel.slice(0, -3)) + } + } + return (c: { request?: Request }) => { const req = c.request if (!req) return @@ -96,6 +109,39 @@ function createStaticFallback() { // O(1) lookup in pre-scanned file map const absolutePath = fileMap.get(pathname) if (absolutePath) { + // Check for pre-compressed .gz variant + const acceptEncoding = req.headers.get('accept-encoding') || '' + if (gzSet.has(pathname) && acceptEncoding.includes('gzip')) { + const gzPath = pathname + '.gz' + const gzAbsolute = fileMap.get(gzPath) + if (gzAbsolute) { + let gzFile = fileCache.get(gzPath) + if (!gzFile) { + gzFile = Bun.file(gzAbsolute) + fileCache.set(gzPath, gzFile) + } + + // Determine original content type from the uncompressed file + let origFile = fileCache.get(pathname) + if (!origFile) { + origFile = Bun.file(absolutePath) + fileCache.set(pathname, origFile) + } + + const headers: Record = { + 'Content-Encoding': 'gzip', + 'Content-Type': origFile.type || 'application/octet-stream', + 'Vary': 'Accept-Encoding', + } + + if (HASHED_EXT.test(pathname)) { + headers['Cache-Control'] = `public, max-age=${STATIC_MAX_AGE}, immutable` + } + + return new Response(gzFile, { headers }) + } + } + let file = fileCache.get(pathname) if (!file) { file = Bun.file(absolutePath) @@ -114,14 +160,19 @@ function createStaticFallback() { return file } - // SPA fallback: serve index.html for unmatched routes + // SPA fallback: serve index.html for unmatched routes with no-cache + // so the browser always checks for a newer version on deploy if (indexFile) { - return indexFile + return new Response(indexFile, { + headers: { + 'Cache-Control': 'no-cache', + } + }) } } } -/** Proxy request to Vite dev server */ +/** Proxy request to Vite dev server — streams the response body */ async function proxyToVite(ctx: RequestContext): Promise { const { host, port } = clientConfig.vite @@ -143,7 +194,8 @@ async function proxyToVite(ctx: RequestContext): Promise { }) ctx.handled = true - ctx.response = new Response(await response.arrayBuffer(), { + // Stream the response body instead of buffering the entire payload in memory + ctx.response = new Response(response.body, { status: response.status, statusText: response.statusText, headers: response.headers diff --git a/core/server/plugins/static-files-plugin.ts b/core/server/plugins/static-files-plugin.ts index 1288aaaf..9721a66b 100644 --- a/core/server/plugins/static-files-plugin.ts +++ b/core/server/plugins/static-files-plugin.ts @@ -1,9 +1,58 @@ -// 🔥 FluxStack Static Files Plugin - Serve Public Files & Uploads +// FluxStack Static Files Plugin - Serve Public Files & Uploads -import { existsSync, statSync } from 'fs' import { mkdir } from 'fs/promises' -import { resolve } from 'path' +import { resolve, extname, basename } from 'path' import type { Plugin, PluginContext } from '../../plugins/types' +import { pluginsConfig } from '@config' + +/** MIME types that should force a download instead of rendering inline */ +const DANGEROUS_MIME_TYPES = new Set([ + 'application/x-msdownload', + 'application/x-executable', + 'application/x-sharedlib', + 'application/x-mach-binary', + 'application/x-dosexec', + 'application/x-httpd-php', + 'application/java-archive', + 'application/x-sh', + 'application/x-csh', + 'application/x-bat', +]) + +/** File extensions that should always force a download */ +const DANGEROUS_EXTENSIONS = new Set([ + '.exe', '.dll', '.bat', '.cmd', '.com', '.msi', + '.sh', '.csh', '.bash', '.ps1', '.vbs', '.wsf', + '.php', '.jsp', '.asp', '.aspx', '.py', '.rb', '.pl', + '.jar', '.war', '.class', + '.scr', '.pif', '.hta', + '.svg', // SVG can contain embedded scripts +]) + +/** Extensions that carry a content hash in their filename (immutable) */ +const HASHED_EXT = /\.[0-9a-f]{8,}\.\w+$/ + +/** + * Generate a weak ETag from Bun.file() metadata. + * Uses file.lastModified (ms timestamp) which is available without reading the file. + * Size comes from stat() since BunFile.size is unreliable until contents are read. + */ +function generateETag(size: number, lastModified: number): string { + return `W/"${size.toString(16)}-${lastModified.toString(16)}"` +} + +/** Sanitize a filename for use in Content-Disposition header */ +function sanitizeFilename(name: string): string { + return name.replace(/[/\\:\0\x01-\x1f\x7f]/g, '_').replace(/"/g, '\\"') +} + +/** Check if a MIME type or extension should force download */ +function shouldForceDownload(filePath: string, mimeType: string | undefined): boolean { + const ext = extname(filePath).toLowerCase() + if (DANGEROUS_EXTENSIONS.has(ext)) return true + if (mimeType && DANGEROUS_MIME_TYPES.has(mimeType)) return true + return false +} export const staticFilesPlugin: Plugin = { name: 'static-files', @@ -14,18 +63,28 @@ export const staticFilesPlugin: Plugin = { tags: ['static', 'files', 'uploads'], setup: async (context: PluginContext) => { + if (!pluginsConfig.staticFilesEnabled) { + context.logger.debug('Static files plugin disabled') + return + } + const projectRoot = process.cwd() - const publicDir = resolve(projectRoot, 'public') - const uploadsDir = resolve(projectRoot, 'uploads') + const publicDir = resolve(projectRoot, pluginsConfig.staticPublicDir ?? 'public') + const uploadsDir = resolve(projectRoot, pluginsConfig.staticUploadsDir ?? 'uploads') + const cacheMaxAge = pluginsConfig.staticCacheMaxAge + const enablePublic = pluginsConfig.staticEnablePublic + const enableUploads = pluginsConfig.staticEnableUploads - // Create directories if they don't exist - await mkdir(publicDir, { recursive: true }) - await mkdir(uploadsDir, { recursive: true }) - await mkdir(resolve(uploadsDir, 'avatars'), { recursive: true }) + // Async handler — uses Bun.file() APIs instead of Node fs + const serveFile = (baseDir: string, isUpload: boolean) => async ({ params, set, request }: any) => { + const requestedPath: string = params['*'] || '' + + // Reject null bytes early — prevents filesystem confusion + if (requestedPath.includes('\0')) { + set.status = 400 + return { error: 'Invalid path' } + } - // Helper to serve files from a directory - const serveFile = (baseDir: string) => ({ params, set }: any) => { - const requestedPath = params['*'] || '' const filePath = resolve(baseDir, requestedPath) // Path traversal protection @@ -34,15 +93,14 @@ export const staticFilesPlugin: Plugin = { return { error: 'Invalid path' } } - // Check if file exists - if (!existsSync(filePath)) { - set.status = 404 - return { error: 'File not found' } - } + const file = Bun.file(filePath) - // Check if it's a file (not directory) + // Bun.file().stat() — single async call, no Node fs import needed. + // Returns size, isFile(), ctime etc. without reading file contents. + let stat: Awaited> try { - if (!statSync(filePath).isFile()) { + stat = await file.stat() + if (!stat.isFile()) { set.status = 404 return { error: 'Not a file' } } @@ -51,19 +109,71 @@ export const staticFilesPlugin: Plugin = { return { error: 'File not found' } } - // Set cache header (1 year) - set.headers['cache-control'] = 'public, max-age=31536000' + // ETag from stat.size (reliable) + file.lastModified (Bun-native, no extra syscall) + const etag = generateETag(stat.size, file.lastModified) + const lastModified = new Date(file.lastModified).toUTCString() + + // Conditional request: If-None-Match takes priority over If-Modified-Since + const ifNoneMatch = request?.headers?.get?.('if-none-match') + if (ifNoneMatch && ifNoneMatch === etag) { + set.status = 304 + return null + } + + const ifModifiedSince = request?.headers?.get?.('if-modified-since') + if (!ifNoneMatch && ifModifiedSince) { + const clientDate = new Date(ifModifiedSince).getTime() + if (!isNaN(clientDate) && file.lastModified <= clientDate) { + set.status = 304 + return null + } + } + + // Security headers + set.headers['x-content-type-options'] = 'nosniff' + set.headers['etag'] = etag + set.headers['last-modified'] = lastModified + + // Cache strategy: hashed assets are immutable, uploads get short cache + if (!isUpload && HASHED_EXT.test(requestedPath)) { + set.headers['cache-control'] = `public, max-age=${cacheMaxAge}, immutable` + } else if (isUpload) { + set.headers['cache-control'] = 'public, max-age=3600, must-revalidate' + } else { + set.headers['cache-control'] = `public, max-age=${cacheMaxAge}` + } + + // Force download for dangerous MIME types + // file.type is resolved from extension by Bun — no disk I/O + if (shouldForceDownload(filePath, file.type)) { + const fileName = sanitizeFilename(basename(requestedPath) || 'download') + set.headers['content-disposition'] = `attachment; filename="${fileName}"` + } + + // Returning Bun.file() directly lets Bun use sendfile(2) for zero-copy transfer + return file + } + + // Register routes based on config flags + if (enablePublic) { + await mkdir(publicDir, { recursive: true }) + context.app.get('/api/static/*', serveFile(publicDir, false)) + context.logger.debug('Static public files route registered: /api/static/*') + } - // Bun.file() handles: content-type, content-length, streaming - return Bun.file(filePath) + if (enableUploads) { + await mkdir(uploadsDir, { recursive: true }) + context.app.get('/api/uploads/*', serveFile(uploadsDir, true)) + context.logger.debug('Static uploads route registered: /api/uploads/*') } - // Register routes - context.app.get('/api/static/*', serveFile(publicDir)) - context.app.get('/api/uploads/*', serveFile(uploadsDir)) + const routes = [ + ...(enablePublic ? ['/api/static/*'] : []), + ...(enableUploads ? ['/api/uploads/*'] : []) + ] - context.logger.debug('📁 Static files plugin ready', { - routes: ['/api/static/*', '/api/uploads/*'] - }) + if (routes.length > 0) { + context.logger.debug('Static files plugin ready', { routes }) + } } }