npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tsn-node-kit

v0.1.31

Published

Identity capture, account provisioning, and xAPI relay for TSN activity nodes.

Readme

tsn-node-kit

Identity capture, account provisioning, and xAPI relay for TSN activity nodes.

Flow is always: Batcher → Catcher → Relay. Never skip steps, never reorder.

Install

npm install tsn-node-kit

Requires socket.io-client >= 4 as a peer dependency for XApiRelay.


Batcher — CodeBatcher

Accepts account information from the LMS and provisions it for the Catcher. Two intake modes:

LMS push (open event or singular login — same shape, count varies):

import { CodeBatcher, type LmsPayload } from 'tsn-node-kit';

const batcher = new CodeBatcher({ nodeId: 'kiosk-01', queuePath: '/data/claims.json', moodle: null });

// LMS sends accounts + whitelist together
const whitelist = batcher.receiveLmsPayload({
  accounts:  [{ code: 'A1B2', eventId: '101', eventName: 'Demo Day' }],
  whitelist: ['https://tsn.teamsteamnation.org/activities/station-complete'],
});
relay.setWhitelist(whitelist); // pass whitelist to relay immediately

const account = batcher.next(); // dispense next account from pool
if (account) catcher.captureAccount(account, whitelist); // direct pass — still goes through catcher

Moodle pull — returns accounts + whitelist together, auto-populates the pool:

batcher.setMoodleConfig({ endpoint, apiKey, eventCode, eventName, courseId: 104721 });

const { accounts, whitelist } = await batcher.getAccounts(50, { name: 'Dallas Camp June 2026' });
// whitelist contains all activity IDs the LMS says are valid for this batch
// multiple items supported — one per allowed activity
relay.setWhitelist(whitelist);

updatePlaceholder(code, data) — attach pre-reg data (name, email, etc.) to a placeholder before the user scans. Does not claim the code or generate a tsn_id.

claim(code, data) — fire-and-forget association (email, etc.). Queues to disk on failure, retried via sync().

markUsed(code) — called by the relay when a code is picked up. Removes from pool and notifies LMS (action=code_used). Wire it: relay.on('code:used', code => batcher.markUsed(code)).

markUsed(code) — called by the relay when a code is picked up by a new user. Removes the code from the pool and notifies the LMS (action=code_used). Wire it: relay.on('code:used', code => batcher.markUsed(code)).


Catcher — CodeCaptureService

Stateless passthrough. Signals user change to the relay — when a new identity arrives, the relay flushes any queued statements for the previous user before starting the new session. The caller holds the whitelist between provisioning and capture, and passes it in at call time.

import { CodeCaptureService, type IdentifiedPayload } from 'tsn-node-kit';

const catcher = new CodeCaptureService();

// Wire to relay — new identity triggers flush of previous user's queue, then switches context
catcher.on('user:identified', (payload: IdentifiedPayload) => relay.receive(payload));
catcher.on('error', (err: Error) => console.error(err.message));

// Code from user (QR scan or manual entry) — JSON string + whitelist from batcher
catcher.capture('{"code":"A1B2","eventId":"101","eventName":"Demo Day"}', whitelist);

// Code from Batcher (single instance login — no user input needed)
catcher.captureAccount({ code: 'A1B2', eventId: '101', eventName: 'Demo Day' }, whitelist);

No stored state — whitelist and account are passed in at call time, emitted, gone.


Relay — XApiRelay

Holds actor (from Catcher) and whitelist (from Batcher). Accepts activity posts, validates whitelist, builds and validates xAPI statements, then posts to LRS or forwards upstream.

import { XApiRelay, validateStatement } from 'tsn-node-kit';
import { io } from 'socket.io-client';

const socket = io(process.env.UPSTREAM_URL!);
const relay  = new XApiRelay(socket, {
  nodeId:     process.env.NODE_ID!,
  whitelist:  [],
  homepage:   'https://tsn.teamsteamnation.org',
  endOfChain: true,   // false = forward via socket; true = post directly to LRS
  lrs: {
    endpoint: process.env.LRS_ENDPOINT!,
    key:      process.env.LRS_KEY!,
    secret:   process.env.LRS_SECRET!,
  },
});

// Wire catcher → relay (socket mode)
catcher.on('user:identified', (payload) => relay.receive(payload));

// Wire relay → batcher (code tracking + LMS notification)
relay.on('code:used', (code: string) => batcher.markUsed(code));

// Activity posts verb + activity ID — relay checks whitelist, builds + validates statement
relay.postActivity(
  { id: 'http://adlnet.gov/expapi/verbs/completed', display: { 'en-US': 'completed' } },
  { id: 'https://tsn.teamsteamnation.org/activities/station-complete' },
);

endOfChain: true — relay posts directly to LRS using lrs config. Enabled immediately on construction — no upstream toggle needed.
endOfChain: false — relay forwards via socket; waits for xapi:lrs:set event from upstream before posting.
If LRS is unreachable or socket is disconnected, statements queue in memory and flush when connection restores.

Socket server mode — call relay.serveSocket(port) to accept socket.io connections from downstream nodes (browsers, kiosks). On connect the server emits xapi:lrs:set to enable the client relay, then receives xapi:statement events and posts to LRS:

