webspresso 0.0.87 → 0.0.89
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/core/kernel/events.js +11 -3
- package/core/orm/cache/listeners.js +22 -15
- package/core/orm/eager-loader.js +64 -70
- package/core/orm/events.js +45 -15
- package/core/orm/repository.js +54 -22
- package/core/orm/schema-helpers.js +31 -2
- package/package.json +1 -1
- package/plugins/csrf/index.js +23 -10
- package/plugins/site-analytics/tracking.js +16 -2
- package/src/file-router.js +61 -16
- package/src/helpers.js +10 -1
package/core/kernel/events.js
CHANGED
|
@@ -40,6 +40,8 @@ function buildContext(payload, meta) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
44
|
+
|
|
43
45
|
/**
|
|
44
46
|
* @returns {{ dispatch: Function, publish: Function, on: Function, off: Function, buildContext: typeof buildContext }}
|
|
45
47
|
*/
|
|
@@ -74,7 +76,8 @@ function createEventBus() {
|
|
|
74
76
|
* @param {KernelEventContext} ctx
|
|
75
77
|
*/
|
|
76
78
|
async function dispatch(eventName, ctx) {
|
|
77
|
-
const list = listeners.get(eventName)
|
|
79
|
+
const list = listeners.get(eventName);
|
|
80
|
+
if (!list || list.length === 0) return undefined;
|
|
78
81
|
let last;
|
|
79
82
|
for (const fn of list) {
|
|
80
83
|
last = await fn(ctx);
|
|
@@ -87,8 +90,13 @@ function createEventBus() {
|
|
|
87
90
|
* @param {KernelEventContext} ctx
|
|
88
91
|
*/
|
|
89
92
|
async function publish(eventName, ctx) {
|
|
90
|
-
const list = listeners.get(eventName)
|
|
91
|
-
|
|
93
|
+
const list = listeners.get(eventName);
|
|
94
|
+
if (!list || list.length === 0) return;
|
|
95
|
+
const promises = [];
|
|
96
|
+
for (const fn of list) {
|
|
97
|
+
promises.push(Promise.resolve(fn(ctx)));
|
|
98
|
+
}
|
|
99
|
+
await Promise.all(promises);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
return { dispatch, publish, on, off, buildContext };
|
|
@@ -10,6 +10,7 @@ const { getModel } = require('../model');
|
|
|
10
10
|
/** @type {Set<import('./layer').OrmCacheLayer>} */
|
|
11
11
|
const layers = new Set();
|
|
12
12
|
let hooked = false;
|
|
13
|
+
let unsubs = [];
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @param {import('./layer').OrmCacheLayer} cacheLayer
|
|
@@ -37,21 +38,20 @@ function registerOrmCacheListeners(cacheLayer) {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
41
|
+
unsubs = [
|
|
42
|
+
ModelEvents.on(`*.${Hooks.AFTER_CREATE}`, (record, ctx) => {
|
|
43
|
+
handleMutation(record, ctx, 'create');
|
|
44
|
+
}),
|
|
45
|
+
ModelEvents.on(`*.${Hooks.AFTER_UPDATE}`, (record, ctx) => {
|
|
46
|
+
handleMutation(record, ctx, 'update');
|
|
47
|
+
}),
|
|
48
|
+
ModelEvents.on(`*.${Hooks.AFTER_DELETE}`, (record, ctx) => {
|
|
49
|
+
handleMutation(record, ctx, 'delete');
|
|
50
|
+
}),
|
|
51
|
+
ModelEvents.on(`*.${Hooks.AFTER_RESTORE}`, (record, ctx) => {
|
|
52
|
+
handleMutation(record, ctx, 'restore');
|
|
53
|
+
}),
|
|
54
|
+
];
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -59,6 +59,13 @@ function registerOrmCacheListeners(cacheLayer) {
|
|
|
59
59
|
*/
|
|
60
60
|
function unregisterOrmCacheListeners(cacheLayer) {
|
|
61
61
|
layers.delete(cacheLayer);
|
|
62
|
+
if (layers.size === 0 && hooked) {
|
|
63
|
+
for (const unsub of unsubs) {
|
|
64
|
+
if (typeof unsub === 'function') unsub();
|
|
65
|
+
}
|
|
66
|
+
unsubs = [];
|
|
67
|
+
hooked = false;
|
|
68
|
+
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
module.exports = {
|
package/core/orm/eager-loader.js
CHANGED
|
@@ -63,39 +63,37 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
|
|
|
63
63
|
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
64
64
|
*/
|
|
65
65
|
async function loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
record[relationName] = null;
|
|
66
|
+
const fkSet = new Set();
|
|
67
|
+
const len = records.length;
|
|
68
|
+
for (let i = 0; i < len; i++) {
|
|
69
|
+
const v = records[i][foreignKey];
|
|
70
|
+
if (v !== null && v !== undefined) fkSet.add(v);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (fkSet.size === 0) {
|
|
74
|
+
for (let i = 0; i < len; i++) {
|
|
75
|
+
records[i][relationName] = null;
|
|
77
76
|
}
|
|
78
77
|
return;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
const foreignKeyValues = Array.from(fkSet);
|
|
82
81
|
let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
|
|
83
|
-
|
|
84
|
-
// Apply scopes to related model
|
|
85
82
|
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
86
|
-
|
|
87
83
|
const relatedRecords = await qb;
|
|
88
84
|
|
|
89
|
-
// Index related records by primary key
|
|
90
85
|
const relatedMap = new Map();
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
const rLen = relatedRecords.length;
|
|
87
|
+
const pk = relatedModel.primaryKey;
|
|
88
|
+
for (let i = 0; i < rLen; i++) {
|
|
89
|
+
const item = relatedRecords[i];
|
|
90
|
+
relatedMap.set(item[pk], item);
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const fkValue =
|
|
98
|
-
|
|
93
|
+
for (let i = 0; i < len; i++) {
|
|
94
|
+
const rec = records[i];
|
|
95
|
+
const fkValue = rec[foreignKey];
|
|
96
|
+
rec[relationName] = fkValue !== null && fkValue !== undefined
|
|
99
97
|
? relatedMap.get(fkValue) || null
|
|
100
98
|
: null;
|
|
101
99
|
}
|
|
@@ -115,43 +113,42 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
|
|
|
115
113
|
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
116
114
|
*/
|
|
117
115
|
async function loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
record[relationName] = [];
|
|
116
|
+
const pkSet = new Set();
|
|
117
|
+
const len = records.length;
|
|
118
|
+
for (let i = 0; i < len; i++) {
|
|
119
|
+
const v = records[i][localKey];
|
|
120
|
+
if (v !== null && v !== undefined) pkSet.add(v);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (pkSet.size === 0) {
|
|
124
|
+
for (let i = 0; i < len; i++) {
|
|
125
|
+
records[i][relationName] = [];
|
|
129
126
|
}
|
|
130
127
|
return;
|
|
131
128
|
}
|
|
132
129
|
|
|
133
|
-
|
|
130
|
+
const primaryKeyValues = Array.from(pkSet);
|
|
134
131
|
let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
|
|
135
|
-
|
|
136
|
-
// Apply scopes to related model
|
|
137
132
|
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
138
|
-
|
|
139
133
|
const relatedRecords = await qb;
|
|
140
134
|
|
|
141
|
-
// Group related records by foreign key
|
|
142
135
|
const relatedGroups = new Map();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
136
|
+
const rLen = relatedRecords.length;
|
|
137
|
+
for (let i = 0; i < rLen; i++) {
|
|
138
|
+
const item = relatedRecords[i];
|
|
139
|
+
const fkValue = item[foreignKey];
|
|
140
|
+
let group = relatedGroups.get(fkValue);
|
|
141
|
+
if (group === undefined) {
|
|
142
|
+
group = [];
|
|
143
|
+
relatedGroups.set(fkValue, group);
|
|
147
144
|
}
|
|
148
|
-
|
|
145
|
+
group.push(item);
|
|
149
146
|
}
|
|
150
147
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const pkValue =
|
|
154
|
-
|
|
148
|
+
for (let i = 0; i < len; i++) {
|
|
149
|
+
const rec = records[i];
|
|
150
|
+
const pkValue = rec[localKey];
|
|
151
|
+
rec[relationName] = (pkValue !== null && pkValue !== undefined ? relatedGroups.get(pkValue) : undefined) || [];
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
@@ -169,42 +166,39 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
|
|
|
169
166
|
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
170
167
|
*/
|
|
171
168
|
async function loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
record[relationName] = null;
|
|
169
|
+
const pkSet = new Set();
|
|
170
|
+
const len = records.length;
|
|
171
|
+
for (let i = 0; i < len; i++) {
|
|
172
|
+
const v = records[i][localKey];
|
|
173
|
+
if (v !== null && v !== undefined) pkSet.add(v);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (pkSet.size === 0) {
|
|
177
|
+
for (let i = 0; i < len; i++) {
|
|
178
|
+
records[i][relationName] = null;
|
|
183
179
|
}
|
|
184
180
|
return;
|
|
185
181
|
}
|
|
186
182
|
|
|
187
|
-
|
|
183
|
+
const primaryKeyValues = Array.from(pkSet);
|
|
188
184
|
let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
|
|
189
|
-
|
|
190
|
-
// Apply scopes to related model
|
|
191
185
|
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
192
|
-
|
|
193
186
|
const relatedRecords = await qb;
|
|
194
187
|
|
|
195
|
-
// Index related records by foreign key (first occurrence wins for hasOne)
|
|
196
188
|
const relatedMap = new Map();
|
|
197
|
-
|
|
198
|
-
|
|
189
|
+
const rLen = relatedRecords.length;
|
|
190
|
+
for (let i = 0; i < rLen; i++) {
|
|
191
|
+
const item = relatedRecords[i];
|
|
192
|
+
const fkValue = item[foreignKey];
|
|
199
193
|
if (!relatedMap.has(fkValue)) {
|
|
200
|
-
relatedMap.set(fkValue,
|
|
194
|
+
relatedMap.set(fkValue, item);
|
|
201
195
|
}
|
|
202
196
|
}
|
|
203
197
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const pkValue =
|
|
207
|
-
|
|
198
|
+
for (let i = 0; i < len; i++) {
|
|
199
|
+
const rec = records[i];
|
|
200
|
+
const pkValue = rec[localKey];
|
|
201
|
+
rec[relationName] = (pkValue !== null && pkValue !== undefined ? relatedMap.get(pkValue) : undefined) || null;
|
|
208
202
|
}
|
|
209
203
|
}
|
|
210
204
|
|
package/core/orm/events.js
CHANGED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* @property {import('knex').Knex.Transaction|null} trx - Transaction if in one
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
19
|
+
|
|
20
|
+
function cancelContext(reason = 'Operation cancelled') {
|
|
21
|
+
this.isCancelled = true;
|
|
22
|
+
this.cancelReason = reason;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Create a cancellation context for before hooks
|
|
20
27
|
* @param {string} model - Model name
|
|
@@ -23,18 +30,14 @@
|
|
|
23
30
|
* @returns {EventContext}
|
|
24
31
|
*/
|
|
25
32
|
function createEventContext(model, operation, trx = null) {
|
|
26
|
-
|
|
33
|
+
return {
|
|
27
34
|
model,
|
|
28
35
|
operation,
|
|
29
36
|
trx,
|
|
30
37
|
isCancelled: false,
|
|
31
38
|
cancelReason: null,
|
|
32
|
-
cancel
|
|
33
|
-
context.isCancelled = true;
|
|
34
|
-
context.cancelReason = reason;
|
|
35
|
-
},
|
|
39
|
+
cancel: cancelContext,
|
|
36
40
|
};
|
|
37
|
-
return context;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -114,6 +117,10 @@ class ModelEventsClass {
|
|
|
114
117
|
* @returns {Function[]} Array of matching listeners
|
|
115
118
|
*/
|
|
116
119
|
getMatchingListeners(model, hook) {
|
|
120
|
+
if (this.listeners.size === 0) {
|
|
121
|
+
return EMPTY_ARRAY;
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
const listeners = [];
|
|
118
125
|
const specificEvent = `${model}.${hook}`;
|
|
119
126
|
const wildcardModel = `*.${hook}`;
|
|
@@ -121,26 +128,47 @@ class ModelEventsClass {
|
|
|
121
128
|
const wildcardAll = '*.*';
|
|
122
129
|
|
|
123
130
|
// Check specific event
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
const sList = this.listeners.get(specificEvent);
|
|
132
|
+
if (sList) {
|
|
133
|
+
listeners.push(...sList);
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
// Check wildcard model (*.beforeCreate)
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
const wmList = this.listeners.get(wildcardModel);
|
|
138
|
+
if (wmList) {
|
|
139
|
+
listeners.push(...wmList);
|
|
131
140
|
}
|
|
132
141
|
|
|
133
142
|
// Check wildcard hook (User.*)
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
const whList = this.listeners.get(wildcardHook);
|
|
144
|
+
if (whList) {
|
|
145
|
+
listeners.push(...whList);
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
// Check full wildcard (*.*)
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
const waList = this.listeners.get(wildcardAll);
|
|
150
|
+
if (waList) {
|
|
151
|
+
listeners.push(...waList);
|
|
141
152
|
}
|
|
142
153
|
|
|
143
|
-
return listeners;
|
|
154
|
+
return listeners.length > 0 ? listeners : EMPTY_ARRAY;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fast boolean check whether any listener matches
|
|
159
|
+
* @param {string} model - Model name
|
|
160
|
+
* @param {string} hook - Hook name
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
hasListeners(model, hook) {
|
|
164
|
+
if (this.listeners.size === 0) return false;
|
|
165
|
+
const l = this.listeners;
|
|
166
|
+
return (
|
|
167
|
+
l.has(`${model}.${hook}`) ||
|
|
168
|
+
l.has(`*.${hook}`) ||
|
|
169
|
+
l.has(`${model}.*`) ||
|
|
170
|
+
l.has('*.*')
|
|
171
|
+
);
|
|
144
172
|
}
|
|
145
173
|
|
|
146
174
|
/**
|
|
@@ -152,6 +180,7 @@ class ModelEventsClass {
|
|
|
152
180
|
*/
|
|
153
181
|
emit(model, hook, data, context) {
|
|
154
182
|
const listeners = this.getMatchingListeners(model, hook);
|
|
183
|
+
if (listeners.length === 0) return;
|
|
155
184
|
const ctx = context || createEventContext(model, hook);
|
|
156
185
|
|
|
157
186
|
for (const listener of listeners) {
|
|
@@ -176,6 +205,7 @@ class ModelEventsClass {
|
|
|
176
205
|
async emitAsync(model, hook, data, context) {
|
|
177
206
|
const listeners = this.getMatchingListeners(model, hook);
|
|
178
207
|
const ctx = context || createEventContext(model, hook);
|
|
208
|
+
if (listeners.length === 0) return ctx;
|
|
179
209
|
|
|
180
210
|
for (const listener of listeners) {
|
|
181
211
|
if (ctx.isCancelled) break;
|
package/core/orm/repository.js
CHANGED
|
@@ -28,6 +28,7 @@ const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('
|
|
|
28
28
|
function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
29
29
|
const scopeContext = initialContext || createScopeContext();
|
|
30
30
|
const jsonColumns = getJsonColumns(model);
|
|
31
|
+
const hasJson = jsonColumns.size > 0;
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Get base query builder
|
|
@@ -47,12 +48,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
47
48
|
*/
|
|
48
49
|
async function findById(id, options = {}, emitEvents = true) {
|
|
49
50
|
const { with: withs = [], select } = options;
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const shouldEmit = emitEvents && (
|
|
52
|
+
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
53
|
+
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
54
|
+
);
|
|
55
|
+
const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
|
|
52
56
|
|
|
53
57
|
async function loadFromDb() {
|
|
54
|
-
if (
|
|
55
|
-
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND,
|
|
58
|
+
if (shouldEmit) {
|
|
59
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, { [model.primaryKey]: id }, ctx);
|
|
56
60
|
if (ctx.isCancelled) {
|
|
57
61
|
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
58
62
|
}
|
|
@@ -70,13 +74,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
70
74
|
return null;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
if (hasJson) {
|
|
78
|
+
deserializeJsonFields(record, jsonColumns);
|
|
79
|
+
}
|
|
74
80
|
|
|
75
81
|
if (withs.length > 0) {
|
|
76
82
|
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
if (
|
|
85
|
+
if (shouldEmit) {
|
|
80
86
|
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -113,7 +119,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
113
119
|
*/
|
|
114
120
|
async function findOne(conditions, options = {}) {
|
|
115
121
|
const { with: withs = [], select } = options;
|
|
116
|
-
const
|
|
122
|
+
const shouldEmit = (
|
|
123
|
+
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
124
|
+
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
125
|
+
);
|
|
126
|
+
const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
|
|
117
127
|
|
|
118
128
|
const condKeys = Object.keys(conditions);
|
|
119
129
|
const isPkOnly =
|
|
@@ -122,9 +132,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
122
132
|
condKeys[0] === model.primaryKey;
|
|
123
133
|
|
|
124
134
|
async function loadFromDb() {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
if (shouldEmit) {
|
|
136
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
|
|
137
|
+
if (ctx.isCancelled) {
|
|
138
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
139
|
+
}
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
let qb = baseQuery();
|
|
@@ -143,13 +155,17 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
143
155
|
return null;
|
|
144
156
|
}
|
|
145
157
|
|
|
146
|
-
|
|
158
|
+
if (hasJson) {
|
|
159
|
+
deserializeJsonFields(record, jsonColumns);
|
|
160
|
+
}
|
|
147
161
|
|
|
148
162
|
if (withs.length > 0) {
|
|
149
163
|
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
|
|
166
|
+
if (shouldEmit) {
|
|
167
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
168
|
+
}
|
|
153
169
|
|
|
154
170
|
return record;
|
|
155
171
|
}
|
|
@@ -183,12 +199,18 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
183
199
|
*/
|
|
184
200
|
async function findAll(options = {}) {
|
|
185
201
|
const { with: withs = [], select } = options;
|
|
186
|
-
const
|
|
202
|
+
const shouldEmit = (
|
|
203
|
+
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
204
|
+
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
205
|
+
);
|
|
206
|
+
const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
|
|
187
207
|
|
|
188
208
|
async function loadFromDb() {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
209
|
+
if (shouldEmit) {
|
|
210
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
211
|
+
if (ctx.isCancelled) {
|
|
212
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
213
|
+
}
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
let qb = baseQuery();
|
|
@@ -199,16 +221,22 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
199
221
|
|
|
200
222
|
const records = await qb;
|
|
201
223
|
|
|
202
|
-
|
|
203
|
-
|
|
224
|
+
if (hasJson) {
|
|
225
|
+
const len = records.length;
|
|
226
|
+
for (let i = 0; i < len; i++) {
|
|
227
|
+
deserializeJsonFields(records[i], jsonColumns);
|
|
228
|
+
}
|
|
204
229
|
}
|
|
205
230
|
|
|
206
231
|
if (withs.length > 0 && records.length > 0) {
|
|
207
232
|
await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
|
|
208
233
|
}
|
|
209
234
|
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
if (shouldEmit) {
|
|
236
|
+
const len = records.length;
|
|
237
|
+
for (let i = 0; i < len; i++) {
|
|
238
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, records[i], ctx);
|
|
239
|
+
}
|
|
212
240
|
}
|
|
213
241
|
|
|
214
242
|
return records;
|
|
@@ -261,7 +289,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
261
289
|
let insertData = applyInsertModifiers(validated, scopeContext, model);
|
|
262
290
|
|
|
263
291
|
// Serialize JSON fields
|
|
264
|
-
|
|
292
|
+
if (hasJson) {
|
|
293
|
+
insertData = serializeJsonFields(insertData, jsonColumns);
|
|
294
|
+
}
|
|
265
295
|
|
|
266
296
|
// Insert and return the record
|
|
267
297
|
const [id] = await knex(model.table).insert(insertData).returning(model.primaryKey);
|
|
@@ -341,7 +371,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
341
371
|
delete updateData[model.primaryKey];
|
|
342
372
|
|
|
343
373
|
// Serialize JSON fields
|
|
344
|
-
|
|
374
|
+
if (hasJson) {
|
|
375
|
+
updateData = serializeJsonFields(updateData, jsonColumns);
|
|
376
|
+
}
|
|
345
377
|
|
|
346
378
|
// Update the record
|
|
347
379
|
const updated = await baseQuery()
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const METADATA_MARKER = '__wdb__';
|
|
8
|
+
const metaWeakMap = new WeakMap();
|
|
9
|
+
const metaStringCache = new Map();
|
|
10
|
+
const schemaColumnsWeakMap = new WeakMap();
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Encode column metadata into a JSON string for Zod .describe()
|
|
@@ -22,14 +25,25 @@ function encodeColumnMeta(meta) {
|
|
|
22
25
|
*/
|
|
23
26
|
function decodeColumnMeta(description) {
|
|
24
27
|
if (!description) return null;
|
|
28
|
+
const cached = metaStringCache.get(description);
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
30
|
+
|
|
25
31
|
try {
|
|
26
32
|
const parsed = JSON.parse(description);
|
|
27
33
|
if (parsed && parsed[METADATA_MARKER]) {
|
|
28
|
-
|
|
34
|
+
const meta = parsed.meta;
|
|
35
|
+
if (metaStringCache.size < 500) {
|
|
36
|
+
metaStringCache.set(description, meta);
|
|
37
|
+
}
|
|
38
|
+
return meta;
|
|
29
39
|
}
|
|
30
40
|
} catch {
|
|
31
41
|
// Not our metadata, ignore
|
|
32
42
|
}
|
|
43
|
+
|
|
44
|
+
if (metaStringCache.size < 500) {
|
|
45
|
+
metaStringCache.set(description, null);
|
|
46
|
+
}
|
|
33
47
|
return null;
|
|
34
48
|
}
|
|
35
49
|
|
|
@@ -39,7 +53,7 @@ function decodeColumnMeta(description) {
|
|
|
39
53
|
* @returns {boolean}
|
|
40
54
|
*/
|
|
41
55
|
function hasColumnMeta(schema) {
|
|
42
|
-
return
|
|
56
|
+
return getColumnMeta(schema) !== null;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
/**
|
|
@@ -48,6 +62,10 @@ function hasColumnMeta(schema) {
|
|
|
48
62
|
* @returns {import('./types').ColumnMeta|null}
|
|
49
63
|
*/
|
|
50
64
|
function getColumnMeta(schema) {
|
|
65
|
+
if (!schema) return null;
|
|
66
|
+
const direct = metaWeakMap.get(schema);
|
|
67
|
+
if (direct !== undefined) return direct;
|
|
68
|
+
if (schema._wdbMeta) return schema._wdbMeta;
|
|
51
69
|
return decodeColumnMeta(schema.description);
|
|
52
70
|
}
|
|
53
71
|
|
|
@@ -253,6 +271,7 @@ class SchemaBuilder {
|
|
|
253
271
|
|
|
254
272
|
// Apply metadata to schema
|
|
255
273
|
this._schema = this._schema.describe(encodeColumnMeta(finalMeta));
|
|
274
|
+
metaWeakMap.set(this._schema, finalMeta);
|
|
256
275
|
this._finalized = true;
|
|
257
276
|
return this._schema;
|
|
258
277
|
}
|
|
@@ -770,6 +789,15 @@ function createSchemaHelpers(z) {
|
|
|
770
789
|
* @returns {Map<string, import('./types').ColumnMeta>}
|
|
771
790
|
*/
|
|
772
791
|
function extractColumnsFromSchema(schema) {
|
|
792
|
+
if (!schema || typeof schema !== 'object' || !schema.shape) {
|
|
793
|
+
return new Map();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const cached = schemaColumnsWeakMap.get(schema);
|
|
797
|
+
if (cached !== undefined) {
|
|
798
|
+
return cached;
|
|
799
|
+
}
|
|
800
|
+
|
|
773
801
|
const columns = new Map();
|
|
774
802
|
const shape = schema.shape;
|
|
775
803
|
|
|
@@ -806,6 +834,7 @@ function extractColumnsFromSchema(schema) {
|
|
|
806
834
|
}
|
|
807
835
|
}
|
|
808
836
|
|
|
837
|
+
schemaColumnsWeakMap.set(schema, columns);
|
|
809
838
|
return columns;
|
|
810
839
|
}
|
|
811
840
|
|
package/package.json
CHANGED
package/plugins/csrf/index.js
CHANGED
|
@@ -11,6 +11,10 @@ const nunjucks = require('nunjucks');
|
|
|
11
11
|
// Storage to pass the active request to Nunjucks template helpers in a thread-safe manner
|
|
12
12
|
const csrfStorage = new AsyncLocalStorage();
|
|
13
13
|
|
|
14
|
+
const NOOP = () => {};
|
|
15
|
+
const EMPTY_OBJECT = Object.freeze({});
|
|
16
|
+
const ignoreRegexCache = new Map();
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* Compare two strings in constant time to prevent timing attacks
|
|
16
20
|
* @param {string} a
|
|
@@ -43,11 +47,18 @@ function isIgnored(req, ignorePaths) {
|
|
|
43
47
|
if (path.startsWith(pattern)) return true;
|
|
44
48
|
// Basic glob matching
|
|
45
49
|
if (pattern.includes('*')) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
let regex = ignoreRegexCache.get(pattern);
|
|
51
|
+
if (regex === undefined) {
|
|
52
|
+
const regexPattern = pattern
|
|
53
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
54
|
+
.replace(/\*\*/g, '.*')
|
|
55
|
+
.replace(/\*/g, '[^/]*');
|
|
56
|
+
regex = new RegExp(`^${regexPattern}$`);
|
|
57
|
+
if (ignoreRegexCache.size < 500) {
|
|
58
|
+
ignoreRegexCache.set(pattern, regex);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (regex.test(path)) return true;
|
|
51
62
|
}
|
|
52
63
|
} else if (pattern instanceof RegExp) {
|
|
53
64
|
if (pattern.test(path)) return true;
|
|
@@ -126,11 +137,14 @@ function csrfPlugin(options = {}) {
|
|
|
126
137
|
cookieOptions.maxAge = cookieOpts.maxAge;
|
|
127
138
|
}
|
|
128
139
|
|
|
140
|
+
const cookieSecret = cookieOptions.signed ? (cookieOpts.secret || process.env.SESSION_SECRET) : undefined;
|
|
141
|
+
const parseCookies = cookieParser(cookieSecret);
|
|
142
|
+
|
|
129
143
|
/**
|
|
130
144
|
* Internal middleware creator
|
|
131
145
|
* @param {Object} [overrideOpts]
|
|
132
146
|
*/
|
|
133
|
-
function createMiddleware(overrideOpts =
|
|
147
|
+
function createMiddleware(overrideOpts = EMPTY_OBJECT) {
|
|
134
148
|
const activeMethods = overrideOpts.methods || methods;
|
|
135
149
|
const activeIgnorePaths = overrideOpts.ignorePaths || ignorePaths;
|
|
136
150
|
|
|
@@ -140,8 +154,7 @@ function csrfPlugin(options = {}) {
|
|
|
140
154
|
|
|
141
155
|
// Initialize cookie parser dynamically if needed
|
|
142
156
|
if (useCookie && !req.cookies) {
|
|
143
|
-
|
|
144
|
-
cookieParser(secret)(req, res, () => {});
|
|
157
|
+
parseCookies(req, res, NOOP);
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
// 1. Retrieve or generate secret token
|
|
@@ -173,8 +186,8 @@ function csrfPlugin(options = {}) {
|
|
|
173
186
|
// 2. Perform CSRF verification for mutating HTTP methods
|
|
174
187
|
const method = req.method ? req.method.toUpperCase() : 'GET';
|
|
175
188
|
if (activeMethods.includes(method) && !isIgnored(req, activeIgnorePaths)) {
|
|
176
|
-
const body = req.body && typeof req.body === 'object' ? req.body :
|
|
177
|
-
const query = req.query && typeof req.query === 'object' ? req.query :
|
|
189
|
+
const body = req.body && typeof req.body === 'object' ? req.body : EMPTY_OBJECT;
|
|
190
|
+
const query = req.query && typeof req.query === 'object' ? req.query : EMPTY_OBJECT;
|
|
178
191
|
const submitted = (
|
|
179
192
|
body._csrf ||
|
|
180
193
|
query._csrf ||
|
|
@@ -82,6 +82,8 @@ function createTrackingMiddleware(options) {
|
|
|
82
82
|
// Sessions tracked in-memory (visitor_id -> last activity timestamp)
|
|
83
83
|
const sessionMap = new Map();
|
|
84
84
|
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
85
|
+
const MAX_SESSIONS = 10000;
|
|
86
|
+
const MAX_QUEUE_SIZE = 5000;
|
|
85
87
|
|
|
86
88
|
// Periodic session cleanup
|
|
87
89
|
setInterval(() => {
|
|
@@ -102,6 +104,11 @@ function createTrackingMiddleware(options) {
|
|
|
102
104
|
return existing.sessionId;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
if (sessionMap.size >= MAX_SESSIONS) {
|
|
108
|
+
const firstKey = sessionMap.keys().next().value;
|
|
109
|
+
if (firstKey !== undefined) sessionMap.delete(firstKey);
|
|
110
|
+
}
|
|
111
|
+
|
|
105
112
|
const sessionId = quickHash(visitorId + '-' + now + '-' + Math.random());
|
|
106
113
|
sessionMap.set(visitorId, { sessionId, lastSeen: now });
|
|
107
114
|
return sessionId;
|
|
@@ -119,8 +126,15 @@ function createTrackingMiddleware(options) {
|
|
|
119
126
|
await knex(tableName).insert(batch);
|
|
120
127
|
} catch (e) {
|
|
121
128
|
console.error('[site-analytics] Batch insert failed:', e.message);
|
|
122
|
-
// Re-queue failed items
|
|
123
|
-
queue.
|
|
129
|
+
// Re-queue failed items with max size limit to prevent memory leak
|
|
130
|
+
if (queue.length + batch.length <= MAX_QUEUE_SIZE) {
|
|
131
|
+
queue.unshift(...batch);
|
|
132
|
+
} else {
|
|
133
|
+
const available = Math.max(0, MAX_QUEUE_SIZE - queue.length);
|
|
134
|
+
if (available > 0) {
|
|
135
|
+
queue.unshift(...batch.slice(batch.length - available));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
124
138
|
}
|
|
125
139
|
}
|
|
126
140
|
|
package/src/file-router.js
CHANGED
|
@@ -16,9 +16,16 @@ const {
|
|
|
16
16
|
clearNjkFrontmatterCaches,
|
|
17
17
|
} = require('./njk-frontmatter');
|
|
18
18
|
|
|
19
|
+
const NOOP = () => {};
|
|
20
|
+
const EMPTY_OBJECT = Object.freeze({});
|
|
21
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
22
|
+
|
|
19
23
|
// Cache for i18n files (key: filePath, value: { mtime, data })
|
|
20
24
|
const i18nCache = new Map();
|
|
21
25
|
|
|
26
|
+
// Cache for merged i18n (key: `${pagesDir}::${routeDir}::${locale}`, value: { globalMtime, routeMtime, data })
|
|
27
|
+
const mergedI18nCache = new Map();
|
|
28
|
+
|
|
22
29
|
// Cache for route configs in production
|
|
23
30
|
const configCache = new Map();
|
|
24
31
|
|
|
@@ -28,14 +35,25 @@ const routeConfigDevCache = new Map();
|
|
|
28
35
|
// Cache for API filename -> { method, baseName } (basename keys; stable per process)
|
|
29
36
|
const methodFromFilenameCache = new Map();
|
|
30
37
|
|
|
38
|
+
// Cache for translation param replacement regexes
|
|
39
|
+
const paramRegexCache = new Map();
|
|
40
|
+
|
|
31
41
|
const MAX_LOCALE_LEN = 16;
|
|
32
42
|
|
|
43
|
+
let cachedSupportedLocalesRaw = null;
|
|
44
|
+
let cachedSupportedLocaleSet = null;
|
|
45
|
+
|
|
33
46
|
/** @returns {Set<string>} */
|
|
34
47
|
function parseSupportedLocaleSet() {
|
|
35
48
|
const raw = process.env.SUPPORTED_LOCALES || 'en';
|
|
36
|
-
|
|
49
|
+
if (raw === cachedSupportedLocalesRaw && cachedSupportedLocaleSet !== null) {
|
|
50
|
+
return cachedSupportedLocaleSet;
|
|
51
|
+
}
|
|
52
|
+
cachedSupportedLocalesRaw = raw;
|
|
53
|
+
cachedSupportedLocaleSet = new Set(
|
|
37
54
|
raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
38
55
|
);
|
|
56
|
+
return cachedSupportedLocaleSet;
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
/**
|
|
@@ -352,7 +370,7 @@ function scanDirectory(dir, baseDir = dir) {
|
|
|
352
370
|
*/
|
|
353
371
|
function loadI18nFile(filePath) {
|
|
354
372
|
if (!fs.existsSync(filePath)) {
|
|
355
|
-
return
|
|
373
|
+
return EMPTY_OBJECT;
|
|
356
374
|
}
|
|
357
375
|
|
|
358
376
|
try {
|
|
@@ -370,7 +388,7 @@ function loadI18nFile(filePath) {
|
|
|
370
388
|
return data;
|
|
371
389
|
} catch (err) {
|
|
372
390
|
console.error('Error loading i18n file:', filePath, err.message);
|
|
373
|
-
return
|
|
391
|
+
return EMPTY_OBJECT;
|
|
374
392
|
}
|
|
375
393
|
}
|
|
376
394
|
|
|
@@ -390,8 +408,28 @@ function loadI18n(pagesDir, routeDir, locale) {
|
|
|
390
408
|
const routePath = path.join(routeDir, 'locales', `${locale}.json`);
|
|
391
409
|
const routeTranslations = loadI18nFile(routePath);
|
|
392
410
|
|
|
411
|
+
const gEmpty = globalTranslations === EMPTY_OBJECT || !globalTranslations || Object.keys(globalTranslations).length === 0;
|
|
412
|
+
const rEmpty = routeTranslations === EMPTY_OBJECT || !routeTranslations || Object.keys(routeTranslations).length === 0;
|
|
413
|
+
|
|
414
|
+
if (gEmpty && rEmpty) return EMPTY_OBJECT;
|
|
415
|
+
if (gEmpty) return routeTranslations;
|
|
416
|
+
if (rEmpty) return globalTranslations;
|
|
417
|
+
|
|
418
|
+
const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
|
|
419
|
+
const gCached = i18nCache.get(globalPath);
|
|
420
|
+
const rCached = i18nCache.get(routePath);
|
|
421
|
+
const gm = gCached?.mtime ?? 0;
|
|
422
|
+
const rm = rCached?.mtime ?? 0;
|
|
423
|
+
|
|
424
|
+
const mergedCached = mergedI18nCache.get(cacheKey);
|
|
425
|
+
if (mergedCached && mergedCached.gm === gm && mergedCached.rm === rm) {
|
|
426
|
+
return mergedCached.data;
|
|
427
|
+
}
|
|
428
|
+
|
|
393
429
|
// Merge: route-specific overrides global
|
|
394
|
-
|
|
430
|
+
const data = { ...globalTranslations, ...routeTranslations };
|
|
431
|
+
mergedI18nCache.set(cacheKey, { gm, rm, data });
|
|
432
|
+
return data;
|
|
395
433
|
}
|
|
396
434
|
|
|
397
435
|
/**
|
|
@@ -400,7 +438,7 @@ function loadI18n(pagesDir, routeDir, locale) {
|
|
|
400
438
|
* @returns {Function} Translation function t(key)
|
|
401
439
|
*/
|
|
402
440
|
function createTranslator(translations) {
|
|
403
|
-
return function t(key, params =
|
|
441
|
+
return function t(key, params = EMPTY_OBJECT) {
|
|
404
442
|
let value = translations[key];
|
|
405
443
|
|
|
406
444
|
if (value === undefined) {
|
|
@@ -422,10 +460,17 @@ function createTranslator(translations) {
|
|
|
422
460
|
}
|
|
423
461
|
|
|
424
462
|
// Replace params like {{name}} in the translation
|
|
425
|
-
if (typeof value === 'string' && Object.keys(params).length > 0) {
|
|
463
|
+
if (typeof value === 'string' && params && params !== EMPTY_OBJECT && Object.keys(params).length > 0) {
|
|
426
464
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
|
427
|
-
|
|
428
|
-
|
|
465
|
+
let regex = paramRegexCache.get(paramKey);
|
|
466
|
+
if (regex === undefined) {
|
|
467
|
+
const escaped = escapeRegExp(paramKey);
|
|
468
|
+
regex = new RegExp(`{{\\s*${escaped}\\s*}}`, 'g');
|
|
469
|
+
if (paramRegexCache.size < 500) {
|
|
470
|
+
paramRegexCache.set(paramKey, regex);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
value = value.replace(regex, paramValue);
|
|
429
474
|
}
|
|
430
475
|
}
|
|
431
476
|
|
|
@@ -585,9 +630,9 @@ function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middleware
|
|
|
585
630
|
* @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
|
|
586
631
|
* @returns {Array} Array of resolved middleware functions
|
|
587
632
|
*/
|
|
588
|
-
function resolveMiddlewares(middlewareConfig, middlewareRegistry =
|
|
589
|
-
if (!middlewareConfig || !Array.isArray(middlewareConfig)) {
|
|
590
|
-
return
|
|
633
|
+
function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
|
|
634
|
+
if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
|
|
635
|
+
return EMPTY_ARRAY;
|
|
591
636
|
}
|
|
592
637
|
|
|
593
638
|
return middlewareConfig.map((mw, index) => {
|
|
@@ -640,7 +685,7 @@ function mountPages(app, options) {
|
|
|
640
685
|
? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
|
|
641
686
|
: { alpine: false, swup: false };
|
|
642
687
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
643
|
-
const log = silent ?
|
|
688
|
+
const log = silent ? NOOP : console.log.bind(console);
|
|
644
689
|
|
|
645
690
|
// Get absolute path to pages directory
|
|
646
691
|
const absolutePagesDir = path.resolve(pagesDir);
|
|
@@ -783,7 +828,7 @@ function mountPages(app, options) {
|
|
|
783
828
|
|
|
784
829
|
const preResolvedMw = routeMiddleware
|
|
785
830
|
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
786
|
-
:
|
|
831
|
+
: EMPTY_ARRAY;
|
|
787
832
|
|
|
788
833
|
if (typeof handlerFn !== 'function') {
|
|
789
834
|
console.warn(`API route ${route.file} does not export a function`);
|
|
@@ -867,7 +912,7 @@ function mountPages(app, options) {
|
|
|
867
912
|
const mountConfig = loadRouteConfig(route.configPath, isDev);
|
|
868
913
|
const preResolvedPageMw = mountConfig?.middleware
|
|
869
914
|
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
870
|
-
:
|
|
915
|
+
: EMPTY_ARRAY;
|
|
871
916
|
|
|
872
917
|
app.get(route.routePath, async (req, res, next) => {
|
|
873
918
|
try {
|
|
@@ -880,11 +925,11 @@ function mountPages(app, options) {
|
|
|
880
925
|
|
|
881
926
|
// Load route config
|
|
882
927
|
const config = loadRouteConfig(route.configPath, isDev);
|
|
883
|
-
const routeHooks = config?.hooks ||
|
|
928
|
+
const routeHooks = config?.hooks || EMPTY_OBJECT;
|
|
884
929
|
|
|
885
930
|
// Create context with plugin helpers merged
|
|
886
931
|
const baseHelpers = createHelpers({ req, res, locale });
|
|
887
|
-
const pluginHelpers = pluginManager ? pluginManager.getHelpers() :
|
|
932
|
+
const pluginHelpers = pluginManager ? pluginManager.getHelpers() : EMPTY_OBJECT;
|
|
888
933
|
if (pluginManager) {
|
|
889
934
|
const contentApi = pluginManager.getPluginAPI('content');
|
|
890
935
|
if (contentApi?.createRequestHelpers) {
|
package/src/helpers.js
CHANGED
|
@@ -18,6 +18,9 @@ dayjs.extend(utc);
|
|
|
18
18
|
dayjs.extend(timezone);
|
|
19
19
|
dayjs.extend(customParseFormat);
|
|
20
20
|
|
|
21
|
+
// Cache for isPath regex patterns
|
|
22
|
+
const isPathRegexCache = new Map();
|
|
23
|
+
|
|
21
24
|
/**
|
|
22
25
|
* Asset Manager - handles asset paths, versioning, and manifest
|
|
23
26
|
*/
|
|
@@ -385,7 +388,13 @@ function createHelpers(ctx) {
|
|
|
385
388
|
const currentPath = req.path;
|
|
386
389
|
if (pattern === currentPath) return true;
|
|
387
390
|
if (pattern.includes('*')) {
|
|
388
|
-
|
|
391
|
+
let regex = isPathRegexCache.get(pattern);
|
|
392
|
+
if (regex === undefined) {
|
|
393
|
+
regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
394
|
+
if (isPathRegexCache.size < 500) {
|
|
395
|
+
isPathRegexCache.set(pattern, regex);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
389
398
|
return regex.test(currentPath);
|
|
390
399
|
}
|
|
391
400
|
return false;
|