webspresso 0.0.91 → 0.0.94

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 (86) hide show
  1. package/README.md +103 -2864
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/polar-migrate.js +50 -0
  9. package/bin/commands/seed.js +7 -1
  10. package/bin/commands/service.js +8 -2
  11. package/bin/commands/skill.js +7 -1
  12. package/bin/webspresso.js +4 -0
  13. package/core/auth/tokens.js +16 -0
  14. package/core/compileSchema.js +1 -5
  15. package/core/compression/compressible.js +17 -5
  16. package/core/compression/compression-stream.js +1 -1
  17. package/core/compression/middleware.js +9 -0
  18. package/core/compression/negotiate.js +23 -11
  19. package/core/kernel/app.js +2 -1
  20. package/core/kernel/index.js +5 -1
  21. package/core/orm/cache/fingerprint.js +23 -5
  22. package/core/orm/cache/layer.js +1 -1
  23. package/core/orm/eager-loader.js +54 -9
  24. package/core/orm/index.js +42 -3
  25. package/core/orm/model.js +11 -2
  26. package/core/orm/transaction.js +3 -1
  27. package/core/queue/adapters/database.js +46 -13
  28. package/core/queue/adapters/memory.js +21 -0
  29. package/core/queue/adapters/redis.js +21 -0
  30. package/core/queue/manager.js +46 -20
  31. package/core/ssr/stream.js +58 -14
  32. package/core/validation/index.js +70 -0
  33. package/index.d.ts +99 -5
  34. package/index.js +7 -1
  35. package/package.json +9 -1
  36. package/plugins/admin-panel/auth.js +10 -0
  37. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  38. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  39. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  40. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  41. package/plugins/admin-panel/index.js +65 -6
  42. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  43. package/plugins/admin-panel/modules/dashboard.js +8 -1
  44. package/plugins/content/index.js +5 -3
  45. package/plugins/data-exchange/export-xlsx.js +28 -6
  46. package/plugins/data-exchange/import.js +120 -58
  47. package/plugins/data-exchange/index.js +5 -0
  48. package/plugins/data-exchange/parse-table.js +14 -5
  49. package/plugins/data-exchange/record-selection.js +2 -0
  50. package/plugins/index.js +6 -0
  51. package/plugins/mcp/auth.js +104 -0
  52. package/plugins/mcp/discovery.js +88 -0
  53. package/plugins/mcp/index.js +208 -0
  54. package/plugins/mcp/prompts.js +149 -0
  55. package/plugins/mcp/providers/orm.js +325 -0
  56. package/plugins/mcp/providers/services.js +190 -0
  57. package/plugins/mcp/providers/system.js +87 -0
  58. package/plugins/mcp/server.js +472 -0
  59. package/plugins/mcp/transports/sse.js +156 -0
  60. package/plugins/mcp/transports/stdio.js +108 -0
  61. package/plugins/polar/.env.example +12 -0
  62. package/plugins/polar/CHANGELOG.md +38 -0
  63. package/plugins/polar/README.md +158 -0
  64. package/plugins/polar/index.js +144 -0
  65. package/plugins/polar/src/api.js +54 -0
  66. package/plugins/polar/src/checkout.js +111 -0
  67. package/plugins/polar/src/config.js +88 -0
  68. package/plugins/polar/src/env.schema.js +36 -0
  69. package/plugins/polar/src/middleware.js +37 -0
  70. package/plugins/polar/src/migration.js +58 -0
  71. package/plugins/polar/src/nunjucks-filters.js +56 -0
  72. package/plugins/polar/src/portal.js +51 -0
  73. package/plugins/polar/src/rate-limit.js +70 -0
  74. package/plugins/polar/src/routes.js +212 -0
  75. package/plugins/polar/src/sync.js +138 -0
  76. package/plugins/polar/src/urls.js +117 -0
  77. package/plugins/polar/src/user-resolver.js +195 -0
  78. package/plugins/polar/src/webhooks.js +248 -0
  79. package/src/file-router.js +203 -125
  80. package/src/helpers.js +60 -13
  81. package/src/njk-frontmatter.js +24 -14
  82. package/src/server.js +30 -12
  83. package/src/services/discovery.js +2 -1
  84. package/src/services/registry.js +2 -0
  85. package/src/services/validator.js +2 -53
  86. package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
@@ -49,6 +49,35 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
49
49
  return records;
50
50
  }
