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-state-builder

v2.6.0

Published

A complete state machine for building USSD applications with back navigation, lifecycle hooks, middleware, validation, i18n, testing utilities, health checks, and multiple storage adapters

Readme

USSD State Builder

npm version License: MIT

A modern, fluent SDK for building USSD applications in Node.js. Write declarative, readable code with automatic CON/END handling, built-in validation, and production-ready features like Redis storage, circuit breakers, and i18n support.

Installation

npm install ussd-state-builder

Quick Start

const { createApp, Validators } = require('ussd-state-builder/sdk');

const app = createApp()
  .state('welcome', s => s
    .message('Welcome to MyBank\n1. Check Balance\n2. Send Money\n3. Exit')
    .on('1').goto('balance')
    .on('2').goto('sendMoney')
    .on('3').end('Goodbye!')
  )
  .state('balance', s => s
    .run(async () => 'Your balance is KES 15,000.00')
    .end()
  )
  .state('sendMoney', s => s
    .message('Enter recipient phone number:')
    .validate(Validators.phone({ country: 'KE' }))
    .save('phone')
    .next('enterAmount')
  )
  .state('enterAmount', s => s
    .message('Enter amount (KES):')
    .validate(Validators.amount({ min: 10, max: 70000 }))
    .save('amount')
    .next('confirm')
  )
  .state('confirm', s => s
    .run(async (input, sid, ctx) => {
      const { phone, amount } = ctx.sessionData;
      return `Send KES ${amount} to ${phone}?\n1. Confirm\n2. Cancel`;
    })
    .on('1').end('Transaction successful!')
    .on('2').end('Transaction cancelled.')
  )
  .start('welcome')
  .build();

// Express.js integration
app.post('/ussd', async (req, res) => {
  const { sessionId, text } = req.body;
  const response = await app.processInput(sessionId, text);
  res.send(response);
});

That's it. No manual CON/END prefixes, no boilerplate state configuration objects. Just clean, declarative code.

Table of Contents


SDK API Reference

State Methods

| Method | Description | Example | |--------|-------------|---------| | .message(text) | Display static text | .message('Enter phone:') | | .menu(title, items) | Create numbered menu | See Menu Builder | | .run(handler) | Custom async handler | .run(async (input, sid, ctx) => ...) | | .validate(fn) | Validate input before processing | .validate(Validators.phone()) | | .save(key) | Save input to session data | .save('phoneNumber') | | .next(state) | Default transition after input | .next('confirmScreen') | | .end() | Mark as terminal state (END prefix) | .end() | | .onEnter(fn) | Lifecycle hook on state entry | .onEnter(async (sid) => log(sid)) | | .onExit(fn) | Lifecycle hook on state exit | .onExit(async (sid) => cleanup(sid)) | | .dynamicMenu(fetcher, opts) | Fetch menu items at runtime | See Dynamic Menus |

Menu Builder

Create numbered menus with automatic routing:

.state('main', s => s
  .menu('Welcome to MyBank', [
    { key: '1', label: 'Check Balance', goto: 'balance' },
    { key: '2', label: 'Send Money', goto: 'sendMoney' },
    { key: '3', label: 'Buy Airtime', goto: 'airtime' },
    { key: '4', label: 'Exit', end: 'Goodbye!' }
  ])
)
// Output: "CON Welcome to MyBank\n1. Check Balance\n2. Send Money\n3. Buy Airtime\n4. Exit"

Menu item actions:

  • goto: 'state' - Navigate to state
  • end: 'message' - End session with message
  • reply: 'text' - Reply without transitioning

Routing

Use .on(input) to define input-based routing:

.state('menu', s => s
  .message('Select option:\n1. Balance\n2. Transfer\n3. Exit')
  .on('1').goto('balance')      // Navigate to another state
  .on('2').goto('transfer')
  .on('3').end('Goodbye!')      // End session with message
  .on('*').reply('Invalid option')  // Catch-all for unmatched input
)

