@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.
- package/dist/app.d.ts +73 -2
- package/dist/app.js +112 -9
- package/dist/app.js.map +6 -5
- package/dist/components/builtin.js +112 -9
- package/dist/components/builtin.js.map +6 -5
- package/dist/context.d.ts +8 -0
- package/dist/context.js +6 -2
- package/dist/context.js.map +3 -3
- package/dist/datasource.d.ts +168 -0
- package/dist/discovery.js +114 -9
- package/dist/discovery.js.map +7 -6
- package/dist/disposable.js +6 -2
- package/dist/disposable.js.map +2 -2
- package/dist/engine.browser.d.ts +15 -0
- package/dist/engine.browser.js +19 -2
- package/dist/engine.browser.js.map +3 -3
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +37 -3
- package/dist/engine.js.map +3 -3
- package/dist/events.js +6 -2
- package/dist/events.js.map +2 -2
- package/dist/index.browser.js +295 -179
- package/dist/index.browser.js.map +9 -8
- package/dist/index.d.ts +3 -1
- package/dist/index.js +298 -179
- package/dist/index.js.map +10 -9
- package/dist/loader.js +6 -2
- package/dist/loader.js.map +2 -2
- package/dist/logger.js +6 -2
- package/dist/logger.js.map +2 -2
- package/dist/plugin.js +6 -2
- package/dist/plugin.js.map +2 -2
- package/dist/remote/client.js +6 -2
- package/dist/remote/client.js.map +2 -2
- package/dist/remote/index.js +152 -11
- package/dist/remote/index.js.map +9 -8
- package/dist/remote/server.js +152 -11
- package/dist/remote/server.js.map +9 -8
- package/dist/renderer.js +6 -2
- package/dist/renderer.js.map +2 -2
- package/dist/resolver.js +6 -2
- package/dist/resolver.js.map +2 -2
- package/dist/router.d.ts +9 -0
- package/dist/router.js +186 -18
- package/dist/router.js.map +6 -5
- package/dist/state.js +13 -6
- package/dist/state.js.map +3 -3
- package/package.json +1 -1
- package/src/app.ts +121 -4
- package/src/context.ts +10 -2
- package/src/datasource.ts +252 -0
- package/src/discovery.ts +2 -0
- package/src/engine.browser.ts +30 -0
- package/src/engine.ts +56 -1
- package/src/index.ts +16 -0
- package/src/managed-router.ts +3 -0
- package/src/remote/server.ts +11 -2
- package/src/router.ts +36 -10
- package/src/state.ts +10 -3
- package/wasm-browser/README.md +10 -10
- package/wasm-browser/hypen_engine.d.ts +368 -131
- package/wasm-browser/hypen_engine.js +896 -645
- package/wasm-browser/hypen_engine_bg.wasm +0 -0
- package/wasm-browser/hypen_engine_bg.wasm.d.ts +20 -13
- package/wasm-browser/package.json +1 -1
- package/wasm-node/README.md +10 -10
- package/wasm-node/hypen_engine.d.ts +318 -89
- package/wasm-node/hypen_engine.js +832 -613
- package/wasm-node/hypen_engine_bg.wasm +0 -0
- package/wasm-node/hypen_engine_bg.wasm.d.ts +20 -13
- 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
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/engine.browser.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
// ============================================================================
|
package/src/managed-router.ts
CHANGED
package/src/remote/server.ts
CHANGED
|
@@ -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)
|
|
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
|
|
631
|
+
clientData.moduleInstance.updateState(state as Record<string, unknown>);
|
|
623
632
|
};
|
|
624
633
|
|
|
625
634
|
await handler({ session, restore });
|