@bugspotter/sdk 0.1.0-alpha.1 → 0.1.0-alpha.3
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/CHANGELOG.md +21 -0
- package/README.md +93 -62
- package/dist/bugspotter.min.js +1 -1
- package/dist/capture/network.js +5 -2
- package/dist/collectors/dom.js +2 -1
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +16 -0
- package/dist/core/file-upload-handler.d.ts +61 -0
- package/dist/core/file-upload-handler.js +157 -0
- package/dist/core/transport.d.ts +14 -25
- package/dist/core/transport.js +39 -145
- package/dist/index.d.ts +20 -2
- package/dist/index.esm.js +103 -40
- package/dist/index.js +112 -47
- package/dist/widget/button.d.ts +5 -0
- package/dist/widget/button.js +50 -12
- package/docs/SESSION_REPLAY.md +66 -4
- package/package.json +2 -3
- package/tsconfig.cjs.json +15 -0
package/dist/capture/network.js
CHANGED
|
@@ -73,8 +73,11 @@ class NetworkCapture extends base_capture_1.BaseCapture {
|
|
|
73
73
|
}
|
|
74
74
|
try {
|
|
75
75
|
const response = await originalFetch(...args);
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// Only log if response is valid (handles mocked data URLs that return undefined)
|
|
77
|
+
if (response && typeof response.status === 'number') {
|
|
78
|
+
const request = this.createNetworkRequest(url, method, response.status, startTime);
|
|
79
|
+
this.addRequest(request);
|
|
80
|
+
}
|
|
78
81
|
return response;
|
|
79
82
|
}
|
|
80
83
|
catch (error) {
|
package/dist/collectors/dom.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DOMCollector = void 0;
|
|
4
4
|
const rrweb_1 = require("rrweb");
|
|
5
5
|
const buffer_1 = require("../core/buffer");
|
|
6
|
+
const constants_1 = require("../constants");
|
|
6
7
|
/**
|
|
7
8
|
* DOM Collector - Records user interactions and DOM mutations
|
|
8
9
|
* @packageDocumentation
|
|
@@ -15,7 +16,7 @@ class DOMCollector {
|
|
|
15
16
|
this.isRecording = false;
|
|
16
17
|
this.sanitizer = config.sanitizer;
|
|
17
18
|
this.config = {
|
|
18
|
-
duration: (_a = config.duration) !== null && _a !== void 0 ? _a :
|
|
19
|
+
duration: (_a = config.duration) !== null && _a !== void 0 ? _a : constants_1.DEFAULT_REPLAY_DURATION_SECONDS,
|
|
19
20
|
sampling: {
|
|
20
21
|
mousemove: (_c = (_b = config.sampling) === null || _b === void 0 ? void 0 : _b.mousemove) !== null && _c !== void 0 ? _c : 50,
|
|
21
22
|
scroll: (_e = (_d = config.sampling) === null || _d === void 0 ? void 0 : _d.scroll) !== null && _e !== void 0 ? _e : 100,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-wide constants
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Default duration in seconds to keep replay events in buffer
|
|
6
|
+
* Used by both BugSpotter and DOMCollector
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_REPLAY_DURATION_SECONDS = 15;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum recommended replay duration in seconds
|
|
11
|
+
* Longer durations increase memory usage
|
|
12
|
+
*/
|
|
13
|
+
export declare const MAX_RECOMMENDED_REPLAY_DURATION_SECONDS = 30;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SDK-wide constants
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_RECOMMENDED_REPLAY_DURATION_SECONDS = exports.DEFAULT_REPLAY_DURATION_SECONDS = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Default duration in seconds to keep replay events in buffer
|
|
9
|
+
* Used by both BugSpotter and DOMCollector
|
|
10
|
+
*/
|
|
11
|
+
exports.DEFAULT_REPLAY_DURATION_SECONDS = 15;
|
|
12
|
+
/**
|
|
13
|
+
* Maximum recommended replay duration in seconds
|
|
14
|
+
* Longer durations increase memory usage
|
|
15
|
+
*/
|
|
16
|
+
exports.MAX_RECOMMENDED_REPLAY_DURATION_SECONDS = 30;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { BugReport } from '../index';
|
|
2
|
+
export interface PresignedUrlData {
|
|
3
|
+
uploadUrl: string;
|
|
4
|
+
storageKey: string;
|
|
5
|
+
}
|
|
6
|
+
export interface FileToUpload {
|
|
7
|
+
type: 'screenshot' | 'replay';
|
|
8
|
+
url: string;
|
|
9
|
+
key: string;
|
|
10
|
+
blob: Blob;
|
|
11
|
+
}
|
|
12
|
+
export interface UploadConfirmation {
|
|
13
|
+
success: boolean;
|
|
14
|
+
type: 'screenshot' | 'replay';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Handles file upload operations using presigned URLs
|
|
18
|
+
* Separates concerns: preparation → upload → confirmation
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Upload timeout is set to 60 seconds (UPLOAD_TIMEOUT_MS).
|
|
22
|
+
* This timeout applies to individual file uploads to S3.
|
|
23
|
+
*/
|
|
24
|
+
export declare class FileUploadHandler {
|
|
25
|
+
private readonly apiEndpoint;
|
|
26
|
+
private readonly apiKey;
|
|
27
|
+
private static readonly UPLOAD_TIMEOUT_MS;
|
|
28
|
+
constructor(apiEndpoint: string, apiKey: string);
|
|
29
|
+
/**
|
|
30
|
+
* Orchestrates the complete file upload flow
|
|
31
|
+
* @throws Error if any step fails
|
|
32
|
+
*/
|
|
33
|
+
uploadFiles(bugId: string, report: BugReport, presignedUrls: {
|
|
34
|
+
screenshot?: PresignedUrlData;
|
|
35
|
+
replay?: PresignedUrlData;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Prepare file blobs and validate presigned URLs
|
|
39
|
+
*/
|
|
40
|
+
private prepareFiles;
|
|
41
|
+
/**
|
|
42
|
+
* Upload files to storage using presigned URLs (parallel execution)
|
|
43
|
+
*/
|
|
44
|
+
private uploadToStorage;
|
|
45
|
+
/**
|
|
46
|
+
* Confirm uploads with backend (parallel execution)
|
|
47
|
+
*/
|
|
48
|
+
private confirmUploads;
|
|
49
|
+
/**
|
|
50
|
+
* Get presigned URL with validation
|
|
51
|
+
*/
|
|
52
|
+
private getPresignedUrl;
|
|
53
|
+
/**
|
|
54
|
+
* Convert data URL to Blob
|
|
55
|
+
*/
|
|
56
|
+
private dataUrlToBlob;
|
|
57
|
+
/**
|
|
58
|
+
* Format file type for error messages (capitalize first letter)
|
|
59
|
+
*/
|
|
60
|
+
private formatFileType;
|
|
61
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FileUploadHandler = void 0;
|
|
4
|
+
const compress_1 = require("./compress");
|
|
5
|
+
const logger_1 = require("../utils/logger");
|
|
6
|
+
const logger = (0, logger_1.getLogger)();
|
|
7
|
+
/**
|
|
8
|
+
* Handles file upload operations using presigned URLs
|
|
9
|
+
* Separates concerns: preparation → upload → confirmation
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Upload timeout is set to 60 seconds (UPLOAD_TIMEOUT_MS).
|
|
13
|
+
* This timeout applies to individual file uploads to S3.
|
|
14
|
+
*/
|
|
15
|
+
class FileUploadHandler {
|
|
16
|
+
constructor(apiEndpoint, apiKey) {
|
|
17
|
+
this.apiEndpoint = apiEndpoint;
|
|
18
|
+
this.apiKey = apiKey;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Orchestrates the complete file upload flow
|
|
22
|
+
* @throws Error if any step fails
|
|
23
|
+
*/
|
|
24
|
+
async uploadFiles(bugId, report, presignedUrls) {
|
|
25
|
+
const filesToUpload = await this.prepareFiles(report, presignedUrls);
|
|
26
|
+
if (filesToUpload.length === 0) {
|
|
27
|
+
return; // No files to upload
|
|
28
|
+
}
|
|
29
|
+
await this.uploadToStorage(filesToUpload);
|
|
30
|
+
await this.confirmUploads(filesToUpload, bugId);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Prepare file blobs and validate presigned URLs
|
|
34
|
+
*/
|
|
35
|
+
async prepareFiles(report, presignedUrls) {
|
|
36
|
+
const files = [];
|
|
37
|
+
// Prepare screenshot
|
|
38
|
+
if (report._screenshotPreview && report._screenshotPreview.startsWith('data:image/')) {
|
|
39
|
+
const screenshotUrl = this.getPresignedUrl('screenshot', presignedUrls);
|
|
40
|
+
const screenshotBlob = await this.dataUrlToBlob(report._screenshotPreview);
|
|
41
|
+
files.push({
|
|
42
|
+
type: 'screenshot',
|
|
43
|
+
url: screenshotUrl.uploadUrl,
|
|
44
|
+
key: screenshotUrl.storageKey,
|
|
45
|
+
blob: screenshotBlob,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Prepare replay
|
|
49
|
+
if (report.replay && report.replay.length > 0) {
|
|
50
|
+
const replayUrl = this.getPresignedUrl('replay', presignedUrls);
|
|
51
|
+
const compressed = await (0, compress_1.compressData)(report.replay);
|
|
52
|
+
const replayBlob = new Blob([compressed], { type: 'application/gzip' });
|
|
53
|
+
files.push({
|
|
54
|
+
type: 'replay',
|
|
55
|
+
url: replayUrl.uploadUrl,
|
|
56
|
+
key: replayUrl.storageKey,
|
|
57
|
+
blob: replayBlob,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Upload files to storage using presigned URLs (parallel execution)
|
|
64
|
+
*/
|
|
65
|
+
async uploadToStorage(files) {
|
|
66
|
+
const uploadPromises = files.map(async (file) => {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timeoutId = setTimeout(() => controller.abort(), FileUploadHandler.UPLOAD_TIMEOUT_MS);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(file.url, {
|
|
71
|
+
method: 'PUT',
|
|
72
|
+
headers: {
|
|
73
|
+
'Content-Type': file.blob.type || 'application/octet-stream',
|
|
74
|
+
},
|
|
75
|
+
body: file.blob,
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
});
|
|
78
|
+
clearTimeout(timeoutId);
|
|
79
|
+
return { success: response.ok, type: file.type };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
clearTimeout(timeoutId);
|
|
83
|
+
logger.error(`Upload failed for ${file.type}:`, error);
|
|
84
|
+
return { success: false, type: file.type };
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const results = await Promise.all(uploadPromises);
|
|
88
|
+
// Check for upload failures
|
|
89
|
+
for (const result of results) {
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
throw new Error(`${this.formatFileType(result.type)} upload failed: Upload to storage failed`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Confirm uploads with backend (parallel execution)
|
|
97
|
+
*/
|
|
98
|
+
async confirmUploads(files, bugId) {
|
|
99
|
+
const confirmPromises = files.map(async (file) => {
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetch(`${this.apiEndpoint}/api/v1/reports/${bugId}/confirm-upload`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
'X-API-Key': this.apiKey,
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
fileType: file.type,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
return { success: response.ok, type: file.type };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
logger.error(`Confirmation failed for ${file.type}:`, error);
|
|
115
|
+
return { success: false, type: file.type };
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const results = await Promise.all(confirmPromises);
|
|
119
|
+
// Check for confirmation failures
|
|
120
|
+
for (const result of results) {
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
throw new Error(`${this.formatFileType(result.type)} confirmation failed: Backend did not acknowledge upload`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get presigned URL with validation
|
|
128
|
+
*/
|
|
129
|
+
getPresignedUrl(type, presignedUrls) {
|
|
130
|
+
const url = presignedUrls[type];
|
|
131
|
+
if (!url) {
|
|
132
|
+
throw new Error(`${this.formatFileType(type)} presigned URL not provided by server`);
|
|
133
|
+
}
|
|
134
|
+
return url;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Convert data URL to Blob
|
|
138
|
+
*/
|
|
139
|
+
async dataUrlToBlob(dataUrl) {
|
|
140
|
+
if (!dataUrl || !dataUrl.startsWith('data:')) {
|
|
141
|
+
throw new Error('Invalid data URL');
|
|
142
|
+
}
|
|
143
|
+
const response = await fetch(dataUrl);
|
|
144
|
+
if (!response || !response.blob) {
|
|
145
|
+
throw new Error('Failed to convert data URL to Blob');
|
|
146
|
+
}
|
|
147
|
+
return await response.blob();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Format file type for error messages (capitalize first letter)
|
|
151
|
+
*/
|
|
152
|
+
formatFileType(type) {
|
|
153
|
+
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.FileUploadHandler = FileUploadHandler;
|
|
157
|
+
FileUploadHandler.UPLOAD_TIMEOUT_MS = 60000; // 60 seconds
|
package/dist/core/transport.d.ts
CHANGED
|
@@ -9,28 +9,19 @@ export declare class TransportError extends Error {
|
|
|
9
9
|
readonly cause?: Error | undefined;
|
|
10
10
|
constructor(message: string, endpoint: string, cause?: Error | undefined);
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Authentication error - not retryable
|
|
14
|
+
*/
|
|
15
|
+
export declare class AuthenticationError extends Error {
|
|
16
|
+
constructor(message: string);
|
|
14
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Authentication configuration - API key only
|
|
20
|
+
*/
|
|
15
21
|
export type AuthConfig = {
|
|
16
22
|
type: 'api-key';
|
|
17
|
-
apiKey
|
|
18
|
-
|
|
19
|
-
type: 'jwt';
|
|
20
|
-
token?: string;
|
|
21
|
-
onTokenExpired?: () => Promise<string>;
|
|
22
|
-
} | {
|
|
23
|
-
type: 'bearer';
|
|
24
|
-
token?: string;
|
|
25
|
-
onTokenExpired?: () => Promise<string>;
|
|
26
|
-
} | {
|
|
27
|
-
type: 'custom';
|
|
28
|
-
customHeader?: {
|
|
29
|
-
name: string;
|
|
30
|
-
value: string;
|
|
31
|
-
};
|
|
32
|
-
} | {
|
|
33
|
-
type: 'none';
|
|
23
|
+
apiKey: string;
|
|
24
|
+
projectId: string;
|
|
34
25
|
};
|
|
35
26
|
export interface RetryConfig {
|
|
36
27
|
/** Maximum number of retry attempts (default: 3) */
|
|
@@ -43,12 +34,10 @@ export interface RetryConfig {
|
|
|
43
34
|
retryOn?: number[];
|
|
44
35
|
}
|
|
45
36
|
export interface TransportOptions {
|
|
46
|
-
/** Authentication configuration */
|
|
47
|
-
auth
|
|
37
|
+
/** Authentication configuration (required) */
|
|
38
|
+
auth: AuthConfig;
|
|
48
39
|
/** Optional logger for debugging */
|
|
49
40
|
logger?: Logger;
|
|
50
|
-
/** Enable retry on token expiration (default: true) */
|
|
51
|
-
enableRetry?: boolean;
|
|
52
41
|
/** Retry configuration */
|
|
53
42
|
retry?: RetryConfig;
|
|
54
43
|
/** Offline queue configuration */
|
|
@@ -59,7 +48,7 @@ export interface TransportOptions {
|
|
|
59
48
|
* @param auth - Authentication configuration
|
|
60
49
|
* @returns HTTP headers for authentication
|
|
61
50
|
*/
|
|
62
|
-
export declare function getAuthHeaders(auth
|
|
51
|
+
export declare function getAuthHeaders(auth: AuthConfig): Record<string, string>;
|
|
63
52
|
/**
|
|
64
53
|
* Submit request with authentication, exponential backoff retry, and offline queue support
|
|
65
54
|
*
|
|
@@ -69,5 +58,5 @@ export declare function getAuthHeaders(auth?: AuthConfig): Record<string, string
|
|
|
69
58
|
* @param authOrOptions - Auth config or TransportOptions
|
|
70
59
|
* @returns Response from the server
|
|
71
60
|
*/
|
|
72
|
-
export declare function submitWithAuth(endpoint: string, body: BodyInit, contentHeaders: Record<string, string
|
|
61
|
+
export declare function submitWithAuth(endpoint: string, body: BodyInit, contentHeaders: Record<string, string> | undefined, options: TransportOptions): Promise<Response>;
|
|
73
62
|
export { clearOfflineQueue, type OfflineConfig } from './offline-queue';
|
package/dist/core/transport.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* exponential backoff retry, and offline queue support
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.clearOfflineQueue = exports.
|
|
7
|
+
exports.clearOfflineQueue = exports.AuthenticationError = exports.TransportError = void 0;
|
|
8
8
|
exports.getAuthHeaders = getAuthHeaders;
|
|
9
9
|
exports.submitWithAuth = submitWithAuth;
|
|
10
10
|
const logger_1 = require("../utils/logger");
|
|
@@ -12,9 +12,7 @@ const offline_queue_1 = require("./offline-queue");
|
|
|
12
12
|
// ============================================================================
|
|
13
13
|
// CONSTANTS
|
|
14
14
|
// ============================================================================
|
|
15
|
-
const TOKEN_REFRESH_STATUS = 401;
|
|
16
15
|
const JITTER_PERCENTAGE = 0.1;
|
|
17
|
-
const DEFAULT_ENABLE_RETRY = true;
|
|
18
16
|
// ============================================================================
|
|
19
17
|
// CUSTOM ERROR TYPES
|
|
20
18
|
// ============================================================================
|
|
@@ -27,13 +25,16 @@ class TransportError extends Error {
|
|
|
27
25
|
}
|
|
28
26
|
}
|
|
29
27
|
exports.TransportError = TransportError;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Authentication error - not retryable
|
|
30
|
+
*/
|
|
31
|
+
class AuthenticationError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'AuthenticationError';
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
|
-
exports.
|
|
37
|
+
exports.AuthenticationError = AuthenticationError;
|
|
37
38
|
// Default configurations
|
|
38
39
|
const DEFAULT_RETRY_CONFIG = {
|
|
39
40
|
maxRetries: 3,
|
|
@@ -45,31 +46,18 @@ const DEFAULT_OFFLINE_CONFIG = {
|
|
|
45
46
|
enabled: false,
|
|
46
47
|
maxQueueSize: 10,
|
|
47
48
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
},
|
|
61
|
-
custom: (config) => {
|
|
62
|
-
const customHeader = config.customHeader;
|
|
63
|
-
if (!customHeader) {
|
|
64
|
-
return {};
|
|
65
|
-
}
|
|
66
|
-
const { name, value } = customHeader;
|
|
67
|
-
return name && value ? { [name]: value } : {};
|
|
68
|
-
},
|
|
69
|
-
none: () => {
|
|
70
|
-
return {};
|
|
71
|
-
},
|
|
72
|
-
};
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// AUTHENTICATION
|
|
51
|
+
// ============================================================================
|
|
52
|
+
/**
|
|
53
|
+
* Generate authentication headers for API key
|
|
54
|
+
*/
|
|
55
|
+
function generateAuthHeaders(config) {
|
|
56
|
+
if (!config || !config.apiKey) {
|
|
57
|
+
throw new AuthenticationError('Authentication is required: API key must be provided');
|
|
58
|
+
}
|
|
59
|
+
return { 'X-API-Key': config.apiKey };
|
|
60
|
+
}
|
|
73
61
|
// ============================================================================
|
|
74
62
|
// RETRY HANDLER - Exponential Backoff Logic
|
|
75
63
|
// ============================================================================
|
|
@@ -98,6 +86,10 @@ class RetryHandler {
|
|
|
98
86
|
}
|
|
99
87
|
catch (error) {
|
|
100
88
|
lastError = error;
|
|
89
|
+
// Don't retry authentication errors - they won't succeed on retry
|
|
90
|
+
if (error instanceof AuthenticationError) {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
101
93
|
// Retry on network errors
|
|
102
94
|
if (attempt < this.config.maxRetries) {
|
|
103
95
|
const delay = this.calculateDelay(attempt);
|
|
@@ -132,57 +124,6 @@ class RetryHandler {
|
|
|
132
124
|
return Math.min(delayWithJitter, this.config.maxDelay);
|
|
133
125
|
}
|
|
134
126
|
}
|
|
135
|
-
/**
|
|
136
|
-
* Type guard to check if parameter is TransportOptions
|
|
137
|
-
*
|
|
138
|
-
* Strategy: Check for properties that ONLY exist in TransportOptions, not in AuthConfig.
|
|
139
|
-
* AuthConfig has: type, apiKey?, token?, onTokenExpired?, customHeader?
|
|
140
|
-
* TransportOptions has: auth?, logger?, enableRetry?, retry?, offline?
|
|
141
|
-
*
|
|
142
|
-
* Key distinction: AuthConfig always has 'type' property, TransportOptions never does.
|
|
143
|
-
*/
|
|
144
|
-
function isTransportOptions(obj) {
|
|
145
|
-
if (typeof obj !== 'object' || obj === null) {
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
const record = obj;
|
|
149
|
-
// If it has 'type' property, it's an AuthConfig, not TransportOptions
|
|
150
|
-
if ('type' in record) {
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
// Key insight: If object has TransportOptions-specific keys (even if undefined),
|
|
154
|
-
// it's likely TransportOptions since AuthConfig never has these keys
|
|
155
|
-
const hasTransportOptionsKeys = 'auth' in record ||
|
|
156
|
-
'retry' in record ||
|
|
157
|
-
'offline' in record ||
|
|
158
|
-
'logger' in record ||
|
|
159
|
-
'enableRetry' in record;
|
|
160
|
-
// Return true if it has any TransportOptions-specific keys
|
|
161
|
-
return hasTransportOptionsKeys;
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Parse transport parameters, supporting both legacy and new API signatures
|
|
165
|
-
*/
|
|
166
|
-
function parseTransportParams(authOrOptions) {
|
|
167
|
-
var _a;
|
|
168
|
-
if (isTransportOptions(authOrOptions)) {
|
|
169
|
-
// Type guard ensures authOrOptions is TransportOptions
|
|
170
|
-
return {
|
|
171
|
-
auth: authOrOptions.auth,
|
|
172
|
-
logger: authOrOptions.logger || (0, logger_1.getLogger)(),
|
|
173
|
-
enableRetry: (_a = authOrOptions.enableRetry) !== null && _a !== void 0 ? _a : DEFAULT_ENABLE_RETRY,
|
|
174
|
-
retryConfig: Object.assign(Object.assign({}, DEFAULT_RETRY_CONFIG), authOrOptions.retry),
|
|
175
|
-
offlineConfig: Object.assign(Object.assign({}, DEFAULT_OFFLINE_CONFIG), authOrOptions.offline),
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
return {
|
|
179
|
-
auth: authOrOptions,
|
|
180
|
-
logger: (0, logger_1.getLogger)(),
|
|
181
|
-
enableRetry: DEFAULT_ENABLE_RETRY,
|
|
182
|
-
retryConfig: DEFAULT_RETRY_CONFIG,
|
|
183
|
-
offlineConfig: DEFAULT_OFFLINE_CONFIG,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
127
|
// ============================================================================
|
|
187
128
|
// HELPER FUNCTIONS
|
|
188
129
|
// ============================================================================
|
|
@@ -207,7 +148,7 @@ async function handleOfflineFailure(error, endpoint, body, contentHeaders, auth,
|
|
|
207
148
|
}
|
|
208
149
|
logger.warn('Network error detected, queueing request for offline retry');
|
|
209
150
|
const queue = new offline_queue_1.OfflineQueue(offlineConfig, logger);
|
|
210
|
-
const authHeaders =
|
|
151
|
+
const authHeaders = generateAuthHeaders(auth);
|
|
211
152
|
await queue.enqueue(endpoint, body, Object.assign(Object.assign({}, contentHeaders), authHeaders));
|
|
212
153
|
}
|
|
213
154
|
// ============================================================================
|
|
@@ -219,13 +160,7 @@ async function handleOfflineFailure(error, endpoint, body, contentHeaders, auth,
|
|
|
219
160
|
* @returns HTTP headers for authentication
|
|
220
161
|
*/
|
|
221
162
|
function getAuthHeaders(auth) {
|
|
222
|
-
|
|
223
|
-
if (!auth) {
|
|
224
|
-
return {};
|
|
225
|
-
}
|
|
226
|
-
// Apply strategy
|
|
227
|
-
const strategy = authStrategies[auth.type];
|
|
228
|
-
return strategy ? strategy(auth) : {};
|
|
163
|
+
return generateAuthHeaders(auth);
|
|
229
164
|
}
|
|
230
165
|
/**
|
|
231
166
|
* Submit request with authentication, exponential backoff retry, and offline queue support
|
|
@@ -236,35 +171,31 @@ function getAuthHeaders(auth) {
|
|
|
236
171
|
* @param authOrOptions - Auth config or TransportOptions
|
|
237
172
|
* @returns Response from the server
|
|
238
173
|
*/
|
|
239
|
-
async function submitWithAuth(endpoint, body, contentHeaders,
|
|
240
|
-
|
|
241
|
-
const
|
|
174
|
+
async function submitWithAuth(endpoint, body, contentHeaders = {}, options) {
|
|
175
|
+
const logger = options.logger || (0, logger_1.getLogger)();
|
|
176
|
+
const retryConfig = Object.assign(Object.assign({}, DEFAULT_RETRY_CONFIG), options.retry);
|
|
177
|
+
const offlineConfig = Object.assign(Object.assign({}, DEFAULT_OFFLINE_CONFIG), options.offline);
|
|
242
178
|
// Process offline queue on each request (run in background without awaiting)
|
|
243
179
|
processQueueInBackground(offlineConfig, retryConfig, logger);
|
|
244
180
|
try {
|
|
245
181
|
// Send with retry logic
|
|
246
|
-
const response = await sendWithRetry(endpoint, body, contentHeaders, auth, retryConfig, logger
|
|
182
|
+
const response = await sendWithRetry(endpoint, body, contentHeaders, options.auth, retryConfig, logger);
|
|
247
183
|
return response;
|
|
248
184
|
}
|
|
249
185
|
catch (error) {
|
|
250
186
|
// Queue for offline retry if enabled
|
|
251
|
-
await handleOfflineFailure(error, endpoint, body, contentHeaders, auth, offlineConfig, logger);
|
|
187
|
+
await handleOfflineFailure(error, endpoint, body, contentHeaders, options.auth, offlineConfig, logger);
|
|
252
188
|
throw error;
|
|
253
189
|
}
|
|
254
190
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
function shouldRetryWithRefresh(auth) {
|
|
259
|
-
return (typeof auth === 'object' &&
|
|
260
|
-
(auth.type === 'jwt' || auth.type === 'bearer') &&
|
|
261
|
-
typeof auth.onTokenExpired === 'function');
|
|
262
|
-
}
|
|
191
|
+
// ============================================================================
|
|
192
|
+
// INTERNAL HELPERS
|
|
193
|
+
// ============================================================================
|
|
263
194
|
/**
|
|
264
195
|
* Make HTTP request with auth headers
|
|
265
196
|
*/
|
|
266
197
|
async function makeRequest(endpoint, body, contentHeaders, auth) {
|
|
267
|
-
const authHeaders =
|
|
198
|
+
const authHeaders = generateAuthHeaders(auth);
|
|
268
199
|
const headers = Object.assign(Object.assign({}, contentHeaders), authHeaders);
|
|
269
200
|
return fetch(endpoint, {
|
|
270
201
|
method: 'POST',
|
|
@@ -275,25 +206,9 @@ async function makeRequest(endpoint, body, contentHeaders, auth) {
|
|
|
275
206
|
/**
|
|
276
207
|
* Send request with exponential backoff retry
|
|
277
208
|
*/
|
|
278
|
-
async function sendWithRetry(endpoint, body, contentHeaders, auth, retryConfig, logger
|
|
209
|
+
async function sendWithRetry(endpoint, body, contentHeaders, auth, retryConfig, logger) {
|
|
279
210
|
const retryHandler = new RetryHandler(retryConfig, logger);
|
|
280
|
-
|
|
281
|
-
// Use retry handler with token refresh support
|
|
282
|
-
return retryHandler.executeWithRetry(async () => {
|
|
283
|
-
const response = await makeRequest(endpoint, body, contentHeaders, auth);
|
|
284
|
-
// Check for 401 and retry with token refresh if applicable (only once)
|
|
285
|
-
if (response.status === TOKEN_REFRESH_STATUS &&
|
|
286
|
-
enableTokenRetry &&
|
|
287
|
-
!hasAttemptedRefresh &&
|
|
288
|
-
shouldRetryWithRefresh(auth)) {
|
|
289
|
-
hasAttemptedRefresh = true;
|
|
290
|
-
const refreshedResponse = await retryWithTokenRefresh(endpoint, body, contentHeaders, auth, logger);
|
|
291
|
-
return refreshedResponse;
|
|
292
|
-
}
|
|
293
|
-
return response;
|
|
294
|
-
}, (status) => {
|
|
295
|
-
return retryConfig.retryOn.includes(status);
|
|
296
|
-
});
|
|
211
|
+
return retryHandler.executeWithRetry(async () => makeRequest(endpoint, body, contentHeaders, auth), (status) => retryConfig.retryOn.includes(status));
|
|
297
212
|
}
|
|
298
213
|
/**
|
|
299
214
|
* Sleep for specified milliseconds
|
|
@@ -326,27 +241,6 @@ function isNetworkError(error) {
|
|
|
326
241
|
// TypeError only if it mentions fetch or network
|
|
327
242
|
(error.name === 'TypeError' && (message.includes('fetch') || message.includes('network'))));
|
|
328
243
|
}
|
|
329
|
-
/**
|
|
330
|
-
* Retry request with refreshed token
|
|
331
|
-
*/
|
|
332
|
-
async function retryWithTokenRefresh(endpoint, body, contentHeaders, auth, logger) {
|
|
333
|
-
try {
|
|
334
|
-
logger.warn('Token expired, attempting refresh...');
|
|
335
|
-
// Get new token
|
|
336
|
-
const newToken = await auth.onTokenExpired();
|
|
337
|
-
// Create updated auth config
|
|
338
|
-
const refreshedAuth = Object.assign(Object.assign({}, auth), { token: newToken });
|
|
339
|
-
// Retry request
|
|
340
|
-
const response = await makeRequest(endpoint, body, contentHeaders, refreshedAuth);
|
|
341
|
-
logger.log('Request retried with refreshed token');
|
|
342
|
-
return response;
|
|
343
|
-
}
|
|
344
|
-
catch (error) {
|
|
345
|
-
logger.error('Token refresh failed:', error);
|
|
346
|
-
// Return original 401 - caller should handle
|
|
347
|
-
return new Response(null, { status: TOKEN_REFRESH_STATUS, statusText: 'Unauthorized' });
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
244
|
// Re-export offline queue utilities
|
|
351
245
|
var offline_queue_2 = require("./offline-queue");
|
|
352
246
|
Object.defineProperty(exports, "clearOfflineQueue", { enumerable: true, get: function () { return offline_queue_2.clearOfflineQueue; } });
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,20 @@ export declare class BugSpotter {
|
|
|
23
23
|
*/
|
|
24
24
|
capture(): Promise<BugReport>;
|
|
25
25
|
private handleBugReport;
|
|
26
|
+
/**
|
|
27
|
+
* Validate authentication configuration
|
|
28
|
+
* @throws Error if configuration is invalid
|
|
29
|
+
*/
|
|
30
|
+
private validateAuthConfig;
|
|
31
|
+
/**
|
|
32
|
+
* Strip endpoint suffix from path
|
|
33
|
+
*/
|
|
34
|
+
private stripEndpointSuffix;
|
|
35
|
+
/**
|
|
36
|
+
* Get the base API URL for confirm-upload calls
|
|
37
|
+
* Extracts scheme, host, and base path from the configured endpoint
|
|
38
|
+
*/
|
|
39
|
+
private getApiBaseUrl;
|
|
26
40
|
private submitBugReport;
|
|
27
41
|
getConfig(): Readonly<BugSpotterConfig>;
|
|
28
42
|
destroy(): void;
|
|
@@ -31,8 +45,11 @@ export interface BugSpotterConfig {
|
|
|
31
45
|
endpoint?: string;
|
|
32
46
|
showWidget?: boolean;
|
|
33
47
|
widgetOptions?: FloatingButtonOptions;
|
|
34
|
-
/**
|
|
35
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Authentication configuration (required)
|
|
50
|
+
* API key authentication with project ID
|
|
51
|
+
*/
|
|
52
|
+
auth: AuthConfig;
|
|
36
53
|
/** Retry configuration for failed requests */
|
|
37
54
|
retry?: RetryConfig;
|
|
38
55
|
/** Offline queue configuration */
|
|
@@ -124,6 +141,7 @@ export type { FloatingButtonOptions } from './widget/button';
|
|
|
124
141
|
export { BugReportModal } from './widget/modal';
|
|
125
142
|
export type { BugReportData, BugReportModalOptions, PIIDetection } from './widget/modal';
|
|
126
143
|
export type { eventWithTime } from '@rrweb/types';
|
|
144
|
+
export { DEFAULT_REPLAY_DURATION_SECONDS, MAX_RECOMMENDED_REPLAY_DURATION_SECONDS, } from './constants';
|
|
127
145
|
/**
|
|
128
146
|
* Convenience function to sanitize text with default PII patterns
|
|
129
147
|
* Useful for quick sanitization without creating a Sanitizer instance
|