@zenithbuild/runtime 0.2.0 → 0.5.0-beta.2.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.
@@ -0,0 +1,640 @@
1
+ // ---------------------------------------------------------------------------
2
+ // zeneffect.js — Zenith Runtime V0
3
+ // ---------------------------------------------------------------------------
4
+ // Explicit dependency effect primitive.
5
+ //
6
+ // API:
7
+ // const dispose = zeneffect([countSignal], () => { ... });
8
+ //
9
+ // Constraints:
10
+ // - Dependencies are explicit
11
+ // - No implicit tracking context
12
+ // - No scheduler
13
+ // - No async queue
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const DEFAULT_EFFECT_OPTIONS = {
17
+ debounceMs: 0,
18
+ throttleMs: 0,
19
+ raf: false,
20
+ flush: 'post'
21
+ };
22
+
23
+ let _activeDependencyCollector = null;
24
+ let _reactiveIdCounter = 0;
25
+ let _scopeIdCounter = 0;
26
+ let _effectIdCounter = 0;
27
+
28
+ export function _nextReactiveId() {
29
+ _reactiveIdCounter += 1;
30
+ return _reactiveIdCounter;
31
+ }
32
+
33
+ export function _trackDependency(source) {
34
+ if (typeof _activeDependencyCollector === 'function') {
35
+ _activeDependencyCollector(source);
36
+ }
37
+ }
38
+
39
+ function createInternalScope(label, mountReady) {
40
+ _scopeIdCounter += 1;
41
+ return {
42
+ __zenith_scope: true,
43
+ id: _scopeIdCounter,
44
+ label,
45
+ mountReady: mountReady === true,
46
+ disposed: false,
47
+ pendingMounts: [],
48
+ disposers: []
49
+ };
50
+ }
51
+
52
+ let _globalScope = createInternalScope('global', true);
53
+
54
+ function isScope(value) {
55
+ return !!value && typeof value === 'object' && value.__zenith_scope === true;
56
+ }
57
+
58
+ function resolveScope(scopeOverride) {
59
+ if (isScope(scopeOverride)) {
60
+ return scopeOverride;
61
+ }
62
+ return _globalScope;
63
+ }
64
+
65
+ export function resetGlobalSideEffects() {
66
+ disposeSideEffectScope(_globalScope);
67
+ _globalScope = createInternalScope('global', true);
68
+ }
69
+
70
+ export function createSideEffectScope(label = 'anonymous') {
71
+ return createInternalScope(label, false);
72
+ }
73
+
74
+ export function activateSideEffectScope(scope) {
75
+ if (!isScope(scope) || scope.disposed || scope.mountReady) {
76
+ return;
77
+ }
78
+ scope.mountReady = true;
79
+
80
+ const pending = scope.pendingMounts.slice();
81
+ scope.pendingMounts.length = 0;
82
+
83
+ for (let i = 0; i < pending.length; i++) {
84
+ const callback = pending[i];
85
+ if (typeof callback !== 'function') continue;
86
+ try {
87
+ callback();
88
+ } catch {
89
+ // failed effect mounts should not crash sibling nodes
90
+ }
91
+ }
92
+ }
93
+
94
+ function registerScopeDisposer(scope, disposer) {
95
+ if (typeof disposer !== 'function') {
96
+ return () => { };
97
+ }
98
+
99
+ if (!scope || scope.disposed) {
100
+ disposer();
101
+ return () => { };
102
+ }
103
+
104
+ scope.disposers.push(disposer);
105
+
106
+ return function unregisterScopeDisposer() {
107
+ if (!scope || scope.disposed) {
108
+ return;
109
+ }
110
+ const index = scope.disposers.indexOf(disposer);
111
+ if (index >= 0) {
112
+ scope.disposers.splice(index, 1);
113
+ }
114
+ };
115
+ }
116
+
117
+ export function disposeSideEffectScope(scope) {
118
+ if (!scope || scope.disposed) {
119
+ return;
120
+ }
121
+
122
+ scope.disposed = true;
123
+
124
+ const disposers = scope.disposers.slice();
125
+ scope.disposers.length = 0;
126
+ scope.pendingMounts.length = 0;
127
+
128
+ for (let i = disposers.length - 1; i >= 0; i--) {
129
+ const disposer = disposers[i];
130
+ if (typeof disposer !== 'function') {
131
+ continue;
132
+ }
133
+ try {
134
+ disposer();
135
+ } catch {
136
+ // cleanup failures must never break teardown flow
137
+ }
138
+ }
139
+ }
140
+
141
+ function normalizeDelay(value, fieldName) {
142
+ if (value === undefined || value === null) {
143
+ return 0;
144
+ }
145
+ if (!Number.isFinite(value) || value < 0) {
146
+ throw new Error(
147
+ `[Zenith Runtime] zenEffect options.${fieldName} must be a non-negative number`
148
+ );
149
+ }
150
+ return Math.floor(value);
151
+ }
152
+
153
+ function normalizeEffectOptions(options) {
154
+ if (options === undefined || options === null) {
155
+ return DEFAULT_EFFECT_OPTIONS;
156
+ }
157
+
158
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
159
+ throw new Error('[Zenith Runtime] zenEffect(effect, options) requires options object when provided');
160
+ }
161
+
162
+ const normalized = {
163
+ debounceMs: normalizeDelay(options.debounceMs, 'debounceMs'),
164
+ throttleMs: normalizeDelay(options.throttleMs, 'throttleMs'),
165
+ raf: options.raf === true,
166
+ flush: options.flush === 'sync' ? 'sync' : 'post'
167
+ };
168
+
169
+ if (options.flush !== undefined && options.flush !== 'sync' && options.flush !== 'post') {
170
+ throw new Error('[Zenith Runtime] zenEffect options.flush must be "post" or "sync"');
171
+ }
172
+
173
+ const schedulingModes =
174
+ (normalized.debounceMs > 0 ? 1 : 0) +
175
+ (normalized.throttleMs > 0 ? 1 : 0) +
176
+ (normalized.raf ? 1 : 0);
177
+
178
+ if (schedulingModes > 1) {
179
+ throw new Error('[Zenith Runtime] zenEffect options may use only one scheduler: debounceMs, throttleMs, or raf');
180
+ }
181
+
182
+ return normalized;
183
+ }
184
+
185
+ function drainCleanupStack(cleanups) {
186
+ for (let i = cleanups.length - 1; i >= 0; i--) {
187
+ const cleanup = cleanups[i];
188
+ if (typeof cleanup !== 'function') {
189
+ continue;
190
+ }
191
+ try {
192
+ cleanup();
193
+ } catch {
194
+ // swallow cleanup errors to preserve deterministic teardown
195
+ }
196
+ }
197
+ cleanups.length = 0;
198
+ }
199
+
200
+ function applyCleanupResult(result, registerCleanup) {
201
+ if (typeof result === 'function') {
202
+ registerCleanup(result);
203
+ return;
204
+ }
205
+
206
+ if (result && typeof result === 'object' && typeof result.cleanup === 'function') {
207
+ registerCleanup(result.cleanup);
208
+ }
209
+ }
210
+
211
+ function requireFunction(callback, label) {
212
+ if (typeof callback !== 'function') {
213
+ throw new Error(`[Zenith Runtime] ${label} requires callback function`);
214
+ }
215
+ }
216
+
217
+ function createMountContext(registerCleanup) {
218
+ return {
219
+ cleanup: registerCleanup
220
+ };
221
+ }
222
+
223
+ function createEffectContext(registerCleanup) {
224
+ return {
225
+ cleanup: registerCleanup,
226
+ timeout(callback, delayMs = 0) {
227
+ requireFunction(callback, 'zenEffect context.timeout(callback, delayMs)');
228
+ const timeoutId = setTimeout(callback, normalizeDelay(delayMs, 'timeout'));
229
+ registerCleanup(() => clearTimeout(timeoutId));
230
+ return timeoutId;
231
+ },
232
+ raf(callback) {
233
+ requireFunction(callback, 'zenEffect context.raf(callback)');
234
+ if (typeof requestAnimationFrame === 'function') {
235
+ const frameId = requestAnimationFrame(callback);
236
+ registerCleanup(() => cancelAnimationFrame(frameId));
237
+ return frameId;
238
+ }
239
+ const timeoutId = setTimeout(callback, 16);
240
+ registerCleanup(() => clearTimeout(timeoutId));
241
+ return timeoutId;
242
+ },
243
+ debounce(callback, delayMs) {
244
+ requireFunction(callback, 'zenEffect context.debounce(callback, delayMs)');
245
+ const waitMs = normalizeDelay(delayMs, 'debounce');
246
+ let timeoutId = null;
247
+
248
+ const wrapped = (...args) => {
249
+ if (timeoutId !== null) {
250
+ clearTimeout(timeoutId);
251
+ }
252
+ timeoutId = setTimeout(() => {
253
+ timeoutId = null;
254
+ callback(...args);
255
+ }, waitMs);
256
+ };
257
+
258
+ registerCleanup(() => {
259
+ if (timeoutId !== null) {
260
+ clearTimeout(timeoutId);
261
+ timeoutId = null;
262
+ }
263
+ });
264
+
265
+ return wrapped;
266
+ },
267
+ throttle(callback, delayMs) {
268
+ requireFunction(callback, 'zenEffect context.throttle(callback, delayMs)');
269
+ const waitMs = normalizeDelay(delayMs, 'throttle');
270
+ let timeoutId = null;
271
+ let lastRun = 0;
272
+ let pendingArgs = null;
273
+
274
+ const invoke = (args) => {
275
+ lastRun = Date.now();
276
+ callback(...args);
277
+ };
278
+
279
+ const wrapped = (...args) => {
280
+ const now = Date.now();
281
+ const elapsed = now - lastRun;
282
+ if (lastRun === 0 || elapsed >= waitMs) {
283
+ if (timeoutId !== null) {
284
+ clearTimeout(timeoutId);
285
+ timeoutId = null;
286
+ }
287
+ pendingArgs = null;
288
+ invoke(args);
289
+ return;
290
+ }
291
+
292
+ pendingArgs = args;
293
+ if (timeoutId !== null) {
294
+ return;
295
+ }
296
+
297
+ timeoutId = setTimeout(() => {
298
+ timeoutId = null;
299
+ if (pendingArgs) {
300
+ const next = pendingArgs;
301
+ pendingArgs = null;
302
+ invoke(next);
303
+ }
304
+ }, waitMs - elapsed);
305
+ };
306
+
307
+ registerCleanup(() => {
308
+ if (timeoutId !== null) {
309
+ clearTimeout(timeoutId);
310
+ timeoutId = null;
311
+ }
312
+ pendingArgs = null;
313
+ });
314
+
315
+ return wrapped;
316
+ }
317
+ };
318
+ }
319
+
320
+ function createScheduler(runNow, options) {
321
+ let microtaskQueued = false;
322
+ let debounceTimer = null;
323
+ let throttleTimer = null;
324
+ let rafHandle = null;
325
+ let lastRunAt = 0;
326
+
327
+ function clearScheduledWork() {
328
+ if (debounceTimer !== null) {
329
+ clearTimeout(debounceTimer);
330
+ debounceTimer = null;
331
+ }
332
+ if (throttleTimer !== null) {
333
+ clearTimeout(throttleTimer);
334
+ throttleTimer = null;
335
+ }
336
+ if (rafHandle !== null) {
337
+ if (typeof cancelAnimationFrame === 'function') {
338
+ cancelAnimationFrame(rafHandle);
339
+ } else {
340
+ clearTimeout(rafHandle);
341
+ }
342
+ rafHandle = null;
343
+ }
344
+ microtaskQueued = false;
345
+ }
346
+
347
+ function invokeNow() {
348
+ microtaskQueued = false;
349
+ debounceTimer = null;
350
+ throttleTimer = null;
351
+ rafHandle = null;
352
+ lastRunAt = Date.now();
353
+ runNow();
354
+ }
355
+
356
+ function schedule() {
357
+ if (options.debounceMs > 0) {
358
+ if (debounceTimer !== null) {
359
+ clearTimeout(debounceTimer);
360
+ }
361
+ debounceTimer = setTimeout(invokeNow, options.debounceMs);
362
+ return;
363
+ }
364
+
365
+ if (options.throttleMs > 0) {
366
+ const now = Date.now();
367
+ const elapsed = now - lastRunAt;
368
+ if (lastRunAt === 0 || elapsed >= options.throttleMs) {
369
+ invokeNow();
370
+ return;
371
+ }
372
+
373
+ if (throttleTimer !== null) {
374
+ return;
375
+ }
376
+
377
+ throttleTimer = setTimeout(invokeNow, options.throttleMs - elapsed);
378
+ return;
379
+ }
380
+
381
+ if (options.raf) {
382
+ if (rafHandle !== null) {
383
+ if (typeof cancelAnimationFrame === 'function') {
384
+ cancelAnimationFrame(rafHandle);
385
+ } else {
386
+ clearTimeout(rafHandle);
387
+ }
388
+ }
389
+
390
+ if (typeof requestAnimationFrame === 'function') {
391
+ rafHandle = requestAnimationFrame(invokeNow);
392
+ } else {
393
+ rafHandle = setTimeout(invokeNow, 16);
394
+ }
395
+ return;
396
+ }
397
+
398
+ if (options.flush === 'sync') {
399
+ invokeNow();
400
+ return;
401
+ }
402
+
403
+ if (microtaskQueued) {
404
+ return;
405
+ }
406
+ microtaskQueued = true;
407
+ queueMicrotask(invokeNow);
408
+ }
409
+
410
+ return {
411
+ schedule,
412
+ cancel: clearScheduledWork
413
+ };
414
+ }
415
+
416
+ function queueWhenScopeReady(scope, callback) {
417
+ if (!scope || scope.disposed) {
418
+ return;
419
+ }
420
+ if (scope.mountReady) {
421
+ callback();
422
+ return;
423
+ }
424
+ scope.pendingMounts.push(callback);
425
+ }
426
+
427
+ function createAutoTrackedEffect(effect, options, scope) {
428
+ let disposed = false;
429
+ const activeSubscriptions = new Map();
430
+ const runCleanups = [];
431
+
432
+ _effectIdCounter += 1;
433
+ const effectId = _effectIdCounter;
434
+
435
+ function registerCleanup(cleanup) {
436
+ if (typeof cleanup !== 'function') {
437
+ throw new Error('[Zenith Runtime] cleanup(fn) requires a function');
438
+ }
439
+ runCleanups.push(cleanup);
440
+ }
441
+
442
+ function runEffectNow() {
443
+ if (disposed || !scope || scope.disposed) {
444
+ return;
445
+ }
446
+
447
+ drainCleanupStack(runCleanups);
448
+
449
+ const nextDependenciesById = new Map();
450
+ const previousCollector = _activeDependencyCollector;
451
+
452
+ _activeDependencyCollector = (source) => {
453
+ if (!source || typeof source.subscribe !== 'function') {
454
+ return;
455
+ }
456
+ const reactiveId = Number.isInteger(source.__zenith_id) ? source.__zenith_id : 0;
457
+ if (!nextDependenciesById.has(reactiveId)) {
458
+ nextDependenciesById.set(reactiveId, source);
459
+ }
460
+ };
461
+
462
+ try {
463
+ const result = effect(createEffectContext(registerCleanup));
464
+ applyCleanupResult(result, registerCleanup);
465
+ } finally {
466
+ _activeDependencyCollector = previousCollector;
467
+ }
468
+
469
+ const nextDependencies = Array.from(nextDependenciesById.values()).sort((left, right) => {
470
+ const leftId = Number.isInteger(left.__zenith_id) ? left.__zenith_id : 0;
471
+ const rightId = Number.isInteger(right.__zenith_id) ? right.__zenith_id : 0;
472
+ return leftId - rightId;
473
+ });
474
+
475
+ const nextSet = new Set(nextDependencies);
476
+
477
+ for (const [dependency, unsubscribe] of activeSubscriptions.entries()) {
478
+ if (nextSet.has(dependency)) {
479
+ continue;
480
+ }
481
+ if (typeof unsubscribe === 'function') {
482
+ unsubscribe();
483
+ }
484
+ activeSubscriptions.delete(dependency);
485
+ }
486
+
487
+ for (let i = 0; i < nextDependencies.length; i++) {
488
+ const dependency = nextDependencies[i];
489
+ if (activeSubscriptions.has(dependency)) {
490
+ continue;
491
+ }
492
+
493
+ const unsubscribe = dependency.subscribe(() => {
494
+ scheduler.schedule();
495
+ });
496
+
497
+ activeSubscriptions.set(
498
+ dependency,
499
+ typeof unsubscribe === 'function' ? unsubscribe : () => { }
500
+ );
501
+ }
502
+
503
+ void effectId;
504
+ }
505
+
506
+ const scheduler = createScheduler(runEffectNow, options);
507
+
508
+ function disposeEffect() {
509
+ if (disposed) {
510
+ return;
511
+ }
512
+ disposed = true;
513
+
514
+ scheduler.cancel();
515
+
516
+ for (const unsubscribe of activeSubscriptions.values()) {
517
+ if (typeof unsubscribe === 'function') {
518
+ unsubscribe();
519
+ }
520
+ }
521
+ activeSubscriptions.clear();
522
+
523
+ drainCleanupStack(runCleanups);
524
+ }
525
+
526
+ registerScopeDisposer(scope, disposeEffect);
527
+ queueWhenScopeReady(scope, () => scheduler.schedule());
528
+
529
+ return disposeEffect;
530
+ }
531
+
532
+ function createExplicitDependencyEffect(effect, dependencies, scope) {
533
+ if (!Array.isArray(dependencies)) {
534
+ throw new Error('[Zenith Runtime] zeneffect(deps, fn) requires an array of dependencies');
535
+ }
536
+
537
+ if (dependencies.length === 0) {
538
+ throw new Error('[Zenith Runtime] zeneffect(deps, fn) requires at least one dependency');
539
+ }
540
+
541
+ if (typeof effect !== 'function') {
542
+ throw new Error('[Zenith Runtime] zeneffect(deps, fn) requires a function');
543
+ }
544
+
545
+ const unsubscribers = dependencies.map((dep, index) => {
546
+ if (!dep || typeof dep.subscribe !== 'function') {
547
+ throw new Error(`[Zenith Runtime] zeneffect dependency at index ${index} must expose subscribe(fn)`);
548
+ }
549
+
550
+ return dep.subscribe(() => {
551
+ effect();
552
+ });
553
+ });
554
+
555
+ effect();
556
+
557
+ return function dispose() {
558
+ for (let i = 0; i < unsubscribers.length; i++) {
559
+ unsubscribers[i]();
560
+ }
561
+ };
562
+ }
563
+
564
+ export function zenEffect(effect, options = null, scopeOverride = null) {
565
+ if (typeof effect !== 'function') {
566
+ throw new Error('[Zenith Runtime] zenEffect(effect) requires a callback function');
567
+ }
568
+
569
+ const opts = normalizeEffectOptions(options);
570
+ const scope = resolveScope(scopeOverride);
571
+
572
+ return createAutoTrackedEffect(effect, opts, scope);
573
+ }
574
+
575
+ export function zeneffect(effectOrDependencies, optionsOrEffect, scopeOverride = null) {
576
+ if (Array.isArray(effectOrDependencies)) {
577
+ if (typeof optionsOrEffect !== 'function') {
578
+ throw new Error('[Zenith Runtime] zeneffect(deps, effect) requires an effect function');
579
+ }
580
+ return createExplicitDependencyEffect(optionsOrEffect, effectOrDependencies, resolveScope(scopeOverride));
581
+ }
582
+
583
+ if (typeof effectOrDependencies === 'function') {
584
+ return createAutoTrackedEffect(
585
+ effectOrDependencies,
586
+ normalizeEffectOptions(optionsOrEffect),
587
+ resolveScope(scopeOverride)
588
+ );
589
+ }
590
+
591
+ throw new Error('[Zenith Runtime] zeneffect() invalid arguments. Expected (effect) or (dependencies, effect)');
592
+ }
593
+
594
+ export function zenMount(callback, scopeOverride = null) {
595
+ if (typeof callback !== 'function') {
596
+ throw new Error('[Zenith Runtime] zenMount(callback) requires a function');
597
+ }
598
+
599
+ const scope = resolveScope(scopeOverride);
600
+ const cleanups = [];
601
+ let executed = false;
602
+ let registeredDisposer = null;
603
+
604
+ function registerCleanup(fn) {
605
+ if (typeof fn !== 'function') {
606
+ throw new Error('[Zenith Runtime] cleanup(fn) requires a function');
607
+ }
608
+ cleanups.push(fn);
609
+ }
610
+
611
+ function runMount() {
612
+ if (scope.disposed || executed) {
613
+ return;
614
+ }
615
+
616
+ executed = true;
617
+
618
+ try {
619
+ const result = callback(createMountContext(registerCleanup));
620
+ applyCleanupResult(result, registerCleanup);
621
+ } catch (error) {
622
+ // Unhandled mount errors shouldn't crash hydration, but we log them
623
+ console.error('[Zenith Runtime] Unhandled error during zenMount:', error);
624
+ }
625
+
626
+ registeredDisposer = registerScopeDisposer(scope, () => {
627
+ drainCleanupStack(cleanups);
628
+ });
629
+ }
630
+
631
+ queueWhenScopeReady(scope, runMount);
632
+
633
+ return function dispose() {
634
+ if (registeredDisposer) {
635
+ registeredDisposer();
636
+ } else {
637
+ drainCleanupStack(cleanups);
638
+ }
639
+ };
640
+ }
package/package.json CHANGED
@@ -1,19 +1,32 @@
1
1
  {
2
2
  "name": "@zenithbuild/runtime",
3
- "version": "0.2.0",
3
+ "version": "0.5.0-beta.2.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
- "types": "./dist/index.d.ts",
7
6
  "exports": {
8
7
  ".": "./dist/index.js"
9
8
  },
10
9
  "publishConfig": {
11
10
  "access": "public"
12
11
  },
12
+ "files": [
13
+ "dist",
14
+ "RUNTIME_CONTRACT.md",
15
+ "HYDRATION_CONTRACT.md",
16
+ "README.md",
17
+ "dist/**",
18
+ "LICENSE",
19
+ "package.json"
20
+ ],
13
21
  "scripts": {
14
- "build": "tsc"
22
+ "build": "mkdir -p dist && cp -a src/* dist/",
23
+ "test": "npm run build && NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.js",
24
+ "prepublishOnly": "npm run build"
15
25
  },
16
26
  "devDependencies": {
17
- "typescript": "^5.3.3"
18
- }
19
- }
27
+ "@jest/globals": "^30.2.0",
28
+ "jest": "^30.2.0",
29
+ "jest-environment-jsdom": "^30.2.0"
30
+ },
31
+ "private": false
32
+ }