@hypen-space/core 0.4.37 → 0.4.39
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 +13 -14
- package/dist/app.js +304 -227
- package/dist/app.js.map +5 -5
- package/dist/components/builtin.d.ts +17 -0
- package/dist/components/builtin.js +336 -256
- package/dist/components/builtin.js.map +6 -6
- package/dist/context.d.ts +11 -20
- package/dist/context.js +69 -101
- package/dist/context.js.map +3 -3
- package/dist/datasource.js +80 -0
- package/dist/datasource.js.map +10 -0
- package/dist/disposable.js +60 -63
- package/dist/disposable.js.map +2 -2
- package/dist/events.js +60 -63
- package/dist/events.js.map +2 -2
- package/dist/hypen.js +78 -0
- package/dist/hypen.js.map +10 -0
- package/dist/index.browser.d.ts +2 -1
- package/dist/index.browser.js +327 -281
- package/dist/index.browser.js.map +7 -8
- package/dist/index.d.ts +0 -3
- package/dist/index.js +547 -246
- package/dist/index.js.map +9 -9
- package/dist/logger.js +60 -64
- package/dist/logger.js.map +2 -2
- package/dist/remote/client.js +258 -158
- package/dist/remote/client.js.map +6 -6
- package/dist/remote/index.d.ts +6 -5
- package/dist/remote/index.js +255 -1985
- package/dist/remote/index.js.map +6 -13
- package/dist/remote/session.js +151 -0
- package/dist/remote/session.js.map +10 -0
- package/dist/renderer.js +60 -63
- package/dist/renderer.js.map +2 -2
- package/dist/result.js +235 -0
- package/dist/result.js.map +10 -0
- package/dist/retry.js +344 -0
- package/dist/retry.js.map +11 -0
- package/dist/router.js +62 -70
- package/dist/router.js.map +2 -2
- package/dist/state.js +3 -8
- package/dist/state.js.map +2 -2
- package/package.json +11 -56
- package/src/components/builtin.ts +78 -56
- package/src/context.ts +22 -65
- package/src/index.browser.ts +5 -4
- package/src/index.ts +10 -23
- package/src/remote/index.ts +9 -5
- package/src/result.ts +11 -0
- package/dist/discovery.d.ts +0 -90
- package/dist/discovery.js +0 -1334
- package/dist/discovery.js.map +0 -15
- package/dist/engine.browser.d.ts +0 -116
- package/dist/engine.browser.js +0 -479
- package/dist/engine.browser.js.map +0 -12
- package/dist/engine.d.ts +0 -107
- package/dist/engine.js +0 -543
- package/dist/engine.js.map +0 -12
- package/dist/loader.d.ts +0 -51
- package/dist/loader.js +0 -292
- package/dist/loader.js.map +0 -11
- package/dist/plugin.d.ts +0 -39
- package/dist/plugin.js +0 -685
- package/dist/plugin.js.map +0 -12
- package/dist/remote/server.d.ts +0 -188
- package/dist/remote/server.js +0 -2270
- package/dist/remote/server.js.map +0 -19
- package/src/discovery.ts +0 -527
- package/src/engine.browser.ts +0 -302
- package/src/engine.ts +0 -282
- package/src/loader.ts +0 -136
- package/src/plugin.ts +0 -220
- package/src/remote/server.ts +0 -879
- package/wasm-browser/README.md +0 -594
- package/wasm-browser/hypen_engine.d.ts +0 -389
- package/wasm-browser/hypen_engine.js +0 -1070
- package/wasm-browser/hypen_engine_bg.wasm +0 -0
- package/wasm-browser/hypen_engine_bg.wasm.d.ts +0 -37
- package/wasm-browser/package.json +0 -24
- package/wasm-node/README.md +0 -594
- package/wasm-node/hypen_engine.d.ts +0 -327
- package/wasm-node/hypen_engine.js +0 -979
- package/wasm-node/hypen_engine_bg.wasm +0 -0
- package/wasm-node/hypen_engine_bg.wasm.d.ts +0 -37
- package/wasm-node/package.json +0 -22
package/src/remote/server.ts
DELETED
|
@@ -1,879 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RemoteServer - Stream Hypen apps over WebSocket with Session Management
|
|
3
|
-
*
|
|
4
|
-
* Usage with inline UI:
|
|
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
|
-
* .build();
|
|
12
|
-
*
|
|
13
|
-
* new RemoteServer()
|
|
14
|
-
* .module("Counter", counter)
|
|
15
|
-
* .ui(`Column { Text("Count: \${state.count}") }`)
|
|
16
|
-
* .listen(3000);
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* Usage with directory-based discovery (auto-resolves imports):
|
|
20
|
-
* ```typescript
|
|
21
|
-
* import { RemoteServer } from "@hypen-space/core";
|
|
22
|
-
* import chatModule from "./apps/chat/Chat/component.js";
|
|
23
|
-
*
|
|
24
|
-
* await new RemoteServer()
|
|
25
|
-
* .source("./apps/chat")
|
|
26
|
-
* .module("Chat", chatModule)
|
|
27
|
-
* .listen(3001);
|
|
28
|
-
* ```
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { Engine } from "../engine.js";
|
|
32
|
-
import { HypenModuleInstance } from "../app.js";
|
|
33
|
-
import type { HypenModule, HypenModuleDefinition } from "../app.js";
|
|
34
|
-
import type {
|
|
35
|
-
RemoteMessage,
|
|
36
|
-
RemoteClient,
|
|
37
|
-
RemoteServerConfig,
|
|
38
|
-
InitialTreeMessage,
|
|
39
|
-
PatchMessage,
|
|
40
|
-
StateUpdateMessage,
|
|
41
|
-
UpdateStateMessage,
|
|
42
|
-
DispatchActionMessage,
|
|
43
|
-
HelloMessage,
|
|
44
|
-
SessionAckMessage,
|
|
45
|
-
SessionExpiredMessage,
|
|
46
|
-
Session,
|
|
47
|
-
SessionConfig,
|
|
48
|
-
} from "./types.js";
|
|
49
|
-
import type { Patch, ResolvedComponent } from "../types.js";
|
|
50
|
-
import type { ServerWebSocket } from "bun";
|
|
51
|
-
import { SessionManager } from "./session.js";
|
|
52
|
-
import { frameworkLoggers } from "../logger.js";
|
|
53
|
-
import {
|
|
54
|
-
discoverComponents,
|
|
55
|
-
loadDiscoveredComponents,
|
|
56
|
-
type DiscoveredComponent,
|
|
57
|
-
} from "../discovery.js";
|
|
58
|
-
|
|
59
|
-
const log = frameworkLoggers.remote;
|
|
60
|
-
|
|
61
|
-
interface ClientData {
|
|
62
|
-
id: string;
|
|
63
|
-
engine: Engine;
|
|
64
|
-
moduleInstance: HypenModuleInstance<any>;
|
|
65
|
-
revision: number;
|
|
66
|
-
connectedAt: Date;
|
|
67
|
-
/** Session ID for this client */
|
|
68
|
-
sessionId: string;
|
|
69
|
-
/** Whether we've received the hello message */
|
|
70
|
-
helloReceived: boolean;
|
|
71
|
-
/** Timeout for legacy clients that don't send hello */
|
|
72
|
-
helloTimeout?: ReturnType<typeof setTimeout>;
|
|
73
|
-
/** Whether this client subscribes to state updates after each render */
|
|
74
|
-
stateSubscribed: boolean;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Builder pattern for hosting Hypen apps over WebSocket
|
|
79
|
-
*/
|
|
80
|
-
export class RemoteServer {
|
|
81
|
-
private _module: HypenModule<any> | null = null;
|
|
82
|
-
private _moduleName: string = "App";
|
|
83
|
-
private _ui: string = "";
|
|
84
|
-
private _sourceDir: string | null = null;
|
|
85
|
-
private _discoveredComponents: Map<string, { template: string; module?: HypenModuleDefinition<any> }> = new Map();
|
|
86
|
-
private _config: RemoteServerConfig = {};
|
|
87
|
-
private _sessionConfig: SessionConfig = {};
|
|
88
|
-
private _onConnectionCallbacks: Array<(client: RemoteClient) => void> = [];
|
|
89
|
-
private _onDisconnectionCallbacks: Array<(client: RemoteClient) => void> = [];
|
|
90
|
-
private clients = new Map<ServerWebSocket<unknown>, ClientData>();
|
|
91
|
-
private nextClientId = 1;
|
|
92
|
-
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
93
|
-
private sessionManager: SessionManager | null = null;
|
|
94
|
-
private _syncActions: boolean = false;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Set the module for this app
|
|
98
|
-
*/
|
|
99
|
-
module(name: string, module: HypenModule<any>): this {
|
|
100
|
-
this._moduleName = name;
|
|
101
|
-
this._module = module;
|
|
102
|
-
return this;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Set the UI DSL string
|
|
107
|
-
*/
|
|
108
|
-
ui(dsl: string): this {
|
|
109
|
-
this._ui = dsl;
|
|
110
|
-
return this;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Set a source directory for automatic component discovery.
|
|
115
|
-
*
|
|
116
|
-
* All .hypen and .ts components in the directory (and subdirectories) are
|
|
117
|
-
* discovered automatically. The entry component's template is used as the
|
|
118
|
-
* UI, so calling .ui() is optional when .source() is set.
|
|
119
|
-
*
|
|
120
|
-
* @example
|
|
121
|
-
* ```typescript
|
|
122
|
-
* new RemoteServer()
|
|
123
|
-
* .source("./apps/chat")
|
|
124
|
-
* .module("Chat", chatModule)
|
|
125
|
-
* .listen(3001);
|
|
126
|
-
* ```
|
|
127
|
-
*/
|
|
128
|
-
source(dir: string): this {
|
|
129
|
-
this._sourceDir = dir;
|
|
130
|
-
return this;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Set server configuration
|
|
135
|
-
*/
|
|
136
|
-
config(config: RemoteServerConfig): this {
|
|
137
|
-
this._config = { ...this._config, ...config };
|
|
138
|
-
return this;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Configure session management
|
|
143
|
-
*/
|
|
144
|
-
session(config: SessionConfig): this {
|
|
145
|
-
this._sessionConfig = config;
|
|
146
|
-
return this;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Enable action synchronization across all connected clients.
|
|
151
|
-
* When enabled, an action dispatched by any client is also dispatched
|
|
152
|
-
* to every other client's engine, keeping all clients in sync.
|
|
153
|
-
*/
|
|
154
|
-
syncActions(): this {
|
|
155
|
-
this._syncActions = true;
|
|
156
|
-
return this;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Register connection callback
|
|
161
|
-
*/
|
|
162
|
-
onConnection(callback: (client: RemoteClient) => void): this {
|
|
163
|
-
this._onConnectionCallbacks.push(callback);
|
|
164
|
-
return this;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Register disconnection callback
|
|
169
|
-
*/
|
|
170
|
-
onDisconnection(callback: (client: RemoteClient) => void): this {
|
|
171
|
-
this._onDisconnectionCallbacks.push(callback);
|
|
172
|
-
return this;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Start the WebSocket server
|
|
177
|
-
*/
|
|
178
|
-
async listen(port?: number): Promise<this> {
|
|
179
|
-
if (!this._module) {
|
|
180
|
-
throw new Error("Module not set. Call .module() before .listen()");
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Run component discovery if source directory is set
|
|
184
|
-
if (this._sourceDir) {
|
|
185
|
-
await this.discoverFromSource();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (!this._ui) {
|
|
189
|
-
throw new Error(
|
|
190
|
-
"UI not set. Call .ui() or .source() before .listen()"
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Initialize session manager
|
|
195
|
-
this.sessionManager = new SessionManager(this._sessionConfig);
|
|
196
|
-
|
|
197
|
-
const finalPort = port ?? this._config.port ?? 3000;
|
|
198
|
-
const hostname = this._config.hostname ?? "0.0.0.0";
|
|
199
|
-
|
|
200
|
-
this.server = Bun.serve({
|
|
201
|
-
port: finalPort,
|
|
202
|
-
hostname,
|
|
203
|
-
websocket: {
|
|
204
|
-
open: (ws) => this.handleOpen(ws),
|
|
205
|
-
message: (ws, message) => this.handleMessage(ws, message),
|
|
206
|
-
close: (ws) => this.handleClose(ws),
|
|
207
|
-
},
|
|
208
|
-
fetch: (req, server) => {
|
|
209
|
-
const url = new URL(req.url);
|
|
210
|
-
|
|
211
|
-
// Upgrade to WebSocket
|
|
212
|
-
if (server.upgrade(req, { data: undefined })) {
|
|
213
|
-
return; // Connection upgraded
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Health check endpoint
|
|
217
|
-
if (url.pathname === "/health") {
|
|
218
|
-
return new Response("OK", { status: 200 });
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Stats endpoint
|
|
222
|
-
if (url.pathname === "/stats") {
|
|
223
|
-
const stats = this.sessionManager?.getStats() ?? {
|
|
224
|
-
activeSessions: 0,
|
|
225
|
-
pendingSessions: 0,
|
|
226
|
-
totalConnections: 0,
|
|
227
|
-
};
|
|
228
|
-
return new Response(JSON.stringify(stats), {
|
|
229
|
-
headers: { "Content-Type": "application/json" },
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return new Response("Hypen Remote Server", { status: 200 });
|
|
234
|
-
},
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
log.info(`Hypen app streaming on ws://${hostname}:${finalPort}`);
|
|
238
|
-
|
|
239
|
-
return this;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Stop the server
|
|
244
|
-
*/
|
|
245
|
-
stop(): void {
|
|
246
|
-
if (this.server) {
|
|
247
|
-
this.server.stop();
|
|
248
|
-
this.server = null;
|
|
249
|
-
}
|
|
250
|
-
if (this.sessionManager) {
|
|
251
|
-
this.sessionManager.destroy();
|
|
252
|
-
this.sessionManager = null;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Get the server URL
|
|
258
|
-
*/
|
|
259
|
-
get url(): string | null {
|
|
260
|
-
if (!this.server) return null;
|
|
261
|
-
const hostname = this._config.hostname ?? "localhost";
|
|
262
|
-
const port = this._config.port ?? 3000;
|
|
263
|
-
return `ws://${hostname}:${port}`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Discover components from the source directory.
|
|
268
|
-
* Populates _discoveredComponents and sets _ui from the entry component
|
|
269
|
-
* if not already set via .ui().
|
|
270
|
-
*/
|
|
271
|
-
private async discoverFromSource(): Promise<void> {
|
|
272
|
-
const dir = this._sourceDir!;
|
|
273
|
-
log.info(`Discovering components from ${dir}...`);
|
|
274
|
-
|
|
275
|
-
const discovered = await discoverComponents(dir);
|
|
276
|
-
const loaded = await loadDiscoveredComponents(discovered);
|
|
277
|
-
|
|
278
|
-
for (const [name, { module, template }] of loaded) {
|
|
279
|
-
this._discoveredComponents.set(name, { template, module });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
log.info(
|
|
283
|
-
`Discovered ${this._discoveredComponents.size} components: ${Array.from(this._discoveredComponents.keys()).join(", ")}`
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
// If .ui() was not called, use the entry component's template
|
|
287
|
-
if (!this._ui) {
|
|
288
|
-
const entry = this._discoveredComponents.get(this._moduleName);
|
|
289
|
-
if (entry) {
|
|
290
|
-
this._ui = entry.template;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Set up a component resolver on a client engine so that imports
|
|
297
|
-
* and component references in templates are resolved from discovered
|
|
298
|
-
* components.
|
|
299
|
-
*/
|
|
300
|
-
private setupComponentResolver(engine: Engine): void {
|
|
301
|
-
if (this._discoveredComponents.size === 0) return;
|
|
302
|
-
|
|
303
|
-
engine.setComponentResolver(
|
|
304
|
-
(componentName: string, _contextPath: string | null) => {
|
|
305
|
-
const comp = this._discoveredComponents.get(componentName);
|
|
306
|
-
if (!comp) {
|
|
307
|
-
log.warn(
|
|
308
|
-
`Component "${componentName}" not found. Available: ${Array.from(this._discoveredComponents.keys()).join(", ") || "(none)"}`
|
|
309
|
-
);
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return {
|
|
314
|
-
source: comp.template,
|
|
315
|
-
path: componentName,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Handle new WebSocket connection
|
|
323
|
-
* Waits for hello message before fully initializing
|
|
324
|
-
*/
|
|
325
|
-
private handleOpen(ws: ServerWebSocket<unknown>) {
|
|
326
|
-
try {
|
|
327
|
-
const clientId = `client_${this.nextClientId++}`;
|
|
328
|
-
const connectedAt = new Date();
|
|
329
|
-
|
|
330
|
-
// Create engine instance for this client
|
|
331
|
-
const engine = new Engine();
|
|
332
|
-
// init() body is synchronous for wasm-node (just new WasmEngine())
|
|
333
|
-
// but declared async, so call it and catch the rejected promise if it fails
|
|
334
|
-
engine.init().catch((err) => log.error("Engine init failed:", err));
|
|
335
|
-
|
|
336
|
-
// Wire up component resolver if we have discovered components
|
|
337
|
-
this.setupComponentResolver(engine);
|
|
338
|
-
|
|
339
|
-
// Create module instance
|
|
340
|
-
const moduleInstance = new HypenModuleInstance(engine, this._module!);
|
|
341
|
-
|
|
342
|
-
log.info(`Client ${clientId} connected, engine initialized`);
|
|
343
|
-
|
|
344
|
-
// Store client data SYNCHRONOUSLY so handleMessage can find it
|
|
345
|
-
const clientData: ClientData = {
|
|
346
|
-
id: clientId,
|
|
347
|
-
engine,
|
|
348
|
-
moduleInstance,
|
|
349
|
-
revision: 0,
|
|
350
|
-
connectedAt,
|
|
351
|
-
sessionId: "",
|
|
352
|
-
helloReceived: false,
|
|
353
|
-
stateSubscribed: false,
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
this.clients.set(ws, clientData);
|
|
357
|
-
|
|
358
|
-
// Set timeout for legacy clients that don't send hello
|
|
359
|
-
clientData.helloTimeout = setTimeout(() => {
|
|
360
|
-
if (!clientData.helloReceived) {
|
|
361
|
-
// Legacy client - create new session automatically
|
|
362
|
-
this.initializeSession(ws, clientData, undefined, undefined).catch(
|
|
363
|
-
(err) => log.error("Error initializing legacy session:", err)
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
}, 1000); // 1 second grace period
|
|
367
|
-
|
|
368
|
-
} catch (error) {
|
|
369
|
-
log.error("Error handling WebSocket open:", error);
|
|
370
|
-
ws.close(1011, "Internal server error");
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Initialize session for a client (new or resumed)
|
|
376
|
-
*/
|
|
377
|
-
private async initializeSession(
|
|
378
|
-
ws: ServerWebSocket<unknown>,
|
|
379
|
-
clientData: ClientData,
|
|
380
|
-
requestedSessionId: string | undefined,
|
|
381
|
-
props: Record<string, any> | undefined
|
|
382
|
-
): Promise<void> {
|
|
383
|
-
if (clientData.helloReceived) return;
|
|
384
|
-
clientData.helloReceived = true;
|
|
385
|
-
log.info(`Initializing session for ${clientData.id} (sessionId: ${requestedSessionId ?? "new"})`);
|
|
386
|
-
|
|
387
|
-
// Clear hello timeout
|
|
388
|
-
if (clientData.helloTimeout) {
|
|
389
|
-
clearTimeout(clientData.helloTimeout);
|
|
390
|
-
clientData.helloTimeout = undefined;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
let session: Session;
|
|
394
|
-
let isNew = true;
|
|
395
|
-
let isRestored = false;
|
|
396
|
-
let restoredState: unknown = null;
|
|
397
|
-
|
|
398
|
-
if (requestedSessionId && this.sessionManager) {
|
|
399
|
-
// Try to resume pending session
|
|
400
|
-
const resumed = this.sessionManager.resumeSession(requestedSessionId);
|
|
401
|
-
if (resumed) {
|
|
402
|
-
session = resumed.session;
|
|
403
|
-
restoredState = resumed.savedState;
|
|
404
|
-
isNew = false;
|
|
405
|
-
isRestored = true;
|
|
406
|
-
|
|
407
|
-
// Call onReconnect hook
|
|
408
|
-
await this.triggerReconnect(clientData, session, restoredState);
|
|
409
|
-
} else {
|
|
410
|
-
// Check for concurrent active session
|
|
411
|
-
const activeSession = this.sessionManager.getActiveSession(requestedSessionId);
|
|
412
|
-
if (activeSession) {
|
|
413
|
-
const handled = await this.handleConcurrentConnection(
|
|
414
|
-
ws,
|
|
415
|
-
clientData,
|
|
416
|
-
activeSession,
|
|
417
|
-
props
|
|
418
|
-
);
|
|
419
|
-
if (!handled) return; // Connection was rejected
|
|
420
|
-
session = activeSession;
|
|
421
|
-
isNew = false;
|
|
422
|
-
} else {
|
|
423
|
-
// Session not found, create new
|
|
424
|
-
session = this.sessionManager.createSession(props);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
} else if (this.sessionManager) {
|
|
428
|
-
// New session
|
|
429
|
-
session = this.sessionManager.createSession(props);
|
|
430
|
-
} else {
|
|
431
|
-
// No session manager (shouldn't happen, but fallback)
|
|
432
|
-
session = {
|
|
433
|
-
id: crypto.randomUUID(),
|
|
434
|
-
ttl: 3600,
|
|
435
|
-
createdAt: new Date(),
|
|
436
|
-
lastConnectedAt: new Date(),
|
|
437
|
-
props,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
clientData.sessionId = session.id;
|
|
442
|
-
this.sessionManager?.trackConnection(session.id, ws);
|
|
443
|
-
|
|
444
|
-
// Send session acknowledgment
|
|
445
|
-
const sessionAck: SessionAckMessage = {
|
|
446
|
-
type: "sessionAck",
|
|
447
|
-
sessionId: session.id,
|
|
448
|
-
isNew,
|
|
449
|
-
isRestored,
|
|
450
|
-
};
|
|
451
|
-
ws.send(JSON.stringify(sessionAck));
|
|
452
|
-
log.info(`Sent sessionAck to ${clientData.id} (session: ${session.id})`);
|
|
453
|
-
|
|
454
|
-
// Render initial tree — capture patches synchronously
|
|
455
|
-
const initialPatches: Patch[] = [];
|
|
456
|
-
clientData.engine.setRenderCallback((patches) => {
|
|
457
|
-
initialPatches.push(...patches);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
try {
|
|
461
|
-
clientData.engine.renderSource(this._ui);
|
|
462
|
-
} catch (renderError) {
|
|
463
|
-
log.error(`Failed to render UI for ${clientData.id}:`, renderError);
|
|
464
|
-
ws.close(1011, "Render failed");
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Now set up streaming render callback for subsequent updates
|
|
469
|
-
this.setupRenderCallback(ws, clientData);
|
|
470
|
-
|
|
471
|
-
// Send initial tree
|
|
472
|
-
const initialMessage: InitialTreeMessage = {
|
|
473
|
-
type: "initialTree",
|
|
474
|
-
module: this._moduleName,
|
|
475
|
-
state: clientData.moduleInstance.getState(),
|
|
476
|
-
patches: initialPatches,
|
|
477
|
-
revision: 0,
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
try {
|
|
481
|
-
ws.send(JSON.stringify(initialMessage));
|
|
482
|
-
} catch (sendError) {
|
|
483
|
-
log.error(`Failed to send initialTree to ${clientData.id}:`, sendError);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
log.info(`Sent initialTree to ${clientData.id} (${initialPatches.length} patches)`);
|
|
487
|
-
|
|
488
|
-
// Notify connection callbacks
|
|
489
|
-
const client: RemoteClient = {
|
|
490
|
-
id: clientData.id,
|
|
491
|
-
socket: ws,
|
|
492
|
-
connectedAt: clientData.connectedAt,
|
|
493
|
-
};
|
|
494
|
-
this._onConnectionCallbacks.forEach((cb) => cb(client));
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Set up the render callback for streaming patches
|
|
499
|
-
*/
|
|
500
|
-
private setupRenderCallback(
|
|
501
|
-
ws: ServerWebSocket<unknown>,
|
|
502
|
-
clientData: ClientData
|
|
503
|
-
): void {
|
|
504
|
-
clientData.engine.setRenderCallback((patches) => {
|
|
505
|
-
const data = this.clients.get(ws);
|
|
506
|
-
if (!data) return;
|
|
507
|
-
|
|
508
|
-
data.revision++;
|
|
509
|
-
|
|
510
|
-
const patchMessage: PatchMessage = {
|
|
511
|
-
type: "patch",
|
|
512
|
-
module: this._moduleName,
|
|
513
|
-
patches,
|
|
514
|
-
revision: data.revision,
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
ws.send(JSON.stringify(patchMessage));
|
|
518
|
-
|
|
519
|
-
// Only send state snapshot to clients that opted in (e.g. Studio)
|
|
520
|
-
if (data.stateSubscribed) {
|
|
521
|
-
const stateMessage: StateUpdateMessage = {
|
|
522
|
-
type: "stateUpdate",
|
|
523
|
-
module: this._moduleName,
|
|
524
|
-
state: data.moduleInstance.getState(),
|
|
525
|
-
revision: data.revision,
|
|
526
|
-
};
|
|
527
|
-
ws.send(JSON.stringify(stateMessage));
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// If allow-multiple, broadcast to other connections on same session
|
|
531
|
-
if (
|
|
532
|
-
this.sessionManager?.getConcurrentPolicy() === "allow-multiple" &&
|
|
533
|
-
data.sessionId
|
|
534
|
-
) {
|
|
535
|
-
const connections = this.sessionManager.getConnections(data.sessionId);
|
|
536
|
-
if (connections) {
|
|
537
|
-
for (const conn of connections) {
|
|
538
|
-
if (conn !== ws) {
|
|
539
|
-
const otherWs = conn as ServerWebSocket<unknown>;
|
|
540
|
-
const otherData = this.clients.get(otherWs);
|
|
541
|
-
otherWs.send(JSON.stringify(patchMessage));
|
|
542
|
-
if (otherData?.stateSubscribed) {
|
|
543
|
-
const stateMessage: StateUpdateMessage = {
|
|
544
|
-
type: "stateUpdate",
|
|
545
|
-
module: this._moduleName,
|
|
546
|
-
state: data.moduleInstance.getState(),
|
|
547
|
-
revision: data.revision,
|
|
548
|
-
};
|
|
549
|
-
otherWs.send(JSON.stringify(stateMessage));
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Handle concurrent connection based on policy
|
|
560
|
-
* Returns true if connection is allowed, false if rejected
|
|
561
|
-
*/
|
|
562
|
-
private async handleConcurrentConnection(
|
|
563
|
-
ws: ServerWebSocket<unknown>,
|
|
564
|
-
clientData: ClientData,
|
|
565
|
-
existingSession: Session,
|
|
566
|
-
props: Record<string, any> | undefined
|
|
567
|
-
): Promise<boolean> {
|
|
568
|
-
const policy = this.sessionManager?.getConcurrentPolicy() ?? "kick-old";
|
|
569
|
-
|
|
570
|
-
switch (policy) {
|
|
571
|
-
case "kick-old": {
|
|
572
|
-
// Kick existing connections
|
|
573
|
-
const existingConnections = this.sessionManager?.getConnections(
|
|
574
|
-
existingSession.id
|
|
575
|
-
);
|
|
576
|
-
if (existingConnections) {
|
|
577
|
-
for (const conn of existingConnections) {
|
|
578
|
-
const oldWs = conn as ServerWebSocket<unknown>;
|
|
579
|
-
const expiredMsg: SessionExpiredMessage = {
|
|
580
|
-
type: "sessionExpired",
|
|
581
|
-
sessionId: existingSession.id,
|
|
582
|
-
reason: "kicked",
|
|
583
|
-
};
|
|
584
|
-
oldWs.send(JSON.stringify(expiredMsg));
|
|
585
|
-
oldWs.close(1000, "Session taken by new connection");
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
return true;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
case "reject-new": {
|
|
592
|
-
// Reject this connection
|
|
593
|
-
const expiredMsg: SessionExpiredMessage = {
|
|
594
|
-
type: "sessionExpired",
|
|
595
|
-
sessionId: existingSession.id,
|
|
596
|
-
reason: "kicked",
|
|
597
|
-
};
|
|
598
|
-
ws.send(JSON.stringify(expiredMsg));
|
|
599
|
-
ws.close(1000, "Session already active");
|
|
600
|
-
return false;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
case "allow-multiple": {
|
|
604
|
-
// Allow both connections
|
|
605
|
-
return true;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
default:
|
|
609
|
-
return true;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Trigger onReconnect hook
|
|
615
|
-
*/
|
|
616
|
-
private async triggerReconnect(
|
|
617
|
-
clientData: ClientData,
|
|
618
|
-
session: Session,
|
|
619
|
-
savedState: unknown
|
|
620
|
-
): Promise<void> {
|
|
621
|
-
const handler = this._module?.handlers.onReconnect;
|
|
622
|
-
if (!handler) return;
|
|
623
|
-
|
|
624
|
-
let restored = false;
|
|
625
|
-
const restore = (state: unknown) => {
|
|
626
|
-
if (state === null || typeof state !== 'object') {
|
|
627
|
-
log.warn("restore() called with non-object state, ignoring:", typeof state);
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
restored = true;
|
|
631
|
-
clientData.moduleInstance.updateState(state as Record<string, unknown>);
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
await handler({ session, restore });
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Handle incoming WebSocket message
|
|
639
|
-
*/
|
|
640
|
-
private handleMessage(
|
|
641
|
-
ws: ServerWebSocket<unknown>,
|
|
642
|
-
message: string | Buffer
|
|
643
|
-
) {
|
|
644
|
-
try {
|
|
645
|
-
const clientData = this.clients.get(ws);
|
|
646
|
-
if (!clientData) return;
|
|
647
|
-
|
|
648
|
-
const msg = JSON.parse(message.toString()) as RemoteMessage;
|
|
649
|
-
|
|
650
|
-
switch (msg.type) {
|
|
651
|
-
case "hello": {
|
|
652
|
-
const helloMsg = msg as HelloMessage;
|
|
653
|
-
this.initializeSession(
|
|
654
|
-
ws,
|
|
655
|
-
clientData,
|
|
656
|
-
helloMsg.sessionId,
|
|
657
|
-
helloMsg.props
|
|
658
|
-
).catch((err) => log.error("Error initializing session from hello:", err));
|
|
659
|
-
break;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
case "dispatchAction": {
|
|
663
|
-
const actionMsg = msg as DispatchActionMessage;
|
|
664
|
-
clientData.engine.dispatchAction(actionMsg.action, actionMsg.payload);
|
|
665
|
-
|
|
666
|
-
// Sync mode: dispatch to all other clients' engines
|
|
667
|
-
if (this._syncActions) {
|
|
668
|
-
for (const [otherWs, otherClient] of this.clients) {
|
|
669
|
-
if (otherWs === ws || !otherClient.helloReceived) continue;
|
|
670
|
-
otherClient.engine.dispatchAction(actionMsg.action, actionMsg.payload);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
break;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
case "updateState": {
|
|
677
|
-
const stateMsg = msg as UpdateStateMessage;
|
|
678
|
-
clientData.moduleInstance.updateState(stateMsg.state);
|
|
679
|
-
|
|
680
|
-
// Sync mode: update state on all other clients' engines
|
|
681
|
-
if (this._syncActions) {
|
|
682
|
-
for (const [otherWs, otherClient] of this.clients) {
|
|
683
|
-
if (otherWs === ws || !otherClient.helloReceived) continue;
|
|
684
|
-
otherClient.moduleInstance.updateState(stateMsg.state);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
break;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
case "subscribeState": {
|
|
691
|
-
clientData.stateSubscribed = true;
|
|
692
|
-
log.info(`Client ${clientData.id} subscribed to state updates`);
|
|
693
|
-
break;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
default:
|
|
697
|
-
// Unknown message type
|
|
698
|
-
break;
|
|
699
|
-
}
|
|
700
|
-
} catch (error) {
|
|
701
|
-
log.error("Error handling WebSocket message:", error);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/**
|
|
706
|
-
* Handle WebSocket close - suspend session instead of destroying
|
|
707
|
-
*/
|
|
708
|
-
private async handleClose(ws: ServerWebSocket<unknown>) {
|
|
709
|
-
const clientData = this.clients.get(ws);
|
|
710
|
-
if (!clientData) return;
|
|
711
|
-
|
|
712
|
-
// Clear hello timeout if still pending
|
|
713
|
-
if (clientData.helloTimeout) {
|
|
714
|
-
clearTimeout(clientData.helloTimeout);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Get current state for session save
|
|
718
|
-
const currentState = clientData.moduleInstance.getState();
|
|
719
|
-
|
|
720
|
-
// Trigger onDisconnect hook
|
|
721
|
-
if (clientData.sessionId && this._module?.handlers.onDisconnect) {
|
|
722
|
-
const session = this.sessionManager?.getActiveSession(clientData.sessionId);
|
|
723
|
-
if (session) {
|
|
724
|
-
await this._module.handlers.onDisconnect({
|
|
725
|
-
state: currentState,
|
|
726
|
-
session,
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Suspend session (don't destroy immediately)
|
|
732
|
-
if (clientData.sessionId && this.sessionManager) {
|
|
733
|
-
this.sessionManager.untrackConnection(clientData.sessionId, ws);
|
|
734
|
-
|
|
735
|
-
// Only suspend if no other connections for this session
|
|
736
|
-
if (this.sessionManager.getConnectionCount(clientData.sessionId) === 0) {
|
|
737
|
-
const session = this.sessionManager.getActiveSession(clientData.sessionId);
|
|
738
|
-
if (session) {
|
|
739
|
-
this.sessionManager.suspendSession(
|
|
740
|
-
clientData.sessionId,
|
|
741
|
-
currentState,
|
|
742
|
-
async (expiredSession) => {
|
|
743
|
-
// Trigger onExpire hook
|
|
744
|
-
if (this._module?.handlers.onExpire) {
|
|
745
|
-
await this._module.handlers.onExpire({ session: expiredSession });
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// Cleanup module instance
|
|
754
|
-
await clientData.moduleInstance.destroy();
|
|
755
|
-
this.clients.delete(ws);
|
|
756
|
-
|
|
757
|
-
// Notify disconnection callbacks
|
|
758
|
-
const client: RemoteClient = {
|
|
759
|
-
id: clientData.id,
|
|
760
|
-
socket: ws,
|
|
761
|
-
connectedAt: clientData.connectedAt,
|
|
762
|
-
};
|
|
763
|
-
this._onDisconnectionCallbacks.forEach((cb) => cb(client));
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Reload components from the source directory and re-render all connected clients.
|
|
768
|
-
* Call this when source files change to implement hot reload.
|
|
769
|
-
*
|
|
770
|
-
* The engine's reconciler diffs the old tree against the new one, producing
|
|
771
|
-
* minimal patches (Create/Remove/SetProp) that are streamed to clients.
|
|
772
|
-
*/
|
|
773
|
-
async reload(): Promise<void> {
|
|
774
|
-
if (this._sourceDir) {
|
|
775
|
-
// Reset _ui so discoverFromSource picks up the latest entry template
|
|
776
|
-
this._ui = "";
|
|
777
|
-
await this.discoverFromSource();
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (!this._ui) {
|
|
781
|
-
log.warn("Reload skipped: no UI template available");
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Re-render all connected clients — reconciler diffs old vs new tree
|
|
786
|
-
for (const [ws, clientData] of this.clients) {
|
|
787
|
-
if (!clientData.helloReceived) continue;
|
|
788
|
-
|
|
789
|
-
// Update component resolver with newly discovered components
|
|
790
|
-
this.setupComponentResolver(clientData.engine);
|
|
791
|
-
|
|
792
|
-
try {
|
|
793
|
-
clientData.engine.renderSource(this._ui);
|
|
794
|
-
log.info(`Hot-reloaded client ${clientData.id}`);
|
|
795
|
-
} catch (e) {
|
|
796
|
-
log.error(`Failed to hot-reload client ${clientData.id}:`, e);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Get current client count
|
|
803
|
-
*/
|
|
804
|
-
getClientCount(): number {
|
|
805
|
-
return this.clients.size;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Get session stats
|
|
810
|
-
*/
|
|
811
|
-
getSessionStats(): {
|
|
812
|
-
activeSessions: number;
|
|
813
|
-
pendingSessions: number;
|
|
814
|
-
totalConnections: number;
|
|
815
|
-
} {
|
|
816
|
-
return this.sessionManager?.getStats() ?? {
|
|
817
|
-
activeSessions: 0,
|
|
818
|
-
pendingSessions: 0,
|
|
819
|
-
totalConnections: 0,
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
/**
|
|
824
|
-
* Broadcast a message to all connected clients
|
|
825
|
-
*/
|
|
826
|
-
broadcast(message: RemoteMessage): void {
|
|
827
|
-
const json = JSON.stringify(message);
|
|
828
|
-
this.clients.forEach((_, ws) => {
|
|
829
|
-
ws.send(json);
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Convenience function to create and start a RemoteServer
|
|
836
|
-
*/
|
|
837
|
-
export async function serve(options: {
|
|
838
|
-
module: HypenModule<any>;
|
|
839
|
-
moduleName?: string;
|
|
840
|
-
ui?: string;
|
|
841
|
-
source?: string;
|
|
842
|
-
port?: number;
|
|
843
|
-
hostname?: string;
|
|
844
|
-
session?: SessionConfig;
|
|
845
|
-
onConnection?: (client: RemoteClient) => void;
|
|
846
|
-
onDisconnection?: (client: RemoteClient) => void;
|
|
847
|
-
}): Promise<RemoteServer> {
|
|
848
|
-
const server = new RemoteServer()
|
|
849
|
-
.module(options.moduleName ?? "App", options.module);
|
|
850
|
-
|
|
851
|
-
if (options.source) {
|
|
852
|
-
server.source(options.source);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
if (options.ui) {
|
|
856
|
-
server.ui(options.ui);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
if (options.port || options.hostname) {
|
|
860
|
-
server.config({
|
|
861
|
-
port: options.port,
|
|
862
|
-
hostname: options.hostname,
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
if (options.session) {
|
|
867
|
-
server.session(options.session);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
if (options.onConnection) {
|
|
871
|
-
server.onConnection(options.onConnection);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
if (options.onDisconnection) {
|
|
875
|
-
server.onDisconnection(options.onDisconnection);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
return server.listen(options.port);
|
|
879
|
-
}
|