diff --git a/plugins/static/index.ts b/plugins/static/index.ts index 5a28440..19824ef 100644 --- a/plugins/static/index.ts +++ b/plugins/static/index.ts @@ -1,213 +1,126 @@ import { createReadStream, promises as fsPromises, Stats, statSync } from 'fs'; import { join } from 'path'; import * as crypto from 'crypto'; -import { - DefaultMiddlewareOptions, - detectMime, - getGamanConfig, - Log, - Priority, -} from '@gaman/common'; +import { detectMime, getGamanConfig, Log, Priority } from '@gaman/common'; import { composeMiddleware, Response } from '@gaman/core'; -import { isDevelopment } from '@gaman/cli/builder/helper.js'; - -export interface StaticFileOptions extends DefaultMiddlewareOptions { - /** - * @ID kustom mime type konten (contoh: { 'css': 'text/css' }) - * @EN custom content mime type (example: { 'css': 'text/css' }) - */ - mimes?: Record; - - /** - * @ID File default jika direktori diakses (default: index.html) - * @EN Default file if directory is accessed (default: index.html) - */ - defaultDocument?: string; - - /** - * @ID Rewriter path (misal: hapus /static/) - * @EN Rewriter path (eg: delete /static/) - * - * @param path - * @returns - */ - rewriteRequestPath?: (path: string) => string; - - /** - * @ID Menangani saat file ditemukan. - * @EN Handles when files are found. - * - * @param path - * @param ctx - * @returns - */ - onFound?: (path: string, ctx: any) => void | Promise; - - /** - * @ID Menangani saat file tidak ditemukan. - * @EN Handling when file is not found. - * - * @param path - * @param ctx - * @returns - */ - onNotFound?: (path: string, ctx: any) => void | Promise; - - /** - * @ID Header Cache-Control (default: 1 jam = 'public, max-age=3600') - * @EN Cache-Control header (default: 1 hour = 'public, max-age=3600') - */ - cacheControl?: string; - - /**public - * @ID Jika `true`, fallback ke `index.html` untuk SPA. - * @EN If `true`, return to `index.html` for SPA. - */ - fallbackToIndexHTML?: boolean; + +export interface StaticFileOptions { + mimes?: Record; + defaultDocument?: string; + rewriteRequestPath?: (path: string) => string; + onFound?: (filePath: string, ctx: any) => void | Promise; + onNotFound?: (filePath: string, ctx: any) => void | Promise; + cacheControl?: string; + fallbackToIndexHTML?: boolean; + priority?: Priority; + includes?: string[]; + excludes?: string[]; } // Buat ETag dari ukuran dan waktu modifikasi file function generateETag(stat: { size: number; mtime: Date }) { - const tag = `${stat.size}-${stat.mtime.getTime()}`; - return `"${crypto.createHash('sha1').update(tag).digest('hex')}"`; + const tag = `${stat.size}-${stat.mtime.getTime()}`; + return `"${crypto.createHash('sha1').update(tag).digest('hex')}"`; } -/** - * Serve static files for your GamanJS app. - * - * This middleware allows you to serve static assets like images, JavaScript, CSS, - * or even entire HTML pages from a specific folder (default: `public/`). - * - * It includes automatic detection for: - * - MIME types (customizable via `mimes`) - * - Brotli (.br) and Gzip (.gz) compression based on `Accept-Encoding` - * - ETag generation for efficient caching (supports 304 Not Modified) - * - * ## Options - * - `mimes`: Custom MIME types. You can map file extensions manually. - * - `priority`: Determines execution order. Use `'very-high'` if you want static to run early. - * - `defaultDocument`: Filename to serve when a directory is requested (default: `index.html`). - * - `rewriteRequestPath`: A function to rewrite request paths (e.g., strip `/static` prefix). - * - `onFound`: Optional callback when a static file is found and served. - * - `onNotFound`: Optional callback when no file is found at the requested path. - * - `cacheControl`: Customize `Cache-Control` header. Default is 1 hour. - * - `fallbackToIndexHTML`: If true, fallback to `index.html` for unmatched routes (SPA support). - * - * ## Example - * ```ts - * staticServe({ - * rewriteRequestPath: (p) => p.replace(/^\/static/, ''), - * fallbackToIndexHTML: true, - * mimes: { - * '.webmanifest': 'application/manifest+json' - * } - * }) - * ``` - */ export function staticServe(options: StaticFileOptions = {}) { - let staticPath; - const defaultDocument = options.defaultDocument ?? 'index.html'; - const cacheControl = options.cacheControl ?? 'public, max-age=3600'; - - const middleware = composeMiddleware(async (ctx, next) => { - let reqPath = ctx.request.pathname; - - //? Rewriting path jika disediakan - if (options.rewriteRequestPath) { - reqPath = options.rewriteRequestPath(reqPath); - } - if (!staticPath) { - const config = await getGamanConfig(); - staticPath = config.build?.staticdir || 'public'; // ? init staticPath for (development) - - if (!isDevelopment(config.build?.outdir || 'dist')) { - /** - * if on production mode staticPath like this: /dist/client/public - */ - staticPath = join(config.build?.outdir || 'dist', 'client', staticPath); - } - } - - let filePath = join(process.cwd(), staticPath, reqPath); - let stats: Stats; - - //? Cari file (jika direktori, cari defaultDocument) - try { - stats = await fsPromises.stat(filePath); - - if (stats.isDirectory()) { - filePath = join(filePath, defaultDocument); - stats = await fsPromises.stat(filePath); - } - } catch { - // Fallback ke index.html untuk SPA - if (options.fallbackToIndexHTML) { - try { - filePath = join(process.cwd(), staticPath, 'index.html'); - stats = await fsPromises.stat(filePath); - } catch { - await options.onNotFound?.(filePath, ctx); - return await next(); - } - } else { - await options.onNotFound?.(filePath, ctx); - return await next(); - } - } - - if (!stats.isFile()) return await next(); - Log.setRoute(''); - Log.setMethod(''); - Log.setStatus(null); - - // ? Gzip/Brotli: cek Accept-Encoding dan cari file terkompresi - const acceptEncoding = ctx.request.header('accept-encoding') || ''; - let encoding: 'br' | 'gzip' | null = null; - let encodedFilePath = filePath; - - if (acceptEncoding.includes('br')) { - try { - await fsPromises.access(`${filePath}.br`); - encoding = 'br'; - encodedFilePath = `${filePath}.br`; - } catch {} - } else if (acceptEncoding.includes('gzip')) { - try { - await fsPromises.access(`${filePath}.gz`); - encoding = 'gzip'; - encodedFilePath = `${filePath}.gz`; - } catch {} - } - - //? Buat ETag dan handle conditional GET - let statForEtag = stats; - try { - statForEtag = statSync(encodedFilePath); - } catch {} - const etag = generateETag(statForEtag); - if (ctx.request.header('if-none-match') === etag) { - return Response.notModified(); - } - - const contentType = - detectMime(filePath, options.mimes) || 'application/octet-stream'; - - await options.onFound?.(encodedFilePath, ctx); - - return Response.stream(createReadStream(encodedFilePath), { - status: 200, - headers: { - 'Content-Type': contentType, - ...(encoding ? { 'Content-Encoding': encoding } : {}), - Vary: 'Accept-Encoding', - ETag: etag, - 'Cache-Control': cacheControl, - }, - }); - }); - return middleware({ - priority: options.priority ?? Priority.MONITOR, - includes: options.includes, - excludes: options.excludes, - }); + let staticPath: string; + const defaultDocument = options.defaultDocument ?? 'index.html'; + const cacheControl = options.cacheControl ?? 'public, max-age=3600'; + + const middleware = composeMiddleware(async (ctx, next) => { + let reqPath = ctx.request.pathname; + + if (options.rewriteRequestPath) { + reqPath = options.rewriteRequestPath(reqPath); + } + + const config = await getGamanConfig(); + staticPath = join(config.build?.outdir || 'dist', 'client'); + const publicPath = config.build?.staticdir || 'public'; + + let filePath = join(process.cwd(), staticPath, reqPath); + let stats: Stats; + + // Cek file & fallback ke defaultDocument + async function tryResolve(base: string) { + let target = join(process.cwd(), base, reqPath); + try { + let s = await fsPromises.stat(target); + if (s.isDirectory()) { + target = join(target, defaultDocument); + s = await fsPromises.stat(target); + } + return { file: target, stats: s }; + } catch { + return null; + } + } + + let resolved = await tryResolve(staticPath) ?? await tryResolve(publicPath); + + if (!resolved) { + if (options.fallbackToIndexHTML) { + filePath = join(process.cwd(), staticPath, 'index.html'); + stats = await fsPromises.stat(filePath); + } else { + await options.onNotFound?.(filePath, ctx); + return next(); + } + } else { + filePath = resolved.file; + stats = resolved.stats; + } + + if (!stats.isFile()) return next(); + + Log.setRoute(''); + Log.setMethod(''); + Log.setStatus(null); + + // Gzip / Brotli + const acceptEncoding = ctx.request.header('accept-encoding') || ''; + let encoding: 'br' | 'gzip' | null = null; + let encodedFilePath = filePath; + + if (acceptEncoding.includes('br')) { + try { + await fsPromises.access(`${filePath}.br`); + encoding = 'br'; + encodedFilePath = `${filePath}.br`; + } catch {} + } else if (acceptEncoding.includes('gzip')) { + try { + await fsPromises.access(`${filePath}.gz`); + encoding = 'gzip'; + encodedFilePath = `${filePath}.gz`; + } catch {} + } + + const statForEtag = statSync(encodedFilePath); + const etag = generateETag(statForEtag); + if (ctx.request.header('if-none-match') === etag) { + return Response.notModified(); + } + + const contentType = detectMime(filePath, options.mimes) || 'application/octet-stream'; + await options.onFound?.(encodedFilePath, ctx); + + return Response.stream(createReadStream(encodedFilePath), { + status: 200, + headers: { + 'Content-Type': contentType, + ...(encoding ? { 'Content-Encoding': encoding } : {}), + Vary: 'Accept-Encoding', + ETag: etag, + 'Cache-Control': cacheControl, + }, + }); + }); + + return middleware({ + priority: options.priority ?? Priority.MONITOR, + includes: options.includes, + excludes: options.excludes, + }); } diff --git a/test/yalc.lock b/test/yalc.lock index b081183..682400f 100644 --- a/test/yalc.lock +++ b/test/yalc.lock @@ -9,6 +9,8 @@ "signature": "f068a705598d2cdf3986d2506cbd2ddd", "file": true }, +66 + "@gaman/cli": { "signature": "b14bcef31c258b1b502eea1e8a1bb48c", "file": true