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

@jayalfredprufrock/handshake

v0.6.2

Published

Describe your backend API as a contract and get a fully-typed http client without a compile step.

Downloads

879

Readme

Tired of managing API schemas in multiple places? With handshake, you define your API contract once using TypeBox schemas. Server adapters then consume those contracts, automatically providing strongly-typed and validated request and response objects. Consumers get a fully-typed HTTP client without a compile step.

Installation

npm install @jayalfredprufrock/handshake typebox

Hono is an optional peer dependency — install it if you're using the server adapter:

npm install hono

Quick Start

1. Define a Contract

A contract describes every endpoint in your API: its HTTP method, path, path parameters, request body, and response shape.

// contract.ts
import { createContract } from "@jayalfredprufrock/handshake/contract";
import { Type } from "typebox";

export const contract = createContract("/api", {
  getUser: {
    method: "GET",
    path: "/users/:id",
    params: Type.Object({ id: Type.String() }),
    response: Type.Object({
      id: Type.String(),
      name: Type.String(),
      email: Type.String(),
    }),
  },

  listUsers: {
    method: "GET",
    path: "/users",
    response: Type.Array(
      Type.Object({
        id: Type.String(),
        name: Type.String(),
        email: Type.String(),
      }),
    ),
  },

  createUser: {
    method: "POST",
    path: "/users",
    body: Type.Object({
      name: Type.String(),
      email: Type.String(),
    }),
    response: Type.Object({
      id: Type.String(),
      name: Type.String(),
      email: Type.String(),
    }),
  },

  deleteUser: {
    method: "DELETE",
    path: "/users/:id",
    params: Type.Object({ id: Type.String() }),
    response: Type.Object({ id: Type.String() }),
  },
});

The first argument to createContract is an optional base path that prefixes all endpoint paths. Omit it to default to "/".

2. Create a Server

Use the Hono adapter to bind handlers to the contract. Every endpoint must have a handler registered before the app can be built — if you miss one, build() throws at startup.

// server.ts
import { serve } from "@hono/node-server";
import { createHonoApp } from "@jayalfredprufrock/handshake/hono";
import { contract } from "./contract";

const users = new Map<string, { id: string; name: string; email: string }>();
let nextId = 1;

const api = createHonoApp(contract);

api.implement("getUser", ({ params }) => {
  const user = users.get(params.id);
  if (!user) {
    return new Response(JSON.stringify({ error: "Not found" }), { status: 404 });
  }
  return user;
});

api.implement("listUsers", () => [...users.values()]);

api.implement("createUser", ({ body }) => {
  const id = String(nextId++);
  const user = { id, ...body };
  users.set(id, user);
  return user;
});

api.implement("deleteUser", ({ params }) => {
  users.delete(params.id);
  return { id: params.id };
});

const app = api.build();

serve({ fetch: app.fetch, port: 3000 });

Handler inputs are fully typed — params, body, and query are inferred from the contract. Handlers can return plain objects (automatically serialized as JSON) or raw Response objects for full control.

The adapter validates incoming requests automatically:

  • Path params and query params are coerced to match the schema (e.g. string "42" becomes number 42).
  • Request bodies are validated and reject missing or extra properties.
  • Responses are validated by default — unknown properties are stripped, and type mismatches produce a 500 error. This can be disabled globally or per-handler:
// Disable response validation globally
const api = createHonoApp(contract, { validateResponse: false });

// Or per-handler
api.implement("listUsers", () => [...users.values()], { validateResponse: false });

Route ordering

At build(), routes are sorted by path specificity before being registered on Hono — literal segments take precedence over :param segments at the same position. This means a contract with /users/:id and /users/me resolves /users/me to the literal handler regardless of the order the endpoints appear in the contract. Within each specificity tier, registration order is preserved.

Bringing your own Hono app

Pass an existing Hono instance as the first argument to register the contract routes on it. This is the way to attach middleware or non-contract routes, and the app's Env generic (Bindings/Variables) is threaded through to c in every handler so c.env and c.var are fully typed.

import { Hono } from "hono";
import { logger } from "hono/logger";

type Env = { Variables: { user: { id: string } } };

