@lessonkit/xapi 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/dist/index.cjs +165 -1
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +163 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,27 +17,41 @@ npm install @lessonkit/xapi @lessonkit/core
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
19
|
```typescript
|
|
20
|
-
import { createXAPIClient, telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
20
|
+
import { createFetchTransport, createXAPIClient, telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
21
|
+
|
|
22
|
+
const { transport, exitTransport } = createFetchTransport({
|
|
23
|
+
url: "/api/xapi/statements",
|
|
24
|
+
timeoutMs: 30_000,
|
|
25
|
+
});
|
|
21
26
|
|
|
22
27
|
const xapi = createXAPIClient({
|
|
23
28
|
courseId: "my-course",
|
|
24
|
-
transport
|
|
25
|
-
|
|
26
|
-
},
|
|
29
|
+
transport,
|
|
30
|
+
exitTransport,
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
xapi.completeLesson({ lessonId: "lesson-1", durationMs: 1200, success: true });
|
|
30
34
|
await xapi.flush();
|
|
35
|
+
xapi.flushOnExit?.(); // pagehide keepalive delivery
|
|
31
36
|
```
|
|
32
37
|
|
|
33
38
|
Map from telemetry events: `telemetryEventToXAPIStatement(event)` — uses canonical LessonKit URNs.
|
|
34
39
|
|
|
40
|
+
Batch analytics sink:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { createFetchBatchSink } from "@lessonkit/xapi";
|
|
44
|
+
|
|
45
|
+
const { batchSink, exitBatchSink } = createFetchBatchSink({ url: "/api/telemetry/batch" });
|
|
46
|
+
```
|
|
47
|
+
|
|
35
48
|
## Behavior
|
|
36
49
|
|
|
37
50
|
- No transport → statements queue in memory (dev warns once).
|
|
38
51
|
- Transport failure → re-queue; call `flush()` to retry.
|
|
39
52
|
- Queue capped at **1000** statements by default; oldest dropped when full (`onCap` / `createInMemoryXAPIQueue({ onCap })`).
|
|
40
53
|
- Concurrent `flush()` calls are coalesced.
|
|
54
|
+
- `createFetchTransport` retries with exponential backoff and uses `AbortSignal.timeout` when available.
|
|
41
55
|
|
|
42
56
|
## Docs
|
|
43
57
|
|
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
createFetchBatchSink: () => createFetchBatchSink,
|
|
24
|
+
createFetchTransport: () => createFetchTransport,
|
|
23
25
|
createInMemoryXAPIQueue: () => createInMemoryXAPIQueue,
|
|
24
26
|
createXAPIClient: () => createXAPIClient,
|
|
25
27
|
telemetryEventToXAPIStatement: () => telemetryEventToXAPIStatement
|
|
@@ -52,6 +54,13 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
52
54
|
const notifyDepth = () => {
|
|
53
55
|
opts?.onDepth?.(buffer.length);
|
|
54
56
|
};
|
|
57
|
+
const removeById = (id) => {
|
|
58
|
+
const idx = buffer.findIndex((s) => s.id === id);
|
|
59
|
+
if (idx >= 0) {
|
|
60
|
+
buffer.splice(idx, 1);
|
|
61
|
+
notifyDepth();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
55
64
|
const runFlush = async (transport) => {
|
|
56
65
|
while (buffer.length) {
|
|
57
66
|
const statement = buffer[0];
|
|
@@ -86,6 +95,7 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
86
95
|
buffer.push(normalized);
|
|
87
96
|
notifyDepth();
|
|
88
97
|
},
|
|
98
|
+
removeById,
|
|
89
99
|
size: () => buffer.length,
|
|
90
100
|
flush: async (transport) => {
|
|
91
101
|
if (flushInFlight) return flushInFlight;
|
|
@@ -94,6 +104,22 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
94
104
|
flushInFlight = null;
|
|
95
105
|
});
|
|
96
106
|
return flushInFlight;
|
|
107
|
+
},
|
|
108
|
+
flushOnExit: (exitTransport) => {
|
|
109
|
+
const startIdx = headInFlight && buffer.length > 0 ? 1 : 0;
|
|
110
|
+
for (let i = startIdx; i < buffer.length; i++) {
|
|
111
|
+
const statement = buffer[i];
|
|
112
|
+
try {
|
|
113
|
+
exitTransport(statement);
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (startIdx === 0) {
|
|
118
|
+
buffer.length = 0;
|
|
119
|
+
} else if (buffer.length > 1) {
|
|
120
|
+
buffer.splice(1);
|
|
121
|
+
}
|
|
122
|
+
notifyDepth();
|
|
97
123
|
}
|
|
98
124
|
};
|
|
99
125
|
}
|
|
@@ -273,6 +299,7 @@ function isDevEnvironment() {
|
|
|
273
299
|
}
|
|
274
300
|
function createXAPIClient(opts) {
|
|
275
301
|
const transport = opts?.transport;
|
|
302
|
+
const exitTransport = opts?.exitTransport;
|
|
276
303
|
const courseId = opts?.courseId;
|
|
277
304
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
278
305
|
maxSize: opts?.maxQueueSize,
|
|
@@ -282,6 +309,7 @@ function createXAPIClient(opts) {
|
|
|
282
309
|
let warnedNoTransport = false;
|
|
283
310
|
let warnedTransportFailure = false;
|
|
284
311
|
const inflightById = /* @__PURE__ */ new Map();
|
|
312
|
+
const inflightStatements = /* @__PURE__ */ new Map();
|
|
285
313
|
const sendOrQueue = (statement) => {
|
|
286
314
|
const normalized = withStatementId2(statement);
|
|
287
315
|
if (!transport) {
|
|
@@ -304,7 +332,11 @@ function createXAPIClient(opts) {
|
|
|
304
332
|
);
|
|
305
333
|
return;
|
|
306
334
|
}
|
|
307
|
-
|
|
335
|
+
inflightStatements.set(normalized.id, normalized);
|
|
336
|
+
const flight = Promise.resolve().then(async () => {
|
|
337
|
+
await transport(normalized);
|
|
338
|
+
queue.removeById(normalized.id);
|
|
339
|
+
}).catch(() => {
|
|
308
340
|
queue.enqueue(normalized);
|
|
309
341
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
310
342
|
warnedTransportFailure = true;
|
|
@@ -315,6 +347,7 @@ function createXAPIClient(opts) {
|
|
|
315
347
|
throw new Error("xAPI transport failed");
|
|
316
348
|
}).finally(() => {
|
|
317
349
|
inflightById.delete(normalized.id);
|
|
350
|
+
inflightStatements.delete(normalized.id);
|
|
318
351
|
});
|
|
319
352
|
inflightById.set(normalized.id, flight);
|
|
320
353
|
void flight.catch(() => {
|
|
@@ -347,6 +380,22 @@ function createXAPIClient(opts) {
|
|
|
347
380
|
await Promise.allSettled(flights);
|
|
348
381
|
}
|
|
349
382
|
},
|
|
383
|
+
flushOnExit: exitTransport ? () => {
|
|
384
|
+
const exitSentIds = /* @__PURE__ */ new Set();
|
|
385
|
+
for (const statement of inflightStatements.values()) {
|
|
386
|
+
if (exitSentIds.has(statement.id)) continue;
|
|
387
|
+
try {
|
|
388
|
+
exitTransport(statement);
|
|
389
|
+
exitSentIds.add(statement.id);
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
queue.flushOnExit((statement) => {
|
|
394
|
+
if (exitSentIds.has(statement.id)) return;
|
|
395
|
+
exitTransport(statement);
|
|
396
|
+
exitSentIds.add(statement.id);
|
|
397
|
+
});
|
|
398
|
+
} : void 0,
|
|
350
399
|
startedLesson: ({ lessonId }) => {
|
|
351
400
|
if (!courseId) return;
|
|
352
401
|
emit({
|
|
@@ -383,8 +432,123 @@ function createXAPIClient(opts) {
|
|
|
383
432
|
}
|
|
384
433
|
};
|
|
385
434
|
}
|
|
435
|
+
|
|
436
|
+
// src/fetchTransport.ts
|
|
437
|
+
function resolveHeaders(headers) {
|
|
438
|
+
if (!headers) return { "Content-Type": "application/json" };
|
|
439
|
+
const resolved = typeof headers === "function" ? headers() : headers;
|
|
440
|
+
return { "Content-Type": "application/json", ...resolved };
|
|
441
|
+
}
|
|
442
|
+
function createAbortSignal(timeoutMs) {
|
|
443
|
+
if (timeoutMs <= 0) return void 0;
|
|
444
|
+
const timeout = AbortSignal;
|
|
445
|
+
if (typeof timeout.timeout === "function") {
|
|
446
|
+
return timeout.timeout(timeoutMs);
|
|
447
|
+
}
|
|
448
|
+
const controller = new AbortController();
|
|
449
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
450
|
+
timer.unref?.();
|
|
451
|
+
return controller.signal;
|
|
452
|
+
}
|
|
453
|
+
function sleep(ms) {
|
|
454
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
455
|
+
}
|
|
456
|
+
function postStatement(url, statement, init) {
|
|
457
|
+
return fetch(url, {
|
|
458
|
+
method: "POST",
|
|
459
|
+
body: JSON.stringify(statement),
|
|
460
|
+
...init
|
|
461
|
+
}).then((res) => {
|
|
462
|
+
if (!res.ok) {
|
|
463
|
+
throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function createFetchTransport(opts) {
|
|
468
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
469
|
+
const retries = opts.retries ?? 2;
|
|
470
|
+
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
471
|
+
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
472
|
+
const transport = async (statement) => {
|
|
473
|
+
let attempt = 0;
|
|
474
|
+
let backoff = initialBackoffMs;
|
|
475
|
+
for (; ; ) {
|
|
476
|
+
try {
|
|
477
|
+
await postStatement(opts.url, statement, {
|
|
478
|
+
...opts.init,
|
|
479
|
+
headers: resolveHeaders(opts.headers),
|
|
480
|
+
signal: createAbortSignal(timeoutMs)
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (attempt >= retries) throw err;
|
|
485
|
+
await sleep(backoff);
|
|
486
|
+
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
487
|
+
attempt += 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const exitTransport = (statement) => {
|
|
492
|
+
try {
|
|
493
|
+
void postStatement(opts.url, statement, {
|
|
494
|
+
...opts.init,
|
|
495
|
+
headers: resolveHeaders(opts.headers),
|
|
496
|
+
keepalive: true
|
|
497
|
+
}).catch(() => void 0);
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
return { transport, exitTransport };
|
|
502
|
+
}
|
|
503
|
+
function createFetchBatchSink(opts) {
|
|
504
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
505
|
+
const retries = opts.retries ?? 2;
|
|
506
|
+
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
507
|
+
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
508
|
+
const postBatch = async (events, init) => {
|
|
509
|
+
let attempt = 0;
|
|
510
|
+
let backoff = initialBackoffMs;
|
|
511
|
+
for (; ; ) {
|
|
512
|
+
try {
|
|
513
|
+
const res = await fetch(opts.url, {
|
|
514
|
+
method: "POST",
|
|
515
|
+
body: JSON.stringify(events),
|
|
516
|
+
...init,
|
|
517
|
+
headers: resolveHeaders(opts.headers),
|
|
518
|
+
signal: createAbortSignal(timeoutMs)
|
|
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;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
return {
|
|
533
|
+
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
534
|
+
exitBatchSink: (events) => {
|
|
535
|
+
try {
|
|
536
|
+
void fetch(opts.url, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: JSON.stringify(events),
|
|
539
|
+
...opts.init,
|
|
540
|
+
headers: resolveHeaders(opts.headers),
|
|
541
|
+
keepalive: true
|
|
542
|
+
}).catch(() => void 0);
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
}
|
|
386
548
|
// Annotate the CommonJS export names for ESM import in node:
|
|
387
549
|
0 && (module.exports = {
|
|
550
|
+
createFetchBatchSink,
|
|
551
|
+
createFetchTransport,
|
|
388
552
|
createInMemoryXAPIQueue,
|
|
389
553
|
createXAPIClient,
|
|
390
554
|
telemetryEventToXAPIStatement
|
package/dist/index.d.cts
CHANGED
|
@@ -32,12 +32,18 @@ type XAPIStatement = {
|
|
|
32
32
|
type XAPITransport = (statement: XAPIStatement) => void | Promise<void>;
|
|
33
33
|
type XAPIQueue = {
|
|
34
34
|
enqueue: (statement: XAPIStatement) => void;
|
|
35
|
+
/** Remove a queued statement by id (e.g. after successful direct transport). */
|
|
36
|
+
removeById: (id: string) => void;
|
|
35
37
|
flush: (transport: XAPITransport) => Promise<void>;
|
|
38
|
+
flushOnExit: (exitTransport: XAPIExitTransport) => void;
|
|
36
39
|
size: () => number;
|
|
37
40
|
};
|
|
41
|
+
type XAPIExitTransport = (statement: XAPIStatement) => void;
|
|
38
42
|
type XAPIClient = {
|
|
39
43
|
send: (statement: XAPIStatement) => void;
|
|
40
44
|
flush: () => Promise<void>;
|
|
45
|
+
/** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
|
|
46
|
+
flushOnExit?: () => void;
|
|
41
47
|
queueSize: () => number;
|
|
42
48
|
startedLesson: (opts: {
|
|
43
49
|
lessonId: LessonId;
|
|
@@ -64,6 +70,8 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
|
|
|
64
70
|
|
|
65
71
|
declare function createXAPIClient(opts?: {
|
|
66
72
|
transport?: XAPITransport;
|
|
73
|
+
/** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
|
|
74
|
+
exitTransport?: XAPIExitTransport;
|
|
67
75
|
courseId?: CourseId;
|
|
68
76
|
queue?: XAPIQueue;
|
|
69
77
|
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
@@ -72,10 +80,47 @@ declare function createXAPIClient(opts?: {
|
|
|
72
80
|
onQueueCap?: () => void;
|
|
73
81
|
}): XAPIClient;
|
|
74
82
|
|
|
83
|
+
type CreateFetchTransportOptions = {
|
|
84
|
+
/** LRS or proxy endpoint (POST). */
|
|
85
|
+
url: string;
|
|
86
|
+
/** Per-request timeout (default 30_000 ms). Uses AbortSignal.timeout when available. */
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
/** Static headers merged into each request (e.g. Authorization from a short-lived token). */
|
|
89
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
90
|
+
/** Retries after transport failure (default 2). */
|
|
91
|
+
retries?: number;
|
|
92
|
+
/** Initial backoff in ms (default 250). Doubles each retry up to maxBackoffMs. */
|
|
93
|
+
backoffMs?: number;
|
|
94
|
+
/** Maximum backoff in ms (default 5_000). */
|
|
95
|
+
maxBackoffMs?: number;
|
|
96
|
+
/** Extra fetch init merged into each request. */
|
|
97
|
+
init?: Omit<RequestInit, "method" | "body" | "signal" | "keepalive">;
|
|
98
|
+
};
|
|
99
|
+
type FetchTransportBundle = {
|
|
100
|
+
transport: XAPITransport;
|
|
101
|
+
/** Best-effort synchronous delivery for pagehide (keepalive fetch). */
|
|
102
|
+
exitTransport: (statement: XAPIStatement) => void;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
106
|
+
* keepalive exit transport for pagehide delivery.
|
|
107
|
+
*/
|
|
108
|
+
declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
|
|
109
|
+
type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
|
|
110
|
+
type FetchBatchSinkBundle = {
|
|
111
|
+
batchSink: (events: unknown[]) => Promise<void>;
|
|
112
|
+
/** Best-effort keepalive POST for pagehide (JSON array body). */
|
|
113
|
+
exitBatchSink: (events: unknown[]) => void;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
|
|
117
|
+
*/
|
|
118
|
+
declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
|
|
119
|
+
|
|
75
120
|
/**
|
|
76
121
|
* Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
|
|
77
122
|
* `lesson_time_on_task` returns null (companion metric; lesson_completed carries duration).
|
|
78
123
|
*/
|
|
79
124
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
80
125
|
|
|
81
|
-
export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -32,12 +32,18 @@ type XAPIStatement = {
|
|
|
32
32
|
type XAPITransport = (statement: XAPIStatement) => void | Promise<void>;
|
|
33
33
|
type XAPIQueue = {
|
|
34
34
|
enqueue: (statement: XAPIStatement) => void;
|
|
35
|
+
/** Remove a queued statement by id (e.g. after successful direct transport). */
|
|
36
|
+
removeById: (id: string) => void;
|
|
35
37
|
flush: (transport: XAPITransport) => Promise<void>;
|
|
38
|
+
flushOnExit: (exitTransport: XAPIExitTransport) => void;
|
|
36
39
|
size: () => number;
|
|
37
40
|
};
|
|
41
|
+
type XAPIExitTransport = (statement: XAPIStatement) => void;
|
|
38
42
|
type XAPIClient = {
|
|
39
43
|
send: (statement: XAPIStatement) => void;
|
|
40
44
|
flush: () => Promise<void>;
|
|
45
|
+
/** Best-effort synchronous flush for pagehide using keepalive transport when configured. */
|
|
46
|
+
flushOnExit?: () => void;
|
|
41
47
|
queueSize: () => number;
|
|
42
48
|
startedLesson: (opts: {
|
|
43
49
|
lessonId: LessonId;
|
|
@@ -64,6 +70,8 @@ declare function createInMemoryXAPIQueue(opts?: InMemoryXAPIQueueOptions): XAPIQ
|
|
|
64
70
|
|
|
65
71
|
declare function createXAPIClient(opts?: {
|
|
66
72
|
transport?: XAPITransport;
|
|
73
|
+
/** Keepalive transport for pagehide flush (e.g. from createFetchTransport). */
|
|
74
|
+
exitTransport?: XAPIExitTransport;
|
|
67
75
|
courseId?: CourseId;
|
|
68
76
|
queue?: XAPIQueue;
|
|
69
77
|
/** When creating the default in-memory queue (max size 1000 unless overridden). */
|
|
@@ -72,10 +80,47 @@ declare function createXAPIClient(opts?: {
|
|
|
72
80
|
onQueueCap?: () => void;
|
|
73
81
|
}): XAPIClient;
|
|
74
82
|
|
|
83
|
+
type CreateFetchTransportOptions = {
|
|
84
|
+
/** LRS or proxy endpoint (POST). */
|
|
85
|
+
url: string;
|
|
86
|
+
/** Per-request timeout (default 30_000 ms). Uses AbortSignal.timeout when available. */
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
/** Static headers merged into each request (e.g. Authorization from a short-lived token). */
|
|
89
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
90
|
+
/** Retries after transport failure (default 2). */
|
|
91
|
+
retries?: number;
|
|
92
|
+
/** Initial backoff in ms (default 250). Doubles each retry up to maxBackoffMs. */
|
|
93
|
+
backoffMs?: number;
|
|
94
|
+
/** Maximum backoff in ms (default 5_000). */
|
|
95
|
+
maxBackoffMs?: number;
|
|
96
|
+
/** Extra fetch init merged into each request. */
|
|
97
|
+
init?: Omit<RequestInit, "method" | "body" | "signal" | "keepalive">;
|
|
98
|
+
};
|
|
99
|
+
type FetchTransportBundle = {
|
|
100
|
+
transport: XAPITransport;
|
|
101
|
+
/** Best-effort synchronous delivery for pagehide (keepalive fetch). */
|
|
102
|
+
exitTransport: (statement: XAPIStatement) => void;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Creates an xAPI transport backed by fetch with timeout, retry backoff, and a
|
|
106
|
+
* keepalive exit transport for pagehide delivery.
|
|
107
|
+
*/
|
|
108
|
+
declare function createFetchTransport(opts: CreateFetchTransportOptions): FetchTransportBundle;
|
|
109
|
+
type CreateFetchBatchSinkOptions = CreateFetchTransportOptions;
|
|
110
|
+
type FetchBatchSinkBundle = {
|
|
111
|
+
batchSink: (events: unknown[]) => Promise<void>;
|
|
112
|
+
/** Best-effort keepalive POST for pagehide (JSON array body). */
|
|
113
|
+
exitBatchSink: (events: unknown[]) => void;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Batch analytics sink with timeout, retry backoff, and keepalive exit delivery.
|
|
117
|
+
*/
|
|
118
|
+
declare function createFetchBatchSink(opts: CreateFetchBatchSinkOptions): FetchBatchSinkBundle;
|
|
119
|
+
|
|
75
120
|
/**
|
|
76
121
|
* Map a LessonKit telemetry event to an xAPI statement, or null if the event should not emit xAPI.
|
|
77
122
|
* `lesson_time_on_task` returns null (companion metric; lesson_completed carries duration).
|
|
78
123
|
*/
|
|
79
124
|
declare function telemetryEventToXAPIStatement(event: TelemetryEvent): XAPIStatement | null;
|
|
80
125
|
|
|
81
|
-
export { type InMemoryXAPIQueueOptions, type XAPIClient, type XAPIObjectDefinition, type XAPIQueue, type XAPIResult, type XAPIScore, type XAPIStatement, type XAPITransport, type XAPIVerbIri, createInMemoryXAPIQueue, createXAPIClient, telemetryEventToXAPIStatement };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,13 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
24
24
|
const notifyDepth = () => {
|
|
25
25
|
opts?.onDepth?.(buffer.length);
|
|
26
26
|
};
|
|
27
|
+
const removeById = (id) => {
|
|
28
|
+
const idx = buffer.findIndex((s) => s.id === id);
|
|
29
|
+
if (idx >= 0) {
|
|
30
|
+
buffer.splice(idx, 1);
|
|
31
|
+
notifyDepth();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
27
34
|
const runFlush = async (transport) => {
|
|
28
35
|
while (buffer.length) {
|
|
29
36
|
const statement = buffer[0];
|
|
@@ -58,6 +65,7 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
58
65
|
buffer.push(normalized);
|
|
59
66
|
notifyDepth();
|
|
60
67
|
},
|
|
68
|
+
removeById,
|
|
61
69
|
size: () => buffer.length,
|
|
62
70
|
flush: async (transport) => {
|
|
63
71
|
if (flushInFlight) return flushInFlight;
|
|
@@ -66,6 +74,22 @@ function createInMemoryXAPIQueue(opts) {
|
|
|
66
74
|
flushInFlight = null;
|
|
67
75
|
});
|
|
68
76
|
return flushInFlight;
|
|
77
|
+
},
|
|
78
|
+
flushOnExit: (exitTransport) => {
|
|
79
|
+
const startIdx = headInFlight && buffer.length > 0 ? 1 : 0;
|
|
80
|
+
for (let i = startIdx; i < buffer.length; i++) {
|
|
81
|
+
const statement = buffer[i];
|
|
82
|
+
try {
|
|
83
|
+
exitTransport(statement);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (startIdx === 0) {
|
|
88
|
+
buffer.length = 0;
|
|
89
|
+
} else if (buffer.length > 1) {
|
|
90
|
+
buffer.splice(1);
|
|
91
|
+
}
|
|
92
|
+
notifyDepth();
|
|
69
93
|
}
|
|
70
94
|
};
|
|
71
95
|
}
|
|
@@ -245,6 +269,7 @@ function isDevEnvironment() {
|
|
|
245
269
|
}
|
|
246
270
|
function createXAPIClient(opts) {
|
|
247
271
|
const transport = opts?.transport;
|
|
272
|
+
const exitTransport = opts?.exitTransport;
|
|
248
273
|
const courseId = opts?.courseId;
|
|
249
274
|
const queue = opts?.queue ?? createInMemoryXAPIQueue({
|
|
250
275
|
maxSize: opts?.maxQueueSize,
|
|
@@ -254,6 +279,7 @@ function createXAPIClient(opts) {
|
|
|
254
279
|
let warnedNoTransport = false;
|
|
255
280
|
let warnedTransportFailure = false;
|
|
256
281
|
const inflightById = /* @__PURE__ */ new Map();
|
|
282
|
+
const inflightStatements = /* @__PURE__ */ new Map();
|
|
257
283
|
const sendOrQueue = (statement) => {
|
|
258
284
|
const normalized = withStatementId2(statement);
|
|
259
285
|
if (!transport) {
|
|
@@ -276,7 +302,11 @@ function createXAPIClient(opts) {
|
|
|
276
302
|
);
|
|
277
303
|
return;
|
|
278
304
|
}
|
|
279
|
-
|
|
305
|
+
inflightStatements.set(normalized.id, normalized);
|
|
306
|
+
const flight = Promise.resolve().then(async () => {
|
|
307
|
+
await transport(normalized);
|
|
308
|
+
queue.removeById(normalized.id);
|
|
309
|
+
}).catch(() => {
|
|
280
310
|
queue.enqueue(normalized);
|
|
281
311
|
if (isDevEnvironment() && !warnedTransportFailure) {
|
|
282
312
|
warnedTransportFailure = true;
|
|
@@ -287,6 +317,7 @@ function createXAPIClient(opts) {
|
|
|
287
317
|
throw new Error("xAPI transport failed");
|
|
288
318
|
}).finally(() => {
|
|
289
319
|
inflightById.delete(normalized.id);
|
|
320
|
+
inflightStatements.delete(normalized.id);
|
|
290
321
|
});
|
|
291
322
|
inflightById.set(normalized.id, flight);
|
|
292
323
|
void flight.catch(() => {
|
|
@@ -319,6 +350,22 @@ function createXAPIClient(opts) {
|
|
|
319
350
|
await Promise.allSettled(flights);
|
|
320
351
|
}
|
|
321
352
|
},
|
|
353
|
+
flushOnExit: exitTransport ? () => {
|
|
354
|
+
const exitSentIds = /* @__PURE__ */ new Set();
|
|
355
|
+
for (const statement of inflightStatements.values()) {
|
|
356
|
+
if (exitSentIds.has(statement.id)) continue;
|
|
357
|
+
try {
|
|
358
|
+
exitTransport(statement);
|
|
359
|
+
exitSentIds.add(statement.id);
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
queue.flushOnExit((statement) => {
|
|
364
|
+
if (exitSentIds.has(statement.id)) return;
|
|
365
|
+
exitTransport(statement);
|
|
366
|
+
exitSentIds.add(statement.id);
|
|
367
|
+
});
|
|
368
|
+
} : void 0,
|
|
322
369
|
startedLesson: ({ lessonId }) => {
|
|
323
370
|
if (!courseId) return;
|
|
324
371
|
emit({
|
|
@@ -355,7 +402,122 @@ function createXAPIClient(opts) {
|
|
|
355
402
|
}
|
|
356
403
|
};
|
|
357
404
|
}
|
|
405
|
+
|
|
406
|
+
// src/fetchTransport.ts
|
|
407
|
+
function resolveHeaders(headers) {
|
|
408
|
+
if (!headers) return { "Content-Type": "application/json" };
|
|
409
|
+
const resolved = typeof headers === "function" ? headers() : headers;
|
|
410
|
+
return { "Content-Type": "application/json", ...resolved };
|
|
411
|
+
}
|
|
412
|
+
function createAbortSignal(timeoutMs) {
|
|
413
|
+
if (timeoutMs <= 0) return void 0;
|
|
414
|
+
const timeout = AbortSignal;
|
|
415
|
+
if (typeof timeout.timeout === "function") {
|
|
416
|
+
return timeout.timeout(timeoutMs);
|
|
417
|
+
}
|
|
418
|
+
const controller = new AbortController();
|
|
419
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
420
|
+
timer.unref?.();
|
|
421
|
+
return controller.signal;
|
|
422
|
+
}
|
|
423
|
+
function sleep(ms) {
|
|
424
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
425
|
+
}
|
|
426
|
+
function postStatement(url, statement, init) {
|
|
427
|
+
return fetch(url, {
|
|
428
|
+
method: "POST",
|
|
429
|
+
body: JSON.stringify(statement),
|
|
430
|
+
...init
|
|
431
|
+
}).then((res) => {
|
|
432
|
+
if (!res.ok) {
|
|
433
|
+
throw new Error(`xAPI fetch failed: ${res.status} ${res.statusText}`);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function createFetchTransport(opts) {
|
|
438
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
439
|
+
const retries = opts.retries ?? 2;
|
|
440
|
+
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
441
|
+
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
442
|
+
const transport = async (statement) => {
|
|
443
|
+
let attempt = 0;
|
|
444
|
+
let backoff = initialBackoffMs;
|
|
445
|
+
for (; ; ) {
|
|
446
|
+
try {
|
|
447
|
+
await postStatement(opts.url, statement, {
|
|
448
|
+
...opts.init,
|
|
449
|
+
headers: resolveHeaders(opts.headers),
|
|
450
|
+
signal: createAbortSignal(timeoutMs)
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (attempt >= retries) throw err;
|
|
455
|
+
await sleep(backoff);
|
|
456
|
+
backoff = Math.min(backoff * 2, maxBackoffMs);
|
|
457
|
+
attempt += 1;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
const exitTransport = (statement) => {
|
|
462
|
+
try {
|
|
463
|
+
void postStatement(opts.url, statement, {
|
|
464
|
+
...opts.init,
|
|
465
|
+
headers: resolveHeaders(opts.headers),
|
|
466
|
+
keepalive: true
|
|
467
|
+
}).catch(() => void 0);
|
|
468
|
+
} catch {
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
return { transport, exitTransport };
|
|
472
|
+
}
|
|
473
|
+
function createFetchBatchSink(opts) {
|
|
474
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
475
|
+
const retries = opts.retries ?? 2;
|
|
476
|
+
const initialBackoffMs = opts.backoffMs ?? 250;
|
|
477
|
+
const maxBackoffMs = opts.maxBackoffMs ?? 5e3;
|
|
478
|
+
const postBatch = async (events, init) => {
|
|
479
|
+
let attempt = 0;
|
|
480
|
+
let backoff = initialBackoffMs;
|
|
481
|
+
for (; ; ) {
|
|
482
|
+
try {
|
|
483
|
+
const res = await fetch(opts.url, {
|
|
484
|
+
method: "POST",
|
|
485
|
+
body: JSON.stringify(events),
|
|
486
|
+
...init,
|
|
487
|
+
headers: resolveHeaders(opts.headers),
|
|
488
|
+
signal: createAbortSignal(timeoutMs)
|
|
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;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
return {
|
|
503
|
+
batchSink: (events) => postBatch(events, opts.init ?? {}),
|
|
504
|
+
exitBatchSink: (events) => {
|
|
505
|
+
try {
|
|
506
|
+
void fetch(opts.url, {
|
|
507
|
+
method: "POST",
|
|
508
|
+
body: JSON.stringify(events),
|
|
509
|
+
...opts.init,
|
|
510
|
+
headers: resolveHeaders(opts.headers),
|
|
511
|
+
keepalive: true
|
|
512
|
+
}).catch(() => void 0);
|
|
513
|
+
} catch {
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
358
518
|
export {
|
|
519
|
+
createFetchBatchSink,
|
|
520
|
+
createFetchTransport,
|
|
359
521
|
createInMemoryXAPIQueue,
|
|
360
522
|
createXAPIClient,
|
|
361
523
|
telemetryEventToXAPIStatement
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/xapi",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
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.3.
|
|
51
|
+
"@lessonkit/core": "1.3.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "^8.5.0",
|