@constela/runtime 0.11.0 → 0.12.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.
package/dist/index.d.ts CHANGED
@@ -17,6 +17,26 @@ type CleanupFn = () => void;
17
17
  type EffectFn = () => void | CleanupFn;
18
18
  declare function createEffect(fn: EffectFn): () => void;
19
19
 
20
+ /**
21
+ * Computed - Derived reactive value
22
+ *
23
+ * A Computed holds a derived value that is automatically recalculated
24
+ * when its dependencies change. It tracks Signal dependencies and
25
+ * memoizes results for efficiency.
26
+ */
27
+ interface Computed<T> {
28
+ get(): T;
29
+ subscribe?(fn: (value: T) => void): () => void;
30
+ }
31
+ /**
32
+ * Creates a computed value that automatically tracks dependencies
33
+ * and memoizes results.
34
+ *
35
+ * @param getter - Function that computes the derived value
36
+ * @returns Computed object with get() and subscribe() methods
37
+ */
38
+ declare function createComputed<T>(getter: () => T): Computed<T>;
39
+
20
40
  /**
21
41
  * StateStore - Centralized state management
22
42
  *
@@ -27,13 +47,62 @@ interface StateStore {
27
47
  get(name: string): unknown;
28
48
  set(name: string, value: unknown): void;
29
49
  subscribe(name: string, fn: (value: unknown) => void): () => void;
50
+ getPath(name: string, path: string | (string | number)[]): unknown;
51
+ setPath(name: string, path: string | (string | number)[], value: unknown): void;
52
+ subscribeToPath(name: string, path: string | (string | number)[], fn: (value: unknown) => void): () => void;
30
53
  }
31
54
  interface StateDefinition {
32
55
  type: string;
33
56
  initial: unknown;
34
57
  }
58
+ /**
59
+ * TypedStateStore - Generic interface for type-safe state access
60
+ *
61
+ * Usage:
62
+ * interface AppState {
63
+ * items: { id: number; liked: boolean }[];
64
+ * filter: string;
65
+ * }
66
+ * const state = createStateStore(definitions) as TypedStateStore<AppState>;
67
+ * state.get('items'); // returns { id: number; liked: boolean }[]
68
+ */
69
+ interface TypedStateStore<T extends Record<string, unknown>> extends StateStore {
70
+ get<K extends keyof T>(name: K): T[K];
71
+ set<K extends keyof T>(name: K, value: T[K]): void;
72
+ subscribe<K extends keyof T>(name: K, fn: (value: T[K]) => void): () => void;
73
+ getPath<K extends keyof T>(name: K, path: string | (string | number)[]): unknown;
74
+ setPath<K extends keyof T>(name: K, path: string | (string | number)[], value: unknown): void;
75
+ subscribeToPath<K extends keyof T>(name: K, path: string | (string | number)[], fn: (value: unknown) => void): () => void;
76
+ }
35
77
  declare function createStateStore(definitions: Record<string, StateDefinition>): StateStore;
36
78
 
79
+ /**
80
+ * Typed State Store - Helper for creating type-safe state stores
81
+ */
82
+
83
+ /**
84
+ * Creates a type-safe state store with inferred types
85
+ *
86
+ * @example
87
+ * interface AppState {
88
+ * items: { id: number; liked: boolean }[];
89
+ * filter: string;
90
+ * count: number;
91
+ * }
92
+ *
93
+ * const state = createTypedStateStore<AppState>({
94
+ * items: { type: 'list', initial: [] },
95
+ * filter: { type: 'string', initial: '' },
96
+ * count: { type: 'number', initial: 0 },
97
+ * });
98
+ *
99
+ * state.get('items'); // correctly typed as { id: number; liked: boolean }[]
100
+ * state.set('count', 10); // type-checked
101
+ */
102
+ declare function createTypedStateStore<T extends Record<string, unknown>>(definitions: {
103
+ [K in keyof T]: StateDefinition;
104
+ }): TypedStateStore<T>;
105
+
37
106
  /**
38
107
  * Expression Evaluator - Evaluates compiled expressions
39
108
  *
@@ -88,6 +157,91 @@ interface StyleExprInput {
88
157
  */
89
158
  declare function evaluateStyle(expr: StyleExprInput, ctx: EvaluationContext): string | undefined;
90
159
 
