@hypen-space/core 0.4.37 → 0.4.39

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 (85) hide show
  1. package/README.md +13 -14
  2. package/dist/app.js +304 -227
  3. package/dist/app.js.map +5 -5
  4. package/dist/components/builtin.d.ts +17 -0
  5. package/dist/components/builtin.js +336 -256
  6. package/dist/components/builtin.js.map +6 -6
  7. package/dist/context.d.ts +11 -20
  8. package/dist/context.js +69 -101
  9. package/dist/context.js.map +3 -3
  10. package/dist/datasource.js +80 -0
  11. package/dist/datasource.js.map +10 -0
  12. package/dist/disposable.js +60 -63
  13. package/dist/disposable.js.map +2 -2
  14. package/dist/events.js +60 -63
  15. package/dist/events.js.map +2 -2
  16. package/dist/hypen.js +78 -0
  17. package/dist/hypen.js.map +10 -0
  18. package/dist/index.browser.d.ts +2 -1
  19. package/dist/index.browser.js +327 -281
  20. package/dist/index.browser.js.map +7 -8
  21. package/dist/index.d.ts +0 -3
  22. package/dist/index.js +547 -246
  23. package/dist/index.js.map +9 -9
  24. package/dist/logger.js +60 -64
  25. package/dist/logger.js.map +2 -2
  26. package/dist/remote/client.js +258 -158
  27. package/dist/remote/client.js.map +6 -6
  28. package/dist/remote/index.d.ts +6 -5
  29. package/dist/remote/index.js +255 -1985
  30. package/dist/remote/index.js.map +6 -13
  31. package/dist/remote/session.js +151 -0
  32. package/dist/remote/session.js.map +10 -0
  33. package/dist/renderer.js +60 -63
  34. package/dist/renderer.js.map +2 -2
  35. package/dist/result.js +235 -0
  36. package/dist/result.js.map +10 -0
  37. package/dist/retry.js +344 -0
  38. package/dist/retry.js.map +11 -0
  39. package/dist/router.js +62 -70
  40. package/dist/router.js.map +2 -2
  41. package/dist/state.js +3 -8
  42. package/dist/state.js.map +2 -2
  43. package/package.json +11 -56
  44. package/src/components/builtin.ts +78 -56
  45. package/src/context.ts +22 -65
  46. package/src/index.browser.ts +5 -4
  47. package/src/index.ts +10 -23
  48. package/src/remote/index.ts +9 -5
  49. package/src/result.ts +11 -0
  50. package/dist/discovery.d.ts +0 -90
  51. package/dist/discovery.js +0 -1334
  52. package/dist/discovery.js.map +0 -15
  53. package/dist/engine.browser.d.ts +0 -116
  54. package/dist/engine.browser.js +0 -479
  55. package/dist/engine.browser.js.map +0 -12
  56. package/dist/engine.d.ts +0 -107
  57. package/dist/engine.js +0 -543
  58. package/dist/engine.js.map +0 -12
  59. package/dist/loader.d.ts +0 -51
  60. package/dist/loader.js +0 -292
  61. package/dist/loader.js.map +0 -11
  62. package/dist/plugin.d.ts +0 -39
  63. package/dist/plugin.js +0 -685
  64. package/dist/plugin.js.map +0 -12
  65. package/dist/remote/server.d.ts +0 -188
  66. package/dist/remote/server.js +0 -2270
  67. package/dist/remote/server.js.map +0 -19
  68. package/src/discovery.ts +0 -527
  69. package/src/engine.browser.ts +0 -302
  70. package/src/engine.ts +0 -282
  71. package/src/loader.ts +0 -136
  72. package/src/plugin.ts +0 -220
  73. package/src/remote/server.ts +0 -879
  74. package/wasm-browser/README.md +0 -594
  75. package/wasm-browser/hypen_engine.d.ts +0 -389
  76. package/wasm-browser/hypen_engine.js +0 -1070
  77. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  78. package/wasm-browser/hypen_engine_bg.wasm.d.ts +0 -37
  79. package/wasm-browser/package.json +0 -24
  80. package/wasm-node/README.md +0 -594
  81. package/wasm-node/hypen_engine.d.ts +0 -327
  82. package/wasm-node/hypen_engine.js +0 -979
  83. package/wasm-node/hypen_engine_bg.wasm +0 -0
  84. package/wasm-node/hypen_engine_bg.wasm.d.ts +0 -37
  85. package/wasm-node/package.json +0 -22
