@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 +161 -0
- package/dist/devtools-browser-agent.cjs.js +2 -0
- package/dist/devtools-browser-agent.cjs.js.map +1 -0
- package/dist/devtools-browser-agent.esm.js +199 -0
- package/dist/devtools-browser-agent.esm.js.map +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/types.d.ts +100 -0
- package/dist/types/withDevtools.d.ts +35 -0
- package/dist/types/ws-client.d.ts +10 -0
- package/package.json +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+

|
|
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
|
+
}
|