@hypen-space/core 0.4.46 → 0.4.82
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/README.md +8 -9
- package/dist/app.d.ts +56 -6
- package/dist/app.js +77 -8
- package/dist/app.js.map +4 -4
- package/dist/components/builtin.js +77 -8
- package/dist/components/builtin.js.map +4 -4
- package/dist/datasource.d.ts +4 -4
- package/dist/datasource.js.map +1 -1
- package/dist/engine-base.d.ts +193 -0
- package/dist/engine-base.js +540 -0
- package/dist/engine-base.js.map +12 -0
- package/dist/hypen.d.ts +24 -19
- package/dist/hypen.js +7 -7
- package/dist/hypen.js.map +3 -3
- package/dist/index.browser.js +86 -14
- package/dist/index.browser.js.map +6 -6
- package/dist/index.d.ts +3 -2
- package/dist/index.js +92 -20
- package/dist/index.js.map +7 -7
- package/dist/persistence.d.ts +21 -0
- package/dist/remote/client.d.ts +2 -0
- package/dist/remote/client.js +3 -2
- package/dist/remote/client.js.map +3 -3
- package/dist/remote/index.js +3 -2
- package/dist/remote/index.js.map +3 -3
- package/dist/remote/types.d.ts +2 -0
- package/dist/router.js +8 -6
- package/dist/router.js.map +3 -3
- package/package.json +7 -1
- package/src/app.ts +177 -29
- package/src/datasource.ts +4 -4
- package/src/engine-base.ts +358 -0
- package/src/hypen.ts +34 -28
- package/src/index.ts +8 -2
- package/src/persistence.ts +25 -0
- package/src/remote/client.ts +3 -0
- package/src/remote/types.ts +2 -0
- package/src/router.ts +13 -6
- package/src/types.ts +1 -0
package/src/app.ts
CHANGED
|
@@ -7,12 +7,27 @@ 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
9
|
import { DataSourceManager, type DataSourcePlugin, type IDataSourceEngine } from "./datasource.js";
|
|
10
|
+
import type { StateStore } from "./persistence.js";
|
|
10
11
|
|
|
11
12
|
// Interface for engine compatibility (works with both engine.js and engine.browser.js)
|
|
12
13
|
export interface IEngine {
|
|
13
14
|
setModule(name: string, actions: string[], stateKeys: string[], initialState: unknown): void;
|
|
15
|
+
registerModule(name: string, actions: string[], stateKeys: string[], initialState: unknown): void;
|
|
14
16
|
onAction(actionName: string, handler: (action: Action) => void | Promise<void>): void;
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Apply a sparse state update.
|
|
19
|
+
*
|
|
20
|
+
* @param scope Lowercase module name to target a named module registered
|
|
21
|
+
* via `registerModule`. Pass `null` (or empty string) to
|
|
22
|
+
* target the primary module set via `setModule`.
|
|
23
|
+
* @param paths Changed state paths (relative to the targeted module).
|
|
24
|
+
* @param values Map of `path -> new value`.
|
|
25
|
+
*/
|
|
26
|
+
updateStateSparse(
|
|
27
|
+
scope: string | null,
|
|
28
|
+
paths: string[],
|
|
29
|
+
values: Record<string, unknown>
|
|
30
|
+
): void;
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
import { createObservableState, type StateChange, getStateSnapshot } from "./state.js";
|
|
@@ -159,6 +174,11 @@ export interface HypenModuleDefinition<T = unknown> {
|
|
|
159
174
|
* Each entry contains the plugin and its configuration.
|
|
160
175
|
*/
|
|
161
176
|
dataSources?: Array<{ plugin: DataSourcePlugin; config: unknown }>;
|
|
177
|
+
/**
|
|
178
|
+
* Pluggable state store for persistence across sessions.
|
|
179
|
+
* Set via the `.persist(store)` builder method.
|
|
180
|
+
*/
|
|
181
|
+
stateStore?: StateStore<T>;
|
|
162
182
|
handlers: {
|
|
163
183
|
onCreated?: LifecycleHandler<T>;
|
|
164
184
|
onAction: Map<string, ActionHandler<T, any>>;
|
|
@@ -195,6 +215,7 @@ export class HypenAppBuilder<T> {
|
|
|
195
215
|
private template?: string;
|
|
196
216
|
private _registry?: Map<string, HypenModuleDefinition>;
|
|
197
217
|
private dataSourceEntries: Array<{ plugin: DataSourcePlugin; config: unknown }> = [];
|
|
218
|
+
private _stateStore?: StateStore<T>;
|
|
198
219
|
|
|
199
220
|
constructor(
|
|
200
221
|
initialState: T,
|
|
@@ -306,6 +327,25 @@ export class HypenAppBuilder<T> {
|
|
|
306
327
|
return this;
|
|
307
328
|
}
|
|
308
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Attach a persistent state store.
|
|
332
|
+
* The store handles save/restore lifecycle automatically.
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* ```typescript
|
|
336
|
+
* import { durableObjectStore, withKey } from "@hypen-space/cf";
|
|
337
|
+
*
|
|
338
|
+
* app
|
|
339
|
+
* .defineState({ user: null, todos: [] }, { name: "TodoList" })
|
|
340
|
+
* .persist(durableObjectStore(withKey(state => state.user?.id)))
|
|
341
|
+
* .build();
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
persist(store: StateStore<T>): this {
|
|
345
|
+
this._stateStore = store;
|
|
346
|
+
return this;
|
|
347
|
+
}
|
|
348
|
+
|
|
309
349
|
/**
|
|
310
350
|
* Register a data source plugin for live database subscriptions.
|
|
311
351
|
*
|
|
@@ -327,10 +367,10 @@ export class HypenAppBuilder<T> {
|
|
|
327
367
|
* .build();
|
|
328
368
|
* ```
|
|
329
369
|
*
|
|
330
|
-
* In Hypen DSL, bind to data source tables with
|
|
370
|
+
* In Hypen DSL, bind to data source tables with `@provider.table`:
|
|
331
371
|
* ```hypen
|
|
332
|
-
* ForEach(items:
|
|
333
|
-
* Text("
|
|
372
|
+
* ForEach(items: @spacetime.message, key: "id") {
|
|
373
|
+
* Text("@{item.text}")
|
|
334
374
|
* }
|
|
335
375
|
* ```
|
|
336
376
|
*/
|
|
@@ -355,7 +395,7 @@ export class HypenAppBuilder<T> {
|
|
|
355
395
|
* })
|
|
356
396
|
* .ui(hypen`
|
|
357
397
|
* Column {
|
|
358
|
-
* Text("Count:
|
|
398
|
+
* Text("Count: @{state.count}")
|
|
359
399
|
* Button { Text("+") }
|
|
360
400
|
* .onClick("@actions.increment")
|
|
361
401
|
* }
|
|
@@ -410,6 +450,7 @@ export class HypenAppBuilder<T> {
|
|
|
410
450
|
initialState: this.initialState,
|
|
411
451
|
template: this.template,
|
|
412
452
|
dataSources: this.dataSourceEntries.length > 0 ? this.dataSourceEntries : undefined,
|
|
453
|
+
stateStore: this._stateStore,
|
|
413
454
|
handlers: {
|
|
414
455
|
onCreated: this.createdHandler,
|
|
415
456
|
onAction: this.actionHandlers,
|
|
@@ -546,47 +587,70 @@ export class HypenModuleInstance<T extends object = any> {
|
|
|
546
587
|
private stateChangeCallbacks: Array<() => void> = [];
|
|
547
588
|
private dataSourceManager?: DataSourceManager;
|
|
548
589
|
private dataSourceAccessor: DataSourceAccessor = {};
|
|
590
|
+
private stateStore?: StateStore<T>;
|
|
591
|
+
private currentPersistKey: string | null = null;
|
|
592
|
+
private persistDebounceTimer: any;
|
|
593
|
+
private sessionId: string;
|
|
594
|
+
/**
|
|
595
|
+
* Lowercase name used to address this module in the engine. Empty string
|
|
596
|
+
* for anonymous modules (which back the engine's primary module slot).
|
|
597
|
+
*/
|
|
598
|
+
private moduleKey: string = "";
|
|
549
599
|
|
|
550
600
|
constructor(
|
|
551
601
|
engine: IEngine,
|
|
552
602
|
definition: HypenModuleDefinition<T>,
|
|
553
603
|
router?: HypenRouter | null,
|
|
554
|
-
globalContext?: HypenGlobalContext
|
|
604
|
+
globalContext?: HypenGlobalContext,
|
|
605
|
+
sessionId?: string
|
|
555
606
|
) {
|
|
556
607
|
this.engine = engine;
|
|
557
608
|
this.definition = definition;
|
|
558
609
|
this.router = router ?? null;
|
|
559
610
|
this.globalContext = globalContext;
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
//
|
|
565
|
-
|
|
611
|
+
this.sessionId = sessionId ?? crypto.randomUUID();
|
|
612
|
+
this.stateStore = definition.stateStore;
|
|
613
|
+
|
|
614
|
+
// Lowercase module name — used as the engine-side scope key. Empty for
|
|
615
|
+
// anonymous modules (which become the engine's primary module).
|
|
616
|
+
const moduleKey = (definition.name || "").toLowerCase();
|
|
617
|
+
this.moduleKey = moduleKey;
|
|
618
|
+
|
|
619
|
+
// Create observable state that forwards changed paths to the engine.
|
|
620
|
+
// Paths are passed through raw — the engine handles module scoping via
|
|
621
|
+
// the IR's `module_scope` field derived from `module <Name> { ... }` in
|
|
622
|
+
// the DSL.
|
|
566
623
|
this.state = createObservableState<T>(definition.initialState as T & object, {
|
|
567
624
|
onChange: (change: StateChange) => {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
625
|
+
this.engine.updateStateSparse(
|
|
626
|
+
moduleKey || null,
|
|
627
|
+
change.paths,
|
|
628
|
+
change.newValues
|
|
629
|
+
);
|
|
571
630
|
this.stateChangeCallbacks.forEach(cb => cb());
|
|
631
|
+
this.persistIfNeeded();
|
|
572
632
|
},
|
|
573
|
-
pathPrefix: statePrefix || undefined,
|
|
574
633
|
});
|
|
575
634
|
|
|
576
|
-
// Register with engine
|
|
577
|
-
//
|
|
578
|
-
//
|
|
635
|
+
// Register the module with the engine. Named modules go into the
|
|
636
|
+
// secondary `engine.modules` slot under their lowercase name; anonymous
|
|
637
|
+
// modules become the engine's primary module.
|
|
579
638
|
const snapshot = getStateSnapshot(this.state);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
639
|
+
if (moduleKey) {
|
|
640
|
+
this.engine.registerModule(
|
|
641
|
+
moduleKey,
|
|
642
|
+
definition.actions,
|
|
643
|
+
definition.stateKeys,
|
|
644
|
+
snapshot
|
|
645
|
+
);
|
|
646
|
+
} else {
|
|
647
|
+
this.engine.setModule(
|
|
648
|
+
"AnonymousModule",
|
|
649
|
+
definition.actions,
|
|
650
|
+
definition.stateKeys,
|
|
651
|
+
snapshot
|
|
652
|
+
);
|
|
653
|
+
}
|
|
590
654
|
|
|
591
655
|
// Register action handlers with flexible parameter support
|
|
592
656
|
for (const [actionName, handler] of definition.handlers.onAction) {
|
|
@@ -766,10 +830,87 @@ export class HypenModuleInstance<T extends object = any> {
|
|
|
766
830
|
return false;
|
|
767
831
|
}
|
|
768
832
|
|
|
833
|
+
/**
|
|
834
|
+
* Re-evaluate the persistence key and debounce saves on state mutation.
|
|
835
|
+
* Handles key transitions (null->value, value->null, value->different value).
|
|
836
|
+
*/
|
|
837
|
+
private persistIfNeeded(): void {
|
|
838
|
+
if (!this.stateStore) return;
|
|
839
|
+
|
|
840
|
+
const store = this.stateStore;
|
|
841
|
+
const newKey = store.resolveKey(
|
|
842
|
+
this.state,
|
|
843
|
+
this.definition.name || "AnonymousModule",
|
|
844
|
+
this.sessionId
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
// Key transition: null -> value (e.g. login)
|
|
848
|
+
if (newKey && !this.currentPersistKey) {
|
|
849
|
+
this.activatePersistence(newKey);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Key transition: value -> null (e.g. logout)
|
|
854
|
+
if (!newKey && this.currentPersistKey) {
|
|
855
|
+
this.currentPersistKey = null;
|
|
856
|
+
return; // stop persisting, but don't delete stored data
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Key transition: value -> different value (e.g. account switch)
|
|
860
|
+
if (newKey && newKey !== this.currentPersistKey) {
|
|
861
|
+
this.activatePersistence(newKey);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// No key = no persistence
|
|
866
|
+
if (!this.currentPersistKey) return;
|
|
867
|
+
|
|
868
|
+
// Same key — debounced save
|
|
869
|
+
clearTimeout(this.persistDebounceTimer);
|
|
870
|
+
this.persistDebounceTimer = setTimeout(() => {
|
|
871
|
+
const snapshot = getStateSnapshot(this.state);
|
|
872
|
+
store.save(this.currentPersistKey!, snapshot);
|
|
873
|
+
}, 50);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Activate persistence for the given key: load stored state and merge.
|
|
878
|
+
*/
|
|
879
|
+
private async activatePersistence(key: string): Promise<void> {
|
|
880
|
+
this.currentPersistKey = key;
|
|
881
|
+
const stored = await this.stateStore!.load(key);
|
|
882
|
+
if (stored) {
|
|
883
|
+
// Merge stored over initialState, then apply to live state
|
|
884
|
+
const merged = { ...this.definition.initialState, ...stored };
|
|
885
|
+
Object.assign(this.state, merged); // triggers re-render via proxy
|
|
886
|
+
} else {
|
|
887
|
+
// First time with this key — save current state
|
|
888
|
+
const snapshot = getStateSnapshot(this.state);
|
|
889
|
+
await this.stateStore!.save(key, snapshot);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
769
893
|
/**
|
|
770
894
|
* Call the onCreated handler and connect data source plugins
|
|
771
895
|
*/
|
|
772
896
|
private async callCreatedHandler(): Promise<void> {
|
|
897
|
+
// Load persisted state before anything else
|
|
898
|
+
if (this.stateStore) {
|
|
899
|
+
const key = this.stateStore.resolveKey(
|
|
900
|
+
this.state,
|
|
901
|
+
this.definition.name || "AnonymousModule",
|
|
902
|
+
this.sessionId
|
|
903
|
+
);
|
|
904
|
+
if (key) {
|
|
905
|
+
this.currentPersistKey = key;
|
|
906
|
+
const stored = await this.stateStore.load(key);
|
|
907
|
+
if (stored) {
|
|
908
|
+
const merged = { ...this.definition.initialState, ...stored };
|
|
909
|
+
Object.assign(this.state, merged);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
773
914
|
// Initialize data source plugins if any are registered
|
|
774
915
|
if (this.definition.dataSources?.length) {
|
|
775
916
|
// The engine must implement IDataSourceEngine methods
|
|
@@ -822,6 +963,13 @@ export class HypenModuleInstance<T extends object = any> {
|
|
|
822
963
|
async destroy(): Promise<void> {
|
|
823
964
|
if (this.isDestroyed) return;
|
|
824
965
|
|
|
966
|
+
// Flush any pending persistence writes before shutdown
|
|
967
|
+
if (this.currentPersistKey && this.stateStore) {
|
|
968
|
+
clearTimeout(this.persistDebounceTimer);
|
|
969
|
+
const snapshot = getStateSnapshot(this.state);
|
|
970
|
+
await this.stateStore.save(this.currentPersistKey, snapshot);
|
|
971
|
+
}
|
|
972
|
+
|
|
825
973
|
// Disconnect all data source plugins
|
|
826
974
|
if (this.dataSourceManager) {
|
|
827
975
|
try {
|
package/src/datasource.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Enables live subscription databases (SpacetimeDB, Firebase, Convex, Electric SQL)
|
|
5
5
|
* to push data into the Hypen reactive engine via a unified plugin interface.
|
|
6
6
|
*
|
|
7
|
-
* UI bindings use the
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
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
11
|
*
|
|
12
12
|
* @module
|
|
13
13
|
*/
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for Hypen WASM engine wrappers.
|
|
3
|
+
*
|
|
4
|
+
* The Hypen engine is distributed as a wasm-bindgen module — two builds:
|
|
5
|
+
*
|
|
6
|
+
* - `wasm-node` (bundler target) — used by `@hypen-space/server` on Node/Bun
|
|
7
|
+
* - `wasm-browser` (web target) — used by `@hypen-space/web-engine` in browsers
|
|
8
|
+
*
|
|
9
|
+
* Both builds expose an identical `WasmEngine` class; the only genuine
|
|
10
|
+
* platform differences are:
|
|
11
|
+
*
|
|
12
|
+
* 1. How the WASM module is *instantiated*. Node can synchronously `new
|
|
13
|
+
* WasmEngine()` (the bundler target auto-initializes); the browser
|
|
14
|
+
* target needs an async dynamic import of the JS glue code and an
|
|
15
|
+
* explicit `wasm_init(url)` call before any `new WasmEngine()`.
|
|
16
|
+
*
|
|
17
|
+
* 2. How host state is *unwrapped* before it crosses the WASM boundary.
|
|
18
|
+
* Node can use `structuredClone` (with a `__getSnapshot` fast path for
|
|
19
|
+
* Hypen's proxy-backed state); the browser path does a
|
|
20
|
+
* `JSON.parse(JSON.stringify())` round-trip plus a Map-to-Object
|
|
21
|
+
* conversion for the native Map values that the wasm-bindgen browser
|
|
22
|
+
* target returns in action payloads.
|
|
23
|
+
*
|
|
24
|
+
* Everything else — method wrappers, error classification, callback wiring,
|
|
25
|
+
* revision plumbing — is identical between the two. That shared surface
|
|
26
|
+
* lives here.
|
|
27
|
+
*
|
|
28
|
+
* Subclasses implement two abstract hooks:
|
|
29
|
+
*
|
|
30
|
+
* - `init(options?): Promise<void>` — platform-specific WASM setup. Must
|
|
31
|
+
* assign `this.wasmEngine` and set `this.initialized = true` on success.
|
|
32
|
+
*
|
|
33
|
+
* - `unwrapForWasm<T>(value: T): T` — platform-specific proxy-to-plain
|
|
34
|
+
* conversion. Called before any value crosses into WASM.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { frameworkLoggers } from "./logger.js";
|
|
38
|
+
import { classifyEngineError } from "./result.js";
|
|
39
|
+
import type {
|
|
40
|
+
Patch,
|
|
41
|
+
Action,
|
|
42
|
+
RenderCallback,
|
|
43
|
+
ActionHandler,
|
|
44
|
+
ComponentResolver,
|
|
45
|
+
} from "./types.js";
|
|
46
|
+
|
|
47
|
+
const log = frameworkLoggers.engine;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Shared base wrapping a wasm-bindgen `WasmEngine`. `any` here is load-
|
|
51
|
+
* bearing: the concrete type is `WasmEngine` from either the `wasm-node`
|
|
52
|
+
* or `wasm-browser` build, and those are separate generated `.d.ts`
|
|
53
|
+
* files that core cannot (and should not) depend on. Subclasses keep
|
|
54
|
+
* their own typed reference if they want stricter checking locally.
|
|
55
|
+
*/
|
|
56
|
+
export abstract class BaseEngine {
|
|
57
|
+
protected wasmEngine: any = null;
|
|
58
|
+
protected initialized = false;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Initialize the WASM module. Platform-specific.
|
|
62
|
+
*
|
|
63
|
+
* Must assign `this.wasmEngine` and set `this.initialized = true`.
|
|
64
|
+
* Idempotent: must early-return if already initialized.
|
|
65
|
+
*/
|
|
66
|
+
abstract init(options?: unknown): Promise<void>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert a host value (possibly a Proxy, possibly containing Maps,
|
|
70
|
+
* possibly wrapped in Hypen's observable state) into a plain-object
|
|
71
|
+
* form safe to pass across the WASM boundary.
|
|
72
|
+
*
|
|
73
|
+
* Called on every `updateState` / `updateStateSparse` / `renderInto`
|
|
74
|
+
* / `setContext` / `registerModule` entry point.
|
|
75
|
+
*/
|
|
76
|
+
protected abstract unwrapForWasm<T>(value: T): T;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Guard that throws if the engine hasn't been initialized.
|
|
80
|
+
* Returns the underlying WASM engine for chained access.
|
|
81
|
+
*/
|
|
82
|
+
protected ensureInitialized(): any {
|
|
83
|
+
if (!this.wasmEngine) {
|
|
84
|
+
throw new Error("Engine not initialized. Call init() first.");
|
|
85
|
+
}
|
|
86
|
+
return this.wasmEngine;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set the render callback that receives patches.
|
|
91
|
+
*/
|
|
92
|
+
setRenderCallback(callback: RenderCallback): void {
|
|
93
|
+
const engine = this.ensureInitialized();
|
|
94
|
+
engine.setRenderCallback((patches: Patch[]) => {
|
|
95
|
+
callback(patches);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Set the component resolver for dynamic component composition.
|
|
101
|
+
*/
|
|
102
|
+
setComponentResolver(resolver: ComponentResolver): void {
|
|
103
|
+
const engine = this.ensureInitialized();
|
|
104
|
+
engine.setComponentResolver(
|
|
105
|
+
(componentName: string, contextPath: string | null) => {
|
|
106
|
+
return resolver(componentName, contextPath);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse and render Hypen DSL source code.
|
|
113
|
+
* @throws {ParseError} if the source fails to parse
|
|
114
|
+
* @throws {RenderError} if rendering fails
|
|
115
|
+
*/
|
|
116
|
+
renderSource(source: string): void {
|
|
117
|
+
const engine = this.ensureInitialized();
|
|
118
|
+
try {
|
|
119
|
+
engine.renderSource(source);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw classifyEngineError(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render a lazy component (for lazy route loading).
|
|
127
|
+
*/
|
|
128
|
+
renderLazyComponent(source: string): void {
|
|
129
|
+
const engine = this.ensureInitialized();
|
|
130
|
+
engine.renderLazyComponent(source);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render a component into a specific parent node (subtree rendering).
|
|
135
|
+
* @throws {ParseError} if the source fails to parse
|
|
136
|
+
* @throws {RenderError} if the parent node is not found or rendering fails
|
|
137
|
+
*/
|
|
138
|
+
renderInto(
|
|
139
|
+
source: string,
|
|
140
|
+
parentNodeId: string,
|
|
141
|
+
state: Record<string, any>,
|
|
142
|
+
): void {
|
|
143
|
+
const engine = this.ensureInitialized();
|
|
144
|
+
try {
|
|
145
|
+
engine.renderInto(source, parentNodeId, this.unwrapForWasm(state));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
throw classifyEngineError(err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Apply a sparse state update.
|
|
153
|
+
*
|
|
154
|
+
* @param scope Lowercase module name to target a named module registered
|
|
155
|
+
* via `registerModule`. Pass `null` (or empty string) to
|
|
156
|
+
* target the primary module set via `setModule`.
|
|
157
|
+
* @param paths Changed state paths (relative to the targeted module).
|
|
158
|
+
* @param values Map of `path -> new value`.
|
|
159
|
+
* @throws {StateError} if the state patch is invalid
|
|
160
|
+
*/
|
|
161
|
+
updateStateSparse(
|
|
162
|
+
scope: string | null,
|
|
163
|
+
paths: string[],
|
|
164
|
+
values: Record<string, any>,
|
|
165
|
+
): void {
|
|
166
|
+
const engine = this.ensureInitialized();
|
|
167
|
+
|
|
168
|
+
if (paths.length === 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
engine.updateStateSparse(scope ?? "", paths, this.unwrapForWasm(values));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw classifyEngineError(err);
|
|
176
|
+
}
|
|
177
|
+
log.debug("State changed (sparse):", { scope, paths });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Apply a full-state patch. See [updateStateSparse] for `scope` semantics.
|
|
182
|
+
* Prefer the sparse form when only a few paths changed.
|
|
183
|
+
*/
|
|
184
|
+
updateState(scope: string | null, statePatch: Record<string, any>): void {
|
|
185
|
+
const engine = this.ensureInitialized();
|
|
186
|
+
engine.updateState(scope ?? "", this.unwrapForWasm(statePatch));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Dispatch an action.
|
|
191
|
+
* @throws {HypenError} if the action dispatch fails
|
|
192
|
+
*/
|
|
193
|
+
dispatchAction(name: string, payload?: any): void {
|
|
194
|
+
const engine = this.ensureInitialized();
|
|
195
|
+
try {
|
|
196
|
+
engine.dispatchAction(name, payload ?? null);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw classifyEngineError(err);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Register an action handler. Errors thrown synchronously or via rejected
|
|
204
|
+
* promises are caught and logged at the framework level; caller-side error
|
|
205
|
+
* recovery should happen inside `handler`.
|
|
206
|
+
*
|
|
207
|
+
* Action payloads may contain platform-specific native values (e.g. Maps
|
|
208
|
+
* in the browser target). Subclasses can override `normalizeAction` to
|
|
209
|
+
* pre-process the action before calling the user handler.
|
|
210
|
+
*/
|
|
211
|
+
onAction(actionName: string, handler: ActionHandler): void {
|
|
212
|
+
const engine = this.ensureInitialized();
|
|
213
|
+
engine.onAction(actionName, (action: Action) => {
|
|
214
|
+
const normalized = this.normalizeAction(action);
|
|
215
|
+
Promise.resolve(handler(normalized)).catch((err) => {
|
|
216
|
+
log.error("Action handler error:", err);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Platform hook: normalize an incoming action before the user handler
|
|
223
|
+
* sees it. Default identity; subclasses can override to unwrap native
|
|
224
|
+
* values (e.g. browser Maps in action payloads).
|
|
225
|
+
*/
|
|
226
|
+
protected normalizeAction(action: Action): Action {
|
|
227
|
+
return action;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Initialize the primary module.
|
|
232
|
+
*/
|
|
233
|
+
setModule(
|
|
234
|
+
name: string,
|
|
235
|
+
actions: string[],
|
|
236
|
+
stateKeys: string[],
|
|
237
|
+
initialState: Record<string, any>,
|
|
238
|
+
): void {
|
|
239
|
+
const engine = this.ensureInitialized();
|
|
240
|
+
engine.setModule(name, actions, stateKeys, initialState);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Register a named module for multi-module apps.
|
|
245
|
+
*
|
|
246
|
+
* Unlike setModule which sets the primary module, this registers an
|
|
247
|
+
* additional module whose state is scoped to `module <name> { ... }`
|
|
248
|
+
* blocks in the DSL.
|
|
249
|
+
*/
|
|
250
|
+
registerModule(
|
|
251
|
+
name: string,
|
|
252
|
+
actions: string[],
|
|
253
|
+
stateKeys: string[],
|
|
254
|
+
initialState: Record<string, any>,
|
|
255
|
+
): void {
|
|
256
|
+
const engine = this.ensureInitialized();
|
|
257
|
+
engine.registerModule(
|
|
258
|
+
name,
|
|
259
|
+
actions,
|
|
260
|
+
stateKeys,
|
|
261
|
+
this.unwrapForWasm(initialState),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get the current revision number.
|
|
267
|
+
*
|
|
268
|
+
* Note: the underlying WASM builder returns `bigint` in some targets
|
|
269
|
+
* and `number` in others; this normalizes to `number` for consumer
|
|
270
|
+
* ergonomics (revision counts don't exceed 2^53).
|
|
271
|
+
*/
|
|
272
|
+
getRevision(): number {
|
|
273
|
+
const engine = this.ensureInitialized();
|
|
274
|
+
return Number(engine.getRevision());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Clear resolved components and caches, preserving primitives and resolver.
|
|
279
|
+
* Call before renderSource() during hot-reload so components are re-resolved
|
|
280
|
+
* from fresh source files.
|
|
281
|
+
*/
|
|
282
|
+
clearResolvedComponents(): void {
|
|
283
|
+
const engine = this.ensureInitialized();
|
|
284
|
+
engine.clearResolvedComponents();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Clear the engine tree.
|
|
289
|
+
*/
|
|
290
|
+
clearTree(): void {
|
|
291
|
+
const engine = this.ensureInitialized();
|
|
292
|
+
engine.clearTree();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Full reset: clears tree, module, component registry, dependencies,
|
|
297
|
+
* and revision. Render callback and component resolver are preserved.
|
|
298
|
+
*/
|
|
299
|
+
reset(): void {
|
|
300
|
+
const engine = this.ensureInitialized();
|
|
301
|
+
engine.reset();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Debug method to inspect parsed components.
|
|
306
|
+
*/
|
|
307
|
+
debugParseComponent(source: string): string {
|
|
308
|
+
const engine = this.ensureInitialized();
|
|
309
|
+
return engine.debugParseComponent(source);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Data Source Context ────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Set (or replace) a named data source context.
|
|
316
|
+
*
|
|
317
|
+
* Registers the provider in the dependency graph, stores the data,
|
|
318
|
+
* and re-renders every node bound to `$name.*`.
|
|
319
|
+
*
|
|
320
|
+
* @param name - Provider name (e.g., "spacetime", "firebase")
|
|
321
|
+
* @param data - Full state object for this provider
|
|
322
|
+
*/
|
|
323
|
+
setContext(name: string, data: Record<string, unknown>): void {
|
|
324
|
+
const engine = this.ensureInitialized();
|
|
325
|
+
try {
|
|
326
|
+
engine.setContext(name, this.unwrapForWasm(data));
|
|
327
|
+
} catch (err) {
|
|
328
|
+
throw classifyEngineError(err);
|
|
329
|
+
}
|
|
330
|
+
log.debug(`Data source "${name}" context set`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Remove a data source context entirely.
|
|
335
|
+
* Drops the provider's state and re-renders bound nodes (they resolve to null).
|
|
336
|
+
*/
|
|
337
|
+
removeContext(name: string): void {
|
|
338
|
+
const engine = this.ensureInitialized();
|
|
339
|
+
engine.removeContext(name);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Resource System ─────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Register resources (name → raw SVG string) with the engine.
|
|
346
|
+
*
|
|
347
|
+
* Each SVG is parsed by the WASM engine. When the engine encounters
|
|
348
|
+
* `Icon(@resources.heart)` in the DSL, it resolves the name from registered
|
|
349
|
+
* resources and injects SVG path data into the Create patch props.
|
|
350
|
+
*
|
|
351
|
+
* @param map - Flat map of resource name to raw SVG string
|
|
352
|
+
*/
|
|
353
|
+
registerResources(map: Record<string, string>): void {
|
|
354
|
+
const engine = this.ensureInitialized();
|
|
355
|
+
engine.registerResources(map);
|
|
356
|
+
log.debug(`Resources registered (${Object.keys(map).length} entries)`);
|
|
357
|
+
}
|
|
358
|
+
}
|