@hypen-space/core 0.4.31 → 0.4.35

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.
Files changed (71) hide show
  1. package/dist/app.d.ts +73 -2
  2. package/dist/app.js +112 -9
  3. package/dist/app.js.map +6 -5
  4. package/dist/components/builtin.js +112 -9
  5. package/dist/components/builtin.js.map +6 -5
  6. package/dist/context.d.ts +8 -0
  7. package/dist/context.js +6 -2
  8. package/dist/context.js.map +3 -3
  9. package/dist/datasource.d.ts +168 -0
  10. package/dist/discovery.js +114 -9
  11. package/dist/discovery.js.map +7 -6
  12. package/dist/disposable.js +6 -2
  13. package/dist/disposable.js.map +2 -2
  14. package/dist/engine.browser.d.ts +15 -0
  15. package/dist/engine.browser.js +19 -2
  16. package/dist/engine.browser.js.map +3 -3
  17. package/dist/engine.d.ts +22 -0
  18. package/dist/engine.js +37 -3
  19. package/dist/engine.js.map +3 -3
  20. package/dist/events.js +6 -2
  21. package/dist/events.js.map +2 -2
  22. package/dist/index.browser.js +295 -179
  23. package/dist/index.browser.js.map +9 -8
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +298 -179
  26. package/dist/index.js.map +10 -9
  27. package/dist/loader.js +6 -2
  28. package/dist/loader.js.map +2 -2
  29. package/dist/logger.js +6 -2
  30. package/dist/logger.js.map +2 -2
  31. package/dist/plugin.js +6 -2
  32. package/dist/plugin.js.map +2 -2
  33. package/dist/remote/client.js +6 -2
  34. package/dist/remote/client.js.map +2 -2
  35. package/dist/remote/index.js +152 -11
  36. package/dist/remote/index.js.map +9 -8
  37. package/dist/remote/server.js +152 -11
  38. package/dist/remote/server.js.map +9 -8
  39. package/dist/renderer.js +6 -2
  40. package/dist/renderer.js.map +2 -2
  41. package/dist/resolver.js +6 -2
  42. package/dist/resolver.js.map +2 -2
  43. package/dist/router.d.ts +9 -0
  44. package/dist/router.js +186 -18
  45. package/dist/router.js.map +6 -5
  46. package/dist/state.js +13 -6
  47. package/dist/state.js.map +3 -3
  48. package/package.json +1 -1
  49. package/src/app.ts +121 -4
  50. package/src/context.ts +10 -2
  51. package/src/datasource.ts +252 -0
  52. package/src/discovery.ts +2 -0
  53. package/src/engine.browser.ts +30 -0
  54. package/src/engine.ts +56 -1
  55. package/src/index.ts +16 -0
  56. package/src/managed-router.ts +3 -0
  57. package/src/remote/server.ts +11 -2
  58. package/src/router.ts +36 -10
  59. package/src/state.ts +10 -3
  60. package/wasm-browser/README.md +10 -10
  61. package/wasm-browser/hypen_engine.d.ts +368 -131
  62. package/wasm-browser/hypen_engine.js +896 -645
  63. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  64. package/wasm-browser/hypen_engine_bg.wasm.d.ts +20 -13
  65. package/wasm-browser/package.json +1 -1
  66. package/wasm-node/README.md +10 -10
  67. package/wasm-node/hypen_engine.d.ts +318 -89
  68. package/wasm-node/hypen_engine.js +832 -613
  69. package/wasm-node/hypen_engine_bg.wasm +0 -0
  70. package/wasm-node/hypen_engine_bg.wasm.d.ts +20 -13
  71. package/wasm-node/package.json +1 -1
package/src/app.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import type { Action } from "./types.js";
7
7
  import type { Session } from "./remote/types.js";
8
8
  import { type Result, Ok, Err, fromPromise, ActionError, HypenError } from "./result.js";
9
+ import { DataSourceManager, type DataSourcePlugin, type IDataSourceEngine } from "./datasource.js";
9
10
 
10
11
  // Interface for engine compatibility (works with both engine.js and engine.browser.js)
