webspresso 0.0.89 → 0.0.91

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.
Files changed (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
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,9 +14,116 @@ 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');
20
+ const { createServiceRegistry } = require('./services');
21
+ const { ShutdownManager, NodeHttpAdapter } = require('../core/shutdown');
22
+ const { createCompressionMiddleware } = require('../core/compression');
23
+ const {
24
+ WebspressoError,
25
+ HttpError,
26
+ NotFoundError,
27
+ RouteNotFoundError,
28
+ RequestAbortedError,
29
+ ValidationError,
30
+ normalizeError,
31
+ toErrorResponseObject,
32
+ preferJsonErrorResponse,
33
+ } = require('../core/errors');
34
+
35
+ // Async storage for tracking template render call stacks (circular extends guard)
36
+ const renderStackStorage = new AsyncLocalStorage();
37
+
38
+ /**
39
+ * Configure Nunjucks environment with circular extends guard and loader protection
40
+ * @param {string|string[]} templateDirs - Template directory paths
41
+ * @param {Object} options - Nunjucks options
42
+ * @returns {nunjucks.Environment}
43
+ */
44
+ function configureSafeNunjucks(templateDirs, options = {}) {
45
+ const env = nunjucks.configure(templateDirs, options);
46
+ const origGetTemplate = env.getTemplate.bind(env);
47
+ const origRender = env.render.bind(env);
48
+ const origRenderString = env.renderString.bind(env);
49
+
50
+ function wrapTemplateRoot(tmpl, name, store) {
51
+ if (!tmpl || tmpl._safeWrapped) return;
52
+ tmpl._safeWrapped = true;
53
+ const origRoot = tmpl.rootRenderFunc;
54
+ if (typeof origRoot !== 'function') return;
55
+
56
+ tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
57
+ const normalizedName = String(name || tmpl.path || 'anonymous');
58
+ store.stack.push(normalizedName);
59
+ let finished = false;
60
+ const done = (err, out) => {
61
+ if (!finished) {
62
+ finished = true;
63
+ const idx = store.stack.lastIndexOf(normalizedName);
64
+ if (idx !== -1) store.stack.splice(idx, 1);
65
+ }
66
+ renderCb(err, out);
67
+ };
68
+ try {
69
+ return origRoot.call(this, e, context, frame, runtime, done);
70
+ } catch (ex) {
71
+ const idx = store.stack.lastIndexOf(normalizedName);
72
+ if (idx !== -1) store.stack.splice(idx, 1);
73
+ throw ex;
74
+ }
75
+ };
76
+ }
77
+
78
+ env.getTemplate = function(name, eagerCompile, parentName, ignoreMissing, cb) {
79
+ if (typeof parentName === 'function') {
80
+ cb = parentName;
81
+ parentName = null;
82
+ }
83
+ if (typeof eagerCompile === 'function') {
84
+ cb = eagerCompile;
85
+ eagerCompile = false;
86
+ }
87
+
88
+ const store = renderStackStorage.getStore();
89
+ if (store && name) {
90
+ const normalizedName = String(name);
91
+ if (store.stack.includes(normalizedName)) {
92
+ const cycle = [...store.stack, normalizedName].join(' -> ');
93
+ const err = new Error(`Circular template extension detected: ${cycle}`);
94
+ if (typeof cb === 'function') return cb(err);
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
100
+ if (tmpl && store) {
101
+ wrapTemplateRoot(tmpl, name, store);
102
+ }
103
+ cb(err, tmpl);
104
+ } : undefined;
105
+
106
+ const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
107
+ if (res && store) {
108
+ wrapTemplateRoot(res, name, store);
109
+ }
110
+ return res;
111
+ };
112
+
113
+ env.render = function(name, ctx, cb) {
114
+ return renderStackStorage.run({ stack: [] }, () => {
115
+ return origRender(name, ctx, cb);
116
+ });
117
+ };
118
+
119
+ env.renderString = function(src, ctx, opts, cb) {
120
+ return renderStackStorage.run({ stack: [] }, () => {
121
+ return origRenderString(src, ctx, opts, cb);
122
+ });
123
+ };
124
+
125
+ return env;
126
+ }
17
127
 
18
128
  /**
19
129
  * Get default Helmet configuration
@@ -57,15 +167,7 @@ function getDefaultHelmetConfig(isDev) {
57
167
  };
58
168
  }
59
169
 
60
- /**
61
- * Use JSON error responses for `pages/api/*` routes and clients that do not prefer HTML.
62
- * @param {import('express').Request} req
63
- * @returns {boolean}
64
- */
65
- function preferJsonErrorResponse(req) {
66
- if (req.path && req.path.startsWith('/api')) return true;
67
- return !req.accepts('html');
68
- }
170
+
69
171
 
70
172
  /**
71
173
  * Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
@@ -310,9 +412,141 @@ function createApp(options = {}) {
310
412
 
311
413
  const clientRuntime = resolveClientRuntime(options);
312
414
 
313
- setAppContext({ db: options.db ?? null });
415
+ const shutdownConfig = {
416
+ enabled: true,
417
+ mode: 'graceful',
418
+ timeout: 10_000,
419
+ ...(options.shutdown || options.server?.shutdown || {}),
420
+ };
421
+
422
+ const shutdownManager = new ShutdownManager({
423
+ ...shutdownConfig,
424
+ logger: logging ? console : null,
425
+ });
426
+
427
+ // Services Layer initialization
428
+ const servicesDirOption = options.servicesDir ?? options.services?.dir;
429
+ const resolvedServicesDir = servicesDirOption
430
+ ? path.resolve(servicesDirOption)
431
+ : (fs.existsSync(path.join(process.cwd(), 'services'))
432
+ ? path.join(process.cwd(), 'services')
433
+ : null);
434
+
435
+ const serviceRegistry = createServiceRegistry({
436
+ servicesDir: resolvedServicesDir,
437
+ isDev,
438
+ logger: logging ? console : null,
439
+ });
440
+
441
+ // Auto-register built-in auth services if database is present and service is not custom-defined
442
+ if (options.db) {
443
+ const { createAuthServices } = require('./services/builtins/auth');
444
+ const authServices = createAuthServices({ db: options.db });
445
+ for (const [name, def] of Object.entries(authServices)) {
446
+ if (!serviceRegistry.has(name)) {
447
+ serviceRegistry.register(name, def);
448
+ }
449
+ }
450
+ }
451
+
452
+ // Auto-register built-in system services (health, cleanup, cache flush, info)
453
+ const { createSystemServices } = require('./services/builtins/system');
454
+ const systemServices = createSystemServices({
455
+ db: options.db ?? null,
456
+ serviceRegistry,
457
+ pluginManager,
458
+ });
459
+ for (const [name, def] of Object.entries(systemServices)) {
460
+ if (!serviceRegistry.has(name)) {
461
+ serviceRegistry.register(name, def);
462
+ }
463
+ }
464
+
465
+ setAppContext({ db: options.db ?? null, shutdownManager, serviceRegistry });
314
466
 
315
467
  const app = express();
468
+ app.serviceRegistry = serviceRegistry;
469
+
470
+ // Services caller middleware
471
+ app.use((req, res, next) => {
472
+ req.service = (name, input, opts) =>
473
+ serviceRegistry.call(name, input, { req, res, db: options.db ?? null, ...(req.context || {}) }, opts);
474
+ next();
475
+ });
476
+
477
+ // SSR Streaming Response Helper
478
+ const { renderStream } = require('../core/ssr/stream');
479
+ app.use((req, res, next) => {
480
+ res.renderStream = (templatePath, context = {}, renderOptions = {}) => {
481
+ return renderStream(res, templatePath, context, {
482
+ env: nunjucksEnv,
483
+ ...renderOptions,
484
+ });
485
+ };
486
+ next();
487
+ });
488
+
489
+ // Async handler wrapper helper for automatic promise rejection handling
490
+ function wrapAsync(fn) {
491
+ if (typeof fn !== 'function') return fn;
492
+ if (fn.length === 4) {
493
+ return function(err, req, res, next) {
494
+ try {
495
+ const ret = fn.call(this, err, req, res, next);
496
+ if (ret && typeof ret.catch === 'function') {
497
+ ret.catch(next);
498
+ }
499
+ return ret;
500
+ } catch (syncErr) {
501
+ return next(syncErr);
502
+ }
503
+ };
504
+ }
505
+ return function(req, res, next) {
506
+ try {
507
+ const ret = fn.call(this, req, res, next);
508
+ if (ret && typeof ret.catch === 'function') {
509
+ ret.catch(next);
510
+ }
511
+ return ret;
512
+ } catch (syncErr) {
513
+ return next(syncErr);
514
+ }
515
+ };
516
+ }
517
+
518
+ // Wrap routing methods on app to catch uncaught async errors
519
+ const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'];
520
+ for (const method of HTTP_METHODS) {
521
+ const origMethod = app[method];
522
+ if (typeof origMethod === 'function') {
523
+ app[method] = function(path, ...handlers) {
524
+ const wrappedHandlers = handlers.flat().map((h) => wrapAsync(h));
525
+ return origMethod.call(this, path, ...wrappedHandlers);
526
+ };
527
+ }
528
+ }
529
+
530
+ // Request draining check during graceful shutdown (zero overhead during normal operation)
531
+ app.use((req, res, next) => {
532
+ if (shutdownManager.isShuttingDown) {
533
+ res.set('Connection', 'close');
534
+ if (!res.headersSent) {
535
+ if (preferJsonErrorResponse(req)) {
536
+ return res.status(503).json({ error: 'Service Unavailable', message: 'Server is shutting down', status: 503 });
537
+ }
538
+ return res.status(503).send('Server is shutting down');
539
+ }
540
+ res.on('finish', () => {
541
+ try {
542
+ if (req.socket && !req.socket.destroyed) {
543
+ req.socket.end();
544
+ }
545
+ } catch (e) {}
546
+ });
547
+ }
548
+ next();
549
+ });
316
550
 
317
551
  // Security headers with Helmet
318
552
  if (helmetConfig !== false) {
@@ -359,19 +593,46 @@ function createApp(options = {}) {
359
593
 
360
594
  app.use(helmet(finalConfig));
361
595
  }
596
+
597
+ // HTTP Response Compression
598
+ const compressionOpt = options.compression ?? options.server?.compression ?? false;
599
+ if (compressionOpt) {
600
+ const compressionConfig = typeof compressionOpt === 'object' ? compressionOpt : {};
601
+ if (compressionConfig.enabled !== false) {
602
+ app.use(createCompressionMiddleware(compressionConfig));
603
+ }
604
+ }
362
605
 
363
606
  // Request timeout middleware
364
607
  if (timeoutConfig !== false) {
365
608
  app.use(timeout(timeoutConfig));
366
609
  }
367
610
 
368
- // Trust proxy (for correct req.ip, req.protocol behind reverse proxy)
369
- app.set('trust proxy', 1);
611
+ // Trust proxy (for correct req.ip, req.protocol behind reverse proxy; configurable via options.trustProxy)
612
+ const trustProxySetting = options.trustProxy !== undefined
613
+ ? options.trustProxy
614
+ : (options.server?.trustProxy !== undefined ? options.server.trustProxy : 1);
615
+ if (trustProxySetting !== false) {
616
+ app.set('trust proxy', trustProxySetting);
617
+ } else {
618
+ app.set('trust proxy', false);
619
+ }
620
+
621
+ // Ensure extended query parser for nested filter/sort query parameters (?filter[field]=val)
622
+ app.set('query parser', 'extended');
370
623
 
371
624
  // JSON body parser for API routes
372
625
  app.use(express.json());
373
626
  app.use(express.urlencoded({ extended: true }));
374
627
 
628
+ // Ensure default empty object for body on mutating requests if not populated
629
+ app.use((req, res, next) => {
630
+ if (req.body === undefined && (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH')) {
631
+ req.body = {};
632
+ }
633
+ next();
634
+ });
635
+
375
636
  // Halt processing if request has timed out (after body parsers)
376
637
  if (timeoutConfig !== false) {
377
638
  app.use(haltOnTimedout);
@@ -405,10 +666,10 @@ function createApp(options = {}) {
405
666
 
406
667
  mountClientRuntime(app, clientRuntime);
407
668
 
408
- // Configure Nunjucks
409
- const templateDirs = viewsDir ? [pagesDir, viewsDir] : [pagesDir];
669
+ // Configure Nunjucks with viewsDir priority and circular extension guard
670
+ const templateDirs = viewsDir ? [viewsDir, pagesDir] : [pagesDir];
410
671
 
411
- const nunjucksEnv = nunjucks.configure(templateDirs, {
672
+ const nunjucksEnv = configureSafeNunjucks(templateDirs, {
412
673
  autoescape: true,
413
674
  express: app,
414
675
  watch: isDev && !isTest,
@@ -417,7 +678,21 @@ function createApp(options = {}) {
417
678
 
418
679
  // Add custom Nunjucks filters
419
680
  nunjucksEnv.addFilter('json', (obj) => {
420
- return JSON.stringify(obj, null, 2);
681
+ const raw = JSON.stringify(obj, null, 2);
682
+ if (!raw) return '';
683
+ return raw
684
+ .replace(/</g, '\\u003c')
685
+ .replace(/>/g, '\\u003e')
686
+ .replace(/&/g, '\\u0026');
687
+ });
688
+
689
+ // Translation filter: {{ 'nav.home' | t }} or {{ 'cart.items' | t({ count: 5 }) }}
690
+ nunjucksEnv.addFilter('t', function(key, params, defaultVal) {
691
+ const tFunc = this.ctx?.t || this.ctx?.fsy?.t;
692
+ if (typeof tFunc === 'function') {
693
+ return tFunc(key, params, defaultVal);
694
+ }
695
+ return defaultVal !== undefined && defaultVal !== null ? defaultVal : key;
421
696
  });
422
697
 
423
698
  nunjucksEnv.addFilter('date', (date, format = 'short') => {
@@ -440,7 +715,7 @@ function createApp(options = {}) {
440
715
  });
441
716
 
442
717
  // Register plugins (sync) — middlewares is the same object later passed to mountPages
443
- const pluginContext = { app, nunjucksEnv, options, middlewares };
718
+ const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager };
444
719
  pluginManager.registerSync(plugins, pluginContext);
445
720
 
446
721
  // Request logging middleware
@@ -468,6 +743,7 @@ function createApp(options = {}) {
468
743
  db: options.db ?? null,
469
744
  clientRuntime,
470
745
  pageAssets: options.pageAssets,
746
+ serviceRegistry,
471
747
  });
472
748
 
473
749
  // Set route metadata in plugin manager
@@ -510,6 +786,7 @@ function createApp(options = {}) {
510
786
  pluginManager,
511
787
  options,
512
788
  clientRuntime,
789
+ middlewares,
513
790
  });
514
791
  }
515
792
 
@@ -528,10 +805,25 @@ function createApp(options = {}) {
528
805
  // Merge plugin helpers
529
806
  const pluginHelpers = pluginManager.getHelpers();
530
807
  Object.assign(fsy, pluginHelpers);
808
+
809
+ // Provide safe i18n translator helper (primary + fallback)
810
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
811
+ const translations = loadI18n(pagesDir, '', locale);
812
+ const fallbackTranslations = (locale !== defaultLocale)
813
+ ? loadI18n(pagesDir, '', defaultLocale)
814
+ : undefined;
815
+ const t = createTranslator(translations, {
816
+ locale,
817
+ fallbackTranslations,
818
+ fallbackLocale: defaultLocale,
819
+ });
531
820
 
532
821
  return {
533
822
  fsy,
534
823
  locale,
824
+ t,
825
+ service: (name, input, opts) =>
826
+ serviceRegistry.call(name, input, { req, res: {}, fsy, locale, t, db: options.db ?? null }, opts),
535
827
  isDev,
536
828
  url: req.url,
537
829
  method: req.method,
@@ -539,20 +831,63 @@ function createApp(options = {}) {
539
831
  };
540
832
  }
541
833
 
834
+ // Pre-resolve error templates and loaders once at startup to avoid runtime synchronous fs operations
835
+ const resolved404Template = typeof errorPages.notFound === 'string'
836
+ ? errorPages.notFound
837
+ : ((viewsDir && fs.existsSync(path.join(viewsDir, '404.njk'))) || fs.existsSync(path.join(pagesDir, '404.njk')))
838
+ ? '404.njk'
839
+ : null;
840
+
841
+ const config404Path = path.join(pagesDir, '404.js');
842
+ const has404DataLoader = fs.existsSync(config404Path);
843
+
844
+ const resolved500Template = typeof errorPages.serverError === 'string'
845
+ ? errorPages.serverError
846
+ : ((viewsDir && fs.existsSync(path.join(viewsDir, '500.njk'))) || fs.existsSync(path.join(pagesDir, '500.njk')))
847
+ ? '500.njk'
848
+ : null;
849
+
850
+ const resolved503Template = typeof errorPages.timeout === 'string'
851
+ ? errorPages.timeout
852
+ : ((viewsDir && fs.existsSync(path.join(viewsDir, '503.njk'))) || fs.existsSync(path.join(pagesDir, '503.njk')))
853
+ ? '503.njk'
854
+ : null;
855
+
542
856
  // 404 handler
543
- app.use((req, res) => {
857
+ app.use(async (req, res) => {
544
858
  res.status(404);
545
859
  const ctx = createErrorContext(req);
546
860
 
547
- // Custom handler function
861
+ // 1. Custom handler function
548
862
  if (typeof errorPages.notFound === 'function') {
549
863
  return errorPages.notFound(req, res, ctx);
550
864
  }
551
865
 
552
- // Custom template
553
- if (typeof errorPages.notFound === 'string') {
866
+ // 2. Custom or auto-discovered 404 template (pre-resolved at startup)
867
+ const notFoundTemplate = resolved404Template;
868
+
869
+ if (notFoundTemplate && !preferJsonErrorResponse(req)) {
554
870
  try {
555
- const html = nunjucksEnv.render(errorPages.notFound, ctx);
871
+ // If pages/404.js exists, execute load() data loader
872
+ if (has404DataLoader) {
873
+ try {
874
+ if (isDev && require.cache[require.resolve(config404Path)]) {
875
+ delete require.cache[require.resolve(config404Path)];
876
+ }
877
+ const config404 = require(config404Path);
878
+ const loadFn = typeof config404 === 'function' ? config404 : config404.load;
879
+ if (typeof loadFn === 'function') {
880
+ const loadData = await loadFn({ req, res, db: ctx.db || null, ctx });
881
+ if (loadData && typeof loadData === 'object') {
882
+ Object.assign(ctx, loadData);
883
+ }
884
+ }
885
+ } catch (loadErr) {
886
+ console.error('Error executing 404.js load():', loadErr);
887
+ }
888
+ }
889
+
890
+ const html = nunjucksEnv.render(notFoundTemplate, ctx);
556
891
  return res.send(html);
557
892
  } catch (e) {
558
893
  console.error('Error rendering 404 template:', e);
@@ -560,15 +895,35 @@ function createApp(options = {}) {
560
895
  }
561
896
 
562
897
  // Default response
563
- if (req.accepts('html')) {
898
+ if (!preferJsonErrorResponse(req)) {
564
899
  res.send(default404Html());
565
900
  } else {
566
901
  res.json({ error: 'Not Found', status: 404 });
567
902
  }
568
903
  });
569
904
 
570
- // Error handler
571
- app.use((err, req, res, next) => {
905
+ let customErrorHandler = null;
906
+ app.setErrorHandler = function(handler) {
907
+ customErrorHandler = typeof handler === 'function' ? handler : null;
908
+ return app;
909
+ };
910
+
911
+ // Central Error Handler
912
+ app.use(async (err, req, res, next) => {
913
+ if (res.headersSent) {
914
+ return next(err);
915
+ }
916
+
917
+ // Client connection aborted
918
+ if (err instanceof RequestAbortedError || req.aborted || (req.socket && req.socket.destroyed)) {
919
+ if (!res.headersSent && !res.writableEnded) {
920
+ try {
921
+ res.end();
922
+ } catch (_) {}
923
+ }
924
+ return;
925
+ }
926
+
572
927
  // Handle timeout errors
573
928
  if (req.timedout) {
574
929
  console.error('Request timed out:', req.method, req.url);
@@ -580,10 +935,12 @@ function createApp(options = {}) {
580
935
  return errorPages.timeout(req, res, ctx);
581
936
  }
582
937
 
583
- // Custom timeout template
584
- if (typeof errorPages.timeout === 'string' && !preferJsonErrorResponse(req)) {
938
+ // Custom or auto-discovered timeout template (pre-resolved at startup)
939
+ const timeoutTemplate = resolved503Template;
940
+
941
+ if (timeoutTemplate && !preferJsonErrorResponse(req)) {
585
942
  try {
586
- const html = nunjucksEnv.render(errorPages.timeout, ctx);
943
+ const html = nunjucksEnv.render(timeoutTemplate, ctx);
587
944
  return res.send(html);
588
945
  } catch (e) {
589
946
  console.error('Error rendering timeout template:', e);
@@ -597,12 +954,41 @@ function createApp(options = {}) {
597
954
  return res.json({ error: 'Request Timeout', status: 503 });
598
955
  }
599
956
  }
600
-
601
- console.error('Server error:', err);
602
- res.status(err.status || 500);
957
+
958
+ const normalized = normalizeError(err, isDev);
959
+ const status = normalized.status || 500;
960
+
961
+ // Attach custom headers from HttpError if present
962
+ if (normalized.headers && typeof normalized.headers === 'object') {
963
+ for (const [key, value] of Object.entries(normalized.headers)) {
964
+ res.setHeader(key, value);
965
+ }
966
+ }
967
+
968
+ // Custom Error Handler hook (app.setErrorHandler)
969
+ if (customErrorHandler) {
970
+ try {
971
+ const handled = await customErrorHandler(err, req, res, next);
972
+ if (res.headersSent || handled === true) {
973
+ return;
974
+ }
975
+ } catch (customErr) {
976
+ console.error('[webspresso] Error in custom error handler:', customErr);
977
+ }
978
+ }
979
+
980
+ // Log errors based on severity
981
+ if (status >= 500) {
982
+ console.error('Server error:', err);
983
+ } else if (isDev && status >= 400) {
984
+ console.warn(`[webspresso] HTTP ${status}:`, normalized.message);
985
+ }
986
+
987
+ res.status(status);
603
988
  const ctx = createErrorContext(req, {
604
- error: isDev ? err : { message: 'Internal Server Error' },
605
- status: err.status || 500
989
+ error: isDev ? err : { message: normalized.message },
990
+ status,
991
+ normalizedError: normalized,
606
992
  });
607
993
 
608
994
  // Custom handler function
@@ -610,10 +996,12 @@ function createApp(options = {}) {
610
996
  return errorPages.serverError(err, req, res, ctx);
611
997
  }
612
998
 
613
- // Custom template (skipped for /api and JSON-preferring clients so they never get HTML)
614
- if (typeof errorPages.serverError === 'string' && !preferJsonErrorResponse(req)) {
999
+ // Custom or auto-discovered 500 template (pre-resolved at startup)
1000
+ const serverErrorTemplate = resolved500Template;
1001
+
1002
+ if (status >= 500 && serverErrorTemplate && !preferJsonErrorResponse(req)) {
615
1003
  try {
616
- const html = nunjucksEnv.render(errorPages.serverError, ctx);
1004
+ const html = nunjucksEnv.render(serverErrorTemplate, ctx);
617
1005
  return res.send(html);
618
1006
  } catch (e) {
619
1007
  console.error('Error rendering 500 template:', e);
@@ -622,17 +1010,55 @@ function createApp(options = {}) {
622
1010
 
623
1011
  // Default response
624
1012
  if (!preferJsonErrorResponse(req)) {
625
- res.send(default500Html(err, isDev));
1013
+ res.send(default500Html(normalized, isDev));
626
1014
  } else {
627
- res.json({
628
- error: 'Internal Server Error',
629
- status: err.status || 500,
630
- ...(isDev && { message: err.message, stack: err.stack })
631
- });
1015
+ res.json(toErrorResponseObject(normalized, isDev));
632
1016
  }
633
1017
  });
1018
+
1019
+ // Decorate app with shutdown lifecycle helpers
1020
+ app.shutdownManager = shutdownManager;
1021
+ Object.defineProperty(app, 'isShuttingDown', {
1022
+ get: () => shutdownManager.isShuttingDown,
1023
+ configurable: true,
1024
+ enumerable: true,
1025
+ });
1026
+
1027
+ app.onShutdown = function(fn) {
1028
+ shutdownManager.onShutdown(fn);
1029
+ return app;
1030
+ };
1031
+
1032
+ app.close = function(reason) {
1033
+ return shutdownManager.close(reason);
1034
+ };
1035
+
1036
+ app.enableShutdownHooks = function() {
1037
+ shutdownManager.enableShutdownHooks();
1038
+ return app;
1039
+ };
1040
+
1041
+ app.disableShutdownHooks = function() {
1042
+ shutdownManager.disableShutdownHooks();
1043
+ return app;
1044
+ };
1045
+
1046
+ // Wrap app.listen to register server adapter and signal hooks
1047
+ const origListen = app.listen.bind(app);
1048
+ app.listen = function(...args) {
1049
+ const server = origListen(...args);
1050
+ app.server = server;
1051
+ const adapter = new NodeHttpAdapter(server);
1052
+ shutdownManager.registerAdapter(adapter);
1053
+
1054
+ if (shutdownConfig.enabled !== false && !isTest) {
1055
+ shutdownManager.enableShutdownHooks();
1056
+ }
1057
+
1058
+ return server;
1059
+ };
634
1060
 
635
- return { app, nunjucksEnv, pluginManager, authMiddleware };
1061
+ return { app, nunjucksEnv, pluginManager, authMiddleware, shutdownManager };
636
1062
  }
637
1063
 
638
1064
  // Export for use as library