Route actions:

  • .goto(state) - Transition to another state
  • .end(message?) - End session with optional message
  • .reply(text) - Reply without changing state (stay in same state)

Wildcard route: Use .on('*') as a catch-all for any unmatched input.

App Configuration

const app = createApp()
  .state('name', configurator)     // Define states
  .form('name', configurator)      // Define multi-step forms
  .start('initialState')           // Set starting state (defaults to first)
  .storage(redisStorage)           // Set storage adapter
  .timeout(300)                    // Session timeout in seconds
  .backNavigation(true)            // Enable '0' for back navigation
  .use('beforeProcess', fn)        // Add middleware
  .hooks({ onError: fn })          // Lifecycle hooks
  .logger(customLogger)            // Custom logger (null to disable)
  .maxInputLength(160)             // Max input length
  // Built-in middleware helpers:
  .logging({ logger: console.log })
  .rateLimit({ maxRequests: 10, windowMs: 60000 })
  .sanitize({ maxLength: 160, trim: true })
  .metrics()
  .build();                        // Compile to USSDStateMachine

Form Builder

Create multi-step data collection flows with minimal boilerplate:

const app = createApp()
  .form('sendMoney', f => f
    .field('phone', field => field
      .prompt('Enter recipient phone:')
      .validate(Validators.phone({ country: 'KE' }))
    )
    .field('amount', field => field
      .prompt('Enter amount (KES):')
      .validate(Validators.amount({ min: 10, max: 70000 }))
    )
    .confirm(ctx =>
      `Send KES ${ctx.sessionData.amount} to ${ctx.sessionData.phone}?\n1. Yes\n2. No`
    )
    .onConfirm('1').end('Transaction sent!')
    .onCancel('2').end('Cancelled.')
  )
  .start('sendMoney_phone')  // Form states are named: {formName}_{fieldName}
  .build();

Field methods:

  • .prompt(text) - Display prompt for field
  • .validate(fn) - Validate input
  • .transform(fn) - Transform value before saving

Form methods:

  • .field(name, configurator) - Add a field
  • .confirm(renderer) - Add confirmation step
  • .onComplete(state) - State to go to after form (without confirm)

Dynamic Menus

Fetch and display data from external sources with automatic pagination:

const { createApp, DynamicMenu } = require('ussd-state-builder/sdk');

const app = createApp()
  .state('selectAccount', s => s
    .run(
      DynamicMenu.from(async (sid, ctx) => {
        // Fetch from your API
        return await api.getAccounts(ctx.sessionData.userId);
      })
        .format(item => `${item.name} - ${item.currency} ${item.balance}`)
        .value(item => item.id)
        .header('Select account:')
        .paginated({ pageSize: 3, moreKey: '#', backKey: '*' })
        .onEmpty('No accounts found')
        .onError(err => `Error: ${err.message}`)
        .build()
    )
    .save('accountId')
    .next('accountDetails')
  )
  .build();

Or use the convenience method:

.state('selectAccount', s => s
  .dynamicMenu(
    async () => api.getAccounts('user123'),
    {
      format: item => item.name,
      value: item => item.id,
      header: 'Select account:',
      pageSize: 4,
      refresh: { key: '*', label: 'Refresh' }
    }
  )
  .save('accountId')
  .next('details')
)

Dynamic Menu Options

| Option | Description | Default | |--------|-------------|---------| | format | Format item for display | String(item) | | value | Extract value from selection | item | | header | Text above menu items | '' | | pageSize | Items per page | All items | | moreKey | Key for next page | '99' | | backKey | Key for previous page | '98' | | maxItems | Limit stored items (memory) | Unlimited | | refresh | { key, label } for refresh | Disabled | | onEmpty | Message when no items | 'No items available' | | onError | Error handler function | Default message |


Validation

20+ built-in validators with composable chains:

