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.
- package/README.md +26 -0
- package/bin/commands/admin-password.js +1 -7
- package/bin/commands/api.js +1 -7
- package/bin/commands/new.js +1 -7
- package/bin/commands/page.js +1 -7
- package/bin/commands/seed.js +1 -7
- package/bin/commands/service.js +1 -7
- package/bin/utils/prompt.js +17 -0
- package/core/cache/tagged-lru.js +9 -3
- package/core/client/adapter/datastar.js +602 -26
- package/core/client/api.js +151 -7
- package/core/client/controller.js +172 -11
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +479 -29
- package/core/client/fragments.js +456 -27
- package/core/client/index.js +18 -1
- package/core/client/server-fragments.js +125 -40
- package/core/client/utils.js +72 -17
- package/core/console/commands.js +26 -5
- package/core/console/repl.js +7 -1
- 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/plugins/plugin-manager.js +155 -1
- 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 +113 -1
- package/package.json +12 -11
- 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 +140 -6
- package/src/dev/api-typegen.js +79 -22
- package/src/dev/typegen.js +61 -35
- package/src/dev-reload/index.js +44 -2
- package/src/file-router.js +24 -6
- package/src/routing/mount-discovered-routes.js +24 -6
- package/src/server.js +59 -3
package/core/client/index.js
CHANGED
|
@@ -192,9 +192,26 @@ function createClientRuntime(options = {}) {
|
|
|
192
192
|
controllers.mountSubtree(addedEl);
|
|
193
193
|
}
|
|
194
194
|
},
|
|
195
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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:
|
|
1046
|
+
ok: !hasPartialErrors,
|
|
1047
|
+
...(hasPartialErrors ? { partial: true, errors: fragmentErrors } : {}),
|
|
963
1048
|
pagePath,
|
|
964
1049
|
totalMs,
|
|
965
1050
|
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/console/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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');
|
package/core/console/repl.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
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
|
};
|