@yoltra/devtools-server 0.4.0 → 0.6.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.
package/README.es.md ADDED
@@ -0,0 +1,162 @@
1
+ ![Yoltra logo](https://yoltra.dev/assets/yoltra-logo.png)
2
+
3
+ # @yoltra/devtools-server
4
+
5
+ > 👉 🇲🇽 Versión en Español  |  [ 🇺🇸 English Version](./README.md) 
6
+
7
+ **Hub WebSocket central que intermedia el tráfico del protocolo DevTools entre los stores de
8
+ Yoltra y las extensiones.**
9
+
10
+ `@yoltra/devtools-server` levanta un servidor WebSocket accesible solo desde localhost que atiende
11
+ los handshakes del protocolo, enruta mensajes entre stores y UIs de DevTools, y mantiene un búfer
12
+ circular de eventos recientes para las extensiones que se conectan tarde.
13
+
14
+ ---
15
+
16
+ ## Instalación
17
+
18
+ ```bash
19
+ npm install @yoltra/devtools-server
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Inicio rápido
25
+
26
+ ### Como librería
27
+
28
+ Empotra el hub en tu propio proceso (runner de pruebas, servidor de desarrollo, extensión de
29
+ VSCode):
30
+
31
+ ```typescript
32
+ import { DevtoolsHub } from "@yoltra/devtools-server";
33
+
34
+ const hub = new DevtoolsHub({ port: 9800 });
35
+ await hub.start();
36
+
37
+ console.log("Hub escuchando en ws://127.0.0.1:9800");
38
+ console.log("Stores conectados:", hub.storeCount);
39
+ console.log("Extensiones conectadas:", hub.extensionCount);
40
+
41
+ // Más tarde...
42
+ await hub.stop();
43
+ ```
44
+
45
+ ### Como CLI independiente
46
+
47
+ ```bash
48
+ npx @yoltra/devtools-server --port 9800 --history-size 1000
49
+ ```
50
+
51
+ O mediante el binario del proyecto:
52
+
53
+ ```bash
54
+ node ./bin/devtools-server.js --port 9800
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Cómo funciona
60
+
61
+ ```
62
+ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐
63
+ │ Store de │ ──── │ Hub de │ ──── │ UI de │
64
+ │ Yoltra │ WS │ DevTools │ WS │ DevTools │
65
+ │ │ ───► │ (este pkg) │ ───► │ (Extensión) │
66
+ └─────────────┘ └──────────────┘ └───────────────┘
67
+ │
68
+ Búfer circular
69
+ (historial de eventos)
70
+ ```
71
+
72
+ 1. Los **stores** se conectan y realizan el handshake del protocolo
73
+ 2. Los eventos del store se **difunden** a todas las extensiones conectadas
74
+ 3. Los comandos de las extensiones (peticiones de estado, viaje en el tiempo) se **enrutan** al
75
+ store destino por su `storeId`
76
+ 4. Los eventos recientes se **guardan en un búfer circular**, así que una extensión que se conecta
77
+ tarde recibe el historial
78
+
79
+ ---
80
+
81
+ ## Configuración
82
+
83
+ ```typescript
84
+ interface DevtoolsHubOptions {
85
+ /** Puerto en el que escuchar. @default 9800 */
86
+ port?: number;
87
+ /** Host en el que escuchar. @default "127.0.0.1" */
88
+ host?: string;
89
+ /** Máximo de eventos retenidos para extensiones que se conectan tarde. @default 1000 */
90
+ historySize?: number;
91
+ }
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Referencia de la API
97
+
98
+ ### `DevtoolsHub`
99
+
100
+ | Método / Propiedad | Descripción |
101
+ | ------------------------- | -------------------------------------------------- |
102
+ | `new DevtoolsHub(opts?)` | Crea una instancia del hub |
103
+ | `hub.start()` | Arranca el servidor WS (devuelve una Promise) |
104
+ | `hub.stop()` | Detiene el servidor y cierra todas las conexiones |
105
+ | `DevtoolsHub.probe(port)` | Comprueba si ya hay un hub corriendo en un puerto |
106
+ | `hub.storeCount` | Número de stores conectados |
107
+ | `hub.extensionCount` | Número de extensiones conectadas |
108
+ | `hub.historySize` | Número de eventos en el búfer circular |
109
+
110
+ ### `RingBuffer<T>`
111
+
112
+ Un búfer circular de tamaño fijo, usado internamente para el historial de eventos:
113
+
114
+ ```typescript
115
+ import { RingBuffer } from "@yoltra/devtools-server";
116
+
117
+ const buf = new RingBuffer<string>(100);
118
+ buf.push("event-1");
119
+ buf.push("event-2");
120
+ buf.toArray(); // ['event-1', 'event-2']
121
+ buf.size; // 2
122
+ buf.clear();
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Sondear antes de arrancar
128
+
129
+ Evita conflictos de puerto comprobando si ya hay un hub corriendo:
130
+
131
+ ```typescript
132
+ import { DevtoolsHub } from "@yoltra/devtools-server";
133
+
134
+ const alreadyRunning = await DevtoolsHub.probe(9800);
135
+
136
+ if (!alreadyRunning) {
137
+ const hub = new DevtoolsHub({ port: 9800 });
138
+ await hub.start();
139
+ }
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Seguridad
145
+
146
+ El hub escucha en `127.0.0.1` (solo localhost) por defecto. Es una restricción de seguridad
147
+ deliberada para v1: el hub no se expone a la red.
148
+
149
+ ---
150
+
151
+ ## Paquetes relacionados
152
+
153
+ - **[@yoltra/devtools-protocol](../devtools-protocol/README.md)** — Formato de cable y tipos de
154
+ mensaje
155
+ - **[@yoltra/devtools-browser-agent](../devtools-browser-agent/README.md)** — Conecta stores del
156
+ navegador a este hub
157
+
158
+ ---
159
+
160
+ ## Licencia
161
+
162
+ **MIT** — De uso libre en proyectos comerciales y de código abierto.
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { startCli } from "../dist/devtools-server.esm.js";
2
+ import { startCli } from "../dist/devtools-server.mjs";
3
3
 
4
4
  startCli();
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * @yoltra/devtools-server v0.4.0
2
+ * @yoltra/devtools-server v0.6.0
3
3
  * (c) 2026 Manu Ramirez <@pixerael>
4
4
  * License: MIT
5
5
  * Homepage: https://yoltra.dev
6
6
  */
7
7
  "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const i=require("@yoltra/devtools-protocol"),h=require("ws");class d{constructor(e){if(this.capacity=e,this.head=0,this.count=0,e<1)throw new Error("RingBuffer capacity must be >= 1");this.items=new Array(e)}push(e){this.items[this.head]=e,this.head=(this.head+1)%this.capacity,this.count<this.capacity&&this.count++}toArray(){if(this.count===0)return[];const e=[],t=this.count<this.capacity?0:this.head;for(let s=0;s<this.count;s++)e.push(this.items[(t+s)%this.capacity]);return e}get size(){return this.count}clear(){this.items.fill(void 0),this.head=0,this.count=0}}class S{constructor(){this.stores=new Map,this.extensions=new Map}register(e){e.role===i.DevtoolsRole.STORE?this.stores.set(e.id,e):this.extensions.set(e.id,e)}unregister(e,t){t===i.DevtoolsRole.STORE?this.stores.delete(e):this.extensions.delete(e)}getStoreSocket(e){return this.stores.get(e)?.ws}fanOutToExtensions(e,t){for(const[,s]of this.extensions)s.ws.readyState===s.ws.OPEN&&(t!==void 0&&!t(s.extensionInfo?.capabilities)||s.ws.send(e))}storeIds(){return[...this.stores.keys()]}sendToStore(e,t){const s=this.stores.get(e);return!s||s.ws.readyState!==s.ws.OPEN?!1:(s.ws.send(t),!0)}buildStoreConnectedMessage(e){if(!e.storeInfo)return null;const t={type:"STORE_CONNECTED",timestamp:new Date().toISOString(),sourceId:"hub",sourceRole:i.DevtoolsRole.HUB,store:{id:e.id,name:e.storeInfo.name,capabilities:e.storeInfo.capabilities}};return JSON.stringify(t)}buildStoreDisconnectedMessage(e,t){const s={type:"STORE_DISCONNECTED",timestamp:new Date().toISOString(),sourceId:"hub",sourceRole:i.DevtoolsRole.HUB,storeId:e,reason:t};return JSON.stringify(s)}buildRegistryMessage(){const e={type:"STORE_REGISTRY",timestamp:new Date().toISOString(),sourceId:"hub",sourceRole:i.DevtoolsRole.HUB,stores:Array.from(this.stores.values()).flatMap(t=>t.storeInfo?[{id:t.id,name:t.storeInfo.name,status:"connected",capabilities:t.storeInfo.capabilities,connectedAt:t.connectedAt}]:[])};return JSON.stringify(e)}get storeCount(){return this.stores.size}get extensionCount(){return this.extensions.size}}const p=5e3,g=8*1024*1024;function y(o,e){if(typeof e!="string"||e.length!==o.length)return!1;let t=0;for(let s=0;s<o.length;s+=1)t|=o.charCodeAt(s)^e.charCodeAt(s);return t===0}function O(o,e,t){if(!o||e.includes(o))return!0;let s;try{s=new URL(o)}catch{return!1}return s.protocol==="chrome-extension:"||s.protocol==="moz-extension:"||s.protocol==="safari-web-extension:"?t.length===0?!0:t.includes(s.hostname):w(s.hostname)}function m(o,e){try{const t=JSON.parse(o);return typeof t.storeId=="string"?e.has(t.storeId):!0}catch{return!0}}function w(o){const e=o.replace(/^\[|\]$/g,"");return e==="localhost"||e.endsWith(".localhost")||e==="127.0.0.1"||e.startsWith("127.")||e==="::1"||e==="0:0:0:0:0:0:0:1"}class f{constructor(e={}){this.router=new S,this.wss=null,this.port=e.port??9800,this.host=e.host??"127.0.0.1",this.allowedOrigins=e.allowedOrigins??[],this.authToken=e.authToken,this.allowedExtensionIds=e.allowedExtensionIds??[],this.maxMessagesPerSecond=e.maxMessagesPerSecond??200,this.history=new d(e.historySize??1e3)}async start(){return new Promise((e,t)=>{this.wss=new h.WebSocketServer({port:this.port,host:this.host,maxPayload:g,verifyClient:s=>O(s.origin,this.allowedOrigins,this.allowedExtensionIds)?!0:(console.warn(`[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${s.origin}`),!1)}),this.wss.on("listening",()=>{this.authToken===void 0&&console.warn("[yoltra devtools] Hub is running without an auth token: any process on this machine can read and drive the connected stores. Pass { authToken } (and the same value to each agent) on a shared or containerised host."),e()}),this.wss.on("error",s=>{t(s)}),this.wss.on("connection",s=>{this.handleConnection(s)})})}async stop(){return new Promise(e=>{if(!this.wss){e();return}this.wss.close(()=>{this.wss=null,e()});for(const t of this.wss.clients)t.close(1001,"Hub shutting down")})}static async probe(e){return new Promise(t=>{const s=new h.WebSocket(`ws://127.0.0.1:${e}`),n=setTimeout(()=>{s.close(),t(!1)},2e3);s.on("open",()=>{clearTimeout(n),s.close(),t(!0)}),s.on("error",()=>{clearTimeout(n),t(!1)})})}handleConnection(e){let t=null,s=Date.now(),n=0;const l=setTimeout(()=>{t||e.close(1008,"Handshake timeout")},p);e.on("message",c=>{let r;try{r=JSON.parse(c.toString())}catch{return}if(r===null||typeof r!="object"||Array.isArray(r)||typeof r.type!="string")return;const a=Date.now();if(a-s>=1e3&&(s=a,n=0),n+=1,n>this.maxMessagesPerSecond){n===this.maxMessagesPerSecond+1&&console.warn(`[yoltra devtools] A ${t?.role??"handshaking"} client exceeded ${this.maxMessagesPerSecond} messages/second; the excess is being dropped.`);return}if(!t){r.type==="HANDSHAKE_REQUEST"&&(clearTimeout(l),t=this.handleHandshake(e,r),t||e.close(1008,"Handshake failed"));return}this.routeMessage(t,r)}),e.on("close",()=>{clearTimeout(l),t&&this.handleDisconnect(t)}),e.on("error",()=>{})}handleHandshake(e,t){if(this.authToken!==void 0&&!y(this.authToken,t.authToken)){const a={type:"HANDSHAKE_RESPONSE",success:!1,negotiatedVersion:i.PROTOCOL_VERSION,hubCapabilities:{maxHistorySize:this.history.capacity,supportedFeatures:[]},error:"Invalid or missing auth token"};return e.send(JSON.stringify(a)),console.warn(`[yoltra devtools] Rejected a ${t.role} handshake: wrong or missing auth token`),null}const s=parseInt(t.protocolVersion?.split(".")[0]??"0"),n=parseInt(i.PROTOCOL_VERSION.split(".")[0]);if(s!==n){const a={type:"HANDSHAKE_RESPONSE",success:!1,negotiatedVersion:i.PROTOCOL_VERSION,hubCapabilities:{maxHistorySize:this.history.capacity,supportedFeatures:[]},error:`Incompatible protocol version: ${t.protocolVersion} (hub: ${i.PROTOCOL_VERSION})`};return e.send(JSON.stringify(a)),null}const l=t.role===i.DevtoolsRole.STORE?t.store?.id:t.extension?.id;if(!l)return console.warn(`[yoltra devtools] Rejected handshake: role ${t.role} without a matching id payload`),null;const c={ws:e,role:t.role,id:l,connectedAt:new Date().toISOString()};t.role===i.DevtoolsRole.STORE&&t.store?c.storeInfo={name:t.store.name,capabilities:t.store.capabilities}:t.role===i.DevtoolsRole.EXTENSION&&t.extension&&(c.extensionInfo={name:t.extension.name,capabilities:t.extension.capabilities}),this.router.register(c);const r={type:"HANDSHAKE_RESPONSE",success:!0,negotiatedVersion:i.PROTOCOL_VERSION,hubCapabilities:{maxHistorySize:this.history.capacity,supportedFeatures:[]}};if(e.send(JSON.stringify(r)),t.role===i.DevtoolsRole.STORE){const a=this.router.buildStoreConnectedMessage(c);a&&this.router.fanOutToExtensions(a)}else if(t.role===i.DevtoolsRole.EXTENSION){e.send(this.router.buildRegistryMessage());const a=new Set(this.router.storeIds());for(const u of this.history.toArray())m(u,a)&&e.send(u)}return c}routeMessage(e,t){const s=JSON.stringify(t);if(e.role===i.DevtoolsRole.STORE)t.type==="STORE_METRICS"?this.router.fanOutToExtensions(s,n=>n?.performanceMetrics!==!1):this.router.fanOutToExtensions(s),t.type==="STORE_EVENT"&&this.history.push(s);else{const n=t.storeId;n&&this.router.sendToStore(n,s)}}handleDisconnect(e){if(this.router.unregister(e.id,e.role),e.role===i.DevtoolsRole.STORE){const t=this.router.buildStoreDisconnectedMessage(e.id,"disconnected");this.router.fanOutToExtensions(t)}}get storeCount(){return this.router.storeCount}get extensionCount(){return this.router.extensionCount}get historySize(){return this.history.size}}async function T(o=process.argv){const e=o.indexOf("--port"),t=parseInt(o.find(r=>r.startsWith("--port="))?.split("=")[1]??(e!==-1?o[e+1]:void 0)??"9800"),s=o.indexOf("--history-size"),n=parseInt(o.find(r=>r.startsWith("--history-size="))?.split("=")[1]??(s!==-1?o[s+1]:void 0)??"1000"),l=new f({port:t,historySize:n}),c=async()=>{console.log(`
8
8
  Shutting down DevTools hub...`),await l.stop(),process.exit(0)};process.on("SIGINT",c),process.on("SIGTERM",c);try{await l.start(),console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${t}`),console.log(`History buffer: ${n} events`)}catch(r){console.error("Failed to start DevTools hub:",r),process.exit(1)}}exports.DevtoolsHub=f;exports.RingBuffer=d;exports.startCli=T;
