pulse-js-framework 1.5.0 → 1.5.2
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/cli/index.js +2 -0
- package/cli/release.js +179 -23
- package/package.json +5 -2
- package/runtime/lru-cache.js +22 -26
- package/runtime/pulse.js +131 -5
- package/runtime/store.js +75 -3
- package/runtime/utils.js +3 -3
package/cli/index.js
CHANGED
|
@@ -87,6 +87,7 @@ Release Options:
|
|
|
87
87
|
--no-push Create commit and tag but don't push
|
|
88
88
|
--title <text> Release title for changelog
|
|
89
89
|
--skip-prompt Use empty changelog (for automation)
|
|
90
|
+
--from-commits Auto-extract changelog from git commits since last tag
|
|
90
91
|
|
|
91
92
|
Examples:
|
|
92
93
|
pulse create my-app
|
|
@@ -107,6 +108,7 @@ Examples:
|
|
|
107
108
|
pulse release patch
|
|
108
109
|
pulse release minor --title "New Features"
|
|
109
110
|
pulse release major --dry-run
|
|
111
|
+
pulse release patch --from-commits
|
|
110
112
|
|
|
111
113
|
Documentation: https://github.com/vincenthirtz/pulse-js-framework
|
|
112
114
|
`);
|
package/cli/release.js
CHANGED
|
@@ -113,6 +113,85 @@ function getCurrentMonthYear() {
|
|
|
113
113
|
return `${months[now.getMonth()]} ${now.getFullYear()}`;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Get the last git tag
|
|
118
|
+
*/
|
|
119
|
+
function getLastTag() {
|
|
120
|
+
try {
|
|
121
|
+
return execSync('git describe --tags --abbrev=0', { cwd: root, encoding: 'utf-8' }).trim();
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get commits since the last tag (or all commits if no tag exists)
|
|
129
|
+
*/
|
|
130
|
+
function getCommitsSinceLastTag() {
|
|
131
|
+
const lastTag = getLastTag();
|
|
132
|
+
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const output = execSync(`git log ${range} --pretty=format:"%s"`, {
|
|
136
|
+
cwd: root,
|
|
137
|
+
encoding: 'utf-8'
|
|
138
|
+
});
|
|
139
|
+
return output.split('\n').filter(line => line.trim());
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse commit messages into changelog categories
|
|
147
|
+
* Supports conventional commits: feat:, fix:, docs:, chore:, refactor:, perf:, test:, style:
|
|
148
|
+
*/
|
|
149
|
+
function parseCommitMessages(commits) {
|
|
150
|
+
const changes = { added: [], changed: [], fixed: [], removed: [] };
|
|
151
|
+
|
|
152
|
+
for (const commit of commits) {
|
|
153
|
+
const lowerCommit = commit.toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Skip version commits (e.g., "v1.5.0")
|
|
156
|
+
if (/^v?\d+\.\d+\.\d+/.test(commit)) continue;
|
|
157
|
+
|
|
158
|
+
// Skip merge commits
|
|
159
|
+
if (lowerCommit.startsWith('merge ')) continue;
|
|
160
|
+
|
|
161
|
+
// Parse conventional commits
|
|
162
|
+
if (lowerCommit.startsWith('feat:') || lowerCommit.startsWith('feat(')) {
|
|
163
|
+
changes.added.push(cleanCommitMessage(commit, 'feat'));
|
|
164
|
+
} else if (lowerCommit.startsWith('fix:') || lowerCommit.startsWith('fix(')) {
|
|
165
|
+
changes.fixed.push(cleanCommitMessage(commit, 'fix'));
|
|
166
|
+
} else if (lowerCommit.startsWith('remove') || lowerCommit.startsWith('deprecate')) {
|
|
167
|
+
changes.removed.push(commit);
|
|
168
|
+
} else if (
|
|
169
|
+
lowerCommit.startsWith('refactor:') ||
|
|
170
|
+
lowerCommit.startsWith('perf:') ||
|
|
171
|
+
lowerCommit.startsWith('chore:') ||
|
|
172
|
+
lowerCommit.startsWith('docs:') ||
|
|
173
|
+
lowerCommit.startsWith('style:') ||
|
|
174
|
+
lowerCommit.startsWith('test:')
|
|
175
|
+
) {
|
|
176
|
+
changes.changed.push(cleanCommitMessage(commit, commit.split(':')[0]));
|
|
177
|
+
} else {
|
|
178
|
+
// Default: treat as a change
|
|
179
|
+
changes.changed.push(commit);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return changes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clean commit message by removing the conventional commit prefix
|
|
188
|
+
*/
|
|
189
|
+
function cleanCommitMessage(message, prefix) {
|
|
190
|
+
// Remove "prefix:" or "prefix(scope):"
|
|
191
|
+
const regex = new RegExp(`^${prefix}(\\([^)]+\\))?:\\s*`, 'i');
|
|
192
|
+
return message.replace(regex, '').trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
116
195
|
/**
|
|
117
196
|
* Update package.json version
|
|
118
197
|
*/
|
|
@@ -296,30 +375,76 @@ function updateReadme(newVersion) {
|
|
|
296
375
|
}
|
|
297
376
|
|
|
298
377
|
/**
|
|
299
|
-
*
|
|
378
|
+
* Build commit message from version, title, and changes
|
|
300
379
|
*/
|
|
301
|
-
function
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
380
|
+
function buildCommitMessage(newVersion, title, changes) {
|
|
381
|
+
// Build header: "v1.5.2 - Title" or just "v1.5.2"
|
|
382
|
+
let message = `v${newVersion}`;
|
|
383
|
+
if (title) {
|
|
384
|
+
message += ` - ${title}`;
|
|
385
|
+
}
|
|
386
|
+
message += '\n\n';
|
|
387
|
+
|
|
388
|
+
// Add change items as bullet points
|
|
389
|
+
const allChanges = [
|
|
390
|
+
...(changes.added || []),
|
|
391
|
+
...(changes.changed || []),
|
|
392
|
+
...(changes.fixed || []),
|
|
393
|
+
...(changes.removed || [])
|
|
308
394
|
];
|
|
309
395
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
} else {
|
|
314
|
-
log.info(` Running: ${cmd.split('\n')[0]}...`);
|
|
315
|
-
try {
|
|
316
|
-
execSync(cmd, { cwd: root, stdio: 'inherit', shell: '/bin/bash' });
|
|
317
|
-
} catch (error) {
|
|
318
|
-
log.error(` Failed: ${error.message}`);
|
|
319
|
-
throw error;
|
|
320
|
-
}
|
|
396
|
+
if (allChanges.length > 0) {
|
|
397
|
+
for (const change of allChanges) {
|
|
398
|
+
message += `- ${change}\n`;
|
|
321
399
|
}
|
|
400
|
+
message += '\n';
|
|
322
401
|
}
|
|
402
|
+
|
|
403
|
+
// Add co-author
|
|
404
|
+
message += 'Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>';
|
|
405
|
+
|
|
406
|
+
return message;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Execute git commands
|
|
411
|
+
*/
|
|
412
|
+
function gitCommitTagPush(newVersion, title, changes, dryRun = false) {
|
|
413
|
+
const commitMessage = buildCommitMessage(newVersion, title, changes);
|
|
414
|
+
|
|
415
|
+
if (dryRun) {
|
|
416
|
+
log.info(' [dry-run] git add -A');
|
|
417
|
+
log.info(' [dry-run] git commit with message:');
|
|
418
|
+
log.info(' ' + commitMessage.split('\n').join('\n '));
|
|
419
|
+
log.info(` [dry-run] git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
420
|
+
log.info(' [dry-run] git push');
|
|
421
|
+
log.info(' [dry-run] git push --tags');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// git add
|
|
426
|
+
log.info(' Running: git add -A...');
|
|
427
|
+
execSync('git add -A', { cwd: root, stdio: 'inherit' });
|
|
428
|
+
|
|
429
|
+
// git commit with heredoc for proper message formatting
|
|
430
|
+
log.info(' Running: git commit...');
|
|
431
|
+
const escapedMessage = commitMessage.replace(/'/g, "'\\''");
|
|
432
|
+
execSync(`git commit -m $'${escapedMessage.replace(/\n/g, '\\n')}'`, {
|
|
433
|
+
cwd: root,
|
|
434
|
+
stdio: 'inherit',
|
|
435
|
+
shell: '/bin/bash'
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// git tag
|
|
439
|
+
log.info(` Running: git tag v${newVersion}...`);
|
|
440
|
+
execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
|
|
441
|
+
|
|
442
|
+
// git push
|
|
443
|
+
log.info(' Running: git push...');
|
|
444
|
+
execSync('git push', { cwd: root, stdio: 'inherit' });
|
|
445
|
+
|
|
446
|
+
log.info(' Running: git push --tags...');
|
|
447
|
+
execSync('git push --tags', { cwd: root, stdio: 'inherit' });
|
|
323
448
|
}
|
|
324
449
|
|
|
325
450
|
/**
|
|
@@ -339,11 +464,13 @@ Options:
|
|
|
339
464
|
--no-push Create commit and tag but don't push
|
|
340
465
|
--title <text> Release title (e.g., "Performance Improvements")
|
|
341
466
|
--skip-prompt Use empty changelog (for automated releases)
|
|
467
|
+
--from-commits Auto-extract changelog from git commits since last tag
|
|
342
468
|
|
|
343
469
|
Examples:
|
|
344
470
|
pulse release patch
|
|
345
471
|
pulse release minor --title "New Features"
|
|
346
472
|
pulse release major --dry-run
|
|
473
|
+
pulse release patch --from-commits --title "Bug Fixes"
|
|
347
474
|
`);
|
|
348
475
|
}
|
|
349
476
|
|
|
@@ -362,6 +489,7 @@ export async function runRelease(args) {
|
|
|
362
489
|
const dryRun = args.includes('--dry-run');
|
|
363
490
|
const noPush = args.includes('--no-push');
|
|
364
491
|
const skipPrompt = args.includes('--skip-prompt');
|
|
492
|
+
const fromCommits = args.includes('--from-commits');
|
|
365
493
|
|
|
366
494
|
let title = '';
|
|
367
495
|
const titleIndex = args.indexOf('--title');
|
|
@@ -403,7 +531,33 @@ export async function runRelease(args) {
|
|
|
403
531
|
// Collect changelog entries
|
|
404
532
|
let changes = { added: [], changed: [], fixed: [], removed: [] };
|
|
405
533
|
|
|
406
|
-
if (
|
|
534
|
+
if (fromCommits) {
|
|
535
|
+
// Auto-extract from git commits since last tag
|
|
536
|
+
const lastTag = getLastTag();
|
|
537
|
+
const commits = getCommitsSinceLastTag();
|
|
538
|
+
|
|
539
|
+
if (commits.length === 0) {
|
|
540
|
+
log.warn('No commits found since last tag');
|
|
541
|
+
} else {
|
|
542
|
+
log.info(`Found ${commits.length} commits since ${lastTag || 'beginning'}`);
|
|
543
|
+
changes = parseCommitMessages(commits);
|
|
544
|
+
|
|
545
|
+
// Show extracted changes
|
|
546
|
+
const totalChanges = Object.values(changes).flat().length;
|
|
547
|
+
if (totalChanges > 0) {
|
|
548
|
+
log.info('');
|
|
549
|
+
log.info('Extracted changelog entries:');
|
|
550
|
+
if (changes.added.length) log.info(` Added: ${changes.added.length} items`);
|
|
551
|
+
if (changes.changed.length) log.info(` Changed: ${changes.changed.length} items`);
|
|
552
|
+
if (changes.fixed.length) log.info(` Fixed: ${changes.fixed.length} items`);
|
|
553
|
+
if (changes.removed.length) log.info(` Removed: ${changes.removed.length} items`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!title) {
|
|
558
|
+
title = await prompt('Release title (e.g., "Performance Improvements"): ');
|
|
559
|
+
}
|
|
560
|
+
} else if (!skipPrompt) {
|
|
407
561
|
if (!title) {
|
|
408
562
|
title = await prompt('Release title (e.g., "Performance Improvements"): ');
|
|
409
563
|
}
|
|
@@ -466,8 +620,10 @@ export async function runRelease(args) {
|
|
|
466
620
|
if (!dryRun) {
|
|
467
621
|
if (noPush) {
|
|
468
622
|
// Only commit and tag, no push
|
|
623
|
+
const commitMessage = buildCommitMessage(newVersion, title, changes);
|
|
469
624
|
execSync('git add -A', { cwd: root, stdio: 'inherit' });
|
|
470
|
-
|
|
625
|
+
const escapedMessage = commitMessage.replace(/'/g, "'\\''");
|
|
626
|
+
execSync(`git commit -m $'${escapedMessage.replace(/\n/g, '\\n')}'`, {
|
|
471
627
|
cwd: root,
|
|
472
628
|
stdio: 'inherit',
|
|
473
629
|
shell: '/bin/bash'
|
|
@@ -475,10 +631,10 @@ export async function runRelease(args) {
|
|
|
475
631
|
execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
|
|
476
632
|
log.info(' Created commit and tag (--no-push specified)');
|
|
477
633
|
} else {
|
|
478
|
-
gitCommitTagPush(newVersion, false);
|
|
634
|
+
gitCommitTagPush(newVersion, title, changes, false);
|
|
479
635
|
}
|
|
480
636
|
} else {
|
|
481
|
-
gitCommitTagPush(newVersion, true);
|
|
637
|
+
gitCommitTagPush(newVersion, title, changes, true);
|
|
482
638
|
}
|
|
483
639
|
|
|
484
640
|
log.info('');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"LICENSE"
|
|
76
76
|
],
|
|
77
77
|
"scripts": {
|
|
78
|
-
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze",
|
|
78
|
+
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:lru-cache && npm run test:utils && npm run test:docs",
|
|
79
79
|
"test:compiler": "node test/compiler.test.js",
|
|
80
80
|
"test:sourcemap": "node test/sourcemap.test.js",
|
|
81
81
|
"test:pulse": "node test/pulse.test.js",
|
|
@@ -86,6 +86,9 @@
|
|
|
86
86
|
"test:lint": "node test/lint.test.js",
|
|
87
87
|
"test:format": "node test/format.test.js",
|
|
88
88
|
"test:analyze": "node test/analyze.test.js",
|
|
89
|
+
"test:lru-cache": "node test/lru-cache.test.js",
|
|
90
|
+
"test:utils": "node test/utils.test.js",
|
|
91
|
+
"test:docs": "node test/docs.test.js",
|
|
89
92
|
"build:netlify": "node scripts/build-netlify.js",
|
|
90
93
|
"version": "node scripts/sync-version.js",
|
|
91
94
|
"docs": "node cli/index.js dev docs"
|
package/runtime/lru-cache.js
CHANGED
|
@@ -8,15 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* LRU Cache implementation
|
|
11
|
+
* Uses Map's insertion order for O(1) operations.
|
|
11
12
|
* @template K, V
|
|
12
13
|
*/
|
|
13
14
|
export class LRUCache {
|
|
14
|
-
/** @type {number} */
|
|
15
|
-
#capacity;
|
|
16
|
-
|
|
17
|
-
/** @type {Map<K, V>} */
|
|
18
|
-
#cache = new Map();
|
|
19
|
-
|
|
20
15
|
/**
|
|
21
16
|
* Create an LRU cache
|
|
22
17
|
* @param {number} capacity - Maximum number of items to store
|
|
@@ -25,7 +20,8 @@ export class LRUCache {
|
|
|
25
20
|
if (capacity <= 0) {
|
|
26
21
|
throw new Error('LRU cache capacity must be greater than 0');
|
|
27
22
|
}
|
|
28
|
-
this
|
|
23
|
+
this._capacity = capacity;
|
|
24
|
+
this._cache = new Map();
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
/**
|
|
@@ -35,14 +31,14 @@ export class LRUCache {
|
|
|
35
31
|
* @returns {V|undefined} The cached value or undefined if not found
|
|
36
32
|
*/
|
|
37
33
|
get(key) {
|
|
38
|
-
if (!this
|
|
34
|
+
if (!this._cache.has(key)) {
|
|
39
35
|
return undefined;
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
// Move to end (most recently used) by re-inserting
|
|
43
|
-
const value = this
|
|
44
|
-
this
|
|
45
|
-
this
|
|
39
|
+
const value = this._cache.get(key);
|
|
40
|
+
this._cache.delete(key);
|
|
41
|
+
this._cache.set(key, value);
|
|
46
42
|
return value;
|
|
47
43
|
}
|
|
48
44
|
|
|
@@ -55,15 +51,15 @@ export class LRUCache {
|
|
|
55
51
|
*/
|
|
56
52
|
set(key, value) {
|
|
57
53
|
// If key exists, delete first to update position
|
|
58
|
-
if (this
|
|
59
|
-
this
|
|
60
|
-
} else if (this
|
|
54
|
+
if (this._cache.has(key)) {
|
|
55
|
+
this._cache.delete(key);
|
|
56
|
+
} else if (this._cache.size >= this._capacity) {
|
|
61
57
|
// Remove oldest (first item in Map)
|
|
62
|
-
const oldest = this
|
|
63
|
-
this
|
|
58
|
+
const oldest = this._cache.keys().next().value;
|
|
59
|
+
this._cache.delete(oldest);
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
this
|
|
62
|
+
this._cache.set(key, value);
|
|
67
63
|
return this;
|
|
68
64
|
}
|
|
69
65
|
|
|
@@ -74,7 +70,7 @@ export class LRUCache {
|
|
|
74
70
|
* @returns {boolean} True if key exists
|
|
75
71
|
*/
|
|
76
72
|
has(key) {
|
|
77
|
-
return this
|
|
73
|
+
return this._cache.has(key);
|
|
78
74
|
}
|
|
79
75
|
|
|
80
76
|
/**
|
|
@@ -83,14 +79,14 @@ export class LRUCache {
|
|
|
83
79
|
* @returns {boolean} True if item was deleted
|
|
84
80
|
*/
|
|
85
81
|
delete(key) {
|
|
86
|
-
return this
|
|
82
|
+
return this._cache.delete(key);
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
/**
|
|
90
86
|
* Clear all items from the cache
|
|
91
87
|
*/
|
|
92
88
|
clear() {
|
|
93
|
-
this
|
|
89
|
+
this._cache.clear();
|
|
94
90
|
}
|
|
95
91
|
|
|
96
92
|
/**
|
|
@@ -98,7 +94,7 @@ export class LRUCache {
|
|
|
98
94
|
* @returns {number} Current size
|
|
99
95
|
*/
|
|
100
96
|
get size() {
|
|
101
|
-
return this
|
|
97
|
+
return this._cache.size;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
100
|
/**
|
|
@@ -106,7 +102,7 @@ export class LRUCache {
|
|
|
106
102
|
* @returns {number} Maximum capacity
|
|
107
103
|
*/
|
|
108
104
|
get capacity() {
|
|
109
|
-
return this
|
|
105
|
+
return this._capacity;
|
|
110
106
|
}
|
|
111
107
|
|
|
112
108
|
/**
|
|
@@ -114,7 +110,7 @@ export class LRUCache {
|
|
|
114
110
|
* @returns {IterableIterator<K>} Iterator of keys
|
|
115
111
|
*/
|
|
116
112
|
keys() {
|
|
117
|
-
return this
|
|
113
|
+
return this._cache.keys();
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
/**
|
|
@@ -122,7 +118,7 @@ export class LRUCache {
|
|
|
122
118
|
* @returns {IterableIterator<V>} Iterator of values
|
|
123
119
|
*/
|
|
124
120
|
values() {
|
|
125
|
-
return this
|
|
121
|
+
return this._cache.values();
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
/**
|
|
@@ -130,7 +126,7 @@ export class LRUCache {
|
|
|
130
126
|
* @returns {IterableIterator<[K, V]>} Iterator of [key, value] pairs
|
|
131
127
|
*/
|
|
132
128
|
entries() {
|
|
133
|
-
return this
|
|
129
|
+
return this._cache.entries();
|
|
134
130
|
}
|
|
135
131
|
|
|
136
132
|
/**
|
|
@@ -138,7 +134,7 @@ export class LRUCache {
|
|
|
138
134
|
* @param {function(V, K, LRUCache): void} callback - Called for each entry
|
|
139
135
|
*/
|
|
140
136
|
forEach(callback) {
|
|
141
|
-
this
|
|
137
|
+
this._cache.forEach((value, key) => callback(value, key, this));
|
|
142
138
|
}
|
|
143
139
|
}
|
|
144
140
|
|
package/runtime/pulse.js
CHANGED
|
@@ -102,6 +102,63 @@ export function resetContext() {
|
|
|
102
102
|
context.effectRegistry.clear();
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Counter for generating unique effect IDs
|
|
107
|
+
* @type {number}
|
|
108
|
+
*/
|
|
109
|
+
let effectIdCounter = 0;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Global effect error handler
|
|
113
|
+
* @type {Function|null}
|
|
114
|
+
*/
|
|
115
|
+
let globalEffectErrorHandler = null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Custom error class for effect-related errors with context information.
|
|
119
|
+
* Provides details about which effect failed, in what phase, and its dependencies.
|
|
120
|
+
*/
|
|
121
|
+
export class EffectError extends Error {
|
|
122
|
+
/**
|
|
123
|
+
* Create an EffectError with context information
|
|
124
|
+
* @param {string} message - Error message
|
|
125
|
+
* @param {Object} options - Error context
|
|
126
|
+
* @param {string} [options.effectId] - Effect identifier
|
|
127
|
+
* @param {string} [options.phase] - Phase when error occurred ('cleanup' | 'execution')
|
|
128
|
+
* @param {number} [options.dependencyCount] - Number of dependencies
|
|
129
|
+
* @param {Error} [options.cause] - Original error that caused this
|
|
130
|
+
*/
|
|
131
|
+
constructor(message, options = {}) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = 'EffectError';
|
|
134
|
+
this.effectId = options.effectId || null;
|
|
135
|
+
this.phase = options.phase || 'unknown';
|
|
136
|
+
this.dependencyCount = options.dependencyCount ?? 0;
|
|
137
|
+
this.cause = options.cause || null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set a global error handler for effect errors.
|
|
143
|
+
* The handler receives an EffectError with full context about the failure.
|
|
144
|
+
* @param {Function|null} handler - Error handler (effectError) => void, or null to clear
|
|
145
|
+
* @returns {Function|null} Previous handler (for restoration)
|
|
146
|
+
* @example
|
|
147
|
+
* // Set up global error tracking
|
|
148
|
+
* const prevHandler = onEffectError((err) => {
|
|
149
|
+
* console.error(`Effect ${err.effectId} failed during ${err.phase}:`, err.cause);
|
|
150
|
+
* reportToErrorService(err);
|
|
151
|
+
* });
|
|
152
|
+
*
|
|
153
|
+
* // Later, restore previous handler
|
|
154
|
+
* onEffectError(prevHandler);
|
|
155
|
+
*/
|
|
156
|
+
export function onEffectError(handler) {
|
|
157
|
+
const prev = globalEffectErrorHandler;
|
|
158
|
+
globalEffectErrorHandler = handler;
|
|
159
|
+
return prev;
|
|
160
|
+
}
|
|
161
|
+
|
|
105
162
|
/**
|
|
106
163
|
* Set the current module ID for HMR effect tracking.
|
|
107
164
|
* Effects created while a module ID is set will be registered for cleanup.
|
|
@@ -386,6 +443,52 @@ export class Pulse {
|
|
|
386
443
|
}
|
|
387
444
|
}
|
|
388
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Handle an effect error with full context information.
|
|
448
|
+
* Tries effect-specific handler, then global handler, then logs.
|
|
449
|
+
* @private
|
|
450
|
+
* @param {Error} error - The original error
|
|
451
|
+
* @param {EffectFn} effectFn - The effect that errored
|
|
452
|
+
* @param {string} phase - Phase when error occurred ('cleanup' | 'execution')
|
|
453
|
+
*/
|
|
454
|
+
function handleEffectError(error, effectFn, phase) {
|
|
455
|
+
const effectError = new EffectError(
|
|
456
|
+
`Effect [${effectFn.id}] error during ${phase}: ${error.message}`,
|
|
457
|
+
{
|
|
458
|
+
effectId: effectFn.id,
|
|
459
|
+
phase,
|
|
460
|
+
dependencyCount: effectFn.dependencies?.size ?? 0,
|
|
461
|
+
cause: error
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
// Try effect-specific handler first
|
|
466
|
+
if (effectFn.onError) {
|
|
467
|
+
try {
|
|
468
|
+
effectFn.onError(effectError);
|
|
469
|
+
return;
|
|
470
|
+
} catch (handlerError) {
|
|
471
|
+
log.error('Effect onError handler threw:', handlerError);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Try global handler
|
|
476
|
+
if (globalEffectErrorHandler) {
|
|
477
|
+
try {
|
|
478
|
+
globalEffectErrorHandler(effectError);
|
|
479
|
+
return;
|
|
480
|
+
} catch (handlerError) {
|
|
481
|
+
log.error('Global effect error handler threw:', handlerError);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Default: log with context
|
|
486
|
+
log.error(`[${effectError.effectId}] ${effectError.message}`, {
|
|
487
|
+
phase: effectError.phase,
|
|
488
|
+
dependencies: effectError.dependencyCount
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
389
492
|
/**
|
|
390
493
|
* Run a single effect safely
|
|
391
494
|
* @private
|
|
@@ -398,7 +501,7 @@ function runEffect(effectFn) {
|
|
|
398
501
|
try {
|
|
399
502
|
effectFn.run();
|
|
400
503
|
} catch (error) {
|
|
401
|
-
|
|
504
|
+
handleEffectError(error, effectFn, 'execution');
|
|
402
505
|
}
|
|
403
506
|
}
|
|
404
507
|
|
|
@@ -568,9 +671,16 @@ export function computed(fn, options = {}) {
|
|
|
568
671
|
return p;
|
|
569
672
|
}
|
|
570
673
|
|
|
674
|
+
/**
|
|
675
|
+
* @typedef {Object} EffectOptions
|
|
676
|
+
* @property {string} [id] - Custom effect identifier for debugging
|
|
677
|
+
* @property {function(EffectError): void} [onError] - Error handler for this effect
|
|
678
|
+
*/
|
|
679
|
+
|
|
571
680
|
/**
|
|
572
681
|
* Create an effect that runs when its dependencies change
|
|
573
682
|
* @param {function(): void|function(): void} fn - Effect function, may return a cleanup function
|
|
683
|
+
* @param {EffectOptions} [options={}] - Effect configuration options
|
|
574
684
|
* @returns {function(): void} Dispose function to stop the effect
|
|
575
685
|
* @example
|
|
576
686
|
* const count = pulse(0);
|
|
@@ -588,19 +698,32 @@ export function computed(fn, options = {}) {
|
|
|
588
698
|
* const timer = setInterval(() => tick(), 1000);
|
|
589
699
|
* return () => clearInterval(timer); // Cleanup on re-run or dispose
|
|
590
700
|
* });
|
|
701
|
+
*
|
|
702
|
+
* // With custom ID and error handler
|
|
703
|
+
* effect(() => {
|
|
704
|
+
* // Effect logic that might fail
|
|
705
|
+
* }, {
|
|
706
|
+
* id: 'data-sync',
|
|
707
|
+
* onError: (err) => console.error('Data sync failed:', err.cause)
|
|
708
|
+
* });
|
|
591
709
|
*/
|
|
592
|
-
export function effect(fn) {
|
|
710
|
+
export function effect(fn, options = {}) {
|
|
711
|
+
const { id: customId, onError } = options;
|
|
712
|
+
const effectId = customId || `effect_${++effectIdCounter}`;
|
|
713
|
+
|
|
593
714
|
// Capture module ID at creation time for HMR tracking
|
|
594
715
|
const moduleId = context.currentModuleId;
|
|
595
716
|
|
|
596
717
|
const effectFn = {
|
|
718
|
+
id: effectId,
|
|
719
|
+
onError,
|
|
597
720
|
run: () => {
|
|
598
721
|
// Run cleanup functions from previous run
|
|
599
722
|
for (const cleanup of effectFn.cleanups) {
|
|
600
723
|
try {
|
|
601
724
|
cleanup();
|
|
602
725
|
} catch (e) {
|
|
603
|
-
|
|
726
|
+
handleEffectError(e, effectFn, 'cleanup');
|
|
604
727
|
}
|
|
605
728
|
}
|
|
606
729
|
effectFn.cleanups = [];
|
|
@@ -618,7 +741,7 @@ export function effect(fn) {
|
|
|
618
741
|
try {
|
|
619
742
|
fn();
|
|
620
743
|
} catch (error) {
|
|
621
|
-
|
|
744
|
+
handleEffectError(error, effectFn, 'execution');
|
|
622
745
|
} finally {
|
|
623
746
|
context.currentEffect = prevEffect;
|
|
624
747
|
}
|
|
@@ -645,7 +768,7 @@ export function effect(fn) {
|
|
|
645
768
|
try {
|
|
646
769
|
cleanup();
|
|
647
770
|
} catch (e) {
|
|
648
|
-
|
|
771
|
+
handleEffectError(e, effectFn, 'cleanup');
|
|
649
772
|
}
|
|
650
773
|
}
|
|
651
774
|
effectFn.cleanups = [];
|
|
@@ -994,6 +1117,9 @@ export default {
|
|
|
994
1117
|
memoComputed,
|
|
995
1118
|
context,
|
|
996
1119
|
resetContext,
|
|
1120
|
+
// Error handling
|
|
1121
|
+
EffectError,
|
|
1122
|
+
onEffectError,
|
|
997
1123
|
// HMR support
|
|
998
1124
|
setCurrentModule,
|
|
999
1125
|
clearCurrentModule,
|
package/runtime/store.js
CHANGED
|
@@ -20,6 +20,12 @@ import { loggers, createLogger } from './logger.js';
|
|
|
20
20
|
|
|
21
21
|
const log = loggers.store;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Maximum nesting depth for nested objects to prevent abuse
|
|
25
|
+
* @type {number}
|
|
26
|
+
*/
|
|
27
|
+
const MAX_NESTING_DEPTH = 10;
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* @typedef {Object} StoreOptions
|
|
25
31
|
* @property {boolean} [persist=false] - Persist state to localStorage
|
|
@@ -46,6 +52,52 @@ const log = loggers.store;
|
|
|
46
52
|
* @typedef {function(Store): Store} StorePlugin
|
|
47
53
|
*/
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Dangerous property names that could cause prototype pollution
|
|
57
|
+
* @type {Set<string>}
|
|
58
|
+
*/
|
|
59
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Safely deserialize persisted state, preventing prototype pollution
|
|
63
|
+
* and property injection attacks.
|
|
64
|
+
* @private
|
|
65
|
+
* @param {Object} savedState - The parsed JSON state
|
|
66
|
+
* @param {Object} schema - The initial state defining allowed keys
|
|
67
|
+
* @returns {Object} Sanitized state object
|
|
68
|
+
*/
|
|
69
|
+
function safeDeserialize(savedState, schema) {
|
|
70
|
+
if (typeof savedState !== 'object' || savedState === null || Array.isArray(savedState)) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = {};
|
|
75
|
+
for (const [key, value] of Object.entries(savedState)) {
|
|
76
|
+
// Block dangerous keys that could pollute prototypes
|
|
77
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
78
|
+
log.warn(`Blocked potentially dangerous key in persisted state: "${key}"`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Only allow keys that exist in the schema (initial state)
|
|
83
|
+
if (!(key in schema)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Recursively validate nested objects
|
|
88
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
89
|
+
if (typeof schema[key] === 'object' && schema[key] !== null && !Array.isArray(schema[key])) {
|
|
90
|
+
result[key] = safeDeserialize(value, schema[key]);
|
|
91
|
+
}
|
|
92
|
+
// If schema expects primitive but got object, skip it
|
|
93
|
+
} else {
|
|
94
|
+
result[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
49
101
|
/**
|
|
50
102
|
* Create a global store with reactive state properties.
|
|
51
103
|
* @template T
|
|
@@ -76,7 +128,9 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
76
128
|
try {
|
|
77
129
|
const saved = localStorage.getItem(storageKey);
|
|
78
130
|
if (saved) {
|
|
79
|
-
|
|
131
|
+
const parsed = JSON.parse(saved);
|
|
132
|
+
const sanitized = safeDeserialize(parsed, initialState);
|
|
133
|
+
state = { ...initialState, ...sanitized };
|
|
80
134
|
}
|
|
81
135
|
} catch (e) {
|
|
82
136
|
log.warn('Failed to load persisted state:', e);
|
|
@@ -93,9 +147,18 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
93
147
|
* @private
|
|
94
148
|
* @param {string} key - State key
|
|
95
149
|
* @param {*} value - Initial value
|
|
150
|
+
* @param {number} [depth=0] - Current nesting depth
|
|
96
151
|
* @returns {Pulse|Object} Pulse or nested object of pulses
|
|
97
152
|
*/
|
|
98
|
-
function createPulse(key, value) {
|
|
153
|
+
function createPulse(key, value, depth = 0) {
|
|
154
|
+
// Prevent excessive nesting depth
|
|
155
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
156
|
+
log.warn(`Max nesting depth (${MAX_NESTING_DEPTH}) exceeded for key: "${key}". Flattening to single pulse.`);
|
|
157
|
+
const p = pulse(value);
|
|
158
|
+
pulses[key] = p;
|
|
159
|
+
return p;
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
100
163
|
// Create a pulse for the nested object itself (for $setState support)
|
|
101
164
|
const objectPulse = pulse(value);
|
|
@@ -104,7 +167,7 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
104
167
|
// Also create nested pulses for individual properties
|
|
105
168
|
const nested = {};
|
|
106
169
|
for (const [k, v] of Object.entries(value)) {
|
|
107
|
-
nested[k] = createPulse(`${key}.${k}`, v);
|
|
170
|
+
nested[k] = createPulse(`${key}.${k}`, v, depth + 1);
|
|
108
171
|
}
|
|
109
172
|
return nested;
|
|
110
173
|
}
|
|
@@ -119,6 +182,15 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
119
182
|
store[key] = createPulse(key, value);
|
|
120
183
|
}
|
|
121
184
|
|
|
185
|
+
// Sync nested pulses for persisted state to ensure consistency
|
|
186
|
+
if (persist && typeof localStorage !== 'undefined') {
|
|
187
|
+
for (const [key, value] of Object.entries(state)) {
|
|
188
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
189
|
+
updateNestedPulses(key, value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
122
194
|
// Persist state changes
|
|
123
195
|
if (persist) {
|
|
124
196
|
effect(() => {
|
package/runtime/utils.js
CHANGED
|
@@ -191,9 +191,9 @@ export function sanitizeUrl(url, options = {}) {
|
|
|
191
191
|
return null;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
// Check for data: protocol
|
|
195
|
-
if (
|
|
196
|
-
return null;
|
|
194
|
+
// Check for data: protocol
|
|
195
|
+
if (lowerUrl.startsWith('data:')) {
|
|
196
|
+
return allowData ? trimmed : null;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
// Allow relative URLs
|