@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 CHANGED
@@ -1,5 +1 @@
1
- export { createBridgeServer, makePublisher, handleClientEnvelope, findFreePort, } from './lib/server.js';
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
- import { connect } from 'node:net';
2
- import { createSocket } from 'node:dgram';
3
- import { appendFileSync, renameSync, statSync, unlinkSync } from 'node:fs';
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");
@@ -1,299 +1,3 @@
1
- import { fingerprint, ulid } from '@lensmcp/core';
2
- import { redactDeep, redactText, redactUrl, safeText, safeTitle } from './redact.js';
3
- const SESSION_ID_PLACEHOLDER = 'pending';
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 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 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 declare function redactDeep(value: unknown, depth?: number): unknown;
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
- import { createServer as createNetServer } from 'node:net';
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.4",
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.4",
23
- "@lensmcp/protocol-types": "1.18.4",
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
  },