@hypen-space/core 0.4.82 → 0.4.83

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.
@@ -2,15 +2,64 @@
2
2
  * Managed Router — orchestrates module mount/unmount on route changes.
3
3
  *
4
4
  * When the router navigates to a route:
5
- * 1. Unmounts the previous module (destroy instance, unregister from GlobalContext)
6
- * 2. Mounts the new module (create instance with namespaced state, register)
5
+ * 1. Deactivates and unmounts the previous module (either persisting it for
6
+ * later reuse or destroying it).
7
+ * 2. Mounts the new module (creating it fresh or restoring from cache) and
8
+ * activates it.
7
9
  *
8
10
  * Module names are used as state prefixes (lowercased) for isolation.
11
+ *
12
+ * ## Persistence (default: on for module-backed routes)
13
+ *
14
+ * By default, any route whose `component` resolves to a registered module
15
+ * definition (or provides one inline via `route.module`) has its module
16
+ * instance **persisted** across navigations. This preserves module state
17
+ * (e.g. fetched data, form inputs, scroll position in state) so that
18
+ * navigating away and back doesn't re-trigger the initial "loading" state
19
+ * that usually lives in `onCreated`.
20
+ *
21
+ * Opt out by setting `persist: false` on the module definition. Routes
22
+ * without a module definition are unchanged — they have nothing to persist.
23
+ *
24
+ * ## Persistence cache bound (LRU)
25
+ *
26
+ * The persisted-module cache is an insertion-ordered LRU with a default
27
+ * cap of `DEFAULT_MAX_PERSISTED_MODULES` (= 10). Persisting a module
28
+ * past the cap evicts the least-recently-persisted entry and tears it
29
+ * down via `instance.destroy()` + `unregisterModule()` — the same path a
30
+ * `persist: false` module takes on unmount. Restoring from the cache
31
+ * counts as a touch (the entry is removed on mount and re-inserted on
32
+ * next unmount, placing it at the MRU end).
33
+ *
34
+ * The cap matches the engine's `DEFAULT_ROUTER_CACHE_SIZE` for the
35
+ * Router IR subtree cache, so SDK-level module retention and
36
+ * engine-level DOM-subtree retention stay in lockstep by default.
37
+ * Override via the `maxPersistedModules` constructor option. Pass
38
+ * `Infinity` to disable eviction (only advisable for bounded route
39
+ * sets — otherwise a long session will leak).
40
+ *
41
+ * ## Lifecycle on navigation
42
+ *
43
+ * First visit to a route:
44
+ * `construct → onCreated → onActivated`
45
+ * Navigate away:
46
+ * `onDeactivated` (then either persist in cache OR `onDestroyed`)
47
+ * Revisit (persisted):
48
+ * `onActivated` (only — `onCreated` does not re-run)
49
+ *
50
+ * Use `onActivated` for per-navigation side effects like refreshing data;
51
+ * use `onCreated` for one-time module setup.
9
52
  */
10
53
  import type { IEngine, HypenModuleDefinition } from "./app.js";
11
54
  import { HypenModuleInstance, HypenApp } from "./app.js";
12
55
  import type { HypenRouter } from "./router.js";
13
56
  import type { HypenGlobalContext } from "./context.js";
