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.
- package/core/client/adapter/datastar.js +700 -50
- package/core/client/api.js +147 -7
- package/core/client/controller.js +254 -22
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +598 -38
- package/core/client/fragments.js +644 -112
- package/core/client/host.js +5 -1
- package/core/client/index.js +13 -1
- package/core/client/lite.js +1 -1
- package/core/client/server-fragments.js +145 -37
- 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 +110 -2
- 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
package/core/client/host.js
CHANGED
|
@@ -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
|
}
|
package/core/client/index.js
CHANGED
|
@@ -192,9 +192,21 @@ function createClientRuntime(options = {}) {
|
|
|
192
192
|
controllers.mountSubtree(addedEl);
|
|
193
193
|
}
|
|
194
194
|
},
|
|
195
|
-
|
|
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
|
});
|
package/core/client/lite.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Webspresso Lightweight Client Runtime (`core/client/lite.js`)
|
|
5
5
|
*
|
|
6
|
-
* Micro-footprint (~16.
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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:
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
908
|
-
|
|
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:
|
|
1027
|
+
ok: !hasPartialErrors,
|
|
1028
|
+
...(hasPartialErrors ? { partial: true, errors: fragmentErrors } : {}),
|
|
921
1029
|
pagePath,
|
|
922
1030
|
totalMs,
|
|
923
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
|
}
|