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

@cruzrojapuebla/plasma

v1.0.0

Published

Full type-safe HTTP client wrapper around `fetch`, designed for strict typing of API routes, parameters, and responses — with zero codegen.

Downloads

28

Readme

@crm/plasma

Full type-safe HTTP client wrapper around fetch, designed for strict typing of API routes, parameters, and responses — with zero codegen.

[!NOTE] Primarily designed for server-side usage (server functions, API routes, loaders), but runs in the browser as well since it relies solely on standard Web APIs (fetch, Headers, FormData).

Installation

pnpm add @crm/plasma
# or
npm install @crm/plasma
# or
yarn add @crm/plasma
# or
bun add @crm/plasma

Quick Start

1. Define your routes

Create a routes.ts file describing your API endpoints. Use z.object() for any query parameters that need validation or coercion.

// routes.ts
import type { ServerRoutes } from '@crm/plasma'
import { z } from 'zod'

export const APP_ROUTES = {
  'get-users': {
    url: '/api/users',
    params: z.object({
      page: z.coerce.number().optional(),
      role: z.enum(['admin', 'user']).optional(),
    }),
    returns: {} as { id: number; name: string }[],
  },
  'create-user': {
    url: '/api/users',
    apiPayload: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    returns: {} as { id: number; name: string; email: string },
  },
} satisfies ServerRoutes

2. Create the client

Instantiate createHttpClient once and export it for use across your app or create separate clients for different purposes.

// client.ts
import { createHttpClient } from '@crm/plasma'
import { APP_ROUTES } from './routes'

export const client = createHttpClient({
  serverUrl: process.env.API_URL,
  routes: APP_ROUTES,
  interceptors: {
    request: [
      async (req) => {
        const token = localStorage.getItem('token')
        if (token) req.headers.set('Authorization', `Bearer ${token}`)
        return req
      },
    ],
  },
})

3. Make requests

All methods return a Go-style [error, data] tuple — no try/catch needed.

const [error, users] = await client.GET('get-users', {
  params: { page: 1, role: 'admin' },
})

if (error) {
  console.error(error)
  return
}

console.log(users) // { id: number; name: string }[]

Core Concepts

Route Definitions

Routes are plain objects that satisfy the ServerRoutes type.

| Field | Type | Description | |---|---|---| | url | `/${string}` | Endpoint path (must start with /) | | params | z.ZodObject | Query parameters schema — enables validation and coercion | | apiPayload | z.ZodObject | Request body schema — validated before the request is sent | | clientInput | z.ZodObject | Input schema for the UI layer (e.g. form validation) — not sent to the API | | returns | unknown | Shape of the API response (type-only, not runtime) |

Error Handling

Every method returns a readonly [Error, null] | readonly [null, T] tuple.

const [error, data] = await client.GET('get-users')

if (error) return handleError(error)
// data is fully typed and non-null here

Errors are returned (not thrown) for:

  • Network failures
  • Non-2xx HTTP responses
  • JSON parse failures
  • apiPayload Zod validation failures

Errors are thrown for:

  • Missing serverUrl
  • Missing Authorization header on a protected route

Authentication

All routes are protected by default. The client verifies that an Authorization header is present after request interceptors run.

// Protected (default) — interceptors must attach the Authorization header
const [error, data] = await client.GET('get-users')

// Public — skips the Authorization check entirely
const [error, data] = await client.POST('login', credentials, { auth: false })

[!CAUTION] If auth is true (the default) and no Authorization header is present after interceptors run, the client throws synchronously — it does not return an error tuple.

Adapters

An adapter transforms the raw API response before it reaches your application.

export const client = createHttpClient({
  serverUrl: process.env.API_URL,
  routes: APP_ROUTES,
  // API returns { data: [...] } — adapter extracts the array
  adapter: (response) => response.data,
})

When an adapter is provided, the data field of the result tuple is typed as the adapter's return type, not the raw returns type.


API Reference

createHttpClient(config)

Creates a typed HTTP client.

| Option | Type | Description | |---|---|---| | serverUrl | string \| undefined | Base URL for your API | | routes | ServerRoutes | Route definitions object | | adapter | (data: any) => any | (Optional) Transform the response body | | interceptors | Interceptors | (Optional) Request/response interceptors |

client.GET(alias[, options])

  • options is omittable when the route has no params schema
  • options is required (and must include params) when the route defines a params: z.object(...) schema
// Route defines `params: z.object(...)` — options is required
const [error, users] = await client.GET('get-users', {
  params: { page: 1, role: 'admin' }, // required
  auth: true,                          // optional, default: true
})

// Route has no `params` schema — second argument can be omitted entirely
const [error, profile] = await client.GET('get-profile')

// Or pass options to override auth when no params are needed
const [error, profile] = await client.GET('get-profile', { auth: false })

| Property | Required | Type | Default | Description | |---|---|---|---|---| | params | When route defines a params schema | z.infer<Route["params"]> | — | Query parameters, validated and coerced by the route's Zod schema | | auth | No | boolean | true | Whether to enforce the Authorization header |

client.POST(alias, body[, options])

If the route defines an apiPayload schema, the body is validated and coerced before being sent. Invalid data returns [ZodError, null] without making a network request.

Options (all optional):

| Property | Required | Type | Default | Description | |---|---|---|---|---| | params | No | object | — | Query parameters appended to the URL | | bodyType | No | 'json' \| 'form-data' | 'json' | Serialization format | | auth | No | boolean | true | Whether to enforce the Authorization header |

