@hypen-space/core 0.2.11 → 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 (48) 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.browser.js +2 -2
  9. package/dist/src/engine.browser.js.map +2 -2
  10. package/dist/src/engine.js +18 -9
  11. package/dist/src/engine.js.map +3 -3
  12. package/dist/src/index.browser.js +863 -82
  13. package/dist/src/index.browser.js.map +11 -7
  14. package/dist/src/index.js +1591 -125
  15. package/dist/src/index.js.map +17 -10
  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.browser.ts +1 -1
  29. package/src/engine.ts +29 -10
  30. package/src/hypen.ts +209 -0
  31. package/src/index.ts +147 -11
  32. package/src/logger.ts +338 -0
  33. package/src/remote/client.ts +263 -56
  34. package/src/remote/index.ts +25 -1
  35. package/src/remote/server.ts +652 -0
  36. package/src/remote/session.ts +256 -0
  37. package/src/remote/types.ts +68 -1
  38. package/src/result.ts +260 -0
  39. package/src/retry.ts +306 -0
  40. package/src/state.ts +103 -45
  41. package/wasm-browser/README.md +4 -0
  42. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  43. package/wasm-browser/package.json +1 -1
  44. package/wasm-node/README.md +4 -0
  45. package/wasm-node/hypen_engine_bg.wasm +0 -0
  46. package/wasm-node/package.json +1 -1
  47. package/wasm-browser/hypen_engine_bg.js +0 -736
  48. package/wasm-node/hypen_engine_bg.js +0 -736
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Session Management for Remote UI
3
+ *
4
+ * Manages session lifecycle including:
5
+ * - Creating new sessions
6
+ * - Suspending sessions on disconnect (pending reconnection)
7
+ * - Resuming sessions on reconnect
8
+ * - Expiring sessions after TTL
9
+ * - Handling concurrent connections
10
+ */
11
+
12
+ import type { Session, SessionConfig } from "./types.js";
13
+
14
+ /**
15
+ * Internal representation of a pending (disconnected) session
16
+ */
17
+ interface PendingSession {
18
+ session: Session;
19
+ savedState: unknown;
20
+ expiryTimer: ReturnType<typeof setTimeout>;
21
+ }
22
+
23
+ /**
24
+ * Callback invoked when a session expires
25
+ */
26
+ export type SessionExpireCallback = (session: Session) => void | Promise<void>;
27
+
28
+ /**
29
+ * Manages session lifecycle for remote UI connections
30
+ */
31
+ export class SessionManager {
32
+ /** Active sessions (currently connected) */
33
+ private activeSessions = new Map<string, Session>();
34
+
35
+ /** Pending sessions (disconnected, waiting for reconnect within TTL) */
36
+ private pendingSessions = new Map<string, PendingSession>();
37
+
38
+ /** Maps session ID to connected WebSocket(s) for concurrent handling */
39
+ private sessionConnections = new Map<string, Set<unknown>>();
40
+
41
+ /** Resolved configuration with defaults */
42
+ private config: Required<SessionConfig>;
43
+
44
+ constructor(config: SessionConfig = {}) {
45
+ this.config = {
46
+ ttl: config.ttl ?? 3600, // 1 hour default
47
+ concurrent: config.concurrent ?? "kick-old",
48
+ generateId: config.generateId ?? (() => crypto.randomUUID()),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Get the configured TTL in seconds
54
+ */
55
+ getTtl(): number {
56
+ return this.config.ttl;
57
+ }
58
+
59
+ /**
60
+ * Get the concurrent connection policy
61
+ */
62
+ getConcurrentPolicy(): "kick-old" | "reject-new" | "allow-multiple" {
63
+ return this.config.concurrent;
64
+ }
65
+
66
+ /**
67
+ * Create a new session
68
+ */
69
+ createSession(props?: Record<string, any>): Session {
70
+ const now = new Date();
71
+ const session: Session = {
72
+ id: this.config.generateId(),
73
+ ttl: this.config.ttl,
74
+ createdAt: now,
75
+ lastConnectedAt: now,
76
+ props,
77
+ };
78
+ this.activeSessions.set(session.id, session);
79
+ return session;
80
+ }
81
+
82
+ /**
83
+ * Get an active (connected) session by ID
84
+ */
85
+ getActiveSession(id: string): Session | null {
86
+ return this.activeSessions.get(id) ?? null;
87
+ }
88
+
89
+ /**
90
+ * Get a pending (disconnected) session by ID
91
+ */
92
+ getPendingSession(id: string): PendingSession | null {
93
+ return this.pendingSessions.get(id) ?? null;
94
+ }
95
+
96
+ /**
97
+ * Check if a session exists (either active or pending)
98
+ */
99
+ hasSession(id: string): boolean {
100
+ return this.activeSessions.has(id) || this.pendingSessions.has(id);
101
+ }
102
+
103
+ /**
104
+ * Suspend a session when client disconnects
105
+ * Moves from active to pending with a TTL timer
106
+ *
107
+ * @param sessionId - The session to suspend
108
+ * @param savedState - State snapshot to restore on reconnect
109
+ * @param onExpire - Callback when TTL expires
110
+ */
111
+ suspendSession(
112
+ sessionId: string,
113
+ savedState: unknown,
114
+ onExpire: SessionExpireCallback
115
+ ): void {
116
+ const session = this.activeSessions.get(sessionId);
117
+ if (!session) return;
118
+
119
+ // Remove from active
120
+ this.activeSessions.delete(sessionId);
121
+
122
+ // Set up expiry timer
123
+ const expiryTimer = setTimeout(async () => {
124
+ const pending = this.pendingSessions.get(sessionId);
125
+ if (pending) {
126
+ this.pendingSessions.delete(sessionId);
127
+ await onExpire(pending.session);
128
+ }
129
+ }, session.ttl * 1000);
130
+
131
+ // Add to pending
132
+ this.pendingSessions.set(sessionId, {
133
+ session,
134
+ savedState,
135
+ expiryTimer,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Resume a pending session when client reconnects
141
+ *
142
+ * @param sessionId - The session to resume
143
+ * @returns Session and saved state, or null if not found/expired
144
+ */
145
+ resumeSession(
146
+ sessionId: string
147
+ ): { session: Session; savedState: unknown } | null {
148
+ const pending = this.pendingSessions.get(sessionId);
149
+ if (!pending) return null;
150
+
151
+ // Clear expiry timer
152
+ clearTimeout(pending.expiryTimer);
153
+ this.pendingSessions.delete(sessionId);
154
+
155
+ // Update last connected time
156
+ pending.session.lastConnectedAt = new Date();
157
+
158
+ // Move back to active
159
+ this.activeSessions.set(sessionId, pending.session);
160
+
161
+ return {
162
+ session: pending.session,
163
+ savedState: pending.savedState,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Destroy a session completely (both active and pending)
169
+ */
170
+ destroySession(sessionId: string): void {
171
+ this.activeSessions.delete(sessionId);
172
+
173
+ const pending = this.pendingSessions.get(sessionId);
174
+ if (pending) {
175
+ clearTimeout(pending.expiryTimer);
176
+ this.pendingSessions.delete(sessionId);
177
+ }
178
+
179
+ this.sessionConnections.delete(sessionId);
180
+ }
181
+
182
+ /**
183
+ * Track a WebSocket connection for a session
184
+ * Used for concurrent connection handling
185
+ */
186
+ trackConnection(sessionId: string, ws: unknown): void {
187
+ let connections = this.sessionConnections.get(sessionId);
188
+ if (!connections) {
189
+ connections = new Set();
190
+ this.sessionConnections.set(sessionId, connections);
191
+ }
192
+ connections.add(ws);
193
+ }
194
+
195
+ /**
196
+ * Untrack a WebSocket connection
197
+ */
198
+ untrackConnection(sessionId: string, ws: unknown): void {
199
+ const connections = this.sessionConnections.get(sessionId);
200
+ if (connections) {
201
+ connections.delete(ws);
202
+ if (connections.size === 0) {
203
+ this.sessionConnections.delete(sessionId);
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get all connections for a session
210
+ */
211
+ getConnections(sessionId: string): Set<unknown> | undefined {
212
+ return this.sessionConnections.get(sessionId);
213
+ }
214
+
215
+ /**
216
+ * Get connection count for a session
217
+ */
218
+ getConnectionCount(sessionId: string): number {
219
+ return this.sessionConnections.get(sessionId)?.size ?? 0;
220
+ }
221
+
222
+ /**
223
+ * Get stats about current sessions
224
+ */
225
+ getStats(): {
226
+ activeSessions: number;
227
+ pendingSessions: number;
228
+ totalConnections: number;
229
+ } {
230
+ let totalConnections = 0;
231
+ for (const connections of this.sessionConnections.values()) {
232
+ totalConnections += connections.size;
233
+ }
234
+
235
+ return {
236
+ activeSessions: this.activeSessions.size,
237
+ pendingSessions: this.pendingSessions.size,
238
+ totalConnections,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Clean up all sessions and timers
244
+ * Call this when shutting down the server
245
+ */
246
+ destroy(): void {
247
+ // Clear all pending session timers
248
+ for (const pending of this.pendingSessions.values()) {
249
+ clearTimeout(pending.expiryTimer);
250
+ }
251
+
252
+ this.activeSessions.clear();
253
+ this.pendingSessions.clear();
254
+ this.sessionConnections.clear();
255
+ }
256
+ }
@@ -8,7 +8,10 @@ export type RemoteMessage =
8
8
  | InitialTreeMessage
9
9
  | PatchMessage
10
10
  | StateUpdateMessage
11
- | DispatchActionMessage;
11
+ | DispatchActionMessage
12
+ | HelloMessage
13
+ | SessionAckMessage
14
+ | SessionExpiredMessage;
12
15
 
13
16
  export interface InitialTreeMessage {
14
17
  type: "initialTree";
@@ -39,6 +42,70 @@ export interface DispatchActionMessage {
39
42
  payload?: any;
40
43
  }
41
44
 
45
+ /**
46
+ * Client → Server: First message after WebSocket opens
47
+ * Used to establish or resume a session
48
+ */
49
+ export interface HelloMessage {
50
+ type: "hello";
51
+ /** Session ID to resume (omit for new session) */
52
+ sessionId?: string;
53
+ /** Client metadata (platform, version, userId, etc.) */
54
+ props?: Record<string, any>;
55
+ }
56
+
57
+ /**
58
+ * Server → Client: Response to HelloMessage
59
+ * Confirms session establishment
60
+ */
61
+ export interface SessionAckMessage {
62
+ type: "sessionAck";
63
+ /** The session ID (generated or resumed) */
64
+ sessionId: string;
65
+ /** True if this is a new session */
66
+ isNew: boolean;
67
+ /** True if state was restored from a previous session */
68
+ isRestored: boolean;
69
+ }
70
+
71
+ /**
72
+ * Server → Client: Session termination notification
73
+ * Sent when session is kicked or expires
74
+ */
75
+ export interface SessionExpiredMessage {
76
+ type: "sessionExpired";
77
+ sessionId: string;
78
+ reason: "ttl" | "kicked" | "manual";
79
+ }
80
+
81
+ /**
82
+ * Session information passed to lifecycle hooks
83
+ */
84
+ export interface Session {
85
+ /** Unique session identifier */
86
+ id: string;
87
+ /** Time-to-live in seconds */
88
+ ttl: number;
89
+ /** When the session was first created */
90
+ createdAt: Date;
91
+ /** When the client last connected */
92
+ lastConnectedAt: Date;
93
+ /** Client-provided metadata */
94
+ props?: Record<string, any>;
95
+ }
96
+
97
+ /**
98
+ * Configuration for session management
99
+ */
100
+ export interface SessionConfig {
101
+ /** Session TTL in seconds (default: 3600 = 1 hour) */
102
+ ttl?: number;
103
+ /** How to handle concurrent connections with same sessionId */
104
+ concurrent?: "kick-old" | "reject-new" | "allow-multiple";
105
+ /** Custom session ID generator */
106
+ generateId?: () => string;
107
+ }
108
+
42
109
  export interface RemoteClient {
43
110
  id: string;
44
111
  socket: any;
package/src/result.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Result Type for Error Handling
3
+ *
4
+ * A lightweight Result type for explicit error handling without exceptions.
5
+ * Provides type-safe error propagation and composition.
6
+ */
7
+
8
+ /**
9
+ * Represents either a successful value or an error
10
+ */
11
+ export type Result<T, E = Error> =
12
+ | { readonly ok: true; readonly value: T }
13
+ | { readonly ok: false; readonly error: E };
14
+
15
+ /**
16
+ * Create a successful Result
17
+ */
18
+ export function Ok<T>(value: T): Result<T, never> {
19
+ return { ok: true, value };
20
+ }
21
+
22
+ /**
23
+ * Create a failed Result
24
+ */
25
+ export function Err<E>(error: E): Result<never, E> {
26
+ return { ok: false, error };
27
+ }
28
+
29
+ /**
30
+ * Check if a Result is Ok
31
+ */
32
+ export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
33
+ return result.ok;
34
+ }
35
+
36
+ /**
37
+ * Check if a Result is Err
38
+ */
39
+ export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
40
+ return !result.ok;
41
+ }
42
+
43
+ /**
44
+ * Wrap a Promise in a Result, catching any thrown errors
45
+ */
46
+ export async function fromPromise<T, E = Error>(
47
+ promise: Promise<T>,
48
+ mapError?: (e: unknown) => E
49
+ ): Promise<Result<T, E>> {
50
+ try {
51
+ const value = await promise;
52
+ return Ok(value);
53
+ } catch (e) {
54
+ if (mapError) {
55
+ return Err(mapError(e));
56
+ }
57
+ return Err(e as E);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Wrap a synchronous function in a Result, catching any thrown errors
63
+ */
64
+ export function fromTry<T, E = Error>(
65
+ fn: () => T,
66
+ mapError?: (e: unknown) => E
67
+ ): Result<T, E> {
68
+ try {
69
+ return Ok(fn());
70
+ } catch (e) {
71
+ if (mapError) {
72
+ return Err(mapError(e));
73
+ }
74
+ return Err(e as E);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Map over a successful Result value
80
+ */
81
+ export function map<T, U, E>(
82
+ result: Result<T, E>,
83
+ fn: (value: T) => U
84
+ ): Result<U, E> {
85
+ if (result.ok) {
86
+ return Ok(fn(result.value));
87
+ }
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Map over a failed Result error
93
+ */
94
+ export function mapErr<T, E, F>(
95
+ result: Result<T, E>,
96
+ fn: (error: E) => F
97
+ ): Result<T, F> {
98
+ if (!result.ok) {
99
+ return Err(fn(result.error));
100
+ }
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * Chain Results together (flatMap)
106
+ */
107
+ export function flatMap<T, U, E>(
108
+ result: Result<T, E>,
109
+ fn: (value: T) => Result<U, E>
110
+ ): Result<U, E> {
111
+ if (result.ok) {
112
+ return fn(result.value);
113
+ }
114
+ return result;
115
+ }
116
+
117
+ /**
118
+ * Unwrap a Result, throwing if it's an error
119
+ */
120
+ export function unwrap<T, E>(result: Result<T, E>): T {
121
+ if (result.ok) {
122
+ return result.value;
123
+ }
124
+ throw result.error;
125
+ }
126
+
127
+ /**
128
+ * Unwrap a Result with a default value
129
+ */
130
+ export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
131
+ if (result.ok) {
132
+ return result.value;
133
+ }
134
+ return defaultValue;
135
+ }
136
+
137
+ /**
138
+ * Unwrap a Result with a lazy default value
139
+ */
140
+ export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (error: E) => T): T {
141
+ if (result.ok) {
142
+ return result.value;
143
+ }
144
+ return fn(result.error);
145
+ }
146
+
147
+ /**
148
+ * Match on a Result, providing handlers for both cases
149
+ */
150
+ export function match<T, E, U>(
151
+ result: Result<T, E>,
152
+ handlers: {
153
+ ok: (value: T) => U;
154
+ err: (error: E) => U;
155
+ }
156
+ ): U {
157
+ if (result.ok) {
158
+ return handlers.ok(result.value);
159
+ }
160
+ return handlers.err(result.error);
161
+ }
162
+
163
+ /**
164
+ * Combine multiple Results into a single Result containing an array
165
+ * Returns the first error encountered, or Ok with all values
166
+ */
167
+ export function all<T, E>(results: Result<T, E>[]): Result<T[], E> {
168
+ const values: T[] = [];
169
+ for (const result of results) {
170
+ if (!result.ok) {
171
+ return result;
172
+ }
173
+ values.push(result.value);
174
+ }
175
+ return Ok(values);
176
+ }
177
+
178
+ /**
179
+ * Base class for typed errors with context
180
+ */
181
+ export class HypenError extends Error {
182
+ readonly code: string;
183
+ readonly context?: Record<string, unknown>;
184
+ override readonly cause?: Error;
185
+
186
+ constructor(
187
+ code: string,
188
+ message: string,
189
+ options?: { context?: Record<string, unknown>; cause?: Error }
190
+ ) {
191
+ super(message);
192
+ this.name = 'HypenError';
193
+ this.code = code;
194
+ this.context = options?.context;
195
+ this.cause = options?.cause;
196
+
197
+ // Maintain proper prototype chain
198
+ Object.setPrototypeOf(this, new.target.prototype);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Error thrown when an action handler fails
204
+ */
205
+ export class ActionError extends HypenError {
206
+ readonly actionName: string;
207
+
208
+ constructor(actionName: string, cause?: unknown) {
209
+ super(
210
+ 'ACTION_ERROR',
211
+ `Action handler "${actionName}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
212
+ {
213
+ context: { actionName },
214
+ cause: cause instanceof Error ? cause : undefined,
215
+ }
216
+ );
217
+ this.name = 'ActionError';
218
+ this.actionName = actionName;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Error thrown when a connection fails
224
+ */
225
+ export class ConnectionError extends HypenError {
226
+ readonly url: string;
227
+ readonly attempt?: number;
228
+
229
+ constructor(url: string, cause?: unknown, attempt?: number) {
230
+ super(
231
+ 'CONNECTION_ERROR',
232
+ `Connection to "${url}" failed${attempt ? ` (attempt ${attempt})` : ''}: ${
233
+ cause instanceof Error ? cause.message : String(cause)
234
+ }`,
235
+ {
236
+ context: { url, attempt },
237
+ cause: cause instanceof Error ? cause : undefined,
238
+ }
239
+ );
240
+ this.name = 'ConnectionError';
241
+ this.url = url;
242
+ this.attempt = attempt;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Error thrown when state operations fail
248
+ */
249
+ export class StateError extends HypenError {
250
+ readonly path?: string;
251
+
252
+ constructor(message: string, path?: string, cause?: unknown) {
253
+ super('STATE_ERROR', message, {
254
+ context: { path },
255
+ cause: cause instanceof Error ? cause : undefined,
256
+ });
257
+ this.name = 'StateError';
258
+ this.path = path;
259
+ }
260
+ }