webspresso 0.0.109 → 0.0.110
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 +175 -28
- package/core/client/api.js +312 -8
- package/core/client/controller.js +655 -30
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +145 -2
- package/core/client/fragments.js +166 -10
- package/core/client/index.d.ts +19 -0
- package/core/client/index.js +47 -3
- package/core/client/lite.js +11 -0
- package/core/client/server-fragments.js +12 -3
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +137 -0
- package/core/index.js +4 -0
- package/index.d.ts +204 -14
- package/index.js +4 -0
- package/package.json +7 -8
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +6 -18
- package/src/client-runtime/resolve.js +3 -2
- package/src/file-router.js +12 -8
- package/src/routing/mount-discovered-routes.js +9 -2
- package/src/server.js +14 -24
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
package/core/client/forms.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { defaultStorage } = require('./storage');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Webspresso Native HTML Form Layer & Typed Form Handle
|
|
5
7
|
*
|
|
@@ -13,6 +15,40 @@
|
|
|
13
15
|
* - Mutation success vs refresh failure separation: a refresh failure never puts a succeeded form into validation error state
|
|
14
16
|
*/
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Validate that a redirect URL uses a safe protocol (http/https) or relative path
|
|
20
|
+
* to prevent DOM-based XSS (e.g. javascript: URIs) and protocol injection.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} target
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function isSafeRedirectUrl(target) {
|
|
26
|
+
if (!target || typeof target !== 'string') return false;
|
|
27
|
+
const trimmed = target.trim();
|
|
28
|
+
if (!trimmed) return false;
|
|
29
|
+
|
|
30
|
+
// Block control characters
|
|
31
|
+
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
|
|
32
|
+
|
|
33
|
+
// Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
|
|
34
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Relative path starting with single slash (e.g. /dashboard)
|
|
39
|
+
if (trimmed.startsWith('/')) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Absolute URL: MUST explicitly parse as http: or https:
|
|
44
|
+
try {
|
|
45
|
+
const parsed = new URL(trimmed);
|
|
46
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
16
52
|
let errorIdSeq = 0;
|
|
17
53
|
|
|
18
54
|
/**
|
|
@@ -401,6 +437,89 @@ function createFormHandle(formEl, options = {}) {
|
|
|
401
437
|
const errorListeners = new Set();
|
|
402
438
|
const stateListeners = new Set();
|
|
403
439
|
|
|
440
|
+
// Form draft persistence setup (sessionStorage by default, or localStorage)
|
|
441
|
+
const rawDraftAttr = formEl
|
|
442
|
+
? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
|
|
443
|
+
: null;
|
|
444
|
+
const isDraftEnabled =
|
|
445
|
+
options.persistDraft !== undefined
|
|
446
|
+
? Boolean(options.persistDraft)
|
|
447
|
+
: rawDraftAttr !== null && rawDraftAttr !== 'false';
|
|
448
|
+
|
|
449
|
+
let draftStorageType = 'session';
|
|
450
|
+
if (typeof options.persistDraft === 'string') {
|
|
451
|
+
draftStorageType = options.persistDraft.toLowerCase() === 'local' ? 'local' : 'session';
|
|
452
|
+
} else if (rawDraftAttr && rawDraftAttr.toLowerCase() === 'local') {
|
|
453
|
+
draftStorageType = 'local';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const draftKey =
|
|
457
|
+
options.draftKey ||
|
|
458
|
+
(formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
|
|
459
|
+
`form_draft:${formName || 'anon'}`;
|
|
460
|
+
|
|
461
|
+
const draftStorage =
|
|
462
|
+
(options.storage && options.storage[draftStorageType]) ||
|
|
463
|
+
(draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
|
|
464
|
+
|
|
465
|
+
function extractDraftValues(el) {
|
|
466
|
+
if (!el || typeof el.querySelectorAll !== 'function') return {};
|
|
467
|
+
const { data } = parseFormElement(el);
|
|
468
|
+
const safeData = {};
|
|
469
|
+
for (const [key, val] of Object.entries(data)) {
|
|
470
|
+
const ctrl = el.querySelector(`[name="${key}"], [name="${key}[]"]`);
|
|
471
|
+
if (!ctrl) {
|
|
472
|
+
safeData[key] = val;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const type = (ctrl.type || '').toLowerCase();
|
|
476
|
+
if (type === 'password' || type === 'file') continue;
|
|
477
|
+
if (ctrl.hasAttribute('data-w-no-draft') || ctrl.getAttribute('data-w-persist') === 'false') continue;
|
|
478
|
+
safeData[key] = val;
|
|
479
|
+
}
|
|
480
|
+
return safeData;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function saveDraft() {
|
|
484
|
+
if (!formEl || !isDraftEnabled) return false;
|
|
485
|
+
const draftPayload = extractDraftValues(formEl);
|
|
486
|
+
return draftStorage.set(draftKey, draftPayload);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function restoreDraft() {
|
|
490
|
+
if (!formEl || !isDraftEnabled) return false;
|
|
491
|
+
const saved = draftStorage.get(draftKey, null);
|
|
492
|
+
if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
|
|
493
|
+
fill(saved, { baseline: false });
|
|
494
|
+
if (typeof formEl.dispatchEvent === 'function') {
|
|
495
|
+
const CustomEventCtor =
|
|
496
|
+
(formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
|
|
497
|
+
(typeof CustomEvent !== 'undefined' ? CustomEvent : null);
|
|
498
|
+
if (CustomEventCtor) {
|
|
499
|
+
formEl.dispatchEvent(
|
|
500
|
+
new CustomEventCtor('w-form:draft-restored', {
|
|
501
|
+
bubbles: true,
|
|
502
|
+
cancelable: true,
|
|
503
|
+
detail: { form: handle, draft: saved, key: draftKey },
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function clearDraft() {
|
|
514
|
+
if (!isDraftEnabled) return false;
|
|
515
|
+
return draftStorage.remove(draftKey);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function hasDraft() {
|
|
519
|
+
if (!isDraftEnabled) return false;
|
|
520
|
+
return draftStorage.has(draftKey);
|
|
521
|
+
}
|
|
522
|
+
|
|
404
523
|
function notifyStateChange() {
|
|
405
524
|
for (const fn of stateListeners) {
|
|
406
525
|
try {
|
|
@@ -799,6 +918,10 @@ function createFormHandle(formEl, options = {}) {
|
|
|
799
918
|
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
800
919
|
}
|
|
801
920
|
|
|
921
|
+
if (isDraftEnabled) {
|
|
922
|
+
clearDraft();
|
|
923
|
+
}
|
|
924
|
+
|
|
802
925
|
// Invoke registered success hooks. Note: Any background fragment refresh failure
|
|
803
926
|
// is handled by the fragment coordinator and NEVER puts this form into validation error state!
|
|
804
927
|
for (const listener of successListeners) {
|
|
@@ -810,7 +933,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
810
933
|
submitOptions.redirectOnSuccess ||
|
|
811
934
|
(formEl && formEl.getAttribute('data-w-redirect')) ||
|
|
812
935
|
null;
|
|
813
|
-
if (redirectTarget) {
|
|
936
|
+
if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
|
|
814
937
|
if (typeof options.navigate === 'function') {
|
|
815
938
|
options.navigate(redirectTarget);
|
|
816
939
|
} else if (typeof window !== 'undefined' && window.location) {
|
|
@@ -859,6 +982,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
859
982
|
}
|
|
860
983
|
|
|
861
984
|
// Listen to native submit and input/change events on `formEl`
|
|
985
|
+
let draftDebounceTimer = null;
|
|
862
986
|
const onDomInput = (evt) => {
|
|
863
987
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
864
988
|
const target = evt && evt.target;
|
|
@@ -873,6 +997,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
873
997
|
}
|
|
874
998
|
}
|
|
875
999
|
}
|
|
1000
|
+
if (isDraftEnabled) {
|
|
1001
|
+
if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
|
|
1002
|
+
draftDebounceTimer = setTimeout(() => {
|
|
1003
|
+
saveDraft();
|
|
1004
|
+
}, 150);
|
|
1005
|
+
}
|
|
876
1006
|
notifyStateChange();
|
|
877
1007
|
};
|
|
878
1008
|
|
|
@@ -900,7 +1030,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
900
1030
|
// Initialize hidden state on empty error placeholders
|
|
901
1031
|
syncErrorsToDom(false);
|
|
902
1032
|
|
|
903
|
-
|
|
1033
|
+
let handle = null;
|
|
1034
|
+
|
|
1035
|
+
handle = {
|
|
904
1036
|
name: formName,
|
|
905
1037
|
el: formEl,
|
|
906
1038
|
get pending() {
|
|
@@ -938,6 +1070,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
938
1070
|
return parseFormElement(formEl, submitter).data;
|
|
939
1071
|
},
|
|
940
1072
|
fill,
|
|
1073
|
+
saveDraft,
|
|
1074
|
+
restoreDraft,
|
|
1075
|
+
clearDraft,
|
|
1076
|
+
get hasDraft() {
|
|
1077
|
+
return hasDraft();
|
|
1078
|
+
},
|
|
941
1079
|
setAction,
|
|
942
1080
|
setRouteParams,
|
|
943
1081
|
setDisabled,
|
|
@@ -1009,6 +1147,10 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1009
1147
|
debug.recordEvent('form:bind', { name: formName });
|
|
1010
1148
|
}
|
|
1011
1149
|
|
|
1150
|
+
if (isDraftEnabled) {
|
|
1151
|
+
restoreDraft();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1012
1154
|
return handle;
|
|
1013
1155
|
}
|
|
1014
1156
|
|
|
@@ -1017,5 +1159,6 @@ module.exports = {
|
|
|
1017
1159
|
isControlDisabled,
|
|
1018
1160
|
coerceScalarControlValue,
|
|
1019
1161
|
createFormHandle,
|
|
1162
|
+
isSafeRedirectUrl,
|
|
1020
1163
|
};
|
|
1021
1164
|
|
package/core/client/fragments.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { defaultStorage } = require('./storage');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Webspresso Client-Side SSR Fragment Coordinator & Invalidation Engine
|
|
5
7
|
*
|
|
@@ -106,6 +108,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
106
108
|
const endpoint = options.endpoint || DEFAULT_FRAGMENT_ENDPOINT;
|
|
107
109
|
const datastar = options.datastar || options.adapter;
|
|
108
110
|
const debug = options.debug || null;
|
|
111
|
+
const defaultTimeout = typeof options.timeout === 'number' ? options.timeout : 15000;
|
|
109
112
|
|
|
110
113
|
// Map<instanceId, { instanceId, name, pagePath, input, dependsOn: Set<string>, version: number, el: Element, abortController: AbortController|null }>
|
|
111
114
|
const instances = new Map();
|
|
@@ -151,25 +154,49 @@ function createFragmentCoordinator(options = {}) {
|
|
|
151
154
|
const input = parseFragmentInputAttr(el.getAttribute('data-w-fragment-input'));
|
|
152
155
|
const dependsOn = new Set(parseDependsOnAttr(el.getAttribute('data-w-depends-on')));
|
|
153
156
|
|
|
157
|
+
// Fragment input persistence setup
|
|
158
|
+
const rawPersistInput =
|
|
159
|
+
el.getAttribute('data-w-persist-input') ||
|
|
160
|
+
(el.getAttribute('data-w-persist') === 'input' ? 'session' : null);
|
|
161
|
+
const isInputPersistEnabled = Boolean(rawPersistInput && rawPersistInput !== 'false');
|
|
162
|
+
const inputStorageType = rawPersistInput && rawPersistInput.toLowerCase() === 'local' ? 'local' : 'session';
|
|
163
|
+
const inputStorageKey =
|
|
164
|
+
el.getAttribute('data-w-persist-key') ||
|
|
165
|
+
el.getAttribute('data-w-input-key') ||
|
|
166
|
+
`frag_input:${name}`;
|
|
167
|
+
|
|
168
|
+
let effectiveInput = input;
|
|
169
|
+
if (isInputPersistEnabled) {
|
|
170
|
+
const store = inputStorageType === 'local' ? defaultStorage.local : defaultStorage.session;
|
|
171
|
+
if (!el.hasAttribute('data-w-fragment-input') || Object.keys(effectiveInput).length === 0) {
|
|
172
|
+
const stored = store.get(inputStorageKey, null);
|
|
173
|
+
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
|
|
174
|
+
effectiveInput = stored;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
154
179
|
const existing = instances.get(instanceId);
|
|
155
180
|
if (existing) {
|
|
156
181
|
existing.name = name;
|
|
157
182
|
existing.pagePath = pagePath;
|
|
158
183
|
existing.el = el;
|
|
159
184
|
existing.dependsOn = dependsOn;
|
|
160
|
-
if (el.hasAttribute('data-w-fragment-input')) {
|
|
161
|
-
existing.input =
|
|
185
|
+
if (el.hasAttribute('data-w-fragment-input') || isInputPersistEnabled) {
|
|
186
|
+
existing.input = effectiveInput;
|
|
162
187
|
}
|
|
188
|
+
existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
|
|
163
189
|
} else {
|
|
164
190
|
instances.set(instanceId, {
|
|
165
191
|
instanceId,
|
|
166
192
|
name,
|
|
167
193
|
pagePath,
|
|
168
|
-
input,
|
|
194
|
+
input: effectiveInput,
|
|
169
195
|
dependsOn,
|
|
170
196
|
version: 0,
|
|
171
197
|
el,
|
|
172
198
|
abortController: null,
|
|
199
|
+
persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
|
|
173
200
|
});
|
|
174
201
|
}
|
|
175
202
|
}
|
|
@@ -229,6 +256,10 @@ function createFragmentCoordinator(options = {}) {
|
|
|
229
256
|
) {
|
|
230
257
|
inst.input = fragmentPayload.input;
|
|
231
258
|
inst.el.setAttribute('data-w-fragment-input', JSON.stringify(fragmentPayload.input));
|
|
259
|
+
if (inst.persistInput) {
|
|
260
|
+
const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
|
|
261
|
+
store.set(inst.persistInput.key, fragmentPayload.input);
|
|
262
|
+
}
|
|
232
263
|
}
|
|
233
264
|
|
|
234
265
|
if (datastar && typeof datastar.patchFragmentElement === 'function') {
|
|
@@ -266,7 +297,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
266
297
|
* Execute a network refresh for a list of target fragment descriptors:
|
|
267
298
|
* `Array<{ instanceId, name, pagePath, input, reqVersion }>`
|
|
268
299
|
*/
|
|
269
|
-
async function executeBatchRefresh(targets, chainIds = [], abortSignal = null) {
|
|
300
|
+
async function executeBatchRefresh(targets, chainIds = [], abortSignal = null, batchTimeout = defaultTimeout) {
|
|
270
301
|
if (!targets || targets.length === 0) {
|
|
271
302
|
return { refreshed: [], skipped: true };
|
|
272
303
|
}
|
|
@@ -312,6 +343,27 @@ function createFragmentCoordinator(options = {}) {
|
|
|
312
343
|
});
|
|
313
344
|
}
|
|
314
345
|
|
|
346
|
+
const batchAbortController = new AbortController();
|
|
347
|
+
let timeoutTimer = null;
|
|
348
|
+
let timedOut = false;
|
|
349
|
+
const timeoutMs = typeof batchTimeout === 'number' ? batchTimeout : defaultTimeout;
|
|
350
|
+
|
|
351
|
+
if (timeoutMs > 0) {
|
|
352
|
+
timeoutTimer = setTimeout(() => {
|
|
353
|
+
timedOut = true;
|
|
354
|
+
batchAbortController.abort();
|
|
355
|
+
}, timeoutMs);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const onCallerAbort = () => batchAbortController.abort();
|
|
359
|
+
if (abortSignal) {
|
|
360
|
+
if (abortSignal.aborted) {
|
|
361
|
+
batchAbortController.abort();
|
|
362
|
+
} else {
|
|
363
|
+
abortSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
315
367
|
let response;
|
|
316
368
|
try {
|
|
317
369
|
response = await fetchImpl(endpoint, {
|
|
@@ -319,11 +371,45 @@ function createFragmentCoordinator(options = {}) {
|
|
|
319
371
|
headers,
|
|
320
372
|
body: JSON.stringify(requestPayload),
|
|
321
373
|
credentials: 'same-origin',
|
|
322
|
-
signal:
|
|
374
|
+
signal: batchAbortController.signal,
|
|
323
375
|
});
|
|
324
376
|
} catch (err) {
|
|
377
|
+
if (timeoutTimer) {
|
|
378
|
+
clearTimeout(timeoutTimer);
|
|
379
|
+
timeoutTimer = null;
|
|
380
|
+
}
|
|
381
|
+
if (abortSignal) {
|
|
382
|
+
try {
|
|
383
|
+
abortSignal.removeEventListener('abort', onCallerAbort);
|
|
384
|
+
} catch (_e) {}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (timedOut) {
|
|
388
|
+
if (debug) {
|
|
389
|
+
debug.trackRequestEnd(reqId, {
|
|
390
|
+
status: 'failed',
|
|
391
|
+
errorKind: 'timeout',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const timeoutError = new Error(`Fragment refresh timed out after ${timeoutMs}ms.`);
|
|
395
|
+
timeoutError.code = 'FRAGMENT_REFRESH_TIMEOUT';
|
|
396
|
+
timeoutError.isTimedOut = true;
|
|
397
|
+
lastFailedBatch = { targets, chainIds };
|
|
398
|
+
for (const cid of chainIds) {
|
|
399
|
+
if (debug) {
|
|
400
|
+
debug.recordChainRefresh(cid, {
|
|
401
|
+
fragmentIds: targets.map((t) => t.instanceId),
|
|
402
|
+
status: 'failed',
|
|
403
|
+
error: timeoutError,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
throw timeoutError;
|
|
408
|
+
}
|
|
409
|
+
|
|
325
410
|
const isAbort =
|
|
326
411
|
(abortSignal && abortSignal.aborted) ||
|
|
412
|
+
batchAbortController.signal.aborted ||
|
|
327
413
|
(err && (err.name === 'AbortError' || err.code === 'ABORT_ERR'));
|
|
328
414
|
|
|
329
415
|
if (debug) {
|
|
@@ -333,7 +419,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
333
419
|
});
|
|
334
420
|
}
|
|
335
421
|
if (isAbort) {
|
|
336
|
-
return { refreshed: [], canceled: true };
|
|
422
|
+
return { ok: false, refreshed: [], canceled: true };
|
|
337
423
|
}
|
|
338
424
|
lastFailedBatch = { targets, chainIds };
|
|
339
425
|
for (const cid of chainIds) {
|
|
@@ -346,6 +432,16 @@ function createFragmentCoordinator(options = {}) {
|
|
|
346
432
|
}
|
|
347
433
|
}
|
|
348
434
|
throw err;
|
|
435
|
+
} finally {
|
|
436
|
+
if (timeoutTimer) {
|
|
437
|
+
clearTimeout(timeoutTimer);
|
|
438
|
+
timeoutTimer = null;
|
|
439
|
+
}
|
|
440
|
+
if (abortSignal) {
|
|
441
|
+
try {
|
|
442
|
+
abortSignal.removeEventListener('abort', onCallerAbort);
|
|
443
|
+
} catch (_e) {}
|
|
444
|
+
}
|
|
349
445
|
}
|
|
350
446
|
|
|
351
447
|
let body = null;
|
|
@@ -429,6 +525,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
429
525
|
}
|
|
430
526
|
|
|
431
527
|
return {
|
|
528
|
+
ok: true,
|
|
432
529
|
refreshed: refreshedIds,
|
|
433
530
|
errors: [],
|
|
434
531
|
timings: {
|
|
@@ -452,7 +549,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
452
549
|
}
|
|
453
550
|
|
|
454
551
|
if (pendingTags.size === 0) {
|
|
455
|
-
return { refreshed: [], skipped: true };
|
|
552
|
+
return { ok: true, refreshed: [], skipped: true };
|
|
456
553
|
}
|
|
457
554
|
|
|
458
555
|
const tagsToFlush = new Set(pendingTags);
|
|
@@ -487,7 +584,7 @@ function createFragmentCoordinator(options = {}) {
|
|
|
487
584
|
});
|
|
488
585
|
}
|
|
489
586
|
}
|
|
490
|
-
return { refreshed: [], skipped: true };
|
|
587
|
+
return { ok: true, refreshed: [], skipped: true };
|
|
491
588
|
}
|
|
492
589
|
|
|
493
590
|
// Parent/Child deduplication: if parent & child are both invalidated, parent refresh covers child
|
|
@@ -602,7 +699,16 @@ function createFragmentCoordinator(options = {}) {
|
|
|
602
699
|
}
|
|
603
700
|
|
|
604
701
|
if (matched.length === 0) {
|
|
605
|
-
|
|
702
|
+
const skippedRes = { ok: true, refreshed: [], skipped: true };
|
|
703
|
+
if (typeof refreshOpts.onSuccess === 'function') {
|
|
704
|
+
await refreshOpts.onSuccess(skippedRes);
|
|
705
|
+
}
|
|
706
|
+
if (typeof refreshOpts.onSettled === 'function') {
|
|
707
|
+
await refreshOpts.onSettled(skippedRes, null);
|
|
708
|
+
} else if (typeof refreshOpts.onComplete === 'function') {
|
|
709
|
+
await refreshOpts.onComplete(skippedRes, null);
|
|
710
|
+
}
|
|
711
|
+
return skippedRes;
|
|
606
712
|
}
|
|
607
713
|
|
|
608
714
|
const deduped = deduplicateParentChildFragments(matched);
|
|
@@ -627,6 +733,10 @@ function createFragmentCoordinator(options = {}) {
|
|
|
627
733
|
if (inst.el && typeof inst.el.setAttribute === 'function') {
|
|
628
734
|
inst.el.setAttribute('data-w-fragment-input', JSON.stringify(inst.input));
|
|
629
735
|
}
|
|
736
|
+
if (inst.persistInput) {
|
|
737
|
+
const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
|
|
738
|
+
store.set(inst.persistInput.key, inst.input);
|
|
739
|
+
}
|
|
630
740
|
}
|
|
631
741
|
|
|
632
742
|
return {
|
|
@@ -638,7 +748,45 @@ function createFragmentCoordinator(options = {}) {
|
|
|
638
748
|
};
|
|
639
749
|
});
|
|
640
750
|
|
|
641
|
-
|
|
751
|
+
try {
|
|
752
|
+
const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
|
|
753
|
+
const enrichedResult = { ok: true, ...result };
|
|
754
|
+
if (!enrichedResult.canceled && enrichedResult.ok !== false) {
|
|
755
|
+
if (typeof refreshOpts.onSuccess === 'function') {
|
|
756
|
+
await refreshOpts.onSuccess(enrichedResult);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (typeof refreshOpts.onSettled === 'function') {
|
|
760
|
+
await refreshOpts.onSettled(enrichedResult, null);
|
|
761
|
+
} else if (typeof refreshOpts.onComplete === 'function') {
|
|
762
|
+
await refreshOpts.onComplete(enrichedResult, null);
|
|
763
|
+
}
|
|
764
|
+
return enrichedResult;
|
|
765
|
+
} catch (err) {
|
|
766
|
+
const failHandler = refreshOpts.onFailure || refreshOpts.onError;
|
|
767
|
+
if (typeof failHandler === 'function') {
|
|
768
|
+
try {
|
|
769
|
+
await failHandler(err);
|
|
770
|
+
} catch (callbackErr) {
|
|
771
|
+
throw callbackErr;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (typeof refreshOpts.onSettled === 'function') {
|
|
775
|
+
await refreshOpts.onSettled(null, err);
|
|
776
|
+
} else if (typeof refreshOpts.onComplete === 'function') {
|
|
777
|
+
await refreshOpts.onComplete(null, err);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (refreshOpts.throwOnError !== true && (typeof failHandler === 'function' || refreshOpts.throwOnError === false)) {
|
|
781
|
+
return {
|
|
782
|
+
ok: false,
|
|
783
|
+
error: err,
|
|
784
|
+
refreshed: [],
|
|
785
|
+
timings: { loadMs: 0, renderMs: 0 },
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
throw err;
|
|
789
|
+
}
|
|
642
790
|
}
|
|
643
791
|
|
|
644
792
|
/**
|
|
@@ -722,6 +870,14 @@ function createFragmentCoordinator(options = {}) {
|
|
|
722
870
|
syncDomInstances();
|
|
723
871
|
return Array.from(instances.values());
|
|
724
872
|
},
|
|
873
|
+
getStoredInput(instanceIdOrName, storageType = 'session') {
|
|
874
|
+
const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
|
|
875
|
+
return store.get(`frag_input:${instanceIdOrName}`, null);
|
|
876
|
+
},
|
|
877
|
+
clearStoredInput(instanceIdOrName, storageType = 'session') {
|
|
878
|
+
const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
|
|
879
|
+
return store.remove(`frag_input:${instanceIdOrName}`);
|
|
880
|
+
},
|
|
725
881
|
get hasFailedRefresh() {
|
|
726
882
|
return Boolean(lastFailedBatch);
|
|
727
883
|
},
|
package/core/client/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
ClientRequestErrorKind,
|
|
4
4
|
ClientRequestError,
|
|
5
5
|
ClientRequestCallOptions,
|
|
6
|
+
ClientRetryOptions,
|
|
6
7
|
ClientApiClient,
|
|
7
8
|
AppApiRouteMap,
|
|
8
9
|
ClientTask,
|
|
@@ -11,12 +12,23 @@ export {
|
|
|
11
12
|
ClientFormHandle,
|
|
12
13
|
ClientFormFillOptions,
|
|
13
14
|
ControllerFormsRegistry,
|
|
15
|
+
FragmentRefreshResult,
|
|
16
|
+
FragmentRefreshOptions,
|
|
14
17
|
ControllerFragmentHandle,
|
|
15
18
|
ControllerContext,
|
|
19
|
+
ControllerErrorMeta,
|
|
20
|
+
ControllerElement,
|
|
21
|
+
ControllerErrorHandler,
|
|
16
22
|
ControllerActionHandler,
|
|
17
23
|
ControllerComputedGetter,
|
|
18
24
|
ControllerTaskFactory,
|
|
19
25
|
ControllerDefinition,
|
|
26
|
+
WatchSource,
|
|
27
|
+
WatchCleanupCallback,
|
|
28
|
+
WatchCallback,
|
|
29
|
+
WatchOptions,
|
|
30
|
+
WatchDescriptor,
|
|
31
|
+
watch,
|
|
20
32
|
defineController,
|
|
21
33
|
deriveControllerNameFromPath,
|
|
22
34
|
FragmentLoaderContext,
|
|
@@ -36,5 +48,12 @@ export {
|
|
|
36
48
|
bootLiteClient,
|
|
37
49
|
createDatastarAdapter,
|
|
38
50
|
createClientDebugger,
|
|
51
|
+
StorageAdapter,
|
|
52
|
+
ClientStorage,
|
|
53
|
+
ControllerPersistConfig,
|
|
54
|
+
createClientStorage,
|
|
55
|
+
defaultStorage,
|
|
56
|
+
ClientDevtoolHandle,
|
|
57
|
+
mountDevtool,
|
|
39
58
|
} from '../../index';
|
|
40
59
|
|
package/core/client/index.js
CHANGED
|
@@ -38,6 +38,8 @@ const {
|
|
|
38
38
|
createControllerRuntime,
|
|
39
39
|
queryScopedTargets,
|
|
40
40
|
isTargetInControllerScope,
|
|
41
|
+
watch,
|
|
42
|
+
getActiveControllerContext,
|
|
41
43
|
} = require('./controller');
|
|
42
44
|
|
|
43
45
|
const {
|
|
@@ -60,6 +62,20 @@ const {
|
|
|
60
62
|
summarizePayloadSafely,
|
|
61
63
|
} = require('./debug');
|
|
62
64
|
|
|
65
|
+
const {
|
|
66
|
+
MemoryStorage,
|
|
67
|
+
StorageAdapter,
|
|
68
|
+
getNativeStorageBackend,
|
|
69
|
+
createClientStorage,
|
|
70
|
+
defaultStorage,
|
|
71
|
+
} = require('./storage');
|
|
72
|
+
|
|
73
|
+
const {
|
|
74
|
+
mountDevtool,
|
|
75
|
+
highlightElement,
|
|
76
|
+
setupReduxDevToolsBridge,
|
|
77
|
+
} = require('./devtool');
|
|
78
|
+
|
|
63
79
|
let _serverFragments = null;
|
|
64
80
|
function getServerFragments() {
|
|
65
81
|
if (!_serverFragments) {
|
|
@@ -217,6 +233,7 @@ function createClientRuntime(options = {}) {
|
|
|
217
233
|
getController: (el, name) => controllers.getInstanceForElement(el, name),
|
|
218
234
|
invalidate: (tags, meta) => fragments.invalidate(tags, meta),
|
|
219
235
|
refresh: (idOrName, input, opts) => fragments.refresh(idOrName, input, opts),
|
|
236
|
+
mountDevtool: (opts) => mountDevtool(runtime, opts),
|
|
220
237
|
forms: {
|
|
221
238
|
parse: parseFormElement,
|
|
222
239
|
create: (formEl, formOpts = {}) =>
|
|
@@ -241,11 +258,26 @@ function createClientRuntime(options = {}) {
|
|
|
241
258
|
*/
|
|
242
259
|
function bootClient(options = {}) {
|
|
243
260
|
const runtime = createClientRuntime(options);
|
|
261
|
+
const shouldMountDevtools =
|
|
262
|
+
options.devtools === true ||
|
|
263
|
+
(options.devtools !== false &&
|
|
264
|
+
(options.debug ||
|
|
265
|
+
options.dev ||
|
|
266
|
+
(typeof window !== 'undefined' &&
|
|
267
|
+
(window.__WEBSPRESSO_DEVTOOLS_ENABLED__ || window.__WEBSPRESSO_DEV__)) ||
|
|
268
|
+
(typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production')));
|
|
269
|
+
|
|
244
270
|
if (typeof document !== 'undefined') {
|
|
271
|
+
const onReady = () => {
|
|
272
|
+
runtime.mount();
|
|
273
|
+
if (shouldMountDevtools) {
|
|
274
|
+
runtime.devtool = mountDevtool(runtime, options.devtoolsOptions || {});
|
|
275
|
+
}
|
|
276
|
+
};
|
|
245
277
|
if (document.readyState === 'loading') {
|
|
246
|
-
document.addEventListener('DOMContentLoaded',
|
|
278
|
+
document.addEventListener('DOMContentLoaded', onReady, { once: true });
|
|
247
279
|
} else {
|
|
248
|
-
|
|
280
|
+
onReady();
|
|
249
281
|
}
|
|
250
282
|
}
|
|
251
283
|
return runtime;
|
|
@@ -266,6 +298,8 @@ module.exports = {
|
|
|
266
298
|
createControllerRuntime,
|
|
267
299
|
queryScopedTargets,
|
|
268
300
|
isTargetInControllerScope,
|
|
301
|
+
watch,
|
|
302
|
+
getActiveControllerContext,
|
|
269
303
|
|
|
270
304
|
// Typed API Client & Request Management
|
|
271
305
|
ClientRequestError,
|
|
@@ -340,8 +374,18 @@ module.exports = {
|
|
|
340
374
|
morphDomTrees,
|
|
341
375
|
parseHtmlFragment,
|
|
342
376
|
|
|
343
|
-
// Debug & Telemetry
|
|
377
|
+
// Debug & Telemetry & DevTools
|
|
344
378
|
createClientDebugger,
|
|
345
379
|
summarizePayloadSafely,
|
|
380
|
+
mountDevtool,
|
|
381
|
+
highlightElement,
|
|
382
|
+
setupReduxDevToolsBridge,
|
|
383
|
+
|
|
384
|
+
// Client Storage & Persistence
|
|
385
|
+
MemoryStorage,
|
|
386
|
+
StorageAdapter,
|
|
387
|
+
getNativeStorageBackend,
|
|
388
|
+
createClientStorage,
|
|
389
|
+
defaultStorage,
|
|
346
390
|
};
|
|
347
391
|
|
package/core/client/lite.js
CHANGED
|
@@ -36,6 +36,13 @@ const {
|
|
|
36
36
|
deduplicateParentChildFragments,
|
|
37
37
|
} = require('./fragments');
|
|
38
38
|
|
|
39
|
+
const {
|
|
40
|
+
createClientStorage,
|
|
41
|
+
defaultStorage,
|
|
42
|
+
StorageAdapter,
|
|
43
|
+
MemoryStorage,
|
|
44
|
+
} = require('./storage');
|
|
45
|
+
|
|
39
46
|
const { NOOP_DEBUGGER } = require('./utils');
|
|
40
47
|
|
|
41
48
|
let _serverFragments = null;
|
|
@@ -160,5 +167,9 @@ module.exports = {
|
|
|
160
167
|
apiResult: (data, meta) => getServerFragments().apiResult(data, meta),
|
|
161
168
|
isApiResultEnvelope: (val) => getServerFragments().isApiResultEnvelope(val),
|
|
162
169
|
defineFragment: (def) => getServerFragments().defineFragment(def),
|
|
170
|
+
createClientStorage,
|
|
171
|
+
defaultStorage,
|
|
172
|
+
StorageAdapter,
|
|
173
|
+
MemoryStorage,
|
|
163
174
|
};
|
|
164
175
|
|