const { Validators, combineValidators, optional } = require('ussd-state-builder');

// Single validators
.state('enterPhone', s => s
  .message('Enter phone number:')
  .validate(Validators.phone({ country: 'KE' }))
  .save('phone')
  .next('enterPin')
)

.state('enterPin', s => s
  .message('Enter 4-digit PIN:')
  .validate(Validators.pin({ length: 4 }))
  .save('pin')
  .next('confirm')
)

// Combine multiple validators
const validateAmount = combineValidators([
  Validators.required(),
  Validators.numeric({ min: 10, max: 70000 }),
  Validators.amount({ maxDecimals: 2 })
]);

// Optional fields (skip validation on empty input)
const validateEmail = optional(Validators.email());

Available Validators

| Validator | Description | |-----------|-------------| | required() | Non-empty input | | numeric(opts) | Number with optional min/max/integer | | phone(opts) | Phone number (KE, UG, TZ, NG, GH, ZA, ET, RW) | | email() | Email address format | | pin(opts) | PIN code (length, numericOnly) | | amount(opts) | Monetary amount with decimal control | | menuOption(opts) | Menu selection within range | | minLength(n) / maxLength(n) / exactLength(n) | String length | | pattern(regex) | Custom regex pattern | | date(opts) | Date format validation | | age(opts) | Age range validation | | idNumber(opts) | ID/passport number (KE, generic) | | password(opts) | Password strength requirements | | alphanumeric() | Letters and numbers only | | url() | HTTP/HTTPS URL format | | ipAddress() | IPv4 address | | oneOf(values) | Whitelist validation | | custom(fn) | Custom validation function |


Storage Adapters

InMemoryStorage (Default)

Best for development and testing:

const { InMemoryStorage } = require('ussd-state-builder');

const app = createApp()
  .storage(new InMemoryStorage())
  .build();

RedisStorage (Production)

Recommended for production with automatic expiration:

const { RedisStorage } = require('ussd-state-builder');

const app = createApp()
  .storage(new RedisStorage({
    url: 'redis://localhost:6379',
    keyPrefix: 'ussd:',
    maxHistorySize: 20
  }))
  .timeout(300)
  .build();

// Graceful shutdown
process.on('SIGTERM', async () => {
  await app.storage.close();
});

MongoDB Storage

const { MongoDBStorage } = require('ussd-state-builder');

const storage = new MongoDBStorage({
  uri: 'mongodb://localhost:27017',
  database: 'ussd_app'
});

PostgreSQL Storage

const { PostgreSQLStorage } = require('ussd-state-builder');

const storage = new PostgreSQLStorage({
  host: 'localhost',
  database: 'ussd_app',
  user: 'user',
  password: 'password'
});

Custom Storage

Extend StorageInterface for custom adapters:

const { StorageInterface } = require('ussd-state-builder');

class MyStorage extends StorageInterface {
  async getState(sessionId) { /* return state string */ }
  async setState(sessionId, state, timeout) { /* store state */ }
  async getData(sessionId) { /* return data object */ }
  async setData(sessionId, data, timeout) { /* store data */ }
  // Optional: getStateHistory, pushStateHistory, popStateHistory
  // Optional: getStateBatch, getDataBatch, deleteSessionBatch
  // Optional: withTransaction, cleanup, close
}

Integration Examples

Express.js

const express = require('express');
const { createApp, Validators } = require('ussd-state-builder/sdk');

const ussd = createApp()
  .state('welcome', s => s.message('Welcome!\n1. Continue').on('1').goto('next'))
  .state('next', s => s.run(async () => 'You selected continue').end())
  .build();

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post('/ussd', async (req, res) => {
  const { sessionId, text, phoneNumber } = req.body;

  try {
    const response = await ussd.processInput(sessionId, text);
    res.send(response);
  } catch (error) {
    console.error('USSD Error:', error);
    res.send('END An error occurred. Please try again.');
  }
});

