webspresso 0.0.89 → 0.0.90

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.
@@ -243,35 +243,59 @@
243
243
  });
244
244
  }
245
245
 
246
- function sanitizeClientHtml(htmlStr) {
247
- if (!htmlStr) return '';
246
+ function setSanitizedHtmlContent(el, htmlStr) {
247
+ if (!htmlStr) {
248
+ el.textContent = '';
249
+ return;
250
+ }
248
251
  try {
249
252
  var parser = new DOMParser();
250
253
  var doc = parser.parseFromString(htmlStr, 'text/html');
251
- var forbiddenTags = ['script', 'iframe', 'object', 'embed', 'applet', 'base'];
252
- forbiddenTags.forEach(function (tag) {
253
- var nodes = doc.querySelectorAll(tag);
254
- for (var i = 0; i < nodes.length; i++) {
255
- nodes[i].parentNode.removeChild(nodes[i]);
256
- }
257
- });
258
- var allNodes = doc.querySelectorAll('*');
259
- for (var i = 0; i < allNodes.length; i++) {
254
+ var allowedTags = {
255
+ p: 1, br: 1, b: 1, i: 1, strong: 1, em: 1, u: 1, s: 1, strike: 1,
256
+ h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1,
257
+ blockquote: 1, pre: 1, code: 1, a: 1, span: 1, div: 1, hr: 1,
258
+ table: 1, thead: 1, tbody: 1, tr: 1, th: 1, td: 1, img: 1
259
+ };
260
+ var allowedAttrs = {
261
+ href: 1, title: 1, target: 1, rel: 1, class: 1, id: 1, src: 1, alt: 1, width: 1, height: 1
262
+ };
263
+ var allNodes = doc.body.querySelectorAll('*');
264
+ for (var i = allNodes.length - 1; i >= 0; i--) {
260
265
  var node = allNodes[i];
266
+ var tag = node.tagName.toLowerCase();
267
+ if (!allowedTags[tag]) {
268
+ if (node.parentNode) {
269
+ node.parentNode.removeChild(node);
270
+ } else {
271
+ node.remove();
272
+ }
273
+ continue;
274
+ }
261
275
  var attrs = Array.prototype.slice.call(node.attributes);
262
276
  for (var j = 0; j < attrs.length; j++) {
263
277
  var attrName = attrs[j].name.toLowerCase();
264
- var attrVal = attrs[j].value.toLowerCase().trim();
265
- if (attrName.indexOf('on') === 0) {
278
+ var attrVal = attrs[j].value.toLowerCase().replace(/[\x00-\x20\s]+/g, '').trim();
279
+ if (attrName.indexOf('on') === 0 || !allowedAttrs[attrName]) {
266
280
  node.removeAttribute(attrs[j].name);
267
- } else if ((attrName === 'href' || attrName === 'src') && attrVal.indexOf('javascript:') === 0) {
281
+ } else if ((attrName === 'href' || attrName === 'src' || attrName === 'action' || attrName === 'formaction' || attrName === 'xlink:href') &&
282
+ /^(?:javascript|data|vbscript):/i.test(attrVal)) {
268
283
  node.removeAttribute(attrs[j].name);
284
+ } else if (tag === 'a' && node.getAttribute('target') === '_blank') {
285
+ node.setAttribute('rel', 'noopener noreferrer');
269
286
  }
270
287
  }
271
288
  }
272
- return doc.body.innerHTML;
289
+
290
+ // Transfer DOM nodes directly to target element
291
+ while (el.firstChild) {
292
+ el.removeChild(el.firstChild);
293
+ }
294
+ while (doc.body.firstChild) {
295
+ el.appendChild(doc.body.firstChild);
296
+ }
273
297
  } catch (e) {
274
- return '';
298
+ el.textContent = htmlStr;
275
299
  }
276
300
  }
277
301
 
@@ -282,7 +306,7 @@
282
306
  var val = data[field];
283
307
  var isHtml = el.getAttribute('data-ws-content-html') === 'true';
284
308
  if (isHtml) {
285
- el.innerHTML = sanitizeClientHtml(val);
309
+ setSanitizedHtmlContent(el, val);
286
310
  } else {
287
311
  el.textContent = val != null ? val : '';
288
312
  }
package/plugins/cors.js CHANGED
@@ -47,9 +47,12 @@ function createCorsMiddleware(options = {}) {
47
47
 
48
48
  function isOriginAllowed(reqOrigin, callback) {
49
49
  if (!config.origin || config.origin === '*') {
50
- return callback(null, config.origin);
50
+ if (config.credentials) {
51
+ return callback(null, false);
52
+ }
53
+ return callback(null, '*');
51
54
  }
52
- if (!reqOrigin) {
55
+ if (!reqOrigin || reqOrigin === 'null') {
53
56
  return callback(null, false);
54
57
  }
55
58
  if (typeof config.origin === 'string') {
@@ -72,6 +75,9 @@ function createCorsMiddleware(options = {}) {
72
75
  if (typeof config.origin === 'function') {
73
76
  return config.origin(reqOrigin, callback);
74
77
  }
78
+ if (config.origin === true) {
79
+ return callback(null, reqOrigin);
80
+ }
75
81
  return callback(null, false);
76
82
  }
77
83
 
@@ -96,15 +102,13 @@ function createCorsMiddleware(options = {}) {
96
102
  }
97
103
 
98
104
  if (allowedOrigin) {
99
- if (allowedOrigin === '*' && config.credentials) {
100
- res.setHeader('Access-Control-Allow-Origin', reqOrigin || '*');
101
- } else {
102
- res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
105
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
106
+ if (allowedOrigin !== '*') {
107
+ res.setHeader('Vary', 'Origin');
103
108
  }
104
- res.setHeader('Vary', 'Origin');
105
109
  }
106
110
 
107
- if (config.credentials) {
111
+ if (config.credentials && allowedOrigin && allowedOrigin !== '*') {
108
112
  res.setHeader('Access-Control-Allow-Credentials', 'true');
109
113
  }
110
114
 
@@ -158,11 +158,11 @@ function csrfPlugin(options = {}) {
158
158
  }
159
159
 
160
160
  // 1. Retrieve or generate secret token
161
- let secret;
161
+ let csrfTokenValue;
162
162
  if (useCookie) {
163
- secret = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
164
- if (!secret) {
165
- secret = crypto.randomBytes(32).toString('hex');
163
+ csrfTokenValue = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
164
+ if (!csrfTokenValue) {
165
+ csrfTokenValue = crypto.randomBytes(32).toString('hex');
166
166
  const isHttps = req.secure || req.headers?.['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true';
167
167
  const effectiveCookieOptions = {
168
168
  httpOnly: true,
@@ -170,18 +170,18 @@ function csrfPlugin(options = {}) {
170
170
  ...cookieOptions,
171
171
  secure: cookieOpts.secure !== undefined ? cookieOpts.secure : isHttps,
172
172
  };
173
- res.cookie(cookieKey, secret, effectiveCookieOptions);
173
+ res.cookie(cookieKey, csrfTokenValue, effectiveCookieOptions);
174
174
  }
175
175
  } else {
176
176
  // Session based
177
177
  if (!req.session[sessionKey]) {
178
178
  req.session[sessionKey] = crypto.randomBytes(32).toString('hex');
179
179
  }
180
- secret = req.session[sessionKey];
180
+ csrfTokenValue = req.session[sessionKey];
181
181
  }
182
182
 
183
183
  // Define standard req.csrfToken()
184
- req.csrfToken = () => secret;
184
+ req.csrfToken = () => csrfTokenValue;
185
185
 
186
186
  // 2. Perform CSRF verification for mutating HTTP methods
187
187
  const method = req.method ? req.method.toUpperCase() : 'GET';
@@ -196,7 +196,7 @@ function csrfPlugin(options = {}) {
196
196
  req.headers['x-xsrf-token']
197
197
  );
198
198
 
199
- if (!submitted || !safeCompare(submitted, secret)) {
199
+ if (!submitted || !safeCompare(submitted, csrfTokenValue)) {
200
200
  res.status(errorStatus);
201
201
  if (req.accepts('html') && !req.path.startsWith('/api')) {
202
202
  return res.send(`<h1>${errorStatus} Forbidden</h1><p>${errorMessage}</p>`);
@@ -432,16 +432,14 @@ function loadI18n(pagesDir, routeDir, locale) {
432
432
  return data;
433
433
  }
434
434
 
435
- /**
436
- * Create a translation function
437
- * @param {Object} translations - Translation object
438
- * @returns {Function} Translation function t(key)
439
- */
440
- function createTranslator(translations) {
441
- return function t(key, params = EMPTY_OBJECT) {
442
- let value = translations[key];
435
+ function createTranslator(translations = {}) {
436
+ return function t(key, params = EMPTY_OBJECT, defaultVal = null) {
437
+ const defaultValue = typeof params === 'string' ? params : defaultVal;
438
+ const interpolationParams = typeof params === 'object' && params !== null ? params : EMPTY_OBJECT;
439
+
440
+ let value = translations ? translations[key] : undefined;
443
441
 
444
- if (value === undefined) {
442
+ if (value === undefined && translations) {
445
443
  // Try nested key lookup (e.g., "meta.title")
446
444
  const parts = key.split('.');
447
445
  value = translations;
@@ -456,12 +454,12 @@ function createTranslator(translations) {
456
454
  }
457
455
 
458
456
  if (value === undefined) {
459
- return key; // Return key if translation not found
457
+ return defaultValue !== null && defaultValue !== undefined ? defaultValue : key;
460
458
  }
461
459
 
462
460
  // Replace params like {{name}} in the translation
463
- if (typeof value === 'string' && params && params !== EMPTY_OBJECT && Object.keys(params).length > 0) {
464
- for (const [paramKey, paramValue] of Object.entries(params)) {
461
+ if (typeof value === 'string' && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
462
+ for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
465
463
  let regex = paramRegexCache.get(paramKey);
466
464
  if (regex === undefined) {
467
465
  const escaped = escapeRegExp(paramKey);
@@ -814,6 +812,18 @@ function mountPages(app, options) {
814
812
  sortRoutes(apiRoutes);
815
813
  sortRoutes(ssrRoutes);
816
814
 
815
+ // Check for duplicate route bindings
816
+ const registeredRouteMap = new Map();
817
+ for (const r of [...apiRoutes, ...ssrRoutes]) {
818
+ const key = `${(r.method || 'GET').toUpperCase()} ${r.routePath}`;
819
+ if (registeredRouteMap.has(key)) {
820
+ const existing = registeredRouteMap.get(key);
821
+ console.warn(`[webspresso] WARNING: Duplicate route detected: ${key} in "${r.file}" conflicts with existing route in "${existing.file}".`);
822
+ } else {
823
+ registeredRouteMap.set(key, r);
824
+ }
825
+ }
826
+
817
827
  const apiStatic = apiRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier === 0);
818
828
  const apiDynamic = apiRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier !== 0);
819
829
  const ssrStatic = ssrRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier === 0);
@@ -1002,7 +1012,9 @@ function mountPages(app, options) {
1002
1012
  // Run load function
1003
1013
  if (config?.load && typeof config.load === 'function') {
1004
1014
  const loadData = await config.load(req, ctx);
1005
- ctx.data = { ...ctx.data, ...loadData };
1015
+ if (loadData && typeof loadData === 'object' && !Array.isArray(loadData)) {
1016
+ ctx.data = { ...ctx.data, ...loadData };
1017
+ }
1006
1018
  }
1007
1019
 
1008
1020
  // Execute hooks: afterLoad
@@ -1012,7 +1024,9 @@ function mountPages(app, options) {
1012
1024
  // Run meta function
1013
1025
  if (config?.meta && typeof config.meta === 'function') {
1014
1026
  const metaData = await config.meta(req, ctx);
1015
- ctx.meta = { ...ctx.meta, ...metaData };
1027
+ if (metaData && typeof metaData === 'object' && !Array.isArray(metaData)) {
1028
+ ctx.meta = { ...ctx.meta, ...metaData };
1029
+ }
1016
1030
  }
1017
1031
 
1018
1032
  // Execute hooks: beforeRender
@@ -1058,6 +1072,9 @@ function mountPages(app, options) {
1058
1072
  await executeHook(globalHooks, 'afterRender', ctx);
1059
1073
  await executeHook(routeHooks, 'afterRender', ctx);
1060
1074
 
1075
+ if (route.routePath === '/404' || route.file === '404.njk') {
1076
+ res.status(404);
1077
+ }
1061
1078
  res.send(ctx.html);
1062
1079
  } catch (err) {
1063
1080
  console.error(`SSR error ${route.routePath}:`, err);
package/src/helpers.js CHANGED
@@ -236,7 +236,7 @@ function createHelpers(ctx) {
236
236
  * @returns {*}
237
237
  */
238
238
  q(name, def = null) {
239
- return req.query[name] !== undefined ? req.query[name] : def;
239
+ return req && req.query && req.query[name] !== undefined ? req.query[name] : def;
240
240
  },
241
241
 
242
242
  /**
@@ -246,7 +246,7 @@ function createHelpers(ctx) {
246
246
  * @returns {*}
247
247
  */
248
248
  param(name, def = null) {
249
- return req.params[name] !== undefined ? req.params[name] : def;
249
+ return req && req.params && req.params[name] !== undefined ? req.params[name] : def;
250
250
  },
251
251
 
252
252
  /**
@@ -256,7 +256,8 @@ function createHelpers(ctx) {
256
256
  * @returns {*}
257
257
  */
258
258
  hdr(name, def = null) {
259
- return req.get(name) || def;
259
+ if (!req) return def;
260
+ return (typeof req.get === 'function' ? req.get(name) : (req.headers && req.headers[name.toLowerCase()])) || def;
260
261
  },
261
262
 
262
263
  /**
@@ -202,7 +202,10 @@ class PluginManager {
202
202
  continue;
203
203
  }
204
204
  pluginMap.set(plugin.name, plugin);
205
- graph.set(plugin.name, new Set(Object.keys(plugin.dependencies || {})));
205
+ const depNames = Array.isArray(plugin.dependencies)
206
+ ? plugin.dependencies
207
+ : Object.keys(plugin.dependencies || {});
208
+ graph.set(plugin.name, new Set(depNames));
206
209
  }
207
210
 
208
211
  // Topological sort (Kahn's algorithm)
@@ -339,7 +342,11 @@ class PluginManager {
339
342
  _validateDependencies(plugin) {
340
343
  if (!plugin.dependencies) return;
341
344
 
342
- for (const [depName, versionRange] of Object.entries(plugin.dependencies)) {
345
+ const depEntries = Array.isArray(plugin.dependencies)
346
+ ? plugin.dependencies.map(d => [d, '*'])
347
+ : Object.entries(plugin.dependencies);
348
+
349
+ for (const [depName, versionRange] of depEntries) {
343
350
  const dep = this.plugins.get(depName);
344
351
 
345
352
  if (!dep) {
@@ -350,7 +357,7 @@ class PluginManager {
350
357
  continue;
351
358
  }
352
359
 
353
- if (dep.version && !semver.satisfies(dep.version, versionRange)) {
360
+ if (dep.version && versionRange !== '*' && !semver.satisfies(dep.version, versionRange)) {
354
361
  console.warn(
355
362
  `[plugin-manager] Plugin "${plugin.name}" requires "${depName}@${versionRange}" ` +
356
363
  `but found v${dep.version}. Plugin may not work correctly.`
package/src/server.js CHANGED
@@ -3,6 +3,9 @@
3
3
  * Express + Nunjucks SSR server with file-based routing
4
4
  */
5
5
 
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { AsyncLocalStorage } = require('async_hooks');
6
9
  const express = require('express');
7
10
  const helmet = require('helmet');
8
11
  const nunjucks = require('nunjucks');
@@ -11,10 +14,103 @@ const timeout = require('connect-timeout');
11
14
  const { setAppContext } = require('./app-context');
12
15
  const { mountClientRuntime } = require('./client-runtime/mount');
13
16
  const { resolveClientRuntime } = require('./client-runtime/resolve');
14
- const { mountPages, detectLocale } = require('./file-router');
17
+ const { mountPages, detectLocale, loadI18n, createTranslator } = require('./file-router');
15
18
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
16
19
  const { createPluginManager } = require('./plugin-manager');
17
20
 
21
+ // Async storage for tracking template render call stacks (circular extends guard)
22
+ const renderStackStorage = new AsyncLocalStorage();
23
+
24
+ /**
25
+ * Configure Nunjucks environment with circular extends guard and loader protection
26
+ * @param {string|string[]} templateDirs - Template directory paths
27
+ * @param {Object} options - Nunjucks options
28
+ * @returns {nunjucks.Environment}
29
+ */
30
+ function configureSafeNunjucks(templateDirs, options = {}) {
31
+ const env = nunjucks.configure(templateDirs, options);
32
+ const origGetTemplate = env.getTemplate.bind(env);
33
+ const origRender = env.render.bind(env);
34
+ const origRenderString = env.renderString.bind(env);
35
+
36
+ function wrapTemplateRoot(tmpl, name, store) {
37
+ if (!tmpl || tmpl._safeWrapped) return;
38
+ tmpl._safeWrapped = true;
39
+ const origRoot = tmpl.rootRenderFunc;
40
+ if (typeof origRoot !== 'function') return;
41
+
42
+ tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
43
+ const normalizedName = String(name || tmpl.path || 'anonymous');
44
+ store.stack.push(normalizedName);
45
+ let finished = false;
46
+ const done = (err, out) => {
47
+ if (!finished) {
48
+ finished = true;
49
+ const idx = store.stack.lastIndexOf(normalizedName);
50
+ if (idx !== -1) store.stack.splice(idx, 1);
51
+ }
52
+ renderCb(err, out);
53
+ };
54
+ try {
55
+ return origRoot.call(this, e, context, frame, runtime, done);
56
+ } catch (ex) {
57
+ const idx = store.stack.lastIndexOf(normalizedName);
58
+ if (idx !== -1) store.stack.splice(idx, 1);
59
+ throw ex;
60
+ }
61
+ };
62
+ }
63
+
64
+ env.getTemplate = function(name, eagerCompile, parentName, ignoreMissing, cb) {
65
+ if (typeof parentName === 'function') {
66
+ cb = parentName;
67
+ parentName = null;
68
+ }
69
+ if (typeof eagerCompile === 'function') {
70
+ cb = eagerCompile;
71
+ eagerCompile = false;
72
+ }
73
+
74
+ const store = renderStackStorage.getStore();
75
+ if (store && name) {
76
+ const normalizedName = String(name);
77
+ if (store.stack.includes(normalizedName)) {
78
+ const cycle = [...store.stack, normalizedName].join(' -> ');
79
+ const err = new Error(`Circular template extension detected: ${cycle}`);
80
+ if (typeof cb === 'function') return cb(err);
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
86
+ if (tmpl && store) {
87
+ wrapTemplateRoot(tmpl, name, store);
88
+ }
89
+ cb(err, tmpl);
90
+ } : undefined;
91
+
92
+ const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
93
+ if (res && store) {
94
+ wrapTemplateRoot(res, name, store);
95
+ }
96
+ return res;
97
+ };
98
+
99
+ env.render = function(name, ctx, cb) {
100
+ return renderStackStorage.run({ stack: [] }, () => {
101
+ return origRender(name, ctx, cb);
102
+ });
103
+ };
104
+
105
+ env.renderString = function(src, ctx, opts, cb) {
106
+ return renderStackStorage.run({ stack: [] }, () => {
107
+ return origRenderString(src, ctx, opts, cb);
108
+ });
109
+ };
110
+
111
+ return env;
112
+ }
113
+
18
114
  /**
19
115
  * Get default Helmet configuration
20
116
  * @param {boolean} isDev - Whether in development mode
@@ -58,13 +154,27 @@ function getDefaultHelmetConfig(isDev) {
58
154
  }
59
155
 
60
156
  /**
61
- * Use JSON error responses for `pages/api/*` routes and clients that do not prefer HTML.
157
+ * Use JSON error responses for `pages/api/*` routes, XHR requests, and clients that prefer JSON.
62
158
  * @param {import('express').Request} req
63
159
  * @returns {boolean}
64
160
  */
65
161
  function preferJsonErrorResponse(req) {
162
+ if (!req) return false;
66
163
  if (req.path && req.path.startsWith('/api')) return true;
67
- return !req.accepts('html');
164
+ if (req.xhr) return true;
165
+
166
+ if (typeof req.accepts === 'function') {
167
+ const preferred = req.accepts(['html', 'json']);
168
+ if (preferred === 'json') return true;
169
+ if (preferred === 'html') return false;
170
+ return !req.accepts('html');
171
+ }
172
+
173
+ const accept = req.headers && req.headers.accept;
174
+ if (accept && accept.includes('application/json') && !accept.includes('text/html')) {
175
+ return true;
176
+ }
177
+ return false;
68
178
  }
69
179
 
70
180
  /**
@@ -405,10 +515,10 @@ function createApp(options = {}) {
405
515
 
406
516
  mountClientRuntime(app, clientRuntime);
407
517
 
408
- // Configure Nunjucks
409
- const templateDirs = viewsDir ? [pagesDir, viewsDir] : [pagesDir];
518
+ // Configure Nunjucks with viewsDir priority and circular extension guard
519
+ const templateDirs = viewsDir ? [viewsDir, pagesDir] : [pagesDir];
410
520
 
411
- const nunjucksEnv = nunjucks.configure(templateDirs, {
521
+ const nunjucksEnv = configureSafeNunjucks(templateDirs, {
412
522
  autoescape: true,
413
523
  express: app,
414
524
  watch: isDev && !isTest,
@@ -528,10 +638,15 @@ function createApp(options = {}) {
528
638
  // Merge plugin helpers
529
639
  const pluginHelpers = pluginManager.getHelpers();
530
640
  Object.assign(fsy, pluginHelpers);
641
+
642
+ // Provide safe i18n translator helper
643
+ const translations = loadI18n(pagesDir, '', locale);
644
+ const t = createTranslator(translations);
531
645
 
532
646
  return {
533
647
  fsy,
534
648
  locale,
649
+ t,
535
650
  isDev,
536
651
  url: req.url,
537
652
  method: req.method,
@@ -539,20 +654,63 @@ function createApp(options = {}) {
539
654
  };
540
655
  }
541
656
 
657
+ // Pre-resolve error templates and loaders once at startup to avoid runtime synchronous fs operations
658
+ const resolved404Template = typeof errorPages.notFound === 'string'
659
+ ? errorPages.notFound
660
+ : ((viewsDir && fs.existsSync(path.join(viewsDir, '404.njk'))) || fs.existsSync(path.join(pagesDir, '404.njk')))
661
+ ? '404.njk'
662
+ : null;
663
+
664
+ const config404Path = path.join(pagesDir, '404.js');
665
+ const has404DataLoader = fs.existsSync(config404Path);
666
+
667
+ const resolved500Template = typeof errorPages.serverError === 'string'
668
+ ? errorPages.serverError
669
+ : ((viewsDir && fs.existsSync(path.join(viewsDir, '500.njk'))) || fs.existsSync(path.join(pagesDir, '500.njk')))
670
+ ? '500.njk'
671
+ : null;
672
+
673
+ const resolved503Template = typeof errorPages.timeout === 'string'
674
+ ? errorPages.timeout
675
+ : ((viewsDir && fs.existsSync(path.join(viewsDir, '503.njk'))) || fs.existsSync(path.join(pagesDir, '503.njk')))
676
+ ? '503.njk'
677
+ : null;
678
+
542
679
  // 404 handler
543
- app.use((req, res) => {
680
+ app.use(async (req, res) => {
544
681
  res.status(404);
545
682
  const ctx = createErrorContext(req);
546
683
 
547
- // Custom handler function
684
+ // 1. Custom handler function
548
685
  if (typeof errorPages.notFound === 'function') {
549
686
  return errorPages.notFound(req, res, ctx);
550
687
  }
551
688
 
552
- // Custom template
553
- if (typeof errorPages.notFound === 'string') {
689
+ // 2. Custom or auto-discovered 404 template (pre-resolved at startup)
690
+ const notFoundTemplate = resolved404Template;
691
+
692
+ if (notFoundTemplate && !preferJsonErrorResponse(req)) {
554
693
  try {
555
- const html = nunjucksEnv.render(errorPages.notFound, ctx);
694
+ // If pages/404.js exists, execute load() data loader
695
+ if (has404DataLoader) {
696
+ try {
697
+ if (isDev && require.cache[require.resolve(config404Path)]) {
698
+ delete require.cache[require.resolve(config404Path)];
699
+ }
700
+ const config404 = require(config404Path);
701
+ const loadFn = typeof config404 === 'function' ? config404 : config404.load;
702
+ if (typeof loadFn === 'function') {
703
+ const loadData = await loadFn({ req, res, db: ctx.db || null, ctx });
704
+ if (loadData && typeof loadData === 'object') {
705
+ Object.assign(ctx, loadData);
706
+ }
707
+ }
708
+ } catch (loadErr) {
709
+ console.error('Error executing 404.js load():', loadErr);
710
+ }
711
+ }
712
+
713
+ const html = nunjucksEnv.render(notFoundTemplate, ctx);
556
714
  return res.send(html);
557
715
  } catch (e) {
558
716
  console.error('Error rendering 404 template:', e);
@@ -560,7 +718,7 @@ function createApp(options = {}) {
560
718
  }
561
719
 
562
720
  // Default response
563
- if (req.accepts('html')) {
721
+ if (!preferJsonErrorResponse(req)) {
564
722
  res.send(default404Html());
565
723
  } else {
566
724
  res.json({ error: 'Not Found', status: 404 });
@@ -569,6 +727,10 @@ function createApp(options = {}) {
569
727
 
570
728
  // Error handler
571
729
  app.use((err, req, res, next) => {
730
+ if (res.headersSent) {
731
+ return next(err);
732
+ }
733
+
572
734
  // Handle timeout errors
573
735
  if (req.timedout) {
574
736
  console.error('Request timed out:', req.method, req.url);
@@ -580,10 +742,12 @@ function createApp(options = {}) {
580
742
  return errorPages.timeout(req, res, ctx);
581
743
  }
582
744
 
583
- // Custom timeout template
584
- if (typeof errorPages.timeout === 'string' && !preferJsonErrorResponse(req)) {
745
+ // Custom or auto-discovered timeout template (pre-resolved at startup)
746
+ const timeoutTemplate = resolved503Template;
747
+
748
+ if (timeoutTemplate && !preferJsonErrorResponse(req)) {
585
749
  try {
586
- const html = nunjucksEnv.render(errorPages.timeout, ctx);
750
+ const html = nunjucksEnv.render(timeoutTemplate, ctx);
587
751
  return res.send(html);
588
752
  } catch (e) {
589
753
  console.error('Error rendering timeout template:', e);
@@ -610,10 +774,12 @@ function createApp(options = {}) {
610
774
  return errorPages.serverError(err, req, res, ctx);
611
775
  }
612
776
 
613
- // Custom template (skipped for /api and JSON-preferring clients so they never get HTML)
614
- if (typeof errorPages.serverError === 'string' && !preferJsonErrorResponse(req)) {
777
+ // Custom or auto-discovered 500 template (pre-resolved at startup)
778
+ const serverErrorTemplate = resolved500Template;
779
+
780
+ if (serverErrorTemplate && !preferJsonErrorResponse(req)) {
615
781
  try {
616
- const html = nunjucksEnv.render(errorPages.serverError, ctx);
782
+ const html = nunjucksEnv.render(serverErrorTemplate, ctx);
617
783
  return res.send(html);
618
784
  } catch (e) {
619
785
  console.error('Error rendering 500 template:', e);
@@ -53,7 +53,7 @@ project/
53
53
  | `db` | DB instance from `createDatabase()` → **`ctx.db`** in `load`, `meta`, plugins |
54
54
  | `middlewares` | Named map; reference by string in route/API config (`middleware: ['auth']`) |
55
55
  | `plugins` | Array of plugin factories/objects |
56
- | `errorPages` | `{ notFound, serverError, timeout }` — function or template path. File-based SSR/API errors are passed to this Express error middleware via `next(err)`. **`serverError` / `timeout` as a template path** is not used for paths under **`/api`** (those get default JSON). |
56
+ | `errorPages` | `{ notFound?: string \| Function, serverError?: string \| Function, timeout?: string \| Function }` — Nunjucks template path (e.g. `notFound: '404.njk'`, relative to `viewsDir`) or Express handler fn `(req, res, ctx) => ...`. Template context receives `{ fsy, locale, isDev, url, method }` (and `{ error, status }` for 500). File-based SSR/API errors are passed to this Express error middleware via `next(err)`. **`serverError` / `timeout` as a template path** is not used for paths under **`/api`** (those get default JSON). |
57
57
  | `timeout` | e.g. `'30s'` or `false` |
58
58
  | `helmet` | `true` / `false` / object |
59
59
  | `assets` | `{ version, manifestPath, prefix }` for `fsy.asset` / `fsy.css` / `fsy.js` |
@@ -160,7 +160,7 @@ Available in all Nunjucks templates:
160
160
  - **Request:** `fsy.q`, `fsy.param`, `fsy.hdr`
161
161
  - **Utils:** `fsy.slugify`, `fsy.truncate`, `fsy.prettyBytes`, `fsy.prettyMs`
162
162
  - **Dates:** `fsy.date`, `fsy.dateFormat`, `fsy.dateFromNow`, `fsy.dateDiff`, …
163
- - **Assets:** `fsy.asset`, `fsy.css`, `fsy.js`, `fsy.img` (with `assets` config)
163
+ - **Assets & Versioning:** `fsy.asset(path)` (returns versioned `?v=...` or Vite/Webpack manifest resolved URL), `fsy.css(href, attrs)` (generates `<link>` tag), `fsy.js(src, attrs)` (generates `<script>` tag with boolean attributes), `fsy.img(src, alt, attrs)` (generates `<img>` tag). Configured via `createApp({ assets: { version, manifestPath, prefix } })`.
164
164
  - **Dev:** `fsy.isDev()`
165
165
  - **SEO:** `fsy.jsonld`
166
166