11
12
  export interface IEngine {
@@ -49,6 +50,20 @@ export interface ActionHandlerContext<T, P = unknown> {
49
50
  action: ActionContext<P>;
50
51
  state: T;
51
52
  context: GlobalContext;
53
+ /** Access to registered data source plugins for mutations */
54
+ dataSources: DataSourceAccessor;
55
+ }
56
+
57
+ /**
58
+ * Provides typed access to registered data source plugins.
59
+ * Unknown method calls are forwarded to `plugin.call()`:
60
+ * `dataSources.spacetime.sendMessage(text)` → `plugin.call("sendMessage", text)`
61
+ *
62
+ * You can also call `.call()` directly if you prefer:
63
+ * `dataSources.spacetime.call("sendMessage", text)`
64
+ */
65
+ export interface DataSourceAccessor {
66
+ [providerName: string]: DataSourcePlugin & Record<string, (...args: unknown[]) => Promise<unknown>>;
52
67
  }
53
68
 
54
69
  /**
@@ -139,6 +154,11 @@ export interface HypenModuleDefinition<T = unknown> {
139
154
  * Set via the `.ui(hypen`...`)` method.
140
155
  */
141
156
  template?: string;
157
+ /**
158
+ * Data source plugins registered via `.useDataSource()`.
159
+ * Each entry contains the plugin and its configuration.
160
+ */
161
+ dataSources?: Array<{ plugin: DataSourcePlugin; config: unknown }>;
142
162
  handlers: {
143
163
  onCreated?: LifecycleHandler<T>;
144
164
  onAction: Map<string, ActionHandler<T, any>>;
@@ -174,6 +194,7 @@ export class HypenAppBuilder<T> {
174
194
  private errorHandler?: ErrorHandler<T>;
175
195
  private template?: string;
176
196
  private _registry?: Map<string, HypenModuleDefinition>;
197
+ private dataSourceEntries: Array<{ plugin: DataSourcePlugin; config: unknown }> = [];
177
198
 
178
199
  constructor(
179
200
  initialState: T,
@@ -285,6 +306,39 @@ export class HypenAppBuilder<T> {
285
306
  return this;
286
307
  }
287
308
 
309
+ /**
310
+ * Register a data source plugin for live database subscriptions.
311
+ *
312
+ * @example
313
+ * ```typescript
314
+ * import { SpacetimeDBPlugin } from "@hypen-space/plugin-spacetimedb";
315
+ *
316
+ * export default app
317
+ * .defineState({ messageText: "" })
318
+ * .useDataSource(new SpacetimeDBPlugin(), {
319
+ * uri: "ws://localhost:3000",
320
+ * moduleName: "chat",
321
+ * tables: ["user", "message"],
322
+ * })
323
+ * .onAction("sendMessage", async ({ state, dataSources }) => {
324
+ * await dataSources.spacetime.sendMessage(state.messageText);
325
+ * state.messageText = "";
326
+ * })
327
+ * .build();
328
+ * ```
329
+ *
330
+ * In Hypen DSL, bind to data source tables with `$provider.table`:
331
+ * ```hypen
332
+ * ForEach(items: $spacetime.message, key: "id") {
333
+ * Text("${item.text}")
334
+ * }
335
+ * ```
336
+ */
337
+ useDataSource<C>(plugin: DataSourcePlugin<C>, config: C): this {
338
+ this.dataSourceEntries.push({ plugin: plugin as DataSourcePlugin, config });
339
+ return this;
340
+ }
341
+
288
342
  /**
289
343
  * Define the component's UI template inline (single-file component).
290
344
  *
@@ -316,6 +370,28 @@ export class HypenAppBuilder<T> {
316
370
  return this.build();
317
371
  }
318
372
 
373
+ /**
374
+ * Load a UI template from a .hypen file on disk.
375
+ *
376
+ * @example
377
+ * ```typescript
378
+ * import { app } from "@hypen-space/core";
379
+ *
380
+ * export default app
381
+ * .defineState({ count: 0 })
382
+ * .onAction("increment", ({ state }) => { state.count += 1; })
383
+ * .uiFile("./counter.hypen");
384
+ * ```
385
+ *
386
+ * @param path - Path to a .hypen template file
387
+ * @returns The built module definition (calls build() internally)
388
+ */
389
+ uiFile(path: string): HypenModuleDefinition<T> {
390
+ const fs = require("fs");
391
+ this.template = fs.readFileSync(path, "utf-8").trim();
392
+ return this.build();
393
+ }
394
+
319
395
  /**
320
396
  * Build the module definition
321
397
  */
@@ -333,6 +409,7 @@ export class HypenAppBuilder<T> {
333
409
  version: this.options.version,
334
410
  initialState: this.initialState,
335
411
  template: this.template,
412
+ dataSources: this.dataSourceEntries.length > 0 ? this.dataSourceEntries : undefined,
336
413
  handlers: {
337
414
  onCreated: this.createdHandler,
338
415
  onAction: this.actionHandlers,
@@ -467,6 +544,8 @@ export class HypenModuleInstance<T extends object = any> {
467
544
  private router: HypenRouter | null;
468
545
  private globalContext?: HypenGlobalContext;
469
546
  private stateChangeCallbacks: Array<() => void> = [];
547
+ private dataSourceManager?: DataSourceManager;
548
+ private dataSourceAccessor: DataSourceAccessor = {};
470
549
 
471
550
  constructor(
472
551
  engine: IEngine,
@@ -530,6 +609,7 @@ export class HypenModuleInstance<T extends object = any> {
530
609
  action: actionCtx,
531
610
  state: this.state,
532
611
  context: context!,
612
+ dataSources: this.dataSourceAccessor,
533
613
  });
534
614
 
535
615
  if (!result.ok) {
@@ -584,8 +664,9 @@ export class HypenModuleInstance<T extends object = any> {
584
664
  };
585
665
 
586
666
  // Expose hypen engine for built-in components (if available)
587
- if ((ctx as any).__hypenEngine) {
588
- (api as any).__hypenEngine = (ctx as any).__hypenEngine;
667
+ const ctxRecord = ctx as unknown as Record<string, unknown>;
668
+ if (ctxRecord.__hypenEngine) {
669
+ (api as Record<string, unknown>).__hypenEngine = ctxRecord.__hypenEngine;
589
670
  }
590
671
 
591
672
  return api;
@@ -675,9 +756,34 @@ export class HypenModuleInstance<T extends object = any> {
675
756
  }
676
757
 
677
758
  /**
678
- * Call the onCreated handler
759
+ * Call the onCreated handler and connect data source plugins
679
760
  */
680
761
  private async callCreatedHandler(): Promise<void> {
762
+ // Initialize data source plugins if any are registered
763
+ if (this.definition.dataSources?.length) {
764
+ // The engine must implement IDataSourceEngine methods
765
+ const dsEngine = this.engine as unknown as IDataSourceEngine;
766
+ this.dataSourceManager = new DataSourceManager(dsEngine);
767
+
768
+ for (const { plugin, config } of this.definition.dataSources) {
769
+ try {
770
+ await this.dataSourceManager.use(plugin, config);
771
+ // Wrap plugin in a Proxy so unknown method calls forward to plugin.call():
772
+ // dataSources.spacetime.sendMessage(text) → plugin.call("sendMessage", text)
773
+ this.dataSourceAccessor[plugin.name] = new Proxy(plugin, {
774
+ get(target, prop) {
775
+ if (typeof prop === 'string' && !(prop in target)) {
776
+ return (...args: unknown[]) => target.call(prop, ...args);
777
+ }
778
+ return (target as unknown as Record<string | symbol, unknown>)[prop];
779
+ },
780
+ }) as typeof plugin & Record<string, (...args: unknown[]) => Promise<unknown>>;
781
+ } catch (e) {
782
+ log.error(`Failed to connect data source "${plugin.name}":`, e);
783
+ }
784
+ }
785
+ }
786
+
681
787
  if (this.definition.handlers.onCreated) {
682
788
  const context = this.globalContext ? this.createGlobalContextAPI() : undefined;
683
789
  try {
@@ -700,11 +806,22 @@ export class HypenModuleInstance<T extends object = any> {
700
806
  }
701
807
 
702
808
  /**
703
- * Destroy the module instance
809
+ * Destroy the module instance and disconnect all data source plugins
704
810
  */
705
811
  async destroy(): Promise<void> {
706
812
  if (this.isDestroyed) return;
707
813
 
814
+ // Disconnect all data source plugins
815
+ if (this.dataSourceManager) {
816
+ try {
817
+ await this.dataSourceManager.disconnectAll();
818
+ } catch (e) {
819
+ log.error("Error disconnecting data sources:", e);
820
+ }
821
+ this.dataSourceManager = undefined;
822
+ this.dataSourceAccessor = {};
823
+ }
824
+
708
825
  if (this.definition.handlers.onDestroyed) {
709
826
  try {
710
827
  await this.definition.handlers.onDestroyed(this.state);
package/src/context.ts CHANGED
@@ -10,9 +10,17 @@ import { frameworkLoggers } from "./logger.js";
10
10
  const log = frameworkLoggers.context;
11
11
 
12
12
  export type ModuleReference<T = unknown> = {
13
- state: T; // Live proxy state
13
+ /**
14
+ * Live proxy state - mutations are tracked and trigger re-renders.
15
+ * Use `getState()` for a snapshot if you only need to read values.
16
+ */
17
+ state: T;
14
18
  setState: (patch: Partial<T>) => void;
15
- getState: () => T; // Snapshot
19
+ /**
20
+ * Returns a deep-cloned snapshot of the current state.
21
+ * Unlike `state`, mutations to the returned object are NOT tracked.
22
+ */
23
+ getState: () => T;
16
24
  };
17
25
 
18
26
  /**
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Data Source Plugin System for Hypen
3
+ *
4
+ * Enables live subscription databases (SpacetimeDB, Firebase, Convex, Electric SQL)
5
+ * to push data into the Hypen reactive engine via a unified plugin interface.
6
+ *
7
+ * UI bindings use the `$provider.path` syntax:
8
+ * - `$spacetime.messages` → SpacetimeDB messages table
9
+ * - `$firebase.user.profile` → Firebase user profile
10
+ * - `$convex.tasks` → Convex tasks table
11
+ *
12
+ * @module
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Core Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Connection status for a data source provider */
20
+ export type DataSourceStatus =
21
+ | "disconnected"
22
+ | "connecting"
23
+ | "connected"
24
+ | "reconnecting"
25
+ | "error";
26
+
27
+ /** Describes a subscription to a table or query */
28
+ export interface DataSourceQuery {
29
+ /** Table or collection name (e.g., "messages", "users") */
30
+ table: string;
31
+ /** Optional filter criteria */
32
+ filter?: Record<string, unknown>;
33
+ /** Optional raw query string (e.g., SQL for SpacetimeDB) */
34
+ sql?: string;
35
+ }
36
+
37
+ /** Handle returned by subscribe() — call unsubscribe() to stop receiving updates */
38
+ export interface DataSourceSubscription {
39
+ /** Stop receiving updates for this subscription */
40
+ unsubscribe(): void;
41
+ /** Current subscription status */
42
+ status: "pending" | "applied" | "error";
43
+ /** Error details if status is "error" */
44
+ error?: Error;
45
+ }
46
+
47
+ /** Describes a batch of changes pushed by a plugin */
48
+ export interface DataSourceChange {
49
+ /** Changed paths relative to the provider root (e.g., ["messages", "users"]) */
50
+ paths: string[];
51
+ /** New values at those paths */
52
+ values: Record<string, unknown>;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Plugin Interface
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Interface that all data source plugins must implement.
61
+ *
62
+ * A plugin manages the connection lifecycle and translates database-specific
63
+ * events (row inserts, updates, deletes) into `DataSourceChange` objects that
64
+ * the Hypen engine can consume.
65
+ *
66
+ * @typeParam TConfig - Plugin-specific configuration type
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * class SpacetimeDBPlugin implements DataSourcePlugin<SpacetimeDBConfig> {
71
+ * readonly name = "spacetime";
72
+ * status: DataSourceStatus = "disconnected";
73
+ *
74
+ * async connect(config: SpacetimeDBConfig, onChange: (change: DataSourceChange) => void) {
75
+ * // Connect to SpacetimeDB, subscribe to tables, push changes via onChange
76
+ * }
77
+ * // ...
78
+ * }
79
+ * ```
80
+ */
81
+ export interface DataSourcePlugin<TConfig = unknown> {
82
+ /** Unique provider name (e.g., "spacetime", "firebase", "convex") */
83
+ readonly name: string;
84
+
85
+ /** Current connection status */
86
+ status: DataSourceStatus;
87
+
88
+ /**
89
+ * Connect to the data source and begin pushing changes.
90
+ *
91
+ * @param config - Plugin-specific configuration
92
+ * @param onChange - Callback to push data changes into the engine.
93
+ * The plugin MUST call this whenever subscribed data changes.
94
+ */
95
+ connect(config: TConfig, onChange: (change: DataSourceChange) => void): Promise<void>;
96
+
97
+ /**
98
+ * Subscribe to a specific table or query.
99
+ * Returns a handle to unsubscribe later.
100
+ */
101
+ subscribe(query: DataSourceQuery): DataSourceSubscription;
102
+
103
+ /**
104
+ * Call a remote procedure / reducer / mutation.
105
+ *
106
+ * @param method - Method name (e.g., "sendMessage" for SpacetimeDB reducer)
107
+ * @param args - Arguments to pass
108
+ * @returns The result from the remote call
109
+ */
110
+ call(method: string, ...args: unknown[]): Promise<unknown>;
111
+
112
+ /**
113
+ * Disconnect from the data source and clean up all subscriptions.
114
+ */
115
+ disconnect(): Promise<void>;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Data Source Manager
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** Interface for the engine's data source operations (maps to WASM setContext/removeContext) */
123
+ export interface IDataSourceEngine {
124
+ /**
125
+ * Set (or replace) a named data source context.
126
+ * Registers the provider, stores the data, and re-renders bound nodes.
127
+ * Sparse merging should happen at the SDK layer before calling this.
128
+ */
129
+ setContext(name: string, data: Record<string, unknown>): void;
130
+ /**
131
+ * Remove a data source context entirely.
132
+ * Drops the provider's state and re-renders bound nodes (they resolve to null).
133
+ */
134
+ removeContext(name: string): void;
135
+ }
136
+
137
+ /**
138
+ * Manages data source plugin lifecycles and bridges them to the Hypen engine.
139
+ *
140
+ * The manager:
141
+ * 1. Registers plugins with the engine (creates state slots)
142
+ * 2. Connects plugins and wires their onChange callbacks to engine updates
143
+ * 3. Manages subscription lifecycles
144
+ * 4. Handles cleanup on disconnect
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const manager = new DataSourceManager(engine);
149
+ *
150
+ * // Register and connect a SpacetimeDB plugin
151
+ * await manager.use(new SpacetimeDBPlugin(), {
152
+ * uri: "ws://localhost:3000",
153
+ * moduleName: "chat",
154
+ * tables: ["user", "message"],
155
+ * });
156
+ *
157
+ * // Access plugin for mutations
158
+ * await manager.get("spacetime").call("sendMessage", "Hello!");
159
+ *
160
+ * // Clean up
161
+ * await manager.disconnectAll();
162
+ * ```
163
+ */
164
+ export class DataSourceManager {
165
+ private plugins = new Map<string, DataSourcePlugin>();
166
+ private configs = new Map<string, unknown>();
167
+ /** Accumulated state per provider — sparse merging happens here before setContext */
168
+ private state = new Map<string, Record<string, unknown>>();
169
+ private engine: IDataSourceEngine;
170
+
171
+ constructor(engine: IDataSourceEngine) {
172
+ this.engine = engine;
173
+ }
174
+
175
+ /**
176
+ * Register, connect, and start a data source plugin.
177
+ *
178
+ * @param plugin - The plugin instance
179
+ * @param config - Plugin-specific configuration
180
+ */
181
+ async use<C>(plugin: DataSourcePlugin<C>, config: C): Promise<void> {
182
+ const { name } = plugin;
183
+
184
+ // Initialize state slot
185
+ this.state.set(name, {});
186
+
187
+ // Store references
188
+ this.plugins.set(name, plugin as DataSourcePlugin);
189
+ this.configs.set(name, config);
190
+
191
+ // Connect the plugin with an onChange callback that merges sparse data
192
+ // and pushes the full state via setContext
193
+ await plugin.connect(config, (change: DataSourceChange) => {
194
+ // Merge changed paths into the accumulated state
195
+ const current = this.state.get(name) ?? {};
196
+ for (const path of change.paths) {
197
+ if (path in change.values) {
198
+ current[path] = change.values[path];
199
+ }
200
+ }
201
+ this.state.set(name, current);
202
+
203
+ // Push the full merged state to the engine
204
+ this.engine.setContext(name, current);
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Get a registered plugin by name.
210
+ * Useful for calling mutations: `manager.get("spacetime").call("sendMessage", text)`
211
+ */
212
+ get<T extends DataSourcePlugin = DataSourcePlugin>(name: string): T | undefined {
213
+ return this.plugins.get(name) as T | undefined;
214
+ }
215
+
216
+ /** Check if a plugin is registered */
217
+ has(name: string): boolean {
218
+ return this.plugins.has(name);
219
+ }
220
+
221
+ /** Get all registered provider names */
222
+ getNames(): string[] {
223
+ return Array.from(this.plugins.keys());
224
+ }
225
+
226
+ /** Get the status of a specific plugin */
227
+ getStatus(name: string): DataSourceStatus | undefined {
228
+ return this.plugins.get(name)?.status;
229
+ }
230
+
231
+ /**
232
+ * Disconnect and remove a specific plugin.
233
+ */
234
+ async remove(name: string): Promise<void> {
235
+ const plugin = this.plugins.get(name);
236
+ if (plugin) {
237
+ await plugin.disconnect();
238
+ this.plugins.delete(name);
239
+ this.configs.delete(name);
240
+ this.state.delete(name);
241
+ this.engine.removeContext(name);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Disconnect all plugins and clean up.
247
+ */
248
+ async disconnectAll(): Promise<void> {
249
+ const names = Array.from(this.plugins.keys());
250
+ await Promise.all(names.map((name) => this.remove(name)));
251
+ }
252
+ }
package/src/discovery.ts CHANGED
@@ -282,6 +282,8 @@ export async function discoverComponents(
282
282
 
283
283
  if (module && typeof module === "object" && module.template) {
284
284
  addSingleFileComponent(baseName, modulePath, module.template);
285
+ } else if (module && typeof module === "object") {
286
+ frameworkLoggers.discovery.warn(`Skipping "${entry.name}": has .ui() pattern but module has no .template property. Did you forget to call .ui(hypen\`...\`)?`);
285
287
  }
286
288
  } catch (e) {
287
289
  // Failed to import, skip this file
@@ -269,4 +269,34 @@ export class Engine {
269
269
  const engine = this.ensureInitialized();
270
270
  return engine.debugParseComponent(source);
271
271
  }
272
+
273
+ // ── Data Source Context ────────────────────────────────────────
274
+
275
+ /**
276
+ * Set (or replace) a named data source context.
277
+ *
278
+ * Registers the provider in the dependency graph, stores the data,
279
+ * and re-renders every node bound to `$name.*`.
280
+ *
281
+ * @param name - Provider name (e.g., "spacetime", "firebase")
282
+ * @param data - Full state object for this provider
283
+ */
284
+ setContext(name: string, data: Record<string, unknown>): void {
285
+ const engine = this.ensureInitialized();
286
+ const plainData = JSON.parse(JSON.stringify(data));
287
+ try {
288
+ engine.setContext(name, plainData);
289
+ } catch (err) {
290
+ throw classifyEngineError(err);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Remove a data source context entirely.
296
+ * Drops the provider's state and re-renders bound nodes (they resolve to null).
297
+ */
298
+ removeContext(name: string): void {
299
+ const engine = this.ensureInitialized();
300
+ engine.removeContext(name);
301
+ }
272
302
  }
package/src/engine.ts CHANGED
@@ -181,10 +181,33 @@ export class Engine {
181
181
  /**
182
182
  * Register an action handler
183
183
  */
184
+ private _onErrorHandler?: (error: Error) => void;
185
+
186
+ /**
187
+ * Set error handler for action errors
188
+ */
189
+ setOnError(handler: (error: Error) => void): void {
190
+ this._onErrorHandler = handler;
191
+ }
192
+
184
193
  onAction(actionName: string, handler: ActionHandler): void {
185
194
  const engine = this.ensureInitialized();
195
+ const handleError = (err: unknown) => {
196
+ log.error("Action handler error:", err);
197
+ if (this._onErrorHandler) {
198
+ this._onErrorHandler(err instanceof Error ? err : new Error(String(err)));
199
+ }
200
+ };
186
201
  engine.onAction(actionName, (action: Action) => {
187
- Promise.resolve(handler(action)).catch((err) => log.error("Action handler error:", err));
202
+ try {
203
+ const result = handler(action);
204
+ // If handler returns a promise, catch async errors too
205
+ if (result && typeof (result as any).catch === "function") {
206
+ (result as any).catch(handleError);
207
+ }
208
+ } catch (err) {
209
+ handleError(err);
210
+ }
188
211
  });
189
212
  }
190
213
 
@@ -224,4 +247,36 @@ export class Engine {
224
247
  const engine = this.ensureInitialized();
225
248
  return engine.debugParseComponent(source);
226
249
  }
250
+
251
+ // ── Data Source Context ────────────────────────────────────────
252
+
253
+ /**
254
+ * Set (or replace) a named data source context.
255
+ *
256
+ * Registers the provider in the dependency graph, stores the data,
257
+ * and re-renders every node bound to `$name.*`.
258
+ * Sparse merging (if needed) should happen at the SDK layer before
259
+ * calling this method with the merged object.
260
+ *
261
+ * @param name - Provider name (e.g., "spacetime", "firebase")
262
+ * @param data - Full state object for this provider
263
+ */
264
+ setContext(name: string, data: Record<string, unknown>): void {
265
+ const engine = this.ensureInitialized();
266
+ try {
267
+ engine.setContext(name, unwrapForWasm(data));
268
+ } catch (err) {
269
+ throw classifyEngineError(err);
270
+ }
271
+ log.debug(`Data source "${name}" context set`);
272
+ }
273
+
274
+ /**
275
+ * Remove a data source context entirely.
276
+ * Drops the provider's state and re-renders bound nodes (they resolve to null).
277
+ */
278
+ removeContext(name: string): void {
279
+ const engine = this.ensureInitialized();
280
+ engine.removeContext(name);
281
+ }
227
282
  }
package/src/index.ts CHANGED
@@ -92,8 +92,24 @@ export type {
92
92
  ErrorContext,
93
93
  ErrorHandler,
94
94
  ErrorHandlerResult,
95
+ // Data source access in action handlers
96
+ DataSourceAccessor,
95
97
  } from "./app.js";
96
98
 
99
+ // ============================================================================
100
+ // DATA SOURCE PLUGIN SYSTEM
101
+ // ============================================================================
102
+
103
+ export { DataSourceManager } from "./datasource.js";
104
+ export type {
105
+ DataSourcePlugin,
106
+ DataSourceStatus,
107
+ DataSourceQuery,
108
+ DataSourceSubscription,
109
+ DataSourceChange,
110
+ IDataSourceEngine,
111
+ } from "./datasource.js";
112
+
97
113
  // ============================================================================
98
114
  // STATE MANAGEMENT
99
115
  // ============================================================================
@@ -84,6 +84,9 @@ export class ManagedRouter {
84
84
  this.globalContext.unregisterModule(moduleId);
85
85
  }
86
86
  this.persistedModules.clear();
87
+
88
+ // Clean up router event listeners
89
+ this.router.dispose();
87
90
  }
88
91
 
89
92
  /**
@@ -303,7 +303,12 @@ export class RemoteServer {
303
303
  engine.setComponentResolver(
304
304
  (componentName: string, _contextPath: string | null) => {
305
305
  const comp = this._discoveredComponents.get(componentName);
306
- if (!comp) return null;
306
+ if (!comp) {
307
+ log.warn(
308
+ `Component "${componentName}" not found. Available: ${Array.from(this._discoveredComponents.keys()).join(", ") || "(none)"}`
309
+ );
310
+ return null;
311
+ }
307
312
 
308
313
  return {
309
314
  source: comp.template,
@@ -618,8 +623,12 @@ export class RemoteServer {
618
623
 
619
624
  let restored = false;
620
625
  const restore = (state: unknown) => {
626
+ if (state === null || typeof state !== 'object') {
627
+ log.warn("restore() called with non-object state, ignoring:", typeof state);
628
+ return;
629
+ }
621
630
  restored = true;
622
- clientData.moduleInstance.updateState(state as any);
631
+ clientData.moduleInstance.updateState(state as Record<string, unknown>);
623
632
  };
624
633
 
625
634
  await handler({ session, restore });