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.
- package/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +4 -2
- package/.agents/AUTH.md +309 -0
- package/.agents/CLI.md +128 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/ERRORS.md +59 -0
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +149 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +97 -1
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +21 -0
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +73 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +45 -6
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +8 -1
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +55 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +540 -14
- package/index.js +44 -0
- package/package.json +11 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +17 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +15 -4
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/admin-component.js +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -0
- package/plugins/redirect/index.js +1 -1
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -1
- package/src/app-context.js +48 -6
- package/src/bundle/index.js +2 -1
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/scan-routes.js +10 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +150 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/pages/page-loader.js +1 -1
- package/src/routing/cascading.js +120 -4
- 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
|
+
};
|
package/src/api/api-loader.js
CHANGED
|
@@ -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;
|
package/src/app-context.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
};
|
package/src/bundle/index.js
CHANGED
|
@@ -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(
|
|
135
|
+
.replace(/\\/g, '\\\\')
|
|
136
|
+
.replace(/[.+?^${}()|[\]]/g, '\\$&')
|
|
136
137
|
.replace(/\*\*\//g, '(.+/)?')
|
|
137
138
|
.replace(/\*\*/g, '.*')
|
|
138
139
|
.replace(/\*/g, '[^/]*') + '$';
|
package/src/dev-reload/index.js
CHANGED
|
@@ -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 ||
|
|
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
|
package/src/file-router.js
CHANGED
|
@@ -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
|
|
1678
|
+
const hookCtx = { req, res, error: err };
|
|
1524
1679
|
try {
|
|
1525
|
-
await executeHook(globalHooks, 'onError',
|
|
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
|
});
|