160
+ /**
161
+ * WebSocket Connection Module - Interface Stubs for TDD
162
+ *
163
+ * This file provides the interface definitions and stub implementations
164
+ * for WebSocket connection management in Constela runtime.
165
+ *
166
+ * TDD Red Phase: These stubs will fail tests until properly implemented.
167
+ */
168
+ /**
169
+ * WebSocket connection interface for sending/receiving data
170
+ */
171
+ interface WebSocketConnection {
172
+ /**
173
+ * Send data through the WebSocket connection
174
+ * Objects and arrays are JSON stringified
175
+ * @param data - The data to send
176
+ */
177
+ send(data: unknown): void;
178
+ /**
179
+ * Close the WebSocket connection
180
+ */
181
+ close(): void;
182
+ /**
183
+ * Get the current connection state
184
+ * @returns The connection state
185
+ */
186
+ getState(): 'connecting' | 'open' | 'closing' | 'closed';
187
+ }
188
+ /**
189
+ * Event handlers for WebSocket connection events
190
+ */
191
+ interface WebSocketHandlers {
192
+ /** Called when connection is established */
193
+ onOpen?: () => Promise<void> | void;
194
+ /** Called when connection is closed */
195
+ onClose?: (code: number, reason: string) => Promise<void> | void;
196
+ /** Called when an error occurs */
197
+ onError?: (error: Event) => Promise<void> | void;
198
+ /** Called when a message is received (JSON parsed if possible) */
199
+ onMessage?: (data: unknown) => Promise<void> | void;
200
+ }
201
+ /**
202
+ * Connection manager for named WebSocket connections
203
+ */
204
+ interface ConnectionManager {
205
+ /**
206
+ * Create a new named WebSocket connection
207
+ * If a connection with the same name exists, it will be closed first
208
+ */
209
+ create(name: string, url: string, handlers: WebSocketHandlers): void;
210
+ /**
211
+ * Get a connection by name
212
+ * @returns The connection or undefined if not found
213
+ */
214
+ get(name: string): WebSocketConnection | undefined;
215
+ /**
216
+ * Send data to a named connection
217
+ * @throws Error if connection not found
218
+ */
219
+ send(name: string, data: unknown): void;
220
+ /**
221
+ * Close a named connection
222
+ * No-op if connection not found
223
+ */
224
+ close(name: string): void;
225
+ /**
226
+ * Close all connections
227
+ */
228
+ closeAll(): void;
229
+ }
230
+ /**
231
+ * Create a WebSocket connection with event handlers
232
+ *
233
+ * @param url - The WebSocket URL (e.g., "wss://api.example.com/ws")
234
+ * @param handlers - Event handlers for connection lifecycle
235
+ * @returns A WebSocketConnection interface for sending/closing
236
+ */
237
+ declare function createWebSocketConnection(url: string, handlers: WebSocketHandlers): WebSocketConnection;
238
+ /**
239
+ * Create a connection manager for managing multiple named connections
240
+ *
241
+ * @returns A ConnectionManager interface
242
+ */
243
+ declare function createConnectionManager(): ConnectionManager;
244
+
91
245
  /**
92
246
  * Action Executor - Executes compiled action steps
93
247
  *
@@ -113,6 +267,7 @@ interface ActionContext {
113
267
  path: string;
114
268
  };
115
269
  imports?: Record<string, unknown>;
270
+ connections?: ConnectionManager;
116
271
  }
117
272
  declare function executeAction(action: CompiledAction, ctx: ActionContext): Promise<void>;
118
273
 
@@ -203,4 +358,4 @@ interface HydrateOptions {
203
358
  */
204
359
  declare function hydrateApp(options: HydrateOptions): AppInstance;
205
360
 
206
- export { type ActionContext, type AppInstance, type EvaluationContext, type HydrateOptions, type RenderContext, type Signal, type StateStore, type StylePreset, createApp, createEffect, createSignal, createStateStore, evaluate, evaluateStyle, executeAction, hydrateApp, render };
361
+ export { type ActionContext, type AppInstance, type Computed, type ConnectionManager, type EvaluationContext, type HydrateOptions, type RenderContext, type Signal, type StateStore, type StylePreset, type TypedStateStore, type WebSocketConnection, type WebSocketHandlers, createApp, createComputed, createConnectionManager, createEffect, createSignal, createStateStore, createTypedStateStore, createWebSocketConnection, evaluate, evaluateStyle, executeAction, hydrateApp, render };
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ var effectDependencies = /* @__PURE__ */ new Map();
9
9
  function setCurrentEffect(effect) {
10
10
  currentEffect = effect;
11
11
  }
