@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.
- 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.js +18 -9
- package/dist/src/engine.js.map +3 -3
- package/dist/src/index.browser.js +870 -81
- 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/plugin.js +2 -2
- package/dist/src/plugin.js.map +2 -2
- 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.ts +29 -10
- package/src/hypen.ts +209 -0
- package/src/index.browser.ts +17 -1
- package/src/index.ts +148 -12
- package/src/logger.ts +338 -0
- package/src/plugin.ts +1 -1
- 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,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
|
+
}
|