@zero-server/sdk 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +460 -443
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +465 -465
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +137 -137
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +255 -255
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/middleware/static.js
CHANGED
|
@@ -1,326 +1,326 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module static
|
|
3
|
-
* @description Static file-serving middleware with MIME detection, directory
|
|
4
|
-
* index files, extension fallbacks, dotfile policies, caching,
|
|
5
|
-
* custom header hooks, and HTTP/2 server push for linked assets.
|
|
6
|
-
*/
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const log = require('../debug')('zero:static');
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Extension → MIME-type lookup table.
|
|
13
|
-
* @type {Object<string, string>}
|
|
14
|
-
*/
|
|
15
|
-
const MIME = {
|
|
16
|
-
// Text
|
|
17
|
-
'.html': 'text/html',
|
|
18
|
-
'.htm': 'text/html',
|
|
19
|
-
'.css': 'text/css',
|
|
20
|
-
'.txt': 'text/plain',
|
|
21
|
-
'.csv': 'text/csv',
|
|
22
|
-
'.xml': 'application/xml',
|
|
23
|
-
'.json': 'application/json',
|
|
24
|
-
'.jsonld': 'application/ld+json',
|
|
25
|
-
|
|
26
|
-
// JavaScript / WASM
|
|
27
|
-
'.js': 'application/javascript',
|
|
28
|
-
'.mjs': 'application/javascript',
|
|
29
|
-
'.wasm': 'application/wasm',
|
|
30
|
-
|
|
31
|
-
// Images
|
|
32
|
-
'.png': 'image/png',
|
|
33
|
-
'.jpg': 'image/jpeg',
|
|
34
|
-
'.jpeg': 'image/jpeg',
|
|
35
|
-
'.gif': 'image/gif',
|
|
36
|
-
'.webp': 'image/webp',
|
|
37
|
-
'.avif': 'image/avif',
|
|
38
|
-
'.svg': 'image/svg+xml',
|
|
39
|
-
'.ico': 'image/x-icon',
|
|
40
|
-
'.bmp': 'image/bmp',
|
|
41
|
-
'.tiff': 'image/tiff',
|
|
42
|
-
'.tif': 'image/tiff',
|
|
43
|
-
|
|
44
|
-
// Fonts
|
|
45
|
-
'.woff': 'font/woff',
|
|
46
|
-
'.woff2': 'font/woff2',
|
|
47
|
-
'.ttf': 'font/ttf',
|
|
48
|
-
'.otf': 'font/otf',
|
|
49
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
50
|
-
|
|
51
|
-
// Audio
|
|
52
|
-
'.mp3': 'audio/mpeg',
|
|
53
|
-
'.ogg': 'audio/ogg',
|
|
54
|
-
'.wav': 'audio/wav',
|
|
55
|
-
'.flac': 'audio/flac',
|
|
56
|
-
'.aac': 'audio/aac',
|
|
57
|
-
'.m4a': 'audio/mp4',
|
|
58
|
-
|
|
59
|
-
// Video
|
|
60
|
-
'.mp4': 'video/mp4',
|
|
61
|
-
'.webm': 'video/webm',
|
|
62
|
-
'.ogv': 'video/ogg',
|
|
63
|
-
'.avi': 'video/x-msvideo',
|
|
64
|
-
'.mov': 'video/quicktime',
|
|
65
|
-
|
|
66
|
-
// Documents / Archives
|
|
67
|
-
'.pdf': 'application/pdf',
|
|
68
|
-
'.zip': 'application/zip',
|
|
69
|
-
'.gz': 'application/gzip',
|
|
70
|
-
'.tar': 'application/x-tar',
|
|
71
|
-
'.7z': 'application/x-7z-compressed',
|
|
72
|
-
|
|
73
|
-
// Other
|
|
74
|
-
'.map': 'application/json',
|
|
75
|
-
'.yaml': 'text/yaml',
|
|
76
|
-
'.yml': 'text/yaml',
|
|
77
|
-
'.md': 'text/markdown',
|
|
78
|
-
'.sh': 'application/x-sh',
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Generate a weak ETag from file stats (mtime + size).
|
|
83
|
-
* @private
|
|
84
|
-
* @param {import('fs').Stats} stat - File system stat object.
|
|
85
|
-
* @returns {string} Weak ETag string (e.g. `W/"1a2b-3c4d"`).
|
|
86
|
-
*/
|
|
87
|
-
function generateETag(stat)
|
|
88
|
-
{
|
|
89
|
-
return 'W/"' + stat.size.toString(16) + '-' + stat.mtimeMs.toString(16) + '"';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Stream a file to the raw Node response, setting Content-Type,
|
|
94
|
-
* Content-Length, ETag, and Last-Modified headers.
|
|
95
|
-
*
|
|
96
|
-
* @private
|
|
97
|
-
* @param {import('./response')} res - Wrapped response object.
|
|
98
|
-
* @param {string} filePath - Absolute path to the file.
|
|
99
|
-
* @param {import('fs').Stats} [stat] - Pre-fetched `fs.Stats` (for Content-Length).
|
|
100
|
-
* @param {import('./request')} [req] - Wrapped request (for conditional checks).
|
|
101
|
-
*/
|
|
102
|
-
function sendFile(res, filePath, stat, req)
|
|
103
|
-
{
|
|
104
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
105
|
-
const ct = MIME[ext] || 'application/octet-stream';
|
|
106
|
-
const raw = res.raw;
|
|
107
|
-
try
|
|
108
|
-
{
|
|
109
|
-
raw.setHeader('Content-Type', ct);
|
|
110
|
-
if (stat)
|
|
111
|
-
{
|
|
112
|
-
if (stat.size) raw.setHeader('Content-Length', stat.size);
|
|
113
|
-
// ETag and Last-Modified for caching
|
|
114
|
-
const etag = generateETag(stat);
|
|
115
|
-
raw.setHeader('ETag', etag);
|
|
116
|
-
raw.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
117
|
-
raw.setHeader('Accept-Ranges', 'bytes');
|
|
118
|
-
|
|
119
|
-
// Conditional request handling (304 Not Modified)
|
|
120
|
-
if (req)
|
|
121
|
-
{
|
|
122
|
-
const ifNoneMatch = req.headers['if-none-match'];
|
|
123
|
-
const ifModifiedSince = req.headers['if-modified-since'];
|
|
124
|
-
if (ifNoneMatch && ifNoneMatch === etag)
|
|
125
|
-
{
|
|
126
|
-
raw.statusCode = 304;
|
|
127
|
-
raw.end();
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (ifModifiedSince && !ifNoneMatch)
|
|
131
|
-
{
|
|
132
|
-
const since = Date.parse(ifModifiedSince);
|
|
133
|
-
if (!isNaN(since) && stat.mtimeMs <= since)
|
|
134
|
-
{
|
|
135
|
-
raw.statusCode = 304;
|
|
136
|
-
raw.end();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Range request support (HTTP 206)
|
|
142
|
-
const rangeHeader = req.headers['range'];
|
|
143
|
-
if (rangeHeader && stat.size > 0)
|
|
144
|
-
{
|
|
145
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
|
146
|
-
if (match)
|
|
147
|
-
{
|
|
148
|
-
let start = match[1] ? parseInt(match[1], 10) : 0;
|
|
149
|
-
let end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
|
|
150
|
-
if (!match[1] && match[2])
|
|
151
|
-
{
|
|
152
|
-
// suffix range: bytes=-500 means last 500 bytes
|
|
153
|
-
start = Math.max(0, stat.size - parseInt(match[2], 10));
|
|
154
|
-
end = stat.size - 1;
|
|
155
|
-
}
|
|
156
|
-
if (start > end || start >= stat.size || end >= stat.size)
|
|
157
|
-
{
|
|
158
|
-
raw.statusCode = 416;
|
|
159
|
-
raw.setHeader('Content-Range', 'bytes */' + stat.size);
|
|
160
|
-
raw.setHeader('Content-Length', 0);
|
|
161
|
-
raw.end();
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
raw.statusCode = 206;
|
|
165
|
-
raw.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + stat.size);
|
|
166
|
-
raw.setHeader('Content-Length', end - start + 1);
|
|
167
|
-
const stream = fs.createReadStream(filePath, { start, end });
|
|
168
|
-
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
169
|
-
log.debug('serving %s (range %d-%d)', filePath, start, end);
|
|
170
|
-
stream.pipe(raw);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
catch (e) { /* best-effort */ }
|
|
178
|
-
const stream = fs.createReadStream(filePath);
|
|
179
|
-
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
180
|
-
log.debug('serving %s', filePath);
|
|
181
|
-
stream.pipe(raw);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Create a static-file-serving middleware.
|
|
186
|
-
*
|
|
187
|
-
* @param {string} root - Root directory to serve files from.
|
|
188
|
-
* @param {object} [options] - Configuration options.
|
|
189
|
-
* @param {string|false} [options.index='index.html'] - Default file for directory requests, or `false` to disable.
|
|
190
|
-
* @param {number} [options.maxAge=0] - `Cache-Control` max-age in **milliseconds**.
|
|
191
|
-
* @param {string} [options.dotfiles='ignore'] - Dotfile policy: `'allow'` | `'deny'` | `'ignore'`.
|
|
192
|
-
* @param {string[]} [options.extensions] - Array of fallback extensions (e.g. `['html', 'htm']`).
|
|
193
|
-
* @param {Function} [options.setHeaders] - `(res, filePath) => void` hook to set custom headers.
|
|
194
|
-
* @param {string[]|Function} [options.pushAssets] - HTTP/2 server push. Array of paths
|
|
195
|
-
* (relative to root) to push when serving HTML files, or a function `(filePath) => string[]`.
|
|
196
|
-
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
197
|
-
*
|
|
198
|
-
* @example
|
|
199
|
-
* app.use(serveStatic('public')); // serve ./public
|
|
200
|
-
* app.use(serveStatic('dist', { maxAge: 86400000 })); // 1-day cache
|
|
201
|
-
* app.use(serveStatic('assets', { extensions: ['html'] })); // .html fallback
|
|
202
|
-
*
|
|
203
|
-
* @example | HTTP/2 Server Push
|
|
204
|
-
* app.use(serveStatic('public', {
|
|
205
|
-
* pushAssets: ['/styles/main.css', '/modules/app.js'],
|
|
206
|
-
* }));
|
|
207
|
-
*/
|
|
208
|
-
function serveStatic(root, options = {})
|
|
209
|
-
{
|
|
210
|
-
root = path.resolve(root);
|
|
211
|
-
const index = options.hasOwnProperty('index') ? options.index : 'index.html';
|
|
212
|
-
const maxAge = options.hasOwnProperty('maxAge') ? options.maxAge : 0;
|
|
213
|
-
const dotfiles = options.hasOwnProperty('dotfiles') ? options.dotfiles : 'ignore'; // allow|deny|ignore
|
|
214
|
-
const extensions = Array.isArray(options.extensions) ? options.extensions : null;
|
|
215
|
-
const setHeaders = typeof options.setHeaders === 'function' ? options.setHeaders : null;
|
|
216
|
-
const pushAssets = options.pushAssets || null;
|
|
217
|
-
|
|
218
|
-
function isDotfile(p)
|
|
219
|
-
{
|
|
220
|
-
return path.basename(p).startsWith('.');
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function applyHeaders(res, filePath)
|
|
224
|
-
{
|
|
225
|
-
if (maxAge) try { res.raw.setHeader('Cache-Control', 'max-age=' + Math.floor(Number(maxAge) / 1000)); } catch (e) { }
|
|
226
|
-
if (setHeaders) try { setHeaders(res, filePath); } catch (e) { }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Push linked assets via HTTP/2 server push when serving HTML files.
|
|
231
|
-
* @private
|
|
232
|
-
*/
|
|
233
|
-
function pushLinkedAssets(res, filePath)
|
|
234
|
-
{
|
|
235
|
-
if (!pushAssets) return;
|
|
236
|
-
// Only push for HTML files
|
|
237
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
238
|
-
if (ext !== '.html' && ext !== '.htm') return;
|
|
239
|
-
// Only push on HTTP/2 connections
|
|
240
|
-
if (!res.supportsPush) return;
|
|
241
|
-
|
|
242
|
-
const assets = typeof pushAssets === 'function'
|
|
243
|
-
? pushAssets(filePath)
|
|
244
|
-
: pushAssets;
|
|
245
|
-
|
|
246
|
-
if (!Array.isArray(assets)) return;
|
|
247
|
-
|
|
248
|
-
for (const assetPath of assets)
|
|
249
|
-
{
|
|
250
|
-
const absPath = path.resolve(root, '.' + path.sep + assetPath);
|
|
251
|
-
// Security: verify asset is within root
|
|
252
|
-
if (!absPath.startsWith(root + path.sep) && absPath !== root) continue;
|
|
253
|
-
res.push(assetPath, { filePath: absPath });
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return (req, res, next) =>
|
|
258
|
-
{
|
|
259
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
260
|
-
let urlPath;
|
|
261
|
-
try { urlPath = decodeURIComponent(req.url.split('?')[0]); } catch (e) { return res.status(400).json({ error: 'Bad Request' }); }
|
|
262
|
-
|
|
263
|
-
// Block null bytes (poison byte attack)
|
|
264
|
-
if (urlPath.indexOf('\0') !== -1) return res.status(400).json({ error: 'Bad Request' });
|
|
265
|
-
|
|
266
|
-
let file = path.resolve(root, '.' + path.sep + urlPath);
|
|
267
|
-
// Normalize and verify the resolved path is within root (prevents path traversal)
|
|
268
|
-
if (!file.startsWith(root + path.sep) && file !== root) return res.status(403).json({ error: 'Forbidden' });
|
|
269
|
-
|
|
270
|
-
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
271
|
-
|
|
272
|
-
fs.stat(file, (err, st) =>
|
|
273
|
-
{
|
|
274
|
-
if (err)
|
|
275
|
-
{
|
|
276
|
-
// try extensions fallback
|
|
277
|
-
if (extensions && !urlPath.endsWith('/'))
|
|
278
|
-
{
|
|
279
|
-
(function tryExt(i)
|
|
280
|
-
{
|
|
281
|
-
if (i >= extensions.length) return next();
|
|
282
|
-
const ext = extensions[i].startsWith('.') ? extensions[i] : '.' + extensions[i];
|
|
283
|
-
const f = file + ext;
|
|
284
|
-
fs.stat(f, (e2, st2) =>
|
|
285
|
-
{
|
|
286
|
-
if (!e2 && st2 && st2.isFile())
|
|
287
|
-
{
|
|
288
|
-
if (isDotfile(f) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
289
|
-
applyHeaders(res, f);
|
|
290
|
-
pushLinkedAssets(res, f);
|
|
291
|
-
return sendFile(res, f, st2, req);
|
|
292
|
-
}
|
|
293
|
-
tryExt(i + 1);
|
|
294
|
-
});
|
|
295
|
-
})(0);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
return next();
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (st.isDirectory())
|
|
302
|
-
{
|
|
303
|
-
if (!index) return next();
|
|
304
|
-
const idxFile = path.join(file, index);
|
|
305
|
-
fs.stat(idxFile, (err2, st2) =>
|
|
306
|
-
{
|
|
307
|
-
if (err2) return next();
|
|
308
|
-
if (isDotfile(idxFile) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
309
|
-
applyHeaders(res, idxFile);
|
|
310
|
-
pushLinkedAssets(res, idxFile);
|
|
311
|
-
sendFile(res, idxFile, st2, req);
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
else
|
|
315
|
-
{
|
|
316
|
-
if (isDotfile(file) && dotfiles === 'ignore') return next();
|
|
317
|
-
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
318
|
-
applyHeaders(res, file);
|
|
319
|
-
pushLinkedAssets(res, file);
|
|
320
|
-
sendFile(res, file, st, req);
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
module.exports = serveStatic;
|
|
1
|
+
/**
|
|
2
|
+
* @module static
|
|
3
|
+
* @description Static file-serving middleware with MIME detection, directory
|
|
4
|
+
* index files, extension fallbacks, dotfile policies, caching,
|
|
5
|
+
* custom header hooks, and HTTP/2 server push for linked assets.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const log = require('../debug')('zero:static');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extension → MIME-type lookup table.
|
|
13
|
+
* @type {Object<string, string>}
|
|
14
|
+
*/
|
|
15
|
+
const MIME = {
|
|
16
|
+
// Text
|
|
17
|
+
'.html': 'text/html',
|
|
18
|
+
'.htm': 'text/html',
|
|
19
|
+
'.css': 'text/css',
|
|
20
|
+
'.txt': 'text/plain',
|
|
21
|
+
'.csv': 'text/csv',
|
|
22
|
+
'.xml': 'application/xml',
|
|
23
|
+
'.json': 'application/json',
|
|
24
|
+
'.jsonld': 'application/ld+json',
|
|
25
|
+
|
|
26
|
+
// JavaScript / WASM
|
|
27
|
+
'.js': 'application/javascript',
|
|
28
|
+
'.mjs': 'application/javascript',
|
|
29
|
+
'.wasm': 'application/wasm',
|
|
30
|
+
|
|
31
|
+
// Images
|
|
32
|
+
'.png': 'image/png',
|
|
33
|
+
'.jpg': 'image/jpeg',
|
|
34
|
+
'.jpeg': 'image/jpeg',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.avif': 'image/avif',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.ico': 'image/x-icon',
|
|
40
|
+
'.bmp': 'image/bmp',
|
|
41
|
+
'.tiff': 'image/tiff',
|
|
42
|
+
'.tif': 'image/tiff',
|
|
43
|
+
|
|
44
|
+
// Fonts
|
|
45
|
+
'.woff': 'font/woff',
|
|
46
|
+
'.woff2': 'font/woff2',
|
|
47
|
+
'.ttf': 'font/ttf',
|
|
48
|
+
'.otf': 'font/otf',
|
|
49
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
50
|
+
|
|
51
|
+
// Audio
|
|
52
|
+
'.mp3': 'audio/mpeg',
|
|
53
|
+
'.ogg': 'audio/ogg',
|
|
54
|
+
'.wav': 'audio/wav',
|
|
55
|
+
'.flac': 'audio/flac',
|
|
56
|
+
'.aac': 'audio/aac',
|
|
57
|
+
'.m4a': 'audio/mp4',
|
|
58
|
+
|
|
59
|
+
// Video
|
|
60
|
+
'.mp4': 'video/mp4',
|
|
61
|
+
'.webm': 'video/webm',
|
|
62
|
+
'.ogv': 'video/ogg',
|
|
63
|
+
'.avi': 'video/x-msvideo',
|
|
64
|
+
'.mov': 'video/quicktime',
|
|
65
|
+
|
|
66
|
+
// Documents / Archives
|
|
67
|
+
'.pdf': 'application/pdf',
|
|
68
|
+
'.zip': 'application/zip',
|
|
69
|
+
'.gz': 'application/gzip',
|
|
70
|
+
'.tar': 'application/x-tar',
|
|
71
|
+
'.7z': 'application/x-7z-compressed',
|
|
72
|
+
|
|
73
|
+
// Other
|
|
74
|
+
'.map': 'application/json',
|
|
75
|
+
'.yaml': 'text/yaml',
|
|
76
|
+
'.yml': 'text/yaml',
|
|
77
|
+
'.md': 'text/markdown',
|
|
78
|
+
'.sh': 'application/x-sh',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a weak ETag from file stats (mtime + size).
|
|
83
|
+
* @private
|
|
84
|
+
* @param {import('fs').Stats} stat - File system stat object.
|
|
85
|
+
* @returns {string} Weak ETag string (e.g. `W/"1a2b-3c4d"`).
|
|
86
|
+
*/
|
|
87
|
+
function generateETag(stat)
|
|
88
|
+
{
|
|
89
|
+
return 'W/"' + stat.size.toString(16) + '-' + stat.mtimeMs.toString(16) + '"';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Stream a file to the raw Node response, setting Content-Type,
|
|
94
|
+
* Content-Length, ETag, and Last-Modified headers.
|
|
95
|
+
*
|
|
96
|
+
* @private
|
|
97
|
+
* @param {import('./response')} res - Wrapped response object.
|
|
98
|
+
* @param {string} filePath - Absolute path to the file.
|
|
99
|
+
* @param {import('fs').Stats} [stat] - Pre-fetched `fs.Stats` (for Content-Length).
|
|
100
|
+
* @param {import('./request')} [req] - Wrapped request (for conditional checks).
|
|
101
|
+
*/
|
|
102
|
+
function sendFile(res, filePath, stat, req)
|
|
103
|
+
{
|
|
104
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
105
|
+
const ct = MIME[ext] || 'application/octet-stream';
|
|
106
|
+
const raw = res.raw;
|
|
107
|
+
try
|
|
108
|
+
{
|
|
109
|
+
raw.setHeader('Content-Type', ct);
|
|
110
|
+
if (stat)
|
|
111
|
+
{
|
|
112
|
+
if (stat.size) raw.setHeader('Content-Length', stat.size);
|
|
113
|
+
// ETag and Last-Modified for caching
|
|
114
|
+
const etag = generateETag(stat);
|
|
115
|
+
raw.setHeader('ETag', etag);
|
|
116
|
+
raw.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
117
|
+
raw.setHeader('Accept-Ranges', 'bytes');
|
|
118
|
+
|
|
119
|
+
// Conditional request handling (304 Not Modified)
|
|
120
|
+
if (req)
|
|
121
|
+
{
|
|
122
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
123
|
+
const ifModifiedSince = req.headers['if-modified-since'];
|
|
124
|
+
if (ifNoneMatch && ifNoneMatch === etag)
|
|
125
|
+
{
|
|
126
|
+
raw.statusCode = 304;
|
|
127
|
+
raw.end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (ifModifiedSince && !ifNoneMatch)
|
|
131
|
+
{
|
|
132
|
+
const since = Date.parse(ifModifiedSince);
|
|
133
|
+
if (!isNaN(since) && stat.mtimeMs <= since)
|
|
134
|
+
{
|
|
135
|
+
raw.statusCode = 304;
|
|
136
|
+
raw.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Range request support (HTTP 206)
|
|
142
|
+
const rangeHeader = req.headers['range'];
|
|
143
|
+
if (rangeHeader && stat.size > 0)
|
|
144
|
+
{
|
|
145
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
|
146
|
+
if (match)
|
|
147
|
+
{
|
|
148
|
+
let start = match[1] ? parseInt(match[1], 10) : 0;
|
|
149
|
+
let end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
|
|
150
|
+
if (!match[1] && match[2])
|
|
151
|
+
{
|
|
152
|
+
// suffix range: bytes=-500 means last 500 bytes
|
|
153
|
+
start = Math.max(0, stat.size - parseInt(match[2], 10));
|
|
154
|
+
end = stat.size - 1;
|
|
155
|
+
}
|
|
156
|
+
if (start > end || start >= stat.size || end >= stat.size)
|
|
157
|
+
{
|
|
158
|
+
raw.statusCode = 416;
|
|
159
|
+
raw.setHeader('Content-Range', 'bytes */' + stat.size);
|
|
160
|
+
raw.setHeader('Content-Length', 0);
|
|
161
|
+
raw.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
raw.statusCode = 206;
|
|
165
|
+
raw.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + stat.size);
|
|
166
|
+
raw.setHeader('Content-Length', end - start + 1);
|
|
167
|
+
const stream = fs.createReadStream(filePath, { start, end });
|
|
168
|
+
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
169
|
+
log.debug('serving %s (range %d-%d)', filePath, start, end);
|
|
170
|
+
stream.pipe(raw);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (e) { /* best-effort */ }
|
|
178
|
+
const stream = fs.createReadStream(filePath);
|
|
179
|
+
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
180
|
+
log.debug('serving %s', filePath);
|
|
181
|
+
stream.pipe(raw);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Create a static-file-serving middleware.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} root - Root directory to serve files from.
|
|
188
|
+
* @param {object} [options] - Configuration options.
|
|
189
|
+
* @param {string|false} [options.index='index.html'] - Default file for directory requests, or `false` to disable.
|
|
190
|
+
* @param {number} [options.maxAge=0] - `Cache-Control` max-age in **milliseconds**.
|
|
191
|
+
* @param {string} [options.dotfiles='ignore'] - Dotfile policy: `'allow'` | `'deny'` | `'ignore'`.
|
|
192
|
+
* @param {string[]} [options.extensions] - Array of fallback extensions (e.g. `['html', 'htm']`).
|
|
193
|
+
* @param {Function} [options.setHeaders] - `(res, filePath) => void` hook to set custom headers.
|
|
194
|
+
* @param {string[]|Function} [options.pushAssets] - HTTP/2 server push. Array of paths
|
|
195
|
+
* (relative to root) to push when serving HTML files, or a function `(filePath) => string[]`.
|
|
196
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* app.use(serveStatic('public')); // serve ./public
|
|
200
|
+
* app.use(serveStatic('dist', { maxAge: 86400000 })); // 1-day cache
|
|
201
|
+
* app.use(serveStatic('assets', { extensions: ['html'] })); // .html fallback
|
|
202
|
+
*
|
|
203
|
+
* @example | HTTP/2 Server Push
|
|
204
|
+
* app.use(serveStatic('public', {
|
|
205
|
+
* pushAssets: ['/styles/main.css', '/modules/app.js'],
|
|
206
|
+
* }));
|
|
207
|
+
*/
|
|
208
|
+
function serveStatic(root, options = {})
|
|
209
|
+
{
|
|
210
|
+
root = path.resolve(root);
|
|
211
|
+
const index = options.hasOwnProperty('index') ? options.index : 'index.html';
|
|
212
|
+
const maxAge = options.hasOwnProperty('maxAge') ? options.maxAge : 0;
|
|
213
|
+
const dotfiles = options.hasOwnProperty('dotfiles') ? options.dotfiles : 'ignore'; // allow|deny|ignore
|
|
214
|
+
const extensions = Array.isArray(options.extensions) ? options.extensions : null;
|
|
215
|
+
const setHeaders = typeof options.setHeaders === 'function' ? options.setHeaders : null;
|
|
216
|
+
const pushAssets = options.pushAssets || null;
|
|
217
|
+
|
|
218
|
+
function isDotfile(p)
|
|
219
|
+
{
|
|
220
|
+
return path.basename(p).startsWith('.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function applyHeaders(res, filePath)
|
|
224
|
+
{
|
|
225
|
+
if (maxAge) try { res.raw.setHeader('Cache-Control', 'max-age=' + Math.floor(Number(maxAge) / 1000)); } catch (e) { }
|
|
226
|
+
if (setHeaders) try { setHeaders(res, filePath); } catch (e) { }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Push linked assets via HTTP/2 server push when serving HTML files.
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
function pushLinkedAssets(res, filePath)
|
|
234
|
+
{
|
|
235
|
+
if (!pushAssets) return;
|
|
236
|
+
// Only push for HTML files
|
|
237
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
238
|
+
if (ext !== '.html' && ext !== '.htm') return;
|
|
239
|
+
// Only push on HTTP/2 connections
|
|
240
|
+
if (!res.supportsPush) return;
|
|
241
|
+
|
|
242
|
+
const assets = typeof pushAssets === 'function'
|
|
243
|
+
? pushAssets(filePath)
|
|
244
|
+
: pushAssets;
|
|
245
|
+
|
|
246
|
+
if (!Array.isArray(assets)) return;
|
|
247
|
+
|
|
248
|
+
for (const assetPath of assets)
|
|
249
|
+
{
|
|
250
|
+
const absPath = path.resolve(root, '.' + path.sep + assetPath);
|
|
251
|
+
// Security: verify asset is within root
|
|
252
|
+
if (!absPath.startsWith(root + path.sep) && absPath !== root) continue;
|
|
253
|
+
res.push(assetPath, { filePath: absPath });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return (req, res, next) =>
|
|
258
|
+
{
|
|
259
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
260
|
+
let urlPath;
|
|
261
|
+
try { urlPath = decodeURIComponent(req.url.split('?')[0]); } catch (e) { return res.status(400).json({ error: 'Bad Request' }); }
|
|
262
|
+
|
|
263
|
+
// Block null bytes (poison byte attack)
|
|
264
|
+
if (urlPath.indexOf('\0') !== -1) return res.status(400).json({ error: 'Bad Request' });
|
|
265
|
+
|
|
266
|
+
let file = path.resolve(root, '.' + path.sep + urlPath);
|
|
267
|
+
// Normalize and verify the resolved path is within root (prevents path traversal)
|
|
268
|
+
if (!file.startsWith(root + path.sep) && file !== root) return res.status(403).json({ error: 'Forbidden' });
|
|
269
|
+
|
|
270
|
+
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
271
|
+
|
|
272
|
+
fs.stat(file, (err, st) =>
|
|
273
|
+
{
|
|
274
|
+
if (err)
|
|
275
|
+
{
|
|
276
|
+
// try extensions fallback
|
|
277
|
+
if (extensions && !urlPath.endsWith('/'))
|
|
278
|
+
{
|
|
279
|
+
(function tryExt(i)
|
|
280
|
+
{
|
|
281
|
+
if (i >= extensions.length) return next();
|
|
282
|
+
const ext = extensions[i].startsWith('.') ? extensions[i] : '.' + extensions[i];
|
|
283
|
+
const f = file + ext;
|
|
284
|
+
fs.stat(f, (e2, st2) =>
|
|
285
|
+
{
|
|
286
|
+
if (!e2 && st2 && st2.isFile())
|
|
287
|
+
{
|
|
288
|
+
if (isDotfile(f) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
289
|
+
applyHeaders(res, f);
|
|
290
|
+
pushLinkedAssets(res, f);
|
|
291
|
+
return sendFile(res, f, st2, req);
|
|
292
|
+
}
|
|
293
|
+
tryExt(i + 1);
|
|
294
|
+
});
|
|
295
|
+
})(0);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
return next();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (st.isDirectory())
|
|
302
|
+
{
|
|
303
|
+
if (!index) return next();
|
|
304
|
+
const idxFile = path.join(file, index);
|
|
305
|
+
fs.stat(idxFile, (err2, st2) =>
|
|
306
|
+
{
|
|
307
|
+
if (err2) return next();
|
|
308
|
+
if (isDotfile(idxFile) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
309
|
+
applyHeaders(res, idxFile);
|
|
310
|
+
pushLinkedAssets(res, idxFile);
|
|
311
|
+
sendFile(res, idxFile, st2, req);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
else
|
|
315
|
+
{
|
|
316
|
+
if (isDotfile(file) && dotfiles === 'ignore') return next();
|
|
317
|
+
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
318
|
+
applyHeaders(res, file);
|
|
319
|
+
pushLinkedAssets(res, file);
|
|
320
|
+
sendFile(res, file, st, req);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = serveStatic;
|