webspresso 0.0.1 → 0.0.3
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/README.md +132 -0
- package/index.js +13 -2
- package/package.json +2 -1
- package/src/file-router.js +53 -4
- package/src/helpers.js +206 -1
- package/src/server.js +182 -53
package/README.md
CHANGED
|
@@ -180,6 +180,138 @@ Creates and configures the Express app.
|
|
|
180
180
|
- `viewsDir` (optional): Path to views/layouts directory
|
|
181
181
|
- `publicDir` (optional): Path to public/static directory
|
|
182
182
|
- `logging` (optional): Enable request logging (default: true in development)
|
|
183
|
+
- `helmet` (optional): Helmet security configuration
|
|
184
|
+
- `true` or `undefined`: Use default secure configuration
|
|
185
|
+
- `false`: Disable Helmet
|
|
186
|
+
- `Object`: Custom Helmet configuration (merged with defaults)
|
|
187
|
+
- `middlewares` (optional): Named middleware registry for routes
|
|
188
|
+
|
|
189
|
+
**Example with middlewares:**
|
|
190
|
+
|
|
191
|
+
```javascript
|
|
192
|
+
const { createApp } = require('webspresso');
|
|
193
|
+
|
|
194
|
+
const { app } = createApp({
|
|
195
|
+
pagesDir: './pages',
|
|
196
|
+
viewsDir: './views',
|
|
197
|
+
middlewares: {
|
|
198
|
+
auth: (req, res, next) => {
|
|
199
|
+
if (!req.session?.user) {
|
|
200
|
+
return res.redirect('/login');
|
|
201
|
+
}
|
|
202
|
+
next();
|
|
203
|
+
},
|
|
204
|
+
admin: (req, res, next) => {
|
|
205
|
+
if (req.session?.user?.role !== 'admin') {
|
|
206
|
+
return res.status(403).send('Forbidden');
|
|
207
|
+
}
|
|
208
|
+
next();
|
|
209
|
+
},
|
|
210
|
+
rateLimit: require('express-rate-limit')({ windowMs: 60000, max: 100 })
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Then use in route configs by name:
|
|
216
|
+
|
|
217
|
+
```javascript
|
|
218
|
+
// pages/admin/index.js
|
|
219
|
+
module.exports = {
|
|
220
|
+
middleware: ['auth', 'admin'], // Use named middlewares
|
|
221
|
+
load(req, ctx) { ... }
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// pages/api/data.get.js
|
|
225
|
+
module.exports = {
|
|
226
|
+
middleware: ['auth', 'rateLimit'],
|
|
227
|
+
handler: (req, res) => res.json({ data: 'protected' })
|
|
228
|
+
};
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Custom Error Pages:**
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
const { createApp } = require('webspresso');
|
|
235
|
+
|
|
236
|
+
const { app } = createApp({
|
|
237
|
+
pagesDir: './pages',
|
|
238
|
+
viewsDir: './views',
|
|
239
|
+
errorPages: {
|
|
240
|
+
// Option 1: Custom handler function
|
|
241
|
+
notFound: (req, res) => {
|
|
242
|
+
res.render('errors/404.njk', { url: req.url });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// Option 2: Template path (rendered with Nunjucks)
|
|
246
|
+
serverError: 'errors/500.njk'
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Error templates receive these variables:
|
|
252
|
+
- `404.njk`: `{ url, method }`
|
|
253
|
+
- `500.njk`: `{ error, status, isDev }`
|
|
254
|
+
|
|
255
|
+
**Asset Management:**
|
|
256
|
+
|
|
257
|
+
Configure asset handling with versioning and manifest support:
|
|
258
|
+
|
|
259
|
+
```javascript
|
|
260
|
+
const { createApp } = require('webspresso');
|
|
261
|
+
const path = require('path');
|
|
262
|
+
|
|
263
|
+
const { app } = createApp({
|
|
264
|
+
pagesDir: './pages',
|
|
265
|
+
viewsDir: './views',
|
|
266
|
+
publicDir: './public',
|
|
267
|
+
assets: {
|
|
268
|
+
// Option 1: Simple versioning (cache busting)
|
|
269
|
+
version: '1.2.3', // or process.env.APP_VERSION
|
|
270
|
+
|
|
271
|
+
// Option 2: Manifest file (Vite, Webpack, etc.)
|
|
272
|
+
manifestPath: path.join(__dirname, 'public/.vite/manifest.json'),
|
|
273
|
+
|
|
274
|
+
// URL prefix for assets
|
|
275
|
+
prefix: '/static'
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Use asset helpers in templates:
|
|
281
|
+
|
|
282
|
+
```njk
|
|
283
|
+
{# Using fsy helpers (auto-resolved) #}
|
|
284
|
+
<link rel="stylesheet" href="{{ fsy.asset('/css/style.css') }}">
|
|
285
|
+
|
|
286
|
+
{# Or generate full HTML tags #}
|
|
287
|
+
{{ fsy.css('/css/style.css') | safe }}
|
|
288
|
+
{{ fsy.js('/js/app.js', { defer: true, type: 'module' }) | safe }}
|
|
289
|
+
{{ fsy.img('/images/logo.png', 'Site Logo', { class: 'logo', loading: 'lazy' }) | safe }}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Asset helpers available in `fsy`:
|
|
293
|
+
- `asset(path)` - Returns versioned/manifest-resolved URL
|
|
294
|
+
- `css(href, attrs)` - Generates `<link>` tag
|
|
295
|
+
- `js(src, attrs)` - Generates `<script>` tag
|
|
296
|
+
- `img(src, alt, attrs)` - Generates `<img>` tag
|
|
297
|
+
|
|
298
|
+
**Manifest Support:**
|
|
299
|
+
|
|
300
|
+
Works with Vite and Webpack manifest formats:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
// Vite manifest format (.vite/manifest.json)
|
|
304
|
+
{
|
|
305
|
+
"css/style.css": { "file": "assets/style-abc123.css" },
|
|
306
|
+
"js/app.js": { "file": "assets/app-xyz789.js" }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Webpack manifest format
|
|
310
|
+
{
|
|
311
|
+
"/css/style.css": "/dist/style.abc123.css",
|
|
312
|
+
"/js/app.js": "/dist/app.xyz789.js"
|
|
313
|
+
}
|
|
314
|
+
```
|
|
183
315
|
|
|
184
316
|
**Returns:** `{ app, nunjucksEnv }`
|
|
185
317
|
|
package/index.js
CHANGED
|
@@ -12,7 +12,13 @@ const {
|
|
|
12
12
|
createTranslator,
|
|
13
13
|
detectLocale
|
|
14
14
|
} = require('./src/file-router');
|
|
15
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
createHelpers,
|
|
17
|
+
utils,
|
|
18
|
+
AssetManager,
|
|
19
|
+
configureAssets,
|
|
20
|
+
getAssetManager
|
|
21
|
+
} = require('./src/helpers');
|
|
16
22
|
|
|
17
23
|
module.exports = {
|
|
18
24
|
// Main API
|
|
@@ -29,6 +35,11 @@ module.exports = {
|
|
|
29
35
|
|
|
30
36
|
// Template helpers
|
|
31
37
|
createHelpers,
|
|
32
|
-
utils
|
|
38
|
+
utils,
|
|
39
|
+
|
|
40
|
+
// Asset management
|
|
41
|
+
AssetManager,
|
|
42
|
+
configureAssets,
|
|
43
|
+
getAssetManager
|
|
33
44
|
};
|
|
34
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"commander": "^11.1.0",
|
|
36
36
|
"express": "^4.18.2",
|
|
37
|
+
"helmet": "^7.2.0",
|
|
37
38
|
"inquirer": "^8.2.6",
|
|
38
39
|
"nunjucks": "^3.2.4"
|
|
39
40
|
},
|
package/src/file-router.js
CHANGED
|
@@ -272,16 +272,48 @@ function detectLocale(req) {
|
|
|
272
272
|
return process.env.DEFAULT_LOCALE || 'en';
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Resolve middleware from config - supports both functions and named strings
|
|
277
|
+
* @param {Array} middlewareConfig - Array of middleware functions or names
|
|
278
|
+
* @param {Object} middlewareRegistry - Named middleware registry
|
|
279
|
+
* @returns {Array} Array of resolved middleware functions
|
|
280
|
+
*/
|
|
281
|
+
function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
282
|
+
if (!middlewareConfig || !Array.isArray(middlewareConfig)) {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return middlewareConfig.map((mw, index) => {
|
|
287
|
+
if (typeof mw === 'function') {
|
|
288
|
+
return mw;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (typeof mw === 'string') {
|
|
292
|
+
const resolved = middlewareRegistry[mw];
|
|
293
|
+
if (!resolved) {
|
|
294
|
+
throw new Error(`Middleware "${mw}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
|
|
295
|
+
}
|
|
296
|
+
if (typeof resolved !== 'function') {
|
|
297
|
+
throw new Error(`Middleware "${mw}" must be a function`);
|
|
298
|
+
}
|
|
299
|
+
return resolved;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
throw new Error(`Invalid middleware at index ${index}: must be a function or string name`);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
275
306
|
/**
|
|
276
307
|
* Mount all pages as routes on the Express app
|
|
277
308
|
* @param {Object} app - Express app
|
|
278
309
|
* @param {Object} options - Options
|
|
279
310
|
* @param {string} options.pagesDir - Pages directory path
|
|
280
311
|
* @param {Object} options.nunjucks - Nunjucks environment
|
|
312
|
+
* @param {Object} options.middlewares - Named middleware registry
|
|
281
313
|
* @param {boolean} options.silent - Suppress console output
|
|
282
314
|
*/
|
|
283
315
|
function mountPages(app, options) {
|
|
284
|
-
const { pagesDir, nunjucks, silent = false } = options;
|
|
316
|
+
const { pagesDir, nunjucks, middlewares = {}, silent = false } = options;
|
|
285
317
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
286
318
|
const log = silent ? () => {} : console.log.bind(console);
|
|
287
319
|
|
|
@@ -353,6 +385,7 @@ function mountPages(app, options) {
|
|
|
353
385
|
for (const route of sortRoutes(apiRoutes)) {
|
|
354
386
|
const handler = require(route.fullPath);
|
|
355
387
|
const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
388
|
+
const routeMiddleware = handler.middleware;
|
|
356
389
|
|
|
357
390
|
if (typeof handlerFn !== 'function') {
|
|
358
391
|
console.warn(`API route ${route.file} does not export a function`);
|
|
@@ -372,6 +405,20 @@ function mountPages(app, options) {
|
|
|
372
405
|
? currentHandler
|
|
373
406
|
: currentHandler.default || currentHandler.handler;
|
|
374
407
|
|
|
408
|
+
// Run middleware if defined
|
|
409
|
+
const mwConfig = isDev ? currentHandler.middleware : routeMiddleware;
|
|
410
|
+
if (mwConfig) {
|
|
411
|
+
const resolvedMw = resolveMiddlewares(mwConfig, middlewares);
|
|
412
|
+
for (const mw of resolvedMw) {
|
|
413
|
+
await new Promise((resolve, reject) => {
|
|
414
|
+
mw(req, res, (err) => {
|
|
415
|
+
if (err) reject(err);
|
|
416
|
+
else resolve();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
375
422
|
await fn(req, res, next);
|
|
376
423
|
} catch (err) {
|
|
377
424
|
console.error(`API error ${route.routePath}:`, err);
|
|
@@ -429,8 +476,9 @@ function mountPages(app, options) {
|
|
|
429
476
|
await executeHook(routeHooks, 'beforeMiddleware', ctx);
|
|
430
477
|
|
|
431
478
|
// Run route middleware
|
|
432
|
-
if (config?.middleware
|
|
433
|
-
|
|
479
|
+
if (config?.middleware) {
|
|
480
|
+
const resolvedMiddlewares = resolveMiddlewares(config.middleware, middlewares);
|
|
481
|
+
for (const mw of resolvedMiddlewares) {
|
|
434
482
|
await new Promise((resolve, reject) => {
|
|
435
483
|
mw(req, res, (err) => {
|
|
436
484
|
if (err) reject(err);
|
|
@@ -515,6 +563,7 @@ module.exports = {
|
|
|
515
563
|
scanDirectory,
|
|
516
564
|
loadI18n,
|
|
517
565
|
createTranslator,
|
|
518
|
-
detectLocale
|
|
566
|
+
detectLocale,
|
|
567
|
+
resolveMiddlewares
|
|
519
568
|
};
|
|
520
569
|
|
package/src/helpers.js
CHANGED
|
@@ -4,6 +4,167 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const querystring = require('querystring');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Asset Manager - handles asset paths, versioning, and manifest
|
|
12
|
+
*/
|
|
13
|
+
class AssetManager {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.publicDir = options.publicDir || 'public';
|
|
16
|
+
this.manifestPath = options.manifestPath || null;
|
|
17
|
+
this.manifest = null;
|
|
18
|
+
this.version = options.version || null;
|
|
19
|
+
this.prefix = options.prefix || '';
|
|
20
|
+
|
|
21
|
+
// Load manifest if path provided
|
|
22
|
+
if (this.manifestPath) {
|
|
23
|
+
this.loadManifest();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load asset manifest file (Vite, Webpack, etc.)
|
|
29
|
+
*/
|
|
30
|
+
loadManifest() {
|
|
31
|
+
try {
|
|
32
|
+
const fullPath = path.resolve(this.manifestPath);
|
|
33
|
+
if (fs.existsSync(fullPath)) {
|
|
34
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
35
|
+
this.manifest = JSON.parse(content);
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.warn('Failed to load asset manifest:', err.message);
|
|
39
|
+
this.manifest = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve asset path from manifest or add version
|
|
45
|
+
* @param {string} assetPath - Asset path
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
resolve(assetPath) {
|
|
49
|
+
// Remove leading slash for manifest lookup
|
|
50
|
+
const lookupPath = assetPath.replace(/^\//, '');
|
|
51
|
+
|
|
52
|
+
// Check manifest first
|
|
53
|
+
if (this.manifest) {
|
|
54
|
+
// Vite manifest format
|
|
55
|
+
if (this.manifest[lookupPath]) {
|
|
56
|
+
const entry = this.manifest[lookupPath];
|
|
57
|
+
const resolved = typeof entry === 'string' ? entry : entry.file;
|
|
58
|
+
return this.prefix + '/' + resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Webpack manifest format (direct mapping)
|
|
62
|
+
if (this.manifest[assetPath]) {
|
|
63
|
+
return this.prefix + this.manifest[assetPath];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Add version query string if provided
|
|
68
|
+
let finalPath = assetPath.startsWith('/') ? assetPath : '/' + assetPath;
|
|
69
|
+
finalPath = this.prefix + finalPath;
|
|
70
|
+
|
|
71
|
+
if (this.version) {
|
|
72
|
+
const separator = finalPath.includes('?') ? '&' : '?';
|
|
73
|
+
finalPath += `${separator}v=${this.version}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return finalPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get asset URL
|
|
81
|
+
*/
|
|
82
|
+
asset(assetPath) {
|
|
83
|
+
return this.resolve(assetPath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generate CSS link tag
|
|
88
|
+
*/
|
|
89
|
+
css(href, attributes = {}) {
|
|
90
|
+
const resolvedHref = this.resolve(href);
|
|
91
|
+
const attrs = this.buildAttributes({
|
|
92
|
+
rel: 'stylesheet',
|
|
93
|
+
href: resolvedHref,
|
|
94
|
+
...attributes
|
|
95
|
+
});
|
|
96
|
+
return `<link ${attrs}>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generate JS script tag
|
|
101
|
+
*/
|
|
102
|
+
js(src, attributes = {}) {
|
|
103
|
+
const resolvedSrc = this.resolve(src);
|
|
104
|
+
const attrs = this.buildAttributes({
|
|
105
|
+
src: resolvedSrc,
|
|
106
|
+
...attributes
|
|
107
|
+
});
|
|
108
|
+
return `<script ${attrs}></script>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate image tag
|
|
113
|
+
*/
|
|
114
|
+
img(src, alt = '', attributes = {}) {
|
|
115
|
+
const resolvedSrc = this.resolve(src);
|
|
116
|
+
const attrs = this.buildAttributes({
|
|
117
|
+
src: resolvedSrc,
|
|
118
|
+
alt,
|
|
119
|
+
...attributes
|
|
120
|
+
});
|
|
121
|
+
return `<img ${attrs}>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build HTML attributes string
|
|
126
|
+
*/
|
|
127
|
+
buildAttributes(attrs) {
|
|
128
|
+
return Object.entries(attrs)
|
|
129
|
+
.filter(([_, v]) => v !== undefined && v !== null && v !== false)
|
|
130
|
+
.map(([k, v]) => v === true ? k : `${k}="${this.escapeHtml(String(v))}"`)
|
|
131
|
+
.join(' ');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Escape HTML special characters
|
|
136
|
+
*/
|
|
137
|
+
escapeHtml(str) {
|
|
138
|
+
return str
|
|
139
|
+
.replace(/&/g, '&')
|
|
140
|
+
.replace(/"/g, '"')
|
|
141
|
+
.replace(/'/g, ''')
|
|
142
|
+
.replace(/</g, '<')
|
|
143
|
+
.replace(/>/g, '>');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Global asset manager instance
|
|
148
|
+
let globalAssetManager = null;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Configure global asset manager
|
|
152
|
+
* @param {Object} options - Asset manager options
|
|
153
|
+
*/
|
|
154
|
+
function configureAssets(options = {}) {
|
|
155
|
+
globalAssetManager = new AssetManager(options);
|
|
156
|
+
return globalAssetManager;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get or create asset manager
|
|
161
|
+
*/
|
|
162
|
+
function getAssetManager() {
|
|
163
|
+
if (!globalAssetManager) {
|
|
164
|
+
globalAssetManager = new AssetManager();
|
|
165
|
+
}
|
|
166
|
+
return globalAssetManager;
|
|
167
|
+
}
|
|
7
168
|
|
|
8
169
|
/**
|
|
9
170
|
* Create the fsy helper object bound to the current request context
|
|
@@ -217,6 +378,47 @@ function createHelpers(ctx) {
|
|
|
217
378
|
return regex.test(currentPath);
|
|
218
379
|
}
|
|
219
380
|
return false;
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
// Asset helpers
|
|
384
|
+
/**
|
|
385
|
+
* Get asset URL with versioning/manifest support
|
|
386
|
+
* @param {string} assetPath - Asset path
|
|
387
|
+
* @returns {string}
|
|
388
|
+
*/
|
|
389
|
+
asset(assetPath) {
|
|
390
|
+
return getAssetManager().asset(assetPath);
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Generate CSS link tag
|
|
395
|
+
* @param {string} href - CSS file path
|
|
396
|
+
* @param {Object} attrs - Additional attributes
|
|
397
|
+
* @returns {string}
|
|
398
|
+
*/
|
|
399
|
+
css(href, attrs = {}) {
|
|
400
|
+
return getAssetManager().css(href, attrs);
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Generate JS script tag
|
|
405
|
+
* @param {string} src - JS file path
|
|
406
|
+
* @param {Object} attrs - Additional attributes
|
|
407
|
+
* @returns {string}
|
|
408
|
+
*/
|
|
409
|
+
js(src, attrs = {}) {
|
|
410
|
+
return getAssetManager().js(src, attrs);
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Generate image tag
|
|
415
|
+
* @param {string} src - Image file path
|
|
416
|
+
* @param {string} alt - Alt text
|
|
417
|
+
* @param {Object} attrs - Additional attributes
|
|
418
|
+
* @returns {string}
|
|
419
|
+
*/
|
|
420
|
+
img(src, alt = '', attrs = {}) {
|
|
421
|
+
return getAssetManager().img(src, alt, attrs);
|
|
220
422
|
}
|
|
221
423
|
};
|
|
222
424
|
}
|
|
@@ -270,6 +472,9 @@ const utils = {
|
|
|
270
472
|
|
|
271
473
|
module.exports = {
|
|
272
474
|
createHelpers,
|
|
273
|
-
utils
|
|
475
|
+
utils,
|
|
476
|
+
AssetManager,
|
|
477
|
+
configureAssets,
|
|
478
|
+
getAssetManager
|
|
274
479
|
};
|
|
275
480
|
|
package/src/server.js
CHANGED
|
@@ -4,9 +4,109 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const express = require('express');
|
|
7
|
+
const helmet = require('helmet');
|
|
7
8
|
const nunjucks = require('nunjucks');
|
|
8
|
-
|
|
9
|
+
|
|
9
10
|
const { mountPages } = require('./file-router');
|
|
11
|
+
const { configureAssets } = require('./helpers');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get default Helmet configuration
|
|
15
|
+
* @param {boolean} isDev - Whether in development mode
|
|
16
|
+
* @returns {Object} Helmet configuration
|
|
17
|
+
*/
|
|
18
|
+
function getDefaultHelmetConfig(isDev) {
|
|
19
|
+
return {
|
|
20
|
+
// Disable CSP in development for easier development (Nunjucks hot reload, etc.)
|
|
21
|
+
contentSecurityPolicy: isDev ? false : {
|
|
22
|
+
directives: {
|
|
23
|
+
defaultSrc: ["'self'"],
|
|
24
|
+
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for Tailwind
|
|
25
|
+
scriptSrc: ["'self'"],
|
|
26
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
27
|
+
fontSrc: ["'self'", "data:"],
|
|
28
|
+
connectSrc: ["'self'"],
|
|
29
|
+
frameSrc: ["'none'"],
|
|
30
|
+
objectSrc: ["'none'"],
|
|
31
|
+
upgradeInsecureRequests: []
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
// Other security headers
|
|
35
|
+
crossOriginEmbedderPolicy: false, // Disable for better compatibility
|
|
36
|
+
crossOriginOpenerPolicy: { policy: 'same-origin' },
|
|
37
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
38
|
+
dnsPrefetchControl: true,
|
|
39
|
+
frameguard: { action: 'deny' },
|
|
40
|
+
hidePoweredBy: true,
|
|
41
|
+
hsts: {
|
|
42
|
+
maxAge: 31536000,
|
|
43
|
+
includeSubDomains: true,
|
|
44
|
+
preload: true
|
|
45
|
+
},
|
|
46
|
+
ieNoOpen: true,
|
|
47
|
+
noSniff: true,
|
|
48
|
+
originAgentCluster: true,
|
|
49
|
+
permittedCrossDomainPolicies: false,
|
|
50
|
+
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
51
|
+
xssFilter: true
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Default 404 page HTML
|
|
57
|
+
*/
|
|
58
|
+
function default404Html() {
|
|
59
|
+
return `<!DOCTYPE html>
|
|
60
|
+
<html>
|
|
61
|
+
<head>
|
|
62
|
+
<title>404 - Not Found</title>
|
|
63
|
+
<style>
|
|
64
|
+
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
65
|
+
.container { text-align: center; }
|
|
66
|
+
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
67
|
+
p { color: #666; margin: 1rem 0; }
|
|
68
|
+
a { color: #0066cc; text-decoration: none; }
|
|
69
|
+
a:hover { text-decoration: underline; }
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<div class="container">
|
|
74
|
+
<h1>404</h1>
|
|
75
|
+
<p>Page not found</p>
|
|
76
|
+
<a href="/">← Back to Home</a>
|
|
77
|
+
</div>
|
|
78
|
+
</body>
|
|
79
|
+
</html>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default 500 page HTML
|
|
84
|
+
*/
|
|
85
|
+
function default500Html(err, isDev) {
|
|
86
|
+
return `<!DOCTYPE html>
|
|
87
|
+
<html>
|
|
88
|
+
<head>
|
|
89
|
+
<title>500 - Server Error</title>
|
|
90
|
+
<style>
|
|
91
|
+
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
92
|
+
.container { text-align: center; }
|
|
93
|
+
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
94
|
+
p { color: #666; margin: 1rem 0; }
|
|
95
|
+
pre { background: #fff; padding: 1rem; border-radius: 4px; text-align: left; overflow: auto; max-width: 600px; }
|
|
96
|
+
a { color: #0066cc; text-decoration: none; }
|
|
97
|
+
a:hover { text-decoration: underline; }
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<div class="container">
|
|
102
|
+
<h1>500</h1>
|
|
103
|
+
<p>Internal Server Error</p>
|
|
104
|
+
${isDev && err ? `<pre>${err.stack || err.message}</pre>` : ''}
|
|
105
|
+
<a href="/">← Back to Home</a>
|
|
106
|
+
</div>
|
|
107
|
+
</body>
|
|
108
|
+
</html>`;
|
|
109
|
+
}
|
|
10
110
|
|
|
11
111
|
/**
|
|
12
112
|
* Create and configure the Express app
|
|
@@ -15,6 +115,15 @@ const { mountPages } = require('./file-router');
|
|
|
15
115
|
* @param {string} options.viewsDir - Path to views directory
|
|
16
116
|
* @param {string} options.publicDir - Path to public/static directory
|
|
17
117
|
* @param {boolean} options.logging - Enable request logging (default: isDev)
|
|
118
|
+
* @param {Object|boolean} options.helmet - Helmet configuration (default: auto-configured, false to disable)
|
|
119
|
+
* @param {Object} options.middlewares - Named middleware registry for route configs
|
|
120
|
+
* @param {Object} options.assets - Asset manager configuration
|
|
121
|
+
* @param {string} options.assets.manifestPath - Path to asset manifest file (Vite, Webpack)
|
|
122
|
+
* @param {string} options.assets.version - Asset version for cache busting
|
|
123
|
+
* @param {string} options.assets.prefix - URL prefix for assets
|
|
124
|
+
* @param {Object} options.errorPages - Custom error page handlers
|
|
125
|
+
* @param {Function|string} options.errorPages.notFound - Custom 404 handler or template path
|
|
126
|
+
* @param {Function|string} options.errorPages.serverError - Custom 500 handler or template path
|
|
18
127
|
* @returns {Object} { app, nunjucksEnv }
|
|
19
128
|
*/
|
|
20
129
|
function createApp(options = {}) {
|
|
@@ -26,15 +135,35 @@ function createApp(options = {}) {
|
|
|
26
135
|
pagesDir,
|
|
27
136
|
viewsDir,
|
|
28
137
|
publicDir,
|
|
29
|
-
logging = isDev && !isTest
|
|
138
|
+
logging = isDev && !isTest,
|
|
139
|
+
helmet: helmetConfig,
|
|
140
|
+
middlewares = {},
|
|
141
|
+
assets: assetsConfig = {},
|
|
142
|
+
errorPages = {}
|
|
30
143
|
} = options;
|
|
31
144
|
|
|
145
|
+
// Configure asset manager
|
|
146
|
+
configureAssets({
|
|
147
|
+
publicDir: publicDir || 'public',
|
|
148
|
+
...assetsConfig
|
|
149
|
+
});
|
|
150
|
+
|
|
32
151
|
if (!pagesDir) {
|
|
33
152
|
throw new Error('pagesDir is required');
|
|
34
153
|
}
|
|
35
154
|
|
|
36
155
|
const app = express();
|
|
37
156
|
|
|
157
|
+
// Security headers with Helmet
|
|
158
|
+
if (helmetConfig !== false) {
|
|
159
|
+
const defaultConfig = getDefaultHelmetConfig(isDev);
|
|
160
|
+
const finalConfig = helmetConfig === undefined || helmetConfig === true
|
|
161
|
+
? defaultConfig
|
|
162
|
+
: { ...defaultConfig, ...helmetConfig };
|
|
163
|
+
|
|
164
|
+
app.use(helmet(finalConfig));
|
|
165
|
+
}
|
|
166
|
+
|
|
38
167
|
// Trust proxy (for correct req.ip, req.protocol behind reverse proxy)
|
|
39
168
|
app.set('trust proxy', 1);
|
|
40
169
|
|
|
@@ -103,73 +232,73 @@ function createApp(options = {}) {
|
|
|
103
232
|
mountPages(app, {
|
|
104
233
|
pagesDir,
|
|
105
234
|
nunjucks: nunjucksEnv,
|
|
235
|
+
middlewares,
|
|
106
236
|
silent: isTest
|
|
107
237
|
});
|
|
108
238
|
|
|
109
239
|
// 404 handler
|
|
110
240
|
app.use((req, res) => {
|
|
111
241
|
res.status(404);
|
|
242
|
+
|
|
243
|
+
// Custom handler function
|
|
244
|
+
if (typeof errorPages.notFound === 'function') {
|
|
245
|
+
return errorPages.notFound(req, res);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Custom template
|
|
249
|
+
if (typeof errorPages.notFound === 'string') {
|
|
250
|
+
try {
|
|
251
|
+
const html = nunjucksEnv.render(errorPages.notFound, {
|
|
252
|
+
url: req.url,
|
|
253
|
+
method: req.method
|
|
254
|
+
});
|
|
255
|
+
return res.send(html);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.error('Error rendering 404 template:', e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Default response
|
|
112
262
|
if (req.accepts('html')) {
|
|
113
|
-
res.send(
|
|
114
|
-
<!DOCTYPE html>
|
|
115
|
-
<html>
|
|
116
|
-
<head>
|
|
117
|
-
<title>404 - Not Found</title>
|
|
118
|
-
<style>
|
|
119
|
-
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
120
|
-
.container { text-align: center; }
|
|
121
|
-
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
122
|
-
p { color: #666; margin: 1rem 0; }
|
|
123
|
-
a { color: #0066cc; text-decoration: none; }
|
|
124
|
-
a:hover { text-decoration: underline; }
|
|
125
|
-
</style>
|
|
126
|
-
</head>
|
|
127
|
-
<body>
|
|
128
|
-
<div class="container">
|
|
129
|
-
<h1>404</h1>
|
|
130
|
-
<p>Page not found</p>
|
|
131
|
-
<a href="/">← Back to Home</a>
|
|
132
|
-
</div>
|
|
133
|
-
</body>
|
|
134
|
-
</html>
|
|
135
|
-
`);
|
|
263
|
+
res.send(default404Html());
|
|
136
264
|
} else {
|
|
137
|
-
res.json({ error: 'Not Found' });
|
|
265
|
+
res.json({ error: 'Not Found', status: 404 });
|
|
138
266
|
}
|
|
139
267
|
});
|
|
140
268
|
|
|
141
269
|
// Error handler
|
|
142
270
|
app.use((err, req, res, next) => {
|
|
143
271
|
console.error('Server error:', err);
|
|
144
|
-
res.status(500);
|
|
272
|
+
res.status(err.status || 500);
|
|
273
|
+
|
|
274
|
+
// Custom handler function
|
|
275
|
+
if (typeof errorPages.serverError === 'function') {
|
|
276
|
+
return errorPages.serverError(err, req, res);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Custom template
|
|
280
|
+
if (typeof errorPages.serverError === 'string') {
|
|
281
|
+
try {
|
|
282
|
+
const html = nunjucksEnv.render(errorPages.serverError, {
|
|
283
|
+
error: isDev ? err : { message: 'Internal Server Error' },
|
|
284
|
+
status: err.status || 500,
|
|
285
|
+
isDev
|
|
286
|
+
});
|
|
287
|
+
return res.send(html);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
console.error('Error rendering 500 template:', e);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Default response
|
|
145
294
|
if (req.accepts('html')) {
|
|
146
|
-
res.send(
|
|
147
|
-
<!DOCTYPE html>
|
|
148
|
-
<html>
|
|
149
|
-
<head>
|
|
150
|
-
<title>500 - Server Error</title>
|
|
151
|
-
<style>
|
|
152
|
-
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
153
|
-
.container { text-align: center; }
|
|
154
|
-
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
155
|
-
p { color: #666; margin: 1rem 0; }
|
|
156
|
-
pre { background: #fff; padding: 1rem; border-radius: 4px; text-align: left; overflow: auto; max-width: 600px; }
|
|
157
|
-
a { color: #0066cc; text-decoration: none; }
|
|
158
|
-
a:hover { text-decoration: underline; }
|
|
159
|
-
</style>
|
|
160
|
-
</head>
|
|
161
|
-
<body>
|
|
162
|
-
<div class="container">
|
|
163
|
-
<h1>500</h1>
|
|
164
|
-
<p>Internal Server Error</p>
|
|
165
|
-
${isDev ? `<pre>${err.stack || err.message}</pre>` : ''}
|
|
166
|
-
<a href="/">← Back to Home</a>
|
|
167
|
-
</div>
|
|
168
|
-
</body>
|
|
169
|
-
</html>
|
|
170
|
-
`);
|
|
295
|
+
res.send(default500Html(err, isDev));
|
|
171
296
|
} else {
|
|
172
|
-
res.json({
|
|
297
|
+
res.json({
|
|
298
|
+
error: 'Internal Server Error',
|
|
299
|
+
status: err.status || 500,
|
|
300
|
+
...(isDev && { message: err.message, stack: err.stack })
|
|
301
|
+
});
|
|
173
302
|
}
|
|
174
303
|
});
|
|
175
304
|
|