pulse-js-framework 1.7.5 → 1.7.8
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 +78 -392
- package/cli/dev.js +14 -0
- package/cli/docs-test.js +633 -0
- package/cli/index.js +313 -31
- package/cli/lint.js +13 -4
- package/cli/logger.js +32 -4
- package/cli/release.js +50 -20
- package/compiler/parser.js +1 -1
- package/package.json +11 -4
- package/runtime/dom-advanced.js +357 -0
- package/runtime/dom-binding.js +230 -0
- package/runtime/dom-conditional.js +133 -0
- package/runtime/dom-element.js +142 -0
- package/runtime/dom-lifecycle.js +178 -0
- package/runtime/dom-list.js +267 -0
- package/runtime/dom-selector.js +267 -0
- package/runtime/dom.js +119 -1279
- package/runtime/form.js +417 -22
- package/runtime/native.js +398 -52
- package/runtime/pulse.js +1 -1
- package/runtime/router.js +6 -5
- package/runtime/store.js +81 -6
- package/types/async.d.ts +310 -0
- package/types/form.d.ts +378 -0
- package/types/index.d.ts +44 -0
- /package/{core → runtime}/errors.js +0 -0
package/runtime/native.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Pulse Native Runtime Module
|
|
3
3
|
* Reactive wrappers for native mobile APIs
|
|
4
4
|
* Integrates with Pulse reactivity system
|
|
5
|
+
*
|
|
6
|
+
* Security: Bridge validation ensures only trusted PulseMobile bridges are accepted.
|
|
7
|
+
* Validates version compatibility, required API surface, and optional signatures.
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
import { pulse, effect, batch } from './pulse.js';
|
|
@@ -9,24 +12,369 @@ import { loggers } from './logger.js';
|
|
|
9
12
|
|
|
10
13
|
const log = loggers.native;
|
|
11
14
|
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Bridge Security - Version and Signature Validation
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimum supported bridge version (semver)
|
|
21
|
+
* Bridges below this version will be rejected
|
|
22
|
+
*/
|
|
23
|
+
const MIN_BRIDGE_VERSION = '1.0.0';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum supported bridge version (semver)
|
|
27
|
+
* Set to null to allow any version >= MIN_BRIDGE_VERSION
|
|
28
|
+
*/
|
|
29
|
+
const MAX_BRIDGE_VERSION = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Required API surface that a valid PulseMobile bridge must expose
|
|
33
|
+
* Missing any of these will cause validation to fail
|
|
34
|
+
*/
|
|
35
|
+
const REQUIRED_BRIDGE_API = {
|
|
36
|
+
// Root properties
|
|
37
|
+
root: ['platform', 'isNative', 'version'],
|
|
38
|
+
// Storage API
|
|
39
|
+
Storage: ['getItem', 'setItem', 'removeItem', 'keys'],
|
|
40
|
+
// Device API
|
|
41
|
+
Device: ['getInfo', 'getNetworkStatus', 'onNetworkChange'],
|
|
42
|
+
// UI API
|
|
43
|
+
UI: ['showToast', 'vibrate'],
|
|
44
|
+
// App API
|
|
45
|
+
App: ['onPause', 'onResume', 'onBackButton', 'minimize'],
|
|
46
|
+
// Clipboard API
|
|
47
|
+
Clipboard: ['copy', 'read']
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cached validation result to avoid repeated checks
|
|
52
|
+
* @type {boolean|null}
|
|
53
|
+
*/
|
|
54
|
+
let _validationCache = null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cached validation error message
|
|
58
|
+
* @type {string|null}
|
|
59
|
+
*/
|
|
60
|
+
let _validationError = null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse semver version string to comparable array
|
|
64
|
+
* @param {string} version - Semver string (e.g., "1.2.3")
|
|
65
|
+
* @returns {number[]} Array of [major, minor, patch]
|
|
66
|
+
*/
|
|
67
|
+
function _parseSemver(version) {
|
|
68
|
+
if (!version || typeof version !== 'string') return [0, 0, 0];
|
|
69
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
70
|
+
if (!match) return [0, 0, 0];
|
|
71
|
+
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compare two semver versions
|
|
76
|
+
* @param {string} a - First version
|
|
77
|
+
* @param {string} b - Second version
|
|
78
|
+
* @returns {number} -1 if a < b, 0 if a == b, 1 if a > b
|
|
79
|
+
*/
|
|
80
|
+
function _compareSemver(a, b) {
|
|
81
|
+
const [aMajor, aMinor, aPatch] = _parseSemver(a);
|
|
82
|
+
const [bMajor, bMinor, bPatch] = _parseSemver(b);
|
|
83
|
+
|
|
84
|
+
if (aMajor !== bMajor) return aMajor < bMajor ? -1 : 1;
|
|
85
|
+
if (aMinor !== bMinor) return aMinor < bMinor ? -1 : 1;
|
|
86
|
+
if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1;
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
12
90
|
/**
|
|
13
|
-
*
|
|
91
|
+
* Validate that an object has all required methods/properties
|
|
92
|
+
* @param {Object} obj - Object to validate
|
|
93
|
+
* @param {string[]} required - Required property names
|
|
94
|
+
* @returns {{valid: boolean, missing: string[]}}
|
|
95
|
+
*/
|
|
96
|
+
function _validateApiSurface(obj, required) {
|
|
97
|
+
if (!obj || typeof obj !== 'object') {
|
|
98
|
+
return { valid: false, missing: required };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const missing = [];
|
|
102
|
+
for (const prop of required) {
|
|
103
|
+
if (!(prop in obj)) {
|
|
104
|
+
missing.push(prop);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { valid: missing.length === 0, missing };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validate the PulseMobile bridge for security
|
|
113
|
+
* Checks version compatibility and required API surface
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} bridge - The PulseMobile bridge object
|
|
116
|
+
* @returns {{valid: boolean, error: string|null, warnings: string[]}}
|
|
117
|
+
*/
|
|
118
|
+
function _validateBridge(bridge) {
|
|
119
|
+
const warnings = [];
|
|
120
|
+
|
|
121
|
+
// 1. Check bridge exists and is an object
|
|
122
|
+
if (!bridge || typeof bridge !== 'object') {
|
|
123
|
+
return {
|
|
124
|
+
valid: false,
|
|
125
|
+
error: 'PulseMobile bridge is not a valid object',
|
|
126
|
+
warnings
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2. Validate version exists and is compatible
|
|
131
|
+
const version = bridge.version;
|
|
132
|
+
if (!version || typeof version !== 'string') {
|
|
133
|
+
return {
|
|
134
|
+
valid: false,
|
|
135
|
+
error: 'PulseMobile bridge missing version property',
|
|
136
|
+
warnings
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check minimum version
|
|
141
|
+
if (_compareSemver(version, MIN_BRIDGE_VERSION) < 0) {
|
|
142
|
+
return {
|
|
143
|
+
valid: false,
|
|
144
|
+
error: `PulseMobile bridge version ${version} is below minimum required ${MIN_BRIDGE_VERSION}`,
|
|
145
|
+
warnings
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check maximum version (if set)
|
|
150
|
+
if (MAX_BRIDGE_VERSION && _compareSemver(version, MAX_BRIDGE_VERSION) > 0) {
|
|
151
|
+
return {
|
|
152
|
+
valid: false,
|
|
153
|
+
error: `PulseMobile bridge version ${version} exceeds maximum supported ${MAX_BRIDGE_VERSION}`,
|
|
154
|
+
warnings
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 3. Validate required root properties
|
|
159
|
+
const rootValidation = _validateApiSurface(bridge, REQUIRED_BRIDGE_API.root, 'root');
|
|
160
|
+
if (!rootValidation.valid) {
|
|
161
|
+
return {
|
|
162
|
+
valid: false,
|
|
163
|
+
error: `PulseMobile bridge missing required properties: ${rootValidation.missing.join(', ')}`,
|
|
164
|
+
warnings
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4. Validate platform value
|
|
169
|
+
const validPlatforms = ['ios', 'android'];
|
|
170
|
+
if (!validPlatforms.includes(bridge.platform)) {
|
|
171
|
+
return {
|
|
172
|
+
valid: false,
|
|
173
|
+
error: `PulseMobile bridge has invalid platform: ${bridge.platform}`,
|
|
174
|
+
warnings
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 5. Validate required API namespaces
|
|
179
|
+
for (const [namespace, methods] of Object.entries(REQUIRED_BRIDGE_API)) {
|
|
180
|
+
if (namespace === 'root') continue;
|
|
181
|
+
|
|
182
|
+
const namespaceObj = bridge[namespace];
|
|
183
|
+
const validation = _validateApiSurface(namespaceObj, methods, namespace);
|
|
184
|
+
|
|
185
|
+
if (!validation.valid) {
|
|
186
|
+
return {
|
|
187
|
+
valid: false,
|
|
188
|
+
error: `PulseMobile.${namespace} missing required methods: ${validation.missing.join(', ')}`,
|
|
189
|
+
warnings
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 6. Validate signature if present (optional enhanced security)
|
|
195
|
+
if (bridge._signature) {
|
|
196
|
+
if (!_verifyBridgeSignature(bridge)) {
|
|
197
|
+
return {
|
|
198
|
+
valid: false,
|
|
199
|
+
error: 'PulseMobile bridge signature verification failed',
|
|
200
|
+
warnings
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
warnings.push('PulseMobile bridge has no signature - running in unsigned mode');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 7. Check for suspicious properties (potential tampering)
|
|
208
|
+
const suspiciousProps = ['eval', 'Function', '__proto__', 'constructor'];
|
|
209
|
+
for (const prop of suspiciousProps) {
|
|
210
|
+
if (prop in bridge && typeof bridge[prop] === 'function') {
|
|
211
|
+
warnings.push(`Suspicious property detected on bridge: ${prop}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { valid: true, error: null, warnings };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Verify bridge signature (for enhanced security)
|
|
220
|
+
* This validates that the bridge was created by a trusted source
|
|
221
|
+
*
|
|
222
|
+
* @param {Object} bridge - The PulseMobile bridge object
|
|
223
|
+
* @returns {boolean} True if signature is valid
|
|
224
|
+
*/
|
|
225
|
+
function _verifyBridgeSignature(bridge) {
|
|
226
|
+
// Signature format: { timestamp, nonce, hash }
|
|
227
|
+
const sig = bridge._signature;
|
|
228
|
+
if (!sig || typeof sig !== 'object') return false;
|
|
229
|
+
|
|
230
|
+
// Validate signature structure
|
|
231
|
+
if (!sig.timestamp || !sig.nonce || !sig.hash) return false;
|
|
232
|
+
|
|
233
|
+
// Check timestamp is recent (within 5 minutes)
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const maxAge = 5 * 60 * 1000; // 5 minutes
|
|
236
|
+
if (Math.abs(now - sig.timestamp) > maxAge) {
|
|
237
|
+
log.warn('Bridge signature timestamp is stale');
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Validate hash format (should be hex string)
|
|
242
|
+
if (typeof sig.hash !== 'string' || !/^[a-f0-9]{64}$/i.test(sig.hash)) {
|
|
243
|
+
log.warn('Bridge signature hash has invalid format');
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Note: Full signature verification would require:
|
|
248
|
+
// 1. A shared secret or public key known to both native app and JS
|
|
249
|
+
// 2. HMAC or RSA signature verification
|
|
250
|
+
// For now, we validate structure and trust the native app environment
|
|
251
|
+
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Clear the validation cache (useful for testing or after bridge changes)
|
|
257
|
+
*/
|
|
258
|
+
export function clearBridgeValidationCache() {
|
|
259
|
+
_validationCache = null;
|
|
260
|
+
_validationError = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get the last bridge validation error (if any)
|
|
265
|
+
* @returns {string|null}
|
|
266
|
+
*/
|
|
267
|
+
export function getBridgeValidationError() {
|
|
268
|
+
return _validationError;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ============================================================================
|
|
272
|
+
// Internal Helpers - Reduce code duplication
|
|
273
|
+
// ============================================================================
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Safely parse JSON, returning the original value if parsing fails
|
|
277
|
+
* @param {string} value - Value to parse
|
|
278
|
+
* @returns {*} Parsed value or original string
|
|
279
|
+
*/
|
|
280
|
+
function _tryParseJson(value) {
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(value);
|
|
283
|
+
} catch {
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Execute a storage operation with native/localStorage fallback
|
|
290
|
+
* @param {Object} options - Operation options
|
|
291
|
+
* @param {Function} options.native - Native storage operation (async)
|
|
292
|
+
* @param {Function} options.web - localStorage fallback operation
|
|
293
|
+
* @returns {Promise<*>} Operation result
|
|
294
|
+
*/
|
|
295
|
+
async function _withStorageFallback({ native, web }) {
|
|
296
|
+
if (isNativeAvailable()) {
|
|
297
|
+
return native(getNative().Storage);
|
|
298
|
+
}
|
|
299
|
+
if (typeof localStorage !== 'undefined') {
|
|
300
|
+
return web(localStorage);
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Execute a storage operation synchronously with native/localStorage fallback
|
|
307
|
+
* @param {Object} options - Operation options
|
|
308
|
+
* @param {Function} options.native - Native storage operation
|
|
309
|
+
* @param {Function} options.web - localStorage fallback operation
|
|
310
|
+
*/
|
|
311
|
+
function _withStorageFallbackSync({ native, web }) {
|
|
312
|
+
if (isNativeAvailable()) {
|
|
313
|
+
native(getNative().Storage);
|
|
314
|
+
} else if (typeof localStorage !== 'undefined') {
|
|
315
|
+
web(localStorage);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ============================================================================
|
|
320
|
+
// Public API
|
|
321
|
+
// ============================================================================
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if PulseMobile bridge is available and valid
|
|
325
|
+
*
|
|
326
|
+
* Security: This function validates the bridge structure, version,
|
|
327
|
+
* and API surface before returning true. Malicious or malformed
|
|
328
|
+
* bridges will be rejected.
|
|
329
|
+
*
|
|
330
|
+
* @returns {boolean} True if a valid PulseMobile bridge is available
|
|
14
331
|
*/
|
|
15
332
|
export function isNativeAvailable() {
|
|
16
|
-
|
|
333
|
+
// Quick check for window and PulseMobile existence
|
|
334
|
+
if (typeof window === 'undefined' || typeof window.PulseMobile === 'undefined') {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Use cached validation result if available
|
|
339
|
+
if (_validationCache !== null) {
|
|
340
|
+
return _validationCache;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Validate the bridge
|
|
344
|
+
const result = _validateBridge(window.PulseMobile);
|
|
345
|
+
|
|
346
|
+
// Log warnings if any
|
|
347
|
+
for (const warning of result.warnings) {
|
|
348
|
+
log.warn(`[Bridge Security] ${warning}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Cache the result
|
|
352
|
+
_validationCache = result.valid;
|
|
353
|
+
_validationError = result.error;
|
|
354
|
+
|
|
355
|
+
// Log error if validation failed
|
|
356
|
+
if (!result.valid) {
|
|
357
|
+
log.error(`[Bridge Security] Bridge validation failed: ${result.error}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return result.valid;
|
|
17
361
|
}
|
|
18
362
|
|
|
19
363
|
/**
|
|
20
|
-
* Get the PulseMobile instance
|
|
364
|
+
* Get the PulseMobile instance (validated)
|
|
365
|
+
*
|
|
366
|
+
* @throws {Error} If bridge is not available or validation failed
|
|
367
|
+
* @returns {Object} The validated PulseMobile bridge
|
|
21
368
|
*/
|
|
22
369
|
export function getNative() {
|
|
23
370
|
if (!isNativeAvailable()) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
371
|
+
const error = _validationError
|
|
372
|
+
? `[Pulse Native] PulseMobile bridge validation failed: ${_validationError}`
|
|
373
|
+
: '[Pulse Native] PulseMobile bridge is not available. ' +
|
|
374
|
+
'This API only works in a Pulse native mobile app. ' +
|
|
375
|
+
'For web, use isNativeAvailable() to check before calling native APIs, ' +
|
|
376
|
+
'or use getPlatform() to detect the current environment.';
|
|
377
|
+
throw new Error(error);
|
|
30
378
|
}
|
|
31
379
|
return window.PulseMobile;
|
|
32
380
|
}
|
|
@@ -69,26 +417,20 @@ export function createNativeStorage(prefix = '') {
|
|
|
69
417
|
cache.set(fullKey, p);
|
|
70
418
|
|
|
71
419
|
// Load initial value from storage
|
|
72
|
-
|
|
73
|
-
|
|
420
|
+
_withStorageFallback({
|
|
421
|
+
native: async (storage) => {
|
|
422
|
+
const value = await storage.getItem(fullKey);
|
|
74
423
|
if (value !== null) {
|
|
75
|
-
|
|
76
|
-
p.set(JSON.parse(value));
|
|
77
|
-
} catch {
|
|
78
|
-
p.set(value);
|
|
79
|
-
}
|
|
424
|
+
p.set(_tryParseJson(value));
|
|
80
425
|
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
p.set(JSON.parse(value));
|
|
87
|
-
} catch {
|
|
88
|
-
p.set(value);
|
|
426
|
+
},
|
|
427
|
+
web: (storage) => {
|
|
428
|
+
const value = storage.getItem(fullKey);
|
|
429
|
+
if (value !== null) {
|
|
430
|
+
p.set(_tryParseJson(value));
|
|
89
431
|
}
|
|
90
432
|
}
|
|
91
|
-
}
|
|
433
|
+
});
|
|
92
434
|
|
|
93
435
|
// Auto-persist on changes
|
|
94
436
|
let initialized = false;
|
|
@@ -100,11 +442,10 @@ export function createNativeStorage(prefix = '') {
|
|
|
100
442
|
return;
|
|
101
443
|
}
|
|
102
444
|
const serialized = JSON.stringify(value);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
445
|
+
_withStorageFallbackSync({
|
|
446
|
+
native: (storage) => storage.setItem(fullKey, serialized),
|
|
447
|
+
web: (storage) => storage.setItem(fullKey, serialized)
|
|
448
|
+
});
|
|
108
449
|
});
|
|
109
450
|
|
|
110
451
|
return p;
|
|
@@ -116,11 +457,10 @@ export function createNativeStorage(prefix = '') {
|
|
|
116
457
|
async remove(key) {
|
|
117
458
|
const fullKey = prefix + key;
|
|
118
459
|
cache.delete(fullKey);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
460
|
+
await _withStorageFallback({
|
|
461
|
+
native: (storage) => storage.removeItem(fullKey),
|
|
462
|
+
web: (storage) => storage.removeItem(fullKey)
|
|
463
|
+
});
|
|
124
464
|
},
|
|
125
465
|
|
|
126
466
|
/**
|
|
@@ -128,25 +468,28 @@ export function createNativeStorage(prefix = '') {
|
|
|
128
468
|
*/
|
|
129
469
|
async clear() {
|
|
130
470
|
cache.clear();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
471
|
+
await _withStorageFallback({
|
|
472
|
+
native: async (storage) => {
|
|
473
|
+
const keys = await storage.keys();
|
|
474
|
+
for (const key of keys) {
|
|
475
|
+
if (key.startsWith(prefix)) {
|
|
476
|
+
await storage.removeItem(key);
|
|
477
|
+
}
|
|
136
478
|
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
479
|
+
},
|
|
480
|
+
web: (storage) => {
|
|
481
|
+
const keysToRemove = [];
|
|
482
|
+
for (let i = 0; i < storage.length; i++) {
|
|
483
|
+
const key = storage.key(i);
|
|
484
|
+
if (key && key.startsWith(prefix)) {
|
|
485
|
+
keysToRemove.push(key);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const key of keysToRemove) {
|
|
489
|
+
storage.removeItem(key);
|
|
144
490
|
}
|
|
145
491
|
}
|
|
146
|
-
|
|
147
|
-
localStorage.removeItem(key);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
492
|
+
});
|
|
150
493
|
}
|
|
151
494
|
};
|
|
152
495
|
}
|
|
@@ -372,5 +715,8 @@ export default {
|
|
|
372
715
|
onBackButton,
|
|
373
716
|
onNativeReady,
|
|
374
717
|
exitApp,
|
|
375
|
-
minimizeApp
|
|
718
|
+
minimizeApp,
|
|
719
|
+
// Security utilities
|
|
720
|
+
clearBridgeValidationCache,
|
|
721
|
+
getBridgeValidationError
|
|
376
722
|
};
|
package/runtime/pulse.js
CHANGED
package/runtime/router.js
CHANGED
|
@@ -17,7 +17,7 @@ import { pulse, effect, batch } from './pulse.js';
|
|
|
17
17
|
import { el } from './dom.js';
|
|
18
18
|
import { loggers } from './logger.js';
|
|
19
19
|
import { createVersionedAsync } from './async.js';
|
|
20
|
-
import { Errors } from '
|
|
20
|
+
import { Errors } from './errors.js';
|
|
21
21
|
|
|
22
22
|
const log = loggers.router;
|
|
23
23
|
|
|
@@ -115,14 +115,15 @@ export function lazy(importFn, options = {}) {
|
|
|
115
115
|
|
|
116
116
|
loadWithTimeout
|
|
117
117
|
.then(module => {
|
|
118
|
-
//
|
|
118
|
+
// Always cache the component, even if navigation occurred
|
|
119
|
+
// This prevents re-showing loading state on future navigations
|
|
120
|
+
cachedComponent = module;
|
|
121
|
+
|
|
122
|
+
// Skip DOM updates if this load attempt is stale (navigation occurred)
|
|
119
123
|
if (loadCtx.isStale()) {
|
|
120
124
|
return;
|
|
121
125
|
}
|
|
122
126
|
|
|
123
|
-
// Cache the component
|
|
124
|
-
cachedComponent = module;
|
|
125
|
-
|
|
126
127
|
// Get the component from module
|
|
127
128
|
const Component = module.default || module;
|
|
128
129
|
const result = typeof Component === 'function'
|
package/runtime/store.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { pulse, computed, effect, batch } from './pulse.js';
|
|
19
19
|
import { loggers, createLogger } from './logger.js';
|
|
20
|
-
import { Errors, createErrorMessage } from '
|
|
20
|
+
import { Errors, createErrorMessage } from './errors.js';
|
|
21
21
|
|
|
22
22
|
const log = loggers.store;
|
|
23
23
|
|
|
@@ -65,15 +65,68 @@ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
|
65
65
|
*/
|
|
66
66
|
const INVALID_TYPES = new Set(['function', 'symbol']);
|
|
67
67
|
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Validation Cache - Optimized O(1) for unchanged values
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cache for validated objects - maps object to its validation timestamp
|
|
74
|
+
* Uses WeakMap to allow garbage collection of unreferenced objects
|
|
75
|
+
* @type {WeakMap<Object, {keys: string[], values: any[]}>}
|
|
76
|
+
*/
|
|
77
|
+
const _validationCache = new WeakMap();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check shallow equality between cached and current object
|
|
81
|
+
* @private
|
|
82
|
+
* @param {Object} obj - Current object
|
|
83
|
+
* @param {{keys: string[], values: any[]}} cached - Cached structure
|
|
84
|
+
* @returns {boolean} True if object structure is unchanged
|
|
85
|
+
*/
|
|
86
|
+
function _shallowEqual(obj, cached) {
|
|
87
|
+
const keys = Object.keys(obj);
|
|
88
|
+
if (keys.length !== cached.keys.length) return false;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < keys.length; i++) {
|
|
91
|
+
if (keys[i] !== cached.keys[i]) return false;
|
|
92
|
+
if (obj[keys[i]] !== cached.values[i]) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Cache an object's structure for future shallow equality checks
|
|
99
|
+
* @private
|
|
100
|
+
* @param {Object} obj - Object to cache
|
|
101
|
+
*/
|
|
102
|
+
function _cacheObject(obj) {
|
|
103
|
+
const keys = Object.keys(obj);
|
|
104
|
+
const values = keys.map(k => obj[k]);
|
|
105
|
+
_validationCache.set(obj, { keys, values });
|
|
106
|
+
}
|
|
107
|
+
|
|
68
108
|
/**
|
|
69
|
-
*
|
|
109
|
+
* Clear validation cache (for testing)
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
export function clearValidationCache() {
|
|
113
|
+
// WeakMap doesn't have clear(), but we can create a new one
|
|
114
|
+
// Since it's module-level, we just document that cache is auto-cleared
|
|
115
|
+
// when objects are garbage collected
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate state values with shallow equality caching
|
|
120
|
+
* O(1) for unchanged objects, O(m) for changed subtrees
|
|
121
|
+
*
|
|
70
122
|
* @private
|
|
71
123
|
* @param {*} value - Value to validate
|
|
72
124
|
* @param {string} path - Current path for error messages
|
|
73
125
|
* @param {WeakSet} seen - Set of objects already visited (for circular detection)
|
|
126
|
+
* @param {boolean} [useCache=true] - Whether to use validation cache
|
|
74
127
|
* @throws {TypeError} If value contains invalid types or circular references
|
|
75
128
|
*/
|
|
76
|
-
function validateStateValue(value, path = 'state', seen = new WeakSet()) {
|
|
129
|
+
function validateStateValue(value, path = 'state', seen = new WeakSet(), useCache = true) {
|
|
77
130
|
const valueType = typeof value;
|
|
78
131
|
|
|
79
132
|
// Check for invalid types
|
|
@@ -97,15 +150,28 @@ function validateStateValue(value, path = 'state', seen = new WeakSet()) {
|
|
|
97
150
|
}
|
|
98
151
|
seen.add(value);
|
|
99
152
|
|
|
153
|
+
// Optimization: Check cache for shallow equality
|
|
154
|
+
if (useCache && !Array.isArray(value)) {
|
|
155
|
+
const cached = _validationCache.get(value);
|
|
156
|
+
if (cached && _shallowEqual(value, cached)) {
|
|
157
|
+
// Object structure unchanged, skip deep validation
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
100
162
|
// Validate array elements
|
|
101
163
|
if (Array.isArray(value)) {
|
|
102
164
|
for (let i = 0; i < value.length; i++) {
|
|
103
|
-
validateStateValue(value[i], `${path}[${i}]`, seen);
|
|
165
|
+
validateStateValue(value[i], `${path}[${i}]`, seen, useCache);
|
|
104
166
|
}
|
|
105
167
|
} else {
|
|
106
168
|
// Validate object properties
|
|
107
169
|
for (const [key, val] of Object.entries(value)) {
|
|
108
|
-
validateStateValue(val, `${path}.${key}`, seen);
|
|
170
|
+
validateStateValue(val, `${path}.${key}`, seen, useCache);
|
|
171
|
+
}
|
|
172
|
+
// Cache this object for future shallow equality checks
|
|
173
|
+
if (useCache) {
|
|
174
|
+
_cacheObject(value);
|
|
109
175
|
}
|
|
110
176
|
}
|
|
111
177
|
}
|
|
@@ -341,8 +407,13 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
341
407
|
/**
|
|
342
408
|
* Set multiple values at once (batched)
|
|
343
409
|
* Supports both top-level and nested object updates
|
|
410
|
+
*
|
|
411
|
+
* Validation: Uses cached shallow equality to validate updates efficiently.
|
|
412
|
+
* O(1) for unchanged objects, O(m) for changed subtrees.
|
|
413
|
+
*
|
|
344
414
|
* @param {Object} updates - Key-value pairs to update
|
|
345
415
|
* @returns {void}
|
|
416
|
+
* @throws {TypeError} If updates contain invalid types (functions, symbols, circular refs)
|
|
346
417
|
* @example
|
|
347
418
|
* // Top-level update
|
|
348
419
|
* store.$setState({ count: 5 });
|
|
@@ -351,6 +422,9 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
351
422
|
* store.$setState({ user: { name: 'Jane', age: 25 } });
|
|
352
423
|
*/
|
|
353
424
|
function setState(updates) {
|
|
425
|
+
// Validate updates before applying (uses cached validation for efficiency)
|
|
426
|
+
validateStateValue(updates, '$setState', new WeakSet(), true);
|
|
427
|
+
|
|
354
428
|
batch(() => {
|
|
355
429
|
for (const [key, value] of Object.entries(updates)) {
|
|
356
430
|
if (pulses[key]) {
|
|
@@ -712,5 +786,6 @@ export default {
|
|
|
712
786
|
createModuleStore,
|
|
713
787
|
usePlugin,
|
|
714
788
|
loggerPlugin,
|
|
715
|
-
historyPlugin
|
|
789
|
+
historyPlugin,
|
|
790
|
+
clearValidationCache
|
|
716
791
|
};
|