// relay-server.js — run alongside your frontend
const relay = new XApiRelay(null, {
  nodeId: 'garage-relay', whitelist: [], endOfChain: true,
  lrs: { endpoint: process.env.LRS_ENDPOINT!, key: process.env.LRS_KEY!, secret: process.env.LRS_SECRET! },
});
relay.serveSocket(4300);
// ws://localhost:4300 — connect VITE_XAPI_SOCKET_URL here

HTTP server mode — call relay.serve(port) to accept plain HTTP POST instead of socket:

// Relay server (Node process — runs on-site or in cloud)
const relay = new XApiRelay(null, {
  nodeId: 'garage-relay', whitelist: [], homepage: 'https://tsn.teamsteamnation.org',
  endOfChain: true, lrs: { endpoint: LRS_ENDPOINT, key: LRS_KEY, secret: LRS_SECRET },
});
relay.serve(3100);
// POST /identify  { account, whitelist }  — from catcher
// POST /activity  { verb, object }        — from any activity node
// GET  /health                            — { actor, queued }

Activity nodes (browser or otherwise) just fetch:

fetch('http://localhost:3100/activity', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ verb: VERB_COMPLETED, object: { id: activityId } }),
});

relay.receive() is a no-op if the same user ID arrives again. A different user triggers a flush of the previous user's queued statements before switching context. The enrollment whitelist in the IdentifiedPayload (course IRIs from the LMS) is carried for reference — it does not update the relay's activity filter. Use setWhitelist() or constructor config to change the filter.

relay.clearActor() — flush the current user's queue and clear actor context. Call when a user explicitly logs out.

Whitelist matching is exactobject.id must equal a whitelist entry exactly. Activity IRIs and course enrollment IRIs are different namespaces; configure the relay's whitelist with activity-level IRIs only.

Statements that fail validation are dropped with a console error — they never reach the LRS.


Statement validation — validateStatement

Also exported standalone for use by reporting tools or upstream nodes:

import { validateStatement, XApiValidationError } from 'tsn-node-kit';

try {
  validateStatement(stmt);
} catch (err) {
  if (err instanceof XApiValidationError) { /* invalid */ }
}

Validates: UUID id, Agent actor with account (homePage + name), verb id, object id, Activity objectType, timestamp.



On-site provisioning UI — batcher.serve()

Spins up a mobile-friendly web page staffers can hit from any phone or browser on the same network. No install required.

const batcher = new CodeBatcher({
  nodeId:    'kiosk-01',
  queuePath: '/data/claims.json',
  moodle: {
    endpoint:  process.env.MOODLE_ENDPOINT!,
    apiKey:    process.env.MOODLE_API_KEY!,
    eventCode: 'CAMP2026',
    eventName: 'Dallas Camp June 2026',
  },
});

batcher.serve(3000);
// [CodeBatcher] provisioning UI → http://localhost:3000
// [CodeBatcher] on your network   → http://192.168.1.42:3000

Staffers open the URL, pick a course from the dropdown, enter a count, and tap Provision Codes. Each generated code appears as a large card with a copy button.

An optional onProvisioned callback fires after each successful provision — use it to auto-pipe single-instance accounts through the catcher. Pass courseUrl in the options object to show an Open Course button in the UI after single-instance provisioning:

batcher.serve(
  3000,
  payload => {
    if (payload.accounts.length === 1) {
      const account = batcher.next();
      if (account) catcher.captureAccount(account, payload.whitelist);
    }
  },
  { courseUrl: process.env.GARAGE_URL ?? 'http://localhost:5173' },
);

When exactly 1 code is provisioned and courseUrl is set, the UI shows a green Open Course button. Tapping it navigates to the activity node in the same tab.

Throws if no Moodle config is set. Auth is not yet implemented.


E2E test

e2e/run.ts exercises the full Batcher → Catcher → Relay chain against a live Moodle + LRS.

# install tsx if needed
npm install -g tsx

# set credentials
export MOODLE_ENDPOINT=https://<host>/local/tsn_extauth/api.php
export MOODLE_API_KEY=<key>
export MOODLE_EVENT_CODE=<event_code>
export MOODLE_EVENT_NAME="<event_name>"
export MOODLE_COURSE_ID=<id>
export LRS_ENDPOINT=https://<host>/xapi
export LRS_KEY=<key>
export LRS_SECRET=<secret>
export BATCH_SIZE=3   # optional, default 3

npx tsx e2e/run.ts

The script provisions BATCH_SIZE codes from Moodle, wires the returned whitelist to the relay, and posts a completed statement for each code to the LRS via endOfChain: true.


Moodle API

Endpoint: POST https://<host>/local/tsn_extauth/api.php — Auth: X-Api-Key: <key>

| Action | Params | Response | |---|---|---| | generate | action, role=student, count, event_code, event_name, course_id? | { ok, accounts: [{code, event_id, event_name}], whitelist: [activityId, ...] } | | update_placeholder | JSON body { code, data } | { ok } | | code_used | JSON body { code } | { ok } | | claim_code | JSON body { code, data } | { ok } | | get_courses | action | { ok, courses: [{id, fullname, shortname, category}] } | | get_events | action | { ok, events: [{event_code, event_name}] } |