webspresso 0.0.99 → 0.0.100

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 (60) hide show
  1. package/.agents/AGENTS.md +2 -2
  2. package/.agents/AUTH.md +246 -0
  3. package/.agents/CLI.md +47 -0
  4. package/.agents/ERRORS.md +59 -0
  5. package/.agents/ORM.md +90 -0
  6. package/.agents/PLUGINS.md +44 -7
  7. package/.agents/QUEUE.md +18 -10
  8. package/.agents/ROUTING.md +33 -0
  9. package/.agents/STREAMING.md +35 -0
  10. package/AGENTS.md +1 -0
  11. package/README.md +145 -93
  12. package/bin/commands/add-docker.js +467 -0
  13. package/bin/commands/deploy-check.js +339 -0
  14. package/bin/webspresso.js +4 -0
  15. package/core/auth/index.js +63 -1
  16. package/core/auth/magic-link.js +170 -0
  17. package/core/auth/manager.js +343 -3
  18. package/core/auth/middleware.js +30 -5
  19. package/core/auth/session-manager.js +255 -0
  20. package/core/auth/sessions.js +351 -0
  21. package/core/auth/tokens.js +1 -0
  22. package/core/auth/totp.js +4 -1
  23. package/core/auth/user-agent.js +161 -0
  24. package/core/orm/index.js +2 -1
  25. package/core/orm/model.js +51 -1
  26. package/core/orm/repository.js +283 -13
  27. package/core/orm/scopes.js +3 -2
  28. package/core/orm/types.js +6 -1
  29. package/core/queue/cron.js +252 -39
  30. package/core/queue/manager.js +20 -2
  31. package/core/queue/scheduler.js +115 -59
  32. package/core/ssr/stream.js +62 -2
  33. package/index.d.ts +274 -14
  34. package/index.js +4 -0
  35. package/package.json +7 -5
  36. package/plugins/admin-panel/modules/admin-users.js +1 -1
  37. package/plugins/audit-log/admin-component.js +54 -54
  38. package/plugins/audit-log/index.js +2 -2
  39. package/plugins/content/client/inline-edit.js +2 -46
  40. package/plugins/cors.js +13 -2
  41. package/plugins/email/auth-bridge.js +66 -0
  42. package/plugins/email/bundled-templates.js +15 -0
  43. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  44. package/plugins/file-manager/admin-component.js +49 -49
  45. package/plugins/file-manager/index.js +4 -4
  46. package/plugins/index.js +2 -0
  47. package/plugins/webhooks/admin-component.js +608 -0
  48. package/plugins/webhooks/api-handlers.js +237 -0
  49. package/plugins/webhooks/crypto.js +144 -0
  50. package/plugins/webhooks/dispatcher.js +708 -0
  51. package/plugins/webhooks/index.js +263 -0
  52. package/plugins/webhooks/matcher.js +113 -0
  53. package/plugins/webhooks/migration-template.js +75 -0
  54. package/plugins/webhooks/models.js +98 -0
  55. package/plugins/webhooks/sender.js +176 -0
  56. package/src/bundle/index.js +2 -1
  57. package/src/file-router.js +173 -4
  58. package/src/helpers.js +7 -1
  59. package/src/njk-frontmatter.js +3 -0
  60. package/src/routing/cascading.js +112 -0
@@ -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
+ };
@@ -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, '[^/]*') + '$';
@@ -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
  });
package/src/helpers.js CHANGED
@@ -294,7 +294,13 @@ class AssetManager {
294
294
  }
295
295
 
