@hypen-space/core 0.4.980 → 0.5.2
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/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 +2 -0
- package/dist/index.js +132 -3058
- package/dist/index.js.map +4 -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/validate.d.ts +19 -0
- package/package.json +13 -1
- package/src/engine-base.ts +87 -4
- package/src/index.ts +5 -0
- package/src/remote/index.ts +16 -0
- package/src/remote/remote-session.ts +998 -0
- package/src/validate.ts +37 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import {
|
|
2
|
+
retry
|
|
3
|
+
} from "./chunk-g2gz9pgg.js";
|
|
4
|
+
import {
|
|
5
|
+
DisposableStack,
|
|
6
|
+
disposableListener,
|
|
7
|
+
disposableTimeout,
|
|
8
|
+
disposableWebSocket
|
|
9
|
+
} from "./chunk-ec0tgy9a.js";
|
|
10
|
+
import {
|
|
11
|
+
ConnectionError,
|
|
12
|
+
Err,
|
|
13
|
+
Ok
|
|
14
|
+
} from "./chunk-7222131a.js";
|
|
15
|
+
import {
|
|
16
|
+
frameworkLoggers
|
|
17
|
+
} from "./chunk-avchpwhf.js";
|
|
18
|
+
|
|
19
|
+
// src/remote/client.ts
|
|
20
|
+
var log = frameworkLoggers.remote;
|
|
21
|
+
|
|
22
|
+
class RemoteEngine {
|
|
23
|
+
ws = null;
|
|
24
|
+
url;
|
|
25
|
+
state = "disconnected";
|
|
26
|
+
options;
|
|
27
|
+
reconnectAttempts = 0;
|
|
28
|
+
disposables = new DisposableStack;
|
|
29
|
+
reconnectDisposable = null;
|
|
30
|
+
currentSessionId = null;
|
|
31
|
+
sessionOptions;
|
|
32
|
+
patchCallbacks = [];
|
|
33
|
+
stateCallbacks = [];
|
|
34
|
+
connectionCallbacks = [];
|
|
35
|
+
disconnectionCallbacks = [];
|
|
36
|
+
errorCallbacks = [];
|
|
37
|
+
sessionEstablishedCallbacks = [];
|
|
38
|
+
sessionExpiredCallbacks = [];
|
|
39
|
+
currentState = null;
|
|
40
|
+
currentRevision = 0;
|
|
41
|
+
moduleName = "";
|
|
42
|
+
navigationDisposable = null;
|
|
43
|
+
lastViewValue = null;
|
|
44
|
+
handlingPopState = false;
|
|
45
|
+
constructor(url, options = {}) {
|
|
46
|
+
this.url = url;
|
|
47
|
+
const navOpt = options.navigation;
|
|
48
|
+
const navigation = navOpt ? {
|
|
49
|
+
backAction: (typeof navOpt === "object" ? navOpt.backAction : undefined) ?? "navigateBack",
|
|
50
|
+
viewStateKey: (typeof navOpt === "object" ? navOpt.viewStateKey : undefined) ?? "currentView"
|
|
51
|
+
} : undefined;
|
|
52
|
+
this.options = {
|
|
53
|
+
autoReconnect: options.autoReconnect ?? true,
|
|
54
|
+
reconnectInterval: options.reconnectInterval ?? 3000,
|
|
55
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
|
|
56
|
+
session: options.session,
|
|
57
|
+
navigation
|
|
58
|
+
};
|
|
59
|
+
this.sessionOptions = options.session;
|
|
60
|
+
if (options.session?.id) {
|
|
61
|
+
this.currentSessionId = options.session.id;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async connect() {
|
|
65
|
+
if (this.state === "connected" || this.state === "connecting") {
|
|
66
|
+
return Ok(undefined);
|
|
67
|
+
}
|
|
68
|
+
this.state = "connecting";
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
try {
|
|
71
|
+
this.ws = new WebSocket(this.url);
|
|
72
|
+
this.disposables.add(disposableWebSocket(this.ws));
|
|
73
|
+
const messageHandler = (event) => {
|
|
74
|
+
this.handleMessage(event.data);
|
|
75
|
+
};
|
|
76
|
+
this.disposables.add(disposableListener(this.ws, "message", messageHandler));
|
|
77
|
+
const errorHandler = () => {
|
|
78
|
+
this.state = "error";
|
|
79
|
+
const error = new ConnectionError(this.url, new Error("WebSocket error"));
|
|
80
|
+
this.errorCallbacks.forEach((cb) => cb(error));
|
|
81
|
+
resolve(Err(error));
|
|
82
|
+
};
|
|
83
|
+
this.disposables.add(disposableListener(this.ws, "error", errorHandler));
|
|
84
|
+
const closeHandler = () => {
|
|
85
|
+
this.state = "disconnected";
|
|
86
|
+
this.disconnectionCallbacks.forEach((cb) => cb());
|
|
87
|
+
this.attemptReconnect();
|
|
88
|
+
};
|
|
89
|
+
this.disposables.add(disposableListener(this.ws, "close", closeHandler));
|
|
90
|
+
this.ws.onopen = () => {
|
|
91
|
+
this.state = "connected";
|
|
92
|
+
this.reconnectAttempts = 0;
|
|
93
|
+
if (this.reconnectDisposable) {
|
|
94
|
+
this.reconnectDisposable.dispose();
|
|
95
|
+
this.reconnectDisposable = null;
|
|
96
|
+
}
|
|
97
|
+
this.sendHello();
|
|
98
|
+
this.connectionCallbacks.forEach((cb) => cb());
|
|
99
|
+
resolve(Ok(undefined));
|
|
100
|
+
};
|
|
101
|
+
} catch (e) {
|
|
102
|
+
this.state = "error";
|
|
103
|
+
const error = new ConnectionError(this.url, e);
|
|
104
|
+
resolve(Err(error));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
sendHello() {
|
|
109
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
110
|
+
return;
|
|
111
|
+
const hello = {
|
|
112
|
+
type: "hello",
|
|
113
|
+
sessionId: this.currentSessionId ?? this.sessionOptions?.id,
|
|
114
|
+
props: this.sessionOptions?.props,
|
|
115
|
+
persistKey: this.sessionOptions?.persistKey
|
|
116
|
+
};
|
|
117
|
+
this.ws.send(JSON.stringify(hello));
|
|
118
|
+
}
|
|
119
|
+
disconnect() {
|
|
120
|
+
if (this.reconnectDisposable) {
|
|
121
|
+
this.reconnectDisposable.dispose();
|
|
122
|
+
this.reconnectDisposable = null;
|
|
123
|
+
}
|
|
124
|
+
if (this.ws) {
|
|
125
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
126
|
+
this.ws.close();
|
|
127
|
+
}
|
|
128
|
+
this.ws = null;
|
|
129
|
+
}
|
|
130
|
+
this.state = "disconnected";
|
|
131
|
+
}
|
|
132
|
+
dispose() {
|
|
133
|
+
this.disconnect();
|
|
134
|
+
if (this.navigationDisposable) {
|
|
135
|
+
this.navigationDisposable.dispose();
|
|
136
|
+
this.navigationDisposable = null;
|
|
137
|
+
}
|
|
138
|
+
this.disposables.dispose();
|
|
139
|
+
}
|
|
140
|
+
setupNavigation() {
|
|
141
|
+
const nav = this.options.navigation;
|
|
142
|
+
if (!nav || typeof window === "undefined")
|
|
143
|
+
return;
|
|
144
|
+
if (this.navigationDisposable)
|
|
145
|
+
return;
|
|
146
|
+
this.navigationDisposable = disposableListener(window, "popstate", () => {
|
|
147
|
+
if (this.handlingPopState)
|
|
148
|
+
return;
|
|
149
|
+
log.debug("Back navigation detected, dispatching:", nav.backAction);
|
|
150
|
+
this.handlingPopState = true;
|
|
151
|
+
this.dispatchAction(nav.backAction);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
trackViewChange(state) {
|
|
155
|
+
const nav = this.options.navigation;
|
|
156
|
+
if (!nav || typeof window === "undefined")
|
|
157
|
+
return;
|
|
158
|
+
const viewValue = this.resolveStateKey(state, nav.viewStateKey);
|
|
159
|
+
if (viewValue === undefined || viewValue === this.lastViewValue)
|
|
160
|
+
return;
|
|
161
|
+
if (this.handlingPopState) {
|
|
162
|
+
this.handlingPopState = false;
|
|
163
|
+
this.lastViewValue = viewValue;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (this.lastViewValue !== null) {
|
|
167
|
+
window.history.pushState({ view: viewValue }, "", "");
|
|
168
|
+
}
|
|
169
|
+
this.lastViewValue = viewValue;
|
|
170
|
+
}
|
|
171
|
+
resolveStateKey(state, key) {
|
|
172
|
+
if (!state || typeof state !== "object")
|
|
173
|
+
return;
|
|
174
|
+
const parts = key.split(".");
|
|
175
|
+
let current = state;
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
if (current == null || typeof current !== "object")
|
|
178
|
+
return;
|
|
179
|
+
current = current[part];
|
|
180
|
+
}
|
|
181
|
+
return typeof current === "string" ? current : undefined;
|
|
182
|
+
}
|
|
183
|
+
dispatchAction(action, payload) {
|
|
184
|
+
if (this.state !== "connected" || !this.ws) {
|
|
185
|
+
log.warn("Cannot dispatch action: not connected");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const message = {
|
|
189
|
+
type: "dispatchAction",
|
|
190
|
+
module: this.moduleName,
|
|
191
|
+
action,
|
|
192
|
+
payload
|
|
193
|
+
};
|
|
194
|
+
this.ws.send(JSON.stringify(message));
|
|
195
|
+
}
|
|
196
|
+
subscribeState() {
|
|
197
|
+
if (this.state !== "connected" || !this.ws) {
|
|
198
|
+
log.warn("Cannot subscribe to state: not connected");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const message = { type: "subscribeState" };
|
|
202
|
+
this.ws.send(JSON.stringify(message));
|
|
203
|
+
}
|
|
204
|
+
updateState(state) {
|
|
205
|
+
if (this.state !== "connected" || !this.ws) {
|
|
206
|
+
log.warn("Cannot update state: not connected");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const message = {
|
|
210
|
+
type: "updateState",
|
|
211
|
+
module: this.moduleName,
|
|
212
|
+
state
|
|
213
|
+
};
|
|
214
|
+
this.ws.send(JSON.stringify(message));
|
|
215
|
+
}
|
|
216
|
+
onPatches(callback) {
|
|
217
|
+
this.patchCallbacks.push(callback);
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
onStateUpdate(callback) {
|
|
221
|
+
this.stateCallbacks.push(callback);
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
onConnect(callback) {
|
|
225
|
+
this.connectionCallbacks.push(callback);
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
onDisconnect(callback) {
|
|
229
|
+
this.disconnectionCallbacks.push(callback);
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
232
|
+
onError(callback) {
|
|
233
|
+
this.errorCallbacks.push(callback);
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
onSessionEstablished(callback) {
|
|
237
|
+
this.sessionEstablishedCallbacks.push(callback);
|
|
238
|
+
return this;
|
|
239
|
+
}
|
|
240
|
+
onSessionExpired(callback) {
|
|
241
|
+
this.sessionExpiredCallbacks.push(callback);
|
|
242
|
+
return this;
|
|
243
|
+
}
|
|
244
|
+
getConnectionState() {
|
|
245
|
+
return this.state;
|
|
246
|
+
}
|
|
247
|
+
getCurrentState() {
|
|
248
|
+
return this.currentState;
|
|
249
|
+
}
|
|
250
|
+
getRevision() {
|
|
251
|
+
return this.currentRevision;
|
|
252
|
+
}
|
|
253
|
+
getSessionId() {
|
|
254
|
+
return this.currentSessionId;
|
|
255
|
+
}
|
|
256
|
+
handleMessage(data) {
|
|
257
|
+
try {
|
|
258
|
+
const message = JSON.parse(data);
|
|
259
|
+
switch (message.type) {
|
|
260
|
+
case "sessionAck":
|
|
261
|
+
this.handleSessionAck(message);
|
|
262
|
+
break;
|
|
263
|
+
case "sessionExpired":
|
|
264
|
+
this.handleSessionExpired(message);
|
|
265
|
+
break;
|
|
266
|
+
case "initialTree":
|
|
267
|
+
this.handleInitialTree(message);
|
|
268
|
+
break;
|
|
269
|
+
case "patch":
|
|
270
|
+
this.handlePatch(message);
|
|
271
|
+
break;
|
|
272
|
+
case "stateUpdate":
|
|
273
|
+
this.currentState = message.state;
|
|
274
|
+
this.trackViewChange(this.currentState);
|
|
275
|
+
this.stateCallbacks.forEach((cb) => cb(this.currentState));
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
log.error("Error handling remote message:", e);
|
|
280
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
281
|
+
this.errorCallbacks.forEach((cb) => cb(error));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
handleSessionAck(message) {
|
|
285
|
+
this.currentSessionId = message.sessionId;
|
|
286
|
+
const info = {
|
|
287
|
+
sessionId: message.sessionId,
|
|
288
|
+
isNew: message.isNew,
|
|
289
|
+
isRestored: message.isRestored
|
|
290
|
+
};
|
|
291
|
+
this.sessionEstablishedCallbacks.forEach((cb) => cb(info));
|
|
292
|
+
}
|
|
293
|
+
handleSessionExpired(message) {
|
|
294
|
+
this.currentSessionId = null;
|
|
295
|
+
this.sessionExpiredCallbacks.forEach((cb) => cb(message.reason));
|
|
296
|
+
}
|
|
297
|
+
handleInitialTree(message) {
|
|
298
|
+
this.moduleName = message.module;
|
|
299
|
+
this.currentState = message.state;
|
|
300
|
+
this.currentRevision = message.revision;
|
|
301
|
+
if (message.patches.length > 0) {
|
|
302
|
+
this.patchCallbacks.forEach((cb) => cb(message.patches));
|
|
303
|
+
}
|
|
304
|
+
this.trackViewChange(message.state);
|
|
305
|
+
this.setupNavigation();
|
|
306
|
+
if (this.options.navigation) {
|
|
307
|
+
this.subscribeState();
|
|
308
|
+
}
|
|
309
|
+
this.stateCallbacks.forEach((cb) => cb(message.state));
|
|
310
|
+
}
|
|
311
|
+
handlePatch(message) {
|
|
312
|
+
if (message.revision <= this.currentRevision) {
|
|
313
|
+
log.warn(`Out of order patch: expected > ${this.currentRevision}, got ${message.revision}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.currentRevision = message.revision;
|
|
317
|
+
if (message.patches.length > 0) {
|
|
318
|
+
this.patchCallbacks.forEach((cb) => cb(message.patches));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
attemptReconnect() {
|
|
322
|
+
if (!this.options.autoReconnect) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.reconnectDisposable = disposableTimeout(() => {
|
|
326
|
+
this.reconnectDisposable = null;
|
|
327
|
+
retry(async () => {
|
|
328
|
+
const result = await this.connect();
|
|
329
|
+
if (!result.ok) {
|
|
330
|
+
throw result.error;
|
|
331
|
+
}
|
|
332
|
+
}, {
|
|
333
|
+
maxAttempts: this.options.maxReconnectAttempts,
|
|
334
|
+
delayMs: this.options.reconnectInterval,
|
|
335
|
+
backoff: "exponential",
|
|
336
|
+
maxDelayMs: 30000,
|
|
337
|
+
jitter: 0.1,
|
|
338
|
+
onRetry: (attempt, error) => {
|
|
339
|
+
log.debug(`Reconnection attempt ${attempt}/${this.options.maxReconnectAttempts} failed: ${error.message}`);
|
|
340
|
+
}
|
|
341
|
+
}).catch((error) => {
|
|
342
|
+
log.error("Max reconnection attempts reached:", error.message);
|
|
343
|
+
this.errorCallbacks.forEach((cb) => cb(new ConnectionError(this.url, error, this.options.maxReconnectAttempts)));
|
|
344
|
+
});
|
|
345
|
+
}, this.options.reconnectInterval);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export { RemoteEngine };
|
|
350
|
+
|
|
351
|
+
//# debugId=AAFA6372D6EFBBA564756E2164756E21
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/remote/client.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * RemoteEngine - Connect to a remote Hypen app over WebSocket\n * Platform-agnostic client (uses standard WebSocket API)\n *\n * Usage:\n * ```typescript\n * const engine = new RemoteEngine(\"ws://localhost:3000\", {\n * session: {\n * id: localStorage.getItem(\"sessionId\") ?? undefined,\n * props: { platform: \"web\", version: \"1.0\" }\n * }\n * });\n *\n * engine.onSessionEstablished(({ sessionId, isNew, isRestored }) => {\n * localStorage.setItem(\"sessionId\", sessionId);\n * console.log(isRestored ? \"Welcome back!\" : \"New session\");\n * });\n *\n * engine.onSessionExpired((reason) => {\n * localStorage.removeItem(\"sessionId\");\n * });\n *\n * const result = await engine.connect();\n * if (!result.ok) {\n * console.error(\"Connection failed:\", result.error);\n * }\n * ```\n */\n\nimport type {\n RemoteMessage,\n InitialTreeMessage,\n PatchMessage,\n DispatchActionMessage,\n UpdateStateMessage,\n SubscribeStateMessage,\n HelloMessage,\n SessionAckMessage,\n SessionExpiredMessage,\n} from \"./types.js\";\nimport type { Patch } from \"../types.js\";\nimport {\n type Result,\n Ok,\n Err,\n ConnectionError,\n} from \"../result.js\";\nimport {\n type Disposable,\n DisposableStack,\n disposableTimeout,\n disposableWebSocket,\n disposableListener,\n} from \"../disposable.js\";\nimport { retry, type RetryOptions } from \"../retry.js\";\nimport { frameworkLoggers } from \"../logger.js\";\n\nconst log = frameworkLoggers.remote;\n\nexport type RemoteConnectionState =\n | \"disconnected\"\n | \"connecting\"\n | \"connected\"\n | \"error\";\n\n/**\n * Session configuration for the client\n */\nexport interface SessionOptions {\n /** Session ID to resume (omit for new session) */\n id?: string;\n /** Client metadata (platform, version, userId, etc.) */\n props?: Record<string, unknown>;\n /** Optional persist/routing key for Durable Object routing (withKey) */\n persistKey?: string;\n}\n\n/**\n * Session information received from server\n */\nexport interface SessionInfo {\n sessionId: string;\n isNew: boolean;\n isRestored: boolean;\n}\n\n/**\n * Navigation options for platform back-button integration.\n *\n * When enabled, the client listens for platform back events (browser popstate,\n * Android back button) and dispatches a configurable action to the server.\n * The server module handles navigation via its own route stack.\n */\nexport interface NavigationOptions {\n /** Action name dispatched on back navigation (default: \"navigateBack\") */\n backAction?: string;\n /**\n * State key that represents the current view/route on the server.\n * When this key changes in a stateUpdate, the client pushes a browser\n * history entry so the back button works. (default: \"currentView\")\n */\n viewStateKey?: string;\n}\n\nexport interface RemoteEngineOptions {\n autoReconnect?: boolean;\n reconnectInterval?: number;\n maxReconnectAttempts?: number;\n /** Session configuration */\n session?: SessionOptions;\n /**\n * Enable platform back-button integration.\n * Pass `true` for defaults, or an object to customize action/key names.\n */\n navigation?: boolean | NavigationOptions;\n}\n\ninterface ResolvedNavigationOptions {\n backAction: string;\n viewStateKey: string;\n}\n\ninterface RequiredOptions {\n autoReconnect: boolean;\n reconnectInterval: number;\n maxReconnectAttempts: number;\n session?: SessionOptions;\n navigation?: ResolvedNavigationOptions;\n}\n\n/**\n * Client-side engine that connects to a remote Hypen app\n */\nexport class RemoteEngine implements Disposable {\n private ws: WebSocket | null = null;\n private readonly url: string;\n private state: RemoteConnectionState = \"disconnected\";\n private readonly options: RequiredOptions;\n private reconnectAttempts = 0;\n\n // Resource management\n private readonly disposables = new DisposableStack();\n private reconnectDisposable: Disposable | null = null;\n\n // Session state\n private currentSessionId: string | null = null;\n private readonly sessionOptions?: SessionOptions;\n\n // Callbacks\n private readonly patchCallbacks: Array<(patches: Patch[]) => void> = [];\n private readonly stateCallbacks: Array<(state: unknown) => void> = [];\n private readonly connectionCallbacks: Array<() => void> = [];\n private readonly disconnectionCallbacks: Array<() => void> = [];\n private readonly errorCallbacks: Array<(error: Error) => void> = [];\n private readonly sessionEstablishedCallbacks: Array<(info: SessionInfo) => void> = [];\n private readonly sessionExpiredCallbacks: Array<(reason: string) => void> = [];\n\n // State\n private currentState: unknown = null;\n private currentRevision = 0;\n private moduleName: string = \"\";\n\n // Navigation\n private navigationDisposable: Disposable | null = null;\n private lastViewValue: string | null = null;\n private handlingPopState = false;\n\n constructor(url: string, options: RemoteEngineOptions = {}) {\n this.url = url;\n const navOpt = options.navigation;\n const navigation = navOpt\n ? {\n backAction: (typeof navOpt === \"object\" ? navOpt.backAction : undefined) ?? \"navigateBack\",\n viewStateKey: (typeof navOpt === \"object\" ? navOpt.viewStateKey : undefined) ?? \"currentView\",\n }\n : undefined;\n\n this.options = {\n autoReconnect: options.autoReconnect ?? true,\n reconnectInterval: options.reconnectInterval ?? 3000,\n maxReconnectAttempts: options.maxReconnectAttempts ?? 10,\n session: options.session,\n navigation,\n };\n this.sessionOptions = options.session;\n\n // If session ID was provided, use it as current\n if (options.session?.id) {\n this.currentSessionId = options.session.id;\n }\n }\n\n /**\n * Connect to the remote server\n * Returns a Result indicating success or failure\n */\n async connect(): Promise<Result<void, ConnectionError>> {\n if (this.state === \"connected\" || this.state === \"connecting\") {\n return Ok(undefined);\n }\n\n this.state = \"connecting\";\n\n return new Promise((resolve) => {\n try {\n this.ws = new WebSocket(this.url);\n\n // Track the WebSocket for cleanup\n this.disposables.add(disposableWebSocket(this.ws));\n\n // Set up message handler\n const messageHandler = (event: MessageEvent) => {\n this.handleMessage(event.data);\n };\n this.disposables.add(\n disposableListener(this.ws, \"message\", messageHandler as EventListener)\n );\n\n // Set up error handler\n const errorHandler = () => {\n this.state = \"error\";\n const error = new ConnectionError(this.url, new Error(\"WebSocket error\"));\n this.errorCallbacks.forEach((cb) => cb(error));\n resolve(Err(error));\n };\n this.disposables.add(\n disposableListener(this.ws, \"error\", errorHandler)\n );\n\n // Set up close handler\n const closeHandler = () => {\n this.state = \"disconnected\";\n this.disconnectionCallbacks.forEach((cb) => cb());\n this.attemptReconnect();\n };\n this.disposables.add(\n disposableListener(this.ws, \"close\", closeHandler)\n );\n\n // Set up open handler\n this.ws.onopen = () => {\n this.state = \"connected\";\n this.reconnectAttempts = 0;\n\n // Cancel any pending reconnect\n if (this.reconnectDisposable) {\n this.reconnectDisposable.dispose();\n this.reconnectDisposable = null;\n }\n\n // Send hello message with session info\n this.sendHello();\n\n this.connectionCallbacks.forEach((cb) => cb());\n resolve(Ok(undefined));\n };\n } catch (e) {\n this.state = \"error\";\n const error = new ConnectionError(this.url, e);\n resolve(Err(error));\n }\n });\n }\n\n /**\n * Send hello message to establish session\n */\n private sendHello(): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;\n\n const hello: HelloMessage = {\n type: \"hello\",\n sessionId: this.currentSessionId ?? this.sessionOptions?.id,\n props: this.sessionOptions?.props,\n persistKey: this.sessionOptions?.persistKey,\n };\n\n this.ws.send(JSON.stringify(hello));\n }\n\n /**\n * Disconnect from the remote server and clean up resources\n */\n disconnect(): void {\n // Cancel any pending reconnect\n if (this.reconnectDisposable) {\n this.reconnectDisposable.dispose();\n this.reconnectDisposable = null;\n }\n\n // Close WebSocket if open\n if (this.ws) {\n if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {\n this.ws.close();\n }\n this.ws = null;\n }\n\n this.state = \"disconnected\";\n }\n\n /**\n * Dispose all resources (alias for disconnect)\n */\n dispose(): void {\n this.disconnect();\n if (this.navigationDisposable) {\n this.navigationDisposable.dispose();\n this.navigationDisposable = null;\n }\n this.disposables.dispose();\n }\n\n // ── Navigation ────────────────────────────────────────────────\n\n /**\n * Set up the browser popstate listener for back-button integration.\n * Called automatically after the initial tree is received.\n */\n private setupNavigation(): void {\n const nav = this.options.navigation;\n if (!nav || typeof window === \"undefined\") return;\n if (this.navigationDisposable) return; // already set up\n\n this.navigationDisposable = disposableListener(window, \"popstate\", () => {\n if (this.handlingPopState) return;\n\n // Browser went back — dispatch the back action to the server\n log.debug(\"Back navigation detected, dispatching:\", nav.backAction);\n this.handlingPopState = true;\n this.dispatchAction(nav.backAction);\n // Flag cleared when we receive the next state update\n });\n }\n\n /**\n * Track view changes from the server and push browser history entries.\n * This keeps the browser history stack in sync with server-side navigation\n * so that the back button has entries to pop.\n */\n private trackViewChange(state: unknown): void {\n const nav = this.options.navigation;\n if (!nav || typeof window === \"undefined\") return;\n\n const viewValue = this.resolveStateKey(state, nav.viewStateKey);\n if (viewValue === undefined || viewValue === this.lastViewValue) return;\n\n if (this.handlingPopState) {\n // This state update was caused by our own back action — don't push\n this.handlingPopState = false;\n this.lastViewValue = viewValue;\n return;\n }\n\n // Only push if we had a previous view (skip the initial load)\n if (this.lastViewValue !== null) {\n window.history.pushState({ view: viewValue }, \"\", \"\");\n }\n this.lastViewValue = viewValue;\n }\n\n /**\n * Resolve a dot-path key from state (e.g., \"currentView\" or \"ui.page\").\n */\n private resolveStateKey(state: unknown, key: string): string | undefined {\n if (!state || typeof state !== \"object\") return undefined;\n const parts = key.split(\".\");\n let current: any = state;\n for (const part of parts) {\n if (current == null || typeof current !== \"object\") return undefined;\n current = current[part];\n }\n return typeof current === \"string\" ? current : undefined;\n }\n\n /**\n * Dispatch an action to the remote server\n */\n dispatchAction(action: string, payload?: unknown): void {\n if (this.state !== \"connected\" || !this.ws) {\n log.warn(\"Cannot dispatch action: not connected\");\n return;\n }\n\n const message: DispatchActionMessage = {\n type: \"dispatchAction\",\n module: this.moduleName,\n action,\n payload,\n };\n\n this.ws.send(JSON.stringify(message));\n }\n\n /**\n * Subscribe to state updates after each render.\n * Only subscribed clients receive stateUpdate messages (avoids overhead for native clients).\n */\n subscribeState(): void {\n if (this.state !== \"connected\" || !this.ws) {\n log.warn(\"Cannot subscribe to state: not connected\");\n return;\n }\n\n const message: SubscribeStateMessage = { type: \"subscribeState\" };\n this.ws.send(JSON.stringify(message));\n }\n\n /**\n * Override the remote module's state (e.g. for time-travel).\n * The server will update state, re-render, and send back patches.\n */\n updateState(state: unknown): void {\n if (this.state !== \"connected\" || !this.ws) {\n log.warn(\"Cannot update state: not connected\");\n return;\n }\n\n const message: UpdateStateMessage = {\n type: \"updateState\",\n module: this.moduleName,\n state,\n };\n\n this.ws.send(JSON.stringify(message));\n }\n\n /**\n * Register callback for patches\n */\n onPatches(callback: (patches: Patch[]) => void): this {\n this.patchCallbacks.push(callback);\n return this;\n }\n\n /**\n * Register callback for state updates\n */\n onStateUpdate(callback: (state: unknown) => void): this {\n this.stateCallbacks.push(callback);\n return this;\n }\n\n /**\n * Register callback for connection\n */\n onConnect(callback: () => void): this {\n this.connectionCallbacks.push(callback);\n return this;\n }\n\n /**\n * Register callback for disconnection\n */\n onDisconnect(callback: () => void): this {\n this.disconnectionCallbacks.push(callback);\n return this;\n }\n\n /**\n * Register callback for errors\n */\n onError(callback: (error: Error) => void): this {\n this.errorCallbacks.push(callback);\n return this;\n }\n\n /**\n * Register callback for session establishment\n * Called when server confirms session (new or resumed)\n */\n onSessionEstablished(callback: (info: SessionInfo) => void): this {\n this.sessionEstablishedCallbacks.push(callback);\n return this;\n }\n\n /**\n * Register callback for session expiration\n * Called when session is kicked or expires\n */\n onSessionExpired(callback: (reason: string) => void): this {\n this.sessionExpiredCallbacks.push(callback);\n return this;\n }\n\n /**\n * Get current connection state\n */\n getConnectionState(): RemoteConnectionState {\n return this.state;\n }\n\n /**\n * Get current app state\n */\n getCurrentState(): unknown {\n return this.currentState;\n }\n\n /**\n * Get current revision\n */\n getRevision(): number {\n return this.currentRevision;\n }\n\n /**\n * Get current session ID\n */\n getSessionId(): string | null {\n return this.currentSessionId;\n }\n\n private handleMessage(data: string): void {\n try {\n const message = JSON.parse(data) as RemoteMessage;\n\n switch (message.type) {\n case \"sessionAck\":\n this.handleSessionAck(message as SessionAckMessage);\n break;\n\n case \"sessionExpired\":\n this.handleSessionExpired(message as SessionExpiredMessage);\n break;\n\n case \"initialTree\":\n this.handleInitialTree(message as InitialTreeMessage);\n break;\n\n case \"patch\":\n this.handlePatch(message as PatchMessage);\n break;\n\n case \"stateUpdate\":\n this.currentState = (message as { state: unknown }).state;\n this.trackViewChange(this.currentState);\n this.stateCallbacks.forEach((cb) => cb(this.currentState));\n break;\n }\n } catch (e) {\n log.error(\"Error handling remote message:\", e);\n const error = e instanceof Error ? e : new Error(String(e));\n this.errorCallbacks.forEach((cb) => cb(error));\n }\n }\n\n private handleSessionAck(message: SessionAckMessage): void {\n this.currentSessionId = message.sessionId;\n\n const info: SessionInfo = {\n sessionId: message.sessionId,\n isNew: message.isNew,\n isRestored: message.isRestored,\n };\n\n this.sessionEstablishedCallbacks.forEach((cb) => cb(info));\n }\n\n private handleSessionExpired(message: SessionExpiredMessage): void {\n this.currentSessionId = null;\n this.sessionExpiredCallbacks.forEach((cb) => cb(message.reason));\n }\n\n private handleInitialTree(message: InitialTreeMessage): void {\n this.moduleName = message.module;\n this.currentState = message.state;\n this.currentRevision = message.revision;\n\n // Apply initial patches\n if (message.patches.length > 0) {\n this.patchCallbacks.forEach((cb) => cb(message.patches));\n }\n\n // Initialize navigation tracking from initial state\n this.trackViewChange(message.state);\n this.setupNavigation();\n\n // Auto-subscribe to state when navigation is enabled so we can\n // track view changes and push browser history entries.\n if (this.options.navigation) {\n this.subscribeState();\n }\n\n // Notify state callbacks\n this.stateCallbacks.forEach((cb) => cb(message.state));\n }\n\n private handlePatch(message: PatchMessage): void {\n // Check revision ordering\n if (message.revision <= this.currentRevision) {\n log.warn(`Out of order patch: expected > ${this.currentRevision}, got ${message.revision}`);\n return;\n }\n\n this.currentRevision = message.revision;\n\n // Apply patches\n if (message.patches.length > 0) {\n this.patchCallbacks.forEach((cb) => cb(message.patches));\n }\n }\n\n private attemptReconnect(): void {\n if (!this.options.autoReconnect) {\n return;\n }\n\n // Use disposable timeout to start reconnection after initial delay\n this.reconnectDisposable = disposableTimeout(() => {\n this.reconnectDisposable = null;\n\n retry(\n async () => {\n const result = await this.connect();\n if (!result.ok) {\n throw result.error;\n }\n },\n {\n maxAttempts: this.options.maxReconnectAttempts,\n delayMs: this.options.reconnectInterval,\n backoff: \"exponential\",\n maxDelayMs: 30000,\n jitter: 0.1,\n onRetry: (attempt, error) => {\n log.debug(`Reconnection attempt ${attempt}/${this.options.maxReconnectAttempts} failed: ${error.message}`);\n },\n }\n ).catch((error) => {\n log.error(\"Max reconnection attempts reached:\", error.message);\n this.errorCallbacks.forEach((cb) =>\n cb(new ConnectionError(this.url, error, this.options.maxReconnectAttempts))\n );\n });\n }, this.options.reconnectInterval);\n }\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "AAFA6372D6EFBBA564756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/portable.ts
|
|
2
|
+
function notInstalled(name) {
|
|
3
|
+
throw new Error(`[@hypen-space/core] Portable helper "${name}" called before the engine was installed. ` + `Import @hypen-space/server, @hypen-space/web-engine, or call setPortableImpl() before use.`);
|
|
4
|
+
}
|
|
5
|
+
var current = {
|
|
6
|
+
diffState: () => notInstalled("diffState"),
|
|
7
|
+
matchPath: () => notInstalled("matchPath"),
|
|
8
|
+
pathGet: () => notInstalled("pathGet"),
|
|
9
|
+
pathHas: () => notInstalled("pathHas"),
|
|
10
|
+
pathSet: () => notInstalled("pathSet"),
|
|
11
|
+
pathDelete: () => notInstalled("pathDelete"),
|
|
12
|
+
encodeUriComponent: () => notInstalled("encodeUriComponent"),
|
|
13
|
+
decodeUriComponent: () => notInstalled("decodeUriComponent"),
|
|
14
|
+
parseQuery: () => notInstalled("parseQuery"),
|
|
15
|
+
buildUrl: () => notInstalled("buildUrl")
|
|
16
|
+
};
|
|
17
|
+
function setPortableImpl(impl) {
|
|
18
|
+
if (impl === null) {
|
|
19
|
+
current = {
|
|
20
|
+
diffState: () => notInstalled("diffState"),
|
|
21
|
+
matchPath: () => notInstalled("matchPath"),
|
|
22
|
+
pathGet: () => notInstalled("pathGet"),
|
|
23
|
+
pathHas: () => notInstalled("pathHas"),
|
|
24
|
+
pathSet: () => notInstalled("pathSet"),
|
|
25
|
+
pathDelete: () => notInstalled("pathDelete"),
|
|
26
|
+
encodeUriComponent: () => notInstalled("encodeUriComponent"),
|
|
27
|
+
decodeUriComponent: () => notInstalled("decodeUriComponent"),
|
|
28
|
+
parseQuery: () => notInstalled("parseQuery"),
|
|
29
|
+
buildUrl: () => notInstalled("buildUrl")
|
|
30
|
+
};
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
current = impl;
|
|
34
|
+
}
|
|
35
|
+
var portable = {
|
|
36
|
+
diffState: (o, n, b) => current.diffState(o, n, b),
|
|
37
|
+
matchPath: (p, path) => current.matchPath(p, path),
|
|
38
|
+
pathGet: (v, p) => current.pathGet(v, p),
|
|
39
|
+
pathHas: (v, p) => current.pathHas(v, p),
|
|
40
|
+
pathSet: (v, p, nv) => current.pathSet(v, p, nv),
|
|
41
|
+
pathDelete: (v, p) => current.pathDelete(v, p),
|
|
42
|
+
encodeUriComponent: (s) => current.encodeUriComponent(s),
|
|
43
|
+
decodeUriComponent: (s) => current.decodeUriComponent(s),
|
|
44
|
+
parseQuery: (f) => current.parseQuery(f),
|
|
45
|
+
buildUrl: (p, q) => current.buildUrl(p, q)
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { setPortableImpl, portable };
|
|
49
|
+
|
|
50
|
+
//# debugId=7697B63C9FB38F6A64756E2164756E21
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/portable.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Portable helper DI seam for `@hypen-space/core`.\n *\n * `@hypen-space/core` is WASM-free: it ships to contexts (tree-shakers,\n * browser remote-client bundles) that must not pull in the 2 MB engine\n * binary. But every Hypen runtime — Node via `@hypen-space/server`,\n * browser SPA via `@hypen-space/web-engine`, tests via the bun preload\n * — composes core with a host that HAS loaded the engine. Those\n * packages call [`setPortableImpl`] at their init time, plugging the\n * canonical Rust-engine impls (`hypen-engine-rs/src/portable/`) into\n * core.\n *\n * There is no TypeScript fallback. A core instance whose portable\n * helpers are called before `setPortableImpl` has run throws a clear\n * error pointing at the fix. Reasons:\n *\n * * Any fallback is a second implementation to maintain, exactly the\n * drift tax this whole refactor eliminated for the other four SDKs.\n * * Core's own consumers of the portable helpers (`HypenRouter.matchPath`,\n * `createObservableState`'s `notifyChange`) are never reached in\n * remote-client-only browser bundles — those bundles drive UI purely\n * from server-pushed patches.\n * * Callers who DO reach the helpers (server, web-engine, tests) always\n * have an engine available.\n */\n\nimport type { StateChange, StatePath } from \"./state\";\n\nexport interface PortableImpl {\n diffState(oldState: any, newState: any, basePath?: string): StateChange;\n matchPath(\n pattern: string,\n path: string,\n ): { params: Record<string, string> } | null;\n pathGet(value: any, path: string): unknown;\n pathHas(value: any, path: string): boolean;\n pathSet(value: any, path: string, newValue: any): any;\n pathDelete(value: any, path: string): { value: any; removed: boolean };\n encodeUriComponent(input: string): string;\n decodeUriComponent(input: string): string;\n parseQuery(fullPath: string): { path: string; query: Record<string, string> };\n buildUrl(path: string, query: Record<string, string>): string;\n}\n\nfunction notInstalled(name: string): never {\n throw new Error(\n `[@hypen-space/core] Portable helper \"${name}\" called before the engine was installed. ` +\n `Import @hypen-space/server, @hypen-space/web-engine, or call setPortableImpl() before use.`,\n );\n}\n\nlet current: PortableImpl = {\n diffState: () => notInstalled(\"diffState\"),\n matchPath: () => notInstalled(\"matchPath\"),\n pathGet: () => notInstalled(\"pathGet\"),\n pathHas: () => notInstalled(\"pathHas\"),\n pathSet: () => notInstalled(\"pathSet\"),\n pathDelete: () => notInstalled(\"pathDelete\"),\n encodeUriComponent: () => notInstalled(\"encodeUriComponent\"),\n decodeUriComponent: () => notInstalled(\"decodeUriComponent\"),\n parseQuery: () => notInstalled(\"parseQuery\"),\n buildUrl: () => notInstalled(\"buildUrl\"),\n};\n\n/**\n * Install the engine-backed [`PortableImpl`]. Called once by\n * `@hypen-space/server` at import time (synchronous wasm-node load) or\n * by `@hypen-space/web-engine` inside `Engine.init()` once the browser\n * wasm module resolves.\n *\n * Passing `null` resets to the \"not installed\" throwing default — used\n * by test teardown when you want to verify the no-engine error path.\n */\nexport function setPortableImpl(impl: PortableImpl | null): void {\n if (impl === null) {\n current = {\n diffState: () => notInstalled(\"diffState\"),\n matchPath: () => notInstalled(\"matchPath\"),\n pathGet: () => notInstalled(\"pathGet\"),\n pathHas: () => notInstalled(\"pathHas\"),\n pathSet: () => notInstalled(\"pathSet\"),\n pathDelete: () => notInstalled(\"pathDelete\"),\n encodeUriComponent: () => notInstalled(\"encodeUriComponent\"),\n decodeUriComponent: () => notInstalled(\"decodeUriComponent\"),\n parseQuery: () => notInstalled(\"parseQuery\"),\n buildUrl: () => notInstalled(\"buildUrl\"),\n };\n return;\n }\n current = impl;\n}\n\n/**\n * Live portable API. Every call dispatches to whatever\n * [`setPortableImpl`] most recently installed.\n */\nexport const portable: PortableImpl = {\n diffState: (o, n, b) => current.diffState(o, n, b),\n matchPath: (p, path) => current.matchPath(p, path),\n pathGet: (v, p) => current.pathGet(v, p),\n pathHas: (v, p) => current.pathHas(v, p),\n pathSet: (v, p, nv) => current.pathSet(v, p, nv),\n pathDelete: (v, p) => current.pathDelete(v, p),\n encodeUriComponent: (s) => current.encodeUriComponent(s),\n decodeUriComponent: (s) => current.decodeUriComponent(s),\n parseQuery: (f) => current.parseQuery(f),\n buildUrl: (p, q) => current.buildUrl(p, q),\n};\n\n// `StatePath` is still part of this module's public surface via\n// re-export; keeping it reachable from `@hypen-space/core/portable`\n// simplifies bindings in server / web-engine adapters.\nexport type { StatePath } from \"./state\";\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";AA4CA,SAAS,YAAY,CAAC,MAAqB;AAAA,EACzC,MAAM,IAAI,MACR,wCAAwC,mDACtC,4FACJ;AAAA;AAGF,IAAI,UAAwB;AAAA,EAC1B,WAAW,MAAM,aAAa,WAAW;AAAA,EACzC,WAAW,MAAM,aAAa,WAAW;AAAA,EACzC,SAAS,MAAM,aAAa,SAAS;AAAA,EACrC,SAAS,MAAM,aAAa,SAAS;AAAA,EACrC,SAAS,MAAM,aAAa,SAAS;AAAA,EACrC,YAAY,MAAM,aAAa,YAAY;AAAA,EAC3C,oBAAoB,MAAM,aAAa,oBAAoB;AAAA,EAC3D,oBAAoB,MAAM,aAAa,oBAAoB;AAAA,EAC3D,YAAY,MAAM,aAAa,YAAY;AAAA,EAC3C,UAAU,MAAM,aAAa,UAAU;AACzC;AAWO,SAAS,eAAe,CAAC,MAAiC;AAAA,EAC/D,IAAI,SAAS,MAAM;AAAA,IACjB,UAAU;AAAA,MACR,WAAW,MAAM,aAAa,WAAW;AAAA,MACzC,WAAW,MAAM,aAAa,WAAW;AAAA,MACzC,SAAS,MAAM,aAAa,SAAS;AAAA,MACrC,SAAS,MAAM,aAAa,SAAS;AAAA,MACrC,SAAS,MAAM,aAAa,SAAS;AAAA,MACrC,YAAY,MAAM,aAAa,YAAY;AAAA,MAC3C,oBAAoB,MAAM,aAAa,oBAAoB;AAAA,MAC3D,oBAAoB,MAAM,aAAa,oBAAoB;AAAA,MAC3D,YAAY,MAAM,aAAa,YAAY;AAAA,MAC3C,UAAU,MAAM,aAAa,UAAU;AAAA,IACzC;AAAA,IACA;AAAA,EACF;AAAA,EACA,UAAU;AAAA;AAOL,IAAM,WAAyB;AAAA,EACpC,WAAW,CAAC,GAAG,GAAG,MAAM,QAAQ,UAAU,GAAG,GAAG,CAAC;AAAA,EACjD,WAAW,CAAC,GAAG,SAAS,QAAQ,UAAU,GAAG,IAAI;AAAA,EACjD,SAAS,CAAC,GAAG,MAAM,QAAQ,QAAQ,GAAG,CAAC;AAAA,EACvC,SAAS,CAAC,GAAG,MAAM,QAAQ,QAAQ,GAAG,CAAC;AAAA,EACvC,SAAS,CAAC,GAAG,GAAG,OAAO,QAAQ,QAAQ,GAAG,GAAG,EAAE;AAAA,EAC/C,YAAY,CAAC,GAAG,MAAM,QAAQ,WAAW,GAAG,CAAC;AAAA,EAC7C,oBAAoB,CAAC,MAAM,QAAQ,mBAAmB,CAAC;AAAA,EACvD,oBAAoB,CAAC,MAAM,QAAQ,mBAAmB,CAAC;AAAA,EACvD,YAAY,CAAC,MAAM,QAAQ,WAAW,CAAC;AAAA,EACvC,UAAU,CAAC,GAAG,MAAM,QAAQ,SAAS,GAAG,CAAC;AAC3C;",
|
|
8
|
+
"debugId": "7697B63C9FB38F6A64756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// src/remote/session.ts
|
|
2
|
+
class SessionManager {
|
|
3
|
+
activeSessions = new Map;
|
|
4
|
+
pendingSessions = new Map;
|
|
5
|
+
sessionConnections = new Map;
|
|
6
|
+
config;
|
|
7
|
+
constructor(config = {}) {
|
|
8
|
+
this.config = {
|
|
9
|
+
ttl: config.ttl ?? 3600,
|
|
10
|
+
concurrent: config.concurrent ?? "kick-old",
|
|
11
|
+
generateId: config.generateId ?? (() => crypto.randomUUID())
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
getTtl() {
|
|
15
|
+
return this.config.ttl;
|
|
16
|
+
}
|
|
17
|
+
getConcurrentPolicy() {
|
|
18
|
+
return this.config.concurrent;
|
|
19
|
+
}
|
|
20
|
+
createSession(props) {
|
|
21
|
+
const now = new Date;
|
|
22
|
+
const session = {
|
|
23
|
+
id: this.config.generateId(),
|
|
24
|
+
ttl: this.config.ttl,
|
|
25
|
+
createdAt: now,
|
|
26
|
+
lastConnectedAt: now,
|
|
27
|
+
props
|
|
28
|
+
};
|
|
29
|
+
this.activeSessions.set(session.id, session);
|
|
30
|
+
return session;
|
|
31
|
+
}
|
|
32
|
+
getActiveSession(id) {
|
|
33
|
+
return this.activeSessions.get(id) ?? null;
|
|
34
|
+
}
|
|
35
|
+
getPendingSession(id) {
|
|
36
|
+
return this.pendingSessions.get(id) ?? null;
|
|
37
|
+
}
|
|
38
|
+
hasSession(id) {
|
|
39
|
+
return this.activeSessions.has(id) || this.pendingSessions.has(id);
|
|
40
|
+
}
|
|
41
|
+
suspendSession(sessionId, savedState, onExpire) {
|
|
42
|
+
const session = this.activeSessions.get(sessionId);
|
|
43
|
+
if (!session)
|
|
44
|
+
return;
|
|
45
|
+
this.activeSessions.delete(sessionId);
|
|
46
|
+
const expiryTimer = setTimeout(async () => {
|
|
47
|
+
const pending = this.pendingSessions.get(sessionId);
|
|
48
|
+
if (pending) {
|
|
49
|
+
this.pendingSessions.delete(sessionId);
|
|
50
|
+
await onExpire(pending.session);
|
|
51
|
+
}
|
|
52
|
+
}, session.ttl * 1000);
|
|
53
|
+
this.pendingSessions.set(sessionId, {
|
|
54
|
+
session,
|
|
55
|
+
savedState,
|
|
56
|
+
expiryTimer
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
resumeSession(sessionId) {
|
|
60
|
+
const pending = this.pendingSessions.get(sessionId);
|
|
61
|
+
if (!pending)
|
|
62
|
+
return null;
|
|
63
|
+
clearTimeout(pending.expiryTimer);
|
|
64
|
+
this.pendingSessions.delete(sessionId);
|
|
65
|
+
pending.session.lastConnectedAt = new Date;
|
|
66
|
+
this.activeSessions.set(sessionId, pending.session);
|
|
67
|
+
return {
|
|
68
|
+
session: pending.session,
|
|
69
|
+
savedState: pending.savedState
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
destroySession(sessionId) {
|
|
73
|
+
this.activeSessions.delete(sessionId);
|
|
74
|
+
const pending = this.pendingSessions.get(sessionId);
|
|
75
|
+
if (pending) {
|
|
76
|
+
clearTimeout(pending.expiryTimer);
|
|
77
|
+
this.pendingSessions.delete(sessionId);
|
|
78
|
+
}
|
|
79
|
+
this.sessionConnections.delete(sessionId);
|
|
80
|
+
}
|
|
81
|
+
trackConnection(sessionId, ws) {
|
|
82
|
+
let connections = this.sessionConnections.get(sessionId);
|
|
83
|
+
if (!connections) {
|
|
84
|
+
connections = new Set;
|
|
85
|
+
this.sessionConnections.set(sessionId, connections);
|
|
86
|
+
}
|
|
87
|
+
connections.add(ws);
|
|
88
|
+
}
|
|
89
|
+
untrackConnection(sessionId, ws) {
|
|
90
|
+
const connections = this.sessionConnections.get(sessionId);
|
|
91
|
+
if (connections) {
|
|
92
|
+
connections.delete(ws);
|
|
93
|
+
if (connections.size === 0) {
|
|
94
|
+
this.sessionConnections.delete(sessionId);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
getConnections(sessionId) {
|
|
99
|
+
return this.sessionConnections.get(sessionId);
|
|
100
|
+
}
|
|
101
|
+
getConnectionCount(sessionId) {
|
|
102
|
+
return this.sessionConnections.get(sessionId)?.size ?? 0;
|
|
103
|
+
}
|
|
104
|
+
getStats() {
|
|
105
|
+
let totalConnections = 0;
|
|
106
|
+
for (const connections of this.sessionConnections.values()) {
|
|
107
|
+
totalConnections += connections.size;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
activeSessions: this.activeSessions.size,
|
|
111
|
+
pendingSessions: this.pendingSessions.size,
|
|
112
|
+
totalConnections
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
destroy() {
|
|
116
|
+
for (const pending of this.pendingSessions.values()) {
|
|
117
|
+
clearTimeout(pending.expiryTimer);
|
|
118
|
+
}
|
|
119
|
+
this.activeSessions.clear();
|
|
120
|
+
this.pendingSessions.clear();
|
|
121
|
+
this.sessionConnections.clear();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { SessionManager };
|
|
126
|
+
|
|
127
|
+
//# debugId=5E921125D9117C4D64756E2164756E21
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/remote/session.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Session Management for Remote UI\n *\n * Manages session lifecycle including:\n * - Creating new sessions\n * - Suspending sessions on disconnect (pending reconnection)\n * - Resuming sessions on reconnect\n * - Expiring sessions after TTL\n * - Handling concurrent connections\n */\n\nimport type { Session, SessionConfig } from \"./types.js\";\n\n/**\n * Internal representation of a pending (disconnected) session\n */\ninterface PendingSession {\n session: Session;\n savedState: unknown;\n expiryTimer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * Callback invoked when a session expires\n */\nexport type SessionExpireCallback = (session: Session) => void | Promise<void>;\n\n/**\n * Manages session lifecycle for remote UI connections\n */\nexport class SessionManager {\n /** Active sessions (currently connected) */\n private activeSessions = new Map<string, Session>();\n\n /** Pending sessions (disconnected, waiting for reconnect within TTL) */\n private pendingSessions = new Map<string, PendingSession>();\n\n /** Maps session ID to connected WebSocket(s) for concurrent handling */\n private sessionConnections = new Map<string, Set<unknown>>();\n\n /** Resolved configuration with defaults */\n private config: Required<SessionConfig>;\n\n constructor(config: SessionConfig = {}) {\n this.config = {\n ttl: config.ttl ?? 3600, // 1 hour default\n concurrent: config.concurrent ?? \"kick-old\",\n generateId: config.generateId ?? (() => crypto.randomUUID()),\n };\n }\n\n /**\n * Get the configured TTL in seconds\n */\n getTtl(): number {\n return this.config.ttl;\n }\n\n /**\n * Get the concurrent connection policy\n */\n getConcurrentPolicy(): \"kick-old\" | \"reject-new\" | \"allow-multiple\" {\n return this.config.concurrent;\n }\n\n /**\n * Create a new session\n */\n createSession(props?: Record<string, any>): Session {\n const now = new Date();\n const session: Session = {\n id: this.config.generateId(),\n ttl: this.config.ttl,\n createdAt: now,\n lastConnectedAt: now,\n props,\n };\n this.activeSessions.set(session.id, session);\n return session;\n }\n\n /**\n * Get an active (connected) session by ID\n */\n getActiveSession(id: string): Session | null {\n return this.activeSessions.get(id) ?? null;\n }\n\n /**\n * Get a pending (disconnected) session by ID\n */\n getPendingSession(id: string): PendingSession | null {\n return this.pendingSessions.get(id) ?? null;\n }\n\n /**\n * Check if a session exists (either active or pending)\n */\n hasSession(id: string): boolean {\n return this.activeSessions.has(id) || this.pendingSessions.has(id);\n }\n\n /**\n * Suspend a session when client disconnects\n * Moves from active to pending with a TTL timer\n *\n * @param sessionId - The session to suspend\n * @param savedState - State snapshot to restore on reconnect\n * @param onExpire - Callback when TTL expires\n */\n suspendSession(\n sessionId: string,\n savedState: unknown,\n onExpire: SessionExpireCallback\n ): void {\n const session = this.activeSessions.get(sessionId);\n if (!session) return;\n\n // Remove from active\n this.activeSessions.delete(sessionId);\n\n // Set up expiry timer\n const expiryTimer = setTimeout(async () => {\n const pending = this.pendingSessions.get(sessionId);\n if (pending) {\n this.pendingSessions.delete(sessionId);\n await onExpire(pending.session);\n }\n }, session.ttl * 1000);\n\n // Add to pending\n this.pendingSessions.set(sessionId, {\n session,\n savedState,\n expiryTimer,\n });\n }\n\n /**\n * Resume a pending session when client reconnects\n *\n * @param sessionId - The session to resume\n * @returns Session and saved state, or null if not found/expired\n */\n resumeSession(\n sessionId: string\n ): { session: Session; savedState: unknown } | null {\n const pending = this.pendingSessions.get(sessionId);\n if (!pending) return null;\n\n // Clear expiry timer\n clearTimeout(pending.expiryTimer);\n this.pendingSessions.delete(sessionId);\n\n // Update last connected time\n pending.session.lastConnectedAt = new Date();\n\n // Move back to active\n this.activeSessions.set(sessionId, pending.session);\n\n return {\n session: pending.session,\n savedState: pending.savedState,\n };\n }\n\n /**\n * Destroy a session completely (both active and pending)\n */\n destroySession(sessionId: string): void {\n this.activeSessions.delete(sessionId);\n\n const pending = this.pendingSessions.get(sessionId);\n if (pending) {\n clearTimeout(pending.expiryTimer);\n this.pendingSessions.delete(sessionId);\n }\n\n this.sessionConnections.delete(sessionId);\n }\n\n /**\n * Track a WebSocket connection for a session\n * Used for concurrent connection handling\n */\n trackConnection(sessionId: string, ws: unknown): void {\n let connections = this.sessionConnections.get(sessionId);\n if (!connections) {\n connections = new Set();\n this.sessionConnections.set(sessionId, connections);\n }\n connections.add(ws);\n }\n\n /**\n * Untrack a WebSocket connection\n */\n untrackConnection(sessionId: string, ws: unknown): void {\n const connections = this.sessionConnections.get(sessionId);\n if (connections) {\n connections.delete(ws);\n if (connections.size === 0) {\n this.sessionConnections.delete(sessionId);\n }\n }\n }\n\n /**\n * Get all connections for a session\n */\n getConnections(sessionId: string): Set<unknown> | undefined {\n return this.sessionConnections.get(sessionId);\n }\n\n /**\n * Get connection count for a session\n */\n getConnectionCount(sessionId: string): number {\n return this.sessionConnections.get(sessionId)?.size ?? 0;\n }\n\n /**\n * Get stats about current sessions\n */\n getStats(): {\n activeSessions: number;\n pendingSessions: number;\n totalConnections: number;\n } {\n let totalConnections = 0;\n for (const connections of this.sessionConnections.values()) {\n totalConnections += connections.size;\n }\n\n return {\n activeSessions: this.activeSessions.size,\n pendingSessions: this.pendingSessions.size,\n totalConnections,\n };\n }\n\n /**\n * Clean up all sessions and timers\n * Call this when shutting down the server\n */\n destroy(): void {\n // Clear all pending session timers\n for (const pending of this.pendingSessions.values()) {\n clearTimeout(pending.expiryTimer);\n }\n\n this.activeSessions.clear();\n this.pendingSessions.clear();\n this.sessionConnections.clear();\n }\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "5E921125D9117C4D64756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|