12
+ function getCurrentEffect() {
13
+ return currentEffect;
14
+ }
12
15
  function registerEffectCleanup(effect) {
13
16
  if (!effectDependencies.has(effect)) {
14
17
  effectDependencies.set(effect, /* @__PURE__ */ new Set());
@@ -95,11 +98,139 @@ function createEffect(fn) {
95
98
  };
96
99
  }
97
100
 
101
+ // src/reactive/computed.ts
102
+ function createComputed(getter) {
103
+ let cachedValue;
104
+ let isDirty = true;
105
+ let isComputing = false;
106
+ let hasValue = false;
107
+ const subscribers = /* @__PURE__ */ new Set();
108
+ const effectSubscribers = /* @__PURE__ */ new Set();
109
+ const markDirty = () => {
110
+ if (!isDirty) {
111
+ isDirty = true;
112
+ const effects = [...effectSubscribers];
113
+ effects.forEach((effect) => effect());
114
+ if (subscribers.size > 0 && hasValue) {
115
+ const oldValue = cachedValue;
116
+ try {
117
+ compute();
118
+ if (!Object.is(cachedValue, oldValue)) {
119
+ notifySubscribers();
120
+ }
121
+ } catch {
122
+ }
123
+ }
124
+ }
125
+ };
126
+ const compute = () => {
127
+ if (isComputing) {
128
+ throw new Error("Circular dependency detected in computed");
129
+ }
130
+ cleanupEffect(markDirty);
131
+ isComputing = true;
132
+ const previousEffect = getCurrentEffect();
133
+ registerEffectCleanup(markDirty);
134
+ setCurrentEffect(markDirty);
135
+ try {
136
+ cachedValue = getter();
137
+ isDirty = false;
138
+ hasValue = true;
139
+ } finally {
140
+ isComputing = false;
141
+ setCurrentEffect(previousEffect);
142
+ }
143
+ };
144
+ const notifySubscribers = () => {
145
+ subscribers.forEach((fn) => {
146
+ try {
147
+ fn(cachedValue);
148
+ } catch (e) {
149
+ console.error("Error in computed subscriber:", e);
150
+ }
151
+ });
152
+ };
153
+ return {
154
+ get() {
155
+ if (isDirty) {
156
+ compute();
157
+ }
158
+ const currentEff = getCurrentEffect();
159
+ if (currentEff && currentEff !== markDirty) {
160
+ effectSubscribers.add(currentEff);
161
+ }
162
+ return cachedValue;
163
+ },
164
+ subscribe(fn) {
165
+ if (isDirty) {
166
+ try {
167
+ compute();
168
+ } catch {
169
+ }
170
+ }
171
+ subscribers.add(fn);
172
+ return () => {
173
+ subscribers.delete(fn);
174
+ };
175
+ }
176
+ };
177
+ }
178
+
98
179
  // src/state/store.ts
180
+ function normalizePath(path) {
181
+ if (typeof path === "string") {
182
+ return path.split(".").map((segment) => {
183
+ const num = parseInt(segment, 10);
184
+ return isNaN(num) ? segment : num;
185
+ });
186
+ }
187
+ return path;
188
+ }
189
+ function getValueAtPath(obj, path) {
190
+ let current = obj;
191
+ for (const key2 of path) {
192
+ if (current == null) return void 0;
193
+ current = current[key2];
194
+ }
195
+ return current;
196
+ }
197
+ function setValueAtPath(obj, path, value) {
198
+ if (path.length === 0) return value;
199
+ const head2 = path[0];
200
+ const rest = path.slice(1);
201
+ const isArrayIndex = typeof head2 === "number";
202
+ let clone3;
203
+ if (isArrayIndex) {
204
+ clone3 = Array.isArray(obj) ? [...obj] : [];
205
+ } else {
206
+ clone3 = obj != null && typeof obj === "object" ? { ...obj } : {};
207
+ }
208
+ const objRecord = obj;
209
+ clone3[head2] = setValueAtPath(
210
+ objRecord?.[head2],
211
+ rest,
212
+ value
213
+ );
214
+ return clone3;
215
+ }
99
216
  function createStateStore(definitions) {
100
217
  const signals = /* @__PURE__ */ new Map();
101
218
  for (const [name, def] of Object.entries(definitions)) {
102
- signals.set(name, createSignal(def.initial));
219
+ let initialValue = def.initial;
220
+ if (name === "theme" && typeof window !== "undefined") {
221
+ try {
222
+ const stored = localStorage.getItem("theme");
223
+ if (stored !== null) {
224
+ try {
225
+ initialValue = JSON.parse(stored);
226
+ } catch {
227
+ initialValue = stored;
228
+ }
229
+ }
230
+ } catch {
231
+ }
232
+ }
233
+ signals.set(name, createSignal(initialValue));
103
234
  }
104
235
  return {
105
236
  get(name) {
@@ -122,10 +253,48 @@ function createStateStore(definitions) {
122
253
  throw new Error(`State field "${name}" does not exist`);
123
254
  }
124
255
  return signal.subscribe(fn);
256
+ },
257
+ getPath(name, path) {
258
+ const signal = signals.get(name);
259
+ if (!signal) {
260
+ throw new Error(`State field "${name}" does not exist`);
261
+ }
262
+ const normalizedPath = normalizePath(path);
263
+ return getValueAtPath(signal.get(), normalizedPath);
264
+ },
265
+ setPath(name, path, value) {
266
+ const signal = signals.get(name);
267
+ if (!signal) {
268
+ throw new Error(`State field "${name}" does not exist`);
269
+ }
270
+ const normalizedPath = normalizePath(path);
271
+ const currentState = signal.get();
272
+ const newState = setValueAtPath(currentState, normalizedPath, value);
273
+ signal.set(newState);
274
+ },
275
+ subscribeToPath(name, path, fn) {
276
+ const signal = signals.get(name);
277
+ if (!signal) {
278
+ throw new Error(`State field "${name}" does not exist`);
279
+ }
280
+ const normalizedPath = normalizePath(path);
281
+ let previousValue = getValueAtPath(signal.get(), normalizedPath);
282
+ return signal.subscribe((newFieldValue) => {
283
+ const newValue = getValueAtPath(newFieldValue, normalizedPath);
284
+ if (newValue !== previousValue) {
285
+ previousValue = newValue;
286
+ fn(newValue);
287
+ }
288
+ });
125
289
  }
126
290
  };
127
291
  }
128
292
 
293
+ // src/state/typed.ts
294
+ function createTypedStateStore(definitions) {
295
+ return createStateStore(definitions);
296
+ }
297
+
129
298
  // src/expression/evaluator.ts
130
299
  function evaluate(expr, ctx) {
131
300
  switch (expr.expr) {
@@ -394,7 +563,7 @@ function createEvalContext(ctx) {
394
563
  }
395
564
  async function executeAction(action, ctx) {
396
565
  for (const step of action.steps) {
397
- if (step.do === "set" || step.do === "update") {
566
+ if (step.do === "set" || step.do === "update" || step.do === "setPath") {
398
567
  executeStepSync(step, ctx);
399
568
  } else if (step.do === "if") {
400
569
  await executeIfStep(step, ctx);
@@ -411,6 +580,9 @@ function executeStepSync(step, ctx) {
411
580
  case "update":
412
581
  executeUpdateStepSync(step, ctx);
413
582
  break;
583
+ case "setPath":
584
+ executeSetPathStepSync(step, ctx);
585
+ break;
414
586
  }
415
587
  }
416
588
  async function executeIfStep(step, ctx) {
@@ -418,7 +590,7 @@ async function executeIfStep(step, ctx) {
418
590
  const condition = evaluate(step.condition, evalCtx);
419
591
  const stepsToExecute = condition ? step.then : step.else || [];
420
592
  for (const nestedStep of stepsToExecute) {
421
- if (nestedStep.do === "set" || nestedStep.do === "update") {
593
+ if (nestedStep.do === "set" || nestedStep.do === "update" || nestedStep.do === "setPath") {
422
594
  executeStepSync(nestedStep, ctx);
423
595
  } else if (nestedStep.do === "if") {
424
596
  await executeIfStep(nestedStep, ctx);
@@ -518,6 +690,31 @@ function executeUpdateStepSync(step, ctx) {
518
690
  }
519
691
  }
520
692
  }
693
+ function executeSetPathStepSync(step, ctx) {
694
+ const evalCtx = createEvalContext(ctx);
695
+ const pathValue = evaluate(step.path, evalCtx);
696
+ let path;
697
+ if (typeof pathValue === "string") {
698
+ path = pathValue.split(".").map((segment) => {
699
+ const num = parseInt(segment, 10);
700
+ return isNaN(num) ? segment : num;
701
+ });
702
+ } else if (Array.isArray(pathValue)) {
703
+ path = pathValue.map((item) => {
704
+ if (typeof item === "object" && item !== null && "expr" in item) {
705
+ return evaluate(item, evalCtx);
706
+ }
707
+ return item;
708
+ });
709
+ } else {
710
+ path = [pathValue];
711
+ }
712
+ const newValue = evaluate(step.value, evalCtx);
713
+ ctx.state.setPath(step.target, path, newValue);
714
+ }
715
+ async function executeSetPathStep(step, ctx) {
716
+ executeSetPathStepSync(step, ctx);
717
+ }
521
718
  async function executeStep(step, ctx) {
522
719
  switch (step.do) {
523
720
  case "set":
@@ -526,6 +723,9 @@ async function executeStep(step, ctx) {
526
723
  case "update":
527
724
  await executeUpdateStep(step, ctx);
528
725
  break;
726
+ case "setPath":
727
+ await executeSetPathStep(step, ctx);
728
+ break;
529
729
  case "fetch":
530
730
  await executeFetchStep(step, ctx);
531
731
  break;
@@ -556,6 +756,12 @@ async function executeStep(step, ctx) {
556
756
  case "if":
557
757
  await executeIfStep(step, ctx);
558
758
  break;
759
+ case "send":
760
+ await executeSendStep(step, ctx);
761
+ break;
762
+ case "close":
763
+ await executeCloseStep(step, ctx);
764
+ break;
559
765
  }
560
766
  }
561
767
  async function executeSetStep(target, value, ctx) {
@@ -910,6 +1116,18 @@ async function executeDomStep(step, ctx) {
910
1116
  break;
911
1117
  }
912
1118
  }
1119
+ async function executeSendStep(step, ctx) {
1120
+ if (!ctx.connections) {
1121
+ throw new Error(`Connection "${step.connection}" not found`);
1122
+ }
1123
+ const evalCtx = createEvalContext(ctx);
1124
+ const data = evaluate(step.data, evalCtx);
1125
+ ctx.connections.send(step.connection, data);
1126
+ }
1127
+ async function executeCloseStep(step, ctx) {
1128
+ if (!ctx.connections) return;
1129
+ ctx.connections.close(step.connection);
1130
+ }
913
1131
 
914
1132
  // ../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js
915
1133
  function L() {
@@ -13081,52 +13299,162 @@ function renderIf(node, ctx) {
13081
13299
  }
13082
13300
  return fragment;
13083
13301
  }
13302
+ function createReactiveLocals(baseLocals, itemKey, itemSignal, indexKey, indexSignal) {
13303
+ return new Proxy(baseLocals, {
13304
+ get(target, prop) {
13305
+ if (prop === itemKey) {
13306
+ return itemSignal.get();
13307
+ }
13308
+ if (indexKey && prop === indexKey) {
13309
+ return indexSignal.get();
13310
+ }
13311
+ return target[prop];
13312
+ },
13313
+ has(target, prop) {
13314
+ if (prop === itemKey) return true;
13315
+ if (indexKey && prop === indexKey) return true;
13316
+ return prop in target;
13317
+ }
13318
+ });
13319
+ }
13084
13320
  function renderEach(node, ctx) {
13085
13321
  const anchor = document.createComment("each");
13322
+ const hasKey = !!node.key;
13323
+ let itemStateMap = /* @__PURE__ */ new Map();
13086
13324
  let currentNodes = [];
13087
13325
  let itemCleanups = [];
13088
13326
  const effectCleanup = createEffect(() => {
13089
13327
  const items = evaluate(node.items, { state: ctx.state, locals: ctx.locals, ...ctx.imports && { imports: ctx.imports } });
13090
- for (const cleanup of itemCleanups) {
13091
- cleanup();
13092
- }
13093
- itemCleanups = [];
13094
- for (const oldNode of currentNodes) {
13095
- if (oldNode.parentNode) {
13096
- oldNode.parentNode.removeChild(oldNode);
13328
+ if (!hasKey || !node.key) {
13329
+ for (const cleanup of itemCleanups) {
13330
+ cleanup();
13097
13331
  }
13332
+ itemCleanups = [];
13333
+ for (const oldNode of currentNodes) {
13334
+ if (oldNode.parentNode) {
13335
+ oldNode.parentNode.removeChild(oldNode);
13336
+ }
13337
+ }
13338
+ currentNodes = [];
13339
+ if (Array.isArray(items)) {
13340
+ items.forEach((item, index) => {
13341
+ const itemLocals = {
13342
+ ...ctx.locals,
13343
+ [node.as]: item
13344
+ };
13345
+ if (node.index) {
13346
+ itemLocals[node.index] = index;
13347
+ }
13348
+ const localCleanups = [];
13349
+ const itemCtx = {
13350
+ ...ctx,
13351
+ locals: itemLocals,
13352
+ cleanups: localCleanups
13353
+ };
13354
+ const itemNode = render(node.body, itemCtx);
13355
+ currentNodes.push(itemNode);
13356
+ itemCleanups.push(...localCleanups);
13357
+ if (anchor.parentNode) {
13358
+ let refNode = anchor.nextSibling;
13359
+ if (currentNodes.length > 1) {
13360
+ const lastExisting = currentNodes[currentNodes.length - 2];
13361
+ if (lastExisting) {
13362
+ refNode = lastExisting.nextSibling;
13363
+ }
13364
+ }
13365
+ anchor.parentNode.insertBefore(itemNode, refNode);
13366
+ }
13367
+ });
13368
+ }
13369
+ return;
13098
13370
  }
13099
- currentNodes = [];
13371
+ const newItemStateMap = /* @__PURE__ */ new Map();
13372
+ const newNodes = [];
13373
+ const seenKeys = /* @__PURE__ */ new Set();
13100
13374
  if (Array.isArray(items)) {
13101
13375
  items.forEach((item, index) => {
13102
- const itemLocals = {
13376
+ const tempLocals = {
13103
13377
  ...ctx.locals,
13104
- [node.as]: item
13378
+ [node.as]: item,
13379
+ ...node.index ? { [node.index]: index } : {}
13105
13380
  };
13106
- if (node.index) {
13107
- itemLocals[node.index] = index;
13108
- }
13109
- const localCleanups = [];
13110
- const itemCtx = {
13111
- ...ctx,
13112
- locals: itemLocals,
13113
- cleanups: localCleanups
13114
- };
13115
- const itemNode = render(node.body, itemCtx);
13116
- currentNodes.push(itemNode);
13117
- itemCleanups.push(...localCleanups);
13118
- if (anchor.parentNode) {
13119
- let refNode = anchor.nextSibling;
13120
- if (currentNodes.length > 1) {
13121
- const lastExisting = currentNodes[currentNodes.length - 2];
13122
- if (lastExisting) {
13123
- refNode = lastExisting.nextSibling;
13124
- }
13381
+ const keyValue = evaluate(node.key, {
13382
+ state: ctx.state,
13383
+ locals: tempLocals,
13384
+ ...ctx.imports && { imports: ctx.imports }
13385
+ });
13386
+ if (seenKeys.has(keyValue)) {
13387
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
13388
+ console.warn(`Duplicate key "${keyValue}" in each loop. Keys should be unique.`);
13125
13389
  }
13126
- anchor.parentNode.insertBefore(itemNode, refNode);
13390
+ }
13391
+ seenKeys.add(keyValue);
13392
+ const existingState = itemStateMap.get(keyValue);
13393
+ if (existingState) {
13394
+ existingState.itemSignal.set(item);
13395
+ existingState.indexSignal.set(index);
13396
+ newItemStateMap.set(keyValue, existingState);
13397
+ newNodes.push(existingState.node);
13398
+ } else {
13399
+ const itemSignal = createSignal(item);
13400
+ const indexSignal = createSignal(index);
13401
+ const reactiveLocals = createReactiveLocals(
13402
+ ctx.locals,
13403
+ node.as,
13404
+ itemSignal,
13405
+ node.index,
13406
+ indexSignal
13407
+ );
13408
+ const localCleanups = [];
13409
+ const itemCtx = {
13410
+ ...ctx,
13411
+ locals: reactiveLocals,
13412
+ cleanups: localCleanups
13413
+ };
13414
+ const itemNode = render(node.body, itemCtx);
13415
+ const newState = {
13416
+ key: keyValue,
13417
+ node: itemNode,
13418
+ cleanups: localCleanups,
13419
+ itemSignal,
13420
+ indexSignal
13421
+ };
13422
+ newItemStateMap.set(keyValue, newState);
13423
+ newNodes.push(itemNode);
13127
13424
  }
13128
13425
  });
13129
13426
  }
13427
+ for (const [key2, state] of itemStateMap) {
13428
+ if (!newItemStateMap.has(key2)) {
13429
+ for (const cleanup of state.cleanups) {
13430
+ cleanup();
13431
+ }
13432
+ if (state.node.parentNode) {
13433
+ state.node.parentNode.removeChild(state.node);
13434
+ }
13435
+ }
13436
+ }
13437
+ const activeElement = document.activeElement;
13438
+ const shouldRestoreFocus = activeElement && activeElement !== document.body;
13439
+ if (anchor.parentNode) {
13440
+ let refNode = anchor;
13441
+ for (const itemNode of newNodes) {
13442
+ const nextSibling = refNode.nextSibling;
13443
+ if (nextSibling !== itemNode) {
13444
+ anchor.parentNode.insertBefore(itemNode, refNode.nextSibling);
13445
+ }
13446
+ refNode = itemNode;
13447
+ }
13448
+ }
13449
+ if (shouldRestoreFocus && activeElement instanceof HTMLElement && document.activeElement !== activeElement) {
13450
+ activeElement.focus();
13451
+ }
13452
+ itemStateMap = newItemStateMap;
13453
+ currentNodes = newNodes;
13454
+ itemCleanups = [];
13455
+ for (const state of itemStateMap.values()) {
13456
+ itemCleanups.push(...state.cleanups);
13457
+ }
13130
13458
  });
13131
13459
  ctx.cleanups?.push(effectCleanup);
13132
13460
  ctx.cleanups?.push(() => {
@@ -13249,6 +13577,20 @@ function createApp(program, mount) {
13249
13577
  }
13250
13578
 
13251
13579
  // src/hydrate.ts
13580
+ function createReactiveLocals2(baseLocals, itemSignal, indexSignal, itemName, indexName) {
13581
+ return new Proxy(baseLocals, {
13582
+ get(target, prop) {
13583
+ if (prop === itemName) return itemSignal.get();
13584
+ if (indexName && prop === indexName) return indexSignal.get();
13585
+ return target[prop];
13586
+ },
13587
+ has(target, prop) {
13588
+ if (prop === itemName) return true;
13589
+ if (indexName && prop === indexName) return true;
13590
+ return prop in target;
13591
+ }
13592
+ });
13593
+ }
13252
13594
  function isEventHandler2(value) {
13253
13595
  return typeof value === "object" && value !== null && "event" in value && "action" in value;
13254
13596
  }
@@ -13679,6 +14021,8 @@ function hydrateEach(node, firstItemDomNode, ctx) {
13679
14021
  if (!parent) return;
13680
14022
  const anchor = document.createComment("each");
13681
14023
  parent.insertBefore(anchor, firstItemDomNode);
14024
+ const hasKey = !!node.key;
14025
+ let itemStateMap = /* @__PURE__ */ new Map();
13682
14026
  let currentNodes = [];
13683
14027
  let itemCleanups = [];
13684
14028
  const initialItems = evaluate(node.items, {
@@ -13690,29 +14034,85 @@ function hydrateEach(node, firstItemDomNode, ctx) {
13690
14034
  let isFirstRun = true;
13691
14035
  if (Array.isArray(initialItems) && initialItems.length > 0) {
13692
14036
  let domNode = firstItemDomNode;
13693
- initialItems.forEach((item, index) => {
13694
- if (!domNode) return;
13695
- currentNodes.push(domNode);
13696
- const itemLocals = {
13697
- ...ctx.locals,
13698
- [node.as]: item
13699
- };
13700
- if (node.index) {
13701
- itemLocals[node.index] = index;
13702
- }
13703
- const localCleanups = [];
13704
- const itemCtx = {
13705
- ...ctx,
13706
- locals: itemLocals,
13707
- cleanups: localCleanups
13708
- };
13709
- hydrate(node.body, domNode, itemCtx);
13710
- itemCleanups.push(...localCleanups);
13711
- domNode = domNode.nextSibling;
13712
- while (domNode && domNode.nodeType === Node.COMMENT_NODE) {
14037
+ if (hasKey && node.key) {
14038
+ const seenKeys = /* @__PURE__ */ new Set();
14039
+ initialItems.forEach((item, index) => {
14040
+ if (!domNode) return;
14041
+ const tempLocals = {
14042
+ ...ctx.locals,
14043
+ [node.as]: item,
14044
+ ...node.index ? { [node.index]: index } : {}
14045
+ };
14046
+ const keyValue = evaluate(node.key, {
14047
+ state: ctx.state,
14048
+ locals: tempLocals,
14049
+ ...ctx.imports && { imports: ctx.imports },
14050
+ ...ctx.route && { route: ctx.route }
14051
+ });
14052
+ if (seenKeys.has(keyValue)) {
14053
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
14054
+ console.warn(`Duplicate key "${keyValue}" in each loop. Keys should be unique.`);
14055
+ }
14056
+ }
14057
+ seenKeys.add(keyValue);
14058
+ const itemSignal = createSignal(item);
14059
+ const indexSignal = createSignal(index);
14060
+ const reactiveLocals = createReactiveLocals2(
14061
+ ctx.locals,
14062
+ itemSignal,
14063
+ indexSignal,
14064
+ node.as,
14065
+ node.index
14066
+ );
14067
+ const localCleanups = [];
14068
+ const itemCtx = {
14069
+ ...ctx,
14070
+ locals: reactiveLocals,
14071
+ cleanups: localCleanups
14072
+ };
14073
+ hydrate(node.body, domNode, itemCtx);
14074
+ const itemState = {
14075
+ key: keyValue,
14076
+ node: domNode,
14077
+ cleanups: localCleanups,
14078
+ itemSignal,
14079
+ indexSignal
14080
+ };
14081
+ itemStateMap.set(keyValue, itemState);
14082
+ currentNodes.push(domNode);
13713
14083
  domNode = domNode.nextSibling;
14084
+ while (domNode && domNode.nodeType === Node.COMMENT_NODE) {
14085
+ domNode = domNode.nextSibling;
14086
+ }
14087
+ });
14088
+ for (const state of itemStateMap.values()) {
14089
+ itemCleanups.push(...state.cleanups);
13714
14090
  }
13715
- });
14091
+ } else {
14092
+ initialItems.forEach((item, index) => {
14093
+ if (!domNode) return;
14094
+ currentNodes.push(domNode);
14095
+ const itemLocals = {
14096
+ ...ctx.locals,
14097
+ [node.as]: item
14098
+ };
14099
+ if (node.index) {
14100
+ itemLocals[node.index] = index;
14101
+ }
14102
+ const localCleanups = [];
14103
+ const itemCtx = {
14104
+ ...ctx,
14105
+ locals: itemLocals,
14106
+ cleanups: localCleanups
14107
+ };
14108
+ hydrate(node.body, domNode, itemCtx);
14109
+ itemCleanups.push(...localCleanups);
14110
+ domNode = domNode.nextSibling;
14111
+ while (domNode && domNode.nodeType === Node.COMMENT_NODE) {
14112
+ domNode = domNode.nextSibling;
14113
+ }
14114
+ });
14115
+ }
13716
14116
  }
13717
14117
  const effectCleanup = createEffect(() => {
13718
14118
  const items = evaluate(node.items, {
@@ -13725,48 +14125,141 @@ function hydrateEach(node, firstItemDomNode, ctx) {
13725
14125
  isFirstRun = false;
13726
14126
  return;
13727
14127
  }
13728
- for (const cleanup of itemCleanups) {
13729
- cleanup();
13730
- }
13731
- itemCleanups = [];
13732
- for (const oldNode of currentNodes) {
13733
- if (oldNode.parentNode) {
13734
- oldNode.parentNode.removeChild(oldNode);
14128
+ if (!hasKey || !node.key) {
14129
+ for (const cleanup of itemCleanups) {
14130
+ cleanup();
14131
+ }
14132
+ itemCleanups = [];
14133
+ for (const oldNode of currentNodes) {
14134
+ if (oldNode.parentNode) {
14135
+ oldNode.parentNode.removeChild(oldNode);
14136
+ }
14137
+ }
14138
+ currentNodes = [];
14139
+ if (Array.isArray(items)) {
14140
+ items.forEach((item, index) => {
14141
+ const itemLocals = {
14142
+ ...ctx.locals,
14143
+ [node.as]: item
14144
+ };
14145
+ if (node.index) {
14146
+ itemLocals[node.index] = index;
14147
+ }
14148
+ const localCleanups = [];
14149
+ const itemCtx = {
14150
+ state: ctx.state,
14151
+ actions: ctx.actions,
14152
+ locals: itemLocals,
14153
+ cleanups: localCleanups,
14154
+ ...ctx.imports && { imports: ctx.imports }
14155
+ };
14156
+ const itemNode = render(node.body, itemCtx);
14157
+ currentNodes.push(itemNode);
14158
+ itemCleanups.push(...localCleanups);
14159
+ if (anchor.parentNode) {
14160
+ let refNode = anchor.nextSibling;
14161
+ if (currentNodes.length > 1) {
14162
+ const lastExisting = currentNodes[currentNodes.length - 2];
14163
+ if (lastExisting) {
14164
+ refNode = lastExisting.nextSibling;
14165
+ }
14166
+ }
14167
+ anchor.parentNode.insertBefore(itemNode, refNode);
14168
+ }
14169
+ });
13735
14170
  }
14171
+ return;
13736
14172
  }
13737
- currentNodes = [];
14173
+ const newItemStateMap = /* @__PURE__ */ new Map();
14174
+ const newNodes = [];
14175
+ const seenKeys = /* @__PURE__ */ new Set();
13738
14176
  if (Array.isArray(items)) {
13739
14177
  items.forEach((item, index) => {
13740
- const itemLocals = {
14178
+ const tempLocals = {
13741
14179
  ...ctx.locals,
13742
- [node.as]: item
14180
+ [node.as]: item,
14181
+ ...node.index ? { [node.index]: index } : {}
13743
14182
  };
13744
- if (node.index) {
13745
- itemLocals[node.index] = index;
13746
- }
13747
- const localCleanups = [];
13748
- const itemCtx = {
14183
+ const keyValue = evaluate(node.key, {
13749
14184
  state: ctx.state,
13750
- actions: ctx.actions,
13751
- locals: itemLocals,
13752
- cleanups: localCleanups,
13753
- ...ctx.imports && { imports: ctx.imports }
13754
- };
13755
- const itemNode = render(node.body, itemCtx);
13756
- currentNodes.push(itemNode);
13757
- itemCleanups.push(...localCleanups);
13758
- if (anchor.parentNode) {
13759
- let refNode = anchor.nextSibling;
13760
- if (currentNodes.length > 1) {
13761
- const lastExisting = currentNodes[currentNodes.length - 2];
13762
- if (lastExisting) {
13763
- refNode = lastExisting.nextSibling;
13764
- }
14185
+ locals: tempLocals,
14186
+ ...ctx.imports && { imports: ctx.imports },
14187
+ ...ctx.route && { route: ctx.route }
14188
+ });
14189
+ if (seenKeys.has(keyValue)) {
14190
+ if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") {
14191
+ console.warn(`Duplicate key "${keyValue}" in each loop. Keys should be unique.`);
13765
14192
  }
13766
- anchor.parentNode.insertBefore(itemNode, refNode);
14193
+ }
14194
+ seenKeys.add(keyValue);
14195
+ const existingState = itemStateMap.get(keyValue);
14196
+ if (existingState) {
14197
+ existingState.itemSignal.set(item);
14198
+ existingState.indexSignal.set(index);
14199
+ newItemStateMap.set(keyValue, existingState);
14200
+ newNodes.push(existingState.node);
14201
+ } else {
14202
+ const itemSignal = createSignal(item);
14203
+ const indexSignal = createSignal(index);
14204
+ const reactiveLocals = createReactiveLocals2(
14205
+ ctx.locals,
14206
+ itemSignal,
14207
+ indexSignal,
14208
+ node.as,
14209
+ node.index
14210
+ );
14211
+ const localCleanups = [];
14212
+ const itemCtx = {
14213
+ state: ctx.state,
14214
+ actions: ctx.actions,
14215
+ locals: reactiveLocals,
14216
+ cleanups: localCleanups,
14217
+ ...ctx.imports && { imports: ctx.imports }
14218
+ };
14219
+ const itemNode = render(node.body, itemCtx);
14220
+ const newState = {
14221
+ key: keyValue,
14222
+ node: itemNode,
14223
+ cleanups: localCleanups,
14224
+ itemSignal,
14225
+ indexSignal
14226
+ };
14227
+ newItemStateMap.set(keyValue, newState);
14228
+ newNodes.push(itemNode);
13767
14229
  }
13768
14230
  });
13769
14231
  }
14232
+ for (const [key2, state] of itemStateMap) {
14233
+ if (!newItemStateMap.has(key2)) {
14234
+ for (const cleanup of state.cleanups) {
14235
+ cleanup();
14236
+ }
14237
+ if (state.node.parentNode) {
14238
+ state.node.parentNode.removeChild(state.node);
14239
+ }
14240
+ }
14241
+ }
14242
+ const activeElement = document.activeElement;
14243
+ const shouldRestoreFocus = activeElement && activeElement !== document.body;
14244
+ if (anchor.parentNode) {
14245
+ let refNode = anchor;
14246
+ for (const itemNode of newNodes) {
14247
+ const nextSibling = refNode.nextSibling;
14248
+ if (nextSibling !== itemNode) {
14249
+ anchor.parentNode.insertBefore(itemNode, refNode.nextSibling);
14250
+ }
14251
+ refNode = itemNode;
14252
+ }
14253
+ }
14254
+ if (shouldRestoreFocus && activeElement instanceof HTMLElement && document.activeElement !== activeElement) {
14255
+ activeElement.focus();
14256
+ }
14257
+ itemStateMap = newItemStateMap;
14258
+ currentNodes = newNodes;
14259
+ itemCleanups = [];
14260
+ for (const state of itemStateMap.values()) {
14261
+ itemCleanups.push(...state.cleanups);
14262
+ }
13770
14263
  });
13771
14264
  ctx.cleanups.push(effectCleanup);
13772
14265
  ctx.cleanups.push(() => {
@@ -13795,11 +14288,105 @@ function initCopyButtons(container) {
13795
14288
  });
13796
14289
  });
13797
14290
  }
14291
+
14292
+ // src/connection/websocket.ts
14293
+ function createWebSocketConnection(url, handlers) {
14294
+ const ws = new WebSocket(url);
14295
+ ws.onopen = () => {
14296
+ handlers.onOpen?.();
14297
+ };
14298
+ ws.onclose = (event) => {
14299
+ handlers.onClose?.(event.code, event.reason);
14300
+ };
14301
+ ws.onerror = (event) => {
14302
+ handlers.onError?.(event);
14303
+ };
14304
+ ws.onmessage = (event) => {
14305
+ let data = event.data;
14306
+ if (typeof data === "string") {
14307
+ try {
14308
+ data = JSON.parse(data);
14309
+ } catch {
14310
+ }
14311
+ }
14312
+ handlers.onMessage?.(data);
14313
+ };
14314
+ return {
14315
+ send(data) {
14316
+ if (ws.readyState === WebSocket.OPEN) {
14317
+ let message;
14318
+ if (typeof data === "string") {
14319
+ message = data;
14320
+ } else {
14321
+ message = JSON.stringify(data);
14322
+ }
14323
+ ws.send(message);
14324
+ }
14325
+ },
14326
+ close() {
14327
+ ws.close();
14328
+ },
14329
+ getState() {
14330
+ switch (ws.readyState) {
14331
+ case WebSocket.CONNECTING:
14332
+ return "connecting";
14333
+ case WebSocket.OPEN:
14334
+ return "open";
14335
+ case WebSocket.CLOSING:
14336
+ return "closing";
14337
+ case WebSocket.CLOSED:
14338
+ return "closed";
14339
+ default:
14340
+ return "closed";
14341
+ }
14342
+ }
14343
+ };
14344
+ }
14345
+ function createConnectionManager() {
14346
+ const connections = /* @__PURE__ */ new Map();
14347
+ return {
14348
+ create(name, url, handlers) {
14349
+ const existing = connections.get(name);
14350
+ if (existing) {
14351
+ existing.close();
14352
+ }
14353
+ const conn = createWebSocketConnection(url, handlers);
14354
+ connections.set(name, conn);
14355
+ },
14356
+ get(name) {
14357
+ return connections.get(name);
14358
+ },
14359
+ send(name, data) {
14360
+ const conn = connections.get(name);
14361
+ if (!conn) {
14362
+ throw new Error(`Connection "${name}" not found`);
14363
+ }
14364
+ conn.send(data);
14365
+ },
14366
+ close(name) {
14367
+ const conn = connections.get(name);
14368
+ if (conn) {
14369
+ conn.close();
14370
+ connections.delete(name);
14371
+ }
14372
+ },
14373
+ closeAll() {
14374
+ for (const conn of connections.values()) {
14375
+ conn.close();
14376
+ }
14377
+ connections.clear();
14378
+ }
14379
+ };
14380
+ }
13798
14381
  export {
13799
14382
  createApp,
14383
+ createComputed,
14384
+ createConnectionManager,
13800
14385
  createEffect,
13801
14386
  createSignal,
13802
14387
  createStateStore,
14388
+ createTypedStateStore,
14389
+ createWebSocketConnection,
13803
14390
  evaluate,
13804
14391
  evaluateStyle,
13805
14392
  executeAction,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/runtime",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Runtime DOM renderer for Constela UI framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,8 +18,8 @@
18
18
  "dompurify": "^3.3.1",
19
19
  "marked": "^17.0.1",
20
20
  "shiki": "^3.20.0",
21
- "@constela/compiler": "0.8.0",
22
- "@constela/core": "0.8.0"
21
+ "@constela/compiler": "0.9.0",
22
+ "@constela/core": "0.9.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/dompurify": "^3.2.0",
@@ -29,7 +29,7 @@
29
29
  "tsup": "^8.0.0",
30
30
  "typescript": "^5.3.0",
31
31
  "vitest": "^2.0.0",
32
- "@constela/server": "4.0.0"
32
+ "@constela/server": "5.0.0"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=20.0.0"