webspresso 0.0.111 → 0.0.113

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.
@@ -41,10 +41,14 @@ function resolveDocument(root) {
41
41
  */
42
42
  function resolveDefaultContainer(root, doc) {
43
43
  if (root && root.nodeType === 1 && root.tagName !== 'HTML') {
44
- // If a custom element root was passed and is not inside a fragment, use it or doc.body
45
44
  if (doc && doc.body && (root === doc.body || root === doc.documentElement)) {
46
45
  return doc.body;
47
46
  }
47
+ const insideFragment =
48
+ typeof root.closest === 'function' ? Boolean(root.closest('[data-w-fragment]')) : false;
49
+ if (!insideFragment) {
50
+ return root;
51
+ }
48
52
  if (!doc || !doc.body) {
49
53
  return root;
50
54
  }
@@ -192,9 +192,21 @@ function createClientRuntime(options = {}) {
192
192
  controllers.mountSubtree(addedEl);
193
193
  }
194
194
  },
195
- onAfterPatch(patchedEl) {
195
+ onBeforePatch(patchedEl, inst) {
196
+ if (controllers) {
197
+ controllers.notifyBeforePatch(patchedEl, {
198
+ fragmentId: inst && inst.instanceId ? inst.instanceId : null,
199
+ fragmentName: inst && inst.name ? inst.name : null,
200
+ });
201
+ }
202
+ },
203
+ onAfterPatch(patchedEl, inst) {
196
204
  if (controllers) {
197
205
  controllers.mountSubtree(patchedEl);
206
+ controllers.notifyAfterPatch(patchedEl, {
207
+ fragmentId: inst && inst.instanceId ? inst.instanceId : null,
208
+ fragmentName: inst && inst.name ? inst.name : null,
209
+ });
198
210
  }
199
211
  },
200
212
  });
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Webspresso Lightweight Client Runtime (`core/client/lite.js`)
5
5
  *
6
- * Micro-footprint (~16.1 KB Brotli) client runtime for server-driven applications.
6
+ * Micro-footprint (~16.9 KB Brotli) client runtime for server-driven applications.
7
7
  * Includes:
8
8
  * - SSR Fragment coordinator (batch refresh, invalidation, auto-revalidation, DOM morphing, focus preservation)
9
9
  * - Native HTML Form enhancement (`<form data-w-form>`, validation errors, lifecycle hooks, draft persistence)
@@ -112,6 +112,15 @@ function defineFragment(config = {}) {
112
112
  refetchInterval: config.refetchInterval !== undefined ? config.refetchInterval : undefined,
113
113
  refetchIntervalInBackground:
114
114
  config.refetchIntervalInBackground !== undefined ? config.refetchIntervalInBackground : undefined,
115
+ persistInput:
116
+ config.persistInput !== undefined
117
+ ? config.persistInput
118
+ : config.persist !== undefined
119
+ ? config.persist
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,
115
124
  };
116
125
  }
117
126
 
