webspresso 0.0.90 → 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 (94) hide show
  1. package/README.md +358 -8
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/middleware.js +9 -0
  5. package/core/compression/compressible.js +51 -0
  6. package/core/compression/compression-stream.js +59 -0
  7. package/core/compression/index.js +25 -0
  8. package/core/compression/middleware.js +301 -0
  9. package/core/compression/negotiate.js +153 -0
  10. package/core/errors/base.js +31 -0
  11. package/core/errors/domain.js +193 -0
  12. package/core/errors/http.js +133 -0
  13. package/core/errors/index.js +77 -0
  14. package/core/errors/middleware.js +162 -0
  15. package/core/errors/normalize.js +171 -0
  16. package/core/orm/complexity.js +120 -0
  17. package/core/orm/events.js +64 -14
  18. package/core/orm/index.js +45 -10
  19. package/core/orm/model.js +2 -0
  20. package/core/orm/query-builder.js +30 -4
  21. package/core/orm/repository.js +57 -25
  22. package/core/orm/transaction.js +39 -5
  23. package/core/orm/utils.js +6 -3
  24. package/core/queue/adapters/database.js +284 -0
  25. package/core/queue/adapters/memory.js +155 -0
  26. package/core/queue/adapters/redis.js +336 -0
  27. package/core/queue/index.js +21 -0
  28. package/core/queue/job.js +103 -0
  29. package/core/queue/manager.js +387 -0
  30. package/core/realtime/adapters/redis.js +431 -0
  31. package/core/realtime/adapters/socket-io.js +203 -0
  32. package/core/realtime/adapters/sse.js +210 -0
  33. package/core/realtime/adapters/websocket.js +337 -0
  34. package/core/realtime/auth-manager.js +140 -0
  35. package/core/realtime/core.js +359 -0
  36. package/core/realtime/emitter.js +98 -0
  37. package/core/realtime/errors.js +71 -0
  38. package/core/realtime/index.js +79 -0
  39. package/core/realtime/reconnect-manager.js +118 -0
  40. package/core/realtime/registry.js +243 -0
  41. package/core/shutdown/http-adapter.js +134 -0
  42. package/core/shutdown/index.js +12 -0
  43. package/core/shutdown/shutdown-manager.js +312 -0
  44. package/core/ssr/index.js +13 -0
  45. package/core/ssr/stream.js +174 -0
  46. package/index.d.ts +829 -52
  47. package/index.js +103 -0
  48. package/package.json +22 -2
  49. package/plugins/admin-panel/api.js +37 -4
  50. package/plugins/admin-panel/app.js +57 -17
  51. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  52. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  53. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  54. package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
  55. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  56. package/plugins/admin-panel/core/admin-module.js +2 -2
  57. package/plugins/admin-panel/core/api-extensions.js +149 -0
  58. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  59. package/plugins/admin-panel/index.js +90 -17
  60. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  61. package/plugins/admin-panel/modules/menu.js +85 -0
  62. package/plugins/admin-panel/modules/user-management.js +5 -2
  63. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  64. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  65. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  66. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  67. package/plugins/basic-auth/index.js +293 -0
  68. package/plugins/data-exchange/export-xlsx.js +5 -1
  69. package/plugins/data-exchange/import.js +16 -2
  70. package/plugins/data-exchange/index.js +14 -0
  71. package/plugins/email/index.js +14 -1
  72. package/plugins/index.js +29 -0
  73. package/plugins/queue/index.js +108 -0
  74. package/plugins/realtime/index.js +150 -0
  75. package/plugins/recaptcha/index.js +11 -0
  76. package/plugins/redirect/index.js +9 -1
  77. package/plugins/rest-resources/index.js +280 -11
  78. package/plugins/upload/index.js +16 -0
  79. package/src/app-context.js +46 -6
  80. package/src/file-router.js +196 -21
  81. package/src/helpers.js +20 -2
  82. package/src/plugin-manager.js +124 -4
  83. package/src/server.js +304 -44
  84. package/src/services/builtins/auth.js +273 -0
  85. package/src/services/builtins/exchange.js +229 -0
  86. package/src/services/builtins/mail.js +134 -0
  87. package/src/services/builtins/media.js +196 -0
  88. package/src/services/builtins/system.js +221 -0
  89. package/src/services/discovery.js +140 -0
  90. package/src/services/executor.js +292 -0
  91. package/src/services/index.js +70 -0
  92. package/src/services/memoize.js +256 -0
  93. package/src/services/registry.js +288 -0
  94. package/src/services/validator.js +281 -0
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();
@@ -153,29 +167,7 @@ function getDefaultHelmetConfig(isDev) {
153
167
  };
154
168
  }
155
169
 
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
170
 
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
171
 
