@yoltra/devtools-browser-agent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ ![Yoltra logo](../../assets/logo.svg)
2
+
3
+ # @yoltra/devtools-browser-agent
4
+
5
+ > [ πŸ‡²πŸ‡½ VersiΓ³n en EspaΓ±ol](./README.es.md) 
6
+ > | πŸ‘‰
7
+ > [ πŸ‡ΊπŸ‡Έ English Version](./README.md) 
8
+
9
+ **Browser DevTools agent β€” connect a Yoltra store to the DevTools hub from the browser.**
10
+
11
+ `@yoltra/devtools-browser-agent` transparently instruments a Yoltra store so every event, state
12
+ change, and metric is forwarded to the DevTools hub in real time. Uses the native browser
13
+ `WebSocket` API (no `ws` dependency) with automatic reconnection and message buffering.
14
+
15
+ ---
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @yoltra/devtools-browser-agent
21
+ ```
22
+
23
+ **Peer dependency:** `@yoltra/core`
24
+
25
+ ---
26
+
27
+ ## Quick Start
28
+
29
+ ```typescript
30
+ import { createStore } from "@yoltra/core";
31
+ import { withDevtools } from "@yoltra/devtools-browser-agent";
32
+
33
+ const store = createStore({
34
+ name: "TodoApp",
35
+ reducer: {
36
+ todos: {
37
+ state: { items: [] },
38
+ when: { channel: "todos" },
39
+ reducer: (state, event) => {
40
+ if (event.type === "add") return { items: [...state.items, event.payload] };
41
+ return state;
42
+ },
43
+ },
44
+ },
45
+ });
46
+
47
+ // Instrument the store β€” connects to hub on ws://localhost:9800
48
+ withDevtools(store, { port: 9800 });
49
+
50
+ // Use the store as normal β€” events are automatically forwarded
51
+ await store.emit("todos", "add", { title: "Buy milk" });
52
+ ```
53
+
54
+ ---
55
+
56
+ ## How It Works
57
+
58
+ 1. **Registers a `when: { any: true }` effect** on the store to intercept every event
59
+ 2. **Computes JSON Patch diffs** between previous and next state
60
+ 3. **Sends `STORE_EVENT` messages** with patches to the hub
61
+ 4. **Buffers messages** (up to 100) while disconnected, flushes on reconnect
62
+ 5. **Handles incoming commands** from extensions:
63
+ - `REQUEST_STATE` β†’ full state snapshot
64
+ - `REQUEST_METRICS` β†’ performance counters
65
+ - `REQUEST_SUBSCRIPTIONS` β†’ reducer/effect inventory
66
+ - `TIME_TRAVEL` β†’ restore store to a previous state
67
+ - `EVENT_REPLAY` β†’ replay events through reducers only
68
+ - `EMIT_TO_STORE` β†’ inject a synthetic event
69
+
70
+ The wrapper is **transparent** β€” it returns the same store instance.
71
+
72
+ ---
73
+
74
+ ## Configuration
75
+
76
+ ```typescript
77
+ interface DevtoolsWrapperConfig {
78
+ /** Hub server port. Required. */
79
+ port: number;
80
+ /** Hub server host. @default "localhost" */
81
+ host?: string;
82
+ /** Persisted store ID (survives reconnects). @default crypto.randomUUID() */
83
+ storeId?: string;
84
+ /** Enable time-travel and event replay. @default false */
85
+ allowReplay?: boolean;
86
+ /** Allow extensions to emit events to this store. @default false */
87
+ allowEmit?: boolean;
88
+ /** Auto-reconnect on disconnect. @default true */
89
+ autoReconnect?: boolean;
90
+ /** Max reconnection attempts. @default Infinity */
91
+ maxReconnectAttempts?: number;
92
+ /** Base delay for exponential backoff (ms). @default 1000 */
93
+ baseDelay?: number;
94
+ /** Max delay cap for backoff (ms). @default 30000 */
95
+ maxDelay?: number;
96
+ }
97
+ ```
98
+
99
+ ### Full-Featured Setup
100
+
101
+ ```typescript
102
+ withDevtools(store, {
103
+ port: 9800,
104
+ storeId: "my-app-store",
105
+ allowReplay: true,
106
+ allowEmit: true,
107
+ autoReconnect: true,
108
+ maxReconnectAttempts: 20,
109
+ baseDelay: 1000,
110
+ maxDelay: 15000,
111
+ });
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Reconnection
117
+
118
+ The agent uses exponential backoff with jitter for reconnection:
119
+
120
+ - Starts at `baseDelay` (default 1s)
121
+ - Doubles each attempt, capped at `maxDelay` (default 30s)
122
+ - Adds 10% jitter to prevent thundering herd
123
+ - Messages are buffered during disconnects and flushed on reconnect
124
+
125
+ ---
126
+
127
+ ## API Reference
128
+
129
+ | Export | Description |
130
+ | ----------------------------- | ----------------------------------------- |
131
+ | `withDevtools(store, config)` | Instrument a store and connect to the hub |
132
+ | `DevtoolsWrapperConfig` | Configuration type |
133
+
134
+ ---
135
+
136
+ ## vs `@yoltra/devtools-node-agent`
137
+
138
+ | Feature | `devtools-browser-agent` | `devtools-node-agent` |
139
+ | ------------- | ------------------------ | ----------------------- |
140
+ | Environment | Browser | Node.js |
141
+ | WebSocket | Native `WebSocket` API | `ws` package |
142
+ | Bundle impact | Zero dependencies | Adds `ws` |
143
+ | Use case | SPAs, browser apps | Servers, CLI tools, SSR |
144
+
145
+ Both agents provide identical instrumentation and protocol compliance.
146
+
147
+ ---
148
+
149
+ ## Related Packages
150
+
151
+ - **[@yoltra/devtools-protocol](../devtools-protocol/README.md)** β€” Wire format and message
152
+ types
153
+ - **[@yoltra/devtools-server](../devtools-server/README.md)** β€” The hub this agent connects to
154
+ - **[@yoltra/devtools-ext](../devtools-ext/README.md)** β€” Browser extension that displays the UI
155
+ - **[@yoltra/core](../../packages/core/README.md)** β€” The store being instrumented
156
+
157
+ ---
158
+
159
+ ## License
160
+
161
+ **MIT** β€” Free to use in commercial and open-source projects.
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const T=require("@yoltra/devtools-protocol"),C=(o,a)=>{const s=new WebSocket(o);return s.onopen=()=>a.onOpen(),s.onmessage=c=>a.onMessage(typeof c.data=="string"?c.data:String(c.data)),s.onclose=()=>a.onClose(),s.onerror=()=>a.onError(),{get readyState(){return s.readyState},send:c=>s.send(c),close:(c,i)=>s.close(c,i),dispose:()=>{s.onopen=null,s.onmessage=null,s.onclose=null,s.onerror=null}}};class N extends T.ReconnectingWsClient{constructor(a,s,c,i,d=C){super(a,s,c,i,d)}}function I(o,a){const s=a.storeId??o.name,c=a.host??"localhost";let i=0,d=0,w=0;const S=[];let E=0;const g=new Map,f=new Map,b=(n,t)=>{const e=y.sampling;if(!e)return!1;const r=`${n}::${t}`,h=l=>l.some(([u,m])=>(u==="*"||u===n)&&(m==="*"||m===t));if(e.ignore?.some(l=>h(l.keys)))return!0;for(const l of e.throttle??[]){if(!h(l.keys))continue;const u=f.get(r)??0,m=Date.now();return m-u<l.intervalMs?!0:(f.set(r,m),!1)}for(const l of e.skip??[]){if(!h(l.keys))continue;const u=(g.get(r)??0)+1;return u<l.every?(g.set(r,u),!0):(g.set(r,0),!1)}return!1},y={replay:a.allowReplay??!1,stateSnapshot:!0,subscriptionMeta:!0,pipelineMeta:!0,emit:a.allowEmit??!1};a.sampling&&(y.sampling=a.sampling);const p=new N(s,o.name,y,{autoReconnect:a.autoReconnect??!0,maxReconnectAttempts:a.maxReconnectAttempts??1/0,baseDelay:a.baseDelay??1e3,maxDelay:a.maxDelay??3e4},a.socketFactory);let D=0;p.onBackpressure(n=>{const t=Date.now();t-D>5e3&&(D=t,console.warn(`[Yoltra DevTools] Backpressure: dropped ${n} event(s) while the hub was unreachable.`))});const v=()=>({timestamp:new Date().toISOString(),sourceId:s,sourceRole:T.DevtoolsRole.STORE});p.onMessage(async n=>{let t;try{t=JSON.parse(n)}catch{return}if(!(t===null||typeof t!="object"||typeof t.type!="string"))switch(t.type){case"REQUEST_STATE":{const e=o.getState(),r={type:"STATE_SNAPSHOT",...v(),storeId:s,state:JSON.parse(JSON.stringify(e)),version:i,reducerNames:Object.keys(e)};p.send(JSON.stringify(r));break}case"REQUEST_METRICS":{const e=o.__devtoolsIntrospect(),r=Date.now()-1e3,h=S.filter(k=>k>=r).length,l=Math.max(0,w-d),u=d>0?E/d:0,m={type:"STORE_METRICS",...v(),storeId:s,metrics:{eventCount:d,eventsPerSecond:h,avgProcessingTimeMs:u,dedupHits:e.dedupHits,queueDepth:e.queueDepth,reducerCount:e.reducers.length,effectCount:e.effects.length,middlewareCount:e.middleware.length,subscriberCount:e.event.length+e.coarse,connectorCount:e.atomic.length,middlewareRejections:l}};p.send(JSON.stringify(m));break}case"TIME_TRAVEL":{if(!y.replay||t.state==null)break;o.__applyExternalState(t.state),i=t.snapshotVersion??i;const e=o.getState(),r={type:"STATE_SNAPSHOT",...v(),storeId:s,state:JSON.parse(JSON.stringify(e)),version:i,reducerNames:Object.keys(e)};p.send(JSON.stringify(r));break}case"EVENT_REPLAY":{y.replay&&o.__replayEvents(t.snapshot,t.events);break}case"EMIT_TO_STORE":{y.emit&&t.event&&await o.emit(t.event.channel,t.event.type,t.event.payload);break}case"REQUEST_SUBSCRIPTIONS":{const e=o.__devtoolsIntrospect(),r={type:"STORE_SUBSCRIPTIONS",...v(),storeId:s,atomic:e.atomic,event:e.event,coarse:e.coarse,effects:e.effects,middleware:e.middleware,reducers:e.reducers};p.send(JSON.stringify(r));break}default:{console.warn(`[Yoltra DevTools] Ignoring unknown message type: ${String(t.type)}`);break}}});const O=o.__yoltraDevtoolsDispose;O&&O();const R=o.instrument(n=>{if(w++,n.committed){d++,E+=n.reduceTimeMs;const e=Date.now();for(S.push(e);S.length>0&&e-S[0]>6e4;)S.shift()}if(b(n.event.channel,n.event.type))return;n.committed&&i++;const t={type:"STORE_EVENT",...v(),storeId:s,event:{id:n.event.id,channel:n.event.channel,type:n.event.type,payload:n.event.payload},patches:n.committed?T.patchesFromChange(n.changedPaths,n.prevValues,n.nextValues):[],snapshotVersion:i,committed:n.committed};p.send(JSON.stringify(t))});p.connect(c,a.port);const _=()=>{R(),p.disconnect(),o.__yoltraDevtoolsDispose=void 0};o.__yoltraDevtoolsDispose=_;const M=o.dispose.bind(o);return o.dispose=()=>{_(),M()},o}exports.withDevtools=I;
2
+ //# sourceMappingURL=devtools-browser-agent.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devtools-browser-agent.cjs.js","sources":["../src/ws-client.ts","../src/withDevtools.ts"],"sourcesContent":["/**\n * Browser WebSocket transport for the DevTools agent.\n *\n * @remarks\n * Injects the native browser `WebSocket` into the shared, transport-agnostic\n * {@link ReconnectingWsClient} from `@yoltra/devtools-protocol`. There is **no**\n * `ws` dependency here β€” that is what keeps this package installable in the\n * browser without pulling in a Node-only transport.\n *\n * @module @yoltra/devtools-browser-agent\n */\n\nimport {\n ReconnectingWsClient,\n type DevtoolsSocketFactory,\n type ReconnectingWsConfig,\n type StoreCapabilities,\n} from \"@yoltra/devtools-protocol\";\n\nexport type { ConnectionState } from \"@yoltra/devtools-protocol\";\n\n/** Opens a native browser WebSocket and adapts it to the shared transport. */\nconst browserSocketFactory: DevtoolsSocketFactory = (url, callbacks) => {\n const ws = new WebSocket(url);\n ws.onopen = () => callbacks.onOpen();\n ws.onmessage = (ev: MessageEvent) =>\n callbacks.onMessage(typeof ev.data === \"string\" ? ev.data : String(ev.data));\n ws.onclose = () => callbacks.onClose();\n ws.onerror = () => callbacks.onError();\n return {\n get readyState() {\n return ws.readyState;\n },\n send: (data) => ws.send(data),\n close: (code, reason) => ws.close(code, reason),\n dispose: () => {\n ws.onopen = null;\n ws.onmessage = null;\n ws.onclose = null;\n ws.onerror = null;\n },\n };\n};\n\n/**\n * Browser DevTools WebSocket client (native `WebSocket` transport).\n *\n * @internal\n */\nexport class DevtoolsWsClient extends ReconnectingWsClient {\n constructor(\n storeId: string,\n storeName: string,\n capabilities: StoreCapabilities,\n config: ReconnectingWsConfig,\n socketFactory: DevtoolsSocketFactory = browserSocketFactory,\n ) {\n super(storeId, storeName, capabilities, config, socketFactory);\n }\n}\n","/**\n * Browser DevTools agent entry point -- the {@link withDevtools} wrapper function.\n *\n * @module @yoltra/devtools-browser-agent\n */\n\nimport type { EventMapBase, InstrumentedEvent, StoreInstance } from \"@yoltra/core\";\nimport {\n DevtoolsRole,\n patchesFromChange,\n type BaseMessage,\n type StateSnapshot,\n type StoreCapabilities,\n type StoreEvent,\n type StoreMetrics,\n} from \"@yoltra/devtools-protocol\";\nimport type { DevtoolsWrapperConfig } from \"./types\";\nimport { DevtoolsWsClient } from \"./ws-client\";\n\n/**\n * Wraps a Yoltra store with DevTools instrumentation for browser environments.\n *\n * @remarks\n * - Connects to the DevTools hub via native `WebSocket`.\n * - Observes every event via the typed `store.instrument()` seam β€” no\n * `as any` bridging, no re-diffing, no full-state clone per event.\n * - Builds precise RFC 6902 patches from the exact changed leaf paths the core\n * reports, and sends a `STORE_EVENT` per event (committed or vetoed).\n * - Handles incoming commands: REQUEST_STATE, REQUEST_METRICS,\n * REQUEST_SUBSCRIPTIONS, TIME_TRAVEL, EVENT_REPLAY, EMIT_TO_STORE.\n * - Returns the **same** store instance (transparent instrumentation).\n * - Auto-reconnects to the hub on disconnect.\n *\n * @typeParam R - Reducer name union.\n * @typeParam S - State record.\n * @typeParam EM - Event map.\n * @param store - The store to instrument.\n * @param config - DevTools wrapper configuration.\n * @returns The same store instance, now instrumented.\n *\n * @example\n * ```ts\n * import { createStore } from '@yoltra/core';\n * import { withDevtools } from '@yoltra/devtools-browser-agent';\n *\n * const store = createStore({ name: 'App', reducer: { ... } });\n * withDevtools(store, { port: 9800 });\n * ```\n *\n * @public\n */\nexport function withDevtools<\n R extends string,\n S extends Record<R, any>,\n EM extends EventMapBase,\n>(store: StoreInstance<R, S, EM>, config: DevtoolsWrapperConfig): StoreInstance<R, S, EM> {\n const storeId = config.storeId ?? store.name;\n const host = config.host ?? \"localhost\";\n let snapshotVersion = 0;\n\n // ── Metrics tracking ──────────────────────────────────────────────────────\n // Counts committed events (events that pass middleware and run reducers).\n let committedEventCount = 0;\n // Counts ALL observed events including those vetoed by middleware.\n let totalAttemptedCount = 0;\n // Timestamps (epoch ms) of recent committed events; pruned to last 60 s.\n const eventTimestamps: number[] = [];\n // Rolling sum of the core-reported reduce time (ms) over committed events.\n let reduceTimeSumMs = 0;\n\n // ── Sampling state ─────────────────────────────────────────────────────────\n // Tracks per-event-key counters and timestamps to enforce the three sampling\n // rule types declared in `capabilities.sampling`:\n // ignore β€” never forward matching events.\n // skip β€” forward every Nth matching event (0-based counter).\n // throttle β€” forward at most once per intervalMs per key.\n const skipCounters = new Map<string, number>(); // current invocation count per key\n const throttleLast = new Map<string, number>(); // last-forwarded epoch ms per key\n\n /**\n * Returns `true` when the event should be suppressed by the sampling config.\n * All three rule types are checked in priority order: ignore β†’ throttle β†’ skip.\n */\n const isSampledOut = (channel: string, type: string): boolean => {\n const sampling = capabilities.sampling;\n if (!sampling) return false;\n\n const eventKey = `${channel}::${type}`;\n\n // Helper: check if any rule's keys array matches this event.\n const matches = (keys: Array<[string, string]>): boolean =>\n keys.some(([c, t]) => (c === \"*\" || c === channel) && (t === \"*\" || t === type));\n\n // 1. Ignore β€” never forward.\n if (sampling.ignore?.some((r) => matches(r.keys))) return true;\n\n // 2. Throttle β€” forward only if intervalMs has elapsed since the last send.\n for (const rule of sampling.throttle ?? []) {\n if (!matches(rule.keys)) continue;\n const last = throttleLast.get(eventKey) ?? 0;\n const nowMs = Date.now();\n if (nowMs - last < rule.intervalMs) return true; // within throttle window\n throttleLast.set(eventKey, nowMs);\n return false; // passed throttle; no further rules apply for this event\n }\n\n // 3. Skip β€” forward every Nth event (counter resets to 0 after firing).\n for (const rule of sampling.skip ?? []) {\n if (!matches(rule.keys)) continue;\n const count = (skipCounters.get(eventKey) ?? 0) + 1;\n if (count < rule.every) {\n skipCounters.set(eventKey, count);\n return true; // suppress until we reach the Nth event\n }\n skipCounters.set(eventKey, 0); // fire β€” reset counter\n return false;\n }\n\n return false;\n };\n\n // Build capabilities from config\n const capabilities: StoreCapabilities = {\n replay: config.allowReplay ?? false,\n stateSnapshot: true,\n subscriptionMeta: true,\n pipelineMeta: true,\n emit: config.allowEmit ?? false,\n };\n if (config.sampling) {\n capabilities.sampling = config.sampling;\n }\n\n // Create browser WS client\n const wsClient = new DevtoolsWsClient(\n storeId,\n store.name,\n capabilities,\n {\n autoReconnect: config.autoReconnect ?? true,\n maxReconnectAttempts: config.maxReconnectAttempts ?? Infinity,\n baseDelay: config.baseDelay ?? 1000,\n maxDelay: config.maxDelay ?? 30000,\n },\n config.socketFactory,\n );\n\n // Surface backpressure: warn (throttled) instead of dropping events silently\n // when the hub is unreachable and the send buffer overflows.\n let lastBackpressureWarn = 0;\n wsClient.onBackpressure((dropped) => {\n const nowMs = Date.now();\n if (nowMs - lastBackpressureWarn > 5_000) {\n lastBackpressureWarn = nowMs;\n console.warn(\n `[Yoltra DevTools] Backpressure: dropped ${dropped} event(s) while the hub was unreachable.`,\n );\n }\n });\n\n const baseMsg = (): Pick<BaseMessage, \"timestamp\" | \"sourceId\" | \"sourceRole\"> => ({\n timestamp: new Date().toISOString(),\n sourceId: storeId,\n sourceRole: DevtoolsRole.STORE,\n });\n\n // Handle incoming messages from hub\n wsClient.onMessage(async (data: string) => {\n let msg: any;\n try {\n msg = JSON.parse(data);\n } catch {\n return;\n }\n\n // Ingress validation (DEV-3): require a well-formed message with a string\n // `type` discriminant before acting on it (EMIT_TO_STORE forwards straight\n // into store.emit, so a malformed payload must not reach it).\n if (msg === null || typeof msg !== \"object\" || typeof msg.type !== \"string\") return;\n\n switch (msg.type) {\n case \"REQUEST_STATE\": {\n const state = store.getState();\n const response: StateSnapshot = {\n type: \"STATE_SNAPSHOT\",\n ...baseMsg(),\n storeId,\n state: JSON.parse(JSON.stringify(state)),\n version: snapshotVersion,\n reducerNames: Object.keys(state as object),\n };\n wsClient.send(JSON.stringify(response));\n break;\n }\n\n case \"REQUEST_METRICS\": {\n const introspection = store.__devtoolsIntrospect();\n // Events committed in the last 1 second (sliding window).\n const cutoffMs = Date.now() - 1_000;\n const eventsPerSecond = eventTimestamps.filter((t) => t >= cutoffMs).length;\n // Middleware rejections = attempted βˆ’ committed (floor at 0).\n const middlewareRejections = Math.max(0, totalAttemptedCount - committedEventCount);\n // Real reduce-phase timing, averaged over committed events.\n const avgProcessingTimeMs =\n committedEventCount > 0 ? reduceTimeSumMs / committedEventCount : 0;\n const response: StoreMetrics = {\n type: \"STORE_METRICS\",\n ...baseMsg(),\n storeId,\n metrics: {\n eventCount: committedEventCount,\n eventsPerSecond,\n avgProcessingTimeMs,\n dedupHits: introspection.dedupHits,\n queueDepth: introspection.queueDepth,\n reducerCount: introspection.reducers.length,\n effectCount: introspection.effects.length,\n middlewareCount: introspection.middleware.length,\n subscriberCount: introspection.event.length + introspection.coarse,\n connectorCount: introspection.atomic.length,\n middlewareRejections,\n },\n };\n wsClient.send(JSON.stringify(response));\n break;\n }\n\n case \"TIME_TRAVEL\": {\n // Time-travel replaces the entire state tree β€” gate on the store's\n // replay capability (default off), same as EVENT_REPLAY. The core seam\n // enforces this too (defense in depth).\n if (!capabilities.replay) break;\n\n // Guard against malformed messages β€” a null state would corrupt the\n // store and cause \"Cannot read property of undefined\" in reducers.\n if (msg.state == null) break;\n\n store.__applyExternalState(msg.state);\n snapshotVersion = msg.snapshotVersion ?? snapshotVersion;\n\n // Notify the extension of the new state so all UI panels re-render with\n // the time-traveled state.\n const traveledState = store.getState();\n const travelSnapshot: StateSnapshot = {\n type: \"STATE_SNAPSHOT\",\n ...baseMsg(),\n storeId,\n state: JSON.parse(JSON.stringify(traveledState)),\n version: snapshotVersion,\n reducerNames: Object.keys(traveledState as object),\n };\n wsClient.send(JSON.stringify(travelSnapshot));\n break;\n }\n\n case \"EVENT_REPLAY\": {\n if (capabilities.replay) {\n store.__replayEvents(msg.snapshot, msg.events);\n }\n break;\n }\n\n case \"EMIT_TO_STORE\": {\n if (capabilities.emit && msg.event) {\n await store.emit(msg.event.channel, msg.event.type, msg.event.payload);\n }\n break;\n }\n\n case \"REQUEST_SUBSCRIPTIONS\": {\n const introspection = store.__devtoolsIntrospect();\n const response = {\n type: \"STORE_SUBSCRIPTIONS\",\n ...baseMsg(),\n storeId,\n atomic: introspection.atomic,\n event: introspection.event,\n coarse: introspection.coarse,\n effects: introspection.effects,\n middleware: introspection.middleware,\n reducers: introspection.reducers,\n };\n wsClient.send(JSON.stringify(response));\n break;\n }\n\n default: {\n // Exhaustiveness fallback (DEV-3): an unhandled command type is protocol\n // drift (a bug), not routine traffic β€” surface it instead of dropping it.\n console.warn(`[Yoltra DevTools] Ignoring unknown message type: ${String(msg.type)}`);\n break;\n }\n }\n });\n\n // If this store was already wrapped (HMR / remount / a double call), tear down\n // the previous devtools attachment first so we don't leak the instrument\n // observer + reconnecting socket or double-send every event (DEV-2).\n const existingDispose = (store as unknown as { __yoltraDevtoolsDispose?: () => void })\n .__yoltraDevtoolsDispose;\n if (existingDispose) existingDispose();\n\n // Observe every event through the typed instrumentation seam. This single\n // observer replaces the old interceptor effect + metrics middleware + manual\n // diff + full-state clone: the core hands us the exact changed leaf paths and\n // their old/new values, so we build precise patches with no re-diff.\n const instrumentUnsub = store.instrument((info: InstrumentedEvent<EM>) => {\n totalAttemptedCount++;\n if (info.committed) {\n committedEventCount++;\n reduceTimeSumMs += info.reduceTimeMs;\n const nowMs = Date.now();\n eventTimestamps.push(nowMs);\n // Prune timestamps older than 60 s to bound memory usage.\n while (eventTimestamps.length > 0 && nowMs - eventTimestamps[0]! > 60_000) {\n eventTimestamps.shift();\n }\n }\n\n if (isSampledOut(info.event.channel, info.event.type)) return;\n\n // Only a committed event advances the state version β€” it carries patches. A\n // vetoed event is logged with committed:false and no bump, so time-travel\n // reconstruction stays correlated (DEV-7). Wire ordering is preserved by the\n // event log's array insertion order, not by this version.\n if (info.committed) snapshotVersion++;\n const storeEvent: StoreEvent = {\n type: \"STORE_EVENT\",\n ...baseMsg(),\n storeId,\n event: {\n id: info.event.id,\n channel: info.event.channel,\n type: info.event.type,\n payload: info.event.payload,\n },\n patches: info.committed\n ? patchesFromChange(info.changedPaths, info.prevValues, info.nextValues)\n : [],\n snapshotVersion,\n committed: info.committed,\n };\n wsClient.send(JSON.stringify(storeEvent));\n });\n\n // Connect to hub\n wsClient.connect(host, config.port);\n\n // Devtools teardown (DEV-2): detach the instrument observer and disconnect the\n // socket. Stored on the store so a later re-wrap tears down this attachment,\n // and folded into store.dispose() so disposing the store also detaches devtools.\n const disposeDevtools = () => {\n instrumentUnsub();\n wsClient.disconnect();\n (store as unknown as { __yoltraDevtoolsDispose?: () => void }).__yoltraDevtoolsDispose =\n undefined;\n };\n (store as unknown as { __yoltraDevtoolsDispose?: () => void }).__yoltraDevtoolsDispose =\n disposeDevtools;\n\n const prevDispose = store.dispose.bind(store);\n store.dispose = () => {\n disposeDevtools();\n prevDispose();\n };\n\n return store;\n}\n"],"names":["browserSocketFactory","url","callbacks","ws","ev","data","code","reason","DevtoolsWsClient","ReconnectingWsClient","storeId","storeName","capabilities","config","socketFactory","withDevtools","store","host","snapshotVersion","committedEventCount","totalAttemptedCount","eventTimestamps","reduceTimeSumMs","skipCounters","throttleLast","isSampledOut","channel","type","sampling","eventKey","matches","keys","c","t","r","rule","last","nowMs","count","wsClient","lastBackpressureWarn","dropped","baseMsg","DevtoolsRole","msg","state","response","introspection","cutoffMs","eventsPerSecond","middlewareRejections","avgProcessingTimeMs","traveledState","travelSnapshot","existingDispose","instrumentUnsub","info","storeEvent","patchesFromChange","disposeDevtools","prevDispose"],"mappings":"6HAsBMA,EAA8C,CAACC,EAAKC,IAAc,CACtE,MAAMC,EAAK,IAAI,UAAUF,CAAG,EAC5B,OAAAE,EAAG,OAAS,IAAMD,EAAU,OAAA,EAC5BC,EAAG,UAAaC,GACdF,EAAU,UAAU,OAAOE,EAAG,MAAS,SAAWA,EAAG,KAAO,OAAOA,EAAG,IAAI,CAAC,EAC7ED,EAAG,QAAU,IAAMD,EAAU,QAAA,EAC7BC,EAAG,QAAU,IAAMD,EAAU,QAAA,EACtB,CACL,IAAI,YAAa,CACf,OAAOC,EAAG,UACZ,EACA,KAAOE,GAASF,EAAG,KAAKE,CAAI,EAC5B,MAAO,CAACC,EAAMC,IAAWJ,EAAG,MAAMG,EAAMC,CAAM,EAC9C,QAAS,IAAM,CACbJ,EAAG,OAAS,KACZA,EAAG,UAAY,KACfA,EAAG,QAAU,KACbA,EAAG,QAAU,IACf,CAAA,CAEJ,EAOO,MAAMK,UAAyBC,EAAAA,oBAAqB,CACzD,YACEC,EACAC,EACAC,EACAC,EACAC,EAAuCd,EACvC,CACA,MAAMU,EAASC,EAAWC,EAAcC,EAAQC,CAAa,CAC/D,CACF,CCRO,SAASC,EAIdC,EAAgCH,EAAwD,CACxF,MAAMH,EAAUG,EAAO,SAAWG,EAAM,KAClCC,EAAOJ,EAAO,MAAQ,YAC5B,IAAIK,EAAkB,EAIlBC,EAAsB,EAEtBC,EAAsB,EAE1B,MAAMC,EAA4B,CAAA,EAElC,IAAIC,EAAkB,EAQtB,MAAMC,MAAmB,IACnBC,MAAmB,IAMnBC,EAAe,CAACC,EAAiBC,IAA0B,CAC/D,MAAMC,EAAWhB,EAAa,SAC9B,GAAI,CAACgB,EAAU,MAAO,GAEtB,MAAMC,EAAW,GAAGH,CAAO,KAAKC,CAAI,GAG9BG,EAAWC,GACfA,EAAK,KAAK,CAAC,CAACC,EAAGC,CAAC,KAAOD,IAAM,KAAOA,IAAMN,KAAaO,IAAM,KAAOA,IAAMN,EAAK,EAGjF,GAAIC,EAAS,QAAQ,KAAMM,GAAMJ,EAAQI,EAAE,IAAI,CAAC,EAAG,MAAO,GAG1D,UAAWC,KAAQP,EAAS,UAAY,CAAA,EAAI,CAC1C,GAAI,CAACE,EAAQK,EAAK,IAAI,EAAG,SACzB,MAAMC,EAAOZ,EAAa,IAAIK,CAAQ,GAAK,EACrCQ,EAAQ,KAAK,IAAA,EACnB,OAAIA,EAAQD,EAAOD,EAAK,WAAmB,IAC3CX,EAAa,IAAIK,EAAUQ,CAAK,EACzB,GACT,CAGA,UAAWF,KAAQP,EAAS,MAAQ,CAAA,EAAI,CACtC,GAAI,CAACE,EAAQK,EAAK,IAAI,EAAG,SACzB,MAAMG,GAASf,EAAa,IAAIM,CAAQ,GAAK,GAAK,EAClD,OAAIS,EAAQH,EAAK,OACfZ,EAAa,IAAIM,EAAUS,CAAK,EACzB,KAETf,EAAa,IAAIM,EAAU,CAAC,EACrB,GACT,CAEA,MAAO,EACT,EAGMjB,EAAkC,CACtC,OAAQC,EAAO,aAAe,GAC9B,cAAe,GACf,iBAAkB,GAClB,aAAc,GACd,KAAMA,EAAO,WAAa,EAAA,EAExBA,EAAO,WACTD,EAAa,SAAWC,EAAO,UAIjC,MAAM0B,EAAW,IAAI/B,EACnBE,EACAM,EAAM,KACNJ,EACA,CACE,cAAeC,EAAO,eAAiB,GACvC,qBAAsBA,EAAO,sBAAwB,IACrD,UAAWA,EAAO,WAAa,IAC/B,SAAUA,EAAO,UAAY,GAAA,EAE/BA,EAAO,aAAA,EAKT,IAAI2B,EAAuB,EAC3BD,EAAS,eAAgBE,GAAY,CACnC,MAAMJ,EAAQ,KAAK,IAAA,EACfA,EAAQG,EAAuB,MACjCA,EAAuBH,EACvB,QAAQ,KACN,2CAA2CI,CAAO,0CAAA,EAGxD,CAAC,EAED,MAAMC,EAAU,KAAmE,CACjF,UAAW,IAAI,KAAA,EAAO,YAAA,EACtB,SAAUhC,EACV,WAAYiC,EAAAA,aAAa,KAAA,GAI3BJ,EAAS,UAAU,MAAOlC,GAAiB,CACzC,IAAIuC,EACJ,GAAI,CACFA,EAAM,KAAK,MAAMvC,CAAI,CACvB,MAAQ,CACN,MACF,CAKA,GAAI,EAAAuC,IAAQ,MAAQ,OAAOA,GAAQ,UAAY,OAAOA,EAAI,MAAS,UAEnE,OAAQA,EAAI,KAAA,CACV,IAAK,gBAAiB,CACpB,MAAMC,EAAQ7B,EAAM,SAAA,EACd8B,EAA0B,CAC9B,KAAM,iBACN,GAAGJ,EAAA,EACH,QAAAhC,EACA,MAAO,KAAK,MAAM,KAAK,UAAUmC,CAAK,CAAC,EACvC,QAAS3B,EACT,aAAc,OAAO,KAAK2B,CAAe,CAAA,EAE3CN,EAAS,KAAK,KAAK,UAAUO,CAAQ,CAAC,EACtC,KACF,CAEA,IAAK,kBAAmB,CACtB,MAAMC,EAAgB/B,EAAM,qBAAA,EAEtBgC,EAAW,KAAK,IAAA,EAAQ,IACxBC,EAAkB5B,EAAgB,OAAQY,GAAMA,GAAKe,CAAQ,EAAE,OAE/DE,EAAuB,KAAK,IAAI,EAAG9B,EAAsBD,CAAmB,EAE5EgC,EACJhC,EAAsB,EAAIG,EAAkBH,EAAsB,EAC9D2B,EAAyB,CAC7B,KAAM,gBACN,GAAGJ,EAAA,EACH,QAAAhC,EACA,QAAS,CACP,WAAYS,EACZ,gBAAA8B,EACA,oBAAAE,EACA,UAAWJ,EAAc,UACzB,WAAYA,EAAc,WAC1B,aAAcA,EAAc,SAAS,OACrC,YAAaA,EAAc,QAAQ,OACnC,gBAAiBA,EAAc,WAAW,OAC1C,gBAAiBA,EAAc,MAAM,OAASA,EAAc,OAC5D,eAAgBA,EAAc,OAAO,OACrC,qBAAAG,CAAA,CACF,EAEFX,EAAS,KAAK,KAAK,UAAUO,CAAQ,CAAC,EACtC,KACF,CAEA,IAAK,cAAe,CAQlB,GAJI,CAAClC,EAAa,QAIdgC,EAAI,OAAS,KAAM,MAEvB5B,EAAM,qBAAqB4B,EAAI,KAAK,EACpC1B,EAAkB0B,EAAI,iBAAmB1B,EAIzC,MAAMkC,EAAgBpC,EAAM,SAAA,EACtBqC,EAAgC,CACpC,KAAM,iBACN,GAAGX,EAAA,EACH,QAAAhC,EACA,MAAO,KAAK,MAAM,KAAK,UAAU0C,CAAa,CAAC,EAC/C,QAASlC,EACT,aAAc,OAAO,KAAKkC,CAAuB,CAAA,EAEnDb,EAAS,KAAK,KAAK,UAAUc,CAAc,CAAC,EAC5C,KACF,CAEA,IAAK,eAAgB,CACfzC,EAAa,QACfI,EAAM,eAAe4B,EAAI,SAAUA,EAAI,MAAM,EAE/C,KACF,CAEA,IAAK,gBAAiB,CAChBhC,EAAa,MAAQgC,EAAI,OAC3B,MAAM5B,EAAM,KAAK4B,EAAI,MAAM,QAASA,EAAI,MAAM,KAAMA,EAAI,MAAM,OAAO,EAEvE,KACF,CAEA,IAAK,wBAAyB,CAC5B,MAAMG,EAAgB/B,EAAM,qBAAA,EACtB8B,EAAW,CACf,KAAM,sBACN,GAAGJ,EAAA,EACH,QAAAhC,EACA,OAAQqC,EAAc,OACtB,MAAOA,EAAc,MACrB,OAAQA,EAAc,OACtB,QAASA,EAAc,QACvB,WAAYA,EAAc,WAC1B,SAAUA,EAAc,QAAA,EAE1BR,EAAS,KAAK,KAAK,UAAUO,CAAQ,CAAC,EACtC,KACF,CAEA,QAAS,CAGP,QAAQ,KAAK,oDAAoD,OAAOF,EAAI,IAAI,CAAC,EAAE,EACnF,KACF,CAAA,CAEJ,CAAC,EAKD,MAAMU,EAAmBtC,EACtB,wBACCsC,GAAiBA,EAAA,EAMrB,MAAMC,EAAkBvC,EAAM,WAAYwC,GAAgC,CAExE,GADApC,IACIoC,EAAK,UAAW,CAClBrC,IACAG,GAAmBkC,EAAK,aACxB,MAAMnB,EAAQ,KAAK,IAAA,EAGnB,IAFAhB,EAAgB,KAAKgB,CAAK,EAEnBhB,EAAgB,OAAS,GAAKgB,EAAQhB,EAAgB,CAAC,EAAK,KACjEA,EAAgB,MAAA,CAEpB,CAEA,GAAII,EAAa+B,EAAK,MAAM,QAASA,EAAK,MAAM,IAAI,EAAG,OAMnDA,EAAK,WAAWtC,IACpB,MAAMuC,EAAyB,CAC7B,KAAM,cACN,GAAGf,EAAA,EACH,QAAAhC,EACA,MAAO,CACL,GAAI8C,EAAK,MAAM,GACf,QAASA,EAAK,MAAM,QACpB,KAAMA,EAAK,MAAM,KACjB,QAASA,EAAK,MAAM,OAAA,EAEtB,QAASA,EAAK,UACVE,EAAAA,kBAAkBF,EAAK,aAAcA,EAAK,WAAYA,EAAK,UAAU,EACrE,CAAA,EACJ,gBAAAtC,EACA,UAAWsC,EAAK,SAAA,EAElBjB,EAAS,KAAK,KAAK,UAAUkB,CAAU,CAAC,CAC1C,CAAC,EAGDlB,EAAS,QAAQtB,EAAMJ,EAAO,IAAI,EAKlC,MAAM8C,EAAkB,IAAM,CAC5BJ,EAAA,EACAhB,EAAS,WAAA,EACRvB,EAA8D,wBAC7D,MACJ,EACCA,EAA8D,wBAC7D2C,EAEF,MAAMC,EAAc5C,EAAM,QAAQ,KAAKA,CAAK,EAC5C,OAAAA,EAAM,QAAU,IAAM,CACpB2C,EAAA,EACAC,EAAA,CACF,EAEO5C,CACT"}
@@ -0,0 +1,199 @@
1
+ import { ReconnectingWsClient as M, patchesFromChange as C, DevtoolsRole as N } from "@yoltra/devtools-protocol";
2
+ const I = (o, a) => {
3
+ const s = new WebSocket(o);
4
+ return s.onopen = () => a.onOpen(), s.onmessage = (c) => a.onMessage(typeof c.data == "string" ? c.data : String(c.data)), s.onclose = () => a.onClose(), s.onerror = () => a.onError(), {
5
+ get readyState() {
6
+ return s.readyState;
7
+ },
8
+ send: (c) => s.send(c),
9
+ close: (c, i) => s.close(c, i),
10
+ dispose: () => {
11
+ s.onopen = null, s.onmessage = null, s.onclose = null, s.onerror = null;
12
+ }
13
+ };
14
+ };
15
+ class x extends M {
16
+ constructor(a, s, c, i, d = I) {
17
+ super(a, s, c, i, d);
18
+ }
19
+ }
20
+ function J(o, a) {
21
+ const s = a.storeId ?? o.name, c = a.host ?? "localhost";
22
+ let i = 0, d = 0, T = 0;
23
+ const S = [];
24
+ let w = 0;
25
+ const g = /* @__PURE__ */ new Map(), E = /* @__PURE__ */ new Map(), O = (n, t) => {
26
+ const e = y.sampling;
27
+ if (!e) return !1;
28
+ const r = `${n}::${t}`, h = (l) => l.some(([u, m]) => (u === "*" || u === n) && (m === "*" || m === t));
29
+ if (e.ignore?.some((l) => h(l.keys))) return !0;
30
+ for (const l of e.throttle ?? []) {
31
+ if (!h(l.keys)) continue;
32
+ const u = E.get(r) ?? 0, m = Date.now();
33
+ return m - u < l.intervalMs ? !0 : (E.set(r, m), !1);
34
+ }
35
+ for (const l of e.skip ?? []) {
36
+ if (!h(l.keys)) continue;
37
+ const u = (g.get(r) ?? 0) + 1;
38
+ return u < l.every ? (g.set(r, u), !0) : (g.set(r, 0), !1);
39
+ }
40
+ return !1;
41
+ }, y = {
42
+ replay: a.allowReplay ?? !1,
43
+ stateSnapshot: !0,
44
+ subscriptionMeta: !0,
45
+ pipelineMeta: !0,
46
+ emit: a.allowEmit ?? !1
47
+ };
48
+ a.sampling && (y.sampling = a.sampling);
49
+ const p = new x(
50
+ s,
51
+ o.name,
52
+ y,
53
+ {
54
+ autoReconnect: a.autoReconnect ?? !0,
55
+ maxReconnectAttempts: a.maxReconnectAttempts ?? 1 / 0,
56
+ baseDelay: a.baseDelay ?? 1e3,
57
+ maxDelay: a.maxDelay ?? 3e4
58
+ },
59
+ a.socketFactory
60
+ );
61
+ let f = 0;
62
+ p.onBackpressure((n) => {
63
+ const t = Date.now();
64
+ t - f > 5e3 && (f = t, console.warn(
65
+ `[Yoltra DevTools] Backpressure: dropped ${n} event(s) while the hub was unreachable.`
66
+ ));
67
+ });
68
+ const v = () => ({
69
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
70
+ sourceId: s,
71
+ sourceRole: N.STORE
72
+ });
73
+ p.onMessage(async (n) => {
74
+ let t;
75
+ try {
76
+ t = JSON.parse(n);
77
+ } catch {
78
+ return;
79
+ }
80
+ if (!(t === null || typeof t != "object" || typeof t.type != "string"))
81
+ switch (t.type) {
82
+ case "REQUEST_STATE": {
83
+ const e = o.getState(), r = {
84
+ type: "STATE_SNAPSHOT",
85
+ ...v(),
86
+ storeId: s,
87
+ state: JSON.parse(JSON.stringify(e)),
88
+ version: i,
89
+ reducerNames: Object.keys(e)
90
+ };
91
+ p.send(JSON.stringify(r));
92
+ break;
93
+ }
94
+ case "REQUEST_METRICS": {
95
+ const e = o.__devtoolsIntrospect(), r = Date.now() - 1e3, h = S.filter((k) => k >= r).length, l = Math.max(0, T - d), u = d > 0 ? w / d : 0, m = {
96
+ type: "STORE_METRICS",
97
+ ...v(),
98
+ storeId: s,
99
+ metrics: {
100
+ eventCount: d,
101
+ eventsPerSecond: h,
102
+ avgProcessingTimeMs: u,
103
+ dedupHits: e.dedupHits,
104
+ queueDepth: e.queueDepth,
105
+ reducerCount: e.reducers.length,
106
+ effectCount: e.effects.length,
107
+ middlewareCount: e.middleware.length,
108
+ subscriberCount: e.event.length + e.coarse,
109
+ connectorCount: e.atomic.length,
110
+ middlewareRejections: l
111
+ }
112
+ };
113
+ p.send(JSON.stringify(m));
114
+ break;
115
+ }
116
+ case "TIME_TRAVEL": {
117
+ if (!y.replay || t.state == null) break;
118
+ o.__applyExternalState(t.state), i = t.snapshotVersion ?? i;
119
+ const e = o.getState(), r = {
120
+ type: "STATE_SNAPSHOT",
121
+ ...v(),
122
+ storeId: s,
123
+ state: JSON.parse(JSON.stringify(e)),
124
+ version: i,
125
+ reducerNames: Object.keys(e)
126
+ };
127
+ p.send(JSON.stringify(r));
128
+ break;
129
+ }
130
+ case "EVENT_REPLAY": {
131
+ y.replay && o.__replayEvents(t.snapshot, t.events);
132
+ break;
133
+ }
134
+ case "EMIT_TO_STORE": {
135
+ y.emit && t.event && await o.emit(t.event.channel, t.event.type, t.event.payload);
136
+ break;
137
+ }
138
+ case "REQUEST_SUBSCRIPTIONS": {
139
+ const e = o.__devtoolsIntrospect(), r = {
140
+ type: "STORE_SUBSCRIPTIONS",
141
+ ...v(),
142
+ storeId: s,
143
+ atomic: e.atomic,
144
+ event: e.event,
145
+ coarse: e.coarse,
146
+ effects: e.effects,
147
+ middleware: e.middleware,
148
+ reducers: e.reducers
149
+ };
150
+ p.send(JSON.stringify(r));
151
+ break;
152
+ }
153
+ default: {
154
+ console.warn(`[Yoltra DevTools] Ignoring unknown message type: ${String(t.type)}`);
155
+ break;
156
+ }
157
+ }
158
+ });
159
+ const _ = o.__yoltraDevtoolsDispose;
160
+ _ && _();
161
+ const R = o.instrument((n) => {
162
+ if (T++, n.committed) {
163
+ d++, w += n.reduceTimeMs;
164
+ const e = Date.now();
165
+ for (S.push(e); S.length > 0 && e - S[0] > 6e4; )
166
+ S.shift();
167
+ }
168
+ if (O(n.event.channel, n.event.type)) return;
169
+ n.committed && i++;
170
+ const t = {
171
+ type: "STORE_EVENT",
172
+ ...v(),
173
+ storeId: s,
174
+ event: {
175
+ id: n.event.id,
176
+ channel: n.event.channel,
177
+ type: n.event.type,
178
+ payload: n.event.payload
179
+ },
180
+ patches: n.committed ? C(n.changedPaths, n.prevValues, n.nextValues) : [],
181
+ snapshotVersion: i,
182
+ committed: n.committed
183
+ };
184
+ p.send(JSON.stringify(t));
185
+ });
186
+ p.connect(c, a.port);
187
+ const D = () => {
188
+ R(), p.disconnect(), o.__yoltraDevtoolsDispose = void 0;
189
+ };
190
+ o.__yoltraDevtoolsDispose = D;
191
+ const b = o.dispose.bind(o);
192
+ return o.dispose = () => {
193
+ D(), b();
194
+ }, o;
195
+ }
196
+ export {
197
+ J as withDevtools
198
+ };
199
+ //# sourceMappingURL=devtools-browser-agent.esm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devtools-browser-agent.esm.js","sources":["../src/ws-client.ts","../src/withDevtools.ts"],"sourcesContent":["/**\n * Browser WebSocket transport for the DevTools agent.\n *\n * @remarks\n * Injects the native browser `WebSocket` into the shared, transport-agnostic\n * {@link ReconnectingWsClient} from `@yoltra/devtools-protocol`. There is **no**\n * `ws` dependency here β€” that is what keeps this package installable in the\n * browser without pulling in a Node-only transport.\n *\n * @module @yoltra/devtools-browser-agent\n */\n\nimport {\n ReconnectingWsClient,\n type DevtoolsSocketFactory,\n type ReconnectingWsConfig,\n type StoreCapabilities,\n} from \"@yoltra/devtools-protocol\";\n\nexport type { ConnectionState } from \"@yoltra/devtools-protocol\";\n\n/** Opens a native browser WebSocket and adapts it to the shared transport. */\nconst browserSocketFactory: DevtoolsSocketFactory = (url, callbacks) => {\n const ws = new WebSocket(url);\n ws.onopen = () => callbacks.onOpen();\n ws.onmessage = (ev: MessageEvent) =>\n callbacks.onMessage(typeof ev.data === \"string\" ? ev.data : String(ev.data));\n ws.onclose = () => callbacks.onClose();\n ws.onerror = () => callbacks.onError();\n return {\n get readyState() {\n return ws.readyState;\n },\n send: (data) => ws.send(data),\n close: (code, reason) => ws.close(code, reason),\n dispose: () => {\n ws.onopen = null;\n ws.onmessage = null;\n ws.onclose = null;\n ws.onerror = null;\n },\n };\n};\n\n/**\n * Browser DevTools WebSocket client (native `WebSocket` transport).\n *\n * @internal\n */\nexport class DevtoolsWsClient extends ReconnectingWsClient {\n constructor(\n storeId: string,\n storeName: string,\n capabilities: StoreCapabilities,\n config: ReconnectingWsConfig,\n socketFactory: DevtoolsSocketFactory = browserSocketFactory,\n ) {\n super(storeId, storeName, capabilities, config, socketFactory);\n }\n}\n","/**\n * Browser DevTools agent entry point -- the {@link withDevtools} wrapper function.\n *\n * @module @yoltra/devtools-browser-agent\n */\n\nimport type { EventMapBase, InstrumentedEvent, StoreInstance } from \"@yoltra/core\";\nimport {\n DevtoolsRole,\n patchesFromChange,\n type BaseMessage,\n type StateSnapshot,\n type StoreCapabilities,\n type StoreEvent,\n type StoreMetrics,\n} from \"@yoltra/devtools-protocol\";\nimport type { DevtoolsWrapperConfig } from \"./types\";\nimport { DevtoolsWsClient } from \"./ws-client\";\n\n/**\n * Wraps a Yoltra store with DevTools instrumentation for browser environments.\n *\n * @remarks\n * - Connects to the DevTools hub via native `WebSocket`.\n * - Observes every event via the typed `store.instrument()` seam β€” no\n * `as any` bridging, no re-diffing, no full-state clone per event.\n * - Builds precise RFC 6902 patches from the exact changed leaf paths the core\n * reports, and sends a `STORE_EVENT` per event (committed or vetoed).\n * - Handles incoming commands: REQUEST_STATE, REQUEST_METRICS,\n * REQUEST_SUBSCRIPTIONS, TIME_TRAVEL, EVENT_REPLAY, EMIT_TO_STORE.\n * - Returns the **same** store instance (transparent instrumentation).\n * - Auto-reconnects to the hub on disconnect.\n *\n * @typeParam R - Reducer name union.\n * @typeParam S - State record.\n * @typeParam EM - Event map.\n * @param store - The store to instrument.\n * @param config - DevTools wrapper configuration.\n * @returns The same store instance, now instrumented.\n *\n * @example\n * ```ts\n * import { createStore } from '@yoltra/core';\n * import { withDevtools } from '@yoltra/devtools-browser-agent';\n *\n * const store = createStore({ name: 'App', reducer: { ... } });\n * withDevtools(store, { port: 9800 });\n * ```\n *\n * @public\n */\nexport function withDevtools<\n R extends string,\n S extends Record<R, any>,\n EM extends EventMapBase,\n>(store: StoreInstance<R, S, EM>, config: DevtoolsWrapperConfig): StoreInstance<R, S, EM> {\n const storeId = config.storeId ?? store.name;\n const host = config.host ?? \"localhost\";\n let snapshotVersion = 0;\n\n // ── Metrics tracking ──────────────────────────────────────────────────────\n // Counts committed events (events that pass middleware and run reducers).\n let committedEventCount = 0;\n // Counts ALL observed events including those vetoed by middleware.\n let totalAttemptedCount = 0;\n // Timestamps (epoch ms) of recent committed events; pruned to last 60 s.\n const eventTimestamps: number[] = [];\n // Rolling sum of the core-reported reduce time (ms) over committed events.\n let reduceTimeSumMs = 0;\n\n // ── Sampling state ─────────────────────────────────────────────────────────\n // Tracks per-event-key counters and timestamps to enforce the three sampling\n // rule types declared in `capabilities.sampling`:\n // ignore β€” never forward matching events.\n // skip β€” forward every Nth matching event (0-based counter).\n // throttle β€” forward at most once per intervalMs per key.\n const skipCounters = new Map<string, number>(); // current invocation count per key\n const throttleLast = new Map<string, number>(); // last-forwarded epoch ms per key\n\n /**\n * Returns `true` when the event should be suppressed by the sampling config.\n * All three rule types are checked in priority order: ignore β†’ throttle β†’ skip.\n */\n const isSampledOut = (channel: string, type: string): boolean => {\n const sampling = capabilities.sampling;\n if (!sampling) return false;\n\n const eventKey = `${channel}::${type}`;\n\n // Helper: check if any rule's keys array matches this event.\n const matches = (keys: Array<[string, string]>): boolean =>\n keys.some(([c, t]) => (c === \"*\" || c === channel) && (t === \"*\" || t === type));\n\n // 1. Ignore β€” never forward.\n if (sampling.ignore?.some((r) => matches(r.keys))) return true;\n\n // 2. Throttle β€” forward only if intervalMs has elapsed since the last send.\n for (const rule of sampling.throttle ?? []) {\n if (!matches(rule.keys)) continue;\n const last = throttleLast.get(eventKey) ?? 0;\n const nowMs = Date.now();\n if (nowMs - last < rule.intervalMs) return true; // within throttle window\n throttleLast.set(eventKey, nowMs);\n return false; // passed throttle; no further rules apply for this event\n }\n\n // 3. Skip β€” forward every Nth event (counter resets to 0 after firing).\n for (const rule of sampling.skip ?? []) {\n if (!matches(rule.keys)) continue;\n const count = (skipCounters.get(eventKey) ?? 0) + 1;\n if (count < rule.every) {\n skipCounters.set(eventKey, count);\n return true; // suppress until we reach the Nth event\n }\n skipCounters.set(eventKey, 0); // fire β€” reset counter\n return false;\n }\n\n return false;\n };\n\n // Build capabilities from config\n const capabilities: StoreCapabilities = {\n replay: config.allowReplay ?? false,\n stateSnapshot: true,\n subscriptionMeta: true,\n pipelineMeta: true,\n emit: config.allowEmit ?? false,\n };\n if (config.sampling) {\n capabilities.sampling = config.sampling;\n }\n\n // Create browser WS client\n const wsClient = new DevtoolsWsClient(\n storeId,\n store.name,\n capabilities,\n {\n autoReconnect: config.autoReconnect ?? true,\n maxReconnectAttempts: config.maxReconnectAttempts ?? Infinity,\n baseDelay: config.baseDelay ?? 1000,\n maxDelay: config.maxDelay ?? 30000,\n },\n config.socketFactory,\n );\n\n // Surface backpressure: warn (throttled) instead of dropping events silently\n // when the hub is unreachable and the send buffer overflows.\n let lastBackpressureWarn = 0;\n wsClient.onBackpressure((dropped) => {\n const nowMs = Date.now();\n if (nowMs - lastBackpressureWarn > 5_000) {\n lastBackpressureWarn = nowMs;\n console.warn(\n `[Yoltra DevTools] Backpressure: dropped ${dropped} event(s) while the hub was unreachable.`,\n );\n }\n });\n\n const baseMsg = (): Pick<BaseMessage, \"timestamp\" | \"sourceId\" | \"sourceRole\"> => ({\n timestamp: new Date().toISOString(),\n sourceId: storeId,\n sourceRole: DevtoolsRole.STORE,\n });\n\n // Handle incoming messages from hub\n wsClient.onMessage(async (data: string) => {\n let msg: any;\n try {\n msg = JSON.parse(data);\n } catch {\n return;\n }\n\n // Ingress validation (DEV-3): require a well-formed message with a string\n // `type` discriminant before acting on it (EMIT_TO_STORE forwards straight\n // into store.emit, so a malformed payload must not reach it).\n if (msg === null || typeof msg !== \"object\" || typeof msg.type !== \"string\") return;\n\n switch (msg.type) {\n case \"REQUEST_STATE\": {\n const state = store.getState();\n const response: StateSnapshot = {\n type: \"STATE_SNAPSHOT\",\n ...baseMsg(),\n storeId,\n state: JSON.parse(JSON.stringify(state)),\n version: snapshotVersion,\n reducerNames: Object.keys(state as object),\n };\n wsClient.send(JSON.stringify(response));\n break;\n }\n\n case \"REQUEST_METRICS\": {\n const introspection = store.__devtoolsIntrospect();\n // Events committed in the last 1 second (sliding window).\n const cutoffMs = Date.now() - 1_000;\n const eventsPerSecond = eventTimestamps.filter((t) => t >= cutoffMs).length;\n // Middleware rejections = attempted βˆ’ committed (floor at 0).\n const middlewareRejections = Math.max(0, totalAttemptedCount - committedEventCount);\n // Real reduce-phase timing, averaged over committed events.\n const avgProcessingTimeMs =\n committedEventCount > 0 ? reduceTimeSumMs / committedEventCount : 0;\n const response: StoreMetrics = {\n type: \"STORE_METRICS\",\n ...baseMsg(),\n storeId,\n metrics: {\n eventCount: committedEventCount,\n eventsPerSecond,\n avgProcessingTimeMs,\n dedupHits: introspection.dedupHits,\n queueDepth: introspection.queueDepth,\n reducerCount: introspection.reducers.length,\n effectCount: introspection.effects.length,\n middlewareCount: introspection.middleware.length,\n subscriberCount: introspection.event.length + introspection.coarse,\n connectorCount: introspection.atomic.length,\n middlewareRejections,\n },\n };\n wsClient.send(JSON.stringify(response));\n break;\n }\n\n case \"TIME_TRAVEL\": {\n // Time-travel replaces the entire state tree β€” gate on the store's\n // replay capability (default off), same as EVENT_REPLAY. The core seam\n // enforces this too (defense in depth).\n if (!capabilities.replay) break;\n\n // Guard against malformed messages β€” a null state would corrupt the\n // store and cause \"Cannot read property of undefined\" in reducers.\n if (msg.state == null) break;\n\n store.__applyExternalState(msg.state);\n snapshotVersion = msg.snapshotVersion ?? snapshotVersion;\n\n // Notify the extension of the new state so all UI panels re-render with\n // the time-traveled state.\n const traveledState = store.getState();\n const travelSnapshot: StateSnapshot = {\n type: \"STATE_SNAPSHOT\",\n ...baseMsg(),\n storeId,\n state: JSON.parse(JSON.stringify(traveledState)),\n version: snapshotVersion,\n reducerNames: Object.keys(traveledState as object),\n };\n wsClient.send(JSON.stringify(travelSnapshot));\n break;\n }\n\n case \"EVENT_REPLAY\": {\n if (capabilities.replay) {\n store.__replayEvents(msg.snapshot, msg.events);\n }\n break;\n }\n\n case \"EMIT_TO_STORE\": {\n if (capabilities.emit && msg.event) {\n await store.emit(msg.event.channel, msg.event.type, msg.event.payload);\n }\n break;\n }\n\n case \"REQUEST_SUBSCRIPTIONS\": {\n const introspection = store.__devtoolsIntrospect();\n const response = {\n type: \"STORE_SUBSCRIPTIONS\",\n ...baseMsg(),\n storeId,\n atomic: introspection.atomic,\n event: introspection.event,\n coarse: introspection.coarse,\n effects: introspection.effects,\n middleware: introspection.middleware,\n reducers: introspection.reducers,\n };\n wsClient.send(JSON.stringify(response));\n break;\n }\n\n default: {\n // Exhaustiveness fallback (DEV-3): an unhandled command type is protocol\n // drift (a bug), not routine traffic β€” surface it instead of dropping it.\n console.warn(`[Yoltra DevTools] Ignoring unknown message type: ${String(msg.type)}`);\n break;\n }\n }\n });\n\n // If this store was already wrapped (HMR / remount / a double call), tear down\n // the previous devtools attachment first so we don't leak the instrument\n // observer + reconnecting socket or double-send every event (DEV-2).\n const existingDispose = (store as unknown as { __yoltraDevtoolsDispose?: () => void })\n .__yoltraDevtoolsDispose;\n if (existingDispose) existingDispose();\n\n // Observe every event through the typed instrumentation seam. This single\n // observer replaces the old interceptor effect + metrics middleware + manual\n // diff + full-state clone: the core hands us the exact changed leaf paths and\n // their old/new values, so we build precise patches with no re-diff.\n const instrumentUnsub = store.instrument((info: InstrumentedEvent<EM>) => {\n totalAttemptedCount++;\n if (info.committed) {\n committedEventCount++;\n reduceTimeSumMs += info.reduceTimeMs;\n const nowMs = Date.now();\n eventTimestamps.push(nowMs);\n // Prune timestamps older than 60 s to bound memory usage.\n while (eventTimestamps.length > 0 && nowMs - eventTimestamps[0]! > 60_000) {\n eventTimestamps.shift();\n }\n }\n\n if (isSampledOut(info.event.channel, info.event.type)) return;\n\n // Only a committed event advances the state version β€” it carries patches. A\n // vetoed event is logged with committed:false and no bump, so time-travel\n // reconstruction stays correlated (DEV-7). Wire ordering is preserved by the\n // event log's array insertion order, not by this version.\n if (info.committed) snapshotVersion++;\n const storeEvent: StoreEvent = {\n type: \"STORE_EVENT\",\n ...baseMsg(),\n storeId,\n event: {\n id: info.event.id,\n channel: info.event.channel,\n type: info.event.type,\n payload: info.event.payload,\n },\n patches: info.committed\n ? patchesFromChange(info.changedPaths, info.prevValues, info.nextValues)\n : [],\n snapshotVersion,\n committed: info.committed,\n };\n wsClient.send(JSON.stringify(storeEvent));\n });\n\n // Connect to hub\n wsClient.connect(host, config.port);\n\n // Devtools teardown (DEV-2): detach the instrument observer and disconnect the\n // socket. Stored on the store so a later re-wrap tears down this attachment,\n // and folded into store.dispose() so disposing the store also detaches devtools.\n const disposeDevtools = () => {\n instrumentUnsub();\n wsClient.disconnect();\n (store as unknown as { __yoltraDevtoolsDispose?: () => void }).__yoltraDevtoolsDispose =\n undefined;\n };\n (store as unknown as { __yoltraDevtoolsDispose?: () => void }).__yoltraDevtoolsDispose =\n disposeDevtools;\n\n const prevDispose = store.dispose.bind(store);\n store.dispose = () => {\n disposeDevtools();\n prevDispose();\n };\n\n return store;\n}\n"],"names":["browserSocketFactory","url","callbacks","ws","ev","data","code","reason","DevtoolsWsClient","ReconnectingWsClient","storeId","storeName","capabilities","config","socketFactory","withDevtools","store","host","snapshotVersion","committedEventCount","totalAttemptedCount","eventTimestamps","reduceTimeSumMs","skipCounters","throttleLast","isSampledOut","channel","type","sampling","eventKey","matches","keys","c","t","r","rule","last","nowMs","count","wsClient","lastBackpressureWarn","dropped","baseMsg","DevtoolsRole","msg","state","response","introspection","cutoffMs","eventsPerSecond","middlewareRejections","avgProcessingTimeMs","traveledState","travelSnapshot","existingDispose","instrumentUnsub","info","storeEvent","patchesFromChange","disposeDevtools","prevDispose"],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @module @yoltra/devtools-browser-agent
3
+ *
4
+ * Browser store wrapper for Yoltra DevTools.
5
+ * Connects a Yoltra store to the DevTools hub via native WebSocket.
6
+ */
7
+ export type { DevtoolsWrapperConfig } from './types';
8
+ export { withDevtools } from './withDevtools';
@@ -0,0 +1,100 @@
1
+ import { DevtoolsSocketFactory, SamplingConfig } from '@yoltra/devtools-protocol';
2
+ /**
3
+ * Configuration for the browser DevTools store wrapper ({@link withDevtools}).
4
+ *
5
+ * @remarks
6
+ * Controls how the browser agent connects to the DevTools hub, which
7
+ * capabilities it advertises (replay, emit), and reconnection behaviour.
8
+ * All fields except {@link port} are optional and have sensible defaults.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const config: DevtoolsWrapperConfig = {
13
+ * port: 9800,
14
+ * host: 'localhost',
15
+ * allowReplay: true,
16
+ * autoReconnect: true,
17
+ * };
18
+ * ```
19
+ *
20
+ * @public
21
+ */
22
+ export interface DevtoolsWrapperConfig {
23
+ /**
24
+ * Hub server host.
25
+ * @defaultValue `"localhost"`
26
+ */
27
+ host?: string;
28
+ /**
29
+ * Hub server port. Required.
30
+ */
31
+ port: number;
32
+ /**
33
+ * Persisted store identifier that survives reconnects.
34
+ *
35
+ * @remarks
36
+ * When omitted a random UUID is generated via `crypto.randomUUID()`.
37
+ * Provide an explicit value to correlate store sessions across page reloads.
38
+ */
39
+ storeId?: string;
40
+ /**
41
+ * Enable event replay capability.
42
+ *
43
+ * @remarks
44
+ * When `true`, the hub may send `EVENT_REPLAY` commands to this store.
45
+ * The store must also support replay internally.
46
+ *
47
+ * @defaultValue `false`
48
+ */
49
+ allowReplay?: boolean;
50
+ /**
51
+ * Allow DevTools extensions to emit events to this store.
52
+ *
53
+ * @remarks
54
+ * When `true`, the hub may send `EMIT_TO_STORE` commands containing
55
+ * arbitrary events that will be dispatched via `store.emit()`.
56
+ *
57
+ * @defaultValue `false`
58
+ */
59
+ allowEmit?: boolean;
60
+ /**
61
+ * Throttle interval for DevTools updates (ms). `0` disables throttling.
62
+ * @defaultValue `0`
63
+ */
64
+ throttleMs?: number;
65
+ /**
66
+ * Sampling configuration (protocol v1 design, implementation deferred).
67
+ *
68
+ * @remarks
69
+ * When provided, advertised to the hub as part of the store's capabilities.
70
+ */
71
+ sampling?: SamplingConfig;
72
+ /**
73
+ * Automatically reconnect to the hub on disconnect.
74
+ * @defaultValue `true`
75
+ */
76
+ autoReconnect?: boolean;
77
+ /**
78
+ * Maximum number of reconnection attempts before giving up.
79
+ * @defaultValue `Infinity`
80
+ */
81
+ maxReconnectAttempts?: number;
82
+ /**
83
+ * Base delay for exponential reconnection backoff (ms).
84
+ * @defaultValue `1000`
85
+ */
86
+ baseDelay?: number;
87
+ /**
88
+ * Maximum delay cap for reconnection backoff (ms).
89
+ * @defaultValue `30000`
90
+ */
91
+ maxDelay?: number;
92
+ /**
93
+ * Custom socket factory (advanced). By default the agent opens a native
94
+ * browser `WebSocket`. Inject a different transport β€” e.g. an in-memory
95
+ * loopback for an embedded panel or a test β€” to connect the agent without a
96
+ * real network socket.
97
+ * @defaultValue the native browser WebSocket factory
98
+ */
99
+ socketFactory?: DevtoolsSocketFactory;
100
+ }
@@ -0,0 +1,35 @@
1
+ import { EventMapBase, StoreInstance } from '@yoltra/core';
2
+ import { DevtoolsWrapperConfig } from './types';
3
+ /**
4
+ * Wraps a Yoltra store with DevTools instrumentation for browser environments.
5
+ *
6
+ * @remarks
7
+ * - Connects to the DevTools hub via native `WebSocket`.
8
+ * - Observes every event via the typed `store.instrument()` seam β€” no
9
+ * `as any` bridging, no re-diffing, no full-state clone per event.
10
+ * - Builds precise RFC 6902 patches from the exact changed leaf paths the core
11
+ * reports, and sends a `STORE_EVENT` per event (committed or vetoed).
12
+ * - Handles incoming commands: REQUEST_STATE, REQUEST_METRICS,
13
+ * REQUEST_SUBSCRIPTIONS, TIME_TRAVEL, EVENT_REPLAY, EMIT_TO_STORE.
14
+ * - Returns the **same** store instance (transparent instrumentation).
15
+ * - Auto-reconnects to the hub on disconnect.
16
+ *
17
+ * @typeParam R - Reducer name union.
18
+ * @typeParam S - State record.
19
+ * @typeParam EM - Event map.
20
+ * @param store - The store to instrument.
21
+ * @param config - DevTools wrapper configuration.
22
+ * @returns The same store instance, now instrumented.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { createStore } from '@yoltra/core';
27
+ * import { withDevtools } from '@yoltra/devtools-browser-agent';
28
+ *
29
+ * const store = createStore({ name: 'App', reducer: { ... } });
30
+ * withDevtools(store, { port: 9800 });
31
+ * ```
32
+ *
33
+ * @public
34
+ */
35
+ export declare function withDevtools<R extends string, S extends Record<R, any>, EM extends EventMapBase>(store: StoreInstance<R, S, EM>, config: DevtoolsWrapperConfig): StoreInstance<R, S, EM>;
@@ -0,0 +1,10 @@
1
+ import { ReconnectingWsClient, DevtoolsSocketFactory, ReconnectingWsConfig, StoreCapabilities } from '@yoltra/devtools-protocol';
2
+ export type { ConnectionState } from '@yoltra/devtools-protocol';
3
+ /**
4
+ * Browser DevTools WebSocket client (native `WebSocket` transport).
5
+ *
6
+ * @internal
7
+ */
8
+ export declare class DevtoolsWsClient extends ReconnectingWsClient {
9
+ constructor(storeId: string, storeName: string, capabilities: StoreCapabilities, config: ReconnectingWsConfig, socketFactory?: DevtoolsSocketFactory);
10
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@yoltra/devtools-browser-agent",
3
+ "version": "0.2.0",
4
+ "description": "Browser store agent for Yoltra DevTools β€” instruments stores and reports to the hub via native WebSocket",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Manu Ramirez <@pixerael>",
8
+ "email": "manu@yoltra.dev"
9
+ },
10
+ "maintainers": [],
11
+ "homepage": "https://yoltra.dev",
12
+ "keywords": [
13
+ "yoltra",
14
+ "devtools",
15
+ "browser",
16
+ "agent"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/yoltra/yoltra.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/yoltra/yoltra/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "dist/devtools-browser-agent.cjs.js",
27
+ "module": "dist/devtools-browser-agent.esm.js",
28
+ "types": "dist/types/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/types/index.d.ts",
32
+ "import": "./dist/devtools-browser-agent.esm.js",
33
+ "require": "./dist/devtools-browser-agent.cjs.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "sideEffects": false,
40
+ "peerDependencies": {
41
+ "@yoltra/core": "^0.2.0"
42
+ },
43
+ "dependencies": {
44
+ "@yoltra/devtools-protocol": "0.2.0"
45
+ },
46
+ "devDependencies": {
47
+ "typedoc": "^0.28.13",
48
+ "typedoc-plugin-markdown": "4.9.0",
49
+ "typedoc-plugin-localization": "3.0.6",
50
+ "typescript": "5.9.3",
51
+ "vite": "^7.1.11",
52
+ "vite-plugin-dts": "^4.5.4",
53
+ "vite-plugin-banner": "0.8.1",
54
+ "vitest": "3.2.4",
55
+ "@yoltra/core": "0.2.0",
56
+ "@yoltra/devtools-ui": "0.2.0"
57
+ },
58
+ "engines": {
59
+ "node": ">=18.18"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "scripts": {
65
+ "build": "vite build",
66
+ "lint": "node ../../tools/repo-tools/bin/repo-eslint.cjs --report-unused-disable-directives --max-warnings 0",
67
+ "test": "vitest --watch=false",
68
+ "typecheck": "tsc --noEmit",
69
+ "docs": "rushx docs:js && rushx docs:md",
70
+ "docs:md": "pnpm typedoc --options ./typedoc.json",
71
+ "docs:js": "pnpm typedoc --options ./typedoc.json --json ./.typedoc/devtools-browser-agent-en.json"
72
+ }
73
+ }