@omniaura/solid-pulse 0.1.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 +128 -0
- package/dist/bridge.d.ts +202 -0
- package/dist/bridge.js +10 -0
- package/dist/bridge.js.map +1 -0
- package/dist/chunk-4QA2G6S3.js +15 -0
- package/dist/chunk-4QA2G6S3.js.map +1 -0
- package/dist/chunk-5FYH2KEZ.js +66 -0
- package/dist/chunk-5FYH2KEZ.js.map +1 -0
- package/dist/chunk-C72EYM65.js +462 -0
- package/dist/chunk-C72EYM65.js.map +1 -0
- package/dist/chunk-IXNWEUNF.js +114 -0
- package/dist/chunk-IXNWEUNF.js.map +1 -0
- package/dist/chunk-TVSI7G5S.js +414 -0
- package/dist/chunk-TVSI7G5S.js.map +1 -0
- package/dist/chunk-WIMCBTHZ.js +21 -0
- package/dist/chunk-WIMCBTHZ.js.map +1 -0
- package/dist/cli.js +229 -0
- package/dist/cli.js.map +1 -0
- package/dist/controller-3akN6Qi0.d.ts +210 -0
- package/dist/core.d.ts +76 -0
- package/dist/core.js +41 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +175 -0
- package/dist/index.js +987 -0
- package/dist/index.js.map +1 -0
- package/dist/panel.d.ts +34 -0
- package/dist/panel.js +408 -0
- package/dist/panel.js.map +1 -0
- package/dist/query.d.ts +93 -0
- package/dist/query.js +164 -0
- package/dist/query.js.map +1 -0
- package/dist/vite.d.ts +41 -0
- package/dist/vite.js +76 -0
- package/dist/vite.js.map +1 -0
- package/package.json +99 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @omniaura/solid-pulse
|
|
2
|
+
|
|
3
|
+
> Flash what actually updates in a SolidJS app — and give agents the same controls as humans.
|
|
4
|
+
|
|
5
|
+
`solid-pulse` is dev-only instrumentation for Solid. It watches the reactive graph through Solid's official dev hooks, the DOM through a `MutationObserver`, and the network through `fetch`/`WebSocket`/`EventSource`, and turns what it sees into precisely named events:
|
|
6
|
+
|
|
7
|
+
| Event | Meaning |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `solid.flush` | a reactive update completed: *n* computations re-ran, by kind (`memo`/`computed`/`effect`/`render`) and by owning component |
|
|
10
|
+
| `solid.computation` | one computation re-ran (only with `verboseComputations`) |
|
|
11
|
+
| `solid.component.mount` / `.dispose` / `.remount` | a component function ran / its owner was disposed / the same-named child was disposed and recreated within a flush (`gapMs`) |
|
|
12
|
+
| `solid.root` | a reactive root was created (multiple roots are counted, not conflated) |
|
|
13
|
+
| `dom.mutation` | the DOM actually changed (targets, attribute names, added/removed counts, attribution to the flush's component) |
|
|
14
|
+
| `dom.detach` | a subtree left the document, recording the `scrollTop` of every recently-scrolled descendant and whether focus was inside |
|
|
15
|
+
| `dom.reattach` | the **same node instance** came back: `gapMs`, `scrollReset: before→after`, `focusLost`, `suspenseInChain`, a stable `selector` |
|
|
16
|
+
| `focus.lost` | the focused element vanished from the document |
|
|
17
|
+
| `net.fetch.start/end/error` | fetch lifecycle; SSE responses detected by content-type and counted frame by frame; aborts flagged |
|
|
18
|
+
| `net.ws.open/message/close/error` | WebSocket lifecycle with subprotocols, direction, counts, `type` field of JSON frames, close codes |
|
|
19
|
+
| `net.sse.*` | `EventSource` lifecycle (and fetch-based SSE) |
|
|
20
|
+
| `query.observe` | a Solid Query observer subscribed — `role: "initiating"` (first observer, its mount causes the fetch) or `"sharing"` (query already had observers) — attributed to the Solid component |
|
|
21
|
+
| `query.fetch.start/success/error` | with `trigger: observer-mount \| invalidation \| background-refetch \| initial` |
|
|
22
|
+
| `query.invalidate`, `query.update`, `query.unobserve`, `query.added/removed`, `mutation.*` | the rest of the cache lifecycle |
|
|
23
|
+
|
|
24
|
+
There is deliberately **no "rerender" event**: Solid never re-runs component bodies. If one ever did, you would see a `pulse.note` saying so.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bun add -d @omniaura/solid-pulse
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// vite.config.ts — dev server only (`apply: "serve"`), production builds never include it
|
|
34
|
+
import solidPulse from "@omniaura/solid-pulse/vite";
|
|
35
|
+
export default defineConfig({ plugins: [solidPulse(), solid()] });
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The plugin injects the runtime as the first module script (so Solid's dev hooks are installed before your first render), mounts the raw-DOM panel, mounts the bridge on the Vite dev server at `/__pulse`, and writes `node_modules/.vite/solid-pulse.json` so the CLI finds it.
|
|
39
|
+
|
|
40
|
+
Manual setup (no Vite, or custom options):
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { initPulse } from "@omniaura/solid-pulse";
|
|
44
|
+
import { mountPanel } from "@omniaura/solid-pulse/panel";
|
|
45
|
+
if (import.meta.env.DEV) {
|
|
46
|
+
const pulse = initPulse({ bridge: "ws://localhost:4567/__pulse/ws" }); // or bridge: true for same-origin
|
|
47
|
+
mountPanel(pulse);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Solid Query adapter (optional)
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { attachQueryClient } from "@omniaura/solid-pulse/query";
|
|
55
|
+
attachQueryClient(pulse, queryClient);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Read-only: it subscribes to the `QueryCache`/`MutationCache` and never changes query behaviour. With the Vite plugin, point `setupModule: "/src/pulse-setup.ts"` at a module that `export default (pulse) => { attachQueryClient(pulse, queryClient) }` — it is evaluated before your app's entry module, so the very first observers are attributed.
|
|
59
|
+
|
|
60
|
+
### Overlay
|
|
61
|
+
|
|
62
|
+
A single `position: fixed; pointer-events: none` container draws flash rectangles (amber = DOM change, green = mount, red = reattach, blue = query, purple = highlight) and a transient badge centred on the component that initiates/observes a query, showing the redacted query key. No layout shift, no focus, capped at 48 live rectangles and 4 badges; respects `prefers-reduced-motion`.
|
|
63
|
+
|
|
64
|
+
## Human ⇄ agent parity
|
|
65
|
+
|
|
66
|
+
Every panel control carries `data-command="<name>"` and calls `controller.run(name, args)`; the CLI and HTTP API call the same function. `solid-pulse commands` prints the contract — including where the human control lives — and the test suite asserts the two sets match.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
solid-pulse status # bridge + connected pages
|
|
70
|
+
solid-pulse commands # the contract
|
|
71
|
+
solid-pulse events kinds=dom,query limit=50 # buffered events through the active filters
|
|
72
|
+
solid-pulse tail kinds=dom.reattach,focus.lost # live (SSE)
|
|
73
|
+
solid-pulse features.set name=flash on=false
|
|
74
|
+
solid-pulse features.set name=verboseComputations on=true
|
|
75
|
+
solid-pulse filters.set kinds=query component=ChatFeed
|
|
76
|
+
solid-pulse inspect.components name=Chat
|
|
77
|
+
solid-pulse inspect.queries active=true
|
|
78
|
+
solid-pulse inspect.element selector='[data-testid=composer]' # source + component context (solid-grab aware)
|
|
79
|
+
solid-pulse dom.highlight selector='.scroll-view' all=true # show a human what the agent is looking at
|
|
80
|
+
solid-pulse record.start id=qa-1 · record.stop · export recording=qa-1 --json > qa-1.json
|
|
81
|
+
solid-pulse note text='step 3: switch thread'
|
|
82
|
+
solid-pulse panel.open tab=query · panel.close
|
|
83
|
+
solid-pulse query.invalidate key='["conversations"]'
|
|
84
|
+
solid-pulse scenario.select name=chat-stream-drop # when @omniaura/scenario-sim is attached
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Options: `--url http://host:port[/__pulse]` (or `SOLID_PULSE_URL`; auto-discovered from `node_modules/.vite/solid-pulse.json`), `--client <id>` when several tabs are connected, `--json`.
|
|
88
|
+
|
|
89
|
+
In-page: `window.__SOLID_PULSE__.run("events.list", { kinds: "dom" })` — handy from `agent-browser eval` or Playwright `page.evaluate`.
|
|
90
|
+
|
|
91
|
+
### HTTP API (what the CLI uses)
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
GET /__pulse/api/status
|
|
95
|
+
GET /__pulse/api/clients
|
|
96
|
+
GET /__pulse/api/commands?client=
|
|
97
|
+
POST /__pulse/api/command {client?, name, args}
|
|
98
|
+
GET /__pulse/api/command?name=&args=<json>&client=
|
|
99
|
+
GET /__pulse/api/events?client=&since=&kinds=&limit=
|
|
100
|
+
GET /__pulse/api/events/stream?client=&kinds= (text/event-stream)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Security and overhead
|
|
104
|
+
|
|
105
|
+
- **Dev only.** The Vite plugin is `apply: "serve"`; the runtime refuses to initialise twice; nothing is imported in production builds (`bun run smoke` proves it against the example app).
|
|
106
|
+
- **Loopback only.** The bridge checks both the peer address and the `Host` header; `allowRemote: true` is an explicit opt-in.
|
|
107
|
+
- **Redaction.** URLs lose `token|key|secret|auth|session|ticket|password|signature|code` params, JWT and bearer tokens are scrubbed, headers are never captured, bodies only with the `captureBodies` feature (truncated).
|
|
108
|
+
- **Bounded.** Ring buffer (2000 events), per-flush aggregation (individual computations only in verbose mode), per-frame flash caps, per-stream message caps (first 200 then every 50th), recordings capped and limited to the last five.
|
|
109
|
+
- **Non-interfering.** The overlay and panel are raw DOM (no Solid), so they never appear in their own event stream, never take focus, and never shift layout. Query instrumentation is a cache subscription — no query behaviour changes.
|
|
110
|
+
- **Hooks, not guesses.** Solid instrumentation uses `DEV.hooks.afterCreateOwner`/`afterUpdate` from Solid's development build and chains any hook already installed (e.g. `solid-devtools`); in a production Solid build it becomes a no-op and says so.
|
|
111
|
+
|
|
112
|
+
## Reading a Suspense flip
|
|
113
|
+
|
|
114
|
+
The bug class this was built for: a tracked `query.data` read under `<Suspense>` flips the boundary to its fallback and back within a couple of milliseconds, detaching the live surface — `scrollTop` resets to 0 and focus drops to `<body>` with no component cleanup. It reads as:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
dom.detach <ChatFeed> section[data-testid=chat] scrollers=1 had focus
|
|
118
|
+
dom.reattach <ChatFeed> section[data-testid=chat] gap=2.1ms SCROLL RESET 1834→0 FOCUS LOST (Suspense in chain)
|
|
119
|
+
focus.lost textarea — element removed from document
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`solid-pulse tail kinds=dom.reattach,focus.lost` while a scroll harness runs turns days of frame-by-frame probing into a one-line alarm.
|
|
123
|
+
|
|
124
|
+
## solid-grab
|
|
125
|
+
|
|
126
|
+
If [`solid-grab`](https://github.com/omniaura/solid-grab) is installed, `inspect.element` returns its formatted source context (file:line:column and component chain) in addition to pulse's own attribution, and the panel's Grab tab explains Alt+click. solid-grab's `data-solid-component` attributes also improve the overlay's component→DOM mapping.
|
|
127
|
+
|
|
128
|
+
MIT © omniaura
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Server, IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
|
|
4
|
+
/** Fixed-capacity FIFO. Overwrites the oldest entry; never grows. */
|
|
5
|
+
declare class RingBuffer<T> {
|
|
6
|
+
readonly capacity: number;
|
|
7
|
+
private items;
|
|
8
|
+
private head;
|
|
9
|
+
private count;
|
|
10
|
+
/** Total number of pushes since creation (dropped + retained). */
|
|
11
|
+
pushed: number;
|
|
12
|
+
constructor(capacity: number);
|
|
13
|
+
get size(): number;
|
|
14
|
+
get dropped(): number;
|
|
15
|
+
push(item: T): void;
|
|
16
|
+
/** Oldest → newest. */
|
|
17
|
+
toArray(): T[];
|
|
18
|
+
clear(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Event model. Every observation the runtime makes is one PulseEvent with a
|
|
23
|
+
* precise `kind`. Kinds are deliberately specific: Solid has no "rerender", so
|
|
24
|
+
* we never report one. What actually happens is one of:
|
|
25
|
+
*
|
|
26
|
+
* solid.flush a reactive flush completed (n computations re-ran)
|
|
27
|
+
* solid.computation one memo/effect/render-effect re-ran (verbose mode)
|
|
28
|
+
* solid.component.mount a component function ran (fresh mount, or hydrate)
|
|
29
|
+
* solid.component.dispose
|
|
30
|
+
* solid.component.remount same-named component disposed + mounted in one flush
|
|
31
|
+
* solid.root a reactive root was created (multiple roots are fine)
|
|
32
|
+
* dom.mutation the DOM actually changed (childList/attr/text)
|
|
33
|
+
* dom.detach a subtree left the document (with scroll/focus state)
|
|
34
|
+
* dom.reattach the same node instance came back (Suspense flip etc.)
|
|
35
|
+
* focus.lost the focused element vanished from the document
|
|
36
|
+
* net.fetch.* fetch lifecycle (start/end/error), SSE-aware
|
|
37
|
+
* net.ws.* WebSocket lifecycle (open/message/close/error)
|
|
38
|
+
* net.sse.* EventSource lifecycle
|
|
39
|
+
* query.* Solid Query cache/observer events (adapter)
|
|
40
|
+
* mutation.* Solid Query mutation cache events (adapter)
|
|
41
|
+
* pulse.* runtime lifecycle / control-plane notes
|
|
42
|
+
*/
|
|
43
|
+
type PulseEventKind = "solid.flush" | "solid.computation" | "solid.component.mount" | "solid.component.dispose" | "solid.component.remount" | "solid.root" | "dom.mutation" | "dom.detach" | "dom.reattach" | "focus.lost" | "net.fetch.start" | "net.fetch.end" | "net.fetch.error" | "net.ws.open" | "net.ws.message" | "net.ws.close" | "net.ws.error" | "net.sse.open" | "net.sse.message" | "net.sse.error" | "net.sse.close" | "query.added" | "query.removed" | "query.observe" | "query.unobserve" | "query.fetch.start" | "query.fetch.success" | "query.fetch.error" | "query.invalidate" | "query.update" | "mutation.start" | "mutation.success" | "mutation.error" | "pulse.note";
|
|
44
|
+
interface ComponentRef {
|
|
45
|
+
id: number;
|
|
46
|
+
name: string;
|
|
47
|
+
/** Component ancestry, innermost first (names only). */
|
|
48
|
+
chain?: string[];
|
|
49
|
+
/** Source location if solid-grab's data-solid-source attribute was found. */
|
|
50
|
+
source?: string | null;
|
|
51
|
+
}
|
|
52
|
+
interface PulseEventBase {
|
|
53
|
+
/** Monotonic per-runtime sequence. */
|
|
54
|
+
seq: number;
|
|
55
|
+
/** performance.now() at capture. */
|
|
56
|
+
t: number;
|
|
57
|
+
/** Date.now() at capture (for cross-process correlation). */
|
|
58
|
+
wall: number;
|
|
59
|
+
kind: PulseEventKind;
|
|
60
|
+
/** Flush group the event belongs to, when attributable. */
|
|
61
|
+
flush?: number;
|
|
62
|
+
/** Component attribution, when known. */
|
|
63
|
+
component?: ComponentRef | null;
|
|
64
|
+
/** Free-form structured payload; shape depends on `kind`. */
|
|
65
|
+
data: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
type PulseEvent = PulseEventBase;
|
|
68
|
+
|
|
69
|
+
interface CommandSpec {
|
|
70
|
+
/** Dotted command name, e.g. `features.set`. Also the CLI verb. */
|
|
71
|
+
name: string;
|
|
72
|
+
summary: string;
|
|
73
|
+
/** Argument name → short description. */
|
|
74
|
+
args?: Record<string, string>;
|
|
75
|
+
/** Where the equivalent human control lives in the panel (parity doc). */
|
|
76
|
+
ui?: string;
|
|
77
|
+
}
|
|
78
|
+
type CommandResult = {
|
|
79
|
+
ok: true;
|
|
80
|
+
value: unknown;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
error: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bridge protocol (JSON over WebSocket) between a page runtime and the bridge
|
|
88
|
+
* server, and the HTTP shape the server exposes to CLIs and agents.
|
|
89
|
+
*
|
|
90
|
+
* Page → server
|
|
91
|
+
* hello first frame; identifies the tab
|
|
92
|
+
* events batched PulseEvents (≤ 50 ms coalescing)
|
|
93
|
+
* result reply to a `command`
|
|
94
|
+
* Server → page
|
|
95
|
+
* command run a controller command; page answers with `result`
|
|
96
|
+
* welcome ack of hello with the server-assigned client id
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
interface ClientSummary {
|
|
100
|
+
clientId: string;
|
|
101
|
+
url: string;
|
|
102
|
+
title: string;
|
|
103
|
+
userAgent: string;
|
|
104
|
+
connectedWall: number;
|
|
105
|
+
lastSeenWall: number;
|
|
106
|
+
events: number;
|
|
107
|
+
commands: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Bridge server (Node/Bun). Accepts page runtimes over WebSocket at
|
|
112
|
+
* `<path>/ws`, mirrors their events into a per-client ring buffer, and exposes
|
|
113
|
+
* a small HTTP API for CLIs and agents at `<path>/api/*`:
|
|
114
|
+
*
|
|
115
|
+
* GET /api/status server + connected clients
|
|
116
|
+
* GET /api/clients
|
|
117
|
+
* GET /api/commands?client= the page's command contract
|
|
118
|
+
* POST /api/command {client?,name,args} run a command in the page
|
|
119
|
+
* GET /api/command?name=&args=<json>&client= (curl-friendly)
|
|
120
|
+
* GET /api/events?client=&since=&kinds=&limit=
|
|
121
|
+
* GET /api/events/stream?client=&kinds= SSE, one `pulse` event per PulseEvent
|
|
122
|
+
*
|
|
123
|
+
* Access: loopback only unless `allowRemote` is set — remote address and Host
|
|
124
|
+
* header are both checked, so a LAN dev server does not leak instrumentation.
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
interface BridgeServerOptions {
|
|
128
|
+
/** URL prefix (default `/__pulse`). */
|
|
129
|
+
path?: string;
|
|
130
|
+
/** Accept non-loopback peers and hosts (default false). */
|
|
131
|
+
allowRemote?: boolean;
|
|
132
|
+
/** Per-client mirror buffer (default 5000 events). */
|
|
133
|
+
bufferSize?: number;
|
|
134
|
+
/** Command timeout in ms (default 10000). */
|
|
135
|
+
commandTimeoutMs?: number;
|
|
136
|
+
log?: (message: string) => void;
|
|
137
|
+
}
|
|
138
|
+
interface ClientState {
|
|
139
|
+
ws: WebSocket;
|
|
140
|
+
summary: ClientSummary;
|
|
141
|
+
commands: CommandSpec[];
|
|
142
|
+
buffer: RingBuffer<PulseEvent>;
|
|
143
|
+
pending: Map<string, {
|
|
144
|
+
resolve: (r: CommandResult) => void;
|
|
145
|
+
timer: ReturnType<typeof setTimeout>;
|
|
146
|
+
}>;
|
|
147
|
+
}
|
|
148
|
+
type EventListener = (clientId: string, events: PulseEvent[]) => void;
|
|
149
|
+
declare class BridgeServer {
|
|
150
|
+
readonly path: string;
|
|
151
|
+
private clients;
|
|
152
|
+
private wss;
|
|
153
|
+
private listeners;
|
|
154
|
+
private nextCommand;
|
|
155
|
+
private startedWall;
|
|
156
|
+
private opts;
|
|
157
|
+
constructor(options?: BridgeServerOptions);
|
|
158
|
+
/** Attach the WebSocket upgrade handler to an existing http server (e.g. Vite's). */
|
|
159
|
+
attach(server: Server): void;
|
|
160
|
+
isAllowed(req: IncomingMessage): boolean;
|
|
161
|
+
/** Connect middleware: handles `<path>/api/*`; returns false when the URL is not ours. */
|
|
162
|
+
handleHttp(req: IncomingMessage, res: ServerResponse): boolean;
|
|
163
|
+
private route;
|
|
164
|
+
private noClientMessage;
|
|
165
|
+
private onConnection;
|
|
166
|
+
/** Pick a client: explicit id, else the most recently seen. */
|
|
167
|
+
pick(clientId?: string): ClientState | null;
|
|
168
|
+
listClients(): ClientSummary[];
|
|
169
|
+
status(): {
|
|
170
|
+
tool: string;
|
|
171
|
+
protocol: number;
|
|
172
|
+
path: string;
|
|
173
|
+
uptimeMs: number;
|
|
174
|
+
allowRemote: boolean;
|
|
175
|
+
clients: ClientSummary[];
|
|
176
|
+
};
|
|
177
|
+
events(clientId: string, opts?: {
|
|
178
|
+
since?: number;
|
|
179
|
+
kinds?: string[];
|
|
180
|
+
limit?: number;
|
|
181
|
+
}): PulseEvent[];
|
|
182
|
+
command(clientId: string, name: string, args?: Record<string, unknown>): Promise<CommandResult>;
|
|
183
|
+
onEvents(listener: EventListener): () => boolean;
|
|
184
|
+
close(): void;
|
|
185
|
+
}
|
|
186
|
+
interface StandaloneOptions extends BridgeServerOptions {
|
|
187
|
+
port?: number;
|
|
188
|
+
host?: string;
|
|
189
|
+
}
|
|
190
|
+
/** Run the bridge as its own process (for non-Vite setups): `solid-pulse bridge --port 4567`. */
|
|
191
|
+
declare function startBridgeServer(options?: StandaloneOptions): {
|
|
192
|
+
bridge: BridgeServer;
|
|
193
|
+
server: Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
194
|
+
ready: Promise<{
|
|
195
|
+
url: string;
|
|
196
|
+
port: number;
|
|
197
|
+
}>;
|
|
198
|
+
url: string;
|
|
199
|
+
close: () => Promise<void>;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export { BridgeServer, type BridgeServerOptions, type StandaloneOptions, startBridgeServer };
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/core/protocol.ts
|
|
2
|
+
var PROTOCOL_VERSION = 1;
|
|
3
|
+
var DEFAULT_PATH = "/__pulse";
|
|
4
|
+
function isPageFrame(value) {
|
|
5
|
+
if (!value || typeof value !== "object") return false;
|
|
6
|
+
const t = value.type;
|
|
7
|
+
return t === "hello" || t === "events" || t === "result";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
PROTOCOL_VERSION,
|
|
12
|
+
DEFAULT_PATH,
|
|
13
|
+
isPageFrame
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=chunk-4QA2G6S3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/protocol.ts"],"sourcesContent":["/**\n * Bridge protocol (JSON over WebSocket) between a page runtime and the bridge\n * server, and the HTTP shape the server exposes to CLIs and agents.\n *\n * Page → server\n * hello first frame; identifies the tab\n * events batched PulseEvents (≤ 50 ms coalescing)\n * result reply to a `command`\n * Server → page\n * command run a controller command; page answers with `result`\n * welcome ack of hello with the server-assigned client id\n */\n\nimport type { PulseEvent } from \"./events.js\";\nimport type { CommandResult, CommandSpec } from \"./controller.js\";\n\nexport const PROTOCOL_VERSION = 1;\nexport const DEFAULT_PATH = \"/__pulse\";\n\nexport interface HelloFrame {\n type: \"hello\";\n protocol: number;\n clientId: string;\n url: string;\n title: string;\n userAgent: string;\n commands: CommandSpec[];\n startedWall: number;\n}\n\nexport interface EventsFrame {\n type: \"events\";\n events: PulseEvent[];\n}\n\nexport interface ResultFrame {\n type: \"result\";\n id: string;\n result: CommandResult;\n}\n\nexport interface CommandFrame {\n type: \"command\";\n id: string;\n name: string;\n args: Record<string, unknown>;\n}\n\nexport interface WelcomeFrame {\n type: \"welcome\";\n clientId: string;\n protocol: number;\n}\n\nexport type PageFrame = HelloFrame | EventsFrame | ResultFrame;\nexport type ServerFrame = CommandFrame | WelcomeFrame;\n\nexport interface ClientSummary {\n clientId: string;\n url: string;\n title: string;\n userAgent: string;\n connectedWall: number;\n lastSeenWall: number;\n events: number;\n commands: number;\n}\n\nexport function isPageFrame(value: unknown): value is PageFrame {\n if (!value || typeof value !== \"object\") return false;\n const t = (value as { type?: unknown }).type;\n return t === \"hello\" || t === \"events\" || t === \"result\";\n}\n\nexport function isServerFrame(value: unknown): value is ServerFrame {\n if (!value || typeof value !== \"object\") return false;\n const t = (value as { type?: unknown }).type;\n return t === \"command\" || t === \"welcome\";\n}\n"],"mappings":";AAgBO,IAAM,mBAAmB;AACzB,IAAM,eAAe;AAmDrB,SAAS,YAAY,OAAoC;AAC9D,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,IAAK,MAA6B;AACxC,SAAO,MAAM,WAAW,MAAM,YAAY,MAAM;AAClD;","names":[]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/core/redact.ts
|
|
2
|
+
var SENSITIVE_PARAM = /(token|key|secret|auth|session|ticket|password|passwd|signature|sig|code)/i;
|
|
3
|
+
var SENSITIVE_HEADER = /^(authorization|cookie|set-cookie|x-api-key|x-auth-token|proxy-authorization)$/i;
|
|
4
|
+
var BEARER = /\b(bearer|basic)\s+[a-z0-9._~+/=-]{8,}/gi;
|
|
5
|
+
var JWT = /\beyJ[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}\b/g;
|
|
6
|
+
var REDACTED = "[redacted]";
|
|
7
|
+
function redactUrl(input) {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(input, "http://localhost/");
|
|
11
|
+
} catch {
|
|
12
|
+
return redactText(input);
|
|
13
|
+
}
|
|
14
|
+
if (url.username || url.password) {
|
|
15
|
+
url.username = "";
|
|
16
|
+
url.password = "";
|
|
17
|
+
}
|
|
18
|
+
let changed = false;
|
|
19
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
20
|
+
if (SENSITIVE_PARAM.test(key)) {
|
|
21
|
+
url.searchParams.set(key, REDACTED);
|
|
22
|
+
changed = true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (url.hash && SENSITIVE_PARAM.test(url.hash)) {
|
|
26
|
+
url.hash = "";
|
|
27
|
+
changed = true;
|
|
28
|
+
}
|
|
29
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(input)) {
|
|
30
|
+
const rel = url.pathname + url.search + url.hash;
|
|
31
|
+
return changed ? rel : redactText(input);
|
|
32
|
+
}
|
|
33
|
+
return redactText(url.toString());
|
|
34
|
+
}
|
|
35
|
+
function redactText(text) {
|
|
36
|
+
return text.replace(JWT, REDACTED).replace(BEARER, (_, scheme) => `${scheme} ${REDACTED}`);
|
|
37
|
+
}
|
|
38
|
+
function redactHeaders(headers) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [k, v] of headers) {
|
|
41
|
+
out[k.toLowerCase()] = SENSITIVE_HEADER.test(k) ? REDACTED : redactText(v);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function redactValue(value, depth = 0) {
|
|
46
|
+
if (depth > 6 || value === null || value === void 0) return value;
|
|
47
|
+
if (typeof value === "string") return redactText(value);
|
|
48
|
+
if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
|
|
49
|
+
if (typeof value === "object") {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, v] of Object.entries(value)) {
|
|
52
|
+
out[k] = SENSITIVE_PARAM.test(k) && typeof v === "string" ? REDACTED : redactValue(v, depth + 1);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
REDACTED,
|
|
61
|
+
redactUrl,
|
|
62
|
+
redactText,
|
|
63
|
+
redactHeaders,
|
|
64
|
+
redactValue
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=chunk-5FYH2KEZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/redact.ts"],"sourcesContent":["/**\n * Redaction. Devtools traffic leaves the page (bridge, CLI, exports), so\n * anything that looks like a credential is scrubbed before it becomes an event.\n * Bodies are never captured unless `captureBodies` is turned on explicitly.\n */\n\nconst SENSITIVE_PARAM = /(token|key|secret|auth|session|ticket|password|passwd|signature|sig|code)/i;\nconst SENSITIVE_HEADER = /^(authorization|cookie|set-cookie|x-api-key|x-auth-token|proxy-authorization)$/i;\nconst BEARER = /\\b(bearer|basic)\\s+[a-z0-9._~+/=-]{8,}/gi;\nconst JWT = /\\beyJ[a-zA-Z0-9_-]{5,}\\.[a-zA-Z0-9_-]{5,}\\.[a-zA-Z0-9_-]{5,}\\b/g;\n\nexport const REDACTED = \"[redacted]\";\n\nexport function redactUrl(input: string): string {\n let url: URL;\n try {\n url = new URL(input, \"http://localhost/\");\n } catch {\n return redactText(input);\n }\n if (url.username || url.password) {\n url.username = \"\";\n url.password = \"\";\n }\n let changed = false;\n for (const key of [...url.searchParams.keys()]) {\n if (SENSITIVE_PARAM.test(key)) {\n url.searchParams.set(key, REDACTED);\n changed = true;\n }\n }\n if (url.hash && SENSITIVE_PARAM.test(url.hash)) {\n url.hash = \"\";\n changed = true;\n }\n // Preserve relative inputs as given (the base above is only for parsing).\n if (!/^[a-z][a-z0-9+.-]*:/i.test(input)) {\n const rel = url.pathname + url.search + url.hash;\n return changed ? rel : redactText(input);\n }\n return redactText(url.toString());\n}\n\nexport function redactText(text: string): string {\n return text.replace(JWT, REDACTED).replace(BEARER, (_, scheme: string) => `${scheme} ${REDACTED}`);\n}\n\nexport function redactHeaders(headers: Iterable<[string, string]>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [k, v] of headers) {\n out[k.toLowerCase()] = SENSITIVE_HEADER.test(k) ? REDACTED : redactText(v);\n }\n return out;\n}\n\n/** Best-effort key redaction inside small structured payloads. */\nexport function redactValue<T>(value: T, depth = 0): T {\n if (depth > 6 || value === null || value === undefined) return value;\n if (typeof value === \"string\") return redactText(value) as T;\n if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1)) as T;\n if (typeof value === \"object\") {\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n out[k] = SENSITIVE_PARAM.test(k) && typeof v === \"string\" ? REDACTED : redactValue(v, depth + 1);\n }\n return out as T;\n }\n return value;\n}\n"],"mappings":";AAMA,IAAM,kBAAkB;AACxB,IAAM,mBAAmB;AACzB,IAAM,SAAS;AACf,IAAM,MAAM;AAEL,IAAM,WAAW;AAEjB,SAAS,UAAU,OAAuB;AAC/C,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,OAAO,mBAAmB;AAAA,EAC1C,QAAQ;AACN,WAAO,WAAW,KAAK;AAAA,EACzB;AACA,MAAI,IAAI,YAAY,IAAI,UAAU;AAChC,QAAI,WAAW;AACf,QAAI,WAAW;AAAA,EACjB;AACA,MAAI,UAAU;AACd,aAAW,OAAO,CAAC,GAAG,IAAI,aAAa,KAAK,CAAC,GAAG;AAC9C,QAAI,gBAAgB,KAAK,GAAG,GAAG;AAC7B,UAAI,aAAa,IAAI,KAAK,QAAQ;AAClC,gBAAU;AAAA,IACZ;AAAA,EACF;AACA,MAAI,IAAI,QAAQ,gBAAgB,KAAK,IAAI,IAAI,GAAG;AAC9C,QAAI,OAAO;AACX,cAAU;AAAA,EACZ;AAEA,MAAI,CAAC,uBAAuB,KAAK,KAAK,GAAG;AACvC,UAAM,MAAM,IAAI,WAAW,IAAI,SAAS,IAAI;AAC5C,WAAO,UAAU,MAAM,WAAW,KAAK;AAAA,EACzC;AACA,SAAO,WAAW,IAAI,SAAS,CAAC;AAClC;AAEO,SAAS,WAAW,MAAsB;AAC/C,SAAO,KAAK,QAAQ,KAAK,QAAQ,EAAE,QAAQ,QAAQ,CAAC,GAAG,WAAmB,GAAG,MAAM,IAAI,QAAQ,EAAE;AACnG;AAEO,SAAS,cAAc,SAA6D;AACzF,QAAM,MAA8B,CAAC;AACrC,aAAW,CAAC,GAAG,CAAC,KAAK,SAAS;AAC5B,QAAI,EAAE,YAAY,CAAC,IAAI,iBAAiB,KAAK,CAAC,IAAI,WAAW,WAAW,CAAC;AAAA,EAC3E;AACA,SAAO;AACT;AAGO,SAAS,YAAe,OAAU,QAAQ,GAAM;AACrD,MAAI,QAAQ,KAAK,UAAU,QAAQ,UAAU,OAAW,QAAO;AAC/D,MAAI,OAAO,UAAU,SAAU,QAAO,WAAW,KAAK;AACtD,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO,MAAM,IAAI,CAAC,MAAM,YAAY,GAAG,QAAQ,CAAC,CAAC;AAC3E,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,MAA+B,CAAC;AACtC,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,KAAgC,GAAG;AACrE,UAAI,CAAC,IAAI,gBAAgB,KAAK,CAAC,KAAK,OAAO,MAAM,WAAW,WAAW,YAAY,GAAG,QAAQ,CAAC;AAAA,IACjG;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;","names":[]}
|