|
1 | 1 | import { createReadStream, promises as fsPromises, Stats, statSync } from 'fs'; |
2 | 2 | import { join } from 'path'; |
3 | 3 | import * as crypto from 'crypto'; |
4 | | -import { |
5 | | - DefaultMiddlewareOptions, |
6 | | - detectMime, |
7 | | - getGamanConfig, |
8 | | - Log, |
9 | | - Priority, |
10 | | -} from '@gaman/common'; |
| 4 | +import { detectMime, getGamanConfig, Log, Priority } from '@gaman/common'; |
11 | 5 | import { composeMiddleware, Response } from '@gaman/core'; |
12 | | -import { isDevelopment } from '@gaman/cli/builder/helper.js'; |
13 | | - |
14 | | -export interface StaticFileOptions extends DefaultMiddlewareOptions { |
15 | | - /** |
16 | | - * @ID kustom mime type konten (contoh: { 'css': 'text/css' }) |
17 | | - * @EN custom content mime type (example: { 'css': 'text/css' }) |
18 | | - */ |
19 | | - mimes?: Record<string, string>; |
20 | | - |
21 | | - /** |
22 | | - * @ID File default jika direktori diakses (default: index.html) |
23 | | - * @EN Default file if directory is accessed (default: index.html) |
24 | | - */ |
25 | | - defaultDocument?: string; |
26 | | - |
27 | | - /** |
28 | | - * @ID Rewriter path (misal: hapus /static/) |
29 | | - * @EN Rewriter path (eg: delete /static/) |
30 | | - * |
31 | | - * @param path |
32 | | - * @returns |
33 | | - */ |
34 | | - rewriteRequestPath?: (path: string) => string; |
35 | | - |
36 | | - /** |
37 | | - * @ID Menangani saat file ditemukan. |
38 | | - * @EN Handles when files are found. |
39 | | - * |
40 | | - * @param path |
41 | | - * @param ctx |
42 | | - * @returns |
43 | | - */ |
44 | | - onFound?: (path: string, ctx: any) => void | Promise<void>; |
45 | | - |
46 | | - /** |
47 | | - * @ID Menangani saat file tidak ditemukan. |
48 | | - * @EN Handling when file is not found. |
49 | | - * |
50 | | - * @param path |
51 | | - * @param ctx |
52 | | - * @returns |
53 | | - */ |
54 | | - onNotFound?: (path: string, ctx: any) => void | Promise<void>; |
55 | | - |
56 | | - /** |
57 | | - * @ID Header Cache-Control (default: 1 jam = 'public, max-age=3600') |
58 | | - * @EN Cache-Control header (default: 1 hour = 'public, max-age=3600') |
59 | | - */ |
60 | | - cacheControl?: string; |
61 | | - |
62 | | - /**public |
63 | | - * @ID Jika `true`, fallback ke `index.html` untuk SPA. |
64 | | - * @EN If `true`, return to `index.html` for SPA. |
65 | | - */ |
66 | | - fallbackToIndexHTML?: boolean; |
| 6 | + |
| 7 | +export interface StaticFileOptions { |
| 8 | + mimes?: Record<string, string>; |
| 9 | + defaultDocument?: string; |
| 10 | + rewriteRequestPath?: (path: string) => string; |
| 11 | + onFound?: (filePath: string, ctx: any) => void | Promise<void>; |
| 12 | + onNotFound?: (filePath: string, ctx: any) => void | Promise<void>; |
| 13 | + cacheControl?: string; |
| 14 | + fallbackToIndexHTML?: boolean; |
| 15 | + priority?: Priority; |
| 16 | + includes?: string[]; |
| 17 | + excludes?: string[]; |
67 | 18 | } |
68 | 19 |
|
69 | 20 | // Buat ETag dari ukuran dan waktu modifikasi file |
70 | 21 | function generateETag(stat: { size: number; mtime: Date }) { |
71 | | - const tag = `${stat.size}-${stat.mtime.getTime()}`; |
72 | | - return `"${crypto.createHash('sha1').update(tag).digest('hex')}"`; |
| 22 | + const tag = `${stat.size}-${stat.mtime.getTime()}`; |
| 23 | + return `"${crypto.createHash('sha1').update(tag).digest('hex')}"`; |
73 | 24 | } |
74 | 25 |
|
75 | | -/** |
76 | | - * Serve static files for your GamanJS app. |
77 | | - * |
78 | | - * This middleware allows you to serve static assets like images, JavaScript, CSS, |
79 | | - * or even entire HTML pages from a specific folder (default: `public/`). |
80 | | - * |
81 | | - * It includes automatic detection for: |
82 | | - * - MIME types (customizable via `mimes`) |
83 | | - * - Brotli (.br) and Gzip (.gz) compression based on `Accept-Encoding` |
84 | | - * - ETag generation for efficient caching (supports 304 Not Modified) |
85 | | - * |
86 | | - * ## Options |
87 | | - * - `mimes`: Custom MIME types. You can map file extensions manually. |
88 | | - * - `priority`: Determines execution order. Use `'very-high'` if you want static to run early. |
89 | | - * - `defaultDocument`: Filename to serve when a directory is requested (default: `index.html`). |
90 | | - * - `rewriteRequestPath`: A function to rewrite request paths (e.g., strip `/static` prefix). |
91 | | - * - `onFound`: Optional callback when a static file is found and served. |
92 | | - * - `onNotFound`: Optional callback when no file is found at the requested path. |
93 | | - * - `cacheControl`: Customize `Cache-Control` header. Default is 1 hour. |
94 | | - * - `fallbackToIndexHTML`: If true, fallback to `index.html` for unmatched routes (SPA support). |
95 | | - * |
96 | | - * ## Example |
97 | | - * ```ts |
98 | | - * staticServe({ |
99 | | - * rewriteRequestPath: (p) => p.replace(/^\/static/, ''), |
100 | | - * fallbackToIndexHTML: true, |
101 | | - * mimes: { |
102 | | - * '.webmanifest': 'application/manifest+json' |
103 | | - * } |
104 | | - * }) |
105 | | - * ``` |
106 | | - */ |
107 | 26 | export function staticServe(options: StaticFileOptions = {}) { |
108 | | - let staticPath; |
109 | | - const defaultDocument = options.defaultDocument ?? 'index.html'; |
110 | | - const cacheControl = options.cacheControl ?? 'public, max-age=3600'; |
111 | | - |
112 | | - const middleware = composeMiddleware(async (ctx, next) => { |
113 | | - let reqPath = ctx.request.pathname; |
114 | | - |
115 | | - //? Rewriting path jika disediakan |
116 | | - if (options.rewriteRequestPath) { |
117 | | - reqPath = options.rewriteRequestPath(reqPath); |
118 | | - } |
119 | | - if (!staticPath) { |
120 | | - const config = await getGamanConfig(); |
121 | | - staticPath = config.build?.staticdir || 'public'; // ? init staticPath for (development) |
122 | | - |
123 | | - if (!isDevelopment(config.build?.outdir || 'dist')) { |
124 | | - /** |
125 | | - * if on production mode staticPath like this: /dist/client/public |
126 | | - */ |
127 | | - staticPath = join(config.build?.outdir || 'dist', 'client', staticPath); |
128 | | - } |
129 | | - } |
130 | | - |
131 | | - let filePath = join(process.cwd(), staticPath, reqPath); |
132 | | - let stats: Stats; |
133 | | - |
134 | | - //? Cari file (jika direktori, cari defaultDocument) |
135 | | - try { |
136 | | - stats = await fsPromises.stat(filePath); |
137 | | - |
138 | | - if (stats.isDirectory()) { |
139 | | - filePath = join(filePath, defaultDocument); |
140 | | - stats = await fsPromises.stat(filePath); |
141 | | - } |
142 | | - } catch { |
143 | | - // Fallback ke index.html untuk SPA |
144 | | - if (options.fallbackToIndexHTML) { |
145 | | - try { |
146 | | - filePath = join(process.cwd(), staticPath, 'index.html'); |
147 | | - stats = await fsPromises.stat(filePath); |
148 | | - } catch { |
149 | | - await options.onNotFound?.(filePath, ctx); |
150 | | - return await next(); |
151 | | - } |
152 | | - } else { |
153 | | - await options.onNotFound?.(filePath, ctx); |
154 | | - return await next(); |
155 | | - } |
156 | | - } |
157 | | - |
158 | | - if (!stats.isFile()) return await next(); |
159 | | - Log.setRoute(''); |
160 | | - Log.setMethod(''); |
161 | | - Log.setStatus(null); |
162 | | - |
163 | | - // ? Gzip/Brotli: cek Accept-Encoding dan cari file terkompresi |
164 | | - const acceptEncoding = ctx.request.header('accept-encoding') || ''; |
165 | | - let encoding: 'br' | 'gzip' | null = null; |
166 | | - let encodedFilePath = filePath; |
167 | | - |
168 | | - if (acceptEncoding.includes('br')) { |
169 | | - try { |
170 | | - await fsPromises.access(`${filePath}.br`); |
171 | | - encoding = 'br'; |
172 | | - encodedFilePath = `${filePath}.br`; |
173 | | - } catch {} |
174 | | - } else if (acceptEncoding.includes('gzip')) { |
175 | | - try { |
176 | | - await fsPromises.access(`${filePath}.gz`); |
177 | | - encoding = 'gzip'; |
178 | | - encodedFilePath = `${filePath}.gz`; |
179 | | - } catch {} |
180 | | - } |
181 | | - |
182 | | - //? Buat ETag dan handle conditional GET |
183 | | - let statForEtag = stats; |
184 | | - try { |
185 | | - statForEtag = statSync(encodedFilePath); |
186 | | - } catch {} |
187 | | - const etag = generateETag(statForEtag); |
188 | | - if (ctx.request.header('if-none-match') === etag) { |
189 | | - return Response.notModified(); |
190 | | - } |
191 | | - |
192 | | - const contentType = |
193 | | - detectMime(filePath, options.mimes) || 'application/octet-stream'; |
194 | | - |
195 | | - await options.onFound?.(encodedFilePath, ctx); |
196 | | - |
197 | | - return Response.stream(createReadStream(encodedFilePath), { |
198 | | - status: 200, |
199 | | - headers: { |
200 | | - 'Content-Type': contentType, |
201 | | - ...(encoding ? { 'Content-Encoding': encoding } : {}), |
202 | | - Vary: 'Accept-Encoding', |
203 | | - ETag: etag, |
204 | | - 'Cache-Control': cacheControl, |
205 | | - }, |
206 | | - }); |
207 | | - }); |
208 | | - return middleware({ |
209 | | - priority: options.priority ?? Priority.MONITOR, |
210 | | - includes: options.includes, |
211 | | - excludes: options.excludes, |
212 | | - }); |
| 27 | + let staticPath: string; |
| 28 | + const defaultDocument = options.defaultDocument ?? 'index.html'; |
| 29 | + const cacheControl = options.cacheControl ?? 'public, max-age=3600'; |
| 30 | + |
| 31 | + const middleware = composeMiddleware(async (ctx, next) => { |
| 32 | + let reqPath = ctx.request.pathname; |
| 33 | + |
| 34 | + if (options.rewriteRequestPath) { |
| 35 | + reqPath = options.rewriteRequestPath(reqPath); |
| 36 | + } |
| 37 | + |
| 38 | + const config = await getGamanConfig(); |
| 39 | + staticPath = join(config.build?.outdir || 'dist', 'client'); |
| 40 | + const publicPath = config.build?.staticdir || 'public'; |
| 41 | + |
| 42 | + let filePath = join(process.cwd(), staticPath, reqPath); |
| 43 | + let stats: Stats; |
| 44 | + |
| 45 | + // Cek file & fallback ke defaultDocument |
| 46 | + async function tryResolve(base: string) { |
| 47 | + let target = join(process.cwd(), base, reqPath); |
| 48 | + try { |
| 49 | + let s = await fsPromises.stat(target); |
| 50 | + if (s.isDirectory()) { |
| 51 | + target = join(target, defaultDocument); |
| 52 | + s = await fsPromises.stat(target); |
| 53 | + } |
| 54 | + return { file: target, stats: s }; |
| 55 | + } catch { |
| 56 | + return null; |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + let resolved = await tryResolve(staticPath) ?? await tryResolve(publicPath); |
| 61 | + |
| 62 | + if (!resolved) { |
| 63 | + if (options.fallbackToIndexHTML) { |
| 64 | + filePath = join(process.cwd(), staticPath, 'index.html'); |
| 65 | + stats = await fsPromises.stat(filePath); |
| 66 | + } else { |
| 67 | + await options.onNotFound?.(filePath, ctx); |
| 68 | + return next(); |
| 69 | + } |
| 70 | + } else { |
| 71 | + filePath = resolved.file; |
| 72 | + stats = resolved.stats; |
| 73 | + } |
| 74 | + |
| 75 | + if (!stats.isFile()) return next(); |
| 76 | + |
| 77 | + Log.setRoute(''); |
| 78 | + Log.setMethod(''); |
| 79 | + Log.setStatus(null); |
| 80 | + |
| 81 | + // Gzip / Brotli |
| 82 | + const acceptEncoding = ctx.request.header('accept-encoding') || ''; |
| 83 | + let encoding: 'br' | 'gzip' | null = null; |
| 84 | + let encodedFilePath = filePath; |
| 85 | + |
| 86 | + if (acceptEncoding.includes('br')) { |
| 87 | + try { |
| 88 | + await fsPromises.access(`${filePath}.br`); |
| 89 | + encoding = 'br'; |
| 90 | + encodedFilePath = `${filePath}.br`; |
| 91 | + } catch {} |
| 92 | + } else if (acceptEncoding.includes('gzip')) { |
| 93 | + try { |
| 94 | + await fsPromises.access(`${filePath}.gz`); |
| 95 | + encoding = 'gzip'; |
| 96 | + encodedFilePath = `${filePath}.gz`; |
| 97 | + } catch {} |
| 98 | + } |
| 99 | + |
| 100 | + const statForEtag = statSync(encodedFilePath); |
| 101 | + const etag = generateETag(statForEtag); |
| 102 | + if (ctx.request.header('if-none-match') === etag) { |
| 103 | + return Response.notModified(); |
| 104 | + } |
| 105 | + |
| 106 | + const contentType = detectMime(filePath, options.mimes) || 'application/octet-stream'; |
| 107 | + await options.onFound?.(encodedFilePath, ctx); |
| 108 | + |
| 109 | + return Response.stream(createReadStream(encodedFilePath), { |
| 110 | + status: 200, |
| 111 | + headers: { |
| 112 | + 'Content-Type': contentType, |
| 113 | + ...(encoding ? { 'Content-Encoding': encoding } : {}), |
| 114 | + Vary: 'Accept-Encoding', |
| 115 | + ETag: etag, |
| 116 | + 'Cache-Control': cacheControl, |
| 117 | + }, |
| 118 | + }); |
| 119 | + }); |
| 120 | + |
| 121 | + return middleware({ |
| 122 | + priority: options.priority ?? Priority.MONITOR, |
| 123 | + includes: options.includes, |
| 124 | + excludes: options.excludes, |
| 125 | + }); |
213 | 126 | } |
0 commit comments