@hypen-space/core 0.2.12 → 0.3.1

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 (49) hide show
  1. package/README.md +182 -11
  2. package/dist/src/app.js +470 -44
  3. package/dist/src/app.js.map +7 -5
  4. package/dist/src/components/builtin.js +470 -44
  5. package/dist/src/components/builtin.js.map +7 -5
  6. package/dist/src/discovery.js +559 -65
  7. package/dist/src/discovery.js.map +8 -6
  8. package/dist/src/engine.js +18 -9
  9. package/dist/src/engine.js.map +3 -3
  10. package/dist/src/index.browser.js +870 -81
  11. package/dist/src/index.browser.js.map +11 -7
  12. package/dist/src/index.js +1591 -125
  13. package/dist/src/index.js.map +17 -10
  14. package/dist/src/plugin.js +2 -2
  15. package/dist/src/plugin.js.map +2 -2
  16. package/dist/src/remote/client.js +525 -35
  17. package/dist/src/remote/client.js.map +7 -4
  18. package/dist/src/remote/index.js +1796 -35
  19. package/dist/src/remote/index.js.map +13 -4
  20. package/dist/src/router.js +55 -29
  21. package/dist/src/router.js.map +3 -3
  22. package/dist/src/state.js +57 -29
  23. package/dist/src/state.js.map +3 -3
  24. package/package.json +8 -2
  25. package/src/app.ts +292 -13
  26. package/src/discovery.ts +123 -18
  27. package/src/disposable.ts +281 -0
  28. package/src/engine.ts +29 -10
  29. package/src/hypen.ts +209 -0
  30. package/src/index.browser.ts +17 -1
  31. package/src/index.ts +148 -12
  32. package/src/logger.ts +338 -0
  33. package/src/plugin.ts +1 -1
  34. package/src/remote/client.ts +263 -56
  35. package/src/remote/index.ts +25 -1
  36. package/src/remote/server.ts +652 -0
  37. package/src/remote/session.ts +256 -0
  38. package/src/remote/types.ts +68 -1
  39. package/src/result.ts +260 -0
  40. package/src/retry.ts +306 -0
  41. package/src/state.ts +103 -45
  42. package/wasm-browser/README.md +4 -0
  43. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  44. package/wasm-browser/package.json +1 -1
  45. package/wasm-node/README.md +4 -0
  46. package/wasm-node/hypen_engine_bg.wasm +0 -0
  47. package/wasm-node/package.json +1 -1
  48. package/wasm-browser/hypen_engine_bg.js +0 -736
  49. package/wasm-node/hypen_engine_bg.js +0 -736
