@constela/runtime 0.11.1 → 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,7 +98,121 @@ 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)) {
@@ -136,10 +253,48 @@ function createStateStore(definitions) {
136
253
  throw new Error(`State field "${name}" does not exist`);
137
254
  }
138
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
+ });
139
289
  }
140
290
  };
141
291
  }
142
292
 
293
+ // src/state/typed.ts
294
+ function createTypedStateStore(definitions) {
295
+ return createStateStore(definitions);
296
+ }
297
+
143
298
  // src/expression/evaluator.ts
144
299
  function evaluate(expr, ctx) {
145
300
  switch (expr.expr) {
@@ -408,7 +563,7 @@ function createEvalContext(ctx) {
408
563
  }
409
564
  async function executeAction(action, ctx) {
410
565
  for (const step of action.steps) {
411
- if (step.do === "set" || step.do === "update") {
566
+ if (step.do === "set" || step.do === "update" || step.do === "setPath") {
412
567
  executeStepSync(step, ctx);
413
568
  } else if (step.do === "if") {
414
569
  await executeIfStep(step, ctx);
@@ -425,6 +580,9 @@ function executeStepSync(step, ctx) {
425
580
  case "update":
426
581
  executeUpdateStepSync(step, ctx);
427
582
  break;
583
+ case "setPath":
584
+ executeSetPathStepSync(step, ctx);
585
+ break;
428
586
  }
429
587
  }
430
588
  async function executeIfStep(step, ctx) {
@@ -432,7 +590,7 @@ async function executeIfStep(step, ctx) {
432
590
  const condition = evaluate(step.condition, evalCtx);
433
591
  const stepsToExecute = condition ? step.then : step.else || [];
434
592
  for (const nestedStep of stepsToExecute) {
435
- if (nestedStep.do === "set" || nestedStep.do === "update") {
593
+ if (nestedStep.do === "set" || nestedStep.do === "update" || nestedStep.do === "setPath") {
436
594
  executeStepSync(nestedStep, ctx);
437
595
  } else if (nestedStep.do === "if") {
438
596
  await executeIfStep(nestedStep, ctx);
@@ -532,6 +690,31 @@ function executeUpdateStepSync(step, ctx) {
532
690
  }
533
691
  }
534
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
+ }
535
718
  async function executeStep(step, ctx) {
536
719
  switch (step.do) {
537
720
  case "set":
@@ -540,6 +723,9 @@ async function executeStep(step, ctx) {
540
723
  case "update":
541
724
  await executeUpdateStep(step, ctx);
542
725
  break;
726
+ case "setPath":
727
+ await executeSetPathStep(step, ctx);
728
+ break;
543
729
  case "fetch":
544
730
  await executeFetchStep(step, ctx);
545
731
  break;
@@ -570,6 +756,12 @@ async function executeStep(step, ctx) {
570
756
  case "if":
571
757
  await executeIfStep(step, ctx);
572
758
  break;
759
+ case "send":
760
+ await executeSendStep(step, ctx);
761
+ break;
762
+ case "close":
763
+ await executeCloseStep(step, ctx);
764
+ break;
573
765
  }
574
766
  }
575
767
  async function executeSetStep(target, value, ctx) {
@@ -924,6 +1116,18 @@ async function executeDomStep(step, ctx) {
924
1116
  break;
925
1117
  }
926
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
+ }
927
1131
 
928
1132
  // ../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js
929
1133
  function L() {
@@ -13095,52 +13299,162 @@ function renderIf(node, ctx) {
13095
13299
  }
13096
13300
  return fragment;
13097
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
+ }
13098
13320
  function renderEach(node, ctx) {
13099
13321
  const anchor = document.createComment("each");
13322
+ const hasKey = !!node.key;
13323
+ let itemStateMap = /* @__PURE__ */ new Map();
13100
13324
  let currentNodes = [];
13101
13325
  let itemCleanups = [];
13102
13326
  const effectCleanup = createEffect(() => {
13103
13327
  const items = evaluate(node.items, { state: ctx.state, locals: ctx.locals, ...ctx.imports && { imports: ctx.imports } });
13104
- for (const cleanup of itemCleanups) {
13105
- cleanup();
13106
- }
13107
- itemCleanups = [];
13108
- for (const oldNode of currentNodes) {
13109
- if (oldNode.parentNode) {
13110
- oldNode.parentNode.removeChild(oldNode);
13328
+ if (!hasKey || !node.key) {
13329
+ for (const cleanup of itemCleanups) {
13330
+ cleanup();
13331
+ }
13332
+ itemCleanups = [];
13333
+ for (const oldNode of currentNodes) {
13334
+ if (oldNode.parentNode) {
13335
+ oldNode.parentNode.removeChild(oldNode);
13336
+ }
13111
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;
13112
13370
  }
13113
- currentNodes = [];
13371
+ const newItemStateMap = /* @__PURE__ */ new Map();
13372
+ const newNodes = [];
13373
+ const seenKeys = /* @__PURE__ */ new Set();
13114
13374
  if (Array.isArray(items)) {
13115
13375
  items.forEach((item, index) => {
13116
- const itemLocals = {
13376
+ const tempLocals = {
13117
13377
  ...ctx.locals,
13118
- [node.as]: item
13119
- };
13120
- if (node.index) {
13121
- itemLocals[node.index] = index;
13122
- }
13123
- const localCleanups = [];
13124
- const itemCtx = {
13125
- ...ctx,
13126
- locals: itemLocals,
13127
- cleanups: localCleanups
13378
+ [node.as]: item,
13379
+ ...node.index ? { [node.index]: index } : {}
13128
13380
  };
13129
- const itemNode = render(node.body, itemCtx);
13130
- currentNodes.push(itemNode);
13131
- itemCleanups.push(...localCleanups);
13132
- if (anchor.parentNode) {
13133
- let refNode = anchor.nextSibling;
13134
- if (currentNodes.length > 1) {
13135
- const lastExisting = currentNodes[currentNodes.length - 2];
13136
- if (lastExisting) {
13137
- refNode = lastExisting.nextSibling;
13138
- }
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.`);
13139
13389
  }
13140
- 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);
13141
13424
  }
13142
13425
  });
13143
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
+ }
13144
13458
  });
13145
13459
  ctx.cleanups?.push(effectCleanup);
13146
13460
  ctx.cleanups?.push(() => {
@@ -13263,6 +13577,20 @@ function createApp(program, mount) {
13263
13577
  }
13264
13578
 
13265
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
+ }
13266
13594
  function isEventHandler2(value) {
13267
13595
  return typeof value === "object" && value !== null && "event" in value && "action" in value;
13268
13596
  }
@@ -13693,6 +14021,8 @@ function hydrateEach(node, firstItemDomNode, ctx) {
13693
14021
  if (!parent) return;
13694
14022
  const anchor = document.createComment("each");
13695
14023
  parent.insertBefore(anchor, firstItemDomNode);
14024
+ const hasKey = !!node.key;
14025
+ let itemStateMap = /* @__PURE__ */ new Map();
13696
14026
  let currentNodes = [];
13697
14027
  let itemCleanups = [];
13698
14028
  const initialItems = evaluate(node.items, {
@@ -13704,29 +14034,85 @@ function hydrateEach(node, firstItemDomNode, ctx) {
13704
14034
  let isFirstRun = true;
13705
14035
  if (Array.isArray(initialItems) && initialItems.length > 0) {
13706
14036
  let domNode = firstItemDomNode;
13707
- initialItems.forEach((item, index) => {
13708
- if (!domNode) return;
13709
- currentNodes.push(domNode);
13710
- const itemLocals = {
13711
- ...ctx.locals,
13712
- [node.as]: item
13713
- };
13714
- if (node.index) {
13715
- itemLocals[node.index] = index;
13716
- }
13717
- const localCleanups = [];
13718
- const itemCtx = {
13719
- ...ctx,
13720
- locals: itemLocals,
13721
- cleanups: localCleanups
13722
- };
13723
- hydrate(node.body, domNode, itemCtx);
13724
- itemCleanups.push(...localCleanups);
13725
- domNode = domNode.nextSibling;
13726
- 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);
13727
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);
13728
14090
  }
13729
- });
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
+ }
13730
14116
  }
13731
14117
  const effectCleanup = createEffect(() => {
13732
14118
  const items = evaluate(node.items, {
@@ -13739,48 +14125,141 @@ function hydrateEach(node, firstItemDomNode, ctx) {
13739
14125
  isFirstRun = false;
13740
14126
  return;
13741
14127
  }
13742
- for (const cleanup of itemCleanups) {
13743
- cleanup();
13744
- }
13745
- itemCleanups = [];
13746
- for (const oldNode of currentNodes) {
13747
- if (oldNode.parentNode) {
13748
- 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
+ }
13749
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
+ });
14170
+ }
14171
+ return;
13750
14172
  }
13751
- currentNodes = [];
14173
+ const newItemStateMap = /* @__PURE__ */ new Map();
14174
+ const newNodes = [];
14175
+ const seenKeys = /* @__PURE__ */ new Set();
13752
14176
  if (Array.isArray(items)) {
13753
14177
  items.forEach((item, index) => {
13754
- const itemLocals = {
14178
+ const tempLocals = {
13755
14179
  ...ctx.locals,
13756
- [node.as]: item
14180
+ [node.as]: item,
14181
+ ...node.index ? { [node.index]: index } : {}
13757
14182
  };
13758
- if (node.index) {
13759
- itemLocals[node.index] = index;
13760
- }
13761
- const localCleanups = [];
13762
- const itemCtx = {
14183
+ const keyValue = evaluate(node.key, {
13763
14184
  state: ctx.state,
13764
- actions: ctx.actions,
13765
- locals: itemLocals,
13766
- cleanups: localCleanups,
13767
- ...ctx.imports && { imports: ctx.imports }
13768
- };
13769
- const itemNode = render(node.body, itemCtx);
13770
- currentNodes.push(itemNode);
13771
- itemCleanups.push(...localCleanups);
13772
- if (anchor.parentNode) {
13773
- let refNode = anchor.nextSibling;
13774
- if (currentNodes.length > 1) {
13775
- const lastExisting = currentNodes[currentNodes.length - 2];
13776
- if (lastExisting) {
13777
- refNode = lastExisting.nextSibling;
13778
- }
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.`);
13779
14192
  }
13780
- 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);
13781
14229
  }
13782
14230
  });
13783
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
+ }
13784
14263
  });
13785
14264
  ctx.cleanups.push(effectCleanup);
13786
14265
  ctx.cleanups.push(() => {
@@ -13809,11 +14288,105 @@ function initCopyButtons(container) {
13809
14288
  });
13810
14289
  });
13811
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
+ }
13812
14381
  export {
13813
14382
  createApp,
14383
+ createComputed,
14384
+ createConnectionManager,
13814
14385
  createEffect,
13815
14386
  createSignal,
13816
14387
  createStateStore,
14388
+ createTypedStateStore,
14389
+ createWebSocketConnection,
13817
14390
  evaluate,
13818
14391
  evaluateStyle,
13819
14392
  executeAction,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/runtime",
3
- "version": "0.11.1",
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/core": "0.8.0",
22
- "@constela/compiler": "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.1.0"
32
+ "@constela/server": "5.0.0"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=20.0.0"