@@ -396,6 +405,8 @@ function wrapFragmentRootHtml({
396
405
  refetchOnMount,
397
406
  refetchInterval,
398
407
  refetchIntervalInBackground,
408
+ persistInput,
409
+ lazy,
399
410
  }) {
400
411
  const safeTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(tag || 'div') ? tag : 'div';
401
412
  const attrs = [
@@ -436,6 +447,16 @@ function wrapFragmentRootHtml({
436
447
  attrs.push(`data-w-refetch-in-background="${escapeHtmlAttr(String(refetchIntervalInBackground))}"`);
437
448
  }
438
449
 
450
+ if (persistInput !== undefined && persistInput !== null && persistInput !== false) {
451
+ const persistAttrVal =
452
+ persistInput === true || persistInput === 'input' ? 'session' : String(persistInput);
453
+ attrs.push(`data-w-persist-input="${escapeHtmlAttr(persistAttrVal)}"`);
454
+ }
455
+
456
+ if (lazy) {
457
+ attrs.push('data-w-lazy="visible"');
458
+ }
459
+
439
460
  if (className) {
440
461
  attrs.push(`class="${escapeHtmlAttr(className)}"`);
441
462
  }
@@ -499,10 +520,18 @@ async function executeAndRenderFragment({
499
520
  // 3. Validate & coerce fragment input
500
521
  const validatedInput = validateFragmentInput(name, fragmentDef, rawInput);
501
522
 
502
- // 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
503
532
  const loadStart = Date.now();
504
533
  let fragmentData = {};
505
- if (typeof fragmentDef.load === 'function') {
534
+ if (!renderAsLazyPlaceholder && typeof fragmentDef.load === 'function') {
506
535
  const fragmentCtx = {
507
536
  ...ctx,
508
537
  req,
@@ -522,20 +551,45 @@ async function executeAndRenderFragment({
522
551
  }
523
552
  const loadMs = Math.max(0, Date.now() - loadStart);
524
553
 
525
- // 5. Render fragment template via Nunjucks
554
+ // 5. Render fragment template (or lazy placeholder) via Nunjucks
526
555
  const renderStart = Date.now();
527
- const templateCtx = {
528
- ...(res && res.locals ? res.locals : {}),
529
- ...baseRenderCtx,
530
- ...fragmentData,
531
- fragmentInput: validatedInput,
532
- fragmentId: instanceId,
533
- fragmentName: name,
534
- };
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
+ };
535
588
 
536
- let innerHtml = nunjucksEnv.render(cleanTemplate, templateCtx);
537
- // Resolve any nested async SSR fragments inside this fragment
538
- 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
+ }
539
593
  const renderMs = Math.max(0, Date.now() - renderStart);
540
594
 
541
595
  if (renderedInstanceIds) {
@@ -572,6 +626,13 @@ async function executeAndRenderFragment({
572
626
  options.refetchIntervalInBackground !== undefined
573
627
  ? options.refetchIntervalInBackground
574
628
  : fragmentDef.refetchIntervalInBackground,
629
+ persistInput:
630
+ options.persistInput !== undefined
631
+ ? options.persistInput
632
+ : options.persist !== undefined
633
+ ? options.persist
634
+ : fragmentDef.persistInput,
635
+ lazy: renderAsLazyPlaceholder,
575
636
  });
576
637
 
577
638
  return {
@@ -620,6 +681,24 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
620
681
 
621
682
  const currentRenderCtx = typeof getRenderCtx === 'function' ? getRenderCtx() : { fsy };
622
683
 
684
+ // If refreshing a parent fragment that contains this nested child fragment,
685
+ // merge the active client-side child input (`req.__fragmentChildInputs`) over static template defaults
686
+ const childInputs = req && req.__fragmentChildInputs;
687
+ let effectiveRawInput = rawInput || {};
688
+ if (childInputs && typeof childInputs === 'object') {
689
+ const override = Object.prototype.hasOwnProperty.call(childInputs, instanceId)
690
+ ? childInputs[instanceId]
691
+ : instanceId === `${cleanName}:default` && Object.prototype.hasOwnProperty.call(childInputs, cleanName)
692
+ ? childInputs[cleanName]
693
+ : undefined;
694
+ if (override && typeof override === 'object' && !isArray(override)) {
695
+ effectiveRawInput =
696
+ effectiveRawInput && typeof effectiveRawInput === 'object' && !isArray(effectiveRawInput)
697
+ ? { ...effectiveRawInput, ...override }
698
+ : { ...override };
699
+ }
700
+ }
701
+
623
702
  // Queue execution on `req.__pendingSsrFragments` and return placeholder token
624
703
  const placeholderIndex = req.__pendingSsrFragments.length;
625
704
  const token = `<!--__WEBSPRESSO_SSR_FRAGMENT_${placeholderIndex}__-->`;
@@ -629,7 +708,7 @@ function attachSsrFragmentHelper({ req, res, ctx, pageMod, pagePath, nunjucksEnv
629
708
  fragmentDef,
630
709
  instanceId,
631
710
  pagePath,
632
- rawInput: rawInput || {},
711
+ rawInput: effectiveRawInput,
633
712
  req,
634
713
  res,
635
714
  ctx,
@@ -774,6 +853,9 @@ function createFragmentRefreshHandler(config) {
774
853
  const startTotal = Date.now();
775
854
  try {
776
855
  const isGet = req.method === 'GET';
856
+ if (!isGet && req.headers && req.headers['sec-fetch-site'] === 'cross-site') {
857
+ throw new ForbiddenError('Cross-site fragment refresh requests are forbidden.');
858
+ }
777
859
  const payload = isGet ? req.query || {} : req.body || {};
778
860
 
779
861
  const pagePath = payload.pagePath || payload.page || req.headers['x-webspresso-page'] || '/';
@@ -857,6 +939,13 @@ function createFragmentRefreshHandler(config) {
857
939
  const ctx = typeof buildContext === 'function' ? buildContext(req, res, { fsy }) : { req, res, fsy };
858
940
  ensureRequestMemo(req, ctx);
859
941
 
942
+ req.__isWebspressoFragmentRefresh = true;
943
+ req.__refreshedFragmentIds = new Set(requestedFragments.map((it) => it.instanceId || `${it.name}:default`));
944
+ req.__fragmentChildInputs =
945
+ payload.childInputs && typeof payload.childInputs === 'object' && !isArray(payload.childInputs)
946
+ ? payload.childInputs
947
+ : null;
948
+
860
949
  const renderedInstanceIds = new Set();
861
950
  attachSsrFragmentHelper({
862
951
  req,
@@ -870,6 +959,8 @@ function createFragmentRefreshHandler(config) {
870
959
  });
871
960
 
872
961
  const results = {};
962
+ const fragmentErrors = {};
963
+ let firstError = null;
873
964
 
874
965
  for (const reqItem of requestedFragments) {
875
966
  const fragName = reqItem.name.trim();
@@ -890,34 +981,51 @@ function createFragmentRefreshHandler(config) {
890
981
  : rawFragDef.middleware || [],
891
982
  };
892
983
 
893
- const rendered = await executeAndRenderFragment({
894
- name: fragName,
895
- fragmentDef: fragDef,
896
- instanceId,
897
- pagePath,
898
- rawInput: reqItem.input || {},
899
- req,
900
- res,
901
- ctx,
902
- nunjucksEnv,
903
- baseRenderCtx: { fsy },
904
- renderedInstanceIds,
905
- });
984
+ try {
985
+ const rendered = await executeAndRenderFragment({
986
+ name: fragName,
987
+ fragmentDef: fragDef,
988
+ instanceId,
989
+ pagePath,
990
+ rawInput: reqItem.input || {},
991
+ req,
992
+ res,
993
+ ctx,
994
+ nunjucksEnv,
995
+ baseRenderCtx: { fsy },
996
+ renderedInstanceIds,
997
+ });
998
+
999
+ results[instanceId] = {
1000
+ instanceId: rendered.instanceId,
1001
+ name: rendered.name,
1002
+ html: rendered.html,
1003
+ input: rendered.input,
1004
+ loadMs: rendered.loadMs,
1005
+ renderMs: rendered.renderMs,
1006
+ };
1007
+ } catch (fragErr) {
1008
+ if (!firstError) firstError = fragErr;
1009
+ fragmentErrors[instanceId] = {
1010
+ instanceId,
1011
+ name: fragName,
1012
+ status: fragErr.statusCode || fragErr.status || 500,
1013
+ code: fragErr.code || 'FRAGMENT_REFRESH_ERROR',
1014
+ message: fragErr.message || `Failed to render fragment "${fragName}"`,
1015
+ };
1016
+ }
1017
+ }
906
1018
 
907
- results[instanceId] = {
908
- instanceId: rendered.instanceId,
909
- name: rendered.name,
910
- html: rendered.html,
911
- input: rendered.input,
912
- loadMs: rendered.loadMs,
913
- renderMs: rendered.renderMs,
914
- };
1019
+ if (firstError && Object.keys(results).length === 0) {
1020
+ return next(firstError);
915
1021
  }
916
1022
 
917
1023
  const totalMs = Math.max(0, Date.now() - startTotal);
918
1024
  res.setHeader('Cache-Control', 'no-store');
1025
+ const hasPartialErrors = Object.keys(fragmentErrors).length > 0;
919
1026
  return res.status(200).json({
920
- ok: true,
1027
+ ok: !hasPartialErrors,
1028
+ ...(hasPartialErrors ? { partial: true, errors: fragmentErrors } : {}),
921
1029
  pagePath,
922
1030
  totalMs,
923
1031
  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,
@@ -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
  };
@@ -3,6 +3,8 @@
3
3
  * @module core/orm/json-fields
4
4
  */
5
5
 
6
+ const { getZodDef, getZodInner, getZodTypeName } = require('../validation/zod-introspect');
7
+
6
8
  /**
7
9
  * Get JSON column names from model
8
10
  * @param {import('./types').ModelDefinition} model - Model definition
@@ -31,16 +33,15 @@ function getJsonColumns(model) {
31
33
  if (model && model.schema && model.schema.shape) {
32
34
  for (const [name, fieldSchema] of Object.entries(model.schema.shape)) {
33
35
  let current = fieldSchema;
34
- while (current && current._def) {
35
- if (current._def.innerType) {
36
- current = current._def.innerType;
37
- } else if (current._def.schema) {
38
- current = current._def.schema;
36
+ while (current && getZodDef(current)) {
37
+ const inner = getZodInner(current);
38
+ if (inner) {
39
+ current = inner;
39
40
  } else {
40
41
  break;
41
42
  }
42
43
  }
43
- const typeName = current?._def?.typeName;
44
+ const typeName = getZodTypeName(current);
44
45
  if (typeName === 'ZodRecord' || typeName === 'ZodObject' || typeName === 'ZodArray') {
45
46
  jsonCols.add(name);
46
47
  }