9
- //# sourceMappingURL=devtools-server.cjs.js.map
9
+ //# sourceMappingURL=devtools-server.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devtools-server.cjs","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 ExtensionCapabilities,\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 * @param wants - Optional predicate over an extension's declared capabilities. Used for\n * traffic an extension has said it cannot display; omit to reach every extension.\n *\n * @public\n */\n fanOutToExtensions(\n message: string,\n wants?: (capabilities: ExtensionCapabilities | undefined) => boolean,\n ): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState !== ext.ws.OPEN) continue;\n if (wants !== undefined && !wants(ext.extensionInfo?.capabilities)) continue;\n ext.ws.send(message);\n }\n }\n\n /**\n * Ids of every currently-connected store.\n *\n * @returns The ids, in registration order.\n *\n * @public\n */\n storeIds(): string[] {\n return [...this.stores.keys()];\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 * Shared secret every client must present in its handshake.\n *\n * @remarks\n * The hub binds to loopback, which keeps the network out — but loopback is not an\n * authentication boundary. Every other process on the machine can reach it, so without a token\n * anything running locally can connect as a panel and read the application's entire state,\n * inject events, and overwrite state through time-travel. That includes a package's install\n * script, and anything else sharing a CI runner or a container.\n *\n * Unset by default, because requiring one would break the zero-configuration local flow that\n * makes the tool worth using. When unset the hub says so once at startup rather than leaving\n * the exposure unmentioned.\n */\n authToken?: string;\n /**\n * Extension ids allowed to connect, e.g. `[\"abcdefghijklmnopabcdefghijklmnop\"]`.\n *\n * @remarks\n * Extension origins all share one scheme, so permitting the scheme permits every extension the\n * user has installed — any of which could open this socket from a devtools page of its own.\n * Naming ids narrows that to the panel meant to connect.\n *\n * Empty by default, which keeps every extension origin allowed: an unpacked build and a store\n * install have different ids, so assuming one would lock out a developer running the extension\n * they just built. Set it alongside {@link DevtoolsHubOptions.authToken} on any machine where\n * other extensions are not automatically trusted.\n */\n allowedExtensionIds?: string[];\n /**\n * Most messages one client may send per second before the excess is dropped.\n *\n * @remarks\n * A command like `REQUEST_STATE` costs the *store* a full serialization of its state and the\n * hub a fan-out, so a client that loops on it turns one cheap socket write into repeated work\n * across every connected process. This bounds that without affecting a panel behaving\n * normally, which sends a handful of commands per interaction.\n *\n * @defaultValue 200\n */\n maxMessagesPerSecond?: number;\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 * Compares two secrets without leaking their contents through timing.\n *\n * @remarks\n * `===` on a secret returns as soon as two characters differ, which is a usable oracle for\n * recovering it one character at a time from a process that can retry freely — and anything on\n * this machine can.\n *\n * @internal\n */\nfunction tokensMatch(expected: string, offered: unknown): boolean {\n if (typeof offered !== \"string\" || offered.length !== expected.length) return false;\n let diff = 0;\n for (let i = 0; i < expected.length; i += 1) {\n diff |= expected.charCodeAt(i) ^ offered.charCodeAt(i);\n }\n return diff === 0;\n}\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 (narrowed to specific ids when\n * {@link DevtoolsHubOptions.allowedExtensionIds} names any), loopback origins\n * (the local dev app running the agent, or a local storeview), and any\n * explicitly configured origins. A remote origin (e.g. `https://evil.com`) is\n * rejected.\n *\n * An origin check is not authentication: it constrains which *page* may open the\n * socket, and says nothing about which *process* did. That is what\n * {@link DevtoolsHubOptions.authToken} is for, and the two are meant to be used\n * together.\n *\n * @internal\n */\nfunction isOriginAllowed(\n origin: string | undefined,\n allowed: readonly string[],\n allowedExtensionIds: readonly string[],\n): 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 // Every extension shares one origin scheme, so allowing the scheme allows all of them: any\n // extension the user has installed, with a devtools page of its own, could open this socket\n // and read whatever the connected stores hold. The extension id is the host part, so an\n // allow-list narrows it to the panel actually meant to connect.\n //\n // Unset by default because there is no id to assume: an unpacked build and a store install\n // have different ones, so a hardcoded default would reject the developer running the\n // extension they just built.\n if (allowedExtensionIds.length === 0) return true;\n return allowedExtensionIds.includes(url.hostname);\n }\n return isLoopbackHost(url.hostname);\n}\n\n/**\n * `true` when a buffered frame belongs to a store that is still connected.\n *\n * @remarks\n * Parses only enough to read `storeId`. A frame that cannot be parsed is kept rather than\n * dropped: it went into the buffer as valid traffic, and silently discarding it here would be a\n * worse failure than replaying one frame too many.\n *\n * @internal\n */\nfunction belongsToLiveStore(raw: string, live: ReadonlySet<string>): boolean {\n try {\n const parsed = JSON.parse(raw) as { storeId?: unknown };\n return typeof parsed.storeId === \"string\" ? live.has(parsed.storeId) : true;\n } catch {\n return true;\n }\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 /** Shared secret required from every client, or `undefined` when the hub is open. */\n private readonly authToken: string | undefined;\n /** Extension ids permitted to connect; empty means every extension origin. */\n private readonly allowedExtensionIds: readonly string[];\n /** Per-second message allowance for one client. */\n private readonly maxMessagesPerSecond: number;\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.authToken = opts.authToken;\n this.allowedExtensionIds = opts.allowedExtensionIds ?? [];\n this.maxMessagesPerSecond = opts.maxMessagesPerSecond ?? 200;\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, this.allowedExtensionIds))\n 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 if (this.authToken === undefined) {\n // Said once, at the only moment it can still be acted on. Binding to loopback keeps\n // the network out but not the machine: every other local process — a package install\n // script, another tenant on a shared runner — can connect as a panel and read the\n // application's whole state. Silence here would present that as a secure default.\n console.warn(\n \"[yoltra devtools] Hub is running without an auth token: any process on this \" +\n \"machine can read and drive the connected stores. Pass { authToken } (and the \" +\n \"same value to each agent) on a shared or containerised host.\",\n );\n }\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 // A fixed window rather than a token bucket: the point is to stop a runaway loop, not to\n // shape traffic, and a counter reset on a timestamp comparison costs nothing per frame.\n let windowStart = Date.now();\n let inWindow = 0;\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 const now = Date.now();\n if (now - windowStart >= 1000) {\n windowStart = now;\n inWindow = 0;\n }\n inWindow += 1;\n if (inWindow > this.maxMessagesPerSecond) {\n // Dropped rather than answered. Closing the socket would punish a burst the same as a\n // flood, and a panel that briefly exceeds the allowance recovers on the next window.\n if (inWindow === this.maxMessagesPerSecond + 1) {\n console.warn(\n `[yoltra devtools] A ${connectionInfo?.role ?? \"handshaking\"} client exceeded ` +\n `${this.maxMessagesPerSecond} messages/second; the excess is being dropped.`,\n );\n }\n return;\n }\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 // Checked before anything is registered or replayed, so an unauthenticated client never\n // reaches the history buffer or the store registry.\n if (this.authToken !== undefined && !tokensMatch(this.authToken, req.authToken)) {\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: \"Invalid or missing auth token\",\n };\n ws.send(JSON.stringify(response));\n console.warn(\n `[yoltra devtools] Rejected a ${req.role} handshake: wrong or missing auth token`,\n );\n return null;\n }\n\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\n // Replay only what the panel can still act on. The buffer holds events from every store\n // that has ever connected, so a long-lived hub greets each new panel with a burst of\n // history for stores that are gone and cannot be selected — pure noise, sent one frame at\n // a time, before anything useful arrives.\n const live = new Set(this.router.storeIds());\n for (const msg of this.history.toArray()) {\n if (!belongsToLiveStore(msg, live)) continue;\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 // Metrics go only to panels that said they display them. The other capability flags\n // describe what an extension can render rather than what traffic it wants, so they are\n // not filters — the documentation used to imply all of them were, and none were.\n if (msg.type === \"STORE_METRICS\") {\n this.router.fanOutToExtensions(raw, (caps) => caps?.performanceMetrics !== false);\n } else {\n this.router.fanOutToExtensions(raw);\n }\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","wants","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","tokensMatch","expected","offered","diff","isOriginAllowed","origin","allowed","allowedExtensionIds","url","isLoopbackHost","belongsToLiveStore","raw","live","parsed","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","windowStart","inWindow","handshakeTimer","data","now","req","response","PROTOCOL_VERSION","reqMajor","ourMajor","connectMsg","sender","caps","disconnectMsg","main","argv","portIdx","a","histIdx","historySize","hub","shutdown"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @yoltra/devtools-server v0.4.0
2
+ * @yoltra/devtools-server v0.6.0
3
3
  * (c) 2026 Manu Ramirez <@pixerael>