const hono = new Hono<Env>();
hono.use("*", logger());
hono.get("/health", (c) => c.json({ ok: true }));

const api = createHonoApp(hono, contract);

api.implement("getUser", ({ c, params }) => {
  const user = c.var.user; // typed from Env
  return { id: params.id, name: user.id };
});

Splitting contracts across files

For larger apps, split the contract into one file per resource and implement each independently with implementContract. Pass the resulting route modules to createHonoApp as an array — each is mounted as an isolated Hono sub-app at the contract's basePath, so per-module middleware stays scoped to its own routes.

Authoring convention: give each sub-contract a basePath (e.g. "/users") and write endpoint paths relative to it (e.g. "/:id"). The basePath is what the sub-app is mounted at.

// contracts/users.ts — pure, no server imports
import { Type } from "typebox";
import { createContract } from "@jayalfredprufrock/handshake/contract";

export const usersContract = createContract("/users", {
  getUser: {
    method: "GET",
    path: "/:id",
    params: Type.Object({ id: Type.String() }),
    response: Type.Object({ id: Type.String(), name: Type.String() }),
  },
  listUsers: {
    method: "GET",
    path: "/",
    response: Type.Array(Type.Object({ id: Type.String(), name: Type.String() })),
  },
});
// routes/users.ts — object form: concise, type-checked for completeness
import { implementContract } from "@jayalfredprufrock/handshake/hono";
import { usersContract } from "../contracts/users";

export const usersRoute = implementContract(usersContract, {
  getUser: ({ params }) => ({ id: params.id, name: "Alice" }),
  listUsers: () => [],
});

Missing a handler for any contract key is a type error. Use the closure form when you need middleware or direct access to the Hono sub-app:

// routes/posts.ts — closure form: middleware + handlers
import { implementContract } from "@jayalfredprufrock/handshake/hono";
import { bearerAuth } from "hono/bearer-auth";
import { postsContract } from "../contracts/posts";

export const postsRoute = implementContract(postsContract, (app) => {
  app.use("*", bearerAuth({ token: process.env.API_TOKEN! }));

  app.implement("getPost", ({ params }) => ({ id: params.id, title: "Hello" }));
});

Assemble the server by passing the route modules to createHonoApp:

// server.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { createHonoApp } from "@jayalfredprufrock/handshake/hono";
import { usersRoute } from "./routes/users";
import { postsRoute } from "./routes/posts";

const root = new Hono();
root.use("*", logger()); // root-level middleware applies to every route

const app = createHonoApp(root, [usersRoute, postsRoute]);

For the client, compose a single contract with combineContracts. It merges the endpoints from each sub-contract, prefixing paths with each sub-contract's basePath, and throws if two sub-contracts define the same endpoint name:

// contracts/index.ts
import { combineContracts } from "@jayalfredprufrock/handshake/contract";
import { usersContract } from "./users";
import { postsContract } from "./posts";

export const contract = combineContracts("/api", [usersContract, postsContract]);

This barrel has zero server dependencies — safe to import from client bundles.

3. Create a Client

The client is generated directly from the contract — no code generation needed. Each endpoint becomes a typed method on the client object.

// client.ts
import { createFetchClient } from "@jayalfredprufrock/handshake/client";
import { contract } from "./contract";

const api = createFetchClient(contract, {
  baseUrl: "http://localhost:3000",
  async fetch(url, init) {
    const res = await fetch(url, {
      ...init,
      headers: { "content-type": "application/json" },
      body: init?.body ? JSON.stringify(init.body) : undefined,
    });
    return res.json();
  },
});

// All methods are fully typed
const users = await api.listUsers();
const created = await api.createUser({ name: "Alice", email: "[email protected]" });
const user = await api.getUser({ id: created.id });
await api.deleteUser({ id: created.id });

Method signatures adapt to the endpoint definition — endpoints with path params take a params object as the first argument, endpoints with a body take it next, and query/request options are always last.

License

MIT

TODO

  1. Investigate pnpm catalog system. Remove unnecessary vite catalogs if possible (since we'll manage vite stuff at the monorepo root). But consider using catalogs to keep typebox version in sync across the monorepo.