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

@nimit9/signet-lib

v0.1.15

Published

Signet client primitives. API client and the wire contract.

Readme

@nimit9/signet-lib

Client primitives. The API layer, because six products hand-rolled one.

What the audit found

lib/api.ts or services/api.ts existed in six families. None shared an error convention, and reading them turned up two real bugs rather than mere duplication:

| Product | Lines | State | |---|---|---| | WeddingApp | 123 | own conventions | | happy-fridge | 88 | handled non-JSON and 204 correctly; errors untyped (Error with .status bolted on) | | paisa | 59 | typed ApiError/AuthError, considered retry policy — but custom headers silently dropped Content-Type | | ipl-fantasy-ui | 49 | no res.ok check on 5 of 9 endpoints — a 500 HTML page reached res.json() | | dhairya11 | 23 | minimal | | agent-commerce | 2 | stub |

The contract

Errors are enveloped, successes are not:

{ success: false, error: string, code?: string, details?: { formErrors, fieldErrors }, requestId?: string }

error is human text; branch on code, never on error. Every error the server kit raises itself has one (VALIDATION_FAILED, RATE_LIMITED, INTERNAL_ERROR, …). requestId is present when the server runs the requestId middleware, and ApiError.requestId falls back to the X-Request-Id header, so a user's report can be traced to the log line.

client.send(path, init) sends with the configured headers and credentials and turns a 401 into AuthError, but returns every other Response untouched — status, redirected, url — for a flow that follows a redirect and checks where it landed. toApiError(res) turns a failed Response into the same ApiError the other methods throw.

ApiError.retryAfter is the seconds until a retry can succeed, when the server said so (a 429 from rateLimit does): from Retry-After, else RateLimit-Reset, else X-RateLimit-Reset. Use it to say "try again in 30 seconds".

ERROR_CODES (and type ErrorCode) lists every code the server kit emits, so an app's user-facing copy can be checked by the compiler rather than a test:

import type { ErrorCode } from "@nimit9/signet-lib/api"
const copy = { ...appCopy, RATE_LIMITED: "Slow down a little." } satisfies Record<ErrorCode | AppErrorCode, string>

DEFAULT_ERROR_COPY gives every one of those codes plain, product-neutral copy ({ title, detail, action }), so an app writes copy only for its own codes and the few it wants to phrase differently:

import { DEFAULT_ERROR_COPY, errorCopy, type ErrorCode, type ErrorCopy } from "@nimit9/signet-lib/api"

export const COPY = {
  ...DEFAULT_ERROR_COPY,
  RATE_LIMITED: { title: "Easy there", detail: "Search again shortly.", action: "wait" },
  PROVIDER_UNAVAILABLE: { title: "Store unavailable", detail: "Try another store.", action: "go_back" },
} satisfies Record<ErrorCode | AppErrorCode, ErrorCopy>

const { title, detail, action } = errorCopy(err.code, COPY)   // unknown code -> INTERNAL_ERROR's copy

action is one of retry, wait, edit, sign_in, go_back; pair wait with ApiError.retryAfter. Need a product action? ErrorCopy is generic: satisfies Record<ErrorCode | AppErrorCode, ErrorCopy<ErrorAction | "new_search">>, and the spread defaults still fit. A new Signet code arrives with its default; a new app code fails the build until it has copy.

Codes are UPPER_SNAKE, except invalid_token and insufficient_scope, whose spelling RFC 6750 fixes. The server test fails if the server emits a code the list is missing.

Asymmetric on purpose — it matches what paisa already serves, so existing routes adopt it without rewriting, and success payloads stay directly usable with no unwrapping at the call site.

@nimit9/signet-server/http writes exactly what @nimit9/signet-lib/api parses.

Use

import { createApiClient, ApiError, AuthError } from "@nimit9/signet-lib/api"

export const api = createApiClient({
  baseUrl: "/api",
  onAuthError: () => { window.location.href = "/login" },
  headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
})

await api.get<Transaction[]>("/transactions" + qs({ from, to, q: search }))
await api.post<Transaction>("/transactions", body)
await api.postForm<ImportResult>("/import", formData)
const pdf = await api.raw("/receipt/42/pdf")

Validation errors arrive structured, not as a JSON string inside a message:

catch (e) {
  if (e instanceof ApiError && e.code === "VALIDATION_FAILED") {
    setFieldErrors(e.details?.fieldErrors ?? {})
  }
}

TanStack Query

import { queryDefaults, onAuthErrorRedirect } from "@nimit9/signet-lib/api/query-client"

const client = new QueryClient({ defaultOptions: queryDefaults() })
onAuthErrorRedirect(client, () => { window.location.href = "/login" })

AuthError is never retried — retrying a 401 only delays the redirect and fires more requests at a dead session. A burst of parallel 401s redirects once.

