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

@nicholasf/whurl

v0.4.0

Published

GraphQL test double and contract layer

Readme

whurl

whurl mocks a GraphQL endpoint straight from its schema, validating every request and response against it, and mocks anything else, REST, OAuth, webhooks, the same way. Every specification can also be exported as a Hurl file and replayed against the real backend, so a test's mock logic becomes a repeatable contract check.

registerWithSchema('http://localhost:4000/graphql', schema)
register('http://auth.example.com/oauth/token')

const WorldQuery = `query World { world { id name description isActive } }`
const AccountsQuery = `query Accounts { accounts { id name username } }`

// Success, status defaults to 200
specify({
  operationName: 'World',
  document: WorldQuery,
  variables: {},
  response: { world: { id: 1, name: 'Aerthos', description: 'A shattered realm', isActive: true } },
})

// GraphQL error, still a 200 by convention, made explicit here
specify({
  status: 200,
  operationName: 'World',
  document: WorldQuery,
  variables: {},
  response: { errors: [{ message: 'forbidden', extensions: { code: 'UNAUTHORISED' } }] },
})

// A non-200 GraphQL response, e.g. behind a gateway that rejects before resolving
specify({
  status: 500,
  operationName: 'World',
  document: WorldQuery,
  variables: {},
  response: { errors: [{ message: 'internal server error' }] },
})

// Repeat a specification across multiple matches
specify({
  operationName: 'Accounts',
  document: AccountsQuery,
  variables: {},
  response: { accounts: [{ id: 1, name: 'Kestrel', username: 'kestrel_runs' }] },
}).repeat(3)

// Matched by variables, not just document
specify({
  operationName: 'Region',
  document: `query Region($id: ID!) { region(id: $id) { id name climate } }`,
  variables: { id: 1 },
  response: { region: { id: 1, name: 'The Shattered Wastes', climate: 'Arid' } },
})

// REST, success, status defaults to 200
specify({ operationName: 'ExchangeToken', method: 'POST', response: { access_token: 'sith-token-abc123', token_type: 'Bearer', expires_in: 3600 } })

// REST, failure, explicit status
specify({ status: 401, operationName: 'ExchangeToken', method: 'POST', response: { error: 'invalid_grant', error_description: 'Refresh token expired' } })

// The call never reaches the server at all, TypeError: Failed to fetch
specifyNetworkError({ operationName: 'World', document: WorldQuery })

whurl intercepts HTTP calls in tests at the network layer — no vi.mock() calls, your real client code runs. For GraphQL endpoints it validates queries and data shapes against your schema using graphql-js, the GraphQL Foundation's reference implementation. For any other endpoint — REST APIs, OAuth providers, external services — it works as a thin wrapper over MSW. Either way, every intercepted call can be recorded as a Hurl file and replayed against a real backend later.

If you've wanted a GraphQL schema to act as a contract between frontend and backend — the way a Swagger file does for REST — whurl is built for that, although it solves the problem differently.

whurl combines two libraries: msw (Mock Service Worker) for HTTP interception in tests, and Hurl for contract verification against real backends. The name comes from Worker and Hurl.

It works at both the client and server layer. Despite the name, MSW's Node implementation doesn't run an actual browser Service Worker — it patches Node's own http.ClientRequest.prototype directly (see Server-to-server calls), so the same interception mocks a frontend component's call to your API just as well as a backend service's own outbound call to a downstream dependency. The example below is a React component, but that's just the example — whurl works the same way from a Node test with no browser involved.

import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { registerWithSchema, specify, reset } from '@nicholasf/whurl'
import { DashboardPage } from './pages/DashboardPage'
import { schema } from '../tests/schema'

beforeEach(() => {
  reset()
  registerWithSchema('http://localhost:3000/graphql', schema)
})

describe('DashboardPage', () => {
  it('renders the authenticated user', async () => {
    // Intercepts the Me query DashboardPage fires on mount
    specify({
      operationName: 'Me',
      document: `query Me { me { id name email } }`,
      variables: {},
      response: { me: { id: '1', name: 'Darth Vader', email: '[email protected]' } },
    })

    render(<DashboardPage />)

    expect(await screen.findByText('Darth Vader')).toBeInTheDocument()
  })
})

