webspresso 0.0.99 → 0.0.101

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 (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. package/src/server.js +180 -26
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Webhook Sender Module
3
+ * Zero-dependency HTTP/HTTPS delivery using native fetch with signatures & timeouts
4
+ * @module plugins/webhooks/sender
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const crypto = require('crypto');
10
+ const { signPayload } = require('./crypto');
11
+
12
+ /**
13
+ * Send a webhook HTTP request to a target endpoint
14
+ *
15
+ * @param {Object} options
16
+ * @param {string} options.url - Destination URL
17
+ * @param {string} options.event - Event name (e.g. 'order.created')
18
+ * @param {any} options.payload - Event payload data
19
+ * @param {string} [options.secret] - Signing secret
20
+ * @param {string} [options.deliveryId] - Unique delivery ID
21
+ * @param {Object} [options.headers] - Custom headers to merge
22
+ * @param {number} [options.timeoutMs=10000] - Request timeout in milliseconds
23
+ * @param {boolean} [options.wrapPayload=true] - Wrap data in standard event envelope
24
+ * @param {number} [options.maxResponseBodyLength=2048] - Max response length to store
25
+ * @returns {Promise<{
26
+ * success: boolean,
27
+ * statusCode: number,
28
+ * responseBody: string,
29
+ * durationMs: number,
30
+ * error: string|null,
31
+ * deliveryId: string,
32
+ * timestamp: number
33
+ * }>}
34
+ */
35
+ async function sendWebhook(options) {
36
+ const {
37
+ url,
38
+ event,
39
+ payload,
40
+ secret,
41
+ deliveryId = crypto.randomUUID ? crypto.randomUUID() : `dlv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`,
42
+ headers: customHeaders = {},
43
+ timeoutMs = 10000,
44
+ wrapPayload = true,
45
+ maxResponseBodyLength = 2048,
46
+ } = options;
47
+
48
+ if (!url || typeof url !== 'string') {
49
+ return {
50
+ success: false,
51
+ statusCode: 0,
52
+ responseBody: '',
53
+ durationMs: 0,
54
+ error: 'Invalid webhook URL: destination URL is required.',
55
+ deliveryId,
56
+ timestamp: Math.floor(Date.now() / 1000),
57
+ };
58
+ }
59
+
60
+ const timestamp = Math.floor(Date.now() / 1000);
61
+
62
+ // Construct request body
63
+ let bodyObj = payload;
64
+ if (wrapPayload && (typeof payload !== 'object' || payload === null || !payload.event)) {
65
+ bodyObj = {
66
+ id: deliveryId,
67
+ event,
68
+ timestamp,
69
+ data: payload,
70
+ };
71
+ }
72
+ const bodyStr = typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj);
73
+
74
+ // Calculate cryptographic signatures if secret is configured
75
+ let signatureHeaders = {};
76
+ if (secret) {
77
+ const { signature, timestampedSignature } = signPayload(bodyStr, secret, { timestamp });
78
+ signatureHeaders = {
79
+ 'x-webspresso-signature': signature,
80
+ 'x-hub-signature-256': signature,
81
+ 'webhook-signature': timestampedSignature,
82
+ };
83
+ }
84
+
85
+ // Parse custom headers if stored as JSON string
86
+ let parsedCustomHeaders = {};
87
+ if (typeof customHeaders === 'string') {
88
+ try {
89
+ parsedCustomHeaders = JSON.parse(customHeaders);
90
+ } catch (e) {
91
+ parsedCustomHeaders = {};
92
+ }
93
+ } else if (typeof customHeaders === 'object' && customHeaders !== null) {
94
+ parsedCustomHeaders = customHeaders;
95
+ }
96
+
97
+ const requestHeaders = {
98
+ 'content-type': 'application/json',
99
+ 'accept': 'application/json, text/plain, */*',
100
+ 'user-agent': 'Webspresso-Webhooks/1.0',
101
+ 'x-webspresso-event': event,
102
+ 'x-webspresso-delivery': deliveryId,
103
+ 'x-webspresso-timestamp': String(timestamp),
104
+ 'webhook-id': deliveryId,
105
+ 'webhook-timestamp': String(timestamp),
106
+ ...signatureHeaders,
107
+ ...parsedCustomHeaders,
108
+ };
109
+
110
+ const startTime = Date.now();
111
+ let controller = null;
112
+ let timeoutId = null;
113
+
114
+ try {
115
+ controller = new AbortController();
116
+ timeoutId = setTimeout(() => {
117
+ controller.abort();
118
+ }, timeoutMs);
119
+
120
+ const response = await fetch(url, {
121
+ method: 'POST',
122
+ headers: requestHeaders,
123
+ body: bodyStr,
124
+ signal: controller.signal,
125
+ });
126
+
127
+ clearTimeout(timeoutId);
128
+ const durationMs = Date.now() - startTime;
129
+
130
+ let responseBody = '';
131
+ try {
132
+ const rawText = await response.text();
133
+ responseBody = rawText ? rawText.slice(0, maxResponseBodyLength) : '';
134
+ } catch (textErr) {
135
+ responseBody = '';
136
+ }
137
+
138
+ const success = response.status >= 200 && response.status < 300;
139
+ const error = success ? null : `HTTP ${response.status}: ${response.statusText || 'Delivery Failed'}`;
140
+
141
+ return {
142
+ success,
143
+ statusCode: response.status,
144
+ responseBody,
145
+ durationMs,
146
+ error,
147
+ deliveryId,
148
+ timestamp,
149
+ bodyStr,
150
+ };
151
+ } catch (err) {
152
+ if (timeoutId) {
153
+ clearTimeout(timeoutId);
154
+ }
155
+ const durationMs = Date.now() - startTime;
156
+ const isTimeout = err.name === 'AbortError' || err.code === 'ABORT_ERR';
157
+ const errorMsg = isTimeout
158
+ ? `Webhook delivery timed out after ${timeoutMs}ms`
159
+ : (err.message || 'Network error');
160
+
161
+ return {
162
+ success: false,
163
+ statusCode: 0,
164
+ responseBody: '',
165
+ durationMs,
166
+ error: errorMsg,
167
+ deliveryId,
168
+ timestamp,
169
+ bodyStr,
170
+ };
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ sendWebhook,
176
+ };
@@ -82,7 +82,7 @@ function createApiHandler(descriptor, context) {
82
82
  options: appOptions = {},
83
83
  } = context;
84
84
 
85
- const isDev = process.env.NODE_ENV === 'development';
85
+ const isDev = context.isDev !== undefined ? context.isDev : (appOptions.isDev !== undefined ? appOptions.isDev : process.env.NODE_ENV === 'development');
86
86
  let cachedMiddlewares = null;
87
87
  let cachedApiDef = null;
88
88
  let cachedHandlerFn = null;
@@ -3,23 +3,25 @@
3
3
  * @module src/app-context
4
4
  */
5
5
 
6
- /** @type {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null }} */
6
+ /** @type {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null, events: object|null, mailer: object|null }} */
7
7
  let context = {
8
8
  db: null,
9
9
  shutdownManager: null,
10
10
  serviceRegistry: null,
11
+ events: null,
12
+ mailer: null,
11
13
  };
12
14
 
13
15
  /**
14
16
  * Merge into the current context (typically called once from createApp).
15
- * @param {{ db?: object|null, shutdownManager?: object|null, serviceRegistry?: object|null }} partial
17
+ * @param {{ db?: object|null, shutdownManager?: object|null, serviceRegistry?: object|null, events?: object|null, mailer?: object|null }} partial
16
18
  */
17
19
  function setAppContext(partial) {
18
20
  context = { ...context, ...partial };
19
21
  }
20
22
 
21
23
  /**
22
- * @returns {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null }}
24
+ * @returns {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null, events: object|null, mailer: object|null }}
23
25
  */
24
26
  function getAppContext() {
25
27
  return context;
@@ -77,15 +79,45 @@ function hasServiceRegistry() {
77
79
  return context.serviceRegistry != null;
78
80
  }
79
81
 
82
+ /**
83
+ * Get the registered EventDispatcher instance
84
+ * @returns {object|null}
85
+ */
86
+ function getEvents() {
87
+ return context.events;
88
+ }
89
+
90
+ /**
91
+ * Check if an EventDispatcher is registered
92
+ * @returns {boolean}
93
+ */
94
+ function hasEvents() {
95
+ return context.events != null;
96
+ }
97
+
80
98
  /**
81
99
  * Clear context (e.g. between tests).
82
100
  */
83
101
  function resetAppContext() {
84
- context = { db: null, shutdownManager: null, serviceRegistry: null };
102
+ context = { db: null, shutdownManager: null, serviceRegistry: null, events: null, mailer: null };
103
+ }
104
+
105
+ /**
106
+ * @returns {object|null}
107
+ */
108
+ function getMailer() {
109
+ return context.mailer;
85
110
  }
86
111
 
87
112
  /**
88
- * Express middleware: sets `req.db` and `req.service` from registered app context.
113
+ * @returns {boolean}
114
+ */
115
+ function hasMailer() {
116
+ return context.mailer != null;
117
+ }
118
+
119
+ /**
120
+ * Express middleware: sets `req.db`, `req.service`, `req.events`, and `req.mailer` from registered app context.
89
121
  * File-based `pages/api/*` routes attach this automatically; use in `setupRoutes`
90
122
  * for manually registered handlers that need `req.db` and `req.service`.
91
123
  * @type {import('express').RequestHandler}
@@ -94,9 +126,15 @@ function attachDbMiddleware(req, res, next) {
94
126
  if (context.db != null) {
95
127
  req.db = context.db;
96
128
  }
129
+ if (context.events != null) {
130
+ req.events = context.events;
131
+ }
132
+ if (context.mailer != null) {
133
+ req.mailer = context.mailer;
134
+ }
97
135
  if (context.serviceRegistry != null) {
98
136
  req.service = (name, input, opts) =>
99
- context.serviceRegistry.call(name, input, req.context || { req, res, db: context.db }, opts);
137
+ context.serviceRegistry.call(name, input, req.context || { req, res, db: context.db, events: context.events, mailer: context.mailer }, opts);
100
138
  }
101
139
  next();
102
140
  }
@@ -110,6 +148,10 @@ module.exports = {
110
148
  hasShutdownManager,
111
149
  getServiceRegistry,
112
150
  hasServiceRegistry,
151
+ getEvents,
152
+ hasEvents,
153
+ getMailer,
154
+ hasMailer,
113
155
  resetAppContext,
114
156
  attachDbMiddleware,
115
157
  };
@@ -132,7 +132,8 @@ function expandGlob(pattern, cwd = process.cwd()) {
132
132
 
133
133
  const isRecursive = normalized.includes('**');
134
134
  const regexPattern = '^' + normalized
135
- .replace(/\./g, '\\.')
135
+ .replace(/\\/g, '\\\\')
136
+ .replace(/[.+?^${}()|[\]]/g, '\\$&')
136
137
  .replace(/\*\*\//g, '(.+/)?')
137
138
  .replace(/\*\*/g, '.*')
138
139
  .replace(/\*/g, '[^/]*') + '$';
@@ -475,12 +475,20 @@ function createDevReloadHub(config = {}) {
475
475
 
476
476
  const recursiveSuccess = addWatcher(absDir, true);
477
477
  if (!recursiveSuccess) {
478
+ const visited = new Set();
478
479
  function walkAndWatch(currentDir) {
480
+ try {
481
+ const real = fs.realpathSync(currentDir);
482
+ if (visited.has(real)) return;
483
+ visited.add(real);
484
+ } catch (_) {
485
+ return;
486
+ }
479
487
  addWatcher(currentDir, false);
480
488
  try {
481
489
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
482
490
  for (const entry of entries) {
483
- if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
491
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && !entry.isSymbolicLink()) {
484
492
  walkAndWatch(path.join(currentDir, entry.name));
485
493
  }
486
494
  }
@@ -62,7 +62,16 @@ function scanDirSafely(dirPath, baseDir = dirPath) {
62
62
  * @returns {Array<Object>} Discovered route descriptors
63
63
  */
64
64
  function scanRoutes(options = {}) {
65
- const rootDir = options.rootDir || process.cwd();
65
+ const rootDir = options.rootDir || (() => {
66
+ if (process.env.NODE_ENV === 'test') {
67
+ try {
68
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
69
+ if (pkg.name === 'webspresso') return process.cwd();
70
+ } catch (_) {}
71
+ return path.resolve(__dirname, '../..');
72
+ }
73
+ return process.cwd();
74
+ })();
66
75
  const descriptors = [];
67
76
 
68
77
  // Helper to resolve directory paths
@@ -23,6 +23,8 @@ const {
23
23
  findCascadingLayouts,
24
24
  executeCascadingLayoutLoads,
25
25
  applyCascadingLayouts,
26
+ findNearestErrorBoundary,
27
+ findNearestLoadingTemplate,
26
28
  templateHasExtends,
27
29
  } = require('./routing/cascading');
28
30
  const {
@@ -33,6 +35,148 @@ const {
33
35
 
34
36
  const NOOP = () => {};
35
37
  const EMPTY_OBJECT = Object.freeze({});
38
+
39
+ /**
40
+ * Handles directory-level error boundaries (_error.njk and companion _error.js)
41
+ * @returns {Promise<boolean>} True if rendered, false if unhandled
42
+ */
43
+ async function handleDirectoryErrorBoundary({
44
+ err,
45
+ route,
46
+ req,
47
+ res,
48
+ ctx,
49
+ absolutePagesDir,
50
+ nunjucks,
51
+ isDev,
52
+ compiledStringTemplateCache,
53
+ baseHelpers,
54
+ }) {
55
+ if (res.headersSent) {
56
+ return false;
57
+ }
58
+
59
+ const errorBoundary = findNearestErrorBoundary(absolutePagesDir, route.file, isDev);
60
+ if (!errorBoundary) {
61
+ return false;
62
+ }
63
+
64
+ const statusCode =
65
+ err?.status ||
66
+ err?.statusCode ||
67
+ (typeof err?.getStatus === 'function' ? err.getStatus() : null) ||
68
+ 500;
69
+
70
+ try {
71
+ let errorData = {};
72
+ let errorConfig = null;
73
+
74
+ if (errorBoundary.configPath) {
75
+ try {
76
+ if (isDev && require.cache[require.resolve(errorBoundary.configPath)]) {
77
+ delete require.cache[require.resolve(errorBoundary.configPath)];
78
+ }
79
+ const mod = require(errorBoundary.configPath);
80
+ errorConfig = mod?.default || mod;
81
+ const loadFn = typeof errorConfig === 'function' ? errorConfig : errorConfig?.load;
82
+ if (typeof loadFn === 'function') {
83
+ const loaded = await loadFn({
84
+ req,
85
+ res,
86
+ error: err,
87
+ ctx,
88
+ db: req.db,
89
+ });
90
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
91
+ Object.assign(errorData, loaded);
92
+ }
93
+ }
94
+ } catch (loadErr) {
95
+ console.error(`[webspresso] Error in _error.js loader "${errorBoundary.configPath}":`, loadErr.message);
96
+ }
97
+ }
98
+
99
+ const njkTpl = loadNjkRouteTemplate(errorBoundary.fullPath, isDev);
100
+ if (njkTpl.dataPatch && Object.keys(njkTpl.dataPatch).length > 0) {
101
+ Object.assign(errorData, njkTpl.dataPatch);
102
+ }
103
+
104
+ const errorRenderContext = {
105
+ ...(ctx?.data || {}),
106
+ ...errorData,
107
+ error: {
108
+ message: err?.message || 'An error occurred',
109
+ status: statusCode,
110
+ statusCode,
111
+ code: err?.code || 'ERROR',
112
+ name: err?.name || 'Error',
113
+ details: err?.details,
114
+ fields: err?.fields,
115
+ stack: isDev ? err?.stack : undefined,
116
+ },
117
+ route: {
118
+ routePath: route.routePath,
119
+ file: route.file,
120
+ },
121
+ meta: Object.assign({}, ctx?.meta, {
122
+ title: errorData.title || `Error ${statusCode}`,
123
+ description: errorData.description || err?.message,
124
+ }),
125
+ locale: ctx?.locale,
126
+ t: ctx?.t,
127
+ fsy: ctx?.fsy || (typeof createHelpers === 'function' ? createHelpers({ req, res, locale: ctx?.locale || 'en' }) : {}),
128
+ clientRuntime: ctx?.clientRuntime,
129
+ req: {
130
+ path: req.path,
131
+ query: req.query,
132
+ params: req.params,
133
+ method: req.method,
134
+ url: req.url,
135
+ },
136
+ isDev,
137
+ };
138
+
139
+ let html;
140
+ if (njkTpl.useStringRender && njkTpl.templateBody != null) {
141
+ if (typeof nunjucks.Template === 'function') {
142
+ let compiled = isDev ? null : compiledStringTemplateCache.get(errorBoundary.fullPath);
143
+ if (!compiled || (isDev && compiled._templateBody !== njkTpl.templateBody)) {
144
+ compiled = new nunjucks.Template(njkTpl.templateBody, nunjucks, errorBoundary.fullPath);
145
+ compiled._templateBody = njkTpl.templateBody;
146
+ compiledStringTemplateCache.set(errorBoundary.fullPath, compiled);
147
+ }
148
+ html = compiled.render(errorRenderContext);
149
+ } else if (typeof nunjucks.renderString === 'function') {
150
+ html = nunjucks.renderString(njkTpl.templateBody, errorRenderContext, { path: errorBoundary.fullPath });
151
+ } else {
152
+ html = nunjucks.render(errorBoundary.errorRelPath, errorRenderContext);
153
+ }
154
+ } else {
155
+ html = nunjucks.render(errorBoundary.errorRelPath, errorRenderContext);
156
+ }
157
+
158
+ const shouldApplyLayout = errorConfig?.layout !== false &&
159
+ errorData?.layout !== false &&
160
+ !templateHasExtends(njkTpl.templateBody, errorBoundary.fullPath);
161
+
162
+ if (shouldApplyLayout) {
163
+ const errorLayouts = findCascadingLayouts(absolutePagesDir, errorBoundary.errorRelPath, isDev);
164
+ if (errorLayouts.length > 0) {
165
+ const layoutCtx = { data: { ...errorRenderContext } };
166
+ await executeCascadingLayoutLoads(errorLayouts, req, layoutCtx, isDev);
167
+ const mergedLayoutContext = { ...errorRenderContext, ...(layoutCtx.data || {}) };
168
+ html = applyCascadingLayouts(nunjucks, errorLayouts, html, mergedLayoutContext, isDev);
169
+ }
170
+ }
171
+
172
+ res.status(statusCode);
173
+ res.send(html);
174
+ return true;
175
+ } catch (renderErr) {
176
+ console.error(`[webspresso] Failed rendering error boundary "${errorBoundary.errorRelPath}":`, renderErr);
177
+ return false;
178
+ }
179
+ }
36
180
  const EMPTY_ARRAY = Object.freeze([]);
37
181
 
38
182
  // Cache for i18n files (key: filePath, value: { mtime, data })
@@ -1459,15 +1603,26 @@ function mountPages(app, options) {
1459
1603
 
1460
1604
  // Render the template
1461
1605
  const templatePath = route.file.split(path.sep).join('/');
1606
+ const nearestLoading = findNearestLoadingTemplate(absolutePagesDir, route.file, isDev);
1607
+ if (nearestLoading) {
1608
+ renderContext.loadingTemplate = nearestLoading.loadingRelPath;
1609
+ }
1462
1610
 
1463
1611
  // SSR Streaming mode (chunked transfer)
1464
- if (config?.stream || ctx.data?.__stream || ctx.defer) {
1465
- const streamDefer = ctx.defer || ctx.data?.__defer || {};
1612
+ if (config?.stream || ctx.data?.__stream || ctx.data?.stream || ctx.defer || ctx.data?.defer) {
1613
+ const streamDefer = ctx.defer || ctx.data?.defer || ctx.data?.__defer || {};
1466
1614
  const status = (route.routePath === '/404' || route.file === '404.njk') ? 404 : 200;
1615
+ const shouldApplyLayout = config?.layout !== false &&
1616
+ ctx.data?.layout !== false &&
1617
+ ctx.meta?.layout !== false &&
1618
+ !templateHasExtends(njkTpl.templateBody, route.fullPath);
1619
+
1467
1620
  return await renderStream(res, templatePath, renderContext, {
1468
1621
  env: nunjucks,
1469
1622
  defer: streamDefer,
1470
1623
  status,
1624
+ loadingTemplate: nearestLoading?.loadingRelPath,
1625
+ layouts: shouldApplyLayout && cascadingLayouts.length > 0 ? cascadingLayouts : undefined,
1471
1626
  });
1472
1627
  }
1473
1628
 
@@ -1520,13 +1675,27 @@ function mountPages(app, options) {
1520
1675
  console.error(`SSR error ${route.routePath}:`, err);
1521
1676
 
1522
1677
  // Execute onError hook
1523
- const ctx = { req, res, error: err };
1678
+ const hookCtx = { req, res, error: err };
1524
1679
  try {
1525
- await executeHook(globalHooks, 'onError', ctx, err);
1680
+ await executeHook(globalHooks, 'onError', hookCtx, err);
1526
1681
  } catch (hookErr) {
1527
1682
  console.error('Error in onError hook:', hookErr);
1528
1683
  }
1529
1684
 
1685
+ const handled = await handleDirectoryErrorBoundary({
1686
+ err,
1687
+ route,
1688
+ req,
1689
+ res,
1690
+ ctx: req.context || hookCtx,
1691
+ absolutePagesDir,
1692
+ nunjucks,
1693
+ isDev,
1694
+ compiledStringTemplateCache,
1695
+ });
1696
+
1697
+ if (handled) return;
1698
+
1530
1699
  return next(err);
1531
1700
  }
1532
1701
  });