4
4
  * License: MIT
5
5
  * Homepage: https://yoltra.dev
@@ -546,4 +546,4 @@ export {
546
546
  p as RingBuffer,
547
547
  x as startCli
548
548
  };
549
- //# sourceMappingURL=devtools-server.esm.js.map
549
+ //# sourceMappingURL=devtools-server.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devtools-server.mjs","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 ExtensionCapabilities,\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 * @param wants - Optional predicate over an extension's declared capabilities. Used for\n * traffic an extension has said it cannot display; omit to reach every extension.\n *\n * @public\n */\n fanOutToExtensions(\n message: string,\n wants?: (capabilities: ExtensionCapabilities | undefined) => boolean,\n ): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState !== ext.ws.OPEN) continue;\n if (wants !== undefined && !wants(ext.extensionInfo?.capabilities)) continue;\n ext.ws.send(message);\n }\n }\n\n /**\n * Ids of every currently-connected store.\n *\n * @returns The ids, in registration order.\n *\n * @public\n */\n storeIds(): string[] {\n return [...this.stores.keys()];\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 * Shared secret every client must present in its handshake.\n *\n * @remarks\n * The hub binds to loopback, which keeps the network out — but loopback is not an\n * authentication boundary. Every other process on the machine can reach it, so without a token\n * anything running locally can connect as a panel and read the application's entire state,\n * inject events, and overwrite state through time-travel. That includes a package's install\n * script, and anything else sharing a CI runner or a container.\n *\n * Unset by default, because requiring one would break the zero-configuration local flow that\n * makes the tool worth using. When unset the hub says so once at startup rather than leaving\n * the exposure unmentioned.\n */\n authToken?: string;\n /**\n * Extension ids allowed to connect, e.g. `[\"abcdefghijklmnopabcdefghijklmnop\"]`.\n *\n * @remarks\n * Extension origins all share one scheme, so permitting the scheme permits every extension the\n * user has installed — any of which could open this socket from a devtools page of its own.\n * Naming ids narrows that to the panel meant to connect.\n *\n * Empty by default, which keeps every extension origin allowed: an unpacked build and a store\n * install have different ids, so assuming one would lock out a developer running the extension\n * they just built. Set it alongside {@link DevtoolsHubOptions.authToken} on any machine where\n * other extensions are not automatically trusted.\n */\n allowedExtensionIds?: string[];\n /**\n * Most messages one client may send per second before the excess is dropped.\n *\n * @remarks\n * A command like `REQUEST_STATE` costs the *store* a full serialization of its state and the\n * hub a fan-out, so a client that loops on it turns one cheap socket write into repeated work\n * across every connected process. This bounds that without affecting a panel behaving\n * normally, which sends a handful of commands per interaction.\n *\n * @defaultValue 200\n */\n maxMessagesPerSecond?: number;\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 * Compares two secrets without leaking their contents through timing.\n *\n * @remarks\n * `===` on a secret returns as soon as two characters differ, which is a usable oracle for\n * recovering it one character at a time from a process that can retry freely — and anything on\n * this machine can.\n *\n * @internal\n */\nfunction tokensMatch(expected: string, offered: unknown): boolean {\n if (typeof offered !== \"string\" || offered.length !== expected.length) return false;\n let diff = 0;\n for (let i = 0; i < expected.length; i += 1) {\n diff |= expected.charCodeAt(i) ^ offered.charCodeAt(i);\n }\n return diff === 0;\n}\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 (narrowed to specific ids when\n * {@link DevtoolsHubOptions.allowedExtensionIds} names any), loopback origins\n * (the local dev app running the agent, or a local storeview), and any\n * explicitly configured origins. A remote origin (e.g. `https://evil.com`) is\n * rejected.\n *\n * An origin check is not authentication: it constrains which *page* may open the\n * socket, and says nothing about which *process* did. That is what\n * {@link DevtoolsHubOptions.authToken} is for, and the two are meant to be used\n * together.\n *\n * @internal\n */\nfunction isOriginAllowed(\n origin: string | undefined,\n allowed: readonly string[],\n allowedExtensionIds: readonly string[],\n): 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 // Every extension shares one origin scheme, so allowing the scheme allows all of them: any\n // extension the user has installed, with a devtools page of its own, could open this socket\n // and read whatever the connected stores hold. The extension id is the host part, so an\n // allow-list narrows it to the panel actually meant to connect.\n //\n // Unset by default because there is no id to assume: an unpacked build and a store install\n // have different ones, so a hardcoded default would reject the developer running the\n // extension they just built.\n if (allowedExtensionIds.length === 0) return true;\n return allowedExtensionIds.includes(url.hostname);\n }\n return isLoopbackHost(url.hostname);\n}\n\n/**\n * `true` when a buffered frame belongs to a store that is still connected.\n *\n * @remarks\n * Parses only enough to read `storeId`. A frame that cannot be parsed is kept rather than\n * dropped: it went into the buffer as valid traffic, and silently discarding it here would be a\n * worse failure than replaying one frame too many.\n *\n * @internal\n */\nfunction belongsToLiveStore(raw: string, live: ReadonlySet<string>): boolean {\n try {\n const parsed = JSON.parse(raw) as { storeId?: unknown };\n return typeof parsed.storeId === \"string\" ? live.has(parsed.storeId) : true;\n } catch {\n return true;\n }\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 /** Shared secret required from every client, or `undefined` when the hub is open. */\n private readonly authToken: string | undefined;\n /** Extension ids permitted to connect; empty means every extension origin. */\n private readonly allowedExtensionIds: readonly string[];\n /** Per-second message allowance for one client. */\n private readonly maxMessagesPerSecond: number;\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.authToken = opts.authToken;\n this.allowedExtensionIds = opts.allowedExtensionIds ?? [];\n this.maxMessagesPerSecond = opts.maxMessagesPerSecond ?? 200;\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, this.allowedExtensionIds))\n 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 if (this.authToken === undefined) {\n // Said once, at the only moment it can still be acted on. Binding to loopback keeps\n // the network out but not the machine: every other local process — a package install\n // script, another tenant on a shared runner — can connect as a panel and read the\n // application's whole state. Silence here would present that as a secure default.\n console.warn(\n \"[yoltra devtools] Hub is running without an auth token: any process on this \" +\n \"machine can read and drive the connected stores. Pass { authToken } (and the \" +\n \"same value to each agent) on a shared or containerised host.\",\n );\n }\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 // A fixed window rather than a token bucket: the point is to stop a runaway loop, not to\n // shape traffic, and a counter reset on a timestamp comparison costs nothing per frame.\n let windowStart = Date.now();\n let inWindow = 0;\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 const now = Date.now();\n if (now - windowStart >= 1000) {\n windowStart = now;\n inWindow = 0;\n }\n inWindow += 1;\n if (inWindow > this.maxMessagesPerSecond) {\n // Dropped rather than answered. Closing the socket would punish a burst the same as a\n // flood, and a panel that briefly exceeds the allowance recovers on the next window.\n if (inWindow === this.maxMessagesPerSecond + 1) {\n console.warn(\n `[yoltra devtools] A ${connectionInfo?.role ?? \"handshaking\"} client exceeded ` +\n `${this.maxMessagesPerSecond} messages/second; the excess is being dropped.`,\n );\n }\n return;\n }\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 // Checked before anything is registered or replayed, so an unauthenticated client never\n // reaches the history buffer or the store registry.\n if (this.authToken !== undefined && !tokensMatch(this.authToken, req.authToken)) {\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: \"Invalid or missing auth token\",\n };\n ws.send(JSON.stringify(response));\n console.warn(\n `[yoltra devtools] Rejected a ${req.role} handshake: wrong or missing auth token`,\n );\n return null;\n }\n\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\n // Replay only what the panel can still act on. The buffer holds events from every store\n // that has ever connected, so a long-lived hub greets each new panel with a burst of\n // history for stores that are gone and cannot be selected — pure noise, sent one frame at\n // a time, before anything useful arrives.\n const live = new Set(this.router.storeIds());\n for (const msg of this.history.toArray()) {\n if (!belongsToLiveStore(msg, live)) continue;\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 // Metrics go only to panels that said they display them. The other capability flags\n // describe what an extension can render rather than what traffic it wants, so they are\n // not filters — the documentation used to imply all of them were, and none were.\n if (msg.type === \"STORE_METRICS\") {\n this.router.fanOutToExtensions(raw, (caps) => caps?.performanceMetrics !== false);\n } else {\n this.router.fanOutToExtensions(raw);\n }\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","wants","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","tokensMatch","expected","offered","diff","isOriginAllowed","origin","allowed","allowedExtensionIds","url","isLoopbackHost","belongsToLiveStore","raw","live","parsed","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","windowStart","inWindow","handshakeTimer","data","now","req","response","PROTOCOL_VERSION","reqMajor","ourMajor","connectMsg","sender","caps","disconnectMsg","main","argv","portIdx","a","histIdx","historySize","hub","shutdown"],"mappings":"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"}
@@ -4,7 +4,7 @@
4
4
  * Central WebSocket hub for the Yoltra DevTools suite.
