@hypen-space/core 0.2.12 → 0.3.0

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 (45) 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 +862 -81
  11. package/dist/src/index.browser.js.map +10 -6
  12. package/dist/src/index.js +1590 -124
  13. package/dist/src/index.js.map +16 -9
  14. package/dist/src/remote/client.js +525 -35
  15. package/dist/src/remote/client.js.map +7 -4
  16. package/dist/src/remote/index.js +1796 -35
  17. package/dist/src/remote/index.js.map +13 -4
  18. package/dist/src/router.js +55 -29
  19. package/dist/src/router.js.map +3 -3
  20. package/dist/src/state.js +57 -29
  21. package/dist/src/state.js.map +3 -3
  22. package/package.json +8 -2
  23. package/src/app.ts +292 -13
  24. package/src/discovery.ts +123 -18
  25. package/src/disposable.ts +281 -0
  26. package/src/engine.ts +29 -10
  27. package/src/hypen.ts +209 -0
  28. package/src/index.ts +147 -11
  29. package/src/logger.ts +338 -0
  30. package/src/remote/client.ts +263 -56
  31. package/src/remote/index.ts +25 -1
  32. package/src/remote/server.ts +652 -0
  33. package/src/remote/session.ts +256 -0
  34. package/src/remote/types.ts +68 -1
  35. package/src/result.ts +260 -0
  36. package/src/retry.ts +306 -0
  37. package/src/state.ts +103 -45
  38. package/wasm-browser/README.md +4 -0
  39. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  40. package/wasm-browser/package.json +1 -1
  41. package/wasm-node/README.md +4 -0
  42. package/wasm-node/hypen_engine_bg.wasm +0 -0
  43. package/wasm-node/package.json +1 -1
  44. package/wasm-browser/hypen_engine_bg.js +0 -736
  45. package/wasm-node/hypen_engine_bg.js +0 -736
@@ -1,6 +1,30 @@
1
1
  /**
2
2
  * RemoteEngine - Connect to a remote Hypen app over WebSocket
3
3
  * Platform-agnostic client (uses standard WebSocket API)
4
+ *
5
+ * Usage:
6
+ * ```typescript
7
+ * const engine = new RemoteEngine("ws://localhost:3000", {
8
+ * session: {
9
+ * id: localStorage.getItem("sessionId") ?? undefined,
10
+ * props: { platform: "web", version: "1.0" }
11
+ * }
12
+ * });
13
+ *
14
+ * engine.onSessionEstablished(({ sessionId, isNew, isRestored }) => {
15
+ * localStorage.setItem("sessionId", sessionId);
16
+ * console.log(isRestored ? "Welcome back!" : "New session");
17
+ * });
18
+ *
19
+ * engine.onSessionExpired((reason) => {
20
+ * localStorage.removeItem("sessionId");
21
+ * });
22
+ *
23
+ * const result = await engine.connect();
24
+ * if (!result.ok) {
25
+ * console.error("Connection failed:", result.error);
26
+ * }
27
+ * ```
4
28
  */
5
29
 
