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

@nakshatra6350/api-diff

v0.4.0

Published

Runtime API contract drift detector for frontend apps

Readme

@nakshatra6350/api-diff

npm version npm downloads license CI

Catch API contract drift before your users do.

The problem

Your backend changes a response shape. Your frontend breaks silently. TypeScript types go stale. Nobody notices until production — when users start seeing blank screens, broken tables, or silent failures.

api-diff intercepts every fetch call at runtime and compares the actual response against a schema you define once. When the shape drifts, it tells you immediately — in dev, in tests, or silently in production while you collect the data.


Install

npm install @nakshatra6350/api-diff

Quick start

import { init, defineSchema } from '@nakshatra6350/api-diff';

// Step 1 — define what you expect from each endpoint
defineSchema('/api/users', {
  id:    { type: 'string' },
  name:  { type: 'string' },
  email: { type: 'string' },
  role:  { type: 'string', required: false },
});

// Step 2 — start intercepting all fetch calls
init('warn');

// That's it. Every fetch to /api/users is now contract-checked.

When your API returns a shape that doesn't match, you'll see this immediately:

[api-diff] Contract drift on /api/users (142ms):
  • email: expected string, got missing
  • id: expected string, got number

Modes

| Mode | Behaviour | Best used in | |------|-----------|--------------| | warn | console.warn on every drift | Development | | throw | Throws an Error on first drift | Tests / CI | | silent | No output — use with onDrift | Production |

init('warn');    // development
init('throw');   // tests
init('silent');  // production

Wildcard URL matching

Define one schema that covers all dynamic route segments — no need to register every individual ID.

// matches /api/users/123, /api/users/abc, /api/users/any-id
defineSchema('/api/users/:id', {
  id:    { type: 'string' },
  name:  { type: 'string' },
  email: { type: 'string' },
});

// * also works as a wildcard segment
defineSchema('/api/users/*', {
  id:   { type: 'string' },
  name: { type: 'string' },
});

// deeply nested dynamic routes
defineSchema('/api/orgs/:orgId/members/:memberId', {
  id:   { type: 'string' },
  role: { type: 'string' },
});

Query strings are automatically stripped before matching — /api/users/123?include=posts matches /api/users/:id cleanly.


Strict mode

By default, api-diff only checks fields you defined in the schema. Strict mode also flags fields the backend added that weren't in your schema — catching API changes in both directions.

init({ mode: 'warn', strict: true });

If your backend silently adds a new field:

[api-diff] Contract drift on /api/users (98ms):
  • newInternalField: expected not in schema, got string

Ignore list

Skip specific fields from strict mode checks — useful for timestamps, Mongo internals, or any field you don't want to validate.

Ignore everywhere (bare field name)

init({
  mode: 'warn',
  strict: true,
  ignore: ['__v', 'updatedAt', 'internalMeta'],
});

__v is ignored whether it appears at root, inside a nested object, or inside every array item.

Ignore only at root level

init({
  mode: 'warn',
  strict: true,
  ignore: ['root.createdOnDate'],
});

createdOnDate is ignored at the top level of the response but still flagged if it appears inside nested objects or array items.

Ignore only inside array items

init({
  mode: 'warn',
  strict: true,
  ignore: ['users[*].createdOnDate'],
});

createdOnDate is ignored inside every item of the users array but still flagged if it appears at root or in other nested objects.

Ignore at a specific nested path

init({
  mode: 'warn',
  strict: true,
  ignore: ['meta.debug'],
});

Only ignores debug when it appears inside the meta object.

Combining patterns

init({
  mode: 'warn',
  strict: true,
  ignore: [
    '__v',                       // everywhere
    'root.createdOnDate',        // root only
    'users[*].createdOnDate',    // inside users array items only
    'meta.debug',                // inside meta object only
  ],
});

onDrift callback

React to drift in your own way — send it to Sentry, analytics, a Slack webhook, or your own backend.

init({
  mode: 'warn',
  onDrift: (url, drifts) => {
    // url    — the endpoint that drifted
    // drifts — array of DriftItem with field, expected, received, severity, responseTime

    // send to Sentry
    Sentry.captureMessage('API contract drift', {
      extra: { url, drifts }
    });

    // or your own analytics
    analytics.track('api_drift', { url, drifts });
  }
});

Combine with silent mode in production to collect drift data without any console output:

init({
  mode: 'silent',
  onDrift: (url, drifts) => {
    fetch('/internal/drift-report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url, drifts, timestamp: Date.now() })
    });
  }
});

