@zenithbuild/runtime 0.5.0-beta.2.5 → 0.6.0

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.
@@ -1,5 +1,8 @@
1
1
  # Zenith Runtime V0 Hydration Contract
2
2
 
3
+ Canonical public docs: `../zenith-docs/documentation/contracts/hydration-contract.md`
4
+
5
+
3
6
  Status: FROZEN (V0)
4
7
 
5
8
  This contract locks hydration and reactivity boundaries for Zenith V0.
@@ -96,3 +99,17 @@ Forbidden in runtime/bundler output:
96
99
  - `process.env`
97
100
 
98
101
  Compile-time guarantees override runtime flexibility.
102
+
103
+ ## 8. Freeze Boundary Contract
104
+
105
+ Runtime payload freezing is allowed only for deterministic internal tables and
106
+ plain JSON-like containers controlled by runtime (`Object` / `Array`).
107
+
108
+ Runtime MUST NOT freeze:
109
+ - `ref()` objects (objects with writable `.current`)
110
+ - function values (handlers/callbacks)
111
+ - host/platform objects (`Node`, `EventTarget`, `URL`, `Request`, `Response`, etc.)
112
+
113
+ Rationale:
114
+ - hydration and `zenMount` must be able to assign `ref.current` without throwing
115
+ - host objects preserve platform mutability semantics
package/README.md CHANGED
@@ -5,6 +5,12 @@
5
5
 
6
6
  The core runtime library for the Zenith framework.
7
7
 
8
+ ## Canonical Docs
9
+
10
+ - Runtime contract: `../zenith-docs/documentation/contracts/runtime-contract.md`
11
+ - Hydration contract: `../zenith-docs/documentation/contracts/hydration-contract.md`
12
+ - Reactive binding model: `../zenith-docs/documentation/reference/reactive-binding-model.md`
13
+
8
14
  ## Overview
9
15
  This package provides the reactivity system, hydration logic, and Virtual DOM primitives used by Zenith applications. It is designed to be lightweight, fast, and tree-shakeable.
10
16
 
@@ -1,5 +1,8 @@
1
1
  # RUNTIME_CONTRACT.md — Sealed Runtime Interface
2
2
 
3
+ Canonical public docs: `../zenith-docs/documentation/contracts/runtime-contract.md`
4
+
5
+
3
6
  > **This document is a legal boundary.**
4
7
  > The runtime is a consumer of bundler output.
5
8
  > It does not reinterpret, normalize, or extend.
