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.
- package/README.md +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/tokens.js +16 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +42 -3
- package/core/orm/model.js +11 -2
- package/core/orm/transaction.js +3 -1
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/ssr/stream.js +58 -14
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +120 -58
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +6 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +156 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +38 -0
- package/plugins/polar/README.md +158 -0
- package/plugins/polar/index.js +144 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +88 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +70 -0
- package/plugins/polar/src/routes.js +212 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +117 -0
- package/plugins/polar/src/user-resolver.js +195 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/src/file-router.js +203 -125
- package/src/helpers.js +60 -13
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
package/core/orm/eager-loader.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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: {
|
package/core/orm/transaction.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|
128
|
+
if (!rows || rows.length === 0) return [];
|
|
116
129
|
|
|
117
|
-
const
|
|
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
|
-
.
|
|
153
|
+
.whereIn('id', ids)
|
|
123
154
|
.update({
|
|
124
155
|
status: 'running',
|
|
125
|
-
attempts:
|
|
156
|
+
attempts: trx.raw('attempts + 1'),
|
|
126
157
|
updated_at: updatedAt,
|
|
127
158
|
});
|
|
128
159
|
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
package/core/queue/manager.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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
|
-
|
|
285
|
-
|
|
289
|
+
const availableSlots = this.concurrency - this.runningCount;
|
|
290
|
+
if (availableSlots <= 0) return;
|
|
286
291
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
345
|
+
if (this.runningCount < this.concurrency) {
|
|
346
|
+
this._scheduleNextTick(0);
|
|
347
|
+
} else {
|
|
348
|
+
this._scheduleNextTick(this.pollInterval);
|
|
349
|
+
}
|
|
324
350
|
}
|
|
325
351
|
|
|
326
352
|
/**
|
package/core/ssr/stream.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
102
|
+
if (!stream.destroyed) {
|
|
103
|
+
stream.push(errorChunk);
|
|
104
|
+
}
|
|
93
105
|
}
|
|
94
106
|
});
|
|
95
107
|
|
|
96
108
|
await Promise.all(resolutionPromises);
|
|
97
|
-
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.
|
|
187
|
+
if (!finished && !res.writableEnded) {
|
|
188
|
+
res.write(chunk);
|
|
189
|
+
}
|
|
152
190
|
});
|
|
153
191
|
|
|
154
192
|
stream.on('end', () => {
|
|
155
|
-
|
|
156
|
-
|
|
193
|
+
if (!finished) {
|
|
194
|
+
finished = true;
|
|
195
|
+
res.end();
|
|
196
|
+
resolve();
|
|
197
|
+
}
|
|
157
198
|
});
|
|
158
199
|
|
|
159
200
|
stream.on('error', (err) => {
|
|
160
|
-
if (!
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
res.
|
|
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
|
+
};
|