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

@krislintigo/luro

v1.0.0

Published

Tiny chainable fetch client — build a request by chaining, await it to send it.

Readme

@krislintigo/luro

Tiny chainable HTTP client on top of native fetch. You describe a request by chaining, and await sends it.

import luro from '@krislintigo/luro'

const user = await luro
  .post('https://api.dev/users')
  .json({ name: 'Kris' })
  .headers({ authorization: `Bearer ${token}` })
  .retry({ attempts: 3 })
  .json<User>()
  • ~5 KB gzipped, zero dependencies, ESM only.
  • Nothing but the request. No hooks, no interceptors, no schema validation, no plugin system — those belong around the client, not inside it.
  • Immutable chain. Every method returns a new request, so a configured chain is a reusable template.
  • Lazy. Nothing is sent until you await the chain or call a body reader.

Install

pnpm add @krislintigo/luro

Requires Node 24+ or any runtime with fetch, AbortSignal.any and AbortSignal.timeout.

Sending a request

Start with a method, finish with await or a reader:

const response = await luro.get('https://api.dev/todos') // Response
const todos = await luro.get('https://api.dev/todos').json<Array<Todo>>() // parsed
const raw = await luro.get('https://api.dev/todos.csv').text() // string

luro.get / post / put / patch / delete / head / options all take (url, options?). luro(url, options?) is the same thing with GET as the default method.

Readers: .json<T>(), .text(), .blob(), .arrayBuffer(), .bytes(), .formData() and .response(). Awaiting the chain itself gives you the raw Response.

An empty body (204, 205, 304, or content-length: 0) resolves .json() to undefined instead of throwing.

Chain reference

| Method | What it does | | -------------------------- | ------------------------------------------------------------------ | | .json(value) | Serializes the value and sets content-type: application/json | | .form(value) | Sends FormData, URLSearchParams, or a plain object as form data | | .body(value) | Sends a raw BodyInit untouched | | .headers(headers) | Merges headers over the inherited ones | | .query(params) | Merges search params into the URL | | .timeout(ms) | Aborts each attempt after ms | | .retry(count \| options) | Retries failed attempts | | .signal(signal) | Attaches an AbortSignal | | .throwHttpErrors(bool) | Resolves non-2xx responses instead of throwing | | .method(method) | Overrides the HTTP method | | .fetch(fn) | Swaps the fetch implementation | | .init(init) | Escape hatch for the rest of RequestInit |

json does double duty on purpose: with an argument it sets the request body, without one it reads the response.

const created = await luro.post(url).json({ title: 'luro' }).json<Todo>()
//                                   ↑ sends                ↑ reads

Clients

create gives you a client with defaults; extend derives a new one from it.

import { create } from '@krislintigo/luro'

const api = create({
  baseUrl: 'https://api.dev/v1',
  headers: { 'x-app': 'my-app' },
  timeout: 10_000,
  retry: 2,
})

const admin = api.extend({ baseUrl: 'https://api.dev/v1/admin' })

await api.get('todos') // https://api.dev/v1/todos
await admin.get('/todos') // https://api.dev/v1/admin/todos

Slashes are normalized on both sides, so a base path is never swallowed. Absolute URLs skip baseUrl entirely.

Every option is available both as a client default and as a chain method, and the chain always wins.

Headers

Layers merge, and null or undefined removes an inherited header:

await api.get('me').headers({ 'x-app': null })

Headers can also be a function — sync or async, resolved on every request. That is all the authentication support this library needs:

const api = create({
  baseUrl: 'https://api.dev',
  headers: async () => ({ authorization: `Bearer ${await getFreshToken()}` }),
})

Query

await api.get('todos').query({ page: 2, tag: ['work', 'home'], done: undefined })
// → /todos?page=2&tag=work&tag=home

Keys replace inherited ones instead of piling up; undefined and null values are dropped. Strings and URLSearchParams work too.

Retry

Retries are opt-in — nothing is ever retried unless you ask for it.

await api.get('flaky').retry(3) // 3 extra attempts
await api.post('orders').json(order).retry({
  attempts: 3,
  delay: 300, // base delay, or (attempt: number) => number
  factor: 2, // exponential backoff
  maxDelay: 10_000,
  jitter: true, // randomize between 50% and 100% of the delay
  statuses: [408, 413, 429, 500, 502, 503, 504],
  methods: ['GET', 'PUT', 'DELETE'], // defaults to every method
  respectRetryAfter: true, // honour the `Retry-After` header
})

Retryable by default: the statuses above, network errors, and timed-out attempts. Never retryable: aborts, and requests with a streaming body (it can only be sent once).

timeout applies per attempt, not to the whole chain.

Errors

import { HttpError, TimeoutError } from '@krislintigo/luro'

try {
  await api.get('todos')
} catch (error) {
  if (error instanceof HttpError) {
    error.status // 404
    error.response // untouched Response — the body is still readable
    const problem = await error.response.json()
  }

  if (error instanceof TimeoutError) {
    error.timeout // ms
  }
}

Non-2xx responses throw HttpError. To handle them as data instead:

const response = await api.get('todos').throwHttpErrors(false)

Aborts reject with the original AbortError, so they stay distinguishable from timeouts.

Escape hatches

Anything this library does not model is one .init() away:

await api.get('me').init({ credentials: 'include', cache: 'no-store', keepalive: true })

And fetch itself is swappable — for tests, for instrumentation, or for a custom agent:

const api = create({ fetch: async (url, init) => myFetch(url, init) })

License

MIT