webspresso 0.0.107 → 0.0.108

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 (91) hide show
  1. package/.agents/AGENTS.md +4 -1
  2. package/.agents/CACHE.md +1 -0
  3. package/.agents/CONTEXT.md +152 -0
  4. package/.agents/ORM.md +74 -0
  5. package/.agents/SHUTDOWN.md +8 -1
  6. package/.agents/skills/webspresso-framework/SKILL.md +2 -1
  7. package/.agents/skills/webspresso-orm-and-database/SKILL.md +9 -0
  8. package/.agents/skills/webspresso-routing-and-api/SKILL.md +18 -2
  9. package/.agents/skills/webspresso-services/SKILL.md +36 -3
  10. package/AGENTS.md +6 -3
  11. package/core/app.js +7 -5
  12. package/core/applySchema.js +3 -72
  13. package/core/cache/lru.js +10 -0
  14. package/core/cache/tagged-lru.js +4 -0
  15. package/core/compileSchema.js +3 -68
  16. package/core/compression/compressible.js +53 -6
  17. package/core/compression/negotiate.js +31 -12
  18. package/core/context/app-context.js +201 -0
  19. package/core/context/index.js +55 -0
  20. package/core/context/request.js +241 -0
  21. package/core/events/dispatcher.js +144 -15
  22. package/core/events/signal.js +9 -0
  23. package/core/health/probes.js +12 -2
  24. package/core/http/index.js +10 -1
  25. package/core/http/pipeline.js +519 -368
  26. package/core/index.js +24 -1
  27. package/core/orm/eager-loader.js +90 -55
  28. package/core/orm/index.js +3 -39
  29. package/core/orm/model.js +6 -1
  30. package/core/orm/schema-helpers.js +59 -0
  31. package/core/orm/transaction.js +109 -14
  32. package/core/plugins/index.js +8 -0
  33. package/core/plugins/plugin-manager.js +1098 -0
  34. package/core/queue/manager.js +55 -27
  35. package/core/rate-limit/stores/memory.js +23 -10
  36. package/core/resilience/circuit-breaker.js +34 -14
  37. package/core/schema-cache.js +2 -64
  38. package/core/services/builtins/auth.js +273 -0
  39. package/core/services/builtins/exchange.js +229 -0
  40. package/core/services/builtins/file-manager.js +178 -0
  41. package/core/services/builtins/mail.js +134 -0
  42. package/core/services/builtins/media.js +196 -0
  43. package/core/services/builtins/system.js +219 -0
  44. package/core/services/discovery.js +133 -0
  45. package/core/services/executor.js +629 -0
  46. package/core/services/index.js +110 -5
  47. package/core/services/loader.js +161 -0
  48. package/core/services/memoize.js +408 -0
  49. package/core/services/registry.js +731 -0
  50. package/core/services/validator.js +424 -0
  51. package/core/shutdown/shutdown-manager.js +146 -85
  52. package/core/ssr/stream.js +109 -16
  53. package/core/stream/text-stream.js +45 -4
  54. package/core/utils/index.js +110 -0
  55. package/core/utils/timeout.js +4 -0
  56. package/core/validation/apply.js +74 -0
  57. package/core/validation/cache.js +66 -0
  58. package/core/validation/compiler.js +77 -0
  59. package/core/validation/index.js +22 -1
  60. package/index.d.ts +77 -4
  61. package/index.js +64 -20
  62. package/package.json +1 -1
  63. package/src/app-context.js +4 -196
  64. package/src/dev/typegen.js +1 -1
  65. package/src/plugin-manager.js +5 -1086
  66. package/src/server.js +4 -1
  67. package/src/services/builtins/auth.js +2 -273
  68. package/src/services/builtins/exchange.js +2 -229
  69. package/src/services/builtins/file-manager.js +2 -178
  70. package/src/services/builtins/mail.js +2 -134
  71. package/src/services/builtins/media.js +2 -196
  72. package/src/services/builtins/system.js +2 -219
  73. package/src/services/discovery.js +2 -133
  74. package/src/services/executor.js +2 -575
  75. package/src/services/index.js +4 -109
  76. package/src/services/loader.js +1 -119
  77. package/src/services/memoize.js +2 -408
  78. package/src/services/registry.js +1 -711
  79. package/src/services/validator.js +2 -424
  80. package/core/utils/array.js +0 -12
  81. package/core/utils/base64url.js +0 -8
  82. package/core/utils/ensure-leading-slash.js +0 -6
  83. package/core/utils/escape-html.js +0 -7
  84. package/core/utils/escape-regexp.js +0 -7
  85. package/core/utils/get-client-ip.js +0 -6
  86. package/core/utils/hmac.js +0 -6
  87. package/core/utils/random.js +0 -9
  88. package/core/utils/safe-compare.js +0 -3
  89. package/core/utils/sha256.js +0 -6
  90. package/core/utils/strip-trailing-slash.js +0 -6
  91. package/core/utils/walk.js +0 -7
@@ -6,34 +6,89 @@
6
6
  * @module core/http/pipeline
7
7
  */
8
8
 
9
+ const fs = require('fs');
9
10
  const path = require('path');
10
11
  const { ZodError } = require('zod');
11
12
  const { translateValidationIssues, getTranslatorForRequest } = require('../../src/api/validation-i18n');
13
+ const { runWithContext } = require('../context/request');
14
+ const { detectLocale, loadI18n, createTranslator, resolvePageAssets, applyPageAssetsToTemplateData } = require('../../src/file-router');
15
+ const { createHelpers, getAssetManager } = require('../../src/helpers');
16
+ const { sendEarlyHints } = require('../early-hints');
12
17
 