57
+ /**
58
+ * Default upper bound on `persistedModules`. Mirrors
59
+ * `DEFAULT_ROUTER_CACHE_SIZE` in the engine's Router IR node so the SDK
60
+ * module cache and the engine subtree cache evict in lockstep.
61
+ */
62
+ export declare const DEFAULT_MAX_PERSISTED_MODULES = 10;
14
63
  export interface RouteDefinition {
15
64
  /** Route path pattern (e.g., "/", "/profile/:id") */
16
65
  path: string;
@@ -19,6 +68,16 @@ export interface RouteDefinition {
19
68
  /** Inline module definition (alternative to registry lookup) */
20
69
  module?: HypenModuleDefinition;
21
70
  }
71
+ export interface ManagedRouterOptions {
72
+ /**
73
+ * Maximum number of persisted module instances retained across
74
+ * navigations. When persistence would push the cache past this cap,
75
+ * the least-recently-persisted entry is destroyed. Defaults to
76
+ * `DEFAULT_MAX_PERSISTED_MODULES`. Pass `Infinity` to disable
77
+ * eviction.
78
+ */
79
+ maxPersistedModules?: number;
80
+ }
22
81
  export declare class ManagedRouter {
23
82
  private router;
24
83
  private engine;
@@ -28,22 +87,67 @@ export declare class ManagedRouter {
28
87
  private activeModule;
29
88
  private activeRoute;
30
89
  private unsubscribe;
31
- /** Cached instances for modules with persist=true */
90
+ /**
91
+ * Cached instances for module-backed routes, keyed by the lowercase
92
+ * module id. Entries are populated on unmount (when `persist` is truthy)
93
+ * and consulted on mount to restore state across navigations.
94
+ *
95
+ * Persistence is the default for any route whose `component` or `module`
96
+ * resolves to a module definition; opt out by setting `persist: false`
97
+ * on the definition.
98
+ *
99
+ * Bounded by `maxPersistedModules`. Map insertion order drives LRU
100
+ * eviction: restoring from the cache removes the entry (so the next
101
+ * persist re-inserts it at the MRU end), and persisting past the cap
102
+ * destroys the oldest entry.
103
+ */
32
104
  private persistedModules;
33
- constructor(router: HypenRouter, engine: IEngine, registry: HypenApp, globalContext: HypenGlobalContext);
105
+ private readonly maxPersistedModules;
106
+ /**
107
+ * Serialized chain of in-flight route transitions. Because mount /
108
+ * unmount are now async (they await `onActivated` / `onDeactivated`),
109
+ * back-to-back navigations must not interleave. Each `handleRouteChange`
110
+ * appends to this chain so transitions run strictly in order.
111
+ */
112
+ private navPromise;
113
+ constructor(router: HypenRouter, engine: IEngine, registry: HypenApp, globalContext: HypenGlobalContext, options?: ManagedRouterOptions);
34
114
  /**
35
115
  * Add a route definition.
36
116
  */
37
117
  addRoute(route: RouteDefinition): this;
38
118
  /**
39
119
  * Start listening for route changes and mount the initial route.
120
+ *
121
+ * Also installs the `@router.*` engine action handlers so DSL authors can
122
+ * write `.onClick(@router.push, to: "/x")` / `@router.back` /
123
+ * `@router.replace` / `@router.forward` and have them dispatched against
124
+ * this session's `HypenRouter` without any per-example wiring. The
125
+ * reserved namespace is set up by `hypen-engine` in `ir/expand.rs`.
40
126
  */
41
127
  start(): void;
128
+ /**
129
+ * Register handlers for the reserved `router.*` action namespace.
130
+ *
131
+ * All navigations go through `queueMicrotask` so the router state mutation
132
+ * lands after the engine finishes dispatching the action — synchronous
133
+ * writes would re-enter the engine's WASM state proxy, which the Rust side
134
+ * rejects ("recursive use of an object").
135
+ */
136
+ private installRouterActions;
42
137
  /**
43
138
  * Stop listening and unmount the active module.
44
139
  * Also destroys all persisted modules.
140
+ *
141
+ * Waits for any in-flight navigation to settle before tearing down so
142
+ * lifecycle hooks always complete in order.
143
+ */
144
+ stop(): Promise<void>;
145
+ /**
146
+ * Returns a promise that resolves once all currently-queued route
147
+ * transitions have finished. Useful in tests to await lifecycle
148
+ * ordering without reaching into internals.
45
149
  */
46
- stop(): void;
150
+ waitForNavigation(): Promise<void>;
47
151
  /**
48
152
  * Get the currently active module instance.
49
153
  */
@@ -53,7 +157,19 @@ export declare class ManagedRouter {
53
157
  */
54
158
  getActiveRoute(): RouteDefinition | null;
55
159
  private handleRouteChange;
160
+ private processRouteChange;
56
161
  private matchRoute;
57
162
  private mount;
58
163
  private unmountActive;
164
+ /**
165
+ * Trim `persistedModules` down to `maxPersistedModules` by destroying
166
+ * the oldest entries (FIFO in Map insertion order, which tracks LRU
167
+ * because `mount()` removes on restore and `unmountActive()` re-inserts
168
+ * at the MRU end on every persist).
169
+ *
170
+ * Errors during eviction are logged — one misbehaving `onDestroyed` hook
171
+ * must not block the router from shrinking the cache on the next
172
+ * navigation.
173
+ */
174
+ private evictPersistedOverflow;
59
175
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Portable helper DI seam for `@hypen-space/core`.
3
+ *
4
+ * `@hypen-space/core` is WASM-free: it ships to contexts (tree-shakers,
5
+ * browser remote-client bundles) that must not pull in the 2 MB engine
6
+ * binary. But every Hypen runtime — Node via `@hypen-space/server`,
7
+ * browser SPA via `@hypen-space/web-engine`, tests via the bun preload
8
+ * — composes core with a host that HAS loaded the engine. Those
9
+ * packages call [`setPortableImpl`] at their init time, plugging the
10
+ * canonical Rust-engine impls (`hypen-engine-rs/src/portable/`) into
11
+ * core.
12
+ *
13
+ * There is no TypeScript fallback. A core instance whose portable
14
+ * helpers are called before `setPortableImpl` has run throws a clear
15
+ * error pointing at the fix. Reasons:
16
+ *
17
+ * * Any fallback is a second implementation to maintain, exactly the
18
+ * drift tax this whole refactor eliminated for the other four SDKs.
19
+ * * Core's own consumers of the portable helpers (`HypenRouter.matchPath`,
20
+ * `createObservableState`'s `notifyChange`) are never reached in
21
+ * remote-client-only browser bundles — those bundles drive UI purely
22
+ * from server-pushed patches.
23
+ * * Callers who DO reach the helpers (server, web-engine, tests) always
24
+ * have an engine available.
25
+ */
26
+ import type { StateChange } from "./state";
27
+ export interface PortableImpl {
28
+ diffState(oldState: any, newState: any, basePath?: string): StateChange;
29
+ matchPath(pattern: string, path: string): {
30
+ params: Record<string, string>;
31
+ } | null;
32
+ pathGet(value: any, path: string): unknown;
33
+ pathHas(value: any, path: string): boolean;
34
+ pathSet(value: any, path: string, newValue: any): any;
35
+ pathDelete(value: any, path: string): {
36
+ value: any;
37
+ removed: boolean;
38
+ };
39
+ encodeUriComponent(input: string): string;
40
+ decodeUriComponent(input: string): string;
41
+ parseQuery(fullPath: string): {
42
+ path: string;
43
+ query: Record<string, string>;
44
+ };
45
+ buildUrl(path: string, query: Record<string, string>): string;
46
+ }
47
+ /**
48
+ * Install the engine-backed [`PortableImpl`]. Called once by
49
+ * `@hypen-space/server` at import time (synchronous wasm-node load) or
50
+ * by `@hypen-space/web-engine` inside `Engine.init()` once the browser
51
+ * wasm module resolves.
52
+ *
53
+ * Passing `null` resets to the "not installed" throwing default — used
54
+ * by test teardown when you want to verify the no-engine error path.
55
+ */
56
+ export declare function setPortableImpl(impl: PortableImpl | null): void;
57
+ /**
58
+ * Live portable API. Every call dispatches to whatever
59
+ * [`setPortableImpl`] most recently installed.
60
+ */
61
+ export declare const portable: PortableImpl;
62
+ export type { StatePath } from "./state";
package/dist/router.d.ts CHANGED
@@ -73,7 +73,13 @@ export declare class HypenRouter {
73
73
  */
74
74
  getState(): RouteState;
75
75
  /**
76
- * Match a pattern against a path
76
+ * Match a pattern against a path.
77
+ *
78
+ * Thin wrapper over [`portable.matchPath`] — the three-case matcher
79
+ * (exact / `/prefix/*` wildcard / `:param`) lives in the Rust engine
80
+ * at `hypen-engine-rs/src/portable/route.rs` and is reached through
81
+ * the WASM installed by server / web-engine, with a TS fallback for
82
+ * standalone core use.
77
83
  */
78
84
  matchPath(pattern: string, path: string): RouteMatch | null;
79
85
  /**
package/dist/router.js CHANGED
@@ -21,6 +21,53 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
21
21
  throw Error('Dynamic require of "' + x + '" is not supported');
22
22
  });
23
23
 
24
+ // src/portable.ts
25
+ function notInstalled(name) {
26
+ throw new Error(`[@hypen-space/core] Portable helper "${name}" called before the engine was installed. ` + `Import @hypen-space/server, @hypen-space/web-engine, or call setPortableImpl() before use.`);
27
+ }
28
+ var current = {
29
+ diffState: () => notInstalled("diffState"),
30
+ matchPath: () => notInstalled("matchPath"),
31
+ pathGet: () => notInstalled("pathGet"),
32
+ pathHas: () => notInstalled("pathHas"),
33
+ pathSet: () => notInstalled("pathSet"),
34
+ pathDelete: () => notInstalled("pathDelete"),
35
+ encodeUriComponent: () => notInstalled("encodeUriComponent"),
36
+ decodeUriComponent: () => notInstalled("decodeUriComponent"),
37
+ parseQuery: () => notInstalled("parseQuery"),
38
+ buildUrl: () => notInstalled("buildUrl")
39
+ };
40
+ function setPortableImpl(impl) {
41
+ if (impl === null) {
42
+ current = {
43
+ diffState: () => notInstalled("diffState"),
44
+ matchPath: () => notInstalled("matchPath"),
45
+ pathGet: () => notInstalled("pathGet"),
46
+ pathHas: () => notInstalled("pathHas"),
47
+ pathSet: () => notInstalled("pathSet"),
48
+ pathDelete: () => notInstalled("pathDelete"),
49
+ encodeUriComponent: () => notInstalled("encodeUriComponent"),
50
+ decodeUriComponent: () => notInstalled("decodeUriComponent"),
51
+ parseQuery: () => notInstalled("parseQuery"),
52
+ buildUrl: () => notInstalled("buildUrl")
53
+ };
54
+ return;
55
+ }
56
+ current = impl;
57
+ }
58
+ var portable = {
59
+ diffState: (o, n, b) => current.diffState(o, n, b),
60
+ matchPath: (p, path) => current.matchPath(p, path),
61
+ pathGet: (v, p) => current.pathGet(v, p),
62
+ pathHas: (v, p) => current.pathHas(v, p),
63
+ pathSet: (v, p, nv) => current.pathSet(v, p, nv),
64
+ pathDelete: (v, p) => current.pathDelete(v, p),
65
+ encodeUriComponent: (s) => current.encodeUriComponent(s),
66
+ decodeUriComponent: (s) => current.decodeUriComponent(s),
67
+ parseQuery: (f) => current.parseQuery(f),
68
+ buildUrl: (p, q) => current.buildUrl(p, q)
69
+ };
70
+
24
71
  // src/state.ts
25
72
  var IS_PROXY = Symbol.for("hypen.isProxy");
26
73
  var RAW_TARGET = Symbol.for("hypen.rawTarget");
@@ -80,51 +127,7 @@ function deepClone(obj) {
80
127
  return cloneInternal(obj);
81
128
  }
82
129
  function diffState(oldState, newState, basePath = "") {
83
- const paths = [];
84
- const newValues = {};
85
- function diff(oldVal, newVal, path) {
86
- if (oldVal === newVal)
87
- return;
88
- if (typeof oldVal !== "object" || typeof newVal !== "object" || oldVal === null || newVal === null) {
89
- if (oldVal !== newVal) {
90
- paths.push(path);
91
- newValues[path] = newVal;
92
- }
93
- return;
94
- }
95
- if (Array.isArray(oldVal) || Array.isArray(newVal)) {
96
- if (!Array.isArray(oldVal) || !Array.isArray(newVal) || oldVal.length !== newVal.length) {
97
- paths.push(path);
98
- newValues[path] = newVal;
99
- return;
100
- }
101
- for (let i = 0;i < newVal.length; i++) {
102
- const itemPath = path ? `${path}.${i}` : `${i}`;
103
- diff(oldVal[i], newVal[i], itemPath);
104
- }
105
- return;
106
- }
107
- const oldKeys = new Set(Object.keys(oldVal));
108
- const newKeys = new Set(Object.keys(newVal));
109
- for (const key of newKeys) {
110
- const propPath = path ? `${path}.${key}` : key;
111
- if (!oldKeys.has(key)) {
112
- paths.push(propPath);
113
- newValues[propPath] = newVal[key];
114
- } else {
115
- diff(oldVal[key], newVal[key], propPath);
116
- }
117
- }
118
- for (const key of oldKeys) {
119
- if (!newKeys.has(key)) {
120
- const propPath = path ? `${path}.${key}` : key;
121
- paths.push(propPath);
122
- newValues[propPath] = undefined;
123
- }
124
- }
125
- }
126
- diff(oldState, newState, basePath);
127
- return { paths, newValues };
130
+ return portable.diffState(oldState, newState, basePath);
128
131
  }
129
132
  function createObservableState(initialState, options) {
130
133
  const opts = options || { onChange: () => {} };
@@ -735,46 +738,22 @@ class HypenRouter {
735
738
  return getStateSnapshot(this.state);
736
739
  }
737
740
  matchPath(pattern, path) {
738
- if (!pattern || typeof pattern !== "string") {
741
+ if (!pattern || typeof pattern !== "string")
739
742
  return null;
740
- }
741
- if (!path || typeof path !== "string") {
743
+ if (!path || typeof path !== "string")
742
744
  return null;
743
- }
744
- if (pattern === path) {
745
- return {
746
- params: {},
747
- query: this.state.query,
748
- path
749
- };
750
- }
751
- if (pattern.endsWith("/*")) {
752
- const prefix = pattern.slice(0, -2);
753
- if (path === prefix || path.startsWith(prefix + "/")) {
754
- return {
755
- params: {},
756
- query: this.state.query,
757
- path
758
- };
759
- }
760
- return null;
761
- }
762
- const paramNames = [];
763
- const regexPattern = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
764
- paramNames.push(name);
765
- return "([^/]+)";
766
- }).replace(/\*/g, ".*");
767
- const regex = new RegExp(`^${regexPattern}$`);
768
- const match = path.match(regex);
769
- if (!match)
745
+ const cleanPath = path.split("?")[0] ?? path;
746
+ const result = portable.matchPath(pattern, cleanPath);
747
+ if (!result)
770
748
  return null;
771
749
  const params = {};
772
- paramNames.forEach((name, i) => {
773
- const value = match[i + 1];
774
- if (value !== undefined) {
775
- params[name] = decodeURIComponent(value);
750
+ for (const [name, raw] of Object.entries(result.params)) {
751
+ try {
752
+ params[name] = decodeURIComponent(raw);
753
+ } catch {
754
+ params[name] = raw;
776
755
  }
777
- });
756
+ }
778
757
  return {
779
758
  params,
780
759
  query: this.state.query,
@@ -829,4 +808,4 @@ export {
829
808
  HypenRouter
830
809
  };
831
810
 
832
- //# debugId=7C245540775DFDF064756E2164756E21
811
+ //# debugId=8162B923E6ED164564756E2164756E21