Response time tracking

Every drift report includes the response time as a raw number in milliseconds — no unit appended so you can use it directly in calculations.

init({
  mode: 'warn',
  onDrift: (url, drifts) => {
    const ms = drifts[0].responseTime;         // e.g. 1842
    const seconds = ms / 1000;                 // e.g. 1.842
    const isSlow = ms > 2000;                  // boolean check

    console.log(`${url} responded in ${ms}ms`);
  }
});

The console output also shows response time automatically:

[api-diff] Contract drift on /api/users/123 (1842ms):
  • email: expected string, got missing

Max response time

Warn when an endpoint is slow — even if the schema is fine. Useful for catching performance regressions alongside contract changes.

init({
  mode: 'warn',
  maxResponseTime: 2000, // warn if response takes longer than 2000ms
});

When breached:

[api-diff] Slow response on /api/users — 2843ms exceeded maxResponseTime of 2000ms

Slow + drifting produces both messages. Works with onDrift too — the callback fires with the drift items and responseTime attached.


Enabled flag

Toggle the interceptor without calling restore() — useful for disabling in production conditionally.

init({
  mode: 'warn',
  enabled: process.env.NODE_ENV !== 'production',
});

When enabled: false, the interceptor is not installed and globalThis.fetch is left untouched.


Schema reference

Supported types

| Type | Matches | |------|---------| | string | typeof value === 'string' | | number | typeof value === 'number' | | boolean | typeof value === 'boolean' | | object | Plain objects — supports nested fields | | array | Array.isArray(value) — supports items for item validation | | null | value === null |

Field options

defineSchema('/api/endpoint', {
  fieldName: {
    type: 'string',       // required — one of the types above
    required: true,       // optional — defaults to true
    fields: { ... },      // optional — only when type is 'object'
    items: { ... },       // optional — only when type is 'array'
  }
});

Examples

Flat response

defineSchema('/api/profile', {
  id:        { type: 'string' },
  username:  { type: 'string' },
  followers: { type: 'number' },
  verified:  { type: 'boolean' },
  bio:       { type: 'string', required: false },
});

Nested object

defineSchema('/api/loans', {
  loanId: { type: 'string' },
  amount: { type: 'number' },
  status: { type: 'string' },
  user: {
    type: 'object',
    fields: {
      id:    { type: 'string' },
      name:  { type: 'string' },
      email: { type: 'string' },
    }
  },
});

Array with item validation

defineSchema('/api/users', {
  users: {
    type: 'array',
    items: {
      id:     { type: 'string' },
      name:   { type: 'string' },
      active: { type: 'boolean' },
    }
  }
});

If the second item in the array has a wrong type:

[api-diff] Contract drift on /api/users (67ms):
  • users[1].id: expected string, got number

Full production setup

import { init, defineSchema } from '@nakshatra6350/api-diff';

defineSchema('/api/users/:id', {
  id:    { type: 'string' },
  name:  { type: 'string' },
  email: { type: 'string' },
  roles: {
    type: 'array',
    items: { name: { type: 'string' } }
  }
});

defineSchema('/api/loans/:id', {
  loanId: { type: 'string' },
  amount: { type: 'number' },
  status: { type: 'string' },
  user: {
    type: 'object',
    fields: {
      id:   { type: 'string' },
      name: { type: 'string' },
    }
  }
});

init({
  mode: 'silent',
  strict: true,
  maxResponseTime: 3000,
  ignore: [
    '__v',
    'updatedAt',
    'root.requestId',
    'roles[*].internalCode',
  ],
  onDrift: (url, drifts) => {
    fetch('/internal/drift-report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        url,
        drifts,
        timestamp: Date.now(),
        slowFields: drifts.filter(d => (d.responseTime ?? 0) > 3000),
      })
    });
  }
});

Test setup with throw mode

import { init, defineSchema, restore, clearRegistry } from '@nakshatra6350/api-diff';

beforeAll(() => {
  defineSchema('/api/users/:id', {
    id:   { type: 'string' },
    name: { type: 'string' },
  });
  init({ mode: 'throw', strict: true });
});

afterAll(() => {
  restore();
  clearRegistry();
});

API

defineSchema(url, schema)

Registers a contract for a URL pattern. Supports exact URLs, :param segments, and * wildcards.

| Parameter | Type | Description | |-----------|------|-------------| | url | string | URL pattern — exact, :param, or * wildcard | | schema | ApiSchema | Shape definition for the response |

