webspresso 0.0.112 → 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.
Files changed (42) hide show
  1. package/README.md +26 -0
  2. package/bin/commands/admin-password.js +1 -7
  3. package/bin/commands/api.js +1 -7
  4. package/bin/commands/new.js +1 -7
  5. package/bin/commands/page.js +1 -7
  6. package/bin/commands/seed.js +1 -7
  7. package/bin/commands/service.js +1 -7
  8. package/bin/utils/prompt.js +17 -0
  9. package/core/cache/tagged-lru.js +9 -3
  10. package/core/client/adapter/datastar.js +602 -26
  11. package/core/client/api.js +151 -7
  12. package/core/client/controller.js +172 -11
  13. package/core/client/devtool.js +59 -33
  14. package/core/client/forms.js +479 -29
  15. package/core/client/fragments.js +456 -27
  16. package/core/client/index.js +18 -1
  17. package/core/client/server-fragments.js +125 -40
  18. package/core/client/utils.js +72 -17
  19. package/core/console/commands.js +26 -5
  20. package/core/console/repl.js +7 -1
  21. package/core/http/pipeline.js +82 -1
  22. package/core/openapi/build-from-api-routes.js +5 -5
  23. package/core/orm/json-fields.js +7 -6
  24. package/core/orm/schema-helpers.js +18 -15
  25. package/core/orm/utils/nanoid.js +7 -8
  26. package/core/plugins/plugin-manager.js +155 -1
  27. package/core/services/validator.js +12 -1
  28. package/core/validation/apply.js +3 -0
  29. package/core/validation/index.js +25 -0
  30. package/core/validation/zod-introspect.js +187 -0
  31. package/index.d.ts +113 -1
  32. package/package.json +12 -11
  33. package/plugins/mcp/providers/services.js +2 -2
  34. package/src/api/define-api.js +2 -0
  35. package/src/api/validation-i18n.js +6 -2
  36. package/src/bundle/index.js +140 -6
  37. package/src/dev/api-typegen.js +79 -22
  38. package/src/dev/typegen.js +61 -35
  39. package/src/dev-reload/index.js +44 -2
  40. package/src/file-router.js +24 -6
  41. package/src/routing/mount-discovered-routes.js +24 -6
  42. package/src/server.js +59 -3
@@ -192,9 +192,26 @@ function createClientRuntime(options = {}) {
192
192
  controllers.mountSubtree(addedEl);
193
193
  }
194
194
  },
195
- onAfterPatch(patchedEl) {
195
+ getPatchHookContext() {
196
+ return controllers ? controllers.getActivePatchHookContext() : null;
197
+ },
198
+ onBeforePatch(patchedEl, inst, meta) {
199
+ if (controllers) {
200
+ controllers.notifyBeforePatch(patchedEl, {
201
+ fragmentId: inst && inst.instanceId ? inst.instanceId : null,
202
+ fragmentName: inst && inst.name ? inst.name : null,
203
+ hookChainDepth: meta && meta.hookChainDepth ? meta.hookChainDepth : 0,
204
+ });
205
+ }
206
+ },
207
+ onAfterPatch(patchedEl, inst, meta) {
196
208
  if (controllers) {
197
209
  controllers.mountSubtree(patchedEl);
210
+ controllers.notifyAfterPatch(patchedEl, {
211
+ fragmentId: inst && inst.instanceId ? inst.instanceId : null,
212
+ fragmentName: inst && inst.name ? inst.name : null,
213
+ hookChainDepth: meta && meta.hookChainDepth ? meta.hookChainDepth : 0,
214
+ });
198
215
  }
199
216
  },
200
217
  });
@@ -118,6 +118,9 @@ function defineFragment(config = {}) {
118
118
  : config.persist !== undefined
119
119
  ? config.persist
120
120
  : undefined,
121
+ lazy: config.lazy !== undefined ? Boolean(config.lazy) : undefined,
122
+ placeholder: config.placeholder !== undefined ? config.placeholder : undefined,
123
+ placeholderTemplate: config.placeholderTemplate !== undefined ? config.placeholderTemplate : undefined,
121
124
  };
122
125
  }
123
126
 
