@lensmcp/client-runtime 1.25.17 → 1.25.21

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/lib/auto.d.ts CHANGED
@@ -5,6 +5,12 @@ export interface AutoInstallOptions {
5
5
  wsToken?: string;
6
6
  /** Reconnect interval after a previously-good connection drops. */
7
7
  reconnectIntervalMs?: number;
8
+ /**
9
+ * Session replay (planning/replay/00-plan.md): the page records itself with rrweb and streams it to the bridge's
10
+ * `POST /replay`. On by default; `false` here, `window.__LENSMCP_REPLAY__ = false` or
11
+ * `<meta name="lensmcp-replay" content="off">` turns it off. The recorder loads as its own chunk (`import()`).
12
+ */
13
+ replay?: boolean;
8
14
  }
9
15
  /** Resolve the bridge WebSocket URL from the runtime environment. */
10
16
  export declare function resolveLensmcpWsUrl(override?: string): string;
@@ -17,3 +23,10 @@ export declare function resolveLensmcpWsToken(override?: string): string | undef
17
23
  * idempotent (the underlying runtime guards against double-install).
18
24
  */
19
25
  export declare function installLensmcp(opts?: AutoInstallOptions): void;
26
+ /** Session replay unless the page or the caller said no. */
27
+ export declare function replayWanted(option: boolean | undefined): boolean;
28
+ /** The bridge's HTTP origin and token from its WebSocket URL; `undefined` for a URL that will not parse. */
29
+ export declare function replayEndpoint(wsUrl: string, wsToken?: string): {
30
+ endpoint: string;
31
+ token?: string;
32
+ } | undefined;
package/lib/auto.js CHANGED
@@ -1 +1 @@
1
- "use strict";var u=Object.defineProperty;var o=(n,e)=>u(n,"name",{value:e,configurable:!0});var m=Object.defineProperty,s=o((n,e)=>m(n,"name",{value:e,configurable:!0}),"o"),_=Object.defineProperty,c=s((n,e)=>_(n,"name",{value:e,configurable:!0}),"o");import{installClientRuntime as f}from"./install.js";const v=5747,g="127.0.0.1";export function resolveLensmcpWsUrl(n){if(n)return n;const e=globalThis,t=e.__LENSMCP_WS_URL__;if(typeof t=="string"&&t.length>0)return t;const a=r("lensmcp-ws");if(a)return a;const l=e.__LENSMCP_WS_PORT__,p=typeof l=="number"||typeof l=="string"?String(l):r("lensmcp-ws-port")??String(v),i=e.__LENSMCP_WS_HOST__;return`ws://${typeof i=="string"&&i.length>0?i:r("lensmcp-ws-host")??(typeof location<"u"&&location.hostname?location.hostname:g)}:${p}`}o(resolveLensmcpWsUrl,"resolveLensmcpWsUrl"),s(resolveLensmcpWsUrl,"resolveLensmcpWsUrl"),c(resolveLensmcpWsUrl,"resolveLensmcpWsUrl");export function resolveLensmcpWsToken(n){if(n)return n;const e=globalThis.__LENSMCP_WS_TOKEN__;return typeof e=="string"&&e.length>0?e:r("lensmcp-ws-token")}o(resolveLensmcpWsToken,"resolveLensmcpWsToken"),s(resolveLensmcpWsToken,"resolveLensmcpWsToken"),c(resolveLensmcpWsToken,"resolveLensmcpWsToken");export function installLensmcp(n={}){if(typeof window>"u")return;const e={wsUrl:resolveLensmcpWsUrl(n.wsUrl)},t=resolveLensmcpWsToken(n.wsToken);t!==void 0&&(e.wsToken=t),n.reconnectIntervalMs!==void 0&&(e.reconnectIntervalMs=n.reconnectIntervalMs),f(e)}o(installLensmcp,"installLensmcp"),s(installLensmcp,"installLensmcp"),c(installLensmcp,"installLensmcp");function r(n){if(!(typeof document>"u"))try{const e=document.querySelector(`meta[name="${n}"]`)?.getAttribute("content");return e&&e.length>0?e:void 0}catch{return}}o(r,"r"),s(r,"r"),c(r,"readMeta");
1
+ "use strict";var _=Object.defineProperty;var r=(t,e)=>_(t,"name",{value:e,configurable:!0});var h=Object.defineProperty,s=r((t,e)=>h(t,"name",{value:e,configurable:!0}),"r");import{installClientRuntime as g}from"./install.js";const y=5747,w="127.0.0.1";export function resolveLensmcpWsUrl(t){if(t)return t;const e=globalThis,n=e.__LENSMCP_WS_URL__;if(typeof n=="string"&&n.length>0)return n;const o=i("lensmcp-ws");if(o)return o;const l=e.__LENSMCP_WS_PORT__,a=typeof l=="number"||typeof l=="string"?String(l):i("lensmcp-ws-port")??String(y),c=e.__LENSMCP_WS_HOST__;return`ws://${typeof c=="string"&&c.length>0?c:i("lensmcp-ws-host")??(typeof location<"u"&&location.hostname?location.hostname:w)}:${a}`}r(resolveLensmcpWsUrl,"resolveLensmcpWsUrl"),s(resolveLensmcpWsUrl,"resolveLensmcpWsUrl");export function resolveLensmcpWsToken(t){if(t)return t;const e=globalThis.__LENSMCP_WS_TOKEN__;return typeof e=="string"&&e.length>0?e:i("lensmcp-ws-token")}r(resolveLensmcpWsToken,"resolveLensmcpWsToken"),s(resolveLensmcpWsToken,"resolveLensmcpWsToken");export function installLensmcp(t={}){if(typeof window>"u")return;const e={wsUrl:resolveLensmcpWsUrl(t.wsUrl)},n=resolveLensmcpWsToken(t.wsToken);n!==void 0&&(e.wsToken=n),t.reconnectIntervalMs!==void 0&&(e.reconnectIntervalMs=t.reconnectIntervalMs);const o=typeof window.fetch=="function"?window.fetch.bind(window):void 0;g(e),replayWanted(t.replay)&&p(e.wsUrl,n,o)}r(installLensmcp,"installLensmcp"),s(installLensmcp,"installLensmcp");export function replayWanted(t){if(t===!1||globalThis.__LENSMCP_REPLAY__===!1)return!1;const e=i("lensmcp-replay")?.toLowerCase();return e!=="off"&&e!=="false"&&e!=="0"}r(replayWanted,"replayWanted"),s(replayWanted,"replayWanted");function p(t,e,n){const o=replayEndpoint(t,e);if(!o)return;const{endpoint:l,token:a}=o,c=n;import("../replay.js").then(({startReplay:f})=>f({endpoint:l,...a?{token:a}:{},tabId:u(),...c?{env:{fetch:s((m,d)=>c(m,d),"fetch")}}:{}})).catch(()=>{})}r(p,"m"),s(p,"startNoBuildReplay");export function replayEndpoint(t,e){try{const n=new URL(t),o=e??n.searchParams.get("token")??void 0;return{endpoint:`${n.protocol==="wss:"?"https:":"http:"}//${n.host}`,...o?{token:o}:{}}}catch{return}}r(replayEndpoint,"replayEndpoint"),s(replayEndpoint,"replayEndpoint");function u(){try{let t=sessionStorage.getItem("lensmcp:tabId");return t||(t=`tab-${Math.random().toString(36).slice(2,10)}`,sessionStorage.setItem("lensmcp:tabId",t)),t}catch{return`tab-${Math.random().toString(36).slice(2,10)}`}}r(u,"g"),s(u,"noBuildTabId");function i(t){if(!(typeof document>"u"))try{const e=document.querySelector(`meta[name="${t}"]`)?.getAttribute("content");return e&&e.length>0?e:void 0}catch{return}}r(i,"i"),s(i,"readMeta");
package/lib/install.js CHANGED
@@ -1 +1 @@
1
- "use strict";var J=Object.defineProperty;var u=(e,r)=>J(e,"name",{value:r,configurable:!0});var P=Object.defineProperty,h=u((e,r)=>P(e,"name",{value:r,configurable:!0}),"u"),M=Object.defineProperty,a=h((e,r)=>M(e,"name",{value:r,configurable:!0}),"i");const _="__LENSMCP_CLIENT__";export function installClientRuntime(e){if(typeof window>"u"||window[_])return;window[_]=!0;const r=v(e.wsUrl,e.wsToken),d=e.reconnectIntervalMs??1e3;let c=null,N=!1;const m=[];let w=[],p=!1;const E=a(()=>{if(p=!1,!w.length)return;const t=w;if(w=[],c&&c.readyState===1)try{let n=0;for(;n<t.length;){const o=[];let f=0;for(;n<t.length&&o.length<200;){let i=JSON.stringify(t[n]);if(i.length>3e5&&(i=JSON.stringify({kind:"console",level:"warn",at:Date.now(),truncated:!0,args:[`[lensmcp] dropped an oversized ${String(t[n]?.kind)} event (${i.length} chars)`]})),o.length&&f+i.length>3e5)break;o.push(i),f+=i.length+1,n+=1}c.send(o.length===1?o[0]:`{"kind":"batch","at":${Date.now()},"events":[${o.join(",")}]}`)}return}catch{}for(const n of t)m.push(n)},"flushBatch"),s=a(t=>{t.at=Date.now(),w.push(t),p||(p=!0,setTimeout(E,0))},"send");window.addEventListener("pagehide",E);const y=a(()=>{try{c=new WebSocket(r)}catch{setTimeout(y,d);return}c.addEventListener("open",()=>{for(N=!0;m.length;){const t=m.splice(0,200);try{c.send(JSON.stringify(t.length===1?t[0]:{kind:"batch",at:Date.now(),events:t}))}catch{}}s({kind:"ready",userAgent:navigator.userAgent,url:location.href})}),c.addEventListener("close",()=>{N&&setTimeout(y,d)}),c.addEventListener("error",()=>{})},"open");y(),window.__LENSMCP_PUBLISH__=t=>{s({...t,kind:"publish"})},window.addEventListener("error",t=>{const n=t.target;if(n&&n!==window&&n.tagName&&!t.message){s({kind:"resource-error",tagName:n.tagName.toLowerCase(),url:n.currentSrc||n.src||n.href||void 0});return}const o=t.error;s({kind:"error",message:t.message,name:o?.name,stack:g(o?.stack),source:t.filename,lineno:t.lineno,colno:t.colno})},!0),window.addEventListener("unhandledrejection",t=>{const n=t.reason;s({kind:"unhandledrejection",reason:g(b(n)),name:typeof n=="object"&&n?n.name:void 0,stack:typeof n=="object"&&n?g(n.stack):void 0})});const j=["log","info","warn","error","debug"];for(const t of j){const n=console[t];console[t]=function(...o){try{s({kind:"console",level:t,args:o.map(L)})}catch{}n.apply(console,o)}}const C=window.fetch.bind(window);window.fetch=async function(t,n){const o=n?.method??(typeof t=="object"&&t&&"method"in t?t.method:"GET"),f=typeof t=="string"?t:t instanceof URL?t.toString():t.url,i=Date.now();try{const l=await C(t,n);return s({kind:"fetch",method:o,url:f,status:l.status,statusText:l.statusText,ok:l.ok,durationMs:Date.now()-i}),l}catch(l){const T=l;throw s({kind:"fetch",method:o,url:f,error:T.message??String(T),durationMs:Date.now()-i}),l}};const O=history.pushState.bind(history),D=history.replaceState.bind(history),k=a(t=>s({kind:"route",url:location.href,from:t}),"notify");history.pushState=function(...t){const n=location.href,o=O(...t);return k(n),o},history.replaceState=function(...t){const n=location.href,o=D(...t);return k(n),o},window.addEventListener("popstate",()=>k())}u(installClientRuntime,"installClientRuntime"),h(installClientRuntime,"installClientRuntime"),a(installClientRuntime,"installClientRuntime");function v(e,r){if(!r)return e;try{const d=new URL(e);return d.searchParams.has("token")||d.searchParams.set("token",r),d.toString()}catch{return e}}u(v,"N"),h(v,"T"),a(v,"withToken");const S=4096;function g(e){return e===void 0||e.length<=S?e:`${e.slice(0,S)}\u2026[lensmcp truncated: ${e.length-S} more chars]`}u(g,"g"),h(g,"k"),a(g,"clipStack");function b(e){if(e==null)return String(e);if(typeof e=="string")return e;if(typeof e.message=="string"&&e.message)return e.message;try{const r=JSON.stringify(e);if(r&&r!=="{}"&&r!=="null")return r}catch{}return String(e)}u(b,"E"),h(b,"M"),a(b,"describeReason");function L(e){if(e===null)return null;const r=typeof e;if(r==="string"||r==="number"||r==="boolean")return e;try{return JSON.parse(JSON.stringify(e))}catch{return String(e)}}u(L,"_"),h(L,"C"),a(L,"safe");
1
+ "use strict";var O=Object.defineProperty;var u=(e,o)=>O(e,"name",{value:o,configurable:!0});var J=Object.defineProperty,a=u((e,o)=>J(e,"name",{value:o,configurable:!0}),"i");const L="__LENSMCP_CLIENT__";export function installClientRuntime(e){if(typeof window>"u"||window[L])return;window[L]=!0;const o=N(e.wsUrl,e.wsToken),d=e.reconnectIntervalMs??1e3;let c=null,S=!1;const w=[];let f=[],m=!1;const v=a(()=>{if(m=!1,!f.length)return;const n=f;if(f=[],c&&c.readyState===1)try{let t=0;for(;t<n.length;){const r=[];let h=0;for(;t<n.length&&r.length<200;){let i=JSON.stringify(n[t]);if(i.length>3e5&&(i=JSON.stringify({kind:"console",level:"warn",at:Date.now(),truncated:!0,args:[`[lensmcp] dropped an oversized ${String(n[t]?.kind)} event (${i.length} chars)`]})),r.length&&h+i.length>3e5)break;r.push(i),h+=i.length+1,t+=1}c.send(r.length===1?r[0]:`{"kind":"batch","at":${Date.now()},"events":[${r.join(",")}]}`)}return}catch{}for(const t of n)w.push(t)},"flushBatch"),s=a(n=>{n.at=Date.now(),f.push(n),m||(m=!0,setTimeout(v,0))},"send");window.addEventListener("pagehide",v);const p=a(()=>{try{c=new WebSocket(o)}catch{setTimeout(p,d);return}c.addEventListener("open",()=>{for(S=!0;w.length;){const n=w.splice(0,200);try{c.send(JSON.stringify(n.length===1?n[0]:{kind:"batch",at:Date.now(),events:n}))}catch{}}s({kind:"ready",userAgent:navigator.userAgent,url:location.href})}),c.addEventListener("close",()=>{S&&setTimeout(p,d)}),c.addEventListener("error",()=>{})},"open");p(),window.__LENSMCP_PUBLISH__=n=>{s({...n,kind:"publish"})},window.addEventListener("error",n=>{const t=n.target;if(t&&t!==window&&t.tagName&&!n.message){s({kind:"resource-error",tagName:t.tagName.toLowerCase(),url:t.currentSrc||t.src||t.href||void 0});return}const r=n.error;s({kind:"error",message:n.message,name:r?.name,stack:g(r?.stack),source:n.filename,lineno:n.lineno,colno:n.colno})},!0),window.addEventListener("unhandledrejection",n=>{const t=n.reason;s({kind:"unhandledrejection",reason:g(E(t)),name:typeof t=="object"&&t?t.name:void 0,stack:typeof t=="object"&&t?g(t.stack):void 0})});const T=["log","info","warn","error","debug"];for(const n of T){const t=console[n];console[n]=function(...r){try{s({kind:"console",level:n,args:r.map(_)})}catch{}t.apply(console,r)}}const j=window.fetch.bind(window);window.fetch=async function(n,t){const r=t?.method??(typeof n=="object"&&n&&"method"in n?n.method:"GET"),h=typeof n=="string"?n:n instanceof URL?n.toString():n.url,i=Date.now();try{const l=await j(n,t);return s({kind:"fetch",method:r,url:h,status:l.status,statusText:l.statusText,ok:l.ok,durationMs:Date.now()-i}),l}catch(l){const b=l;throw s({kind:"fetch",method:r,url:h,error:b.message??String(b),durationMs:Date.now()-i}),l}};const C=history.pushState.bind(history),D=history.replaceState.bind(history),y=a(n=>s({kind:"route",url:location.href,from:n}),"notify");history.pushState=function(...n){const t=location.href,r=C(...n);return y(t),r},history.replaceState=function(...n){const t=location.href,r=D(...n);return y(t),r},window.addEventListener("popstate",()=>y())}u(installClientRuntime,"installClientRuntime"),a(installClientRuntime,"installClientRuntime");function N(e,o){if(!o)return e;try{const d=new URL(e);return d.searchParams.has("token")||d.searchParams.set("token",o),d.toString()}catch{return e}}u(N,"T"),a(N,"withToken");const k=4096;function g(e){return e===void 0||e.length<=k?e:`${e.slice(0,k)}\u2026[lensmcp truncated: ${e.length-k} more chars]`}u(g,"k"),a(g,"clipStack");function E(e){if(e==null)return String(e);if(typeof e=="string")return e;if(typeof e.message=="string"&&e.message)return e.message;try{const o=JSON.stringify(e);if(o&&o!=="{}"&&o!=="null")return o}catch{}return String(e)}u(E,"M"),a(E,"describeReason");function _(e){if(e===null)return null;const o=typeof e;if(o==="string"||o==="number"||o==="boolean")return e;try{return JSON.parse(JSON.stringify(e))}catch{return String(e)}}u(_,"C"),a(_,"safe");
@@ -0,0 +1,87 @@
1
+ export declare const LENS_TAGS: readonly ["lens/session", "lens/flow", "lens/target", "lens/request", "lens/response", "lens/visit", "lens/nav", "lens/hmr", "lens/error", "lens/gap"];
2
+ export type LensTag = (typeof LENS_TAGS)[number];
3
+ export type GapReason = 'overflow' | 'bridge-down' | 'too-big' | 'budget';
4
+ export interface TargetPayload {
5
+ /** What the user did: `click`, `submit`, `change`. */
6
+ kind: string;
7
+ /** rrweb's node id of the element (−1 when rrweb has not seen it). */
8
+ id: number;
9
+ tag: string;
10
+ role?: string;
11
+ /** The element's text, whitespace collapsed, at most 80 characters. */
12
+ text?: string;
13
+ /** `aria-label`, `title`, `placeholder`, `name` or `alt`. */
14
+ label?: string;
15
+ testId?: string;
16
+ inputType?: string;
17
+ /** The nearest `data-agent-source` (file:line, stamped by LensMCP's Babel transform). */
18
+ source?: string;
19
+ /** The nearest `data-agent-component`. */
20
+ component?: string;
21
+ }
22
+ export interface LensPayloads {
23
+ 'lens/session': {
24
+ tabId: string;
25
+ loadId: string;
26
+ app?: string;
27
+ url: string;
28
+ wire: number;
29
+ rrweb: string;
30
+ viewport: {
31
+ width: number;
32
+ height: number;
33
+ };
34
+ };
35
+ 'lens/flow': {
36
+ flowId: string;
37
+ kind?: string;
38
+ originNodeId?: string;
39
+ };
40
+ 'lens/target': TargetPayload;
41
+ /** A request the page sent (fetch or XHR). No request id: one our headers may not go to (a third party). */
42
+ 'lens/request': {
43
+ requestId?: string;
44
+ flowId?: string;
45
+ method?: string;
46
+ url?: string;
47
+ };
48
+ 'lens/response': {
49
+ requestId?: string;
50
+ flowId?: string;
51
+ status?: number;
52
+ ms?: number;
53
+ error?: string;
54
+ };
55
+ 'lens/visit': {
56
+ visitId: string;
57
+ phase: 'start' | 'ready' | 'end';
58
+ pattern?: string;
59
+ path?: string;
60
+ nav?: 'hard' | 'spa';
61
+ startAt?: number;
62
+ readyBy?: string;
63
+ readyMs?: number;
64
+ };
65
+ 'lens/nav': {
66
+ from?: string;
67
+ to: string;
68
+ };
69
+ 'lens/hmr': {
70
+ type: string;
71
+ files?: string[];
72
+ triggeredBy?: string;
73
+ };
74
+ 'lens/error': {
75
+ message: string;
76
+ source?: string;
77
+ };
78
+ 'lens/gap': {
79
+ fromSeq: number;
80
+ reason: GapReason;
81
+ };
82
+ }
83
+ export declare function isLensTag(tag: unknown): tag is LensTag;
84
+ /** A payload as it may enter the stream: plain JSON, bounded, URLs redacted. `undefined` = refused. */
85
+ export declare function sanitizeAnchor(tag: unknown, payload: unknown): Record<string, unknown> | undefined;
86
+ /** The ids an anchor contributes to its chunk's summary — what the lens joins the backend on (`f:` flow, `r:` request, `v:` visit). */
87
+ export declare function anchorIds(tag: string, payload: Record<string, unknown>): string[];
@@ -0,0 +1 @@
1
+ "use strict";var c=Object.defineProperty;var s=(r,t)=>c(r,"name",{value:t,configurable:!0});var p=Object.defineProperty,n=s((r,t)=>p(r,"name",{value:t,configurable:!0}),"t");import{redactUrl as d}from"@lensmcp/core/redact";export const LENS_TAGS=["lens/session","lens/flow","lens/target","lens/request","lens/response","lens/visit","lens/nav","lens/hmr","lens/error","lens/gap"];const y=new Set(LENS_TAGS);export function isLensTag(r){return typeof r=="string"&&y.has(r)}s(isLensTag,"isLensTag"),n(isLensTag,"isLensTag");const u=512,v=20,g=3,I=new Set(["url","to","from"]);export function sanitizeAnchor(r,t){if(!(!isLensTag(r)||!t||typeof t!="object"||Array.isArray(t)))return o(t,0,void 0)}s(sanitizeAnchor,"sanitizeAnchor"),n(sanitizeAnchor,"sanitizeAnchor");function o(r,t,i){if(r===null||typeof r=="boolean")return r;if(typeof r=="number")return Number.isFinite(r)?r:void 0;if(typeof r=="string"){const e=i&&I.has(i)?d(r):r;return e.length>u?`${e.slice(0,u-1)}\u2026`:e}if(typeof r!="object"||t>=g)return;if(Array.isArray(r))return r.slice(0,v).map(e=>o(e,t+1,void 0));const f={};for(const[e,a]of Object.entries(r)){const l=o(a,t+1,e);l!==void 0&&(f[e]=l)}return f}s(o,"s"),n(o,"walk");export function anchorIds(r,t){return r==="lens/flow"&&typeof t.flowId=="string"?[`f:${t.flowId}`]:(r==="lens/request"||r==="lens/response")&&typeof t.requestId=="string"?[`r:${t.requestId}`]:r==="lens/visit"&&typeof t.visitId=="string"?[`v:${t.visitId}`]:[]}s(anchorIds,"anchorIds"),n(anchorIds,"anchorIds");
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared blobs (plan §2.6, the owner's dedup idea, unit set by S0): the heavy part of a recording that repeats across
3
+ * loads is stored ONCE, by its SHA-256, and a chunk carries a reference.
4
+ *
5
+ * - The load's first full snapshot — the parsed shell, identical for every route and reload of an SPA — keeps its
6
+ * `timestamp` and `initialOffset`; its `node` becomes a reference.
7
+ * - Any other snapshot or mutation whose data serializes to ≥ 8 KB (a first render: the app chrome and the global
8
+ * CSS repeat on every route) is replaced whole.
9
+ *
10
+ * The hash is over the EXACT serialized bytes, so identical bytes mean identical rrweb node ids and the incrementals
11
+ * that follow replay exactly. Reading rehydrates every reference (`rehydrate`), so the player gets the original stream.
12
+ * Strings were the wrong unit: below 8 KB gzip already does better than a reference (S0, `dedup.mjs`).
13
+ */
14
+ import type { RrwebEvent } from './types.js';
15
+ /**
16
+ * A reference in place of serialized data. A NUL never survives the HTML parser, so no page text starts with it.
17
+ * Lock-step with the bridge and the lens reader (S2/S4).
18
+ */
19
+ export declare const BLOB_PREFIX = "\0lens-blob:";
20
+ export declare function sha256Hex(subtle: SubtleCrypto, text: string): Promise<string>;
21
+ export interface SerializedChunk {
22
+ /** The chunk body: a JSON array of rrweb events, blobs as references. */
23
+ json: string;
24
+ /** Every blob the body references, by hash → its serialized data. */
25
+ blobs: Map<string, string>;
26
+ /** The hash of the load's base snapshot when this chunk holds it. */
27
+ base?: string;
28
+ /** Main-thread milliseconds spent serializing (the awaits on hashing excluded). */
29
+ ms: number;
30
+ }
31
+ /**
32
+ * The events as they are stored. Without `subtle` (an insecure context) nothing is referenced — correct, just bigger.
33
+ * `isBase(e)`: is this the load's first full snapshot.
34
+ */
35
+ export declare function serializeChunk(events: readonly RrwebEvent[], o: {
36
+ subtle?: SubtleCrypto;
37
+ blobMinChars: number;
38
+ isBase(e: RrwebEvent): boolean;
39
+ perf(): number;
40
+ }): Promise<SerializedChunk>;
41
+ /** Every reference in a stored value replaced by its blob — what a reader does before the player sees a chunk. */
42
+ export declare function rehydrate(value: unknown, blob: (hash: string) => string | undefined): unknown;
43
+ /**
44
+ * The hashes this page knows the bridge holds: what it never uploads again. Kept in memory and in `localStorage`
45
+ * (oldest dropped past `max`), so a reload of the same page sends references only (the owner's option a). The bridge
46
+ * stays the authority — a chunk naming a blob it lacks is answered 409 and the blob goes up after all.
47
+ */
48
+ export declare class KnownHashes {
49
+ private readonly max;
50
+ private readonly storage?;
51
+ private readonly key;
52
+ private readonly set;
53
+ constructor(max: number, storage?: {
54
+ getItem(key: string): string | null;
55
+ setItem(key: string, value: string): void;
56
+ } | undefined, key?: string);
57
+ has(hash: string): boolean;
58
+ add(hash: string): void;
59
+ forget(hash: string): void;
60
+ private save;
61
+ }
@@ -0,0 +1 @@
1
+ "use strict";var S=Object.defineProperty;var c=(e,t)=>S(e,"name",{value:t,configurable:!0});var m=Object.defineProperty,h=c((e,t)=>m(e,"name",{value:t,configurable:!0}),"f");export const BLOB_PREFIX="\0lens-blob:";const O=2,v=3;export async function sha256Hex(e,t){const s=await e.digest("SHA-256",new TextEncoder().encode(t));let r="";for(const i of new Uint8Array(s))r+=i.toString(16).padStart(2,"0");return r}c(sha256Hex,"sha256Hex"),h(sha256Hex,"sha256Hex");export async function serializeChunk(e,t){const s=[],r=new Map;let i,n=0;for(const a of e){let o=t.perf();if(!t.subtle||a.type!==2&&a.type!==3){s.push(JSON.stringify(a)),n+=t.perf()-o;continue}if(a.type===2&&t.isBase(a)){const d=a.data,g=JSON.stringify(d.node);n+=t.perf()-o;const y=await sha256Hex(t.subtle,g);o=t.perf(),r.set(y,g),i=y,s.push(u(a,JSON.stringify({...d,node:BLOB_PREFIX+y}))),n+=t.perf()-o;continue}const p=JSON.stringify(a.data);if(p.length<t.blobMinChars){s.push(u(a,p)),n+=t.perf()-o;continue}n+=t.perf()-o;const l=await sha256Hex(t.subtle,p);o=t.perf(),r.set(l,p),s.push(u(a,JSON.stringify(BLOB_PREFIX+l))),n+=t.perf()-o}const f=t.perf(),b=`[${s.join(",")}]`;return n+=t.perf()-f,{json:b,blobs:r,...i?{base:i}:{},ms:n}}c(serializeChunk,"serializeChunk"),h(serializeChunk,"serializeChunk");function u(e,t){const s=e.delay;return`{"type":${e.type},"data":${t},"timestamp":${e.timestamp}${s!==void 0?`,"delay":${s}`:""}}`}c(u,"d"),h(u,"eventJson");export function rehydrate(e,t){if(typeof e=="string"){if(!e.startsWith(BLOB_PREFIX))return e;const s=t(e.slice(BLOB_PREFIX.length));return s===void 0?e:rehydrate(JSON.parse(s),t)}if(Array.isArray(e))return e.map(s=>rehydrate(s,t));if(e&&typeof e=="object"){const s={};for(const[r,i]of Object.entries(e))s[r]=rehydrate(i,t);return s}return e}c(rehydrate,"rehydrate"),h(rehydrate,"rehydrate");export class KnownHashes{static{c(this,"KnownHashes")}static{h(this,"KnownHashes")}constructor(t,s,r="lensmcp:replay:blobs"){this.max=t,this.storage=s,this.key=r,this.set=new Set;try{const i=s?.getItem(r),n=i?JSON.parse(i):[];if(Array.isArray(n))for(const f of n.slice(-t))typeof f=="string"&&/^[0-9a-f]{64}$/.test(f)&&this.set.add(f)}catch{}}has(t){return this.set.has(t)}add(t){for(this.set.delete(t),this.set.add(t);this.set.size>this.max;)this.set.delete(this.set.values().next().value);this.save()}forget(t){this.set.delete(t)&&this.save()}save(){try{this.storage?.setItem(this.key,JSON.stringify([...this.set]))}catch{}}}
@@ -0,0 +1,57 @@
1
+ import { type ReplayConfig, type ReplayEnv } from './types.js';
2
+ export type RecorderState = 'idle' | 'recording' | 'paused' | 'stopped';
3
+ export declare class ReplayRecorder {
4
+ private readonly cfg;
5
+ private readonly env;
6
+ readonly loadId: string;
7
+ private readonly limits;
8
+ private readonly privacy;
9
+ private readonly record;
10
+ private readonly uploader;
11
+ private state;
12
+ private stopRecord;
13
+ private seq;
14
+ private buffer;
15
+ private bufferChars;
16
+ private bufferAnchors;
17
+ private timer;
18
+ private base;
19
+ /** After a gap: nothing is kept until the bridge answers, then one checkout restarts a playable stream. */
20
+ private awaitingCheckout;
21
+ private checkoutPending;
22
+ private processing;
23
+ private costWindowStart;
24
+ private costSpent;
25
+ private overSeconds;
26
+ constructor(cfg: ReplayConfig, env: ReplayEnv);
27
+ get status(): RecorderState;
28
+ start(): void;
29
+ /** Writes an anchor into the stream (the registry refuses anything else). A navigation resumes a paused recording. */
30
+ mark(tag: unknown, payload: unknown): void;
31
+ /** What the user acted on, described now (`lens/target`). */
32
+ target(target: unknown, kind: string): void;
33
+ /** A fresh snapshot when the page is idle — a visit is ready, so the visit plays on its own. Never dropped: retried once. */
34
+ checkpoint(): void;
35
+ /** At unload: what is queued and what is not cut yet go by beacon. */
36
+ pagehide(): void;
37
+ stop(): void;
38
+ private begin;
39
+ private onEmit;
40
+ private takeSummary;
41
+ private cut;
42
+ private prepare;
43
+ private onOutcome;
44
+ /** Data was lost: say from which chunk, keep nothing until the bridge answers, then re-snapshot. */
45
+ private gap;
46
+ private checkoutNow;
47
+ /** The cost guard: the recorder's own main-thread time, per second. */
48
+ private spend;
49
+ private pause;
50
+ private resume;
51
+ private halt;
52
+ /**
53
+ * The few lifecycle events the bus carries — never a chunk (plan §4.2). Severity `info`, always: they are about the
54
+ * lens, and a browser `warning` marks the app's own tab as warning (browser://tabs).
55
+ */
56
+ private publish;
57
+ }
@@ -0,0 +1 @@
1
+ "use strict";var f=Object.defineProperty;var a=(i,t)=>f(i,"name",{value:t,configurable:!0});var m=Object.defineProperty,r=a((i,t)=>m(i,"name",{value:t,configurable:!0}),"i");import{anchorIds as g,sanitizeAnchor as b}from"./anchors.js";import{KnownHashes as v,serializeChunk as y}from"./blobs.js";import{describeTarget as k}from"./target.js";import{gzip as S,Uploader as w}from"./uploader.js";import{DEFAULT_LIMITS as I,DEV_PRIVACY as C,REPLAY_WIRE_VERSION as M,RRWEB_VERSION as R}from"./types.js";const c=2,A=3,u=4,E=5,q=0;function l(i){if(i.type===c)return 64*1024;if(i.type===A){const t=i.data;if(t.source===q)return 64+160*(t.adds?.length??0)+60*(t.attributes?.length??0)+80*(t.texts?.length??0)+24*(t.removes?.length??0)}return 120}a(l,"E"),r(l,"estimateChars");const O=r(()=>`load-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,"newLoadId");export class ReplayRecorder{static{a(this,"ReplayRecorder")}static{r(this,"ReplayRecorder")}constructor(t,s){this.cfg=t,this.env=s,this.loadId=O(),this.state="idle",this.seq=0,this.buffer=[],this.bufferChars=0,this.bufferAnchors=[],this.awaitingCheckout=!1,this.checkoutPending=!1,this.processing=Promise.resolve(),this.costWindowStart=0,this.costSpent=0,this.overSeconds=0,this.limits={...I,...t.limits},this.privacy={...C,...t.privacy},this.record=t.record,this.uploader=new w({endpoint:t.endpoint.replace(/\/+$/,""),...t.token?{token:t.token}:{},tabId:t.tabId,loadId:this.loadId,...t.app?{app:t.app}:{},env:s,known:new v(this.limits.knownHashes,s.storage),retryMaxMs:this.limits.retryMaxMs,noChannelAfter:this.limits.noChannelAfter,beaconMaxBytes:this.limits.beaconMaxBytes,onOutcome:r((e,h)=>this.onOutcome(e,h),"onOutcome")})}get status(){return this.state}start(){this.state==="idle"&&(this.state="recording",this.costWindowStart=this.env.perf(),this.begin(),this.mark("lens/session",{tabId:this.cfg.tabId,loadId:this.loadId,...this.cfg.app?{app:this.cfg.app}:{},url:this.env.href(),wire:M,rrweb:R,viewport:this.env.viewport()}))}mark(t,s){if(t==="lens/nav"&&this.state==="paused"&&this.resume(),this.state!=="recording")return;const e=b(t,s);if(e)try{this.record.addCustomEvent(t,e)}catch{}}target(t,s){if(this.state!=="recording")return;const e=k(t,s,h=>this.record.mirror.getId(h));e&&this.mark("lens/target",e)}checkpoint(){this.state!=="recording"||this.checkoutPending||this.awaitingCheckout||(this.checkoutPending=!0,this.env.idle(()=>{this.checkoutPending=!1,this.checkoutNow()||this.env.idle(()=>this.checkoutNow())}))}pagehide(){if(this.state!=="recording"&&this.state!=="paused"||(this.timer!==void 0&&(this.env.clearTimeout(this.timer),this.timer=void 0),this.uploader.beaconQueued(),!this.buffer.length||this.awaitingCheckout))return;const t=this.buffer,s=this.takeSummary(t);try{this.uploader.beacon(s,JSON.stringify(t),"identity")}catch{}}stop(){this.state!=="stopped"&&(this.state==="recording"&&this.cut(),this.halt())}begin(){const t=this.privacy;try{this.stopRecord=this.record({emit:r(s=>this.onEmit(s),"emit"),checkoutEveryNms:this.limits.checkoutEveryMs,maskAllInputs:t.maskAllInputs,...t.maskAllInputs?{}:{maskInputOptions:{password:!1}},...t.maskTextSelector?{maskTextSelector:t.maskTextSelector}:{},...t.blockSelector?{blockSelector:t.blockSelector}:{},slimDOMOptions:"all",sampling:{mousemove:50,scroll:150,media:800,input:"last"},recordCanvas:!1,collectFonts:!1,errorHandler:r(()=>!0,"errorHandler")})}catch{this.halt()}}onEmit(t){if(this.state!=="recording"||this.awaitingCheckout)return;const s=this.env.perf();if(t.type===u&&this.buffer.length&&this.cut(),t.type===c&&!this.base&&(this.base=t),this.buffer.push(t),this.bufferChars+=l(t),t.type===E){const e=t.data;typeof e.tag=="string"&&e.payload&&typeof e.payload=="object"&&this.bufferAnchors.push(...g(e.tag,e.payload))}this.buffer.length>=this.limits.flushEvents||this.bufferChars>=this.limits.flushChars?this.cut():this.timer===void 0&&(this.timer=this.env.setTimeout(()=>{this.timer=void 0,this.cut()},this.limits.flushMs)),this.spend(this.env.perf()-s)}takeSummary(t){const s={seq:this.seq++,t0:t[0]?.timestamp??0,t1:t[t.length-1]?.timestamp??0,n:t.length,full:t[0]?.type===u&&t.some(e=>e.type===c),anchors:[...new Set(this.bufferAnchors)]};return this.buffer=[],this.bufferChars=0,this.bufferAnchors=[],s}cut(){if(this.timer!==void 0&&(this.env.clearTimeout(this.timer),this.timer=void 0),!this.buffer.length)return;const t=this.buffer,s=this.takeSummary(t);this.processing=this.processing.then(()=>this.prepare(s,t)).catch(()=>{})}async prepare(t,s){if(this.uploader.disabled||this.state==="stopped")return;const e=await y(s,{...this.env.subtle?{subtle:this.env.subtle}:{},blobMinChars:this.limits.blobMinChars,isBase:r(n=>n===this.base,"isBase"),perf:r(()=>this.env.perf(),"perf")});this.spend(e.ms);const h=await S(e.json),o=h??e.json;let d=typeof o=="string"?o.length:o.byteLength;for(const n of e.blobs.values())d+=n.length;const p={summary:e.base?{...t,base:e.base}:t,body:o,enc:h?"gzip":"identity",blobs:e.blobs,size:d};this.uploader.enqueue(p),this.uploader.pendingSize>this.limits.pendingChars&&this.gap("overflow")}onOutcome(t,s){if(s==="refused"){this.halt();return}if(s==="too-big"){this.gap("too-big",t.summary.seq);return}this.awaitingCheckout&&(this.awaitingCheckout=!1,this.checkoutNow())}gap(t,s){if(this.state!=="recording"||this.awaitingCheckout)return;const e=this.uploader.drop();this.buffer=[],this.bufferChars=0,this.bufferAnchors=[],this.mark("lens/gap",{fromSeq:s??e??this.seq,reason:t}),this.cut(),this.awaitingCheckout=!0,this.publish("replay-gap",`session replay lost a stretch of this page (${t})`,{reason:t,fromSeq:s??e??this.seq})}checkoutNow(){if(this.state!=="recording")return!0;const t=this.env.perf();try{return this.record.takeFullSnapshot(!0),!0}catch{return!1}finally{this.spend(this.env.perf()-t)}}spend(t){const s=this.env.perf();if(s-this.costWindowStart>=1e3&&(this.overSeconds=this.costSpent>this.limits.budgetMsPerSecond?this.overSeconds+1:0,this.costSpent=0,this.costWindowStart=s,this.overSeconds>=this.limits.budgetSeconds)){this.pause();return}this.costSpent+=t}pause(){if(this.state==="recording"){this.mark("lens/gap",{fromSeq:this.seq,reason:"budget"}),this.cut();try{this.stopRecord?.()}catch{}this.stopRecord=void 0,this.state="paused",this.overSeconds=0,this.publish("replay-paused",`session replay paused: its own work stayed over ${this.limits.budgetMsPerSecond} ms a second for ${this.limits.budgetSeconds} s`,{})}}resume(){this.state==="paused"&&(this.state="recording",this.costWindowStart=this.env.perf(),this.costSpent=0,this.begin())}halt(){this.state="stopped",this.timer!==void 0&&(this.env.clearTimeout(this.timer),this.timer=void 0);try{this.stopRecord?.()}catch{}this.stopRecord=void 0,this.uploader.drop(),this.buffer=[]}publish(t,s,e){try{this.env.publish?.({source:"client-runtime",category:"runtime",severity:"info",title:s,fingerprint:`replay:${t}:${this.loadId}`,raw:{kind:t,loadId:this.loadId,tabId:this.cfg.tabId,...e}})}catch{}}}
@@ -0,0 +1,13 @@
1
+ import type { ReplayConfig, ReplayEnv } from './types.js';
2
+ export interface ReplayHandle {
3
+ readonly loadId: string;
4
+ /** Writes a `lens/*` anchor into the stream; anything outside the registry is ignored. */
5
+ mark(tag: string, payload: unknown): void;
6
+ /** A fresh snapshot when the page is idle (a visit is ready). */
7
+ checkpoint(): void;
8
+ stop(): void;
9
+ }
10
+ export declare const REPLAY_HANDLE = "__LENSMCP_REPLAY__";
11
+ export declare function startReplay(config: ReplayConfig): ReplayHandle | undefined;
12
+ /** The page's own clock, timers, storage and crypto, overridable piece by piece. */
13
+ export declare function pageEnv(over?: Partial<ReplayEnv>): ReplayEnv;
@@ -0,0 +1 @@
1
+ "use strict";var u=Object.defineProperty;var s=(a,e)=>u(a,"name",{value:e,configurable:!0});var p=Object.defineProperty,o=s((a,e)=>p(a,"name",{value:e,configurable:!0}),"n");import{ReplayRecorder as f}from"./recorder.js";export const REPLAY_HANDLE="__LENSMCP_REPLAY__";const d=["click","submit","change"];export function startReplay(a){if(typeof window>"u"||typeof document>"u")return;const e=globalThis;if(e[REPLAY_HANDLE]===!1)return;const c=e[REPLAY_HANDLE];if(c)return c;const r=new f(a,pageEnv(a.env)),i={loadId:r.loadId,mark:o((t,n)=>r.mark(t,n),"mark"),checkpoint:o(()=>r.checkpoint(),"checkpoint"),stop:o(()=>r.stop(),"stop")};e[REPLAY_HANDLE]=i,r.start();for(const t of d)document.addEventListener(t,n=>r.target(n.target,t),{capture:!0,passive:!0});return window.addEventListener("pagehide",()=>r.pagehide()),i}s(startReplay,"startReplay"),o(startReplay,"startReplay");export function pageEnv(a={}){const e=window;let c;try{c=e.localStorage}catch{}const r=e.isSecureContext&&e.crypto?.subtle?e.crypto.subtle:void 0,i=typeof e.fetch=="function"?e.fetch.bind(e):void 0;return{fetch:o((t,n)=>i?i(t,n):Promise.reject(new Error("no fetch")),"fetch"),...typeof navigator<"u"&&typeof navigator.sendBeacon=="function"?{sendBeacon:o((t,n)=>navigator.sendBeacon(t,n),"sendBeacon")}:{},now:o(()=>Date.now(),"now"),perf:o(()=>typeof performance<"u"?performance.now():Date.now(),"perf"),setTimeout:o((t,n)=>e.setTimeout(t,n),"setTimeout"),clearTimeout:o(t=>e.clearTimeout(t),"clearTimeout"),idle:o(t=>{typeof e.requestIdleCallback=="function"?e.requestIdleCallback(t,{timeout:2e3}):e.setTimeout(t,50)},"idle"),...c?{storage:c}:{},...r?{subtle:r}:{},href:o(()=>e.location.href,"href"),viewport:o(()=>({width:e.innerWidth,height:e.innerHeight}),"viewport"),publish:o(t=>{const n=e.__LENSMCP_PUBLISH__;typeof n=="function"&&n(t)},"publish"),...a}}s(pageEnv,"pageEnv"),o(pageEnv,"pageEnv");
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `lens/target`: what the user clicked, described at click time (plan §2.4, D7). rrweb's own click event carries only
3
+ * a node id, and naming the element afterwards would need the DOM rebuilt; the page has the live element, so it says
4
+ * what it is now — and where in the code it came from (`data-agent-source`, stamped by LensMCP's Babel transform).
5
+ */
6
+ import type { TargetPayload } from './anchors.js';
7
+ export declare function describeTarget(target: unknown, kind: string, idOf: (node: unknown) => number): TargetPayload | undefined;
@@ -0,0 +1 @@
1
+ "use strict";var x=Object.defineProperty;var r=(t,n)=>x(t,"name",{value:n,configurable:!0});var A=Object.defineProperty,s=r((t,n)=>A(t,"name",{value:n,configurable:!0}),"c");const T="a,button,input,select,textarea,label,summary,option,[role],[data-testid],[onclick],[tabindex]",k=80,N=s(t=>!!t&&typeof t.closest=="function"&&typeof t.getAttribute=="function"&&typeof t.tagName=="string","isElement");export function describeTarget(t,n,m){if(!N(t))return;const o=t.closest(T)??t,e=s(v=>o.getAttribute(v)??void 0,"attr"),c=(o.textContent??"").replace(/\s+/g," ").trim(),f=o.closest("[data-agent-source]"),y=o.closest("[data-agent-component]");let i=-1;try{i=m(o)}catch{}const a={kind:n,id:i,tag:o.tagName.toLowerCase()},l=e("role");l&&(a.role=l),c&&(a.text=c.length>80?`${c.slice(0,79)}\u2026`:c);const d=e("aria-label")??e("title")??e("placeholder")??e("name")??e("alt");d&&(a.label=d);const u=e("data-testid")??e("data-test")??e("data-cy");u&&(a.testId=u);const g=o.tagName==="INPUT"?e("type"):void 0;g&&(a.inputType=g);const p=f?.getAttribute("data-agent-source");p&&(a.source=p);const b=y?.getAttribute("data-agent-component");return b&&(a.component=b),a}r(describeTarget,"describeTarget"),s(describeTarget,"describeTarget");
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Session replay (planning/replay/00-plan.md): the page records itself with rrweb and streams the recording to the
3
+ * LensMCP bridge on its OWN channel — `POST /replay` — never the event bus, whose frame caps, clamp, flood guard and
4
+ * redactor would each break an rrweb stream (§1.2).
5
+ *
6
+ * The unit is a page load (`loadId`): one continuous rrweb stream, cut into chunks. A chunk that starts at a full
7
+ * snapshot plays on its own; a flow, a visit or a time window is a query over the load (§2.3).
8
+ */
9
+ import type { eventWithTime } from '@rrweb/types';
10
+ export type RrwebEvent = eventWithTime;
11
+ /** The rrweb version the recorder is built and tested against (`@rrweb/record`, exact pin). */
12
+ export declare const RRWEB_VERSION = "2.1.7";
13
+ /** The chunk format the bridge reads (`v=` on every upload). */
14
+ export declare const REPLAY_WIRE_VERSION = 1;
15
+ /** What the recorder needs of rrweb's `record` — structural, so a spec can drive the recorder without a DOM. */
16
+ export interface RecordApi {
17
+ (options: RecordOptions): (() => void) | undefined;
18
+ addCustomEvent(tag: string, payload: unknown): void;
19
+ takeFullSnapshot(isCheckout?: boolean): void;
20
+ mirror: {
21
+ getId(node: unknown): number;
22
+ };
23
+ }
24
+ export interface RecordOptions {
25
+ emit(event: RrwebEvent, isCheckout?: boolean): void;
26
+ [option: string]: unknown;
27
+ }
28
+ /**
29
+ * What the recording keeps from view. Dev records everything (owner, D3): a dev tab shows dev data. The masking
30
+ * options stay so a production build used for support can turn them on (§13).
31
+ */
32
+ export interface ReplayPrivacy {
33
+ /** Every input's value masked (rrweb `maskAllInputs`). */
34
+ maskAllInputs: boolean;
35
+ /** Elements whose text is masked (rrweb `maskTextSelector`). */
36
+ maskTextSelector?: string;
37
+ /** Elements recorded as an empty box (rrweb `blockSelector`). Dev keeps the opt-out marker. */
38
+ blockSelector?: string;
39
+ }
40
+ export declare const DEV_PRIVACY: ReplayPrivacy;
41
+ /** Defaults measured in S0 (planning/replay/01-s0-measurements.md). */
42
+ export interface ReplayLimits {
43
+ /** A chunk goes out at most this long after its first event… */
44
+ flushMs: number;
45
+ /** …or once it holds this many characters (estimated)… */
46
+ flushChars: number;
47
+ /** …or this many events, whichever comes first. A checkout always starts a new chunk. */
48
+ flushEvents: number;
49
+ /** rrweb's time checkout: bounds how far a seek fast-forwards. */
50
+ checkoutEveryMs: number;
51
+ /** Characters waiting for the bridge (down, or not answered yet) before they are dropped as a gap. */
52
+ pendingChars: number;
53
+ /** An event whose data serializes to at least this many characters is stored once, by hash (§2.6). */
54
+ blobMinChars: number;
55
+ /** `sendBeacon`'s practical cap — a tail over it is lost at unload. */
56
+ beaconMaxBytes: number;
57
+ /** The longest pause between two tries of a chunk while the bridge is down. */
58
+ retryMaxMs: number;
59
+ /**
60
+ * Network failures, before a load's first upload has ever succeeded, that mean there is no replay channel at all.
61
+ * A cross-origin answer without CORS headers is unreadable — a fetch rejects — so an older bridge, a refused
62
+ * origin or a refused token looks exactly like a bridge that is down; only "never once answered" tells them apart.
63
+ */
64
+ noChannelAfter: number;
65
+ /** The recorder's own main-thread work allowed per second… */
66
+ budgetMsPerSecond: number;
67
+ /** …sustained this many seconds over it before recording pauses. */
68
+ budgetSeconds: number;
69
+ /** Blob hashes remembered as uploaded (memory + localStorage). */
70
+ knownHashes: number;
71
+ }
72
+ export declare const DEFAULT_LIMITS: ReplayLimits;
73
+ /** The page's side of the world, injectable so a spec runs the recorder in Node. */
74
+ export interface ReplayEnv {
75
+ /** The NATIVE fetch, captured before any LensMCP tap wraps it — a wrapped one records the uploads as app traffic. */
76
+ fetch: (url: string, init: {
77
+ method: string;
78
+ headers?: Record<string, string>;
79
+ body?: BodyInit;
80
+ credentials?: RequestCredentials;
81
+ keepalive?: boolean;
82
+ }) => Promise<{
83
+ status: number;
84
+ text(): Promise<string>;
85
+ }>;
86
+ sendBeacon?: (url: string, body: Blob) => boolean;
87
+ now(): number;
88
+ /** A monotonic clock for the cost guard (`performance.now`). */
89
+ perf(): number;
90
+ setTimeout(fn: () => void, ms: number): unknown;
91
+ clearTimeout(handle: unknown): void;
92
+ /** Runs `fn` when the page is idle (`requestIdleCallback`, else a short timeout). */
93
+ idle(fn: () => void): void;
94
+ /** Where blob hashes are remembered across reloads (`localStorage`); absent → memory only. */
95
+ storage?: {
96
+ getItem(key: string): string | null;
97
+ setItem(key: string, value: string): void;
98
+ };
99
+ /** `crypto.subtle` — only in a secure context; absent → no dedup, everything inline (§2.6.7). */
100
+ subtle?: SubtleCrypto;
101
+ /** The page's URL, read when a load starts. */
102
+ href(): string;
103
+ viewport(): {
104
+ width: number;
105
+ height: number;
106
+ };
107
+ /** The inline runtime's bus channel (`__LENSMCP_PUBLISH__`), for the few lifecycle events the bus carries. */
108
+ publish?: (envelope: Record<string, unknown>) => void;
109
+ }
110
+ export interface ReplayConfig {
111
+ /** The bridge's HTTP origin, e.g. `http://127.0.0.1:53211`. */
112
+ endpoint: string;
113
+ /** This dev-server start's bridge token. */
114
+ token?: string;
115
+ tabId: string;
116
+ app?: string;
117
+ record: RecordApi;
118
+ privacy?: Partial<ReplayPrivacy>;
119
+ limits?: Partial<ReplayLimits>;
120
+ env?: Partial<ReplayEnv>;
121
+ }
@@ -0,0 +1 @@
1
+ "use strict";export const RRWEB_VERSION="2.1.7",REPLAY_WIRE_VERSION=1,DEV_PRIVACY={maskAllInputs:!1,blockSelector:"[data-lens-private]"},DEFAULT_LIMITS={flushMs:2e3,flushChars:256*1024,flushEvents:2e3,checkoutEveryMs:18e4,pendingChars:8*1024*1024,blobMinChars:8*1024,beaconMaxBytes:6e4,retryMaxMs:1e4,noChannelAfter:4,budgetMsPerSecond:50,budgetSeconds:5,knownHashes:2e3};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The upload side of a load (plan §3.3–3.4): one chunk at a time, in order, to the bridge's `POST /replay`.
3
+ *
4
+ * - the NATIVE fetch only — a wrapped one adds lens headers to the loopback call, which forces a CORS preflight and
5
+ * records the upload as app traffic (S0);
6
+ * - `text/plain` with the encoding in the query keeps it a CORS simple request; the chunk's summary rides in the URL
7
+ * so the bridge writes the bytes without decoding them;
8
+ * - blobs the bridge may lack go up first (`POST /replay/blob?sha=`), and a chunk is posted only once its blobs are
9
+ * acknowledged, so a stored chunk never points at a missing blob; a 409 says the bridge lost one after all;
10
+ * - a bridge that answers 404 / 405 / 426 has no replay channel — recording stops, quietly. A browser hands that
11
+ * status back only when the answer carries CORS headers; without them (an older bridge, a refused origin) the
12
+ * fetch just rejects, so a load whose FIRST upload never succeeds stops after `noChannelAfter` failed tries;
13
+ * - a bridge that is down (restarting, or not up yet) is retried with a growing pause, in order, for as long as it
14
+ * takes: the queue waits rather than leaving a hole mid-stream. Only the recorder's pending cap drops data.
15
+ */
16
+ import type { KnownHashes } from './blobs.js';
17
+ import { type ReplayEnv } from './types.js';
18
+ export interface ChunkSummary {
19
+ seq: number;
20
+ t0: number;
21
+ t1: number;
22
+ n: number;
23
+ /** Starts at a full snapshot: plays on its own. */
24
+ full: boolean;
25
+ /** Ids the chunk's anchors carry: `f:` flow, `r:` request, `v:` visit. */
26
+ anchors: string[];
27
+ /** The load's base snapshot hash, on the chunk that holds it. */
28
+ base?: string;
29
+ }
30
+ export interface PreparedChunk {
31
+ summary: ChunkSummary;
32
+ body: Uint8Array | string;
33
+ enc: 'gzip' | 'identity';
34
+ /** Every blob the body references — uploaded first unless the bridge is known to hold it. */
35
+ blobs: Map<string, string>;
36
+ /** Bytes on the wire (characters for an identity body). */
37
+ size: number;
38
+ }
39
+ export type UploadOutcome = 'ok' | 'refused' | 'too-big';
40
+ export interface UploaderOptions {
41
+ endpoint: string;
42
+ token?: string;
43
+ tabId: string;
44
+ loadId: string;
45
+ /** The app the page belongs to (the bridge keeps it with the load). */
46
+ app?: string;
47
+ env: ReplayEnv;
48
+ known: KnownHashes;
49
+ /** The longest pause between two tries of one chunk. */
50
+ retryMaxMs: number;
51
+ /** Network failures before the first success that mean there is no replay channel. */
52
+ noChannelAfter: number;
53
+ beaconMaxBytes: number;
54
+ onOutcome(chunk: PreparedChunk, outcome: UploadOutcome): void;
55
+ }
56
+ /** gzip through the platform's CompressionStream; `undefined` where there is none (the body goes as is). */
57
+ export declare function gzip(text: string): Promise<Uint8Array | undefined>;
58
+ export declare class Uploader {
59
+ private readonly o;
60
+ private readonly queue;
61
+ private busy;
62
+ private queued;
63
+ /** The bridge has taken at least one upload of this load. */
64
+ private answered;
65
+ private networkFailures;
66
+ /** No replay channel at this bridge: nothing more is sent. */
67
+ disabled: boolean;
68
+ constructor(o: UploaderOptions);
69
+ /** Bytes waiting for the bridge, the one in flight included. */
70
+ get pendingSize(): number;
71
+ enqueue(chunk: PreparedChunk): void;
72
+ /** Drops every chunk not yet in flight (a gap); returns the first dropped seq. */
73
+ drop(): number | undefined;
74
+ /**
75
+ * At unload: what is queued goes by `sendBeacon`, which survives the page — each chunk that fits its cap and whose
76
+ * blobs the bridge already holds (a beacon cannot upload a blob first). The rest is lost, and the next load does not
77
+ * pretend otherwise.
78
+ */
79
+ beaconQueued(): void;
80
+ /** One chunk by `sendBeacon`. */
81
+ beacon(summary: ChunkSummary, body: Uint8Array | string, enc: 'gzip' | 'identity'): boolean;
82
+ private pump;
83
+ private send;
84
+ private params;
85
+ private url;
86
+ /**
87
+ * One upload. The answer's body is always read (a few bytes of JSON): a response the page drops unread is cancelled
88
+ * by the browser, and every upload showed as "(canceled)" in the developer's own Network panel (the S2 browser check).
89
+ */
90
+ private post;
91
+ private sleep;
92
+ }
@@ -0,0 +1 @@
1
+ "use strict";var d=Object.defineProperty;var a=(n,t)=>d(n,"name",{value:t,configurable:!0});var y=Object.defineProperty,u=a((n,t)=>y(n,"name",{value:t,configurable:!0}),"a");import{REPLAY_WIRE_VERSION as b}from"./types.js";const g=4e3;export async function gzip(n){if(!(typeof CompressionStream!="function"||typeof Blob!="function"||typeof Response!="function"))try{const t=new Blob([n]).stream().pipeThrough(new CompressionStream("gzip"));return new Uint8Array(await new Response(t).arrayBuffer())}catch{return}}a(gzip,"gzip"),u(gzip,"gzip");export class Uploader{static{a(this,"Uploader")}static{u(this,"Uploader")}constructor(t){this.o=t,this.queue=[],this.busy=!1,this.queued=0,this.answered=!1,this.networkFailures=0,this.disabled=!1}get pendingSize(){return this.queued}enqueue(t){this.disabled||(this.queue.push(t),this.queued+=t.size,this.pump())}drop(){const t=this.queue[0]?.summary.seq;for(const e of this.queue.splice(0))this.queued-=e.size;return t}beaconQueued(){for(const t of this.queue.splice(0))this.queued-=t.size,[...t.blobs.keys()].every(e=>this.o.known.has(e))&&this.beacon(t.summary,t.body,t.enc)}beacon(t,e,s){const i=this.o.env.sendBeacon,o=typeof e=="string"?e.length:e.byteLength;if(this.disabled||!i||o>this.o.beaconMaxBytes)return!1;try{return i(this.url("/replay",this.params(t,s,[])),new Blob([e],{type:"text/plain"}))}catch{return!1}}async pump(){if(!this.busy){this.busy=!0;try{for(;!this.disabled&&this.queue.length;){const t=this.queue.shift(),e=await this.send(t);this.queued-=t.size,e==="refused"&&(this.disabled=!0),this.o.onOutcome(t,e)}}finally{this.busy=!1}}}async send(t){for(let e=0;!this.disabled;e++){e>0&&await this.sleep(Math.min(this.o.retryMaxMs,500*2**(e-1)));try{for(const[o,r]of t.blobs){if(this.o.known.has(o))continue;const f=await gzip(r),h=await this.post("/replay/blob",{sha:o,enc:f?"gzip":"identity"},f??r);if(h.status>=200&&h.status<300){this.o.known.add(o);continue}const l=c(h.status);if(l)return l;throw new Error(`blob ${h.status}`)}const s=await this.post("/replay",this.params(t.summary,t.enc,[...t.blobs.keys()]),t.body);if(s.status>=200&&s.status<300)return this.answered=!0,"ok";if(s.status===409){const o=await p(s);for(const r of o.length?o:t.blobs.keys())this.o.known.forget(r);continue}const i=c(s.status);if(i)return i}catch{if(!this.answered&&++this.networkFailures>=this.o.noChannelAfter)return"refused"}}return"refused"}params(t,e,s){const i={v:String(b),load:this.o.loadId,tab:this.o.tabId,seq:String(t.seq),t0:String(t.t0),t1:String(t.t1),n:String(t.n),full:t.full?"1":"0",enc:e};if(this.o.app&&(i.app=this.o.app),t.base&&(i.base=t.base),t.anchors.length){let o="";for(const r of t.anchors){if(o.length+r.length+1>g){i.am="1";break}o+=(o?",":"")+r}i.a=o}return s.length&&(i.b=s.join(",")),i}url(t,e){const s=new URLSearchParams(this.o.token?{token:this.o.token,...e}:e);return`${this.o.endpoint}${t}?${s.toString()}`}async post(t,e,s){const i=await this.o.env.fetch(this.url(t,e),{method:"POST",headers:{"content-type":"text/plain"},body:s,credentials:"omit",keepalive:(typeof s=="string"?s.length:s.byteLength)<=this.o.beaconMaxBytes}),o=await i.text().catch(()=>"");return{status:i.status,text:u(async()=>o,"text")}}sleep(t){return new Promise(e=>this.o.env.setTimeout(e,t))}}function c(n){if(n===413)return"too-big";if(n===400||n===401||n===403||n===404||n===405||n===426)return"refused"}a(c,"c"),u(c,"verdictOf");async function p(n){try{const t=JSON.parse(await n.text());return Array.isArray(t.missing)?t.missing.filter(e=>typeof e=="string"):[]}catch{return[]}}a(p,"y"),u(p,"missingOf");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/client-runtime",
3
- "version": "1.25.17",
3
+ "version": "1.25.21",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -16,9 +16,27 @@
16
16
  "types": "./auto.d.ts",
17
17
  "import": "./auto.js",
18
18
  "default": "./auto.js"
19
+ },
20
+ "./replay": {
21
+ "types": "./replay.d.ts",
22
+ "import": "./replay.js",
23
+ "default": "./replay.js"
24
+ },
25
+ "./replay-format": {
26
+ "types": "./replay-format.d.ts",
27
+ "import": "./replay-format.js",
28
+ "default": "./replay-format.js"
29
+ },
30
+ "./replay-source": {
31
+ "types": "./replay-source.d.ts",
32
+ "import": "./replay-source.js",
33
+ "default": "./replay-source.js"
19
34
  }
20
35
  },
