@yoltra/devtools-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ /*!
2
+ * @yoltra/devtools-server v0.2.0
3
+ * (c) 2026 Manu Ramirez <@pixerael>
4
+ * License: MIT
5
+ * Homepage: https://yoltra.dev
6
+ */
7
+ import { DevtoolsRole as r, PROTOCOL_VERSION as l } from "@yoltra/devtools-protocol";
8
+ import { WebSocketServer as h, WebSocket as d } from "ws";
9
+ class f {
10
+ /**
11
+ * @param capacity - Maximum number of items. Must be at least 1.
12
+ */
13
+ constructor(t) {
14
+ if (this.capacity = t, this.head = 0, this.count = 0, t < 1) throw new Error("RingBuffer capacity must be >= 1");
15
+ this.items = new Array(t);
16
+ }
17
+ /**
18
+ * Push an item. Overwrites the oldest if at capacity.
19
+ *
20
+ * @param item - Item to add.
21
+ *
22
+ * @public
23
+ */
24
+ push(t) {
25
+ this.items[this.head] = t, this.head = (this.head + 1) % this.capacity, this.count < this.capacity && this.count++;
26
+ }
27
+ /**
28
+ * Returns all items in insertion order (oldest first).
29
+ *
30
+ * @returns A new array containing buffered items from oldest to newest.
31
+ *
32
+ * @public
33
+ */
34
+ toArray() {
35
+ if (this.count === 0) return [];
36
+ const t = [], e = this.count < this.capacity ? 0 : this.head;
37
+ for (let s = 0; s < this.count; s++)
38
+ t.push(this.items[(e + s) % this.capacity]);
39
+ return t;
40
+ }
41
+ /**
42
+ * Current number of items stored in the buffer.
43
+ *
44
+ * @returns A value between `0` and {@link capacity} inclusive.
45
+ *
46
+ * @public
47
+ */
48
+ get size() {
49
+ return this.count;
50
+ }
51
+ /**
52
+ * Remove all items.
53
+ *
54
+ * @public
55
+ */
56
+ clear() {
57
+ this.items.fill(void 0), this.head = 0, this.count = 0;
58
+ }
59
+ }
60
+ class p {
61
+ constructor() {
62
+ this.stores = /* @__PURE__ */ new Map(), this.extensions = /* @__PURE__ */ new Map();
63
+ }
64
+ /**
65
+ * Register a newly handshaked connection.
66
+ *
67
+ * @param info - Connection info from the completed handshake.
68
+ *
69
+ * @public
70
+ */
71
+ register(t) {
72
+ t.role === r.STORE ? this.stores.set(t.id, t) : this.extensions.set(t.id, t);
73
+ }
74
+ /**
75
+ * Remove a connection by ID.
76
+ *
77
+ * @param id - Client ID to remove.
78
+ * @param role - Client role (`STORE` or `EXTENSION`).
79
+ *
80
+ * @public
81
+ */
82
+ unregister(t, e) {
83
+ e === r.STORE ? this.stores.delete(t) : this.extensions.delete(t);
84
+ }
85
+ /**
86
+ * Get the WebSocket for a specific store.
87
+ *
88
+ * @param storeId - Store UUID.
89
+ * @returns The store's WebSocket, or `undefined` if not connected.
90
+ *
91
+ * @public
92
+ */
93
+ getStoreSocket(t) {
94
+ return this.stores.get(t)?.ws;
95
+ }
96
+ /**
97
+ * Route a message from a store to all extensions (fan-out).
98
+ *
99
+ * @remarks
100
+ * Only sends to extensions whose WebSocket is in the `OPEN` ready-state;
101
+ * connections in a closing or closed state are silently skipped.
102
+ *
103
+ * @param message - Serialized JSON message string.
104
+ *
105
+ * @public
106
+ */
107
+ fanOutToExtensions(t) {
108
+ for (const [, e] of this.extensions)
109
+ e.ws.readyState === e.ws.OPEN && e.ws.send(t);
110
+ }
111
+ /**
112
+ * Route a message from an extension to a specific store.
113
+ *
114
+ * @param storeId - Target store UUID.
115
+ * @param message - Serialized JSON message string.
116
+ * @returns `true` if the message was sent, `false` if the store was
117
+ * not found or its socket was not open.
118
+ *
119
+ * @public
120
+ */
121
+ sendToStore(t, e) {
122
+ const s = this.stores.get(t);
123
+ return !s || s.ws.readyState !== s.ws.OPEN ? !1 : (s.ws.send(e), !0);
124
+ }
125
+ /**
126
+ * Build a `STORE_CONNECTED` broadcast message.
127
+ *
128
+ * @param info - Store connection info (must have {@link ConnectionInfo.storeInfo}).
129
+ * @returns Serialized {@link StoreConnected} JSON string.
130
+ *
131
+ * @public
132
+ */
133
+ buildStoreConnectedMessage(t) {
134
+ if (!t.storeInfo) return null;
135
+ const e = {
136
+ type: "STORE_CONNECTED",
137
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
138
+ sourceId: "hub",
139
+ sourceRole: r.HUB,
140
+ store: {
141
+ id: t.id,
142
+ name: t.storeInfo.name,
143
+ capabilities: t.storeInfo.capabilities
144
+ }
145
+ };
146
+ return JSON.stringify(e);
147
+ }
148
+ /**
149
+ * Build a `STORE_DISCONNECTED` broadcast message.
150
+ *
151
+ * @param storeId - Disconnected store ID.
152
+ * @param reason - Optional human-readable disconnect reason.
153
+ * @returns Serialized {@link StoreDisconnected} JSON string.
154
+ *
155
+ * @public
156
+ */
157
+ buildStoreDisconnectedMessage(t, e) {
158
+ const s = {
159
+ type: "STORE_DISCONNECTED",
160
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
161
+ sourceId: "hub",
162
+ sourceRole: r.HUB,
163
+ storeId: t,
164
+ reason: e
165
+ };
166
+ return JSON.stringify(s);
167
+ }
168
+ /**
169
+ * Build a `STORE_REGISTRY` message listing all connected stores.
170
+ *
171
+ * @returns Serialized {@link StoreRegistry} JSON string.
172
+ *
173
+ * @public
174
+ */
175
+ buildRegistryMessage() {
176
+ const t = {
177
+ type: "STORE_REGISTRY",
178
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
179
+ sourceId: "hub",
180
+ sourceRole: r.HUB,
181
+ stores: Array.from(this.stores.values()).flatMap((e) => e.storeInfo ? [
182
+ {
183
+ id: e.id,
184
+ name: e.storeInfo.name,
185
+ status: "connected",
186
+ capabilities: e.storeInfo.capabilities,
187
+ connectedAt: e.connectedAt
188
+ }
189
+ ] : [])
190
+ };
191
+ return JSON.stringify(t);
192
+ }
193
+ /**
194
+ * Number of connected stores.
195
+ *
196
+ * @returns Current store connection count.
197
+ *
198
+ * @public
199
+ */
200
+ get storeCount() {
201
+ return this.stores.size;
202
+ }
203
+ /**
204
+ * Number of connected extensions.
205
+ *
206
+ * @returns Current extension connection count.
207
+ *
208
+ * @public
209
+ */
210
+ get extensionCount() {
211
+ return this.extensions.size;
212
+ }
213
+ }
214
+ const S = 5e3, y = 8 * 1024 * 1024;
215
+ function g(o, t) {
216
+ if (!o || t.includes(o)) return !0;
217
+ let e;
218
+ try {
219
+ e = new URL(o);
220
+ } catch {
221
+ return !1;
222
+ }
223
+ return e.protocol === "chrome-extension:" || e.protocol === "moz-extension:" || e.protocol === "safari-web-extension:" ? !0 : m(e.hostname);
224
+ }
225
+ function m(o) {
226
+ const t = o.replace(/^\[|\]$/g, "");
227
+ return t === "localhost" || t.endsWith(".localhost") || t === "127.0.0.1" || t.startsWith("127.") || t === "::1" || t === "0:0:0:0:0:0:0:1";
228
+ }
229
+ class O {
230
+ /**
231
+ * Create a new DevTools hub instance.
232
+ *
233
+ * @param opts - Hub configuration. All fields are optional.
234
+ *
235
+ * @public
236
+ */
237
+ constructor(t = {}) {
238
+ this.router = new p(), this.wss = null, this.port = t.port ?? 9800, this.host = t.host ?? "127.0.0.1", this.allowedOrigins = t.allowedOrigins ?? [], this.history = new f(t.historySize ?? 1e3);
239
+ }
240
+ /**
241
+ * Start the WebSocket server and begin accepting connections.
242
+ *
243
+ * @returns Resolves once the server is bound and listening.
244
+ * @throws If the underlying `WebSocketServer` emits an error during
245
+ * startup (e.g. port already in use).
246
+ *
247
+ * @public
248
+ */
249
+ async start() {
250
+ return new Promise((t, e) => {
251
+ this.wss = new h({
252
+ port: this.port,
253
+ host: this.host,
254
+ // Bound the frame size (DEV-1): oversized frames fan out to every
255
+ // extension and buffer into history, so an unbounded cap is a local
256
+ // DoS / memory-amplification vector.
257
+ maxPayload: y,
258
+ // Reject cross-site WebSocket hijacking: the loopback bind alone does
259
+ // not stop a page you visit from opening ws://127.0.0.1:<port>.
260
+ verifyClient: (s) => g(s.origin, this.allowedOrigins) ? !0 : (console.warn(
261
+ `[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${s.origin}`
262
+ ), !1)
263
+ }), this.wss.on("listening", () => {
264
+ t();
265
+ }), this.wss.on("error", (s) => {
266
+ e(s);
267
+ }), this.wss.on("connection", (s) => {
268
+ this.handleConnection(s);
269
+ });
270
+ });
271
+ }
272
+ /**
273
+ * Stop the server and close all connections.
274
+ *
275
+ * @remarks
276
+ * Existing client sockets are closed with code `1001` ("Going Away")
277
+ * before the server socket is torn down.
278
+ *
279
+ * @returns Resolves once the server has fully shut down.
280
+ *
281
+ * @public
282
+ */
283
+ async stop() {
284
+ return new Promise((t) => {
285
+ if (!this.wss) {
286
+ t();
287
+ return;
288
+ }
289
+ this.wss.close(() => {
290
+ this.wss = null, t();
291
+ });
292
+ for (const e of this.wss.clients)
293
+ e.close(1001, "Hub shutting down");
294
+ });
295
+ }
296
+ /**
297
+ * Check if a DevTools hub is already running on the given port.
298
+ *
299
+ * @param port - Port to probe.
300
+ * @returns `true` if a hub is listening and responds to handshake.
301
+ *
302
+ * @public
303
+ */
304
+ static async probe(t) {
305
+ return new Promise((e) => {
306
+ const s = new d(`ws://127.0.0.1:${t}`), n = setTimeout(() => {
307
+ s.close(), e(!1);
308
+ }, 2e3);
309
+ s.on("open", () => {
310
+ clearTimeout(n), s.close(), e(!0);
311
+ }), s.on("error", () => {
312
+ clearTimeout(n), e(!1);
313
+ });
314
+ });
315
+ }
316
+ /**
317
+ * Handle a new WebSocket connection: wait for handshake, then route messages.
318
+ *
319
+ * @remarks
320
+ * Starts a handshake timeout timer. If the first valid message is a
321
+ * `HANDSHAKE_REQUEST` the connection is promoted to a routed client;
322
+ * otherwise it is closed after {@link HANDSHAKE_TIMEOUT_MS}.
323
+ *
324
+ * @param ws - Newly accepted WebSocket.
325
+ */
326
+ handleConnection(t) {
327
+ let e = null;
328
+ const s = setTimeout(() => {
329
+ e || t.close(1008, "Handshake timeout");
330
+ }, S);
331
+ t.on("message", (n) => {
332
+ let i;
333
+ try {
334
+ i = JSON.parse(n.toString());
335
+ } catch {
336
+ return;
337
+ }
338
+ if (!(i === null || typeof i != "object" || Array.isArray(i)) && typeof i.type == "string") {
339
+ if (!e) {
340
+ i.type === "HANDSHAKE_REQUEST" && (clearTimeout(s), e = this.handleHandshake(t, i), e || t.close(1008, "Handshake failed"));
341
+ return;
342
+ }
343
+ this.routeMessage(e, i);
344
+ }
345
+ }), t.on("close", () => {
346
+ clearTimeout(s), e && this.handleDisconnect(e);
347
+ }), t.on("error", () => {
348
+ });
349
+ }
350
+ /**
351
+ * Process a handshake request: validate, register, and respond.
352
+ *
353
+ * @remarks
354
+ * Performs a major-version compatibility check against
355
+ * {@link PROTOCOL_VERSION}. On success the connection is registered with
356
+ * the {@link Router} and post-handshake side-effects are triggered
357
+ * (store-connected broadcast or registry + history replay).
358
+ *
359
+ * @param ws - The client WebSocket.
360
+ * @param req - Parsed handshake request payload.
361
+ * @returns The new {@link ConnectionInfo} on success, or `null` if the
362
+ * handshake was rejected.
363
+ */
364
+ handleHandshake(t, e) {
365
+ const s = parseInt(e.protocolVersion?.split(".")[0] ?? "0"), n = parseInt(l.split(".")[0]);
366
+ if (s !== n) {
367
+ const u = {
368
+ type: "HANDSHAKE_RESPONSE",
369
+ success: !1,
370
+ negotiatedVersion: l,
371
+ hubCapabilities: {
372
+ maxHistorySize: this.history.capacity,
373
+ supportedFeatures: []
374
+ },
375
+ error: `Incompatible protocol version: ${e.protocolVersion} (hub: ${l})`
376
+ };
377
+ return t.send(JSON.stringify(u)), null;
378
+ }
379
+ const i = e.role === r.STORE ? e.store?.id : e.extension?.id;
380
+ if (!i)
381
+ return console.warn(
382
+ `[yoltra devtools] Rejected handshake: role ${e.role} without a matching id payload`
383
+ ), null;
384
+ const a = {
385
+ ws: t,
386
+ role: e.role,
387
+ id: i,
388
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString()
389
+ };
390
+ e.role === r.STORE && e.store ? a.storeInfo = {
391
+ name: e.store.name,
392
+ capabilities: e.store.capabilities
393
+ } : e.role === r.EXTENSION && e.extension && (a.extensionInfo = {
394
+ name: e.extension.name,
395
+ capabilities: e.extension.capabilities
396
+ }), this.router.register(a);
397
+ const c = {
398
+ type: "HANDSHAKE_RESPONSE",
399
+ success: !0,
400
+ negotiatedVersion: l,
401
+ hubCapabilities: {
402
+ maxHistorySize: this.history.capacity,
403
+ supportedFeatures: []
404
+ }
405
+ };
406
+ if (t.send(JSON.stringify(c)), e.role === r.STORE) {
407
+ const u = this.router.buildStoreConnectedMessage(a);
408
+ u && this.router.fanOutToExtensions(u);
409
+ } else if (e.role === r.EXTENSION) {
410
+ t.send(this.router.buildRegistryMessage());
411
+ for (const u of this.history.toArray())
412
+ t.send(u);
413
+ }
414
+ return a;
415
+ }
416
+ /**
417
+ * Route a post-handshake message based on the sender's role.
418
+ *
419
+ * @remarks
420
+ * Store messages are fanned-out to all extensions and, if the message
421
+ * type is `STORE_EVENT`, buffered in the ring buffer for replay.
422
+ * Extension messages are forwarded to the store identified by
423
+ * `msg.storeId`.
424
+ *
425
+ * @param sender - Connection info of the sending client.
426
+ * @param msg - Parsed message payload (untyped; serialized internally).
427
+ */
428
+ routeMessage(t, e) {
429
+ const s = JSON.stringify(e);
430
+ if (t.role === r.STORE)
431
+ this.router.fanOutToExtensions(s), e.type === "STORE_EVENT" && this.history.push(s);
432
+ else {
433
+ const n = e.storeId;
434
+ n && this.router.sendToStore(n, s);
435
+ }
436
+ }
437
+ /**
438
+ * Handle a client disconnection.
439
+ *
440
+ * @remarks
441
+ * Unregisters the client from the {@link Router}. If the client was a
442
+ * store, a `STORE_DISCONNECTED` event is broadcast to all extensions.
443
+ *
444
+ * @param info - Connection info of the disconnected client.
445
+ */
446
+ handleDisconnect(t) {
447
+ if (this.router.unregister(t.id, t.role), t.role === r.STORE) {
448
+ const e = this.router.buildStoreDisconnectedMessage(t.id, "disconnected");
449
+ this.router.fanOutToExtensions(e);
450
+ }
451
+ }
452
+ /**
453
+ * Current number of connected stores.
454
+ *
455
+ * @public
456
+ */
457
+ get storeCount() {
458
+ return this.router.storeCount;
459
+ }
460
+ /**
461
+ * Current number of connected extensions.
462
+ *
463
+ * @public
464
+ */
465
+ get extensionCount() {
466
+ return this.router.extensionCount;
467
+ }
468
+ /**
469
+ * Number of events in the history ring buffer.
470
+ *
471
+ * @public
472
+ */
473
+ get historySize() {
474
+ return this.history.size;
475
+ }
476
+ }
477
+ async function T(o = process.argv) {
478
+ const t = o.indexOf("--port"), e = parseInt(
479
+ o.find((c) => c.startsWith("--port="))?.split("=")[1] ?? (t !== -1 ? o[t + 1] : void 0) ?? "9800"
480
+ ), s = o.indexOf("--history-size"), n = parseInt(
481
+ o.find((c) => c.startsWith("--history-size="))?.split("=")[1] ?? (s !== -1 ? o[s + 1] : void 0) ?? "1000"
482
+ ), i = new O({ port: e, historySize: n }), a = async () => {
483
+ console.log(`
484
+ Shutting down DevTools hub...`), await i.stop(), process.exit(0);
485
+ };
486
+ process.on("SIGINT", a), process.on("SIGTERM", a);
487
+ try {
488
+ await i.start(), console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${e}`), console.log(`History buffer: ${n} events`);
489
+ } catch (c) {
490
+ console.error("Failed to start DevTools hub:", c), process.exit(1);
491
+ }
492
+ }
493
+ export {
494
+ O as DevtoolsHub,
495
+ f as RingBuffer,
496
+ T as startCli
497
+ };
498
+ //# sourceMappingURL=devtools-server.esm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devtools-server.esm.js","sources":["../src/ring-buffer.ts","../src/router.ts","../src/hub.ts","../src/cli.ts"],"sourcesContent":["/**\n * Fixed-size circular buffer for bounded event retention.\n *\n * @module @yoltra/devtools-server\n */\n\n/**\n * Fixed-size circular buffer that overwrites the oldest entry on overflow.\n *\n * @typeParam T - Item type stored in the buffer.\n *\n * @remarks\n * Used by the hub to retain event history for late-connecting extensions.\n * The buffer pre-allocates an array of the given capacity and uses modular\n * arithmetic to track insertion position, making {@link push} an O(1)\n * operation with no memory allocation after construction.\n *\n * @public\n */\nexport class RingBuffer<T> {\n private readonly items: Array<T | undefined>;\n private head = 0;\n private count = 0;\n\n /**\n * @param capacity - Maximum number of items. Must be at least 1.\n */\n constructor(public readonly capacity: number) {\n if (capacity < 1) throw new Error(\"RingBuffer capacity must be >= 1\");\n this.items = new Array(capacity);\n }\n\n /**\n * Push an item. Overwrites the oldest if at capacity.\n *\n * @param item - Item to add.\n *\n * @public\n */\n push(item: T): void {\n this.items[this.head] = item;\n this.head = (this.head + 1) % this.capacity;\n if (this.count < this.capacity) {\n this.count++;\n }\n }\n\n /**\n * Returns all items in insertion order (oldest first).\n *\n * @returns A new array containing buffered items from oldest to newest.\n *\n * @public\n */\n toArray(): T[] {\n if (this.count === 0) return [];\n const result: T[] = [];\n const start = this.count < this.capacity ? 0 : this.head;\n for (let i = 0; i < this.count; i++) {\n result.push(this.items[(start + i) % this.capacity] as T);\n }\n return result;\n }\n\n /**\n * Current number of items stored in the buffer.\n *\n * @returns A value between `0` and {@link capacity} inclusive.\n *\n * @public\n */\n get size(): number {\n return this.count;\n }\n\n /**\n * Remove all items.\n *\n * @public\n */\n clear(): void {\n this.items.fill(undefined);\n this.head = 0;\n this.count = 0;\n }\n}\n","/**\n * Message routing layer for the DevTools hub.\n *\n * @module @yoltra/devtools-server\n */\n\nimport {\n DevtoolsRole,\n type StoreConnected,\n type StoreDisconnected,\n type StoreRegistry,\n} from \"@yoltra/devtools-protocol\";\nimport type { WebSocket } from \"ws\";\nimport type { ConnectionInfo } from \"./connection\";\n\n/**\n * Routes DevTools protocol messages between stores and extensions.\n *\n * @remarks\n * The router maintains two parallel maps -- one for store connections and\n * one for extension connections -- and exposes helpers that implement the\n * three core routing patterns of the DevTools protocol:\n *\n * - **Fan-out**: Store messages are forwarded to every connected extension.\n * - **Targeted delivery**: Extension commands are routed to a specific\n * store identified by `storeId`.\n * - **Lifecycle broadcast**: `STORE_CONNECTED` / `STORE_DISCONNECTED`\n * events are broadcast to all extensions whenever a store joins or\n * leaves.\n *\n * @public\n */\nexport class Router {\n /** All store connections, keyed by store ID. */\n private readonly stores = new Map<string, ConnectionInfo>();\n /** All extension connections, keyed by extension ID. */\n private readonly extensions = new Map<string, ConnectionInfo>();\n\n /**\n * Register a newly handshaked connection.\n *\n * @param info - Connection info from the completed handshake.\n *\n * @public\n */\n register(info: ConnectionInfo): void {\n if (info.role === DevtoolsRole.STORE) {\n this.stores.set(info.id, info);\n } else {\n this.extensions.set(info.id, info);\n }\n }\n\n /**\n * Remove a connection by ID.\n *\n * @param id - Client ID to remove.\n * @param role - Client role (`STORE` or `EXTENSION`).\n *\n * @public\n */\n unregister(id: string, role: DevtoolsRole): void {\n if (role === DevtoolsRole.STORE) {\n this.stores.delete(id);\n } else {\n this.extensions.delete(id);\n }\n }\n\n /**\n * Get the WebSocket for a specific store.\n *\n * @param storeId - Store UUID.\n * @returns The store's WebSocket, or `undefined` if not connected.\n *\n * @public\n */\n getStoreSocket(storeId: string): WebSocket | undefined {\n return this.stores.get(storeId)?.ws;\n }\n\n /**\n * Route a message from a store to all extensions (fan-out).\n *\n * @remarks\n * Only sends to extensions whose WebSocket is in the `OPEN` ready-state;\n * connections in a closing or closed state are silently skipped.\n *\n * @param message - Serialized JSON message string.\n *\n * @public\n */\n fanOutToExtensions(message: string): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState === ext.ws.OPEN) {\n ext.ws.send(message);\n }\n }\n }\n\n /**\n * Route a message from an extension to a specific store.\n *\n * @param storeId - Target store UUID.\n * @param message - Serialized JSON message string.\n * @returns `true` if the message was sent, `false` if the store was\n * not found or its socket was not open.\n *\n * @public\n */\n sendToStore(storeId: string, message: string): boolean {\n const store = this.stores.get(storeId);\n if (!store || store.ws.readyState !== store.ws.OPEN) return false;\n store.ws.send(message);\n return true;\n }\n\n /**\n * Build a `STORE_CONNECTED` broadcast message.\n *\n * @param info - Store connection info (must have {@link ConnectionInfo.storeInfo}).\n * @returns Serialized {@link StoreConnected} JSON string.\n *\n * @public\n */\n buildStoreConnectedMessage(info: ConnectionInfo): string | null {\n // Only a fully-registered STORE connection carries storeInfo. Guard instead\n // of asserting so an incomplete registration can't crash the hub; the caller\n // skips fan-out when this returns null.\n if (!info.storeInfo) return null;\n const msg: StoreConnected = {\n type: \"STORE_CONNECTED\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n store: {\n id: info.id,\n name: info.storeInfo.name,\n capabilities: info.storeInfo.capabilities,\n },\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Build a `STORE_DISCONNECTED` broadcast message.\n *\n * @param storeId - Disconnected store ID.\n * @param reason - Optional human-readable disconnect reason.\n * @returns Serialized {@link StoreDisconnected} JSON string.\n *\n * @public\n */\n buildStoreDisconnectedMessage(storeId: string, reason?: string): string {\n const msg: StoreDisconnected = {\n type: \"STORE_DISCONNECTED\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n storeId,\n reason,\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Build a `STORE_REGISTRY` message listing all connected stores.\n *\n * @returns Serialized {@link StoreRegistry} JSON string.\n *\n * @public\n */\n buildRegistryMessage(): string {\n const msg: StoreRegistry = {\n type: \"STORE_REGISTRY\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n stores: Array.from(this.stores.values()).flatMap((s) => {\n // Skip connections whose registration hasn't completed (no storeInfo).\n if (!s.storeInfo) return [];\n return [\n {\n id: s.id,\n name: s.storeInfo.name,\n status: \"connected\" as const,\n capabilities: s.storeInfo.capabilities,\n connectedAt: s.connectedAt,\n },\n ];\n }),\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Number of connected stores.\n *\n * @returns Current store connection count.\n *\n * @public\n */\n get storeCount(): number {\n return this.stores.size;\n }\n\n /**\n * Number of connected extensions.\n *\n * @returns Current extension connection count.\n *\n * @public\n */\n get extensionCount(): number {\n return this.extensions.size;\n }\n}\n","/**\n * Central WebSocket hub that brokers DevTools protocol traffic.\n *\n * @module @yoltra/devtools-server\n */\n\nimport {\n DevtoolsRole,\n PROTOCOL_VERSION,\n type HandshakeRequest,\n type HandshakeResponse,\n} from \"@yoltra/devtools-protocol\";\nimport { WebSocket, WebSocketServer } from \"ws\";\nimport type { ConnectionInfo } from \"./connection\";\nimport { RingBuffer } from \"./ring-buffer\";\nimport { Router } from \"./router\";\n\n/**\n * Configuration for the DevTools hub server.\n *\n * @remarks\n * All fields are optional; sensible defaults are applied when omitted.\n *\n * @public\n */\nexport interface DevtoolsHubOptions {\n /** Port to bind on. @default 9800 */\n port?: number;\n /** Host to bind on. @default \"127.0.0.1\" (localhost only for v1 security) */\n host?: string;\n /** Maximum events retained in the ring buffer for late-connecting extensions. @default 1000 */\n historySize?: number;\n /**\n * Extra WebSocket `Origin` values to accept, beyond the always-allowed set\n * (no Origin, browser-extension origins, and loopback origins). Use this only\n * for a non-loopback local dev host (e.g. a custom `.local` domain). Adding a\n * remote origin re-opens the cross-site hijack surface — don't.\n */\n allowedOrigins?: string[];\n}\n\n/**\n * Timeout for receiving a handshake request after a WebSocket connection\n * is established, in milliseconds.\n *\n * @remarks\n * If the client does not send a valid `HANDSHAKE_REQUEST` within this\n * window the connection is closed with code `1008` (Policy Violation).\n *\n * @internal\n */\nconst HANDSHAKE_TIMEOUT_MS = 5_000;\n\n/**\n * Maximum accepted WebSocket frame size (bytes). Frames fan out to every\n * extension and buffer into history, so an unbounded size is a local\n * DoS / memory-amplification vector. 8 MiB comfortably covers real state\n * snapshots while rejecting hostile oversized frames.\n */\nconst MAX_WS_PAYLOAD_BYTES = 8 * 1024 * 1024;\n\n/**\n * Whether a WebSocket `Origin` may connect to the hub.\n *\n * @remarks\n * The hub binds to loopback, but that does not stop a page you visit from\n * opening `ws://127.0.0.1:<port>` — WebSockets are exempt from same-origin/CORS,\n * so a remote page could otherwise exfiltrate state and drive the store. We\n * allow only: no Origin (node agent, CLI, some extension contexts), browser\n * extension origins (the user-installed panel), loopback origins (the local dev\n * app running the agent, or a local storeview), and any explicitly configured\n * origins. A remote origin (e.g. `https://evil.com`) is rejected.\n *\n * @internal\n */\nfunction isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean {\n if (!origin) return true; // non-browser client; not reachable from a web page\n if (allowed.includes(origin)) return true;\n let url: URL;\n try {\n url = new URL(origin);\n } catch {\n return false;\n }\n if (\n url.protocol === \"chrome-extension:\" ||\n url.protocol === \"moz-extension:\" ||\n url.protocol === \"safari-web-extension:\"\n ) {\n return true;\n }\n return isLoopbackHost(url.hostname);\n}\n\n/** Loopback host check: `localhost`, the 127.0.0.0/8 block, and IPv6 `::1`. @internal */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h.startsWith(\"127.\") ||\n h === \"::1\" ||\n h === \"0:0:0:0:0:0:0:1\"\n );\n}\n\n/**\n * Central WebSocket hub that brokers messages between Yoltra stores and DevTools extensions.\n *\n * @remarks\n * - Accepts WS connections, validates protocol handshakes, and routes messages.\n * - Store events are fan-out to all extension clients.\n * - Extension commands are routed to the target store by `storeId`.\n * - Maintains a ring buffer of recent events for late-connecting extensions.\n * - Binds to localhost only (v1 security).\n *\n * @example Embeddable usage\n * ```ts\n * import { DevtoolsHub } from '@yoltra/devtools-server';\n *\n * const hub = new DevtoolsHub({ port: 9800 });\n * await hub.start();\n * // ... later\n * await hub.stop();\n * ```\n *\n * @public\n */\nexport class DevtoolsHub {\n private readonly port: number;\n private readonly host: string;\n private readonly allowedOrigins: readonly string[];\n private readonly router = new Router();\n private readonly history: RingBuffer<string>;\n private wss: WebSocketServer | null = null;\n\n /**\n * Create a new DevTools hub instance.\n *\n * @param opts - Hub configuration. All fields are optional.\n *\n * @public\n */\n constructor(opts: DevtoolsHubOptions = {}) {\n this.port = opts.port ?? 9800;\n this.host = opts.host ?? \"127.0.0.1\";\n this.allowedOrigins = opts.allowedOrigins ?? [];\n this.history = new RingBuffer<string>(opts.historySize ?? 1000);\n }\n\n /**\n * Start the WebSocket server and begin accepting connections.\n *\n * @returns Resolves once the server is bound and listening.\n * @throws If the underlying `WebSocketServer` emits an error during\n * startup (e.g. port already in use).\n *\n * @public\n */\n async start(): Promise<void> {\n return new Promise((resolve, reject) => {\n this.wss = new WebSocketServer({\n port: this.port,\n host: this.host,\n // Bound the frame size (DEV-1): oversized frames fan out to every\n // extension and buffer into history, so an unbounded cap is a local\n // DoS / memory-amplification vector.\n maxPayload: MAX_WS_PAYLOAD_BYTES,\n // Reject cross-site WebSocket hijacking: the loopback bind alone does\n // not stop a page you visit from opening ws://127.0.0.1:<port>.\n verifyClient: (info: { origin?: string }) => {\n if (isOriginAllowed(info.origin, this.allowedOrigins)) return true;\n console.warn(\n `[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${info.origin}`,\n );\n return false;\n },\n });\n\n this.wss.on(\"listening\", () => {\n resolve();\n });\n\n this.wss.on(\"error\", (err) => {\n reject(err);\n });\n\n this.wss.on(\"connection\", (ws) => {\n this.handleConnection(ws);\n });\n });\n }\n\n /**\n * Stop the server and close all connections.\n *\n * @remarks\n * Existing client sockets are closed with code `1001` (\"Going Away\")\n * before the server socket is torn down.\n *\n * @returns Resolves once the server has fully shut down.\n *\n * @public\n */\n async stop(): Promise<void> {\n return new Promise((resolve) => {\n if (!this.wss) {\n resolve();\n return;\n }\n this.wss.close(() => {\n this.wss = null;\n resolve();\n });\n // Close all existing connections\n for (const client of this.wss.clients) {\n client.close(1001, \"Hub shutting down\");\n }\n });\n }\n\n /**\n * Check if a DevTools hub is already running on the given port.\n *\n * @param port - Port to probe.\n * @returns `true` if a hub is listening and responds to handshake.\n *\n * @public\n */\n static async probe(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const ws = new WebSocket(`ws://127.0.0.1:${port}`);\n const timeout = setTimeout(() => {\n ws.close();\n resolve(false);\n }, 2_000);\n\n ws.on(\"open\", () => {\n clearTimeout(timeout);\n ws.close();\n resolve(true);\n });\n\n ws.on(\"error\", () => {\n clearTimeout(timeout);\n resolve(false);\n });\n });\n }\n\n /**\n * Handle a new WebSocket connection: wait for handshake, then route messages.\n *\n * @remarks\n * Starts a handshake timeout timer. If the first valid message is a\n * `HANDSHAKE_REQUEST` the connection is promoted to a routed client;\n * otherwise it is closed after {@link HANDSHAKE_TIMEOUT_MS}.\n *\n * @param ws - Newly accepted WebSocket.\n */\n private handleConnection(ws: WebSocket): void {\n let connectionInfo: ConnectionInfo | null = null;\n\n // Handshake timeout: close if no handshake within 5s\n const handshakeTimer = setTimeout(() => {\n if (!connectionInfo) {\n ws.close(1008, \"Handshake timeout\");\n }\n }, HANDSHAKE_TIMEOUT_MS);\n\n ws.on(\"message\", (data) => {\n let parsed: any;\n try {\n parsed = JSON.parse(data.toString());\n } catch {\n return; // Ignore malformed messages\n }\n\n // Ingress validation (DEV-3): every protocol message is a plain object\n // with a string `type` discriminant. Reject anything else (null, arrays,\n // primitives, missing type) before it reaches handshake/routing.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return;\n if (typeof parsed.type !== \"string\") return;\n\n // Handle handshake\n if (!connectionInfo) {\n if (parsed.type === \"HANDSHAKE_REQUEST\") {\n clearTimeout(handshakeTimer);\n connectionInfo = this.handleHandshake(ws, parsed as HandshakeRequest);\n if (!connectionInfo) {\n ws.close(1008, \"Handshake failed\");\n }\n }\n return;\n }\n\n // Route messages based on role\n this.routeMessage(connectionInfo, parsed);\n });\n\n ws.on(\"close\", () => {\n clearTimeout(handshakeTimer);\n if (connectionInfo) {\n this.handleDisconnect(connectionInfo);\n }\n });\n\n ws.on(\"error\", () => {\n // Error is followed by close event, handled there\n });\n }\n\n /**\n * Process a handshake request: validate, register, and respond.\n *\n * @remarks\n * Performs a major-version compatibility check against\n * {@link PROTOCOL_VERSION}. On success the connection is registered with\n * the {@link Router} and post-handshake side-effects are triggered\n * (store-connected broadcast or registry + history replay).\n *\n * @param ws - The client WebSocket.\n * @param req - Parsed handshake request payload.\n * @returns The new {@link ConnectionInfo} on success, or `null` if the\n * handshake was rejected.\n */\n private handleHandshake(ws: WebSocket, req: HandshakeRequest): ConnectionInfo | null {\n // Basic protocol version check (accept same major version)\n const reqMajor = parseInt(req.protocolVersion?.split(\".\")[0] ?? \"0\");\n const ourMajor = parseInt(PROTOCOL_VERSION.split(\".\")[0]);\n if (reqMajor !== ourMajor) {\n const response: HandshakeResponse = {\n type: \"HANDSHAKE_RESPONSE\",\n success: false,\n negotiatedVersion: PROTOCOL_VERSION,\n hubCapabilities: {\n maxHistorySize: this.history.capacity,\n supportedFeatures: [],\n },\n error: `Incompatible protocol version: ${req.protocolVersion} (hub: ${PROTOCOL_VERSION})`,\n };\n ws.send(JSON.stringify(response));\n return null;\n }\n\n // A STORE handshake must carry `store`, an EXTENSION handshake `extension`.\n // Guard the role/payload match instead of dereferencing a missing field.\n const id = req.role === DevtoolsRole.STORE ? req.store?.id : req.extension?.id;\n if (!id) {\n console.warn(\n `[yoltra devtools] Rejected handshake: role ${req.role} without a matching id payload`,\n );\n return null;\n }\n\n // Build connection info\n const info: ConnectionInfo = {\n ws,\n role: req.role,\n id,\n connectedAt: new Date().toISOString(),\n };\n\n if (req.role === DevtoolsRole.STORE && req.store) {\n info.storeInfo = {\n name: req.store.name,\n capabilities: req.store.capabilities,\n };\n } else if (req.role === DevtoolsRole.EXTENSION && req.extension) {\n info.extensionInfo = {\n name: req.extension.name,\n capabilities: req.extension.capabilities,\n };\n }\n\n // Register in router\n this.router.register(info);\n\n // Send handshake response\n const response: HandshakeResponse = {\n type: \"HANDSHAKE_RESPONSE\",\n success: true,\n negotiatedVersion: PROTOCOL_VERSION,\n hubCapabilities: {\n maxHistorySize: this.history.capacity,\n supportedFeatures: [],\n },\n };\n ws.send(JSON.stringify(response));\n\n // Post-handshake actions\n if (req.role === DevtoolsRole.STORE) {\n // Broadcast STORE_CONNECTED to all extensions\n const connectMsg = this.router.buildStoreConnectedMessage(info);\n if (connectMsg) this.router.fanOutToExtensions(connectMsg);\n } else if (req.role === DevtoolsRole.EXTENSION) {\n // Send current store registry to the new extension\n ws.send(this.router.buildRegistryMessage());\n // Send buffered event history\n for (const msg of this.history.toArray()) {\n ws.send(msg);\n }\n }\n\n return info;\n }\n\n /**\n * Route a post-handshake message based on the sender's role.\n *\n * @remarks\n * Store messages are fanned-out to all extensions and, if the message\n * type is `STORE_EVENT`, buffered in the ring buffer for replay.\n * Extension messages are forwarded to the store identified by\n * `msg.storeId`.\n *\n * @param sender - Connection info of the sending client.\n * @param msg - Parsed message payload (untyped; serialized internally).\n */\n private routeMessage(sender: ConnectionInfo, msg: any): void {\n const raw = JSON.stringify(msg);\n\n if (sender.role === DevtoolsRole.STORE) {\n // Store messages → fan-out to all extensions\n this.router.fanOutToExtensions(raw);\n\n // Buffer STORE_EVENT messages in the ring buffer\n if (msg.type === \"STORE_EVENT\") {\n this.history.push(raw);\n }\n } else {\n // Extension commands → route to target store\n const storeId = msg.storeId as string | undefined;\n if (storeId) {\n this.router.sendToStore(storeId, raw);\n }\n }\n }\n\n /**\n * Handle a client disconnection.\n *\n * @remarks\n * Unregisters the client from the {@link Router}. If the client was a\n * store, a `STORE_DISCONNECTED` event is broadcast to all extensions.\n *\n * @param info - Connection info of the disconnected client.\n */\n private handleDisconnect(info: ConnectionInfo): void {\n this.router.unregister(info.id, info.role);\n\n if (info.role === DevtoolsRole.STORE) {\n // Broadcast STORE_DISCONNECTED to all extensions\n const disconnectMsg = this.router.buildStoreDisconnectedMessage(info.id, \"disconnected\");\n this.router.fanOutToExtensions(disconnectMsg);\n }\n }\n\n /**\n * Current number of connected stores.\n *\n * @public\n */\n get storeCount(): number {\n return this.router.storeCount;\n }\n\n /**\n * Current number of connected extensions.\n *\n * @public\n */\n get extensionCount(): number {\n return this.router.extensionCount;\n }\n\n /**\n * Number of events in the history ring buffer.\n *\n * @public\n */\n get historySize(): number {\n return this.history.size;\n }\n}\n","/**\n * CLI entry-point for the standalone DevTools hub process.\n *\n * @module @yoltra/devtools-server\n */\n\nimport { DevtoolsHub } from \"./hub\";\n\n/**\n * Parse CLI arguments and start the hub server.\n *\n * @remarks\n * Supported flags:\n *\n * | Flag | Default | Description |\n * | ------------------ | ------- | ---------------------------------- |\n * | `--port` | `9800` | WebSocket port to bind on. |\n * | `--history-size` | `1000` | Ring-buffer capacity for replays. |\n *\n * The function installs `SIGINT` and `SIGTERM` handlers for graceful\n * shutdown and exits with code `1` if the server fails to start.\n *\n * Usage: `npx @yoltra/devtools-server [--port 9800] [--history-size 1000]`\n *\n * @param argv - Argument vector to parse. Defaults to `process.argv`.\n * @returns Resolves once the hub is listening; never resolves during\n * normal operation (the process stays alive until a signal).\n *\n * @public\n */\nexport async function main(argv: string[] = process.argv): Promise<void> {\n const portIdx = argv.indexOf(\"--port\");\n const port = parseInt(\n argv.find((a) => a.startsWith(\"--port=\"))?.split(\"=\")[1] ??\n (portIdx !== -1 ? argv[portIdx + 1] : undefined) ??\n \"9800\",\n );\n\n const histIdx = argv.indexOf(\"--history-size\");\n const historySize = parseInt(\n argv.find((a) => a.startsWith(\"--history-size=\"))?.split(\"=\")[1] ??\n (histIdx !== -1 ? argv[histIdx + 1] : undefined) ??\n \"1000\",\n );\n\n const hub = new DevtoolsHub({ port, historySize });\n\n // Graceful shutdown\n const shutdown = async () => {\n console.log(\"\\nShutting down DevTools hub...\");\n await hub.stop();\n process.exit(0);\n };\n\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n\n try {\n await hub.start();\n console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${port}`);\n console.log(`History buffer: ${historySize} events`);\n } catch (err) {\n console.error(\"Failed to start DevTools hub:\", err);\n process.exit(1);\n }\n}\n"],"names":["RingBuffer","capacity","item","result","start","i","Router","info","DevtoolsRole","id","role","storeId","message","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","isOriginAllowed","origin","allowed","url","isLoopbackHost","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","handshakeTimer","data","parsed","req","reqMajor","ourMajor","PROTOCOL_VERSION","response","connectMsg","sender","raw","disconnectMsg","main","argv","portIdx","a","histIdx","historySize","hub","shutdown"],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * CLI entry-point for the standalone DevTools hub process.
3
+ *
4
+ * @module @yoltra/devtools-server
5
+ */
6
+ /**
7
+ * Parse CLI arguments and start the hub server.
8
+ *
9
+ * @remarks
10
+ * Supported flags:
11
+ *
12
+ * | Flag | Default | Description |
13
+ * | ------------------ | ------- | ---------------------------------- |
14
+ * | `--port` | `9800` | WebSocket port to bind on. |
15
+ * | `--history-size` | `1000` | Ring-buffer capacity for replays. |
16
+ *
17
+ * The function installs `SIGINT` and `SIGTERM` handlers for graceful
18
+ * shutdown and exits with code `1` if the server fails to start.
19
+ *
20
+ * Usage: `npx @yoltra/devtools-server [--port 9800] [--history-size 1000]`
21
+ *
22
+ * @param argv - Argument vector to parse. Defaults to `process.argv`.
23
+ * @returns Resolves once the hub is listening; never resolves during
24
+ * normal operation (the process stays alive until a signal).
25
+ *
26
+ * @public
27
+ */
28
+ export declare function main(argv?: string[]): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { DevtoolsRole, ExtensionCapabilities, StoreCapabilities } from '@yoltra/devtools-protocol';
2
+ import { WebSocket } from 'ws';
3
+ /**
4
+ * Information tracked for each connected WebSocket client.
5
+ *
6
+ * @remarks
7
+ * A `ConnectionInfo` record is created during the handshake phase and
8
+ * persists for the lifetime of the WebSocket connection. The {@link Router}
9
+ * indexes these records by {@link ConnectionInfo.id | id} to enable
10
+ * targeted message routing and lifecycle broadcasts.
11
+ *
12
+ * @public
13
+ */
14
+ export interface ConnectionInfo {
15
+ /** The raw WebSocket instance used for sending and receiving frames. */
16
+ ws: WebSocket;
17
+ /** Role assigned during the handshake (`STORE` or `EXTENSION`). */
18
+ role: DevtoolsRole;
19
+ /** Unique client ID (store wrapper UUID or extension UUID). */
20
+ id: string;
21
+ /** ISO 8601 timestamp recording when the client connected. */
22
+ connectedAt: string;
23
+ /**
24
+ * Store-specific metadata, present only when {@link role} is
25
+ * {@link DevtoolsRole.STORE}.
26
+ */
27
+ storeInfo?: {
28
+ /** Human-readable store name. */
29
+ name: string;
30
+ /** Feature capabilities reported by the store. */
31
+ capabilities: StoreCapabilities;
32
+ };
33
+ /**
34
+ * Extension-specific metadata, present only when {@link role} is
35
+ * {@link DevtoolsRole.EXTENSION}.
36
+ */
37
+ extensionInfo?: {
38
+ /** Human-readable extension name. */
39
+ name: string;
40
+ /** Feature capabilities reported by the extension. */
41
+ capabilities: ExtensionCapabilities;
42
+ };
43
+ }