6
30
  import type {
@@ -8,37 +32,95 @@ import type {
8
32
  InitialTreeMessage,
9
33
  PatchMessage,
10
34
  DispatchActionMessage,
35
+ HelloMessage,
36
+ SessionAckMessage,
37
+ SessionExpiredMessage,
11
38
  } from "./types.js";
12
39
  import type { Patch } from "../engine.js";
40
+ import {
41
+ type Result,
42
+ Ok,
43
+ Err,
44
+ ConnectionError,
45
+ } from "../result.js";
46
+ import {
47
+ type Disposable,
48
+ DisposableStack,
49
+ disposableTimeout,
50
+ disposableWebSocket,
51
+ disposableListener,
52
+ } from "../disposable.js";
53
+ import { retry, type RetryOptions } from "../retry.js";
54
+
55
+ export type RemoteConnectionState =
56
+ | "disconnected"
57
+ | "connecting"
58
+ | "connected"
59
+ | "error";
13
60
 
14
- export type RemoteConnectionState = "disconnected" | "connecting" | "connected" | "error";
61
+ /**
62
+ * Session configuration for the client
63
+ */
64
+ export interface SessionOptions {
65
+ /** Session ID to resume (omit for new session) */
66
+ id?: string;
67
+ /** Client metadata (platform, version, userId, etc.) */
68
+ props?: Record<string, unknown>;
69
+ }
70
+
71
+ /**
72
+ * Session information received from server
73
+ */
74
+ export interface SessionInfo {
75
+ sessionId: string;
76
+ isNew: boolean;
77
+ isRestored: boolean;
78
+ }
15
79
 
16
80
  export interface RemoteEngineOptions {
17
81
  autoReconnect?: boolean;
18
82
  reconnectInterval?: number;
19
83
  maxReconnectAttempts?: number;
84
+ /** Session configuration */
85
+ session?: SessionOptions;
86
+ }
87
+
88
+ interface RequiredOptions {
89
+ autoReconnect: boolean;
90
+ reconnectInterval: number;
91
+ maxReconnectAttempts: number;
92
+ session?: SessionOptions;
20
93
  }
21
94
 
22
95
  /**
23
96
  * Client-side engine that connects to a remote Hypen app
24
97
  */
25
- export class RemoteEngine {
98
+ export class RemoteEngine implements Disposable {
26
99
  private ws: WebSocket | null = null;
27
- private url: string;
100
+ private readonly url: string;
28
101
  private state: RemoteConnectionState = "disconnected";
29
- private options: Required<RemoteEngineOptions>;
102
+ private readonly options: RequiredOptions;
30
103
  private reconnectAttempts = 0;
31
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
104
+
105
+ // Resource management
106
+ private readonly disposables = new DisposableStack();
107
+ private reconnectDisposable: Disposable | null = null;
108
+
109
+ // Session state
110
+ private currentSessionId: string | null = null;
111
+ private readonly sessionOptions?: SessionOptions;
32
112
 
33
113
  // Callbacks
34
- private patchCallbacks: Array<(patches: Patch[]) => void> = [];
35
- private stateCallbacks: Array<(state: any) => void> = [];
36
- private connectionCallbacks: Array<() => void> = [];
37
- private disconnectionCallbacks: Array<() => void> = [];
38
- private errorCallbacks: Array<(error: Error) => void> = [];
114
+ private readonly patchCallbacks: Array<(patches: Patch[]) => void> = [];
115
+ private readonly stateCallbacks: Array<(state: unknown) => void> = [];
116
+ private readonly connectionCallbacks: Array<() => void> = [];
117
+ private readonly disconnectionCallbacks: Array<() => void> = [];
118
+ private readonly errorCallbacks: Array<(error: Error) => void> = [];
119
+ private readonly sessionEstablishedCallbacks: Array<(info: SessionInfo) => void> = [];
120
+ private readonly sessionExpiredCallbacks: Array<(reason: string) => void> = [];
39
121
 
40
122
  // State
41
- private currentState: any = null;
123
+ private currentState: unknown = null;
42
124
  private currentRevision = 0;
43
125
  private moduleName: string = "";
44
126
 
@@ -48,74 +130,136 @@ export class RemoteEngine {
48
130
  autoReconnect: options.autoReconnect ?? true,
49
131
  reconnectInterval: options.reconnectInterval ?? 3000,
50
132
  maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
133
+ session: options.session,
51
134
  };
135
+ this.sessionOptions = options.session;
136
+
137
+ // If session ID was provided, use it as current
138
+ if (options.session?.id) {
139
+ this.currentSessionId = options.session.id;
140
+ }
52
141
  }
53
142
 
54
143
  /**
55
144
  * Connect to the remote server
145
+ * Returns a Result indicating success or failure
56
146
  */
57
- async connect(): Promise<void> {
147
+ async connect(): Promise<Result<void, ConnectionError>> {
58
148
  if (this.state === "connected" || this.state === "connecting") {
59
- return;
149
+ return Ok(undefined);
60
150
  }
61
151
 
62
152
  this.state = "connecting";
63
153
 
64
- return new Promise((resolve, reject) => {
154
+ return new Promise((resolve) => {
65
155
  try {
66
156
  this.ws = new WebSocket(this.url);
67
157
 
68
- this.ws.onopen = () => {
69
- this.state = "connected";
70
- this.reconnectAttempts = 0;
71
- this.connectionCallbacks.forEach((cb) => cb());
72
- resolve();
73
- };
158
+ // Track the WebSocket for cleanup
159
+ this.disposables.add(disposableWebSocket(this.ws));
74
160
 
75
- this.ws.onmessage = (event) => {
161
+ // Set up message handler
162
+ const messageHandler = (event: MessageEvent) => {
76
163
  this.handleMessage(event.data);
77
164
  };
165
+ this.disposables.add(
166
+ disposableListener(this.ws, "message", messageHandler as EventListener)
167
+ );
78
168
 
79
- this.ws.onerror = () => {
169
+ // Set up error handler
170
+ const errorHandler = () => {
80
171
  this.state = "error";
81
- const error = new Error("WebSocket error");
172
+ const error = new ConnectionError(this.url, new Error("WebSocket error"));
82
173
  this.errorCallbacks.forEach((cb) => cb(error));
83
- reject(error);
174
+ resolve(Err(error));
84
175
  };
176
+ this.disposables.add(
177
+ disposableListener(this.ws, "error", errorHandler)
178
+ );
85
179
 
86
- this.ws.onclose = () => {
180
+ // Set up close handler
181
+ const closeHandler = () => {
87
182
  this.state = "disconnected";
88
183
  this.disconnectionCallbacks.forEach((cb) => cb());
89
184
  this.attemptReconnect();
90
185
  };
91
- } catch (error) {
186
+ this.disposables.add(
187
+ disposableListener(this.ws, "close", closeHandler)
188
+ );
189
+
190
+ // Set up open handler
191
+ this.ws.onopen = () => {
192
+ this.state = "connected";
193
+ this.reconnectAttempts = 0;
194
+
195
+ // Cancel any pending reconnect
196
+ if (this.reconnectDisposable) {
197
+ this.reconnectDisposable.dispose();
198
+ this.reconnectDisposable = null;
199
+ }
200
+
201
+ // Send hello message with session info
202
+ this.sendHello();
203
+
204
+ this.connectionCallbacks.forEach((cb) => cb());
205
+ resolve(Ok(undefined));
206
+ };
207
+ } catch (e) {
92
208
  this.state = "error";
93
- reject(error);
209
+ const error = new ConnectionError(this.url, e);
210
+ resolve(Err(error));
94
211
  }
95
212
  });
96
213
  }
97
214
 
98
215
  /**
99
- * Disconnect from the remote server
216
+ * Send hello message to establish session
217
+ */
218
+ private sendHello(): void {
219
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
220
+
221
+ const hello: HelloMessage = {
222
+ type: "hello",
223
+ sessionId: this.currentSessionId ?? this.sessionOptions?.id,
224
+ props: this.sessionOptions?.props,
225
+ };
226
+
227
+ this.ws.send(JSON.stringify(hello));
228
+ }
229
+
230
+ /**
231
+ * Disconnect from the remote server and clean up resources
100
232
  */
101
233
  disconnect(): void {
102
- if (this.reconnectTimer) {
103
- clearTimeout(this.reconnectTimer);
104
- this.reconnectTimer = null;
234
+ // Cancel any pending reconnect
235
+ if (this.reconnectDisposable) {
236
+ this.reconnectDisposable.dispose();
237
+ this.reconnectDisposable = null;
105
238
  }
106
239
 
240
+ // Close WebSocket if open
107
241
  if (this.ws) {
108
- this.ws.close();
242
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
243
+ this.ws.close();
244
+ }
109
245
  this.ws = null;
110
246
  }
111
247
 
112
248
  this.state = "disconnected";
113
249
  }
114
250
 
251
+ /**
252
+ * Dispose all resources (alias for disconnect)
253
+ */
254
+ dispose(): void {
255
+ this.disconnect();
256
+ this.disposables.dispose();
257
+ }
258
+
115
259
  /**
116
260
  * Dispatch an action to the remote server
117
261
  */
118
- dispatchAction(action: string, payload?: any): void {
262
+ dispatchAction(action: string, payload?: unknown): void {
119
263
  if (this.state !== "connected" || !this.ws) {
120
264
  console.warn("Cannot dispatch action: not connected");
121
265
  return;
@@ -142,7 +286,7 @@ export class RemoteEngine {
142
286
  /**
143
287
  * Register callback for state updates
144
288
  */
145
- onStateUpdate(callback: (state: any) => void): this {
289
+ onStateUpdate(callback: (state: unknown) => void): this {
146
290
  this.stateCallbacks.push(callback);
147
291
  return this;
148
292
  }
@@ -171,6 +315,24 @@ export class RemoteEngine {
171
315
  return this;
172
316
  }
173
317
 
318
+ /**
319
+ * Register callback for session establishment
320
+ * Called when server confirms session (new or resumed)
321
+ */
322
+ onSessionEstablished(callback: (info: SessionInfo) => void): this {
323
+ this.sessionEstablishedCallbacks.push(callback);
324
+ return this;
325
+ }
326
+
327
+ /**
328
+ * Register callback for session expiration
329
+ * Called when session is kicked or expires
330
+ */
331
+ onSessionExpired(callback: (reason: string) => void): this {
332
+ this.sessionExpiredCallbacks.push(callback);
333
+ return this;
334
+ }
335
+
174
336
  /**
175
337
  * Get current connection state
176
338
  */
@@ -181,7 +343,7 @@ export class RemoteEngine {
181
343
  /**
182
344
  * Get current app state
183
345
  */
184
- getCurrentState(): any {
346
+ getCurrentState(): unknown {
185
347
  return this.currentState;
186
348
  }
187
349
 
@@ -192,11 +354,26 @@ export class RemoteEngine {
192
354
  return this.currentRevision;
193
355
  }
194
356
 
357
+ /**
358
+ * Get current session ID
359
+ */
360
+ getSessionId(): string | null {
361
+ return this.currentSessionId;
362
+ }
363
+
195
364
  private handleMessage(data: string): void {
196
365
  try {
197
366
  const message = JSON.parse(data) as RemoteMessage;
198
367
 
199
368
  switch (message.type) {
369
+ case "sessionAck":
370
+ this.handleSessionAck(message as SessionAckMessage);
371
+ break;
372
+
373
+ case "sessionExpired":
374
+ this.handleSessionExpired(message as SessionExpiredMessage);
375
+ break;
376
+
200
377
  case "initialTree":
201
378
  this.handleInitialTree(message as InitialTreeMessage);
202
379
  break;
@@ -206,18 +383,34 @@ export class RemoteEngine {
206
383
  break;
207
384
 
208
385
  case "stateUpdate":
209
- this.currentState = message.state;
210
- this.stateCallbacks.forEach((cb) => cb(message.state));
386
+ this.currentState = (message as { state: unknown }).state;
387
+ this.stateCallbacks.forEach((cb) => cb(this.currentState));
211
388
  break;
212
389
  }
213
- } catch (error) {
214
- console.error("Error handling remote message:", error);
215
- this.errorCallbacks.forEach((cb) =>
216
- cb(error instanceof Error ? error : new Error(String(error)))
217
- );
390
+ } catch (e) {
391
+ console.error("Error handling remote message:", e);
392
+ const error = e instanceof Error ? e : new Error(String(e));
393
+ this.errorCallbacks.forEach((cb) => cb(error));
218
394
  }
219
395
  }
220
396
 
397
+ private handleSessionAck(message: SessionAckMessage): void {
398
+ this.currentSessionId = message.sessionId;
399
+
400
+ const info: SessionInfo = {
401
+ sessionId: message.sessionId,
402
+ isNew: message.isNew,
403
+ isRestored: message.isRestored,
404
+ };
405
+
406
+ this.sessionEstablishedCallbacks.forEach((cb) => cb(info));
407
+ }
408
+
409
+ private handleSessionExpired(message: SessionExpiredMessage): void {
410
+ this.currentSessionId = null;
411
+ this.sessionExpiredCallbacks.forEach((cb) => cb(message.reason));
412
+ }
413
+
221
414
  private handleInitialTree(message: InitialTreeMessage): void {
222
415
  this.moduleName = message.module;
223
416
  this.currentState = message.state;
@@ -254,20 +447,34 @@ export class RemoteEngine {
254
447
  return;
255
448
  }
256
449
 
257
- if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
258
- console.error("Max reconnection attempts reached");
259
- return;
260
- }
261
-
262
- this.reconnectAttempts++;
263
-
264
- console.log(
265
- `Attempting to reconnect (${this.reconnectAttempts}/${this.options.maxReconnectAttempts})...`
266
- );
267
-
268
- this.reconnectTimer = setTimeout(() => {
269
- this.connect().catch((error) => {
270
- console.error("Reconnection failed:", error);
450
+ // Use disposable timeout to start reconnection after initial delay
451
+ this.reconnectDisposable = disposableTimeout(() => {
452
+ this.reconnectDisposable = null;
453
+
454
+ retry(
455
+ async () => {
456
+ const result = await this.connect();
457
+ if (!result.ok) {
458
+ throw result.error;
459
+ }
460
+ },
461
+ {
462
+ maxAttempts: this.options.maxReconnectAttempts,
463
+ delayMs: this.options.reconnectInterval,
464
+ backoff: "exponential",
465
+ maxDelayMs: 30000,
466
+ jitter: 0.1,
467
+ onRetry: (attempt, error) => {
468
+ console.log(
469
+ `Reconnection attempt ${attempt}/${this.options.maxReconnectAttempts} failed: ${error.message}`
470
+ );
471
+ },
472
+ }
473
+ ).catch((error) => {
474
+ console.error("Max reconnection attempts reached:", error.message);
475
+ this.errorCallbacks.forEach((cb) =>
476
+ cb(new ConnectionError(this.url, error, this.options.maxReconnectAttempts))
477
+ );
271
478
  });
272
479
  }, this.options.reconnectInterval);
273
480
  }
@@ -2,16 +2,40 @@
2
2
  * Remote UI streaming for Hypen
3
3
  *
4
4
  * Client-side: Connect to remote Hypen apps
5
+ * Server-side: Stream Hypen apps over WebSocket
5
6
  */
6
7
 
8
+ // Client
7
9
  export { RemoteEngine } from "./client.js";
10
+ export type {
11
+ RemoteConnectionState,
12
+ RemoteEngineOptions,
13
+ SessionOptions,
14
+ SessionInfo,
15
+ } from "./client.js";
16
+
17
+ // Server
18
+ export { RemoteServer, serve } from "./server.js";
19
+
20
+ // Session Management
21
+ export { SessionManager } from "./session.js";
22
+ export type { SessionExpireCallback } from "./session.js";
23
+
24
+ // Types
8
25
  export type {
9
26
  RemoteMessage,
10
27
  InitialTreeMessage,
11
28
  PatchMessage,
12
29
  DispatchActionMessage,
13
30
  StateUpdateMessage,
31
+ HelloMessage,
32
+ SessionAckMessage,
33
+ SessionExpiredMessage,
34
+ Session,
35
+ SessionConfig,
14
36
  RemoteClient,
15
37
  RemoteServerConfig,
16
38
  } from "./types.js";
17
- export type { RemoteConnectionState, RemoteEngineOptions } from "./client.js";
39
+
40
+ // Re-export Patch type from engine
41
+ export type { Patch } from "../engine.js";