@fallow-cli/beacon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.js +302 -0
- package/dist/node.js +384 -0
- package/package.json +41 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
+
for (let key of __getOwnPropNames(mod))
|
|
11
|
+
if (!__hasOwnProp.call(to, key))
|
|
12
|
+
__defProp(to, key, {
|
|
13
|
+
get: () => mod[key],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// src/transport.ts
|
|
21
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 30000;
|
|
22
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
23
|
+
var DEFAULT_MAX_BATCH_SIZE = 100;
|
|
24
|
+
var MAX_RETRIES = 5;
|
|
25
|
+
var BASE_BACKOFF_MS = 3000;
|
|
26
|
+
var MAX_BACKOFF_MS = 30 * 60 * 1000;
|
|
27
|
+
var SERVERLESS_ENV_VARS = ["AWS_LAMBDA_FUNCTION_NAME", "VERCEL", "NETLIFY", "K_SERVICE"];
|
|
28
|
+
var isServerless = () => SERVERLESS_ENV_VARS.some((v) => typeof process !== "undefined" && process.env[v]);
|
|
29
|
+
var generatePayloadId = () => {
|
|
30
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
31
|
+
return crypto.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
34
|
+
};
|
|
35
|
+
var jitter = (ms) => ms + Math.random() * ms * 0.5;
|
|
36
|
+
var backoffMs = (attempt) => Math.min(jitter(BASE_BACKOFF_MS * 2 ** attempt), MAX_BACKOFF_MS);
|
|
37
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
var unrefTimer = (timer) => {
|
|
39
|
+
if (typeof timer === "object" && "unref" in timer) {
|
|
40
|
+
timer.unref();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var createTransport = (config) => {
|
|
44
|
+
const queue = [];
|
|
45
|
+
const droppedReports = [];
|
|
46
|
+
let flushTimer = null;
|
|
47
|
+
let state = "active";
|
|
48
|
+
let retryAfterUntil = 0;
|
|
49
|
+
let retryCount = 0;
|
|
50
|
+
let destroyed = false;
|
|
51
|
+
const flushIntervalMs = config.flushIntervalMs ?? (isServerless() ? 0 : DEFAULT_FLUSH_INTERVAL_MS);
|
|
52
|
+
const maxQueueSize = config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
53
|
+
const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
|
|
54
|
+
const trackDrop = (reason, count) => {
|
|
55
|
+
const existing = droppedReports.find((r) => r.reason === reason);
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.droppedCount += count;
|
|
58
|
+
} else {
|
|
59
|
+
droppedReports.push({
|
|
60
|
+
reason,
|
|
61
|
+
droppedCount: count,
|
|
62
|
+
timestamp: new Date().toISOString()
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const handleAuthError = (batchSize) => {
|
|
67
|
+
state = "shutdown";
|
|
68
|
+
trackDrop("auth_failure", batchSize);
|
|
69
|
+
config.onFallback?.();
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
const handleRateLimit = (response, batchSize) => {
|
|
73
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
74
|
+
const waitMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : backoffMs(retryCount);
|
|
75
|
+
retryAfterUntil = Date.now() + waitMs;
|
|
76
|
+
state = "backoff";
|
|
77
|
+
retryCount++;
|
|
78
|
+
trackDrop("ratelimit_backoff", batchSize);
|
|
79
|
+
if (retryCount <= MAX_RETRIES) {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
state = "active";
|
|
82
|
+
}, waitMs);
|
|
83
|
+
unrefTimer(timer);
|
|
84
|
+
} else {
|
|
85
|
+
state = "shutdown";
|
|
86
|
+
config.onFallback?.();
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
const retryWithBackoff = async (fn, batchSize) => {
|
|
91
|
+
if (retryCount >= MAX_RETRIES) {
|
|
92
|
+
trackDrop("network_error", batchSize);
|
|
93
|
+
retryCount = 0;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const waitMs = backoffMs(retryCount);
|
|
97
|
+
retryCount++;
|
|
98
|
+
state = "backoff";
|
|
99
|
+
await sleep(waitMs);
|
|
100
|
+
state = "active";
|
|
101
|
+
return fn();
|
|
102
|
+
};
|
|
103
|
+
const sendBatch = async (batch) => {
|
|
104
|
+
if (state === "shutdown" || destroyed)
|
|
105
|
+
return false;
|
|
106
|
+
if (Date.now() < retryAfterUntil) {
|
|
107
|
+
trackDrop("ratelimit_backoff", batch.length);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const body = JSON.stringify({
|
|
111
|
+
batch,
|
|
112
|
+
...droppedReports.length > 0 ? { clientReports: droppedReports.splice(0) } : {}
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetch(`${config.endpoint}/v1/ingest`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
120
|
+
},
|
|
121
|
+
body
|
|
122
|
+
});
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
retryCount = 0;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (response.status === 401 || response.status === 403)
|
|
128
|
+
return handleAuthError(batch.length);
|
|
129
|
+
if (response.status === 429)
|
|
130
|
+
return handleRateLimit(response, batch.length);
|
|
131
|
+
return retryWithBackoff(() => sendBatch(batch), batch.length);
|
|
132
|
+
} catch {
|
|
133
|
+
return retryWithBackoff(() => sendBatch(batch), batch.length);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const flush = async () => {
|
|
137
|
+
if (queue.length === 0 || state === "shutdown" || destroyed)
|
|
138
|
+
return;
|
|
139
|
+
while (queue.length > 0) {
|
|
140
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
141
|
+
const ok = await sendBatch(batch);
|
|
142
|
+
if (!ok) {
|
|
143
|
+
queue.unshift(...batch);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const scheduleFlush = () => {
|
|
149
|
+
if (flushTimer !== null || flushIntervalMs === 0 || destroyed)
|
|
150
|
+
return;
|
|
151
|
+
flushTimer = setTimeout(() => {
|
|
152
|
+
flushTimer = null;
|
|
153
|
+
flush();
|
|
154
|
+
scheduleFlush();
|
|
155
|
+
}, flushIntervalMs);
|
|
156
|
+
unrefTimer(flushTimer);
|
|
157
|
+
};
|
|
158
|
+
const enqueue = (payload) => {
|
|
159
|
+
if (state === "shutdown" || destroyed)
|
|
160
|
+
return;
|
|
161
|
+
if (config.beforeSend) {
|
|
162
|
+
const transformed = config.beforeSend(payload);
|
|
163
|
+
if (transformed === null)
|
|
164
|
+
return;
|
|
165
|
+
queue.push(transformed);
|
|
166
|
+
} else {
|
|
167
|
+
queue.push(payload);
|
|
168
|
+
}
|
|
169
|
+
if (queue.length > maxQueueSize) {
|
|
170
|
+
const dropped = queue.splice(0, queue.length - maxQueueSize);
|
|
171
|
+
trackDrop("queue_overflow", dropped.length);
|
|
172
|
+
}
|
|
173
|
+
if (queue.length >= maxBatchSize || flushIntervalMs === 0) {
|
|
174
|
+
flush();
|
|
175
|
+
} else {
|
|
176
|
+
scheduleFlush();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const destroy = () => {
|
|
180
|
+
destroyed = true;
|
|
181
|
+
if (flushTimer !== null) {
|
|
182
|
+
clearTimeout(flushTimer);
|
|
183
|
+
flushTimer = null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (flushIntervalMs > 0) {
|
|
187
|
+
const initialDelay = Math.random() * flushIntervalMs;
|
|
188
|
+
const startTimer = setTimeout(() => {
|
|
189
|
+
scheduleFlush();
|
|
190
|
+
}, initialDelay);
|
|
191
|
+
unrefTimer(startTimer);
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
enqueue,
|
|
195
|
+
flush,
|
|
196
|
+
destroy,
|
|
197
|
+
state: () => state,
|
|
198
|
+
pendingCount: () => queue.length
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
var sendBeaconFallback = (endpoint, payload) => {
|
|
202
|
+
if (typeof navigator === "undefined" || !navigator.sendBeacon)
|
|
203
|
+
return false;
|
|
204
|
+
const body = JSON.stringify({ batch: [payload] });
|
|
205
|
+
return navigator.sendBeacon(`${endpoint}/v1/ingest`, new Blob([body], { type: "application/json" }));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// src/browser.ts
|
|
209
|
+
var shouldSample = (rate) => Math.random() < rate;
|
|
210
|
+
var extractFromIstanbul = (coverageMap, config) => {
|
|
211
|
+
const functions = [];
|
|
212
|
+
const denyPaths = config.denyPaths ?? [/node_modules/, /\.test\./];
|
|
213
|
+
for (const [filePath, fileCoverage] of Object.entries(coverageMap)) {
|
|
214
|
+
if (denyPaths.some((re) => re.test(filePath)))
|
|
215
|
+
continue;
|
|
216
|
+
for (const [fnId, fnMeta] of Object.entries(fileCoverage.fnMap)) {
|
|
217
|
+
const hitCount = fileCoverage.f[fnId] ?? 0;
|
|
218
|
+
functions.push({
|
|
219
|
+
filePath,
|
|
220
|
+
functionName: fnMeta.name || `(anonymous@${filePath}:${fnMeta.loc.start.line})`,
|
|
221
|
+
lineNumber: fnMeta.loc.start.line,
|
|
222
|
+
hitCount,
|
|
223
|
+
trackingState: hitCount > 0 ? "called" : "never_called"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return functions;
|
|
228
|
+
};
|
|
229
|
+
var getIstanbulCoverage = () => {
|
|
230
|
+
if (typeof window === "undefined")
|
|
231
|
+
return null;
|
|
232
|
+
const cov = window.__coverage__;
|
|
233
|
+
return cov ?? null;
|
|
234
|
+
};
|
|
235
|
+
var createBrowserBeacon = (config) => {
|
|
236
|
+
let transport = null;
|
|
237
|
+
let started = false;
|
|
238
|
+
let sampled = false;
|
|
239
|
+
const enabled = config.enabled ?? true;
|
|
240
|
+
const sampleRate = config.sampleRate ?? 1;
|
|
241
|
+
const collectAndSend = (useBeaconApi) => {
|
|
242
|
+
if (!started || !sampled)
|
|
243
|
+
return;
|
|
244
|
+
const coverageMap = getIstanbulCoverage();
|
|
245
|
+
if (!coverageMap)
|
|
246
|
+
return;
|
|
247
|
+
const functions = extractFromIstanbul(coverageMap, config);
|
|
248
|
+
if (functions.length === 0)
|
|
249
|
+
return;
|
|
250
|
+
const payload = {
|
|
251
|
+
payloadId: generatePayloadId(),
|
|
252
|
+
projectId: config.projectId,
|
|
253
|
+
environment: config.environment,
|
|
254
|
+
commitSha: config.commitSha,
|
|
255
|
+
timestamp: new Date().toISOString(),
|
|
256
|
+
functions
|
|
257
|
+
};
|
|
258
|
+
if (useBeaconApi) {
|
|
259
|
+
sendBeaconFallback(config.endpoint, payload);
|
|
260
|
+
} else {
|
|
261
|
+
transport?.enqueue(payload);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const onVisibilityChange = () => {
|
|
265
|
+
if (document.visibilityState === "hidden") {
|
|
266
|
+
collectAndSend(true);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const onBeforeUnload = () => {
|
|
270
|
+
collectAndSend(true);
|
|
271
|
+
};
|
|
272
|
+
const start = () => {
|
|
273
|
+
if (!enabled || started)
|
|
274
|
+
return;
|
|
275
|
+
if (typeof window === "undefined")
|
|
276
|
+
return;
|
|
277
|
+
sampled = shouldSample(sampleRate);
|
|
278
|
+
if (!sampled)
|
|
279
|
+
return;
|
|
280
|
+
started = true;
|
|
281
|
+
transport = createTransport(config);
|
|
282
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
283
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
284
|
+
};
|
|
285
|
+
const stop = () => {
|
|
286
|
+
if (!started)
|
|
287
|
+
return;
|
|
288
|
+
started = false;
|
|
289
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
290
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
291
|
+
transport?.destroy();
|
|
292
|
+
transport = null;
|
|
293
|
+
};
|
|
294
|
+
const flush = async () => {
|
|
295
|
+
collectAndSend(false);
|
|
296
|
+
await transport?.flush();
|
|
297
|
+
};
|
|
298
|
+
return { start, stop, flush };
|
|
299
|
+
};
|
|
300
|
+
export {
|
|
301
|
+
createBrowserBeacon
|
|
302
|
+
};
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
+
for (let key of __getOwnPropNames(mod))
|
|
11
|
+
if (!__hasOwnProp.call(to, key))
|
|
12
|
+
__defProp(to, key, {
|
|
13
|
+
get: () => mod[key],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// src/transport.ts
|
|
21
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 30000;
|
|
22
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
23
|
+
var DEFAULT_MAX_BATCH_SIZE = 100;
|
|
24
|
+
var MAX_RETRIES = 5;
|
|
25
|
+
var BASE_BACKOFF_MS = 3000;
|
|
26
|
+
var MAX_BACKOFF_MS = 30 * 60 * 1000;
|
|
27
|
+
var SERVERLESS_ENV_VARS = ["AWS_LAMBDA_FUNCTION_NAME", "VERCEL", "NETLIFY", "K_SERVICE"];
|
|
28
|
+
var isServerless = () => SERVERLESS_ENV_VARS.some((v) => typeof process !== "undefined" && process.env[v]);
|
|
29
|
+
var generatePayloadId = () => {
|
|
30
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
31
|
+
return crypto.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
34
|
+
};
|
|
35
|
+
var jitter = (ms) => ms + Math.random() * ms * 0.5;
|
|
36
|
+
var backoffMs = (attempt) => Math.min(jitter(BASE_BACKOFF_MS * 2 ** attempt), MAX_BACKOFF_MS);
|
|
37
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
var unrefTimer = (timer) => {
|
|
39
|
+
if (typeof timer === "object" && "unref" in timer) {
|
|
40
|
+
timer.unref();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var createTransport = (config) => {
|
|
44
|
+
const queue = [];
|
|
45
|
+
const droppedReports = [];
|
|
46
|
+
let flushTimer = null;
|
|
47
|
+
let state = "active";
|
|
48
|
+
let retryAfterUntil = 0;
|
|
49
|
+
let retryCount = 0;
|
|
50
|
+
let destroyed = false;
|
|
51
|
+
const flushIntervalMs = config.flushIntervalMs ?? (isServerless() ? 0 : DEFAULT_FLUSH_INTERVAL_MS);
|
|
52
|
+
const maxQueueSize = config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
53
|
+
const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
|
|
54
|
+
const trackDrop = (reason, count) => {
|
|
55
|
+
const existing = droppedReports.find((r) => r.reason === reason);
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.droppedCount += count;
|
|
58
|
+
} else {
|
|
59
|
+
droppedReports.push({
|
|
60
|
+
reason,
|
|
61
|
+
droppedCount: count,
|
|
62
|
+
timestamp: new Date().toISOString()
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const handleAuthError = (batchSize) => {
|
|
67
|
+
state = "shutdown";
|
|
68
|
+
trackDrop("auth_failure", batchSize);
|
|
69
|
+
config.onFallback?.();
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
const handleRateLimit = (response, batchSize) => {
|
|
73
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
74
|
+
const waitMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : backoffMs(retryCount);
|
|
75
|
+
retryAfterUntil = Date.now() + waitMs;
|
|
76
|
+
state = "backoff";
|
|
77
|
+
retryCount++;
|
|
78
|
+
trackDrop("ratelimit_backoff", batchSize);
|
|
79
|
+
if (retryCount <= MAX_RETRIES) {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
state = "active";
|
|
82
|
+
}, waitMs);
|
|
83
|
+
unrefTimer(timer);
|
|
84
|
+
} else {
|
|
85
|
+
state = "shutdown";
|
|
86
|
+
config.onFallback?.();
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
const retryWithBackoff = async (fn, batchSize) => {
|
|
91
|
+
if (retryCount >= MAX_RETRIES) {
|
|
92
|
+
trackDrop("network_error", batchSize);
|
|
93
|
+
retryCount = 0;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const waitMs = backoffMs(retryCount);
|
|
97
|
+
retryCount++;
|
|
98
|
+
state = "backoff";
|
|
99
|
+
await sleep(waitMs);
|
|
100
|
+
state = "active";
|
|
101
|
+
return fn();
|
|
102
|
+
};
|
|
103
|
+
const sendBatch = async (batch) => {
|
|
104
|
+
if (state === "shutdown" || destroyed)
|
|
105
|
+
return false;
|
|
106
|
+
if (Date.now() < retryAfterUntil) {
|
|
107
|
+
trackDrop("ratelimit_backoff", batch.length);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const body = JSON.stringify({
|
|
111
|
+
batch,
|
|
112
|
+
...droppedReports.length > 0 ? { clientReports: droppedReports.splice(0) } : {}
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetch(`${config.endpoint}/v1/ingest`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
120
|
+
},
|
|
121
|
+
body
|
|
122
|
+
});
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
retryCount = 0;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (response.status === 401 || response.status === 403)
|
|
128
|
+
return handleAuthError(batch.length);
|
|
129
|
+
if (response.status === 429)
|
|
130
|
+
return handleRateLimit(response, batch.length);
|
|
131
|
+
return retryWithBackoff(() => sendBatch(batch), batch.length);
|
|
132
|
+
} catch {
|
|
133
|
+
return retryWithBackoff(() => sendBatch(batch), batch.length);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const flush = async () => {
|
|
137
|
+
if (queue.length === 0 || state === "shutdown" || destroyed)
|
|
138
|
+
return;
|
|
139
|
+
while (queue.length > 0) {
|
|
140
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
141
|
+
const ok = await sendBatch(batch);
|
|
142
|
+
if (!ok) {
|
|
143
|
+
queue.unshift(...batch);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const scheduleFlush = () => {
|
|
149
|
+
if (flushTimer !== null || flushIntervalMs === 0 || destroyed)
|
|
150
|
+
return;
|
|
151
|
+
flushTimer = setTimeout(() => {
|
|
152
|
+
flushTimer = null;
|
|
153
|
+
flush();
|
|
154
|
+
scheduleFlush();
|
|
155
|
+
}, flushIntervalMs);
|
|
156
|
+
unrefTimer(flushTimer);
|
|
157
|
+
};
|
|
158
|
+
const enqueue = (payload) => {
|
|
159
|
+
if (state === "shutdown" || destroyed)
|
|
160
|
+
return;
|
|
161
|
+
if (config.beforeSend) {
|
|
162
|
+
const transformed = config.beforeSend(payload);
|
|
163
|
+
if (transformed === null)
|
|
164
|
+
return;
|
|
165
|
+
queue.push(transformed);
|
|
166
|
+
} else {
|
|
167
|
+
queue.push(payload);
|
|
168
|
+
}
|
|
169
|
+
if (queue.length > maxQueueSize) {
|
|
170
|
+
const dropped = queue.splice(0, queue.length - maxQueueSize);
|
|
171
|
+
trackDrop("queue_overflow", dropped.length);
|
|
172
|
+
}
|
|
173
|
+
if (queue.length >= maxBatchSize || flushIntervalMs === 0) {
|
|
174
|
+
flush();
|
|
175
|
+
} else {
|
|
176
|
+
scheduleFlush();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const destroy = () => {
|
|
180
|
+
destroyed = true;
|
|
181
|
+
if (flushTimer !== null) {
|
|
182
|
+
clearTimeout(flushTimer);
|
|
183
|
+
flushTimer = null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (flushIntervalMs > 0) {
|
|
187
|
+
const initialDelay = Math.random() * flushIntervalMs;
|
|
188
|
+
const startTimer = setTimeout(() => {
|
|
189
|
+
scheduleFlush();
|
|
190
|
+
}, initialDelay);
|
|
191
|
+
unrefTimer(startTimer);
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
enqueue,
|
|
195
|
+
flush,
|
|
196
|
+
destroy,
|
|
197
|
+
state: () => state,
|
|
198
|
+
pendingCount: () => queue.length
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
var sendBeaconFallback = (endpoint, payload) => {
|
|
202
|
+
if (typeof navigator === "undefined" || !navigator.sendBeacon)
|
|
203
|
+
return false;
|
|
204
|
+
const body = JSON.stringify({ batch: [payload] });
|
|
205
|
+
return navigator.sendBeacon(`${endpoint}/v1/ingest`, new Blob([body], { type: "application/json" }));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// src/node.ts
|
|
209
|
+
var isTopLevelScript = (fn) => fn.functionName === "" && fn.ranges.length === 1 && fn.ranges[0]?.startOffset === 0;
|
|
210
|
+
var normalizeUrl = (url) => url.startsWith("file://") ? url.slice(7) : url;
|
|
211
|
+
var toFunctionCoverage = (entry, fn, filePath) => {
|
|
212
|
+
const hitCount = fn.ranges[0]?.count ?? 0;
|
|
213
|
+
return {
|
|
214
|
+
filePath,
|
|
215
|
+
functionName: fn.functionName || `(anonymous@${entry.url})`,
|
|
216
|
+
lineNumber: null,
|
|
217
|
+
hitCount,
|
|
218
|
+
trackingState: hitCount > 0 ? "called" : "never_called"
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
var shouldSkipEntry = (entry, denyPaths) => !entry.url || entry.url.startsWith("node:") || denyPaths.some((re) => re.test(entry.url));
|
|
222
|
+
var extractFunctions = (entries, config) => {
|
|
223
|
+
const denyPaths = config.denyPaths ?? [/node_modules/, /\.test\./];
|
|
224
|
+
return entries.filter((entry) => !shouldSkipEntry(entry, denyPaths)).flatMap((entry) => {
|
|
225
|
+
const filePath = normalizeUrl(entry.url);
|
|
226
|
+
return entry.functions.filter((fn) => !isTopLevelScript(fn)).map((fn) => toFunctionCoverage(entry, fn, filePath));
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
var diffSnapshots = (previous, current) => {
|
|
230
|
+
const snapshot = new Map;
|
|
231
|
+
const incremental = [];
|
|
232
|
+
for (const fn of current) {
|
|
233
|
+
const key = `${fn.filePath}::${fn.functionName}`;
|
|
234
|
+
const prevCount = previous.get(key) ?? 0;
|
|
235
|
+
snapshot.set(key, fn.hitCount);
|
|
236
|
+
const diff = fn.hitCount - prevCount;
|
|
237
|
+
if (diff > 0) {
|
|
238
|
+
incremental.push({
|
|
239
|
+
...fn,
|
|
240
|
+
hitCount: diff,
|
|
241
|
+
trackingState: "called"
|
|
242
|
+
});
|
|
243
|
+
} else if (prevCount === 0 && fn.hitCount === 0) {
|
|
244
|
+
incremental.push(fn);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { incremental, snapshot };
|
|
248
|
+
};
|
|
249
|
+
var createNodeBeacon = (config) => {
|
|
250
|
+
let transport = null;
|
|
251
|
+
let snapshotTimer = null;
|
|
252
|
+
let previousSnapshot = new Map;
|
|
253
|
+
let isBaseline = true;
|
|
254
|
+
let started = false;
|
|
255
|
+
let coverageDir = null;
|
|
256
|
+
let beforeExitHandler = null;
|
|
257
|
+
const schedule = config.snapshotSchedule ?? "idle";
|
|
258
|
+
const enabled = config.enabled ?? true;
|
|
259
|
+
const takeSnapshot = async () => {
|
|
260
|
+
if (!transport || !started)
|
|
261
|
+
return;
|
|
262
|
+
try {
|
|
263
|
+
const v8 = await import("node:v8");
|
|
264
|
+
const fs = await import("node:fs");
|
|
265
|
+
const path = await import("node:path");
|
|
266
|
+
v8.takeCoverage();
|
|
267
|
+
if (!coverageDir)
|
|
268
|
+
return;
|
|
269
|
+
const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));
|
|
270
|
+
const allEntries = [];
|
|
271
|
+
for (const file of files) {
|
|
272
|
+
try {
|
|
273
|
+
const content = fs.readFileSync(path.join(coverageDir, file), "utf-8");
|
|
274
|
+
const parsed = JSON.parse(content);
|
|
275
|
+
allEntries.push(...parsed.result);
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
const functions = extractFunctions(allEntries, config);
|
|
279
|
+
const { incremental, snapshot } = diffSnapshots(previousSnapshot, functions);
|
|
280
|
+
previousSnapshot = snapshot;
|
|
281
|
+
if (isBaseline) {
|
|
282
|
+
isBaseline = false;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (incremental.length === 0)
|
|
286
|
+
return;
|
|
287
|
+
const payload = {
|
|
288
|
+
payloadId: generatePayloadId(),
|
|
289
|
+
projectId: config.projectId,
|
|
290
|
+
environment: config.environment,
|
|
291
|
+
commitSha: config.commitSha,
|
|
292
|
+
timestamp: new Date().toISOString(),
|
|
293
|
+
functions: incremental
|
|
294
|
+
};
|
|
295
|
+
transport.enqueue(payload);
|
|
296
|
+
} catch {}
|
|
297
|
+
};
|
|
298
|
+
const scheduleSnapshot = () => {
|
|
299
|
+
if (!started)
|
|
300
|
+
return;
|
|
301
|
+
switch (schedule) {
|
|
302
|
+
case "idle": {
|
|
303
|
+
const scheduleNext = () => {
|
|
304
|
+
if (!started)
|
|
305
|
+
return;
|
|
306
|
+
snapshotTimer = setTimeout(() => {
|
|
307
|
+
takeSnapshot().then(() => {
|
|
308
|
+
if (started)
|
|
309
|
+
scheduleNext();
|
|
310
|
+
});
|
|
311
|
+
}, 60000);
|
|
312
|
+
if (typeof snapshotTimer === "object" && "unref" in snapshotTimer) {
|
|
313
|
+
snapshotTimer.unref();
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
scheduleNext();
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case "interval": {
|
|
320
|
+
const intervalMs = config.flushIntervalMs ?? 30000;
|
|
321
|
+
const loop = () => {
|
|
322
|
+
if (!started)
|
|
323
|
+
return;
|
|
324
|
+
snapshotTimer = setTimeout(() => {
|
|
325
|
+
takeSnapshot().then(loop);
|
|
326
|
+
}, intervalMs);
|
|
327
|
+
if (typeof snapshotTimer === "object" && "unref" in snapshotTimer) {
|
|
328
|
+
snapshotTimer.unref();
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
loop();
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case "manual":
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const start = () => {
|
|
339
|
+
if (!enabled || started)
|
|
340
|
+
return;
|
|
341
|
+
started = true;
|
|
342
|
+
coverageDir = process.env.NODE_V8_COVERAGE ?? null;
|
|
343
|
+
if (!coverageDir) {
|
|
344
|
+
(async () => {
|
|
345
|
+
const os = await import("node:os");
|
|
346
|
+
const path = await import("node:path");
|
|
347
|
+
const fs = await import("node:fs");
|
|
348
|
+
coverageDir = path.join(os.tmpdir(), `fallow-v8-cov-${process.pid}`);
|
|
349
|
+
fs.mkdirSync(coverageDir, { recursive: true });
|
|
350
|
+
process.env.NODE_V8_COVERAGE = coverageDir;
|
|
351
|
+
})();
|
|
352
|
+
}
|
|
353
|
+
transport = createTransport(config);
|
|
354
|
+
takeSnapshot();
|
|
355
|
+
scheduleSnapshot();
|
|
356
|
+
beforeExitHandler = () => {
|
|
357
|
+
takeSnapshot().then(() => transport?.flush());
|
|
358
|
+
};
|
|
359
|
+
process.on("beforeExit", beforeExitHandler);
|
|
360
|
+
};
|
|
361
|
+
const stop = async () => {
|
|
362
|
+
started = false;
|
|
363
|
+
if (snapshotTimer !== null) {
|
|
364
|
+
clearTimeout(snapshotTimer);
|
|
365
|
+
snapshotTimer = null;
|
|
366
|
+
}
|
|
367
|
+
if (beforeExitHandler) {
|
|
368
|
+
process.off("beforeExit", beforeExitHandler);
|
|
369
|
+
beforeExitHandler = null;
|
|
370
|
+
}
|
|
371
|
+
await takeSnapshot();
|
|
372
|
+
await transport?.flush();
|
|
373
|
+
transport?.destroy();
|
|
374
|
+
transport = null;
|
|
375
|
+
};
|
|
376
|
+
const flush = async () => {
|
|
377
|
+
await takeSnapshot();
|
|
378
|
+
await transport?.flush();
|
|
379
|
+
};
|
|
380
|
+
return { start, stop, flush };
|
|
381
|
+
};
|
|
382
|
+
export {
|
|
383
|
+
createNodeBeacon
|
|
384
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fallow-cli/beacon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight production coverage beacon for fallow cloud",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"coverage",
|
|
7
|
+
"dead-code",
|
|
8
|
+
"fallow",
|
|
9
|
+
"istanbul",
|
|
10
|
+
"v8"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/node.js",
|
|
18
|
+
"browser": "./dist/browser.js",
|
|
19
|
+
"types": "./dist/node.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"bun": "./src/node.ts",
|
|
23
|
+
"import": "./dist/node.js",
|
|
24
|
+
"types": "./dist/node.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./browser": {
|
|
27
|
+
"import": "./dist/browser.js",
|
|
28
|
+
"types": "./dist/browser.d.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bun build src/node.ts src/browser.ts --outdir dist --target node",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "5.7.3",
|
|
38
|
+
"vitest": "3.1.1"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {}
|
|
41
|
+
}
|