@hypen-space/core 0.4.980 → 0.5.2

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.
Files changed (96) hide show
  1. package/README.md +5 -8
  2. package/dist/app.js +13 -1168
  3. package/dist/app.js.map +3 -9
  4. package/dist/chunk-00zt3w6e.js +58 -0
  5. package/dist/chunk-00zt3w6e.js.map +10 -0
  6. package/dist/chunk-3zvyth50.js +87 -0
  7. package/dist/chunk-3zvyth50.js.map +10 -0
  8. package/dist/chunk-5gtx3pza.js +11 -0
  9. package/dist/chunk-5gtx3pza.js.map +9 -0
  10. package/dist/chunk-7222131a.js +191 -0
  11. package/dist/chunk-7222131a.js.map +10 -0
  12. package/dist/chunk-avchpwhf.js +200 -0
  13. package/dist/chunk-avchpwhf.js.map +10 -0
  14. package/dist/chunk-ec0tgy9a.js +154 -0
  15. package/dist/chunk-ec0tgy9a.js.map +10 -0
  16. package/dist/chunk-fnss4dm2.js +479 -0
  17. package/dist/chunk-fnss4dm2.js.map +10 -0
  18. package/dist/chunk-g2gz9pgg.js +133 -0
  19. package/dist/chunk-g2gz9pgg.js.map +10 -0
  20. package/dist/chunk-g72v6mmf.js +341 -0
  21. package/dist/chunk-g72v6mmf.js.map +10 -0
  22. package/dist/chunk-g7g3jxty.js +351 -0
  23. package/dist/chunk-g7g3jxty.js.map +10 -0
  24. package/dist/chunk-g830re2c.js +50 -0
  25. package/dist/chunk-g830re2c.js.map +10 -0
  26. package/dist/chunk-gtwh1sg7.js +127 -0
  27. package/dist/chunk-gtwh1sg7.js.map +10 -0
  28. package/dist/chunk-k12cwfst.js +72 -0
  29. package/dist/chunk-k12cwfst.js.map +10 -0
  30. package/dist/chunk-mbqqsz6r.js +128 -0
  31. package/dist/chunk-mbqqsz6r.js.map +10 -0
  32. package/dist/chunk-ne632swm.js +209 -0
  33. package/dist/chunk-ne632swm.js.map +10 -0
  34. package/dist/chunk-p3pj1bax.js +206 -0
  35. package/dist/chunk-p3pj1bax.js.map +10 -0
  36. package/dist/chunk-vshdk2pw.js +56 -0
  37. package/dist/chunk-vshdk2pw.js.map +10 -0
  38. package/dist/chunk-wp2ebn2m.js +51 -0
  39. package/dist/chunk-wp2ebn2m.js.map +10 -0
  40. package/dist/chunk-x4qv4jm9.js +88 -0
  41. package/dist/chunk-x4qv4jm9.js.map +10 -0
  42. package/dist/chunk-ycxp4drd.js +211 -0
  43. package/dist/chunk-ycxp4drd.js.map +10 -0
  44. package/dist/components/builtin.js +14 -1246
  45. package/dist/components/builtin.js.map +3 -10
  46. package/dist/context.js +7 -362
  47. package/dist/context.js.map +3 -6
  48. package/dist/datasource.js +5 -76
  49. package/dist/datasource.js.map +3 -4
  50. package/dist/disposable.js +20 -367
  51. package/dist/disposable.js.map +3 -5
  52. package/dist/engine-base.d.ts +38 -2
  53. package/dist/engine-base.js +45 -412
  54. package/dist/engine-base.js.map +4 -6
  55. package/dist/events.js +7 -285
  56. package/dist/events.js.map +3 -5
  57. package/dist/hypen.js +8 -71
  58. package/dist/hypen.js.map +3 -4
  59. package/dist/index.browser.js +58 -2152
  60. package/dist/index.browser.js.map +3 -16
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.js +132 -3058
  63. package/dist/index.js.map +4 -22
  64. package/dist/logger.js +16 -220
  65. package/dist/logger.js.map +3 -4
  66. package/dist/managed-router.js +12 -1367
  67. package/dist/managed-router.js.map +3 -10
  68. package/dist/portable.js +6 -70
  69. package/dist/portable.js.map +3 -4
  70. package/dist/remote/client.js +9 -1010
  71. package/dist/remote/client.js.map +3 -8
  72. package/dist/remote/index.d.ts +2 -0
  73. package/dist/remote/index.js +511 -1066
  74. package/dist/remote/index.js.map +4 -9
  75. package/dist/remote/remote-session.d.ts +245 -0
  76. package/dist/remote/session.js +5 -147
  77. package/dist/remote/session.js.map +3 -4
  78. package/dist/renderer.js +7 -271
  79. package/dist/renderer.js.map +3 -5
  80. package/dist/resolver.js +5 -484
  81. package/dist/resolver.js.map +3 -5
  82. package/dist/result.js +25 -211
  83. package/dist/result.js.map +3 -4
  84. package/dist/retry.js +10 -336
  85. package/dist/retry.js.map +3 -5
  86. package/dist/router.js +9 -807
  87. package/dist/router.js.map +3 -8
  88. package/dist/state.js +10 -274
  89. package/dist/state.js.map +3 -5
  90. package/dist/validate.d.ts +19 -0
  91. package/package.json +13 -1
  92. package/src/engine-base.ts +87 -4
  93. package/src/index.ts +5 -0
  94. package/src/remote/index.ts +16 -0
  95. package/src/remote/remote-session.ts +998 -0
  96. package/src/validate.ts +37 -0
