@hypen-space/core 0.4.81 → 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.
- package/README.md +10 -4
- package/dist/app.d.ts +78 -1
- package/dist/app.js +102 -46
- package/dist/app.js.map +6 -5
- package/dist/components/builtin.js +102 -46
- package/dist/components/builtin.js.map +6 -5
- package/dist/engine-base.d.ts +19 -0
- package/dist/engine-base.js +32 -1
- package/dist/engine-base.js.map +3 -3
- package/dist/index.browser.js +117 -85
- package/dist/index.browser.js.map +7 -6
- package/dist/index.d.ts +4 -0
- package/dist/index.js +318 -85
- package/dist/index.js.map +9 -7
- package/dist/managed-router.d.ts +121 -5
- package/dist/portable.d.ts +62 -0
- package/dist/router.d.ts +7 -1
- package/dist/router.js +60 -81
- package/dist/router.js.map +6 -5
- package/dist/state.js +49 -46
- package/dist/state.js.map +5 -4
- package/dist/types.d.ts +12 -1
- package/package.json +7 -1
- package/src/app.ts +131 -1
- package/src/engine-base.ts +55 -0
- package/src/index.ts +13 -0
- package/src/managed-router.ts +285 -35
- package/src/portable.ts +113 -0
- package/src/router.ts +25 -50
- package/src/state.ts +11 -70
- package/src/types.ts +23 -1
package/src/managed-router.ts
CHANGED
|
@@ -2,10 +2,53 @@
|
|
|
2
2
|
* Managed Router — orchestrates module mount/unmount on route changes.
|
|
3
3
|
*
|
|
4
4
|
* When the router navigates to a route:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
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
|
|
|
11
54
|
import type { IEngine, HypenModuleDefinition } from "./app.js";
|
|
@@ -16,6 +59,13 @@ import { createLogger } from "./logger.js";
|
|
|
16
59
|
|
|
17
60
|
const log = createLogger("ManagedRouter");
|
|
18
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Default upper bound on `persistedModules`. Mirrors
|
|
64
|
+
* `DEFAULT_ROUTER_CACHE_SIZE` in the engine's Router IR node so the SDK
|
|
65
|
+
* module cache and the engine subtree cache evict in lockstep.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_MAX_PERSISTED_MODULES = 10;
|
|
68
|
+
|
|
19
69
|
export interface RouteDefinition {
|
|
20
70
|
/** Route path pattern (e.g., "/", "/profile/:id") */
|
|
21
71
|
path: string;
|
|
@@ -25,6 +75,17 @@ export interface RouteDefinition {
|
|
|
25
75
|
module?: HypenModuleDefinition;
|
|
26
76
|
}
|
|
27
77
|
|
|
78
|
+
export interface ManagedRouterOptions {
|
|
79
|
+
/**
|
|
80
|
+
* Maximum number of persisted module instances retained across
|
|
81
|
+
* navigations. When persistence would push the cache past this cap,
|
|
82
|
+
* the least-recently-persisted entry is destroyed. Defaults to
|
|
83
|
+
* `DEFAULT_MAX_PERSISTED_MODULES`. Pass `Infinity` to disable
|
|
84
|
+
* eviction.
|
|
85
|
+
*/
|
|
86
|
+
maxPersistedModules?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
28
89
|
export class ManagedRouter {
|
|
29
90
|
private router: HypenRouter;
|
|
30
91
|
private engine: IEngine;
|
|
@@ -34,19 +95,46 @@ export class ManagedRouter {
|
|
|
34
95
|
private activeModule: HypenModuleInstance | null = null;
|
|
35
96
|
private activeRoute: RouteDefinition | null = null;
|
|
36
97
|
private unsubscribe: (() => void) | null = null;
|
|
37
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Cached instances for module-backed routes, keyed by the lowercase
|
|
100
|
+
* module id. Entries are populated on unmount (when `persist` is truthy)
|
|
101
|
+
* and consulted on mount to restore state across navigations.
|
|
102
|
+
*
|
|
103
|
+
* Persistence is the default for any route whose `component` or `module`
|
|
104
|
+
* resolves to a module definition; opt out by setting `persist: false`
|
|
105
|
+
* on the definition.
|
|
106
|
+
*
|
|
107
|
+
* Bounded by `maxPersistedModules`. Map insertion order drives LRU
|
|
108
|
+
* eviction: restoring from the cache removes the entry (so the next
|
|
109
|
+
* persist re-inserts it at the MRU end), and persisting past the cap
|
|
110
|
+
* destroys the oldest entry.
|
|
111
|
+
*/
|
|
38
112
|
private persistedModules = new Map<string, HypenModuleInstance>();
|
|
113
|
+
private readonly maxPersistedModules: number;
|
|
114
|
+
/**
|
|
115
|
+
* Serialized chain of in-flight route transitions. Because mount /
|
|
116
|
+
* unmount are now async (they await `onActivated` / `onDeactivated`),
|
|
117
|
+
* back-to-back navigations must not interleave. Each `handleRouteChange`
|
|
118
|
+
* appends to this chain so transitions run strictly in order.
|
|
119
|
+
*/
|
|
120
|
+
private navPromise: Promise<void> = Promise.resolve();
|
|
39
121
|
|
|
40
122
|
constructor(
|
|
41
123
|
router: HypenRouter,
|
|
42
124
|
engine: IEngine,
|
|
43
125
|
registry: HypenApp,
|
|
44
|
-
globalContext: HypenGlobalContext
|
|
126
|
+
globalContext: HypenGlobalContext,
|
|
127
|
+
options: ManagedRouterOptions = {}
|
|
45
128
|
) {
|
|
46
129
|
this.router = router;
|
|
47
130
|
this.engine = engine;
|
|
48
131
|
this.registry = registry;
|
|
49
132
|
this.globalContext = globalContext;
|
|
133
|
+
const cap = options.maxPersistedModules ?? DEFAULT_MAX_PERSISTED_MODULES;
|
|
134
|
+
// Guard against obviously-invalid caps — a zero or negative limit would
|
|
135
|
+
// mean "never persist", which the call site should express via
|
|
136
|
+
// `persist: false` on definitions instead.
|
|
137
|
+
this.maxPersistedModules = cap > 0 ? cap : DEFAULT_MAX_PERSISTED_MODULES;
|
|
50
138
|
}
|
|
51
139
|
|
|
52
140
|
/**
|
|
@@ -59,36 +147,109 @@ export class ManagedRouter {
|
|
|
59
147
|
|
|
60
148
|
/**
|
|
61
149
|
* Start listening for route changes and mount the initial route.
|
|
150
|
+
*
|
|
151
|
+
* Also installs the `@router.*` engine action handlers so DSL authors can
|
|
152
|
+
* write `.onClick(@router.push, to: "/x")` / `@router.back` /
|
|
153
|
+
* `@router.replace` / `@router.forward` and have them dispatched against
|
|
154
|
+
* this session's `HypenRouter` without any per-example wiring. The
|
|
155
|
+
* reserved namespace is set up by `hypen-engine` in `ir/expand.rs`.
|
|
62
156
|
*/
|
|
63
157
|
start(): void {
|
|
64
158
|
this.unsubscribe = this.router.onNavigate((routeState: RouteState) => {
|
|
65
159
|
this.handleRouteChange(routeState);
|
|
66
160
|
});
|
|
161
|
+
this.installRouterActions();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Register handlers for the reserved `router.*` action namespace.
|
|
166
|
+
*
|
|
167
|
+
* All navigations go through `queueMicrotask` so the router state mutation
|
|
168
|
+
* lands after the engine finishes dispatching the action — synchronous
|
|
169
|
+
* writes would re-enter the engine's WASM state proxy, which the Rust side
|
|
170
|
+
* rejects ("recursive use of an object").
|
|
171
|
+
*/
|
|
172
|
+
private installRouterActions(): void {
|
|
173
|
+
const router = this.router;
|
|
174
|
+
const defer = (fn: () => void) => queueMicrotask(fn);
|
|
175
|
+
const readTo = (action: { payload?: unknown } | null | undefined): string | null => {
|
|
176
|
+
const payload = action?.payload as Record<string, unknown> | null | undefined;
|
|
177
|
+
const to = payload?.to;
|
|
178
|
+
return typeof to === "string" && to.length > 0 ? to : null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
this.engine.onAction("router.push", (action) => {
|
|
182
|
+
const to = readTo(action);
|
|
183
|
+
if (to) defer(() => router.push(to));
|
|
184
|
+
});
|
|
185
|
+
this.engine.onAction("router.replace", (action) => {
|
|
186
|
+
const to = readTo(action);
|
|
187
|
+
if (to) defer(() => router.replace(to));
|
|
188
|
+
});
|
|
189
|
+
this.engine.onAction("router.back", () => {
|
|
190
|
+
defer(() => router.back());
|
|
191
|
+
});
|
|
192
|
+
this.engine.onAction("router.forward", () => {
|
|
193
|
+
defer(() => router.forward());
|
|
194
|
+
});
|
|
67
195
|
}
|
|
68
196
|
|
|
69
197
|
/**
|
|
70
198
|
* Stop listening and unmount the active module.
|
|
71
199
|
* Also destroys all persisted modules.
|
|
200
|
+
*
|
|
201
|
+
* Waits for any in-flight navigation to settle before tearing down so
|
|
202
|
+
* lifecycle hooks always complete in order.
|
|
72
203
|
*/
|
|
73
|
-
stop(): void {
|
|
204
|
+
async stop(): Promise<void> {
|
|
74
205
|
if (this.unsubscribe) {
|
|
75
206
|
this.unsubscribe();
|
|
76
207
|
this.unsubscribe = null;
|
|
77
208
|
}
|
|
78
|
-
this.unmountActive();
|
|
79
209
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
210
|
+
// Wait for any in-flight navigation before tearing down.
|
|
211
|
+
const cleanup = this.navPromise
|
|
212
|
+
.catch(() => {
|
|
213
|
+
/* previous navigation errors already reported */
|
|
214
|
+
})
|
|
215
|
+
.then(async () => {
|
|
216
|
+
await this.unmountActive();
|
|
217
|
+
|
|
218
|
+
// Destroy all persisted modules on full stop. Instances in this
|
|
219
|
+
// map are inactive (they were deactivated when persisted), so
|
|
220
|
+
// `destroy()` just fires `onDestroyed`.
|
|
221
|
+
for (const [moduleId, instance] of this.persistedModules) {
|
|
222
|
+
log.debug(`Destroying persisted module on stop: ${moduleId}`);
|
|
223
|
+
try {
|
|
224
|
+
await instance.destroy();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
log.error(`Error destroying persisted module ${moduleId}:`, e);
|
|
227
|
+
}
|
|
228
|
+
this.globalContext.unregisterModule(moduleId);
|
|
229
|
+
}
|
|
230
|
+
this.persistedModules.clear();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
this.navPromise = cleanup;
|
|
234
|
+
await cleanup;
|
|
87
235
|
|
|
88
236
|
// Clean up router event listeners
|
|
89
237
|
this.router.dispose();
|
|
90
238
|
}
|
|
91
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Returns a promise that resolves once all currently-queued route
|
|
242
|
+
* transitions have finished. Useful in tests to await lifecycle
|
|
243
|
+
* ordering without reaching into internals.
|
|
244
|
+
*/
|
|
245
|
+
async waitForNavigation(): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
await this.navPromise;
|
|
248
|
+
} catch {
|
|
249
|
+
/* navigation errors are logged; callers don't need to rethrow */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
92
253
|
/**
|
|
93
254
|
* Get the currently active module instance.
|
|
94
255
|
*/
|
|
@@ -104,23 +265,35 @@ export class ManagedRouter {
|
|
|
104
265
|
}
|
|
105
266
|
|
|
106
267
|
private handleRouteChange(routeState: RouteState): void {
|
|
268
|
+
// Chain onto the in-flight navigation promise so transitions run
|
|
269
|
+
// strictly in order. Async activate/deactivate hooks otherwise make
|
|
270
|
+
// it possible for a late `A → B` to finish before an earlier `B → A`.
|
|
271
|
+
this.navPromise = this.navPromise
|
|
272
|
+
.catch((e) => {
|
|
273
|
+
log.error("Previous navigation failed:", e);
|
|
274
|
+
})
|
|
275
|
+
.then(() => this.processRouteChange(routeState));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async processRouteChange(routeState: RouteState): Promise<void> {
|
|
107
279
|
const path = routeState.currentPath;
|
|
108
280
|
const matched = this.matchRoute(path);
|
|
109
281
|
|
|
110
282
|
if (!matched) {
|
|
111
283
|
log.debug(`No route matched for path: ${path}`);
|
|
112
|
-
this.unmountActive();
|
|
284
|
+
await this.unmountActive();
|
|
113
285
|
return;
|
|
114
286
|
}
|
|
115
287
|
|
|
116
|
-
// If same route, no need to remount
|
|
288
|
+
// If same route, no need to remount. Still a no-op for same-path
|
|
289
|
+
// navigations (e.g. reselecting the active tab).
|
|
117
290
|
if (this.activeRoute && this.activeRoute.path === matched.path) {
|
|
118
291
|
return;
|
|
119
292
|
}
|
|
120
293
|
|
|
121
|
-
// Unmount old, mount new
|
|
122
|
-
this.unmountActive();
|
|
123
|
-
this.mount(matched);
|
|
294
|
+
// Unmount old, mount new.
|
|
295
|
+
await this.unmountActive();
|
|
296
|
+
await this.mount(matched);
|
|
124
297
|
}
|
|
125
298
|
|
|
126
299
|
private matchRoute(path: string): RouteDefinition | null {
|
|
@@ -132,7 +305,7 @@ export class ManagedRouter {
|
|
|
132
305
|
return null;
|
|
133
306
|
}
|
|
134
307
|
|
|
135
|
-
private mount(route: RouteDefinition): void {
|
|
308
|
+
private async mount(route: RouteDefinition): Promise<void> {
|
|
136
309
|
// Look up module definition: inline first, then registry
|
|
137
310
|
const definition = route.module || this.registry.get(route.component);
|
|
138
311
|
if (!definition) {
|
|
@@ -149,12 +322,19 @@ export class ManagedRouter {
|
|
|
149
322
|
|
|
150
323
|
const moduleId = namedDef.name!.toLowerCase();
|
|
151
324
|
|
|
152
|
-
// Check for a persisted instance first
|
|
325
|
+
// Check for a persisted instance first. If we hit the cache, we reuse
|
|
326
|
+
// its state verbatim — `onCreated` has already fired (once) and
|
|
327
|
+
// activate() below will fire `onActivated` without re-running the
|
|
328
|
+
// one-time setup.
|
|
153
329
|
const persisted = this.persistedModules.get(moduleId);
|
|
154
330
|
if (persisted) {
|
|
155
331
|
log.debug(`Restoring persisted module: ${moduleId} for route: ${route.path}`);
|
|
156
332
|
this.activeModule = persisted;
|
|
157
333
|
this.activeRoute = route;
|
|
334
|
+
// Remove from the cache while it's active so that a concurrent
|
|
335
|
+
// navigation can't double-mount the same instance.
|
|
336
|
+
this.persistedModules.delete(moduleId);
|
|
337
|
+
await persisted.activate();
|
|
158
338
|
return;
|
|
159
339
|
}
|
|
160
340
|
|
|
@@ -172,26 +352,96 @@ export class ManagedRouter {
|
|
|
172
352
|
|
|
173
353
|
this.activeModule = instance;
|
|
174
354
|
this.activeRoute = route;
|
|
355
|
+
|
|
356
|
+
// Fire `onActivated` after `onCreated` resolves. `activate()` awaits
|
|
357
|
+
// the instance's internal readiness promise, so this is safe even
|
|
358
|
+
// though the constructor kicks off `onCreated` asynchronously.
|
|
359
|
+
await instance.activate();
|
|
175
360
|
}
|
|
176
361
|
|
|
177
|
-
private unmountActive(): void {
|
|
178
|
-
if (this.activeModule
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (persist) {
|
|
184
|
-
log.debug(`Persisting module: ${moduleId} (persist=true)`);
|
|
185
|
-
this.persistedModules.set(moduleId, this.activeModule);
|
|
186
|
-
// Keep registered in GlobalContext so other modules can still access its state
|
|
187
|
-
} else {
|
|
188
|
-
log.debug(`Unmounting module: ${moduleId}`);
|
|
189
|
-
this.activeModule.destroy();
|
|
190
|
-
this.globalContext.unregisterModule(moduleId);
|
|
191
|
-
}
|
|
362
|
+
private async unmountActive(): Promise<void> {
|
|
363
|
+
if (!this.activeModule || !this.activeRoute) {
|
|
364
|
+
this.activeModule = null;
|
|
365
|
+
this.activeRoute = null;
|
|
366
|
+
return;
|
|
192
367
|
}
|
|
193
368
|
|
|
369
|
+
const active = this.activeModule;
|
|
370
|
+
const route = this.activeRoute;
|
|
371
|
+
const definition = route.module || this.registry.get(route.component);
|
|
372
|
+
const moduleId = (definition?.name || route.component).toLowerCase();
|
|
373
|
+
|
|
374
|
+
// Persistence default: any route backed by a module definition
|
|
375
|
+
// persists unless the definition explicitly opts out.
|
|
376
|
+
//
|
|
377
|
+
// definition present & persist !== false → cache the instance
|
|
378
|
+
// definition present & persist === false → destroy the instance
|
|
379
|
+
// definition missing → nothing to persist
|
|
380
|
+
//
|
|
381
|
+
// This is the new default behavior — prior to this change, persist
|
|
382
|
+
// was opt-in via `persist: true`. Modules that relied on a fresh
|
|
383
|
+
// instance on every navigation should set `persist: false` or move
|
|
384
|
+
// per-navigation work into the new `onActivated` hook.
|
|
385
|
+
const persist = definition != null && definition.persist !== false;
|
|
386
|
+
|
|
387
|
+
// Clear the active slot *before* firing lifecycle hooks so the
|
|
388
|
+
// module cannot observe itself as "active" from inside onDeactivated.
|
|
194
389
|
this.activeModule = null;
|
|
195
390
|
this.activeRoute = null;
|
|
391
|
+
|
|
392
|
+
// Always deactivate first — regardless of whether we persist or
|
|
393
|
+
// destroy — so `onDeactivated → (onDestroyed)` ordering holds.
|
|
394
|
+
try {
|
|
395
|
+
await active.deactivate();
|
|
396
|
+
} catch (e) {
|
|
397
|
+
log.error(`Error deactivating module ${moduleId}:`, e);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (persist) {
|
|
401
|
+
log.debug(`Persisting module: ${moduleId}`);
|
|
402
|
+
// If this id is already cached (e.g. after being destroyed and
|
|
403
|
+
// re-activated without passing through mount(), though rare),
|
|
404
|
+
// delete first so the re-insert places it at the MRU end.
|
|
405
|
+
this.persistedModules.delete(moduleId);
|
|
406
|
+
this.persistedModules.set(moduleId, active);
|
|
407
|
+
await this.evictPersistedOverflow();
|
|
408
|
+
// Keep registered in GlobalContext so other modules can still
|
|
409
|
+
// access its state while it's off-screen.
|
|
410
|
+
} else {
|
|
411
|
+
log.debug(`Unmounting module: ${moduleId}`);
|
|
412
|
+
try {
|
|
413
|
+
await active.destroy();
|
|
414
|
+
} catch (e) {
|
|
415
|
+
log.error(`Error destroying module ${moduleId}:`, e);
|
|
416
|
+
}
|
|
417
|
+
this.globalContext.unregisterModule(moduleId);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Trim `persistedModules` down to `maxPersistedModules` by destroying
|
|
423
|
+
* the oldest entries (FIFO in Map insertion order, which tracks LRU
|
|
424
|
+
* because `mount()` removes on restore and `unmountActive()` re-inserts
|
|
425
|
+
* at the MRU end on every persist).
|
|
426
|
+
*
|
|
427
|
+
* Errors during eviction are logged — one misbehaving `onDestroyed` hook
|
|
428
|
+
* must not block the router from shrinking the cache on the next
|
|
429
|
+
* navigation.
|
|
430
|
+
*/
|
|
431
|
+
private async evictPersistedOverflow(): Promise<void> {
|
|
432
|
+
while (this.persistedModules.size > this.maxPersistedModules) {
|
|
433
|
+
const oldest = this.persistedModules.keys().next();
|
|
434
|
+
if (oldest.done) break;
|
|
435
|
+
const oldestId = oldest.value;
|
|
436
|
+
const evicted = this.persistedModules.get(oldestId)!;
|
|
437
|
+
this.persistedModules.delete(oldestId);
|
|
438
|
+
log.debug(`Evicting persisted module (LRU): ${oldestId}`);
|
|
439
|
+
try {
|
|
440
|
+
await evicted.destroy();
|
|
441
|
+
} catch (e) {
|
|
442
|
+
log.error(`Error destroying evicted module ${oldestId}:`, e);
|
|
443
|
+
}
|
|
444
|
+
this.globalContext.unregisterModule(oldestId);
|
|
445
|
+
}
|
|
196
446
|
}
|
|
197
447
|
}
|
package/src/portable.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
|
|
27
|
+
import type { StateChange, StatePath } from "./state";
|
|
28
|
+
|
|
29
|
+
export interface PortableImpl {
|
|
30
|
+
diffState(oldState: any, newState: any, basePath?: string): StateChange;
|
|
31
|
+
matchPath(
|
|
32
|
+
pattern: string,
|
|
33
|
+
path: string,
|
|
34
|
+
): { params: Record<string, string> } | null;
|
|
35
|
+
pathGet(value: any, path: string): unknown;
|
|
36
|
+
pathHas(value: any, path: string): boolean;
|
|
37
|
+
pathSet(value: any, path: string, newValue: any): any;
|
|
38
|
+
pathDelete(value: any, path: string): { value: any; removed: boolean };
|
|
39
|
+
encodeUriComponent(input: string): string;
|
|
40
|
+
decodeUriComponent(input: string): string;
|
|
41
|
+
parseQuery(fullPath: string): { path: string; query: Record<string, string> };
|
|
42
|
+
buildUrl(path: string, query: Record<string, string>): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function notInstalled(name: string): never {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[@hypen-space/core] Portable helper "${name}" called before the engine was installed. ` +
|
|
48
|
+
`Import @hypen-space/server, @hypen-space/web-engine, or call setPortableImpl() before use.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let current: PortableImpl = {
|
|
53
|
+
diffState: () => notInstalled("diffState"),
|
|
54
|
+
matchPath: () => notInstalled("matchPath"),
|
|
55
|
+
pathGet: () => notInstalled("pathGet"),
|
|
56
|
+
pathHas: () => notInstalled("pathHas"),
|
|
57
|
+
pathSet: () => notInstalled("pathSet"),
|
|
58
|
+
pathDelete: () => notInstalled("pathDelete"),
|
|
59
|
+
encodeUriComponent: () => notInstalled("encodeUriComponent"),
|
|
60
|
+
decodeUriComponent: () => notInstalled("decodeUriComponent"),
|
|
61
|
+
parseQuery: () => notInstalled("parseQuery"),
|
|
62
|
+
buildUrl: () => notInstalled("buildUrl"),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Install the engine-backed [`PortableImpl`]. Called once by
|
|
67
|
+
* `@hypen-space/server` at import time (synchronous wasm-node load) or
|
|
68
|
+
* by `@hypen-space/web-engine` inside `Engine.init()` once the browser
|
|
69
|
+
* wasm module resolves.
|
|
70
|
+
*
|
|
71
|
+
* Passing `null` resets to the "not installed" throwing default — used
|
|
72
|
+
* by test teardown when you want to verify the no-engine error path.
|
|
73
|
+
*/
|
|
74
|
+
export function setPortableImpl(impl: PortableImpl | null): void {
|
|
75
|
+
if (impl === null) {
|
|
76
|
+
current = {
|
|
77
|
+
diffState: () => notInstalled("diffState"),
|
|
78
|
+
matchPath: () => notInstalled("matchPath"),
|
|
79
|
+
pathGet: () => notInstalled("pathGet"),
|
|
80
|
+
pathHas: () => notInstalled("pathHas"),
|
|
81
|
+
pathSet: () => notInstalled("pathSet"),
|
|
82
|
+
pathDelete: () => notInstalled("pathDelete"),
|
|
83
|
+
encodeUriComponent: () => notInstalled("encodeUriComponent"),
|
|
84
|
+
decodeUriComponent: () => notInstalled("decodeUriComponent"),
|
|
85
|
+
parseQuery: () => notInstalled("parseQuery"),
|
|
86
|
+
buildUrl: () => notInstalled("buildUrl"),
|
|
87
|
+
};
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
current = impl;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Live portable API. Every call dispatches to whatever
|
|
95
|
+
* [`setPortableImpl`] most recently installed.
|
|
96
|
+
*/
|
|
97
|
+
export const portable: PortableImpl = {
|
|
98
|
+
diffState: (o, n, b) => current.diffState(o, n, b),
|
|
99
|
+
matchPath: (p, path) => current.matchPath(p, path),
|
|
100
|
+
pathGet: (v, p) => current.pathGet(v, p),
|
|
101
|
+
pathHas: (v, p) => current.pathHas(v, p),
|
|
102
|
+
pathSet: (v, p, nv) => current.pathSet(v, p, nv),
|
|
103
|
+
pathDelete: (v, p) => current.pathDelete(v, p),
|
|
104
|
+
encodeUriComponent: (s) => current.encodeUriComponent(s),
|
|
105
|
+
decodeUriComponent: (s) => current.decodeUriComponent(s),
|
|
106
|
+
parseQuery: (f) => current.parseQuery(f),
|
|
107
|
+
buildUrl: (p, q) => current.buildUrl(p, q),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// `StatePath` is still part of this module's public surface via
|
|
111
|
+
// re-export; keeping it reachable from `@hypen-space/core/portable`
|
|
112
|
+
// simplifies bindings in server / web-engine adapters.
|
|
113
|
+
export type { StatePath } from "./state";
|
package/src/router.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { createObservableState, getStateSnapshot } from "./state.js";
|
|
7
7
|
import { disposableListener, type Disposable } from "./disposable.js";
|
|
8
8
|
import { frameworkLoggers } from "./logger.js";
|
|
9
|
+
import { portable } from "./portable.js";
|
|
9
10
|
|
|
10
11
|
const log = frameworkLoggers.router;
|
|
11
12
|
|
|
@@ -232,61 +233,35 @@ export class HypenRouter {
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
/**
|
|
235
|
-
* Match a pattern against a path
|
|
236
|
+
* Match a pattern against a path.
|
|
237
|
+
*
|
|
238
|
+
* Thin wrapper over [`portable.matchPath`] — the three-case matcher
|
|
239
|
+
* (exact / `/prefix/*` wildcard / `:param`) lives in the Rust engine
|
|
240
|
+
* at `hypen-engine-rs/src/portable/route.rs` and is reached through
|
|
241
|
+
* the WASM installed by server / web-engine, with a TS fallback for
|
|
242
|
+
* standalone core use.
|
|
236
243
|
*/
|
|
237
244
|
matchPath(pattern: string, path: string): RouteMatch | null {
|
|
238
|
-
|
|
239
|
-
if (!
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
params: {},
|
|
250
|
-
query: this.state.query,
|
|
251
|
-
path,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Wildcard match: /dashboard/* matches /dashboard/anything
|
|
256
|
-
if (pattern.endsWith("/*")) {
|
|
257
|
-
const prefix = pattern.slice(0, -2);
|
|
258
|
-
if (path === prefix || path.startsWith(prefix + "/")) {
|
|
259
|
-
return {
|
|
260
|
-
params: {},
|
|
261
|
-
query: this.state.query,
|
|
262
|
-
path,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
245
|
+
if (!pattern || typeof pattern !== "string") return null;
|
|
246
|
+
if (!path || typeof path !== "string") return null;
|
|
247
|
+
|
|
248
|
+
// Strip any query portion before matching; the engine matcher
|
|
249
|
+
// operates on the clean path and we track `query` ourselves.
|
|
250
|
+
// `noUncheckedIndexedAccess` makes `[0]` `string | undefined`; fall
|
|
251
|
+
// back to the full path when split yields an empty array (which
|
|
252
|
+
// only happens for empty input — already ruled out above).
|
|
253
|
+
const cleanPath = path.split("?")[0] ?? path;
|
|
254
|
+
const result = portable.matchPath(pattern, cleanPath);
|
|
255
|
+
if (!result) return null;
|
|
267
256
|
|
|
268
|
-
// Parameter match: /users/:id matches /users/123
|
|
269
|
-
const paramNames: string[] = [];
|
|
270
|
-
const regexPattern = pattern
|
|
271
|
-
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
272
|
-
paramNames.push(name);
|
|
273
|
-
return "([^/]+)";
|
|
274
|
-
})
|
|
275
|
-
.replace(/\*/g, ".*");
|
|
276
|
-
|
|
277
|
-
const regex = new RegExp(`^${regexPattern}$`);
|
|
278
|
-
const match = path.match(regex);
|
|
279
|
-
|
|
280
|
-
if (!match) return null;
|
|
281
|
-
|
|
282
|
-
// Extract params
|
|
283
257
|
const params: Record<string, string> = {};
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
258
|
+
for (const [name, raw] of Object.entries(result.params)) {
|
|
259
|
+
try {
|
|
260
|
+
params[name] = decodeURIComponent(raw);
|
|
261
|
+
} catch {
|
|
262
|
+
params[name] = raw;
|
|
288
263
|
}
|
|
289
|
-
}
|
|
264
|
+
}
|
|
290
265
|
|
|
291
266
|
return {
|
|
292
267
|
params,
|