296
296
  if (routePath) {
297
- let cleanPath = String(routePath).trim().replace(/^\/+/, '').replace(/\/+$/, '');
297
+ let cleanPath = String(routePath).trim();
298
+ while (cleanPath.startsWith('/')) {
299
+ cleanPath = cleanPath.slice(1);
300
+ }
301
+ while (cleanPath.endsWith('/')) {
302
+ cleanPath = cleanPath.slice(0, -1);
303
+ }
298
304
  if (cleanPath) {
299
305
  candidates.add(cleanPath);
300
306
  if (cleanPath.includes(':')) {
@@ -83,6 +83,9 @@ function frontmatterToPatches(fm) {
83
83
  if (d && typeof d === 'object' && !Array.isArray(d)) {
84
84
  dataPatch = { ...d };
85
85
  }
86
+ if (Object.prototype.hasOwnProperty.call(fmObj, 'layout') && fmObj.layout !== undefined) {
87
+ dataPatch.layout = fmObj.layout;
88
+ }
86
89
 
87
90
  return { metaPatch, dataPatch };
88
91
  }
@@ -16,6 +16,8 @@ const EMPTY_ARRAY = Object.freeze([]);
16
16
  // Caches for production mode (key: directory path)
17
17
  const middlewareFileCache = new Map();
18
18
  const layoutFileCache = new Map();
19
+ const errorFileCache = new Map();
20
+ const loadingFileCache = new Map();
19
21
 
20
22
  /**
21
23
  * Returns ancestor directory segments from root down to file's directory.
@@ -359,9 +361,117 @@ function applyCascadingLayouts(nunjucksEnv, layouts, pageHtml, renderContext, is
359
361
  return currentHtml;
360
362
  }
361
363
 
364
+ /**
365
+ * Discover the nearest _error.njk error boundary template in the directory hierarchy
366
+ * (from the file's directory segment climbing up to the root pages directory).
367
+ *
368
+ * @param {string} baseDir - Base pages directory
369
+ * @param {string} relativeFilePath - Relative file path (e.g. 'dashboard/billing/invoices.njk')
370
+ * @param {boolean} [isDev=false]
371
+ * @returns {{ dirSegment: string, errorRelPath: string, fullPath: string, configPath: string|null }|null}
372
+ */
373
+ function findNearestErrorBoundary(baseDir, relativeFilePath, isDev = false) {
374
+ const chain = getDirectoryChain(relativeFilePath);
375
+ const cacheKey = `${baseDir}::${chain.join('>')}`;
376
+
377
+ if (!isDev && errorFileCache.has(cacheKey)) {
378
+ return errorFileCache.get(cacheKey);
379
+ }
380
+
381
+ const reversedChain = [...chain].reverse();
382
+
383
+ for (const dirSegment of reversedChain) {
384
+ const errorNjkName = '_error.njk';
385
+ const errorJsName = '_error.js';
386
+
387
+ const errorRelPath = dirSegment === ''
388
+ ? errorNjkName
389
+ : `${dirSegment}/${errorNjkName}`;
390
+
391
+ const fullPath = dirSegment === ''
392
+ ? path.join(baseDir, errorNjkName)
393
+ : path.join(baseDir, ...dirSegment.split('/'), errorNjkName);
394
+
395
+ const fullJsPath = dirSegment === ''
396
+ ? path.join(baseDir, errorJsName)
397
+ : path.join(baseDir, ...dirSegment.split('/'), errorJsName);
398
+
399
+ if (fs.existsSync(fullPath)) {
400
+ const result = {
401
+ dirSegment,
402
+ errorRelPath: errorRelPath.split(path.sep).join('/'),
403
+ fullPath,
404
+ configPath: fs.existsSync(fullJsPath) ? fullJsPath : null,
405
+ };
406
+
407
+ if (!isDev) {
408
+ errorFileCache.set(cacheKey, result);
409
+ }
410
+ return result;
411
+ }
412
+ }
413
+
414
+ if (!isDev) {
415
+ errorFileCache.set(cacheKey, null);
416
+ }
417
+ return null;
418
+ }
419
+
420
+ /**
421
+ * Discover the nearest _loading.njk loading/skeleton template in the directory hierarchy
422
+ * (from the file's directory segment climbing up to the root pages directory).
423
+ *
424
+ * @param {string} baseDir - Base pages directory
425
+ * @param {string} relativeFilePath - Relative file path (e.g. 'dashboard/billing/invoices.njk')
426
+ * @param {boolean} [isDev=false]
427
+ * @returns {{ dirSegment: string, loadingRelPath: string, fullPath: string }|null}
428
+ */
429
+ function findNearestLoadingTemplate(baseDir, relativeFilePath, isDev = false) {
430
+ const chain = getDirectoryChain(relativeFilePath);
431
+ const cacheKey = `${baseDir}::${chain.join('>')}`;
432
+
433
+ if (!isDev && loadingFileCache.has(cacheKey)) {
434
+ return loadingFileCache.get(cacheKey);
435
+ }
436
+
437
+ const reversedChain = [...chain].reverse();
438
+
439
+ for (const dirSegment of reversedChain) {
440
+ const loadingNjkName = '_loading.njk';
441
+
442
+ const loadingRelPath = dirSegment === ''
443
+ ? loadingNjkName
444
+ : `${dirSegment}/${loadingNjkName}`;
445
+
446
+ const fullPath = dirSegment === ''
447
+ ? path.join(baseDir, loadingNjkName)
448
+ : path.join(baseDir, ...dirSegment.split('/'), loadingNjkName);
449
+
450
+ if (fs.existsSync(fullPath)) {
451
+ const result = {
452
+ dirSegment,
453
+ loadingRelPath: loadingRelPath.split(path.sep).join('/'),
454
+ fullPath,
455
+ };
456
+
457
+ if (!isDev) {
458
+ loadingFileCache.set(cacheKey, result);
459
+ }
460
+ return result;
461
+ }
462
+ }
463
+
464
+ if (!isDev) {
465
+ loadingFileCache.set(cacheKey, null);
466
+ }
467
+ return null;
468
+ }
469
+
362
470
  function clearCascadingCaches() {
363
471
  middlewareFileCache.clear();
364
472
  layoutFileCache.clear();
473
+ errorFileCache.clear();
474
+ loadingFileCache.clear();
365
475
  }
366
476
 
367
477
  module.exports = {
@@ -374,5 +484,7 @@ module.exports = {
374
484
  findCascadingLayouts,
375
485
  executeCascadingLayoutLoads,
376
486
  applyCascadingLayouts,
487
+ findNearestErrorBoundary,
488
+ findNearestLoadingTemplate,
377
489
  clearCascadingCaches,
378
490
  };