@lensmcp/bridge 1.18.4 → 1.18.7
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/index.js +1 -5
- package/lib/constants.js +1 -24
- package/lib/env-sink.js +3 -203
- package/lib/event-mappers.js +3 -299
- package/lib/redact.d.ts +18 -87
- package/lib/redact.js +1 -215
- package/lib/server.d.ts +2 -0
- package/lib/server.js +1 -109
- package/lib/types.d.ts +18 -0
- package/lib/types.js +1 -1
- package/main.js +1 -47
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
export { envelopeToEvent } from './lib/event-mappers.js';
|
|
3
|
-
export { redactUrl, redactText, redactDeep, isSecretParam, clip, safeText, safeTitle, REDACTED, MAX_TITLE_CHARS, MAX_MESSAGE_CHARS, MAX_URL_CHARS, } from './lib/redact.js';
|
|
4
|
-
export { resolveEnvSink, resetEnvSinkCache, udsEnvSink } from './lib/env-sink.js';
|
|
5
|
-
export { DEFAULT_WS_HOST, DEFAULT_WS_PORT, ENV_WS_HOST, ENV_WS_PORT, ENV_SESSION_ID, } from './lib/constants.js';
|
|
1
|
+
"use strict";export{createBridgeServer,makePublisher,handleClientEnvelope,findFreePort}from"./lib/server.js";export{envelopeToEvent}from"./lib/event-mappers.js";export{redactUrl,redactText,redactDeep,isSecretParam,clip,safeText,safeTitle,REDACTED,MAX_TITLE_CHARS,MAX_MESSAGE_CHARS,MAX_URL_CHARS}from"./lib/redact.js";export{resolveEnvSink,resetEnvSinkCache,udsEnvSink}from"./lib/env-sink.js";export{DEFAULT_WS_HOST,DEFAULT_WS_PORT,ENV_WS_HOST,ENV_WS_PORT,ENV_SESSION_ID}from"./lib/constants.js";
|
package/lib/constants.js
CHANGED
|
@@ -1,24 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Shared transport constants for the LensMCP browser-event bridge.
|
|
3
|
-
*
|
|
4
|
-
* The bridge is the single WebSocket endpoint that the injected
|
|
5
|
-
* `@lensmcp/client-runtime` posts envelopes to. For the Vite plugin this
|
|
6
|
-
* endpoint is born inside the dev server on an ephemeral port; for every
|
|
7
|
-
* other host (webpack, Next.js, no-build) it runs as a standalone sidecar
|
|
8
|
-
* on a FIXED, env-overridable port — so the browser runtime can find it
|
|
9
|
-
* without a build step baking the URL in.
|
|
10
|
-
*
|
|
11
|
-
* `DEFAULT_WS_PORT` must agree between the standalone bridge runner
|
|
12
|
-
* (`@lensmcp/bridge`) and the no-build client entry
|
|
13
|
-
* (`@lensmcp/client-runtime/auto`), which lives in a separate
|
|
14
|
-
* browser-targeted package and re-declares the same literal.
|
|
15
|
-
*/
|
|
16
|
-
export const DEFAULT_WS_HOST = '127.0.0.1';
|
|
17
|
-
/** Default fixed port for the standalone bridge sidecar. Override via `LENSMCP_WS_PORT`. */
|
|
18
|
-
export const DEFAULT_WS_PORT = 5747;
|
|
19
|
-
/** Env var: bind host for the standalone bridge. */
|
|
20
|
-
export const ENV_WS_HOST = 'LENSMCP_WS_HOST';
|
|
21
|
-
/** Env var: bind port for the standalone bridge (`0` selects an ephemeral port). */
|
|
22
|
-
export const ENV_WS_PORT = 'LENSMCP_WS_PORT';
|
|
23
|
-
/** Env var: session id stamped onto every event the bridge publishes. */
|
|
24
|
-
export const ENV_SESSION_ID = 'LENSMCP_SESSION_ID';
|
|
1
|
+
"use strict";export const DEFAULT_WS_HOST="127.0.0.1",DEFAULT_WS_PORT=5747,ENV_WS_HOST="LENSMCP_WS_HOST",ENV_WS_PORT="LENSMCP_WS_PORT",ENV_SESSION_ID="LENSMCP_SESSION_ID";
|
package/lib/env-sink.js
CHANGED
|
@@ -1,203 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// ---- file-sink hygiene — mirrors @lensmcp/node-instrumentation's emit.ts contract ------------------
|
|
5
|
-
// (bridge deliberately has no dep on node-instrumentation, so the ~40 lines are mirrored, same env
|
|
6
|
-
// vars.) Browser producers are the highest-volume writers on the bus — a valtio store churning per
|
|
7
|
-
// frame or a fetch retry loop must not grow events.jsonl unboundedly (2026-07-28: ~680 ev/s, 254 MB
|
|
8
|
-
// in 2h, every ingesting consumer OOM-crash-looped). Per-fingerprint token bucket + round-robin
|
|
9
|
-
// rotation: live file caps at MAX_EVENT_BYTES, history kept as `.1` … `.N`, oldest dropped.
|
|
10
|
-
/** Same cap contract as emit.ts: `LENSMCP_EVENT_MAX_BYTES` (default 50 MB) per generation. */
|
|
11
|
-
const MAX_EVENT_BYTES = (() => {
|
|
12
|
-
const n = Number(process.env['LENSMCP_EVENT_MAX_BYTES']);
|
|
13
|
-
return Number.isFinite(n) && n > 0 ? n : 50 * 1024 * 1024;
|
|
14
|
-
})();
|
|
15
|
-
/** Same history contract as emit.ts: `LENSMCP_EVENT_MAX_FILES` rotated generations (default 5). */
|
|
16
|
-
const MAX_EVENT_FILES = (() => {
|
|
17
|
-
const n = Number(process.env['LENSMCP_EVENT_MAX_FILES']);
|
|
18
|
-
return Number.isFinite(n) && n >= 1 && n <= 20 ? Math.floor(n) : 5;
|
|
19
|
-
})();
|
|
20
|
-
const ROTATE_CHECK_BYTES = 1024 * 1024; // stat at most ~once per MB appended
|
|
21
|
-
let bytesSinceCheck = 0;
|
|
22
|
-
function rotateIfLarge(file, addedBytes) {
|
|
23
|
-
bytesSinceCheck += addedBytes;
|
|
24
|
-
if (bytesSinceCheck < ROTATE_CHECK_BYTES)
|
|
25
|
-
return;
|
|
26
|
-
bytesSinceCheck = 0;
|
|
27
|
-
try {
|
|
28
|
-
if (statSync(file).size <= MAX_EVENT_BYTES)
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return; /* missing / already rotated by a concurrent writer — fine */
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
unlinkSync(`${file}.${MAX_EVENT_FILES}`); // drop the oldest generation
|
|
36
|
-
}
|
|
37
|
-
catch { /* none to drop */ }
|
|
38
|
-
for (let gen = MAX_EVENT_FILES - 1; gen >= 1; gen--) {
|
|
39
|
-
try {
|
|
40
|
-
renameSync(`${file}.${gen}`, `${file}.${gen + 1}`);
|
|
41
|
-
}
|
|
42
|
-
catch { /* gap in history */ }
|
|
43
|
-
}
|
|
44
|
-
try {
|
|
45
|
-
renameSync(file, `${file}.1`);
|
|
46
|
-
}
|
|
47
|
-
catch { /* lost the rotate race — a concurrent cascade ran */ }
|
|
48
|
-
}
|
|
49
|
-
/** Per-fingerprint token bucket (burst 30, then 5/s; `LENSMCP_EVENT_MAX_EPS` overrides, 0/'off' disables). */
|
|
50
|
-
const FLOOD_PER_SEC = (() => {
|
|
51
|
-
const raw = process.env['LENSMCP_EVENT_MAX_EPS'];
|
|
52
|
-
if (raw === 'off')
|
|
53
|
-
return Infinity;
|
|
54
|
-
const n = Number(raw);
|
|
55
|
-
if (raw !== undefined && raw !== '' && Number.isFinite(n))
|
|
56
|
-
return n <= 0 ? Infinity : n;
|
|
57
|
-
return 5;
|
|
58
|
-
})();
|
|
59
|
-
const FLOOD_BURST = Math.max(30, FLOOD_PER_SEC === Infinity ? 0 : FLOOD_PER_SEC * 4);
|
|
60
|
-
const floodBuckets = new Map();
|
|
61
|
-
/** Returns `-1` to drop, else the suppressed-since-last-admit count to annotate (0 normally). */
|
|
62
|
-
function floodAdmit(fingerprint) {
|
|
63
|
-
if (FLOOD_PER_SEC === Infinity)
|
|
64
|
-
return 0;
|
|
65
|
-
const now = Date.now();
|
|
66
|
-
let b = floodBuckets.get(fingerprint);
|
|
67
|
-
if (!b) {
|
|
68
|
-
if (floodBuckets.size >= 2048) {
|
|
69
|
-
for (const [k, v] of floodBuckets)
|
|
70
|
-
if (now - v.lastSeenAt > 60_000)
|
|
71
|
-
floodBuckets.delete(k);
|
|
72
|
-
if (floodBuckets.size >= 2048)
|
|
73
|
-
floodBuckets.clear();
|
|
74
|
-
}
|
|
75
|
-
b = { tokens: FLOOD_BURST, lastRefillAt: now, suppressed: 0, lastSeenAt: now };
|
|
76
|
-
floodBuckets.set(fingerprint, b);
|
|
77
|
-
}
|
|
78
|
-
b.lastSeenAt = now;
|
|
79
|
-
b.tokens = Math.min(FLOOD_BURST, b.tokens + ((now - b.lastRefillAt) / 1000) * FLOOD_PER_SEC);
|
|
80
|
-
b.lastRefillAt = now;
|
|
81
|
-
if (b.tokens >= 1) {
|
|
82
|
-
b.tokens -= 1;
|
|
83
|
-
const suppressed = b.suppressed;
|
|
84
|
-
b.suppressed = 0;
|
|
85
|
-
return suppressed;
|
|
86
|
-
}
|
|
87
|
-
b.suppressed += 1;
|
|
88
|
-
return -1;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Resolve a cross-process event sink from env, mirroring
|
|
92
|
-
* `@lensmcp/nest-instrumentation`'s `defaultEventSink` so every producer
|
|
93
|
-
* (Nest app, Vite plugin, standalone bridge) speaks the same transport:
|
|
94
|
-
* 1. `LENSMCP_EVENT_FILE` — append one JSON line per event.
|
|
95
|
-
* 2. `LENSMCP_UDS` — Unix domain socket, framed NDJSON, reconnecting.
|
|
96
|
-
* 3. `LENSMCP_IPC_SOCKET` (`host:port`) — one UDP datagram per event.
|
|
97
|
-
* Cached after first resolution; returns `null` when none is set.
|
|
98
|
-
*/
|
|
99
|
-
let cachedEnvSink;
|
|
100
|
-
export function resolveEnvSink() {
|
|
101
|
-
if (cachedEnvSink !== undefined)
|
|
102
|
-
return cachedEnvSink;
|
|
103
|
-
const filePath = process.env['LENSMCP_EVENT_FILE'];
|
|
104
|
-
if (filePath) {
|
|
105
|
-
cachedEnvSink = (event) => {
|
|
106
|
-
try {
|
|
107
|
-
const suppressed = floodAdmit(event.fingerprint);
|
|
108
|
-
if (suppressed < 0)
|
|
109
|
-
return; // flood-guarded — next admitted event carries the count
|
|
110
|
-
const line = JSON.stringify(suppressed > 0 ? { ...event, raw: { ...(event.raw ?? {}), floodSuppressed: suppressed } } : event) + '\n';
|
|
111
|
-
rotateIfLarge(filePath, line.length);
|
|
112
|
-
appendFileSync(filePath, line);
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
/* swallow */
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
return cachedEnvSink;
|
|
119
|
-
}
|
|
120
|
-
const udsPath = process.env['LENSMCP_UDS'];
|
|
121
|
-
if (udsPath) {
|
|
122
|
-
cachedEnvSink = udsEnvSink(udsPath);
|
|
123
|
-
return cachedEnvSink;
|
|
124
|
-
}
|
|
125
|
-
const socketPath = process.env['LENSMCP_IPC_SOCKET'];
|
|
126
|
-
if (socketPath) {
|
|
127
|
-
const [host, portStr] = socketPath.split(':');
|
|
128
|
-
const port = Number(portStr);
|
|
129
|
-
if (host && Number.isFinite(port)) {
|
|
130
|
-
const sock = createSocket('udp4');
|
|
131
|
-
sock.unref();
|
|
132
|
-
cachedEnvSink = (event) => {
|
|
133
|
-
try {
|
|
134
|
-
const buf = Buffer.from(JSON.stringify(event));
|
|
135
|
-
sock.send(buf, port, host, () => undefined);
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
/* swallow */
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
return cachedEnvSink;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
cachedEnvSink = null;
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
/** Test/embedding hook: drop the memoised sink so the next resolve re-reads env. */
|
|
148
|
-
export function resetEnvSinkCache() {
|
|
149
|
-
cachedEnvSink = undefined;
|
|
150
|
-
}
|
|
151
|
-
/** Reconnecting NDJSON client over a Unix domain socket (see session/uds.ts). */
|
|
152
|
-
export function udsEnvSink(path) {
|
|
153
|
-
const queue = [];
|
|
154
|
-
let sock = null;
|
|
155
|
-
let connecting = false;
|
|
156
|
-
const flush = () => {
|
|
157
|
-
if (!sock || !sock.writable)
|
|
158
|
-
return;
|
|
159
|
-
while (queue.length > 0)
|
|
160
|
-
sock.write(queue.shift());
|
|
161
|
-
};
|
|
162
|
-
const open = () => {
|
|
163
|
-
if (connecting || sock)
|
|
164
|
-
return;
|
|
165
|
-
connecting = true;
|
|
166
|
-
const s = connect(path);
|
|
167
|
-
s.on('connect', () => {
|
|
168
|
-
connecting = false;
|
|
169
|
-
sock = s;
|
|
170
|
-
flush();
|
|
171
|
-
});
|
|
172
|
-
s.on('drain', flush);
|
|
173
|
-
s.on('error', () => {
|
|
174
|
-
connecting = false;
|
|
175
|
-
sock = null;
|
|
176
|
-
});
|
|
177
|
-
s.on('close', () => {
|
|
178
|
-
sock = null;
|
|
179
|
-
});
|
|
180
|
-
s.unref();
|
|
181
|
-
};
|
|
182
|
-
open();
|
|
183
|
-
return (event) => {
|
|
184
|
-
queue.push(JSON.stringify(event) + '\n');
|
|
185
|
-
while (queue.length > 10000)
|
|
186
|
-
queue.shift();
|
|
187
|
-
if (sock && sock.writable) {
|
|
188
|
-
flush();
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
// Dead socket (server restart) — drop it so open() reconnects.
|
|
192
|
-
if (sock) {
|
|
193
|
-
try {
|
|
194
|
-
sock.destroy();
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
/* ignore */
|
|
198
|
-
}
|
|
199
|
-
sock = null;
|
|
200
|
-
}
|
|
201
|
-
open();
|
|
202
|
-
};
|
|
203
|
-
}
|
|
1
|
+
"use strict";var y=Object.defineProperty;var f=(e,n)=>y(e,"name",{value:n,configurable:!0});var b=Object.defineProperty,l=f((e,n)=>b(e,"name",{value:n,configurable:!0}),"f"),_=Object.defineProperty,c=l((e,n)=>_(e,"name",{value:n,configurable:!0}),"i");import{connect as M}from"node:net";import{createSocket as C}from"node:dgram";import{appendFileSync as g,renameSync as d,statSync as A,unlinkSync as L}from"node:fs";const P=(()=>{const e=Number(process.env.LENSMCP_EVENT_MAX_BYTES);return Number.isFinite(e)&&e>0?e:50*1024*1024})(),h=(()=>{const e=Number(process.env.LENSMCP_EVENT_MAX_FILES);return Number.isFinite(e)&&e>=1&&e<=20?Math.floor(e):5})(),w=1024*1024;let p=0;function E(e,n){if(p+=n,!(p<w)){p=0;try{if(A(e).size<=P)return}catch{return}try{L(`${e}.${h}`)}catch{}for(let t=h-1;t>=1;t--)try{d(`${e}.${t}`,`${e}.${t+1}`)}catch{}try{d(e,`${e}.1`)}catch{}}}f(E,"d"),l(E,"C"),c(E,"rotateIfLarge");const S=(()=>{const e=process.env.LENSMCP_EVENT_MAX_EPS;if(e==="off")return 1/0;const n=Number(e);return e!==void 0&&e!==""&&Number.isFinite(n)?n<=0?1/0:n:5})(),m=Math.max(30,S===1/0?0:S*4),a=new Map;function v(e){if(S===1/0)return 0;const n=Date.now();let t=a.get(e);if(!t){if(a.size>=2048){for(const[r,s]of a)n-s.lastSeenAt>6e4&&a.delete(r);a.size>=2048&&a.clear()}t={tokens:m,lastRefillAt:n,suppressed:0,lastSeenAt:n},a.set(e,t)}if(t.lastSeenAt=n,t.tokens=Math.min(m,t.tokens+(n-t.lastRefillAt)/1e3*S),t.lastRefillAt=n,t.tokens>=1){t.tokens-=1;const r=t.suppressed;return t.suppressed=0,r}return t.suppressed+=1,-1}f(v,"v"),l(v,"T"),c(v,"floodAdmit");let o;export function resolveEnvSink(){if(o!==void 0)return o;const e=process.env.LENSMCP_EVENT_FILE;if(e)return o=c(r=>{try{const s=v(r.fingerprint);if(s<0)return;const u=JSON.stringify(s>0?{...r,raw:{...r.raw??{},floodSuppressed:s}}:r)+`
|
|
2
|
+
`;E(e,u.length),g(e,u)}catch{}},"cachedEnvSink"),o;const n=process.env.LENSMCP_UDS;if(n)return o=udsEnvSink(n),o;const t=process.env.LENSMCP_IPC_SOCKET;if(t){const[r,s]=t.split(":"),u=Number(s);if(r&&Number.isFinite(u)){const i=C("udp4");return i.unref(),o=c(N=>{try{const k=Buffer.from(JSON.stringify(N));i.send(k,u,r,()=>{})}catch{}},"cachedEnvSink"),o}}return o=null,null}f(resolveEnvSink,"resolveEnvSink"),l(resolveEnvSink,"resolveEnvSink"),c(resolveEnvSink,"resolveEnvSink");export function resetEnvSinkCache(){o=void 0}f(resetEnvSinkCache,"resetEnvSinkCache"),l(resetEnvSinkCache,"resetEnvSinkCache"),c(resetEnvSinkCache,"resetEnvSinkCache");export function udsEnvSink(e){const n=[];let t=null,r=!1;const s=c(()=>{if(!(!t||!t.writable))for(;n.length>0;)t.write(n.shift())},"flush"),u=c(()=>{if(r||t)return;r=!0;const i=M(e);i.on("connect",()=>{r=!1,t=i,s()}),i.on("drain",s),i.on("error",()=>{r=!1,t=null}),i.on("close",()=>{t=null}),i.unref()},"open");return u(),i=>{for(n.push(JSON.stringify(i)+`
|
|
3
|
+
`);n.length>1e4;)n.shift();if(t&&t.writable){s();return}if(t){try{t.destroy()}catch{}t=null}u()}}f(udsEnvSink,"udsEnvSink"),l(udsEnvSink,"udsEnvSink"),c(udsEnvSink,"udsEnvSink");
|
package/lib/event-mappers.js
CHANGED
|
@@ -1,299 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
/**
|
|
5
|
-
* Translate a single client-runtime envelope into the normalised
|
|
6
|
-
* `BaseEvent` consumed by the LensMCP event bus. The bridge publishes
|
|
7
|
-
* these; reducers in `@lensmcp/<app>` materialise them into
|
|
8
|
-
* `runtime://browser/*` resources.
|
|
9
|
-
*
|
|
10
|
-
* ── Substance is the point ──────────────────────────────────────────────────
|
|
11
|
-
* This mapper decides what a reader of `runtime://browser/errors` can actually
|
|
12
|
-
* learn. Measured on foodguard (2026-07-30) an entry looked like:
|
|
13
|
-
*
|
|
14
|
-
* { id, timestamp, severity: 'error', source: 'client-runtime',
|
|
15
|
-
* fingerprint: 'runtime:52331dbd', tabId: 'tab-gal0r3bs',
|
|
16
|
-
* raw: { kind: 'error', at: 1785450203325, tabId: 'tab-gal0r3bs' } }
|
|
17
|
-
*
|
|
18
|
-
* — no message, no stack, no URL, no status. The resource could report that
|
|
19
|
-
* two errors happened and nothing whatsoever about what they were. A signal
|
|
20
|
-
* that is correct but undiagnosable is only half a signal, so every branch
|
|
21
|
-
* below now carries identity: a title that names the failure, a bounded
|
|
22
|
-
* message/stack, and for a request its method, URL and STATUS.
|
|
23
|
-
*
|
|
24
|
-
* Every string that reaches `title`/`message` is redacted and bounded first
|
|
25
|
-
* (`./redact.js`) — enrichment is exactly what creates the leak risk, so the
|
|
26
|
-
* two land together. See that module for what is and is not scrubbed.
|
|
27
|
-
*/
|
|
28
|
-
export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
|
|
29
|
-
// Per-tab identity stamped by the client runtime — keys browser://tabs.
|
|
30
|
-
const tab = env.tabId ? { tabId: env.tabId } : {};
|
|
31
|
-
switch (env.kind) {
|
|
32
|
-
case 'error': {
|
|
33
|
-
// A cross-origin script yields the opaque "Script error." with no stack
|
|
34
|
-
// and no file. Saying so is more use than a bare message, because the
|
|
35
|
-
// fix is a `crossorigin` attribute rather than anything in the app.
|
|
36
|
-
const opaque = !env.stack && !env.source && (!env.message || env.message === 'Script error.');
|
|
37
|
-
const name = env.name && env.name !== 'Error' ? `${env.name}: ` : '';
|
|
38
|
-
// Opaque FIRST: the browser's own "Script error." is a message, but a
|
|
39
|
-
// useless one — naming the cause points at the actual fix (a
|
|
40
|
-
// `crossorigin` attribute on the script tag), which the raw string does not.
|
|
41
|
-
const title = opaque
|
|
42
|
-
? 'Uncaught cross-origin script error (no detail available — add crossorigin to the script tag)'
|
|
43
|
-
: env.message
|
|
44
|
-
? `${name}${env.message}`
|
|
45
|
-
: `Uncaught ${env.name ?? 'error'}`;
|
|
46
|
-
return {
|
|
47
|
-
id: ulid(),
|
|
48
|
-
sessionId,
|
|
49
|
-
timestamp: env.at,
|
|
50
|
-
source: 'client-runtime',
|
|
51
|
-
category: 'runtime',
|
|
52
|
-
severity: 'error',
|
|
53
|
-
context: { sessionId, ...tab },
|
|
54
|
-
fingerprint: fingerprint({
|
|
55
|
-
kind: 'runtime',
|
|
56
|
-
identity: `error:${normaliseStackTopFrame(env.stack)}`,
|
|
57
|
-
file: env.source,
|
|
58
|
-
// The message is the discriminator between two errors thrown from
|
|
59
|
-
// the same frame. Without it every anonymous error collapsed into
|
|
60
|
-
// one fingerprint, so a count of them meant nothing.
|
|
61
|
-
detail: env.message ?? env.name,
|
|
62
|
-
}),
|
|
63
|
-
title: safeTitle(title),
|
|
64
|
-
message: safeText(env.stack ?? env.message),
|
|
65
|
-
location: env.source
|
|
66
|
-
? { file: redactUrl(env.source), line: env.lineno, column: env.colno }
|
|
67
|
-
: undefined,
|
|
68
|
-
// `raw` is read too — redact it as thoroughly as the rendered fields,
|
|
69
|
-
// or the credential just moves one key deeper.
|
|
70
|
-
raw: {
|
|
71
|
-
...env,
|
|
72
|
-
...(env.message ? { message: redactText(env.message) } : {}),
|
|
73
|
-
...(env.stack ? { stack: safeText(env.stack) } : {}),
|
|
74
|
-
...(env.source ? { source: redactUrl(env.source) } : {}),
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
case 'resource-error': {
|
|
79
|
-
const url = env.url ? redactUrl(env.url) : undefined;
|
|
80
|
-
return {
|
|
81
|
-
id: ulid(),
|
|
82
|
-
sessionId,
|
|
83
|
-
timestamp: env.at,
|
|
84
|
-
source: 'client-runtime',
|
|
85
|
-
category: 'runtime',
|
|
86
|
-
severity: 'error',
|
|
87
|
-
context: { sessionId, ...tab },
|
|
88
|
-
fingerprint: fingerprint({
|
|
89
|
-
kind: 'runtime',
|
|
90
|
-
// Path-normalised so a cache-busted asset does not mint a new
|
|
91
|
-
// fingerprint on every reload, while two different failing assets
|
|
92
|
-
// stay distinct.
|
|
93
|
-
identity: `resource-error:${env.tagName}:${normaliseUrl(env.url ?? '')}`,
|
|
94
|
-
}),
|
|
95
|
-
title: safeTitle(`Failed to load <${env.tagName}>${url ? ` ${url}` : ''}`),
|
|
96
|
-
...(url ? { relatedUrls: [url] } : {}),
|
|
97
|
-
raw: { ...env, ...(url ? { url } : {}) },
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
case 'unhandledrejection': {
|
|
101
|
-
const name = env.name && env.name !== 'Error' ? `${env.name}: ` : '';
|
|
102
|
-
const summary = env.reason.split('\n')[0] ?? env.reason;
|
|
103
|
-
return {
|
|
104
|
-
id: ulid(),
|
|
105
|
-
sessionId,
|
|
106
|
-
timestamp: env.at,
|
|
107
|
-
source: 'client-runtime',
|
|
108
|
-
category: 'runtime',
|
|
109
|
-
severity: 'error',
|
|
110
|
-
context: { sessionId, ...tab },
|
|
111
|
-
fingerprint: fingerprint({
|
|
112
|
-
kind: 'runtime',
|
|
113
|
-
identity: `unhandledrejection:${env.reason.slice(0, 80)}`,
|
|
114
|
-
}),
|
|
115
|
-
// The reason belongs in the title. "Unhandled promise rejection" on
|
|
116
|
-
// its own is a category, not a finding — a reader had to open `raw` to
|
|
117
|
-
// learn anything, and the status-line summary showed only the category.
|
|
118
|
-
title: safeTitle(`Unhandled promise rejection: ${name}${summary}`),
|
|
119
|
-
message: safeText(env.stack ?? env.reason),
|
|
120
|
-
raw: {
|
|
121
|
-
...env,
|
|
122
|
-
reason: redactText(env.reason),
|
|
123
|
-
...(env.stack ? { stack: safeText(env.stack) } : {}),
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
case 'console': {
|
|
128
|
-
const severity = env.level === 'error' ? 'error' : env.level === 'warn' ? 'warning' : 'info';
|
|
129
|
-
// Redact the args FIRST, then join. Joining first and scrubbing the
|
|
130
|
-
// result only catches credential SHAPES (a JWT, a `Bearer …`) — it
|
|
131
|
-
// cannot know that a key literally named `apiKey` holds one, so
|
|
132
|
-
// `console.log({ apiKey })` leaked into `message` while `raw.args` was
|
|
133
|
-
// correctly redacted. Redacting at the structured end catches both.
|
|
134
|
-
const args = redactDeep(env.args);
|
|
135
|
-
const text = args.map(stringify).join(' ');
|
|
136
|
-
return {
|
|
137
|
-
id: ulid(),
|
|
138
|
-
sessionId,
|
|
139
|
-
timestamp: env.at,
|
|
140
|
-
source: 'client-runtime',
|
|
141
|
-
category: 'runtime',
|
|
142
|
-
severity,
|
|
143
|
-
context: { sessionId, ...tab },
|
|
144
|
-
fingerprint: fingerprint({
|
|
145
|
-
kind: 'runtime',
|
|
146
|
-
identity: `console:${env.level}:${text.slice(0, 80)}`,
|
|
147
|
-
}),
|
|
148
|
-
title: `console.${env.level}`,
|
|
149
|
-
message: safeText(text),
|
|
150
|
-
// Console args are the one genuinely unstructured captured payload —
|
|
151
|
-
// `console.log('auth', { token })` is ordinary debugging code.
|
|
152
|
-
raw: { ...env, args },
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
case 'fetch': {
|
|
156
|
-
const failed = env.error !== undefined || (env.status !== undefined && env.status >= 400);
|
|
157
|
-
const url = redactUrl(env.url);
|
|
158
|
-
const status = env.status !== undefined
|
|
159
|
-
? ` → ${env.status}${env.statusText ? ` ${env.statusText}` : ''}`
|
|
160
|
-
: '';
|
|
161
|
-
return {
|
|
162
|
-
id: ulid(),
|
|
163
|
-
sessionId,
|
|
164
|
-
timestamp: env.at,
|
|
165
|
-
source: 'client-runtime',
|
|
166
|
-
category: 'network',
|
|
167
|
-
severity: failed ? 'error' : 'info',
|
|
168
|
-
context: { sessionId, ...tab },
|
|
169
|
-
fingerprint: fingerprint({
|
|
170
|
-
kind: 'network',
|
|
171
|
-
identity: `${env.method}:${normaliseUrl(env.url)}:${env.status ?? 'err'}`,
|
|
172
|
-
}),
|
|
173
|
-
title: safeTitle(`${env.method} ${url}${status}`),
|
|
174
|
-
message: safeText(env.error),
|
|
175
|
-
relatedUrls: [url],
|
|
176
|
-
raw: { ...env, url },
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
case 'route':
|
|
180
|
-
return {
|
|
181
|
-
id: ulid(),
|
|
182
|
-
sessionId,
|
|
183
|
-
timestamp: env.at,
|
|
184
|
-
source: 'client-runtime',
|
|
185
|
-
category: 'runtime',
|
|
186
|
-
severity: 'info',
|
|
187
|
-
context: { sessionId, url: env.url, ...tab },
|
|
188
|
-
fingerprint: fingerprint({ kind: 'runtime', identity: `route:${env.url}` }),
|
|
189
|
-
title: `Route → ${env.url}`,
|
|
190
|
-
raw: env,
|
|
191
|
-
};
|
|
192
|
-
case 'ready':
|
|
193
|
-
return {
|
|
194
|
-
id: ulid(),
|
|
195
|
-
sessionId,
|
|
196
|
-
timestamp: env.at,
|
|
197
|
-
source: 'client-runtime',
|
|
198
|
-
category: 'runtime',
|
|
199
|
-
severity: 'info',
|
|
200
|
-
context: { sessionId, url: env.url, ...tab },
|
|
201
|
-
fingerprint: fingerprint({ kind: 'runtime', identity: 'client-ready' }),
|
|
202
|
-
title: 'Client runtime ready',
|
|
203
|
-
message: env.userAgent,
|
|
204
|
-
raw: env,
|
|
205
|
-
};
|
|
206
|
-
case 'publish': {
|
|
207
|
-
// Pre-formed publish envelope from in-page instrumentation. Trust
|
|
208
|
-
// its source/category (the producer is our own injected code); the
|
|
209
|
-
// reducers gate on those, and the context carries the flow id so
|
|
210
|
-
// React renders correlate with the originating user action.
|
|
211
|
-
const net = networkDetails(env.category, env.raw);
|
|
212
|
-
// A FAILED REQUEST IS AN ERROR, wherever it was published from.
|
|
213
|
-
//
|
|
214
|
-
// `flow-fetch` classified every non-ok response as `warning`, so a 4xx
|
|
215
|
-
// or 5xx never reached `runtime://browser/failed-requests` (the runtime
|
|
216
|
-
// reducer routes only `severity === 'error'` there). On foodguard that
|
|
217
|
-
// silently hid 116 real failures — 98×401, 10×503, 8×502. Normalising
|
|
218
|
-
// here rather than only at the publisher means the invariant holds for
|
|
219
|
-
// any producer, including an app pinned to an older instrumentation
|
|
220
|
-
// build.
|
|
221
|
-
const severity = net && net.status !== undefined && net.status >= 400
|
|
222
|
-
? 'error'
|
|
223
|
-
: (env.severity ?? 'info');
|
|
224
|
-
// The publisher embedded the un-redacted URL in its own title AND its
|
|
225
|
-
// own fingerprint (flow-fetch builds both from the raw URL); swap in the
|
|
226
|
-
// redacted form rather than dropping either. The substitution is
|
|
227
|
-
// deterministic, so the fingerprint stays a stable dedupe key.
|
|
228
|
-
const scrub = (s) => net && net.url !== net.redactedUrl ? s.split(net.url).join(net.redactedUrl) : s;
|
|
229
|
-
const title = scrub(env.title);
|
|
230
|
-
return {
|
|
231
|
-
id: ulid(),
|
|
232
|
-
sessionId,
|
|
233
|
-
timestamp: env.at,
|
|
234
|
-
source: env.source,
|
|
235
|
-
category: env.category,
|
|
236
|
-
severity,
|
|
237
|
-
context: { sessionId, ...tab, ...(env.context ?? {}) },
|
|
238
|
-
fingerprint: env.fingerprint
|
|
239
|
-
? scrub(env.fingerprint)
|
|
240
|
-
: fingerprint({ kind: 'publish', identity: title }),
|
|
241
|
-
title: safeTitle(title),
|
|
242
|
-
message: safeText(env.message),
|
|
243
|
-
...(net ? { relatedUrls: [net.redactedUrl] } : {}),
|
|
244
|
-
raw: net
|
|
245
|
-
? { ...redactDeep(env.raw), url: net.redactedUrl }
|
|
246
|
-
: redactDeep(env.raw),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
// --- helpers ---
|
|
252
|
-
/**
|
|
253
|
-
* Pull `{ url, status }` off a network `publish` envelope's `raw` (the shape
|
|
254
|
-
* `flow-fetch` emits: `fetch-start` / `fetch-done` / `fetch-error`). Returns
|
|
255
|
-
* undefined for anything that is not a network publish carrying a URL, so
|
|
256
|
-
* every other publish passes through untouched.
|
|
257
|
-
*/
|
|
258
|
-
function networkDetails(category, raw) {
|
|
259
|
-
if (category !== 'network' || !raw || typeof raw !== 'object')
|
|
260
|
-
return undefined;
|
|
261
|
-
const r = raw;
|
|
262
|
-
if (typeof r.url !== 'string' || r.url.length === 0)
|
|
263
|
-
return undefined;
|
|
264
|
-
return {
|
|
265
|
-
url: r.url,
|
|
266
|
-
redactedUrl: redactUrl(r.url),
|
|
267
|
-
...(typeof r.status === 'number' ? { status: r.status } : {}),
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
function normaliseStackTopFrame(stack) {
|
|
271
|
-
if (!stack)
|
|
272
|
-
return 'unknown';
|
|
273
|
-
const lines = stack.split('\n').slice(0, 2);
|
|
274
|
-
for (const line of lines) {
|
|
275
|
-
const m = line.match(/at\s+(\S+)/);
|
|
276
|
-
if (m && m[1])
|
|
277
|
-
return m[1];
|
|
278
|
-
}
|
|
279
|
-
return 'unknown';
|
|
280
|
-
}
|
|
281
|
-
function normaliseUrl(url) {
|
|
282
|
-
try {
|
|
283
|
-
const u = new URL(url, 'http://x');
|
|
284
|
-
return `${u.pathname}`;
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
return url;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
function stringify(v) {
|
|
291
|
-
if (typeof v === 'string')
|
|
292
|
-
return v;
|
|
293
|
-
try {
|
|
294
|
-
return JSON.stringify(v);
|
|
295
|
-
}
|
|
296
|
-
catch {
|
|
297
|
-
return String(v);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
1
|
+
"use strict";var h=Object.defineProperty;var c=(t,e)=>h(t,"name",{value:e,configurable:!0});var x=Object.defineProperty,l=c((t,e)=>x(t,"name",{value:e,configurable:!0}),"c"),U=Object.defineProperty,d=l((t,e)=>U(t,"name",{value:e,configurable:!0}),"s");import{fingerprint as o,ulid as a}from"@lensmcp/core";import{redactDeep as f,redactText as I,redactUrl as m,safeText as u,safeTitle as g}from"./redact.js";const b="pending";export function envelopeToEvent(t,e=b){const s=t.tabId?{tabId:t.tabId}:{};switch(t.kind){case"error":{const r=!t.stack&&!t.source&&(!t.message||t.message==="Script error."),i=t.name&&t.name!=="Error"?`${t.name}: `:"",n=r?"Uncaught cross-origin script error (no detail available \u2014 add crossorigin to the script tag)":t.message?`${i}${t.message}`:`Uncaught ${t.name??"error"}`;return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:"error",context:{sessionId:e,...s},fingerprint:o({kind:"runtime",identity:`error:${v(t.stack)}`,file:t.source,detail:t.message??t.name}),title:g(n),message:u(t.stack??t.message),location:t.source?{file:m(t.source),line:t.lineno,column:t.colno}:void 0,raw:{...t,...t.message?{message:I(t.message)}:{},...t.stack?{stack:u(t.stack)}:{},...t.source?{source:m(t.source)}:{}}}}case"resource-error":{const r=t.url?m(t.url):void 0;return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:"error",context:{sessionId:e,...s},fingerprint:o({kind:"runtime",identity:`resource-error:${t.tagName}:${p(t.url??"")}`}),title:g(`Failed to load <${t.tagName}>${r?` ${r}`:""}`),...r?{relatedUrls:[r]}:{},raw:{...t,...r?{url:r}:{}}}}case"unhandledrejection":{const r=t.name&&t.name!=="Error"?`${t.name}: `:"",i=t.reason.split(`
|
|
2
|
+
`)[0]??t.reason;return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:"error",context:{sessionId:e,...s},fingerprint:o({kind:"runtime",identity:`unhandledrejection:${t.reason.slice(0,80)}`}),title:g(`Unhandled promise rejection: ${r}${i}`),message:u(t.stack??t.reason),raw:{...t,reason:I(t.reason),...t.stack?{stack:u(t.stack)}:{}}}}case"console":{const r=t.level==="error"?"error":t.level==="warn"?"warning":"info",i=f(t.args),n=i.map($).join(" ");return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:r,context:{sessionId:e,...s},fingerprint:o({kind:"runtime",identity:`console:${t.level}:${n.slice(0,80)}`}),title:`console.${t.level}`,message:u(n),raw:{...t,args:i}}}case"fetch":{const r=t.error!==void 0||t.status!==void 0&&t.status>=400,i=m(t.url),n=t.status!==void 0?` \u2192 ${t.status}${t.statusText?` ${t.statusText}`:""}`:"";return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"network",severity:r?"error":"info",context:{sessionId:e,...s},fingerprint:o({kind:"network",identity:`${t.method}:${p(t.url)}:${t.status??"err"}`}),title:g(`${t.method} ${i}${n}`),message:u(t.error),relatedUrls:[i],raw:{...t,url:i}}}case"route":return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:"info",context:{sessionId:e,url:t.url,...s},fingerprint:o({kind:"runtime",identity:`route:${t.url}`}),title:`Route \u2192 ${t.url}`,raw:t};case"ready":return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:"info",context:{sessionId:e,url:t.url,...s},fingerprint:o({kind:"runtime",identity:"client-ready"}),title:"Client runtime ready",message:t.userAgent,raw:t};case"gone":return{id:a(),sessionId:e,timestamp:t.at,source:"client-runtime",category:"runtime",severity:"info",context:{sessionId:e,...s},fingerprint:o({kind:"runtime",identity:"client-gone"}),title:"Client runtime gone",raw:t};case"publish":{const r=y(t.category,t.raw),i=r&&r.status!==void 0&&r.status>=400?"error":t.severity??"info",n=d(w=>r&&r.url!==r.redactedUrl?w.split(r.url).join(r.redactedUrl):w,"scrub"),k=n(t.title);return{id:a(),sessionId:e,timestamp:t.at,source:t.source,category:t.category,severity:i,context:{sessionId:e,...s,...t.context??{}},fingerprint:t.fingerprint?n(t.fingerprint):o({kind:"publish",identity:k}),title:g(k),message:u(t.message),...r?{relatedUrls:[r.redactedUrl]}:{},raw:r?{...f(t.raw),url:r.redactedUrl}:f(t.raw)}}}}c(envelopeToEvent,"envelopeToEvent"),l(envelopeToEvent,"envelopeToEvent"),d(envelopeToEvent,"envelopeToEvent");function y(t,e){if(t!=="network"||!e||typeof e!="object")return;const s=e;if(!(typeof s.url!="string"||s.url.length===0))return{url:s.url,redactedUrl:m(s.url),...typeof s.status=="number"?{status:s.status}:{}}}c(y,"k"),l(y,"w"),d(y,"networkDetails");function v(t){if(!t)return"unknown";const e=t.split(`
|
|
3
|
+
`).slice(0,2);for(const s of e){const r=s.match(/at\s+(\S+)/);if(r&&r[1])return r[1]}return"unknown"}c(v,"v"),l(v,"h"),d(v,"normaliseStackTopFrame");function p(t){try{return`${new URL(t,"http://x").pathname}`}catch{return t}}c(p,"p"),l(p,"y"),d(p,"normaliseUrl");function $(t){if(typeof t=="string")return t;try{return JSON.stringify(t)}catch{return String(t)}}c($,"w"),l($,"x"),d($,"stringify");
|
package/lib/redact.d.ts
CHANGED
|
@@ -1,89 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Redaction + bounding
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* So
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
* generator was written, with no reader anywhere in the repo. This is the
|
|
20
|
-
* first code to honour that intent; the lists below are a superset of it.
|
|
21
|
-
*
|
|
22
|
-
* ── Deliberately NOT done here ──────────────────────────────────────────────
|
|
23
|
-
* • Request/response HEADERS are never captured in the first place (the taps
|
|
24
|
-
* read none), which is the strongest possible defence — nothing to redact.
|
|
25
|
-
* Keep it that way; do not add header capture without extending this module.
|
|
26
|
-
* • Request/response BODIES are likewise never captured.
|
|
27
|
-
* • Free text is scrubbed for credential SHAPES (JWT, `Bearer …`), not parsed
|
|
28
|
-
* for arbitrary URLs. A URL embedded in a stack frame keeps its query
|
|
29
|
-
* string. Widening that means running the URL redactor over every message,
|
|
30
|
-
* which is a cost/false-positive trade not worth making blind.
|
|
31
|
-
*/
|
|
32
|
-
/** Replacement for a redacted value. Short, obvious, greppable. */
|
|
33
|
-
export declare const REDACTED = "REDACTED";
|
|
34
|
-
/**
|
|
35
|
-
* Bounds. `raw` is structurally clamped to 32KiB by the bus transform
|
|
36
|
-
* (`createEventClamp`, wired in `@lensmcp/session`), but `title`/`message` are
|
|
37
|
-
* NOT — they are plain strings on `BaseEvent` and reach every reader
|
|
38
|
-
* unclamped. A stack from a deep React tree is routinely tens of KB, so these
|
|
39
|
-
* are the bounds that actually keep an event small.
|
|
40
|
-
*/
|
|
41
|
-
export declare const MAX_TITLE_CHARS = 300;
|
|
42
|
-
export declare const MAX_MESSAGE_CHARS = 4096;
|
|
43
|
-
export declare const MAX_URL_CHARS = 1024;
|
|
44
|
-
/** Is this query-parameter name carrying a credential? */
|
|
45
|
-
export declare function isSecretParam(name: string): boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Strip credentials from a URL, preserving every other byte.
|
|
48
|
-
*
|
|
49
|
-
* Implemented as a regex rewrite rather than `new URL(...)` on purpose:
|
|
50
|
-
* `URL` re-encodes as it round-trips (`+` vs `%20`, dropped default ports,
|
|
51
|
-
* sorted-looking output), which would mean the URL an agent reads is not the
|
|
52
|
-
* URL the app requested. It also throws on relative inputs, which is most of
|
|
53
|
-
* what a SPA fetches. This keeps the string byte-identical apart from the
|
|
54
|
-
* redacted values, and never throws.
|
|
55
|
-
*
|
|
56
|
-
* Redacts, in order: userinfo, then any query OR fragment parameter whose name
|
|
57
|
-
* looks like a credential. The fragment matters — an OAuth implicit-flow
|
|
58
|
-
* callback puts the access token there (`#access_token=…`).
|
|
59
|
-
*/
|
|
60
|
-
export declare function redactUrl(url: string): string;
|
|
61
|
-
/**
|
|
62
|
-
* Scrub credential SHAPES out of free text (an error message, a stack, a
|
|
63
|
-
* console line). Cheap, linear, and tuned for precision over recall — a false
|
|
64
|
-
* positive destroys the diagnostic value this whole change is adding.
|
|
65
|
-
*/
|
|
66
|
-
export declare function redactText(text: string): string;
|
|
67
|
-
/**
|
|
68
|
-
* Bound a string, marking what was removed. Marker text matches
|
|
69
|
-
* `@lensmcp/storage`'s `clampEventRaw` so the two truncations read the same in
|
|
70
|
-
* a transcript.
|
|
71
|
-
*/
|
|
72
|
-
export declare function clip(value: string, max: number): string;
|
|
73
|
-
/** Redact then bound — the standard treatment for any captured free text. */
|
|
74
|
-
export declare function safeText(value: string | undefined, max?: number): string | undefined;
|
|
75
|
-
/** Redact then bound — the standard treatment for a captured title. */
|
|
76
|
-
export declare function safeTitle(value: string): string;
|
|
77
|
-
/**
|
|
78
|
-
* Scrub credential shapes out of every string leaf of an arbitrary value.
|
|
79
|
-
*
|
|
80
|
-
* Needed for `console` arguments, which are the one captured payload that is
|
|
81
|
-
* genuinely unstructured — `console.log('auth', { token })` is ordinary
|
|
82
|
-
* debugging code, and the whole point of this change is that those payloads
|
|
83
|
-
* now reach a transcript. Bounded on depth and array length so a pathological
|
|
84
|
-
* object cannot make the bridge the slow part of a page's logging.
|
|
85
|
-
*
|
|
86
|
-
* Values are returned by reference when nothing changed, so the common case
|
|
87
|
-
* (no credential anywhere) allocates nothing.
|
|
2
|
+
* Redaction + bounding — re-exported from `@lensmcp/core/redact`.
|
|
3
|
+
*
|
|
4
|
+
* The implementation used to live HERE, applied in `envelopeToEvent` (the one
|
|
5
|
+
* function every browser envelope crosses). That worked, and it covered only
|
|
6
|
+
* the browser: the cluster GATEWAY publishes its own events straight onto the
|
|
7
|
+
* same bus without crossing that function, and was found writing
|
|
8
|
+
* `?access_token=sk_live_…` verbatim into `title`, `raw.url` and
|
|
9
|
+
* `raw.steps[].url` of a real `.lensmcp/events.jsonl`.
|
|
10
|
+
*
|
|
11
|
+
* Fixing that needed the same redactor in `@lensmcp/cluster`, which sits at
|
|
12
|
+
* `layer:install` and cannot sensibly depend on this WebSocket-server package.
|
|
13
|
+
* So the module moved DOWN to `layer:core` — reachable by both — and this file
|
|
14
|
+
* stays as the re-export so nothing in the bridge (or any consumer of
|
|
15
|
+
* `@lensmcp/bridge`'s public API) has to change.
|
|
16
|
+
*
|
|
17
|
+
* There is exactly ONE implementation. Do not add logic here; edit
|
|
18
|
+
* `libs/core/src/lib/redact.ts`.
|
|
88
19
|
*/
|
|
89
|
-
export
|
|
20
|
+
export { clip, isSecretParam, MAX_MESSAGE_CHARS, MAX_TITLE_CHARS, MAX_URL_CHARS, redactDeep, redactText, redactUrl, redactUrlParams, REDACTED, safeText, safeTitle, } from '@lensmcp/core/redact';
|
package/lib/redact.js
CHANGED
|
@@ -1,215 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Redaction + bounding for browser-originated event text.
|
|
3
|
-
*
|
|
4
|
-
* ── Why this exists ─────────────────────────────────────────────────────────
|
|
5
|
-
* Until now the browser taps recorded almost nothing about an error, so there
|
|
6
|
-
* was nothing to leak. Enriching them (message, stack, method/URL/status)
|
|
7
|
-
* changes that: a request URL routinely carries a credential in its query
|
|
8
|
-
* string (`?access_token=…`, `?code=…` on an OAuth callback, `?key=…` on a
|
|
9
|
-
* Google API), and an error message can quote a JWT or an `Authorization`
|
|
10
|
-
* header. Those values then land in `runtime://browser/*`, which an agent
|
|
11
|
-
* reads and may quote into a transcript.
|
|
12
|
-
*
|
|
13
|
-
* So enrichment and redaction ship together, in the same place. This module is
|
|
14
|
-
* applied in `envelopeToEvent` — the ONE function every browser envelope
|
|
15
|
-
* crosses on its way to the bus — rather than at each tap, so a tap cannot
|
|
16
|
-
* forget it and a hand-rolled client cannot bypass it.
|
|
17
|
-
*
|
|
18
|
-
* `nx.json` has carried a `redaction: { headers, paths }` block since the init
|
|
19
|
-
* generator was written, with no reader anywhere in the repo. This is the
|
|
20
|
-
* first code to honour that intent; the lists below are a superset of it.
|
|
21
|
-
*
|
|
22
|
-
* ── Deliberately NOT done here ──────────────────────────────────────────────
|
|
23
|
-
* • Request/response HEADERS are never captured in the first place (the taps
|
|
24
|
-
* read none), which is the strongest possible defence — nothing to redact.
|
|
25
|
-
* Keep it that way; do not add header capture without extending this module.
|
|
26
|
-
* • Request/response BODIES are likewise never captured.
|
|
27
|
-
* • Free text is scrubbed for credential SHAPES (JWT, `Bearer …`), not parsed
|
|
28
|
-
* for arbitrary URLs. A URL embedded in a stack frame keeps its query
|
|
29
|
-
* string. Widening that means running the URL redactor over every message,
|
|
30
|
-
* which is a cost/false-positive trade not worth making blind.
|
|
31
|
-
*/
|
|
32
|
-
/** Replacement for a redacted value. Short, obvious, greppable. */
|
|
33
|
-
export const REDACTED = 'REDACTED';
|
|
34
|
-
/**
|
|
35
|
-
* Bounds. `raw` is structurally clamped to 32KiB by the bus transform
|
|
36
|
-
* (`createEventClamp`, wired in `@lensmcp/session`), but `title`/`message` are
|
|
37
|
-
* NOT — they are plain strings on `BaseEvent` and reach every reader
|
|
38
|
-
* unclamped. A stack from a deep React tree is routinely tens of KB, so these
|
|
39
|
-
* are the bounds that actually keep an event small.
|
|
40
|
-
*/
|
|
41
|
-
export const MAX_TITLE_CHARS = 300;
|
|
42
|
-
export const MAX_MESSAGE_CHARS = 4096;
|
|
43
|
-
export const MAX_URL_CHARS = 1024;
|
|
44
|
-
/**
|
|
45
|
-
* Substrings that make a query-parameter name a credential regardless of how
|
|
46
|
-
* it is spelled or delimited. These are safe as substrings — no ordinary
|
|
47
|
-
* parameter name contains them innocently.
|
|
48
|
-
*/
|
|
49
|
-
const SECRET_SUBSTRINGS = [
|
|
50
|
-
'token',
|
|
51
|
-
'secret',
|
|
52
|
-
'password',
|
|
53
|
-
'passwd',
|
|
54
|
-
'apikey',
|
|
55
|
-
'credential',
|
|
56
|
-
'assertion',
|
|
57
|
-
'signature',
|
|
58
|
-
];
|
|
59
|
-
/**
|
|
60
|
-
* Whole delimiter-separated words that make a parameter name a credential.
|
|
61
|
-
* Matched per-word (splitting on `-`, `_`, `.`) rather than as substrings,
|
|
62
|
-
* because the short ones collide: `auth` is a substring of `author`, `key` of
|
|
63
|
-
* `keywords`, `sig` of `sign_up_date`. Word matching keeps `?author=jane` and
|
|
64
|
-
* `?keywords=x` intact while still catching `?api_key=`/`?x-auth=`.
|
|
65
|
-
*/
|
|
66
|
-
const SECRET_WORDS = new Set([
|
|
67
|
-
'key',
|
|
68
|
-
'auth',
|
|
69
|
-
'authorization',
|
|
70
|
-
'jwt',
|
|
71
|
-
'code',
|
|
72
|
-
'sig',
|
|
73
|
-
'pwd',
|
|
74
|
-
'pass',
|
|
75
|
-
'session',
|
|
76
|
-
'sid',
|
|
77
|
-
'bearer',
|
|
78
|
-
'otp',
|
|
79
|
-
// The `headers` half of the `nx.json` redaction contract. Relevant to
|
|
80
|
-
// `redactDeep` (a headers object logged to console) as much as to a query
|
|
81
|
-
// string.
|
|
82
|
-
'cookie',
|
|
83
|
-
'set-cookie',
|
|
84
|
-
]);
|
|
85
|
-
/** Is this query-parameter name carrying a credential? */
|
|
86
|
-
export function isSecretParam(name) {
|
|
87
|
-
const n = name.toLowerCase();
|
|
88
|
-
if (SECRET_SUBSTRINGS.some((s) => n.includes(s)))
|
|
89
|
-
return true;
|
|
90
|
-
for (const word of n.split(/[-_.]+/)) {
|
|
91
|
-
if (word && SECRET_WORDS.has(word))
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* `scheme://user:pass@host` — credentials in the authority component.
|
|
98
|
-
* Anchored at the scheme so it cannot match a `@` inside a path or query.
|
|
99
|
-
*/
|
|
100
|
-
const USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@\s]*@/i;
|
|
101
|
-
/**
|
|
102
|
-
* One `?name=value` / `&name=value` / `#name=value` pair. Values are matched
|
|
103
|
-
* lazily up to the next delimiter, so the walk is linear and cannot backtrack
|
|
104
|
-
* catastrophically on a long URL.
|
|
105
|
-
*/
|
|
106
|
-
const PARAM_RE = /([?&#])([^=&#\s]+)=([^&#\s]*)/g;
|
|
107
|
-
/**
|
|
108
|
-
* Strip credentials from a URL, preserving every other byte.
|
|
109
|
-
*
|
|
110
|
-
* Implemented as a regex rewrite rather than `new URL(...)` on purpose:
|
|
111
|
-
* `URL` re-encodes as it round-trips (`+` vs `%20`, dropped default ports,
|
|
112
|
-
* sorted-looking output), which would mean the URL an agent reads is not the
|
|
113
|
-
* URL the app requested. It also throws on relative inputs, which is most of
|
|
114
|
-
* what a SPA fetches. This keeps the string byte-identical apart from the
|
|
115
|
-
* redacted values, and never throws.
|
|
116
|
-
*
|
|
117
|
-
* Redacts, in order: userinfo, then any query OR fragment parameter whose name
|
|
118
|
-
* looks like a credential. The fragment matters — an OAuth implicit-flow
|
|
119
|
-
* callback puts the access token there (`#access_token=…`).
|
|
120
|
-
*/
|
|
121
|
-
export function redactUrl(url) {
|
|
122
|
-
if (!url)
|
|
123
|
-
return url;
|
|
124
|
-
const withoutUserinfo = url.replace(USERINFO_RE, `$1${REDACTED}@`);
|
|
125
|
-
const withoutSecrets = withoutUserinfo.replace(PARAM_RE, (match, delim, name) => isSecretParam(name) ? `${delim}${name}=${REDACTED}` : match);
|
|
126
|
-
return clip(withoutSecrets, MAX_URL_CHARS);
|
|
127
|
-
}
|
|
128
|
-
/** A JSON Web Token: three base64url segments, the first starting `eyJ` (`{"`). */
|
|
129
|
-
const JWT_RE = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g;
|
|
130
|
-
/** `Bearer <token>` / `Basic <creds>`, however it was quoted or spaced. */
|
|
131
|
-
const SCHEME_CREDENTIAL_RE = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
132
|
-
/**
|
|
133
|
-
* A credential-bearing header quoted into free text — `authorization: xyz`,
|
|
134
|
-
* `"x-api-key": "xyz"`, `cookie=xyz`. Only the VALUE is replaced so the reader
|
|
135
|
-
* still learns which header was involved.
|
|
136
|
-
*/
|
|
137
|
-
const HEADER_CREDENTIAL_RE = /\b(authorization|x-api-key|api-key|cookie|set-cookie|proxy-authorization)\b(\s*[:=]\s*)(["']?)([^\s"',;]{4,})\3/gi;
|
|
138
|
-
/**
|
|
139
|
-
* Scrub credential SHAPES out of free text (an error message, a stack, a
|
|
140
|
-
* console line). Cheap, linear, and tuned for precision over recall — a false
|
|
141
|
-
* positive destroys the diagnostic value this whole change is adding.
|
|
142
|
-
*/
|
|
143
|
-
export function redactText(text) {
|
|
144
|
-
return text
|
|
145
|
-
.replace(HEADER_CREDENTIAL_RE, (_m, header, sep, quote) => `${header}${sep}${quote}${REDACTED}${quote}`)
|
|
146
|
-
.replace(JWT_RE, REDACTED)
|
|
147
|
-
.replace(SCHEME_CREDENTIAL_RE, (_m, scheme) => `${scheme} ${REDACTED}`);
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Bound a string, marking what was removed. Marker text matches
|
|
151
|
-
* `@lensmcp/storage`'s `clampEventRaw` so the two truncations read the same in
|
|
152
|
-
* a transcript.
|
|
153
|
-
*/
|
|
154
|
-
export function clip(value, max) {
|
|
155
|
-
if (value.length <= max)
|
|
156
|
-
return value;
|
|
157
|
-
return `${value.slice(0, max)}…[lensmcp truncated: ${value.length - max} more chars]`;
|
|
158
|
-
}
|
|
159
|
-
/** Redact then bound — the standard treatment for any captured free text. */
|
|
160
|
-
export function safeText(value, max = MAX_MESSAGE_CHARS) {
|
|
161
|
-
if (value === undefined)
|
|
162
|
-
return undefined;
|
|
163
|
-
return clip(redactText(value), max);
|
|
164
|
-
}
|
|
165
|
-
/** Redact then bound — the standard treatment for a captured title. */
|
|
166
|
-
export function safeTitle(value) {
|
|
167
|
-
return clip(redactText(value), MAX_TITLE_CHARS);
|
|
168
|
-
}
|
|
169
|
-
/** Depth ceiling for {@link redactDeep}; doubles as an allocation-free cycle guard. */
|
|
170
|
-
const MAX_REDACT_DEPTH = 6;
|
|
171
|
-
/** Array ceiling for {@link redactDeep}. Beyond this the tail is left as-is for the raw clamp to shed. */
|
|
172
|
-
const MAX_REDACT_ITEMS = 64;
|
|
173
|
-
/**
|
|
174
|
-
* Scrub credential shapes out of every string leaf of an arbitrary value.
|
|
175
|
-
*
|
|
176
|
-
* Needed for `console` arguments, which are the one captured payload that is
|
|
177
|
-
* genuinely unstructured — `console.log('auth', { token })` is ordinary
|
|
178
|
-
* debugging code, and the whole point of this change is that those payloads
|
|
179
|
-
* now reach a transcript. Bounded on depth and array length so a pathological
|
|
180
|
-
* object cannot make the bridge the slow part of a page's logging.
|
|
181
|
-
*
|
|
182
|
-
* Values are returned by reference when nothing changed, so the common case
|
|
183
|
-
* (no credential anywhere) allocates nothing.
|
|
184
|
-
*/
|
|
185
|
-
export function redactDeep(value, depth = 0) {
|
|
186
|
-
if (typeof value === 'string') {
|
|
187
|
-
return redactText(value);
|
|
188
|
-
}
|
|
189
|
-
if (value === null || typeof value !== 'object' || depth >= MAX_REDACT_DEPTH) {
|
|
190
|
-
return value;
|
|
191
|
-
}
|
|
192
|
-
if (Array.isArray(value)) {
|
|
193
|
-
let changed = false;
|
|
194
|
-
const out = value.map((item, i) => {
|
|
195
|
-
if (i >= MAX_REDACT_ITEMS)
|
|
196
|
-
return item;
|
|
197
|
-
const next = redactDeep(item, depth + 1);
|
|
198
|
-
if (next !== item)
|
|
199
|
-
changed = true;
|
|
200
|
-
return next;
|
|
201
|
-
});
|
|
202
|
-
return changed ? out : value;
|
|
203
|
-
}
|
|
204
|
-
let changed = false;
|
|
205
|
-
const out = {};
|
|
206
|
-
for (const [key, item] of Object.entries(value)) {
|
|
207
|
-
// A key named like a credential is redacted whatever its value's shape —
|
|
208
|
-
// this is the `paths` half of the `nx.json` redaction contract.
|
|
209
|
-
const next = isSecretParam(key) && item !== undefined && item !== null ? REDACTED : redactDeep(item, depth + 1);
|
|
210
|
-
if (next !== item)
|
|
211
|
-
changed = true;
|
|
212
|
-
out[key] = next;
|
|
213
|
-
}
|
|
214
|
-
return changed ? out : value;
|
|
215
|
-
}
|
|
1
|
+
"use strict";export{clip,isSecretParam,MAX_MESSAGE_CHARS,MAX_TITLE_CHARS,MAX_URL_CHARS,redactDeep,redactText,redactUrl,redactUrlParams,REDACTED,safeText,safeTitle}from"@lensmcp/core/redact";
|
package/lib/server.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export declare function makePublisher(deps: BridgeDeps): (event: BaseEvent) => v
|
|
|
41
41
|
* exercised over a real WebSocket without booting any bundler.
|
|
42
42
|
*/
|
|
43
43
|
export declare function handleClientEnvelope(raw: string, publish: (event: BaseEvent) => void, sessionId?: string): void;
|
|
44
|
+
/** The tab a raw envelope belongs to, or undefined for a malformed/untagged one. */
|
|
45
|
+
export declare function readTabId(raw: string): string | undefined;
|
|
44
46
|
/**
|
|
45
47
|
* Stand up the browser-event bridge: a WebSocket server the injected
|
|
46
48
|
* `@lensmcp/client-runtime` connects to, forwarding every envelope onto
|
package/lib/server.js
CHANGED
|
@@ -1,109 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { DEFAULT_WS_HOST, DEFAULT_WS_PORT } from './constants.js';
|
|
4
|
-
import { resolveEnvSink } from './env-sink.js';
|
|
5
|
-
import { envelopeToEvent } from './event-mappers.js';
|
|
6
|
-
/**
|
|
7
|
-
* Build a `publish` function honouring the bus → sink → env-sink → console
|
|
8
|
-
* precedence. Mirrors the logic the Vite plugin used to inline, lifted here
|
|
9
|
-
* so the standalone sidecar and the plugin share one path.
|
|
10
|
-
*/
|
|
11
|
-
export function makePublisher(deps) {
|
|
12
|
-
let resolvedSink;
|
|
13
|
-
return (event) => {
|
|
14
|
-
if (deps.bus) {
|
|
15
|
-
deps.bus.publish(event);
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
if (deps.sink) {
|
|
19
|
-
deps.sink(event);
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
// Resolve the env sink once, lazily — matches the Nest sink contract.
|
|
23
|
-
if (resolvedSink === undefined)
|
|
24
|
-
resolvedSink = resolveEnvSink();
|
|
25
|
-
if (resolvedSink) {
|
|
26
|
-
resolvedSink(event);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
// Standalone fallback so the bridge is debuggable without any transport.
|
|
30
|
-
if (event.severity === 'error' || event.severity === 'fatal') {
|
|
31
|
-
console.debug(`[lensmcp-bridge] ${event.severity}: ${event.title}`);
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Parse one raw WebSocket message from the injected client runtime and
|
|
37
|
-
* forward the resulting event via `publish`. Exported so it can be
|
|
38
|
-
* exercised over a real WebSocket without booting any bundler.
|
|
39
|
-
*/
|
|
40
|
-
export function handleClientEnvelope(raw, publish, sessionId) {
|
|
41
|
-
let env;
|
|
42
|
-
try {
|
|
43
|
-
env = JSON.parse(raw);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return; // ignore malformed
|
|
47
|
-
}
|
|
48
|
-
if (!env || typeof env !== 'object' || !('kind' in env))
|
|
49
|
-
return;
|
|
50
|
-
publish(envelopeToEvent(env, sessionId));
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Stand up the browser-event bridge: a WebSocket server the injected
|
|
54
|
-
* `@lensmcp/client-runtime` connects to, forwarding every envelope onto
|
|
55
|
-
* the event bus (in-process or cross-process). Build-agnostic — the Vite
|
|
56
|
-
* plugin uses it on an ephemeral port; the standalone sidecar uses it on a
|
|
57
|
-
* fixed port for webpack / Next.js / no-build hosts.
|
|
58
|
-
*
|
|
59
|
-
* Resolves once the server is listening, with the actual bound `port`.
|
|
60
|
-
*/
|
|
61
|
-
export async function createBridgeServer(options = {}) {
|
|
62
|
-
const host = options.host ?? DEFAULT_WS_HOST;
|
|
63
|
-
const requestedPort = options.port ?? DEFAULT_WS_PORT;
|
|
64
|
-
const port = requestedPort === 0 ? await findFreePort() : requestedPort;
|
|
65
|
-
const publish = makePublisher(options);
|
|
66
|
-
const wss = new WebSocketServer({ host, port });
|
|
67
|
-
wss.on('connection', (socket) => {
|
|
68
|
-
socket.on('message', (raw) => {
|
|
69
|
-
handleClientEnvelope(raw.toString(), publish, options.sessionId);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
await new Promise((resolve, reject) => {
|
|
73
|
-
const onError = (err) => {
|
|
74
|
-
wss.off('listening', onListening);
|
|
75
|
-
reject(err);
|
|
76
|
-
};
|
|
77
|
-
const onListening = () => {
|
|
78
|
-
wss.off('error', onError);
|
|
79
|
-
resolve();
|
|
80
|
-
};
|
|
81
|
-
wss.once('error', onError);
|
|
82
|
-
wss.once('listening', onListening);
|
|
83
|
-
});
|
|
84
|
-
return {
|
|
85
|
-
port,
|
|
86
|
-
host,
|
|
87
|
-
close: () => new Promise((resolve) => {
|
|
88
|
-
wss.close(() => resolve());
|
|
89
|
-
}),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
/** Allocate an ephemeral free port (used when `port: 0` is requested). */
|
|
93
|
-
export function findFreePort() {
|
|
94
|
-
return new Promise((resolve, reject) => {
|
|
95
|
-
const s = createNetServer();
|
|
96
|
-
s.unref();
|
|
97
|
-
s.on('error', reject);
|
|
98
|
-
s.listen(0, () => {
|
|
99
|
-
const addr = s.address();
|
|
100
|
-
if (!addr) {
|
|
101
|
-
s.close();
|
|
102
|
-
reject(new Error('Could not allocate free port'));
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const freePort = addr.port;
|
|
106
|
-
s.close(() => resolve(freePort));
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
}
|
|
1
|
+
"use strict";var v=Object.defineProperty;var i=(e,r)=>v(e,"name",{value:r,configurable:!0});var b=Object.defineProperty,c=i((e,r)=>b(e,"name",{value:r,configurable:!0}),"i"),m=Object.defineProperty,t=c((e,r)=>m(e,"name",{value:r,configurable:!0}),"o");import{createServer as g}from"node:net";import{WebSocketServer as S}from"ws";import{DEFAULT_WS_HOST as h,DEFAULT_WS_PORT as y}from"./constants.js";import{resolveEnvSink as P}from"./env-sink.js";import{envelopeToEvent as k}from"./event-mappers.js";export function makePublisher(e){let r;return o=>{if(e.bus){e.bus.publish(o);return}if(e.sink){e.sink(o);return}if(r===void 0&&(r=P()),r){r(o);return}(o.severity==="error"||o.severity==="fatal")&&console.debug(`[lensmcp-bridge] ${o.severity}: ${o.title}`)}}i(makePublisher,"makePublisher"),c(makePublisher,"makePublisher"),t(makePublisher,"makePublisher");export function handleClientEnvelope(e,r,o){let n;try{n=JSON.parse(e)}catch{return}!n||typeof n!="object"||!("kind"in n)||r(k(n,o))}i(handleClientEnvelope,"handleClientEnvelope"),c(handleClientEnvelope,"handleClientEnvelope"),t(handleClientEnvelope,"handleClientEnvelope");export function readTabId(e){try{const r=JSON.parse(e);return typeof r?.tabId=="string"&&r.tabId?r.tabId:void 0}catch{return}}i(readTabId,"readTabId"),c(readTabId,"readTabId"),t(readTabId,"readTabId");export async function createBridgeServer(e={}){const r=e.host??h,o=e.port??y,n=o===0?await findFreePort():o,f=makePublisher(e),s=new S({host:r,port:n});return s.on("connection",a=>{let l;a.on("message",u=>{const d=u.toString();l??=readTabId(d),handleClientEnvelope(d,f,e.sessionId)}),a.on("close",()=>{l&&handleClientEnvelope(JSON.stringify({kind:"gone",at:Date.now(),tabId:l}),f,e.sessionId)})}),await new Promise((a,l)=>{const u=t(p=>{s.off("listening",d),l(p)},"onError"),d=t(()=>{s.off("error",u),a()},"onListening");s.once("error",u),s.once("listening",d)}),{port:n,host:r,close:t(()=>new Promise(a=>{s.close(()=>a())}),"close")}}i(createBridgeServer,"createBridgeServer"),c(createBridgeServer,"createBridgeServer"),t(createBridgeServer,"createBridgeServer");export function findFreePort(){return new Promise((e,r)=>{const o=g();o.unref(),o.on("error",r),o.listen(0,()=>{const n=o.address();if(!n){o.close(),r(new Error("Could not allocate free port"));return}const f=n.port;o.close(()=>e(f))})})}i(findFreePort,"findFreePort"),c(findFreePort,"findFreePort"),t(findFreePort,"findFreePort");
|
package/lib/types.d.ts
CHANGED
|
@@ -79,6 +79,24 @@ export type ClientEnvelope = (
|
|
|
79
79
|
at: number;
|
|
80
80
|
userAgent: string;
|
|
81
81
|
url: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The tab's transport closed — its document is GONE.
|
|
85
|
+
*
|
|
86
|
+
* Synthesised by the BRIDGE on socket close, never sent by the page: the
|
|
87
|
+
* failure this exists for is exactly the one a page cannot report. `ready`
|
|
88
|
+
* comes from the injected client runtime, so a tab whose entry script failed
|
|
89
|
+
* to load can never announce a document boundary — and under `ready`-only
|
|
90
|
+
* retraction its error was immortal by construction.
|
|
91
|
+
*
|
|
92
|
+
* Measured on foodguard (2026-08-01): 38 retained browser errors across 26
|
|
93
|
+
* DISTINCT tabs, none with more than two — 26 dead one-shot documents, 15 of
|
|
94
|
+
* them the client script itself failing to load. `agent://current-status`
|
|
95
|
+
* reported `failing` on all of it while the app served cleanly.
|
|
96
|
+
*/
|
|
97
|
+
| {
|
|
98
|
+
kind: 'gone';
|
|
99
|
+
at: number;
|
|
82
100
|
} | {
|
|
83
101
|
kind: 'publish';
|
|
84
102
|
at: number;
|
package/lib/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
"use strict";export{};
|
package/main.js
CHANGED
|
@@ -1,47 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Standalone bridge sidecar — the build-agnostic entry.
|
|
3
|
-
*
|
|
4
|
-
* Run directly (`node bridge.js`) or via `lensmcp bridge`. Stands up the
|
|
5
|
-
* WebSocket the injected `@lensmcp/client-runtime` connects to, on a FIXED
|
|
6
|
-
* port (so a no-build / webpack / Next.js host can point at a known URL),
|
|
7
|
-
* and forwards every browser envelope onto the cross-process event sink
|
|
8
|
-
* the MCP server tails.
|
|
9
|
-
*
|
|
10
|
-
* Env:
|
|
11
|
-
* LENSMCP_WS_HOST bind host (default 127.0.0.1)
|
|
12
|
-
* LENSMCP_WS_PORT bind port (default 5747; `0` = ephemeral)
|
|
13
|
-
* LENSMCP_SESSION_ID session id stamped onto every event (optional)
|
|
14
|
-
* LENSMCP_EVENT_FILE JSONL sink the MCP server ingests (or LENSMCP_UDS /
|
|
15
|
-
* LENSMCP_IPC_SOCKET) — resolved by the env sink.
|
|
16
|
-
*/
|
|
17
|
-
import { DEFAULT_WS_HOST, DEFAULT_WS_PORT, ENV_SESSION_ID, ENV_WS_HOST, ENV_WS_PORT } from './lib/constants.js';
|
|
18
|
-
import { createBridgeServer } from './lib/server.js';
|
|
19
|
-
async function main() {
|
|
20
|
-
const host = process.env[ENV_WS_HOST] ?? DEFAULT_WS_HOST;
|
|
21
|
-
const portRaw = process.env[ENV_WS_PORT];
|
|
22
|
-
const port = portRaw !== undefined && portRaw !== '' ? Number(portRaw) : DEFAULT_WS_PORT;
|
|
23
|
-
if (!Number.isFinite(port) || port < 0) {
|
|
24
|
-
console.error(`[lensmcp-bridge] invalid ${ENV_WS_PORT}=${portRaw}`);
|
|
25
|
-
process.exit(2);
|
|
26
|
-
}
|
|
27
|
-
const sessionId = process.env[ENV_SESSION_ID];
|
|
28
|
-
let server;
|
|
29
|
-
try {
|
|
30
|
-
server = await createBridgeServer({ host, port, sessionId });
|
|
31
|
-
}
|
|
32
|
-
catch (err) {
|
|
33
|
-
console.error(`[lensmcp-bridge] failed to listen on ws://${host}:${port} — ${err.message}`);
|
|
34
|
-
process.exit(1);
|
|
35
|
-
}
|
|
36
|
-
console.log(`[lensmcp-bridge] listening on ws://${server.host}:${server.port}`);
|
|
37
|
-
let closing = false;
|
|
38
|
-
const shutdown = () => {
|
|
39
|
-
if (closing)
|
|
40
|
-
return;
|
|
41
|
-
closing = true;
|
|
42
|
-
void server.close().then(() => process.exit(0));
|
|
43
|
-
};
|
|
44
|
-
process.on('SIGINT', shutdown);
|
|
45
|
-
process.on('SIGTERM', shutdown);
|
|
46
|
-
}
|
|
47
|
-
void main();
|
|
1
|
+
"use strict";var d=Object.defineProperty;var n=(s,e)=>d(s,"name",{value:e,configurable:!0});var v=Object.defineProperty,a=n((s,e)=>v(s,"name",{value:e,configurable:!0}),"n"),g=Object.defineProperty,l=a((s,e)=>g(s,"name",{value:e,configurable:!0}),"n");import{DEFAULT_WS_HOST as u,DEFAULT_WS_PORT as _,ENV_SESSION_ID as b,ENV_WS_HOST as T,ENV_WS_PORT as p}from"./lib/constants.js";import{createBridgeServer as f}from"./lib/server.js";async function t(){const s=process.env[T]??u,e=process.env[p],o=e!==void 0&&e!==""?Number(e):_;(!Number.isFinite(o)||o<0)&&(console.error(`[lensmcp-bridge] invalid ${p}=${e}`),process.exit(2));const m=process.env[b];let r;try{r=await f({host:s,port:o,sessionId:m})}catch(S){console.error(`[lensmcp-bridge] failed to listen on ws://${s}:${o} \u2014 ${S.message}`),process.exit(1)}console.log(`[lensmcp-bridge] listening on ws://${r.host}:${r.port}`);let c=!1;const i=l(()=>{c||(c=!0,r.close().then(()=>process.exit(0)))},"shutdown");process.on("SIGINT",i),process.on("SIGTERM",i)}n(t,"l"),a(t,"g"),l(t,"main"),t();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/bridge",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@lensmcp/core": "1.18.
|
|
23
|
-
"@lensmcp/protocol-types": "1.18.
|
|
22
|
+
"@lensmcp/core": "1.18.7",
|
|
23
|
+
"@lensmcp/protocol-types": "1.18.7",
|
|
24
24
|
"tslib": "^2.3.0",
|
|
25
25
|
"ws": "^8.21.0"
|
|
26
26
|
},
|