pulse-js-framework 1.7.2 → 1.7.3
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/package.json +8 -2
- package/runtime/async.js +619 -0
- package/runtime/devtools.js +619 -0
- package/runtime/dom.js +254 -40
- package/runtime/form.js +659 -0
- package/runtime/pulse.js +36 -3
- package/runtime/router.js +51 -5
- package/runtime/store.js +45 -0
package/runtime/router.js
CHANGED
|
@@ -23,6 +23,9 @@ const log = loggers.router;
|
|
|
23
23
|
* Lazy load helper for route components
|
|
24
24
|
* Wraps a dynamic import to provide loading states and error handling
|
|
25
25
|
*
|
|
26
|
+
* MEMORY SAFETY: Uses load version tracking to prevent stale promise callbacks
|
|
27
|
+
* from updating containers that are no longer in the DOM (e.g., after navigation).
|
|
28
|
+
*
|
|
26
29
|
* @param {function} importFn - Dynamic import function () => import('./Component.js')
|
|
27
30
|
* @param {Object} options - Lazy loading options
|
|
28
31
|
* @param {function} options.loading - Loading component function
|
|
@@ -52,6 +55,8 @@ export function lazy(importFn, options = {}) {
|
|
|
52
55
|
// Cache for loaded component
|
|
53
56
|
let cachedComponent = null;
|
|
54
57
|
let loadPromise = null;
|
|
58
|
+
// Version counter to invalidate stale load callbacks
|
|
59
|
+
let currentLoadVersion = 0;
|
|
55
60
|
|
|
56
61
|
return function lazyHandler(ctx) {
|
|
57
62
|
// Return cached component if already loaded
|
|
@@ -69,6 +74,23 @@ export function lazy(importFn, options = {}) {
|
|
|
69
74
|
const container = el('div.lazy-route');
|
|
70
75
|
let loadingTimer = null;
|
|
71
76
|
let timeoutTimer = null;
|
|
77
|
+
let isAborted = false;
|
|
78
|
+
|
|
79
|
+
// Increment version and capture for this load attempt
|
|
80
|
+
const loadVersion = ++currentLoadVersion;
|
|
81
|
+
|
|
82
|
+
// Helper to check if this load is still valid
|
|
83
|
+
const isStale = () => isAborted || loadVersion !== currentLoadVersion;
|
|
84
|
+
|
|
85
|
+
// Cleanup function to abort this load attempt
|
|
86
|
+
const abort = () => {
|
|
87
|
+
isAborted = true;
|
|
88
|
+
clearTimeout(loadingTimer);
|
|
89
|
+
clearTimeout(timeoutTimer);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Attach abort method to container for cleanup on navigation
|
|
93
|
+
container._pulseAbortLazyLoad = abort;
|
|
72
94
|
|
|
73
95
|
// Start loading if not already
|
|
74
96
|
if (!loadPromise) {
|
|
@@ -78,7 +100,7 @@ export function lazy(importFn, options = {}) {
|
|
|
78
100
|
// Delay showing loading state to avoid flash
|
|
79
101
|
if (LoadingComponent && delay > 0) {
|
|
80
102
|
loadingTimer = setTimeout(() => {
|
|
81
|
-
if (!cachedComponent) {
|
|
103
|
+
if (!cachedComponent && !isStale()) {
|
|
82
104
|
container.replaceChildren(LoadingComponent());
|
|
83
105
|
}
|
|
84
106
|
}, delay);
|
|
@@ -105,6 +127,11 @@ export function lazy(importFn, options = {}) {
|
|
|
105
127
|
clearTimeout(loadingTimer);
|
|
106
128
|
clearTimeout(timeoutTimer);
|
|
107
129
|
|
|
130
|
+
// Ignore if this load attempt is stale (navigation occurred)
|
|
131
|
+
if (isStale()) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
// Cache the component
|
|
109
136
|
cachedComponent = module;
|
|
110
137
|
|
|
@@ -117,7 +144,7 @@ export function lazy(importFn, options = {}) {
|
|
|
117
144
|
: Component;
|
|
118
145
|
|
|
119
146
|
// Replace loading with actual component
|
|
120
|
-
if (result instanceof Node) {
|
|
147
|
+
if (result instanceof Node && !isStale()) {
|
|
121
148
|
container.replaceChildren(result);
|
|
122
149
|
}
|
|
123
150
|
})
|
|
@@ -126,6 +153,11 @@ export function lazy(importFn, options = {}) {
|
|
|
126
153
|
clearTimeout(timeoutTimer);
|
|
127
154
|
loadPromise = null; // Allow retry
|
|
128
155
|
|
|
156
|
+
// Ignore if this load attempt is stale
|
|
157
|
+
if (isStale()) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
129
161
|
if (ErrorComponent) {
|
|
130
162
|
container.replaceChildren(ErrorComponent(err));
|
|
131
163
|
} else {
|
|
@@ -432,6 +464,12 @@ const QUERY_LIMITS = {
|
|
|
432
464
|
|
|
433
465
|
/**
|
|
434
466
|
* Parse query string into object with validation
|
|
467
|
+
*
|
|
468
|
+
* SECURITY: Enforces hard limits BEFORE parsing to prevent DoS attacks.
|
|
469
|
+
* - Max total length: 2KB
|
|
470
|
+
* - Max value length: 1KB
|
|
471
|
+
* - Max parameters: 50
|
|
472
|
+
*
|
|
435
473
|
* @param {string} search - Query string (with or without leading ?)
|
|
436
474
|
* @returns {Object} Parsed query parameters
|
|
437
475
|
*/
|
|
@@ -439,14 +477,15 @@ function parseQuery(search) {
|
|
|
439
477
|
if (!search) return {};
|
|
440
478
|
|
|
441
479
|
// Remove leading ? if present
|
|
442
|
-
|
|
480
|
+
let queryStr = search.startsWith('?') ? search.slice(1) : search;
|
|
443
481
|
|
|
444
|
-
//
|
|
482
|
+
// SECURITY: Enforce hard limit BEFORE parsing to prevent DoS
|
|
445
483
|
if (queryStr.length > QUERY_LIMITS.maxTotalLength) {
|
|
446
484
|
log.warn(`Query string exceeds maximum length (${QUERY_LIMITS.maxTotalLength} chars). Truncating.`);
|
|
485
|
+
queryStr = queryStr.slice(0, QUERY_LIMITS.maxTotalLength);
|
|
447
486
|
}
|
|
448
487
|
|
|
449
|
-
const params = new URLSearchParams(queryStr
|
|
488
|
+
const params = new URLSearchParams(queryStr);
|
|
450
489
|
const query = {};
|
|
451
490
|
let paramCount = 0;
|
|
452
491
|
|
|
@@ -808,6 +847,9 @@ export function createRouter(options = {}) {
|
|
|
808
847
|
|
|
809
848
|
/**
|
|
810
849
|
* Router outlet - renders the current route's component
|
|
850
|
+
*
|
|
851
|
+
* MEMORY SAFETY: Aborts any pending lazy loads when navigating away
|
|
852
|
+
* to prevent stale callbacks from updating the DOM.
|
|
811
853
|
*/
|
|
812
854
|
function outlet(container) {
|
|
813
855
|
if (typeof container === 'string') {
|
|
@@ -825,6 +867,10 @@ export function createRouter(options = {}) {
|
|
|
825
867
|
// Cleanup previous view
|
|
826
868
|
if (cleanup) cleanup();
|
|
827
869
|
if (currentView) {
|
|
870
|
+
// Abort any pending lazy loads before removing the view
|
|
871
|
+
if (currentView._pulseAbortLazyLoad) {
|
|
872
|
+
currentView._pulseAbortLazyLoad();
|
|
873
|
+
}
|
|
828
874
|
container.replaceChildren();
|
|
829
875
|
}
|
|
830
876
|
|
package/runtime/store.js
CHANGED
|
@@ -108,9 +108,51 @@ function validateStateValue(value, path = 'state', seen = new WeakSet()) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Recursively sanitize a value, removing dangerous keys from nested objects.
|
|
113
|
+
* @private
|
|
114
|
+
* @param {*} value - Value to sanitize
|
|
115
|
+
* @param {number} depth - Current nesting depth
|
|
116
|
+
* @returns {*} Sanitized value
|
|
117
|
+
*/
|
|
118
|
+
function sanitizeValue(value, depth = 0) {
|
|
119
|
+
// Prevent deep nesting attacks
|
|
120
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
121
|
+
log.warn('Maximum nesting depth exceeded in persisted state');
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (value === null || typeof value !== 'object') {
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
// Recursively sanitize array elements
|
|
131
|
+
return value.map(item => sanitizeValue(item, depth + 1));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sanitize object
|
|
135
|
+
const result = {};
|
|
136
|
+
for (const [key, val] of Object.entries(value)) {
|
|
137
|
+
// Block dangerous keys at every nesting level
|
|
138
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
139
|
+
log.warn(`Blocked dangerous key in persisted state: "${key}"`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
result[key] = sanitizeValue(val, depth + 1);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
/**
|
|
112
148
|
* Safely deserialize persisted state, preventing prototype pollution
|
|
113
149
|
* and property injection attacks.
|
|
150
|
+
*
|
|
151
|
+
* SECURITY: Validates at every nesting level including arrays.
|
|
152
|
+
* - Blocks __proto__, constructor, prototype keys
|
|
153
|
+
* - Enforces maximum nesting depth
|
|
154
|
+
* - Only allows keys defined in schema
|
|
155
|
+
*
|
|
114
156
|
* @private
|
|
115
157
|
* @param {Object} savedState - The parsed JSON state
|
|
116
158
|
* @param {Object} schema - The initial state defining allowed keys
|
|
@@ -140,6 +182,9 @@ function safeDeserialize(savedState, schema) {
|
|
|
140
182
|
result[key] = safeDeserialize(value, schema[key]);
|
|
141
183
|
}
|
|
142
184
|
// If schema expects primitive but got object, skip it
|
|
185
|
+
} else if (Array.isArray(value)) {
|
|
186
|
+
// Sanitize arrays to remove dangerous keys from nested objects
|
|
187
|
+
result[key] = sanitizeValue(value, 0);
|
|
143
188
|
} else {
|
|
144
189
|
result[key] = value;
|
|
145
190
|
}
|