Skip to content

Commit 9a046fb

Browse files
committed
[chore][static] check directory dist first
1 parent 81e53d9 commit 9a046fb

File tree

2 files changed

+115
-202
lines changed

2 files changed

+115
-202
lines changed

plugins/static/index.ts

Lines changed: 114 additions & 201 deletions
Original file line numberDiff line numberDiff line change
@@ -1,213 +1,126 @@
11
import { createReadStream, promises as fsPromises, Stats, statSync } from 'fs';
22
import { join } from 'path';
33
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';
115
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[];
6718
}
6819

6920
// Buat ETag dari ukuran dan waktu modifikasi file
7021
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')}"`;
7324
}
7425

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-
*/
10726
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+
});
213126
}

scripts/yalc-push.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ yalc push
2121
cd ../../plugins/cors
2222
yalc push
2323

24-
cd ../static
24+
cd ../../plugins/static
2525
yalc push
2626

2727
cd ../ejs

0 commit comments

Comments
 (0)