@lessonkit/xapi 1.3.1 → 1.4.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/index.cjs +177 -81
- package/dist/index.d.cts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +174 -81
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -20,10 +20,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
FetchHttpError: () => FetchHttpError,
|
|
23
24
|
createFetchBatchSink: () => createFetchBatchSink,
|
|
24
25
|
createFetchTransport: () => createFetchTransport,
|
|
25
26
|
createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
|
|
26
27
|
createXAPIClient: () => createXAPIClient,
|
|
28
|
+
isRetryableFetchError: () => isRetryableFetchError,
|
|
29
|
+
isRetryableFetchHttpStatus: () => isRetryableFetchHttpStatus,
|
|
27
30
|
telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
|
|
28
31
|
});
|
|
29
32
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -46,11 +49,15 @@ function withStatementId(statement) {
|
|
|
46
49
|
return statement;
|
|
47
50
|
}
|
|
48
51
|
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
52
|
+
var DEFAULT_MAX_HEAD_FAILURES = 10;
|
|
49
53
|
function createInMemoryXAPIQueue(opts) {
|
|
50
54
|
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
55
|
+
const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
|
|
51
56
|
const buffer = [];
|
|
52
57
|
let flushInFlight = null;
|
|
53
58
|
let headInFlight = false;
|
|
59
|
+
let headInFlightId;
|
|
60
|
+
let headFailureCount = 0;
|
|
54
61
|
const notifyDepth = () => {
|
|
55
62
|
opts?.onDepth?.(buffer.length);
|
|
56
63
|
};
|
|
@@ -65,14 +72,25 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
65
72
|
while (buffer.length) {
|
|
66
73
|
const statement = buffer[0];
|
|
67
74
|
headInFlight = true;
|
|
75
|
+
headInFlightId = statement.id;
|
|
68
76
|
try {
|
|
69
77
|
await transport(statement);
|
|
70
78
|
buffer.shift();
|
|
79
|
+
headFailureCount = 0;
|
|
71
80
|
notifyDepth();
|
|
72
|
-
} catch {
|
|
73
|
-
|
|
81
|
+
} catch (err) {
|
|
82
|
+
headFailureCount += 1;
|
|
83
|
+
if (headFailureCount >= maxHeadFailures) {
|
|
84
|
+
buffer.shift();
|
|
85
|
+
headFailureCount = 0;
|
|
86
|
+
notifyDepth();
|
|
87
|
+
opts?.onHeadSkipped?.(statement, err);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
74
91
|
} finally {
|
|
75
92
|
headInFlight = false;
|
|
93
|
+
headInFlightId = void 0;
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
};
|
|
@@ -81,12 +99,10 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
81
99
|
const normalized = withStatementId(statement);
|
|
82
100
|
if (buffer.some((s) => s.id === normalized.id)) return;
|
|
83
101
|
if (buffer.length >= maxSize) {
|
|
84
|
-
if (headInFlight && buffer.length <= 1) {
|
|
85
|
-
opts?.onCap?.();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
102
|
if (headInFlight) {
|
|
89
|
-
buffer.
|
|
103
|
+
if (buffer.length > 1) {
|
|
104
|
+
buffer.splice(1, 1);
|
|
105
|
+
}
|
|
90
106
|
} else {
|
|
91
107
|
buffer.shift();
|
|
92
108
|
}
|
|
@@ -106,21 +122,16 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
106
122
|
return flushInFlight;
|
|
107
123
|
},
|
|
108
124
|
flushOnExit: (exitTransport) => {
|
|
109
|
-
const
|
|
110
|
-
for (let i = startIdx; i < buffer.length; i++) {
|
|
111
|
-
const statement = buffer[i];
|
|
125
|
+
for (const statement of buffer) {
|
|
112
126
|
try {
|
|
113
127
|
exitTransport(statement);
|
|
114
128
|
} catch {
|
|
115
129
|
}
|
|
116
130
|
}
|
|
117
|
-
|
|
118
|
-
buffer.length = 0;
|
|
119
|
-
} else if (buffer.length > 1) {
|
|
120
|
-
buffer.splice(1);
|
|
121
|
-
}
|
|
131
|
+
buffer.length = 0;
|
|
122
132
|
notifyDepth();
|
|
123
|
-
}
|
|
133
|
+
},
|
|
134
|
+
getHeadInFlightId: () => headInFlightId
|
|
124
135
|
};
|
|
125
136
|
}
|
|
126
137
|
|
|
@@ -270,7 +281,33 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
270
281
|
hotspot_opened: experiencedBlockMapper,
|
|
271
282
|
accordion_section_toggled: experiencedBlockMapper,
|
|
272
283
|
flashcard_flipped: experiencedBlockMapper,
|
|
273
|
-
image_slider_changed: experiencedBlockMapper
|
|
284
|
+
image_slider_changed: experiencedBlockMapper,
|
|
285
|
+
video_cue_reached: experiencedBlockMapper,
|
|
286
|
+
video_segment_completed: (event, ctx) => {
|
|
287
|
+
if (event.name !== "video_segment_completed") return null;
|
|
288
|
+
const lessonId = event.lessonId;
|
|
289
|
+
const blockId = event.data.blockId;
|
|
290
|
+
if (!lessonId || !blockId) return null;
|
|
291
|
+
return statementFor(
|
|
292
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
293
|
+
XAPIVerbs.completed,
|
|
294
|
+
ctx.timestamp
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
memory_card_flipped: experiencedBlockMapper,
|
|
298
|
+
information_wall_search: experiencedBlockMapper,
|
|
299
|
+
parallax_slide_viewed: experiencedBlockMapper,
|
|
300
|
+
questionnaire_submitted: (event, ctx) => {
|
|
301
|
+
if (event.name !== "questionnaire_submitted") return null;
|
|
302
|
+
const lessonId = event.lessonId;
|
|
303
|
+
const blockId = event.data.blockId;
|
|
304
|
+
if (!lessonId || !blockId) return null;
|
|
305
|
+
return statementFor(
|
|
306
|
+
(0, import_core.buildLessonkitUrn)({ courseId: ctx.courseId, lessonId, blockId }),
|
|
307
|
+
XAPIVerbs.completed,
|
|
308
|
+
ctx.timestamp
|
|
309
|
+
);
|
|
310
|
+
}
|
|
274
311
|
};
|
|
275
312
|
function telemetryEventToXAPIStatement(event) {
|
|
276
313
|
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
@@ -310,9 +347,16 @@ function createXAPIClient(opts) {
|
|
|
310
347
|
let warnedTransportFailure = false;
|
|
311
348
|
const inflightById = /* @__PURE__ */ new Map();
|
|
312
349
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
350
|
+
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
351
|
+
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
352
|
+
const deliveryTransport = transport ? async (statement) => {
|
|
353
|
+
if (exitNetworkSentIds.has(statement.id)) return;
|
|
354
|
+
await transport(statement);
|
|
355
|
+
} : void 0;
|
|
313
356
|
const sendOrQueue = (statement) => {
|
|
314
357
|
const normalized = withStatementId2(statement);
|
|
315
|
-
if (
|
|
358
|
+
if (exitDeliveredIds.has(normalized.id)) return;
|
|
359
|
+
if (!deliveryTransport) {
|
|
316
360
|
queue.enqueue(normalized);
|
|
317
361
|
if (isDevEnvironment() && !warnedNoTransport) {
|
|
318
362
|
warnedNoTransport = true;
|
|
@@ -334,17 +378,19 @@ function createXAPIClient(opts) {
|
|
|
334
378
|
}
|
|
335
379
|
inflightStatements.set(normalized.id, normalized);
|
|
336
380
|
const flight = Promise.resolve().then(async () => {
|
|
337
|
-
await
|
|
381
|
+
await deliveryTransport(normalized);
|
|
338
382
|
queue.removeById(normalized.id);
|
|
339
|
-
}).catch(() => {
|
|
383
|
+
}).catch((err) => {
|
|
384
|
+
if (exitDeliveredIds.has(normalized.id)) return;
|
|
340
385
|
queue.enqueue(normalized);
|
|
386
|
+
opts?.onTransportError?.(err);
|
|
341
387
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
342
388
|
warnedTransportFailure = true;
|
|
343
389
|
console.warn(
|
|
344
390
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
345
391
|
);
|
|
346
392
|
}
|
|
347
|
-
throw new Error("xAPI transport failed");
|
|
393
|
+
throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
|
|
348
394
|
}).finally(() => {
|
|
349
395
|
inflightById.delete(normalized.id);
|
|
350
396
|
inflightStatements.delete(normalized.id);
|
|
@@ -373,27 +419,36 @@ function createXAPIClient(opts) {
|
|
|
373
419
|
},
|
|
374
420
|
queueSize: () => queue.size(),
|
|
375
421
|
flush: async () => {
|
|
376
|
-
if (!
|
|
377
|
-
await queue.flush(
|
|
422
|
+
if (!deliveryTransport) return;
|
|
423
|
+
await queue.flush(deliveryTransport);
|
|
378
424
|
const flights = [...inflightById.values()];
|
|
379
425
|
if (flights.length > 0) {
|
|
380
|
-
await Promise.
|
|
426
|
+
await Promise.all(flights);
|
|
427
|
+
}
|
|
428
|
+
if (queue.size() > 0) {
|
|
429
|
+
throw new Error("xAPI flush incomplete: statements remain queued after flush");
|
|
381
430
|
}
|
|
382
431
|
},
|
|
383
432
|
flushOnExit: exitTransport ? () => {
|
|
384
|
-
const
|
|
433
|
+
const headId = queue.getHeadInFlightId?.();
|
|
434
|
+
if (headId) {
|
|
435
|
+
exitNetworkSentIds.add(headId);
|
|
436
|
+
exitDeliveredIds.add(headId);
|
|
437
|
+
opts.abortInFlight?.(headId);
|
|
438
|
+
}
|
|
385
439
|
for (const statement of inflightStatements.values()) {
|
|
386
|
-
|
|
440
|
+
exitNetworkSentIds.add(statement.id);
|
|
441
|
+
exitDeliveredIds.add(statement.id);
|
|
442
|
+
opts.abortInFlight?.(statement.id);
|
|
443
|
+
}
|
|
444
|
+
queue.flushOnExit((statement) => {
|
|
445
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
446
|
+
exitNetworkSentIds.add(statement.id);
|
|
447
|
+
exitDeliveredIds.add(statement.id);
|
|
387
448
|
try {
|
|
388
449
|
exitTransport(statement);
|
|
389
|
-
exitSentIds.add(statement.id);
|
|
390
450
|
} catch {
|
|
391
451
|
}
|
|
392
|
-
}
|
|
393
|
-
queue.flushOnExit((statement) => {
|
|
394
|
-
if (exitSentIds.has(statement.id)) return;
|
|
395
|
-
exitTransport(statement);
|
|
396
|
-
exitSentIds.add(statement.id);
|
|
397
452
|
});
|
|
398
453
|
} : void 0,
|
|
399
454
|
startedLesson: ({ lessonId }) => {
|
|
@@ -434,58 +489,100 @@ function createXAPIClient(opts) {
|
|
|
434
489
|
}
|
|
435
490
|
|
|
436
491
|
// src/fetchTransport.ts
|
|
492
|
+
var FetchHttpError = class extends Error {
|
|
493
|
+
status;
|
|
494
|
+
constructor(status, statusText, kind = "xapi") {
|
|
495
|
+
super(
|
|
496
|
+
kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
|
|
497
|
+
);
|
|
498
|
+
this.name = "FetchHttpError";
|
|
499
|
+
this.status = status;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
function isRetryableFetchHttpStatus(status) {
|
|
503
|
+
return status === 429 || status >= 500;
|
|
504
|
+
}
|
|
505
|
+
function isRetryableFetchError(err) {
|
|
506
|
+
if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
437
509
|
function resolveHeaders(headers) {
|
|
438
510
|
if (!headers) return { "Content-Type": "application/json" };
|
|
439
511
|
const resolved = typeof headers === "function" ? headers() : headers;
|
|
440
512
|
return { "Content-Type": "application/json", ...resolved };
|
|
441
513
|
}
|
|
442
514
|
function createAbortSignal(timeoutMs) {
|
|
443
|
-
if (timeoutMs <= 0) return void 0
|
|
444
|
-
|
|
445
|
-
if (typeof timeout.timeout === "function") {
|
|
446
|
-
return timeout.timeout(timeoutMs);
|
|
447
|
-
}
|
|
515
|
+
if (timeoutMs <= 0) return { signal: void 0, abort: () => {
|
|
516
|
+
} };
|
|
448
517
|
const controller = new AbortController();
|
|
449
518
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
450
519
|
timer.unref?.();
|
|
451
|
-
return
|
|
520
|
+
return {
|
|
521
|
+
signal: controller.signal,
|
|
522
|
+
abort: () => {
|
|
523
|
+
clearTimeout(timer);
|
|
524
|
+
controller.abort();
|
|
525
|
+
}
|
|
526
|
+
};
|
|
452
527
|
}
|
|
453
528
|
function sleep(ms) {
|
|
454
529
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
455
530
|
}
|
|
456
531
|
function postStatement(url, statement, init) {
|
|
457
532
|
return fetch(url, {
|
|
533
|
+
...init,
|
|
458
534
|
method: "POST",
|
|
459
|
-
body: JSON.stringify(statement)
|
|
460
|
-
...init
|
|
535
|
+
body: JSON.stringify(statement)
|
|
461
536
|
}).then((res) => {
|
|
462
537
|
if (!res.ok) {
|
|
463
|
-
throw new
|
|
538
|
+
throw new FetchHttpError(res.status, res.statusText, "xapi");
|
|
464
539
|
}
|
|
465
540
|
});
|
|
466
541
|
}
|
|
542
|
+
async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
543
|
+
let attempt = 0;
|
|
544
|
+
let backoff = initialBackoffMs;
|
|
545
|
+
for (; ; ) {
|
|
546
|
+
try {
|
|
547
|
+
await post();
|
|
548
|
+
return;
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (!isRetryableFetchError(err) || attempt >= retries) throw err;
|
|
551
|
+
await sleep(backoff);
|
|
552
|
+
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
553
|
+
attempt += 1;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
467
557
|
function createFetchTransport(opts) {
|
|
468
558
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
469
|
-
const
|
|
559
|
+
const rawRetries = opts.retries ?? 2;
|
|
560
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
470
561
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
471
562
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
563
|
+
const activeControllers = /* @__PURE__ */ new Map();
|
|
472
564
|
const transport = async (statement) => {
|
|
473
|
-
let
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
signal
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
565
|
+
let abortCleanup;
|
|
566
|
+
activeControllers.set(statement.id, {
|
|
567
|
+
abort: () => abortCleanup?.()
|
|
568
|
+
});
|
|
569
|
+
try {
|
|
570
|
+
await postWithRetry(
|
|
571
|
+
() => {
|
|
572
|
+
const { signal, abort } = createAbortSignal(timeoutMs);
|
|
573
|
+
abortCleanup = abort;
|
|
574
|
+
return postStatement(opts.url, statement, {
|
|
575
|
+
...opts.init,
|
|
576
|
+
headers: resolveHeaders(opts.headers),
|
|
577
|
+
signal
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
retries,
|
|
581
|
+
initialBackoffMs,
|
|
582
|
+
maxBackoffMs
|
|
583
|
+
);
|
|
584
|
+
} finally {
|
|
585
|
+
activeControllers.delete(statement.id);
|
|
489
586
|
}
|
|
490
587
|
};
|
|
491
588
|
const exitTransport = (statement) => {
|
|
@@ -498,36 +595,32 @@ function createFetchTransport(opts) {
|
|
|
498
595
|
} catch {
|
|
499
596
|
}
|
|
500
597
|
};
|
|
501
|
-
|
|
598
|
+
const abortInFlight = (statementId) => {
|
|
599
|
+
activeControllers.get(statementId)?.abort();
|
|
600
|
+
activeControllers.delete(statementId);
|
|
601
|
+
};
|
|
602
|
+
return { transport, exitTransport, abortInFlight };
|
|
502
603
|
}
|
|
503
604
|
function createFetchBatchSink(opts) {
|
|
504
605
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
505
|
-
const
|
|
606
|
+
const rawRetries = opts.retries ?? 2;
|
|
607
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
506
608
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
507
609
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
508
610
|
const postBatch = async (events, init) => {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!res.ok) {
|
|
521
|
-
throw new Error(`telemetry batch fetch failed: ${res.status} ${res.statusText}`);
|
|
522
|
-
}
|
|
523
|
-
return;
|
|
524
|
-
} catch (err) {
|
|
525
|
-
if (attempt >= retries) throw err;
|
|
526
|
-
await sleep(backoff);
|
|
527
|
-
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
528
|
-
attempt += 1;
|
|
611
|
+
await postWithRetry(async () => {
|
|
612
|
+
const { signal } = createAbortSignal(timeoutMs);
|
|
613
|
+
const res = await fetch(opts.url, {
|
|
614
|
+
...init,
|
|
615
|
+
method: "POST",
|
|
616
|
+
body: JSON.stringify(events),
|
|
617
|
+
headers: resolveHeaders(opts.headers),
|
|
618
|
+
signal
|
|
619
|
+
});
|
|
620
|
+
if (!res.ok) {
|
|
621
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
529
622
|
}
|
|
530
|
-
}
|
|
623
|
+
}, retries, initialBackoffMs, maxBackoffMs);
|
|
531
624
|
};
|
|
532
625
|
return {
|
|
533
626
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
@@ -547,9 +640,12 @@ function createFetchBatchSink(opts) {
|
|
|
547
640
|
}
|
|
548
641
|
// Annotate the CommonJS export names for ESM import in node:
|
|
549
642
|
0 && (module.exports = {
|
|
643
|
+
FetchHttpError,
|
|
550
644
|
createFetchBatchSink,
|
|
551
645
|
createFetchTransport,
|
|
552
646
|
createInMemoryXAPIQueue,
|
|
553
647
|
createXAPIClient,
|
|
648
|
+
isRetryableFetchError,
|
|
649
|
+
isRetryableFetchHttpStatus,
|
|
554
650
|
telemetryEventToXAPIStatement
|
|
555
651
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -37,6 +37,8 @@ type XAPIQueue = {
|
|
|
37
37
|
flush: (transport: XAPITransport) => Promise<void>;
|
|
38
38
|
flushOnExit: (exitTransport: XAPIExitTransport) => void;
|
|
39
39
|
size: () => number;
|
|
40
|
+
/** Statement id currently being delivered via flush, if any. */
|
|
41
|
+
getHeadInFlightId?: () => string | undefined;
|
|
40
42
|
};
|
|
41
43
|
type XAPIExitTransport = (statement: XAPIStatement) => void;
|
|
42
44
|
type XAPIClient = {
|
|
@@ -65,6 +67,10 @@ type InMemoryXAPIQueueOptions = {
|
|
|
65
67
|
onDepth?: (size: number) => void;
|
|
66
68
|
/** Called when an oldest statement is dropped because the queue is at maxSize. */
|
|
67
69
|
onCap?: () => void;
|
|
70
|
+
/** Failures at queue head before skipping (default 10). */
|
|
71
|
+
maxHeadFailures?: number;
|
|
72
|
+
/** Called when the queue head is skipped after repeated transport failures. */
|
|
73
|
+
onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
|
|
68
74
|
};
|
|
69
75
|
declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
|
|
70
76
|
|
|
@@ -72,12 +78,16 @@ declare function createXAPIClient(opts?: {
|
|
|
72
78
|
transport?: XAPITransport;
|
|
73
79
|
/** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
|
|
74
80
|
exitTransport?: XAPIExitTransport;
|
|
81
|
+
/** Abort in-flight transport by statement id (e.g. from createFetchTransport). */
|
|
82
|
+
abortInFlight?: (statementId: string) => void;
|
|
75
83
|
courseId?: CourseId;
|
|
76
84
|
queue?: XAPIQueue;
|
|
77
85
|
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
78
86
|
maxQueueSize?: number;
|
|
79
87
|
onQueueDepth?: (size: number) => void;
|
|
80
88
|
onQueueCap?: () => void;
|
|
89
|
+
/** Called when transport fails after retries (statement is re-queued). */
|
|
90
|
+
onTransportError?: (err: unknown) => void;
|
|
81
91
|
}): XAPIClient;
|
|
82
92
|
|
|
83
93
|
type CreateFetchTransportOptions = {
|
|
@@ -100,7 +110,17 @@ type FetchTransportBundle = {
|
|
|
100
110
|
transport: XAPITransport;
|
|
101
111
|
/** Best-effort synchronous delivery for pagehide (keepalive fetch). */
|
|
102
112
|
exitTransport: (statement: XAPIStatement) => void;
|
|
113
|
+
/** Abort an in-flight transport request by statement id (used on pagehide). */
|
|
114
|
+
abortInFlight: (statementId: string) => void;
|
|
103
115
|
};
|
|
116
|
+
/** HTTP error from fetch transport with status for retry policy. */
|
|
117
|
+
declare class FetchHttpError extends Error {
|
|
118
|
+
readonly status: number;
|
|
119
|
+
constructor(status: number, statusText: string, kind?: "xapi" | "batch");
|
|
120
|
+
}
|
|
121
|
+
/** Retry 429 and 5xx; do not retry other 4xx (auth/config errors). */
|
|
122
|
+
declare function isRetryableFetchHttpStatus(status: number): boolean;
|
|
123
|
+
declare function isRetryableFetchError(err: unknown): boolean;
|
|
104
124
|
/**
|
|
105
125
|
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
106
126
|
* keepalive exit transport for pagehide delivery.
|
|
@@ -123,4 +143,4 @@ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchB
|
|
|
123
143
|
*/
|
|
124
144
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
125
145
|
|
|
126
|
-
export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
146
|
+
export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, FetchHttpError, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, isRetryableFetchError, isRetryableFetchHttpStatus, telemetryEventToXAPIStatement };
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ type XAPIQueue = {
|
|
|
37
37
|
flush: (transport: XAPITransport) => Promise<void>;
|
|
38
38
|
flushOnExit: (exitTransport: XAPIExitTransport) => void;
|
|
39
39
|
size: () => number;
|
|
40
|
+
/** Statement id currently being delivered via flush, if any. */
|
|
41
|
+
getHeadInFlightId?: () => string | undefined;
|
|
40
42
|
};
|
|
41
43
|
type XAPIExitTransport = (statement: XAPIStatement) => void;
|
|
42
44
|
type XAPIClient = {
|
|
@@ -65,6 +67,10 @@ type InMemoryXAPIQueueOptions = {
|
|
|
65
67
|
onDepth?: (size: number) => void;
|
|
66
68
|
/** Called when an oldest statement is dropped because the queue is at maxSize. */
|
|
67
69
|
onCap?: () => void;
|
|
70
|
+
/** Failures at queue head before skipping (default 10). */
|
|
71
|
+
maxHeadFailures?: number;
|
|
72
|
+
/** Called when the queue head is skipped after repeated transport failures. */
|
|
73
|
+
onHeadSkipped?: (statement: XAPIStatement, err: unknown) => void;
|
|
68
74
|
};
|
|
69
75
|
declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQueue;
|
|
70
76
|
|
|
@@ -72,12 +78,16 @@ declare function createXAPIClient(opts?: {
|
|
|
72
78
|
transport?: XAPITransport;
|
|
73
79
|
/** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
|
|
74
80
|
exitTransport?: XAPIExitTransport;
|
|
81
|
+
/** Abort in-flight transport by statement id (e.g. from createFetchTransport). */
|
|
82
|
+
abortInFlight?: (statementId: string) => void;
|
|
75
83
|
courseId?: CourseId;
|
|
76
84
|
queue?: XAPIQueue;
|
|
77
85
|
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
78
86
|
maxQueueSize?: number;
|
|
79
87
|
onQueueDepth?: (size: number) => void;
|
|
80
88
|
onQueueCap?: () => void;
|
|
89
|
+
/** Called when transport fails after retries (statement is re-queued). */
|
|
90
|
+
onTransportError?: (err: unknown) => void;
|
|
81
91
|
}): XAPIClient;
|
|
82
92
|
|
|
83
93
|
type CreateFetchTransportOptions = {
|
|
@@ -100,7 +110,17 @@ type FetchTransportBundle = {
|
|
|
100
110
|
transport: XAPITransport;
|
|
101
111
|
/** Best-effort synchronous delivery for pagehide (keepalive fetch). */
|
|
102
112
|
exitTransport: (statement: XAPIStatement) => void;
|
|
113
|
+
/** Abort an in-flight transport request by statement id (used on pagehide). */
|
|
114
|
+
abortInFlight: (statementId: string) => void;
|
|
103
115
|
};
|
|
116
|
+
/** HTTP error from fetch transport with status for retry policy. */
|
|
117
|
+
declare class FetchHttpError extends Error {
|
|
118
|
+
readonly status: number;
|
|
119
|
+
constructor(status: number, statusText: string, kind?: "xapi" | "batch");
|
|
120
|
+
}
|
|
121
|
+
/** Retry 429 and 5xx; do not retry other 4xx (auth/config errors). */
|
|
122
|
+
declare function isRetryableFetchHttpStatus(status: number): boolean;
|
|
123
|
+
declare function isRetryableFetchError(err: unknown): boolean;
|
|
104
124
|
/**
|
|
105
125
|
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
106
126
|
* keepalive exit transport for pagehide delivery.
|
|
@@ -123,4 +143,4 @@ declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchB
|
|
|
123
143
|
*/
|
|
124
144
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
125
145
|
|
|
126
|
-
export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
146
|
+
export { type CreateFetchBatchSinkOptions, type CreateFetchTransportOptions, type FetchBatchSinkBundle, FetchHttpError, type FetchTransportBundle, type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIExitTransport, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createFetchBatchSink, createFetchTransport, createInMemoryXAPIQueue, createXAPIClient, isRetryableFetchError, isRetryableFetchHttpStatus, telemetryEventToXAPIStatement };
|
package/dist/index.js
CHANGED
|
@@ -16,11 +16,15 @@ function withStatementId(statement) {
|
|
|
16
16
|
return statement;
|
|
17
17
|
}
|
|
18
18
|
var DEFAULT_MAX_QUEUE_SIZE = 1e3;
|
|
19
|
+
var DEFAULT_MAX_HEAD_FAILURES = 10;
|
|
19
20
|
function createInMemoryXAPIQueue(opts) {
|
|
20
21
|
const maxSize = opts?.maxSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
22
|
+
const maxHeadFailures = opts?.maxHeadFailures ?? DEFAULT_MAX_HEAD_FAILURES;
|
|
21
23
|
const buffer = [];
|
|
22
24
|
let flushInFlight = null;
|
|
23
25
|
let headInFlight = false;
|
|
26
|
+
let headInFlightId;
|
|
27
|
+
let headFailureCount = 0;
|
|
24
28
|
const notifyDepth = () => {
|
|
25
29
|
opts?.onDepth?.(buffer.length);
|
|
26
30
|
};
|
|
@@ -35,14 +39,25 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
35
39
|
while (buffer.length) {
|
|
36
40
|
const statement = buffer[0];
|
|
37
41
|
headInFlight = true;
|
|
42
|
+
headInFlightId = statement.id;
|
|
38
43
|
try {
|
|
39
44
|
await transport(statement);
|
|
40
45
|
buffer.shift();
|
|
46
|
+
headFailureCount = 0;
|
|
41
47
|
notifyDepth();
|
|
42
|
-
} catch {
|
|
43
|
-
|
|
48
|
+
} catch (err) {
|
|
49
|
+
headFailureCount += 1;
|
|
50
|
+
if (headFailureCount >= maxHeadFailures) {
|
|
51
|
+
buffer.shift();
|
|
52
|
+
headFailureCount = 0;
|
|
53
|
+
notifyDepth();
|
|
54
|
+
opts?.onHeadSkipped?.(statement, err);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
44
58
|
} finally {
|
|
45
59
|
headInFlight = false;
|
|
60
|
+
headInFlightId = void 0;
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
};
|
|
@@ -51,12 +66,10 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
51
66
|
const normalized = withStatementId(statement);
|
|
52
67
|
if (buffer.some((s) => s.id === normalized.id)) return;
|
|
53
68
|
if (buffer.length >= maxSize) {
|
|
54
|
-
if (headInFlight && buffer.length <= 1) {
|
|
55
|
-
opts?.onCap?.();
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
69
|
if (headInFlight) {
|
|
59
|
-
buffer.
|
|
70
|
+
if (buffer.length > 1) {
|
|
71
|
+
buffer.splice(1, 1);
|
|
72
|
+
}
|
|
60
73
|
} else {
|
|
61
74
|
buffer.shift();
|
|
62
75
|
}
|
|
@@ -76,21 +89,16 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
76
89
|
return flushInFlight;
|
|
77
90
|
},
|
|
78
91
|
flushOnExit: (exitTransport) => {
|
|
79
|
-
const
|
|
80
|
-
for (let i = startIdx; i < buffer.length; i++) {
|
|
81
|
-
const statement = buffer[i];
|
|
92
|
+
for (const statement of buffer) {
|
|
82
93
|
try {
|
|
83
94
|
exitTransport(statement);
|
|
84
95
|
} catch {
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
|
-
|
|
88
|
-
buffer.length = 0;
|
|
89
|
-
} else if (buffer.length > 1) {
|
|
90
|
-
buffer.splice(1);
|
|
91
|
-
}
|
|
98
|
+
buffer.length = 0;
|
|
92
99
|
notifyDepth();
|
|
93
|
-
}
|
|
100
|
+
},
|
|
101
|
+
getHeadInFlightId: () => headInFlightId
|
|
94
102
|
};
|
|
95
103
|
}
|
|
96
104
|
|
|
@@ -240,7 +248,33 @@ var TELEMETRY_XAPI_MAPPERS = {
|
|
|
240
248
|
hotspot_opened: experiencedBlockMapper,
|
|
241
249
|
accordion_section_toggled: experiencedBlockMapper,
|
|
242
250
|
flashcard_flipped: experiencedBlockMapper,
|
|
243
|
-
image_slider_changed: experiencedBlockMapper
|
|
251
|
+
image_slider_changed: experiencedBlockMapper,
|
|
252
|
+
video_cue_reached: experiencedBlockMapper,
|
|
253
|
+
video_segment_completed: (event, ctx) => {
|
|
254
|
+
if (event.name !== "video_segment_completed") return null;
|
|
255
|
+
const lessonId = event.lessonId;
|
|
256
|
+
const blockId = event.data.blockId;
|
|
257
|
+
if (!lessonId || !blockId) return null;
|
|
258
|
+
return statementFor(
|
|
259
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
260
|
+
XAPIVerbs.completed,
|
|
261
|
+
ctx.timestamp
|
|
262
|
+
);
|
|
263
|
+
},
|
|
264
|
+
memory_card_flipped: experiencedBlockMapper,
|
|
265
|
+
information_wall_search: experiencedBlockMapper,
|
|
266
|
+
parallax_slide_viewed: experiencedBlockMapper,
|
|
267
|
+
questionnaire_submitted: (event, ctx) => {
|
|
268
|
+
if (event.name !== "questionnaire_submitted") return null;
|
|
269
|
+
const lessonId = event.lessonId;
|
|
270
|
+
const blockId = event.data.blockId;
|
|
271
|
+
if (!lessonId || !blockId) return null;
|
|
272
|
+
return statementFor(
|
|
273
|
+
buildLessonkitUrn({ courseId: ctx.courseId, lessonId, blockId }),
|
|
274
|
+
XAPIVerbs.completed,
|
|
275
|
+
ctx.timestamp
|
|
276
|
+
);
|
|
277
|
+
}
|
|
244
278
|
};
|
|
245
279
|
function telemetryEventToXAPIStatement(event) {
|
|
246
280
|
const mapper = TELEMETRY_XAPI_MAPPERS[event.name];
|
|
@@ -280,9 +314,16 @@ function createXAPIClient(opts) {
|
|
|
280
314
|
let warnedTransportFailure = false;
|
|
281
315
|
const inflightById = /* @__PURE__ */ new Map();
|
|
282
316
|
const inflightStatements = /* @__PURE__ */ new Map();
|
|
317
|
+
const exitDeliveredIds = /* @__PURE__ */ new Set();
|
|
318
|
+
const exitNetworkSentIds = /* @__PURE__ */ new Set();
|
|
319
|
+
const deliveryTransport = transport ? async (statement) => {
|
|
320
|
+
if (exitNetworkSentIds.has(statement.id)) return;
|
|
321
|
+
await transport(statement);
|
|
322
|
+
} : void 0;
|
|
283
323
|
const sendOrQueue = (statement) => {
|
|
284
324
|
const normalized = withStatementId2(statement);
|
|
285
|
-
if (
|
|
325
|
+
if (exitDeliveredIds.has(normalized.id)) return;
|
|
326
|
+
if (!deliveryTransport) {
|
|
286
327
|
queue.enqueue(normalized);
|
|
287
328
|
if (isDevEnvironment() && !warnedNoTransport) {
|
|
288
329
|
warnedNoTransport = true;
|
|
@@ -304,17 +345,19 @@ function createXAPIClient(opts) {
|
|
|
304
345
|
}
|
|
305
346
|
inflightStatements.set(normalized.id, normalized);
|
|
306
347
|
const flight = Promise.resolve().then(async () => {
|
|
307
|
-
await
|
|
348
|
+
await deliveryTransport(normalized);
|
|
308
349
|
queue.removeById(normalized.id);
|
|
309
|
-
}).catch(() => {
|
|
350
|
+
}).catch((err) => {
|
|
351
|
+
if (exitDeliveredIds.has(normalized.id)) return;
|
|
310
352
|
queue.enqueue(normalized);
|
|
353
|
+
opts?.onTransportError?.(err);
|
|
311
354
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
312
355
|
warnedTransportFailure = true;
|
|
313
356
|
console.warn(
|
|
314
357
|
"[lessonkit] xAPI transport failed; statement re-queued. Check your LRS endpoint or transport implementation."
|
|
315
358
|
);
|
|
316
359
|
}
|
|
317
|
-
throw new Error("xAPI transport failed");
|
|
360
|
+
throw err instanceof Error ? err : new Error("xAPI transport failed", { cause: err });
|
|
318
361
|
}).finally(() => {
|
|
319
362
|
inflightById.delete(normalized.id);
|
|
320
363
|
inflightStatements.delete(normalized.id);
|
|
@@ -343,27 +386,36 @@ function createXAPIClient(opts) {
|
|
|
343
386
|
},
|
|
344
387
|
queueSize: () => queue.size(),
|
|
345
388
|
flush: async () => {
|
|
346
|
-
if (!
|
|
347
|
-
await queue.flush(
|
|
389
|
+
if (!deliveryTransport) return;
|
|
390
|
+
await queue.flush(deliveryTransport);
|
|
348
391
|
const flights = [...inflightById.values()];
|
|
349
392
|
if (flights.length > 0) {
|
|
350
|
-
await Promise.
|
|
393
|
+
await Promise.all(flights);
|
|
394
|
+
}
|
|
395
|
+
if (queue.size() > 0) {
|
|
396
|
+
throw new Error("xAPI flush incomplete: statements remain queued after flush");
|
|
351
397
|
}
|
|
352
398
|
},
|
|
353
399
|
flushOnExit: exitTransport ? () => {
|
|
354
|
-
const
|
|
400
|
+
const headId = queue.getHeadInFlightId?.();
|
|
401
|
+
if (headId) {
|
|
402
|
+
exitNetworkSentIds.add(headId);
|
|
403
|
+
exitDeliveredIds.add(headId);
|
|
404
|
+
opts.abortInFlight?.(headId);
|
|
405
|
+
}
|
|
355
406
|
for (const statement of inflightStatements.values()) {
|
|
356
|
-
|
|
407
|
+
exitNetworkSentIds.add(statement.id);
|
|
408
|
+
exitDeliveredIds.add(statement.id);
|
|
409
|
+
opts.abortInFlight?.(statement.id);
|
|
410
|
+
}
|
|
411
|
+
queue.flushOnExit((statement) => {
|
|
412
|
+
if (exitDeliveredIds.has(statement.id)) return;
|
|
413
|
+
exitNetworkSentIds.add(statement.id);
|
|
414
|
+
exitDeliveredIds.add(statement.id);
|
|
357
415
|
try {
|
|
358
416
|
exitTransport(statement);
|
|
359
|
-
exitSentIds.add(statement.id);
|
|
360
417
|
} catch {
|
|
361
418
|
}
|
|
362
|
-
}
|
|
363
|
-
queue.flushOnExit((statement) => {
|
|
364
|
-
if (exitSentIds.has(statement.id)) return;
|
|
365
|
-
exitTransport(statement);
|
|
366
|
-
exitSentIds.add(statement.id);
|
|
367
419
|
});
|
|
368
420
|
} : void 0,
|
|
369
421
|
startedLesson: ({ lessonId }) => {
|
|
@@ -404,58 +456,100 @@ function createXAPIClient(opts) {
|
|
|
404
456
|
}
|
|
405
457
|
|
|
406
458
|
// src/fetchTransport.ts
|
|
459
|
+
var FetchHttpError = class extends Error {
|
|
460
|
+
status;
|
|
461
|
+
constructor(status, statusText, kind = "xapi") {
|
|
462
|
+
super(
|
|
463
|
+
kind === "xapi" ? `xAPI fetch failed: ${status} ${statusText}` : `telemetry batch fetch failed: ${status} ${statusText}`
|
|
464
|
+
);
|
|
465
|
+
this.name = "FetchHttpError";
|
|
466
|
+
this.status = status;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
function isRetryableFetchHttpStatus(status) {
|
|
470
|
+
return status === 429 || status >= 500;
|
|
471
|
+
}
|
|
472
|
+
function isRetryableFetchError(err) {
|
|
473
|
+
if (err instanceof FetchHttpError) return isRetryableFetchHttpStatus(err.status);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
407
476
|
function resolveHeaders(headers) {
|
|
408
477
|
if (!headers) return { "Content-Type": "application/json" };
|
|
409
478
|
const resolved = typeof headers === "function" ? headers() : headers;
|
|
410
479
|
return { "Content-Type": "application/json", ...resolved };
|
|
411
480
|
}
|
|
412
481
|
function createAbortSignal(timeoutMs) {
|
|
413
|
-
if (timeoutMs <= 0) return void 0
|
|
414
|
-
|
|
415
|
-
if (typeof timeout.timeout === "function") {
|
|
416
|
-
return timeout.timeout(timeoutMs);
|
|
417
|
-
}
|
|
482
|
+
if (timeoutMs <= 0) return { signal: void 0, abort: () => {
|
|
483
|
+
} };
|
|
418
484
|
const controller = new AbortController();
|
|
419
485
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
420
486
|
timer.unref?.();
|
|
421
|
-
return
|
|
487
|
+
return {
|
|
488
|
+
signal: controller.signal,
|
|
489
|
+
abort: () => {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
controller.abort();
|
|
492
|
+
}
|
|
493
|
+
};
|
|
422
494
|
}
|
|
423
495
|
function sleep(ms) {
|
|
424
496
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
425
497
|
}
|
|
426
498
|
function postStatement(url, statement, init) {
|
|
427
499
|
return fetch(url, {
|
|
500
|
+
...init,
|
|
428
501
|
method: "POST",
|
|
429
|
-
body: JSON.stringify(statement)
|
|
430
|
-
...init
|
|
502
|
+
body: JSON.stringify(statement)
|
|
431
503
|
}).then((res) => {
|
|
432
504
|
if (!res.ok) {
|
|
433
|
-
throw new
|
|
505
|
+
throw new FetchHttpError(res.status, res.statusText, "xapi");
|
|
434
506
|
}
|
|
435
507
|
});
|
|
436
508
|
}
|
|
509
|
+
async function postWithRetry(post, retries, initialBackoffMs, maxBackoffMs) {
|
|
510
|
+
let attempt = 0;
|
|
511
|
+
let backoff = initialBackoffMs;
|
|
512
|
+
for (; ; ) {
|
|
513
|
+
try {
|
|
514
|
+
await post();
|
|
515
|
+
return;
|
|
516
|
+
} catch (err) {
|
|
517
|
+
if (!isRetryableFetchError(err) || attempt >= retries) throw err;
|
|
518
|
+
await sleep(backoff);
|
|
519
|
+
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
520
|
+
attempt += 1;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
437
524
|
function createFetchTransport(opts) {
|
|
438
525
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
439
|
-
const
|
|
526
|
+
const rawRetries = opts.retries ?? 2;
|
|
527
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
440
528
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
441
529
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
530
|
+
const activeControllers = /* @__PURE__ */ new Map();
|
|
442
531
|
const transport = async (statement) => {
|
|
443
|
-
let
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
signal
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
532
|
+
let abortCleanup;
|
|
533
|
+
activeControllers.set(statement.id, {
|
|
534
|
+
abort: () => abortCleanup?.()
|
|
535
|
+
});
|
|
536
|
+
try {
|
|
537
|
+
await postWithRetry(
|
|
538
|
+
() => {
|
|
539
|
+
const { signal, abort } = createAbortSignal(timeoutMs);
|
|
540
|
+
abortCleanup = abort;
|
|
541
|
+
return postStatement(opts.url, statement, {
|
|
542
|
+
...opts.init,
|
|
543
|
+
headers: resolveHeaders(opts.headers),
|
|
544
|
+
signal
|
|
545
|
+
});
|
|
546
|
+
},
|
|
547
|
+
retries,
|
|
548
|
+
initialBackoffMs,
|
|
549
|
+
maxBackoffMs
|
|
550
|
+
);
|
|
551
|
+
} finally {
|
|
552
|
+
activeControllers.delete(statement.id);
|
|
459
553
|
}
|
|
460
554
|
};
|
|
461
555
|
const exitTransport = (statement) => {
|
|
@@ -468,36 +562,32 @@ function createFetchTransport(opts) {
|
|
|
468
562
|
} catch {
|
|
469
563
|
}
|
|
470
564
|
};
|
|
471
|
-
|
|
565
|
+
const abortInFlight = (statementId) => {
|
|
566
|
+
activeControllers.get(statementId)?.abort();
|
|
567
|
+
activeControllers.delete(statementId);
|
|
568
|
+
};
|
|
569
|
+
return { transport, exitTransport, abortInFlight };
|
|
472
570
|
}
|
|
473
571
|
function createFetchBatchSink(opts) {
|
|
474
572
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
475
|
-
const
|
|
573
|
+
const rawRetries = opts.retries ?? 2;
|
|
574
|
+
const retries = Number.isFinite(rawRetries) ? Math.max(0, Math.floor(rawRetries)) : 2;
|
|
476
575
|
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
477
576
|
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
478
577
|
const postBatch = async (events, init) => {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (!res.ok) {
|
|
491
|
-
throw new Error(`telemetry batch fetch failed: ${res.status} ${res.statusText}`);
|
|
492
|
-
}
|
|
493
|
-
return;
|
|
494
|
-
} catch (err) {
|
|
495
|
-
if (attempt >= retries) throw err;
|
|
496
|
-
await sleep(backoff);
|
|
497
|
-
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
498
|
-
attempt += 1;
|
|
578
|
+
await postWithRetry(async () => {
|
|
579
|
+
const { signal } = createAbortSignal(timeoutMs);
|
|
580
|
+
const res = await fetch(opts.url, {
|
|
581
|
+
...init,
|
|
582
|
+
method: "POST",
|
|
583
|
+
body: JSON.stringify(events),
|
|
584
|
+
headers: resolveHeaders(opts.headers),
|
|
585
|
+
signal
|
|
586
|
+
});
|
|
587
|
+
if (!res.ok) {
|
|
588
|
+
throw new FetchHttpError(res.status, res.statusText, "batch");
|
|
499
589
|
}
|
|
500
|
-
}
|
|
590
|
+
}, retries, initialBackoffMs, maxBackoffMs);
|
|
501
591
|
};
|
|
502
592
|
return {
|
|
503
593
|
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
@@ -516,9 +606,12 @@ function createFetchBatchSink(opts) {
|
|
|
516
606
|
};
|
|
517
607
|
}
|
|
518
608
|
export {
|
|
609
|
+
FetchHttpError,
|
|
519
610
|
createFetchBatchSink,
|
|
520
611
|
createFetchTransport,
|
|
521
612
|
createInMemoryXAPIQueue,
|
|
522
613
|
createXAPIClient,
|
|
614
|
+
isRetryableFetchError,
|
|
615
|
+
isRetryableFetchHttpStatus,
|
|
523
616
|
telemetryEventToXAPIStatement
|
|
524
617
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/xapi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "xAPI statement generation primitives for LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"lint": "echo \"(no lint configured yet)\""
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@lessonkit/core": "1.
|
|
51
|
+
"@lessonkit/core": "1.4.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|