180
172
  /**
181
173
  * Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
@@ -420,9 +412,141 @@ function createApp(options = {}) {
420
412
 
421
413
  const clientRuntime = resolveClientRuntime(options);
422
414
 
423
- 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 });
424
466
 
425
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
+ });
426
550
 
427
551
  // Security headers with Helmet
428
552
  if (helmetConfig !== false) {
@@ -469,19 +593,46 @@ function createApp(options = {}) {
469
593
 
470
594
  app.use(helmet(finalConfig));
471
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
+ }
472
605
 
473
606
  // Request timeout middleware
474
607
  if (timeoutConfig !== false) {
475
608
  app.use(timeout(timeoutConfig));
476
609
  }
477
610
 
478
- // Trust proxy (for correct req.ip, req.protocol behind reverse proxy)
479
- 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');
480
623
 
481
624
  // JSON body parser for API routes
482
625
  app.use(express.json());
483
626
  app.use(express.urlencoded({ extended: true }));
484
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
+
485
636
  // Halt processing if request has timed out (after body parsers)
486
637
  if (timeoutConfig !== false) {
487
638
  app.use(haltOnTimedout);
@@ -527,7 +678,21 @@ function createApp(options = {}) {
527
678
 
528
679
  // Add custom Nunjucks filters
529
680
  nunjucksEnv.addFilter('json', (obj) => {
530
- 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;
531
696
  });
532
697
 
533
698
  nunjucksEnv.addFilter('date', (date, format = 'short') => {
@@ -550,7 +715,7 @@ function createApp(options = {}) {
550
715
  });
551
716
 
552
717
  // Register plugins (sync) — middlewares is the same object later passed to mountPages
553
- const pluginContext = { app, nunjucksEnv, options, middlewares };
718
+ const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager };
554
719
  pluginManager.registerSync(plugins, pluginContext);
555
720
 
556
721
  // Request logging middleware
@@ -578,6 +743,7 @@ function createApp(options = {}) {
578
743
  db: options.db ?? null,
579
744
  clientRuntime,
580
745
  pageAssets: options.pageAssets,
746
+ serviceRegistry,
581
747
  });
582
748
 
583
749
  // Set route metadata in plugin manager
@@ -620,6 +786,7 @@ function createApp(options = {}) {
620
786
  pluginManager,
621
787
  options,
622
788
  clientRuntime,
789
+ middlewares,
623
790
  });
624
791
  }
625
792
 
@@ -639,14 +806,24 @@ function createApp(options = {}) {
639
806
  const pluginHelpers = pluginManager.getHelpers();
640
807
  Object.assign(fsy, pluginHelpers);
641
808
 
642
- // Provide safe i18n translator helper
809
+ // Provide safe i18n translator helper (primary + fallback)
810
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
643
811
  const translations = loadI18n(pagesDir, '', locale);
644
- const t = createTranslator(translations);
812
+ const fallbackTranslations = (locale !== defaultLocale)
813
+ ? loadI18n(pagesDir, '', defaultLocale)
814
+ : undefined;
815
+ const t = createTranslator(translations, {
816
+ locale,
817
+ fallbackTranslations,
818
+ fallbackLocale: defaultLocale,
819
+ });
645
820
 
646
821
  return {
647
822
  fsy,
648
823
  locale,
649
824
  t,
825
+ service: (name, input, opts) =>
826
+ serviceRegistry.call(name, input, { req, res: {}, fsy, locale, t, db: options.db ?? null }, opts),
650
827
  isDev,
651
828
  url: req.url,
652
829
  method: req.method,
@@ -725,12 +902,28 @@ function createApp(options = {}) {
725
902
  }
726
903
  });
727
904
 
728
- // Error handler
729
- 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) => {
730
913
  if (res.headersSent) {
731
914
  return next(err);
732
915
  }
733
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
+
734
927
  // Handle timeout errors
735
928
  if (req.timedout) {
736
929
  console.error('Request timed out:', req.method, req.url);
@@ -761,12 +954,41 @@ function createApp(options = {}) {
761
954
  return res.json({ error: 'Request Timeout', status: 503 });
762
955
  }
763
956
  }
764
-
765
- console.error('Server error:', err);
766
- 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);
767
988
  const ctx = createErrorContext(req, {
768
- error: isDev ? err : { message: 'Internal Server Error' },
769
- status: err.status || 500
989
+ error: isDev ? err : { message: normalized.message },
990
+ status,
991
+ normalizedError: normalized,
770
992
  });
771
993
 
772
994
  // Custom handler function
@@ -777,7 +999,7 @@ function createApp(options = {}) {
777
999
  // Custom or auto-discovered 500 template (pre-resolved at startup)
778
1000
  const serverErrorTemplate = resolved500Template;
779
1001
 
780
- if (serverErrorTemplate && !preferJsonErrorResponse(req)) {
1002
+ if (status >= 500 && serverErrorTemplate && !preferJsonErrorResponse(req)) {
781
1003
  try {
782
1004
  const html = nunjucksEnv.render(serverErrorTemplate, ctx);
783
1005
  return res.send(html);
@@ -788,17 +1010,55 @@ function createApp(options = {}) {
788
1010
 
789
1011
  // Default response
790
1012
  if (!preferJsonErrorResponse(req)) {
791
- res.send(default500Html(err, isDev));
1013
+ res.send(default500Html(normalized, isDev));
792
1014
  } else {
793
- res.json({
794
- error: 'Internal Server Error',
795
- status: err.status || 500,
796
- ...(isDev && { message: err.message, stack: err.stack })
797
- });
1015
+ res.json(toErrorResponseObject(normalized, isDev));
798
1016
  }
799
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
+ };
800
1060
 
801
- return { app, nunjucksEnv, pluginManager, authMiddleware };
1061
+ return { app, nunjucksEnv, pluginManager, authMiddleware, shutdownManager };
802
1062
  }
803
1063
 
804
1064
  // Export for use as library