Only transient failures are retried: network errors, 5xx, 408 and 429. A 400, 403, 404 or 422 fails identically every time, so retrying it only delays the error. A 429 waits the server's Retry-After. Pass shouldRetry to change the filter, maxRetries to change the count.

Fixed here

  • Every response checks res.ok. ipl skipped it on 5 of 9 endpoints.
  • Caller headers extend the defaults instead of replacing them. paisa spread caller options after the computed headers, so any custom header dropped Content-Type. FormData still correctly sends none, so the browser sets the multipart boundary.
  • Error bodies are read once as text, then parsed. Calling res.json() first consumes the stream, so a res.text() fallback comes back empty and the real message is lost. This one was caught by the test suite, in this package's own first draft.
  • 204 and non-JSON bodies return undefined instead of throwing a parse error. happy-fridge had this; paisa did not.
  • A non-JSON error body is never the message. A Cloudflare 502 is a whole HTML page, and it reached a toast as err.message. The message is the envelope's error, else a bare string error, else a bare string message, else "Request failed (502 Bad Gateway)" ("(HTTP 502)" without status text). ApiError.body holds JSON only, so it is undefined for HTML or plain text; use client.send() to read such a body.

Test

bun run test — 26 assertions, with fetch stubbed. Covers each bug above.

Format

import { createCurrencyFormatter, createCompactCurrencyFormatter } from "@nimit9/signet-lib/format"

const money = createCurrencyFormatter("INR", "en-IN")
money(1234)        // ₹1,234
money(1234.5, true) // ₹1,234.50

const compact = createCompactCurrencyFormatter("INR", "en-IN")
compact(1_200_000)  // ₹12L
compact(35_000_000) // ₹3.5Cr

Intl compact notation renders 12 lakh as "₹1.2M", which is wrong for Indian numbering. Locales using the lakh/crore system get that scale; every other locale falls through to Intl. Detection is by locale, not by currency — the earlier version hardcoded it to INR.

Dates use Intl throughout. paisa pulled in date-fns for what the platform already does.

Date arithmetic

import { addDays, addMonths, startOfMonth, endOfMonth, differenceInCalendarDays } from "@nimit9/signet-lib/format"

addMonths(new Date(2026, 0, 31), 1)                          // Feb 28 2026 (Feb 29 in a leap year)
addDays(today, -7)                                            // a week ago, same time of day
const range = [startOfMonth(d), endOfMonth(d)]               // 00:00:00.000 → 23:59:59.999
differenceInCalendarDays(new Date(2026, 2, 9), new Date(2026, 2, 8)) // 1, even on a 23-hour DST day
startOfYear(d)                                                // Jan 1st, 00:00:00.000

toDateOnly(new Date(2024, 11, 25, 23, 30))                    // "2024-12-25", the local calendar date
parseDateOnly("2024-12-25")                                   // local midnight on 25 Dec
parseDateOnly("2025-02-30")                                   // invalid Date, not rolled into March

addDays, addMonths, startOfDay, startOfMonth, startOfYear, endOfMonth, differenceInCalendarDays, isSameDay and isSameMonth cover what paisa kept date-fns for. They behave like date-fns' defaults: local time, a Date or epoch ms in, a new Date out, the input never mutated. addMonths clamps to the end of a shorter month. differenceInCalendarDays compares calendar dates, not milliseconds / 86,400,000, which floors a spring-forward day to 0. Amounts may be negative; fractions truncate. An invalid date gives an invalid Date, NaN or false rather than throwing.

toDateOnly and parseDateOnly convert between a local calendar date and a "YYYY-MM-DD" string, for date inputs, query strings and date-only API fields. new Date("2024-12-25") is UTC midnight, which is 24 Dec anywhere west of UTC; parseDateOnly gives local midnight, and returns an invalid Date for any other shape or for an impossible date. toDateOnly gives "" for an invalid date. The date formatters (createDateFormatter, createDateShortFormatter, createDateTimeFormatter, createRelativeTimeFormatter) read a date-only string the same way; a string with a time or zone parses as before.

Crypto: fail-closed field decryption

For apps that encrypt user data on the client (paisa's private mode). You bring decrypt; decryptFields enforces the rule that is easy to get wrong: a field that should be ciphertext and does not decrypt becomes null, never passes through.

import { decryptFields } from "@nimit9/signet-lib/crypto"

const { data, undecrypted } = await decryptFields(
  payload,
  ["transactions.description", "transactions.merchant.cleanName"],
  {
    decrypt: (ct) => decryptField(ct, key),
    // Optional. Return false only for values that are certainly legacy plaintext.
    isCiphertext: (v) => looksLikeAesGcmBase64(v),
  },
)
// undecrypted: ["transactions.3.description", ...]  (empty when all decrypted)
  • Paths are dot-separated; arrays are walked without an index.
  • With no isCiphertext, every non-empty string at a path is treated as ciphertext. A throwing isCiphertext also counts as ciphertext.
  • Never throws, never mutates the input. A shared object decrypts under every reference.