@yoltra/devtools-server 0.2.0 → 0.4.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.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Yoltra logo](../../assets/yoltra-logo.png)
1
+ ![Yoltra logo](https://yoltra.dev/assets/yoltra-logo.png)
2
2
 
3
3
  # @yoltra/devtools-server
4
4
 
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * @yoltra/devtools-server v0.2.0
2
+ * @yoltra/devtools-server v0.4.0
3
3
  * (c) 2026 Manu Ramirez <@pixerael>
4
4
  * License: MIT
5
5
  * Homepage: https://yoltra.dev
6
6
  */
7
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const o=require("@yoltra/devtools-protocol"),u=require("ws");class h{constructor(t){if(this.capacity=t,this.head=0,this.count=0,t<1)throw new Error("RingBuffer capacity must be >= 1");this.items=new Array(t)}push(t){this.items[this.head]=t,this.head=(this.head+1)%this.capacity,this.count<this.capacity&&this.count++}toArray(){if(this.count===0)return[];const t=[],e=this.count<this.capacity?0:this.head;for(let s=0;s<this.count;s++)t.push(this.items[(e+s)%this.capacity]);return t}get size(){return this.count}clear(){this.items.fill(void 0),this.head=0,this.count=0}}class f{constructor(){this.stores=new Map,this.extensions=new Map}register(t){t.role===o.DevtoolsRole.STORE?this.stores.set(t.id,t):this.extensions.set(t.id,t)}unregister(t,e){e===o.DevtoolsRole.STORE?this.stores.delete(t):this.extensions.delete(t)}getStoreSocket(t){return this.stores.get(t)?.ws}fanOutToExtensions(t){for(const[,e]of this.extensions)e.ws.readyState===e.ws.OPEN&&e.ws.send(t)}sendToStore(t,e){const s=this.stores.get(t);return!s||s.ws.readyState!==s.ws.OPEN?!1:(s.ws.send(e),!0)}buildStoreConnectedMessage(t){if(!t.storeInfo)return null;const e={type:"STORE_CONNECTED",timestamp:new Date().toISOString(),sourceId:"hub",sourceRole:o.DevtoolsRole.HUB,store:{id:t.id,name:t.storeInfo.name,capabilities:t.storeInfo.capabilities}};return JSON.stringify(e)}buildStoreDisconnectedMessage(t,e){const s={type:"STORE_DISCONNECTED",timestamp:new Date().toISOString(),sourceId:"hub",sourceRole:o.DevtoolsRole.HUB,storeId:t,reason:e};return JSON.stringify(s)}buildRegistryMessage(){const t={type:"STORE_REGISTRY",timestamp:new Date().toISOString(),sourceId:"hub",sourceRole:o.DevtoolsRole.HUB,stores:Array.from(this.stores.values()).flatMap(e=>e.storeInfo?[{id:e.id,name:e.storeInfo.name,status:"connected",capabilities:e.storeInfo.capabilities,connectedAt:e.connectedAt}]:[])};return JSON.stringify(t)}get storeCount(){return this.stores.size}get extensionCount(){return this.extensions.size}}const p=5e3,S=8*1024*1024;function y(i,t){if(!i||t.includes(i))return!0;let e;try{e=new URL(i)}catch{return!1}return e.protocol==="chrome-extension:"||e.protocol==="moz-extension:"||e.protocol==="safari-web-extension:"?!0:O(e.hostname)}function O(i){const t=i.replace(/^\[|\]$/g,"");return t==="localhost"||t.endsWith(".localhost")||t==="127.0.0.1"||t.startsWith("127.")||t==="::1"||t==="0:0:0:0:0:0:0:1"}class d{constructor(t={}){this.router=new f,this.wss=null,this.port=t.port??9800,this.host=t.host??"127.0.0.1",this.allowedOrigins=t.allowedOrigins??[],this.history=new h(t.historySize??1e3)}async start(){return new Promise((t,e)=>{this.wss=new u.WebSocketServer({port:this.port,host:this.host,maxPayload:S,verifyClient:s=>y(s.origin,this.allowedOrigins)?!0:(console.warn(`[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${s.origin}`),!1)}),this.wss.on("listening",()=>{t()}),this.wss.on("error",s=>{e(s)}),this.wss.on("connection",s=>{this.handleConnection(s)})})}async stop(){return new Promise(t=>{if(!this.wss){t();return}this.wss.close(()=>{this.wss=null,t()});for(const e of this.wss.clients)e.close(1001,"Hub shutting down")})}static async probe(t){return new Promise(e=>{const s=new u.WebSocket(`ws://127.0.0.1:${t}`),r=setTimeout(()=>{s.close(),e(!1)},2e3);s.on("open",()=>{clearTimeout(r),s.close(),e(!0)}),s.on("error",()=>{clearTimeout(r),e(!1)})})}handleConnection(t){let e=null;const s=setTimeout(()=>{e||t.close(1008,"Handshake timeout")},p);t.on("message",r=>{let n;try{n=JSON.parse(r.toString())}catch{return}if(!(n===null||typeof n!="object"||Array.isArray(n))&&typeof n.type=="string"){if(!e){n.type==="HANDSHAKE_REQUEST"&&(clearTimeout(s),e=this.handleHandshake(t,n),e||t.close(1008,"Handshake failed"));return}this.routeMessage(e,n)}}),t.on("close",()=>{clearTimeout(s),e&&this.handleDisconnect(e)}),t.on("error",()=>{})}handleHandshake(t,e){const s=parseInt(e.protocolVersion?.split(".")[0]??"0"),r=parseInt(o.PROTOCOL_VERSION.split(".")[0]);if(s!==r){const l={type:"HANDSHAKE_RESPONSE",success:!1,negotiatedVersion:o.PROTOCOL_VERSION,hubCapabilities:{maxHistorySize:this.history.capacity,supportedFeatures:[]},error:`Incompatible protocol version: ${e.protocolVersion} (hub: ${o.PROTOCOL_VERSION})`};return t.send(JSON.stringify(l)),null}const n=e.role===o.DevtoolsRole.STORE?e.store?.id:e.extension?.id;if(!n)return console.warn(`[yoltra devtools] Rejected handshake: role ${e.role} without a matching id payload`),null;const c={ws:t,role:e.role,id:n,connectedAt:new Date().toISOString()};e.role===o.DevtoolsRole.STORE&&e.store?c.storeInfo={name:e.store.name,capabilities:e.store.capabilities}:e.role===o.DevtoolsRole.EXTENSION&&e.extension&&(c.extensionInfo={name:e.extension.name,capabilities:e.extension.capabilities}),this.router.register(c);const a={type:"HANDSHAKE_RESPONSE",success:!0,negotiatedVersion:o.PROTOCOL_VERSION,hubCapabilities:{maxHistorySize:this.history.capacity,supportedFeatures:[]}};if(t.send(JSON.stringify(a)),e.role===o.DevtoolsRole.STORE){const l=this.router.buildStoreConnectedMessage(c);l&&this.router.fanOutToExtensions(l)}else if(e.role===o.DevtoolsRole.EXTENSION){t.send(this.router.buildRegistryMessage());for(const l of this.history.toArray())t.send(l)}return c}routeMessage(t,e){const s=JSON.stringify(e);if(t.role===o.DevtoolsRole.STORE)this.router.fanOutToExtensions(s),e.type==="STORE_EVENT"&&this.history.push(s);else{const r=e.storeId;r&&this.router.sendToStore(r,s)}}handleDisconnect(t){if(this.router.unregister(t.id,t.role),t.role===o.DevtoolsRole.STORE){const e=this.router.buildStoreDisconnectedMessage(t.id,"disconnected");this.router.fanOutToExtensions(e)}}get storeCount(){return this.router.storeCount}get extensionCount(){return this.router.extensionCount}get historySize(){return this.history.size}}async function g(i=process.argv){const t=i.indexOf("--port"),e=parseInt(i.find(a=>a.startsWith("--port="))?.split("=")[1]??(t!==-1?i[t+1]:void 0)??"9800"),s=i.indexOf("--history-size"),r=parseInt(i.find(a=>a.startsWith("--history-size="))?.split("=")[1]??(s!==-1?i[s+1]:void 0)??"1000"),n=new d({port:e,historySize:r}),c=async()=>{console.log(`
8
- Shutting down DevTools hub...`),await n.stop(),process.exit(0)};process.on("SIGINT",c),process.on("SIGTERM",c);try{await n.start(),console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${e}`),console.log(`History buffer: ${r} events`)}catch(a){console.error("Failed to start DevTools hub:",a),process.exit(1)}}exports.DevtoolsHub=d;exports.RingBuffer=h;exports.startCli=g;
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
+ 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
9
  //# sourceMappingURL=devtools-server.cjs.js.map
@@ -1 +1 @@
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 StoreConnected,\n type StoreDisconnected,\n type StoreRegistry,\n} from \"@yoltra/devtools-protocol\";\nimport type { WebSocket } from \"ws\";\nimport type { ConnectionInfo } from \"./connection\";\n\n/**\n * Routes DevTools protocol messages between stores and extensions.\n *\n * @remarks\n * The router maintains two parallel maps -- one for store connections and\n * one for extension connections -- and exposes helpers that implement the\n * three core routing patterns of the DevTools protocol:\n *\n * - **Fan-out**: Store messages are forwarded to every connected extension.\n * - **Targeted delivery**: Extension commands are routed to a specific\n * store identified by `storeId`.\n * - **Lifecycle broadcast**: `STORE_CONNECTED` / `STORE_DISCONNECTED`\n * events are broadcast to all extensions whenever a store joins or\n * leaves.\n *\n * @public\n */\nexport class Router {\n /** All store connections, keyed by store ID. */\n private readonly stores = new Map<string, ConnectionInfo>();\n /** All extension connections, keyed by extension ID. */\n private readonly extensions = new Map<string, ConnectionInfo>();\n\n /**\n * Register a newly handshaked connection.\n *\n * @param info - Connection info from the completed handshake.\n *\n * @public\n */\n register(info: ConnectionInfo): void {\n if (info.role === DevtoolsRole.STORE) {\n this.stores.set(info.id, info);\n } else {\n this.extensions.set(info.id, info);\n }\n }\n\n /**\n * Remove a connection by ID.\n *\n * @param id - Client ID to remove.\n * @param role - Client role (`STORE` or `EXTENSION`).\n *\n * @public\n */\n unregister(id: string, role: DevtoolsRole): void {\n if (role === DevtoolsRole.STORE) {\n this.stores.delete(id);\n } else {\n this.extensions.delete(id);\n }\n }\n\n /**\n * Get the WebSocket for a specific store.\n *\n * @param storeId - Store UUID.\n * @returns The store's WebSocket, or `undefined` if not connected.\n *\n * @public\n */\n getStoreSocket(storeId: string): WebSocket | undefined {\n return this.stores.get(storeId)?.ws;\n }\n\n /**\n * Route a message from a store to all extensions (fan-out).\n *\n * @remarks\n * Only sends to extensions whose WebSocket is in the `OPEN` ready-state;\n * connections in a closing or closed state are silently skipped.\n *\n * @param message - Serialized JSON message string.\n *\n * @public\n */\n fanOutToExtensions(message: string): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState === ext.ws.OPEN) {\n ext.ws.send(message);\n }\n }\n }\n\n /**\n * Route a message from an extension to a specific store.\n *\n * @param storeId - Target store UUID.\n * @param message - Serialized JSON message string.\n * @returns `true` if the message was sent, `false` if the store was\n * not found or its socket was not open.\n *\n * @public\n */\n sendToStore(storeId: string, message: string): boolean {\n const store = this.stores.get(storeId);\n if (!store || store.ws.readyState !== store.ws.OPEN) return false;\n store.ws.send(message);\n return true;\n }\n\n /**\n * Build a `STORE_CONNECTED` broadcast message.\n *\n * @param info - Store connection info (must have {@link ConnectionInfo.storeInfo}).\n * @returns Serialized {@link StoreConnected} JSON string.\n *\n * @public\n */\n buildStoreConnectedMessage(info: ConnectionInfo): string | null {\n // Only a fully-registered STORE connection carries storeInfo. Guard instead\n // of asserting so an incomplete registration can't crash the hub; the caller\n // skips fan-out when this returns null.\n if (!info.storeInfo) return null;\n const msg: StoreConnected = {\n type: \"STORE_CONNECTED\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n store: {\n id: info.id,\n name: info.storeInfo.name,\n capabilities: info.storeInfo.capabilities,\n },\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Build a `STORE_DISCONNECTED` broadcast message.\n *\n * @param storeId - Disconnected store ID.\n * @param reason - Optional human-readable disconnect reason.\n * @returns Serialized {@link StoreDisconnected} JSON string.\n *\n * @public\n */\n buildStoreDisconnectedMessage(storeId: string, reason?: string): string {\n const msg: StoreDisconnected = {\n type: \"STORE_DISCONNECTED\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n storeId,\n reason,\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Build a `STORE_REGISTRY` message listing all connected stores.\n *\n * @returns Serialized {@link StoreRegistry} JSON string.\n *\n * @public\n */\n buildRegistryMessage(): string {\n const msg: StoreRegistry = {\n type: \"STORE_REGISTRY\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n stores: Array.from(this.stores.values()).flatMap((s) => {\n // Skip connections whose registration hasn't completed (no storeInfo).\n if (!s.storeInfo) return [];\n return [\n {\n id: s.id,\n name: s.storeInfo.name,\n status: \"connected\" as const,\n capabilities: s.storeInfo.capabilities,\n connectedAt: s.connectedAt,\n },\n ];\n }),\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Number of connected stores.\n *\n * @returns Current store connection count.\n *\n * @public\n */\n get storeCount(): number {\n return this.stores.size;\n }\n\n /**\n * Number of connected extensions.\n *\n * @returns Current extension connection count.\n *\n * @public\n */\n get extensionCount(): number {\n return this.extensions.size;\n }\n}\n","/**\n * Central WebSocket hub that brokers DevTools protocol traffic.\n *\n * @module @yoltra/devtools-server\n */\n\nimport {\n DevtoolsRole,\n PROTOCOL_VERSION,\n type HandshakeRequest,\n type HandshakeResponse,\n} from \"@yoltra/devtools-protocol\";\nimport { WebSocket, WebSocketServer } from \"ws\";\nimport type { ConnectionInfo } from \"./connection\";\nimport { RingBuffer } from \"./ring-buffer\";\nimport { Router } from \"./router\";\n\n/**\n * Configuration for the DevTools hub server.\n *\n * @remarks\n * All fields are optional; sensible defaults are applied when omitted.\n *\n * @public\n */\nexport interface DevtoolsHubOptions {\n /** Port to bind on. @default 9800 */\n port?: number;\n /** Host to bind on. @default \"127.0.0.1\" (localhost only for v1 security) */\n host?: string;\n /** Maximum events retained in the ring buffer for late-connecting extensions. @default 1000 */\n historySize?: number;\n /**\n * Extra WebSocket `Origin` values to accept, beyond the always-allowed set\n * (no Origin, browser-extension origins, and loopback origins). Use this only\n * for a non-loopback local dev host (e.g. a custom `.local` domain). Adding a\n * remote origin re-opens the cross-site hijack surface — don't.\n */\n allowedOrigins?: string[];\n}\n\n/**\n * Timeout for receiving a handshake request after a WebSocket connection\n * is established, in milliseconds.\n *\n * @remarks\n * If the client does not send a valid `HANDSHAKE_REQUEST` within this\n * window the connection is closed with code `1008` (Policy Violation).\n *\n * @internal\n */\nconst HANDSHAKE_TIMEOUT_MS = 5_000;\n\n/**\n * Maximum accepted WebSocket frame size (bytes). Frames fan out to every\n * extension and buffer into history, so an unbounded size is a local\n * DoS / memory-amplification vector. 8 MiB comfortably covers real state\n * snapshots while rejecting hostile oversized frames.\n */\nconst MAX_WS_PAYLOAD_BYTES = 8 * 1024 * 1024;\n\n/**\n * Whether a WebSocket `Origin` may connect to the hub.\n *\n * @remarks\n * The hub binds to loopback, but that does not stop a page you visit from\n * opening `ws://127.0.0.1:<port>` — WebSockets are exempt from same-origin/CORS,\n * so a remote page could otherwise exfiltrate state and drive the store. We\n * allow only: no Origin (node agent, CLI, some extension contexts), browser\n * extension origins (the user-installed panel), loopback origins (the local dev\n * app running the agent, or a local storeview), and any explicitly configured\n * origins. A remote origin (e.g. `https://evil.com`) is rejected.\n *\n * @internal\n */\nfunction isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean {\n if (!origin) return true; // non-browser client; not reachable from a web page\n if (allowed.includes(origin)) return true;\n let url: URL;\n try {\n url = new URL(origin);\n } catch {\n return false;\n }\n if (\n url.protocol === \"chrome-extension:\" ||\n url.protocol === \"moz-extension:\" ||\n url.protocol === \"safari-web-extension:\"\n ) {\n return true;\n }\n return isLoopbackHost(url.hostname);\n}\n\n/** Loopback host check: `localhost`, the 127.0.0.0/8 block, and IPv6 `::1`. @internal */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h.startsWith(\"127.\") ||\n h === \"::1\" ||\n h === \"0:0:0:0:0:0:0:1\"\n );\n}\n\n/**\n * Central WebSocket hub that brokers messages between Yoltra stores and DevTools extensions.\n *\n * @remarks\n * - Accepts WS connections, validates protocol handshakes, and routes messages.\n * - Store events are fan-out to all extension clients.\n * - Extension commands are routed to the target store by `storeId`.\n * - Maintains a ring buffer of recent events for late-connecting extensions.\n * - Binds to localhost only (v1 security).\n *\n * @example Embeddable usage\n * ```ts\n * import { DevtoolsHub } from '@yoltra/devtools-server';\n *\n * const hub = new DevtoolsHub({ port: 9800 });\n * await hub.start();\n * // ... later\n * await hub.stop();\n * ```\n *\n * @public\n */\nexport class DevtoolsHub {\n private readonly port: number;\n private readonly host: string;\n private readonly allowedOrigins: readonly string[];\n private readonly router = new Router();\n private readonly history: RingBuffer<string>;\n private wss: WebSocketServer | null = null;\n\n /**\n * Create a new DevTools hub instance.\n *\n * @param opts - Hub configuration. All fields are optional.\n *\n * @public\n */\n constructor(opts: DevtoolsHubOptions = {}) {\n this.port = opts.port ?? 9800;\n this.host = opts.host ?? \"127.0.0.1\";\n this.allowedOrigins = opts.allowedOrigins ?? [];\n this.history = new RingBuffer<string>(opts.historySize ?? 1000);\n }\n\n /**\n * Start the WebSocket server and begin accepting connections.\n *\n * @returns Resolves once the server is bound and listening.\n * @throws If the underlying `WebSocketServer` emits an error during\n * startup (e.g. port already in use).\n *\n * @public\n */\n async start(): Promise<void> {\n return new Promise((resolve, reject) => {\n this.wss = new WebSocketServer({\n port: this.port,\n host: this.host,\n // Bound the frame size (DEV-1): oversized frames fan out to every\n // extension and buffer into history, so an unbounded cap is a local\n // DoS / memory-amplification vector.\n maxPayload: MAX_WS_PAYLOAD_BYTES,\n // Reject cross-site WebSocket hijacking: the loopback bind alone does\n // not stop a page you visit from opening ws://127.0.0.1:<port>.\n verifyClient: (info: { origin?: string }) => {\n if (isOriginAllowed(info.origin, this.allowedOrigins)) return true;\n console.warn(\n `[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${info.origin}`,\n );\n return false;\n },\n });\n\n this.wss.on(\"listening\", () => {\n resolve();\n });\n\n this.wss.on(\"error\", (err) => {\n reject(err);\n });\n\n this.wss.on(\"connection\", (ws) => {\n this.handleConnection(ws);\n });\n });\n }\n\n /**\n * Stop the server and close all connections.\n *\n * @remarks\n * Existing client sockets are closed with code `1001` (\"Going Away\")\n * before the server socket is torn down.\n *\n * @returns Resolves once the server has fully shut down.\n *\n * @public\n */\n async stop(): Promise<void> {\n return new Promise((resolve) => {\n if (!this.wss) {\n resolve();\n return;\n }\n this.wss.close(() => {\n this.wss = null;\n resolve();\n });\n // Close all existing connections\n for (const client of this.wss.clients) {\n client.close(1001, \"Hub shutting down\");\n }\n });\n }\n\n /**\n * Check if a DevTools hub is already running on the given port.\n *\n * @param port - Port to probe.\n * @returns `true` if a hub is listening and responds to handshake.\n *\n * @public\n */\n static async probe(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const ws = new WebSocket(`ws://127.0.0.1:${port}`);\n const timeout = setTimeout(() => {\n ws.close();\n resolve(false);\n }, 2_000);\n\n ws.on(\"open\", () => {\n clearTimeout(timeout);\n ws.close();\n resolve(true);\n });\n\n ws.on(\"error\", () => {\n clearTimeout(timeout);\n resolve(false);\n });\n });\n }\n\n /**\n * Handle a new WebSocket connection: wait for handshake, then route messages.\n *\n * @remarks\n * Starts a handshake timeout timer. If the first valid message is a\n * `HANDSHAKE_REQUEST` the connection is promoted to a routed client;\n * otherwise it is closed after {@link HANDSHAKE_TIMEOUT_MS}.\n *\n * @param ws - Newly accepted WebSocket.\n */\n private handleConnection(ws: WebSocket): void {\n let connectionInfo: ConnectionInfo | null = null;\n\n // Handshake timeout: close if no handshake within 5s\n const handshakeTimer = setTimeout(() => {\n if (!connectionInfo) {\n ws.close(1008, \"Handshake timeout\");\n }\n }, HANDSHAKE_TIMEOUT_MS);\n\n ws.on(\"message\", (data) => {\n let parsed: any;\n try {\n parsed = JSON.parse(data.toString());\n } catch {\n return; // Ignore malformed messages\n }\n\n // Ingress validation (DEV-3): every protocol message is a plain object\n // with a string `type` discriminant. Reject anything else (null, arrays,\n // primitives, missing type) before it reaches handshake/routing.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return;\n if (typeof parsed.type !== \"string\") return;\n\n // Handle handshake\n if (!connectionInfo) {\n if (parsed.type === \"HANDSHAKE_REQUEST\") {\n clearTimeout(handshakeTimer);\n connectionInfo = this.handleHandshake(ws, parsed as HandshakeRequest);\n if (!connectionInfo) {\n ws.close(1008, \"Handshake failed\");\n }\n }\n return;\n }\n\n // Route messages based on role\n this.routeMessage(connectionInfo, parsed);\n });\n\n ws.on(\"close\", () => {\n clearTimeout(handshakeTimer);\n if (connectionInfo) {\n this.handleDisconnect(connectionInfo);\n }\n });\n\n ws.on(\"error\", () => {\n // Error is followed by close event, handled there\n });\n }\n\n /**\n * Process a handshake request: validate, register, and respond.\n *\n * @remarks\n * Performs a major-version compatibility check against\n * {@link PROTOCOL_VERSION}. On success the connection is registered with\n * the {@link Router} and post-handshake side-effects are triggered\n * (store-connected broadcast or registry + history replay).\n *\n * @param ws - The client WebSocket.\n * @param req - Parsed handshake request payload.\n * @returns The new {@link ConnectionInfo} on success, or `null` if the\n * handshake was rejected.\n */\n private handleHandshake(ws: WebSocket, req: HandshakeRequest): ConnectionInfo | null {\n // Basic protocol version check (accept same major version)\n const reqMajor = parseInt(req.protocolVersion?.split(\".\")[0] ?? \"0\");\n const ourMajor = parseInt(PROTOCOL_VERSION.split(\".\")[0]);\n if (reqMajor !== ourMajor) {\n const response: HandshakeResponse = {\n type: \"HANDSHAKE_RESPONSE\",\n success: false,\n negotiatedVersion: PROTOCOL_VERSION,\n hubCapabilities: {\n maxHistorySize: this.history.capacity,\n supportedFeatures: [],\n },\n error: `Incompatible protocol version: ${req.protocolVersion} (hub: ${PROTOCOL_VERSION})`,\n };\n ws.send(JSON.stringify(response));\n return null;\n }\n\n // A STORE handshake must carry `store`, an EXTENSION handshake `extension`.\n // Guard the role/payload match instead of dereferencing a missing field.\n const id = req.role === DevtoolsRole.STORE ? req.store?.id : req.extension?.id;\n if (!id) {\n console.warn(\n `[yoltra devtools] Rejected handshake: role ${req.role} without a matching id payload`,\n );\n return null;\n }\n\n // Build connection info\n const info: ConnectionInfo = {\n ws,\n role: req.role,\n id,\n connectedAt: new Date().toISOString(),\n };\n\n if (req.role === DevtoolsRole.STORE && req.store) {\n info.storeInfo = {\n name: req.store.name,\n capabilities: req.store.capabilities,\n };\n } else if (req.role === DevtoolsRole.EXTENSION && req.extension) {\n info.extensionInfo = {\n name: req.extension.name,\n capabilities: req.extension.capabilities,\n };\n }\n\n // Register in router\n this.router.register(info);\n\n // Send handshake response\n const response: HandshakeResponse = {\n type: \"HANDSHAKE_RESPONSE\",\n success: true,\n negotiatedVersion: PROTOCOL_VERSION,\n hubCapabilities: {\n maxHistorySize: this.history.capacity,\n supportedFeatures: [],\n },\n };\n ws.send(JSON.stringify(response));\n\n // Post-handshake actions\n if (req.role === DevtoolsRole.STORE) {\n // Broadcast STORE_CONNECTED to all extensions\n const connectMsg = this.router.buildStoreConnectedMessage(info);\n if (connectMsg) this.router.fanOutToExtensions(connectMsg);\n } else if (req.role === DevtoolsRole.EXTENSION) {\n // Send current store registry to the new extension\n ws.send(this.router.buildRegistryMessage());\n // Send buffered event history\n for (const msg of this.history.toArray()) {\n ws.send(msg);\n }\n }\n\n return info;\n }\n\n /**\n * Route a post-handshake message based on the sender's role.\n *\n * @remarks\n * Store messages are fanned-out to all extensions and, if the message\n * type is `STORE_EVENT`, buffered in the ring buffer for replay.\n * Extension messages are forwarded to the store identified by\n * `msg.storeId`.\n *\n * @param sender - Connection info of the sending client.\n * @param msg - Parsed message payload (untyped; serialized internally).\n */\n private routeMessage(sender: ConnectionInfo, msg: any): void {\n const raw = JSON.stringify(msg);\n\n if (sender.role === DevtoolsRole.STORE) {\n // Store messages → fan-out to all extensions\n this.router.fanOutToExtensions(raw);\n\n // Buffer STORE_EVENT messages in the ring buffer\n if (msg.type === \"STORE_EVENT\") {\n this.history.push(raw);\n }\n } else {\n // Extension commands → route to target store\n const storeId = msg.storeId as string | undefined;\n if (storeId) {\n this.router.sendToStore(storeId, raw);\n }\n }\n }\n\n /**\n * Handle a client disconnection.\n *\n * @remarks\n * Unregisters the client from the {@link Router}. If the client was a\n * store, a `STORE_DISCONNECTED` event is broadcast to all extensions.\n *\n * @param info - Connection info of the disconnected client.\n */\n private handleDisconnect(info: ConnectionInfo): void {\n this.router.unregister(info.id, info.role);\n\n if (info.role === DevtoolsRole.STORE) {\n // Broadcast STORE_DISCONNECTED to all extensions\n const disconnectMsg = this.router.buildStoreDisconnectedMessage(info.id, \"disconnected\");\n this.router.fanOutToExtensions(disconnectMsg);\n }\n }\n\n /**\n * Current number of connected stores.\n *\n * @public\n */\n get storeCount(): number {\n return this.router.storeCount;\n }\n\n /**\n * Current number of connected extensions.\n *\n * @public\n */\n get extensionCount(): number {\n return this.router.extensionCount;\n }\n\n /**\n * Number of events in the history ring buffer.\n *\n * @public\n */\n get historySize(): number {\n return this.history.size;\n }\n}\n","/**\n * CLI entry-point for the standalone DevTools hub process.\n *\n * @module @yoltra/devtools-server\n */\n\nimport { DevtoolsHub } from \"./hub\";\n\n/**\n * Parse CLI arguments and start the hub server.\n *\n * @remarks\n * Supported flags:\n *\n * | Flag | Default | Description |\n * | ------------------ | ------- | ---------------------------------- |\n * | `--port` | `9800` | WebSocket port to bind on. |\n * | `--history-size` | `1000` | Ring-buffer capacity for replays. |\n *\n * The function installs `SIGINT` and `SIGTERM` handlers for graceful\n * shutdown and exits with code `1` if the server fails to start.\n *\n * Usage: `npx @yoltra/devtools-server [--port 9800] [--history-size 1000]`\n *\n * @param argv - Argument vector to parse. Defaults to `process.argv`.\n * @returns Resolves once the hub is listening; never resolves during\n * normal operation (the process stays alive until a signal).\n *\n * @public\n */\nexport async function main(argv: string[] = process.argv): Promise<void> {\n const portIdx = argv.indexOf(\"--port\");\n const port = parseInt(\n argv.find((a) => a.startsWith(\"--port=\"))?.split(\"=\")[1] ??\n (portIdx !== -1 ? argv[portIdx + 1] : undefined) ??\n \"9800\",\n );\n\n const histIdx = argv.indexOf(\"--history-size\");\n const historySize = parseInt(\n argv.find((a) => a.startsWith(\"--history-size=\"))?.split(\"=\")[1] ??\n (histIdx !== -1 ? argv[histIdx + 1] : undefined) ??\n \"1000\",\n );\n\n const hub = new DevtoolsHub({ port, historySize });\n\n // Graceful shutdown\n const shutdown = async () => {\n console.log(\"\\nShutting down DevTools hub...\");\n await hub.stop();\n process.exit(0);\n };\n\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n\n try {\n await hub.start();\n console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${port}`);\n console.log(`History buffer: ${historySize} events`);\n } catch (err) {\n console.error(\"Failed to start DevTools hub:\", err);\n process.exit(1);\n }\n}\n"],"names":["RingBuffer","capacity","item","result","start","i","Router","info","DevtoolsRole","id","role","storeId","message","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","isOriginAllowed","origin","allowed","url","isLoopbackHost","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","handshakeTimer","data","parsed","req","reqMajor","ourMajor","PROTOCOL_VERSION","response","connectMsg","sender","raw","disconnectMsg","main","argv","portIdx","histIdx","historySize","hub","shutdown"],"mappings":"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"}
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,12 +1,12 @@
1
1
  /*!
2
- * @yoltra/devtools-server v0.2.0
2
+ * @yoltra/devtools-server v0.4.0
3
3
  * (c) 2026 Manu Ramirez <@pixerael>
4
4
  * License: MIT
5
5
  * Homepage: https://yoltra.dev
6
6
  */
7
- import { DevtoolsRole as r, PROTOCOL_VERSION as l } from "@yoltra/devtools-protocol";
8
- import { WebSocketServer as h, WebSocket as d } from "ws";
9
- class f {
7
+ import { DevtoolsRole as r, PROTOCOL_VERSION as u } from "@yoltra/devtools-protocol";
8
+ import { WebSocketServer as d, WebSocket as f } from "ws";
9
+ class p {
10
10
  /**
11
11
  * @param capacity - Maximum number of items. Must be at least 1.
12
12
  */
@@ -57,7 +57,7 @@ class f {
57
57
  this.items.fill(void 0), this.head = 0, this.count = 0;
58
58
  }
59
59
  }
60
- class p {
60
+ class S {
61
61
  constructor() {
62
62
  this.stores = /* @__PURE__ */ new Map(), this.extensions = /* @__PURE__ */ new Map();
63
63
  }
@@ -101,12 +101,24 @@ class p {
101
101
  * connections in a closing or closed state are silently skipped.
102
102
  *
103
103
  * @param message - Serialized JSON message string.
104
+ * @param wants - Optional predicate over an extension's declared capabilities. Used for
105
+ * traffic an extension has said it cannot display; omit to reach every extension.
106
+ *
107
+ * @public
108
+ */
109
+ fanOutToExtensions(t, e) {
110
+ for (const [, s] of this.extensions)
111
+ s.ws.readyState === s.ws.OPEN && (e !== void 0 && !e(s.extensionInfo?.capabilities) || s.ws.send(t));
112
+ }
113
+ /**
114
+ * Ids of every currently-connected store.
115
+ *
116
+ * @returns The ids, in registration order.
104
117
  *
105
118
  * @public
106
119
  */
107
- fanOutToExtensions(t) {
108
- for (const [, e] of this.extensions)
109
- e.ws.readyState === e.ws.OPEN && e.ws.send(t);
120
+ storeIds() {
121
+ return [...this.stores.keys()];
110
122
  }
111
123
  /**
112
124
  * Route a message from an extension to a specific store.
@@ -211,22 +223,37 @@ class p {
211
223
  return this.extensions.size;
212
224
  }
213
225
  }
214
- const S = 5e3, y = 8 * 1024 * 1024;
215
- function g(o, t) {
226
+ const g = 5e3, y = 8 * 1024 * 1024;
227
+ function m(o, t) {
228
+ if (typeof t != "string" || t.length !== o.length) return !1;
229
+ let e = 0;
230
+ for (let s = 0; s < o.length; s += 1)
231
+ e |= o.charCodeAt(s) ^ t.charCodeAt(s);
232
+ return e === 0;
233
+ }
234
+ function w(o, t, e) {
216
235
  if (!o || t.includes(o)) return !0;
217
- let e;
236
+ let s;
218
237
  try {
219
- e = new URL(o);
238
+ s = new URL(o);
220
239
  } catch {
221
240
  return !1;
222
241
  }
223
- return e.protocol === "chrome-extension:" || e.protocol === "moz-extension:" || e.protocol === "safari-web-extension:" ? !0 : m(e.hostname);
242
+ return s.protocol === "chrome-extension:" || s.protocol === "moz-extension:" || s.protocol === "safari-web-extension:" ? e.length === 0 ? !0 : e.includes(s.hostname) : O(s.hostname);
224
243
  }
225
- function m(o) {
244
+ function E(o, t) {
245
+ try {
246
+ const e = JSON.parse(o);
247
+ return typeof e.storeId == "string" ? t.has(e.storeId) : !0;
248
+ } catch {
249
+ return !0;
250
+ }
251
+ }
252
+ function O(o) {
226
253
  const t = o.replace(/^\[|\]$/g, "");
227
254
  return t === "localhost" || t.endsWith(".localhost") || t === "127.0.0.1" || t.startsWith("127.") || t === "::1" || t === "0:0:0:0:0:0:0:1";
228
255
  }
229
- class O {
256
+ class T {
230
257
  /**
231
258
  * Create a new DevTools hub instance.
232
259
  *
@@ -235,7 +262,7 @@ class O {
235
262
  * @public
236
263
  */
237
264
  constructor(t = {}) {
238
- this.router = new p(), this.wss = null, this.port = t.port ?? 9800, this.host = t.host ?? "127.0.0.1", this.allowedOrigins = t.allowedOrigins ?? [], this.history = new f(t.historySize ?? 1e3);
265
+ this.router = new S(), this.wss = null, this.port = t.port ?? 9800, this.host = t.host ?? "127.0.0.1", this.allowedOrigins = t.allowedOrigins ?? [], this.authToken = t.authToken, this.allowedExtensionIds = t.allowedExtensionIds ?? [], this.maxMessagesPerSecond = t.maxMessagesPerSecond ?? 200, this.history = new p(t.historySize ?? 1e3);
239
266
  }
240
267
  /**
241
268
  * Start the WebSocket server and begin accepting connections.
@@ -248,7 +275,7 @@ class O {
248
275
  */
249
276
  async start() {
250
277
  return new Promise((t, e) => {
251
- this.wss = new h({
278
+ this.wss = new d({
252
279
  port: this.port,
253
280
  host: this.host,
254
281
  // Bound the frame size (DEV-1): oversized frames fan out to every
@@ -257,11 +284,13 @@ class O {
257
284
  maxPayload: y,
258
285
  // Reject cross-site WebSocket hijacking: the loopback bind alone does
259
286
  // not stop a page you visit from opening ws://127.0.0.1:<port>.
260
- verifyClient: (s) => g(s.origin, this.allowedOrigins) ? !0 : (console.warn(
287
+ verifyClient: (s) => w(s.origin, this.allowedOrigins, this.allowedExtensionIds) ? !0 : (console.warn(
261
288
  `[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${s.origin}`
262
289
  ), !1)
263
290
  }), this.wss.on("listening", () => {
264
- t();
291
+ this.authToken === void 0 && console.warn(
292
+ "[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."
293
+ ), t();
265
294
  }), this.wss.on("error", (s) => {
266
295
  e(s);
267
296
  }), this.wss.on("connection", (s) => {
@@ -303,7 +332,7 @@ class O {
303
332
  */
304
333
  static async probe(t) {
305
334
  return new Promise((e) => {
306
- const s = new d(`ws://127.0.0.1:${t}`), n = setTimeout(() => {
335
+ const s = new f(`ws://127.0.0.1:${t}`), n = setTimeout(() => {
307
336
  s.close(), e(!1);
308
337
  }, 2e3);
309
338
  s.on("open", () => {
@@ -324,26 +353,32 @@ class O {
324
353
  * @param ws - Newly accepted WebSocket.
325
354
  */
326
355
  handleConnection(t) {
327
- let e = null;
328
- const s = setTimeout(() => {
356
+ let e = null, s = Date.now(), n = 0;
357
+ const h = setTimeout(() => {
329
358
  e || t.close(1008, "Handshake timeout");
330
- }, S);
331
- t.on("message", (n) => {
359
+ }, g);
360
+ t.on("message", (c) => {
332
361
  let i;
333
362
  try {
334
- i = JSON.parse(n.toString());
363
+ i = JSON.parse(c.toString());
335
364
  } catch {
336
365
  return;
337
366
  }
338
- if (!(i === null || typeof i != "object" || Array.isArray(i)) && typeof i.type == "string") {
339
- if (!e) {
340
- i.type === "HANDSHAKE_REQUEST" && (clearTimeout(s), e = this.handleHandshake(t, i), e || t.close(1008, "Handshake failed"));
341
- return;
342
- }
343
- this.routeMessage(e, i);
367
+ if (i === null || typeof i != "object" || Array.isArray(i) || typeof i.type != "string") return;
368
+ const a = Date.now();
369
+ if (a - s >= 1e3 && (s = a, n = 0), n += 1, n > this.maxMessagesPerSecond) {
370
+ n === this.maxMessagesPerSecond + 1 && console.warn(
371
+ `[yoltra devtools] A ${e?.role ?? "handshaking"} client exceeded ${this.maxMessagesPerSecond} messages/second; the excess is being dropped.`
372
+ );
373
+ return;
374
+ }
375
+ if (!e) {
376
+ i.type === "HANDSHAKE_REQUEST" && (clearTimeout(h), e = this.handleHandshake(t, i), e || t.close(1008, "Handshake failed"));
377
+ return;
344
378
  }
379
+ this.routeMessage(e, i);
345
380
  }), t.on("close", () => {
346
- clearTimeout(s), e && this.handleDisconnect(e);
381
+ clearTimeout(h), e && this.handleDisconnect(e);
347
382
  }), t.on("error", () => {
348
383
  });
349
384
  }
@@ -362,56 +397,72 @@ class O {
362
397
  * handshake was rejected.
363
398
  */
364
399
  handleHandshake(t, e) {
365
- const s = parseInt(e.protocolVersion?.split(".")[0] ?? "0"), n = parseInt(l.split(".")[0]);
400
+ if (this.authToken !== void 0 && !m(this.authToken, e.authToken)) {
401
+ const a = {
402
+ type: "HANDSHAKE_RESPONSE",
403
+ success: !1,
404
+ negotiatedVersion: u,
405
+ hubCapabilities: {
406
+ maxHistorySize: this.history.capacity,
407
+ supportedFeatures: []
408
+ },
409
+ error: "Invalid or missing auth token"
410
+ };
411
+ return t.send(JSON.stringify(a)), console.warn(
412
+ `[yoltra devtools] Rejected a ${e.role} handshake: wrong or missing auth token`
413
+ ), null;
414
+ }
415
+ const s = parseInt(e.protocolVersion?.split(".")[0] ?? "0"), n = parseInt(u.split(".")[0]);
366
416
  if (s !== n) {
367
- const u = {
417
+ const a = {
368
418
  type: "HANDSHAKE_RESPONSE",
369
419
  success: !1,
370
- negotiatedVersion: l,
420
+ negotiatedVersion: u,
371
421
  hubCapabilities: {
372
422
  maxHistorySize: this.history.capacity,
373
423
  supportedFeatures: []
374
424
  },
375
- error: `Incompatible protocol version: ${e.protocolVersion} (hub: ${l})`
425
+ error: `Incompatible protocol version: ${e.protocolVersion} (hub: ${u})`
376
426
  };
377
- return t.send(JSON.stringify(u)), null;
427
+ return t.send(JSON.stringify(a)), null;
378
428
  }
379
- const i = e.role === r.STORE ? e.store?.id : e.extension?.id;
380
- if (!i)
429
+ const h = e.role === r.STORE ? e.store?.id : e.extension?.id;
430
+ if (!h)
381
431
  return console.warn(
382
432
  `[yoltra devtools] Rejected handshake: role ${e.role} without a matching id payload`
383
433
  ), null;
384
- const a = {
434
+ const c = {
385
435
  ws: t,
386
436
  role: e.role,
387
- id: i,
437
+ id: h,
388
438
  connectedAt: (/* @__PURE__ */ new Date()).toISOString()
389
439
  };
390
- e.role === r.STORE && e.store ? a.storeInfo = {
440
+ e.role === r.STORE && e.store ? c.storeInfo = {
391
441
  name: e.store.name,
392
442
  capabilities: e.store.capabilities
393
- } : e.role === r.EXTENSION && e.extension && (a.extensionInfo = {
443
+ } : e.role === r.EXTENSION && e.extension && (c.extensionInfo = {
394
444
  name: e.extension.name,
395
445
  capabilities: e.extension.capabilities
396
- }), this.router.register(a);
397
- const c = {
446
+ }), this.router.register(c);
447
+ const i = {
398
448
  type: "HANDSHAKE_RESPONSE",
399
449
  success: !0,
400
- negotiatedVersion: l,
450
+ negotiatedVersion: u,
401
451
  hubCapabilities: {
402
452
  maxHistorySize: this.history.capacity,
403
453
  supportedFeatures: []
404
454
  }
405
455
  };
406
- if (t.send(JSON.stringify(c)), e.role === r.STORE) {
407
- const u = this.router.buildStoreConnectedMessage(a);
408
- u && this.router.fanOutToExtensions(u);
456
+ if (t.send(JSON.stringify(i)), e.role === r.STORE) {
457
+ const a = this.router.buildStoreConnectedMessage(c);
458
+ a && this.router.fanOutToExtensions(a);
409
459
  } else if (e.role === r.EXTENSION) {
410
460
  t.send(this.router.buildRegistryMessage());
411
- for (const u of this.history.toArray())
412
- t.send(u);
461
+ const a = new Set(this.router.storeIds());
462
+ for (const l of this.history.toArray())
463
+ E(l, a) && t.send(l);
413
464
  }
414
- return a;
465
+ return c;
415
466
  }
416
467
  /**
417
468
  * Route a post-handshake message based on the sender's role.
@@ -428,7 +479,7 @@ class O {
428
479
  routeMessage(t, e) {
429
480
  const s = JSON.stringify(e);
430
481
  if (t.role === r.STORE)
431
- this.router.fanOutToExtensions(s), e.type === "STORE_EVENT" && this.history.push(s);
482
+ e.type === "STORE_METRICS" ? this.router.fanOutToExtensions(s, (n) => n?.performanceMetrics !== !1) : this.router.fanOutToExtensions(s), e.type === "STORE_EVENT" && this.history.push(s);
432
483
  else {
433
484
  const n = e.storeId;
434
485
  n && this.router.sendToStore(n, s);
@@ -474,25 +525,25 @@ class O {
474
525
  return this.history.size;
475
526
  }
476
527
  }
477
- async function T(o = process.argv) {
528
+ async function x(o = process.argv) {
478
529
  const t = o.indexOf("--port"), e = parseInt(
479
- o.find((c) => c.startsWith("--port="))?.split("=")[1] ?? (t !== -1 ? o[t + 1] : void 0) ?? "9800"
530
+ o.find((i) => i.startsWith("--port="))?.split("=")[1] ?? (t !== -1 ? o[t + 1] : void 0) ?? "9800"
480
531
  ), s = o.indexOf("--history-size"), n = parseInt(
481
- o.find((c) => c.startsWith("--history-size="))?.split("=")[1] ?? (s !== -1 ? o[s + 1] : void 0) ?? "1000"
482
- ), i = new O({ port: e, historySize: n }), a = async () => {
532
+ o.find((i) => i.startsWith("--history-size="))?.split("=")[1] ?? (s !== -1 ? o[s + 1] : void 0) ?? "1000"
533
+ ), h = new T({ port: e, historySize: n }), c = async () => {
483
534
  console.log(`
484
- Shutting down DevTools hub...`), await i.stop(), process.exit(0);
535
+ Shutting down DevTools hub...`), await h.stop(), process.exit(0);
485
536
  };
486
- process.on("SIGINT", a), process.on("SIGTERM", a);
537
+ process.on("SIGINT", c), process.on("SIGTERM", c);
487
538
  try {
488
- await i.start(), console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${e}`), console.log(`History buffer: ${n} events`);
489
- } catch (c) {
490
- console.error("Failed to start DevTools hub:", c), process.exit(1);
539
+ await h.start(), console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${e}`), console.log(`History buffer: ${n} events`);
540
+ } catch (i) {
541
+ console.error("Failed to start DevTools hub:", i), process.exit(1);
491
542
  }
492
543
  }
493
544
  export {
494
- O as DevtoolsHub,
495
- f as RingBuffer,
496
- T as startCli
545
+ T as DevtoolsHub,
546
+ p as RingBuffer,
547
+ x as startCli
497
548
  };
498
549
  //# sourceMappingURL=devtools-server.esm.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"devtools-server.esm.js","sources":["../src/ring-buffer.ts","../src/router.ts","../src/hub.ts","../src/cli.ts"],"sourcesContent":["/**\n * Fixed-size circular buffer for bounded event retention.\n *\n * @module @yoltra/devtools-server\n */\n\n/**\n * Fixed-size circular buffer that overwrites the oldest entry on overflow.\n *\n * @typeParam T - Item type stored in the buffer.\n *\n * @remarks\n * Used by the hub to retain event history for late-connecting extensions.\n * The buffer pre-allocates an array of the given capacity and uses modular\n * arithmetic to track insertion position, making {@link push} an O(1)\n * operation with no memory allocation after construction.\n *\n * @public\n */\nexport class RingBuffer<T> {\n private readonly items: Array<T | undefined>;\n private head = 0;\n private count = 0;\n\n /**\n * @param capacity - Maximum number of items. Must be at least 1.\n */\n constructor(public readonly capacity: number) {\n if (capacity < 1) throw new Error(\"RingBuffer capacity must be >= 1\");\n this.items = new Array(capacity);\n }\n\n /**\n * Push an item. Overwrites the oldest if at capacity.\n *\n * @param item - Item to add.\n *\n * @public\n */\n push(item: T): void {\n this.items[this.head] = item;\n this.head = (this.head + 1) % this.capacity;\n if (this.count < this.capacity) {\n this.count++;\n }\n }\n\n /**\n * Returns all items in insertion order (oldest first).\n *\n * @returns A new array containing buffered items from oldest to newest.\n *\n * @public\n */\n toArray(): T[] {\n if (this.count === 0) return [];\n const result: T[] = [];\n const start = this.count < this.capacity ? 0 : this.head;\n for (let i = 0; i < this.count; i++) {\n result.push(this.items[(start + i) % this.capacity] as T);\n }\n return result;\n }\n\n /**\n * Current number of items stored in the buffer.\n *\n * @returns A value between `0` and {@link capacity} inclusive.\n *\n * @public\n */\n get size(): number {\n return this.count;\n }\n\n /**\n * Remove all items.\n *\n * @public\n */\n clear(): void {\n this.items.fill(undefined);\n this.head = 0;\n this.count = 0;\n }\n}\n","/**\n * Message routing layer for the DevTools hub.\n *\n * @module @yoltra/devtools-server\n */\n\nimport {\n DevtoolsRole,\n type StoreConnected,\n type StoreDisconnected,\n type StoreRegistry,\n} from \"@yoltra/devtools-protocol\";\nimport type { WebSocket } from \"ws\";\nimport type { ConnectionInfo } from \"./connection\";\n\n/**\n * Routes DevTools protocol messages between stores and extensions.\n *\n * @remarks\n * The router maintains two parallel maps -- one for store connections and\n * one for extension connections -- and exposes helpers that implement the\n * three core routing patterns of the DevTools protocol:\n *\n * - **Fan-out**: Store messages are forwarded to every connected extension.\n * - **Targeted delivery**: Extension commands are routed to a specific\n * store identified by `storeId`.\n * - **Lifecycle broadcast**: `STORE_CONNECTED` / `STORE_DISCONNECTED`\n * events are broadcast to all extensions whenever a store joins or\n * leaves.\n *\n * @public\n */\nexport class Router {\n /** All store connections, keyed by store ID. */\n private readonly stores = new Map<string, ConnectionInfo>();\n /** All extension connections, keyed by extension ID. */\n private readonly extensions = new Map<string, ConnectionInfo>();\n\n /**\n * Register a newly handshaked connection.\n *\n * @param info - Connection info from the completed handshake.\n *\n * @public\n */\n register(info: ConnectionInfo): void {\n if (info.role === DevtoolsRole.STORE) {\n this.stores.set(info.id, info);\n } else {\n this.extensions.set(info.id, info);\n }\n }\n\n /**\n * Remove a connection by ID.\n *\n * @param id - Client ID to remove.\n * @param role - Client role (`STORE` or `EXTENSION`).\n *\n * @public\n */\n unregister(id: string, role: DevtoolsRole): void {\n if (role === DevtoolsRole.STORE) {\n this.stores.delete(id);\n } else {\n this.extensions.delete(id);\n }\n }\n\n /**\n * Get the WebSocket for a specific store.\n *\n * @param storeId - Store UUID.\n * @returns The store's WebSocket, or `undefined` if not connected.\n *\n * @public\n */\n getStoreSocket(storeId: string): WebSocket | undefined {\n return this.stores.get(storeId)?.ws;\n }\n\n /**\n * Route a message from a store to all extensions (fan-out).\n *\n * @remarks\n * Only sends to extensions whose WebSocket is in the `OPEN` ready-state;\n * connections in a closing or closed state are silently skipped.\n *\n * @param message - Serialized JSON message string.\n *\n * @public\n */\n fanOutToExtensions(message: string): void {\n for (const [, ext] of this.extensions) {\n if (ext.ws.readyState === ext.ws.OPEN) {\n ext.ws.send(message);\n }\n }\n }\n\n /**\n * Route a message from an extension to a specific store.\n *\n * @param storeId - Target store UUID.\n * @param message - Serialized JSON message string.\n * @returns `true` if the message was sent, `false` if the store was\n * not found or its socket was not open.\n *\n * @public\n */\n sendToStore(storeId: string, message: string): boolean {\n const store = this.stores.get(storeId);\n if (!store || store.ws.readyState !== store.ws.OPEN) return false;\n store.ws.send(message);\n return true;\n }\n\n /**\n * Build a `STORE_CONNECTED` broadcast message.\n *\n * @param info - Store connection info (must have {@link ConnectionInfo.storeInfo}).\n * @returns Serialized {@link StoreConnected} JSON string.\n *\n * @public\n */\n buildStoreConnectedMessage(info: ConnectionInfo): string | null {\n // Only a fully-registered STORE connection carries storeInfo. Guard instead\n // of asserting so an incomplete registration can't crash the hub; the caller\n // skips fan-out when this returns null.\n if (!info.storeInfo) return null;\n const msg: StoreConnected = {\n type: \"STORE_CONNECTED\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n store: {\n id: info.id,\n name: info.storeInfo.name,\n capabilities: info.storeInfo.capabilities,\n },\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Build a `STORE_DISCONNECTED` broadcast message.\n *\n * @param storeId - Disconnected store ID.\n * @param reason - Optional human-readable disconnect reason.\n * @returns Serialized {@link StoreDisconnected} JSON string.\n *\n * @public\n */\n buildStoreDisconnectedMessage(storeId: string, reason?: string): string {\n const msg: StoreDisconnected = {\n type: \"STORE_DISCONNECTED\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n storeId,\n reason,\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Build a `STORE_REGISTRY` message listing all connected stores.\n *\n * @returns Serialized {@link StoreRegistry} JSON string.\n *\n * @public\n */\n buildRegistryMessage(): string {\n const msg: StoreRegistry = {\n type: \"STORE_REGISTRY\",\n timestamp: new Date().toISOString(),\n sourceId: \"hub\",\n sourceRole: DevtoolsRole.HUB,\n stores: Array.from(this.stores.values()).flatMap((s) => {\n // Skip connections whose registration hasn't completed (no storeInfo).\n if (!s.storeInfo) return [];\n return [\n {\n id: s.id,\n name: s.storeInfo.name,\n status: \"connected\" as const,\n capabilities: s.storeInfo.capabilities,\n connectedAt: s.connectedAt,\n },\n ];\n }),\n };\n return JSON.stringify(msg);\n }\n\n /**\n * Number of connected stores.\n *\n * @returns Current store connection count.\n *\n * @public\n */\n get storeCount(): number {\n return this.stores.size;\n }\n\n /**\n * Number of connected extensions.\n *\n * @returns Current extension connection count.\n *\n * @public\n */\n get extensionCount(): number {\n return this.extensions.size;\n }\n}\n","/**\n * Central WebSocket hub that brokers DevTools protocol traffic.\n *\n * @module @yoltra/devtools-server\n */\n\nimport {\n DevtoolsRole,\n PROTOCOL_VERSION,\n type HandshakeRequest,\n type HandshakeResponse,\n} from \"@yoltra/devtools-protocol\";\nimport { WebSocket, WebSocketServer } from \"ws\";\nimport type { ConnectionInfo } from \"./connection\";\nimport { RingBuffer } from \"./ring-buffer\";\nimport { Router } from \"./router\";\n\n/**\n * Configuration for the DevTools hub server.\n *\n * @remarks\n * All fields are optional; sensible defaults are applied when omitted.\n *\n * @public\n */\nexport interface DevtoolsHubOptions {\n /** Port to bind on. @default 9800 */\n port?: number;\n /** Host to bind on. @default \"127.0.0.1\" (localhost only for v1 security) */\n host?: string;\n /** Maximum events retained in the ring buffer for late-connecting extensions. @default 1000 */\n historySize?: number;\n /**\n * Extra WebSocket `Origin` values to accept, beyond the always-allowed set\n * (no Origin, browser-extension origins, and loopback origins). Use this only\n * for a non-loopback local dev host (e.g. a custom `.local` domain). Adding a\n * remote origin re-opens the cross-site hijack surface — don't.\n */\n allowedOrigins?: string[];\n}\n\n/**\n * Timeout for receiving a handshake request after a WebSocket connection\n * is established, in milliseconds.\n *\n * @remarks\n * If the client does not send a valid `HANDSHAKE_REQUEST` within this\n * window the connection is closed with code `1008` (Policy Violation).\n *\n * @internal\n */\nconst HANDSHAKE_TIMEOUT_MS = 5_000;\n\n/**\n * Maximum accepted WebSocket frame size (bytes). Frames fan out to every\n * extension and buffer into history, so an unbounded size is a local\n * DoS / memory-amplification vector. 8 MiB comfortably covers real state\n * snapshots while rejecting hostile oversized frames.\n */\nconst MAX_WS_PAYLOAD_BYTES = 8 * 1024 * 1024;\n\n/**\n * Whether a WebSocket `Origin` may connect to the hub.\n *\n * @remarks\n * The hub binds to loopback, but that does not stop a page you visit from\n * opening `ws://127.0.0.1:<port>` — WebSockets are exempt from same-origin/CORS,\n * so a remote page could otherwise exfiltrate state and drive the store. We\n * allow only: no Origin (node agent, CLI, some extension contexts), browser\n * extension origins (the user-installed panel), loopback origins (the local dev\n * app running the agent, or a local storeview), and any explicitly configured\n * origins. A remote origin (e.g. `https://evil.com`) is rejected.\n *\n * @internal\n */\nfunction isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean {\n if (!origin) return true; // non-browser client; not reachable from a web page\n if (allowed.includes(origin)) return true;\n let url: URL;\n try {\n url = new URL(origin);\n } catch {\n return false;\n }\n if (\n url.protocol === \"chrome-extension:\" ||\n url.protocol === \"moz-extension:\" ||\n url.protocol === \"safari-web-extension:\"\n ) {\n return true;\n }\n return isLoopbackHost(url.hostname);\n}\n\n/** Loopback host check: `localhost`, the 127.0.0.0/8 block, and IPv6 `::1`. @internal */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h.startsWith(\"127.\") ||\n h === \"::1\" ||\n h === \"0:0:0:0:0:0:0:1\"\n );\n}\n\n/**\n * Central WebSocket hub that brokers messages between Yoltra stores and DevTools extensions.\n *\n * @remarks\n * - Accepts WS connections, validates protocol handshakes, and routes messages.\n * - Store events are fan-out to all extension clients.\n * - Extension commands are routed to the target store by `storeId`.\n * - Maintains a ring buffer of recent events for late-connecting extensions.\n * - Binds to localhost only (v1 security).\n *\n * @example Embeddable usage\n * ```ts\n * import { DevtoolsHub } from '@yoltra/devtools-server';\n *\n * const hub = new DevtoolsHub({ port: 9800 });\n * await hub.start();\n * // ... later\n * await hub.stop();\n * ```\n *\n * @public\n */\nexport class DevtoolsHub {\n private readonly port: number;\n private readonly host: string;\n private readonly allowedOrigins: readonly string[];\n private readonly router = new Router();\n private readonly history: RingBuffer<string>;\n private wss: WebSocketServer | null = null;\n\n /**\n * Create a new DevTools hub instance.\n *\n * @param opts - Hub configuration. All fields are optional.\n *\n * @public\n */\n constructor(opts: DevtoolsHubOptions = {}) {\n this.port = opts.port ?? 9800;\n this.host = opts.host ?? \"127.0.0.1\";\n this.allowedOrigins = opts.allowedOrigins ?? [];\n this.history = new RingBuffer<string>(opts.historySize ?? 1000);\n }\n\n /**\n * Start the WebSocket server and begin accepting connections.\n *\n * @returns Resolves once the server is bound and listening.\n * @throws If the underlying `WebSocketServer` emits an error during\n * startup (e.g. port already in use).\n *\n * @public\n */\n async start(): Promise<void> {\n return new Promise((resolve, reject) => {\n this.wss = new WebSocketServer({\n port: this.port,\n host: this.host,\n // Bound the frame size (DEV-1): oversized frames fan out to every\n // extension and buffer into history, so an unbounded cap is a local\n // DoS / memory-amplification vector.\n maxPayload: MAX_WS_PAYLOAD_BYTES,\n // Reject cross-site WebSocket hijacking: the loopback bind alone does\n // not stop a page you visit from opening ws://127.0.0.1:<port>.\n verifyClient: (info: { origin?: string }) => {\n if (isOriginAllowed(info.origin, this.allowedOrigins)) return true;\n console.warn(\n `[yoltra devtools] Rejected WebSocket connection from disallowed origin: ${info.origin}`,\n );\n return false;\n },\n });\n\n this.wss.on(\"listening\", () => {\n resolve();\n });\n\n this.wss.on(\"error\", (err) => {\n reject(err);\n });\n\n this.wss.on(\"connection\", (ws) => {\n this.handleConnection(ws);\n });\n });\n }\n\n /**\n * Stop the server and close all connections.\n *\n * @remarks\n * Existing client sockets are closed with code `1001` (\"Going Away\")\n * before the server socket is torn down.\n *\n * @returns Resolves once the server has fully shut down.\n *\n * @public\n */\n async stop(): Promise<void> {\n return new Promise((resolve) => {\n if (!this.wss) {\n resolve();\n return;\n }\n this.wss.close(() => {\n this.wss = null;\n resolve();\n });\n // Close all existing connections\n for (const client of this.wss.clients) {\n client.close(1001, \"Hub shutting down\");\n }\n });\n }\n\n /**\n * Check if a DevTools hub is already running on the given port.\n *\n * @param port - Port to probe.\n * @returns `true` if a hub is listening and responds to handshake.\n *\n * @public\n */\n static async probe(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const ws = new WebSocket(`ws://127.0.0.1:${port}`);\n const timeout = setTimeout(() => {\n ws.close();\n resolve(false);\n }, 2_000);\n\n ws.on(\"open\", () => {\n clearTimeout(timeout);\n ws.close();\n resolve(true);\n });\n\n ws.on(\"error\", () => {\n clearTimeout(timeout);\n resolve(false);\n });\n });\n }\n\n /**\n * Handle a new WebSocket connection: wait for handshake, then route messages.\n *\n * @remarks\n * Starts a handshake timeout timer. If the first valid message is a\n * `HANDSHAKE_REQUEST` the connection is promoted to a routed client;\n * otherwise it is closed after {@link HANDSHAKE_TIMEOUT_MS}.\n *\n * @param ws - Newly accepted WebSocket.\n */\n private handleConnection(ws: WebSocket): void {\n let connectionInfo: ConnectionInfo | null = null;\n\n // Handshake timeout: close if no handshake within 5s\n const handshakeTimer = setTimeout(() => {\n if (!connectionInfo) {\n ws.close(1008, \"Handshake timeout\");\n }\n }, HANDSHAKE_TIMEOUT_MS);\n\n ws.on(\"message\", (data) => {\n let parsed: any;\n try {\n parsed = JSON.parse(data.toString());\n } catch {\n return; // Ignore malformed messages\n }\n\n // Ingress validation (DEV-3): every protocol message is a plain object\n // with a string `type` discriminant. Reject anything else (null, arrays,\n // primitives, missing type) before it reaches handshake/routing.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return;\n if (typeof parsed.type !== \"string\") return;\n\n // Handle handshake\n if (!connectionInfo) {\n if (parsed.type === \"HANDSHAKE_REQUEST\") {\n clearTimeout(handshakeTimer);\n connectionInfo = this.handleHandshake(ws, parsed as HandshakeRequest);\n if (!connectionInfo) {\n ws.close(1008, \"Handshake failed\");\n }\n }\n return;\n }\n\n // Route messages based on role\n this.routeMessage(connectionInfo, parsed);\n });\n\n ws.on(\"close\", () => {\n clearTimeout(handshakeTimer);\n if (connectionInfo) {\n this.handleDisconnect(connectionInfo);\n }\n });\n\n ws.on(\"error\", () => {\n // Error is followed by close event, handled there\n });\n }\n\n /**\n * Process a handshake request: validate, register, and respond.\n *\n * @remarks\n * Performs a major-version compatibility check against\n * {@link PROTOCOL_VERSION}. On success the connection is registered with\n * the {@link Router} and post-handshake side-effects are triggered\n * (store-connected broadcast or registry + history replay).\n *\n * @param ws - The client WebSocket.\n * @param req - Parsed handshake request payload.\n * @returns The new {@link ConnectionInfo} on success, or `null` if the\n * handshake was rejected.\n */\n private handleHandshake(ws: WebSocket, req: HandshakeRequest): ConnectionInfo | null {\n // Basic protocol version check (accept same major version)\n const reqMajor = parseInt(req.protocolVersion?.split(\".\")[0] ?? \"0\");\n const ourMajor = parseInt(PROTOCOL_VERSION.split(\".\")[0]);\n if (reqMajor !== ourMajor) {\n const response: HandshakeResponse = {\n type: \"HANDSHAKE_RESPONSE\",\n success: false,\n negotiatedVersion: PROTOCOL_VERSION,\n hubCapabilities: {\n maxHistorySize: this.history.capacity,\n supportedFeatures: [],\n },\n error: `Incompatible protocol version: ${req.protocolVersion} (hub: ${PROTOCOL_VERSION})`,\n };\n ws.send(JSON.stringify(response));\n return null;\n }\n\n // A STORE handshake must carry `store`, an EXTENSION handshake `extension`.\n // Guard the role/payload match instead of dereferencing a missing field.\n const id = req.role === DevtoolsRole.STORE ? req.store?.id : req.extension?.id;\n if (!id) {\n console.warn(\n `[yoltra devtools] Rejected handshake: role ${req.role} without a matching id payload`,\n );\n return null;\n }\n\n // Build connection info\n const info: ConnectionInfo = {\n ws,\n role: req.role,\n id,\n connectedAt: new Date().toISOString(),\n };\n\n if (req.role === DevtoolsRole.STORE && req.store) {\n info.storeInfo = {\n name: req.store.name,\n capabilities: req.store.capabilities,\n };\n } else if (req.role === DevtoolsRole.EXTENSION && req.extension) {\n info.extensionInfo = {\n name: req.extension.name,\n capabilities: req.extension.capabilities,\n };\n }\n\n // Register in router\n this.router.register(info);\n\n // Send handshake response\n const response: HandshakeResponse = {\n type: \"HANDSHAKE_RESPONSE\",\n success: true,\n negotiatedVersion: PROTOCOL_VERSION,\n hubCapabilities: {\n maxHistorySize: this.history.capacity,\n supportedFeatures: [],\n },\n };\n ws.send(JSON.stringify(response));\n\n // Post-handshake actions\n if (req.role === DevtoolsRole.STORE) {\n // Broadcast STORE_CONNECTED to all extensions\n const connectMsg = this.router.buildStoreConnectedMessage(info);\n if (connectMsg) this.router.fanOutToExtensions(connectMsg);\n } else if (req.role === DevtoolsRole.EXTENSION) {\n // Send current store registry to the new extension\n ws.send(this.router.buildRegistryMessage());\n // Send buffered event history\n for (const msg of this.history.toArray()) {\n ws.send(msg);\n }\n }\n\n return info;\n }\n\n /**\n * Route a post-handshake message based on the sender's role.\n *\n * @remarks\n * Store messages are fanned-out to all extensions and, if the message\n * type is `STORE_EVENT`, buffered in the ring buffer for replay.\n * Extension messages are forwarded to the store identified by\n * `msg.storeId`.\n *\n * @param sender - Connection info of the sending client.\n * @param msg - Parsed message payload (untyped; serialized internally).\n */\n private routeMessage(sender: ConnectionInfo, msg: any): void {\n const raw = JSON.stringify(msg);\n\n if (sender.role === DevtoolsRole.STORE) {\n // Store messages → fan-out to all extensions\n this.router.fanOutToExtensions(raw);\n\n // Buffer STORE_EVENT messages in the ring buffer\n if (msg.type === \"STORE_EVENT\") {\n this.history.push(raw);\n }\n } else {\n // Extension commands → route to target store\n const storeId = msg.storeId as string | undefined;\n if (storeId) {\n this.router.sendToStore(storeId, raw);\n }\n }\n }\n\n /**\n * Handle a client disconnection.\n *\n * @remarks\n * Unregisters the client from the {@link Router}. If the client was a\n * store, a `STORE_DISCONNECTED` event is broadcast to all extensions.\n *\n * @param info - Connection info of the disconnected client.\n */\n private handleDisconnect(info: ConnectionInfo): void {\n this.router.unregister(info.id, info.role);\n\n if (info.role === DevtoolsRole.STORE) {\n // Broadcast STORE_DISCONNECTED to all extensions\n const disconnectMsg = this.router.buildStoreDisconnectedMessage(info.id, \"disconnected\");\n this.router.fanOutToExtensions(disconnectMsg);\n }\n }\n\n /**\n * Current number of connected stores.\n *\n * @public\n */\n get storeCount(): number {\n return this.router.storeCount;\n }\n\n /**\n * Current number of connected extensions.\n *\n * @public\n */\n get extensionCount(): number {\n return this.router.extensionCount;\n }\n\n /**\n * Number of events in the history ring buffer.\n *\n * @public\n */\n get historySize(): number {\n return this.history.size;\n }\n}\n","/**\n * CLI entry-point for the standalone DevTools hub process.\n *\n * @module @yoltra/devtools-server\n */\n\nimport { DevtoolsHub } from \"./hub\";\n\n/**\n * Parse CLI arguments and start the hub server.\n *\n * @remarks\n * Supported flags:\n *\n * | Flag | Default | Description |\n * | ------------------ | ------- | ---------------------------------- |\n * | `--port` | `9800` | WebSocket port to bind on. |\n * | `--history-size` | `1000` | Ring-buffer capacity for replays. |\n *\n * The function installs `SIGINT` and `SIGTERM` handlers for graceful\n * shutdown and exits with code `1` if the server fails to start.\n *\n * Usage: `npx @yoltra/devtools-server [--port 9800] [--history-size 1000]`\n *\n * @param argv - Argument vector to parse. Defaults to `process.argv`.\n * @returns Resolves once the hub is listening; never resolves during\n * normal operation (the process stays alive until a signal).\n *\n * @public\n */\nexport async function main(argv: string[] = process.argv): Promise<void> {\n const portIdx = argv.indexOf(\"--port\");\n const port = parseInt(\n argv.find((a) => a.startsWith(\"--port=\"))?.split(\"=\")[1] ??\n (portIdx !== -1 ? argv[portIdx + 1] : undefined) ??\n \"9800\",\n );\n\n const histIdx = argv.indexOf(\"--history-size\");\n const historySize = parseInt(\n argv.find((a) => a.startsWith(\"--history-size=\"))?.split(\"=\")[1] ??\n (histIdx !== -1 ? argv[histIdx + 1] : undefined) ??\n \"1000\",\n );\n\n const hub = new DevtoolsHub({ port, historySize });\n\n // Graceful shutdown\n const shutdown = async () => {\n console.log(\"\\nShutting down DevTools hub...\");\n await hub.stop();\n process.exit(0);\n };\n\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n\n try {\n await hub.start();\n console.log(`Yoltra DevTools hub running on ws://127.0.0.1:${port}`);\n console.log(`History buffer: ${historySize} events`);\n } catch (err) {\n console.error(\"Failed to start DevTools hub:\", err);\n process.exit(1);\n }\n}\n"],"names":["RingBuffer","capacity","item","result","start","i","Router","info","DevtoolsRole","id","role","storeId","message","ext","store","msg","reason","s","HANDSHAKE_TIMEOUT_MS","MAX_WS_PAYLOAD_BYTES","isOriginAllowed","origin","allowed","url","isLoopbackHost","hostname","h","DevtoolsHub","opts","resolve","reject","WebSocketServer","err","ws","client","port","WebSocket","timeout","connectionInfo","handshakeTimer","data","parsed","req","reqMajor","ourMajor","PROTOCOL_VERSION","response","connectMsg","sender","raw","disconnectMsg","main","argv","portIdx","a","histIdx","historySize","hub","shutdown"],"mappings":"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"}
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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"}
@@ -25,6 +25,47 @@ export interface DevtoolsHubOptions {
25
25
  * remote origin re-opens the cross-site hijack surface — don't.
26
26
  */
27
27
  allowedOrigins?: string[];
28
+ /**
29
+ * Shared secret every client must present in its handshake.
30
+ *
31
+ * @remarks
32
+ * The hub binds to loopback, which keeps the network out — but loopback is not an
33
+ * authentication boundary. Every other process on the machine can reach it, so without a token
34
+ * anything running locally can connect as a panel and read the application's entire state,
35
+ * inject events, and overwrite state through time-travel. That includes a package's install
36
+ * script, and anything else sharing a CI runner or a container.
37
+ *
38
+ * Unset by default, because requiring one would break the zero-configuration local flow that
39
+ * makes the tool worth using. When unset the hub says so once at startup rather than leaving
40
+ * the exposure unmentioned.
41
+ */
42
+ authToken?: string;
43
+ /**
44
+ * Extension ids allowed to connect, e.g. `["abcdefghijklmnopabcdefghijklmnop"]`.
45
+ *
46
+ * @remarks
47
+ * Extension origins all share one scheme, so permitting the scheme permits every extension the
48
+ * user has installed — any of which could open this socket from a devtools page of its own.
49
+ * Naming ids narrows that to the panel meant to connect.
50
+ *
51
+ * Empty by default, which keeps every extension origin allowed: an unpacked build and a store
52
+ * install have different ids, so assuming one would lock out a developer running the extension
53
+ * they just built. Set it alongside {@link DevtoolsHubOptions.authToken} on any machine where
54
+ * other extensions are not automatically trusted.
55
+ */
56
+ allowedExtensionIds?: string[];
57
+ /**
58
+ * Most messages one client may send per second before the excess is dropped.
59
+ *
60
+ * @remarks
61
+ * A command like `REQUEST_STATE` costs the *store* a full serialization of its state and the
62
+ * hub a fan-out, so a client that loops on it turns one cheap socket write into repeated work
63
+ * across every connected process. This bounds that without affecting a panel behaving
64
+ * normally, which sends a handful of commands per interaction.
65
+ *
66
+ * @defaultValue 200
67
+ */
68
+ maxMessagesPerSecond?: number;
28
69
  }
29
70
  /**
30
71
  * Central WebSocket hub that brokers messages between Yoltra stores and DevTools extensions.
@@ -52,6 +93,12 @@ export declare class DevtoolsHub {
52
93
  private readonly port;
53
94
  private readonly host;
54
95
  private readonly allowedOrigins;
96
+ /** Shared secret required from every client, or `undefined` when the hub is open. */
97
+ private readonly authToken;
98
+ /** Extension ids permitted to connect; empty means every extension origin. */
99
+ private readonly allowedExtensionIds;
100
+ /** Per-second message allowance for one client. */
101
+ private readonly maxMessagesPerSecond;
55
102
  private readonly router;
56
103
  private readonly history;
57
104
  private wss;
@@ -1,4 +1,4 @@
1
- import { DevtoolsRole } from '@yoltra/devtools-protocol';
1
+ import { DevtoolsRole, ExtensionCapabilities } from '@yoltra/devtools-protocol';
2
2
  import { WebSocket } from 'ws';
3
3
  import { ConnectionInfo } from './connection';
4
4
  /**
@@ -57,10 +57,20 @@ export declare class Router {
57
57
  * connections in a closing or closed state are silently skipped.
58
58
  *
59
59
  * @param message - Serialized JSON message string.
60
+ * @param wants - Optional predicate over an extension's declared capabilities. Used for
61
+ * traffic an extension has said it cannot display; omit to reach every extension.
60
62
  *
61
63
  * @public
62
64
  */
63
- fanOutToExtensions(message: string): void;
65
+ fanOutToExtensions(message: string, wants?: (capabilities: ExtensionCapabilities | undefined) => boolean): void;
66
+ /**
67
+ * Ids of every currently-connected store.
68
+ *
69
+ * @returns The ids, in registration order.
70
+ *
71
+ * @public
72
+ */
73
+ storeIds(): string[];
64
74
  /**
65
75
  * Route a message from an extension to a specific store.
66
76
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoltra/devtools-server",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Hub WebSocket server for Yoltra DevTools — standalone CLI and embeddable library",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -42,18 +42,19 @@
42
42
  "sideEffects": false,
43
43
  "dependencies": {
44
44
  "ws": "^8.19.0",
45
- "@yoltra/devtools-protocol": "0.2.0"
45
+ "@yoltra/devtools-protocol": "0.4.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/ws": "^8.18.1",
49
48
  "@types/node": "^24.0.12",
49
+ "@types/ws": "^8.18.1",
50
+ "@vitest/coverage-v8": "3.2.4",
50
51
  "typedoc": "^0.28.13",
51
- "typedoc-plugin-markdown": "4.9.0",
52
52
  "typedoc-plugin-localization": "3.0.6",
53
+ "typedoc-plugin-markdown": "4.9.0",
53
54
  "typescript": "5.9.3",
54
55
  "vite": "^7.1.11",
55
- "vite-plugin-dts": "^4.5.4",
56
56
  "vite-plugin-banner": "0.8.1",
57
+ "vite-plugin-dts": "^4.5.4",
57
58
  "vitest": "3.2.4"
58
59
  },
59
60
  "engines": {
@@ -64,9 +65,9 @@
64
65
  },
65
66
  "scripts": {
66
67
  "build": "vite build",
67
- "test": "vitest --watch=false",
68
+ "test": "vitest --watch=false --coverage",
68
69
  "lint": "node ../../tools/repo-tools/bin/repo-eslint.cjs --report-unused-disable-directives --max-warnings 0",
69
- "typecheck": "tsc --noEmit",
70
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.vitest.json --noEmit",
70
71
  "docs": "rushx docs:js && rushx docs:md",
71
72
  "docs:md": "pnpm typedoc --options ./typedoc.json",
72
73
  "docs:js": "pnpm typedoc --options ./typedoc.json --json ./.typedoc/devtools-server-en.json",