Then, later on, after you are happy with your mock logic, you can whurl it at the backend!

Run your tests with WHURL=true and whurl writes a Hurl file for every consumed specification:

WHURL=true vitest run

The Me specification above produces whurl/2026-06-25T11-38-15-Me.hurl:

POST http://localhost:3000/graphql
Content-Type: application/json

{
  "query": "query Me { me { id name email } }"
}

The backend team runs this against their real implementation — no frontend required:

hurl whurl/*.hurl

Installation

npm install @nicholasf/whurl

Setup

Call start() and stop() in your test setup file:

// tests/setup.ts
import { beforeAll, afterAll } from 'vitest'
import { start, stop } from '@nicholasf/whurl'

beforeAll(() => start())
afterAll(() => stop())
// vitest.config.ts
export default {
  test: {
    setupFiles: ['./tests/setup.ts'],
  },
}

Registering endpoints

For a plain HTTP endpoint:

import { register } from '@nicholasf/whurl'

register('http://localhost:3000/api/accounts')

For a GraphQL endpoint, provide the schema string. The schema isn't just parsed once and discarded — whurl keeps it, because every specification you declare later (see Specifications) is checked against it. That's the difference between whurl and a plain stub: if a mocked response has a typo'd field name, references a field that doesn't exist on the type, or is missing a field the operation actually returns, whurl throws at specify() time — not later, as a confusing failure against the real API, or worse, not at all.

import { registerWithSchema } from '@nicholasf/whurl'

registerWithSchema('http://localhost:3000/graphql', `
  type User { id: ID! name: String! email: String! }
  type Query { me: User }
`)

whurl also parses the schema string immediately and throws if it's invalid, so a broken schema fails fast here rather than surfacing later as a confusing specify() error.

Specifications

A specification declares what a GraphQL operation should return. whurl validates the response shape against the registered schema and intercepts the matching request, returning the specified data.

import { registerWithSchema, specify } from '@nicholasf/whurl'

registerWithSchema('http://localhost:3000/graphql', schema)

specify({
  operationName: 'Me',
  document: `query Me { me { id name email } }`,
  variables: {},
  response: { me: { id: '1', name: 'Darth Vader', email: '[email protected]' } },
})

whurl matches a specification to an intercepted request by comparing document and the incoming request's document — both parsed and printed back out, so differences in whitespace or formatting don't matter — together with a deep-equality check between variables and the incoming request's variables. operationName plays no part in matching; whurl derives the real operation name from document and throws at specify() time if it doesn't match the operationName given.

Operations must be named:

# ✓ whurl can match this
query Me {
  me { id name email }
}

# ✗ whurl will throw at intercept time
{
  me { id name email }
}

By default a specification is matched once and then exhausted. Chain .repeat(n) to allow it to be matched more times:

const meQuery = `query Me { me { id name email } }`

specify({ operationName: 'Me', document: meQuery, variables: {}, response: { me: { id: '1', name: 'Darth Vader', email: '[email protected]' } } })          // matched once
specify({ operationName: 'Me', document: meQuery, variables: {}, response: { me: { id: '1', name: 'Darth Vader', email: '[email protected]' } } }).repeat(3) // matched three times

Registering the same document and variables more than once queues the specifications — the first matching request consumes the first one registered, the next matching request consumes the second, and so on:

const meQuery = `query Me { me { id name email } }`

specify({ operationName: 'Me', document: meQuery, variables: {}, response: { me: { id: '1', name: 'Darth Vader', email: '[email protected]' } } })
specify({ operationName: 'Me', document: meQuery, variables: {}, response: { me: { id: '2', name: 'Luke Skywalker', email: '[email protected]' } } })
// First matching request returns Darth Vader, the next returns Luke Skywalker

Specifications for the same document with different variables are independent — each is matched only against a request carrying the same variables:

const postQuery = `query Post($id: ID!) { post(id: $id) { id title } }`

specify({ operationName: 'Post', document: postQuery, variables: { id: '1' }, response: { post: { id: '1', title: 'First post' } } })
specify({ operationName: 'Post', document: postQuery, variables: { id: '2' }, response: { post: { id: '2', title: 'Second post' } } })

specify() also takes an array, registering each entry independently — this is shorthand for calling specify() once per entry, not a distinct matching behaviour:

specify([
  { operationName: 'Post', document: postQuery, variables: { id: '1' }, response: { post: { id: '1', title: 'First post' } } },
  { operationName: 'Post', document: postQuery, variables: { id: '2' }, response: { post: { id: '2', title: 'Second post' } } },
])

Call reset() between tests to clear all specifications and registered endpoints:

import { beforeEach } from 'vitest'
import { reset } from '@nicholasf/whurl'

beforeEach(() => reset())

Plain HTTP endpoints

whurl can intercept any HTTP endpoint, not just GraphQL. This is useful for mocking OAuth providers, REST APIs, or any other HTTP dependency your components talk to during tests.

Register a plain endpoint with register(), then declare specifications giving an operation name, HTTP method, and response — REST has no document to match against, so this form has no document/variables fields:

import { register, specify } from '@nicholasf/whurl'

register('http://auth.example.com/oauth/token')

specify({
  operationName: 'ExchangeToken',
  method: 'POST',
  response: {
    access_token: 'sith-token-abc123',
    token_type: 'Bearer',
    expires_in: 3600,
  },
})

whurl is just MSW here — register + specify is a thin DSL over an MSW handler. The intercept, the response envelope, the lifecycle — all MSW. whurl adds the operation name as a label and the .repeat(n) lifetime on top.

Server-to-server calls

whurl's interceptor uses setupServer from msw/node, not msw/browser. msw/node works by patching Node's own http.ClientRequest.prototype (and the equivalent hook for fetch/undici) directly in the process — there's no Service Worker or DOM involved. That patch catches any outgoing request made from that Node process, regardless of whether the caller is a React component's fetch() during a jsdom test or a backend service's own outbound call to a downstream API. Vitest already runs tests in a plain Node process by default, so this falls out for free:

import { register, specify, reset } from '@nicholasf/whurl'
import { getExchangeRate } from './exchangeRateService'

beforeEach(() => reset())

it('fetches the current exchange rate', async () => {
  register('https://api.exchangerate.example.com/latest')
  specify({ operationName: 'GetRate', method: 'GET', response: { base: 'USD', rates: { EUR: 0.92 } } })

  const rate = await getExchangeRate('EUR')

  expect(rate).toBe(0.92)
})

Hurl export

When tests run with WHURL=true, whurl writes a Hurl file for each consumed specification:

WHURL=true vitest run

This produces files in whurl/:

whurl/2026-06-25T11-38-15-Me.hurl
whurl/2026-06-25T11-38-15-CreatePost.hurl

The backend team can then run these against their implementation — no frontend required:

hurl whurl/*.hurl

Query variables are expressed as Hurl variables so the backend team can supply real values via a variables file:

hurl whurl/*.hurl --variables-file whurl/vars.env

Interceptor

HTTP interception is behind an Interceptor interface. The current implementation uses MSW, but the interface allows a different worker implementation to be swapped in without changing any test code:

interface Interceptor {
  start(): void
  stop(): void
  reset(): void
}

This means whurl is not tied to MSW specifically — any library that can intercept HTTP requests and return a response can be wired in by implementing this interface.

Verbose mode

Set WHURL_VERBOSE=true to log each matched specification as a Hurl request to stdout as tests run:

WHURL_VERBOSE=true vitest run

Output for a GraphQL spec:

POST http://localhost:3000/graphql
Content-Type: application/json

{
  "query": "query Me { me { id name email } }"
}

Output for a plain HTTP spec:

GET http://localhost:3000/api/accounts

Verbose mode and WHURL=true file export are independent — both can be active at once.

Status

Early development. The API is not yet stable and will change before the first release.