51
51
 
52
+ /**
53
+ * Execute whereIn query in chunks to prevent parameter limits and query planner degradation
54
+ * @param {import('knex').Knex} knex
55
+ * @param {string} table
56
+ * @param {string} column
57
+ * @param {Array<string|number>} values
58
+ * @param {import('./types').ScopeContext} scopeContext
59
+ * @param {import('./types').ModelDefinition} model
60
+ * @param {number} [chunkSize=500]
61
+ * @returns {Promise<Object[]>}
62
+ */
63
+ async function queryInChunks(knex, table, column, values, scopeContext, model, chunkSize = 500) {
64
+ if (values.length <= chunkSize) {
65
+ let qb = knex(table).whereIn(column, values);
66
+ qb = applyScopes(qb, scopeContext, model);
67
+ return await qb;
68
+ }
69
+
70
+ const results = [];
71
+ for (let i = 0; i < values.length; i += chunkSize) {
72
+ const slice = values.slice(i, i + chunkSize);
73
+ let qb = knex(table).whereIn(column, slice);
74
+ qb = applyScopes(qb, scopeContext, model);
75
+ const chunkResults = await qb;
76
+ results.push(...chunkResults);
77
+ }
78
+ return results;
79
+ }
80
+
52
81
  /**
53
82
  * Load belongsTo relation
54
83
  * Parent record has foreign key pointing to related record's primary key
@@ -78,9 +107,14 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
78
107
  }
79
108
 
80
109
  const foreignKeyValues = Array.from(fkSet);
81
- let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
82
- qb = applyScopes(qb, scopeContext, relatedModel);
83
- const relatedRecords = await qb;
110
+ const relatedRecords = await queryInChunks(
111
+ knex,
112
+ relatedModel.table,
113
+ relatedModel.primaryKey,
114
+ foreignKeyValues,
115
+ scopeContext,
116
+ relatedModel
117
+ );
84
118
 
85
119
  const relatedMap = new Map();
86
120
  const rLen = relatedRecords.length;
@@ -128,9 +162,14 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
128
162
  }
129
163
 
130
164
  const primaryKeyValues = Array.from(pkSet);
131
- let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
132
- qb = applyScopes(qb, scopeContext, relatedModel);
133
- const relatedRecords = await qb;
165
+ const relatedRecords = await queryInChunks(
166
+ knex,
167
+ relatedModel.table,
168
+ foreignKey,
169
+ primaryKeyValues,
170
+ scopeContext,
171
+ relatedModel
172
+ );
134
173
 
135
174
  const relatedGroups = new Map();
136
175
  const rLen = relatedRecords.length;
@@ -181,9 +220,14 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
181
220
  }
182
221
 
183
222
  const primaryKeyValues = Array.from(pkSet);
184
- let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
185
- qb = applyScopes(qb, scopeContext, relatedModel);
186
- const relatedRecords = await qb;
223
+ const relatedRecords = await queryInChunks(
224
+ knex,
225
+ relatedModel.table,
226
+ foreignKey,
227
+ primaryKeyValues,
228
+ scopeContext,
229
+ relatedModel
230
+ );
187
231
 
188
232
  const relatedMap = new Map();
189
233
  const rLen = relatedRecords.length;
@@ -222,5 +266,6 @@ module.exports = {
222
266
  loadBelongsTo,
223
267
  loadHasMany,
224
268
  loadHasOne,
269
+ queryInChunks,
225
270
  };
226
271
 
package/core/orm/index.js CHANGED
@@ -74,10 +74,46 @@ function createDatabase(config) {
74
74
  }
75
75
  }
76
76
 
77
- // Create Knex instance
77
+ // Create Knex instance (default useNullAsDefault: true for SQLite to prevent unneeded warnings)
78
+ const isSqlite = client === 'better-sqlite3' || client === 'sqlite3';
79
+ const knexConfig = {
80
+ ...(isSqlite && config.useNullAsDefault === undefined ? { useNullAsDefault: true } : {}),
81
+ ...config,
82
+ };
83
+
84
+ if (isSqlite) {
85
+ const userAfterCreate = knexConfig.pool?.afterCreate;
86
+ knexConfig.pool = {
87
+ ...(knexConfig.pool || {}),
88
+ afterCreate: (conn, cb) => {
89
+ try {
90
+ if (typeof conn.pragma === 'function') {
91
+ const isMemory =
92
+ knexConfig.connection === ':memory:' ||
93
+ knexConfig.connection?.filename === ':memory:' ||
94
+ knexConfig.connection?.database === ':memory:';
95
+
96
+ if (!isMemory) {
97
+ try { conn.pragma('journal_mode = WAL'); } catch (e) {}
98
+ }
99
+ try { conn.pragma('synchronous = NORMAL'); } catch (e) {}
100
+ try { conn.pragma('busy_timeout = 5000'); } catch (e) {}
101
+ try { conn.pragma('foreign_keys = ON'); } catch (e) {}
102
+ }
103
+ } catch (e) {}
104
+
105
+ if (typeof userAfterCreate === 'function') {
106
+ userAfterCreate(conn, cb);
107
+ } else {
108
+ cb(null, conn);
109
+ }
110
+ },
111
+ };
112
+ }
113
+
78
114
  let knexInstance;
79
115
  try {
80
- knexInstance = knex(config);
116
+ knexInstance = knex(knexConfig);
81
117
  } catch (e) {
82
118
  // Provide helpful error message
83
119
  if (e.message && (e.message.includes('Cannot find module') || e.message.includes('npm install'))) {
@@ -239,7 +275,10 @@ function createDatabase(config) {
239
275
  * @returns {Promise<*>} Result of callback
240
276
  */
