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

ussd-interpreter-json

v0.1.1

Published

A JSON-driven USSD interpreter with async-correct replay, fingerprint-based API deduplication, and a pluggable store

Readme

ussd-interpreter-json

npm version CI license node

A JSON-driven USSD interpreter with async-correct replay, fingerprint-based API deduplication, and a pluggable store. Define your flow in JSON. Wire your SDKs in a function registry. The library handles the rest.

npm install ussd-interpreter-json

Requires Node.js >= 20.

Quick start

import { Flow, Interpreter, Registry, MemoryStore } from 'ussd-interpreter-json'

// 1. Register your async behaviour
const registry = new Registry()

// Registry functions own SDK clients and API keys. The interpreter never
// makes network calls — it calls registry.call(name, ctx) and awaits the result.
registry.register('lookupCustomer', async (ctx) => {
  const customer = await bankSdk.getCustomer(ctx.session.accountNumber)
  return { customerName: customer.name, customerType: customer.type }
})

registry.register('computeFee', (ctx) => {
  return { fee: Math.min(ctx.session.amount * 0.01, 100) }
})

registry.register('fetchBalance', async (ctx) => {
  const balance = await bankSdk.getBalance(ctx.gateway.phoneNumber)
  return { balance }
})

registry.register('transferFunds', async (ctx) => {
  const res = await mpesa.stkPush({
    phone: ctx.gateway.phoneNumber,
    amount: ctx.session.amount,
    reference: ctx.session.accountNumber
  })
  return { ref: res.checkoutRequestId }
})

// 2. Define your flow in JSON
const flow = Flow.fromJSON({
  states: {
    main: {
      type: 'navigation',
      render: 'Welcome\n1. Transfer\n2. Balance',
      transitions: { 1: 'enter_account', 2: 'show_balance' }
    },
    enter_account: {
      type: 'data',
      render: 'Enter account number:',
      capture: 'accountNumber',
      validate: { pattern: '^\\d{10}$', message: 'Must be 10 digits.' },
      next: 'enter_amount',
      onArrive: {
        id: 'lookup',
        fn: 'lookupCustomer',
        dependencies: ['accountNumber'],
        timeout: 3000
      }
    },
    enter_amount: {
      type: 'data',
      render: 'Enter amount (KES):',
      capture: 'amount',
      validate: { type: 'number', min: 1, max: 150000, message: '1 - 150,000.' },
      transform: 'toNumber',
      next: 'confirm',
      onArrive: {
        id: 'calc_fee',
        fn: 'computeFee',
        dependencies: ['amount', 'customerType'],
        timeout: 1000
      }
    },
    confirm: {
      type: 'navigation',
      render: 'Send KES {{amount}} to {{accountNumber}}?\nFee: KES {{fee}}\n1. Confirm\n2. Cancel',
      transitions: { 1: 'success', 2: 'cancelled' },
      label: 'Reviewed transfer.'
    },
    success: {
      type: 'terminal',
      render: 'Sent. Ref: {{ref}}',
      onArrive: {
        id: 'execute',
        fn: 'transferFunds',
        sideEffect: true,
        dedupSideEffect: true,
        timeout: 10000
      }
    },
    show_balance: {
      type: 'terminal',
      render: 'Balance: KES {{balance ?? "--"}}',
      onArrive: {
        id: 'fetch_bal',
        fn: 'fetchBalance',
        dependencies: ['gateway.phoneNumber'],
        timeout: 3000
      }
    },
    cancelled: {
      type: 'terminal',
      render: 'Cancelled.'
    }
  }
}, { registry })

// 3. Create the interpreter
const interpreter = new Interpreter(flow, {
  store: new MemoryStore({ ttl: 120 }),
  key: (gateway) => gateway.phoneNumber
})

// 4. Handle a request
// The handler is three lines. Resumption, consent, and dedup are automatic.
async function handleRequest (req) {
  const session = await interpreter.resolveAsync(req.body.text, req.body)
  return respond(session)
}

State types

| Type | What it does | |------|-------------| | navigation | Renders a menu. Maps input tokens to next states via transitions. Supports backToken (default 0) and rootToken (default 00). | | data | Captures free-form input into a named variable. Validates and transforms before proceeding to next. "0" is raw data — never back. | | terminal | Ends the session. All further input is rejected. onArrive on a terminal defaults to sideEffect: true. | | branch | Invisible decision node. Calls a registered function that returns a state ID. Auto-advances — the user never sees it. | | pagination | Splits {{content}} across multiple screens. Handles next, back, accept, and cancel tokens. On the last page with no accept configured, the session ends. |

Branch — root routing

registry.register('routeRoot', async (ctx) => {
  const meta = await ctx.store.getPhoneMeta(ctx.gateway.phoneNumber)
  if (!meta.registered) return 'unregistered_menu'
  return 'registered_menu'
})

const flow = Flow.fromJSON({
  states: {
    _root: { type: 'branch', fn: 'routeRoot', dependencies: ['gateway.phoneNumber'] },
    unregistered_menu: { type: 'navigation', render: 'Welcome\n1. Sign Up', transitions: { 1: 'signup' } },
    registered_menu: { type: 'navigation', render: 'Welcome back\n1. Transfer', transitions: { 1: 'transfer' } }
  }
}, { registry })