@@ -1,879 +0,0 @@
1
- /**
2
- * RemoteServer - Stream Hypen apps over WebSocket with Session Management
3
- *
4
- * Usage with inline UI:
5
- * ```typescript
6
- * import { RemoteServer, app } from "@hypen-space/core";
7
- *
8
- * const counter = app
9
- * .defineState({ count: 0 })
10
- * .onAction("increment", ({ state }) => state.count++)
11
- * .build();
12
- *
13
- * new RemoteServer()
14
- * .module("Counter", counter)
15
- * .ui(`Column { Text("Count: \${state.count}") }`)
16
- * .listen(3000);
17
- * ```
18
- *
19
- * Usage with directory-based discovery (auto-resolves imports):
20
- * ```typescript
21
- * import { RemoteServer } from "@hypen-space/core";
22
- * import chatModule from "./apps/chat/Chat/component.js";
23
- *
24
- * await new RemoteServer()
25
- * .source("./apps/chat")
26
- * .module("Chat", chatModule)
27
- * .listen(3001);
28
- * ```
29
- */
30
-
31
- import { Engine } from "../engine.js";
32
- import { HypenModuleInstance } from "../app.js";
33
- import type { HypenModule, HypenModuleDefinition } from "../app.js";
34
- import type {
35
- RemoteMessage,
36
- RemoteClient,
37
- RemoteServerConfig,
38
- InitialTreeMessage,
39
- PatchMessage,
40
- StateUpdateMessage,
41
- UpdateStateMessage,
42
- DispatchActionMessage,
43
- HelloMessage,
44
- SessionAckMessage,
45
- SessionExpiredMessage,
46
- Session,
47
- SessionConfig,
48
- } from "./types.js";
49
- import type { Patch, ResolvedComponent } from "../types.js";
50
- import type { ServerWebSocket } from "bun";
51
- import { SessionManager } from "./session.js";
52
- import { frameworkLoggers } from "../logger.js";
53
- import {
54
- discoverComponents,
55
- loadDiscoveredComponents,
56
- type DiscoveredComponent,
57
- } from "../discovery.js";
58
-
59
- const log = frameworkLoggers.remote;
60
-
61
- interface ClientData {
62
- id: string;
63
- engine: Engine;
64
- moduleInstance: HypenModuleInstance<any>;
65
- revision: number;
66
- connectedAt: Date;
67
- /** Session ID for this client */
68
- sessionId: string;
69
- /** Whether we've received the hello message */
70
- helloReceived: boolean;
71
- /** Timeout for legacy clients that don't send hello */
72
- helloTimeout?: ReturnType<typeof setTimeout>;
73
- /** Whether this client subscribes to state updates after each render */
74
- stateSubscribed: boolean;
75
- }
76
-
77
- /**
78
- * Builder pattern for hosting Hypen apps over WebSocket
79
- */
80
- export class RemoteServer {
81
- private _module: HypenModule<any> | null = null;
82
- private _moduleName: string = "App";
83
- private _ui: string = "";
84
- private _sourceDir: string | null = null;
85
- private _discoveredComponents: Map<string, { template: string; module?: HypenModuleDefinition<any> }> = new Map();
86
- private _config: RemoteServerConfig = {};
87
- private _sessionConfig: SessionConfig = {};
88
- private _onConnectionCallbacks: Array<(client: RemoteClient) => void> = [];
89
- private _onDisconnectionCallbacks: Array<(client: RemoteClient) => void> = [];
90
- private clients = new Map<ServerWebSocket<unknown>, ClientData>();
91
- private nextClientId = 1;
92
- private server: ReturnType<typeof Bun.serve> | null = null;
93
- private sessionManager: SessionManager | null = null;
94
- private _syncActions: boolean = false;
95
-
96
- /**
97
- * Set the module for this app
98
- */
99
- module(name: string, module: HypenModule<any>): this {
100
- this._moduleName = name;
101
- this._module = module;
102
- return this;
103
- }
104
-
105
- /**
106
- * Set the UI DSL string
107
- */
108
- ui(dsl: string): this {
109
- this._ui = dsl;
110
- return this;
111
- }
112
-
113
- /**
114
- * Set a source directory for automatic component discovery.
115
- *
116
- * All .hypen and .ts components in the directory (and subdirectories) are
117
- * discovered automatically. The entry component's template is used as the
118
- * UI, so calling .ui() is optional when .source() is set.
119
- *
120
- * @example
121
- * ```typescript
122
- * new RemoteServer()
123
- * .source("./apps/chat")
124
- * .module("Chat", chatModule)
125
- * .listen(3001);
126
- * ```
127
- */
128
- source(dir: string): this {
129
- this._sourceDir = dir;
130
- return this;
131
- }
132
-
133
- /**
134
- * Set server configuration
135
- */
136
- config(config: RemoteServerConfig): this {
137
- this._config = { ...this._config, ...config };
138
- return this;
139
- }
140
-
141
- /**
142
- * Configure session management
143
- */
144
- session(config: SessionConfig): this {
145
- this._sessionConfig = config;
146
- return this;
147
- }
148
-
149
- /**
150
- * Enable action synchronization across all connected clients.
151
- * When enabled, an action dispatched by any client is also dispatched
152
- * to every other client's engine, keeping all clients in sync.
153
- */
154
- syncActions(): this {
155
- this._syncActions = true;
156
- return this;
157
- }
158
-
159
- /**
160
- * Register connection callback
161
- */
162
- onConnection(callback: (client: RemoteClient) => void): this {
163
- this._onConnectionCallbacks.push(callback);
164
- return this;
165
- }
166
-
167
- /**
168
- * Register disconnection callback
169
- */
170
- onDisconnection(callback: (client: RemoteClient) => void): this {
171
- this._onDisconnectionCallbacks.push(callback);
172
- return this;
173
- }
174
-
175
- /**
176
- * Start the WebSocket server
177
- */
178
- async listen(port?: number): Promise<this> {
179
- if (!this._module) {
180
- throw new Error("Module not set. Call .module() before .listen()");
181
- }
182
-
183
- // Run component discovery if source directory is set
184
- if (this._sourceDir) {
185
- await this.discoverFromSource();
186
- }
187
-
188
- if (!this._ui) {
189
- throw new Error(
190
- "UI not set. Call .ui() or .source() before .listen()"
191
- );
192
- }
193
-
194
- // Initialize session manager
195
- this.sessionManager = new SessionManager(this._sessionConfig);
196
-
197
- const finalPort = port ?? this._config.port ?? 3000;
198
- const hostname = this._config.hostname ?? "0.0.0.0";
199
-
200
- this.server = Bun.serve({
201
- port: finalPort,
202
- hostname,
203
- websocket: {
204
- open: (ws) => this.handleOpen(ws),
205
- message: (ws, message) => this.handleMessage(ws, message),
206
- close: (ws) => this.handleClose(ws),
207
- },
208
- fetch: (req, server) => {
209
- const url = new URL(req.url);
210
-
211
- // Upgrade to WebSocket
212
- if (server.upgrade(req, { data: undefined })) {
213
- return; // Connection upgraded
214
- }
215
-
216
- // Health check endpoint
217
- if (url.pathname === "/health") {
218
- return new Response("OK", { status: 200 });
219
- }
220
-
221
- // Stats endpoint
222
- if (url.pathname === "/stats") {
223
- const stats = this.sessionManager?.getStats() ?? {
224
- activeSessions: 0,
225
- pendingSessions: 0,
226
- totalConnections: 0,
227
- };
228
- return new Response(JSON.stringify(stats), {
229
- headers: { "Content-Type": "application/json" },
230
- });
231
- }
232
-
233
- return new Response("Hypen Remote Server", { status: 200 });
234
- },
235
- });
236
-
237
- log.info(`Hypen app streaming on ws://${hostname}:${finalPort}`);
238
-
239
- return this;
240
- }
241
-
242
- /**
243
- * Stop the server
244
- */
245
- stop(): void {
246
- if (this.server) {
247
- this.server.stop();
248
- this.server = null;
249
- }
250
- if (this.sessionManager) {
251
- this.sessionManager.destroy();
252
- this.sessionManager = null;
253
- }
254
- }
255
-
256
- /**
257
- * Get the server URL
258
- */
259
- get url(): string | null {
260
- if (!this.server) return null;
261
- const hostname = this._config.hostname ?? "localhost";
262
- const port = this._config.port ?? 3000;
263
- return `ws://${hostname}:${port}`;
264
- }
265
-
266
- /**
267
- * Discover components from the source directory.
268
- * Populates _discoveredComponents and sets _ui from the entry component
269
- * if not already set via .ui().
270
- */
271
- private async discoverFromSource(): Promise<void> {
272
- const dir = this._sourceDir!;
273
- log.info(`Discovering components from ${dir}...`);
274
-
275
- const discovered = await discoverComponents(dir);
276
- const loaded = await loadDiscoveredComponents(discovered);
277
-
278
- for (const [name, { module, template }] of loaded) {
279
- this._discoveredComponents.set(name, { template, module });
280
- }
281
-
282
- log.info(
283
- `Discovered ${this._discoveredComponents.size} components: ${Array.from(this._discoveredComponents.keys()).join(", ")}`
284
- );
285
-
286
- // If .ui() was not called, use the entry component's template
287
- if (!this._ui) {
288
- const entry = this._discoveredComponents.get(this._moduleName);
289
- if (entry) {
290
- this._ui = entry.template;
291
- }
292
- }
293
- }
294
-
295
- /**
296
- * Set up a component resolver on a client engine so that imports
297
- * and component references in templates are resolved from discovered
298
- * components.
299
- */
300
- private setupComponentResolver(engine: Engine): void {
301
- if (this._discoveredComponents.size === 0) return;
302
-
303
- engine.setComponentResolver(
304
- (componentName: string, _contextPath: string | null) => {
305
- const comp = this._discoveredComponents.get(componentName);
306
- if (!comp) {
307
- log.warn(
308
- `Component "${componentName}" not found. Available: ${Array.from(this._discoveredComponents.keys()).join(", ") || "(none)"}`
309
- );
310
- return null;
311
- }
312
-
313
- return {
314
- source: comp.template,
315
- path: componentName,
316
- };
317
- }
318
- );
319
- }
320
-
321
- /**
322
- * Handle new WebSocket connection
323
- * Waits for hello message before fully initializing
324
- */
325
- private handleOpen(ws: ServerWebSocket<unknown>) {
326
- try {
327
- const clientId = `client_${this.nextClientId++}`;
328
- const connectedAt = new Date();
329
-
330
- // Create engine instance for this client
331
- const engine = new Engine();
332
- // init() body is synchronous for wasm-node (just new WasmEngine())
333
- // but declared async, so call it and catch the rejected promise if it fails
334
- engine.init().catch((err) => log.error("Engine init failed:", err));
335
-
336
- // Wire up component resolver if we have discovered components
337
- this.setupComponentResolver(engine);
338
-
339
- // Create module instance
340
- const moduleInstance = new HypenModuleInstance(engine, this._module!);
341
-
342
- log.info(`Client ${clientId} connected, engine initialized`);
343
-
344
- // Store client data SYNCHRONOUSLY so handleMessage can find it
345
- const clientData: ClientData = {
346
- id: clientId,
347
- engine,
348
- moduleInstance,
349
- revision: 0,
350
- connectedAt,
351
- sessionId: "",
352
- helloReceived: false,
353
- stateSubscribed: false,
354
- };
355
-
356
- this.clients.set(ws, clientData);
357
-
358
- // Set timeout for legacy clients that don't send hello
359
- clientData.helloTimeout = setTimeout(() => {
360
- if (!clientData.helloReceived) {
361
- // Legacy client - create new session automatically
362
- this.initializeSession(ws, clientData, undefined, undefined).catch(
363
- (err) => log.error("Error initializing legacy session:", err)
364
- );
365
- }
366
- }, 1000); // 1 second grace period
367
-
368
- } catch (error) {
369
- log.error("Error handling WebSocket open:", error);
370
- ws.close(1011, "Internal server error");
371
- }
372
- }
373
-
374
- /**
375
- * Initialize session for a client (new or resumed)
376
- */
377
- private async initializeSession(
378
- ws: ServerWebSocket<unknown>,
379
- clientData: ClientData,
380
- requestedSessionId: string | undefined,
381
- props: Record<string, any> | undefined
382
- ): Promise<void> {
383
- if (clientData.helloReceived) return;
384
- clientData.helloReceived = true;
385
- log.info(`Initializing session for ${clientData.id} (sessionId: ${requestedSessionId ?? "new"})`);
386
-
387
- // Clear hello timeout
388
- if (clientData.helloTimeout) {
389
- clearTimeout(clientData.helloTimeout);
390
- clientData.helloTimeout = undefined;
391
- }
392
-
393
- let session: Session;
394
- let isNew = true;
395
- let isRestored = false;
396
- let restoredState: unknown = null;
397
-
398
- if (requestedSessionId && this.sessionManager) {
399
- // Try to resume pending session
400
- const resumed = this.sessionManager.resumeSession(requestedSessionId);
401
- if (resumed) {
402
- session = resumed.session;
403
- restoredState = resumed.savedState;
404
- isNew = false;
405
- isRestored = true;
406
-
407
- // Call onReconnect hook
408
- await this.triggerReconnect(clientData, session, restoredState);
409
- } else {
410
- // Check for concurrent active session
411
- const activeSession = this.sessionManager.getActiveSession(requestedSessionId);
412
- if (activeSession) {
413
- const handled = await this.handleConcurrentConnection(
414
- ws,
415
- clientData,
416
- activeSession,
417
- props
418
- );
419
- if (!handled) return; // Connection was rejected
420
- session = activeSession;
421
- isNew = false;
422
- } else {
423
- // Session not found, create new
424
- session = this.sessionManager.createSession(props);
425
- }
426
- }
427
- } else if (this.sessionManager) {
428
- // New session
429
- session = this.sessionManager.createSession(props);
430
- } else {
431
- // No session manager (shouldn't happen, but fallback)
432
- session = {
433
- id: crypto.randomUUID(),
434
- ttl: 3600,
435
- createdAt: new Date(),
436
- lastConnectedAt: new Date(),
437
- props,
438
- };
439
- }
440
-
441
- clientData.sessionId = session.id;
442
- this.sessionManager?.trackConnection(session.id, ws);
443
-
444
- // Send session acknowledgment
445
- const sessionAck: SessionAckMessage = {
446
- type: "sessionAck",
447
- sessionId: session.id,
448
- isNew,
449
- isRestored,
450
- };
451
- ws.send(JSON.stringify(sessionAck));
452
- log.info(`Sent sessionAck to ${clientData.id} (session: ${session.id})`);
453
-
454
- // Render initial tree — capture patches synchronously
455
- const initialPatches: Patch[] = [];
456
- clientData.engine.setRenderCallback((patches) => {
457
- initialPatches.push(...patches);
458
- });
459
-
460
- try {
461
- clientData.engine.renderSource(this._ui);
462
- } catch (renderError) {
463
- log.error(`Failed to render UI for ${clientData.id}:`, renderError);
464
- ws.close(1011, "Render failed");
465
- return;
466
- }
467
-
468
- // Now set up streaming render callback for subsequent updates
469
- this.setupRenderCallback(ws, clientData);
470
-
471
- // Send initial tree
472
- const initialMessage: InitialTreeMessage = {
473
- type: "initialTree",
474
- module: this._moduleName,
475
- state: clientData.moduleInstance.getState(),
476
- patches: initialPatches,
477
- revision: 0,
478
- };
479
-
480
- try {
481
- ws.send(JSON.stringify(initialMessage));
482
- } catch (sendError) {
483
- log.error(`Failed to send initialTree to ${clientData.id}:`, sendError);
484
- return;
485
- }
486
- log.info(`Sent initialTree to ${clientData.id} (${initialPatches.length} patches)`);
487
-
488
- // Notify connection callbacks
489
- const client: RemoteClient = {
490
- id: clientData.id,
491
- socket: ws,
492
- connectedAt: clientData.connectedAt,
493
- };
494
- this._onConnectionCallbacks.forEach((cb) => cb(client));
495
- }
496
-
497
- /**
498
- * Set up the render callback for streaming patches
499
- */
500
- private setupRenderCallback(
501
- ws: ServerWebSocket<unknown>,
502
- clientData: ClientData
503
- ): void {
504
- clientData.engine.setRenderCallback((patches) => {
505
- const data = this.clients.get(ws);
506
- if (!data) return;
507
-
508
- data.revision++;
509
-
510
- const patchMessage: PatchMessage = {
511
- type: "patch",
512
- module: this._moduleName,
513
- patches,
514
- revision: data.revision,
515
- };
516
-
517
- ws.send(JSON.stringify(patchMessage));
518
-
519
- // Only send state snapshot to clients that opted in (e.g. Studio)
520
- if (data.stateSubscribed) {
521
- const stateMessage: StateUpdateMessage = {
522
- type: "stateUpdate",
523
- module: this._moduleName,
524
- state: data.moduleInstance.getState(),
525
- revision: data.revision,
526
- };
527
- ws.send(JSON.stringify(stateMessage));
528
- }
529
-
530
- // If allow-multiple, broadcast to other connections on same session
531
- if (
532
- this.sessionManager?.getConcurrentPolicy() === "allow-multiple" &&
533
- data.sessionId
534
- ) {
535
- const connections = this.sessionManager.getConnections(data.sessionId);
536
- if (connections) {
537
- for (const conn of connections) {
538
- if (conn !== ws) {
539
- const otherWs = conn as ServerWebSocket<unknown>;
540
- const otherData = this.clients.get(otherWs);
541
- otherWs.send(JSON.stringify(patchMessage));
542
- if (otherData?.stateSubscribed) {
543
- const stateMessage: StateUpdateMessage = {
544
- type: "stateUpdate",
545
- module: this._moduleName,
546
- state: data.moduleInstance.getState(),
547
- revision: data.revision,
548
- };
549
- otherWs.send(JSON.stringify(stateMessage));
550
- }
551
- }
552
- }
553
- }
554
- }
555
- });
556
- }
557
-
558
- /**
559
- * Handle concurrent connection based on policy
560
- * Returns true if connection is allowed, false if rejected
561
- */
562
- private async handleConcurrentConnection(
563
- ws: ServerWebSocket<unknown>,
564
- clientData: ClientData,
565
- existingSession: Session,
566
- props: Record<string, any> | undefined
567
- ): Promise<boolean> {
568
- const policy = this.sessionManager?.getConcurrentPolicy() ?? "kick-old";
569
-
570
- switch (policy) {
571
- case "kick-old": {
572
- // Kick existing connections
573
- const existingConnections = this.sessionManager?.getConnections(
574
- existingSession.id
575
- );
576
- if (existingConnections) {
577
- for (const conn of existingConnections) {
578
- const oldWs = conn as ServerWebSocket<unknown>;
579
- const expiredMsg: SessionExpiredMessage = {
580
- type: "sessionExpired",
581
- sessionId: existingSession.id,
582
- reason: "kicked",
583
- };
584
- oldWs.send(JSON.stringify(expiredMsg));
585
- oldWs.close(1000, "Session taken by new connection");
586
- }
587
- }
588
- return true;
589
- }
590
-
591
- case "reject-new": {
592
- // Reject this connection
593
- const expiredMsg: SessionExpiredMessage = {
594
- type: "sessionExpired",
595
- sessionId: existingSession.id,
596
- reason: "kicked",
597
- };
598
- ws.send(JSON.stringify(expiredMsg));
599
- ws.close(1000, "Session already active");
600
- return false;
601
- }
602
-
603
- case "allow-multiple": {
604
- // Allow both connections
605
- return true;
606
- }
607
-
608
- default:
609
- return true;
610
- }
611
- }
612
-
613
- /**
614
- * Trigger onReconnect hook
615
- */
616
- private async triggerReconnect(
617
- clientData: ClientData,
618
- session: Session,
619
- savedState: unknown
620
- ): Promise<void> {
621
- const handler = this._module?.handlers.onReconnect;
622
- if (!handler) return;
623
-
624
- let restored = false;
625
- const restore = (state: unknown) => {
626
- if (state === null || typeof state !== 'object') {
627
- log.warn("restore() called with non-object state, ignoring:", typeof state);
628
- return;
629
- }
630
- restored = true;
631
- clientData.moduleInstance.updateState(state as Record<string, unknown>);
632
- };
633
-
634
- await handler({ session, restore });
635
- }
636
-
637
- /**
638
- * Handle incoming WebSocket message
639
- */
640
- private handleMessage(
641
- ws: ServerWebSocket<unknown>,
642
- message: string | Buffer
643
- ) {
644
- try {
645
- const clientData = this.clients.get(ws);
646
- if (!clientData) return;
647
-
648
- const msg = JSON.parse(message.toString()) as RemoteMessage;
649
-
650
- switch (msg.type) {
651
- case "hello": {
652
- const helloMsg = msg as HelloMessage;
653
- this.initializeSession(
654
- ws,
655
- clientData,
656
- helloMsg.sessionId,
657
- helloMsg.props
658
- ).catch((err) => log.error("Error initializing session from hello:", err));
659
- break;
660
- }
661
-
662
- case "dispatchAction": {
663
- const actionMsg = msg as DispatchActionMessage;
664
- clientData.engine.dispatchAction(actionMsg.action, actionMsg.payload);
665
-
666
- // Sync mode: dispatch to all other clients' engines
667
- if (this._syncActions) {
668
- for (const [otherWs, otherClient] of this.clients) {
669
- if (otherWs === ws || !otherClient.helloReceived) continue;
670
- otherClient.engine.dispatchAction(actionMsg.action, actionMsg.payload);
671
- }
672
- }
673
- break;
674
- }
675
-
676
- case "updateState": {
677
- const stateMsg = msg as UpdateStateMessage;
678
- clientData.moduleInstance.updateState(stateMsg.state);
679
-
680
- // Sync mode: update state on all other clients' engines
681
- if (this._syncActions) {
682
- for (const [otherWs, otherClient] of this.clients) {
683
- if (otherWs === ws || !otherClient.helloReceived) continue;
684
- otherClient.moduleInstance.updateState(stateMsg.state);
685
- }
686
- }
687
- break;
688
- }
689
-
690
- case "subscribeState": {
691
- clientData.stateSubscribed = true;
692
- log.info(`Client ${clientData.id} subscribed to state updates`);
693
- break;
694
- }
695
-
696
- default:
697
- // Unknown message type
698
- break;
699
- }
700
- } catch (error) {
701
- log.error("Error handling WebSocket message:", error);
702
- }
703
- }
704
-
705
- /**
706
- * Handle WebSocket close - suspend session instead of destroying
707
- */
708
- private async handleClose(ws: ServerWebSocket<unknown>) {
709
- const clientData = this.clients.get(ws);
710
- if (!clientData) return;
711
-
712
- // Clear hello timeout if still pending
713
- if (clientData.helloTimeout) {
714
- clearTimeout(clientData.helloTimeout);
715
- }
716
-
717
- // Get current state for session save
718
- const currentState = clientData.moduleInstance.getState();
719
-
720
- // Trigger onDisconnect hook
721
- if (clientData.sessionId && this._module?.handlers.onDisconnect) {
722
- const session = this.sessionManager?.getActiveSession(clientData.sessionId);
723
- if (session) {
724
- await this._module.handlers.onDisconnect({
725
- state: currentState,
726
- session,
727
- });
728
- }
729
- }
730
-
731
- // Suspend session (don't destroy immediately)
732
- if (clientData.sessionId && this.sessionManager) {
733
- this.sessionManager.untrackConnection(clientData.sessionId, ws);
734
-
735
- // Only suspend if no other connections for this session
736
- if (this.sessionManager.getConnectionCount(clientData.sessionId) === 0) {
737
- const session = this.sessionManager.getActiveSession(clientData.sessionId);
738
- if (session) {
739
- this.sessionManager.suspendSession(
740
- clientData.sessionId,
741
- currentState,
742
- async (expiredSession) => {
743
- // Trigger onExpire hook
744
- if (this._module?.handlers.onExpire) {
745
- await this._module.handlers.onExpire({ session: expiredSession });
746
- }
747
- }
748
- );
749
- }
750
- }
751
- }
752
-
753
- // Cleanup module instance
754
- await clientData.moduleInstance.destroy();
755
- this.clients.delete(ws);
756
-
757
- // Notify disconnection callbacks
758
- const client: RemoteClient = {
759
- id: clientData.id,
760
- socket: ws,
761
- connectedAt: clientData.connectedAt,
762
- };
763
- this._onDisconnectionCallbacks.forEach((cb) => cb(client));
764
- }
765
-
766
- /**
767
- * Reload components from the source directory and re-render all connected clients.
768
- * Call this when source files change to implement hot reload.
769
- *
770
- * The engine's reconciler diffs the old tree against the new one, producing
771
- * minimal patches (Create/Remove/SetProp) that are streamed to clients.
772
- */
773
- async reload(): Promise<void> {
774
- if (this._sourceDir) {
775
- // Reset _ui so discoverFromSource picks up the latest entry template
776
- this._ui = "";
777
- await this.discoverFromSource();
778
- }
779
-
780
- if (!this._ui) {
781
- log.warn("Reload skipped: no UI template available");
782
- return;
783
- }
784
-
785
- // Re-render all connected clients — reconciler diffs old vs new tree
786
- for (const [ws, clientData] of this.clients) {
787
- if (!clientData.helloReceived) continue;
788
-
789
- // Update component resolver with newly discovered components
790
- this.setupComponentResolver(clientData.engine);
791
-
792
- try {
793
- clientData.engine.renderSource(this._ui);
794
- log.info(`Hot-reloaded client ${clientData.id}`);
795
- } catch (e) {
796
- log.error(`Failed to hot-reload client ${clientData.id}:`, e);
797
- }
798
- }
799
- }
800
-
801
- /**
802
- * Get current client count
803
- */
804
- getClientCount(): number {
805
- return this.clients.size;
806
- }
807
-
808
- /**
809
- * Get session stats
810
- */
811
- getSessionStats(): {
812
- activeSessions: number;
813
- pendingSessions: number;
814
- totalConnections: number;
815
- } {
816
- return this.sessionManager?.getStats() ?? {
817
- activeSessions: 0,
818
- pendingSessions: 0,
819
- totalConnections: 0,
820
- };
821
- }
822
-
823
- /**
824
- * Broadcast a message to all connected clients
825
- */
826
- broadcast(message: RemoteMessage): void {
827
- const json = JSON.stringify(message);
828
- this.clients.forEach((_, ws) => {
829
- ws.send(json);
830
- });
831
- }
832
- }
833
-
834
- /**
835
- * Convenience function to create and start a RemoteServer
836
- */
837
- export async function serve(options: {
838
- module: HypenModule<any>;
839
- moduleName?: string;
840
- ui?: string;
841
- source?: string;
842
- port?: number;
843
- hostname?: string;
844
- session?: SessionConfig;
845
- onConnection?: (client: RemoteClient) => void;
846
- onDisconnection?: (client: RemoteClient) => void;
847
- }): Promise<RemoteServer> {
848
- const server = new RemoteServer()
849
- .module(options.moduleName ?? "App", options.module);
850
-
851
- if (options.source) {
852
- server.source(options.source);
853
- }
854
-
855
- if (options.ui) {
856
- server.ui(options.ui);
857
- }
858
-
859
- if (options.port || options.hostname) {
860
- server.config({
861
- port: options.port,
862
- hostname: options.hostname,
863
- });
864
- }
865
-
866
- if (options.session) {
867
- server.session(options.session);
868
- }
869
-
870
- if (options.onConnection) {
871
- server.onConnection(options.onConnection);
872
- }
873
-
874
- if (options.onDisconnection) {
875
- server.onDisconnection(options.onDisconnection);
876
- }
877
-
878
- return server.listen(options.port);
879
- }