Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 73 additions & 21 deletions core/plugins/built-in/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<string, string> {
function collectFiles(dir: string): Map<string, string> {
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<string, string>()
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
}

Expand All @@ -60,6 +64,15 @@ function createStaticFallback() {
// Bun.file() handle cache — avoids re-creating handles on repeated requests
const fileCache = new Map<string, ReturnType<typeof Bun.file>>()

// Build a set of paths that have a pre-compressed .gz sibling
const gzSet = new Set<string>()
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
Expand Down Expand Up @@ -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<string, string> = {
'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)
Expand All @@ -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<void> {
const { host, port } = clientConfig.vite

Expand All @@ -143,7 +194,8 @@ async function proxyToVite(ctx: RequestContext): Promise<void> {
})

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
Expand Down
168 changes: 139 additions & 29 deletions core/server/plugins/static-files-plugin.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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
Expand All @@ -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<ReturnType<typeof file.stat>>
try {
if (!statSync(filePath).isFile()) {
stat = await file.stat()
if (!stat.isFile()) {
set.status = 404
return { error: 'Not a file' }
}
Expand All @@ -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 })
}
}
}
Loading