241
277
  async function transaction(callback, scopeContext) {
242
- return knexInstance.transaction(async (trx) => {
278
+ const ambientTrx = getAmbientTransaction();
279
+ const runner = (ambientTrx && typeof ambientTrx.transaction === 'function') ? ambientTrx : knexInstance;
280
+
281
+ return runner.transaction(async (trx) => {
243
282
  const trxContext = {
244
283
  trx,
245
284
  getRepository(modelName, sContext) {
package/core/orm/model.js CHANGED
@@ -46,13 +46,22 @@ function defineModel(options) {
46
46
  throw new Error('Model schema is required and must be a Zod schema');
47
47
  }
48
48
 
49
+ let normalizedSchema = schema;
50
+ if (schema && schema.shape) {
51
+ for (const [key, val] of Object.entries(schema.shape)) {
52
+ if (val && typeof val._finalize === 'function') {
53
+ schema.shape[key] = val._finalize();
54
+ }
55
+ }
56
+ }
57
+
49
58
  // Check for duplicate registration
50
59
  if (modelRegistry.has(name)) {
51
60
  throw new Error(`Model "${name}" is already defined`);
52
61
  }
53
62
 
54
63
  // Extract column metadata from schema
55
- const columns = extractColumnsFromSchema(schema);
64
+ const columns = extractColumnsFromSchema(normalizedSchema);
56
65
 
57
66
  // Validate relations
58
67
  const normalizedRelations = {};
@@ -80,7 +89,7 @@ function defineModel(options) {
80
89
  const model = {
81
90
  name,
82
91
  table,
83
- schema,
92
+ schema: normalizedSchema,
84
93
  primaryKey,
85
94
  relations: normalizedRelations,
86
95
  scopes: {
@@ -87,7 +87,9 @@ function createTransactionContext(trx, scopeContext) {
87
87
  * @returns {Promise<*>} Result of callback
88
88
  */
89
89
  async function runTransaction(knex, callback, scopeContext) {
90
- return knex.transaction(async (trx) => {
90
+ const ambientTrx = getAmbientTransaction();
91
+ const runner = (ambientTrx && typeof ambientTrx.transaction === 'function') ? ambientTrx : knex;
92
+ return runner.transaction(async (trx) => {
91
93
  const ctx = createTransactionContext(trx, scopeContext);
92
94
  return runWithAmbientTransaction(trx, () => callback(ctx));
93
95
  });
@@ -46,6 +46,7 @@ class DatabaseQueueAdapter {
46
46
  table.dateTime('run_at').notNullable().index();
47
47
  table.dateTime('created_at').notNullable();
48
48
  table.dateTime('updated_at').notNullable();
49
+ table.index(['status', 'run_at', 'priority']);
49
50
  });
50
51
  }
51
52
 
@@ -88,8 +89,20 @@ class DatabaseQueueAdapter {
88
89
  * @returns {Promise<Job|null>}
89
90
  */
90
91
  async dequeue(jobNames = []) {
92
+ const jobs = await this.dequeueMany(jobNames, 1);
93
+ return jobs.length > 0 ? jobs[0] : null;
94
+ }
95
+
96
+ /**
97
+ * Dequeue multiple pending jobs up to limit in a single transaction
98
+ * @param {string[]} [jobNames]
99
+ * @param {number} [limit=1]
100
+ * @returns {Promise<Job[]>}
101
+ */
102
+ async dequeueMany(jobNames = [], limit = 1) {
91
103
  await this.ensureTable();
92
104
  const now = new Date();
105
+ const take = Math.max(1, limit || 1);
93
106
 
94
107
  return this.knex.transaction(async (trx) => {
95
108
  let query = trx(this.tableName)
@@ -97,14 +110,14 @@ class DatabaseQueueAdapter {
97
110
  .where('run_at', '<=', now)
98
111
  .orderBy('priority', 'desc')
99
112
  .orderBy('run_at', 'asc')
100
- .limit(1);
113
+ .limit(take);
101
114
 
102
115
  if (jobNames && jobNames.length > 0) {
103
116
  query = query.whereIn('name', jobNames);
104
117
  }
105
118
 
106
119
  // If Postgres or MySQL support forUpdate / skipLocked
107
- const client = this.knex.client.config.client;
120
+ const client = this.knex.client?.config?.client;
108
121
  if (client === 'pg' || client === 'postgres' || client === 'mysql' || client === 'mysql2') {
109
122
  try {
110
123
  query = query.forUpdate().skipLocked();
@@ -112,26 +125,46 @@ class DatabaseQueueAdapter {
112
125
  }
113
126
 
114
127
  const rows = await query;
115
- if (!rows || rows.length === 0) return null;
128
+ if (!rows || rows.length === 0) return [];
116
129
 
117
- const row = rows[0];
118
- const nextAttempts = Number(row.attempts) + 1;
130
+ const ids = rows.map((r) => r.id);
119
131
  const updatedAt = new Date();
120
132
 
133
+ if (take === 1) {
134
+ const row = rows[0];
135
+ const nextAttempts = Number(row.attempts) + 1;
136
+ await trx(this.tableName)
137
+ .where('id', row.id)
138
+ .update({
139
+ status: 'running',
140
+ attempts: nextAttempts,
141
+ updated_at: updatedAt,
142
+ });
143
+
144
+ return [this._rowToJob({
145
+ ...row,
146
+ status: 'running',
147
+ attempts: nextAttempts,
148
+ updated_at: updatedAt,
149
+ })];
150
+ }
151
+
121
152
  await trx(this.tableName)
122
- .where('id', row.id)
153
+ .whereIn('id', ids)
123
154
  .update({
124
155
  status: 'running',
125
- attempts: nextAttempts,
156
+ attempts: trx.raw('attempts + 1'),
126
157
  updated_at: updatedAt,
127
158
  });
128
159
 
129
- return this._rowToJob({
130
- ...row,
131
- status: 'running',
132
- attempts: nextAttempts,
133
- updated_at: updatedAt,
134
- });
160
+ return rows.map((row) =>
161
+ this._rowToJob({
162
+ ...row,
163
+ status: 'running',
164
+ attempts: Number(row.attempts) + 1,
165
+ updated_at: updatedAt,
166
+ })
167
+ );
135
168
  });
136
169
  }
137
170
 
@@ -60,6 +60,27 @@ class MemoryQueueAdapter {
60
60
  return null;
61
61
  }
62
62
 
63
+ /**
64
+ * Dequeue multiple pending jobs up to limit
65
+ * @param {string[]} [jobNames]
66
+ * @param {number} [limit=1]
67
+ * @returns {Promise<Job[]>}
68
+ */
69
+ async dequeueMany(jobNames = [], limit = 1) {
70
+ if (limit <= 1) {
71
+ const single = await this.dequeue(jobNames);
72
+ return single ? [single] : [];
73
+ }
74
+
75
+ const jobs = [];
76
+ for (let i = 0; i < limit; i++) {
77
+ const job = await this.dequeue(jobNames);
78
+ if (!job) break;
79
+ jobs.push(job);
80
+ }
81
+ return jobs;
82
+ }
83
+
63
84
  /**
64
85
  * Mark job as completed
65
86
  * @param {string|number} jobId
@@ -157,6 +157,27 @@ class RedisQueueAdapter {
157
157
  return null;
158
158
  }
159
159
 
160
+ /**
161
+ * Dequeue multiple pending jobs up to limit
162
+ * @param {string[]} [jobNames]
163
+ * @param {number} [limit=1]
164
+ * @returns {Promise<Job[]>}
165
+ */
166
+ async dequeueMany(jobNames = [], limit = 1) {
167
+ if (limit <= 1) {
168
+ const single = await this.dequeue(jobNames);
169
+ return single ? [single] : [];
170
+ }
171
+
172
+ const jobs = [];
173
+ for (let i = 0; i < limit; i++) {
174
+ const job = await this.dequeue(jobNames);
175
+ if (!job) break;
176
+ jobs.push(job);
177
+ }
178
+ return jobs;
179
+ }
180
+
160
181
  /**
161
182
  * Mark job as completed
162
183
  * @param {string|number} jobId
@@ -26,6 +26,8 @@ class QueueManager extends EventEmitter {
26
26
  this.adapter = options.adapter || new MemoryQueueAdapter();
27
27
  this.concurrency = Math.max(1, options.concurrency || 2);
28
28
  this.pollInterval = Math.max(50, options.pollInterval || 500);
29
+ this.maxPollInterval = Math.max(this.pollInterval, options.maxPollInterval || (this.pollInterval * 5));
30
+ this._currentPollInterval = this.pollInterval;
29
31
  this.jobsDir = options.jobsDir || null;
30
32
 
31
33
  /** @type {Map<string, { handler: Function, options: Object }>} */
@@ -125,7 +127,8 @@ class QueueManager extends EventEmitter {
125
127
  const enqueued = await this.adapter.enqueue(job);
126
128
  this.emit('job:queued', enqueued);
127
129
 
128
- // Trigger immediate poll if capacity is available
130
+ // Reset polling backoff and trigger immediate poll if capacity is available
131
+ this._currentPollInterval = this.pollInterval;
129
132
  if (this.isRunning && !this.isPaused && this.runningCount < this.concurrency) {
130
133
  setImmediate(() => this._tick());
131
134
  }
@@ -140,6 +143,7 @@ class QueueManager extends EventEmitter {
140
143
  if (this.isRunning) return this;
141
144
  this.isRunning = true;
142
145
  this.isPaused = false;
146
+ this._currentPollInterval = this.pollInterval;
143
147
  this._scheduleNextTick(0);
144
148
  this.emit('start');
145
149
  return this;
@@ -164,6 +168,7 @@ class QueueManager extends EventEmitter {
164
168
  resume() {
165
169
  if (!this.isRunning) return this.start();
166
170
  this.isPaused = false;
171
+ this._currentPollInterval = this.pollInterval;
167
172
  this._scheduleNextTick(0);
168
173
  this.emit('resume');
169
174
  return this;
@@ -268,7 +273,7 @@ class QueueManager extends EventEmitter {
268
273
  * Schedule next tick
269
274
  * @private
270
275
  */
271
- _scheduleNextTick(delay = this.pollInterval) {
276
+ _scheduleNextTick(delay = this._currentPollInterval) {
272
277
  if (!this.isRunning || this.isPaused || this.isDraining) return;
273
278
  if (this._timer) clearTimeout(this._timer);
274
279
  this._timer = setTimeout(() => this._tick(), delay);
@@ -281,28 +286,45 @@ class QueueManager extends EventEmitter {
281
286
  async _tick() {
282
287
  if (!this.isRunning || this.isPaused || this.isDraining) return;
283
288
 
284
- while (this.runningCount < this.concurrency) {
285
- if (this.isPaused || this.isDraining) break;
289
+ const availableSlots = this.concurrency - this.runningCount;
290
+ if (availableSlots <= 0) return;
286
291
 
287
- const registeredNames = Array.from(this.handlers.keys());
288
- if (registeredNames.length === 0) {
289
- break;
290
- }
292
+ const registeredNames = Array.from(this.handlers.keys());
293
+ if (registeredNames.length === 0) {
294
+ this._scheduleNextTick(this._currentPollInterval);
295
+ return;
296
+ }
291
297
 
292
- let job = null;
293
- try {
294
- job = await this.adapter.dequeue(registeredNames);
295
- } catch (err) {
296
- this.emit('error', err);
297
- break;
298
+ let jobs = [];
299
+ try {
300
+ if (typeof this.adapter.dequeueMany === 'function' && availableSlots > 1) {
301
+ jobs = await this.adapter.dequeueMany(registeredNames, availableSlots);
302
+ } else {
303
+ const single = await this.adapter.dequeue(registeredNames);
304
+ if (single) jobs = [single];
298
305
  }
306
+ } catch (err) {
307
+ this.emit('error', err);
308
+ this._scheduleNextTick(this._currentPollInterval);
309
+ return;
310
+ }
299
311
 
300
- if (!job) {
301
- // No jobs ready
302
- break;
303
- }
312
+ if (!jobs || jobs.length === 0) {
313
+ // No jobs ready -> back off polling interval
314
+ this._currentPollInterval = Math.min(
315
+ this.maxPollInterval,
316
+ Math.round(this._currentPollInterval * 1.5)
317
+ );
318
+ this._scheduleNextTick(this._currentPollInterval);
319
+ return;
320
+ }
321
+
322
+ // Found job(s) -> reset polling interval
323
+ this._currentPollInterval = this.pollInterval;
324
+
325
+ for (const job of jobs) {
326
+ if (this.isPaused || this.isDraining) break;
304
327
 
305
- // Found a job -> process it
306
328
  this.runningCount++;
307
329
  this.activeJobs.set(job.id, job);
308
330
 
@@ -320,7 +342,11 @@ class QueueManager extends EventEmitter {
320
342
  });
321
343
  }
322
344
 
323
- this._scheduleNextTick(this.pollInterval);
345
+ if (this.runningCount < this.concurrency) {
346
+ this._scheduleNextTick(0);
347
+ } else {
348
+ this._scheduleNextTick(this.pollInterval);
349
+ }
324
350
  }
325
351
 
326
352
  /**
@@ -17,7 +17,9 @@ const { Readable } = require('stream');
17
17
  * @returns {Readable}
18
18
  */
19
19
  function createHtmlStream(options = {}) {
20
- const { env, templatePath, context = {}, defer = {} } = options;
20
+ const { env, templatePath, context = {}, defer = {}, nonce: optionNonce } = options;
21
+ const nonce = optionNonce || context.nonce || context.cspNonce || '';
22
+ const scriptTag = nonce ? `<script nonce="${nonce}">` : '<script>';
21
23
 
22
24
  if (!env) {
23
25
  throw new Error('createHtmlStream requires a valid Nunjucks environment in options.env');
@@ -63,6 +65,9 @@ function createHtmlStream(options = {}) {
63
65
 
64
66
  try {
65
67
  const resolvedData = await promise;
68
+ if (stream.destroyed) {
69
+ return;
70
+ }
66
71
  let slotHtml = '';
67
72
  if (slotTemplate) {
68
73
  slotHtml = env.render(slotTemplate, { ...context, [key]: resolvedData });
@@ -73,7 +78,7 @@ function createHtmlStream(options = {}) {
73
78
  // Push an out-of-order streaming chunk with inline replacement script or comment
74
79
  const slotChunk = `
75
80
  <template data-stream-slot="${key}">${slotHtml}</template>
76
- <script>
81
+ ${scriptTag}
77
82
  (function(){
78
83
  var t = document.querySelector('template[data-stream-slot="${key}"]');
79
84
  var target = document.querySelector('[data-stream-target="${key}"]') || document.getElementById('stream-target-${key}');
@@ -83,18 +88,27 @@ function createHtmlStream(options = {}) {
83
88
  }
84
89
  })();
85
90
  </script>`;
86
- stream.push(slotChunk);
91
+ if (!stream.destroyed) {
92
+ stream.push(slotChunk);
93
+ }
87
94
  } catch (slotErr) {
95
+ if (stream.destroyed) {
96
+ return;
97
+ }
88
98
  const errorChunk = `
89
99
  <template data-stream-slot="${key}">
90
100
  <!-- Slot Error (${key}): ${slotErr.message} -->
91
101
  </template>`;
92
- stream.push(errorChunk);
102
+ if (!stream.destroyed) {
103
+ stream.push(errorChunk);
104
+ }
93
105
  }
94
106
  });
95
107
 
96
108
  await Promise.all(resolutionPromises);
97
- stream.push(null); // End of stream
109
+ if (!stream.destroyed) {
110
+ stream.push(null); // End of stream
111
+ }
98
112
  } catch (err) {
99
113
  stream.destroy(err);
100
114
  }
@@ -122,8 +136,17 @@ function renderStream(res, templatePath, context = {}, options = {}) {
122
136
  defer = {},
123
137
  status = 200,
124
138
  flushImmediately = true,
139
+ nonce: optionNonce,
125
140
  } = options;
126
141
 
142
+ const nonce =
143
+ optionNonce ||
144
+ res?.locals?.cspNonce ||
145
+ res?.locals?.nonce ||
146
+ context?.cspNonce ||
147
+ context?.nonce ||
148
+ '';
149
+
127
150
  if (!env) {
128
151
  return reject(new Error('renderStream requires a valid Nunjucks environment in options.env'));
129
152
  }
@@ -145,25 +168,46 @@ function renderStream(res, templatePath, context = {}, options = {}) {
145
168
  templatePath,
146
169
  context,
147
170
  defer,
171
+ nonce,
148
172
  });
149
173
 
174
+ let finished = false;
175
+ const cleanup = () => {
176
+ if (finished) return;
177
+ finished = true;
178
+ if (!stream.destroyed) {
179
+ stream.destroy();
180
+ }
181
+ resolve();
182
+ };
183
+
184
+ res.on('close', cleanup);
185
+
150
186
  stream.on('data', (chunk) => {
151
- res.write(chunk);
187
+ if (!finished && !res.writableEnded) {
188
+ res.write(chunk);
189
+ }
152
190
  });
153
191
 
154
192
  stream.on('end', () => {
155
- res.end();
156
- resolve();
193
+ if (!finished) {
194
+ finished = true;
195
+ res.end();
196
+ resolve();
197
+ }
157
198
  });
158
199
 
159
200
  stream.on('error', (err) => {
160
- if (!res.headersSent) {
161
- res.status(500).send('Streaming Render Error');
162
- } else {
163
- res.write(`<!-- Streaming Error: ${err.message} -->`);
164
- res.end();
201
+ if (!finished) {
202
+ finished = true;
203
+ if (!res.headersSent) {
204
+ res.status(500).send('Streaming Render Error');
205
+ } else if (!res.writableEnded) {
206
+ res.write(`<!-- Streaming Error: ${err.message} -->`);
207
+ res.end();
208
+ }
209
+ reject(err);
165
210
  }
166
- reject(err);
167
211
  });
168
212
  });
169
213
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Webspresso Unified Validation Core
3
+ * Centralized Zod extensions, prototype pollution defense, and schema utilities
4
+ * @module core/validation
5
+ */
6
+
7
+ const z = require('zod');
8
+ const { extendZ, generateNanoid, zodNanoid } = require('../orm/utils/nanoid');
9
+
10
+ /**
11
+ * Standard Zod instance extended with z.nanoid() for framework-wide validation.
12
+ */
13
+ const zExtended = extendZ(z);
14
+
15
+ /**
16
+ * Check if an object contains dangerous prototype pollution keys
17
+ * @param {*} obj
18
+ * @returns {boolean}
19
+ */
20
+ function hasDangerousKeys(obj) {
21
+ if (obj === null || typeof obj !== 'object') {
22
+ return false;
23
+ }
24
+ if (Array.isArray(obj)) {
25
+ return obj.some(item => hasDangerousKeys(item));
26
+ }
27
+ for (const key of Object.keys(obj)) {
28
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
29
+ return true;
30
+ }
31
+ if (typeof obj[key] === 'object' && obj[key] !== null && hasDangerousKeys(obj[key])) {
32
+ return true;
33
+ }
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * Sanitize input payload against prototype pollution
40
+ * @param {*} input
41
+ * @returns {*} Sanitized input
42
+ */
43
+ function sanitizeInput(input) {
44
+ if (input === null || typeof input !== 'object') {
45
+ return input;
46
+ }
47
+ if (!hasDangerousKeys(input)) {
48
+ return input;
49
+ }
50
+ if (Array.isArray(input)) {
51
+ return input.map(item => sanitizeInput(item));
52
+ }
53
+ const clean = {};
54
+ for (const key of Object.keys(input)) {
55
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
56
+ continue;
57
+ }
58
+ clean[key] = sanitizeInput(input[key]);
59
+ }
60
+ return clean;
61
+ }
62
+
63
+ module.exports = {
64
+ z: zExtended,
65
+ extendZ,
66
+ generateNanoid,
67
+ zodNanoid,
68
+ hasDangerousKeys,
69
+ sanitizeInput,
70
+ };