@@ -403,6 +406,7 @@ function wrapFragmentRootHtml({
403
406
  refetchInterval,
404
407
  refetchIntervalInBackground,
405
408
  persistInput,
409
+ lazy,
406
410
  }) {
407
411
  const safeTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(tag || 'div') ? tag : 'div';
408
412
  const attrs = [
@@ -449,6 +453,10 @@ function wrapFragmentRootHtml({
449
453
  attrs.push(`data-w-persist-input="${escapeHtmlAttr(persistAttrVal)}"`);
450
454
  }
451
455
 
456
+ if (lazy) {
457
+ attrs.push('data-w-lazy="visible"');
458
+ }
459
+
452
460
  if (className) {
453
461
  attrs.push(`class="${escapeHtmlAttr(className)}"`);
454
462
  }
@@ -512,10 +520,18 @@ async function executeAndRenderFragment({
512
520
  // 3. Validate & coerce fragment input
513
521
  const validatedInput = validateFragmentInput(name, fragmentDef, rawInput);
514
522
 
515
- // 4. Execute fragment loader (`load(fragmentCtx)`)
523
+ const isLazy = Boolean(options.lazy !== undefined ? options.lazy : fragmentDef.lazy);
524
+ const isRefreshTarget = Boolean(
525
+ req &&
526
+ req.__isWebspressoFragmentRefresh &&
527
+ (!req.__refreshedFragmentIds || req.__refreshedFragmentIds.has(instanceId))
528
+ );
529
+ const renderAsLazyPlaceholder = isLazy && !isRefreshTarget;
530
+
531
+ // 4. Execute fragment loader (`load(fragmentCtx)`) unless rendering initial lazy placeholder
516
532
  const loadStart = Date.now();
517
533
  let fragmentData = {};
518
- if (typeof fragmentDef.load === 'function') {
534
+ if (!renderAsLazyPlaceholder && typeof fragmentDef.load === 'function') {
519
535
  const fragmentCtx = {
520
536
  ...ctx,
521
537
  req,
@@ -535,20 +551,45 @@ async function executeAndRenderFragment({
535
551
  }
536
552
  const loadMs = Math.max(0, Date.now() - loadStart);
537
553
 
538
- // 5. Render fragment template via Nunjucks
554
+ // 5. Render fragment template (or lazy placeholder) via Nunjucks
539
555
  const renderStart = Date.now();
540
- const templateCtx = {
541
- ...(res && res.locals ? res.locals : {}),
542
- ...baseRenderCtx,
543
- ...fragmentData,
544
- fragmentInput: validatedInput,
545
- fragmentId: instanceId,
546
- fragmentName: name,
547
- };
556
+ let innerHtml = '';
557
+ if (renderAsLazyPlaceholder) {
558
+ const phTemplate = options.placeholderTemplate || fragmentDef.placeholderTemplate;
559
+ const phOption = options.placeholder !== undefined ? options.placeholder : fragmentDef.placeholder;
560
+ if (typeof phTemplate === 'string' && phTemplate.trim()) {
561
+ const cleanPh = phTemplate.trim().replace(/^\/+/, '');
562
+ if (cleanPh.includes('..')) {
563
+ throw new BadRequestError(`Invalid placeholder template path for fragment "${name}".`);
564
+ }
565
+ innerHtml = nunjucksEnv.render(cleanPh, {
566
+ ...(res && res.locals ? res.locals : {}),
567
+ ...baseRenderCtx,
568
+ fragmentInput: validatedInput,
569
+ fragmentId: instanceId,
570
+ fragmentName: name,
571
+ });
572
+ } else if (typeof phOption === 'function') {
573
+ innerHtml = String(phOption({ input: validatedInput, instanceId, name }) || '');
574
+ } else if (typeof phOption === 'string') {
575
+ innerHtml = phOption;
576
+ } else {
577
+ innerHtml = '<div class="w-fragment-placeholder" role="status" aria-busy="true" aria-live="polite" style="min-height:48px">Loading...</div>';
578
+ }
579
+ } else {
580
+ const templateCtx = {
581
+ ...(res && res.locals ? res.locals : {}),
582
+ ...baseRenderCtx,
583
+ ...fragmentData,
584
+ fragmentInput: validatedInput,
585
+ fragmentId: instanceId,
586
+ fragmentName: name,
587
+ };
548
588
 
549
- let innerHtml = nunjucksEnv.render(cleanTemplate, templateCtx);
550
- // Resolve any nested async SSR fragments inside this fragment
551
- innerHtml = await resolvePendingSsrFragments(innerHtml, req);
589
+ innerHtml = nunjucksEnv.render(cleanTemplate, templateCtx);
590
+ // Resolve any nested async SSR fragments inside this fragment
591
+ innerHtml = await resolvePendingSsrFragments(innerHtml, req);
592
+ }
552
593
  const renderMs = Math.max(0, Date.now() - renderStart);
553
594
 
554
595
  if (renderedInstanceIds) {
@@ -591,6 +632,7 @@ async function executeAndRenderFragment({
591
632
  : options.persist !== undefined
592
633
  ? options.persist
593
634
  : fragmentDef.persistInput,
635
+ lazy: renderAsLazyPlaceholder,
594
636
  });
595
637
 
596
638
  return {
@@ -604,6 +646,17 @@ async function executeAndRenderFragment({
604
646
  };
605
647
  }
606
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
+
607
660
  /**
608
661
  * Create the `fsy.fragment(name, input?, options?)` template helper for a page render pass.
609
662
  * Uses the exact same `executeAndRenderFragment` pipeline for initial SSR as subsequent refreshes!
@@ -623,7 +676,9 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
623
676
 
624
677
  function renderFragmentHelper(name, rawInput = {}, options = {}) {
625
678
  const cleanName = String(name || '').trim();
626
- const fragmentDef = fragmentsMap[cleanName];
679
+ const fragmentDef =
680
+ (Object.prototype.hasOwnProperty.call(fragmentsMap, cleanName) ? fragmentsMap[cleanName] : null) ||
681
+ getPluginRegisteredFragment(cleanName);
627
682
  if (!fragmentDef) {
628
683
  throw new NotFoundError(
629
684
  `Fragment "${cleanName}" is not defined in fragments export for page "${pagePath}".`
@@ -811,6 +866,9 @@ function createFragmentRefreshHandler(config) {
811
866
  const startTotal = Date.now();
812
867
  try {
813
868
  const isGet = req.method === 'GET';
869
+ if (!isGet && req.headers && req.headers['sec-fetch-site'] === 'cross-site') {
870
+ throw new ForbiddenError('Cross-site fragment refresh requests are forbidden.');
871
+ }
814
872
  const payload = isGet ? req.query || {} : req.body || {};
815
873
 
816
874
  const pagePath = payload.pagePath || payload.page || req.headers['x-webspresso-page'] || '/';
@@ -853,9 +911,10 @@ function createFragmentRefreshHandler(config) {
853
911
  req.params = { ...(req.params || {}), ...params };
854
912
 
855
913
  const pageMod = typeof entry.getPageMod === 'function' ? entry.getPageMod() : entry.pageMod;
856
- const fragmentsMap = (pageMod && pageMod.fragments) || {};
914
+ const pageFragmentsMap = (pageMod && pageMod.fragments) || {};
915
+ const fragmentsMap = { ...pageFragmentsMap };
857
916
 
858
- // 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
859
918
  // (Rejects arbitrary template paths or unknown fragment names)
860
919
  for (const item of requestedFragments) {
861
920
  if (!item || typeof item.name !== 'string' || !item.name.trim()) {
@@ -865,7 +924,12 @@ function createFragmentRefreshHandler(config) {
865
924
  throw new BadRequestError('Client cannot specify arbitrary template paths in fragment refresh requests.');
866
925
  }
867
926
  if (!Object.prototype.hasOwnProperty.call(fragmentsMap, item.name)) {
868
- 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
+ }
869
933
  }
870
934
  }
871
935
 
@@ -894,6 +958,8 @@ function createFragmentRefreshHandler(config) {
894
958
  const ctx = typeof buildContext === 'function' ? buildContext(req, res, { fsy }) : { req, res, fsy };
895
959
  ensureRequestMemo(req, ctx);
896
960
 
961
+ req.__isWebspressoFragmentRefresh = true;
962
+ req.__refreshedFragmentIds = new Set(requestedFragments.map((it) => it.instanceId || `${it.name}:default`));
897
963
  req.__fragmentChildInputs =
898
964
  payload.childInputs && typeof payload.childInputs === 'object' && !isArray(payload.childInputs)
899
965
  ? payload.childInputs
@@ -912,6 +978,8 @@ function createFragmentRefreshHandler(config) {
912
978
  });
913
979
 
914
980
  const results = {};
981
+ const fragmentErrors = {};
982
+ let firstError = null;
915
983
 
916
984
  for (const reqItem of requestedFragments) {
917
985
  const fragName = reqItem.name.trim();
@@ -932,34 +1000,51 @@ function createFragmentRefreshHandler(config) {
932
1000
  : rawFragDef.middleware || [],
933
1001
  };
934
1002
 
935
- const rendered = await executeAndRenderFragment({
936
- name: fragName,
937
- fragmentDef: fragDef,
938
- instanceId,
939
- pagePath,
940
- rawInput: reqItem.input || {},
941
- req,
942
- res,
943
- ctx,
944
- nunjucksEnv,
945
- baseRenderCtx: { fsy },
946
- renderedInstanceIds,
947
- });
1003
+ try {
1004
+ const rendered = await executeAndRenderFragment({
1005
+ name: fragName,
1006
+ fragmentDef: fragDef,
1007
+ instanceId,
1008
+ pagePath,
1009
+ rawInput: reqItem.input || {},
1010
+ req,
1011
+ res,
1012
+ ctx,
1013
+ nunjucksEnv,
1014
+ baseRenderCtx: { fsy },
1015
+ renderedInstanceIds,
1016
+ });
1017
+
1018
+ results[instanceId] = {
1019
+ instanceId: rendered.instanceId,
1020
+ name: rendered.name,
1021
+ html: rendered.html,
1022
+ input: rendered.input,
1023
+ loadMs: rendered.loadMs,
1024
+ renderMs: rendered.renderMs,
1025
+ };
1026
+ } catch (fragErr) {
1027
+ if (!firstError) firstError = fragErr;
1028
+ fragmentErrors[instanceId] = {
1029
+ instanceId,
1030
+ name: fragName,
1031
+ status: fragErr.statusCode || fragErr.status || 500,
1032
+ code: fragErr.code || 'FRAGMENT_REFRESH_ERROR',
1033
+ message: fragErr.message || `Failed to render fragment "${fragName}"`,
1034
+ };
1035
+ }
1036
+ }
948
1037
 
949
- results[instanceId] = {
950
- instanceId: rendered.instanceId,
951
- name: rendered.name,
952
- html: rendered.html,
953
- input: rendered.input,
954
- loadMs: rendered.loadMs,
955
- renderMs: rendered.renderMs,
956
- };
1038
+ if (firstError && Object.keys(results).length === 0) {
1039
+ return next(firstError);
957
1040
  }
958
1041
 
959
1042
  const totalMs = Math.max(0, Date.now() - startTotal);
960
1043
  res.setHeader('Cache-Control', 'no-store');
1044
+ const hasPartialErrors = Object.keys(fragmentErrors).length > 0;
961
1045
  return res.status(200).json({
962
- ok: true,
1046
+ ok: !hasPartialErrors,
1047
+ ...(hasPartialErrors ? { partial: true, errors: fragmentErrors } : {}),
963
1048
  pagePath,
964
1049
  totalMs,
965
1050
  fragments: results,
@@ -185,7 +185,12 @@ function parseDataValue(val) {
185
185
  if (val === 'undefined') return undefined;
186
186
  if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(val)) {
187
187
  const num = Number(val);
188
- if (isNumber(num)) return num;
188
+ if (isNumber(num)) {
189
+ if (/^-?(?:0|[1-9]\d*)$/.test(val) && !Number.isSafeInteger(num)) {
190
+ return val;
191
+ }
192
+ return num;
193
+ }
189
194
  }
190
195
  if ((val.startsWith('{') && val.endsWith('}')) || (val.startsWith('[') && val.endsWith(']'))) {
191
196
  try {
@@ -350,9 +355,31 @@ function uid(prefix = 'id', len = 5) {
350
355
  return `${prefix}_${Date.now()}_${randomId(len)}`;
351
356
  }
352
357
 
358
+ /**
359
+ * Parse a delay/duration attribute such as `"200"`, `"200ms"`, or `"1s"` into milliseconds.
360
+ *
361
+ * @param {string|number|null|undefined} raw
362
+ * @param {number} [fallback=0]
363
+ * @returns {number}
364
+ */
365
+ function parseDelayMs(raw, fallback = 0) {
366
+ if (raw === null || raw === undefined || raw === '') return fallback;
367
+ if (typeof raw === 'number') return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
368
+ const s = String(raw).trim().toLowerCase();
369
+ if (!s) return fallback;
370
+ const msMatch = s.match(/^(\d+(?:\.\d+)?)\s*ms$/);
371
+ if (msMatch) return Math.max(0, Math.round(Number(msMatch[1])));
372
+ const sMatch = s.match(/^(\d+(?:\.\d+)?)\s*s$/);
373
+ if (sMatch) return Math.max(0, Math.round(Number(sMatch[1]) * 1000));
374
+ const num = Number(s);
375
+ return Number.isFinite(num) && num >= 0 ? Math.round(num) : fallback;
376
+ }
377
+
353
378
  /**
354
379
  * Apply or restore automatic busy/loading state (`data-w-busy`, `aria-busy`, `disabled`,
355
380
  * `data-w-loading-text` / `data-w-disable-with`, and `data-w-loading-class`) on an interactive element.
381
+ * Respects `data-w-loading-delay` for visual indicators (`loading-text`, `loading-class`) while
382
+ * applying `data-w-busy`, `aria-busy`, and `disabled` double-submit protection immediately.
356
383
  *
357
384
  * @param {Element|null|undefined} el
358
385
  * @param {boolean} isBusy
@@ -360,6 +387,29 @@ function uid(prefix = 'id', len = 5) {
360
387
  function setElementBusyState(el, isBusy) {
361
388
  if (!el || typeof el.setAttribute !== 'function') return;
362
389
 
390
+ const applyVisualBusy = () => {
391
+ const tag = el.tagName || '';
392
+ const loadingText = el.getAttribute('data-w-loading-text') ?? el.getAttribute('data-w-disable-with');
393
+ if (loadingText !== null) {
394
+ if (tag === 'INPUT' && (el.type === 'submit' || el.type === 'button')) {
395
+ el.__wOrigValue = el.value;
396
+ el.value = loadingText;
397
+ } else {
398
+ el.__wOrigText = el.textContent;
399
+ el.textContent = loadingText;
400
+ }
401
+ }
402
+
403
+ const loadingClass = safeTrim(el.getAttribute('data-w-loading-class'));
404
+ if (loadingClass && el.classList) {
405
+ const classes = loadingClass.split(/\s+/).filter(Boolean);
406
+ el.__wAddedClasses = classes;
407
+ for (let i = 0; i < classes.length; i++) {
408
+ el.classList.add(classes[i]);
409
+ }
410
+ }
411
+ };
412
+
363
413
  if (isBusy) {
364
414
  el.__wBusyCount = (el.__wBusyCount || 0) + 1;
365
415
  if (el.__wBusyCount > 1) return;
@@ -386,24 +436,22 @@ function setElementBusyState(el, isBusy) {
386
436
  el.setAttribute('aria-disabled', 'true');
387
437
  }
388
438
 
389
- const loadingText = el.getAttribute('data-w-loading-text') ?? el.getAttribute('data-w-disable-with');
390
- if (loadingText !== null) {
391
- if (tag === 'INPUT' && (el.type === 'submit' || el.type === 'button')) {
392
- el.__wOrigValue = el.value;
393
- el.value = loadingText;
394
- } else {
395
- el.__wOrigText = el.textContent;
396
- el.textContent = loadingText;
397
- }
439
+ const delayMs = parseDelayMs(el.getAttribute('data-w-loading-delay'), 0);
440
+ el.__wBusyGen = (el.__wBusyGen || 0) + 1;
441
+ if (el.__wBusyTimer) {
442
+ clearTimeout(el.__wBusyTimer);
443
+ el.__wBusyTimer = null;
398
444
  }
399
445
 
400
- const loadingClass = safeTrim(el.getAttribute('data-w-loading-class'));
401
- if (loadingClass && el.classList) {
402
- const classes = loadingClass.split(/\s+/).filter(Boolean);
403
- el.__wAddedClasses = classes;
404
- for (let i = 0; i < classes.length; i++) {
405
- el.classList.add(classes[i]);
406
- }
446
+ if (delayMs > 0) {
447
+ const gen = el.__wBusyGen;
448
+ el.__wBusyTimer = setTimeout(() => {
449
+ el.__wBusyTimer = null;
450
+ if (el.__wBusyGen !== gen || !el.__wActionBusy || el.isConnected === false) return;
451
+ applyVisualBusy();
452
+ }, delayMs);
453
+ } else {
454
+ applyVisualBusy();
407
455
  }
408
456
  } else {
409
457
  if (!el.__wBusyCount) return;
@@ -411,6 +459,12 @@ function setElementBusyState(el, isBusy) {
411
459
  if (el.__wBusyCount > 0) return;
412
460
 
413
461
  el.__wActionBusy = false;
462
+ el.__wBusyGen = (el.__wBusyGen || 0) + 1;
463
+ if (el.__wBusyTimer) {
464
+ clearTimeout(el.__wBusyTimer);
465
+ el.__wBusyTimer = null;
466
+ }
467
+
414
468
  el.removeAttribute('data-w-busy');
415
469
  el.removeAttribute('aria-busy');
416
470
 
@@ -459,6 +513,7 @@ module.exports = {
459
513
  parseDataValue,
460
514
  decorateElement,
461
515
  parseDurationMs,
516
+ parseDelayMs,
462
517
  parseRefetchMode,
463
518
  randomId,
464
519
  uid,
@@ -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
  }
@@ -229,13 +229,71 @@ async function executeApiPipeline(params) {
229
229
  req.service.invalidateTags = req.service.invalidateByTags;
230
230
  }
231
231
 
232
+ const rawValidateHeader = req && req.headers ? req.headers['x-webspresso-validate-fields'] : null;
233
+ const hasValidateHeader = Boolean(rawValidateHeader && String(rawValidateHeader).trim());
234
+ if (hasValidateHeader && !(apiDef && apiDef.validateFields)) {
235
+ if (res && typeof res.status === 'function' && typeof res.json === 'function') {
236
+ return res.status(400).json({
237
+ status: 400,
238
+ error: 'Field validation not enabled on this endpoint',
239
+ message: 'Field validation not enabled on this endpoint',
240
+ code: 'FIELD_VALIDATION_NOT_ENABLED',
241
+ });
242
+ }
243
+ const err = new Error('Field validation not enabled on this endpoint');
244
+ err.status = 400;
245
+ throw err;
246
+ }
247
+
248
+ const isFieldValidationRequest = Boolean(hasValidateHeader && apiDef && apiDef.validateFields);
249
+ const requestedValidateFields = isFieldValidationRequest
250
+ ? String(rawValidateHeader).split(',').map((s) => s.trim()).filter(Boolean)
251
+ : [];
252
+ const allowedValidateFields =
253
+ isFieldValidationRequest && Array.isArray(apiDef.validateFields)
254
+ ? requestedValidateFields.filter((f) => apiDef.validateFields.includes(f))
255
+ : requestedValidateFields;
256
+ const fieldValidationErrors = {};
257
+
232
258
  // 2. Schema validation
233
259
  const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
234
260
  const routeDir = descriptor._routeDir !== undefined
235
261
  ? descriptor._routeDir
236
262
  : (descriptor._routeDir = descriptor.file ? path.dirname(descriptor.file) : '');
237
263
 
238
- if (compiledSchema) {
264
+ if (isFieldValidationRequest) {
265
+ req.input = {
266
+ body: req.body,
267
+ params: req.params,
268
+ query: req.query,
269
+ };
270
+ if (compiledSchema && compiledSchema.body) {
271
+ let issues = null;
272
+ if (typeof compiledSchema.body.safeParse === 'function') {
273
+ const parsed = compiledSchema.body.safeParse(req.body);
274
+ if (parsed.success) {
275
+ req.input.body = parsed.data;
276
+ } else if (parsed.error && Array.isArray(parsed.error.issues)) {
277
+ issues = parsed.error.issues;
278
+ }
279
+ } else if (typeof compiledSchema.body.parse === 'function') {
280
+ try {
281
+ req.input.body = compiledSchema.body.parse(req.body);
282
+ } catch (parseErr) {
283
+ if (Array.isArray(parseErr?.issues)) issues = parseErr.issues;
284
+ }
285
+ }
286
+ if (issues) {
287
+ const translated = translateValidationIssues(issues, { req, pagesDir, routeDir, isDev });
288
+ for (const issue of translated) {
289
+ const pathKey = Array.isArray(issue.path) && issue.path.length > 0 ? String(issue.path[0]) : null;
290
+ if (pathKey && allowedValidateFields.includes(pathKey) && !fieldValidationErrors[pathKey]) {
291
+ fieldValidationErrors[pathKey] = issue.message || 'Invalid value';
292
+ }
293
+ }
294
+ }
295
+ }
296
+ } else if (compiledSchema) {
239
297
  try {
240
298
  validateRequestInput(req, compiledSchema);
241
299
  } catch (err) {
@@ -348,6 +406,29 @@ async function executeApiPipeline(params) {
348
406
  if (res && (res.headersSent || res.writableEnded || res.finished)) return;
349
407
  }
350
408
 
409
+ if (isFieldValidationRequest) {
410
+ if (typeof apiDef.validate === 'function') {
411
+ const customErrs = await apiDef.validate(apiCtx, allowedValidateFields);
412
+ if (customErrs && typeof customErrs === 'object') {
413
+ for (const [k, v] of Object.entries(customErrs)) {
414
+ if (allowedValidateFields.includes(k) && v && !fieldValidationErrors[k]) {
415
+ fieldValidationErrors[k] = String(v);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ const payload = {
421
+ ok: Object.keys(fieldValidationErrors).length === 0,
422
+ valid: Object.keys(fieldValidationErrors).length === 0,
423
+ errors: fieldValidationErrors,
424
+ validatedFields: allowedValidateFields,
425
+ };
426
+ if (res && typeof res.status === 'function' && typeof res.json === 'function') {
427
+ return res.status(200).json(payload);
428
+ }
429
+ return payload;
430
+ }
431
+
351
432
  const result = await handlerFn(req, res, apiCtx);
352
433
 
353
434
  // 5. Auto-respond if handler returned a value and response not yet sent
@@ -7,7 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { zodToJsonSchema } = require('zod-to-json-schema');
10
+ const { zodToJsonSchemaCompat, getZodTypeName } = require('../validation/zod-introspect');
11
11
  const { compileSchema } = require('../compileSchema');
12
12
  const { generateOrmOpenApiSchemas } = require('./orm-components');
13
13
 
@@ -100,13 +100,13 @@ function inferOperationTags(routePath, explicitTags) {
100
100
  * @returns {object[]}
101
101
  */
102
102
  function expandZodObjectToParameters(zodObj, where) {
103
- if (!zodObj || zodObj._def?.typeName !== 'ZodObject') return [];
103
+ if (!zodObj || getZodTypeName(zodObj) !== 'ZodObject') return [];
104
104
  const shape = zodObj.shape;
105
105
  return Object.entries(shape).map(([name, zType]) => ({
106
106
  name,
107
107
  in: where,
108
108
  required: where === 'path' ? true : !zType.isOptional(),
109
- schema: zodToJsonSchema(zType, { target: 'openApi3', $refStrategy: 'none' }),
109
+ schema: zodToJsonSchemaCompat(zType, { target: 'openApi3', $refStrategy: 'none' }),
110
110
  }));
111
111
  }
112
112
 
@@ -169,7 +169,7 @@ function buildOperation(route, compiled, mod = null) {
169
169
  description: 'OK',
170
170
  content: {
171
171
  'application/json': {
172
- schema: zodToJsonSchema(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
172
+ schema: zodToJsonSchemaCompat(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
173
173
  },
174
174
  },
175
175
  };
@@ -188,7 +188,7 @@ function buildOperation(route, compiled, mod = null) {
188
188
  op.requestBody = {
189
189
  content: {
190
190
  'application/json': {
191
- schema: zodToJsonSchema(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
191
+ schema: zodToJsonSchemaCompat(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
192
192
  },
193
193
  },
194
194
  };