@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.
- package/README.md +182 -11
- package/dist/src/app.js +470 -44
- package/dist/src/app.js.map +7 -5
- package/dist/src/components/builtin.js +470 -44
- package/dist/src/components/builtin.js.map +7 -5
- package/dist/src/discovery.js +559 -65
- package/dist/src/discovery.js.map +8 -6
- package/dist/src/engine.browser.js +2 -2
- package/dist/src/engine.browser.js.map +2 -2
- package/dist/src/engine.js +18 -9
- package/dist/src/engine.js.map +3 -3
- package/dist/src/index.browser.js +863 -82
- package/dist/src/index.browser.js.map +11 -7
- package/dist/src/index.js +1591 -125
- package/dist/src/index.js.map +17 -10
- package/dist/src/remote/client.js +525 -35
- package/dist/src/remote/client.js.map +7 -4
- package/dist/src/remote/index.js +1796 -35
- package/dist/src/remote/index.js.map +13 -4
- package/dist/src/router.js +55 -29
- package/dist/src/router.js.map +3 -3
- package/dist/src/state.js +57 -29
- package/dist/src/state.js.map +3 -3
- package/package.json +8 -2
- package/src/app.ts +292 -13
- package/src/discovery.ts +123 -18
- package/src/disposable.ts +281 -0
- package/src/engine.browser.ts +1 -1
- package/src/engine.ts +29 -10
- package/src/hypen.ts +209 -0
- package/src/index.ts +147 -11
- package/src/logger.ts +338 -0
- package/src/remote/client.ts +263 -56
- package/src/remote/index.ts +25 -1
- package/src/remote/server.ts +652 -0
- package/src/remote/session.ts +256 -0
- package/src/remote/types.ts +68 -1
- package/src/result.ts +260 -0
- package/src/retry.ts +306 -0
- package/src/state.ts +103 -45
- package/wasm-browser/README.md +4 -0
- package/wasm-browser/hypen_engine_bg.wasm +0 -0
- package/wasm-browser/package.json +1 -1
- package/wasm-node/README.md +4 -0
- package/wasm-node/hypen_engine_bg.wasm +0 -0
- package/wasm-node/package.json +1 -1
- package/wasm-browser/hypen_engine_bg.js +0 -736
- 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
|
+
}
|
package/src/remote/types.ts
CHANGED
|
@@ -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
|
+
}
|