package/dist/env.js ADDED
@@ -0,0 +1,22 @@
1
+ // ---------------------------------------------------------------------------
2
+ // env.js — Zenith Runtime canonical environment accessors
3
+ // ---------------------------------------------------------------------------
4
+ // SSR-safe access to window and document. Returns null when not in browser.
5
+ // Use zenWindow() / zenDocument() instead of direct window/document access.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * SSR-safe window accessor.
10
+ * @returns {Window | null}
11
+ */
12
+ export function zenWindow() {
13
+ return typeof window === 'undefined' ? null : window;
14
+ }
15
+
16
+ /**
17
+ * SSR-safe document accessor.
18
+ * @returns {Document | null}
19
+ */
20
+ export function zenDocument() {
21
+ return typeof document === 'undefined' ? null : document;
22
+ }
package/dist/hydrate.js CHANGED
@@ -32,8 +32,6 @@ const BOOLEAN_ATTRIBUTES = new Set([
32
32
  const STRICT_MEMBER_CHAIN_LITERAL_RE = /^(?:true|false|null|undefined|[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*)$/;
33
33
  const UNSAFE_MEMBER_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
34
34
 
35
- const COMPILED_LITERAL_CACHE = new Map();
36
-
37
35
  /**
38
36
  * Hydrate a pre-rendered DOM tree using explicit payload tables.
39
37
  *
@@ -43,6 +41,7 @@ const COMPILED_LITERAL_CACHE = new Map();
43
41
  * expressions: Array<{ marker_index: number, signal_index?: number|null, state_index?: number|null, component_instance?: string|null, component_binding?: string|null, literal?: string|null }>,
44
42
  * markers: Array<{ index: number, kind: 'text' | 'attr' | 'event', selector: string, attr?: string }>,
45
43
  * events: Array<{ index: number, event: string, selector: string }>,
44
+ * refs?: Array<{ index: number, state_index: number, selector: string }>,
46
45
  * state_values: Array<*>,
47
46
  * state_keys?: Array<string>,
48
47
  * signals: Array<{ id: number, kind: 'signal', state_index: number }>,
@@ -55,7 +54,22 @@ export function hydrate(payload) {
55
54
  try {
56
55
  const normalized = _validatePayload(payload);
57
56
  _deepFreezePayload(payload);
58
- const { root, expressions, markers, events, stateValues, stateKeys, signals, components, route, params, ssrData, props } = normalized;
57
+ const {
58
+ root,
59
+ expressions,
60
+ markers,
61
+ events,
62
+ refs,
63
+ stateValues,
64
+ stateKeys,
65
+ signals,
66
+ components,
67
+ route,
68
+ params,
69
+ ssrData,
70
+ props,
71
+ exprFns
72
+ } = normalized;
59
73
 
60
74
  const componentBindings = Object.create(null);
61
75
 
@@ -72,6 +86,23 @@ export function hydrate(payload) {
72
86
  signalMap.set(i, candidate);
73
87
  }
74
88
 
89
+ const hydratedRefs = [];
90
+ for (let i = 0; i < refs.length; i++) {
91
+ const refBinding = refs[i];
92
+ const targetRef = stateValues[refBinding.state_index];
93
+ const nodes = _resolveNodes(root, refBinding.selector, refBinding.index, 'ref');
94
+ targetRef.current = nodes[0] || null;
95
+ hydratedRefs.push(targetRef);
96
+ }
97
+
98
+ if (hydratedRefs.length > 0) {
99
+ _registerDisposer(() => {
100
+ for (let i = 0; i < hydratedRefs.length; i++) {
101
+ hydratedRefs[i].current = null;
102
+ }
103
+ });
104
+ }
105
+
75
106
  for (let i = 0; i < components.length; i++) {
76
107
  const component = components[i];
77
108
  const resolvedProps = Object.freeze(_resolveComponentProps(component.props || [], signalMap, {
@@ -149,7 +180,8 @@ export function hydrate(payload) {
149
180
  params,
150
181
  ssrData,
151
182
  marker.kind,
152
- props
183
+ props,
184
+ exprFns
153
185
  );
154
186
  _applyMarkerValue(nodes, marker, value);
155
187
  }
@@ -176,7 +208,8 @@ export function hydrate(payload) {
176
208
  params,
177
209
  ssrData,
178
210
  marker.kind,
179
- props
211
+ props,
212
+ exprFns
180
213
  );
181
214
  _applyMarkerValue(nodes, marker, value);
182
215
  }
@@ -184,13 +217,17 @@ export function hydrate(payload) {
184
217
  const dependentMarkersBySignal = new Map();
185
218
  for (let i = 0; i < expressions.length; i++) {
186
219
  const expression = expressions[i];
187
- if (!Number.isInteger(expression.signal_index)) {
220
+ const signalIndices = _resolveExpressionSignalIndices(expression);
221
+ if (signalIndices.length === 0) {
188
222
  continue;
189
223
  }
190
- if (!dependentMarkersBySignal.has(expression.signal_index)) {
191
- dependentMarkersBySignal.set(expression.signal_index, []);
224
+ for (let j = 0; j < signalIndices.length; j++) {
225
+ const signalIndex = signalIndices[j];
226
+ if (!dependentMarkersBySignal.has(signalIndex)) {
227
+ dependentMarkersBySignal.set(signalIndex, []);
228
+ }
229
+ dependentMarkersBySignal.get(signalIndex).push(expression.marker_index);
192
230
  }
193
- dependentMarkersBySignal.get(expression.signal_index).push(expression.marker_index);
194
231
  }
195
232
 
196
233
  for (const [signalId, markerIndexes] of dependentMarkersBySignal.entries()) {
@@ -246,6 +283,7 @@ export function hydrate(payload) {
246
283
  }
247
284
 
248
285
  const eventIndices = new Set();
286
+ const escDispatchEntries = [];
249
287
  for (let i = 0; i < events.length; i++) {
250
288
  const eventBinding = events[i];
251
289
  if (eventIndices.has(eventBinding.index)) {
@@ -262,7 +300,9 @@ export function hydrate(payload) {
262
300
  componentBindings,
263
301
  params,
264
302
  ssrData,
265
- 'event'
303
+ 'event',
304
+ props || {},
305
+ exprFns
266
306
  );
267
307
  if (typeof handler !== 'function') {
268
308
  throwZenithRuntimeError({
@@ -291,11 +331,67 @@ export function hydrate(payload) {
291
331
  });
292
332
  }
293
333
  };
334
+ if (eventBinding.event === 'esc') {
335
+ escDispatchEntries.push({
336
+ node,
337
+ handler: wrappedHandler
338
+ });
339
+ continue;
340
+ }
294
341
  node.addEventListener(eventBinding.event, wrappedHandler);
295
342
  _registerListener(node, eventBinding.event, wrappedHandler);
296
343
  }
297
344
  }
298
345
 
346
+ if (escDispatchEntries.length > 0) {
347
+ const doc = root && root.ownerDocument ? root.ownerDocument : (typeof document !== 'undefined' ? document : null);
348
+ if (doc && typeof doc.addEventListener === 'function') {
349
+ const escDispatchListener = function zenithEscDispatch(event) {
350
+ if (!event || event.key !== 'Escape') {
351
+ return;
352
+ }
353
+
354
+ const activeElement = doc.activeElement || null;
355
+ let targetEntry = null;
356
+
357
+ if (activeElement && activeElement !== doc.body && activeElement !== doc.documentElement) {
358
+ for (let i = escDispatchEntries.length - 1; i >= 0; i--) {
359
+ const entry = escDispatchEntries[i];
360
+ if (!entry || !entry.node) {
361
+ continue;
362
+ }
363
+ if (!entry.node.isConnected) {
364
+ continue;
365
+ }
366
+ if (typeof entry.node.contains === 'function' && entry.node.contains(activeElement)) {
367
+ targetEntry = entry;
368
+ break;
369
+ }
370
+ }
371
+ }
372
+
373
+ if (!targetEntry && (activeElement === null || activeElement === doc.body || activeElement === doc.documentElement)) {
374
+ for (let i = escDispatchEntries.length - 1; i >= 0; i--) {
375
+ const entry = escDispatchEntries[i];
376
+ if (!entry || !entry.node || !entry.node.isConnected) {
377
+ continue;
378
+ }
379
+ targetEntry = entry;
380
+ break;
381
+ }
382
+ }
383
+
384
+ if (!targetEntry) {
385
+ return;
386
+ }
387
+
388
+ return targetEntry.handler.call(targetEntry.node, event);
389
+ };
390
+ doc.addEventListener('keydown', escDispatchListener);
391
+ _registerListener(doc, 'keydown', escDispatchListener);
392
+ }
393
+ }
394
+
299
395
  return cleanup;
300
396
  } catch (error) {
301
397
  rethrowZenithRuntimeError(error, {
@@ -336,6 +432,8 @@ function _validatePayload(payload) {
336
432
  throw new Error('[Zenith Runtime] hydrate(payload) requires events[]');
337
433
  }
338
434
 
435
+ const refs = Array.isArray(payload.refs) ? payload.refs : [];
436
+
339
437
  const stateValues = payload.state_values;
340
438
  if (!Array.isArray(stateValues)) {
341
439
  throw new Error('[Zenith Runtime] hydrate(payload) requires state_values[]');
@@ -365,6 +463,7 @@ function _validatePayload(payload) {
365
463
  const ssrData = payload.ssr_data && typeof payload.ssr_data === 'object'
366
464
  ? payload.ssr_data
367
465
  : {};
466
+ const exprFns = Array.isArray(payload.expr_fns) ? payload.expr_fns : [];
368
467
 
369
468
  if (markers.length !== expressions.length) {
370
469
  throw new Error(
@@ -385,6 +484,23 @@ function _validatePayload(payload) {
385
484
  `[Zenith Runtime] expression table out of order at position ${i}: marker_index=${expression.marker_index}`
386
485
  );
387
486
  }
487
+ if (expression.fn_index !== undefined && expression.fn_index !== null) {
488
+ if (!Number.isInteger(expression.fn_index) || expression.fn_index < 0) {
489
+ throw new Error(`[Zenith Runtime] expression at position ${i} has invalid fn_index`);
490
+ }
491
+ }
492
+ if (expression.signal_indices !== undefined) {
493
+ if (!Array.isArray(expression.signal_indices)) {
494
+ throw new Error(`[Zenith Runtime] expression at position ${i} must provide signal_indices[]`);
495
+ }
496
+ for (let j = 0; j < expression.signal_indices.length; j++) {
497
+ if (!Number.isInteger(expression.signal_indices[j]) || expression.signal_indices[j] < 0) {
498
+ throw new Error(
499
+ `[Zenith Runtime] expression at position ${i} has invalid signal_indices[${j}]`
500
+ );
501
+ }
502
+ }
503
+ }
388
504
  }
389
505
 
390
506
  for (let i = 0; i < markers.length; i++) {
@@ -425,6 +541,34 @@ function _validatePayload(payload) {
425
541
  }
426
542
  }
427
543
 
544
+ for (let i = 0; i < refs.length; i++) {
545
+ const refBinding = refs[i];
546
+ if (!refBinding || typeof refBinding !== 'object' || Array.isArray(refBinding)) {
547
+ throw new Error(`[Zenith Runtime] ref binding at position ${i} must be an object`);
548
+ }
549
+ if (!Number.isInteger(refBinding.index) || refBinding.index < 0) {
550
+ throw new Error(`[Zenith Runtime] ref binding at position ${i} requires non-negative index`);
551
+ }
552
+ if (
553
+ !Number.isInteger(refBinding.state_index) ||
554
+ refBinding.state_index < 0 ||
555
+ refBinding.state_index >= stateValues.length
556
+ ) {
557
+ throw new Error(
558
+ `[Zenith Runtime] ref binding at position ${i} has out-of-bounds state_index`
559
+ );
560
+ }
561
+ if (typeof refBinding.selector !== 'string' || refBinding.selector.length === 0) {
562
+ throw new Error(`[Zenith Runtime] ref binding at position ${i} requires selector`);
563
+ }
564
+ const candidate = stateValues[refBinding.state_index];
565
+ if (!candidate || typeof candidate !== 'object' || !Object.prototype.hasOwnProperty.call(candidate, 'current')) {
566
+ throw new Error(
567
+ `[Zenith Runtime] ref binding at position ${i} must resolve to a ref-like object`
568
+ );
569
+ }
570
+ }
571
+
428
572
  for (let i = 0; i < signals.length; i++) {
429
573
  const signalDescriptor = signals[i];
430
574
  if (!signalDescriptor || typeof signalDescriptor !== 'object' || Array.isArray(signalDescriptor)) {
@@ -478,13 +622,18 @@ function _validatePayload(payload) {
478
622
  for (let i = 0; i < expressions.length; i++) Object.freeze(expressions[i]);
479
623
  for (let i = 0; i < markers.length; i++) Object.freeze(markers[i]);
480
624
  for (let i = 0; i < events.length; i++) Object.freeze(events[i]);
625
+ for (let i = 0; i < refs.length; i++) Object.freeze(refs[i]);
481
626
  for (let i = 0; i < signals.length; i++) Object.freeze(signals[i]);
482
627
  for (let i = 0; i < components.length; i++) {
483
628
  const c = components[i];
484
629
  if (Array.isArray(c.props)) {
485
630
  for (let j = 0; j < c.props.length; j++) {
486
631
  const propDesc = c.props[j];
487
- if (propDesc && typeof propDesc === 'object' && propDesc.value && typeof propDesc.value === 'object') {
632
+ if (
633
+ propDesc &&
634
+ typeof propDesc === 'object' &&
635
+ _isHydrationFreezableContainer(propDesc.value)
636
+ ) {
488
637
  Object.freeze(propDesc.value);
489
638
  }
490
639
  Object.freeze(propDesc);
@@ -497,6 +646,7 @@ function _validatePayload(payload) {
497
646
  Object.freeze(expressions);
498
647
  Object.freeze(markers);
499
648
  Object.freeze(events);
649
+ Object.freeze(refs);
500
650
  Object.freeze(signals);
501
651
  Object.freeze(components);
502
652
 
@@ -505,6 +655,7 @@ function _validatePayload(payload) {
505
655
  expressions,
506
656
  markers,
507
657
  events,
658
+ refs,
508
659
  stateValues,
509
660
  stateKeys,
510
661
  signals,
@@ -512,7 +663,8 @@ function _validatePayload(payload) {
512
663
  route,
513
664
  params: Object.freeze(params),
514
665
  ssrData: Object.freeze(ssrData),
515
- props: Object.freeze(props)
666
+ props: Object.freeze(props),
667
+ exprFns: Object.freeze(exprFns)
516
668
  };
517
669
 
518
670
  return Object.freeze(validatedPayload);
@@ -576,7 +728,34 @@ function _resolveNodes(root, selector, index, kind) {
576
728
  return nodes;
577
729
  }
578
730
 
579
- function _evaluateExpression(binding, stateValues, stateKeys, signalMap, componentBindings, params, ssrData, mode, props) {
731
+ function _resolveExpressionSignalIndices(binding) {
732
+ if (!binding || typeof binding !== 'object') {
733
+ return [];
734
+ }
735
+ if (Array.isArray(binding.signal_indices) && binding.signal_indices.length > 0) {
736
+ return [...new Set(binding.signal_indices.filter((value) => Number.isInteger(value) && value >= 0))];
737
+ }
738
+ if (Number.isInteger(binding.signal_index) && binding.signal_index >= 0) {
739
+ return [binding.signal_index];
740
+ }
741
+ return [];
742
+ }
743
+
744
+ function _evaluateExpression(binding, stateValues, stateKeys, signalMap, componentBindings, params, ssrData, mode, props, exprFns) {
745
+ if (binding.fn_index != null && binding.fn_index !== undefined) {
746
+ const fns = Array.isArray(exprFns) ? exprFns : [];
747
+ const fn = fns[binding.fn_index];
748
+ if (typeof fn === 'function') {
749
+ return fn({
750
+ signalMap,
751
+ params,
752
+ ssrData,
753
+ props: props || {},
754
+ componentBindings,
755
+ zenhtml: _zenhtml
756
+ });
757
+ }
758
+ }
580
759
  if (binding.signal_index !== null && binding.signal_index !== undefined) {
581
760
  const signalValue = signalMap.get(binding.signal_index);
582
761
  if (!signalValue || typeof signalValue.get !== 'function') {
@@ -647,17 +826,9 @@ function _evaluateExpression(binding, stateValues, stateKeys, signalMap, compone
647
826
  return props || {};
648
827
  }
649
828
 
650
- const evaluated = _evaluateLiteralExpression(
651
- trimmedLiteral,
652
- stateValues,
653
- stateKeys,
654
- params,
655
- ssrData,
656
- mode,
657
- props
658
- );
659
- if (evaluated !== UNRESOLVED_LITERAL) {
660
- return evaluated;
829
+ const primitiveValue = _resolvePrimitiveLiteral(trimmedLiteral);
830
+ if (primitiveValue !== UNRESOLVED_LITERAL) {
831
+ return primitiveValue;
661
832
  }
662
833
  if (_isLikelyExpressionLiteral(trimmedLiteral)) {
663
834
  throwZenithRuntimeError({
@@ -780,33 +951,39 @@ function _resolveStrictMemberChainLiteral(
780
951
  return cursor;
781
952
  }
782
953
 
783
- function _evaluateLiteralExpression(expression, stateValues, stateKeys, params, ssrData, mode, props) {
784
- const scope = _buildLiteralScope(stateValues, stateKeys, params, ssrData, mode, props);
785
- const scopeKeys = Object.keys(scope);
786
- const scopeValues = scopeKeys.map((key) => scope[key]);
787
- const rewritten = _rewriteMarkupLiterals(expression);
788
- const cacheKey = `${scopeKeys.join('|')}::${rewritten}`;
954
+ function _resolvePrimitiveLiteral(literal) {
955
+ if (typeof literal !== 'string') {
956
+ return UNRESOLVED_LITERAL;
957
+ }
958
+ if (literal === 'true') return true;
959
+ if (literal === 'false') return false;
960
+ if (literal === 'null') return null;
961
+ if (literal === 'undefined') return undefined;
789
962
 
790
- let evaluator = COMPILED_LITERAL_CACHE.get(cacheKey);
791
- if (!evaluator) {
963
+ if (/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(literal)) {
964
+ return Number(literal);
965
+ }
966
+
967
+ if (literal.length >= 2 && literal.startsWith('"') && literal.endsWith('"')) {
792
968
  try {
793
- evaluator = Function(
794
- ...scopeKeys,
795
- `return (${rewritten});`
796
- );
797
- } catch (err) {
798
- if (isZenithRuntimeError(err)) throw err;
969
+ return JSON.parse(literal);
970
+ } catch {
799
971
  return UNRESOLVED_LITERAL;
800
972
  }
801
- COMPILED_LITERAL_CACHE.set(cacheKey, evaluator);
802
973
  }
803
974
 
804
- try {
805
- return evaluator(...scopeValues);
806
- } catch (err) {
807
- if (isZenithRuntimeError(err)) throw err;
808
- return UNRESOLVED_LITERAL;
975
+ if (literal.length >= 2 && literal.startsWith('\'') && literal.endsWith('\'')) {
976
+ return literal
977
+ .slice(1, -1)
978
+ .replace(/\\\\/g, '\\')
979
+ .replace(/\\'/g, '\'');
980
+ }
981
+
982
+ if (literal.length >= 2 && literal.startsWith('`') && literal.endsWith('`')) {
983
+ return literal.slice(1, -1);
809
984
  }
985
+
986
+ return UNRESOLVED_LITERAL;
810
987
  }
811
988
 
812
989
  function _buildLiteralScope(stateValues, stateKeys, params, ssrData, mode, props) {
@@ -1580,10 +1757,7 @@ function _applyAttribute(node, attrName, value) {
1580
1757
  }
1581
1758
 
1582
1759
  function _deepFreezePayload(obj) {
1583
- if (!obj || typeof obj !== 'object' || Object.isFrozen(obj)) return;
1584
- // Skip DOM nodes, signals (objects with get/subscribe), and functions
1585
- if (typeof obj.nodeType === 'number') return;
1586
- if (typeof obj.get === 'function' && typeof obj.subscribe === 'function') return;
1760
+ if (!_isHydrationFreezableContainer(obj) || Object.isFrozen(obj)) return;
1587
1761
 
1588
1762
  Object.freeze(obj);
1589
1763
  const keys = Object.keys(obj);
@@ -1594,3 +1768,47 @@ function _deepFreezePayload(obj) {
1594
1768
  }
1595
1769
  }
1596
1770
  }
1771
+
1772
+ function _isHydrationRefObject(obj) {
1773
+ if (!obj || typeof obj !== 'object') {
1774
+ return false;
1775
+ }
1776
+ if (obj.__zenith_ref === true) {
1777
+ return true;
1778
+ }
1779
+ if (!Object.prototype.hasOwnProperty.call(obj, 'current')) {
1780
+ return false;
1781
+ }
1782
+ if (typeof obj.get === 'function' && typeof obj.subscribe === 'function') {
1783
+ return false;
1784
+ }
1785
+ const keys = Object.keys(obj);
1786
+ if (keys.length === 1 && keys[0] === 'current') {
1787
+ return true;
1788
+ }
1789
+ if (keys.length === 2 && keys.includes('current') && keys.includes('__zenith_ref')) {
1790
+ return true;
1791
+ }
1792
+ return false;
1793
+ }
1794
+
1795
+ function _isPlainObject(value) {
1796
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1797
+ return false;
1798
+ }
1799
+ const proto = Object.getPrototypeOf(value);
1800
+ return proto === Object.prototype || proto === null;
1801
+ }
1802
+
1803
+ function _isHydrationFreezableContainer(value) {
1804
+ if (Array.isArray(value)) return true;
1805
+ if (!_isPlainObject(value)) return false;
1806
+
1807
+ if (_isHydrationRefObject(value)) {
1808
+ return false;
1809
+ }
1810
+ if (typeof value.get === 'function' && typeof value.subscribe === 'function') {
1811
+ return false;
1812
+ }
1813
+ return true;
1814
+ }
package/dist/index.js CHANGED
@@ -2,3 +2,5 @@ export { signal } from './signal.js';
2
2
  export { state } from './state.js';
3
3
  export { zeneffect } from './zeneffect.js';
4
4
  export { hydrate } from './hydrate.js';
5
+ export { zenWindow, zenDocument } from './env.js';
6
+ export { zenOn, zenResize, collectRefs } from './platform.js';
@@ -0,0 +1,102 @@
1
+ // ---------------------------------------------------------------------------
2
+ // platform.js — Zenith Runtime canonical DOM/platform helpers
3
+ // ---------------------------------------------------------------------------
4
+ // zenOn: SSR-safe event subscription with disposer
5
+ // zenResize: window resize handler with rAF throttle
6
+ // collectRefs: deterministic null-filtered ref collection
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { zenWindow } from './env.js';
10
+
11
+ /**
12
+ * SSR-safe event subscription. Returns disposer.
13
+ * @param {EventTarget | null} target
14
+ * @param {string} eventName
15
+ * @param {EventListener} handler
16
+ * @param {AddEventListenerOptions | boolean} [options]
17
+ * @returns {() => void}
18
+ */
19
+ export function zenOn(target, eventName, handler, options) {
20
+ if (!target || typeof target.addEventListener !== 'function') {
21
+ return () => {};
22
+ }
23
+ target.addEventListener(eventName, handler, options);
24
+ return () => {
25
+ target.removeEventListener(eventName, handler, options);
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Window resize handler with requestAnimationFrame throttle.
31
+ * Returns disposer.
32
+ * @param {(size: { w: number; h: number }) => void} handler
33
+ * @returns {() => void}
34
+ */
35
+ export function zenResize(handler) {
36
+ const win = zenWindow();
37
+ if (!win || typeof win.addEventListener !== 'function') {
38
+ return () => {};
39
+ }
40
+ const hasRaf =
41
+ typeof win.requestAnimationFrame === 'function'
42
+ && typeof win.cancelAnimationFrame === 'function';
43
+ let scheduledId = null;
44
+ let lastW = Number.NaN;
45
+ let lastH = Number.NaN;
46
+
47
+ const schedule = (callback) => {
48
+ if (hasRaf) {
49
+ return win.requestAnimationFrame(callback);
50
+ }
51
+ return win.setTimeout(callback, 0);
52
+ };
53
+
54
+ const cancel = (id) => {
55
+ if (hasRaf) {
56
+ win.cancelAnimationFrame(id);
57
+ return;
58
+ }
59
+ win.clearTimeout(id);
60
+ };
61
+
62
+ function onResize() {
63
+ if (scheduledId !== null) return;
64
+ scheduledId = schedule(() => {
65
+ scheduledId = null;
66
+ const w = win.innerWidth;
67
+ const h = win.innerHeight;
68
+ if (w !== lastW || h !== lastH) {
69
+ lastW = w;
70
+ lastH = h;
71
+ handler({ w, h });
72
+ }
73
+ });
74
+ }
75
+
76
+ win.addEventListener('resize', onResize);
77
+ onResize();
78
+
79
+ return () => {
80
+ if (scheduledId !== null) {
81
+ cancel(scheduledId);
82
+ scheduledId = null;
83
+ }
84
+ win.removeEventListener('resize', onResize);
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Deterministic null-filtered collection of ref.current values.
90
+ * @param {...{ current?: Element | null }} refs
91
+ * @returns {Element[]}
92
+ */
93
+ export function collectRefs(...refs) {
94
+ const out = [];
95
+ for (let i = 0; i < refs.length; i++) {
96
+ const node = refs[i] && refs[i].current;
97
+ if (node && typeof node === 'object' && typeof node.nodeType === 'number') {
98
+ out.push(node);
99
+ }
100
+ }
101
+ return out;
102
+ }
package/dist/template.js CHANGED
@@ -22,6 +22,8 @@ function buildRuntimeModuleSource() {
22
22
  const segments = [
23
23
  stripImports(readRuntimeSourceFile('zeneffect.js')),
24
24
  stripImports(readRuntimeSourceFile('ref.js')),
25
+ stripImports(readRuntimeSourceFile('env.js')),
26
+ stripImports(readRuntimeSourceFile('platform.js')),
25
27
  stripImports(readRuntimeSourceFile('signal.js')),
26
28
  stripImports(readRuntimeSourceFile('state.js')),
27
29
  stripImports(readRuntimeSourceFile('diagnostics.js')),
@@ -119,18 +121,101 @@ const RUNTIME_DEV_CLIENT_SOURCE = `(() => {
119
121
  });
120
122
  }
121
123
 
122
- function swapStylesheet(nextHref) {
124
+ let cssSwapEpoch = 0;
125
+
126
+ function withCacheBuster(nextHref) {
127
+ const separator = nextHref.includes('?') ? '&' : '?';
128
+ return nextHref + separator + '__zenith_dev=' + Date.now();
129
+ }
130
+
131
+ function isSameOriginStylesheet(href) {
132
+ if (typeof href !== 'string' || href.length === 0) {
133
+ return false;
134
+ }
135
+ if (href.startsWith('http://') || href.startsWith('https://')) {
136
+ try {
137
+ return new URL(href, window.location.href).origin === window.location.origin;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+ return true;
143
+ }
144
+
145
+ function findPrimaryStylesheet() {
146
+ const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
147
+ .filter(function (link) {
148
+ return isSameOriginStylesheet(link.getAttribute('href') || '');
149
+ });
150
+ if (links.length === 0) {
151
+ return null;
152
+ }
153
+ const marked = links.find(function (link) {
154
+ return link.getAttribute('data-zenith-dev-primary') === 'true';
155
+ });
156
+ if (marked) {
157
+ return marked;
158
+ }
159
+ links[0].setAttribute('data-zenith-dev-primary', 'true');
160
+ return links[0];
161
+ }
162
+
163
+ function scheduleCssRetry(previousHref, attempt) {
164
+ if (attempt >= 3) {
165
+ window.location.reload();
166
+ return;
167
+ }
168
+ const delayMs = (attempt + 1) * 100;
169
+ setTimeout(function () {
170
+ fetchDevState().then(function (statePayload) {
171
+ const href = statePayload && typeof statePayload.cssHref === 'string' && statePayload.cssHref.length > 0
172
+ ? statePayload.cssHref
173
+ : previousHref;
174
+ swapStylesheet(href, attempt + 1);
175
+ });
176
+ }, delayMs);
177
+ }
178
+
179
+ function swapStylesheet(nextHref, attempt) {
180
+ const tries = Number.isInteger(attempt) ? attempt : 0;
123
181
  if (typeof nextHref !== 'string' || nextHref.length === 0) {
124
182
  window.location.reload();
125
183
  return;
126
184
  }
127
- const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
128
- if (links.length === 0) {
185
+ const activeLink = findPrimaryStylesheet();
186
+ if (!activeLink) {
129
187
  window.location.reload();
130
188
  return;
131
189
  }
132
- const separator = nextHref.includes('?') ? '&' : '?';
133
- links[0].setAttribute('href', nextHref + separator + '__zenith_dev=' + Date.now());
190
+
191
+ const swapId = ++cssSwapEpoch;
192
+ const nextLink = activeLink.cloneNode(true);
193
+ nextLink.setAttribute('href', withCacheBuster(nextHref));
194
+ nextLink.removeAttribute('data-zenith-dev-primary');
195
+ nextLink.setAttribute('data-zenith-dev-pending', 'true');
196
+ activeLink.removeAttribute('data-zenith-dev-primary');
197
+
198
+ nextLink.addEventListener('load', function () {
199
+ if (swapId !== cssSwapEpoch) {
200
+ try { nextLink.remove(); } catch { }
201
+ return;
202
+ }
203
+ nextLink.removeAttribute('data-zenith-dev-pending');
204
+ nextLink.setAttribute('data-zenith-dev-primary', 'true');
205
+ try { activeLink.remove(); } catch { }
206
+ }, { once: true });
207
+
208
+ nextLink.addEventListener('error', function () {
209
+ if (swapId !== cssSwapEpoch) {
210
+ try { nextLink.remove(); } catch { }
211
+ return;
212
+ }
213
+ try { nextLink.remove(); } catch { }
214
+ activeLink.setAttribute('data-zenith-dev-primary', 'true');
215
+ scheduleCssRetry(nextHref, tries);
216
+ }, { once: true });
217
+
218
+ activeLink.insertAdjacentElement('afterend', nextLink);
134
219
  }
135
220
 
136
221
  const state = ensureDevState();
@@ -341,8 +426,10 @@ const RUNTIME_DEV_CLIENT_SOURCE = `(() => {
341
426
  appendLog('[css_update] ' + (payload.href || ''));
342
427
  emitDebug('css_update', payload);
343
428
  fetchDevState().then(function (statePayload) {
344
- if (statePayload && typeof statePayload.cssHref === 'string' && statePayload.cssHref.length > 0) {
429
+ if (statePayload) {
345
430
  updateInfo({ ...payload, ...statePayload });
431
+ }
432
+ if (statePayload && typeof statePayload.cssHref === 'string' && statePayload.cssHref.length > 0) {
346
433
  swapStylesheet(statePayload.cssHref);
347
434
  return;
348
435
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@zenithbuild/runtime",
3
- "version": "0.5.0-beta.2.5",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
7
- ".": "./dist/index.js"
7
+ ".": "./dist/index.js",
8
+ "./template": "./dist/template.js"
8
9
  },
9
10
  "publishConfig": {
10
11
  "access": "public"