Template syntax

All render, content, and label fields support interpolation:

| Syntax | Example | Output with { name: "Ann", bal: 0 } | |--------|---------|--------------------------------------| | {{key}} | Hello {{name}} | Hello Ann | | {{nested.key}} | {{user.name}} | value at path | | {{key ?? "fallback"}} | Bal: {{bal ?? "--"}} | Bal: -- |

Unclosed {{ tokens are left as literal text. Null intermediates don't throw.

Validation

Data states validate captured input before proceeding.

Regex:

{ "pattern": "^\\d{10}$", "message": "Must be 10 digits." }

Number range:

{ "type": "number", "min": 1, "max": 150000, "message": "1 - 150,000." }

Function-based:

{ "fn": "verifyPin", "message": "Invalid PIN." }

The registered function receives the raw token and returns true for valid, or a string for the error message.

Composite (all must pass, cheap checks first):

{
  "all": [
    { "pattern": "^\\d+$", "message": "Digits only." },
    { "fn": "verifyCode", "message": "Code used." }
  ]
}

Transforms convert captured values before storing: "toNumber", "trim".

onArrive hooks

{
  "onArrive": {
    "id": "lookup",
    "fn": "lookupCustomer",
    "dependencies": ["accountNumber"],
    "timeout": 3000
  }
}

| Field | Purpose | |-------|---------| | fn | Registered function name (required) | | id | Unique ID for fingerprint tracking (defaults to fn) | | dependencies | Keys that trigger re-execution when their values change | | timeout | Milliseconds before the call is aborted | | sideEffect | Set to true for operations that change external state | | dedupSideEffect | With sideEffect: true, stores the result and skips on replay | | onFail | "continue" — silence the error and proceed. Default: throw. |

Data state hooks fire after capture — the captured value is available as a dependency. Navigation and terminal hooks fire when the state is entered.

Side-effect deduplication

When sideEffect: true + dedupSideEffect: true, the result is stored in the session. On replay, the same state with the same dependencies skips execution — the stored result is used. The user pays once regardless of how many requests they take.

registry.register('transferFunds', async (ctx) => {
  const res = await mpesa.stkPush({ ... })
  return { ref: res.checkoutRequestId }
})

Session stories — buildStory()

const session = await interpreter.resolveAsync('1*0724000001*500*1', gateway)
const story = session.buildStory({ redact: ['accountNumber'] })

// {
//   outcome: 'completed',
//   exitState: 'success',
//   path: ['main', 'enter_account', 'enter_amount', 'confirm', 'success'],
//   steps: [
//     { state: 'enter_account', label: 'Entered account [REDACTED].' },
//     ...
//   ],
//   narrative: '... Entered account [REDACTED]. ... Transfer completed. Ref: TXN001.'
// }

States without a label appear in path but not in steps or narrative. Branch states are always invisible. redact: true on a data state auto-redacts its captured key.

enrich — external data injection

Background jobs can update session data between requests. Useful for KYC approval, profile updates, or any out-of-band state change.

const result = await interpreter.enrich(phoneNumber, {
  kycVerified: true,
  tier: 'premium'
})
// → { sessionId, data: { ..., kycVerified: true } } or null if no active session

No fingerprint invalidation needed — the next replay detects changed dependency values and re-runs affected onArrive hooks automatically.

Configuration

const interpreter = new Interpreter(flow, {
  store: new MemoryStore({ ttl: 120 }),
  key: (gateway) => gateway.phoneNumber,
  separator: '*',            // default
  maxTokens: 20,             // guard against overlong input
  maxRetries: 3,             // validation retries before kill
  consumeBudget: 200,        // max consume() calls per replay
  onArriveTimeout: 5000,     // per-hook timeout (ms)

  resumption: {
    enabled: true,           // track sessions across dials
    consent: true,           // prompt before resuming
    resumeToken: '1',        // user picks resume
    discardToken: '2',       // user picks start fresh
    render: 'You have an active session.\n1. Resume\n2. Start fresh'
  },

  // Lifecycle events (fire-and-forget — errors are caught)
  onDuplicate: (existingSessionId, gateway) => { ... },
  onResume: (sessionId, lastInput) => { ... },
  onSessionEnd: (sessionId, story) => { ... }
})

Custom store adapter

Swap MemoryStore for any storage backend. Implement these methods:

class RedisStore {
  async getSession (sessionId) { ... }            // → entry or undefined
  async setSession (sessionId, entry) { ... }
  async getPhoneSessions (phoneKey) { ... }       // → Set of sessionIds
  async addPhoneSession (phoneKey, sessionId) { ... }
  async removePhoneSession (phoneKey, sessionId) { ... }
  async getPhoneMeta (phoneKey) { ... }           // → object (cross-session)
  async setPhoneMeta (phoneKey, meta) { ... }
}

MemoryStore is built in. Everything else is your adapter.

License

MIT