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 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
- let _inquirer;
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) {
@@ -6,13 +6,7 @@
6
6
  * @module bin/commands/api
7
7
  */
8
8
 
9
- let _inquirer;
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
 
@@ -3,13 +3,7 @@
3
3
  * Creates a new Webspresso project
4
4
  */
5
5
 
6
- let _inquirer;
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
 
@@ -6,13 +6,7 @@
6
6
  * @module bin/commands/page
7
7
  */
8
8
 
9
- let _inquirer;
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
 
@@ -3,13 +3,7 @@
3
3
  * Run database seeders to populate database with fake data
4
4
  */
5
5
 
6
- let _inquirer;
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');
@@ -5,13 +5,7 @@
5
5
  */
6
6
 
7
7
  const path = require('path');
8
- let _inquirer;
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
+
@@ -165,14 +165,18 @@ class TaggedLruCache {
165
165
  set(key, value, options = {}) {
166
166
  const tags = normalizeTags(options.tags);
167
167
 
168
- // If key already had tags, clean them up first before assigning new tags
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
- this._addKeyToTags(key, tags);
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._addKeyToTags(key, tags);
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';
@@ -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
- const patchCycleTracker = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
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
- for (let i = 0; i < affected.length; i++) {
2102
- const inst = affected[i];
2103
- inst.invokeBeforePatch({
2104
- ...meta,
2105
- isFragmentRoot: inst.el === fragmentEl,
2106
- isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
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
- if (patchCycleTracker && fragmentEl) {
2113
- const now = Date.now();
2114
- const prev = patchCycleTracker.get(fragmentEl) || { count: 0, lastAt: 0 };
2115
- const count = now - prev.lastAt < 100 ? prev.count + 1 : 1;
2116
- patchCycleTracker.set(fragmentEl, { count, lastAt: now });
2117
- if (count > 5) {
2118
- const msg = `Possible patch hook refresh loop detected on fragment "${meta.fragmentName || meta.fragmentId || 'unknown'}" (${count} rapid patch cycles).`;
2119
- if (debug && typeof debug.recordEvent === 'function') {
2120
- debug.recordEvent('patch:loop-warning', {
2121
- code: 'PATCH_HOOK_LOOP_DETECTED',
2122
- fragmentId: meta.fragmentId || null,
2123
- fragmentName: meta.fragmentName || null,
2124
- count,
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
- // Reverse order for afterPatch: inner descendants first, outer ancestors second
2135
- for (let i = affected.length - 1; i >= 0; i--) {
2136
- const inst = affected[i];
2137
- inst.invokeAfterPatch({
2138
- ...meta,
2139
- isFragmentRoot: inst.el === fragmentEl,
2140
- isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
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);
@@ -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
@@ -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
- const patchMode = fragmentPayload.mode || (patchOpts && patchOpts.mode) || 'replace';
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.lastUpdatedAt = now;
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
  });
@@ -192,20 +192,25 @@ function createClientRuntime(options = {}) {
192
192
  controllers.mountSubtree(addedEl);
193
193
  }
194
194
  },
195
- onBeforePatch(patchedEl, inst) {
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 = fragmentsMap[cleanName];
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 fragmentsMap = (pageMod && pageMod.fragments) || {};
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
- throw new NotFoundError(`Fragment "${item.name}" is not registered on page "${entry.routePath}".`);
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
 
@@ -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
- console.log(` • \x1b[32m${s.name}\x1b[0m ${s.description ? `(${s.description})` : ''}`);
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
- console.log('\n Tip: Call a service with: await service(\'' + services[0].name + '\', payload)\n');
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 result = discoverAppRoutes({ rootDir });
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 || typeof context.flags.list !== 'function') {
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');
@@ -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
- replServer.setupHistory(historyPath, () => {});
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.113",
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": "^11.1.0",
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": "^7.2.0",
144
- "inquirer": "^8.2.6",
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",
@@ -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/` and `views/`
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
- * @returns {Array<{ controllerName: string, entryName: string, filePath: string, relativePath: string }>}
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
 
@@ -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', connect);
286
+ document.addEventListener('DOMContentLoaded', init);
251
287
  } else {
252
- connect();
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) {