init(config?)

Installs the fetch interceptor globally. Call once at your app's entry point.

init('warn');  // simple

init({         // full config
  mode: 'warn',
  strict: false,
  ignore: [],
  maxResponseTime: undefined,
  enabled: true,
  onDrift: (url, drifts) => { ... }
});

| Option | Type | Default | Description | |--------|------|---------|-------------| | mode | 'warn' \| 'throw' \| 'silent' | 'warn' | What to do when drift is detected | | strict | boolean | false | Also flag unexpected fields not in schema | | ignore | string[] | [] | Fields to skip — bare name, root.field, arr[*].field, or exact path | | maxResponseTime | number | undefined | Warn when response exceeds this value in ms | | enabled | boolean | true | Set to false to skip interceptor entirely | | onDrift | (url, drifts) => void | undefined | Callback fired on every drift or slow response event |

restore()

Removes the fetch interceptor and restores the original fetch. Use in test teardown.

clearRegistry()

Clears all registered schemas. Use in test teardown alongside restore().


TypeScript support

Full TypeScript support ships with the package — no @types install needed.

import type {
  ApiSchema,
  SchemaField,
  FieldType,
  DiffResult,
  DriftItem,
  DiffMode,
  InitConfig,
  OnDriftCallback,
} from '@nakshatra6350/api-diff';

const handleDrift: OnDriftCallback = (url, drifts) => {
  drifts.forEach(d => {
    console.log(d.field);        // string
    console.log(d.expected);     // string
    console.log(d.received);     // string
    console.log(d.severity);     // 'missing' | 'type_mismatch' | 'unexpected'
    console.log(d.responseTime); // number (ms) | undefined
  });
};

How it works

  1. defineSchema() registers URL patterns compiled to regex at registration time — zero overhead per request
  2. init() wraps globalThis.fetch with a thin interceptor
  3. Every fetch call passes through — if the URL matches a pattern, the response is cloned
  4. The clone is parsed as JSON — non-JSON responses are skipped with a debug log
  5. The parsed data is deep-compared against the schema field by field, type by type, index by index for arrays
  6. In strict mode, the response is also checked for fields not present in the schema
  7. The ignore list is evaluated using four matching strategies: bare name, root. prefix, [*] wildcard, and exact path
  8. Response time is measured from just before the original fetch to when it resolves — attached as a raw number to every DriftItem
  9. If maxResponseTime is set and breached, a separate warning fires even when the schema passes
  10. The onDrift callback fires first, then the console output based on mode
  11. The original response is returned untouched — your app continues to work normally

Why not OpenAPI or Zod?

| Tool | When to use it | |------|----------------| | OpenAPI validators | You own the backend and can generate specs — heavy setup, needs backend cooperation | | Zod | Compile-time + runtime validation wired into your data layer — great but requires active schema maintenance | | api-diff | Runtime drift detection at the fetch layer — zero backend changes, 5-line setup, safety net under your types |

These are not competitors. Use Zod for domain validation and api-diff as an early warning system at the network boundary. When your Zod schemas go stale, api-diff still catches it.


Changelog

v0.4.0

  • ✨ Granular ignore patterns — bare field name (everywhere), root.field (root only), arr[*].field (array items only), exact path
  • maxResponseTime — warn when an endpoint is slow, independent of schema drift
  • enabled flag — toggle interceptor without calling restore()
  • responseTime on DriftItem — raw number in ms, no unit string appended
  • clearRegistry() — clear all registered schemas, useful in test teardown
  • 🔧 Non-JSON responses now emit a console.debug in warn mode instead of silently skipping

v0.3.0

  • ✨ Wildcard URL matching — :param and * segment patterns for dynamic REST routes
  • ✨ Strict mode — flag unexpected fields the backend added that aren't in your schema
  • 🔧 Query strings stripped before URL pattern matching

v0.2.0

  • ✨ Array item validation — validate every item in an array response with exact index in error path
  • onDrift callback — pipe drift events to Sentry, analytics, or your own endpoint
  • init() now accepts a config object in addition to a mode string

v0.1.0

  • 🚀 Initial release — runtime fetch interception, deep schema diffing, three modes, full TypeScript support

Contributing

Issues and PRs are welcome. Please open an issue first to discuss any large changes.

git clone https://github.com/nakshatra6350/api-diff.git
cd api-diff
npm install
npm test

License

MIT © Nakshatra