13
18
  /**
14
- * Runs a sequence of middleware functions
15
- * Supports Express-style (req, res, next)
19
+ * Validates request input against compiled schema
20
+ * @param {Object} req - Request object
21
+ * @param {Object} compiledSchema - Compiled Zod schema object
22
+ */
23
+ function validateRequestInput(req, compiledSchema) {
24
+ req.input = {
25
+ body: undefined,
26
+ params: undefined,
27
+ query: undefined,
28
+ headers: undefined,
29
+ };
30
+
31
+ if (!compiledSchema) return;
32
+
33
+ if (compiledSchema.body && typeof compiledSchema.body.parse === 'function') {
34
+ req.input.body = compiledSchema.body.parse(req.body);
35
+ } else {
36
+ req.input.body = req.body;
37
+ }
38
+
39
+ if (compiledSchema.params && typeof compiledSchema.params.parse === 'function') {
40
+ req.input.params = compiledSchema.params.parse(req.params);
41
+ } else {
42
+ req.input.params = req.params;
43
+ }
44
+
45
+ if (compiledSchema.query && typeof compiledSchema.query.parse === 'function') {
46
+ req.input.query = compiledSchema.query.parse(req.query);
47
+ } else {
48
+ req.input.query = req.query;
49
+ }
50
+
51
+ if (compiledSchema.headers && typeof compiledSchema.headers.parse === 'function') {
52
+ req.input.headers = compiledSchema.headers.parse(req.headers);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Runs a sequence of middleware functions.
58
+ * Supports Express-style (req, res, next), 2-arg modern async (req, res),
59
+ * error propagation, and response termination detection.
60
+ *
16
61
  * @param {Function[]} middlewares
17
62
  * @param {Object} req
18
63
  * @param {Object} res
64
+ * @returns {Promise<void>}
19
65
  */
20
66
  async function runMiddlewares(middlewares, req, res) {
21
- if (!Array.isArray(middlewares)) return;
67
+ if (!Array.isArray(middlewares) || middlewares.length === 0) return;
22
68
 
23
69
  for (const mw of middlewares) {
24
70
  if (typeof mw !== 'function') continue;
71
+
72
+ // Fast-path: if response already finished/flushed, stop executing chain
73
+ if (res && (res.headersSent || res.writableEnded || res.finished)) return;
74
+
25
75
  await new Promise((resolve, reject) => {
26
76
  let settled = false;
77
+ const prevEnd = typeof res?.end === 'function' ? res.end : null;
78
+
27
79
  const done = (err) => {
28
80
  if (settled) return;
29
81
  settled = true;
82
+ if (res && prevEnd && res.end !== prevEnd) {
83
+ res.end = prevEnd;
84
+ }
30
85
  if (err) reject(err);
31
86
  else resolve();
32
87
  };
33
88
 
34
- const prevEnd = res?.end;
35
- if (res && typeof res.end === 'function') {
89
+ if (prevEnd) {
36
90
  res.end = function (...args) {
91
+ if (res && prevEnd) res.end = prevEnd;
37
92
  const ret = prevEnd.apply(this, args);
38
93
  done();
39
94
  return ret;
@@ -41,33 +96,63 @@ async function runMiddlewares(middlewares, req, res) {
41
96
  }
42
97
 
43
98
  try {
44
- mw(req, res, (err) => {
99
+ const ret = mw(req, res, (err) => {
45
100
  done(err);
46
101
  });
102
+
103
+ if (ret && typeof ret.then === 'function') {
104
+ ret.then(
105
+ () => {
106
+ // Auto-advance for 2-arg async middlewares or when response was sent
107
+ if (mw.length < 3 || (res && (res.headersSent || res.writableEnded || res.finished))) {
108
+ done();
109
+ }
110
+ },
111
+ (asyncErr) => {
112
+ // Asynchronous exception in async middleware caught and forwarded
113
+ done(asyncErr);
114
+ }
115
+ );
116
+ } else if (mw.length < 3 || (res && (res.headersSent || res.writableEnded || res.finished))) {
117
+ done();
118
+ }
47
119
  } catch (syncErr) {
48
120
  done(syncErr);
49
121
  }
50
122
  });
51
- if (res && res.headersSent) return;
123
+
124
+ if (res && (res.headersSent || res.writableEnded || res.finished)) return;
52
125
  }
53
126
  }
54
127
 
128
+ /**
129
+ * Creates a reusable pipeline runner function for a middleware array
130
+ * @param {Function[]} [middlewares=[]]
131
+ * @returns {(req: any, res: any) => Promise<void>}
132
+ */
133
+ function createPipelineRunner(middlewares = []) {
134
+ const mws = Array.isArray(middlewares) ? middlewares.slice() : [];
135
+ return function pipelineRunner(req, res) {
136
+ return runMiddlewares(mws, req, res);
137
+ };
138
+ }
139
+
55
140
  /**
56
141
  * Decorates an error with route execution metadata
57
142
  * @param {Error} err
58
- * @param {Object} descriptor
59
- * @param {Object} req
143
+ * @param {Object} [descriptor]
144
+ * @param {Object} [req]
60
145
  * @returns {Error}
61
146
  */
62
- function enrichRouteError(err, descriptor, req) {
147
+ function enrichRouteError(err, descriptor = {}, req = {}) {
63
148
  if (err && typeof err === 'object') {
64
- if (!err.route) err.route = descriptor.path;
65
- if (!err.method) err.method = descriptor.method ? descriptor.method.toUpperCase() : (req.method || 'GET');
66
- if (!err.source) err.source = descriptor.source || descriptor.file;
67
- if (!err.file) err.file = descriptor.file;
68
- if (!err.module) err.module = descriptor.module || null;
69
- if (!err.phase) err.phase = descriptor && descriptor.type === 'page' ? 'page' : 'handler';
70
- if (!err.requestId) err.requestId = req && (req.id || (req.headers && req.headers['x-request-id'])) || null;
149
+ if (!err.route && descriptor?.path) err.route = descriptor.path;
150
+ if (!err.method) err.method = descriptor?.method ? descriptor.method.toUpperCase() : (req?.method || 'GET');
151
+ if (!err.source && (descriptor?.source || descriptor?.file)) err.source = descriptor.source || descriptor.file;
152
+ if (!err.file && descriptor?.file) err.file = descriptor.file;
153
+ if (!err.module && descriptor?.module) err.module = descriptor.module;
154
+ if (!err.phase) err.phase = descriptor?.type === 'page' ? 'page' : 'handler';
155
+ if (!err.requestId) err.requestId = (req && (req.id || (req.headers && req.headers['x-request-id']))) || null;
71
156
  }
72
157
  return err;
73
158
  }
@@ -87,7 +172,7 @@ function enrichRouteError(err, descriptor, req) {
87
172
  */
88
173
  async function executeApiPipeline(params) {
89
174
  const {
90
- descriptor,
175
+ descriptor = {},
91
176
  context = {},
92
177
  apiDef,
93
178
  handlerFn,
@@ -97,159 +182,171 @@ async function executeApiPipeline(params) {
97
182
  res,
98
183
  } = params;
99
184
 
100
- const appOptions = context.options || {};
101
- const isDev = context.isDev !== undefined
102
- ? context.isDev
103
- : (appOptions.isDev !== undefined ? appOptions.isDev : process.env.NODE_ENV === 'development');
104
- const db = context.db || req.db;
105
- const serviceRegistry = context.serviceRegistry;
106
-
107
- // 1. Injections
108
- if (db && !req.db) req.db = db;
109
- if (!req.service && serviceRegistry) {
110
- const getCallContext = (explicitCtx) => {
111
- if (explicitCtx) return explicitCtx;
112
- const ctx = req.context ? { ...req.context } : { req, res, db: req.db || db };
113
- if (!ctx.req) ctx.req = req;
114
- if (!ctx.res) ctx.res = res;
115
- if (!ctx.db) ctx.db = req.db || db;
116
- if (req.user !== undefined) ctx.user = req.user;
117
- if (req.auth !== undefined) ctx.auth = req.auth;
118
- if (req.session !== undefined) ctx.session = req.session;
119
- return ctx;
120
- };
121
- req.service = (name, input, opts) => {
122
- return serviceRegistry.call(name, input, getCallContext(opts?.ctx), opts);
123
- };
124
- req.service.call = (name, input, opts) => req.service(name, input, opts);
125
- req.service.execute = (name, input, opts) => req.service(name, input, opts);
126
- req.service.invalidate = (name, input, ctx) =>
127
- serviceRegistry.invalidate(name, input, getCallContext(ctx));
128
- req.service.clearCache = (name) =>
129
- serviceRegistry.clearCache(name);
130
- req.service.invalidateByTag = (tag, serviceName) =>
131
- serviceRegistry.invalidateByTag(tag, serviceName);
132
- req.service.invalidateByTags = (tags, serviceName) =>
133
- serviceRegistry.invalidateByTags(tags, serviceName);
134
- req.service.invalidateTag = req.service.invalidateByTag;
135
- req.service.invalidateTags = req.service.invalidateByTags;
136
- }
185
+ try {
186
+ const appOptions = context.options || {};
187
+ const isDev = context.isDev !== undefined
188
+ ? context.isDev
189
+ : (appOptions.isDev !== undefined ? appOptions.isDev : process.env.NODE_ENV === 'development');
190
+ const db = context.db || req.db;
191
+ const serviceRegistry = context.serviceRegistry;
192
+
193
+ // 1. Injections
194
+ if (db && !req.db) req.db = db;
195
+ if (!req.service && serviceRegistry) {
196
+ const getCallContext = (explicitCtx) => {
197
+ if (explicitCtx) return explicitCtx;
198
+ const ctx = req.context ? { ...req.context } : { req, res, db: req.db || db };
199
+ if (!ctx.req) ctx.req = req;
200
+ if (!ctx.res) ctx.res = res;
201
+ if (!ctx.db) ctx.db = req.db || db;
202
+ if (req.user !== undefined) ctx.user = req.user;
203
+ if (req.auth !== undefined) ctx.auth = req.auth;
204
+ if (req.session !== undefined) ctx.session = req.session;
205
+ return ctx;
206
+ };
207
+ req.service = (name, input, opts) => {
208
+ return serviceRegistry.call(name, input, getCallContext(opts?.ctx), opts);
209
+ };
210
+ req.service.call = req.service;
211
+ req.service.execute = req.service;
212
+ req.service.invalidate = (name, input, ctx) =>
213
+ serviceRegistry.invalidate(name, input, getCallContext(ctx));
214
+ req.service.clearCache = (name) =>
215
+ serviceRegistry.clearCache(name);
216
+ req.service.invalidateByTag = (tag, serviceName) =>
217
+ serviceRegistry.invalidateByTag(tag, serviceName);
218
+ req.service.invalidateByTags = (tags, serviceName) =>
219
+ serviceRegistry.invalidateByTags(tags, serviceName);
220
+ req.service.invalidateTag = req.service.invalidateByTag;
221
+ req.service.invalidateTags = req.service.invalidateByTags;
222
+ }
137
223
 
138
- // 2. Schema validation
139
- if (compiledSchema) {
140
- try {
141
- const { validateRequestInput } = require('../../src/api/api-loader');
142
- validateRequestInput(req, compiledSchema);
143
- } catch (err) {
144
- if (err instanceof ZodError || err.name === 'ZodError' || Array.isArray(err?.issues)) {
145
- const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
146
- const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
147
- const translatedIssues = translateValidationIssues(err.issues, {
148
- req,
149
- pagesDir,
150
- routeDir,
151
- isDev,
152
- });
153
- if (res && typeof res.status === 'function' && typeof res.json === 'function') {
154
- return res.status(400).json({
155
- error: 'Validation Error',
156
- issues: translatedIssues,
224
+ // 2. Schema validation
225
+ const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
226
+ const routeDir = descriptor._routeDir !== undefined
227
+ ? descriptor._routeDir
228
+ : (descriptor._routeDir = descriptor.file ? path.dirname(descriptor.file) : '');
229
+
230
+ if (compiledSchema) {
231
+ try {
232
+ validateRequestInput(req, compiledSchema);
233
+ } catch (err) {
234
+ if (err instanceof ZodError || err.name === 'ZodError' || Array.isArray(err?.issues)) {
235
+ const translatedIssues = translateValidationIssues(err.issues, {
236
+ req,
237
+ pagesDir,
238
+ routeDir,
239
+ isDev,
157
240
  });
241
+ if (res && typeof res.status === 'function' && typeof res.json === 'function') {
242
+ return res.status(400).json({
243
+ error: 'Validation Error',
244
+ issues: translatedIssues,
245
+ });
246
+ }
247
+ const validationErr = new Error('Validation Error');
248
+ validationErr.status = 400;
249
+ validationErr.issues = translatedIssues;
250
+ throw validationErr;
158
251
  }
159
- const validationErr = new Error('Validation Error');
160
- validationErr.status = 400;
161
- validationErr.issues = translatedIssues;
162
- throw validationErr;
252
+ throw err;
163
253
  }
164
- throw err;
254
+ } else {
255
+ req.input = {
256
+ body: req.body,
257
+ params: req.params,
258
+ query: req.query,
259
+ };
165
260
  }
166
- } else {
167
- req.input = {
168
- body: req.body,
169
- params: req.params,
170
- query: req.query,
171
- };
172
- }
173
261
 
174
- // 2.5 Feature Flag Guard
175
- if (apiDef && apiDef.flag) {
176
- const flagKey = typeof apiDef.flag === 'string' ? apiDef.flag : apiDef.flag.key;
177
- const isEnabled = typeof req.hasFeature === 'function'
178
- ? req.hasFeature(flagKey)
179
- : ((context.app?.flags || context.flags)?.isEnabled(flagKey, req) ?? false);
180
-
181
- if (!isEnabled) {
182
- const statusCode = (typeof apiDef.flag === 'object' && apiDef.flag.statusCode) || 404;
183
- const message = (typeof apiDef.flag === 'object' && apiDef.flag.message) || (statusCode === 403 ? 'Forbidden' : 'Not Found');
184
-
185
- if (res && typeof res.status === 'function') {
186
- return res.status(statusCode).json({
187
- error: statusCode === 403 ? 'Forbidden' : 'Not Found',
188
- message,
189
- feature: flagKey,
190
- });
262
+ // 2.5 Feature Flag Guard
263
+ if (apiDef && apiDef.flag) {
264
+ const flagKey = typeof apiDef.flag === 'string' ? apiDef.flag : apiDef.flag.key;
265
+ const isEnabled = typeof req.hasFeature === 'function'
266
+ ? req.hasFeature(flagKey)
267
+ : ((context.app?.flags || context.flags)?.isEnabled(flagKey, req) ?? false);
268
+
269
+ if (!isEnabled) {
270
+ const statusCode = (typeof apiDef.flag === 'object' && apiDef.flag.statusCode) || 404;
271
+ const message = (typeof apiDef.flag === 'object' && apiDef.flag.message) || (statusCode === 403 ? 'Forbidden' : 'Not Found');
272
+
273
+ if (res && typeof res.status === 'function') {
274
+ return res.status(statusCode).json({
275
+ error: statusCode === 403 ? 'Forbidden' : 'Not Found',
276
+ message,
277
+ feature: flagKey,
278
+ });
279
+ }
280
+ const notFound = new Error(message);
281
+ notFound.status = statusCode;
282
+ notFound.statusCode = statusCode;
283
+ throw notFound;
191
284
  }
192
- const notFound = new Error(message);
193
- notFound.status = statusCode;
194
- notFound.statusCode = statusCode;
195
- throw notFound;
196
285
  }
197
- }
198
286
 
199
- // 3. Middlewares
200
- if (middlewares.length > 0) {
201
- await runMiddlewares(middlewares, req, res);
202
- if (res && res.headersSent) return;
203
- }
287
+ // 3. Build Context
288
+ const tr = getTranslatorForRequest(req, { pagesDir, routeDir, isDev });
289
+
290
+ const apiCtx = {
291
+ req,
292
+ res,
293
+ params: req.params || {},
294
+ query: req.query || {},
295
+ body: req.body || {},
296
+ input: req.input,
297
+ locale: tr.locale,
298
+ t: tr.t,
299
+ service: req.service
300
+ ? req.service
301
+ : (name, input, opts) =>
302
+ serviceRegistry ? serviceRegistry.call(name, input, req.context || { req, res, db, user: req.user, session: req.session }, opts) : null,
303
+ module: descriptor.module || null,
304
+ config: appOptions,
305
+ logger: req.logger || console,
306
+ db,
307
+ user: req.user,
308
+ auth: req.auth,
309
+ session: req.session,
310
+ queue: req.queue || context.queue || null,
311
+ cache: req.cache || context.cache || null,
312
+ flags: req.flags || null,
313
+ hasFeature: typeof req.hasFeature === 'function' ? (k, c) => req.hasFeature(k, c) : () => false,
314
+ getVariant: typeof req.getVariant === 'function' ? (k, c) => req.getVariant(k, c) : () => null,
315
+ sse: (sourceOrOptions, maybeOptions) => (res && typeof res.sse === 'function' ? res.sse(sourceOrOptions, maybeOptions) : null),
316
+ streamText: (sourceOrCallback, maybeOptions) => (res && typeof res.streamText === 'function' ? res.streamText(sourceOrCallback, maybeOptions) : null),
317
+ streamJson: (sourceOrCallback, maybeOptions) => (res && typeof res.streamJson === 'function' ? res.streamJson(sourceOrCallback, maybeOptions) : null),
318
+ };
204
319
 
205
- // 4. Build Context & Execute
206
- const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
207
- const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
208
- const tr = getTranslatorForRequest(req, { pagesDir, routeDir, isDev });
320
+ req.ctx = apiCtx;
321
+ if (res) {
322
+ res.service = apiCtx.service;
323
+ res.input = apiCtx.input;
324
+ res.module = apiCtx.module;
325
+ }
209
326
 
210
- const apiCtx = {
211
- req,
212
- res,
213
- params: req.params || {},
214
- query: req.query || {},
215
- body: req.body || {},
216
- input: req.input,
217
- locale: tr.locale,
218
- t: tr.t,
219
- service: req.service
220
- ? req.service
221
- : (name, input, opts) =>
222
- serviceRegistry ? serviceRegistry.call(name, input, req.context || { req, res, db, user: req.user, session: req.session }, opts) : null,
223
- module: descriptor.module || null,
224
- config: appOptions,
225
- logger: console,
226
- db,
227
- flags: req.flags || null,
228
- hasFeature: typeof req.hasFeature === 'function' ? (k, c) => req.hasFeature(k, c) : () => false,
229
- getVariant: typeof req.getVariant === 'function' ? (k, c) => req.getVariant(k, c) : () => null,
230
- sse: (sourceOrOptions, maybeOptions) => (res && typeof res.sse === 'function' ? res.sse(sourceOrOptions, maybeOptions) : null),
231
- streamText: (sourceOrCallback, maybeOptions) => (res && typeof res.streamText === 'function' ? res.streamText(sourceOrCallback, maybeOptions) : null),
232
- streamJson: (sourceOrCallback, maybeOptions) => (res && typeof res.streamJson === 'function' ? res.streamJson(sourceOrCallback, maybeOptions) : null),
233
- };
327
+ // 4. Execute within Ambient Request Context
328
+ return await runWithContext(apiCtx, async () => {
329
+ if (middlewares.length > 0) {
330
+ await runMiddlewares(middlewares, req, res);
331
+ if (res && (res.headersSent || res.writableEnded || res.finished)) return;
332
+ }
234
333
 
235
- req.ctx = apiCtx;
236
- if (res) {
237
- res.service = apiCtx.service;
238
- res.input = apiCtx.input;
239
- res.module = apiCtx.module;
240
- }
334
+ const result = await handlerFn(req, res, apiCtx);
241
335
 
242
- const result = await handlerFn(req, res, apiCtx);
336
+ // 5. Auto-respond if handler returned a value and response not yet sent
337
+ if (res && !res.headersSent && !res.writableEnded && !res.finished) {
338
+ if (result !== undefined && typeof res.json === 'function') {
339
+ res.json(result);
340
+ return result;
341
+ }
342
+ }
243
343
 
244
- // 5. Auto-respond if handler returned a value and response not yet sent
245
- if (res && !res.headersSent) {
246
- if (result !== undefined && typeof res.json === 'function') {
247
- res.json(result);
248
344
  return result;
249
- }
345
+ });
346
+ } catch (err) {
347
+ enrichRouteError(err, descriptor, req);
348
+ throw err;
250
349
  }
251
-
252
- return result;
253
350
  }
254
351
 
255
352
  /**
@@ -258,7 +355,7 @@ async function executeApiPipeline(params) {
258
355
  */
259
356
  async function executePagePipeline(params) {
260
357
  const {
261
- descriptor,
358
+ descriptor = {},
262
359
  context = {},
263
360
  pageDef,
264
361
  middlewares = [],
@@ -269,251 +366,305 @@ async function executePagePipeline(params) {
269
366
  hasNjkTemplate,
270
367
  } = params;
271
368
 
272
- const appOptions = context.options || {};
273
- const isDev = context.isDev !== undefined
274
- ? context.isDev
275
- : (appOptions.isDev !== undefined ? appOptions.isDev : process.env.NODE_ENV === 'development');
276
- const db = context.db || req.db;
277
- const serviceRegistry = context.serviceRegistry;
278
- const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
279
-
280
- // 1. Injections
281
- if (db && !req.db) req.db = db;
282
- if (!req.service && serviceRegistry) {
283
- const baseContext = req.context || { req, res, db, user: req.user, session: req.session };
284
- req.service = (name, input, opts) =>
285
- serviceRegistry.call(name, input, baseContext, opts);
286
- req.service.call = (name, input, opts) => req.service(name, input, opts);
287
- req.service.execute = (name, input, opts) => req.service(name, input, opts);
288
- req.service.invalidate = (name, input, ctx) =>
289
- serviceRegistry.invalidate(name, input, ctx || baseContext);
290
- req.service.clearCache = (name) =>
291
- serviceRegistry.clearCache(name);
292
- req.service.invalidateByTag = (tag, serviceName) =>
293
- serviceRegistry.invalidateByTag(tag, serviceName);
294
- req.service.invalidateByTags = (tags, serviceName) =>
295
- serviceRegistry.invalidateByTags(tags, serviceName);
296
- req.service.invalidateTag = req.service.invalidateByTag;
297
- req.service.invalidateTags = req.service.invalidateByTags;
298
- }
299
-
300
- // 2. Middlewares
301
- if (middlewares.length > 0) {
302
- await runMiddlewares(middlewares, req, res);
303
- if (res && res.headersSent) return;
304
- }
369
+ try {
370
+ const appOptions = context.options || {};
371
+ const isDev = context.isDev !== undefined
372
+ ? context.isDev
373
+ : (appOptions.isDev !== undefined ? appOptions.isDev : process.env.NODE_ENV === 'development');
374
+ const db = context.db || req.db;
375
+ const serviceRegistry = context.serviceRegistry;
376
+ const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
377
+
378
+ // 1. Injections
379
+ if (db && !req.db) req.db = db;
380
+ if (!req.service && serviceRegistry) {
381
+ const baseContext = req.context || { req, res, db, user: req.user, session: req.session };
382
+ req.service = (name, input, opts) =>
383
+ serviceRegistry.call(name, input, baseContext, opts);
384
+ req.service.call = req.service;
385
+ req.service.execute = req.service;
386
+ req.service.invalidate = (name, input, ctx) =>
387
+ serviceRegistry.invalidate(name, input, ctx || baseContext);
388
+ req.service.clearCache = (name) =>
389
+ serviceRegistry.clearCache(name);
390
+ req.service.invalidateByTag = (tag, serviceName) =>
391
+ serviceRegistry.invalidateByTag(tag, serviceName);
392
+ req.service.invalidateByTags = (tags, serviceName) =>
393
+ serviceRegistry.invalidateByTags(tags, serviceName);
394
+ req.service.invalidateTag = req.service.invalidateByTag;
395
+ req.service.invalidateTags = req.service.invalidateByTags;
396
+ }
305
397
 
306
- // 3. Build Page Context
307
- const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 7331}`;
308
- const { detectLocale, loadI18n, createTranslator, resolvePageAssets, applyPageAssetsToTemplateData } = require('../../src/file-router');
309
- const { createHelpers, getAssetManager } = require('../../src/helpers');
310
- const { sendEarlyHints } = require('../early-hints');
311
-
312
- const locale = detectLocale(req);
313
- const fsy = createHelpers({ req, res, baseUrl, locale });
314
-
315
- const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
316
- let t;
317
- const translatorCache = context.translatorCache;
318
- if (!isDev && translatorCache) {
319
- const transKey = `${pagesDir}::::${locale}::${defaultLocale}`;
320
- t = translatorCache.get(transKey);
321
- if (!t) {
322
- const translations = loadI18n(pagesDir, '', locale, false);
323
- const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, false) : undefined;
398
+ // 2. Build Page Context
399
+ const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 7331}`;
400
+ const locale = detectLocale(req);
401
+ const fsy = createHelpers({ req, res, baseUrl, locale });
402
+
403
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
404
+ let t;
405
+ const translatorCache = context.translatorCache;
406
+ if (!isDev && translatorCache) {
407
+ const transKey = `${pagesDir}::::${locale}::${defaultLocale}`;
408
+ t = translatorCache.get(transKey);
409
+ if (!t) {
410
+ const translations = loadI18n(pagesDir, '', locale, false);
411
+ const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, false) : undefined;
412
+ t = createTranslator(translations, {
413
+ locale,
414
+ fallbackTranslations,
415
+ fallbackLocale: defaultLocale,
416
+ });
417
+ translatorCache.set(transKey, t);
418
+ }
419
+ } else {
420
+ const translations = loadI18n(pagesDir, '', locale, isDev);
421
+ const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, isDev) : undefined;
324
422
  t = createTranslator(translations, {
325
423
  locale,
326
424
  fallbackTranslations,
327
425
  fallbackLocale: defaultLocale,
328
426
  });
329
- translatorCache.set(transKey, t);
330
427
  }
331
- } else {
332
- const translations = loadI18n(pagesDir, '', locale, isDev);
333
- const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, isDev) : undefined;
334
- t = createTranslator(translations, {
428
+
429
+ const pageCtx = {
430
+ req,
431
+ res,
432
+ params: req.params || {},
433
+ query: req.query || {},
434
+ service: req.service
435
+ ? req.service
436
+ : (name, input, opts) =>
437
+ serviceRegistry ? serviceRegistry.call(name, input, req.context || { req, res, db, user: req.user, session: req.session }, opts) : null,
438
+ module: descriptor.module || null,
439
+ config: appOptions,
440
+ logger: req.logger || console,
441
+ redirect: (url, status = 302) => {
442
+ if (res && typeof res.redirect === 'function') {
443
+ res.redirect(status, url);
444
+ }
445
+ return { __redirected: true };
446
+ },
447
+ error: (status = 500, message = 'Page Error') => {
448
+ const err = new Error(message);
449
+ err.status = status;
450
+ throw err;
451
+ },
452
+ earlyHints: (links, cb) => sendEarlyHints(res, links, cb),
453
+ renderStream: (tpl, data = {}, opts = {}) => {
454
+ if (res && typeof res.renderStream === 'function') {
455
+ return res.renderStream(tpl, data, opts);
456
+ }
457
+ const { renderStream } = require('../ssr/stream');
458
+ return renderStream(res, tpl, data, { env: nunjucks, ...opts });
459
+ },
460
+ streamText: (sourceOrCallback, maybeOptions) => {
461
+ if (res && typeof res.streamText === 'function') {
462
+ return res.streamText(sourceOrCallback, maybeOptions);
463
+ }
464
+ const { streamText } = require('../stream/text-stream');
465
+ return streamText(req, res, sourceOrCallback, maybeOptions);
466
+ },
467
+ streamJson: (sourceOrCallback, maybeOptions) => {
468
+ if (res && typeof res.streamJson === 'function') {
469
+ return res.streamJson(sourceOrCallback, maybeOptions);
470
+ }
471
+ const { streamJson } = require('../stream/text-stream');
472
+ return streamJson(req, res, sourceOrCallback, maybeOptions);
473
+ },
474
+ fsy,
335
475
  locale,
336
- fallbackTranslations,
337
- fallbackLocale: defaultLocale,
338
- });
339
- }
476
+ t,
477
+ db,
478
+ user: req.user,
479
+ auth: req.auth,
480
+ session: req.session,
481
+ queue: req.queue || context.queue || null,
482
+ cache: req.cache || context.cache || null,
483
+ };
340
484
 
341
- const pageCtx = {
342
- req,
343
- res,
344
- params: req.params || {},
345
- query: req.query || {},
346
- service: req.service
347
- ? req.service
348
- : (name, input, opts) =>
349
- serviceRegistry ? serviceRegistry.call(name, input, req.context || { req, res, db, user: req.user, session: req.session }, opts) : null,
350
- module: descriptor.module || null,
351
- config: appOptions,
352
- logger: console,
353
- redirect: (url, status = 302) => {
354
- if (res && typeof res.redirect === 'function') {
355
- res.redirect(status, url);
485
+ req.ctx = pageCtx;
486
+
487
+ // 3. Execute within Ambient Request Context
488
+ return await runWithContext(pageCtx, async () => {
489
+ // 3.1. Middlewares
490
+ if (middlewares.length > 0) {
491
+ await runMiddlewares(middlewares, req, res);
492
+ if (res && (res.headersSent || res.writableEnded || res.finished)) return;
356
493
  }
357
- return { __redirected: true };
358
- },
359
- error: (status = 500, message = 'Page Error') => {
360
- const err = new Error(message);
361
- err.status = status;
362
- throw err;
363
- },
364
- earlyHints: (links, cb) => sendEarlyHints(res, links, cb),
365
- fsy,
366
- locale,
367
- t,
368
- db,
369
- };
370
494
 
371
- // 4. Early Hints
372
- const earlyHintsOpt = pageDef.earlyHints !== undefined
373
- ? pageDef.earlyHints
374
- : (descriptor.earlyHints !== undefined
375
- ? descriptor.earlyHints
376
- : (appOptions && (appOptions.earlyHints ?? appOptions.server?.earlyHints)));
377
-
378
- if (earlyHintsOpt && earlyHintsOpt !== false && res && !res.headersSent) {
379
- const auto = typeof earlyHintsOpt === 'object' ? (earlyHintsOpt.auto !== false) : Boolean(earlyHintsOpt);
380
- const linksToSend = [];
381
-
382
- if (Array.isArray(earlyHintsOpt)) {
383
- linksToSend.push(...earlyHintsOpt);
384
- } else if (typeof earlyHintsOpt === 'object' && earlyHintsOpt.links) {
385
- const customLinks = typeof earlyHintsOpt.links === 'function' ? earlyHintsOpt.links(req) : earlyHintsOpt.links;
386
- if (Array.isArray(customLinks)) linksToSend.push(...customLinks);
387
- else if (customLinks) linksToSend.push(customLinks);
388
- }
495
+ // 4. Early Hints
496
+ const earlyHintsOpt = pageDef.earlyHints !== undefined
497
+ ? pageDef.earlyHints
498
+ : (descriptor.earlyHints !== undefined
499
+ ? descriptor.earlyHints
500
+ : (appOptions && (appOptions.earlyHints ?? appOptions.server?.earlyHints)));
501
+
502
+ if (earlyHintsOpt && earlyHintsOpt !== false && res && !res.headersSent && !res.writableEnded && !res.finished) {
503
+ const auto = typeof earlyHintsOpt === 'object' ? (earlyHintsOpt.auto !== false) : Boolean(earlyHintsOpt);
504
+ const linksToSend = [];
505
+
506
+ if (Array.isArray(earlyHintsOpt)) {
507
+ linksToSend.push(...earlyHintsOpt);
508
+ } else if (typeof earlyHintsOpt === 'object' && earlyHintsOpt.links) {
509
+ const customLinks = typeof earlyHintsOpt.links === 'function' ? earlyHintsOpt.links(req) : earlyHintsOpt.links;
510
+ if (Array.isArray(customLinks)) linksToSend.push(...customLinks);
511
+ else if (customLinks) linksToSend.push(customLinks);
512
+ }
389
513
 
390
- if (auto) {
391
- const assetManager = fsy?.assetManager || getAssetManager();
392
- if (assetManager && typeof assetManager.getRouteAssetCandidates === 'function') {
393
- const candidates = assetManager.getRouteAssetCandidates(
394
- descriptor.source || descriptor.file,
395
- descriptor.path
396
- );
397
- if (Array.isArray(candidates)) {
398
- for (const cand of candidates) {
399
- if (typeof assetManager.hasPageCss === 'function' && assetManager.hasPageCss(cand)) {
400
- linksToSend.push(assetManager.pageCssUrl(cand));
401
- break;
402
- }
403
- }
404
- for (const cand of candidates) {
405
- if (typeof assetManager.hasPageJs === 'function' && assetManager.hasPageJs(cand)) {
406
- linksToSend.push(assetManager.pageJsUrl(cand));
407
- break;
514
+ if (auto) {
515
+ const assetManager = fsy?.assetManager || getAssetManager();
516
+ if (assetManager && typeof assetManager.getRouteAssetCandidates === 'function') {
517
+ const candidates = assetManager.getRouteAssetCandidates(
518
+ descriptor.source || descriptor.file,
519
+ descriptor.path
520
+ );
521
+ if (Array.isArray(candidates)) {
522
+ for (const cand of candidates) {
523
+ if (typeof assetManager.hasPageCss === 'function' && assetManager.hasPageCss(cand)) {
524
+ linksToSend.push(assetManager.pageCssUrl(cand));
525
+ break;
526
+ }
527
+ }
528
+ for (const cand of candidates) {
529
+ if (typeof assetManager.hasPageJs === 'function' && assetManager.hasPageJs(cand)) {
530
+ linksToSend.push(assetManager.pageJsUrl(cand));
531
+ break;
532
+ }
533
+ }
408
534
  }
409
535
  }
410
536
  }
537
+
538
+ if (linksToSend.length > 0) {
539
+ sendEarlyHints(res, linksToSend);
540
+ }
411
541
  }
412
- }
413
542
 
414
- if (linksToSend.length > 0) {
415
- sendEarlyHints(res, linksToSend);
416
- }
417
- }
543
+ // 5. Run load() lifecycle
544
+ let loadedData = {};
545
+ if (typeof pageDef.load === 'function') {
546
+ loadedData = (await pageDef.load(pageCtx)) || {};
547
+ if (loadedData.__redirected || (res && (res.headersSent || res.writableEnded || res.finished))) {
548
+ return;
549
+ }
550
+ }
418
551
 
419
- // 5. Run load() lifecycle
420
- let loadedData = {};
421
- if (typeof pageDef.load === 'function') {
422
- loadedData = (await pageDef.load(pageCtx)) || {};
423
- if (loadedData.__redirected || (res && res.headersSent)) {
424
- return;
425
- }
426
- }
552
+ // 6. Run head() lifecycle
553
+ let headData = {};
554
+ if (typeof pageDef.head === 'function') {
555
+ headData = (await pageDef.head(loadedData, pageCtx)) || {};
556
+ }
427
557
 
428
- // 6. Run head() lifecycle
429
- let headData = {};
430
- if (typeof pageDef.head === 'function') {
431
- headData = (await pageDef.head(loadedData, pageCtx)) || {};
432
- }
558
+ // 7. Check for companion Nunjucks template
559
+ const templateExists = hasNjkTemplate !== undefined && hasNjkTemplate !== null
560
+ ? hasNjkTemplate
561
+ : (njkCandidate ? fs.existsSync(njkCandidate) : false);
562
+
563
+ if (templateExists && nunjucks) {
564
+ let pageHead = headData;
565
+ let pageAssetsActive = false;
566
+ const pageAssetsOpt = context.pageAssets || (appOptions && appOptions.pageAssets);
567
+ if (pageAssetsOpt) {
568
+ const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
569
+ if (pageAssetsResolved.enabled) {
570
+ const pageAssetBundle = applyPageAssetsToTemplateData(
571
+ pageAssetsResolved,
572
+ loadedData,
573
+ {
574
+ file: descriptor.source || descriptor.file,
575
+ routePath: descriptor.path,
576
+ },
577
+ fsy?.assetManager || getAssetManager()
578
+ );
579
+ loadedData = pageAssetBundle.data;
580
+ pageHead = {
581
+ ...headData,
582
+ stylesheets: [...(headData.stylesheets || []), ...(pageAssetBundle.pageHead?.stylesheets || [])],
583
+ scripts: [...(headData.scripts || []), ...(pageAssetBundle.pageHead?.scripts || [])],
584
+ };
585
+ pageAssetsActive = true;
586
+ }
587
+ }
433
588
 
434
- // 7. Check for companion Nunjucks template
435
- const fs = require('fs');
436
- const templateExists = hasNjkTemplate !== undefined && hasNjkTemplate !== null
437
- ? hasNjkTemplate
438
- : (njkCandidate ? fs.existsSync(njkCandidate) : false);
439
-
440
- if (templateExists && nunjucks) {
441
- let pageHead = headData;
442
- let pageAssetsActive = false;
443
- const pageAssetsOpt = context.pageAssets || (appOptions && appOptions.pageAssets);
444
- if (pageAssetsOpt) {
445
- const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
446
- if (pageAssetsResolved.enabled) {
447
- const pageAssetBundle = applyPageAssetsToTemplateData(
448
- pageAssetsResolved,
449
- loadedData,
450
- {
451
- file: descriptor.source || descriptor.file,
452
- routePath: descriptor.path,
453
- },
454
- fsy?.assetManager || getAssetManager()
455
- );
456
- loadedData = pageAssetBundle.data;
457
- pageHead = {
458
- ...headData,
459
- stylesheets: [...(headData.stylesheets || []), ...(pageAssetBundle.pageHead?.stylesheets || [])],
460
- scripts: [...(headData.scripts || []), ...(pageAssetBundle.pageHead?.scripts || [])],
589
+ const templateData = {
590
+ ...loadedData,
591
+ head: pageHead,
592
+ pageHead,
593
+ pageAssets: pageAssetsActive || Boolean(loadedData.pageAssets),
594
+ fsy,
595
+ locale,
596
+ t,
597
+ params: req.params,
598
+ query: req.query,
461
599
  };
462
- pageAssetsActive = true;
463
- }
464
- }
465
600
 
466
- const templateData = {
467
- ...loadedData,
468
- head: pageHead,
469
- pageHead,
470
- pageAssets: pageAssetsActive || Boolean(loadedData.pageAssets),
471
- fsy,
472
- locale,
473
- t,
474
- params: req.params,
475
- query: req.query,
476
- };
601
+ const isStreaming = Boolean(
602
+ pageDef.stream ||
603
+ descriptor.stream ||
604
+ loadedData.__stream ||
605
+ loadedData.stream ||
606
+ loadedData.defer ||
607
+ loadedData.__defer
608
+ );
477
609
 
478
- return new Promise((resolve, reject) => {
479
- nunjucks.render(njkCandidate, templateData, (err, html) => {
480
- if (err) return reject(err);
481
- if (res) {
482
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
483
- res.send(html);
610
+ if (isStreaming && res && !res.headersSent) {
611
+ const { renderStream } = require('../ssr/stream');
612
+ const streamDefer = loadedData.defer || loadedData.__defer || pageDef.defer || {};
613
+ return await renderStream(res, njkCandidate, templateData, {
614
+ env: nunjucks,
615
+ defer: streamDefer,
616
+ status: 200,
617
+ loadingTemplate: pageDef.loadingTemplate || descriptor.loadingTemplate,
618
+ layouts: pageDef.layouts || descriptor.layouts,
619
+ });
484
620
  }
485
- resolve(html);
486
- });
487
- });
488
- }
489
621
 
490
- // 8. Render via definePage render() method
491
- if (typeof pageDef.render === 'function') {
492
- const html = await pageDef.render(loadedData, pageCtx);
493
- if (res) {
494
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
495
- return res.send(html);
496
- }
497
- return html;
498
- }
622
+ return new Promise((resolve, reject) => {
623
+ nunjucks.render(njkCandidate, templateData, (err, html) => {
624
+ if (err) return reject(err);
625
+ if (res && !res.headersSent && !res.writableEnded && !res.finished) {
626
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
627
+ res.send(html);
628
+ }
629
+ resolve(html);
630
+ });
631
+ });
632
+ }
499
633
 
500
- // 9. Fallback: if json or data
501
- if (typeof loadedData === 'object' && loadedData !== null) {
502
- if (res && typeof res.json === 'function') {
503
- return res.json(loadedData);
504
- }
505
- return loadedData;
506
- }
634
+ // 8. Render via definePage render() method
635
+ if (typeof pageDef.render === 'function') {
636
+ const html = await pageDef.render(loadedData, pageCtx);
637
+ if (res && !res.headersSent && !res.writableEnded && !res.finished) {
638
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
639
+ return res.send(html);
640
+ }
641
+ return html;
642
+ }
643
+
644
+ // 9. Fallback: if json or data
645
+ if (typeof loadedData === 'object' && loadedData !== null) {
646
+ if (res && !res.headersSent && !res.writableEnded && !res.finished && typeof res.json === 'function') {
647
+ return res.json(loadedData);
648
+ }
649
+ return loadedData;
650
+ }
507
651
 
508
- if (res && typeof res.send === 'function') {
509
- return res.send(String(loadedData));
652
+ if (res && !res.headersSent && !res.writableEnded && !res.finished && typeof res.send === 'function') {
653
+ return res.send(String(loadedData));
654
+ }
655
+ return String(loadedData);
656
+ });
657
+ } catch (err) {
658
+ enrichRouteError(err, descriptor, req);
659
+ throw err;
510
660
  }
511
- return String(loadedData);
512
661
  }
513
662
 
514
663
  module.exports = {
664
+ createPipelineRunner,
515
665
  runMiddlewares,
516
666
  enrichRouteError,
667
+ validateRequestInput,
517
668
  executeApiPipeline,
518
669
  executePagePipeline,
519
670
  };