@hypen-space/core 0.4.980 → 0.5.3
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 +5 -8
- package/dist/a11y.d.ts +82 -0
- package/dist/app.js +13 -1168
- package/dist/app.js.map +3 -9
- package/dist/chunk-00zt3w6e.js +58 -0
- package/dist/chunk-00zt3w6e.js.map +10 -0
- package/dist/chunk-3zvyth50.js +87 -0
- package/dist/chunk-3zvyth50.js.map +10 -0
- package/dist/chunk-5gtx3pza.js +11 -0
- package/dist/chunk-5gtx3pza.js.map +9 -0
- package/dist/chunk-7222131a.js +191 -0
- package/dist/chunk-7222131a.js.map +10 -0
- package/dist/chunk-avchpwhf.js +200 -0
- package/dist/chunk-avchpwhf.js.map +10 -0
- package/dist/chunk-ec0tgy9a.js +154 -0
- package/dist/chunk-ec0tgy9a.js.map +10 -0
- package/dist/chunk-fnss4dm2.js +479 -0
- package/dist/chunk-fnss4dm2.js.map +10 -0
- package/dist/chunk-g2gz9pgg.js +133 -0
- package/dist/chunk-g2gz9pgg.js.map +10 -0
- package/dist/chunk-g72v6mmf.js +341 -0
- package/dist/chunk-g72v6mmf.js.map +10 -0
- package/dist/chunk-g7g3jxty.js +351 -0
- package/dist/chunk-g7g3jxty.js.map +10 -0
- package/dist/chunk-g830re2c.js +50 -0
- package/dist/chunk-g830re2c.js.map +10 -0
- package/dist/chunk-gtwh1sg7.js +127 -0
- package/dist/chunk-gtwh1sg7.js.map +10 -0
- package/dist/chunk-k12cwfst.js +72 -0
- package/dist/chunk-k12cwfst.js.map +10 -0
- package/dist/chunk-mbqqsz6r.js +128 -0
- package/dist/chunk-mbqqsz6r.js.map +10 -0
- package/dist/chunk-ne632swm.js +209 -0
- package/dist/chunk-ne632swm.js.map +10 -0
- package/dist/chunk-p3pj1bax.js +206 -0
- package/dist/chunk-p3pj1bax.js.map +10 -0
- package/dist/chunk-vshdk2pw.js +56 -0
- package/dist/chunk-vshdk2pw.js.map +10 -0
- package/dist/chunk-wp2ebn2m.js +51 -0
- package/dist/chunk-wp2ebn2m.js.map +10 -0
- package/dist/chunk-x4qv4jm9.js +88 -0
- package/dist/chunk-x4qv4jm9.js.map +10 -0
- package/dist/chunk-ycxp4drd.js +211 -0
- package/dist/chunk-ycxp4drd.js.map +10 -0
- package/dist/components/builtin.js +14 -1246
- package/dist/components/builtin.js.map +3 -10
- package/dist/context.js +7 -362
- package/dist/context.js.map +3 -6
- package/dist/datasource.js +5 -76
- package/dist/datasource.js.map +3 -4
- package/dist/disposable.js +20 -367
- package/dist/disposable.js.map +3 -5
- package/dist/engine-base.d.ts +38 -2
- package/dist/engine-base.js +45 -412
- package/dist/engine-base.js.map +4 -6
- package/dist/events.js +7 -285
- package/dist/events.js.map +3 -5
- package/dist/hypen.js +8 -71
- package/dist/hypen.js.map +3 -4
- package/dist/index.browser.js +58 -2152
- package/dist/index.browser.js.map +3 -16
- package/dist/index.d.ts +4 -0
- package/dist/index.js +175 -3057
- package/dist/index.js.map +5 -22
- package/dist/logger.js +16 -220
- package/dist/logger.js.map +3 -4
- package/dist/managed-router.js +12 -1367
- package/dist/managed-router.js.map +3 -10
- package/dist/portable.js +6 -70
- package/dist/portable.js.map +3 -4
- package/dist/remote/client.js +9 -1010
- package/dist/remote/client.js.map +3 -8
- package/dist/remote/index.d.ts +2 -0
- package/dist/remote/index.js +511 -1066
- package/dist/remote/index.js.map +4 -9
- package/dist/remote/remote-session.d.ts +245 -0
- package/dist/remote/session.js +5 -147
- package/dist/remote/session.js.map +3 -4
- package/dist/renderer.js +7 -271
- package/dist/renderer.js.map +3 -5
- package/dist/resolver.js +5 -484
- package/dist/resolver.js.map +3 -5
- package/dist/result.js +25 -211
- package/dist/result.js.map +3 -4
- package/dist/retry.js +10 -336
- package/dist/retry.js.map +3 -5
- package/dist/router.js +9 -807
- package/dist/router.js.map +3 -8
- package/dist/state.js +10 -274
- package/dist/state.js.map +3 -5
- package/dist/types.d.ts +129 -1
- package/dist/validate.d.ts +19 -0
- package/package.json +13 -1
- package/src/a11y.ts +127 -0
- package/src/engine-base.ts +87 -4
- package/src/index.ts +10 -0
- package/src/remote/index.ts +16 -0
- package/src/remote/remote-session.ts +998 -0
- package/src/types.ts +131 -1
- package/src/validate.ts +37 -0
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteSession - Transport-agnostic per-client session for Hypen remote apps.
|
|
3
|
+
*
|
|
4
|
+
* One `RemoteSession` owns one logical client: a dedicated engine instance,
|
|
5
|
+
* a module instance, a session id, and the logic that drives the Hypen remote
|
|
6
|
+
* protocol (hello → sessionAck → initialTree → streaming patches).
|
|
7
|
+
*
|
|
8
|
+
* It does NOT know how bytes reach the client. That is the job of the
|
|
9
|
+
* `SessionTransport` interface — a minimal `{ send, close }` contract that
|
|
10
|
+
* can be backed by a Bun `ServerWebSocket`, an SSE `ReadableStream`, an
|
|
11
|
+
* in-memory queue (see `AsyncQueueTransport`), or anything else.
|
|
12
|
+
*
|
|
13
|
+
* Backend devs can plug Hypen into an existing Express/Fastify/ws/uWebSockets
|
|
14
|
+
* setup by:
|
|
15
|
+
*
|
|
16
|
+
* 1. Calling `server.prepare()` once (discovery + session manager init).
|
|
17
|
+
* 2. Calling `server.createSession(transport)` per client.
|
|
18
|
+
* 3. Forwarding incoming JSON messages via `session.receive(msg)`.
|
|
19
|
+
* 4. Calling `session.destroy()` when the transport closes.
|
|
20
|
+
*
|
|
21
|
+
* Or, for a pure async-iterator style:
|
|
22
|
+
*
|
|
23
|
+
* const transport = new AsyncQueueTransport();
|
|
24
|
+
* const session = server.createSession(transport);
|
|
25
|
+
* for await (const out of transport.stream()) { ...forward to client... }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
HypenApp,
|
|
30
|
+
HypenModule,
|
|
31
|
+
HypenModuleDefinition,
|
|
32
|
+
} from "../app.js";
|
|
33
|
+
import { HypenModuleInstance, HypenApp as HypenAppClass } from "../app.js";
|
|
34
|
+
import { HypenGlobalContext } from "../context.js";
|
|
35
|
+
import { HypenRouter } from "../router.js";
|
|
36
|
+
import { ManagedRouter } from "../managed-router.js";
|
|
37
|
+
import type {
|
|
38
|
+
DispatchActionMessage,
|
|
39
|
+
HelloMessage,
|
|
40
|
+
InitialTreeMessage,
|
|
41
|
+
PatchMessage,
|
|
42
|
+
RemoteClient,
|
|
43
|
+
RemoteMessage,
|
|
44
|
+
Session,
|
|
45
|
+
SessionAckMessage,
|
|
46
|
+
SessionExpiredMessage,
|
|
47
|
+
StateUpdateMessage,
|
|
48
|
+
UpdateStateMessage,
|
|
49
|
+
} from "./types.js";
|
|
50
|
+
import { SessionManager } from "./session.js";
|
|
51
|
+
import type { Patch } from "../types.js";
|
|
52
|
+
import { frameworkLoggers } from "../logger.js";
|
|
53
|
+
import { BaseEngine } from "../engine-base.js";
|
|
54
|
+
|
|
55
|
+
const log = frameworkLoggers.remote;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Server → client messages emitted by a `RemoteSession`.
|
|
59
|
+
*/
|
|
60
|
+
export type OutgoingMessage =
|
|
61
|
+
| InitialTreeMessage
|
|
62
|
+
| PatchMessage
|
|
63
|
+
| StateUpdateMessage
|
|
64
|
+
| SessionAckMessage
|
|
65
|
+
| SessionExpiredMessage;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal contract a `RemoteSession` needs to reach the client. Implement
|
|
69
|
+
* this to plug Hypen into any transport.
|
|
70
|
+
*
|
|
71
|
+
* `send` receives a structured message — serialize however you like
|
|
72
|
+
* (JSON, MessagePack, protobuf). Built-in adapters use `JSON.stringify`.
|
|
73
|
+
*/
|
|
74
|
+
export interface SessionTransport {
|
|
75
|
+
send(message: OutgoingMessage): void;
|
|
76
|
+
close(code?: number, reason?: string): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The subset of `RemoteServer` state a session needs. Kept as an interface so
|
|
81
|
+
* sessions can be used standalone in tests and so alternate hosts (e.g. a
|
|
82
|
+
* Cloudflare Durable Object wrapper) can satisfy it without subclassing
|
|
83
|
+
* `RemoteServer`.
|
|
84
|
+
*/
|
|
85
|
+
export interface SessionHost {
|
|
86
|
+
readonly module: HypenModule<any>;
|
|
87
|
+
readonly moduleName: string;
|
|
88
|
+
readonly ui: string;
|
|
89
|
+
readonly resources: Record<string, string>;
|
|
90
|
+
readonly app: HypenApp | null;
|
|
91
|
+
readonly syncActions: boolean;
|
|
92
|
+
readonly sessionManager: SessionManager;
|
|
93
|
+
readonly discoveredComponents: Map<
|
|
94
|
+
string,
|
|
95
|
+
{ template: string; module?: HypenModuleDefinition<any> }
|
|
96
|
+
>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Construct a fresh engine for this session. Called once per session.
|
|
100
|
+
*
|
|
101
|
+
* Each session owns its own engine instance, so this must return a new
|
|
102
|
+
* engine every call (not a shared singleton). This is the seam that keeps
|
|
103
|
+
* `RemoteSession` engine-agnostic: `@hypen-space/server` returns
|
|
104
|
+
* `new Engine()` (Node/Bun WASM), a Cloudflare Durable Object returns a
|
|
105
|
+
* `CFEngine`, etc. The returned engine may be un-initialised — the session
|
|
106
|
+
* calls `init()` itself.
|
|
107
|
+
*/
|
|
108
|
+
createEngine(): BaseEngine;
|
|
109
|
+
|
|
110
|
+
/** Other live sessions (for syncActions fan-out and allow-multiple peer broadcast). */
|
|
111
|
+
otherSessions(self: RemoteSession): Iterable<RemoteSession>;
|
|
112
|
+
|
|
113
|
+
/** Sessions that currently share `sessionId` (for kick-old / reject-new). */
|
|
114
|
+
sessionsForId(sessionId: string): Iterable<RemoteSession>;
|
|
115
|
+
|
|
116
|
+
/** Fired after hello → initialTree completes. */
|
|
117
|
+
onSessionReady(session: RemoteSession, client: RemoteClient): void;
|
|
118
|
+
|
|
119
|
+
/** Fired when the session is fully torn down. */
|
|
120
|
+
onSessionDestroyed(session: RemoteSession, client: RemoteClient): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Options passed when constructing a session.
|
|
125
|
+
*/
|
|
126
|
+
export interface RemoteSessionOptions {
|
|
127
|
+
/** Internal client id (for logs and `RemoteClient.id`). Auto-generated if omitted. */
|
|
128
|
+
clientId?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Grace period in ms before a connected client that has not sent `hello`
|
|
131
|
+
* is auto-initialised as a legacy (no-session-id) client. Set `null` to
|
|
132
|
+
* disable the grace period entirely — useful for transports like SSE where
|
|
133
|
+
* the first message may be deliberately delayed.
|
|
134
|
+
* @default 1000
|
|
135
|
+
*/
|
|
136
|
+
helloGraceMs?: number | null;
|
|
137
|
+
/**
|
|
138
|
+
* `socket` value stored on the `RemoteClient` record surfaced to
|
|
139
|
+
* `onConnection`/`onDisconnection` callbacks. Pass through the raw
|
|
140
|
+
* transport handle (e.g. a `ServerWebSocket`) so user code has the
|
|
141
|
+
* escape hatch when it needs it.
|
|
142
|
+
*/
|
|
143
|
+
socketHandle?: unknown;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let nextSessionCounter = 1;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One client's worth of server-side state. Transport-agnostic.
|
|
150
|
+
*/
|
|
151
|
+
export class RemoteSession {
|
|
152
|
+
readonly id: string;
|
|
153
|
+
readonly connectedAt: Date;
|
|
154
|
+
|
|
155
|
+
private readonly host: SessionHost;
|
|
156
|
+
private readonly transport: SessionTransport;
|
|
157
|
+
private readonly socketHandle: unknown;
|
|
158
|
+
|
|
159
|
+
readonly engine: BaseEngine;
|
|
160
|
+
private _moduleInstance: HypenModuleInstance<any> | null = null;
|
|
161
|
+
|
|
162
|
+
private _sessionId: string | null = null;
|
|
163
|
+
private _helloReceived = false;
|
|
164
|
+
private _stateSubscribed = false;
|
|
165
|
+
private _revision = 0;
|
|
166
|
+
private _destroyed = false;
|
|
167
|
+
private helloTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
168
|
+
/**
|
|
169
|
+
* Per-session ManagedRouter auto-wired from `Router {}` blocks found
|
|
170
|
+
* in the primary template. `null` when auto-wiring is disabled, no
|
|
171
|
+
* Routers are present, or no registered modules matched any route's
|
|
172
|
+
* element list. Torn down alongside the session in `destroy()`.
|
|
173
|
+
*/
|
|
174
|
+
private _autoManagedRouter: ManagedRouter | null = null;
|
|
175
|
+
/**
|
|
176
|
+
* Toggled by `RemoteServer.disableAutoRouter()` when the host wants
|
|
177
|
+
* to wire a ManagedRouter by hand via `onSessionCreate` instead.
|
|
178
|
+
*/
|
|
179
|
+
autoRouterEnabled: boolean = true;
|
|
180
|
+
|
|
181
|
+
// Promises that let callers hook into session lifecycle without racing
|
|
182
|
+
// against the hello handshake or the transport close event.
|
|
183
|
+
// `ready` — resolves after the hello handshake, primary module
|
|
184
|
+
// construction, and initial render have completed.
|
|
185
|
+
// `moduleInstance` is guaranteed non-null here.
|
|
186
|
+
// `closed` — resolves after `destroy()` has finished its teardown
|
|
187
|
+
// (onDisconnect hook, module teardown, session suspend).
|
|
188
|
+
readonly ready: Promise<void>;
|
|
189
|
+
readonly closed: Promise<void>;
|
|
190
|
+
private _resolveReady!: () => void;
|
|
191
|
+
private _resolveClosed!: () => void;
|
|
192
|
+
|
|
193
|
+
constructor(
|
|
194
|
+
host: SessionHost,
|
|
195
|
+
transport: SessionTransport,
|
|
196
|
+
options: RemoteSessionOptions = {}
|
|
197
|
+
) {
|
|
198
|
+
this.host = host;
|
|
199
|
+
this.transport = transport;
|
|
200
|
+
this.id = options.clientId ?? `client_${nextSessionCounter++}`;
|
|
201
|
+
this.connectedAt = new Date();
|
|
202
|
+
this.socketHandle = options.socketHandle;
|
|
203
|
+
|
|
204
|
+
this.ready = new Promise<void>((resolve) => {
|
|
205
|
+
this._resolveReady = resolve;
|
|
206
|
+
});
|
|
207
|
+
this.closed = new Promise<void>((resolve) => {
|
|
208
|
+
this._resolveClosed = resolve;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
this.engine = host.createEngine();
|
|
212
|
+
// init() is synchronous for wasm-node but declared async.
|
|
213
|
+
this.engine.init().catch((err) => log.error("Engine init failed:", err));
|
|
214
|
+
|
|
215
|
+
this.setupComponentResolver();
|
|
216
|
+
if (Object.keys(host.resources).length > 0) {
|
|
217
|
+
this.engine.registerResources(host.resources);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
log.info(`Session ${this.id} created, engine initialized`);
|
|
221
|
+
|
|
222
|
+
// Auto-initialise legacy clients that never send `hello`.
|
|
223
|
+
const graceMs = options.helloGraceMs ?? 1000;
|
|
224
|
+
if (graceMs !== null) {
|
|
225
|
+
this.helloTimeout = setTimeout(() => {
|
|
226
|
+
if (!this._helloReceived) {
|
|
227
|
+
this.initializeSession(undefined, undefined).catch((err) =>
|
|
228
|
+
log.error("Error initializing legacy session:", err)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}, graceMs);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
get sessionId(): string | null {
|
|
236
|
+
return this._sessionId;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get moduleInstance(): HypenModuleInstance<any> | null {
|
|
240
|
+
return this._moduleInstance;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
get helloReceived(): boolean {
|
|
244
|
+
return this._helloReceived;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
get stateSubscribed(): boolean {
|
|
248
|
+
return this._stateSubscribed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
get revision(): number {
|
|
252
|
+
return this._revision;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
get isDestroyed(): boolean {
|
|
256
|
+
return this._destroyed;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The `RemoteClient` record surfaced to `onConnection`/`onDisconnection`
|
|
261
|
+
* callbacks. Useful to construct consistently from both the Bun adapter
|
|
262
|
+
* and custom transports.
|
|
263
|
+
*/
|
|
264
|
+
toRemoteClient(): RemoteClient {
|
|
265
|
+
return {
|
|
266
|
+
id: this.id,
|
|
267
|
+
socket: this.socketHandle,
|
|
268
|
+
connectedAt: this.connectedAt,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Feed a client → server message into this session.
|
|
274
|
+
*
|
|
275
|
+
* Invoked by the transport adapter when it receives a message. Accepts
|
|
276
|
+
* either a parsed `RemoteMessage` or a raw JSON string.
|
|
277
|
+
*/
|
|
278
|
+
async receive(raw: RemoteMessage | string | Buffer): Promise<void> {
|
|
279
|
+
if (this._destroyed) return;
|
|
280
|
+
|
|
281
|
+
// `Buffer<ArrayBufferLike>`-as-narrowed-union doesn't reliably collapse
|
|
282
|
+
// to `RemoteMessage` in the `else` branch under the current @types/node,
|
|
283
|
+
// so we project the non-raw case through an explicit assertion.
|
|
284
|
+
let msg: RemoteMessage;
|
|
285
|
+
try {
|
|
286
|
+
if (typeof raw === "string" || raw instanceof Buffer) {
|
|
287
|
+
msg = JSON.parse(raw.toString()) as RemoteMessage;
|
|
288
|
+
} else {
|
|
289
|
+
msg = raw as RemoteMessage;
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
log.error(`Invalid message on session ${this.id}:`, err);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
switch (msg.type) {
|
|
298
|
+
case "hello": {
|
|
299
|
+
const hello = msg as HelloMessage;
|
|
300
|
+
await this.initializeSession(hello.sessionId, hello.props);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
case "dispatchAction": {
|
|
305
|
+
const action = msg as DispatchActionMessage;
|
|
306
|
+
this.engine.dispatchAction(action.action, action.payload);
|
|
307
|
+
|
|
308
|
+
if (this.host.syncActions) {
|
|
309
|
+
for (const other of this.host.otherSessions(this)) {
|
|
310
|
+
if (!other._helloReceived) continue;
|
|
311
|
+
other.engine.dispatchAction(action.action, action.payload);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case "updateState": {
|
|
318
|
+
const stateMsg = msg as UpdateStateMessage;
|
|
319
|
+
this._moduleInstance?.updateState(stateMsg.state);
|
|
320
|
+
|
|
321
|
+
if (this.host.syncActions) {
|
|
322
|
+
for (const other of this.host.otherSessions(this)) {
|
|
323
|
+
if (!other._helloReceived || !other._moduleInstance) continue;
|
|
324
|
+
other._moduleInstance.updateState(stateMsg.state);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case "subscribeState": {
|
|
331
|
+
this._stateSubscribed = true;
|
|
332
|
+
log.info(`Session ${this.id} subscribed to state updates`);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
default:
|
|
337
|
+
// Unknown message type — ignore.
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
log.error(`Error handling message on session ${this.id}:`, err);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Send an outgoing message through this session's transport. Exposed so
|
|
347
|
+
* peer sessions can broadcast to each other (allow-multiple) without
|
|
348
|
+
* touching transport internals.
|
|
349
|
+
*/
|
|
350
|
+
send(message: OutgoingMessage): void {
|
|
351
|
+
if (this._destroyed) return;
|
|
352
|
+
try {
|
|
353
|
+
this.transport.send(message);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
log.error(`Failed to send message on session ${this.id}:`, err);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Notify the client their session is gone and close the transport.
|
|
361
|
+
*/
|
|
362
|
+
expireAndClose(reason: "ttl" | "kicked" | "manual"): void {
|
|
363
|
+
if (!this._sessionId) {
|
|
364
|
+
this.transport.close(1000, "Session closed");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const expired: SessionExpiredMessage = {
|
|
368
|
+
type: "sessionExpired",
|
|
369
|
+
sessionId: this._sessionId,
|
|
370
|
+
reason,
|
|
371
|
+
};
|
|
372
|
+
this.send(expired);
|
|
373
|
+
this.transport.close(1000, "Session " + reason);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Tear down the session. Call this from the transport adapter when the
|
|
378
|
+
* underlying connection closes, or manually to evict a client.
|
|
379
|
+
*
|
|
380
|
+
* Runs `onDisconnect`, suspends the session so it can be resumed later,
|
|
381
|
+
* and fires the host's `onSessionDestroyed` hook.
|
|
382
|
+
*/
|
|
383
|
+
async destroy(): Promise<void> {
|
|
384
|
+
if (this._destroyed) return;
|
|
385
|
+
this._destroyed = true;
|
|
386
|
+
|
|
387
|
+
if (this.helloTimeout) {
|
|
388
|
+
clearTimeout(this.helloTimeout);
|
|
389
|
+
this.helloTimeout = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const currentState = this._moduleInstance?.getState() ?? {};
|
|
393
|
+
|
|
394
|
+
// onDisconnect hook
|
|
395
|
+
if (this._sessionId && this.host.module.handlers.onDisconnect) {
|
|
396
|
+
const session = this.host.sessionManager.getActiveSession(this._sessionId);
|
|
397
|
+
if (session) {
|
|
398
|
+
try {
|
|
399
|
+
await this.host.module.handlers.onDisconnect({
|
|
400
|
+
state: currentState,
|
|
401
|
+
session,
|
|
402
|
+
});
|
|
403
|
+
} catch (err) {
|
|
404
|
+
log.error(`onDisconnect threw on session ${this.id}:`, err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Suspend session if no other connections remain for it
|
|
410
|
+
if (this._sessionId) {
|
|
411
|
+
this.host.sessionManager.untrackConnection(this._sessionId, this);
|
|
412
|
+
if (this.host.sessionManager.getConnectionCount(this._sessionId) === 0) {
|
|
413
|
+
const session = this.host.sessionManager.getActiveSession(
|
|
414
|
+
this._sessionId
|
|
415
|
+
);
|
|
416
|
+
if (session) {
|
|
417
|
+
this.host.sessionManager.suspendSession(
|
|
418
|
+
this._sessionId,
|
|
419
|
+
currentState,
|
|
420
|
+
async (expiredSession) => {
|
|
421
|
+
if (this.host.module.handlers.onExpire) {
|
|
422
|
+
await this.host.module.handlers.onExpire({
|
|
423
|
+
session: expiredSession,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (this._autoManagedRouter) {
|
|
433
|
+
try {
|
|
434
|
+
await this._autoManagedRouter.stop();
|
|
435
|
+
} catch (err) {
|
|
436
|
+
log.error(`Auto-router stop failed on ${this.id}:`, err);
|
|
437
|
+
}
|
|
438
|
+
this._autoManagedRouter = null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (this._moduleInstance) {
|
|
442
|
+
try {
|
|
443
|
+
await this._moduleInstance.destroy();
|
|
444
|
+
} catch (err) {
|
|
445
|
+
log.error(`ModuleInstance.destroy() failed on ${this.id}:`, err);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
this.host.onSessionDestroyed(this, this.toRemoteClient());
|
|
450
|
+
// Ensure `ready` never dangles: if teardown happens before init (e.g.
|
|
451
|
+
// transport dies before hello arrives), unblock any waiter so they can
|
|
452
|
+
// observe `closed` and clean up without deadlocking.
|
|
453
|
+
this._resolveReady();
|
|
454
|
+
this._resolveClosed();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ------------------------------------------------------------------
|
|
458
|
+
// internals
|
|
459
|
+
// ------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
private setupComponentResolver(): void {
|
|
462
|
+
this.engine.setComponentResolver((componentName, _contextPath) => {
|
|
463
|
+
const comp = this.host.discoveredComponents.get(componentName);
|
|
464
|
+
if (!comp) return null;
|
|
465
|
+
return { source: comp.template, path: componentName };
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private registerNestedModules(): void {
|
|
470
|
+
const primary = this.host.moduleName;
|
|
471
|
+
|
|
472
|
+
if (this.host.app) {
|
|
473
|
+
for (const [name, def] of this.host.app.components) {
|
|
474
|
+
if (name === primary) continue;
|
|
475
|
+
const stateKeys =
|
|
476
|
+
def.initialState !== null && typeof def.initialState === "object"
|
|
477
|
+
? Object.keys(def.initialState as object)
|
|
478
|
+
: [];
|
|
479
|
+
const snapshot =
|
|
480
|
+
def.initialState !== null && typeof def.initialState === "object"
|
|
481
|
+
? structuredClone(def.initialState)
|
|
482
|
+
: {};
|
|
483
|
+
this.engine.registerModule(
|
|
484
|
+
name,
|
|
485
|
+
def.actions ?? [],
|
|
486
|
+
stateKeys,
|
|
487
|
+
snapshot
|
|
488
|
+
);
|
|
489
|
+
log.info(
|
|
490
|
+
`Registered nested module "${name}" (${def.actions?.length ?? 0} actions, ${stateKeys.length} state keys)`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const [name, comp] of this.host.discoveredComponents) {
|
|
496
|
+
if (name === primary) continue;
|
|
497
|
+
if (!comp.module) continue;
|
|
498
|
+
if (this.host.app?.has(name)) continue;
|
|
499
|
+
|
|
500
|
+
const def = comp.module;
|
|
501
|
+
|
|
502
|
+
// Skip pure UI components (no state, no actions, no lifecycle).
|
|
503
|
+
// `loadDiscoveredComponents` creates an auto-stateless module
|
|
504
|
+
// (`app.defineState({}).build()`) for every .hypen file without a
|
|
505
|
+
// sidecar .ts, which means pure UI components like Notifications,
|
|
506
|
+
// Profile, Messages, BottomNav, Feed, etc. all get registered as
|
|
507
|
+
// nested modules. That registration doesn't shadow unscoped
|
|
508
|
+
// `@state.x` bindings (those still hit the primary module), but
|
|
509
|
+
// it does occupy a scope slot the engine may otherwise route
|
|
510
|
+
// dependency updates through — and every extra module carries
|
|
511
|
+
// revision bookkeeping overhead per render. When the module has
|
|
512
|
+
// no state and no actions, registering it is pure noise.
|
|
513
|
+
const hasState =
|
|
514
|
+
def.initialState !== null &&
|
|
515
|
+
typeof def.initialState === "object" &&
|
|
516
|
+
Object.keys(def.initialState as object).length > 0;
|
|
517
|
+
const hasActions = (def.actions?.length ?? 0) > 0;
|
|
518
|
+
if (!hasState && !hasActions) continue;
|
|
519
|
+
|
|
520
|
+
const stateKeys =
|
|
521
|
+
def.initialState !== null && typeof def.initialState === "object"
|
|
522
|
+
? Object.keys(def.initialState as object)
|
|
523
|
+
: [];
|
|
524
|
+
const snapshot =
|
|
525
|
+
def.initialState !== null && typeof def.initialState === "object"
|
|
526
|
+
? JSON.parse(JSON.stringify(def.initialState))
|
|
527
|
+
: {};
|
|
528
|
+
this.engine.registerModule(name, def.actions ?? [], stateKeys, snapshot);
|
|
529
|
+
log.info(`Registered nested module "${name}" from discovery`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async initializeSession(
|
|
534
|
+
requestedSessionId: string | undefined,
|
|
535
|
+
props: Record<string, any> | undefined
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
if (this._helloReceived || this._destroyed) return;
|
|
538
|
+
this._helloReceived = true;
|
|
539
|
+
log.info(
|
|
540
|
+
`Initializing session for ${this.id} (sessionId: ${requestedSessionId ?? "new"})`
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
if (this.helloTimeout) {
|
|
544
|
+
clearTimeout(this.helloTimeout);
|
|
545
|
+
this.helloTimeout = null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let session: Session;
|
|
549
|
+
let isNew = true;
|
|
550
|
+
let isRestored = false;
|
|
551
|
+
let restoredState: unknown = null;
|
|
552
|
+
|
|
553
|
+
const sm = this.host.sessionManager;
|
|
554
|
+
|
|
555
|
+
if (requestedSessionId) {
|
|
556
|
+
const resumed = sm.resumeSession(requestedSessionId);
|
|
557
|
+
if (resumed) {
|
|
558
|
+
session = resumed.session;
|
|
559
|
+
restoredState = resumed.savedState;
|
|
560
|
+
isNew = false;
|
|
561
|
+
isRestored = true;
|
|
562
|
+
} else {
|
|
563
|
+
const activeSession = sm.getActiveSession(requestedSessionId);
|
|
564
|
+
if (activeSession) {
|
|
565
|
+
const allowed = this.resolveConcurrentConnection(
|
|
566
|
+
activeSession,
|
|
567
|
+
props
|
|
568
|
+
);
|
|
569
|
+
if (!allowed) return;
|
|
570
|
+
session = activeSession;
|
|
571
|
+
isNew = false;
|
|
572
|
+
} else {
|
|
573
|
+
session = sm.createSession(props);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
session = sm.createSession(props);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this._sessionId = session.id;
|
|
581
|
+
sm.trackConnection(session.id, this);
|
|
582
|
+
|
|
583
|
+
const sessionAck: SessionAckMessage = {
|
|
584
|
+
type: "sessionAck",
|
|
585
|
+
sessionId: session.id,
|
|
586
|
+
isNew,
|
|
587
|
+
isRestored,
|
|
588
|
+
};
|
|
589
|
+
this.send(sessionAck);
|
|
590
|
+
log.info(`Sent sessionAck to ${this.id} (session: ${session.id})`);
|
|
591
|
+
|
|
592
|
+
// Create ModuleInstance HERE (not in constructor) so that onCreated fires
|
|
593
|
+
// right before renderSource. See original server.ts comment for why.
|
|
594
|
+
if (!this._moduleInstance) {
|
|
595
|
+
this._moduleInstance = new HypenModuleInstance(
|
|
596
|
+
this.engine,
|
|
597
|
+
this.host.module
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (isRestored) {
|
|
602
|
+
await this.triggerReconnect(session, restoredState);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
await this._moduleInstance.waitForReady();
|
|
606
|
+
|
|
607
|
+
this.registerNestedModules();
|
|
608
|
+
|
|
609
|
+
// Flush state proxy microtasks so onCreated mutations propagate.
|
|
610
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
611
|
+
|
|
612
|
+
// Capture initial render patches (initial tree + any re-render from onCreated state).
|
|
613
|
+
const initialPatches: Patch[] = [];
|
|
614
|
+
this.engine.setRenderCallback((patches) => {
|
|
615
|
+
initialPatches.push(...patches);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
this.engine.renderSource(this.host.ui);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
log.error(`Failed to render UI for ${this.id}:`, err);
|
|
622
|
+
this.transport.close(1011, "Render failed");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Switch to the streaming callback for subsequent patches.
|
|
627
|
+
this.setupStreamingRenderCallback();
|
|
628
|
+
|
|
629
|
+
const initialMessage: InitialTreeMessage = {
|
|
630
|
+
type: "initialTree",
|
|
631
|
+
module: this.host.moduleName,
|
|
632
|
+
state: this._moduleInstance.getState(),
|
|
633
|
+
patches: initialPatches,
|
|
634
|
+
revision: 0,
|
|
635
|
+
};
|
|
636
|
+
this.send(initialMessage);
|
|
637
|
+
log.info(
|
|
638
|
+
`Sent initialTree to ${this.id} (${initialPatches.length} patches)`
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
this.host.onSessionReady(this, this.toRemoteClient());
|
|
642
|
+
this._resolveReady();
|
|
643
|
+
|
|
644
|
+
// Auto-wire a ManagedRouter from the template's own Router {} blocks.
|
|
645
|
+
// The user gets routing "for free" from the DSL — no addRoute calls
|
|
646
|
+
// in their server code. Guarded by `_autoRouterEnabled` so callers
|
|
647
|
+
// that want bespoke wiring can opt out via
|
|
648
|
+
// `RemoteServer.disableAutoRouter()`.
|
|
649
|
+
if (this.autoRouterEnabled) {
|
|
650
|
+
this.autoWireManagedRouter();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Parse Router {} blocks out of the primary template and spin up a
|
|
656
|
+
* per-session ManagedRouter bound to the shared engine, auto-adding
|
|
657
|
+
* one route per Route() — components resolved from the HypenApp
|
|
658
|
+
* registry. The primary module's `location` field (if present) is
|
|
659
|
+
* kept in sync with the router path so the Router IR re-renders on
|
|
660
|
+
* navigation.
|
|
661
|
+
*
|
|
662
|
+
* Both top-level routers (`moduleScope` = primary / unscoped) and
|
|
663
|
+
* routers nested inside per-route module templates are registered
|
|
664
|
+
* here, flattened into a single route table against the session's
|
|
665
|
+
* `HypenRouter`. The URL is a single string, so nested-router routes
|
|
666
|
+
* share the parent's URL space — authors who want sub-path semantics
|
|
667
|
+
* must spell out the full prefix in their `Route(path: ...)`. On
|
|
668
|
+
* pattern conflicts (multiple routes matching the same path),
|
|
669
|
+
* `ManagedRouter` picks first-match, and the engine emits routers
|
|
670
|
+
* in outer→inner order so primary routes win ties naturally.
|
|
671
|
+
*/
|
|
672
|
+
private autoWireManagedRouter(): void {
|
|
673
|
+
// A registry is only needed to resolve component-backed routes; inline
|
|
674
|
+
// routes (`Route(path) { ...markup... }`) need none. Fall back to an empty
|
|
675
|
+
// registry so a single-module app with an inline `Router {}` still gets its
|
|
676
|
+
// `@router.push` handler + `state.location` mirror wired.
|
|
677
|
+
const app = this.host.app ?? new HypenAppClass();
|
|
678
|
+
|
|
679
|
+
// Collect router blocks from both the primary template AND every
|
|
680
|
+
// discovered child component template. `discoverRouters` walks a
|
|
681
|
+
// single IR tree and does not resolve `Foo()` component references
|
|
682
|
+
// — child templates live in separate source strings in
|
|
683
|
+
// `host.discoveredComponents`. Running discover on each separately
|
|
684
|
+
// and concatenating (primary first) gives us the true cross-tree
|
|
685
|
+
// router inventory. Without this pass, a nested `module Home {
|
|
686
|
+
// Router { ... } }` block declared in `Home/component.hypen`
|
|
687
|
+
// (the canonical `.source()`-discovery shape) is silently
|
|
688
|
+
// invisible to the SDK and the route never mounts.
|
|
689
|
+
const discovered: ReturnType<BaseEngine["discoverRouters"]> = [];
|
|
690
|
+
const runDiscover = (source: string, label: string): void => {
|
|
691
|
+
try {
|
|
692
|
+
const blocks = this.engine.discoverRouters(source);
|
|
693
|
+
for (const b of blocks) discovered.push(b);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
log.error(`Auto-router: discoverRouters failed on ${label}:`, err);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
runDiscover(this.host.ui, this.id);
|
|
699
|
+
for (const [name, comp] of this.host.discoveredComponents) {
|
|
700
|
+
if (comp.template) runDiscover(comp.template, `${this.id} / ${name}`);
|
|
701
|
+
}
|
|
702
|
+
if (discovered.length === 0) return;
|
|
703
|
+
|
|
704
|
+
const primaryScope = this.host.moduleName.toLowerCase();
|
|
705
|
+
|
|
706
|
+
// Build a single top-level ManagedRouter covering every route from
|
|
707
|
+
// every discovered router block (primary AND nested). Multiple
|
|
708
|
+
// `Router {}` blocks flatten into one route list — the engine's
|
|
709
|
+
// own Router IR still renders them independently, but the SDK only
|
|
710
|
+
// needs a single `HypenRouter` per session.
|
|
711
|
+
const router = new HypenRouter();
|
|
712
|
+
const globalContext = new HypenGlobalContext();
|
|
713
|
+
const managed = new ManagedRouter(router, this.engine, app, globalContext);
|
|
714
|
+
|
|
715
|
+
const primary = this._moduleInstance;
|
|
716
|
+
if (primary) {
|
|
717
|
+
globalContext.registerModule(primaryScope, primary);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
let added = 0;
|
|
721
|
+
const seenPaths = new Set<string>();
|
|
722
|
+
for (const block of discovered) {
|
|
723
|
+
for (const route of block.routes) {
|
|
724
|
+
// First path wins — outer Router blocks emit first.
|
|
725
|
+
if (seenPaths.has(route.path)) {
|
|
726
|
+
log.debug(
|
|
727
|
+
`Auto-router: path "${route.path}" already registered; ignoring nested duplicate`
|
|
728
|
+
);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const component = this.pickComponent(route.elementNames);
|
|
732
|
+
if (!component) {
|
|
733
|
+
log.debug(
|
|
734
|
+
`Auto-router: no registered module matched route "${route.path}" — skipping`
|
|
735
|
+
);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
managed.addRoute({ path: route.path, component });
|
|
739
|
+
seenPaths.add(route.path);
|
|
740
|
+
added += 1;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// `added` counts component-backed routes (mounted via ManagedRouter).
|
|
745
|
+
// Routes can also be fully INLINE — `Route(path: "/x") { ...markup... }`
|
|
746
|
+
// with no component — in which case the engine's own Router IR renders
|
|
747
|
+
// them straight from `state.location`, and there's nothing for
|
|
748
|
+
// ManagedRouter to mount. We still need the URL→`location` mirror and the
|
|
749
|
+
// `@router.push` action handler so navigation works. So: bail only if no
|
|
750
|
+
// Router blocks were discovered at all; otherwise wire the router even
|
|
751
|
+
// when `added === 0`.
|
|
752
|
+
const locationKey = this.primaryHasLocationKey() ? "location" : null;
|
|
753
|
+
if (added === 0 && !locationKey) {
|
|
754
|
+
// No component routes and no `location` state to drive inline routes —
|
|
755
|
+
// nothing this router could affect.
|
|
756
|
+
log.debug(`Auto-router: nothing to mount for ${this.id}`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Mirror router path into primary module state's `location` so the
|
|
761
|
+
// engine-level Router IR reconciles to the matching Route subtree.
|
|
762
|
+
// Deferring via `queueMicrotask` keeps the engine write off the
|
|
763
|
+
// synchronous notify path (Rust rejects re-entrant WASM state
|
|
764
|
+
// proxy calls).
|
|
765
|
+
if (locationKey) {
|
|
766
|
+
router.onNavigate((rs) => {
|
|
767
|
+
const path = rs.currentPath;
|
|
768
|
+
queueMicrotask(() => {
|
|
769
|
+
try {
|
|
770
|
+
this._moduleInstance?.updateState({ [locationKey]: path });
|
|
771
|
+
} catch (err) {
|
|
772
|
+
log.error(`Auto-router: state.${locationKey} sync failed:`, err);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// `start()` installs the `@router.push`/`@router.back`/... action handlers
|
|
779
|
+
// and subscribes the router. Needed for both component and inline routes.
|
|
780
|
+
managed.start();
|
|
781
|
+
// HypenRouter doesn't fire onNavigate on subscribe; kick an
|
|
782
|
+
// explicit push so the initial route mounts.
|
|
783
|
+
router.push(router.getCurrentPath());
|
|
784
|
+
|
|
785
|
+
this._autoManagedRouter = managed;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Pick the first element name that resolves to a registered module. */
|
|
789
|
+
private pickComponent(elementNames: string[]): string | null {
|
|
790
|
+
const app = this.host.app;
|
|
791
|
+
if (!app) return null;
|
|
792
|
+
for (const name of elementNames) {
|
|
793
|
+
if (app.has(name)) return name;
|
|
794
|
+
}
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Does the primary module's initial state have a `location` field? */
|
|
799
|
+
private primaryHasLocationKey(): boolean {
|
|
800
|
+
const state = this._moduleInstance?.getState();
|
|
801
|
+
return (
|
|
802
|
+
state !== null &&
|
|
803
|
+
state !== undefined &&
|
|
804
|
+
typeof state === "object" &&
|
|
805
|
+
"location" in state
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private setupStreamingRenderCallback(): void {
|
|
810
|
+
this.engine.setRenderCallback((patches) => {
|
|
811
|
+
if (this._destroyed) return;
|
|
812
|
+
this._revision++;
|
|
813
|
+
log.info(
|
|
814
|
+
`Streaming ${patches.length} patches to ${this.id} (rev ${this._revision})`
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
const patchMessage: PatchMessage = {
|
|
818
|
+
type: "patch",
|
|
819
|
+
module: this.host.moduleName,
|
|
820
|
+
patches,
|
|
821
|
+
revision: this._revision,
|
|
822
|
+
};
|
|
823
|
+
this.send(patchMessage);
|
|
824
|
+
|
|
825
|
+
if (this._stateSubscribed && this._moduleInstance) {
|
|
826
|
+
const stateMessage: StateUpdateMessage = {
|
|
827
|
+
type: "stateUpdate",
|
|
828
|
+
module: this.host.moduleName,
|
|
829
|
+
state: this._moduleInstance.getState(),
|
|
830
|
+
revision: this._revision,
|
|
831
|
+
};
|
|
832
|
+
this.send(stateMessage);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// allow-multiple: fan out to other sessions sharing our sessionId.
|
|
836
|
+
if (
|
|
837
|
+
this._sessionId &&
|
|
838
|
+
this.host.sessionManager.getConcurrentPolicy() === "allow-multiple"
|
|
839
|
+
) {
|
|
840
|
+
for (const peer of this.host.sessionsForId(this._sessionId)) {
|
|
841
|
+
if (peer === this) continue;
|
|
842
|
+
peer.send(patchMessage);
|
|
843
|
+
if (peer._stateSubscribed && this._moduleInstance) {
|
|
844
|
+
const stateMessage: StateUpdateMessage = {
|
|
845
|
+
type: "stateUpdate",
|
|
846
|
+
module: this.host.moduleName,
|
|
847
|
+
state: this._moduleInstance.getState(),
|
|
848
|
+
revision: this._revision,
|
|
849
|
+
};
|
|
850
|
+
peer.send(stateMessage);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private resolveConcurrentConnection(
|
|
858
|
+
existingSession: Session,
|
|
859
|
+
_props: Record<string, any> | undefined
|
|
860
|
+
): boolean {
|
|
861
|
+
const policy = this.host.sessionManager.getConcurrentPolicy();
|
|
862
|
+
|
|
863
|
+
switch (policy) {
|
|
864
|
+
case "kick-old": {
|
|
865
|
+
for (const peer of this.host.sessionsForId(existingSession.id)) {
|
|
866
|
+
if (peer === this) continue;
|
|
867
|
+
peer.expireAndClose("kicked");
|
|
868
|
+
}
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
case "reject-new": {
|
|
872
|
+
this.expireAndClose("kicked");
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
case "allow-multiple":
|
|
876
|
+
default:
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private async triggerReconnect(
|
|
882
|
+
session: Session,
|
|
883
|
+
savedState: unknown
|
|
884
|
+
): Promise<void> {
|
|
885
|
+
const handler = this.host.module.handlers.onReconnect;
|
|
886
|
+
if (!handler) return;
|
|
887
|
+
|
|
888
|
+
const restore = (state: unknown) => {
|
|
889
|
+
if (state === null || typeof state !== "object") {
|
|
890
|
+
log.warn(
|
|
891
|
+
"restore() called with non-object state, ignoring:",
|
|
892
|
+
typeof state
|
|
893
|
+
);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
this._moduleInstance?.updateState(state as Record<string, unknown>);
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
await handler({ session, restore });
|
|
900
|
+
// `savedState` is threaded through for handlers that don't call restore().
|
|
901
|
+
void savedState;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
// Built-in transports
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Wrap a Bun `ServerWebSocket` as a `SessionTransport`.
|
|
911
|
+
*
|
|
912
|
+
* Each outgoing message is JSON-serialized. The caller is responsible for
|
|
913
|
+
* calling `session.receive()` on incoming messages and `session.destroy()`
|
|
914
|
+
* on close.
|
|
915
|
+
*/
|
|
916
|
+
export function createBunWebSocketTransport(
|
|
917
|
+
ws: { send(data: string): void; close(code?: number, reason?: string): void }
|
|
918
|
+
): SessionTransport {
|
|
919
|
+
return {
|
|
920
|
+
send(message) {
|
|
921
|
+
ws.send(JSON.stringify(message));
|
|
922
|
+
},
|
|
923
|
+
close(code, reason) {
|
|
924
|
+
ws.close(code, reason);
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* In-memory transport that buffers outgoing messages and exposes them as
|
|
931
|
+
* an async iterator via `.stream()`. Lets you drive a session from code
|
|
932
|
+
* that prefers `for await` over callbacks — e.g. piping to SSE, HTTP/2
|
|
933
|
+
* server push, or gRPC streaming.
|
|
934
|
+
*
|
|
935
|
+
* @example
|
|
936
|
+
* ```ts
|
|
937
|
+
* const transport = new AsyncQueueTransport();
|
|
938
|
+
* const session = server.createSession(transport);
|
|
939
|
+
* for await (const msg of transport.stream()) {
|
|
940
|
+
* res.write(`data: ${JSON.stringify(msg)}\n\n`); // SSE
|
|
941
|
+
* }
|
|
942
|
+
* ```
|
|
943
|
+
*/
|
|
944
|
+
export class AsyncQueueTransport implements SessionTransport {
|
|
945
|
+
private queue: OutgoingMessage[] = [];
|
|
946
|
+
private waiters: Array<
|
|
947
|
+
(value: IteratorResult<OutgoingMessage, void>) => void
|
|
948
|
+
> = [];
|
|
949
|
+
private closed = false;
|
|
950
|
+
|
|
951
|
+
send(message: OutgoingMessage): void {
|
|
952
|
+
if (this.closed) return;
|
|
953
|
+
const w = this.waiters.shift();
|
|
954
|
+
if (w) {
|
|
955
|
+
w({ value: message, done: false });
|
|
956
|
+
} else {
|
|
957
|
+
this.queue.push(message);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
close(_code?: number, _reason?: string): void {
|
|
962
|
+
if (this.closed) return;
|
|
963
|
+
this.closed = true;
|
|
964
|
+
while (this.waiters.length) {
|
|
965
|
+
const w = this.waiters.shift()!;
|
|
966
|
+
w({ value: undefined, done: true });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Async iterator over outgoing messages. Completes once the transport is
|
|
972
|
+
* closed and the queue is drained.
|
|
973
|
+
*/
|
|
974
|
+
stream(): AsyncIterableIterator<OutgoingMessage> {
|
|
975
|
+
const self = this;
|
|
976
|
+
const iter: AsyncIterableIterator<OutgoingMessage> = {
|
|
977
|
+
[Symbol.asyncIterator]() {
|
|
978
|
+
return iter;
|
|
979
|
+
},
|
|
980
|
+
async next(): Promise<IteratorResult<OutgoingMessage, void>> {
|
|
981
|
+
if (self.queue.length) {
|
|
982
|
+
return { value: self.queue.shift()!, done: false };
|
|
983
|
+
}
|
|
984
|
+
if (self.closed) {
|
|
985
|
+
return { value: undefined, done: true };
|
|
986
|
+
}
|
|
987
|
+
return new Promise((resolve) => {
|
|
988
|
+
self.waiters.push(resolve);
|
|
989
|
+
});
|
|
990
|
+
},
|
|
991
|
+
async return(): Promise<IteratorResult<OutgoingMessage, void>> {
|
|
992
|
+
self.close();
|
|
993
|
+
return { value: undefined, done: true };
|
|
994
|
+
},
|
|
995
|
+
};
|
|
996
|
+
return iter;
|
|
997
|
+
}
|
|
998
|
+
}
|