app.listen(3000);

Africa's Talking Integration

app.post('/ussd', async (req, res) => {
  const { sessionId, serviceCode, phoneNumber, text } = req.body;

  // Store phone number in session on first request
  if (!text) {
    await ussd.setSessionData(sessionId, { phoneNumber });
  }

  const response = await ussd.processInput(sessionId, text);
  res.set('Content-Type', 'text/plain');
  res.send(response);
});

With Authentication Context

const app = createApp()
  .state('welcome', s => s
    .run(async (input, sid, ctx) => {
      // Access session data set during request handling
      const phone = ctx.sessionData.phoneNumber;
      const user = await db.findUserByPhone(phone);

      if (user) {
        return `Welcome back, ${user.name}!\n1. Balance\n2. Transfer`;
      }
      return 'Welcome! Please register first.\n1. Register';
    })
    .on('1').goto('balance')
    .on('2').goto('transfer')
  )
  .build();

Advanced Features

Middleware

Add cross-cutting concerns like logging, rate limiting, and sanitization:

const {
  createLoggingMiddleware,
  createRateLimitMiddleware,
  createSanitizationMiddleware,
  createMetricsMiddleware
} = require('ussd-state-builder');

const app = createApp()
  .use('beforeProcess', createLoggingMiddleware({ logger: console }))
  .use('beforeProcess', createRateLimitMiddleware({ maxRequests: 10, windowMs: 60000 }))
  .use('beforeProcess', createSanitizationMiddleware({ removeSpecialChars: true }))
  .use('afterProcess', createMetricsMiddleware())
  .build();

Distributed Rate Limiting (for multi-instance deployments):

const { createDistributedRateLimitMiddleware } = require('ussd-state-builder');

const app = createApp()
  .use('beforeProcess', createDistributedRateLimitMiddleware({
    redisClient,
    maxRequests: 10,
    windowMs: 60000,
    keyPrefix: 'ussd:ratelimit:'
  }))
  .build();

Internationalization

Built-in support for 8 languages:

const { createUSSDI18n, LanguageDetector } = require('ussd-state-builder');

const i18n = createUSSDI18n({ defaultLanguage: 'en' });

const app = createApp()
  .state('welcome', s => s
    .run(async (input, sid, ctx) => {
      // Auto-detect language from phone number
      const lang = LanguageDetector.fromPhoneNumber(ctx.sessionData.phoneNumber);
      return i18n.t('common.welcome', { lang });
    })
    .next('menu')
  )
  .build();

Supported languages: English (en), Swahili (sw), French (fr), Amharic (am), Arabic (ar), Portuguese (pt), Hausa (ha), Somali (so)

Resilience Patterns

Circuit Breaker - Protect against cascading failures:

const { createProtectedStorage, RedisStorage } = require('ussd-state-builder');

const storage = createProtectedStorage(new RedisStorage({ url: 'redis://localhost' }), {
  failureThreshold: 5,
  resetTimeout: 30000,
  onStateChange: (from, to) => console.log(`Circuit: ${from} -> ${to}`)
});

const app = createApp()
  .storage(storage)
  .build();

Retry with Backoff:

const { createRetryStorage, RedisStorage } = require('ussd-state-builder');

const storage = createRetryStorage(new RedisStorage({ url: 'redis://localhost' }), {
  maxRetries: 3,
  baseDelay: 100,
  maxDelay: 5000,
  jitter: true
});

Combine resilience layers:

const { createRetryStorage, createProtectedStorage, createCachedStorage } = require('ussd-state-builder');

let storage = new RedisStorage(redisConfig);
storage = createRetryStorage(storage, { maxRetries: 3 });       // Retry transient errors
storage = createProtectedStorage(storage, { failureThreshold: 5 }); // Circuit breaker
storage = createCachedStorage(storage, { maxSize: 1000 });      // Cache for performance

Health Checks

Kubernetes-style health probes:

