webspresso 0.0.112 → 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.
- package/core/client/adapter/datastar.js +587 -26
- package/core/client/api.js +147 -7
- package/core/client/controller.js +134 -11
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +475 -29
- package/core/client/fragments.js +397 -25
- package/core/client/index.js +13 -1
- package/core/client/server-fragments.js +102 -36
- package/core/client/utils.js +72 -17
- package/core/http/pipeline.js +82 -1
- package/core/openapi/build-from-api-routes.js +5 -5
- package/core/orm/json-fields.js +7 -6
- package/core/orm/schema-helpers.js +18 -15
- package/core/orm/utils/nanoid.js +7 -8
- package/core/services/validator.js +12 -1
- package/core/validation/apply.js +3 -0
- package/core/validation/index.js +25 -0
- package/core/validation/zod-introspect.js +187 -0
- package/index.d.ts +98 -1
- package/package.json +9 -8
- package/plugins/mcp/providers/services.js +2 -2
- package/src/api/define-api.js +2 -0
- package/src/api/validation-i18n.js +6 -2
- package/src/bundle/index.js +67 -3
- package/src/dev/api-typegen.js +79 -22
- package/src/dev/typegen.js +61 -35
- package/src/file-router.js +24 -6
- package/src/routing/mount-discovered-routes.js +24 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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 {
|
|
@@ -811,6 +853,9 @@ function createFragmentRefreshHandler(config) {
|
|
|
811
853
|
const startTotal = Date.now();
|
|
812
854
|
try {
|
|
813
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
|
+
}
|
|
814
859
|
const payload = isGet ? req.query || {} : req.body || {};
|
|
815
860
|
|
|
816
861
|
const pagePath = payload.pagePath || payload.page || req.headers['x-webspresso-page'] || '/';
|
|
@@ -894,6 +939,8 @@ function createFragmentRefreshHandler(config) {
|
|
|
894
939
|
const ctx = typeof buildContext === 'function' ? buildContext(req, res, { fsy }) : { req, res, fsy };
|
|
895
940
|
ensureRequestMemo(req, ctx);
|
|
896
941
|
|
|
942
|
+
req.__isWebspressoFragmentRefresh = true;
|
|
943
|
+
req.__refreshedFragmentIds = new Set(requestedFragments.map((it) => it.instanceId || `${it.name}:default`));
|
|
897
944
|
req.__fragmentChildInputs =
|
|
898
945
|
payload.childInputs && typeof payload.childInputs === 'object' && !isArray(payload.childInputs)
|
|
899
946
|
? payload.childInputs
|
|
@@ -912,6 +959,8 @@ function createFragmentRefreshHandler(config) {
|
|
|
912
959
|
});
|
|
913
960
|
|
|
914
961
|
const results = {};
|
|
962
|
+
const fragmentErrors = {};
|
|
963
|
+
let firstError = null;
|
|
915
964
|
|
|
916
965
|
for (const reqItem of requestedFragments) {
|
|
917
966
|
const fragName = reqItem.name.trim();
|
|
@@ -932,34 +981,51 @@ function createFragmentRefreshHandler(config) {
|
|
|
932
981
|
: rawFragDef.middleware || [],
|
|
933
982
|
};
|
|
934
983
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
}
|
|
948
1018
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
name: rendered.name,
|
|
952
|
-
html: rendered.html,
|
|
953
|
-
input: rendered.input,
|
|
954
|
-
loadMs: rendered.loadMs,
|
|
955
|
-
renderMs: rendered.renderMs,
|
|
956
|
-
};
|
|
1019
|
+
if (firstError && Object.keys(results).length === 0) {
|
|
1020
|
+
return next(firstError);
|
|
957
1021
|
}
|
|
958
1022
|
|
|
959
1023
|
const totalMs = Math.max(0, Date.now() - startTotal);
|
|
960
1024
|
res.setHeader('Cache-Control', 'no-store');
|
|
1025
|
+
const hasPartialErrors = Object.keys(fragmentErrors).length > 0;
|
|
961
1026
|
return res.status(200).json({
|
|
962
|
-
ok:
|
|
1027
|
+
ok: !hasPartialErrors,
|
|
1028
|
+
...(hasPartialErrors ? { partial: true, errors: fragmentErrors } : {}),
|
|
963
1029
|
pagePath,
|
|
964
1030
|
totalMs,
|
|
965
1031
|
fragments: results,
|
package/core/client/utils.js
CHANGED
|
@@ -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))
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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,
|
package/core/http/pipeline.js
CHANGED
|
@@ -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 (
|
|
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 {
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
191
|
+
schema: zodToJsonSchemaCompat(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
|
|
192
192
|
},
|
|
193
193
|
},
|
|
194
194
|
};
|
package/core/orm/json-fields.js
CHANGED
|
@@ -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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
44
|
+
const typeName = getZodTypeName(current);
|
|
44
45
|
if (typeName === 'ZodRecord' || typeName === 'ZodObject' || typeName === 'ZodArray') {
|
|
45
46
|
jsonCols.add(name);
|
|
46
47
|
}
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { safeJsonParse } = require('../utils/json');
|
|
8
|
+
const {
|
|
9
|
+
getZodDef,
|
|
10
|
+
getZodTypeName,
|
|
11
|
+
getZodInner,
|
|
12
|
+
getZodEnumValues,
|
|
13
|
+
isZodInt,
|
|
14
|
+
} = require('../validation/zod-introspect');
|
|
8
15
|
|
|
9
16
|
const METADATA_MARKER = '__wdb__';
|
|
10
17
|
const metaWeakMap = new WeakMap();
|
|
@@ -665,10 +672,7 @@ function createSchemaHelpers(z) {
|
|
|
665
672
|
*/
|
|
666
673
|
json(options = {}) {
|
|
667
674
|
const { nullable = false, ...rest } = options;
|
|
668
|
-
let schema = z.unknown();
|
|
669
|
-
if (nullable) {
|
|
670
|
-
schema = schema.nullable().optional();
|
|
671
|
-
}
|
|
675
|
+
let schema = nullable ? z.unknown().nullable().optional() : z.unknown().optional();
|
|
672
676
|
return createSchemaBuilder(schema, {
|
|
673
677
|
type: 'json',
|
|
674
678
|
nullable,
|
|
@@ -888,14 +892,14 @@ function extractColumnsFromSchema(schema) {
|
|
|
888
892
|
// Unwrap optional/nullable wrappers to get to the base schema
|
|
889
893
|
let current = schemaToCheck;
|
|
890
894
|
let isNullable = false;
|
|
891
|
-
while (current && current
|
|
892
|
-
|
|
895
|
+
while (current && getZodDef(current)) {
|
|
896
|
+
const currentType = getZodTypeName(current);
|
|
897
|
+
if (currentType === 'ZodNullable' || currentType === 'ZodOptional') {
|
|
893
898
|
isNullable = true;
|
|
894
899
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
current = current._def.schema;
|
|
900
|
+
const inner = getZodInner(current);
|
|
901
|
+
if (inner) {
|
|
902
|
+
current = inner;
|
|
899
903
|
} else {
|
|
900
904
|
break;
|
|
901
905
|
}
|
|
@@ -905,16 +909,15 @@ function extractColumnsFromSchema(schema) {
|
|
|
905
909
|
const meta = getColumnMeta(schemaToCheck) || (current ? getColumnMeta(current) : null);
|
|
906
910
|
if (meta) {
|
|
907
911
|
columns.set(key, meta);
|
|
908
|
-
} else if (current && current
|
|
909
|
-
const typeName = current
|
|
912
|
+
} else if (current && getZodTypeName(current)) {
|
|
913
|
+
const typeName = getZodTypeName(current);
|
|
910
914
|
let inferred = null;
|
|
911
915
|
switch (typeName) {
|
|
912
916
|
case 'ZodString':
|
|
913
917
|
inferred = { type: 'string', maxLength: 255, nullable: isNullable };
|
|
914
918
|
break;
|
|
915
919
|
case 'ZodNumber': {
|
|
916
|
-
|
|
917
|
-
inferred = { type: isInt ? 'integer' : 'float', nullable: isNullable };
|
|
920
|
+
inferred = { type: isZodInt(current) ? 'integer' : 'float', nullable: isNullable };
|
|
918
921
|
break;
|
|
919
922
|
}
|
|
920
923
|
case 'ZodBoolean':
|
|
@@ -924,7 +927,7 @@ function extractColumnsFromSchema(schema) {
|
|
|
924
927
|
inferred = { type: 'timestamp', nullable: isNullable };
|
|
925
928
|
break;
|
|
926
929
|
case 'ZodEnum':
|
|
927
|
-
inferred = { type: 'enum', enumValues: current
|
|
930
|
+
inferred = { type: 'enum', enumValues: getZodEnumValues(current), nullable: isNullable };
|
|
928
931
|
break;
|
|
929
932
|
case 'ZodArray':
|
|
930
933
|
inferred = { type: 'files', nullable: isNullable };
|
package/core/orm/utils/nanoid.js
CHANGED
|
@@ -63,14 +63,13 @@ function resolveNanoidSize(arg) {
|
|
|
63
63
|
* @param {typeof import('zod').z} zod
|
|
64
64
|
*/
|
|
65
65
|
function extendZ(zod) {
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return Reflect.get(target, prop, receiver);
|
|
66
|
+
return Object.create(zod, {
|
|
67
|
+
nanoid: {
|
|
68
|
+
value: function nanoid(arg) {
|
|
69
|
+
return zodNanoid(zod, resolveNanoidSize(arg));
|
|
70
|
+
},
|
|
71
|
+
enumerable: true,
|
|
72
|
+
configurable: true,
|
|
74
73
|
},
|
|
75
74
|
});
|
|
76
75
|
}
|
|
@@ -293,7 +293,18 @@ function validateServiceInput(schema, input, serviceName, options = {}) {
|
|
|
293
293
|
// 1. Zod schema validation
|
|
294
294
|
if (compiled instanceof z.ZodType || typeof compiled.safeParse === 'function') {
|
|
295
295
|
try {
|
|
296
|
-
|
|
296
|
+
let result;
|
|
297
|
+
if (compiled._zod && typeof compiled._zod.run === 'function') {
|
|
298
|
+
const rawRun = compiled._zod.run({ value: sanitized, issues: [] }, { async: true });
|
|
299
|
+
if (rawRun instanceof Promise) {
|
|
300
|
+
// Attach no-op catch so async refinements rejected during sync preflight don't emit UnhandledRejection
|
|
301
|
+
rawRun.catch(() => {});
|
|
302
|
+
throw new Error('Encountered Promise during synchronous parse');
|
|
303
|
+
}
|
|
304
|
+
result = rawRun.issues.length ? compiled.safeParse(sanitized) : { success: true, data: rawRun.value };
|
|
305
|
+
} else {
|
|
306
|
+
result = compiled.safeParse(sanitized);
|
|
307
|
+
}
|
|
297
308
|
if (!result.success) {
|
|
298
309
|
const { message, fields, fieldErrors } = formatZodErrors(result.error, serviceName);
|
|
299
310
|
throw createServiceValidationError(message, {
|