webspresso 0.0.113 → 0.0.114
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 +26 -0
- package/bin/commands/admin-password.js +1 -7
- package/bin/commands/api.js +1 -7
- package/bin/commands/new.js +1 -7
- package/bin/commands/page.js +1 -7
- package/bin/commands/seed.js +1 -7
- package/bin/commands/service.js +1 -7
- package/bin/utils/prompt.js +17 -0
- package/core/cache/tagged-lru.js +9 -3
- package/core/client/adapter/datastar.js +15 -0
- package/core/client/api.js +4 -0
- package/core/client/controller.js +72 -34
- package/core/client/forms.js +6 -2
- package/core/client/fragments.js +64 -7
- package/core/client/index.js +7 -2
- package/core/client/server-fragments.js +23 -4
- package/core/console/commands.js +26 -5
- package/core/console/repl.js +7 -1
- package/core/plugins/plugin-manager.js +155 -1
- package/index.d.ts +15 -0
- package/package.json +4 -4
- package/src/bundle/index.js +73 -3
- package/src/dev-reload/index.js +44 -2
- package/src/server.js +59 -3
package/README.md
CHANGED
|
@@ -153,6 +153,31 @@ app.listen(PORT, () => {
|
|
|
153
153
|
});
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
### 5. Client Behavior Controller (`pages/notes.client.ts`)
|
|
157
|
+
|
|
158
|
+
Co-locate reactive client logic right next to `pages/notes.njk` (`<div data-w-controller="notes">`) with automatic bundling and server-import boundary protection:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { defineController } from 'webspresso/client';
|
|
162
|
+
|
|
163
|
+
export default defineController({
|
|
164
|
+
state: () => ({ title: '', saving: false }),
|
|
165
|
+
actions: {
|
|
166
|
+
async create(ctx) {
|
|
167
|
+
if (!ctx.state.title.trim()) return;
|
|
168
|
+
ctx.state.saving = true;
|
|
169
|
+
try {
|
|
170
|
+
await ctx.api.post('/api/notes', { title: ctx.state.title });
|
|
171
|
+
ctx.state.title = '';
|
|
172
|
+
await ctx.fragments.invalidate('notes'); // Refresh SSR fragments tagged with "notes"
|
|
173
|
+
} finally {
|
|
174
|
+
ctx.state.saving = false;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
156
181
|
---
|
|
157
182
|
|
|
158
183
|
## ⚡ High-Performance Architecture (Cold Start & AOT)
|
|
@@ -202,6 +227,7 @@ Detailed topic guides are available in the repository:
|
|
|
202
227
|
|
|
203
228
|
- **[Installation & First App](docs/getting-started/installation.md)** — Node.js requirements, setup, and first steps.
|
|
204
229
|
- **[File-Based Routing & API Routes](.agents/ROUTING.md)** — Routing conventions, parameters, and cascading layouts.
|
|
230
|
+
- **[Client Behavior & SSR Fragments](docs/guides/client-behavior.md)** — `defineController`, `defineFragment`, forms, and auto-revalidation.
|
|
205
231
|
- **[SSR Streaming & Chunked Transfer](.agents/STREAMING.md)** — Out-of-order slots, `_loading.njk` skeletons, and TTFB optimization.
|
|
206
232
|
- **[Database & ORM Guide](.agents/ORM.md)** — Repositories, relations, soft deletes, and ambient transactions.
|
|
207
233
|
- **[Dual Authentication System](.agents/AUTH.md)** — Sessions, JWT rotation, magic links, and device management.
|
|
@@ -3,13 +3,7 @@
|
|
|
3
3
|
* Reset admin user password via CLI
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
const inquirer = {
|
|
8
|
-
prompt: (...args) => {
|
|
9
|
-
if (!_inquirer) _inquirer = require('inquirer');
|
|
10
|
-
return _inquirer.prompt(...args);
|
|
11
|
-
},
|
|
12
|
-
};
|
|
6
|
+
const inquirer = require('../utils/prompt');
|
|
13
7
|
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
14
8
|
|
|
15
9
|
function registerCommand(program) {
|
package/bin/commands/api.js
CHANGED
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
* @module bin/commands/api
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const inquirer = {
|
|
11
|
-
prompt: (...args) => {
|
|
12
|
-
if (!_inquirer) _inquirer = require('inquirer');
|
|
13
|
-
return _inquirer.prompt(...args);
|
|
14
|
-
},
|
|
15
|
-
};
|
|
9
|
+
const inquirer = require('../utils/prompt');
|
|
16
10
|
const fs = require('fs');
|
|
17
11
|
const path = require('path');
|
|
18
12
|
|
package/bin/commands/new.js
CHANGED
|
@@ -3,13 +3,7 @@
|
|
|
3
3
|
* Creates a new Webspresso project
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
const inquirer = {
|
|
8
|
-
prompt: (...args) => {
|
|
9
|
-
if (!_inquirer) _inquirer = require('inquirer');
|
|
10
|
-
return _inquirer.prompt(...args);
|
|
11
|
-
},
|
|
12
|
-
};
|
|
6
|
+
const inquirer = require('../utils/prompt');
|
|
13
7
|
const fs = require('fs');
|
|
14
8
|
const path = require('path');
|
|
15
9
|
|
package/bin/commands/page.js
CHANGED
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
* @module bin/commands/page
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const inquirer = {
|
|
11
|
-
prompt: (...args) => {
|
|
12
|
-
if (!_inquirer) _inquirer = require('inquirer');
|
|
13
|
-
return _inquirer.prompt(...args);
|
|
14
|
-
},
|
|
15
|
-
};
|
|
9
|
+
const inquirer = require('../utils/prompt');
|
|
16
10
|
const fs = require('fs');
|
|
17
11
|
const path = require('path');
|
|
18
12
|
|
package/bin/commands/seed.js
CHANGED
|
@@ -3,13 +3,7 @@
|
|
|
3
3
|
* Run database seeders to populate database with fake data
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
const inquirer = {
|
|
8
|
-
prompt: (...args) => {
|
|
9
|
-
if (!_inquirer) _inquirer = require('inquirer');
|
|
10
|
-
return _inquirer.prompt(...args);
|
|
11
|
-
},
|
|
12
|
-
};
|
|
6
|
+
const inquirer = require('../utils/prompt');
|
|
13
7
|
const fs = require('fs');
|
|
14
8
|
const path = require('path');
|
|
15
9
|
const { execSync } = require('child_process');
|
package/bin/commands/service.js
CHANGED
|
@@ -5,13 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const path = require('path');
|
|
8
|
-
|
|
9
|
-
const inquirer = {
|
|
10
|
-
prompt: (...args) => {
|
|
11
|
-
if (!_inquirer) _inquirer = require('inquirer');
|
|
12
|
-
return _inquirer.prompt(...args);
|
|
13
|
-
},
|
|
14
|
-
};
|
|
8
|
+
const inquirer = require('../utils/prompt');
|
|
15
9
|
const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
|
|
16
10
|
|
|
17
11
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared lazy ESM loader for Inquirer v13+ in CommonJS CLI commands.
|
|
3
|
+
*/
|
|
4
|
+
let _inquirer;
|
|
5
|
+
|
|
6
|
+
const inquirer = {
|
|
7
|
+
async prompt(...args) {
|
|
8
|
+
if (!_inquirer) {
|
|
9
|
+
const mod = await import('inquirer');
|
|
10
|
+
_inquirer = mod.default || mod;
|
|
11
|
+
}
|
|
12
|
+
return _inquirer.prompt(...args);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
module.exports = inquirer;
|
|
17
|
+
|
package/core/cache/tagged-lru.js
CHANGED
|
@@ -165,14 +165,18 @@ class TaggedLruCache {
|
|
|
165
165
|
set(key, value, options = {}) {
|
|
166
166
|
const tags = normalizeTags(options.tags);
|
|
167
167
|
|
|
168
|
-
//
|
|
168
|
+
// Clear previous tags before lru.set so stale tags never survive if lru.set rejects (e.g. > maxBytes)
|
|
169
169
|
if (this.keyToTags.has(key)) {
|
|
170
170
|
this._removeKeyFromTags(key);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
// Run lru.set first so any internal _removeEntry('delete') on overwrite fires onEvict BEFORE we bind new tags
|
|
174
174
|
this.lru.set(key, value, options);
|
|
175
175
|
|
|
176
|
+
if (tags.length > 0 && this.lru.has(key)) {
|
|
177
|
+
this._addKeyToTags(key, tags);
|
|
178
|
+
}
|
|
179
|
+
|
|
176
180
|
return this;
|
|
177
181
|
}
|
|
178
182
|
|
|
@@ -290,7 +294,9 @@ class TaggedLruCache {
|
|
|
290
294
|
if (this.keyToTags.has(key)) {
|
|
291
295
|
this._removeKeyFromTags(key);
|
|
292
296
|
}
|
|
293
|
-
this.
|
|
297
|
+
if (tags.length > 0 && this.lru.has(key)) {
|
|
298
|
+
this._addKeyToTags(key, tags);
|
|
299
|
+
}
|
|
294
300
|
}
|
|
295
301
|
return val;
|
|
296
302
|
}
|
|
@@ -1026,6 +1026,21 @@ function syncFormControlValue(toEl, fromEl, ownership, preserveDraft) {
|
|
|
1026
1026
|
function morphDomTrees(toEl, fromEl, ctx = {}) {
|
|
1027
1027
|
if (!toEl || !fromEl) return toEl;
|
|
1028
1028
|
|
|
1029
|
+
// Preserve an already-loaded lazy child fragment if a parent refresh sends a lazy placeholder
|
|
1030
|
+
if (
|
|
1031
|
+
toEl.hasAttribute &&
|
|
1032
|
+
fromEl.hasAttribute &&
|
|
1033
|
+
toEl.hasAttribute('data-w-fragment') &&
|
|
1034
|
+
fromEl.hasAttribute('data-w-fragment') &&
|
|
1035
|
+
!toEl.hasAttribute('data-w-lazy')
|
|
1036
|
+
) {
|
|
1037
|
+
const incomingLazy = fromEl.getAttribute('data-w-lazy');
|
|
1038
|
+
if (incomingLazy && incomingLazy !== 'false' && incomingLazy !== 'loaded') {
|
|
1039
|
+
toEl.__wPreservedLazyChild = true;
|
|
1040
|
+
return toEl;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1029
1044
|
const rawOwnership = getClientOwnership(toEl);
|
|
1030
1045
|
const ignoreMode = getIgnoreMode(toEl);
|
|
1031
1046
|
const isForm = toEl.tagName === 'FORM';
|
package/core/client/api.js
CHANGED
|
@@ -693,6 +693,10 @@ function executeXhrUpload({ url, method, headers, body, credentials, signal, onU
|
|
|
693
693
|
|
|
694
694
|
xhr.onload = () => {
|
|
695
695
|
cleanup();
|
|
696
|
+
if (xhr.status === 0) {
|
|
697
|
+
reject(new TypeError('Network request failed'));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
696
700
|
const status = xhr.status === 1223 ? 204 : xhr.status;
|
|
697
701
|
const rawText = typeof xhr.responseText === 'string' ? xhr.responseText : '';
|
|
698
702
|
const headerObj = parseXhrHeaders(xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '');
|
|
@@ -2066,7 +2066,28 @@ function createControllerRuntime(options = {}) {
|
|
|
2066
2066
|
return mounted;
|
|
2067
2067
|
}
|
|
2068
2068
|
|
|
2069
|
-
|
|
2069
|
+
let activeHookContext = null;
|
|
2070
|
+
|
|
2071
|
+
function isInsideIgnoredSubtree(el, rootEl) {
|
|
2072
|
+
let cur = el && el.parentElement ? el.parentElement : null;
|
|
2073
|
+
while (cur) {
|
|
2074
|
+
if (typeof cur.hasAttribute === 'function' && cur.hasAttribute('data-w-ignore')) {
|
|
2075
|
+
const mode = (cur.getAttribute('data-w-ignore') || '').trim().toLowerCase();
|
|
2076
|
+
if (mode !== 'self') {
|
|
2077
|
+
return true;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
if (cur === rootEl) break;
|
|
2081
|
+
cur = cur.parentElement || null;
|
|
2082
|
+
}
|
|
2083
|
+
if (el === rootEl && el && typeof el.hasAttribute === 'function' && el.hasAttribute('data-w-ignore')) {
|
|
2084
|
+
const mode = (el.getAttribute('data-w-ignore') || '').trim().toLowerCase();
|
|
2085
|
+
if (mode === 'all' || mode === 'both') {
|
|
2086
|
+
return true;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
2070
2091
|
|
|
2071
2092
|
function collectAffectedInstances(fragmentEl) {
|
|
2072
2093
|
if (!fragmentEl) return [];
|
|
@@ -2078,6 +2099,7 @@ function createControllerRuntime(options = {}) {
|
|
|
2078
2099
|
(typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el)) ||
|
|
2079
2100
|
(typeof inst.el.contains === 'function' && inst.el.contains(fragmentEl))
|
|
2080
2101
|
) {
|
|
2102
|
+
if (isInsideIgnoredSubtree(inst.el, fragmentEl)) continue;
|
|
2081
2103
|
affected.push(inst);
|
|
2082
2104
|
}
|
|
2083
2105
|
}
|
|
@@ -2098,47 +2120,60 @@ function createControllerRuntime(options = {}) {
|
|
|
2098
2120
|
|
|
2099
2121
|
function notifyBeforePatch(fragmentEl, meta = {}) {
|
|
2100
2122
|
const affected = collectAffectedInstances(fragmentEl);
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2123
|
+
const prevHookCtx = activeHookContext;
|
|
2124
|
+
activeHookContext = {
|
|
2125
|
+
fragmentEl,
|
|
2126
|
+
depth: meta && typeof meta.hookChainDepth === 'number' ? meta.hookChainDepth : 0,
|
|
2127
|
+
};
|
|
2128
|
+
try {
|
|
2129
|
+
for (let i = 0; i < affected.length; i++) {
|
|
2130
|
+
const inst = affected[i];
|
|
2131
|
+
inst.invokeBeforePatch({
|
|
2132
|
+
...meta,
|
|
2133
|
+
isFragmentRoot: inst.el === fragmentEl,
|
|
2134
|
+
isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
} finally {
|
|
2138
|
+
activeHookContext = prevHookCtx;
|
|
2108
2139
|
}
|
|
2109
2140
|
}
|
|
2110
2141
|
|
|
2111
2142
|
function notifyAfterPatch(fragmentEl, meta = {}) {
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
const
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
message: msg,
|
|
2126
|
-
});
|
|
2127
|
-
} else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
|
|
2128
|
-
console.warn(`[webspresso] ${msg}`);
|
|
2129
|
-
}
|
|
2143
|
+
const chainDepth = meta && typeof meta.hookChainDepth === 'number' ? meta.hookChainDepth : 0;
|
|
2144
|
+
if (chainDepth > 3) {
|
|
2145
|
+
const msg = `Possible patch hook refresh loop detected on fragment "${meta.fragmentName || meta.fragmentId || 'unknown'}" (causal chain depth ${chainDepth}).`;
|
|
2146
|
+
if (debug && typeof debug.recordEvent === 'function') {
|
|
2147
|
+
debug.recordEvent('patch:loop-warning', {
|
|
2148
|
+
code: 'PATCH_HOOK_LOOP_DETECTED',
|
|
2149
|
+
fragmentId: meta.fragmentId || null,
|
|
2150
|
+
fragmentName: meta.fragmentName || null,
|
|
2151
|
+
count: chainDepth,
|
|
2152
|
+
message: msg,
|
|
2153
|
+
});
|
|
2154
|
+
} else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
|
|
2155
|
+
console.warn(`[webspresso] ${msg}`);
|
|
2130
2156
|
}
|
|
2131
2157
|
}
|
|
2132
2158
|
|
|
2133
2159
|
const affected = collectAffectedInstances(fragmentEl);
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2160
|
+
const prevHookCtx = activeHookContext;
|
|
2161
|
+
activeHookContext = {
|
|
2162
|
+
fragmentEl,
|
|
2163
|
+
depth: chainDepth,
|
|
2164
|
+
};
|
|
2165
|
+
try {
|
|
2166
|
+
// Reverse order for afterPatch: inner descendants first, outer ancestors second
|
|
2167
|
+
for (let i = affected.length - 1; i >= 0; i--) {
|
|
2168
|
+
const inst = affected[i];
|
|
2169
|
+
inst.invokeAfterPatch({
|
|
2170
|
+
...meta,
|
|
2171
|
+
isFragmentRoot: inst.el === fragmentEl,
|
|
2172
|
+
isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
} finally {
|
|
2176
|
+
activeHookContext = prevHookCtx;
|
|
2142
2177
|
}
|
|
2143
2178
|
}
|
|
2144
2179
|
|
|
@@ -2157,6 +2192,9 @@ function createControllerRuntime(options = {}) {
|
|
|
2157
2192
|
unmountSubtree,
|
|
2158
2193
|
notifyBeforePatch,
|
|
2159
2194
|
notifyAfterPatch,
|
|
2195
|
+
getActivePatchHookContext() {
|
|
2196
|
+
return activeHookContext;
|
|
2197
|
+
},
|
|
2160
2198
|
mountControllerOnElement,
|
|
2161
2199
|
getInstanceForElement(el, name) {
|
|
2162
2200
|
const map = elementInstances.get(el);
|
package/core/client/forms.js
CHANGED
|
@@ -469,6 +469,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
469
469
|
const validationSeqByField = new Map();
|
|
470
470
|
const validationTimersByField = new Map();
|
|
471
471
|
const validationAbortByField = new Map();
|
|
472
|
+
let validationEpoch = 0;
|
|
472
473
|
|
|
473
474
|
let activeSubmitAbort = null;
|
|
474
475
|
let uploadEntries = [];
|
|
@@ -476,6 +477,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
476
477
|
|
|
477
478
|
function cancelFieldValidation(fieldName) {
|
|
478
479
|
if (fieldName) {
|
|
480
|
+
validationSeqByField.set(fieldName, (validationSeqByField.get(fieldName) || 0) + 1);
|
|
479
481
|
const t = validationTimersByField.get(fieldName);
|
|
480
482
|
if (t) {
|
|
481
483
|
clearTimeout(t);
|
|
@@ -490,6 +492,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
490
492
|
}
|
|
491
493
|
return;
|
|
492
494
|
}
|
|
495
|
+
validationEpoch += 1;
|
|
493
496
|
for (const t of validationTimersByField.values()) clearTimeout(t);
|
|
494
497
|
validationTimersByField.clear();
|
|
495
498
|
for (const ac of validationAbortByField.values()) {
|
|
@@ -1324,6 +1327,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1324
1327
|
if (!formEl || !api || !fieldName) return { ok: true, valid: true, errors: {} };
|
|
1325
1328
|
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1326
1329
|
|
|
1330
|
+
const epoch = validationEpoch;
|
|
1327
1331
|
const seq = (validationSeqByField.get(norm) || 0) + 1;
|
|
1328
1332
|
validationSeqByField.set(norm, seq);
|
|
1329
1333
|
|
|
@@ -1368,7 +1372,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1368
1372
|
signal: ac.signal,
|
|
1369
1373
|
});
|
|
1370
1374
|
|
|
1371
|
-
if (seq !== validationSeqByField.get(norm)) {
|
|
1375
|
+
if (epoch !== validationEpoch || seq !== validationSeqByField.get(norm)) {
|
|
1372
1376
|
return { ok: true, stale: true, valid: true, errors: {} };
|
|
1373
1377
|
}
|
|
1374
1378
|
|
|
@@ -1386,7 +1390,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1386
1390
|
errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {},
|
|
1387
1391
|
};
|
|
1388
1392
|
} catch (err) {
|
|
1389
|
-
if (seq !== validationSeqByField.get(norm) || (err && err.isCanceled)) {
|
|
1393
|
+
if (epoch !== validationEpoch || seq !== validationSeqByField.get(norm) || (err && err.isCanceled)) {
|
|
1390
1394
|
return { ok: true, stale: true, canceled: Boolean(err && err.isCanceled), errors: {} };
|
|
1391
1395
|
}
|
|
1392
1396
|
// If server returned a 400/422 validation envelope, extract only this field's error
|
package/core/client/fragments.js
CHANGED
|
@@ -157,8 +157,9 @@ function createFragmentCoordinator(options = {}) {
|
|
|
157
157
|
let isDisposed = false;
|
|
158
158
|
let lifecycleListenersAttached = false;
|
|
159
159
|
|
|
160
|
-
// Per-version patch options (mode, limit, target, removeKeys, onBeforePatch, onAfterPatch)
|
|
160
|
+
// Per-version patch options (mode, limit, target, removeKeys, onBeforePatch, onAfterPatch, hookChainDepth)
|
|
161
161
|
const pendingPatchOptsByKey = new Map();
|
|
162
|
+
let pendingInvalidateHookDepth = 0;
|
|
162
163
|
|
|
163
164
|
// Lazy fragment IntersectionObserver state
|
|
164
165
|
let lazyObserver = null;
|
|
@@ -792,16 +793,21 @@ function createFragmentCoordinator(options = {}) {
|
|
|
792
793
|
pendingPatchOptsByKey.delete(patchKey);
|
|
793
794
|
}
|
|
794
795
|
|
|
796
|
+
const patchMeta = {
|
|
797
|
+
hookChainDepth: (patchOpts && patchOpts.hookChainDepth) || 0,
|
|
798
|
+
};
|
|
799
|
+
|
|
795
800
|
if (typeof options.onBeforePatch === 'function') {
|
|
796
|
-
options.onBeforePatch(inst.el, inst);
|
|
801
|
+
options.onBeforePatch(inst.el, inst, patchMeta);
|
|
797
802
|
}
|
|
798
803
|
if (patchOpts && typeof patchOpts.onBeforePatch === 'function') {
|
|
799
804
|
try {
|
|
800
|
-
patchOpts.onBeforePatch(inst.el, inst);
|
|
805
|
+
patchOpts.onBeforePatch(inst.el, inst, patchMeta);
|
|
801
806
|
} catch (_e) {}
|
|
802
807
|
}
|
|
803
808
|
|
|
804
809
|
const patchStart = Date.now();
|
|
810
|
+
const patchMode = fragmentPayload.mode || (patchOpts && patchOpts.mode) || 'replace';
|
|
805
811
|
|
|
806
812
|
const committedInput =
|
|
807
813
|
fragmentPayload.input && typeof fragmentPayload.input === 'object' && !isArray(fragmentPayload.input)
|
|
@@ -811,6 +817,8 @@ function createFragmentCoordinator(options = {}) {
|
|
|
811
817
|
: null;
|
|
812
818
|
|
|
813
819
|
if (
|
|
820
|
+
patchMode !== 'append' &&
|
|
821
|
+
patchMode !== 'prepend' &&
|
|
814
822
|
committedInput &&
|
|
815
823
|
!followUpNeeded &&
|
|
816
824
|
pendingTags.size === 0
|
|
@@ -827,7 +835,16 @@ function createFragmentCoordinator(options = {}) {
|
|
|
827
835
|
}
|
|
828
836
|
}
|
|
829
837
|
|
|
830
|
-
|
|
838
|
+
// Track existing strict descendant fragments before replace morph so removed children can be cleaned up
|
|
839
|
+
const prevDescendants = [];
|
|
840
|
+
if (patchMode !== 'append' && patchMode !== 'prepend') {
|
|
841
|
+
for (const otherInst of instances.values()) {
|
|
842
|
+
if (otherInst !== inst && otherInst.el && isStrictAncestor(inst.el, otherInst.el)) {
|
|
843
|
+
prevDescendants.push(otherInst);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
831
848
|
if (patchMode === 'append' || patchMode === 'prepend') {
|
|
832
849
|
patchIncrementalList(inst.el, html, {
|
|
833
850
|
mode: patchMode,
|
|
@@ -861,20 +878,40 @@ function createFragmentCoordinator(options = {}) {
|
|
|
861
878
|
const now = Date.now();
|
|
862
879
|
inst.lastUpdatedAt = now;
|
|
863
880
|
|
|
881
|
+
// Clean up any nested child fragments removed during this parent patch
|
|
882
|
+
for (let i = 0; i < prevDescendants.length; i++) {
|
|
883
|
+
const prevChild = prevDescendants[i];
|
|
884
|
+
if (!prevChild.el || prevChild.el.isConnected === false || !isStrictAncestor(inst.el, prevChild.el)) {
|
|
885
|
+
clearInstanceInterval(prevChild);
|
|
886
|
+
unobserveLazyFragment(prevChild);
|
|
887
|
+
if (prevChild.abortController) {
|
|
888
|
+
try {
|
|
889
|
+
prevChild.abortController.abort();
|
|
890
|
+
} catch (_e) {}
|
|
891
|
+
prevChild.abortController = null;
|
|
892
|
+
}
|
|
893
|
+
instances.delete(prevChild.instanceId);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
864
897
|
// Re-sync nested fragment instances that may have been updated inside this parent fragment
|
|
865
898
|
scanDomInstances(inst.el);
|
|
866
899
|
for (const otherInst of instances.values()) {
|
|
867
900
|
if (otherInst !== inst && otherInst.el && isStrictAncestor(inst.el, otherInst.el)) {
|
|
868
|
-
otherInst.
|
|
901
|
+
if (otherInst.el.__wPreservedLazyChild) {
|
|
902
|
+
delete otherInst.el.__wPreservedLazyChild;
|
|
903
|
+
} else if (!(otherInst.lazy && otherInst.lazyState === 'idle')) {
|
|
904
|
+
otherInst.lastUpdatedAt = now;
|
|
905
|
+
}
|
|
869
906
|
}
|
|
870
907
|
}
|
|
871
908
|
|
|
872
909
|
if (typeof options.onAfterPatch === 'function') {
|
|
873
|
-
options.onAfterPatch(inst.el, inst);
|
|
910
|
+
options.onAfterPatch(inst.el, inst, patchMeta);
|
|
874
911
|
}
|
|
875
912
|
if (patchOpts && typeof patchOpts.onAfterPatch === 'function') {
|
|
876
913
|
try {
|
|
877
|
-
patchOpts.onAfterPatch(inst.el, inst);
|
|
914
|
+
patchOpts.onAfterPatch(inst.el, inst, patchMeta);
|
|
878
915
|
} catch (_e) {}
|
|
879
916
|
}
|
|
880
917
|
|
|
@@ -1339,8 +1376,16 @@ function createFragmentCoordinator(options = {}) {
|
|
|
1339
1376
|
// Parent/Child deduplication: if parent & child are both invalidated, parent refresh covers child
|
|
1340
1377
|
const dedupedInstances = deduplicateParentChildFragments(matchingInstances);
|
|
1341
1378
|
|
|
1379
|
+
const batchHookDepth = pendingInvalidateHookDepth;
|
|
1380
|
+
pendingInvalidateHookDepth = 0;
|
|
1381
|
+
|
|
1342
1382
|
const targets = dedupedInstances.map((inst) => {
|
|
1343
1383
|
inst.version += 1;
|
|
1384
|
+
if (batchHookDepth > 0) {
|
|
1385
|
+
pendingPatchOptsByKey.set(`${inst.instanceId}:${inst.version}`, {
|
|
1386
|
+
hookChainDepth: batchHookDepth,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1344
1389
|
return {
|
|
1345
1390
|
instanceId: inst.instanceId,
|
|
1346
1391
|
name: inst.name,
|
|
@@ -1385,6 +1430,13 @@ function createFragmentCoordinator(options = {}) {
|
|
|
1385
1430
|
* @returns {Promise<{ refreshed: string[], skipped?: boolean }>}
|
|
1386
1431
|
*/
|
|
1387
1432
|
function invalidate(tags, meta = {}) {
|
|
1433
|
+
const activeHook = typeof options.getPatchHookContext === 'function' ? options.getPatchHookContext() : null;
|
|
1434
|
+
if (activeHook) {
|
|
1435
|
+
const nextDepth = (activeHook.depth || 0) + 1;
|
|
1436
|
+
if (nextDepth > pendingInvalidateHookDepth) {
|
|
1437
|
+
pendingInvalidateHookDepth = nextDepth;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1388
1440
|
const tagList = isArray(tags) ? tags : [tags];
|
|
1389
1441
|
for (const raw of tagList) {
|
|
1390
1442
|
if (typeof raw === 'string' && raw.trim()) {
|
|
@@ -1480,11 +1532,15 @@ function createFragmentCoordinator(options = {}) {
|
|
|
1480
1532
|
unobserveLazyFragment(inst);
|
|
1481
1533
|
}
|
|
1482
1534
|
|
|
1535
|
+
const activeHook = typeof options.getPatchHookContext === 'function' ? options.getPatchHookContext() : null;
|
|
1536
|
+
const hookChainDepth = activeHook ? (activeHook.depth || 0) + 1 : 0;
|
|
1537
|
+
|
|
1483
1538
|
if (
|
|
1484
1539
|
refreshOpts.mode ||
|
|
1485
1540
|
refreshOpts.limit !== undefined ||
|
|
1486
1541
|
refreshOpts.target ||
|
|
1487
1542
|
refreshOpts.removeKeys ||
|
|
1543
|
+
hookChainDepth > 0 ||
|
|
1488
1544
|
typeof refreshOpts.onBeforePatch === 'function' ||
|
|
1489
1545
|
typeof refreshOpts.onAfterPatch === 'function'
|
|
1490
1546
|
) {
|
|
@@ -1493,6 +1549,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
1493
1549
|
limit: refreshOpts.limit,
|
|
1494
1550
|
target: refreshOpts.target,
|
|
1495
1551
|
removeKeys: refreshOpts.removeKeys,
|
|
1552
|
+
hookChainDepth,
|
|
1496
1553
|
onBeforePatch: refreshOpts.onBeforePatch,
|
|
1497
1554
|
onAfterPatch: refreshOpts.onAfterPatch,
|
|
1498
1555
|
});
|
package/core/client/index.js
CHANGED
|
@@ -192,20 +192,25 @@ function createClientRuntime(options = {}) {
|
|
|
192
192
|
controllers.mountSubtree(addedEl);
|
|
193
193
|
}
|
|
194
194
|
},
|
|
195
|
-
|
|
195
|
+
getPatchHookContext() {
|
|
196
|
+
return controllers ? controllers.getActivePatchHookContext() : null;
|
|
197
|
+
},
|
|
198
|
+
onBeforePatch(patchedEl, inst, meta) {
|
|
196
199
|
if (controllers) {
|
|
197
200
|
controllers.notifyBeforePatch(patchedEl, {
|
|
198
201
|
fragmentId: inst && inst.instanceId ? inst.instanceId : null,
|
|
199
202
|
fragmentName: inst && inst.name ? inst.name : null,
|
|
203
|
+
hookChainDepth: meta && meta.hookChainDepth ? meta.hookChainDepth : 0,
|
|
200
204
|
});
|
|
201
205
|
}
|
|
202
206
|
},
|
|
203
|
-
onAfterPatch(patchedEl, inst) {
|
|
207
|
+
onAfterPatch(patchedEl, inst, meta) {
|
|
204
208
|
if (controllers) {
|
|
205
209
|
controllers.mountSubtree(patchedEl);
|
|
206
210
|
controllers.notifyAfterPatch(patchedEl, {
|
|
207
211
|
fragmentId: inst && inst.instanceId ? inst.instanceId : null,
|
|
208
212
|
fragmentName: inst && inst.name ? inst.name : null,
|
|
213
|
+
hookChainDepth: meta && meta.hookChainDepth ? meta.hookChainDepth : 0,
|
|
209
214
|
});
|
|
210
215
|
}
|
|
211
216
|
},
|
|
@@ -646,6 +646,17 @@ async function executeAndRenderFragment({
|
|
|
646
646
|
};
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
function getPluginRegisteredFragment(name) {
|
|
650
|
+
try {
|
|
651
|
+
const { getPluginManager } = require('../plugins/plugin-manager');
|
|
652
|
+
const pm = typeof getPluginManager === 'function' ? getPluginManager() : null;
|
|
653
|
+
if (pm && typeof pm.getFragment === 'function') {
|
|
654
|
+
return pm.getFragment(name) || null;
|
|
655
|
+
}
|
|
656
|
+
} catch (_e) {}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
649
660
|
/**
|
|
650
661
|
* Create the `fsy.fragment(name, input?, options?)` template helper for a page render pass.
|
|
651
662
|
* Uses the exact same `executeAndRenderFragment` pipeline for initial SSR as subsequent refreshes!
|
|
@@ -665,7 +676,9 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
|
|
|
665
676
|
|
|
666
677
|
function renderFragmentHelper(name, rawInput = {}, options = {}) {
|
|
667
678
|
const cleanName = String(name || '').trim();
|
|
668
|
-
const fragmentDef =
|
|
679
|
+
const fragmentDef =
|
|
680
|
+
(Object.prototype.hasOwnProperty.call(fragmentsMap, cleanName) ? fragmentsMap[cleanName] : null) ||
|
|
681
|
+
getPluginRegisteredFragment(cleanName);
|
|
669
682
|
if (!fragmentDef) {
|
|
670
683
|
throw new NotFoundError(
|
|
671
684
|
`Fragment "${cleanName}" is not defined in fragments export for page "${pagePath}".`
|
|
@@ -898,9 +911,10 @@ function createFragmentRefreshHandler(config) {
|
|
|
898
911
|
req.params = { ...(req.params || {}), ...params };
|
|
899
912
|
|
|
900
913
|
const pageMod = typeof entry.getPageMod === 'function' ? entry.getPageMod() : entry.pageMod;
|
|
901
|
-
const
|
|
914
|
+
const pageFragmentsMap = (pageMod && pageMod.fragments) || {};
|
|
915
|
+
const fragmentsMap = { ...pageFragmentsMap };
|
|
902
916
|
|
|
903
|
-
// Validate that all requested fragments are registered on this page BEFORE running loaders
|
|
917
|
+
// Validate that all requested fragments are registered on this page (or by a plugin) BEFORE running loaders
|
|
904
918
|
// (Rejects arbitrary template paths or unknown fragment names)
|
|
905
919
|
for (const item of requestedFragments) {
|
|
906
920
|
if (!item || typeof item.name !== 'string' || !item.name.trim()) {
|
|
@@ -910,7 +924,12 @@ function createFragmentRefreshHandler(config) {
|
|
|
910
924
|
throw new BadRequestError('Client cannot specify arbitrary template paths in fragment refresh requests.');
|
|
911
925
|
}
|
|
912
926
|
if (!Object.prototype.hasOwnProperty.call(fragmentsMap, item.name)) {
|
|
913
|
-
|
|
927
|
+
const pluginFrag = getPluginRegisteredFragment(item.name);
|
|
928
|
+
if (pluginFrag) {
|
|
929
|
+
fragmentsMap[item.name] = pluginFrag;
|
|
930
|
+
} else {
|
|
931
|
+
throw new NotFoundError(`Fragment "${item.name}" is not registered on page "${entry.routePath}".`);
|
|
932
|
+
}
|
|
914
933
|
}
|
|
915
934
|
}
|
|
916
935
|
|
package/core/console/commands.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* @module core/console/commands
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
9
11
|
const { discoverAppRoutes } = require('../../src/discovery/scan-routes');
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -50,9 +52,12 @@ function registerConsoleCommands(replServer, { context, meta, rootDir }) {
|
|
|
50
52
|
} else {
|
|
51
53
|
console.log(`\n⚙️ Registered Services (${services.length}):`);
|
|
52
54
|
for (const s of services) {
|
|
53
|
-
|
|
55
|
+
const name = typeof s === 'string' ? s : (s?.name || String(s));
|
|
56
|
+
const desc = typeof s === 'object' && s?.description ? `(${s.description})` : '';
|
|
57
|
+
console.log(` • \x1b[32m${name}\x1b[0m ${desc}`);
|
|
54
58
|
}
|
|
55
|
-
|
|
59
|
+
const firstName = typeof services[0] === 'string' ? services[0] : (services[0]?.name || String(services[0]));
|
|
60
|
+
console.log('\n Tip: Call a service with: await service(\'' + firstName + '\', payload)\n');
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
this.displayPrompt();
|
|
@@ -64,7 +69,14 @@ function registerConsoleCommands(replServer, { context, meta, rootDir }) {
|
|
|
64
69
|
help: 'Scan and list all application API endpoints and SSR pages',
|
|
65
70
|
action() {
|
|
66
71
|
try {
|
|
67
|
-
const
|
|
72
|
+
const pagesApiDir = ['src/pages/api', 'pages/api']
|
|
73
|
+
.map((rel) => path.join(rootDir, rel))
|
|
74
|
+
.find((p) => fs.existsSync(p));
|
|
75
|
+
const hasStandaloneApi = ['src/api', 'api'].some((rel) => fs.existsSync(path.join(rootDir, rel)));
|
|
76
|
+
const result = discoverAppRoutes({
|
|
77
|
+
rootDir,
|
|
78
|
+
...(!hasStandaloneApi && pagesApiDir ? { api: { dir: pagesApiDir, prefix: '/api' } } : {}),
|
|
79
|
+
});
|
|
68
80
|
const descriptors = result.descriptors || [];
|
|
69
81
|
|
|
70
82
|
if (descriptors.length === 0) {
|
|
@@ -101,10 +113,19 @@ function registerConsoleCommands(replServer, { context, meta, rootDir }) {
|
|
|
101
113
|
replServer.defineCommand('flags', {
|
|
102
114
|
help: 'List all active feature flags and targeting rules',
|
|
103
115
|
action() {
|
|
104
|
-
if (!context.flags
|
|
116
|
+
if (!context.flags) {
|
|
105
117
|
console.log('\n⚠️ No feature flags configured.\n');
|
|
106
118
|
} else {
|
|
107
|
-
const flags = context.flags.list
|
|
119
|
+
const flags = typeof context.flags.list === 'function'
|
|
120
|
+
? context.flags.list()
|
|
121
|
+
: (typeof context.flags.store?.getAll === 'function'
|
|
122
|
+
? context.flags.store.getAll()
|
|
123
|
+
: (typeof context.flags.getAllFlags === 'function' ? context.flags.getAllFlags() : null));
|
|
124
|
+
if (!flags || typeof flags !== 'object') {
|
|
125
|
+
console.log('\n⚠️ No feature flags configured.\n');
|
|
126
|
+
this.displayPrompt();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
108
129
|
const entries = Object.entries(flags);
|
|
109
130
|
if (entries.length === 0) {
|
|
110
131
|
console.log('\n⚠️ No feature flags registered.\n');
|
package/core/console/repl.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module core/console/repl
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
const fs = require('fs');
|
|
10
11
|
const repl = require('repl');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
const { buildConsoleContext } = require('./context');
|
|
@@ -67,7 +68,12 @@ async function startConsole(options = {}) {
|
|
|
67
68
|
if (options.history !== false && typeof replServer.setupHistory === 'function') {
|
|
68
69
|
const historyPath = options.historyPath || path.resolve(meta.rootDir, '.webspresso_history');
|
|
69
70
|
try {
|
|
70
|
-
|
|
71
|
+
if (fs.existsSync(path.dirname(historyPath))) {
|
|
72
|
+
const res = replServer.setupHistory(historyPath, () => {});
|
|
73
|
+
if (res && typeof res.catch === 'function') {
|
|
74
|
+
res.catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
71
77
|
} catch (_) {
|
|
72
78
|
// Gracefully continue without persistent history if write fails
|
|
73
79
|
}
|
|
@@ -147,6 +147,9 @@ class PluginManager {
|
|
|
147
147
|
this.routes = []; // Collected route metadata
|
|
148
148
|
this.customRoutes = []; // Routes added by plugins
|
|
149
149
|
this.cspDirectives = new Map(); // directive -> Set of sources (from plugins)
|
|
150
|
+
this.clientControllers = new Map(); // name -> { name, filePath, pluginName }
|
|
151
|
+
this.clientServices = new Map(); // name -> { name, filePath, pluginName }
|
|
152
|
+
this.registeredFragments = new Map(); // name -> { name, def, pluginName }
|
|
150
153
|
this.disposers = []; // Array<{ name: string, fn: Function }>
|
|
151
154
|
this._cachedHelpers = null; // Cached helpers object
|
|
152
155
|
this.pendingAsyncOperations = []; // Track background promises
|
|
@@ -271,7 +274,7 @@ class PluginManager {
|
|
|
271
274
|
|
|
272
275
|
// Build graph
|
|
273
276
|
for (const plugin of plugins) {
|
|
274
|
-
if (!plugin.name) {
|
|
277
|
+
if (!plugin || !plugin.name) {
|
|
275
278
|
console.warn('[plugin-manager] Skipping plugin without name:', plugin);
|
|
276
279
|
continue;
|
|
277
280
|
}
|
|
@@ -730,6 +733,33 @@ class PluginManager {
|
|
|
730
733
|
*/
|
|
731
734
|
getScriptInjector() {
|
|
732
735
|
return getCachedScriptInjector();
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Register a client controller entry file (`.client.js` / `.client.ts`) for bundler discovery
|
|
740
|
+
* @param {string} name - Controller name used in `data-w-controller="<name>"`
|
|
741
|
+
* @param {string} filePath - Absolute or project-relative path to the client controller module
|
|
742
|
+
*/
|
|
743
|
+
registerClientController(name, filePath) {
|
|
744
|
+
self.registerClientController(name, filePath, pluginName);
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Register a client service module (`defineClientService(...)`) for bundler discovery
|
|
749
|
+
* @param {string} name - Service name used in `ctx.services.get('<name>')`
|
|
750
|
+
* @param {string} filePath - Absolute or project-relative path to the client service module
|
|
751
|
+
*/
|
|
752
|
+
registerClientService(name, filePath) {
|
|
753
|
+
self.registerClientService(name, filePath, pluginName);
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Register a reusable SSR fragment (`defineFragment(...)`) usable across pages
|
|
758
|
+
* @param {string} name - Fragment name used in `fsy.fragment('<name>', ...)`
|
|
759
|
+
* @param {Object} fragmentDef - Fragment definition created via `defineFragment(...)`
|
|
760
|
+
*/
|
|
761
|
+
registerFragment(name, fragmentDef) {
|
|
762
|
+
self.registerFragment(name, fragmentDef, pluginName);
|
|
733
763
|
}
|
|
734
764
|
};
|
|
735
765
|
}
|
|
@@ -808,6 +838,130 @@ class PluginManager {
|
|
|
808
838
|
}
|
|
809
839
|
}
|
|
810
840
|
|
|
841
|
+
/**
|
|
842
|
+
* Register a client controller module from a plugin.
|
|
843
|
+
* @param {string} name
|
|
844
|
+
* @param {string} filePath
|
|
845
|
+
* @param {string} [pluginName='unknown']
|
|
846
|
+
*/
|
|
847
|
+
registerClientController(name, filePath, pluginName = 'unknown') {
|
|
848
|
+
const cleanName = typeof name === 'string' ? name.trim() : '';
|
|
849
|
+
const cleanPath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
850
|
+
if (!cleanName || !cleanPath) {
|
|
851
|
+
throw new TypeError(`[plugin-manager] registerClientController(name, filePath) requires non-empty strings (plugin "${pluginName}").`);
|
|
852
|
+
}
|
|
853
|
+
const existing = this.clientControllers.get(cleanName);
|
|
854
|
+
if (existing && existing.filePath !== cleanPath) {
|
|
855
|
+
const err = new Error(
|
|
856
|
+
`Client controller name collision: "${cleanName}" is registered by both plugin "${existing.pluginName}" (${existing.filePath}) and "${pluginName}" (${cleanPath}).`
|
|
857
|
+
);
|
|
858
|
+
err.code = 'PLUGIN_CLIENT_CONTROLLER_COLLISION';
|
|
859
|
+
throw err;
|
|
860
|
+
}
|
|
861
|
+
this.clientControllers.set(cleanName, {
|
|
862
|
+
name: cleanName,
|
|
863
|
+
filePath: cleanPath,
|
|
864
|
+
pluginName,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Register a client service module from a plugin.
|
|
870
|
+
* @param {string} name
|
|
871
|
+
* @param {string} filePath
|
|
872
|
+
* @param {string} [pluginName='unknown']
|
|
873
|
+
*/
|
|
874
|
+
registerClientService(name, filePath, pluginName = 'unknown') {
|
|
875
|
+
const cleanName = typeof name === 'string' ? name.trim() : '';
|
|
876
|
+
const cleanPath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
877
|
+
if (!cleanName || !cleanPath) {
|
|
878
|
+
throw new TypeError(`[plugin-manager] registerClientService(name, filePath) requires non-empty strings (plugin "${pluginName}").`);
|
|
879
|
+
}
|
|
880
|
+
const existing = this.clientServices.get(cleanName);
|
|
881
|
+
if (existing && existing.filePath !== cleanPath) {
|
|
882
|
+
const err = new Error(
|
|
883
|
+
`Client service name collision: "${cleanName}" is registered by both plugin "${existing.pluginName}" (${existing.filePath}) and "${pluginName}" (${cleanPath}).`
|
|
884
|
+
);
|
|
885
|
+
err.code = 'PLUGIN_CLIENT_SERVICE_COLLISION';
|
|
886
|
+
throw err;
|
|
887
|
+
}
|
|
888
|
+
this.clientServices.set(cleanName, {
|
|
889
|
+
name: cleanName,
|
|
890
|
+
filePath: cleanPath,
|
|
891
|
+
pluginName,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Register a reusable SSR fragment definition from a plugin.
|
|
897
|
+
* @param {string} name
|
|
898
|
+
* @param {Object} fragmentDef
|
|
899
|
+
* @param {string} [pluginName='unknown']
|
|
900
|
+
*/
|
|
901
|
+
registerFragment(name, fragmentDef, pluginName = 'unknown') {
|
|
902
|
+
const cleanName = typeof name === 'string' ? name.trim() : '';
|
|
903
|
+
if (!cleanName || !fragmentDef || typeof fragmentDef !== 'object') {
|
|
904
|
+
throw new TypeError(`[plugin-manager] registerFragment(name, fragmentDef) requires a valid fragment name and definition (plugin "${pluginName}").`);
|
|
905
|
+
}
|
|
906
|
+
if (this.registeredFragments.has(cleanName)) {
|
|
907
|
+
console.warn(`[plugin-manager] Fragment "${cleanName}" is already registered (registered by "${pluginName}" overwrites existing fragment).`);
|
|
908
|
+
}
|
|
909
|
+
this.registeredFragments.set(cleanName, {
|
|
910
|
+
name: cleanName,
|
|
911
|
+
def: fragmentDef,
|
|
912
|
+
pluginName,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Get all client controllers registered by plugins.
|
|
918
|
+
* @returns {Array<{ name: string, filePath: string, pluginName: string }>}
|
|
919
|
+
*/
|
|
920
|
+
getClientControllers() {
|
|
921
|
+
return Array.from(this.clientControllers.values());
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Get all client services registered by plugins.
|
|
926
|
+
* @returns {Array<{ name: string, filePath: string, pluginName: string }>}
|
|
927
|
+
*/
|
|
928
|
+
getClientServices() {
|
|
929
|
+
return Array.from(this.clientServices.values());
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Look up a plugin-registered fragment definition by name.
|
|
934
|
+
* @param {string} name
|
|
935
|
+
* @returns {Object|null}
|
|
936
|
+
*/
|
|
937
|
+
getFragment(name) {
|
|
938
|
+
const entry = this.registeredFragments.get(name);
|
|
939
|
+
return entry ? entry.def : null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Get all plugin-registered fragments as a plain map `{ [name]: fragmentDef }`.
|
|
944
|
+
* @returns {Record<string, Object>}
|
|
945
|
+
*/
|
|
946
|
+
getFragments() {
|
|
947
|
+
const out = {};
|
|
948
|
+
for (const [name, entry] of this.registeredFragments) {
|
|
949
|
+
out[name] = entry.def;
|
|
950
|
+
}
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Get combined client manifest of controllers, services, and fragment names registered by plugins.
|
|
956
|
+
*/
|
|
957
|
+
getClientManifest() {
|
|
958
|
+
return {
|
|
959
|
+
controllers: this.getClientControllers(),
|
|
960
|
+
services: this.getClientServices(),
|
|
961
|
+
fragments: Array.from(this.registeredFragments.keys()),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
811
965
|
/**
|
|
812
966
|
* Register a request-scoped helper provider (called on each SSR request)
|
|
813
967
|
* @param {Function} fn - (req) => Record<string, any>
|
package/index.d.ts
CHANGED
|
@@ -1919,6 +1919,9 @@ export interface PluginRegisterContext {
|
|
|
1919
1919
|
addHelper(name: string, fn: (...args: unknown[]) => unknown): void;
|
|
1920
1920
|
addFilter(name: string, fn: (...args: unknown[]) => unknown): void;
|
|
1921
1921
|
addRoute(method: string, path: string, ...handlers: RequestHandler[]): void;
|
|
1922
|
+
registerClientController(name: string, filePath: string): void;
|
|
1923
|
+
registerClientService(name: string, filePath: string): void;
|
|
1924
|
+
registerFragment(name: string, fragmentDef: FragmentDefinition<any, any>): void;
|
|
1922
1925
|
routes: unknown;
|
|
1923
1926
|
[key: string]: unknown;
|
|
1924
1927
|
}
|
|
@@ -1936,6 +1939,18 @@ export interface WebspressoPlugin {
|
|
|
1936
1939
|
}
|
|
1937
1940
|
|
|
1938
1941
|
export class PluginManager {
|
|
1942
|
+
registerClientController(name: string, filePath: string, pluginName?: string): void;
|
|
1943
|
+
registerClientService(name: string, filePath: string, pluginName?: string): void;
|
|
1944
|
+
registerFragment(name: string, fragmentDef: FragmentDefinition<any, any>, pluginName?: string): void;
|
|
1945
|
+
getClientControllers(): Array<{ name: string; filePath: string; pluginName: string }>;
|
|
1946
|
+
getClientServices(): Array<{ name: string; filePath: string; pluginName: string }>;
|
|
1947
|
+
getFragment(name: string): FragmentDefinition<any, any> | null;
|
|
1948
|
+
getFragments(): Record<string, FragmentDefinition<any, any>>;
|
|
1949
|
+
getClientManifest(): {
|
|
1950
|
+
controllers: Array<{ name: string; filePath: string; pluginName: string }>;
|
|
1951
|
+
services: Array<{ name: string; filePath: string; pluginName: string }>;
|
|
1952
|
+
fragments: string[];
|
|
1953
|
+
};
|
|
1939
1954
|
[key: string]: unknown;
|
|
1940
1955
|
}
|
|
1941
1956
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.114",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -134,14 +134,14 @@
|
|
|
134
134
|
"views/partials/webspresso-client-runtime.njk"
|
|
135
135
|
],
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"commander": "^
|
|
137
|
+
"commander": "^14.0.3",
|
|
138
138
|
"cookie-parser": "^1.4.7",
|
|
139
139
|
"csv-parse": "^7.0.2",
|
|
140
140
|
"dayjs": "^1.11.19",
|
|
141
141
|
"express": "^5.2.1",
|
|
142
142
|
"express-session": "^1.18.0",
|
|
143
|
-
"helmet": "^
|
|
144
|
-
"inquirer": "^
|
|
143
|
+
"helmet": "^8.3.0",
|
|
144
|
+
"inquirer": "^13.4.3",
|
|
145
145
|
"knex": "^3.1.0",
|
|
146
146
|
"multer": "^2.4.0",
|
|
147
147
|
"nunjucks": "^3.2.4",
|
package/src/bundle/index.js
CHANGED
|
@@ -213,22 +213,52 @@ function findPageEntryPoints(cwd = process.cwd()) {
|
|
|
213
213
|
walkViews(dirToScan);
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// 4. Plugin-registered client controllers and services (if PluginManager is active)
|
|
217
|
+
try {
|
|
218
|
+
const { getPluginManager } = require('../../core/plugins/plugin-manager');
|
|
219
|
+
const pm = typeof getPluginManager === 'function' ? getPluginManager() : null;
|
|
220
|
+
if (pm) {
|
|
221
|
+
if (typeof pm.getClientControllers === 'function') {
|
|
222
|
+
for (const ctrl of pm.getClientControllers()) {
|
|
223
|
+
const entryName = `plugins/${ctrl.pluginName}/${ctrl.name}`;
|
|
224
|
+
if (!pageEntries[entryName]) {
|
|
225
|
+
pageEntries[entryName] = path.isAbsolute(ctrl.filePath)
|
|
226
|
+
? ctrl.filePath
|
|
227
|
+
: path.resolve(cwd, ctrl.filePath);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (typeof pm.getClientServices === 'function') {
|
|
232
|
+
for (const svc of pm.getClientServices()) {
|
|
233
|
+
const entryName = `plugins/${svc.pluginName}/services/${svc.name}`;
|
|
234
|
+
if (!pageEntries[entryName]) {
|
|
235
|
+
pageEntries[entryName] = path.isAbsolute(svc.filePath)
|
|
236
|
+
? svc.filePath
|
|
237
|
+
: path.resolve(cwd, svc.filePath);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (_e) {}
|
|
243
|
+
|
|
216
244
|
return pageEntries;
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
/**
|
|
220
|
-
* Discovers all `.client.{js,ts,mjs,jsx,tsx}` controller files across `pages
|
|
248
|
+
* Discovers all `.client.{js,ts,mjs,jsx,tsx}` controller files across `pages/`, `views/`, and plugins
|
|
221
249
|
* and validates deterministic controller names for collisions.
|
|
222
250
|
*
|
|
223
251
|
* @param {string} [cwd=process.cwd()]
|
|
224
|
-
* @
|
|
252
|
+
* @param {object} [options]
|
|
253
|
+
* @returns {Array<{ controllerName: string, entryName: string, filePath: string, relativePath: string, pluginName?: string }>}
|
|
225
254
|
*/
|
|
226
|
-
function discoverClientControllers(cwd = process.cwd()) {
|
|
255
|
+
function discoverClientControllers(cwd = process.cwd(), options = {}) {
|
|
227
256
|
const pageEntries = findPageEntryPoints(cwd);
|
|
228
257
|
const discovered = [];
|
|
229
258
|
const byControllerName = new Map();
|
|
230
259
|
|
|
231
260
|
for (const [entryName, filePath] of Object.entries(pageEntries)) {
|
|
261
|
+
if (entryName.startsWith('plugins/')) continue;
|
|
232
262
|
const relativePath = path.relative(cwd, filePath).replace(/\\/g, '/');
|
|
233
263
|
const controllerName = deriveControllerNameFromPath(relativePath);
|
|
234
264
|
if (!controllerName) continue;
|
|
@@ -256,6 +286,46 @@ function discoverClientControllers(cwd = process.cwd()) {
|
|
|
256
286
|
discovered.push(record);
|
|
257
287
|
}
|
|
258
288
|
|
|
289
|
+
try {
|
|
290
|
+
const pm =
|
|
291
|
+
options.pluginManager ||
|
|
292
|
+
require('../../core/plugins/plugin-manager').getPluginManager();
|
|
293
|
+
if (pm && typeof pm.getClientControllers === 'function') {
|
|
294
|
+
for (const ctrl of pm.getClientControllers()) {
|
|
295
|
+
const absPath = path.isAbsolute(ctrl.filePath)
|
|
296
|
+
? ctrl.filePath
|
|
297
|
+
: path.resolve(cwd, ctrl.filePath);
|
|
298
|
+
const relativePath = path.relative(cwd, absPath).replace(/\\/g, '/');
|
|
299
|
+
if (byControllerName.has(ctrl.name)) {
|
|
300
|
+
const existing = byControllerName.get(ctrl.name);
|
|
301
|
+
if (existing.filePath !== absPath) {
|
|
302
|
+
const err = new Error(
|
|
303
|
+
`Controller name collision: "${ctrl.name}" is defined by both "${existing.relativePath}" and "${relativePath}".`
|
|
304
|
+
);
|
|
305
|
+
err.code = 'CONTROLLER_NAME_COLLISION';
|
|
306
|
+
err.controllerName = ctrl.name;
|
|
307
|
+
err.files = [existing.relativePath, relativePath];
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const record = {
|
|
313
|
+
controllerName: ctrl.name,
|
|
314
|
+
entryName: `plugins/${ctrl.pluginName}/${ctrl.name}`,
|
|
315
|
+
filePath: absPath,
|
|
316
|
+
relativePath,
|
|
317
|
+
pluginName: ctrl.pluginName,
|
|
318
|
+
};
|
|
319
|
+
byControllerName.set(ctrl.name, record);
|
|
320
|
+
discovered.push(record);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err && err.code === 'CONTROLLER_NAME_COLLISION') {
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
259
329
|
return discovered;
|
|
260
330
|
}
|
|
261
331
|
|
package/src/dev-reload/index.js
CHANGED
|
@@ -168,8 +168,39 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
|
|
|
168
168
|
document.addEventListener('keydown', onKeyDown);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function saveScrollPosition() {
|
|
172
|
+
try {
|
|
173
|
+
if (typeof window.sessionStorage !== 'undefined') {
|
|
174
|
+
window.sessionStorage.setItem('__ws_dev_scroll', JSON.stringify({
|
|
175
|
+
url: window.location.pathname + window.location.search,
|
|
176
|
+
x: window.scrollX || 0,
|
|
177
|
+
y: window.scrollY || 0,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
} catch (_) {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function restoreScrollPosition() {
|
|
184
|
+
try {
|
|
185
|
+
if (typeof window.sessionStorage === 'undefined') return;
|
|
186
|
+
var raw = window.sessionStorage.getItem('__ws_dev_scroll');
|
|
187
|
+
if (!raw) return;
|
|
188
|
+
window.sessionStorage.removeItem('__ws_dev_scroll');
|
|
189
|
+
var pos = JSON.parse(raw);
|
|
190
|
+
if (pos && pos.url === (window.location.pathname + window.location.search)) {
|
|
191
|
+
var apply = function() { window.scrollTo(pos.x || 0, pos.y || 0); };
|
|
192
|
+
if (typeof window.requestAnimationFrame === 'function') {
|
|
193
|
+
window.requestAnimationFrame(apply);
|
|
194
|
+
} else {
|
|
195
|
+
apply();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (_) {}
|
|
199
|
+
}
|
|
200
|
+
|
|
171
201
|
function safeReload(reason) {
|
|
172
202
|
clearOverlay();
|
|
203
|
+
saveScrollPosition();
|
|
173
204
|
var reasonMsg = reason ? ' (' + reason + ')' : '';
|
|
174
205
|
console.log('[Webspresso] 🔄 Reloading page' + reasonMsg + '...');
|
|
175
206
|
|
|
@@ -246,10 +277,15 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
|
|
|
246
277
|
};
|
|
247
278
|
}
|
|
248
279
|
|
|
280
|
+
function init() {
|
|
281
|
+
restoreScrollPosition();
|
|
282
|
+
connect();
|
|
283
|
+
}
|
|
284
|
+
|
|
249
285
|
if (document.readyState === 'loading') {
|
|
250
|
-
document.addEventListener('DOMContentLoaded',
|
|
286
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
251
287
|
} else {
|
|
252
|
-
|
|
288
|
+
init();
|
|
253
289
|
}
|
|
254
290
|
})();
|
|
255
291
|
`;
|
|
@@ -290,6 +326,7 @@ function createDevReloadHub(config = {}) {
|
|
|
290
326
|
const autoInject = options.autoInject !== false;
|
|
291
327
|
const watchPublicAll = options.watchPublicAll === true;
|
|
292
328
|
const extraWatch = Array.isArray(options.watch) ? options.watch : [];
|
|
329
|
+
let ignoreClientFiles = Boolean(options.ignoreClientFiles);
|
|
293
330
|
|
|
294
331
|
const templateExtensions = new Set(
|
|
295
332
|
(Array.isArray(options.templateExtensions)
|
|
@@ -393,6 +430,9 @@ function createDevReloadHub(config = {}) {
|
|
|
393
430
|
setDevMetaProvider: (fn) => {
|
|
394
431
|
getDevMetaCallback = fn;
|
|
395
432
|
},
|
|
433
|
+
setIgnoreClientFiles: (flag) => {
|
|
434
|
+
ignoreClientFiles = Boolean(flag);
|
|
435
|
+
},
|
|
396
436
|
invalidateTemplateCache,
|
|
397
437
|
triggerTemplateReload: (filename, reason = 'template') => {
|
|
398
438
|
onTemplateFileChanged(filename, reason);
|
|
@@ -594,6 +634,7 @@ function createDevReloadHub(config = {}) {
|
|
|
594
634
|
if (watchTemplates && viewsDir && fs.existsSync(viewsDir)) {
|
|
595
635
|
watchDirectory(viewsDir, (eventType, filename) => {
|
|
596
636
|
if (!filename || shouldIgnore(filename)) return;
|
|
637
|
+
if (ignoreClientFiles && filename.includes('.client.')) return;
|
|
597
638
|
const ext = path.extname(filename).toLowerCase();
|
|
598
639
|
if (ext === '.css') {
|
|
599
640
|
onCssFileChanged(filename);
|
|
@@ -607,6 +648,7 @@ function createDevReloadHub(config = {}) {
|
|
|
607
648
|
if (watchTemplates && pagesDir && fs.existsSync(pagesDir)) {
|
|
608
649
|
watchDirectory(pagesDir, (eventType, filename) => {
|
|
609
650
|
if (!filename || shouldIgnore(filename)) return;
|
|
651
|
+
if (ignoreClientFiles && filename.includes('.client.')) return;
|
|
610
652
|
const ext = path.extname(filename).toLowerCase();
|
|
611
653
|
if (ext === '.css') {
|
|
612
654
|
onCssFileChanged(filename);
|
package/src/server.js
CHANGED
|
@@ -24,7 +24,7 @@ const { resolveClientRuntime } = require('./client-runtime/resolve');
|
|
|
24
24
|
const { mountPages, detectLocale, loadI18n, createTranslator } = require('./file-router');
|
|
25
25
|
const { configureAssets, createHelpers, getScriptInjector, AssetManager } = require('./helpers');
|
|
26
26
|
const { createDevReloadHub } = require('./dev-reload');
|
|
27
|
-
const { createDevBundler } = require('./bundle');
|
|
27
|
+
const { createDevBundler, findDefaultEntryPoints } = require('./bundle');
|
|
28
28
|
const { registerClearHook } = require('./njk-frontmatter');
|
|
29
29
|
const { createPluginManager } = require('./plugin-manager');
|
|
30
30
|
const { createServiceRegistry } = require('./services');
|
|
@@ -1558,15 +1558,19 @@ function createApp(options = {}) {
|
|
|
1558
1558
|
// Client-side JS incremental bundler (esbuild context() API)
|
|
1559
1559
|
const bundleOption = options.bundle;
|
|
1560
1560
|
const isBundleExplicit = bundleOption === true || (typeof bundleOption === 'object' && bundleOption.enabled !== false);
|
|
1561
|
-
const isBundleDisabled = bundleOption === false;
|
|
1561
|
+
const isBundleDisabled = bundleOption === false || (typeof bundleOption === 'object' && bundleOption.enabled === false);
|
|
1562
|
+
const shouldAutoBundle = bundleOption === undefined && isDev && !isTest && findDefaultEntryPoints(rootDir).length > 0;
|
|
1562
1563
|
|
|
1563
|
-
if (isBundleExplicit && !isBundleDisabled) {
|
|
1564
|
+
if ((isBundleExplicit || shouldAutoBundle) && !isBundleDisabled) {
|
|
1564
1565
|
const bundlerOpts = typeof bundleOption === 'object' ? { ...bundleOption } : {};
|
|
1565
1566
|
if (!bundlerOpts.cwd) bundlerOpts.cwd = rootDir;
|
|
1566
1567
|
const devBundler = createDevBundler(bundlerOpts, devReloadHub);
|
|
1567
1568
|
app.devBundler = devBundler;
|
|
1568
1569
|
|
|
1569
1570
|
if (devBundler.isAvailable() && bundlerOpts.watch !== false && isDev && !isTest) {
|
|
1571
|
+
if (devReloadHub && typeof devReloadHub.setIgnoreClientFiles === 'function') {
|
|
1572
|
+
devReloadHub.setIgnoreClientFiles(true);
|
|
1573
|
+
}
|
|
1570
1574
|
devBundler.startWatch().catch((err) => {
|
|
1571
1575
|
if (logging) console.error('[Webspresso Bundler] Watch error:', err.message);
|
|
1572
1576
|
});
|
|
@@ -1577,6 +1581,58 @@ function createApp(options = {}) {
|
|
|
1577
1581
|
}
|
|
1578
1582
|
}
|
|
1579
1583
|
|
|
1584
|
+
// Auto-refresh generated TypeScript types & typed API client in dev mode when models/services/api routes change
|
|
1585
|
+
if (isDev && !isTest && options.typegen !== false) {
|
|
1586
|
+
const typegenWatchDirs = [
|
|
1587
|
+
path.join(rootDir, 'models'),
|
|
1588
|
+
path.join(rootDir, 'services'),
|
|
1589
|
+
path.join(rootDir, 'pages', 'api'),
|
|
1590
|
+
].filter((d) => fs.existsSync(d));
|
|
1591
|
+
|
|
1592
|
+
if (typegenWatchDirs.length > 0) {
|
|
1593
|
+
const typegenWatchers = [];
|
|
1594
|
+
let typegenTimer = null;
|
|
1595
|
+
const scheduleTypegenRefresh = () => {
|
|
1596
|
+
if (typegenTimer) clearTimeout(typegenTimer);
|
|
1597
|
+
typegenTimer = setTimeout(() => {
|
|
1598
|
+
typegenTimer = null;
|
|
1599
|
+
try {
|
|
1600
|
+
const { generateTypes } = require('./dev/typegen');
|
|
1601
|
+
generateTypes({ rootDir, silent: true });
|
|
1602
|
+
const hasGeneratedClient =
|
|
1603
|
+
fs.existsSync(path.join(rootDir, '.webspresso', 'client.ts')) ||
|
|
1604
|
+
fs.existsSync(path.join(rootDir, 'src', 'client.ts'));
|
|
1605
|
+
if (hasGeneratedClient) {
|
|
1606
|
+
const { generateClient } = require('./dev/client-gen');
|
|
1607
|
+
generateClient({ rootDir, silent: true });
|
|
1608
|
+
}
|
|
1609
|
+
} catch (_) {}
|
|
1610
|
+
}, 250);
|
|
1611
|
+
if (typeof typegenTimer.unref === 'function') typegenTimer.unref();
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
for (const watchDir of typegenWatchDirs) {
|
|
1615
|
+
try {
|
|
1616
|
+
const w = fs.watch(watchDir, { recursive: true }, (_evt, filename) => {
|
|
1617
|
+
if (!filename || filename.includes('node_modules') || filename.includes('.webspresso')) return;
|
|
1618
|
+
scheduleTypegenRefresh();
|
|
1619
|
+
});
|
|
1620
|
+
w.on('error', () => {});
|
|
1621
|
+
typegenWatchers.push(w);
|
|
1622
|
+
} catch (_) {}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
if (shutdownManager) {
|
|
1626
|
+
shutdownManager.registerDisposer('devTypegen', () => {
|
|
1627
|
+
if (typegenTimer) clearTimeout(typegenTimer);
|
|
1628
|
+
for (const w of typegenWatchers) {
|
|
1629
|
+
try { w.close(); } catch (_) {}
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1580
1636
|
// Configure Nunjucks with viewsDir priority and circular extension guard
|
|
1581
1637
|
const templateDirs = [viewsDir, pagesDir].filter(Boolean);
|
|
1582
1638
|
if (templateDirs.length === 0) {
|