@@ -0,0 +1,652 @@
1
+ /**
2
+ * RemoteServer - Stream Hypen apps over WebSocket with Session Management
3
+ *
4
+ * Usage:
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
+ * .onDisconnect(async ({ state, session }) => {
12
+ * await redis.set(`session:${session.id}`, JSON.stringify(state));
13
+ * })
14
+ * .onReconnect(async ({ session, restore }) => {
15
+ * const saved = await redis.get(`session:${session.id}`);
16
+ * if (saved) restore(JSON.parse(saved));
17
+ * })
18
+ * .onExpire(async ({ session }) => {
19
+ * await redis.del(`session:${session.id}`);
20
+ * })
21
+ * .build();
22
+ *
23
+ * new RemoteServer()
24
+ * .module("Counter", counter)
25
+ * .ui(`Column { Text("Count: \${state.count}") }`)
26
+ * .session({ ttl: 3600, concurrent: "kick-old" })
27
+ * .listen(3000);
28
+ * ```
29
+ */
30
+
31
+ import { Engine } from "../engine.js";
32
+ import { HypenModuleInstance } from "../app.js";
33
+ import type { HypenModule } from "../app.js";
34
+ import type {
35
+ RemoteMessage,
36
+ RemoteClient,
37
+ RemoteServerConfig,
38
+ InitialTreeMessage,
39
+ PatchMessage,
40
+ DispatchActionMessage,
41
+ HelloMessage,
42
+ SessionAckMessage,
43
+ SessionExpiredMessage,
44
+ Session,
45
+ SessionConfig,
46
+ } from "./types.js";
47
+ import type { Patch } from "../engine.js";
48
+ import type { ServerWebSocket } from "bun";
49
+ import { SessionManager } from "./session.js";
50
+
51
+ interface ClientData {
52
+ id: string;
53
+ engine: Engine;
54
+ moduleInstance: HypenModuleInstance<any>;
55
+ revision: number;
56
+ connectedAt: Date;
57
+ /** Session ID for this client */
58
+ sessionId: string;
59
+ /** Whether we've received the hello message */
60
+ helloReceived: boolean;
61
+ /** Timeout for legacy clients that don't send hello */
62
+ helloTimeout?: ReturnType<typeof setTimeout>;
63
+ }
64
+
65
+ /**
66
+ * Builder pattern for hosting Hypen apps over WebSocket
67
+ */
68
+ export class RemoteServer {
69
+ private _module: HypenModule<any> | null = null;
70
+ private _moduleName: string = "App";
71
+ private _ui: string = "";
72
+ private _config: RemoteServerConfig = {};
73
+ private _sessionConfig: SessionConfig = {};
74
+ private _onConnectionCallbacks: Array<(client: RemoteClient) => void> = [];
75
+ private _onDisconnectionCallbacks: Array<(client: RemoteClient) => void> = [];
76
+ private clients = new Map<ServerWebSocket<unknown>, ClientData>();
77
+ private nextClientId = 1;
78
+ private server: ReturnType<typeof Bun.serve> | null = null;
79
+ private sessionManager: SessionManager | null = null;
80
+
81
+ /**
82
+ * Set the module for this app
83
+ */
84
+ module(name: string, module: HypenModule<any>): this {
85
+ this._moduleName = name;
86
+ this._module = module;
87
+ return this;
88
+ }
89
+
90
+ /**
91
+ * Set the UI DSL string
92
+ */
93
+ ui(dsl: string): this {
94
+ this._ui = dsl;
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Set server configuration
100
+ */
101
+ config(config: RemoteServerConfig): this {
102
+ this._config = { ...this._config, ...config };
103
+ return this;
104
+ }
105
+
106
+ /**
107
+ * Configure session management
108
+ */
109
+ session(config: SessionConfig): this {
110
+ this._sessionConfig = config;
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * Register connection callback
116
+ */
117
+ onConnection(callback: (client: RemoteClient) => void): this {
118
+ this._onConnectionCallbacks.push(callback);
119
+ return this;
120
+ }
121
+
122
+ /**
123
+ * Register disconnection callback
124
+ */
125
+ onDisconnection(callback: (client: RemoteClient) => void): this {
126
+ this._onDisconnectionCallbacks.push(callback);
127
+ return this;
128
+ }
129
+
130
+ /**
131
+ * Start the WebSocket server
132
+ */
133
+ listen(port?: number): this {
134
+ if (!this._module) {
135
+ throw new Error("Module not set. Call .module() before .listen()");
136
+ }
137
+
138
+ if (!this._ui) {
139
+ throw new Error("UI not set. Call .ui() before .listen()");
140
+ }
141
+
142
+ // Initialize session manager
143
+ this.sessionManager = new SessionManager(this._sessionConfig);
144
+
145
+ const finalPort = port ?? this._config.port ?? 3000;
146
+ const hostname = this._config.hostname ?? "0.0.0.0";
147
+
148
+ this.server = Bun.serve({
149
+ port: finalPort,
150
+ hostname,
151
+ websocket: {
152
+ open: (ws) => this.handleOpen(ws),
153
+ message: (ws, message) => this.handleMessage(ws, message),
154
+ close: (ws) => this.handleClose(ws),
155
+ },
156
+ fetch: (req, server) => {
157
+ const url = new URL(req.url);
158
+
159
+ // Upgrade to WebSocket
160
+ if (server.upgrade(req, { data: undefined })) {
161
+ return; // Connection upgraded
162
+ }
163
+
164
+ // Health check endpoint
165
+ if (url.pathname === "/health") {
166
+ return new Response("OK", { status: 200 });
167
+ }
168
+
169
+ // Stats endpoint
170
+ if (url.pathname === "/stats") {
171
+ const stats = this.sessionManager?.getStats() ?? {
172
+ activeSessions: 0,
173
+ pendingSessions: 0,
174
+ totalConnections: 0,
175
+ };
176
+ return new Response(JSON.stringify(stats), {
177
+ headers: { "Content-Type": "application/json" },
178
+ });
179
+ }
180
+
181
+ return new Response("Hypen Remote Server", { status: 200 });
182
+ },
183
+ });
184
+
185
+ console.log(`🚀 Hypen app streaming on ws://${hostname}:${finalPort}`);
186
+
187
+ return this;
188
+ }
189
+
190
+ /**
191
+ * Stop the server
192
+ */
193
+ stop(): void {
194
+ if (this.server) {
195
+ this.server.stop();
196
+ this.server = null;
197
+ }
198
+ if (this.sessionManager) {
199
+ this.sessionManager.destroy();
200
+ this.sessionManager = null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Get the server URL
206
+ */
207
+ get url(): string | null {
208
+ if (!this.server) return null;
209
+ const hostname = this._config.hostname ?? "localhost";
210
+ const port = this._config.port ?? 3000;
211
+ return `ws://${hostname}:${port}`;
212
+ }
213
+
214
+ /**
215
+ * Handle new WebSocket connection
216
+ * Waits for hello message before fully initializing
217
+ */
218
+ private async handleOpen(ws: ServerWebSocket<unknown>) {
219
+ try {
220
+ const clientId = `client_${this.nextClientId++}`;
221
+ const connectedAt = new Date();
222
+
223
+ // Create engine instance for this client
224
+ const engine = new Engine();
225
+ await engine.init();
226
+
227
+ // Create module instance
228
+ const moduleInstance = new HypenModuleInstance(engine, this._module!);
229
+
230
+ // Store client data (session will be set when hello is received)
231
+ const clientData: ClientData = {
232
+ id: clientId,
233
+ engine,
234
+ moduleInstance,
235
+ revision: 0,
236
+ connectedAt,
237
+ sessionId: "",
238
+ helloReceived: false,
239
+ };
240
+
241
+ this.clients.set(ws, clientData);
242
+
243
+ // Set timeout for legacy clients that don't send hello
244
+ clientData.helloTimeout = setTimeout(() => {
245
+ if (!clientData.helloReceived) {
246
+ // Legacy client - create new session automatically
247
+ this.initializeSession(ws, clientData, undefined, undefined);
248
+ }
249
+ }, 1000); // 1 second grace period
250
+
251
+ } catch (error) {
252
+ console.error("Error handling WebSocket open:", error);
253
+ ws.close(1011, "Internal server error");
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Initialize session for a client (new or resumed)
259
+ */
260
+ private async initializeSession(
261
+ ws: ServerWebSocket<unknown>,
262
+ clientData: ClientData,
263
+ requestedSessionId: string | undefined,
264
+ props: Record<string, any> | undefined
265
+ ): Promise<void> {
266
+ if (clientData.helloReceived) return;
267
+ clientData.helloReceived = true;
268
+
269
+ // Clear hello timeout
270
+ if (clientData.helloTimeout) {
271
+ clearTimeout(clientData.helloTimeout);
272
+ clientData.helloTimeout = undefined;
273
+ }
274
+
275
+ let session: Session;
276
+ let isNew = true;
277
+ let isRestored = false;
278
+ let restoredState: unknown = null;
279
+
280
+ if (requestedSessionId && this.sessionManager) {
281
+ // Try to resume pending session
282
+ const resumed = this.sessionManager.resumeSession(requestedSessionId);
283
+ if (resumed) {
284
+ session = resumed.session;
285
+ restoredState = resumed.savedState;
286
+ isNew = false;
287
+ isRestored = true;
288
+
289
+ // Call onReconnect hook
290
+ await this.triggerReconnect(clientData, session, restoredState);
291
+ } else {
292
+ // Check for concurrent active session
293
+ const activeSession = this.sessionManager.getActiveSession(requestedSessionId);
294
+ if (activeSession) {
295
+ const handled = await this.handleConcurrentConnection(
296
+ ws,
297
+ clientData,
298
+ activeSession,
299
+ props
300
+ );
301
+ if (!handled) return; // Connection was rejected
302
+ session = activeSession;
303
+ isNew = false;
304
+ } else {
305
+ // Session not found, create new
306
+ session = this.sessionManager.createSession(props);
307
+ }
308
+ }
309
+ } else if (this.sessionManager) {
310
+ // New session
311
+ session = this.sessionManager.createSession(props);
312
+ } else {
313
+ // No session manager (shouldn't happen, but fallback)
314
+ session = {
315
+ id: crypto.randomUUID(),
316
+ ttl: 3600,
317
+ createdAt: new Date(),
318
+ lastConnectedAt: new Date(),
319
+ props,
320
+ };
321
+ }
322
+
323
+ clientData.sessionId = session.id;
324
+ this.sessionManager?.trackConnection(session.id, ws);
325
+
326
+ // Send session acknowledgment
327
+ const sessionAck: SessionAckMessage = {
328
+ type: "sessionAck",
329
+ sessionId: session.id,
330
+ isNew,
331
+ isRestored,
332
+ };
333
+ ws.send(JSON.stringify(sessionAck));
334
+
335
+ // Set up render callback
336
+ this.setupRenderCallback(ws, clientData);
337
+
338
+ // Render initial tree
339
+ const initialPatches: Patch[] = [];
340
+ clientData.engine.setRenderCallback((patches) => {
341
+ initialPatches.push(...patches);
342
+ });
343
+ clientData.engine.renderSource(this._ui);
344
+
345
+ // Now set up streaming render callback
346
+ this.setupRenderCallback(ws, clientData);
347
+
348
+ // Send initial tree
349
+ const initialMessage: InitialTreeMessage = {
350
+ type: "initialTree",
351
+ module: this._moduleName,
352
+ state: clientData.moduleInstance.getState(),
353
+ patches: initialPatches,
354
+ revision: 0,
355
+ };
356
+ ws.send(JSON.stringify(initialMessage));
357
+
358
+ // Notify connection callbacks
359
+ const client: RemoteClient = {
360
+ id: clientData.id,
361
+ socket: ws,
362
+ connectedAt: clientData.connectedAt,
363
+ };
364
+ this._onConnectionCallbacks.forEach((cb) => cb(client));
365
+ }
366
+
367
+ /**
368
+ * Set up the render callback for streaming patches
369
+ */
370
+ private setupRenderCallback(
371
+ ws: ServerWebSocket<unknown>,
372
+ clientData: ClientData
373
+ ): void {
374
+ clientData.engine.setRenderCallback((patches) => {
375
+ const data = this.clients.get(ws);
376
+ if (!data) return;
377
+
378
+ data.revision++;
379
+
380
+ const message: PatchMessage = {
381
+ type: "patch",
382
+ module: this._moduleName,
383
+ patches,
384
+ revision: data.revision,
385
+ };
386
+
387
+ ws.send(JSON.stringify(message));
388
+
389
+ // If allow-multiple, broadcast to other connections on same session
390
+ if (
391
+ this.sessionManager?.getConcurrentPolicy() === "allow-multiple" &&
392
+ data.sessionId
393
+ ) {
394
+ const connections = this.sessionManager.getConnections(data.sessionId);
395
+ if (connections) {
396
+ for (const conn of connections) {
397
+ if (conn !== ws) {
398
+ (conn as ServerWebSocket<unknown>).send(JSON.stringify(message));
399
+ }
400
+ }
401
+ }
402
+ }
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Handle concurrent connection based on policy
408
+ * Returns true if connection is allowed, false if rejected
409
+ */
410
+ private async handleConcurrentConnection(
411
+ ws: ServerWebSocket<unknown>,
412
+ clientData: ClientData,
413
+ existingSession: Session,
414
+ props: Record<string, any> | undefined
415
+ ): Promise<boolean> {
416
+ const policy = this.sessionManager?.getConcurrentPolicy() ?? "kick-old";
417
+
418
+ switch (policy) {
419
+ case "kick-old": {
420
+ // Kick existing connections
421
+ const existingConnections = this.sessionManager?.getConnections(
422
+ existingSession.id
423
+ );
424
+ if (existingConnections) {
425
+ for (const conn of existingConnections) {
426
+ const oldWs = conn as ServerWebSocket<unknown>;
427
+ const expiredMsg: SessionExpiredMessage = {
428
+ type: "sessionExpired",
429
+ sessionId: existingSession.id,
430
+ reason: "kicked",
431
+ };
432
+ oldWs.send(JSON.stringify(expiredMsg));
433
+ oldWs.close(1000, "Session taken by new connection");
434
+ }
435
+ }
436
+ return true;
437
+ }
438
+
439
+ case "reject-new": {
440
+ // Reject this connection
441
+ const expiredMsg: SessionExpiredMessage = {
442
+ type: "sessionExpired",
443
+ sessionId: existingSession.id,
444
+ reason: "kicked",
445
+ };
446
+ ws.send(JSON.stringify(expiredMsg));
447
+ ws.close(1000, "Session already active");
448
+ return false;
449
+ }
450
+
451
+ case "allow-multiple": {
452
+ // Allow both connections
453
+ return true;
454
+ }
455
+
456
+ default:
457
+ return true;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Trigger onReconnect hook
463
+ */
464
+ private async triggerReconnect(
465
+ clientData: ClientData,
466
+ session: Session,
467
+ savedState: unknown
468
+ ): Promise<void> {
469
+ const handler = this._module?.handlers.onReconnect;
470
+ if (!handler) return;
471
+
472
+ let restored = false;
473
+ const restore = (state: unknown) => {
474
+ restored = true;
475
+ clientData.moduleInstance.updateState(state as any);
476
+ };
477
+
478
+ await handler({ session, restore });
479
+ }
480
+
481
+ /**
482
+ * Handle incoming WebSocket message
483
+ */
484
+ private handleMessage(
485
+ ws: ServerWebSocket<unknown>,
486
+ message: string | Buffer
487
+ ) {
488
+ try {
489
+ const clientData = this.clients.get(ws);
490
+ if (!clientData) return;
491
+
492
+ const msg = JSON.parse(message.toString()) as RemoteMessage;
493
+
494
+ switch (msg.type) {
495
+ case "hello": {
496
+ const helloMsg = msg as HelloMessage;
497
+ this.initializeSession(
498
+ ws,
499
+ clientData,
500
+ helloMsg.sessionId,
501
+ helloMsg.props
502
+ );
503
+ break;
504
+ }
505
+
506
+ case "dispatchAction": {
507
+ const actionMsg = msg as DispatchActionMessage;
508
+ clientData.engine.dispatchAction(actionMsg.action, actionMsg.payload);
509
+ break;
510
+ }
511
+
512
+ default:
513
+ // Unknown message type
514
+ break;
515
+ }
516
+ } catch (error) {
517
+ console.error("Error handling WebSocket message:", error);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Handle WebSocket close - suspend session instead of destroying
523
+ */
524
+ private async handleClose(ws: ServerWebSocket<unknown>) {
525
+ const clientData = this.clients.get(ws);
526
+ if (!clientData) return;
527
+
528
+ // Clear hello timeout if still pending
529
+ if (clientData.helloTimeout) {
530
+ clearTimeout(clientData.helloTimeout);
531
+ }
532
+
533
+ // Get current state for session save
534
+ const currentState = clientData.moduleInstance.getState();
535
+
536
+ // Trigger onDisconnect hook
537
+ if (clientData.sessionId && this._module?.handlers.onDisconnect) {
538
+ const session = this.sessionManager?.getActiveSession(clientData.sessionId);
539
+ if (session) {
540
+ await this._module.handlers.onDisconnect({
541
+ state: currentState,
542
+ session,
543
+ });
544
+ }
545
+ }
546
+
547
+ // Suspend session (don't destroy immediately)
548
+ if (clientData.sessionId && this.sessionManager) {
549
+ this.sessionManager.untrackConnection(clientData.sessionId, ws);
550
+
551
+ // Only suspend if no other connections for this session
552
+ if (this.sessionManager.getConnectionCount(clientData.sessionId) === 0) {
553
+ const session = this.sessionManager.getActiveSession(clientData.sessionId);
554
+ if (session) {
555
+ this.sessionManager.suspendSession(
556
+ clientData.sessionId,
557
+ currentState,
558
+ async (expiredSession) => {
559
+ // Trigger onExpire hook
560
+ if (this._module?.handlers.onExpire) {
561
+ await this._module.handlers.onExpire({ session: expiredSession });
562
+ }
563
+ }
564
+ );
565
+ }
566
+ }
567
+ }
568
+
569
+ // Cleanup module instance
570
+ await clientData.moduleInstance.destroy();
571
+ this.clients.delete(ws);
572
+
573
+ // Notify disconnection callbacks
574
+ const client: RemoteClient = {
575
+ id: clientData.id,
576
+ socket: ws,
577
+ connectedAt: clientData.connectedAt,
578
+ };
579
+ this._onDisconnectionCallbacks.forEach((cb) => cb(client));
580
+ }
581
+
582
+ /**
583
+ * Get current client count
584
+ */
585
+ getClientCount(): number {
586
+ return this.clients.size;
587
+ }
588
+
589
+ /**
590
+ * Get session stats
591
+ */
592
+ getSessionStats(): {
593
+ activeSessions: number;
594
+ pendingSessions: number;
595
+ totalConnections: number;
596
+ } {
597
+ return this.sessionManager?.getStats() ?? {
598
+ activeSessions: 0,
599
+ pendingSessions: 0,
600
+ totalConnections: 0,
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Broadcast a message to all connected clients
606
+ */
607
+ broadcast(message: RemoteMessage): void {
608
+ const json = JSON.stringify(message);
609
+ this.clients.forEach((_, ws) => {
610
+ ws.send(json);
611
+ });
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Convenience function to create and start a RemoteServer
617
+ */
618
+ export function serve(options: {
619
+ module: HypenModule<any>;
620
+ moduleName?: string;
621
+ ui: string;
622
+ port?: number;
623
+ hostname?: string;
624
+ session?: SessionConfig;
625
+ onConnection?: (client: RemoteClient) => void;
626
+ onDisconnection?: (client: RemoteClient) => void;
627
+ }): RemoteServer {
628
+ const server = new RemoteServer()
629
+ .module(options.moduleName ?? "App", options.module)
630
+ .ui(options.ui);
631
+
632
+ if (options.port || options.hostname) {
633
+ server.config({
634
+ port: options.port,
635
+ hostname: options.hostname,
636
+ });
637
+ }
638
+
639
+ if (options.session) {
640
+ server.session(options.session);
641
+ }
642
+
643
+ if (options.onConnection) {
644
+ server.onConnection(options.onConnection);
645
+ }
646
+
647
+ if (options.onDisconnection) {
648
+ server.onDisconnection(options.onDisconnection);
649
+ }
650
+
651
+ return server.listen(options.port);
652
+ }