webspresso 0.0.90 → 0.0.93

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 (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
package/src/server.js CHANGED
@@ -17,6 +17,20 @@ const { resolveClientRuntime } = require('./client-runtime/resolve');
17
17
  const { mountPages, detectLocale, loadI18n, createTranslator } = require('./file-router');
18
18
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
19
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');
20
34
 
21
35
  // Async storage for tracking template render call stacks (circular extends guard)
22
36
  const renderStackStorage = new AsyncLocalStorage();
@@ -33,7 +47,7 @@ function configureSafeNunjucks(templateDirs, options = {}) {
33
47
  const origRender = env.render.bind(env);
34
48
  const origRenderString = env.renderString.bind(env);
35
49
 
36
- function wrapTemplateRoot(tmpl, name, store) {
50
+ function wrapTemplateRoot(tmpl, name) {
37
51
  if (!tmpl || tmpl._safeWrapped) return;
38
52
  tmpl._safeWrapped = true;
39
53
  const origRoot = tmpl.rootRenderFunc;
@@ -41,21 +55,28 @@ function configureSafeNunjucks(templateDirs, options = {}) {
41
55
 
42
56
  tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
43
57
  const normalizedName = String(name || tmpl.path || 'anonymous');
44
- store.stack.push(normalizedName);
58
+ const store = renderStackStorage.getStore();
59
+ if (store) {
60
+ store.stack.push(normalizedName);
61
+ }
45
62
  let finished = false;
46
63
  const done = (err, out) => {
47
64
  if (!finished) {
48
65
  finished = true;
49
- const idx = store.stack.lastIndexOf(normalizedName);
50
- if (idx !== -1) store.stack.splice(idx, 1);
66
+ if (store) {
67
+ const idx = store.stack.lastIndexOf(normalizedName);
68
+ if (idx !== -1) store.stack.splice(idx, 1);
69
+ }
51
70
  }
52
71
  renderCb(err, out);
53
72
  };
54
73
  try {
55
74
  return origRoot.call(this, e, context, frame, runtime, done);
56
75
  } catch (ex) {
57
- const idx = store.stack.lastIndexOf(normalizedName);
58
- if (idx !== -1) store.stack.splice(idx, 1);
76
+ if (store) {
77
+ const idx = store.stack.lastIndexOf(normalizedName);
78
+ if (idx !== -1) store.stack.splice(idx, 1);
79
+ }
59
80
  throw ex;
60
81
  }
61
82
  };
@@ -83,15 +104,15 @@ function configureSafeNunjucks(templateDirs, options = {}) {
83
104
  }
84
105
 
85
106
  const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
86
- if (tmpl && store) {
87
- wrapTemplateRoot(tmpl, name, store);
107
+ if (tmpl) {
108
+ wrapTemplateRoot(tmpl, name);
88
109
  }
89
110
  cb(err, tmpl);
90
111
  } : undefined;
91
112
 
92
113
  const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
93
- if (res && store) {
94
- wrapTemplateRoot(res, name, store);
114
+ if (res) {
115
+ wrapTemplateRoot(res, name);
95
116
  }
96
117
  return res;
97
118
  };
@@ -153,29 +174,7 @@ function getDefaultHelmetConfig(isDev) {
153
174
  };
154
175
  }
155
176
 
156
- /**
157
- * Use JSON error responses for `pages/api/*` routes, XHR requests, and clients that prefer JSON.
158
- * @param {import('express').Request} req
159
- * @returns {boolean}
160
- */
161
- function preferJsonErrorResponse(req) {
162
- if (!req) return false;
163
- if (req.path && req.path.startsWith('/api')) return true;
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
177
 
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;
178
- }
179
178
 
180
179
  /**
181
180
  * Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
@@ -420,9 +419,152 @@ function createApp(options = {}) {
420
419
 
421
420
  const clientRuntime = resolveClientRuntime(options);
422
421
 
423
- setAppContext({ db: options.db ?? null });
422
+ const shutdownConfig = {
423
+ enabled: true,
424
+ mode: 'graceful',
425
+ timeout: 10_000,
426
+ ...(options.shutdown || options.server?.shutdown || {}),
427
+ };
428
+
429
+ const shutdownManager = new ShutdownManager({
430
+ ...shutdownConfig,
431
+ logger: logging ? console : null,
432
+ });
433
+
434
+ // Services Layer initialization
435
+ const servicesDirOption = options.servicesDir ?? options.services?.dir;
436
+ const resolvedServicesDir = servicesDirOption
437
+ ? path.resolve(servicesDirOption)
438
+ : (fs.existsSync(path.join(process.cwd(), 'services'))
439
+ ? path.join(process.cwd(), 'services')
440
+ : null);
441
+
442
+ const serviceRegistry = createServiceRegistry({
443
+ servicesDir: resolvedServicesDir,
444
+ isDev,
445
+ logger: logging ? console : null,
446
+ });
447
+
448
+ // Auto-register built-in auth services if database is present and service is not custom-defined
449
+ if (options.db) {
450
+ const { createAuthServices } = require('./services/builtins/auth');
451
+ const authServices = createAuthServices({ db: options.db });
452
+ for (const [name, def] of Object.entries(authServices)) {
453
+ if (!serviceRegistry.has(name)) {
454
+ serviceRegistry.register(name, def);
455
+ }
456
+ }
457
+ }
458
+
459
+ // Auto-register built-in system services (health, cleanup, cache flush, info)
460
+ const { createSystemServices } = require('./services/builtins/system');
461
+ const systemServices = createSystemServices({
462
+ db: options.db ?? null,
463
+ serviceRegistry,
464
+ pluginManager,
465
+ });
466
+ for (const [name, def] of Object.entries(systemServices)) {
467
+ if (!serviceRegistry.has(name)) {
468
+ serviceRegistry.register(name, def);
469
+ }
470
+ }
471
+
472
+ setAppContext({ db: options.db ?? null, shutdownManager, serviceRegistry });
424
473
 
425
474
  const app = express();
475
+ app.serviceRegistry = serviceRegistry;
476
+
477
+ // Request context & services caller middleware
478
+ app.use((req, res, next) => {
479
+ if (options.db) {
480
+ req.db = options.db;
481
+ }
482
+ req.context = {
483
+ req,
484
+ res,
485
+ db: options.db ?? null,
486
+ app,
487
+ serviceRegistry,
488
+ ...(req.context || {}),
489
+ };
490
+ req.service = (name, input, opts) =>
491
+ serviceRegistry.call(name, input, req.context, opts);
492
+ next();
493
+ });
494
+
495
+ // SSR Streaming Response Helper
496
+ const { renderStream } = require('../core/ssr/stream');
497
+ app.use((req, res, next) => {
498
+ res.renderStream = (templatePath, context = {}, renderOptions = {}) => {
499
+ return renderStream(res, templatePath, context, {
500
+ env: nunjucksEnv,
501
+ ...renderOptions,
502
+ });
503
+ };
504
+ next();
505
+ });
506
+
507
+ // Async handler wrapper helper for automatic promise rejection handling
508
+ function wrapAsync(fn) {
509
+ if (typeof fn !== 'function') return fn;
510
+ if (fn.length === 4) {
511
+ return function(err, req, res, next) {
512
+ try {
513
+ const ret = fn.call(this, err, req, res, next);
514
+ if (ret && typeof ret.catch === 'function') {
515
+ ret.catch(next);
516
+ }
517
+ return ret;
518
+ } catch (syncErr) {
519
+ return next(syncErr);
520
+ }
521
+ };
522
+ }
523
+ return function(req, res, next) {
524
+ try {
525
+ const ret = fn.call(this, req, res, next);
526
+ if (ret && typeof ret.catch === 'function') {
527
+ ret.catch(next);
528
+ }
529
+ return ret;
530
+ } catch (syncErr) {
531
+ return next(syncErr);
532
+ }
533
+ };
534
+ }
535
+
536
+ // Wrap routing methods on app to catch uncaught async errors
537
+ const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'];
538
+ for (const method of HTTP_METHODS) {
539
+ const origMethod = app[method];
540
+ if (typeof origMethod === 'function') {
541
+ app[method] = function(path, ...handlers) {
542
+ const wrappedHandlers = handlers.flat().map((h) => wrapAsync(h));
543
+ return origMethod.call(this, path, ...wrappedHandlers);
544
+ };
545
+ }
546
+ }
547
+
548
+ // Request draining check during graceful shutdown (zero overhead during normal operation)
549
+ app.use((req, res, next) => {
550
+ if (shutdownManager.isShuttingDown) {
551
+ res.set('Connection', 'close');
552
+ if (!res.headersSent) {
553
+ if (preferJsonErrorResponse(req)) {
554
+ return res.status(503).json({ error: 'Service Unavailable', message: 'Server is shutting down', status: 503 });
555
+ }
556
+ return res.status(503).send('Server is shutting down');
557
+ }
558
+ res.on('finish', () => {
559
+ try {
560
+ if (req.socket && !req.socket.destroyed) {
561
+ req.socket.end();
562
+ }
563
+ } catch (e) {}
564
+ });
565
+ }
566
+ next();
567
+ });
426
568
 
427
569
  // Security headers with Helmet
428
570
  if (helmetConfig !== false) {
@@ -469,19 +611,46 @@ function createApp(options = {}) {
469
611
 
470
612
  app.use(helmet(finalConfig));
471
613
  }
614
+
615
+ // HTTP Response Compression
616
+ const compressionOpt = options.compression ?? options.server?.compression ?? false;
617
+ if (compressionOpt) {
618
+ const compressionConfig = typeof compressionOpt === 'object' ? compressionOpt : {};
619
+ if (compressionConfig.enabled !== false) {
620
+ app.use(createCompressionMiddleware(compressionConfig));
621
+ }
622
+ }
472
623
 
473
624
  // Request timeout middleware
474
625
  if (timeoutConfig !== false) {
475
626
  app.use(timeout(timeoutConfig));
476
627
  }
477
628
 
478
- // Trust proxy (for correct req.ip, req.protocol behind reverse proxy)
479
- app.set('trust proxy', 1);
629
+ // Trust proxy (for correct req.ip, req.protocol behind reverse proxy; configurable via options.trustProxy)
630
+ const trustProxySetting = options.trustProxy !== undefined
631
+ ? options.trustProxy
632
+ : (options.server?.trustProxy !== undefined ? options.server.trustProxy : 1);
633
+ if (trustProxySetting !== false) {
634
+ app.set('trust proxy', trustProxySetting);
635
+ } else {
636
+ app.set('trust proxy', false);
637
+ }
638
+
639
+ // Ensure extended query parser for nested filter/sort query parameters (?filter[field]=val)
640
+ app.set('query parser', 'extended');
480
641
 
481
642
  // JSON body parser for API routes
482
643
  app.use(express.json());
483
644
  app.use(express.urlencoded({ extended: true }));
484
645
 
646
+ // Ensure default empty object for body on mutating requests if not populated
647
+ app.use((req, res, next) => {
648
+ if (req.body === undefined && (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH')) {
649
+ req.body = {};
650
+ }
651
+ next();
652
+ });
653
+
485
654
  // Halt processing if request has timed out (after body parsers)
486
655
  if (timeoutConfig !== false) {
487
656
  app.use(haltOnTimedout);
@@ -527,7 +696,21 @@ function createApp(options = {}) {
527
696
 
528
697
  // Add custom Nunjucks filters
529
698
  nunjucksEnv.addFilter('json', (obj) => {
530
- return JSON.stringify(obj, null, 2);
699
+ const raw = JSON.stringify(obj, null, 2);
700
+ if (!raw) return '';
701
+ return raw
702
+ .replace(/</g, '\\u003c')
703
+ .replace(/>/g, '\\u003e')
704
+ .replace(/&/g, '\\u0026');
705
+ });
706
+
707
+ // Translation filter: {{ 'nav.home' | t }} or {{ 'cart.items' | t({ count: 5 }) }}
708
+ nunjucksEnv.addFilter('t', function(key, params, defaultVal) {
709
+ const tFunc = this.ctx?.t || this.ctx?.fsy?.t;
710
+ if (typeof tFunc === 'function') {
711
+ return tFunc(key, params, defaultVal);
712
+ }
713
+ return defaultVal !== undefined && defaultVal !== null ? defaultVal : key;
531
714
  });
532
715
 
533
716
  nunjucksEnv.addFilter('date', (date, format = 'short') => {
@@ -550,7 +733,7 @@ function createApp(options = {}) {
550
733
  });
551
734
 
552
735
  // Register plugins (sync) — middlewares is the same object later passed to mountPages
553
- const pluginContext = { app, nunjucksEnv, options, middlewares };
736
+ const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager };
554
737
  pluginManager.registerSync(plugins, pluginContext);
555
738
 
556
739
  // Request logging middleware
@@ -578,6 +761,7 @@ function createApp(options = {}) {
578
761
  db: options.db ?? null,
579
762
  clientRuntime,
580
763
  pageAssets: options.pageAssets,
764
+ serviceRegistry,
581
765
  });
582
766
 
583
767
  // Set route metadata in plugin manager
@@ -620,6 +804,7 @@ function createApp(options = {}) {
620
804
  pluginManager,
621
805
  options,
622
806
  clientRuntime,
807
+ middlewares,
623
808
  });
624
809
  }
625
810
 
@@ -639,14 +824,24 @@ function createApp(options = {}) {
639
824
  const pluginHelpers = pluginManager.getHelpers();
640
825
  Object.assign(fsy, pluginHelpers);
641
826
 
642
- // Provide safe i18n translator helper
827
+ // Provide safe i18n translator helper (primary + fallback)
828
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
643
829
  const translations = loadI18n(pagesDir, '', locale);
644
- const t = createTranslator(translations);
830
+ const fallbackTranslations = (locale !== defaultLocale)
831
+ ? loadI18n(pagesDir, '', defaultLocale)
832
+ : undefined;
833
+ const t = createTranslator(translations, {
834
+ locale,
835
+ fallbackTranslations,
836
+ fallbackLocale: defaultLocale,
837
+ });
645
838
 
646
839
  return {
647
840
  fsy,
648
841
  locale,
649
842
  t,
843
+ service: (name, input, opts) =>
844
+ serviceRegistry.call(name, input, { req, res: {}, fsy, locale, t, db: options.db ?? null }, opts),
650
845
  isDev,
651
846
  url: req.url,
652
847
  method: req.method,
@@ -725,12 +920,28 @@ function createApp(options = {}) {
725
920
  }
726
921
  });
727
922
 
728
- // Error handler
729
- app.use((err, req, res, next) => {
923
+ let customErrorHandler = null;
924
+ app.setErrorHandler = function(handler) {
925
+ customErrorHandler = typeof handler === 'function' ? handler : null;
926
+ return app;
927
+ };
928
+
929
+ // Central Error Handler
930
+ app.use(async (err, req, res, next) => {
730
931
  if (res.headersSent) {
731
932
  return next(err);
732
933
  }
733
934
 
935
+ // Client connection aborted
936
+ if (err instanceof RequestAbortedError || req.aborted || (req.socket && req.socket.destroyed)) {
937
+ if (!res.headersSent && !res.writableEnded) {
938
+ try {
939
+ res.end();
940
+ } catch (_) {}
941
+ }
942
+ return;
943
+ }
944
+
734
945
  // Handle timeout errors
735
946
  if (req.timedout) {
736
947
  console.error('Request timed out:', req.method, req.url);
@@ -761,12 +972,41 @@ function createApp(options = {}) {
761
972
  return res.json({ error: 'Request Timeout', status: 503 });
762
973
  }
763
974
  }
764
-
765
- console.error('Server error:', err);
766
- res.status(err.status || 500);
975
+
976
+ const normalized = normalizeError(err, isDev);
977
+ const status = normalized.status || 500;
978
+
979
+ // Attach custom headers from HttpError if present
980
+ if (normalized.headers && typeof normalized.headers === 'object') {
981
+ for (const [key, value] of Object.entries(normalized.headers)) {
982
+ res.setHeader(key, value);
983
+ }
984
+ }
985
+
986
+ // Custom Error Handler hook (app.setErrorHandler)
987
+ if (customErrorHandler) {
988
+ try {
989
+ const handled = await customErrorHandler(err, req, res, next);
990
+ if (res.headersSent || handled === true) {
991
+ return;
992
+ }
993
+ } catch (customErr) {
994
+ console.error('[webspresso] Error in custom error handler:', customErr);
995
+ }
996
+ }
997
+
998
+ // Log errors based on severity
999
+ if (status >= 500) {
1000
+ console.error('Server error:', err);
1001
+ } else if (isDev && status >= 400) {
1002
+ console.warn(`[webspresso] HTTP ${status}:`, normalized.message);
1003
+ }
1004
+
1005
+ res.status(status);
767
1006
  const ctx = createErrorContext(req, {
768
- error: isDev ? err : { message: 'Internal Server Error' },
769
- status: err.status || 500
1007
+ error: isDev ? err : { message: normalized.message },
1008
+ status,
1009
+ normalizedError: normalized,
770
1010
  });
771
1011
 
772
1012
  // Custom handler function
@@ -777,7 +1017,7 @@ function createApp(options = {}) {
777
1017
  // Custom or auto-discovered 500 template (pre-resolved at startup)
778
1018
  const serverErrorTemplate = resolved500Template;
779
1019
 
780
- if (serverErrorTemplate && !preferJsonErrorResponse(req)) {
1020
+ if (status >= 500 && serverErrorTemplate && !preferJsonErrorResponse(req)) {
781
1021
  try {
782
1022
  const html = nunjucksEnv.render(serverErrorTemplate, ctx);
783
1023
  return res.send(html);
@@ -788,17 +1028,55 @@ function createApp(options = {}) {
788
1028
 
789
1029
  // Default response
790
1030
  if (!preferJsonErrorResponse(req)) {
791
- res.send(default500Html(err, isDev));
1031
+ res.send(default500Html(normalized, isDev));
792
1032
  } else {
793
- res.json({
794
- error: 'Internal Server Error',
795
- status: err.status || 500,
796
- ...(isDev && { message: err.message, stack: err.stack })
797
- });
1033
+ res.json(toErrorResponseObject(normalized, isDev));
798
1034
  }
799
1035
  });
1036
+
1037
+ // Decorate app with shutdown lifecycle helpers
1038
+ app.shutdownManager = shutdownManager;
1039
+ Object.defineProperty(app, 'isShuttingDown', {
1040
+ get: () => shutdownManager.isShuttingDown,
1041
+ configurable: true,
1042
+ enumerable: true,
1043
+ });
1044
+
1045
+ app.onShutdown = function(fn) {
1046
+ shutdownManager.onShutdown(fn);
1047
+ return app;
1048
+ };
1049
+
1050
+ app.close = function(reason) {
1051
+ return shutdownManager.close(reason);
1052
+ };
1053
+
1054
+ app.enableShutdownHooks = function() {
1055
+ shutdownManager.enableShutdownHooks();
1056
+ return app;
1057
+ };
1058
+
1059
+ app.disableShutdownHooks = function() {
1060
+ shutdownManager.disableShutdownHooks();
1061
+ return app;
1062
+ };
1063
+
1064
+ // Wrap app.listen to register server adapter and signal hooks
1065
+ const origListen = app.listen.bind(app);
1066
+ app.listen = function(...args) {
1067
+ const server = origListen(...args);
1068
+ app.server = server;
1069
+ const adapter = new NodeHttpAdapter(server);
1070
+ shutdownManager.registerAdapter(adapter);
1071
+
1072
+ if (shutdownConfig.enabled !== false && !isTest) {
1073
+ shutdownManager.enableShutdownHooks();
1074
+ }
1075
+
1076
+ return server;
1077
+ };
800
1078
 
801
- return { app, nunjucksEnv, pluginManager, authMiddleware };
1079
+ return { app, nunjucksEnv, pluginManager, authMiddleware, shutdownManager };
802
1080
  }
803
1081
 
804
1082
  // Export for use as library