@sailfish-ai/recorder 1.8.21 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/babel-plugin-sailfish-source.cjs +45 -0
- package/dist/babel-plugin-sailfish-source.js +182 -0
- package/dist/babel-plugin-sailfish-source.js.br +0 -0
- package/dist/babel-plugin-sailfish-source.js.gz +0 -0
- package/dist/fiberHook.js +319 -0
- package/dist/fiberTypes.js +25 -0
- package/dist/index.js +57 -12
- package/dist/recorder.cjs +1606 -1465
- package/dist/recorder.js +1255 -1114
- package/dist/recorder.js.br +0 -0
- package/dist/recorder.js.gz +0 -0
- package/dist/recording.js +18 -25
- package/dist/types/babel-plugin-sailfish-source.d.ts +40 -0
- package/dist/types/fiberHook.d.ts +28 -0
- package/dist/types/fiberTypes.d.ts +122 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/types.d.ts +1 -0
- package/package.json +20 -4
- package/dist/recorder.umd.cjs +0 -8138
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const e = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea", "details", "summary", "Button", "Link", "Input", "Select", "Checkbox", "Radio", "Switch", "Tab", "MenuItem", "IconButton", "TextField", "TextArea"]), t = /* @__PURE__ */ new Set(["button", "link", "checkbox", "radio", "switch", "tab", "menuitem", "option", "combobox", "textbox", "searchbox", "slider", "spinbutton"]);
|
|
4
|
+
function sailfishSourcePlugin(i) {
|
|
5
|
+
const { types: n } = i;
|
|
6
|
+
return { name: "sailfish-source", visitor: { JSXOpeningElement(i2, r) {
|
|
7
|
+
const { opts: s = {}, filename: o } = r, { allElements: a = false, rootDir: u = process.cwd() } = s;
|
|
8
|
+
if (!o) return;
|
|
9
|
+
const l = i2.node.name;
|
|
10
|
+
let c;
|
|
11
|
+
if (n.isJSXIdentifier(l)) c = l.name;
|
|
12
|
+
else {
|
|
13
|
+
if (!n.isJSXMemberExpression(l)) return;
|
|
14
|
+
c = n.isJSXIdentifier(l.property) ? l.property.name : "";
|
|
15
|
+
}
|
|
16
|
+
if (!a) {
|
|
17
|
+
const r2 = (function isInteractiveElement(i3, n2, r3) {
|
|
18
|
+
if (e.has(n2)) return true;
|
|
19
|
+
n2[0], n2[0].toLowerCase();
|
|
20
|
+
for (const e2 of i3.node.attributes) {
|
|
21
|
+
if (!r3.isJSXAttribute(e2) || !r3.isJSXIdentifier(e2.name)) continue;
|
|
22
|
+
const i4 = e2.name.name;
|
|
23
|
+
if ("onClick" === i4 || "onPress" === i4) return true;
|
|
24
|
+
if ("data-testid" === i4) return true;
|
|
25
|
+
if ("role" === i4 && r3.isStringLiteral(e2.value) && t.has(e2.value.value.toLowerCase())) return true;
|
|
26
|
+
if ("tabIndex" === i4) return true;
|
|
27
|
+
if ("href" === i4 || "to" === i4) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
})(i2, c, n);
|
|
31
|
+
if (!r2) return;
|
|
32
|
+
}
|
|
33
|
+
if (i2.node.attributes.some((e2) => n.isJSXAttribute(e2) && n.isJSXIdentifier(e2.name) && "data-sf-source" === e2.name.name)) return;
|
|
34
|
+
const f = i2.node.loc;
|
|
35
|
+
if (!f) return;
|
|
36
|
+
let d = o;
|
|
37
|
+
try {
|
|
38
|
+
d = (void 0)(u, o), d = d.replace(/\\/g, "/");
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
const m = `${d}:${f.start.line}`, b = n.jsxAttribute(n.jsxIdentifier("data-sf-source"), n.stringLiteral(m));
|
|
42
|
+
i2.node.attributes.push(b);
|
|
43
|
+
} } };
|
|
44
|
+
}
|
|
45
|
+
exports.default = sailfishSourcePlugin, exports.sailfishSourcePlugin = sailfishSourcePlugin;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Babel Plugin: Sailfish Source Injection
|
|
3
|
+
*
|
|
4
|
+
* Injects data-sf-source attributes into JSX elements at compile time.
|
|
5
|
+
* This provides source file/line information for coverage analysis.
|
|
6
|
+
*
|
|
7
|
+
* Usage in babel.config.js:
|
|
8
|
+
*
|
|
9
|
+
* const sailfishSourcePlugin = require('@anthropic/veritas/babel-plugin-sailfish-source');
|
|
10
|
+
*
|
|
11
|
+
* module.exports = {
|
|
12
|
+
* plugins: [
|
|
13
|
+
* [sailfishSourcePlugin, {
|
|
14
|
+
* // Options:
|
|
15
|
+
* // - allElements: Add to all elements (default: false, only interactive)
|
|
16
|
+
* // - rootDir: Strip this prefix from file paths (default: process.cwd())
|
|
17
|
+
* }]
|
|
18
|
+
* ]
|
|
19
|
+
* };
|
|
20
|
+
*
|
|
21
|
+
* Or in vite.config.ts:
|
|
22
|
+
*
|
|
23
|
+
* import sailfishSourcePlugin from '@anthropic/veritas/babel-plugin-sailfish-source';
|
|
24
|
+
*
|
|
25
|
+
* export default defineConfig({
|
|
26
|
+
* plugins: [
|
|
27
|
+
* react({
|
|
28
|
+
* babel: {
|
|
29
|
+
* plugins: [sailfishSourcePlugin]
|
|
30
|
+
* }
|
|
31
|
+
* })
|
|
32
|
+
* ]
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
import * as path from "path";
|
|
36
|
+
// Interactive element tag names that get source info by default
|
|
37
|
+
const INTERACTIVE_TAGS = new Set([
|
|
38
|
+
"button",
|
|
39
|
+
"a",
|
|
40
|
+
"input",
|
|
41
|
+
"select",
|
|
42
|
+
"textarea",
|
|
43
|
+
"details",
|
|
44
|
+
"summary",
|
|
45
|
+
// Also include common component patterns
|
|
46
|
+
"Button",
|
|
47
|
+
"Link",
|
|
48
|
+
"Input",
|
|
49
|
+
"Select",
|
|
50
|
+
"Checkbox",
|
|
51
|
+
"Radio",
|
|
52
|
+
"Switch",
|
|
53
|
+
"Tab",
|
|
54
|
+
"MenuItem",
|
|
55
|
+
"IconButton",
|
|
56
|
+
"TextField",
|
|
57
|
+
"TextArea",
|
|
58
|
+
]);
|
|
59
|
+
// Interactive role values
|
|
60
|
+
const INTERACTIVE_ROLES = new Set([
|
|
61
|
+
"button",
|
|
62
|
+
"link",
|
|
63
|
+
"checkbox",
|
|
64
|
+
"radio",
|
|
65
|
+
"switch",
|
|
66
|
+
"tab",
|
|
67
|
+
"menuitem",
|
|
68
|
+
"option",
|
|
69
|
+
"combobox",
|
|
70
|
+
"textbox",
|
|
71
|
+
"searchbox",
|
|
72
|
+
"slider",
|
|
73
|
+
"spinbutton",
|
|
74
|
+
]);
|
|
75
|
+
export default function sailfishSourcePlugin(babel) {
|
|
76
|
+
const { types: t } = babel;
|
|
77
|
+
return {
|
|
78
|
+
name: "sailfish-source",
|
|
79
|
+
visitor: {
|
|
80
|
+
JSXOpeningElement(nodePath, state) {
|
|
81
|
+
const { opts = {}, filename } = state;
|
|
82
|
+
const { allElements = false, rootDir = process.cwd() } = opts;
|
|
83
|
+
// Skip if no filename available
|
|
84
|
+
if (!filename) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Get element name
|
|
88
|
+
const nameNode = nodePath.node.name;
|
|
89
|
+
let tagName;
|
|
90
|
+
if (t.isJSXIdentifier(nameNode)) {
|
|
91
|
+
tagName = nameNode.name;
|
|
92
|
+
}
|
|
93
|
+
else if (t.isJSXMemberExpression(nameNode)) {
|
|
94
|
+
// e.g., MUI.Button -> Button
|
|
95
|
+
tagName = t.isJSXIdentifier(nameNode.property)
|
|
96
|
+
? nameNode.property.name
|
|
97
|
+
: "";
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Check if element should get source info
|
|
103
|
+
if (!allElements) {
|
|
104
|
+
const isInteractive = isInteractiveElement(nodePath, tagName, t);
|
|
105
|
+
if (!isInteractive) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Check if already has data-sf-source
|
|
110
|
+
const hasSourceAttr = nodePath.node.attributes.some((attr) => t.isJSXAttribute(attr) &&
|
|
111
|
+
t.isJSXIdentifier(attr.name) &&
|
|
112
|
+
attr.name.name === "data-sf-source");
|
|
113
|
+
if (hasSourceAttr) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Get source location
|
|
117
|
+
const loc = nodePath.node.loc;
|
|
118
|
+
if (!loc) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Create relative file path
|
|
122
|
+
let relativePath = filename;
|
|
123
|
+
try {
|
|
124
|
+
relativePath = path.relative(rootDir, filename);
|
|
125
|
+
// Normalize to forward slashes
|
|
126
|
+
relativePath = relativePath.replace(/\\/g, "/");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Keep absolute path if relative fails
|
|
130
|
+
}
|
|
131
|
+
// Create source string: "file:line"
|
|
132
|
+
const sourceValue = `${relativePath}:${loc.start.line}`;
|
|
133
|
+
// Add data-sf-source attribute
|
|
134
|
+
const sourceAttr = t.jsxAttribute(t.jsxIdentifier("data-sf-source"), t.stringLiteral(sourceValue));
|
|
135
|
+
nodePath.node.attributes.push(sourceAttr);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if element is interactive based on tag name, attributes, or role.
|
|
142
|
+
*/
|
|
143
|
+
function isInteractiveElement(nodePath, tagName, t) {
|
|
144
|
+
// Check tag name
|
|
145
|
+
if (INTERACTIVE_TAGS.has(tagName)) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
// Check for lowercase tags (HTML elements)
|
|
149
|
+
const isLowerCase = tagName[0] === tagName[0].toLowerCase();
|
|
150
|
+
// Check attributes
|
|
151
|
+
for (const attr of nodePath.node.attributes) {
|
|
152
|
+
if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const attrName = attr.name.name;
|
|
156
|
+
// Has onClick handler
|
|
157
|
+
if (attrName === "onClick" || attrName === "onPress") {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
// Has data-testid (likely important for testing)
|
|
161
|
+
if (attrName === "data-testid") {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
// Has role attribute with interactive value
|
|
165
|
+
if (attrName === "role" && t.isStringLiteral(attr.value)) {
|
|
166
|
+
if (INTERACTIVE_ROLES.has(attr.value.value.toLowerCase())) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Has tabIndex (focusable)
|
|
171
|
+
if (attrName === "tabIndex") {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
// Has href (link-like)
|
|
175
|
+
if (attrName === "href" || attrName === "to") {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
// Also export as named export for ESM
|
|
182
|
+
export { sailfishSourcePlugin };
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Fiber Hook Implementation
|
|
3
|
+
*
|
|
4
|
+
* Hooks into React's internals using the same mechanism as React DevTools.
|
|
5
|
+
* Adds data-sf-component attributes to DOM elements for coverage correlation.
|
|
6
|
+
* These attributes are captured by rrweb and extracted during on-demand analysis.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: React 19 removed _debugSource, so we use _debugOwner to get component names.
|
|
9
|
+
* Source file/line information is no longer available directly from fibers.
|
|
10
|
+
*/
|
|
11
|
+
import { FiberTag } from "./fiberTypes";
|
|
12
|
+
// Debug flag - enable for troubleshooting
|
|
13
|
+
const DEBUG = false;
|
|
14
|
+
// Track elements we've already annotated to avoid re-processing
|
|
15
|
+
const annotatedElements = new WeakSet();
|
|
16
|
+
// Flag to track if hook is installed
|
|
17
|
+
let hookInstalled = false;
|
|
18
|
+
// Store reference to our handler for chaining
|
|
19
|
+
let ourCommitHandler = null;
|
|
20
|
+
let originalCommitHandler = null;
|
|
21
|
+
// Check if we're in development mode
|
|
22
|
+
const IS_DEV = process.env.NODE_ENV !== "production";
|
|
23
|
+
/**
|
|
24
|
+
* Get the display name for a component from its Fiber.
|
|
25
|
+
*/
|
|
26
|
+
function getDisplayName(fiber) {
|
|
27
|
+
const type = fiber.type;
|
|
28
|
+
if (!type) {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
// Function or class component
|
|
32
|
+
if (typeof type === "function") {
|
|
33
|
+
return type.displayName || type.name || "";
|
|
34
|
+
}
|
|
35
|
+
// ForwardRef
|
|
36
|
+
if (typeof type === "object" && type !== null) {
|
|
37
|
+
if (type.$$typeof) {
|
|
38
|
+
// React.forwardRef
|
|
39
|
+
if (type.render) {
|
|
40
|
+
return type.render.displayName || type.render.name || "";
|
|
41
|
+
}
|
|
42
|
+
// React.memo
|
|
43
|
+
if (type.type) {
|
|
44
|
+
return getDisplayNameFromType(type.type);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Host component (DOM element) - don't return tag name
|
|
49
|
+
if (typeof type === "string") {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Helper to get display name from a type object.
|
|
56
|
+
*/
|
|
57
|
+
function getDisplayNameFromType(type) {
|
|
58
|
+
if (typeof type === "function") {
|
|
59
|
+
return type.displayName || type.name || "";
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get component names from _debugOwner chain.
|
|
65
|
+
* Returns array from nearest owner to root (e.g., ["Button", "Sidebar", "App"])
|
|
66
|
+
*/
|
|
67
|
+
function getOwnerChain(fiber) {
|
|
68
|
+
const owners = [];
|
|
69
|
+
let owner = fiber?._debugOwner;
|
|
70
|
+
while (owner) {
|
|
71
|
+
const name = getDisplayName(owner);
|
|
72
|
+
if (name && !owners.includes(name)) {
|
|
73
|
+
owners.push(name);
|
|
74
|
+
}
|
|
75
|
+
owner = owner._debugOwner;
|
|
76
|
+
}
|
|
77
|
+
return owners;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Find the nearest named component by walking up _debugOwner chain.
|
|
81
|
+
*/
|
|
82
|
+
function findNearestComponentName(fiber) {
|
|
83
|
+
// First check the fiber itself
|
|
84
|
+
const selfName = fiber ? getDisplayName(fiber) : "";
|
|
85
|
+
if (selfName) {
|
|
86
|
+
return selfName;
|
|
87
|
+
}
|
|
88
|
+
// Then check _debugOwner chain
|
|
89
|
+
let owner = fiber?._debugOwner;
|
|
90
|
+
while (owner) {
|
|
91
|
+
const name = getDisplayName(owner);
|
|
92
|
+
if (name) {
|
|
93
|
+
return name;
|
|
94
|
+
}
|
|
95
|
+
owner = owner._debugOwner;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get the React Fiber node attached to a DOM element.
|
|
101
|
+
*/
|
|
102
|
+
function getFiberFromElement(element) {
|
|
103
|
+
const keys = Object.keys(element);
|
|
104
|
+
// React 18+ uses __reactFiber$
|
|
105
|
+
const fiberKey = keys.find((key) => key.startsWith("__reactFiber$"));
|
|
106
|
+
if (fiberKey) {
|
|
107
|
+
return element[fiberKey];
|
|
108
|
+
}
|
|
109
|
+
// Older React versions use __reactInternalInstance$
|
|
110
|
+
const internalKey = keys.find((key) => key.startsWith("__reactInternalInstance$"));
|
|
111
|
+
if (internalKey) {
|
|
112
|
+
return element[internalKey];
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Add component attributes to a DOM element.
|
|
118
|
+
*
|
|
119
|
+
* Attributes added:
|
|
120
|
+
* - data-sf-component: Nearest owner component name (e.g., "Button")
|
|
121
|
+
* - data-sf-owners: Full owner chain (e.g., "Button,Sidebar,App")
|
|
122
|
+
*/
|
|
123
|
+
function annotateElement(element, fiber) {
|
|
124
|
+
if (annotatedElements.has(element)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
// Get component name from owner chain
|
|
128
|
+
const componentName = findNearestComponentName(fiber);
|
|
129
|
+
if (!componentName) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// Get full owner chain for more context
|
|
133
|
+
const ownerChain = getOwnerChain(fiber);
|
|
134
|
+
try {
|
|
135
|
+
// Add component name
|
|
136
|
+
element.setAttribute("data-sf-component", componentName);
|
|
137
|
+
// Add owner chain if we have multiple owners
|
|
138
|
+
if (ownerChain.length > 0) {
|
|
139
|
+
element.setAttribute("data-sf-owners", ownerChain.join(","));
|
|
140
|
+
}
|
|
141
|
+
annotatedElements.add(element);
|
|
142
|
+
if (DEBUG) {
|
|
143
|
+
console.log(`[Sailfish Fiber] Annotated ${element.tagName} with component: ${componentName}, owners: ${ownerChain.join(" > ")}`);
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
// Ignore errors (e.g., SVG elements may not support setAttribute)
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Process a single DOM element - find its fiber and add component attributes.
|
|
154
|
+
*/
|
|
155
|
+
function processElement(element) {
|
|
156
|
+
if (annotatedElements.has(element)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const fiber = getFiberFromElement(element);
|
|
160
|
+
if (!fiber) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return annotateElement(element, fiber);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Process all DOM elements in a fiber's subtree.
|
|
167
|
+
*/
|
|
168
|
+
function processFiberDOM(fiber) {
|
|
169
|
+
let count = 0;
|
|
170
|
+
// If this fiber has a direct DOM node
|
|
171
|
+
if (fiber.tag === FiberTag.HostComponent &&
|
|
172
|
+
fiber.stateNode instanceof HTMLElement) {
|
|
173
|
+
if (annotateElement(fiber.stateNode, fiber)) {
|
|
174
|
+
count++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Process children
|
|
178
|
+
let child = fiber.child;
|
|
179
|
+
while (child) {
|
|
180
|
+
count += processFiberDOM(child);
|
|
181
|
+
child = child.sibling;
|
|
182
|
+
}
|
|
183
|
+
return count;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Process a commit, adding component attributes to DOM elements.
|
|
187
|
+
*/
|
|
188
|
+
function processCommit(root) {
|
|
189
|
+
let processedCount = 0;
|
|
190
|
+
try {
|
|
191
|
+
const currentFiber = root.current;
|
|
192
|
+
if (!currentFiber) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
processedCount = processFiberDOM(currentFiber);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// Never break React - log and continue
|
|
199
|
+
console.warn("[Sailfish] Error processing fiber commit:", error);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (DEBUG && processedCount > 0) {
|
|
203
|
+
console.log(`[Sailfish Fiber] Commit processed: ${processedCount} elements annotated`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Create our commit handler that processes commits and chains to original.
|
|
208
|
+
*/
|
|
209
|
+
function createCommitHandler(existingHook) {
|
|
210
|
+
return function onCommitFiberRoot(rendererID, root, priorityLevel) {
|
|
211
|
+
try {
|
|
212
|
+
processCommit(root);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
console.warn("[Sailfish] Error in onCommitFiberRoot:", error);
|
|
216
|
+
}
|
|
217
|
+
// Call original if it exists
|
|
218
|
+
if (originalCommitHandler) {
|
|
219
|
+
try {
|
|
220
|
+
originalCommitHandler.call(existingHook, rendererID, root, priorityLevel);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
console.warn("[Sailfish] Error calling original onCommitFiberRoot:", error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Install the Fiber hook.
|
|
230
|
+
*
|
|
231
|
+
* NOTE: For best results, this should be called BEFORE React loads.
|
|
232
|
+
* If React has already loaded, call processExistingTree() after.
|
|
233
|
+
*
|
|
234
|
+
* @returns true if hook was installed, false if already installed or failed
|
|
235
|
+
*/
|
|
236
|
+
export function installFiberHook() {
|
|
237
|
+
if (hookInstalled) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
if (typeof window === "undefined") {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const existingHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
245
|
+
if (existingHook) {
|
|
246
|
+
// Save original commit handler
|
|
247
|
+
originalCommitHandler =
|
|
248
|
+
existingHook.onCommitFiberRoot?.bind(existingHook) || null;
|
|
249
|
+
// Create our handler
|
|
250
|
+
ourCommitHandler = createCommitHandler(existingHook);
|
|
251
|
+
// Replace handler on the existing hook
|
|
252
|
+
try {
|
|
253
|
+
existingHook.onCommitFiberRoot = ourCommitHandler;
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
console.error("[Sailfish Fiber] Failed to replace handler:", e);
|
|
257
|
+
}
|
|
258
|
+
console.log("[Sailfish] React Fiber hook installed (chained with existing)");
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Create our handler
|
|
262
|
+
ourCommitHandler = createCommitHandler(null);
|
|
263
|
+
// Install our hook
|
|
264
|
+
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
265
|
+
supportsFiber: true,
|
|
266
|
+
renderers: new Map(),
|
|
267
|
+
inject(renderer) {
|
|
268
|
+
const id = this.renderers.size + 1;
|
|
269
|
+
this.renderers.set(id, renderer);
|
|
270
|
+
return id;
|
|
271
|
+
},
|
|
272
|
+
onCommitFiberRoot: ourCommitHandler,
|
|
273
|
+
};
|
|
274
|
+
console.log("[Sailfish] React Fiber hook installed (fresh)");
|
|
275
|
+
}
|
|
276
|
+
hookInstalled = true;
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
console.warn("[Sailfish] Failed to install fiber hook:", error);
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Check if the fiber hook is installed.
|
|
286
|
+
*/
|
|
287
|
+
export function isFiberHookInstalled() {
|
|
288
|
+
return hookInstalled;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Force process the current React tree by walking the DOM.
|
|
292
|
+
* Call this after React has mounted to capture the initial tree.
|
|
293
|
+
*/
|
|
294
|
+
export function processExistingTree() {
|
|
295
|
+
if (typeof window === "undefined")
|
|
296
|
+
return;
|
|
297
|
+
if (!IS_DEV)
|
|
298
|
+
return;
|
|
299
|
+
let processedCount = 0;
|
|
300
|
+
let totalElements = 0;
|
|
301
|
+
try {
|
|
302
|
+
// Walk all elements in the document
|
|
303
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null);
|
|
304
|
+
let node = walker.currentNode;
|
|
305
|
+
while (node) {
|
|
306
|
+
totalElements++;
|
|
307
|
+
if (node instanceof HTMLElement) {
|
|
308
|
+
if (processElement(node)) {
|
|
309
|
+
processedCount++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
node = walker.nextNode();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
console.warn("[Sailfish Fiber] Error processing existing tree:", error);
|
|
317
|
+
}
|
|
318
|
+
console.log(`[Sailfish Fiber] Processed existing tree: ${processedCount} elements annotated out of ${totalElements} elements`);
|
|
319
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Fiber types for component visibility tracking.
|
|
3
|
+
* These interfaces represent the minimal subset of React's internal Fiber structure
|
|
4
|
+
* needed for instrumentation without coupling to React's implementation details.
|
|
5
|
+
*/
|
|
6
|
+
// React Fiber work tags (subset we care about)
|
|
7
|
+
export const FiberTag = {
|
|
8
|
+
FunctionComponent: 0,
|
|
9
|
+
ClassComponent: 1,
|
|
10
|
+
IndeterminateComponent: 2,
|
|
11
|
+
HostRoot: 3,
|
|
12
|
+
HostPortal: 4,
|
|
13
|
+
HostComponent: 5,
|
|
14
|
+
HostText: 6,
|
|
15
|
+
Fragment: 7,
|
|
16
|
+
Mode: 8,
|
|
17
|
+
ContextConsumer: 9,
|
|
18
|
+
ContextProvider: 10,
|
|
19
|
+
ForwardRef: 11,
|
|
20
|
+
Profiler: 12,
|
|
21
|
+
SuspenseComponent: 13,
|
|
22
|
+
MemoComponent: 14,
|
|
23
|
+
SimpleMemoComponent: 15,
|
|
24
|
+
LazyComponent: 16,
|
|
25
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,8 @@ const INCLUDE = "include";
|
|
|
39
39
|
const SAME_ORIGIN = "same-origin";
|
|
40
40
|
const ALLOWED_HEADERS_HEADER = "access-control-allow-headers";
|
|
41
41
|
const OPTIONS = "OPTIONS";
|
|
42
|
+
const AUTHORIZATION_HEADER_KEY_LOWER = "authorization";
|
|
43
|
+
const AUTHORIZATION_HEADER_KEY = "Authorization";
|
|
42
44
|
const ActionType = {
|
|
43
45
|
PROPAGATE: "propagate",
|
|
44
46
|
IGNORE: "ignore",
|
|
@@ -54,6 +56,7 @@ export const DEFAULT_CAPTURE_SETTINGS = {
|
|
|
54
56
|
recordSsn: false,
|
|
55
57
|
recordDob: false,
|
|
56
58
|
sampling: {},
|
|
59
|
+
enableFiberTracking: true,
|
|
57
60
|
};
|
|
58
61
|
export const DEFAULT_CONSOLE_RECORDING_SETTINGS = {
|
|
59
62
|
level: ["info", "log", "warn", "error"],
|
|
@@ -83,6 +86,41 @@ export const DEFAULT_CONSOLE_RECORDING_SETTINGS = {
|
|
|
83
86
|
// recordBody: true,
|
|
84
87
|
// recordInitialRequests: false,
|
|
85
88
|
// };
|
|
89
|
+
function maskAuthorizationHeader(requestHeaders) {
|
|
90
|
+
const authKey = requestHeaders[AUTHORIZATION_HEADER_KEY_LOWER]
|
|
91
|
+
? AUTHORIZATION_HEADER_KEY_LOWER
|
|
92
|
+
: requestHeaders[AUTHORIZATION_HEADER_KEY]
|
|
93
|
+
? AUTHORIZATION_HEADER_KEY
|
|
94
|
+
: null;
|
|
95
|
+
if (!authKey) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const authValue = requestHeaders[authKey];
|
|
99
|
+
const spaceIndex = authValue.indexOf(" ");
|
|
100
|
+
if (spaceIndex !== -1) {
|
|
101
|
+
const scheme = authValue.slice(0, spaceIndex + 1); // "Bearer "
|
|
102
|
+
const token = authValue.slice(spaceIndex + 1);
|
|
103
|
+
if (token.length > 8) {
|
|
104
|
+
requestHeaders[authKey] =
|
|
105
|
+
scheme +
|
|
106
|
+
token.slice(0, 4) +
|
|
107
|
+
"*".repeat(token.length - 8) +
|
|
108
|
+
token.slice(-4);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
requestHeaders[authKey] = scheme + "*".repeat(token.length);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (authValue.length > 8) {
|
|
115
|
+
requestHeaders[authKey] =
|
|
116
|
+
authValue.slice(0, 4) +
|
|
117
|
+
"*".repeat(authValue.length - 8) +
|
|
118
|
+
authValue.slice(-4);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
requestHeaders[authKey] = "*".repeat(authValue.length);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
86
124
|
function trackDomainChangesOnce() {
|
|
87
125
|
const g = (window.__sailfish_recorder ||= {});
|
|
88
126
|
if (g.routeWatcherIntervalId) {
|
|
@@ -403,12 +441,11 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
|
|
|
403
441
|
const startTime = Date.now();
|
|
404
442
|
let finished = false;
|
|
405
443
|
const requestBody = args[0]; // Capture the request body/payload
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
444
|
+
// Note: _capturedRequestHeaders already includes x-sf3-rid and funcSpan headers
|
|
445
|
+
// because setRequestHeader() above goes through our intercepted version
|
|
446
|
+
const requestHeaders = { ...this._capturedRequestHeaders };
|
|
447
|
+
// Mask authorization header for privacy (preserve scheme like "Bearer", mask the token)
|
|
448
|
+
maskAuthorizationHeader(requestHeaders);
|
|
412
449
|
// 4️⃣ Helper to emit networkRequestFinished
|
|
413
450
|
const emitFinished = (success, status, errorMsg, responseData, responseHeaders) => {
|
|
414
451
|
if (finished)
|
|
@@ -576,20 +613,28 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
576
613
|
console.warn("[Sailfish] Failed to capture request data:", e);
|
|
577
614
|
}
|
|
578
615
|
}
|
|
579
|
-
//
|
|
616
|
+
// Remove any existing Sailfish headers (in case app already set them)
|
|
580
617
|
delete requestHeaders[xSf3RidHeader];
|
|
581
|
-
const
|
|
582
|
-
if (
|
|
583
|
-
delete requestHeaders[
|
|
618
|
+
const funcSpanHeader = getFuncSpanHeader();
|
|
619
|
+
if (funcSpanHeader) {
|
|
620
|
+
delete requestHeaders[funcSpanHeader.name];
|
|
621
|
+
}
|
|
622
|
+
// Add Sailfish headers with correct values
|
|
623
|
+
const xSf3RidValue = `${sessionId}/${urlAndStoredUuids.page_visit_uuid}/${networkUUID}`;
|
|
624
|
+
requestHeaders[xSf3RidHeader] = xSf3RidValue;
|
|
625
|
+
if (funcSpanHeader) {
|
|
626
|
+
requestHeaders[funcSpanHeader.name] = funcSpanHeader.value;
|
|
584
627
|
}
|
|
628
|
+
// Mask authorization header for privacy (preserve scheme like "Bearer", mask the token)
|
|
629
|
+
maskAuthorizationHeader(requestHeaders);
|
|
585
630
|
try {
|
|
586
631
|
let response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
|
|
587
632
|
let isRetry = false;
|
|
588
633
|
// Retry logic for 400/403 before logging finished event
|
|
589
634
|
if (BAD_HTTP_STATUS.includes(response.status)) {
|
|
590
635
|
DEBUG && console.log("Perform retry as status was fail:", response);
|
|
591
|
-
//
|
|
592
|
-
|
|
636
|
+
// Remove x-sf3-rid from captured headers since retry doesn't include it
|
|
637
|
+
delete requestHeaders[xSf3RidHeader];
|
|
593
638
|
response = await retryWithoutPropagateHeaders(target, thisArg, args, url);
|
|
594
639
|
isRetry = true;
|
|
595
640
|
}
|