client.PATCH(alias, body[, options])

Identical signature to client.POST. Use for partial update requests.


Interceptors

Interceptors are async functions that run before the request is sent (request) or after the response is received (response). Both sync and async are supported, and they execute in order — each one receives the output of the previous.

Request Interceptors

type RequestInterceptor = (request: HttpRequest, context: HttpRequest) => HttpRequest | Promise<HttpRequest>
  • request — the current request state (may already be modified by a previous interceptor)
  • context — a snapshot of the original request before any interceptors ran; useful for logging or error correlation
import type { RequestInterceptor } from '@crm/plasma'

const authInterceptor: RequestInterceptor = async (request, context) => {
  const token = localStorage.getItem('token')
  if (token) request.headers.set('Authorization', `Bearer ${token}`)
  return request
}

The HttpRequest shape:

| Property | Type | Description | |---|---|---| | url | string | Full resolved URL (serverUrl + path + query) | | method | 'GET' \| 'POST' \| 'PATCH' \| 'PUT' \| 'DELETE' | HTTP method | | headers | Headers | Mutable headers object | | body | BodyInit \| null \| undefined | Serialized request body |

Response Interceptors

type ResponseInterceptor = (response: Response, context: HttpRequest) => Response | Promise<Response>
  • response — the current response (may have been modified by a previous interceptor)
  • context — the original HttpRequest that generated this response; useful for logging, retries, or redirects
import type { ResponseInterceptor } from '@crm/plasma'

const unauthorizedInterceptor: ResponseInterceptor = async (response, context) => {
  if (response.status === 401) {
    console.warn(`Unauthorized on ${context.method} ${context.url}`)
    localStorage.removeItem('token')
    window.location.href = '/login'
  }
  return response
}

Use the context parameter to act on the original request inside a response interceptor. The following example logs and redirects forbidden access attempts:

import type { ResponseInterceptor } from "@crm/plasma";

import { redirect } from "@tanstack/react-router";

import { getUserProfile } from "@/core/users/services/get-user-profile";

export const forbiddenInterceptor: ResponseInterceptor = async (response, context) => {
    if (response.status === 403) {
        const user = await getUserProfile({ data: { userId: "" } });

        console.warn(
            `User **${user.name}** with id **${user.id}** tried to access the resource [${context.method}] **${context.url}**. \nLogged out by forbidden interceptor.`,
        );

        throw redirect({
            to: "/login",
        });
    }

    return response;
};

Wiring interceptors

export const client = createHttpClient({
  serverUrl: process.env.API_URL,
  routes: APP_ROUTES,
  interceptors: {
    request: [authInterceptor],
    response: [unauthorizedInterceptor, forbiddenInterceptor],
  },
})

Examples

Server Function (TanStack Start)

import { createServerFn } from '@tanstack/react-start'
import { client } from '../utils/http'
import { usersAdapter } from '../adapters/users.adapter'

export const getUsers = createServerFn().handler(async () => {
  const [error, response] = await client.GET('get-users')

  if (error) throw error

  return usersAdapter(response)
})

File upload with form-data

const [error, result] = await client.POST('upload-avatar', formPayload, {
  bodyType: 'form-data',
})

Public endpoint

const [error, session] = await client.POST('login', credentials, {
  auth: false,
})

Advanced Usage

import { DATABASE_STATUS } from "@/constants/status";
import { createServerFn } from "@tanstack/react-start";
import { ZodError } from "zod";
import { format } from "@/lib/time";
import { vacationsClient } from "../utils/http";
import { VACATION_ROUTES } from "../utils/routes";

export const uploadVacation = createServerFn({ method: "POST" })
    .inputValidator((data: FormData) => {
        if (!(data instanceof FormData)) {
            throw new Error("Expected FormData");
        }

        const segment = data.get("segment")?.toString().split(" - ")[0];

        const payload = {
            segment,
            employeeWhoCovers: data.get("employee-who-covers") || undefined,
            days: data.getAll("days"),
        };

        return VACATION_ROUTES["upload-vacation"].clientInput.parse(payload);
    })
    .handler(async ({ data: { days, segment, employeeWhoCovers } }) => {
        const requestDate = new Date();
        const thisYear = new Date().getFullYear();

        const requestYear = `${thisYear}-12-31` as const;

        const requestData = {
            fechaSolicitud: requestDate,
            numDias: days.length,
            estatusVacacion: DATABASE_STATUS.PENDING,
            observaciones: "-",
            segmento: segment,
            fk_cubre: employeeWhoCovers,
            anio_solicitud: requestYear,
            diasVacaciones: days.map((date) => format(date)).toString(),
        };

        const [error, response] = await vacationsClient.POST("upload-vacation", requestData, {
            bodyType: "form-data",
        });

        if (error) {
            if (error instanceof ZodError) {
                return {
                    success: false,
                    error: "Invalid payload data",
                    code: "",
                };
            }

            throw error;
        }

        if (response.status !== 201) {
            return {
                success: false,
                error: "No se pudieron procesar las vacaciones. Intenta más tarde.",
            };
        }

        return {
            success: true,
        };
    });

Development & Testing

# Run the test suite
pnpm test

# Type check
pnpm check-types

# Lint
pnpm lint

When contributing:

  1. Run tests before committing — pnpm test
  2. Add or update tests when changing behavior
  3. Update this README and the developer docs when adding features
  4. New features must not break existing type inference or runtime behavior