21
36
  "dependencies": {
37
+ "@lensmcp/core": "1.25.21",
38
+ "@rrweb/record": "2.1.7",
39
+ "@rrweb/types": "2.1.7",
22
40
  "tslib": "^2.3.0"
23
41
  },
24
42
  "license": "Apache-2.0",
@@ -0,0 +1 @@
1
+ export {};
package/replay-boot.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";import{record as p}from"@rrweb/record";import{startReplay as n}from"./lib/replay/start.js";const e="__LENSMCP_REPLAY_BOOT__",o=globalThis,t=o[e];if(t&&typeof t.endpoint=="string"&&typeof t.tabId=="string"){delete o[e];try{n({endpoint:t.endpoint,...t.token?{token:t.token}:{},tabId:t.tabId,...t.app?{app:t.app}:{},record:p,...t.fetch?{env:{fetch:t.fetch}}:{}})}catch{}}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@lensmcp/client-runtime/replay-format` — the recording's wire format, with no rrweb and no DOM: what the bridge
3
+ * (stores chunks) and the lens (reads them back) share with the recorder.
4
+ */
5
+ export { BLOB_PREFIX, rehydrate } from './lib/replay/blobs.js';
6
+ export { LENS_TAGS, isLensTag, anchorIds, type LensTag, type LensPayloads, type TargetPayload, type GapReason } from './lib/replay/anchors.js';
7
+ export { REPLAY_WIRE_VERSION, RRWEB_VERSION } from './lib/replay/types.js';
8
+ export type { ChunkSummary } from './lib/replay/uploader.js';
@@ -0,0 +1 @@
1
+ "use strict";export{BLOB_PREFIX,rehydrate}from"./lib/replay/blobs.js";export{LENS_TAGS,isLensTag,anchorIds}from"./lib/replay/anchors.js";export{REPLAY_WIRE_VERSION,RRWEB_VERSION}from"./lib/replay/types.js";