5
5
  * Can be used as an embeddable library or a standalone CLI server.
6
6
  */
7
- export { main as startCli } from './cli';
8
- export { DevtoolsHub } from './hub';
9
- export type { DevtoolsHubOptions } from './hub';
10
- export { RingBuffer } from './ring-buffer';
7
+ export { main as startCli } from './cli.js';
8
+ export { DevtoolsHub } from './hub.js';
9
+ export type { DevtoolsHubOptions } from './hub.js';
10
+ export { RingBuffer } from './ring-buffer.js';
@@ -1,6 +1,6 @@
1
1
  import { DevtoolsRole, ExtensionCapabilities } from '@yoltra/devtools-protocol';
2
2
  import { WebSocket } from 'ws';
3
- import { ConnectionInfo } from './connection';
3
+ import { ConnectionInfo } from './connection.js';
4
4
  /**
5
5
  * Routes DevTools protocol messages between stores and extensions.
6
6
  *
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@yoltra/devtools-server",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Hub WebSocket server for Yoltra DevTools — standalone CLI and embeddable library",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Manu Ramirez <@pixerael>",
8
- "email": "manu@yoltra.dev"
8
+ "email": "opensource@yoltra.dev"
9
9
  },
10
10
  "maintainers": [],
11
11
  "homepage": "https://yoltra.dev",
@@ -22,14 +22,14 @@
22
22
  "url": "https://github.com/yoltra/yoltra/issues"
23
23
  },
24
24
  "type": "module",
25
- "main": "dist/devtools-server.cjs.js",
26
- "module": "dist/devtools-server.esm.js",
25
+ "main": "dist/devtools-server.cjs",
26
+ "module": "dist/devtools-server.mjs",
27
27
  "types": "dist/types/index.d.ts",
28
28
  "exports": {
29
29
  ".": {
30
30
  "types": "./dist/types/index.d.ts",
31
- "import": "./dist/devtools-server.esm.js",
32
- "require": "./dist/devtools-server.cjs.js"
31
+ "import": "./dist/devtools-server.mjs",
32
+ "require": "./dist/devtools-server.cjs"
33
33
  }
34
34
  },
35
35
  "bin": {
@@ -42,20 +42,20 @@
42
42
  "sideEffects": false,
43
43
  "dependencies": {
44
44
  "ws": "^8.19.0",
45
- "@yoltra/devtools-protocol": "0.4.0"
45
+ "@yoltra/devtools-protocol": "0.6.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^24.0.12",
49
49
  "@types/ws": "^8.18.1",
50
- "@vitest/coverage-v8": "3.2.4",
50
+ "@vitest/coverage-v8": "3.2.7",
51
51
  "typedoc": "^0.28.13",
52
52
  "typedoc-plugin-localization": "3.0.6",
53
53
  "typedoc-plugin-markdown": "4.9.0",
54
54
  "typescript": "5.9.3",
55
- "vite": "^7.1.11",
55
+ "vite": "^7.3.6",
56
56
  "vite-plugin-banner": "0.8.1",
57
57
  "vite-plugin-dts": "^4.5.4",
58
- "vitest": "3.2.4"
58
+ "vitest": "3.2.7"
59
59
  },
60
60
  "engines": {
61
61
  "node": ">=18.18"
@@ -64,13 +64,14 @@
64
64
  "access": "public"
65
65
  },
66
66
  "scripts": {
67
- "build": "vite build",
67
+ "build": "vite build && node ../../tools/repo-tools/bin/dts-extensions.mjs dist/types",
68
68
  "test": "vitest --watch=false --coverage",
69
69
  "lint": "node ../../tools/repo-tools/bin/repo-eslint.cjs --report-unused-disable-directives --max-warnings 0",
70
70
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.vitest.json --noEmit",
71
- "docs": "rushx docs:js && rushx docs:md",
72
- "docs:md": "pnpm typedoc --options ./typedoc.json",
73
- "docs:js": "pnpm typedoc --options ./typedoc.json --json ./.typedoc/devtools-server-en.json",
71
+ "docs": "rushx docs:js && rushx docs:md && rushx docs:stamp",
72
+ "docs:stamp": "node ../../tools/repo-tools/bin/docs-stamp.mjs docs",
73
+ "docs:md": "typedoc --options ./typedoc.json",
74
+ "docs:js": "typedoc --options ./typedoc.json --json ./.typedoc/devtools-server-en.json",
74
75
  "start": "node bin/devtools-server.js"
75
76
  }
76
77
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"devtools-server.cjs.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 ExtensionCapabilities,\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 * @param wants - Optional predicate over an extension's declared capabilities. Used for\n * traffic an extension has said it cannot display; omit to reach every extension.\n *\n * @public\n */\n fanOutToExtensions(\n message: string,\n wants?: (capabilities: ExtensionCapabilities | undefined) => boolean,\n ): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState !== ext.ws.OPEN) continue;\n if (wants !== undefined && !wants(ext.extensionInfo?.capabilities)) continue;\n ext.ws.send(message);\n }\n }\n\n /**\n * Ids of every currently-connected store.\n *\n * @returns The ids, in registration order.\n *\n * @public\n */\n storeIds(): string[] {\n return [...this.stores.keys()];\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 * Shared secret every client must present in its handshake.\n *\n * @remarks\n * The hub binds to loopback, which keeps the network out — but loopback is not an\n * authentication boundary. Every other process on the machine can reach it, so without a token\n * anything running locally can connect as a panel and read the application's entire state,\n * inject events, and overwrite state through time-travel. That includes a package's install\n * script, and anything else sharing a CI runner or a container.\n *\n * Unset by default, because requiring one would break the zero-configuration local flow that\n * makes the tool worth using. When unset the hub says so once at startup rather than leaving\n * the exposure unmentioned.\n */\n authToken?: string;\n /**\n * Extension ids allowed to connect, e.g. `[\"abcdefghijklmnopabcdefghijklmnop\"]`.\n *\n * @remarks\n * Extension origins all share one scheme, so permitting the scheme permits every extension the\n * user has installed — any of which could open this socket from a devtools page of its own.\n * Naming ids narrows that to the panel meant to connect.\n *\n * Empty by default, which keeps every extension origin allowed: an unpacked build and a store\n * install have different ids, so assuming one would lock out a developer running the extension\n * they just built. Set it alongside {@link DevtoolsHubOptions.authToken} on any machine where\n * other extensions are not automatically trusted.\n */\n allowedExtensionIds?: string[];\n /**\n * Most messages one client may send per second before the excess is dropped.\n *\n * @remarks\n * A command like `REQUEST_STATE` costs the *store* a full serialization of its state and the\n * hub a fan-out, so a client that loops on it turns one cheap socket write into repeated work\n * across every connected process. This bounds that without affecting a panel behaving\n * normally, which sends a handful of commands per interaction.\n *\n * @defaultValue 200\n */\n maxMessagesPerSecond?: number;\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 * Compares two secrets without leaking their contents through timing.\n *\n * @remarks\n * `===` on a secret returns as soon as two characters differ, which is a usable oracle for\n * recovering it one character at a time from a process that can retry freely — and anything on\n * this machine can.\n *\n * @internal\n */\nfunction tokensMatch(expected: string, offered: unknown): boolean {\n if (typeof offered !== \"string\" || offered.length !== expected.length) return false;\n let diff = 0;\n for (let i = 0; i < expected.length; i += 1) {\n diff |= expected.charCodeAt(i) ^ offered.charCodeAt(i);\n }\n return diff === 0;\n}\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 (narrowed to specific ids when\n * {@link DevtoolsHubOptions.allowedExtensionIds} names any), loopback origins\n * (the local dev app running the agent, or a local storeview), and any\n * explicitly configured origins. A remote origin (e.g. `https://evil.com`) is\n * rejected.\n *\n * An origin check is not authentication: it constrains which *page* may open the\n * socket, and says nothing about which *process* did. That is what\n * {@link DevtoolsHubOptions.authToken} is for, and the two are meant to be used\n * together.\n *\n * @internal\n */\nfunction isOriginAllowed(\n origin: string | undefined,\n allowed: readonly string[],\n allowedExtensionIds: readonly string[],\n): 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 // Every extension shares one origin scheme, so allowing the scheme allows all of them: any\n // extension the user has installed, with a devtools page of its own, could open this socket\n // and read whatever the connected stores hold. The extension id is the host part, so an\n // allow-list narrows it to the panel actually meant to connect.\n //\n // Unset by default because there is no id to assume: an unpacked build and a store install\n // have different ones, so a hardcoded default would reject the developer running the\n // extension they just built.\n if (allowedExtensionIds.length === 0) return true;\n return allowedExtensionIds.includes(url.hostname);\n }\n return isLoopbackHost(url.hostname);\n}\n\n/**\n * `true` when a buffered frame belongs to a store that is still connected.\n *\n * @remarks\n * Parses only enough to read `storeId`. A frame that cannot be parsed is kept rather than\n * dropped: it went into the buffer as valid traffic, and silently discarding it here would be a\n * worse failure than replaying one frame too many.\n *\n * @internal\n */\nfunction belongsToLiveStore(raw: string, live: ReadonlySet<string>): boolean {\n try {\n const parsed = JSON.parse(raw) as { storeId?: unknown };\n return typeof parsed.storeId === \"string\" ? live.has(parsed.storeId) : true;\n } catch {\n return true;\n }\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 /** Shared secret required from every client, or `undefined` when the hub is open. */\n private readonly authToken: string | undefined;\n /** Extension ids permitted to connect; empty means every extension origin. */\n private readonly allowedExtensionIds: readonly string[];\n /** Per-second message allowance for one client. */\n private readonly maxMessagesPerSecond: number;\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.authToken = opts.authToken;\n this.allowedExtensionIds = opts.allowedExtensionIds ?? [];\n this.maxMessagesPerSecond = opts.maxMessagesPerSecond ?? 200;\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, this.allowedExtensionIds))\n 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 if (this.authToken === undefined) {\n // Said once, at the only moment it can still be acted on. Binding to loopback keeps\n // the network out but not the machine: every other local process — a package install\n // script, another tenant on a shared runner — can connect as a panel and read the\n // application's whole state. Silence here would present that as a secure default.\n console.warn(\n \"[yoltra devtools] Hub is running without an auth token: any process on this \" +\n \"machine can read and drive the connected stores. Pass { authToken } (and the \" +\n \"same value to each agent) on a shared or containerised host.\",\n );\n }\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 // A fixed window rather than a token bucket: the point is to stop a runaway loop, not to\n // shape traffic, and a counter reset on a timestamp comparison costs nothing per frame.\n let windowStart = Date.now();\n let inWindow = 0;\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 const now = Date.now();\n if (now - windowStart >= 1000) {\n windowStart = now;\n inWindow = 0;\n }\n inWindow += 1;\n if (inWindow > this.maxMessagesPerSecond) {\n // Dropped rather than answered. Closing the socket would punish a burst the same as a\n // flood, and a panel that briefly exceeds the allowance recovers on the next window.\n if (inWindow === this.maxMessagesPerSecond + 1) {\n console.warn(\n `[yoltra devtools] A ${connectionInfo?.role ?? \"handshaking\"} client exceeded ` +\n `${this.maxMessagesPerSecond} messages/second; the excess is being dropped.`,\n );\n }\n return;\n }\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 // Checked before anything is registered or replayed, so an unauthenticated client never\n // reaches the history buffer or the store registry.\n if (this.authToken !== undefined && !tokensMatch(this.authToken, req.authToken)) {\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: \"Invalid or missing auth token\",\n };\n ws.send(JSON.stringify(response));\n console.warn(\n `[yoltra devtools] Rejected a ${req.role} handshake: wrong or missing auth token`,\n );\n return null;\n }\n\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\n // Replay only what the panel can still act on. The buffer holds events from every store\n // that has ever connected, so a long-lived hub greets each new panel with a burst of\n // history for stores that are gone and cannot be selected — pure noise, sent one frame at\n // a time, before anything useful arrives.\n const live = new Set(this.router.storeIds());\n for (const msg of this.history.toArray()) {\n if (!belongsToLiveStore(msg, live)) continue;\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 // Metrics go only to panels that said they display them. The other capability flags\n // describe what an extension can render rather than what traffic it wants, so they are\n // not filters — the documentation used to imply all of them were, and none were.\n if (msg.type === \"STORE_METRICS\") {\n this.router.fanOutToExtensions(raw, (caps) => caps?.performanceMetrics !== false);\n } else {\n this.router.fanOutToExtensions(raw);\n }\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","wants","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","tokensMatch","expected","offered","diff","isOriginAllowed","origin","allowed","allowedExtensionIds","url","isLoopbackHost","belongsToLiveStore","raw","live","parsed","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","windowStart","inWindow","handshakeTimer","data","now","req","response","PROTOCOL_VERSION","reqMajor","ourMajor","connectMsg","sender","caps","disconnectMsg","main","argv","portIdx","a","histIdx","historySize","hub","shutdown"],"mappings":"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"}
@@ -1 +0,0 @@
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 ExtensionCapabilities,\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 * @param wants - Optional predicate over an extension's declared capabilities. Used for\n * traffic an extension has said it cannot display; omit to reach every extension.\n *\n * @public\n */\n fanOutToExtensions(\n message: string,\n wants?: (capabilities: ExtensionCapabilities | undefined) => boolean,\n ): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState !== ext.ws.OPEN) continue;\n if (wants !== undefined && !wants(ext.extensionInfo?.capabilities)) continue;\n ext.ws.send(message);\n }\n }\n\n /**\n * Ids of every currently-connected store.\n *\n * @returns The ids, in registration order.\n *\n * @public\n */\n storeIds(): string[] {\n return [...this.stores.keys()];\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 * Shared secret every client must present in its handshake.\n *\n * @remarks\n * The hub binds to loopback, which keeps the network out — but loopback is not an\n * authentication boundary. Every other process on the machine can reach it, so without a token\n * anything running locally can connect as a panel and read the application's entire state,\n * inject events, and overwrite state through time-travel. That includes a package's install\n * script, and anything else sharing a CI runner or a container.\n *\n * Unset by default, because requiring one would break the zero-configuration local flow that\n * makes the tool worth using. When unset the hub says so once at startup rather than leaving\n * the exposure unmentioned.\n */\n authToken?: string;\n /**\n * Extension ids allowed to connect, e.g. `[\"abcdefghijklmnopabcdefghijklmnop\"]`.\n *\n * @remarks\n * Extension origins all share one scheme, so permitting the scheme permits every extension the\n * user has installed — any of which could open this socket from a devtools page of its own.\n * Naming ids narrows that to the panel meant to connect.\n *\n * Empty by default, which keeps every extension origin allowed: an unpacked build and a store\n * install have different ids, so assuming one would lock out a developer running the extension\n * they just built. Set it alongside {@link DevtoolsHubOptions.authToken} on any machine where\n * other extensions are not automatically trusted.\n */\n allowedExtensionIds?: string[];\n /**\n * Most messages one client may send per second before the excess is dropped.\n *\n * @remarks\n * A command like `REQUEST_STATE` costs the *store* a full serialization of its state and the\n * hub a fan-out, so a client that loops on it turns one cheap socket write into repeated work\n * across every connected process. This bounds that without affecting a panel behaving\n * normally, which sends a handful of commands per interaction.\n *\n * @defaultValue 200\n */\n maxMessagesPerSecond?: number;\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 * Compares two secrets without leaking their contents through timing.\n *\n * @remarks\n * `===` on a secret returns as soon as two characters differ, which is a usable oracle for\n * recovering it one character at a time from a process that can retry freely — and anything on\n * this machine can.\n *\n * @internal\n */\nfunction tokensMatch(expected: string, offered: unknown): boolean {\n if (typeof offered !== \"string\" || offered.length !== expected.length) return false;\n let diff = 0;\n for (let i = 0; i < expected.length; i += 1) {\n diff |= expected.charCodeAt(i) ^ offered.charCodeAt(i);\n }\n return diff === 0;\n}\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 (narrowed to specific ids when\n * {@link DevtoolsHubOptions.allowedExtensionIds} names any), loopback origins\n * (the local dev app running the agent, or a local storeview), and any\n * explicitly configured origins. A remote origin (e.g. `https://evil.com`) is\n * rejected.\n *\n * An origin check is not authentication: it constrains which *page* may open the\n * socket, and says nothing about which *process* did. That is what\n * {@link DevtoolsHubOptions.authToken} is for, and the two are meant to be used\n * together.\n *\n * @internal\n */\nfunction isOriginAllowed(\n origin: string | undefined,\n allowed: readonly string[],\n allowedExtensionIds: readonly string[],\n): 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 // Every extension shares one origin scheme, so allowing the scheme allows all of them: any\n // extension the user has installed, with a devtools page of its own, could open this socket\n // and read whatever the connected stores hold. The extension id is the host part, so an\n // allow-list narrows it to the panel actually meant to connect.\n //\n // Unset by default because there is no id to assume: an unpacked build and a store install\n // have different ones, so a hardcoded default would reject the developer running the\n // extension they just built.\n if (allowedExtensionIds.length === 0) return true;\n return allowedExtensionIds.includes(url.hostname);\n }\n return isLoopbackHost(url.hostname);\n}\n\n/**\n * `true` when a buffered frame belongs to a store that is still connected.\n *\n * @remarks\n * Parses only enough to read `storeId`. A frame that cannot be parsed is kept rather than\n * dropped: it went into the buffer as valid traffic, and silently discarding it here would be a\n * worse failure than replaying one frame too many.\n *\n * @internal\n */\nfunction belongsToLiveStore(raw: string, live: ReadonlySet<string>): boolean {\n try {\n const parsed = JSON.parse(raw) as { storeId?: unknown };\n return typeof parsed.storeId === \"string\" ? live.has(parsed.storeId) : true;\n } catch {\n return true;\n }\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 /** Shared secret required from every client, or `undefined` when the hub is open. */\n private readonly authToken: string | undefined;\n /** Extension ids permitted to connect; empty means every extension origin. */\n private readonly allowedExtensionIds: readonly string[];\n /** Per-second message allowance for one client. */\n private readonly maxMessagesPerSecond: number;\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.authToken = opts.authToken;\n this.allowedExtensionIds = opts.allowedExtensionIds ?? [];\n this.maxMessagesPerSecond = opts.maxMessagesPerSecond ?? 200;\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, this.allowedExtensionIds))\n 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 if (this.authToken === undefined) {\n // Said once, at the only moment it can still be acted on. Binding to loopback keeps\n // the network out but not the machine: every other local process — a package install\n // script, another tenant on a shared runner — can connect as a panel and read the\n // application's whole state. Silence here would present that as a secure default.\n console.warn(\n \"[yoltra devtools] Hub is running without an auth token: any process on this \" +\n \"machine can read and drive the connected stores. Pass { authToken } (and the \" +\n \"same value to each agent) on a shared or containerised host.\",\n );\n }\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 // A fixed window rather than a token bucket: the point is to stop a runaway loop, not to\n // shape traffic, and a counter reset on a timestamp comparison costs nothing per frame.\n let windowStart = Date.now();\n let inWindow = 0;\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 const now = Date.now();\n if (now - windowStart >= 1000) {\n windowStart = now;\n inWindow = 0;\n }\n inWindow += 1;\n if (inWindow > this.maxMessagesPerSecond) {\n // Dropped rather than answered. Closing the socket would punish a burst the same as a\n // flood, and a panel that briefly exceeds the allowance recovers on the next window.\n if (inWindow === this.maxMessagesPerSecond + 1) {\n console.warn(\n `[yoltra devtools] A ${connectionInfo?.role ?? \"handshaking\"} client exceeded ` +\n `${this.maxMessagesPerSecond} messages/second; the excess is being dropped.`,\n );\n }\n return;\n }\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 // Checked before anything is registered or replayed, so an unauthenticated client never\n // reaches the history buffer or the store registry.\n if (this.authToken !== undefined && !tokensMatch(this.authToken, req.authToken)) {\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: \"Invalid or missing auth token\",\n };\n ws.send(JSON.stringify(response));\n console.warn(\n `[yoltra devtools] Rejected a ${req.role} handshake: wrong or missing auth token`,\n );\n return null;\n }\n\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\n // Replay only what the panel can still act on. The buffer holds events from every store\n // that has ever connected, so a long-lived hub greets each new panel with a burst of\n // history for stores that are gone and cannot be selected — pure noise, sent one frame at\n // a time, before anything useful arrives.\n const live = new Set(this.router.storeIds());\n for (const msg of this.history.toArray()) {\n if (!belongsToLiveStore(msg, live)) continue;\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 // Metrics go only to panels that said they display them. The other capability flags\n // describe what an extension can render rather than what traffic it wants, so they are\n // not filters — the documentation used to imply all of them were, and none were.\n if (msg.type === \"STORE_METRICS\") {\n this.router.fanOutToExtensions(raw, (caps) => caps?.performanceMetrics !== false);\n } else {\n this.router.fanOutToExtensions(raw);\n }\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","wants","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","tokensMatch","expected","offered","diff","isOriginAllowed","origin","allowed","allowedExtensionIds","url","isLoopbackHost","belongsToLiveStore","raw","live","parsed","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","windowStart","inWindow","handshakeTimer","data","now","req","response","PROTOCOL_VERSION","reqMajor","ourMajor","connectMsg","sender","caps","disconnectMsg","main","argv","portIdx","a","histIdx","historySize","hub","shutdown"],"mappings":"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"}