@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.
@@ -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. 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
 
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
- /** Cached instances for modules with persist=true */
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
- // Destroy all persisted modules on full stop
81
- for (const [moduleId, instance] of this.persistedModules) {
82
- log.debug(`Destroying persisted module on stop: ${moduleId}`);
83
- instance.destroy();
84
- this.globalContext.unregisterModule(moduleId);
85
- }
86
- this.persistedModules.clear();
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 && this.activeRoute) {
179
- const definition = this.activeRoute.module || this.registry.get(this.activeRoute.component);
180
- const moduleId = (definition?.name || this.activeRoute.component).toLowerCase();
181
- const persist = definition?.persist === true;
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
  }
@@ -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
- // Handle invalid inputs gracefully
239
- if (!pattern || typeof pattern !== 'string') {
240
- return null;
241
- }
242
- if (!path || typeof path !== 'string') {
243
- return null;
244
- }
245
-
246
- // Exact match
247
- if (pattern === path) {
248
- return {
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
- paramNames.forEach((name, i) => {
285
- const value = match[i + 1];
286
- if (value !== undefined) {
287
- params[name] = decodeURIComponent(value);
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,