@@ -0,0 +1,998 @@
1
+ /**
2
+ * RemoteSession - Transport-agnostic per-client session for Hypen remote apps.
3
+ *
4
+ * One `RemoteSession` owns one logical client: a dedicated engine instance,
5
+ * a module instance, a session id, and the logic that drives the Hypen remote
6
+ * protocol (hello → sessionAck → initialTree → streaming patches).
7
+ *
8
+ * It does NOT know how bytes reach the client. That is the job of the
9
+ * `SessionTransport` interface — a minimal `{ send, close }` contract that
10
+ * can be backed by a Bun `ServerWebSocket`, an SSE `ReadableStream`, an
11
+ * in-memory queue (see `AsyncQueueTransport`), or anything else.
12
+ *
13
+ * Backend devs can plug Hypen into an existing Express/Fastify/ws/uWebSockets
14
+ * setup by:
15
+ *
16
+ * 1. Calling `server.prepare()` once (discovery + session manager init).
17
+ * 2. Calling `server.createSession(transport)` per client.
18
+ * 3. Forwarding incoming JSON messages via `session.receive(msg)`.
19
+ * 4. Calling `session.destroy()` when the transport closes.
20
+ *
21
+ * Or, for a pure async-iterator style:
22
+ *
23
+ * const transport = new AsyncQueueTransport();
24
+ * const session = server.createSession(transport);
25
+ * for await (const out of transport.stream()) { ...forward to client... }
26
+ */
27
+
28
+ import type {
29
+ HypenApp,
30
+ HypenModule,
31
+ HypenModuleDefinition,
32
+ } from "../app.js";
33
+ import { HypenModuleInstance, HypenApp as HypenAppClass } from "../app.js";
34
+ import { HypenGlobalContext } from "../context.js";
35
+ import { HypenRouter } from "../router.js";
36
+ import { ManagedRouter } from "../managed-router.js";
37
+ import type {
38
+ DispatchActionMessage,
39
+ HelloMessage,
40
+ InitialTreeMessage,
41
+ PatchMessage,
42
+ RemoteClient,
43
+ RemoteMessage,
44
+ Session,
45
+ SessionAckMessage,
46
+ SessionExpiredMessage,
47
+ StateUpdateMessage,
48
+ UpdateStateMessage,
49
+ } from "./types.js";
50
+ import { SessionManager } from "./session.js";
51
+ import type { Patch } from "../types.js";
52
+ import { frameworkLoggers } from "../logger.js";
53
+ import { BaseEngine } from "../engine-base.js";
54
+
55
+ const log = frameworkLoggers.remote;
56
+
57
+ /**
58
+ * Server → client messages emitted by a `RemoteSession`.
59
+ */
60
+ export type OutgoingMessage =
61
+ | InitialTreeMessage
62
+ | PatchMessage
63
+ | StateUpdateMessage
64
+ | SessionAckMessage
65
+ | SessionExpiredMessage;
66
+
67
+ /**
68
+ * Minimal contract a `RemoteSession` needs to reach the client. Implement
69
+ * this to plug Hypen into any transport.
70
+ *
71
+ * `send` receives a structured message — serialize however you like
72
+ * (JSON, MessagePack, protobuf). Built-in adapters use `JSON.stringify`.
73
+ */
74
+ export interface SessionTransport {
75
+ send(message: OutgoingMessage): void;
76
+ close(code?: number, reason?: string): void;
77
+ }
78
+
79
+ /**
80
+ * The subset of `RemoteServer` state a session needs. Kept as an interface so
81
+ * sessions can be used standalone in tests and so alternate hosts (e.g. a
82
+ * Cloudflare Durable Object wrapper) can satisfy it without subclassing
83
+ * `RemoteServer`.
84
+ */
85
+ export interface SessionHost {
86
+ readonly module: HypenModule<any>;
87
+ readonly moduleName: string;
88
+ readonly ui: string;
89
+ readonly resources: Record<string, string>;
90
+ readonly app: HypenApp | null;
91
+ readonly syncActions: boolean;
92
+ readonly sessionManager: SessionManager;
93
+ readonly discoveredComponents: Map<
94
+ string,
95
+ { template: string; module?: HypenModuleDefinition<any> }
96
+ >;
97
+
98
+ /**
99
+ * Construct a fresh engine for this session. Called once per session.
100
+ *
101
+ * Each session owns its own engine instance, so this must return a new
102
+ * engine every call (not a shared singleton). This is the seam that keeps
103
+ * `RemoteSession` engine-agnostic: `@hypen-space/server` returns
104
+ * `new Engine()` (Node/Bun WASM), a Cloudflare Durable Object returns a
105
+ * `CFEngine`, etc. The returned engine may be un-initialised — the session
106
+ * calls `init()` itself.
107
+ */
108
+ createEngine(): BaseEngine;
109
+
110
+ /** Other live sessions (for syncActions fan-out and allow-multiple peer broadcast). */
111
+ otherSessions(self: RemoteSession): Iterable<RemoteSession>;
112
+
113
+ /** Sessions that currently share `sessionId` (for kick-old / reject-new). */
114
+ sessionsForId(sessionId: string): Iterable<RemoteSession>;
115
+
116
+ /** Fired after hello → initialTree completes. */
117
+ onSessionReady(session: RemoteSession, client: RemoteClient): void;
118
+
119
+ /** Fired when the session is fully torn down. */
120
+ onSessionDestroyed(session: RemoteSession, client: RemoteClient): void;
121
+ }
122
+
123
+ /**
124
+ * Options passed when constructing a session.
125
+ */
126
+ export interface RemoteSessionOptions {
127
+ /** Internal client id (for logs and `RemoteClient.id`). Auto-generated if omitted. */
128
+ clientId?: string;
129
+ /**
130
+ * Grace period in ms before a connected client that has not sent `hello`
131
+ * is auto-initialised as a legacy (no-session-id) client. Set `null` to
132
+ * disable the grace period entirely — useful for transports like SSE where
133
+ * the first message may be deliberately delayed.
134
+ * @default 1000
135
+ */
136
+ helloGraceMs?: number | null;
137
+ /**
138
+ * `socket` value stored on the `RemoteClient` record surfaced to
139
+ * `onConnection`/`onDisconnection` callbacks. Pass through the raw
140
+ * transport handle (e.g. a `ServerWebSocket`) so user code has the
141
+ * escape hatch when it needs it.
142
+ */
143
+ socketHandle?: unknown;
144
+ }
145
+
146
+ let nextSessionCounter = 1;
147
+
148
+ /**
149
+ * One client's worth of server-side state. Transport-agnostic.
150
+ */
151
+ export class RemoteSession {
152
+ readonly id: string;
153
+ readonly connectedAt: Date;
154
+
155
+ private readonly host: SessionHost;
156
+ private readonly transport: SessionTransport;
157
+ private readonly socketHandle: unknown;
158
+
159
+ readonly engine: BaseEngine;
160
+ private _moduleInstance: HypenModuleInstance<any> | null = null;
161
+
162
+ private _sessionId: string | null = null;
163
+ private _helloReceived = false;
164
+ private _stateSubscribed = false;
165
+ private _revision = 0;
166
+ private _destroyed = false;
167
+ private helloTimeout: ReturnType<typeof setTimeout> | null = null;
168
+ /**
169
+ * Per-session ManagedRouter auto-wired from `Router {}` blocks found
170
+ * in the primary template. `null` when auto-wiring is disabled, no
171
+ * Routers are present, or no registered modules matched any route's
172
+ * element list. Torn down alongside the session in `destroy()`.
173
+ */
174
+ private _autoManagedRouter: ManagedRouter | null = null;
175
+ /**
176
+ * Toggled by `RemoteServer.disableAutoRouter()` when the host wants
177
+ * to wire a ManagedRouter by hand via `onSessionCreate` instead.
178
+ */
179
+ autoRouterEnabled: boolean = true;
180
+
181
+ // Promises that let callers hook into session lifecycle without racing
182
+ // against the hello handshake or the transport close event.
183
+ // `ready` — resolves after the hello handshake, primary module
184
+ // construction, and initial render have completed.
185
+ // `moduleInstance` is guaranteed non-null here.
186
+ // `closed` — resolves after `destroy()` has finished its teardown
187
+ // (onDisconnect hook, module teardown, session suspend).
188
+ readonly ready: Promise<void>;
189
+ readonly closed: Promise<void>;
190
+ private _resolveReady!: () => void;
191
+ private _resolveClosed!: () => void;
192
+
193
+ constructor(
194
+ host: SessionHost,
195
+ transport: SessionTransport,
196
+ options: RemoteSessionOptions = {}
197
+ ) {
198
+ this.host = host;
199
+ this.transport = transport;
200
+ this.id = options.clientId ?? `client_${nextSessionCounter++}`;
201
+ this.connectedAt = new Date();
202
+ this.socketHandle = options.socketHandle;
203
+
204
+ this.ready = new Promise<void>((resolve) => {
205
+ this._resolveReady = resolve;
206
+ });
207
+ this.closed = new Promise<void>((resolve) => {
208
+ this._resolveClosed = resolve;
209
+ });
210
+
211
+ this.engine = host.createEngine();
212
+ // init() is synchronous for wasm-node but declared async.
213
+ this.engine.init().catch((err) => log.error("Engine init failed:", err));
214
+
215
+ this.setupComponentResolver();
216
+ if (Object.keys(host.resources).length > 0) {
217
+ this.engine.registerResources(host.resources);
218
+ }
219
+
220
+ log.info(`Session ${this.id} created, engine initialized`);
221
+
222
+ // Auto-initialise legacy clients that never send `hello`.
223
+ const graceMs = options.helloGraceMs ?? 1000;
224
+ if (graceMs !== null) {
225
+ this.helloTimeout = setTimeout(() => {
226
+ if (!this._helloReceived) {
227
+ this.initializeSession(undefined, undefined).catch((err) =>
228
+ log.error("Error initializing legacy session:", err)
229
+ );
230
+ }
231
+ }, graceMs);
232
+ }
233
+ }
234
+
235
+ get sessionId(): string | null {
236
+ return this._sessionId;
237
+ }
238
+
239
+ get moduleInstance(): HypenModuleInstance<any> | null {
240
+ return this._moduleInstance;
241
+ }
242
+
243
+ get helloReceived(): boolean {
244
+ return this._helloReceived;
245
+ }
246
+
247
+ get stateSubscribed(): boolean {
248
+ return this._stateSubscribed;
249
+ }
250
+
251
+ get revision(): number {
252
+ return this._revision;
253
+ }
254
+
255
+ get isDestroyed(): boolean {
256
+ return this._destroyed;
257
+ }
258
+
259
+ /**
260
+ * The `RemoteClient` record surfaced to `onConnection`/`onDisconnection`
261
+ * callbacks. Useful to construct consistently from both the Bun adapter
262
+ * and custom transports.
263
+ */
264
+ toRemoteClient(): RemoteClient {
265
+ return {
266
+ id: this.id,
267
+ socket: this.socketHandle,
268
+ connectedAt: this.connectedAt,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Feed a client → server message into this session.
274
+ *
275
+ * Invoked by the transport adapter when it receives a message. Accepts
276
+ * either a parsed `RemoteMessage` or a raw JSON string.
277
+ */
278
+ async receive(raw: RemoteMessage | string | Buffer): Promise<void> {
279
+ if (this._destroyed) return;
280
+
281
+ // `Buffer<ArrayBufferLike>`-as-narrowed-union doesn't reliably collapse
282
+ // to `RemoteMessage` in the `else` branch under the current @types/node,
283
+ // so we project the non-raw case through an explicit assertion.
284
+ let msg: RemoteMessage;
285
+ try {
286
+ if (typeof raw === "string" || raw instanceof Buffer) {
287
+ msg = JSON.parse(raw.toString()) as RemoteMessage;
288
+ } else {
289
+ msg = raw as RemoteMessage;
290
+ }
291
+ } catch (err) {
292
+ log.error(`Invalid message on session ${this.id}:`, err);
293
+ return;
294
+ }
295
+
296
+ try {
297
+ switch (msg.type) {
298
+ case "hello": {
299
+ const hello = msg as HelloMessage;
300
+ await this.initializeSession(hello.sessionId, hello.props);
301
+ break;
302
+ }
303
+
304
+ case "dispatchAction": {
305
+ const action = msg as DispatchActionMessage;
306
+ this.engine.dispatchAction(action.action, action.payload);
307
+
308
+ if (this.host.syncActions) {
309
+ for (const other of this.host.otherSessions(this)) {
310
+ if (!other._helloReceived) continue;
311
+ other.engine.dispatchAction(action.action, action.payload);
312
+ }
313
+ }
314
+ break;
315
+ }
316
+
317
+ case "updateState": {
318
+ const stateMsg = msg as UpdateStateMessage;
319
+ this._moduleInstance?.updateState(stateMsg.state);
320
+
321
+ if (this.host.syncActions) {
322
+ for (const other of this.host.otherSessions(this)) {
323
+ if (!other._helloReceived || !other._moduleInstance) continue;
324
+ other._moduleInstance.updateState(stateMsg.state);
325
+ }
326
+ }
327
+ break;
328
+ }
329
+
330
+ case "subscribeState": {
331
+ this._stateSubscribed = true;
332
+ log.info(`Session ${this.id} subscribed to state updates`);
333
+ break;
334
+ }
335
+
336
+ default:
337
+ // Unknown message type — ignore.
338
+ break;
339
+ }
340
+ } catch (err) {
341
+ log.error(`Error handling message on session ${this.id}:`, err);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Send an outgoing message through this session's transport. Exposed so
347
+ * peer sessions can broadcast to each other (allow-multiple) without
348
+ * touching transport internals.
349
+ */
350
+ send(message: OutgoingMessage): void {
351
+ if (this._destroyed) return;
352
+ try {
353
+ this.transport.send(message);
354
+ } catch (err) {
355
+ log.error(`Failed to send message on session ${this.id}:`, err);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Notify the client their session is gone and close the transport.
361
+ */
362
+ expireAndClose(reason: "ttl" | "kicked" | "manual"): void {
363
+ if (!this._sessionId) {
364
+ this.transport.close(1000, "Session closed");
365
+ return;
366
+ }
367
+ const expired: SessionExpiredMessage = {
368
+ type: "sessionExpired",
369
+ sessionId: this._sessionId,
370
+ reason,
371
+ };
372
+ this.send(expired);
373
+ this.transport.close(1000, "Session " + reason);
374
+ }
375
+
376
+ /**
377
+ * Tear down the session. Call this from the transport adapter when the
378
+ * underlying connection closes, or manually to evict a client.
379
+ *
380
+ * Runs `onDisconnect`, suspends the session so it can be resumed later,
381
+ * and fires the host's `onSessionDestroyed` hook.
382
+ */
383
+ async destroy(): Promise<void> {
384
+ if (this._destroyed) return;
385
+ this._destroyed = true;
386
+
387
+ if (this.helloTimeout) {
388
+ clearTimeout(this.helloTimeout);
389
+ this.helloTimeout = null;
390
+ }
391
+
392
+ const currentState = this._moduleInstance?.getState() ?? {};
393
+
394
+ // onDisconnect hook
395
+ if (this._sessionId && this.host.module.handlers.onDisconnect) {
396
+ const session = this.host.sessionManager.getActiveSession(this._sessionId);
397
+ if (session) {
398
+ try {
399
+ await this.host.module.handlers.onDisconnect({
400
+ state: currentState,
401
+ session,
402
+ });
403
+ } catch (err) {
404
+ log.error(`onDisconnect threw on session ${this.id}:`, err);
405
+ }
406
+ }
407
+ }
408
+
409
+ // Suspend session if no other connections remain for it
410
+ if (this._sessionId) {
411
+ this.host.sessionManager.untrackConnection(this._sessionId, this);
412
+ if (this.host.sessionManager.getConnectionCount(this._sessionId) === 0) {
413
+ const session = this.host.sessionManager.getActiveSession(
414
+ this._sessionId
415
+ );
416
+ if (session) {
417
+ this.host.sessionManager.suspendSession(
418
+ this._sessionId,
419
+ currentState,
420
+ async (expiredSession) => {
421
+ if (this.host.module.handlers.onExpire) {
422
+ await this.host.module.handlers.onExpire({
423
+ session: expiredSession,
424
+ });
425
+ }
426
+ }
427
+ );
428
+ }
429
+ }
430
+ }
431
+
432
+ if (this._autoManagedRouter) {
433
+ try {
434
+ await this._autoManagedRouter.stop();
435
+ } catch (err) {
436
+ log.error(`Auto-router stop failed on ${this.id}:`, err);
437
+ }
438
+ this._autoManagedRouter = null;
439
+ }
440
+
441
+ if (this._moduleInstance) {
442
+ try {
443
+ await this._moduleInstance.destroy();
444
+ } catch (err) {
445
+ log.error(`ModuleInstance.destroy() failed on ${this.id}:`, err);
446
+ }
447
+ }
448
+
449
+ this.host.onSessionDestroyed(this, this.toRemoteClient());
450
+ // Ensure `ready` never dangles: if teardown happens before init (e.g.
451
+ // transport dies before hello arrives), unblock any waiter so they can
452
+ // observe `closed` and clean up without deadlocking.
453
+ this._resolveReady();
454
+ this._resolveClosed();
455
+ }
456
+
457
+ // ------------------------------------------------------------------
458
+ // internals
459
+ // ------------------------------------------------------------------
460
+
461
+ private setupComponentResolver(): void {
462
+ this.engine.setComponentResolver((componentName, _contextPath) => {
463
+ const comp = this.host.discoveredComponents.get(componentName);
464
+ if (!comp) return null;
465
+ return { source: comp.template, path: componentName };
466
+ });
467
+ }
468
+
469
+ private registerNestedModules(): void {
470
+ const primary = this.host.moduleName;
471
+
472
+ if (this.host.app) {
473
+ for (const [name, def] of this.host.app.components) {
474
+ if (name === primary) continue;
475
+ const stateKeys =
476
+ def.initialState !== null && typeof def.initialState === "object"
477
+ ? Object.keys(def.initialState as object)
478
+ : [];
479
+ const snapshot =
480
+ def.initialState !== null && typeof def.initialState === "object"
481
+ ? structuredClone(def.initialState)
482
+ : {};
483
+ this.engine.registerModule(
484
+ name,
485
+ def.actions ?? [],
486
+ stateKeys,
487
+ snapshot
488
+ );
489
+ log.info(
490
+ `Registered nested module "${name}" (${def.actions?.length ?? 0} actions, ${stateKeys.length} state keys)`
491
+ );
492
+ }
493
+ }
494
+
495
+ for (const [name, comp] of this.host.discoveredComponents) {
496
+ if (name === primary) continue;
497
+ if (!comp.module) continue;
498
+ if (this.host.app?.has(name)) continue;
499
+
500
+ const def = comp.module;
501
+
502
+ // Skip pure UI components (no state, no actions, no lifecycle).
503
+ // `loadDiscoveredComponents` creates an auto-stateless module
504
+ // (`app.defineState({}).build()`) for every .hypen file without a
505
+ // sidecar .ts, which means pure UI components like Notifications,
506
+ // Profile, Messages, BottomNav, Feed, etc. all get registered as
507
+ // nested modules. That registration doesn't shadow unscoped
508
+ // `@state.x` bindings (those still hit the primary module), but
509
+ // it does occupy a scope slot the engine may otherwise route
510
+ // dependency updates through — and every extra module carries
511
+ // revision bookkeeping overhead per render. When the module has
512
+ // no state and no actions, registering it is pure noise.
513
+ const hasState =
514
+ def.initialState !== null &&
515
+ typeof def.initialState === "object" &&
516
+ Object.keys(def.initialState as object).length > 0;
517
+ const hasActions = (def.actions?.length ?? 0) > 0;
518
+ if (!hasState && !hasActions) continue;
519
+
520
+ const stateKeys =
521
+ def.initialState !== null && typeof def.initialState === "object"
522
+ ? Object.keys(def.initialState as object)
523
+ : [];
524
+ const snapshot =
525
+ def.initialState !== null && typeof def.initialState === "object"
526
+ ? JSON.parse(JSON.stringify(def.initialState))
527
+ : {};
528
+ this.engine.registerModule(name, def.actions ?? [], stateKeys, snapshot);
529
+ log.info(`Registered nested module "${name}" from discovery`);
530
+ }
531
+ }
532
+
533
+ private async initializeSession(
534
+ requestedSessionId: string | undefined,
535
+ props: Record<string, any> | undefined
536
+ ): Promise<void> {
537
+ if (this._helloReceived || this._destroyed) return;
538
+ this._helloReceived = true;
539
+ log.info(
540
+ `Initializing session for ${this.id} (sessionId: ${requestedSessionId ?? "new"})`
541
+ );
542
+
543
+ if (this.helloTimeout) {
544
+ clearTimeout(this.helloTimeout);
545
+ this.helloTimeout = null;
546
+ }
547
+
548
+ let session: Session;
549
+ let isNew = true;
550
+ let isRestored = false;
551
+ let restoredState: unknown = null;
552
+
553
+ const sm = this.host.sessionManager;
554
+
555
+ if (requestedSessionId) {
556
+ const resumed = sm.resumeSession(requestedSessionId);
557
+ if (resumed) {
558
+ session = resumed.session;
559
+ restoredState = resumed.savedState;
560
+ isNew = false;
561
+ isRestored = true;
562
+ } else {
563
+ const activeSession = sm.getActiveSession(requestedSessionId);
564
+ if (activeSession) {
565
+ const allowed = this.resolveConcurrentConnection(
566
+ activeSession,
567
+ props
568
+ );
569
+ if (!allowed) return;
570
+ session = activeSession;
571
+ isNew = false;
572
+ } else {
573
+ session = sm.createSession(props);
574
+ }
575
+ }
576
+ } else {
577
+ session = sm.createSession(props);
578
+ }
579
+
580
+ this._sessionId = session.id;
581
+ sm.trackConnection(session.id, this);
582
+
583
+ const sessionAck: SessionAckMessage = {
584
+ type: "sessionAck",
585
+ sessionId: session.id,
586
+ isNew,
587
+ isRestored,
588
+ };
589
+ this.send(sessionAck);
590
+ log.info(`Sent sessionAck to ${this.id} (session: ${session.id})`);
591
+
592
+ // Create ModuleInstance HERE (not in constructor) so that onCreated fires
593
+ // right before renderSource. See original server.ts comment for why.
594
+ if (!this._moduleInstance) {
595
+ this._moduleInstance = new HypenModuleInstance(
596
+ this.engine,
597
+ this.host.module
598
+ );
599
+ }
600
+
601
+ if (isRestored) {
602
+ await this.triggerReconnect(session, restoredState);
603
+ }
604
+
605
+ await this._moduleInstance.waitForReady();
606
+
607
+ this.registerNestedModules();
608
+
609
+ // Flush state proxy microtasks so onCreated mutations propagate.
610
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
611
+
612
+ // Capture initial render patches (initial tree + any re-render from onCreated state).
613
+ const initialPatches: Patch[] = [];
614
+ this.engine.setRenderCallback((patches) => {
615
+ initialPatches.push(...patches);
616
+ });
617
+
618
+ try {
619
+ this.engine.renderSource(this.host.ui);
620
+ } catch (err) {
621
+ log.error(`Failed to render UI for ${this.id}:`, err);
622
+ this.transport.close(1011, "Render failed");
623
+ return;
624
+ }
625
+
626
+ // Switch to the streaming callback for subsequent patches.
627
+ this.setupStreamingRenderCallback();
628
+
629
+ const initialMessage: InitialTreeMessage = {
630
+ type: "initialTree",
631
+ module: this.host.moduleName,
632
+ state: this._moduleInstance.getState(),
633
+ patches: initialPatches,
634
+ revision: 0,
635
+ };
636
+ this.send(initialMessage);
637
+ log.info(
638
+ `Sent initialTree to ${this.id} (${initialPatches.length} patches)`
639
+ );
640
+
641
+ this.host.onSessionReady(this, this.toRemoteClient());
642
+ this._resolveReady();
643
+
644
+ // Auto-wire a ManagedRouter from the template's own Router {} blocks.
645
+ // The user gets routing "for free" from the DSL — no addRoute calls
646
+ // in their server code. Guarded by `_autoRouterEnabled` so callers
647
+ // that want bespoke wiring can opt out via
648
+ // `RemoteServer.disableAutoRouter()`.
649
+ if (this.autoRouterEnabled) {
650
+ this.autoWireManagedRouter();
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Parse Router {} blocks out of the primary template and spin up a
656
+ * per-session ManagedRouter bound to the shared engine, auto-adding
657
+ * one route per Route() — components resolved from the HypenApp
658
+ * registry. The primary module's `location` field (if present) is
659
+ * kept in sync with the router path so the Router IR re-renders on
660
+ * navigation.
661
+ *
662
+ * Both top-level routers (`moduleScope` = primary / unscoped) and
663
+ * routers nested inside per-route module templates are registered
664
+ * here, flattened into a single route table against the session's
665
+ * `HypenRouter`. The URL is a single string, so nested-router routes
666
+ * share the parent's URL space — authors who want sub-path semantics
667
+ * must spell out the full prefix in their `Route(path: ...)`. On
668
+ * pattern conflicts (multiple routes matching the same path),
669
+ * `ManagedRouter` picks first-match, and the engine emits routers
670
+ * in outer→inner order so primary routes win ties naturally.
671
+ */
672
+ private autoWireManagedRouter(): void {
673
+ // A registry is only needed to resolve component-backed routes; inline
674
+ // routes (`Route(path) { ...markup... }`) need none. Fall back to an empty
675
+ // registry so a single-module app with an inline `Router {}` still gets its
676
+ // `@router.push` handler + `state.location` mirror wired.
677
+ const app = this.host.app ?? new HypenAppClass();
678
+
679
+ // Collect router blocks from both the primary template AND every
680
+ // discovered child component template. `discoverRouters` walks a
681
+ // single IR tree and does not resolve `Foo()` component references
682
+ // — child templates live in separate source strings in
683
+ // `host.discoveredComponents`. Running discover on each separately
684
+ // and concatenating (primary first) gives us the true cross-tree
685
+ // router inventory. Without this pass, a nested `module Home {
686
+ // Router { ... } }` block declared in `Home/component.hypen`
687
+ // (the canonical `.source()`-discovery shape) is silently
688
+ // invisible to the SDK and the route never mounts.
689
+ const discovered: ReturnType<BaseEngine["discoverRouters"]> = [];
690
+ const runDiscover = (source: string, label: string): void => {
691
+ try {
692
+ const blocks = this.engine.discoverRouters(source);
693
+ for (const b of blocks) discovered.push(b);
694
+ } catch (err) {
695
+ log.error(`Auto-router: discoverRouters failed on ${label}:`, err);
696
+ }
697
+ };
698
+ runDiscover(this.host.ui, this.id);
699
+ for (const [name, comp] of this.host.discoveredComponents) {
700
+ if (comp.template) runDiscover(comp.template, `${this.id} / ${name}`);
701
+ }
702
+ if (discovered.length === 0) return;
703
+
704
+ const primaryScope = this.host.moduleName.toLowerCase();
705
+
706
+ // Build a single top-level ManagedRouter covering every route from
707
+ // every discovered router block (primary AND nested). Multiple
708
+ // `Router {}` blocks flatten into one route list — the engine's
709
+ // own Router IR still renders them independently, but the SDK only
710
+ // needs a single `HypenRouter` per session.
711
+ const router = new HypenRouter();
712
+ const globalContext = new HypenGlobalContext();
713
+ const managed = new ManagedRouter(router, this.engine, app, globalContext);
714
+
715
+ const primary = this._moduleInstance;
716
+ if (primary) {
717
+ globalContext.registerModule(primaryScope, primary);
718
+ }
719
+
720
+ let added = 0;
721
+ const seenPaths = new Set<string>();
722
+ for (const block of discovered) {
723
+ for (const route of block.routes) {
724
+ // First path wins — outer Router blocks emit first.
725
+ if (seenPaths.has(route.path)) {
726
+ log.debug(
727
+ `Auto-router: path "${route.path}" already registered; ignoring nested duplicate`
728
+ );
729
+ continue;
730
+ }
731
+ const component = this.pickComponent(route.elementNames);
732
+ if (!component) {
733
+ log.debug(
734
+ `Auto-router: no registered module matched route "${route.path}" — skipping`
735
+ );
736
+ continue;
737
+ }
738
+ managed.addRoute({ path: route.path, component });
739
+ seenPaths.add(route.path);
740
+ added += 1;
741
+ }
742
+ }
743
+
744
+ // `added` counts component-backed routes (mounted via ManagedRouter).
745
+ // Routes can also be fully INLINE — `Route(path: "/x") { ...markup... }`
746
+ // with no component — in which case the engine's own Router IR renders
747
+ // them straight from `state.location`, and there's nothing for
748
+ // ManagedRouter to mount. We still need the URL→`location` mirror and the
749
+ // `@router.push` action handler so navigation works. So: bail only if no
750
+ // Router blocks were discovered at all; otherwise wire the router even
751
+ // when `added === 0`.
752
+ const locationKey = this.primaryHasLocationKey() ? "location" : null;
753
+ if (added === 0 && !locationKey) {
754
+ // No component routes and no `location` state to drive inline routes —
755
+ // nothing this router could affect.
756
+ log.debug(`Auto-router: nothing to mount for ${this.id}`);
757
+ return;
758
+ }
759
+
760
+ // Mirror router path into primary module state's `location` so the
761
+ // engine-level Router IR reconciles to the matching Route subtree.
762
+ // Deferring via `queueMicrotask` keeps the engine write off the
763
+ // synchronous notify path (Rust rejects re-entrant WASM state
764
+ // proxy calls).
765
+ if (locationKey) {
766
+ router.onNavigate((rs) => {
767
+ const path = rs.currentPath;
768
+ queueMicrotask(() => {
769
+ try {
770
+ this._moduleInstance?.updateState({ [locationKey]: path });
771
+ } catch (err) {
772
+ log.error(`Auto-router: state.${locationKey} sync failed:`, err);
773
+ }
774
+ });
775
+ });
776
+ }
777
+
778
+ // `start()` installs the `@router.push`/`@router.back`/... action handlers
779
+ // and subscribes the router. Needed for both component and inline routes.
780
+ managed.start();
781
+ // HypenRouter doesn't fire onNavigate on subscribe; kick an
782
+ // explicit push so the initial route mounts.
783
+ router.push(router.getCurrentPath());
784
+
785
+ this._autoManagedRouter = managed;
786
+ }
787
+
788
+ /** Pick the first element name that resolves to a registered module. */
789
+ private pickComponent(elementNames: string[]): string | null {
790
+ const app = this.host.app;
791
+ if (!app) return null;
792
+ for (const name of elementNames) {
793
+ if (app.has(name)) return name;
794
+ }
795
+ return null;
796
+ }
797
+
798
+ /** Does the primary module's initial state have a `location` field? */
799
+ private primaryHasLocationKey(): boolean {
800
+ const state = this._moduleInstance?.getState();
801
+ return (
802
+ state !== null &&
803
+ state !== undefined &&
804
+ typeof state === "object" &&
805
+ "location" in state
806
+ );
807
+ }
808
+
809
+ private setupStreamingRenderCallback(): void {
810
+ this.engine.setRenderCallback((patches) => {
811
+ if (this._destroyed) return;
812
+ this._revision++;
813
+ log.info(
814
+ `Streaming ${patches.length} patches to ${this.id} (rev ${this._revision})`
815
+ );
816
+
817
+ const patchMessage: PatchMessage = {
818
+ type: "patch",
819
+ module: this.host.moduleName,
820
+ patches,
821
+ revision: this._revision,
822
+ };
823
+ this.send(patchMessage);
824
+
825
+ if (this._stateSubscribed && this._moduleInstance) {
826
+ const stateMessage: StateUpdateMessage = {
827
+ type: "stateUpdate",
828
+ module: this.host.moduleName,
829
+ state: this._moduleInstance.getState(),
830
+ revision: this._revision,
831
+ };
832
+ this.send(stateMessage);
833
+ }
834
+
835
+ // allow-multiple: fan out to other sessions sharing our sessionId.
836
+ if (
837
+ this._sessionId &&
838
+ this.host.sessionManager.getConcurrentPolicy() === "allow-multiple"
839
+ ) {
840
+ for (const peer of this.host.sessionsForId(this._sessionId)) {
841
+ if (peer === this) continue;
842
+ peer.send(patchMessage);
843
+ if (peer._stateSubscribed && this._moduleInstance) {
844
+ const stateMessage: StateUpdateMessage = {
845
+ type: "stateUpdate",
846
+ module: this.host.moduleName,
847
+ state: this._moduleInstance.getState(),
848
+ revision: this._revision,
849
+ };
850
+ peer.send(stateMessage);
851
+ }
852
+ }
853
+ }
854
+ });
855
+ }
856
+
857
+ private resolveConcurrentConnection(
858
+ existingSession: Session,
859
+ _props: Record<string, any> | undefined
860
+ ): boolean {
861
+ const policy = this.host.sessionManager.getConcurrentPolicy();
862
+
863
+ switch (policy) {
864
+ case "kick-old": {
865
+ for (const peer of this.host.sessionsForId(existingSession.id)) {
866
+ if (peer === this) continue;
867
+ peer.expireAndClose("kicked");
868
+ }
869
+ return true;
870
+ }
871
+ case "reject-new": {
872
+ this.expireAndClose("kicked");
873
+ return false;
874
+ }
875
+ case "allow-multiple":
876
+ default:
877
+ return true;
878
+ }
879
+ }
880
+
881
+ private async triggerReconnect(
882
+ session: Session,
883
+ savedState: unknown
884
+ ): Promise<void> {
885
+ const handler = this.host.module.handlers.onReconnect;
886
+ if (!handler) return;
887
+
888
+ const restore = (state: unknown) => {
889
+ if (state === null || typeof state !== "object") {
890
+ log.warn(
891
+ "restore() called with non-object state, ignoring:",
892
+ typeof state
893
+ );
894
+ return;
895
+ }
896
+ this._moduleInstance?.updateState(state as Record<string, unknown>);
897
+ };
898
+
899
+ await handler({ session, restore });
900
+ // `savedState` is threaded through for handlers that don't call restore().
901
+ void savedState;
902
+ }
903
+ }
904
+
905
+ // ---------------------------------------------------------------------------
906
+ // Built-in transports
907
+ // ---------------------------------------------------------------------------
908
+
909
+ /**
910
+ * Wrap a Bun `ServerWebSocket` as a `SessionTransport`.
911
+ *
912
+ * Each outgoing message is JSON-serialized. The caller is responsible for
913
+ * calling `session.receive()` on incoming messages and `session.destroy()`
914
+ * on close.
915
+ */
916
+ export function createBunWebSocketTransport(
917
+ ws: { send(data: string): void; close(code?: number, reason?: string): void }
918
+ ): SessionTransport {
919
+ return {
920
+ send(message) {
921
+ ws.send(JSON.stringify(message));
922
+ },
923
+ close(code, reason) {
924
+ ws.close(code, reason);
925
+ },
926
+ };
927
+ }
928
+
929
+ /**
930
+ * In-memory transport that buffers outgoing messages and exposes them as
931
+ * an async iterator via `.stream()`. Lets you drive a session from code
932
+ * that prefers `for await` over callbacks — e.g. piping to SSE, HTTP/2
933
+ * server push, or gRPC streaming.
934
+ *
935
+ * @example
936
+ * ```ts
937
+ * const transport = new AsyncQueueTransport();
938
+ * const session = server.createSession(transport);
939
+ * for await (const msg of transport.stream()) {
940
+ * res.write(`data: ${JSON.stringify(msg)}\n\n`); // SSE
941
+ * }
942
+ * ```
943
+ */
944
+ export class AsyncQueueTransport implements SessionTransport {
945
+ private queue: OutgoingMessage[] = [];
946
+ private waiters: Array<
947
+ (value: IteratorResult<OutgoingMessage, void>) => void
948
+ > = [];
949
+ private closed = false;
950
+
951
+ send(message: OutgoingMessage): void {
952
+ if (this.closed) return;
953
+ const w = this.waiters.shift();
954
+ if (w) {
955
+ w({ value: message, done: false });
956
+ } else {
957
+ this.queue.push(message);
958
+ }
959
+ }
960
+
961
+ close(_code?: number, _reason?: string): void {
962
+ if (this.closed) return;
963
+ this.closed = true;
964
+ while (this.waiters.length) {
965
+ const w = this.waiters.shift()!;
966
+ w({ value: undefined, done: true });
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Async iterator over outgoing messages. Completes once the transport is
972
+ * closed and the queue is drained.
973
+ */
974
+ stream(): AsyncIterableIterator<OutgoingMessage> {
975
+ const self = this;
976
+ const iter: AsyncIterableIterator<OutgoingMessage> = {
977
+ [Symbol.asyncIterator]() {
978
+ return iter;
979
+ },
980
+ async next(): Promise<IteratorResult<OutgoingMessage, void>> {
981
+ if (self.queue.length) {
982
+ return { value: self.queue.shift()!, done: false };
983
+ }
984
+ if (self.closed) {
985
+ return { value: undefined, done: true };
986
+ }
987
+ return new Promise((resolve) => {
988
+ self.waiters.push(resolve);
989
+ });
990
+ },
991
+ async return(): Promise<IteratorResult<OutgoingMessage, void>> {
992
+ self.close();
993
+ return { value: undefined, done: true };
994
+ },
995
+ };
996
+ return iter;
997
+ }
998
+ }