@reproapp/react-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +1261 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { record } from "rrweb";
|
|
4
|
+
import { gzip } from 'pako';
|
|
5
|
+
// config
|
|
6
|
+
const MAX_BYTES = 900 * 1024; // 900 KB target per POST (tune)
|
|
7
|
+
const SESSION_MAX_MS = 60 * 1000; // cap each session at 1 minute
|
|
8
|
+
// estimate JSON bytes
|
|
9
|
+
function jsonBytes(obj) {
|
|
10
|
+
try {
|
|
11
|
+
return new TextEncoder().encode(JSON.stringify(obj)).length;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return Infinity;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// split [events] into <= MAX_BYTES chunks by bisection
|
|
18
|
+
function splitEventsBySize(events, mkEnvelope) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const stack = [events.slice(0)]; // LIFO for fewer allocs
|
|
21
|
+
while (stack.length) {
|
|
22
|
+
const cur = stack.pop();
|
|
23
|
+
const env = mkEnvelope(cur);
|
|
24
|
+
if (jsonBytes(env) <= MAX_BYTES || cur.length <= 1) {
|
|
25
|
+
out.push(cur);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const mid = Math.floor(cur.length / 2);
|
|
29
|
+
stack.push(cur.slice(0, mid));
|
|
30
|
+
stack.push(cur.slice(mid));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// ---- small helpers ----
|
|
35
|
+
const now = () => Date.now();
|
|
36
|
+
const newAID = () => `A_${now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
37
|
+
// server time normalization
|
|
38
|
+
const offsetRef = { current: 0 }; // ms to add to client time
|
|
39
|
+
const nowServer = () => now() + (offsetRef.current || 0);
|
|
40
|
+
async function getJSON(url, headers) {
|
|
41
|
+
const r = await fetch(url, headers ? { headers } : undefined);
|
|
42
|
+
if (!r.ok)
|
|
43
|
+
throw new Error(`${r.status}`);
|
|
44
|
+
return (await r.json());
|
|
45
|
+
}
|
|
46
|
+
const INTERNAL_HEADER = "X-Repro-Internal";
|
|
47
|
+
const REQUEST_START_HEADER = "X-Bug-Request-Start";
|
|
48
|
+
const NGROK_SKIP_HEADER = "ngrok-skip-browser-warning";
|
|
49
|
+
const NGROK_SKIP_VALUE = "true";
|
|
50
|
+
const API_BASE = "https://oozy-loreta-gully.ngrok-free.dev";
|
|
51
|
+
let __reproCtx = null;
|
|
52
|
+
/** Manually attach Repro to any Axios instance (no recursion). */
|
|
53
|
+
export function attachAxios(axiosInstance) {
|
|
54
|
+
if (!axiosInstance || axiosInstance.__reproAttached)
|
|
55
|
+
return;
|
|
56
|
+
axiosInstance.__reproAttached = true;
|
|
57
|
+
axiosInstance.interceptors.request.use((config) => {
|
|
58
|
+
const ctx = __reproCtx;
|
|
59
|
+
if (!ctx)
|
|
60
|
+
return config;
|
|
61
|
+
const sid = ctx.getSid();
|
|
62
|
+
const aid = ctx.getAid();
|
|
63
|
+
const url = `${config.baseURL || ""}${config.url || ""}`;
|
|
64
|
+
const isInternal = url.startsWith(ctx.base);
|
|
65
|
+
const isSdkInternal = !!config.headers?.[INTERNAL_HEADER];
|
|
66
|
+
if (!config.headers)
|
|
67
|
+
config.headers = {};
|
|
68
|
+
const setHeader = (key, value) => {
|
|
69
|
+
if (!config.headers)
|
|
70
|
+
return;
|
|
71
|
+
if (typeof config.headers.set === "function") {
|
|
72
|
+
config.headers.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
config.headers[key] = value;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
if (!isSdkInternal) {
|
|
79
|
+
const requestStart = nowServer();
|
|
80
|
+
setHeader(REQUEST_START_HEADER, String(requestStart));
|
|
81
|
+
}
|
|
82
|
+
const sdkToken = ctx.getToken();
|
|
83
|
+
const userToken = ctx.getUserToken();
|
|
84
|
+
if (isInternal) {
|
|
85
|
+
setHeader(NGROK_SKIP_HEADER, NGROK_SKIP_VALUE);
|
|
86
|
+
if (sdkToken && !config.headers["x-sdk-token"]) {
|
|
87
|
+
setHeader("x-sdk-token", sdkToken);
|
|
88
|
+
}
|
|
89
|
+
const existingAuth = config.headers?.Authorization ??
|
|
90
|
+
config.headers?.authorization;
|
|
91
|
+
if (userToken && !existingAuth) {
|
|
92
|
+
setHeader("Authorization", `Bearer ${userToken}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (sid && aid && !isInternal && !isSdkInternal) {
|
|
96
|
+
setHeader("X-Bug-Session-Id", sid);
|
|
97
|
+
setHeader("X-Bug-Action-Id", aid);
|
|
98
|
+
}
|
|
99
|
+
return config;
|
|
100
|
+
});
|
|
101
|
+
axiosInstance.interceptors.response.use(async (resp) => {
|
|
102
|
+
const ctx = __reproCtx;
|
|
103
|
+
if (!ctx)
|
|
104
|
+
return resp;
|
|
105
|
+
const url = `${resp.config.baseURL || ""}${resp.config.url || ""}`;
|
|
106
|
+
const isInternal = url.startsWith(ctx.base);
|
|
107
|
+
const isSdkInternal = !!resp.config.headers?.[INTERNAL_HEADER];
|
|
108
|
+
if (isInternal && resp.status === 401) {
|
|
109
|
+
ctx.onUnauthorized?.();
|
|
110
|
+
}
|
|
111
|
+
const sid = ctx.getSid();
|
|
112
|
+
const aid = ctx.getAid();
|
|
113
|
+
if (!isInternal && !isSdkInternal && sid && aid && !ctx.hasReqMarked.has(aid)) {
|
|
114
|
+
ctx.hasReqMarked.add(aid);
|
|
115
|
+
}
|
|
116
|
+
return resp;
|
|
117
|
+
}, (err) => {
|
|
118
|
+
const ctx = __reproCtx;
|
|
119
|
+
if (ctx) {
|
|
120
|
+
const status = err?.response?.status;
|
|
121
|
+
const url = `${err?.config?.baseURL || ""}${err?.config?.url || ""}`;
|
|
122
|
+
if (status === 401 && url.startsWith(ctx.base)) {
|
|
123
|
+
ctx.onUnauthorized?.();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return Promise.reject(err);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export function ReproProvider({ appId, children, button, masking }) {
|
|
130
|
+
const base = API_BASE;
|
|
131
|
+
const storageKey = `repro-auth-${appId}`;
|
|
132
|
+
const initialAuth = (() => {
|
|
133
|
+
if (typeof window === "undefined")
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
137
|
+
if (!raw)
|
|
138
|
+
return null;
|
|
139
|
+
const parsed = JSON.parse(raw);
|
|
140
|
+
if (!parsed || typeof parsed !== "object")
|
|
141
|
+
return null;
|
|
142
|
+
const email = typeof parsed.email === "string" ? parsed.email : null;
|
|
143
|
+
const password = typeof parsed.password === "string" ? parsed.password : null;
|
|
144
|
+
const token = typeof parsed.token === "string" && parsed.token.trim().length
|
|
145
|
+
? parsed.token.trim()
|
|
146
|
+
: null;
|
|
147
|
+
if (!email || !token)
|
|
148
|
+
return null;
|
|
149
|
+
return {
|
|
150
|
+
email,
|
|
151
|
+
password,
|
|
152
|
+
token,
|
|
153
|
+
data: parsed.data,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
const initialAuthRef = useRef(initialAuth);
|
|
161
|
+
// ---- refs & state (hooks MUST be inside component) ----
|
|
162
|
+
const sdkTokenRef = useRef(null);
|
|
163
|
+
const sessionIdRef = useRef(null);
|
|
164
|
+
const stopRecRef = useRef();
|
|
165
|
+
const rrwebEventsRef = useRef([]);
|
|
166
|
+
const currentAidRef = useRef(null);
|
|
167
|
+
const aidExpiryTimerRef = useRef(null);
|
|
168
|
+
const sessionExpiryTimerRef = useRef(null);
|
|
169
|
+
const origFetchRef = useRef();
|
|
170
|
+
const hasReqMarkedRef = useRef(new Set());
|
|
171
|
+
const lastActionLabelRef = useRef(null);
|
|
172
|
+
const actionMeta = useRef(new Map());
|
|
173
|
+
const nextSeqRef = useRef(1); // rrweb chunk counter
|
|
174
|
+
const isFlushingRef = useRef(false);
|
|
175
|
+
const backoffRef = useRef(0); // ms
|
|
176
|
+
const isStoppingRef = useRef(false);
|
|
177
|
+
// NEW: track installed click handler + dedupe recent clicks
|
|
178
|
+
const clickHandlerRef = useRef(null);
|
|
179
|
+
const lastClickRef = useRef(null);
|
|
180
|
+
const [ready, setReady] = useState(false);
|
|
181
|
+
const [recording, setRecording] = useState(false);
|
|
182
|
+
const recordingRef = useRef(false);
|
|
183
|
+
const [auth, setAuth] = useState(initialAuth);
|
|
184
|
+
const userPasswordRef = useRef(initialAuth?.password ?? null);
|
|
185
|
+
const userTokenRef = useRef(initialAuth?.token ?? null);
|
|
186
|
+
const [showLogin, setShowLogin] = useState(false);
|
|
187
|
+
const [loginEmail, setLoginEmail] = useState(initialAuth?.email ?? "");
|
|
188
|
+
const [loginPassword, setLoginPassword] = useState(initialAuth?.password ?? "");
|
|
189
|
+
const [loginError, setLoginError] = useState(null);
|
|
190
|
+
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
191
|
+
const disableLogin = isLoggingIn || !loginEmail.trim() || !loginPassword.trim();
|
|
192
|
+
const [shareUrl, setShareUrl] = useState(null);
|
|
193
|
+
const [copyStatus, setCopyStatus] = useState("idle");
|
|
194
|
+
const copyStatusTimerRef = useRef(null);
|
|
195
|
+
const logoutInFlightRef = useRef(false);
|
|
196
|
+
const authCheckInFlightRef = useRef(false);
|
|
197
|
+
const lastAuthCheckTokenRef = useRef(null);
|
|
198
|
+
const [controlsHidden, setControlsHidden] = useState(false);
|
|
199
|
+
const [showHiddenNotice, setShowHiddenNotice] = useState(true);
|
|
200
|
+
const shortcutKeysRef = useRef(new Set());
|
|
201
|
+
const clearCopyStatusTimer = () => {
|
|
202
|
+
if (copyStatusTimerRef.current == null)
|
|
203
|
+
return;
|
|
204
|
+
if (typeof window !== "undefined") {
|
|
205
|
+
window.clearTimeout(copyStatusTimerRef.current);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
clearTimeout(copyStatusTimerRef.current);
|
|
209
|
+
}
|
|
210
|
+
copyStatusTimerRef.current = null;
|
|
211
|
+
};
|
|
212
|
+
const resetCopyFeedback = () => {
|
|
213
|
+
clearCopyStatusTimer();
|
|
214
|
+
setCopyStatus("idle");
|
|
215
|
+
};
|
|
216
|
+
const clearShareInfo = () => {
|
|
217
|
+
setShareUrl(null);
|
|
218
|
+
resetCopyFeedback();
|
|
219
|
+
};
|
|
220
|
+
const setCopyStatusWithTimeout = (status) => {
|
|
221
|
+
clearCopyStatusTimer();
|
|
222
|
+
setCopyStatus(status);
|
|
223
|
+
if (status === "idle")
|
|
224
|
+
return;
|
|
225
|
+
if (typeof window !== "undefined") {
|
|
226
|
+
copyStatusTimerRef.current = window.setTimeout(() => {
|
|
227
|
+
setCopyStatus("idle");
|
|
228
|
+
copyStatusTimerRef.current = null;
|
|
229
|
+
}, 2200);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const clearSessionExpiryTimer = () => {
|
|
233
|
+
if (sessionExpiryTimerRef.current == null)
|
|
234
|
+
return;
|
|
235
|
+
if (typeof window !== "undefined") {
|
|
236
|
+
window.clearTimeout(sessionExpiryTimerRef.current);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
clearTimeout(sessionExpiryTimerRef.current);
|
|
240
|
+
}
|
|
241
|
+
sessionExpiryTimerRef.current = null;
|
|
242
|
+
};
|
|
243
|
+
const addTenantHeader = (headers) => ({
|
|
244
|
+
...headers,
|
|
245
|
+
[NGROK_SKIP_HEADER]: NGROK_SKIP_VALUE,
|
|
246
|
+
});
|
|
247
|
+
const setTenantOnHeaders = (headers, isInternal) => {
|
|
248
|
+
if (isInternal) {
|
|
249
|
+
headers.set(NGROK_SKIP_HEADER, NGROK_SKIP_VALUE);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
__reproCtx = {
|
|
254
|
+
base,
|
|
255
|
+
getSid: () => sessionIdRef.current,
|
|
256
|
+
getAid: () => currentAidRef.current,
|
|
257
|
+
getToken: () => sdkTokenRef.current,
|
|
258
|
+
getUserToken: () => userTokenRef.current,
|
|
259
|
+
getUserPassword: () => userPasswordRef.current,
|
|
260
|
+
getFetch: () => (origFetchRef.current ?? window.fetch.bind(window)),
|
|
261
|
+
hasReqMarked: hasReqMarkedRef.current,
|
|
262
|
+
onUnauthorized: () => handleUnauthorized(),
|
|
263
|
+
};
|
|
264
|
+
return () => {
|
|
265
|
+
__reproCtx = null;
|
|
266
|
+
};
|
|
267
|
+
}, [base]);
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const storedToken = initialAuthRef.current?.token ?? null;
|
|
270
|
+
if (!storedToken) {
|
|
271
|
+
lastAuthCheckTokenRef.current = null;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!ready)
|
|
275
|
+
return;
|
|
276
|
+
const sdkToken = sdkTokenRef.current;
|
|
277
|
+
if (!sdkToken)
|
|
278
|
+
return;
|
|
279
|
+
if (auth?.token !== storedToken) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (authCheckInFlightRef.current ||
|
|
283
|
+
lastAuthCheckTokenRef.current === storedToken) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
let cancelled = false;
|
|
287
|
+
authCheckInFlightRef.current = true;
|
|
288
|
+
lastAuthCheckTokenRef.current = storedToken;
|
|
289
|
+
(async () => {
|
|
290
|
+
try {
|
|
291
|
+
const fetcher = origFetchRef.current ?? window.fetch.bind(window);
|
|
292
|
+
const resp = await fetcher(`${base}/v1/apps/${appId}/users/me`, {
|
|
293
|
+
method: "GET",
|
|
294
|
+
headers: addTenantHeader({
|
|
295
|
+
Accept: "application/json",
|
|
296
|
+
Authorization: `Bearer ${storedToken}`,
|
|
297
|
+
"x-sdk-token": sdkToken,
|
|
298
|
+
[INTERNAL_HEADER]: "1",
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
if (cancelled)
|
|
302
|
+
return;
|
|
303
|
+
if (resp.ok)
|
|
304
|
+
return;
|
|
305
|
+
handleUnauthorizedStatus(resp.status);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
if (!cancelled) {
|
|
309
|
+
// Avoid logging out on transient validation failures.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
authCheckInFlightRef.current = false;
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
return () => {
|
|
317
|
+
cancelled = true;
|
|
318
|
+
};
|
|
319
|
+
}, [auth, appId, base, ready]);
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
userPasswordRef.current = auth?.password ?? null;
|
|
322
|
+
userTokenRef.current = auth?.token ?? null;
|
|
323
|
+
if (typeof window !== "undefined") {
|
|
324
|
+
try {
|
|
325
|
+
if (auth) {
|
|
326
|
+
window.localStorage.setItem(storageKey, JSON.stringify(auth));
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
window.localStorage.removeItem(storageKey);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* ignore storage failures */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}, [auth, storageKey]);
|
|
337
|
+
const requireAuth = () => {
|
|
338
|
+
if (!auth) {
|
|
339
|
+
setShowLogin(true);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
};
|
|
344
|
+
async function login(email, password) {
|
|
345
|
+
setIsLoggingIn(true);
|
|
346
|
+
setLoginError(null);
|
|
347
|
+
try {
|
|
348
|
+
const resp = await fetch(`${base}/v1/apps/${appId}/users/login`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: addTenantHeader({
|
|
351
|
+
Accept: "application/json",
|
|
352
|
+
"Content-Type": "application/json",
|
|
353
|
+
[INTERNAL_HEADER]: "1",
|
|
354
|
+
}),
|
|
355
|
+
body: JSON.stringify({ email, password }),
|
|
356
|
+
});
|
|
357
|
+
if (!resp.ok) {
|
|
358
|
+
throw new Error(`Login failed (${resp.status})`);
|
|
359
|
+
}
|
|
360
|
+
const data = await resp.json();
|
|
361
|
+
const accessTokenFromUser = typeof data?.user?.accessToken === "string"
|
|
362
|
+
? data.user.accessToken.trim()
|
|
363
|
+
: null;
|
|
364
|
+
const accessTokenFromData = typeof data?.accessToken === "string"
|
|
365
|
+
? data.accessToken.trim()
|
|
366
|
+
: null;
|
|
367
|
+
const accessToken = accessTokenFromUser || accessTokenFromData;
|
|
368
|
+
if (!accessToken) {
|
|
369
|
+
throw new Error("Login response did not include an access token.");
|
|
370
|
+
}
|
|
371
|
+
setAuth({ email, password, token: accessToken, data });
|
|
372
|
+
setLoginPassword("");
|
|
373
|
+
setShowLogin(false);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
setLoginError(err?.message || "Unable to login");
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
setIsLoggingIn(false);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function handleLoginSubmit(evt) {
|
|
383
|
+
evt.preventDefault();
|
|
384
|
+
if (disableLogin)
|
|
385
|
+
return;
|
|
386
|
+
login(loginEmail.trim(), loginPassword.trim());
|
|
387
|
+
}
|
|
388
|
+
async function logout(options) {
|
|
389
|
+
if (logoutInFlightRef.current)
|
|
390
|
+
return;
|
|
391
|
+
logoutInFlightRef.current = true;
|
|
392
|
+
try {
|
|
393
|
+
if (recording && !isStoppingRef.current) {
|
|
394
|
+
try {
|
|
395
|
+
await stop();
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
/* ignore */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
setAuth(null);
|
|
402
|
+
setLoginPassword("");
|
|
403
|
+
clearShareInfo();
|
|
404
|
+
setShowLogin(options?.showLogin ?? false);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
logoutInFlightRef.current = false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function handleUnauthorized() {
|
|
411
|
+
void logout({ showLogin: true });
|
|
412
|
+
}
|
|
413
|
+
const handleUnauthorizedStatus = (status) => {
|
|
414
|
+
if (status === 401) {
|
|
415
|
+
handleUnauthorized();
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
};
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
if (!showLogin) {
|
|
422
|
+
setLoginError(null);
|
|
423
|
+
}
|
|
424
|
+
}, [showLogin]);
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
return () => {
|
|
427
|
+
clearCopyStatusTimer();
|
|
428
|
+
clearSessionExpiryTimer();
|
|
429
|
+
};
|
|
430
|
+
}, []);
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
const pressed = shortcutKeysRef.current;
|
|
433
|
+
if (typeof window === "undefined")
|
|
434
|
+
return;
|
|
435
|
+
const handleKeyDown = (evt) => {
|
|
436
|
+
const hasMod = evt.ctrlKey || evt.metaKey;
|
|
437
|
+
if (!hasMod) {
|
|
438
|
+
pressed.clear();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const key = (evt.key || "").toLowerCase();
|
|
442
|
+
if (key === "r" || key === "o") {
|
|
443
|
+
pressed.add(key);
|
|
444
|
+
const combo = pressed.has("r") && pressed.has("o");
|
|
445
|
+
if (combo) {
|
|
446
|
+
if (controlsHidden) {
|
|
447
|
+
evt.preventDefault();
|
|
448
|
+
}
|
|
449
|
+
setControlsHidden(false);
|
|
450
|
+
setShowHiddenNotice(false);
|
|
451
|
+
pressed.clear();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (controlsHidden) {
|
|
455
|
+
evt.preventDefault();
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (key === "control" || key === "meta") {
|
|
460
|
+
pressed.clear();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
pressed.clear();
|
|
464
|
+
};
|
|
465
|
+
const handleKeyUp = (evt) => {
|
|
466
|
+
const key = (evt.key || "").toLowerCase();
|
|
467
|
+
if (key === "r" || key === "o") {
|
|
468
|
+
pressed.delete(key);
|
|
469
|
+
}
|
|
470
|
+
else if (key === "control" || key === "meta") {
|
|
471
|
+
pressed.clear();
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
475
|
+
window.addEventListener("keyup", handleKeyUp, true);
|
|
476
|
+
return () => {
|
|
477
|
+
window.removeEventListener("keydown", handleKeyDown, true);
|
|
478
|
+
window.removeEventListener("keyup", handleKeyUp, true);
|
|
479
|
+
};
|
|
480
|
+
}, [controlsHidden]);
|
|
481
|
+
// ---- bootstrap once ----
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
let mounted = true;
|
|
484
|
+
(async () => {
|
|
485
|
+
try {
|
|
486
|
+
const resp = await getJSON(`${base}/v1/sdk/bootstrap?appId=${encodeURIComponent(appId)}`, addTenantHeader({ [INTERNAL_HEADER]: "1" }));
|
|
487
|
+
if (mounted && resp.enabled && resp.sdkToken) {
|
|
488
|
+
sdkTokenRef.current = resp.sdkToken;
|
|
489
|
+
setReady(true);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
setReady(false);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
setReady(false);
|
|
497
|
+
}
|
|
498
|
+
})();
|
|
499
|
+
return () => {
|
|
500
|
+
mounted = false;
|
|
501
|
+
};
|
|
502
|
+
}, [appId, base]);
|
|
503
|
+
const rrBufferRef = useRef([]);
|
|
504
|
+
const rrFlushTimerRef = useRef(null);
|
|
505
|
+
const CHUNK_SIZE = 80; // send when buffer hits 200 events
|
|
506
|
+
const FLUSH_MS = 1500; // or every 2s, whichever first
|
|
507
|
+
async function sendChunkGzip({ baseUrl, sid, token, envelope, seq }) {
|
|
508
|
+
try {
|
|
509
|
+
const json = JSON.stringify({ ...envelope, seq });
|
|
510
|
+
const gz = gzip(json); // Uint8Array
|
|
511
|
+
const r = await (origFetchRef.current ?? window.fetch)(`${baseUrl}/v1/sessions/${sid}/events`, {
|
|
512
|
+
method: "POST",
|
|
513
|
+
headers: addTenantHeader({
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
"Content-Encoding": "gzip",
|
|
516
|
+
"x-sdk-token": token,
|
|
517
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
518
|
+
[INTERNAL_HEADER]: "1",
|
|
519
|
+
}),
|
|
520
|
+
body: gz,
|
|
521
|
+
});
|
|
522
|
+
if (handleUnauthorizedStatus(r.status))
|
|
523
|
+
return 'fail';
|
|
524
|
+
if (r.status === 413)
|
|
525
|
+
return 'too_large';
|
|
526
|
+
if (!r.ok)
|
|
527
|
+
return 'fail';
|
|
528
|
+
return 'ok';
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return 'fail';
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async function flushRrwebBuffer(reason) {
|
|
535
|
+
if (isFlushingRef.current)
|
|
536
|
+
return;
|
|
537
|
+
const sid = sessionIdRef.current;
|
|
538
|
+
const token = sdkTokenRef.current;
|
|
539
|
+
const baseUrl = API_BASE;
|
|
540
|
+
if (!sid || !token)
|
|
541
|
+
return;
|
|
542
|
+
if (!rrBufferRef.current.length)
|
|
543
|
+
return;
|
|
544
|
+
isFlushingRef.current = true;
|
|
545
|
+
try {
|
|
546
|
+
if (backoffRef.current > 0) {
|
|
547
|
+
await new Promise(r => setTimeout(r, backoffRef.current));
|
|
548
|
+
}
|
|
549
|
+
// COPY buffer; do not mutate until we know what succeeded
|
|
550
|
+
const fullSlice = rrBufferRef.current.slice(0);
|
|
551
|
+
const seq = nextSeqRef.current; // do NOT increment yet
|
|
552
|
+
const mkEnvelope = (slice) => {
|
|
553
|
+
const tFirst = slice[0]?.timestamp ?? nowServer();
|
|
554
|
+
const tLast = slice[slice.length - 1]?.timestamp ?? tFirst;
|
|
555
|
+
return { type: 'rrweb', seq, tFirst, tLast, events: slice };
|
|
556
|
+
};
|
|
557
|
+
// Pre-split by size into <= MAX_BYTES pieces (same seq for first, then seq+1, …)
|
|
558
|
+
const pieces = splitEventsBySize(fullSlice, mkEnvelope);
|
|
559
|
+
// Send each piece in order, incrementing seq only on success per piece
|
|
560
|
+
let sentCount = 0;
|
|
561
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
562
|
+
const piece = pieces[i];
|
|
563
|
+
const pieceSeq = seq + i; // stable seq per piece
|
|
564
|
+
const env = mkEnvelope(piece);
|
|
565
|
+
const jsonSize = jsonBytes(env);
|
|
566
|
+
let res;
|
|
567
|
+
if (jsonSize > 64 * 1024) {
|
|
568
|
+
res = await sendChunkGzip({ baseUrl, sid, token, envelope: env, seq: pieceSeq });
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
res = await sendChunk({ baseUrl, sid, token, envelope: env, seq: pieceSeq });
|
|
572
|
+
}
|
|
573
|
+
if (res === 'ok') {
|
|
574
|
+
sentCount += piece.length;
|
|
575
|
+
nextSeqRef.current = pieceSeq + 1; // advance to next expected seq
|
|
576
|
+
backoffRef.current = 0;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (res === 'too_large' && piece.length > 1) {
|
|
580
|
+
// If a piece is STILL too large after pre-split, split it again and retry
|
|
581
|
+
const more = splitEventsBySize(piece, mkEnvelope);
|
|
582
|
+
// splice into 'pieces' at current position
|
|
583
|
+
pieces.splice(i, 1, ...more);
|
|
584
|
+
i -= 1; // reprocess at same index
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
// failure: stop; keep remaining events in buffer for retry
|
|
588
|
+
backoffRef.current = Math.min(4000, (backoffRef.current || 250) * 2);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
// success for `sentCount` events → drop them from buffer
|
|
592
|
+
if (sentCount > 0) {
|
|
593
|
+
rrBufferRef.current.splice(0, sentCount);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
isFlushingRef.current = false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function sendChunk({ baseUrl, sid, token, envelope, seq }) {
|
|
601
|
+
try {
|
|
602
|
+
const r = await (origFetchRef.current ?? window.fetch)(`${baseUrl}/v1/sessions/${sid}/events`, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: addTenantHeader({
|
|
605
|
+
"Content-Type": "application/json",
|
|
606
|
+
"x-sdk-token": token,
|
|
607
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
608
|
+
[INTERNAL_HEADER]: "1",
|
|
609
|
+
}),
|
|
610
|
+
body: JSON.stringify({ ...envelope, seq }), // ensure seq matches piece
|
|
611
|
+
});
|
|
612
|
+
if (handleUnauthorizedStatus(r.status))
|
|
613
|
+
return 'fail';
|
|
614
|
+
if (r.status === 413)
|
|
615
|
+
return 'too_large';
|
|
616
|
+
if (!r.ok)
|
|
617
|
+
return 'fail';
|
|
618
|
+
return 'ok';
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return 'fail';
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Auto-attach to window.axios if present.
|
|
626
|
+
* If a team uses a custom Axios instance, they can call exported attachAxios(instance).
|
|
627
|
+
*/
|
|
628
|
+
function attachAxiosIfPresent() {
|
|
629
|
+
const ax = window.axios;
|
|
630
|
+
if (!ax || ax.__reproAttached)
|
|
631
|
+
return;
|
|
632
|
+
ax.__reproAttached = true;
|
|
633
|
+
ax.interceptors.request.use((config) => {
|
|
634
|
+
const sid = sessionIdRef.current;
|
|
635
|
+
const aid = currentAidRef.current;
|
|
636
|
+
const url = `${config.baseURL || ""}${config.url || ""}`;
|
|
637
|
+
const isInternal = url.startsWith(base);
|
|
638
|
+
const isSdkInternal = config.headers?.[INTERNAL_HEADER] != null;
|
|
639
|
+
if (isInternal) {
|
|
640
|
+
config.headers = config.headers || {};
|
|
641
|
+
config.headers[NGROK_SKIP_HEADER] = NGROK_SKIP_VALUE;
|
|
642
|
+
}
|
|
643
|
+
if (sid && aid && !isInternal && !isSdkInternal) {
|
|
644
|
+
config.headers = config.headers || {};
|
|
645
|
+
config.headers["X-Bug-Session-Id"] = sid;
|
|
646
|
+
config.headers["X-Bug-Action-Id"] = aid;
|
|
647
|
+
}
|
|
648
|
+
return config;
|
|
649
|
+
});
|
|
650
|
+
ax.interceptors.response.use(async (resp) => {
|
|
651
|
+
const url = `${resp.config.baseURL || ""}${resp.config.url || ""}`;
|
|
652
|
+
const hdrs = resp.config.headers || {};
|
|
653
|
+
const isInternal = url.startsWith(base);
|
|
654
|
+
const isSdkInternal = hdrs[INTERNAL_HEADER] != null;
|
|
655
|
+
if (isInternal) {
|
|
656
|
+
handleUnauthorizedStatus(resp.status);
|
|
657
|
+
}
|
|
658
|
+
if (!isInternal &&
|
|
659
|
+
!isSdkInternal &&
|
|
660
|
+
sessionIdRef.current &&
|
|
661
|
+
currentAidRef.current &&
|
|
662
|
+
sdkTokenRef.current &&
|
|
663
|
+
!hasReqMarkedRef.current.has(currentAidRef.current)) {
|
|
664
|
+
hasReqMarkedRef.current.add(currentAidRef.current);
|
|
665
|
+
// use ORIGINAL fetch to avoid recursion
|
|
666
|
+
await origFetchRef.current(`${base}/v1/sessions/${sessionIdRef.current}/events`, {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: addTenantHeader({
|
|
669
|
+
"Content-Type": "application/json",
|
|
670
|
+
"x-sdk-token": sdkTokenRef.current,
|
|
671
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
672
|
+
[INTERNAL_HEADER]: "1",
|
|
673
|
+
}),
|
|
674
|
+
body: JSON.stringify({
|
|
675
|
+
seq: nowServer(),
|
|
676
|
+
events: [
|
|
677
|
+
{
|
|
678
|
+
type: "action",
|
|
679
|
+
aid: currentAidRef.current,
|
|
680
|
+
label: lastActionLabelRef.current,
|
|
681
|
+
tStart: nowServer(),
|
|
682
|
+
tEnd: nowServer(),
|
|
683
|
+
hasReq: true,
|
|
684
|
+
ui: {},
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
}),
|
|
688
|
+
}).catch(() => { });
|
|
689
|
+
}
|
|
690
|
+
return resp;
|
|
691
|
+
}, (err) => Promise.reject(err));
|
|
692
|
+
}
|
|
693
|
+
// ---- derive a label from a click target (best-effort, minimal) ----
|
|
694
|
+
function labelFromClickTarget(target) {
|
|
695
|
+
const el = target;
|
|
696
|
+
if (!el)
|
|
697
|
+
return "Click";
|
|
698
|
+
const txt = (el.innerText || el.getAttribute?.("aria-label") || "").trim().slice(0, 40) ||
|
|
699
|
+
el.getAttribute?.("title") ||
|
|
700
|
+
"";
|
|
701
|
+
const id = el.id ? `#${el.id}` : "";
|
|
702
|
+
const cls = el.className && typeof el.className === "string"
|
|
703
|
+
? `.${el.className.split(" ").slice(0, 2).join(".")}`
|
|
704
|
+
: "";
|
|
705
|
+
return txt ? `Click • ${txt}` : `Click • ${(el.tagName || "el").toLowerCase()}${id || cls}`;
|
|
706
|
+
}
|
|
707
|
+
// ---- START recording ----
|
|
708
|
+
async function start() {
|
|
709
|
+
if (!sdkTokenRef.current || recordingRef.current)
|
|
710
|
+
return;
|
|
711
|
+
// 1) start session
|
|
712
|
+
if (!requireAuth())
|
|
713
|
+
return;
|
|
714
|
+
clearShareInfo();
|
|
715
|
+
const r = await fetch(`${base}/v1/sessions`, {
|
|
716
|
+
method: "POST",
|
|
717
|
+
headers: addTenantHeader({
|
|
718
|
+
"Content-Type": "application/json",
|
|
719
|
+
"x-sdk-token": sdkTokenRef.current,
|
|
720
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
721
|
+
}),
|
|
722
|
+
body: JSON.stringify({ clientTime: nowServer() }),
|
|
723
|
+
});
|
|
724
|
+
if (!r.ok) {
|
|
725
|
+
handleUnauthorizedStatus(r.status);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const sess = (await r.json());
|
|
729
|
+
sessionIdRef.current = sess.sessionId; // <-- MISSING, add this
|
|
730
|
+
offsetRef.current = Number(sess.clockOffsetMs || 0);
|
|
731
|
+
// 2) hold original fetch & install interceptor
|
|
732
|
+
origFetchRef.current = window.fetch.bind(window);
|
|
733
|
+
nextSeqRef.current = 1; // reset for new session
|
|
734
|
+
rrBufferRef.current = [];
|
|
735
|
+
if (rrFlushTimerRef.current)
|
|
736
|
+
window.clearInterval(rrFlushTimerRef.current);
|
|
737
|
+
rrFlushTimerRef.current = window.setInterval(() => {
|
|
738
|
+
flushRrwebBuffer('timer');
|
|
739
|
+
}, FLUSH_MS);
|
|
740
|
+
window.fetch = async (input, init = {}) => {
|
|
741
|
+
// figure request url
|
|
742
|
+
const urlStr = typeof input === "string" || input instanceof URL
|
|
743
|
+
? String(input)
|
|
744
|
+
: input.url;
|
|
745
|
+
// existing incoming headers (if any)
|
|
746
|
+
const hdrsIn = new Headers(init.headers || input?.headers || {});
|
|
747
|
+
const isInternal = urlStr.startsWith(base);
|
|
748
|
+
const isSdkInternal = hdrsIn.has(INTERNAL_HEADER) || hdrsIn.has(INTERNAL_HEADER.toLowerCase());
|
|
749
|
+
// inject bug headers for app requests only (not our API or SDK-internal posts)
|
|
750
|
+
const headers = new Headers(init.headers || {});
|
|
751
|
+
setTenantOnHeaders(headers, isInternal);
|
|
752
|
+
if (!isSdkInternal) {
|
|
753
|
+
const requestStart = nowServer();
|
|
754
|
+
headers.set(REQUEST_START_HEADER, String(requestStart));
|
|
755
|
+
}
|
|
756
|
+
if (sessionIdRef.current && currentAidRef.current && !isInternal && !isSdkInternal) {
|
|
757
|
+
headers.set("X-Bug-Session-Id", sessionIdRef.current);
|
|
758
|
+
headers.set("X-Bug-Action-Id", currentAidRef.current);
|
|
759
|
+
}
|
|
760
|
+
init.headers = headers;
|
|
761
|
+
// always call ORIGINAL fetch to avoid recursion
|
|
762
|
+
const res = await origFetchRef.current(input, init);
|
|
763
|
+
if (isInternal) {
|
|
764
|
+
handleUnauthorizedStatus(res.status);
|
|
765
|
+
}
|
|
766
|
+
// mark hasReq ONCE per action (skip internal/API calls)
|
|
767
|
+
if (!isInternal &&
|
|
768
|
+
!isSdkInternal &&
|
|
769
|
+
sessionIdRef.current &&
|
|
770
|
+
currentAidRef.current &&
|
|
771
|
+
!hasReqMarkedRef.current.has(currentAidRef.current)) {
|
|
772
|
+
hasReqMarkedRef.current.add(currentAidRef.current);
|
|
773
|
+
}
|
|
774
|
+
return res;
|
|
775
|
+
};
|
|
776
|
+
attachAxiosIfPresent();
|
|
777
|
+
// 3) click -> new ActionId + minimal action event (via ORIGINAL fetch)
|
|
778
|
+
// Remove any previous handler to avoid duplicates
|
|
779
|
+
if (clickHandlerRef.current) {
|
|
780
|
+
document.removeEventListener("click", clickHandlerRef.current, { capture: true });
|
|
781
|
+
clickHandlerRef.current = null;
|
|
782
|
+
}
|
|
783
|
+
const clickHandler = (evt) => {
|
|
784
|
+
if (!sessionIdRef.current || !sdkTokenRef.current)
|
|
785
|
+
return;
|
|
786
|
+
// Skip clicks on the SDK's own UI
|
|
787
|
+
const targetEl = evt.target;
|
|
788
|
+
if (targetEl && targetEl.closest('[data-repro-internal="1"]'))
|
|
789
|
+
return;
|
|
790
|
+
// Dedupe: ignore same-label clicks within 250ms (dev/StrictMode safety)
|
|
791
|
+
const label = labelFromClickTarget(targetEl);
|
|
792
|
+
const t = nowServer();
|
|
793
|
+
if (lastClickRef.current && t - lastClickRef.current.t < 250 && lastClickRef.current.label === label) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
lastClickRef.current = { t, label };
|
|
797
|
+
const aid = newAID();
|
|
798
|
+
currentAidRef.current = aid;
|
|
799
|
+
lastActionLabelRef.current = label;
|
|
800
|
+
if (aidExpiryTimerRef.current)
|
|
801
|
+
window.clearTimeout(aidExpiryTimerRef.current);
|
|
802
|
+
aidExpiryTimerRef.current = window.setTimeout(() => {
|
|
803
|
+
currentAidRef.current = null;
|
|
804
|
+
}, 5000);
|
|
805
|
+
// post action row (internal; avoid recursion)
|
|
806
|
+
origFetchRef.current(`${base}/v1/sessions/${sessionIdRef.current}/events`, {
|
|
807
|
+
method: "POST",
|
|
808
|
+
headers: addTenantHeader({
|
|
809
|
+
"Content-Type": "application/json",
|
|
810
|
+
"x-sdk-token": sdkTokenRef.current,
|
|
811
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
812
|
+
[INTERNAL_HEADER]: "1",
|
|
813
|
+
}),
|
|
814
|
+
body: JSON.stringify({
|
|
815
|
+
seq: t,
|
|
816
|
+
events: [
|
|
817
|
+
{
|
|
818
|
+
type: "action",
|
|
819
|
+
aid,
|
|
820
|
+
label,
|
|
821
|
+
tStart: t,
|
|
822
|
+
tEnd: t,
|
|
823
|
+
hasReq: false,
|
|
824
|
+
hasDb: false,
|
|
825
|
+
error: false,
|
|
826
|
+
ui: { kind: "click" },
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
}),
|
|
830
|
+
})
|
|
831
|
+
.then((resp) => {
|
|
832
|
+
handleUnauthorizedStatus(resp.status);
|
|
833
|
+
})
|
|
834
|
+
.catch(() => { });
|
|
835
|
+
};
|
|
836
|
+
clickHandlerRef.current = clickHandler;
|
|
837
|
+
document.addEventListener("click", clickHandler, { capture: true });
|
|
838
|
+
// 4) rrweb: keep events in memory; upload on stop()
|
|
839
|
+
stopRecRef.current = record({
|
|
840
|
+
emit: (ev) => {
|
|
841
|
+
rrBufferRef.current.push(ev);
|
|
842
|
+
// stream out if we reach CHUNK_SIZE
|
|
843
|
+
if (rrBufferRef.current.length >= CHUNK_SIZE) {
|
|
844
|
+
flushRrwebBuffer('size');
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
...(masking ?? {}),
|
|
848
|
+
});
|
|
849
|
+
// 5) cleanup registration
|
|
850
|
+
stop._cleanup = () => {
|
|
851
|
+
if (clickHandlerRef.current) {
|
|
852
|
+
document.removeEventListener("click", clickHandlerRef.current, { capture: true });
|
|
853
|
+
clickHandlerRef.current = null;
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
clearSessionExpiryTimer();
|
|
857
|
+
sessionExpiryTimerRef.current = window.setTimeout(() => {
|
|
858
|
+
void stop();
|
|
859
|
+
}, SESSION_MAX_MS);
|
|
860
|
+
recordingRef.current = true;
|
|
861
|
+
setRecording(true);
|
|
862
|
+
}
|
|
863
|
+
// ---- STOP recording ----
|
|
864
|
+
async function stop() {
|
|
865
|
+
clearSessionExpiryTimer();
|
|
866
|
+
if (!recordingRef.current || isStoppingRef.current)
|
|
867
|
+
return;
|
|
868
|
+
isStoppingRef.current = true;
|
|
869
|
+
try {
|
|
870
|
+
// stop rrweb + listeners
|
|
871
|
+
stopRecRef.current?.();
|
|
872
|
+
stop._cleanup?.();
|
|
873
|
+
// clear periodic timer
|
|
874
|
+
if (rrFlushTimerRef.current) {
|
|
875
|
+
window.clearInterval(rrFlushTimerRef.current);
|
|
876
|
+
rrFlushTimerRef.current = null;
|
|
877
|
+
}
|
|
878
|
+
// final flush of whatever is left
|
|
879
|
+
await flushRrwebBuffer('stop');
|
|
880
|
+
const origFetch = origFetchRef.current ?? window.fetch.bind(window);
|
|
881
|
+
const sid = sessionIdRef.current;
|
|
882
|
+
const token = sdkTokenRef.current;
|
|
883
|
+
// 1) upload rrweb chunks (internal; use original fetch)
|
|
884
|
+
try {
|
|
885
|
+
const events = rrwebEventsRef.current;
|
|
886
|
+
const CHUNK = 500;
|
|
887
|
+
for (let i = 0; i < events.length; i += CHUNK) {
|
|
888
|
+
const slice = events.slice(i, i + CHUNK);
|
|
889
|
+
const seq = nextSeqRef.current++;
|
|
890
|
+
const tFirst = slice[0]?.timestamp ?? nowServer();
|
|
891
|
+
const tLast = slice[slice.length - 1]?.timestamp ?? tFirst;
|
|
892
|
+
const chunkRes = await origFetch(`${base}/v1/sessions/${sid}/events`, {
|
|
893
|
+
method: "POST",
|
|
894
|
+
headers: addTenantHeader({
|
|
895
|
+
"Content-Type": "application/json",
|
|
896
|
+
"x-sdk-token": token,
|
|
897
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
898
|
+
[INTERNAL_HEADER]: "1",
|
|
899
|
+
}),
|
|
900
|
+
body: JSON.stringify({
|
|
901
|
+
type: "rrweb",
|
|
902
|
+
seq,
|
|
903
|
+
tFirst,
|
|
904
|
+
tLast,
|
|
905
|
+
events: slice, // send raw array
|
|
906
|
+
}),
|
|
907
|
+
});
|
|
908
|
+
if (handleUnauthorizedStatus(chunkRes.status)) {
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
/* ignore in MVP */
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
rrwebEventsRef.current = [];
|
|
918
|
+
nextSeqRef.current = 1;
|
|
919
|
+
actionMeta.current.clear();
|
|
920
|
+
}
|
|
921
|
+
// 2) finish (internal; use original fetch)
|
|
922
|
+
try {
|
|
923
|
+
const res = await origFetch(`${base}/v1/sessions/${sid}/finish`, {
|
|
924
|
+
method: "POST",
|
|
925
|
+
headers: addTenantHeader({
|
|
926
|
+
"Content-Type": "application/json",
|
|
927
|
+
"x-sdk-token": token,
|
|
928
|
+
...(userTokenRef.current ? { Authorization: `Bearer ${userTokenRef.current}` } : {}),
|
|
929
|
+
[INTERNAL_HEADER]: "1",
|
|
930
|
+
}),
|
|
931
|
+
body: JSON.stringify({ notes: "" }),
|
|
932
|
+
});
|
|
933
|
+
if (handleUnauthorizedStatus(res.status)) {
|
|
934
|
+
clearShareInfo();
|
|
935
|
+
}
|
|
936
|
+
else if (res.ok) {
|
|
937
|
+
let fin = null;
|
|
938
|
+
try {
|
|
939
|
+
fin = await res.json();
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
fin = null;
|
|
943
|
+
}
|
|
944
|
+
if (fin?.viewerUrl) {
|
|
945
|
+
resetCopyFeedback();
|
|
946
|
+
setShareUrl(fin.viewerUrl);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
clearShareInfo();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
clearShareInfo();
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
clearShareInfo();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
finally {
|
|
961
|
+
// 3) restore fetch & reset
|
|
962
|
+
if (origFetchRef.current)
|
|
963
|
+
window.fetch = origFetchRef.current;
|
|
964
|
+
sessionIdRef.current = null;
|
|
965
|
+
currentAidRef.current = null;
|
|
966
|
+
lastActionLabelRef.current = null;
|
|
967
|
+
hasReqMarkedRef.current.clear();
|
|
968
|
+
if (aidExpiryTimerRef.current)
|
|
969
|
+
window.clearTimeout(aidExpiryTimerRef.current);
|
|
970
|
+
// also reset dedupe
|
|
971
|
+
lastClickRef.current = null;
|
|
972
|
+
setRecording(false);
|
|
973
|
+
recordingRef.current = false;
|
|
974
|
+
isStoppingRef.current = false;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// ---- UI (floating button) ----
|
|
978
|
+
const btnLabel = recording ? (button?.text ?? "Stop & Report") : (button?.text ?? "Record");
|
|
979
|
+
const loginLabel = "Authenticate to Record";
|
|
980
|
+
const floatingContainerBaseStyle = {
|
|
981
|
+
position: "fixed",
|
|
982
|
+
zIndex: 2147483647,
|
|
983
|
+
display: "flex",
|
|
984
|
+
flexDirection: "column",
|
|
985
|
+
alignItems: "flex-end",
|
|
986
|
+
gap: 12,
|
|
987
|
+
width: "100%",
|
|
988
|
+
maxWidth: 360,
|
|
989
|
+
};
|
|
990
|
+
const floatingContainerStyle = {
|
|
991
|
+
...floatingContainerBaseStyle,
|
|
992
|
+
right: 16,
|
|
993
|
+
bottom: 16,
|
|
994
|
+
};
|
|
995
|
+
const buttonRowStyle = {
|
|
996
|
+
display: "flex",
|
|
997
|
+
gap: 10,
|
|
998
|
+
justifyContent: "flex-end",
|
|
999
|
+
width: "100%",
|
|
1000
|
+
};
|
|
1001
|
+
const buttonBaseStyle = {
|
|
1002
|
+
display: "inline-flex",
|
|
1003
|
+
alignItems: "center",
|
|
1004
|
+
justifyContent: "center",
|
|
1005
|
+
gap: 8,
|
|
1006
|
+
padding: "12px 24px",
|
|
1007
|
+
borderRadius: 9999,
|
|
1008
|
+
border: "none",
|
|
1009
|
+
background: "#f4f5f7",
|
|
1010
|
+
cursor: "pointer",
|
|
1011
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system",
|
|
1012
|
+
fontSize: "14px",
|
|
1013
|
+
fontWeight: 600,
|
|
1014
|
+
color: "#ffffff",
|
|
1015
|
+
boxShadow: "0 14px 24px rgba(15, 23, 42, 0.18)",
|
|
1016
|
+
transition: "transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease",
|
|
1017
|
+
minWidth: 150,
|
|
1018
|
+
};
|
|
1019
|
+
const recordButtonStyle = {
|
|
1020
|
+
...buttonBaseStyle,
|
|
1021
|
+
background: recording
|
|
1022
|
+
? "linear-gradient(120deg, #ef4444, #b91c1c)"
|
|
1023
|
+
: "linear-gradient(120deg, #2563eb, #1d4ed8)",
|
|
1024
|
+
boxShadow: recording
|
|
1025
|
+
? "0 18px 32px rgba(239, 68, 68, 0.35)"
|
|
1026
|
+
: "0 18px 32px rgba(37, 99, 235, 0.35)",
|
|
1027
|
+
};
|
|
1028
|
+
const loginButtonStyle = {
|
|
1029
|
+
...buttonBaseStyle,
|
|
1030
|
+
background: "linear-gradient(120deg, #14b8a6, #0d9488)",
|
|
1031
|
+
};
|
|
1032
|
+
const logoutButtonStyle = {
|
|
1033
|
+
...buttonBaseStyle,
|
|
1034
|
+
background: "#ffffff",
|
|
1035
|
+
border: "1px solid #e5e7eb",
|
|
1036
|
+
color: "#1f2933",
|
|
1037
|
+
boxShadow: "0 10px 18px rgba(15, 23, 42, 0.12)",
|
|
1038
|
+
};
|
|
1039
|
+
const shareCardStyle = {
|
|
1040
|
+
width: "100%",
|
|
1041
|
+
background: "#ffffff",
|
|
1042
|
+
borderRadius: 16,
|
|
1043
|
+
padding: "14px 16px",
|
|
1044
|
+
border: "1px solid #e5e7eb",
|
|
1045
|
+
boxShadow: "0 20px 36px rgba(15, 23, 42, 0.2)",
|
|
1046
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system",
|
|
1047
|
+
};
|
|
1048
|
+
const shareLinkStyle = {
|
|
1049
|
+
marginTop: 6,
|
|
1050
|
+
padding: "8px 10px",
|
|
1051
|
+
borderRadius: 10,
|
|
1052
|
+
background: "#f3f4f6",
|
|
1053
|
+
fontSize: 12,
|
|
1054
|
+
color: "#374151",
|
|
1055
|
+
wordBreak: "break-all",
|
|
1056
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
1057
|
+
};
|
|
1058
|
+
const copyButtonStyle = {
|
|
1059
|
+
...buttonBaseStyle,
|
|
1060
|
+
padding: "8px 16px",
|
|
1061
|
+
fontSize: 13,
|
|
1062
|
+
background: "#111827",
|
|
1063
|
+
boxShadow: "none",
|
|
1064
|
+
minWidth: 110,
|
|
1065
|
+
};
|
|
1066
|
+
const hideButtonStyle = {
|
|
1067
|
+
border: "none",
|
|
1068
|
+
background: "transparent",
|
|
1069
|
+
color: "#6b7280",
|
|
1070
|
+
cursor: "pointer",
|
|
1071
|
+
fontWeight: 700,
|
|
1072
|
+
padding: "6px 8px",
|
|
1073
|
+
borderRadius: 8,
|
|
1074
|
+
fontSize: 12,
|
|
1075
|
+
};
|
|
1076
|
+
const modalOverlayStyle = {
|
|
1077
|
+
position: "fixed",
|
|
1078
|
+
inset: 0,
|
|
1079
|
+
background: "rgba(15, 23, 42, 0.38)",
|
|
1080
|
+
display: "flex",
|
|
1081
|
+
alignItems: "center",
|
|
1082
|
+
justifyContent: "center",
|
|
1083
|
+
zIndex: 2147483648,
|
|
1084
|
+
padding: 16,
|
|
1085
|
+
};
|
|
1086
|
+
const modalStyle = {
|
|
1087
|
+
width: "100%",
|
|
1088
|
+
maxWidth: 360,
|
|
1089
|
+
background: "#ffffff",
|
|
1090
|
+
borderRadius: 12,
|
|
1091
|
+
boxShadow: "0 20px 40px rgba(15, 23, 42, 0.25)",
|
|
1092
|
+
padding: "24px 24px 20px",
|
|
1093
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system",
|
|
1094
|
+
};
|
|
1095
|
+
const hiddenNoticeStyle = {
|
|
1096
|
+
position: "fixed",
|
|
1097
|
+
right: 16,
|
|
1098
|
+
bottom: 16,
|
|
1099
|
+
zIndex: 2147483647,
|
|
1100
|
+
maxWidth: 320,
|
|
1101
|
+
background: "#111827",
|
|
1102
|
+
color: "#e5e7eb",
|
|
1103
|
+
borderRadius: 12,
|
|
1104
|
+
padding: "12px 14px",
|
|
1105
|
+
boxShadow: "0 16px 30px rgba(0, 0, 0, 0.3)",
|
|
1106
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system",
|
|
1107
|
+
fontSize: 13,
|
|
1108
|
+
lineHeight: 1.4,
|
|
1109
|
+
};
|
|
1110
|
+
const hiddenNoticeCloseStyle = {
|
|
1111
|
+
border: "none",
|
|
1112
|
+
background: "transparent",
|
|
1113
|
+
color: "#e5e7eb",
|
|
1114
|
+
cursor: "pointer",
|
|
1115
|
+
fontWeight: 700,
|
|
1116
|
+
padding: 0,
|
|
1117
|
+
lineHeight: 1,
|
|
1118
|
+
fontSize: 14,
|
|
1119
|
+
};
|
|
1120
|
+
const labelStyle = {
|
|
1121
|
+
display: "block",
|
|
1122
|
+
fontSize: 13,
|
|
1123
|
+
fontWeight: 600,
|
|
1124
|
+
color: "#111827",
|
|
1125
|
+
marginBottom: 6,
|
|
1126
|
+
};
|
|
1127
|
+
const inputStyle = {
|
|
1128
|
+
width: "100%",
|
|
1129
|
+
padding: "10px 12px",
|
|
1130
|
+
borderRadius: 6,
|
|
1131
|
+
border: "1px solid #d1d5db",
|
|
1132
|
+
fontSize: 14,
|
|
1133
|
+
marginBottom: 14,
|
|
1134
|
+
boxSizing: "border-box",
|
|
1135
|
+
fontFamily: "inherit",
|
|
1136
|
+
};
|
|
1137
|
+
const hideControls = () => {
|
|
1138
|
+
setControlsHidden(true);
|
|
1139
|
+
setShowHiddenNotice(true);
|
|
1140
|
+
};
|
|
1141
|
+
async function copyShareLink() {
|
|
1142
|
+
if (!shareUrl)
|
|
1143
|
+
return;
|
|
1144
|
+
const text = shareUrl;
|
|
1145
|
+
try {
|
|
1146
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
1147
|
+
await navigator.clipboard.writeText(text);
|
|
1148
|
+
}
|
|
1149
|
+
else if (typeof document !== "undefined") {
|
|
1150
|
+
const helper = document.createElement("textarea");
|
|
1151
|
+
helper.value = text;
|
|
1152
|
+
helper.style.position = "fixed";
|
|
1153
|
+
helper.style.opacity = "0";
|
|
1154
|
+
helper.style.pointerEvents = "none";
|
|
1155
|
+
document.body.appendChild(helper);
|
|
1156
|
+
helper.focus();
|
|
1157
|
+
helper.select();
|
|
1158
|
+
document.execCommand("copy");
|
|
1159
|
+
document.body.removeChild(helper);
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
throw new Error("clipboard unavailable");
|
|
1163
|
+
}
|
|
1164
|
+
setCopyStatusWithTimeout("copied");
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
setCopyStatusWithTimeout("error");
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return (_jsxs(_Fragment, { children: [children, !controlsHidden && (shareUrl || ready) && (_jsxs("div", { "data-repro-internal": "1", style: floatingContainerStyle, children: [_jsx("div", { "data-repro-internal": "1", style: { display: "flex", alignItems: "center", justifyContent: "flex-end", width: "100%", gap: 8 }, "aria-label": "Recording controls header", children: _jsx("button", { type: "button", "data-repro-internal": "1", onClick: hideControls, style: hideButtonStyle, "aria-label": "Hide recording controls", children: "Hide" }) }), shareUrl && (_jsxs("div", { "data-repro-internal": "1", style: shareCardStyle, children: [_jsxs("div", { style: {
|
|
1171
|
+
display: "flex",
|
|
1172
|
+
alignItems: "center",
|
|
1173
|
+
justifyContent: "space-between",
|
|
1174
|
+
gap: 12,
|
|
1175
|
+
width: "100%",
|
|
1176
|
+
}, children: [_jsx("div", { style: { fontSize: 12, fontWeight: 600, color: "#0f172a" }, children: "Latest capture ready to share" }), _jsx("button", { type: "button", "data-repro-internal": "1", onClick: () => clearShareInfo(), style: {
|
|
1177
|
+
border: "none",
|
|
1178
|
+
background: "transparent",
|
|
1179
|
+
color: "#9ca3af",
|
|
1180
|
+
cursor: "pointer",
|
|
1181
|
+
padding: 4,
|
|
1182
|
+
lineHeight: 1,
|
|
1183
|
+
fontWeight: 700,
|
|
1184
|
+
borderRadius: 999,
|
|
1185
|
+
width: 24,
|
|
1186
|
+
height: 24,
|
|
1187
|
+
display: "flex",
|
|
1188
|
+
alignItems: "center",
|
|
1189
|
+
justifyContent: "center",
|
|
1190
|
+
}, "aria-label": "Close share link", children: "\u00D7" })] }), _jsx("div", { style: shareLinkStyle, title: shareUrl, children: shareUrl }), _jsxs("div", { style: {
|
|
1191
|
+
marginTop: 10,
|
|
1192
|
+
display: "flex",
|
|
1193
|
+
alignItems: "center",
|
|
1194
|
+
justifyContent: "space-between",
|
|
1195
|
+
gap: 12,
|
|
1196
|
+
width: "100%",
|
|
1197
|
+
}, children: [_jsx("span", { style: {
|
|
1198
|
+
fontSize: 12,
|
|
1199
|
+
minWidth: 100,
|
|
1200
|
+
color: copyStatus === "copied"
|
|
1201
|
+
? "#059669"
|
|
1202
|
+
: copyStatus === "error"
|
|
1203
|
+
? "#b91c1c"
|
|
1204
|
+
: "#6b7280",
|
|
1205
|
+
visibility: copyStatus === "idle" ? "hidden" : "visible",
|
|
1206
|
+
transition: "color 0.2s ease",
|
|
1207
|
+
}, children: copyStatus === "copied"
|
|
1208
|
+
? "Link copied!"
|
|
1209
|
+
: copyStatus === "error"
|
|
1210
|
+
? "Unable to copy"
|
|
1211
|
+
: "placeholder" }), _jsx("button", { type: "button", "data-repro-internal": "1", onClick: () => void copyShareLink(), style: copyButtonStyle, children: copyStatus === "copied" ? "Copied" : "Copy link" })] })] })), ready && auth && (_jsxs("div", { "data-repro-internal": "1", style: buttonRowStyle, children: [_jsxs("button", { "data-repro-internal": "1", onClick: () => (recording ? stop() : start()), style: recordButtonStyle, type: "button", children: [_jsx("span", { "aria-hidden": "true", style: {
|
|
1212
|
+
width: 10,
|
|
1213
|
+
height: 10,
|
|
1214
|
+
borderRadius: "999px",
|
|
1215
|
+
background: recording ? "#fecaca" : "#bbf7d0",
|
|
1216
|
+
boxShadow: recording
|
|
1217
|
+
? "0 0 12px rgba(239, 68, 68, 0.7)"
|
|
1218
|
+
: "0 0 10px rgba(16, 185, 129, 0.6)",
|
|
1219
|
+
} }), btnLabel] }), _jsx("button", { "data-repro-internal": "1", onClick: () => void logout(), style: logoutButtonStyle, type: "button", children: "Log out" })] })), ready && !auth && (_jsx("div", { "data-repro-internal": "1", style: buttonRowStyle, children: _jsx("button", { "data-repro-internal": "1", onClick: () => {
|
|
1220
|
+
setLoginError(null);
|
|
1221
|
+
setShowLogin(true);
|
|
1222
|
+
}, style: loginButtonStyle, type: "button", children: loginLabel }) }))] })), controlsHidden && showHiddenNotice && (_jsxs("div", { "data-repro-internal": "1", style: hiddenNoticeStyle, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }, children: [_jsx("div", { style: { fontWeight: 700 }, children: "Recording controls hidden" }), _jsx("button", { type: "button", "data-repro-internal": "1", style: hiddenNoticeCloseStyle, "aria-label": "Close hidden controls message", onClick: () => setShowHiddenNotice(false), children: "\u00D7" })] }), _jsx("div", { children: "Press Ctrl/Cmd + R + O to show them again." })] })), showLogin && (_jsx("div", { "data-repro-internal": "1", style: modalOverlayStyle, onClick: (evt) => {
|
|
1223
|
+
if (evt.target === evt.currentTarget && !isLoggingIn) {
|
|
1224
|
+
setShowLogin(false);
|
|
1225
|
+
}
|
|
1226
|
+
}, children: _jsxs("div", { "data-repro-internal": "1", style: modalStyle, children: [_jsx("h3", { style: { margin: 0, marginBottom: 12, fontSize: 18, color: "#0f172a" }, children: "Authenticate to start recording" }), _jsxs("form", { onSubmit: handleLoginSubmit, children: [_jsx("label", { style: labelStyle, htmlFor: "repro-login-email", children: "Email" }), _jsx("input", { id: "repro-login-email", "data-repro-internal": "1", style: inputStyle, type: "email", value: loginEmail, placeholder: "user@example.com", onChange: (evt) => {
|
|
1227
|
+
setLoginEmail(evt.target.value);
|
|
1228
|
+
setLoginError(null);
|
|
1229
|
+
}, autoComplete: "email", required: true }), _jsx("label", { style: labelStyle, htmlFor: "repro-login-password", children: "Password" }), _jsx("input", { id: "repro-login-password", "data-repro-internal": "1", style: inputStyle, type: "password", value: loginPassword, placeholder: "Enter your workspace password", onChange: (evt) => {
|
|
1230
|
+
setLoginPassword(evt.target.value);
|
|
1231
|
+
setLoginError(null);
|
|
1232
|
+
}, autoComplete: "current-password", required: true }), loginError && (_jsx("div", { style: {
|
|
1233
|
+
color: "#b91c1c",
|
|
1234
|
+
background: "#fee2e2",
|
|
1235
|
+
borderRadius: 8,
|
|
1236
|
+
padding: "8px 12px",
|
|
1237
|
+
fontSize: 13,
|
|
1238
|
+
marginBottom: 12,
|
|
1239
|
+
}, children: loginError })), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 12 }, children: [_jsx("button", { type: "button", "data-repro-internal": "1", onClick: () => {
|
|
1240
|
+
if (!isLoggingIn)
|
|
1241
|
+
setShowLogin(false);
|
|
1242
|
+
}, style: {
|
|
1243
|
+
padding: "10px 16px",
|
|
1244
|
+
borderRadius: 6,
|
|
1245
|
+
border: "1px solid transparent",
|
|
1246
|
+
background: "transparent",
|
|
1247
|
+
color: "#4b5563",
|
|
1248
|
+
fontWeight: 600,
|
|
1249
|
+
cursor: isLoggingIn ? "not-allowed" : "pointer",
|
|
1250
|
+
}, disabled: isLoggingIn, children: "Cancel" }), _jsx("button", { type: "submit", "data-repro-internal": "1", style: {
|
|
1251
|
+
padding: "10px 16px",
|
|
1252
|
+
borderRadius: 6,
|
|
1253
|
+
border: "none",
|
|
1254
|
+
background: disableLogin ? "#d1d5db" : "#2563eb",
|
|
1255
|
+
color: disableLogin ? "#6b7280" : "#ffffff",
|
|
1256
|
+
fontWeight: 700,
|
|
1257
|
+
cursor: disableLogin ? "not-allowed" : "pointer",
|
|
1258
|
+
transition: "background 0.2s ease",
|
|
1259
|
+
}, disabled: disableLogin, children: isLoggingIn ? "Signing in..." : "Sign in" })] })] })] }) }))] }));
|
|
1260
|
+
}
|
|
1261
|
+
export default ReproProvider;
|