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.
- package/README.md +450 -53
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +21 -2
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +10 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/scopes.js +2 -1
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +67 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +131 -18
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +220 -28
- package/src/helpers.js +24 -5
- package/src/plugin-manager.js +134 -7
- package/src/server.js +470 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
- 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
|
-
|
|
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
|
-
|
|
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 ? [
|
|
669
|
+
// Configure Nunjucks with viewsDir priority and circular extension guard
|
|
670
|
+
const templateDirs = viewsDir ? [viewsDir, pagesDir] : [pagesDir];
|
|
410
671
|
|
|
411
|
-
const nunjucksEnv =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
571
|
-
app.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
602
|
-
|
|
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:
|
|
605
|
-
status
|
|
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
|
|
614
|
-
|
|
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(
|
|
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(
|
|
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
|