const { HealthCheck } = require('ussd-state-builder');

const health = new HealthCheck({
  storage,
  stateMachine: app,
  cacheTTL: 5000,
  startupChecks: {
    database: async () => { await db.ping(); }
  }
});

// Run startup checks before accepting traffic
await health.runStartupChecks();

// Kubernetes probes
expressApp.get('/healthz', async (req, res) => {
  const result = await health.liveness();
  res.status(200).json(result);
});

expressApp.get('/readyz', async (req, res) => {
  const result = await health.readiness();
  res.status(result.status === 'ready' ? 200 : 503).json(result);
});

Testing

Built machines have a .test() method that returns a fluent tester:

const app = createApp()
  .state('welcome', s => s.message('Welcome!\n1. Continue').on('1').goto('next'))
  .state('next', s => s.message('Done').end())
  .build();

// Test flows directly on the built app
await app.test()
  .start()
  .expectResponse(/Welcome/)
  .input('1')
  .expectResponse(/Done/)
  .run();

// Or use USSDTester directly
const { USSDTester } = require('ussd-state-builder');
const tester = new USSDTester(app);

State Inspection

Built machines also have an .inspect() method for debugging:

const inspector = app.inspect();

// Get state machine summary
console.log(inspector.getSummary());
// { totalStates: 2, initialState: 'welcome', ... }

// Validate configuration
const result = inspector.validate();
console.log(result.valid, result.errors, result.warnings);

// Generate ASCII diagram
console.log(inspector.toAsciiDiagram());

// Get specific state info
console.log(inspector.getStateInfo('welcome'));

Run tests:

npm test                 # Run all tests
npm run test:watch       # Watch mode
npm run test:coverage    # Coverage report

Traditional API

The SDK compiles to the same USSDStateMachine class, which you can use directly if preferred:

const { USSDStateMachine, ResponseBuilder } = require('ussd-state-builder');

const ussd = new USSDStateMachine({
  initialState: 'WELCOME',
  timeout: 300,
  states: {
    WELCOME: {
      handler: async (input) => ({
        response: ResponseBuilder.menu('Welcome', ['Balance', 'Transfer']),
        nextState: 'MENU'
      })
    },
    MENU: {
      handler: async (input, sessionId, context) => {
        if (input === '1') {
          return { response: 'END Your balance is KES 15,000', nextState: 'END' };
        }
        return { response: ResponseBuilder.error('Invalid option') };
      }
    }
  }
});

ResponseBuilder Utilities

const { ResponseBuilder } = require('ussd-state-builder');

ResponseBuilder.menu('Select Option', ['Balance', 'Transfer']);
// "CON Select Option\n1. Balance\n2. Transfer"

ResponseBuilder.confirm('Proceed with payment?');
// "CON Proceed with payment?\n1. Yes\n2. No"

ResponseBuilder.input('Enter phone number:');
// "CON Enter phone number:"

ResponseBuilder.error('Invalid input');
// "CON Invalid input\nPlease try again."

ResponseBuilder.end('Thank you!');
// "END Thank you!"

ResponseBuilder.paginate(items, page, pageSize, { title: 'Items' });
ResponseBuilder.withBack('CON Enter amount:');
ResponseBuilder.formatAmount(1234.5, 'KES ');  // "KES 1,234.50"

TypeScript Support

Full TypeScript definitions included:

import { createApp, Validators, DynamicMenu } from 'ussd-state-builder/sdk';
import type { StateContext, StateHandlerResult } from 'ussd-state-builder';

const app = createApp()
  .state('welcome', s => s
    .run(async (input: string, sid: string, ctx: StateContext): Promise<string> => {
      return 'Welcome!';
    })
    .next('menu')
  )
  .build();

Documentation

Examples

See the examples directory:

Contributing

Contributions are welcome! Please read our contributing guidelines and submit pull requests.

License

MIT © Anthony Kiplimo