@bugspotter/sdk 0.1.0-alpha.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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/dist/bugspotter.min.js +2 -0
- package/dist/bugspotter.min.js.LICENSE.txt +14 -0
- package/dist/capture/base-capture.d.ts +34 -0
- package/dist/capture/base-capture.js +23 -0
- package/dist/capture/capture-lifecycle.d.ts +24 -0
- package/dist/capture/capture-lifecycle.js +2 -0
- package/dist/capture/console.d.ts +29 -0
- package/dist/capture/console.js +107 -0
- package/dist/capture/metadata.d.ts +21 -0
- package/dist/capture/metadata.js +76 -0
- package/dist/capture/network.d.ts +32 -0
- package/dist/capture/network.js +135 -0
- package/dist/capture/screenshot.d.ts +19 -0
- package/dist/capture/screenshot.js +52 -0
- package/dist/collectors/dom.d.ts +67 -0
- package/dist/collectors/dom.js +164 -0
- package/dist/collectors/index.d.ts +2 -0
- package/dist/collectors/index.js +5 -0
- package/dist/core/buffer.d.ts +50 -0
- package/dist/core/buffer.js +88 -0
- package/dist/core/circular-buffer.d.ts +42 -0
- package/dist/core/circular-buffer.js +77 -0
- package/dist/core/compress.d.ts +49 -0
- package/dist/core/compress.js +245 -0
- package/dist/core/offline-queue.d.ts +76 -0
- package/dist/core/offline-queue.js +301 -0
- package/dist/core/transport.d.ts +73 -0
- package/dist/core/transport.js +352 -0
- package/dist/core/upload-helpers.d.ts +32 -0
- package/dist/core/upload-helpers.js +79 -0
- package/dist/core/uploader.d.ts +70 -0
- package/dist/core/uploader.js +185 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.esm.js +205 -0
- package/dist/index.js +244 -0
- package/dist/utils/logger.d.ts +28 -0
- package/dist/utils/logger.js +84 -0
- package/dist/utils/sanitize-patterns.d.ts +103 -0
- package/dist/utils/sanitize-patterns.js +282 -0
- package/dist/utils/sanitize.d.ts +73 -0
- package/dist/utils/sanitize.js +254 -0
- package/dist/widget/button.d.ts +33 -0
- package/dist/widget/button.js +143 -0
- package/dist/widget/components/dom-element-cache.d.ts +62 -0
- package/dist/widget/components/dom-element-cache.js +105 -0
- package/dist/widget/components/form-validator.d.ts +66 -0
- package/dist/widget/components/form-validator.js +115 -0
- package/dist/widget/components/pii-detection-display.d.ts +64 -0
- package/dist/widget/components/pii-detection-display.js +142 -0
- package/dist/widget/components/redaction-canvas.d.ts +95 -0
- package/dist/widget/components/redaction-canvas.js +230 -0
- package/dist/widget/components/screenshot-processor.d.ts +44 -0
- package/dist/widget/components/screenshot-processor.js +191 -0
- package/dist/widget/components/style-manager.d.ts +37 -0
- package/dist/widget/components/style-manager.js +296 -0
- package/dist/widget/components/template-manager.d.ts +66 -0
- package/dist/widget/components/template-manager.js +198 -0
- package/dist/widget/modal.d.ts +62 -0
- package/dist/widget/modal.js +299 -0
- package/docs/CDN.md +213 -0
- package/docs/FRAMEWORK_INTEGRATION.md +1104 -0
- package/docs/PUBLISHING.md +550 -0
- package/docs/SESSION_REPLAY.md +381 -0
- package/package.json +90 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.compressData = compressData;
|
|
7
|
+
exports.decompressData = decompressData;
|
|
8
|
+
exports.compressImage = compressImage;
|
|
9
|
+
exports.getCompressionRatio = getCompressionRatio;
|
|
10
|
+
exports.estimateSize = estimateSize;
|
|
11
|
+
exports.resetCompressionCache = resetCompressionCache;
|
|
12
|
+
const pako_1 = __importDefault(require("pako"));
|
|
13
|
+
const logger_1 = require("../utils/logger");
|
|
14
|
+
/**
|
|
15
|
+
* Compression utilities for BugSpotter SDK
|
|
16
|
+
* Handles payload and image compression using browser-native APIs
|
|
17
|
+
*/
|
|
18
|
+
const logger = (0, logger_1.getLogger)();
|
|
19
|
+
// Configuration constants
|
|
20
|
+
const COMPRESSION_DEFAULTS = {
|
|
21
|
+
GZIP_LEVEL: 6, // Balanced speed/size ratio (0-9)
|
|
22
|
+
IMAGE_MAX_WIDTH: 1920,
|
|
23
|
+
IMAGE_MAX_HEIGHT: 1080,
|
|
24
|
+
IMAGE_WEBP_QUALITY: 0.8,
|
|
25
|
+
IMAGE_JPEG_QUALITY: 0.85,
|
|
26
|
+
IMAGE_LOAD_TIMEOUT: 3000, // milliseconds
|
|
27
|
+
};
|
|
28
|
+
// Singleton instances for performance
|
|
29
|
+
let textEncoder = null;
|
|
30
|
+
let textDecoder = null;
|
|
31
|
+
let webpSupportCache = null;
|
|
32
|
+
/**
|
|
33
|
+
* Get or create TextEncoder instance
|
|
34
|
+
*/
|
|
35
|
+
function getTextEncoder() {
|
|
36
|
+
if (!textEncoder) {
|
|
37
|
+
textEncoder = new TextEncoder();
|
|
38
|
+
}
|
|
39
|
+
return textEncoder;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get or create TextDecoder instance
|
|
43
|
+
*/
|
|
44
|
+
function getTextDecoder() {
|
|
45
|
+
if (!textDecoder) {
|
|
46
|
+
textDecoder = new TextDecoder();
|
|
47
|
+
}
|
|
48
|
+
return textDecoder;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Convert data to string representation
|
|
52
|
+
*/
|
|
53
|
+
function dataToString(data) {
|
|
54
|
+
return typeof data === 'string' ? data : JSON.stringify(data);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Compress JSON or string data using gzip
|
|
58
|
+
* @param data - Data to compress (will be JSON stringified if object)
|
|
59
|
+
* @param config - Optional compression configuration
|
|
60
|
+
* @returns Compressed data as Uint8Array
|
|
61
|
+
*/
|
|
62
|
+
async function compressData(data, config) {
|
|
63
|
+
var _a;
|
|
64
|
+
try {
|
|
65
|
+
const jsonString = dataToString(data);
|
|
66
|
+
const encoder = getTextEncoder();
|
|
67
|
+
const uint8Data = encoder.encode(jsonString);
|
|
68
|
+
const gzipLevel = (_a = config === null || config === void 0 ? void 0 : config.gzipLevel) !== null && _a !== void 0 ? _a : COMPRESSION_DEFAULTS.GZIP_LEVEL;
|
|
69
|
+
// pako.gzip already returns Uint8Array, no need to wrap it
|
|
70
|
+
const compressed = pako_1.default.gzip(uint8Data, { level: gzipLevel });
|
|
71
|
+
return compressed;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
logger.error('Compression failed:', error);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Try to parse string as JSON, return string if not valid JSON
|
|
80
|
+
*/
|
|
81
|
+
function tryParseJSON(jsonString) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(jsonString);
|
|
84
|
+
}
|
|
85
|
+
catch (_a) {
|
|
86
|
+
return jsonString;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Decompress gzipped data back to original format
|
|
91
|
+
* Useful for testing and verification
|
|
92
|
+
* @param compressed - Compressed Uint8Array data
|
|
93
|
+
* @param config - Optional configuration
|
|
94
|
+
* @returns Decompressed and parsed data (or string if input was string)
|
|
95
|
+
*/
|
|
96
|
+
function decompressData(compressed, config) {
|
|
97
|
+
try {
|
|
98
|
+
const decompressed = pako_1.default.ungzip(compressed);
|
|
99
|
+
const decoder = getTextDecoder();
|
|
100
|
+
const jsonString = decoder.decode(decompressed);
|
|
101
|
+
return tryParseJSON(jsonString);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if ((config === null || config === void 0 ? void 0 : config.verbose) !== false) {
|
|
105
|
+
(0, logger_1.getLogger)().error('Decompression failed:', error);
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`Failed to decompress data: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check if code is running in browser environment
|
|
112
|
+
*/
|
|
113
|
+
function isBrowserEnvironment() {
|
|
114
|
+
return (typeof document !== 'undefined' &&
|
|
115
|
+
typeof Image !== 'undefined' &&
|
|
116
|
+
typeof HTMLCanvasElement !== 'undefined');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if browser supports WebP format (cached result)
|
|
120
|
+
*/
|
|
121
|
+
function supportsWebP() {
|
|
122
|
+
if (webpSupportCache !== null) {
|
|
123
|
+
return webpSupportCache;
|
|
124
|
+
}
|
|
125
|
+
if (!isBrowserEnvironment()) {
|
|
126
|
+
webpSupportCache = false;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const canvas = document.createElement('canvas');
|
|
131
|
+
webpSupportCache = canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
|
132
|
+
}
|
|
133
|
+
catch (_a) {
|
|
134
|
+
webpSupportCache = false;
|
|
135
|
+
}
|
|
136
|
+
return webpSupportCache;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Load image from base64 string with timeout
|
|
140
|
+
*/
|
|
141
|
+
function loadImage(base64, timeout) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const img = new Image();
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
reject(new Error(`Image load timeout after ${timeout}ms`));
|
|
146
|
+
}, timeout);
|
|
147
|
+
img.onload = () => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolve(img);
|
|
150
|
+
};
|
|
151
|
+
img.onerror = () => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
reject(new Error('Failed to load image'));
|
|
154
|
+
};
|
|
155
|
+
img.src = base64;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Calculate resized dimensions maintaining aspect ratio
|
|
160
|
+
*/
|
|
161
|
+
function calculateResizedDimensions(width, height, maxWidth, maxHeight) {
|
|
162
|
+
let newWidth = width;
|
|
163
|
+
let newHeight = height;
|
|
164
|
+
if (newWidth > maxWidth) {
|
|
165
|
+
newHeight = (newHeight * maxWidth) / newWidth;
|
|
166
|
+
newWidth = maxWidth;
|
|
167
|
+
}
|
|
168
|
+
if (newHeight > maxHeight) {
|
|
169
|
+
newWidth = (newWidth * maxHeight) / newHeight;
|
|
170
|
+
newHeight = maxHeight;
|
|
171
|
+
}
|
|
172
|
+
return { width: newWidth, height: newHeight };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Optimize and compress screenshot image
|
|
176
|
+
* Converts to WebP if supported, resizes if too large, then compresses
|
|
177
|
+
* @param base64 - Base64 encoded image (PNG or other format)
|
|
178
|
+
* @param config - Optional compression configuration
|
|
179
|
+
* @returns Optimized base64 image string
|
|
180
|
+
*/
|
|
181
|
+
async function compressImage(base64, config) {
|
|
182
|
+
var _a, _b, _c, _d;
|
|
183
|
+
try {
|
|
184
|
+
if (!isBrowserEnvironment()) {
|
|
185
|
+
return base64;
|
|
186
|
+
}
|
|
187
|
+
const maxWidth = (_a = config === null || config === void 0 ? void 0 : config.imageMaxWidth) !== null && _a !== void 0 ? _a : COMPRESSION_DEFAULTS.IMAGE_MAX_WIDTH;
|
|
188
|
+
const maxHeight = (_b = config === null || config === void 0 ? void 0 : config.imageMaxHeight) !== null && _b !== void 0 ? _b : COMPRESSION_DEFAULTS.IMAGE_MAX_HEIGHT;
|
|
189
|
+
const webpQuality = (_c = config === null || config === void 0 ? void 0 : config.webpQuality) !== null && _c !== void 0 ? _c : COMPRESSION_DEFAULTS.IMAGE_WEBP_QUALITY;
|
|
190
|
+
const jpegQuality = (_d = config === null || config === void 0 ? void 0 : config.jpegQuality) !== null && _d !== void 0 ? _d : COMPRESSION_DEFAULTS.IMAGE_JPEG_QUALITY;
|
|
191
|
+
const timeout = COMPRESSION_DEFAULTS.IMAGE_LOAD_TIMEOUT;
|
|
192
|
+
const img = await loadImage(base64, timeout);
|
|
193
|
+
const canvas = document.createElement('canvas');
|
|
194
|
+
const ctx = canvas.getContext('2d');
|
|
195
|
+
if (!ctx) {
|
|
196
|
+
throw new Error('Failed to get 2D canvas context');
|
|
197
|
+
}
|
|
198
|
+
const { width, height } = calculateResizedDimensions(img.width, img.height, maxWidth, maxHeight);
|
|
199
|
+
canvas.width = width;
|
|
200
|
+
canvas.height = height;
|
|
201
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
202
|
+
if (supportsWebP()) {
|
|
203
|
+
return canvas.toDataURL('image/webp', webpQuality);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
return canvas.toDataURL('image/jpeg', jpegQuality);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if ((config === null || config === void 0 ? void 0 : config.verbose) !== false) {
|
|
211
|
+
(0, logger_1.getLogger)().error('Image compression failed:', error);
|
|
212
|
+
}
|
|
213
|
+
return base64;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Calculate compression ratio for analytics
|
|
218
|
+
* @param originalSize - Original data size in bytes
|
|
219
|
+
* @param compressedSize - Compressed data size in bytes
|
|
220
|
+
* @returns Compression ratio as percentage (0-100)
|
|
221
|
+
*/
|
|
222
|
+
function getCompressionRatio(originalSize, compressedSize) {
|
|
223
|
+
if (originalSize <= 0) {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
return Math.round((1 - compressedSize / originalSize) * 100);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Estimate payload size before compression
|
|
230
|
+
* @param data - Data to estimate
|
|
231
|
+
* @returns Estimated size in bytes
|
|
232
|
+
*/
|
|
233
|
+
function estimateSize(data) {
|
|
234
|
+
const jsonString = dataToString(data);
|
|
235
|
+
return getTextEncoder().encode(jsonString).length;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Reset cached instances (useful for testing)
|
|
239
|
+
* @internal
|
|
240
|
+
*/
|
|
241
|
+
function resetCompressionCache() {
|
|
242
|
+
textEncoder = null;
|
|
243
|
+
textDecoder = null;
|
|
244
|
+
webpSupportCache = null;
|
|
245
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline queue for storing failed requests with localStorage persistence
|
|
3
|
+
*/
|
|
4
|
+
import { type Logger } from '../utils/logger';
|
|
5
|
+
/**
|
|
6
|
+
* Storage interface for queue persistence
|
|
7
|
+
*/
|
|
8
|
+
export interface StorageAdapter {
|
|
9
|
+
getItem(key: string): string | null;
|
|
10
|
+
setItem(key: string, value: string): void;
|
|
11
|
+
removeItem(key: string): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* LocalStorage implementation of StorageAdapter
|
|
15
|
+
*/
|
|
16
|
+
export declare class LocalStorageAdapter implements StorageAdapter {
|
|
17
|
+
getItem(key: string): string | null;
|
|
18
|
+
setItem(key: string, value: string): void;
|
|
19
|
+
removeItem(key: string): void;
|
|
20
|
+
}
|
|
21
|
+
export interface OfflineConfig {
|
|
22
|
+
/** Enable offline queue (default: false) */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Maximum number of requests to queue (default: 10) */
|
|
25
|
+
maxQueueSize?: number;
|
|
26
|
+
}
|
|
27
|
+
export declare class OfflineQueue {
|
|
28
|
+
private config;
|
|
29
|
+
private logger;
|
|
30
|
+
private storage;
|
|
31
|
+
private requestCounter;
|
|
32
|
+
constructor(config: OfflineConfig, logger?: Logger, storage?: StorageAdapter);
|
|
33
|
+
/**
|
|
34
|
+
* Queue a request for offline retry
|
|
35
|
+
*/
|
|
36
|
+
enqueue(endpoint: string, body: BodyInit, headers: Record<string, string>): void;
|
|
37
|
+
/**
|
|
38
|
+
* Process offline queue
|
|
39
|
+
*/
|
|
40
|
+
process(retryableStatusCodes: number[]): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Clear offline queue
|
|
43
|
+
*/
|
|
44
|
+
clear(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Get queue size
|
|
47
|
+
*/
|
|
48
|
+
size(): number;
|
|
49
|
+
/**
|
|
50
|
+
* Serialize body to string format
|
|
51
|
+
*/
|
|
52
|
+
private serializeBody;
|
|
53
|
+
/**
|
|
54
|
+
* Create a queued request object
|
|
55
|
+
*/
|
|
56
|
+
private createQueuedRequest;
|
|
57
|
+
/**
|
|
58
|
+
* Validate that item size doesn't exceed localStorage limits
|
|
59
|
+
*/
|
|
60
|
+
private validateItemSize;
|
|
61
|
+
private getQueue;
|
|
62
|
+
private saveQueue;
|
|
63
|
+
/**
|
|
64
|
+
* Check if error is a quota exceeded error (cross-browser compatible)
|
|
65
|
+
*/
|
|
66
|
+
private isQuotaExceededError;
|
|
67
|
+
/**
|
|
68
|
+
* Clear oldest 50% of items and retry save
|
|
69
|
+
*/
|
|
70
|
+
private clearOldestItems;
|
|
71
|
+
private generateRequestId;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Global function to clear offline queue
|
|
75
|
+
*/
|
|
76
|
+
export declare function clearOfflineQueue(): void;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Offline queue for storing failed requests with localStorage persistence
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.OfflineQueue = exports.LocalStorageAdapter = void 0;
|
|
7
|
+
exports.clearOfflineQueue = clearOfflineQueue;
|
|
8
|
+
const logger_1 = require("../utils/logger");
|
|
9
|
+
/**
|
|
10
|
+
* LocalStorage implementation of StorageAdapter
|
|
11
|
+
*/
|
|
12
|
+
class LocalStorageAdapter {
|
|
13
|
+
getItem(key) {
|
|
14
|
+
try {
|
|
15
|
+
if (typeof localStorage === 'undefined') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return localStorage.getItem(key);
|
|
19
|
+
}
|
|
20
|
+
catch (_a) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
setItem(key, value) {
|
|
25
|
+
if (typeof localStorage === 'undefined') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
localStorage.setItem(key, value);
|
|
29
|
+
}
|
|
30
|
+
removeItem(key) {
|
|
31
|
+
try {
|
|
32
|
+
if (typeof localStorage === 'undefined') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
localStorage.removeItem(key);
|
|
36
|
+
}
|
|
37
|
+
catch (_a) {
|
|
38
|
+
// Ignore errors
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.LocalStorageAdapter = LocalStorageAdapter;
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// CONSTANTS
|
|
45
|
+
// ============================================================================
|
|
46
|
+
const QUEUE_STORAGE_KEY = 'bugspotter_offline_queue';
|
|
47
|
+
const QUEUE_EXPIRY_DAYS = 7;
|
|
48
|
+
const MAX_RETRY_ATTEMPTS = 5;
|
|
49
|
+
const MAX_ITEM_SIZE_BYTES = 100 * 1024; // 100KB per item
|
|
50
|
+
const DEFAULT_OFFLINE_CONFIG = {
|
|
51
|
+
enabled: false,
|
|
52
|
+
maxQueueSize: 10,
|
|
53
|
+
};
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// OFFLINE QUEUE CLASS
|
|
56
|
+
// ============================================================================
|
|
57
|
+
class OfflineQueue {
|
|
58
|
+
constructor(config, logger, storage) {
|
|
59
|
+
this.requestCounter = 0;
|
|
60
|
+
this.config = Object.assign(Object.assign({}, DEFAULT_OFFLINE_CONFIG), config);
|
|
61
|
+
this.logger = logger || (0, logger_1.getLogger)();
|
|
62
|
+
this.storage = storage || new LocalStorageAdapter();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Queue a request for offline retry
|
|
66
|
+
*/
|
|
67
|
+
enqueue(endpoint, body, headers) {
|
|
68
|
+
try {
|
|
69
|
+
const serializedBody = this.serializeBody(body);
|
|
70
|
+
if (!serializedBody || !this.validateItemSize(serializedBody)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const queue = this.getQueue();
|
|
74
|
+
// Ensure space in queue
|
|
75
|
+
if (queue.length >= this.config.maxQueueSize) {
|
|
76
|
+
this.logger.warn(`Offline queue is full (${this.config.maxQueueSize}), removing oldest request`);
|
|
77
|
+
queue.shift();
|
|
78
|
+
}
|
|
79
|
+
queue.push(this.createQueuedRequest(endpoint, serializedBody, headers));
|
|
80
|
+
this.saveQueue(queue);
|
|
81
|
+
this.logger.log(`Request queued for offline retry (queue size: ${queue.length})`);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
this.logger.error('Failed to queue request for offline retry:', error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Process offline queue
|
|
89
|
+
*/
|
|
90
|
+
async process(retryableStatusCodes) {
|
|
91
|
+
const queue = this.getQueue();
|
|
92
|
+
if (queue.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.logger.log(`Processing offline queue (${queue.length} requests)`);
|
|
96
|
+
const successfulIds = [];
|
|
97
|
+
const failedRequests = [];
|
|
98
|
+
for (const request of queue) {
|
|
99
|
+
// Check if request has exceeded max retry attempts
|
|
100
|
+
if (request.attempts >= MAX_RETRY_ATTEMPTS) {
|
|
101
|
+
this.logger.warn(`Max retry attempts (${MAX_RETRY_ATTEMPTS}) reached for request (id: ${request.id}), removing`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Check if request has expired
|
|
105
|
+
const age = Date.now() - request.timestamp;
|
|
106
|
+
const maxAge = QUEUE_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
|
|
107
|
+
if (age > maxAge) {
|
|
108
|
+
this.logger.warn(`Removing expired queued request (id: ${request.id})`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
// Attempt to send
|
|
113
|
+
const response = await fetch(request.endpoint, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: request.headers,
|
|
116
|
+
body: request.body,
|
|
117
|
+
});
|
|
118
|
+
if (response.ok) {
|
|
119
|
+
this.logger.log(`Successfully sent queued request (id: ${request.id})`);
|
|
120
|
+
successfulIds.push(request.id);
|
|
121
|
+
}
|
|
122
|
+
else if (retryableStatusCodes.includes(response.status)) {
|
|
123
|
+
// Keep in queue for next attempt
|
|
124
|
+
request.attempts++;
|
|
125
|
+
failedRequests.push(request);
|
|
126
|
+
this.logger.warn(`Queued request failed with status ${response.status}, will retry later (id: ${request.id})`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Non-retryable error, remove from queue
|
|
130
|
+
this.logger.warn(`Queued request failed with non-retryable status ${response.status}, removing (id: ${request.id})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// Network error, keep in queue
|
|
135
|
+
request.attempts++;
|
|
136
|
+
failedRequests.push(request);
|
|
137
|
+
this.logger.warn(`Queued request failed with network error, will retry later (id: ${request.id}):`, error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Update queue (remove successful and expired, keep failed)
|
|
141
|
+
this.saveQueue(failedRequests);
|
|
142
|
+
if (successfulIds.length > 0 || failedRequests.length < queue.length) {
|
|
143
|
+
this.logger.log(`Offline queue processed: ${successfulIds.length} successful, ${failedRequests.length} remaining`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Clear offline queue
|
|
148
|
+
*/
|
|
149
|
+
clear() {
|
|
150
|
+
try {
|
|
151
|
+
this.storage.removeItem(QUEUE_STORAGE_KEY);
|
|
152
|
+
}
|
|
153
|
+
catch (_a) {
|
|
154
|
+
// Ignore storage errors
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Get queue size
|
|
159
|
+
*/
|
|
160
|
+
size() {
|
|
161
|
+
return this.getQueue().length;
|
|
162
|
+
}
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// PRIVATE METHODS
|
|
165
|
+
// ============================================================================
|
|
166
|
+
/**
|
|
167
|
+
* Serialize body to string format
|
|
168
|
+
*/
|
|
169
|
+
serializeBody(body) {
|
|
170
|
+
if (typeof body === 'string') {
|
|
171
|
+
return body;
|
|
172
|
+
}
|
|
173
|
+
if (body instanceof Blob) {
|
|
174
|
+
this.logger.warn('Cannot queue Blob for offline retry, skipping');
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return JSON.stringify(body);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Create a queued request object
|
|
181
|
+
*/
|
|
182
|
+
createQueuedRequest(endpoint, body, headers) {
|
|
183
|
+
return {
|
|
184
|
+
id: this.generateRequestId(),
|
|
185
|
+
endpoint,
|
|
186
|
+
body,
|
|
187
|
+
headers,
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
attempts: 0,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Validate that item size doesn't exceed localStorage limits
|
|
194
|
+
*/
|
|
195
|
+
validateItemSize(body) {
|
|
196
|
+
const sizeInBytes = new Blob([body]).size;
|
|
197
|
+
if (sizeInBytes > MAX_ITEM_SIZE_BYTES) {
|
|
198
|
+
this.logger.warn(`Request body too large (${sizeInBytes} bytes), skipping queue`);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
getQueue() {
|
|
204
|
+
try {
|
|
205
|
+
const stored = this.storage.getItem(QUEUE_STORAGE_KEY);
|
|
206
|
+
if (!stored) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
return JSON.parse(stored);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
// Log corrupted data and clear it to prevent repeated errors
|
|
213
|
+
this.logger.warn('Failed to parse offline queue data, clearing corrupted queue:', error);
|
|
214
|
+
this.clear();
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
saveQueue(queue) {
|
|
219
|
+
try {
|
|
220
|
+
this.storage.setItem(QUEUE_STORAGE_KEY, JSON.stringify(queue));
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// Handle quota exceeded error (check multiple properties for cross-browser compatibility)
|
|
224
|
+
if (this.isQuotaExceededError(error)) {
|
|
225
|
+
this.logger.error('localStorage quota exceeded, clearing oldest items');
|
|
226
|
+
this.clearOldestItems(queue);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
this.logger.error('Failed to save offline queue:', error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Check if error is a quota exceeded error (cross-browser compatible)
|
|
235
|
+
*/
|
|
236
|
+
isQuotaExceededError(error) {
|
|
237
|
+
if (!(error instanceof Error)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
// Check error name (standard)
|
|
241
|
+
if (error.name === 'QuotaExceededError') {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
// Check DOMException code (Safari, older browsers)
|
|
245
|
+
if ('code' in error && error.code === 22) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
// Check error message as fallback (Firefox, Chrome variants)
|
|
249
|
+
const message = error.message.toLowerCase();
|
|
250
|
+
return message.includes('quota') || message.includes('storage') || message.includes('exceeded');
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Clear oldest 50% of items and retry save
|
|
254
|
+
*/
|
|
255
|
+
clearOldestItems(queue) {
|
|
256
|
+
try {
|
|
257
|
+
const trimmedQueue = queue.slice(Math.floor(queue.length / 2));
|
|
258
|
+
this.storage.setItem(QUEUE_STORAGE_KEY, JSON.stringify(trimmedQueue));
|
|
259
|
+
this.logger.log(`Trimmed offline queue to ${trimmedQueue.length} items due to quota`);
|
|
260
|
+
}
|
|
261
|
+
catch (_a) {
|
|
262
|
+
// If still failing, clear everything
|
|
263
|
+
this.logger.error('Failed to save even after trimming, clearing queue');
|
|
264
|
+
this.clear();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
generateRequestId() {
|
|
268
|
+
// Increment counter for additional entropy
|
|
269
|
+
this.requestCounter = (this.requestCounter + 1) % 10000;
|
|
270
|
+
// Use crypto.getRandomValues for better randomness (browser-safe fallback)
|
|
271
|
+
let randomPart;
|
|
272
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
273
|
+
const array = new Uint32Array(2);
|
|
274
|
+
crypto.getRandomValues(array);
|
|
275
|
+
randomPart = Array.from(array, (num) => {
|
|
276
|
+
return num.toString(36);
|
|
277
|
+
}).join('');
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// Fallback to Math.random for environments without crypto
|
|
281
|
+
randomPart =
|
|
282
|
+
Math.random().toString(36).substring(2, 9) + Math.random().toString(36).substring(2, 9);
|
|
283
|
+
}
|
|
284
|
+
return `req_${Date.now()}_${this.requestCounter}_${randomPart}`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
exports.OfflineQueue = OfflineQueue;
|
|
288
|
+
/**
|
|
289
|
+
* Global function to clear offline queue
|
|
290
|
+
*/
|
|
291
|
+
function clearOfflineQueue() {
|
|
292
|
+
try {
|
|
293
|
+
const storage = new LocalStorageAdapter();
|
|
294
|
+
storage.removeItem(QUEUE_STORAGE_KEY);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
// Log error for debugging purposes
|
|
298
|
+
const logger = (0, logger_1.getLogger)();
|
|
299
|
+
logger.warn('Failed to clear offline queue:', error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport layer for bug report submission with flexible authentication,
|
|
3
|
+
* exponential backoff retry, and offline queue support
|
|
4
|
+
*/
|
|
5
|
+
import { type Logger } from '../utils/logger';
|
|
6
|
+
import { type OfflineConfig } from './offline-queue';
|
|
7
|
+
export declare class TransportError extends Error {
|
|
8
|
+
readonly endpoint: string;
|
|
9
|
+
readonly cause?: Error | undefined;
|
|
10
|
+
constructor(message: string, endpoint: string, cause?: Error | undefined);
|
|
11
|
+
}
|
|
12
|
+
export declare class TokenRefreshError extends TransportError {
|
|
13
|
+
constructor(endpoint: string, cause?: Error);
|
|
14
|
+
}
|
|
15
|
+
export type AuthConfig = {
|
|
16
|
+
type: 'api-key';
|
|
17
|
+
apiKey?: string;
|
|
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';
|
|
34
|
+
};
|
|
35
|
+
export interface RetryConfig {
|
|
36
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
37
|
+
maxRetries?: number;
|
|
38
|
+
/** Base delay in milliseconds (default: 1000) */
|
|
39
|
+
baseDelay?: number;
|
|
40
|
+
/** Maximum delay in milliseconds (default: 30000) */
|
|
41
|
+
maxDelay?: number;
|
|
42
|
+
/** HTTP status codes to retry on (default: [502, 503, 504, 429]) */
|
|
43
|
+
retryOn?: number[];
|
|
44
|
+
}
|
|
45
|
+
export interface TransportOptions {
|
|
46
|
+
/** Authentication configuration */
|
|
47
|
+
auth?: AuthConfig;
|
|
48
|
+
/** Optional logger for debugging */
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
/** Enable retry on token expiration (default: true) */
|
|
51
|
+
enableRetry?: boolean;
|
|
52
|
+
/** Retry configuration */
|
|
53
|
+
retry?: RetryConfig;
|
|
54
|
+
/** Offline queue configuration */
|
|
55
|
+
offline?: OfflineConfig;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get authentication headers based on configuration
|
|
59
|
+
* @param auth - Authentication configuration
|
|
60
|
+
* @returns HTTP headers for authentication
|
|
61
|
+
*/
|
|
62
|
+
export declare function getAuthHeaders(auth?: AuthConfig): Record<string, string>;
|
|
63
|
+
/**
|
|
64
|
+
* Submit request with authentication, exponential backoff retry, and offline queue support
|
|
65
|
+
*
|
|
66
|
+
* @param endpoint - API endpoint URL
|
|
67
|
+
* @param body - Request body (must be serializable for retry)
|
|
68
|
+
* @param contentHeaders - Content-related headers (Content-Type, etc.)
|
|
69
|
+
* @param authOrOptions - Auth config or TransportOptions
|
|
70
|
+
* @returns Response from the server
|
|
71
|
+
*/
|
|
72
|
+
export declare function submitWithAuth(endpoint: string, body: BodyInit, contentHeaders: Record<string, string>, authOrOptions?: AuthConfig | TransportOptions): Promise<Response>;
|
|
73
|
+
export { clearOfflineQueue, type OfflineConfig } from './offline-queue';
|