@firtoz/hono-fetcher
v2.6.0
Published
Type-safe Hono API client with full TypeScript inference for routes, params, and payloads
Downloads
784
Maintainers
Readme
@firtoz/hono-fetcher
Type-safe Hono API client with full TypeScript inference for routes, params, and payloads.
⚠️ Early WIP Notice: This package is in very early development and is not production-ready. It is TypeScript-only and may have breaking changes. While I (the maintainer) have limited time, I'm open to PRs for features, bug fixes, or additional support (like JS builds). Please feel free to try it out and contribute! See CONTRIBUTING.md for details.
Features
- 🔒 Fully Type-Safe - Complete TypeScript inference for routes, parameters, request bodies, and responses
- 🎯 Path Parameters - Automatic extraction and validation of path parameters (
:id,:slug, etc.) - 📝 Request Bodies - Type-safe JSON and form data support with automatic serialization
- 🌐 Cloudflare Workers - First-class support for Durable Objects with
honoDoFetcher - 🔌 WebSocket Support - Type-safe WebSocket connections with automatic acceptance and configuration
- 🚀 Zero Runtime Overhead - All type inference happens at compile time
- 🔄 Full HTTP Methods - Support for GET, POST, PUT, DELETE, and PATCH
Installation
bun add @firtoz/hono-fetcherPeer Dependencies
This package requires the following peer dependencies:
bun add honoFor Durable Object support, use wrangler types to generate accurate types:
wrangler typesThis generates worker-configuration.d.ts with types for your specific environment bindings.
Quick Start
Basic Usage
import { Hono } from 'hono';
import { honoFetcher } from '@firtoz/hono-fetcher';
// Define your Hono app
const app = new Hono()
.get('/users/:id', (c) => {
const id = c.req.param('id');
return c.json({ id, name: `User ${id}` });
})
.post('/users', async (c) => {
const body = await c.req.json<{ name: string }>();
return c.json({ id: '123', ...body });
});
// Create a typed fetcher
const api = honoFetcher<typeof app>(app.request);
// Use it with full type safety!
const response = await api.get({
url: '/users/:id',
params: { id: '123' }, // ✅ Type-safe params
});
const user = await response.json(); // ✅ Inferred type: { id: string; name: string }
// POST with body
await api.post({
url: '/users',
body: { name: 'John' }, // ✅ Type-safe body
});Remote API Usage
For remote APIs, you have two options:
Option 1: Using honoDirectFetcher (Recommended)
import { honoDirectFetcher } from '@firtoz/hono-fetcher';
import type { AppType } from './backend/app'; // Your backend app type
// Simply pass the base URL
const api = honoDirectFetcher<AppType>('https://api.example.com');
// Use it immediately
const response = await api.get({
url: '/users/:id',
params: { id: '123' }
});Option 2: Using honoFetcher with Custom Fetch
import { honoFetcher } from '@firtoz/hono-fetcher';
// For more control over the fetch behavior
const api = honoFetcher<typeof app>((url, init) => {
return fetch(`https://api.example.com${url}`, init);
});Durable Objects
import { honoDoFetcher, honoDoFetcherWithName } from '@firtoz/hono-fetcher';
import { DurableObject } from 'cloudflare:workers';
import { Hono } from 'hono';
// Define your Durable Object with a Hono app
export class ChatRoomDO extends DurableObject {
app = new Hono()
.get('/messages', (c) => {
return c.json({ messages: [] });
})
.post('/messages', async (c) => {
const { text } = await c.req.json<{ text: string }>();
return c.json({ id: '1', text });
});
fetch(request: Request) {
return this.app.fetch(request);
}
}
// `using api` disposes the stub. Returning the DO `Response` directly is a common pass-through pattern;
// if you consume the body in this worker instead, also dispose the RPC result (e.g. `using res`).
// See https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
using api = honoDoFetcherWithName(env.CHAT_ROOM, 'room-1');
return api.get({ url: '/messages' });
}
};API Reference
honoFetcher<T>(fetcher)
Creates a type-safe API client from a Hono app type.
Parameters
fetcher: (url: string, init?: RequestInit) => Response | Promise<Response>- Function that performs the actual fetch
Returns
A typed fetcher with methods for each HTTP verb: get, post, put, delete, patch
Example
const api = honoFetcher<typeof app>(app.request);honoDirectFetcher<T>(baseUrl)
Convenience wrapper around honoFetcher for remote APIs. Automatically prepends the base URL to all requests.
Parameters
baseUrl: string- The base URL of your API (e.g.,'https://api.example.com')
Returns
A typed fetcher with methods for each HTTP verb: get, post, put, delete, patch
Example
import { honoDirectFetcher } from '@firtoz/hono-fetcher';
import type { AppType } from './backend/app';
const api = honoDirectFetcher<AppType>('https://api.example.com');
// Make requests
const response = await api.get({
url: '/users/:id',
params: { id: '123' }
});Method Signature
All methods follow this signature:
method({
url: string; // The route path
params?: object; // Path parameters (required if route has :params)
body?: object; // Request body (for POST/PUT/PATCH)
form?: object; // Form data (for POST/PUT/PATCH)
init?: RequestInit; // Additional fetch options
})Path Parameters
Routes with path parameters (:id, :slug, etc.) require the params field:
const app = new Hono()
.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
.get('/posts/:id/comments/:commentId', (c) =>
c.json({
postId: c.req.param('id'),
commentId: c.req.param('commentId')
})
);
const api = honoFetcher<typeof app>(app.request);
// Single parameter
await api.get({
url: '/users/:id',
params: { id: '123' } // ✅ Required and type-safe
});
// Multiple parameters
await api.get({
url: '/posts/:id/comments/:commentId',
params: { id: '1', commentId: '42' } // ✅ Both required
});Request Bodies
JSON Bodies
const app = new Hono()
.post('/users', async (c) => {
const { name, email } = await c.req.json<{ name: string; email: string }>();
return c.json({ id: '1', name, email });
});
const api = honoFetcher<typeof app>(app.request);
await api.post({
url: '/users',
body: { name: 'John', email: '[email protected]' } // ✅ Type-safe
});Form Data
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono()
.post('/upload',
zValidator('form', z.object({
title: z.string(),
count: z.coerce.number()
})),
async (c) => {
const data = c.req.valid('form');
return c.json({ success: true, data });
}
);
const api = honoFetcher<typeof app>(app.request);
await api.post({
url: '/upload',
form: { title: 'Hello', count: '5' } // ✅ Automatically sent as FormData
});Custom Headers and Options
Pass additional fetch options via the init parameter:
await api.get({
url: '/users/:id',
params: { id: '123' },
init: {
headers: {
'Authorization': 'Bearer token',
'X-Custom-Header': 'value'
},
signal: abortController.signal
}
});WebSocket Support
hono-fetcher provides first-class support for WebSocket connections with full type safety.
Basic WebSocket Connection
import { honoFetcher } from '@firtoz/hono-fetcher';
const api = honoFetcher<typeof app>(fetcher);
// Connect to a WebSocket endpoint
const wsResponse = await api.websocket({
url: '/chat',
});
// Access the WebSocket
const ws = wsResponse.webSocket;
if (ws) {
ws.send(JSON.stringify({ type: 'hello' }));
ws.addEventListener('message', (event) => {
console.log('Received:', event.data);
});
}WebSocket with Auto-Accept
By default, WebSockets are automatically accepted for convenience:
// Default behavior - WebSocket is auto-accepted
const wsResp = await api.websocket({
url: '/websocket',
// config.autoAccept defaults to true
});
// WebSocket is ready to use immediately!
wsResp.webSocket?.send('Hello!');Manual WebSocket Acceptance
For advanced scenarios where you need control over when the WebSocket is accepted:
const wsResp = await api.websocket({
url: '/websocket',
config: { autoAccept: false }, // Disable auto-accept
});
const ws = wsResp.webSocket;
if (ws) {
// Set up your listeners first
ws.addEventListener('message', (event) => {
console.log('Message:', event.data);
});
// Then manually accept when ready
ws.accept();
}WebSocket with Path Parameters
const api = honoFetcher<typeof app>(fetcher);
// WebSocket endpoint with path parameters
const wsResp = await api.websocket({
url: '/rooms/:roomId/websocket',
params: { roomId: 'room-123' }, // Type-safe params!
});Integration with ZodWebSocketClient
For even better type safety, combine with @firtoz/websocket-do's ZodWebSocketClient:
import { ZodWebSocketClient } from '@firtoz/websocket-do';
import { honoDoFetcherWithName } from '@firtoz/hono-fetcher';
// 1. Connect to DO WebSocket
const api = honoDoFetcherWithName(env.CHAT_ROOM, 'room-1');
const wsResp = await api.websocket({
url: '/websocket',
config: { autoAccept: false }, // Let ZodWebSocketClient handle acceptance
});
// 2. Wrap with type-safe client
const client = new ZodWebSocketClient({
webSocket: wsResp.webSocket,
clientSchema: ClientMessageSchema,
serverSchema: ServerMessageSchema,
onMessage: (message) => {
// Fully typed message!
console.log('Received:', message);
},
});
// 3. Now accept
wsResp.webSocket?.accept();
// 4. Send type-safe messages
client.send({ type: 'chat', text: 'Hello!' }); // Validated with Zod!See the ZodWebSocketClient documentation for more details on type-safe WebSocket communication.
Durable Objects API
All of these return a fetcher that is also a Disposable for the stub: api[Symbol.dispose]() releases the Durable Object stub only.
RPC Response typing: honoDoFetcherWithName / honoDoFetcherWithId (and honoDoFetcher when you pass a full DurableObjectStub) use TypedDoFetcher: HTTP results are RpcDisposableJsonResponse and websocket is Response & Disposable, so using res = await … type-checks when Workers RPC attaches disposers. If you pass only Pick<DurableObjectStub, "fetch"> (minimal mock), the return type is TypedHonoFetcher<Hono> with plain JsonResponse / Response—not typed as Disposable, so we do not pretend mocks have RPC disposers. See Durable Object stubs and disposal and the RPC lifecycle doc.
honoDoFetcher<T>(stub)
Creates a typed fetcher for a Durable Object stub with support for both HTTP and WebSocket connections.
Returns: TypedDoFetcher<T> & Disposable when T is a full DurableObjectStub<DOWithHonoApp>; TypedHonoFetcher<Hono> & Disposable when T is only Pick<DurableObjectStub<DOWithHonoApp>, "fetch"> (e.g. unit tests).
using api = honoDoFetcher(env.MY_DO.getByName('example'));
// HTTP — dispose each RPC Response (stub disposal alone is not enough)
using res = await api.get({ url: '/status' });
const data = await res.json();
// WebSocket
using wsRes = await api.websocket({ url: '/ws' });
wsRes.webSocket?.accept();honoDoFetcherWithName<T>(namespace, name)
Convenience method to create a fetcher from a namespace and name. Uses a single stub internally and wires disposal to that stub.
Returns: TypedDoFetcher<DurableObjectStub<T>> & Disposable
using api = honoDoFetcherWithName(env.MY_DO, 'example');
using res = await api.get({ url: '/status' });
await res.json();
using wsRes = await api.websocket({ url: '/chat' });
wsRes.webSocket?.accept();honoDoFetcherWithId<T>(namespace, id)
Convenience method to create a fetcher from a namespace and hex ID string.
Returns: TypedDoFetcher<DurableObjectStub<T>> & Disposable
using api = honoDoFetcherWithId(env.MY_DO, 'abc123...');
using res = await api.get({ url: '/status' });
await res.json();Durable Object stubs and disposal
Cloudflare Workers RPC gives separate disposers for the Durable Object stub and for non-primitive RPC results such as the Response from stub.fetch(). Disposing only the stub can still produce “An RPC stub was not disposed properly” if the Response was not released. See the official Workers RPC lifecycle documentation.
- Stub (
using api = honoDoFetcherWithName(...)): Releases the stub when the block ends. This does not release RPCResponseobjects from individual requests. - Response (HTTP / WebSocket upgrade): On a real stub (
TypedDoFetcher), TypeScript types those results asDisposablesousing res/using wsResis valid. At runtime,[Symbol.dispose]is present when Workers RPC attaches it (often in production); minimalfetch-only mocks are typed as non-Disposable**Response**s because disposers are usually absent. Preferusing reswhen types allow it; otherwise callres[Symbol.dispose]()or use aDisposableStackas described in Cloudflare’s Workers RPC lifecycle documentation. Reading the body does not implicitly dispose the RPC result in this library. - Returning
Responseto the client: Do not useusing reson a response youreturnfrom your Worker—disposal can run on scope exit before the runtime finishes serving it. Return the value directly; see Workers RPC lifecycle for pass-through patterns. - Vite SSR, some Miniflare setups, or test mocks may expose stubs or
Responseobjects withoutSymbol.dispose; there is nothing to call in that case. - Errors from
Symbol.dispose: If the runtime’s dispose implementation throws (for example during unwind after your code threw), the library catches the error and logs it withconsole.error. It does not rethrow, so your original error is not masked by aSuppressedError. - TypeScript
using: Add"ESNext.Disposable"to thecompilerOptions.libarray in yourtsconfig.json(alongside your existing libs) sousingandDisposabletype-check. TypeScript 5.2+ is required forusing. That applies toTypedDoFetcher(fullDurableObjectStubpath):RpcDisposableJsonResponse/Response & Disposableonwebsocket.Pick<stub, "fetch">clients get ordinaryTypedHonoFetcherreturn types withoutDisposableon responses. Cloudflare’s runtime rules for RPC disposal are documented under Workers RPC lifecycle.
Type Exports
TypedHonoFetcher<T>
The main fetcher type with methods for all available HTTP verbs.
import type { TypedHonoFetcher } from '@firtoz/hono-fetcher';
function createApi(): TypedHonoFetcher<typeof app> {
return honoFetcher<typeof app>(app.request);
}JsonResponse<T>
Extended Response type with properly typed json() method.
import type { JsonResponse } from '@firtoz/hono-fetcher';
const response: JsonResponse<{ id: string }> = await api.get({ url: '/user' });
const data = await response.json(); // Type: { id: string }RpcDisposableJsonResponse<T> / BaseDisposableTypedHonoFetcher<T> / HonoDoFetcherStubInput
RpcDisposableJsonResponse<T> is JsonResponse<T> & Disposable. BaseDisposableTypedHonoFetcher<T> mirrors TypedHonoFetcher<T> but uses that for HTTP methods and Response & Disposable for websocket. TypedDoFetcher is BaseDisposableTypedHonoFetcher<Hono<…, DO schema>> — used only when honoDoFetcher is given a full DurableObjectStub, or when using honoDoFetcherWithName / honoDoFetcherWithId. HonoDoFetcherStubInput documents the DurableObjectStub | Pick<stub, "fetch"> union for honoDoFetcher. Background: Workers RPC lifecycle (official disposal / using / DisposableStack guidance).
WebSocketConfig
Configuration options for WebSocket connections.
import type { WebSocketConfig } from '@firtoz/hono-fetcher';
const config: WebSocketConfig = {
autoAccept: false, // Default: true
};
await api.websocket({ url: '/ws', config });Options:
autoAccept?: boolean- Whether to automatically callaccept()on the WebSocket. Defaults totruefor convenience. Set tofalseif you need manual control over when the WebSocket is accepted (e.g., when using withZodWebSocketClient).
ParsePathParams<T>
Utility type to extract path parameters from a route string.
import type { ParsePathParams } from '@firtoz/hono-fetcher';
type Params = ParsePathParams<'/users/:id/posts/:postId'>;
// Type: { id: string; postId: string }DOWithHonoApp
Type for Durable Objects that expose a Hono app.
import type { DOWithHonoApp } from '@firtoz/hono-fetcher';
export class MyDO extends DurableObject implements DOWithHonoApp {
app = new Hono()
.get('/status', (c) => c.json({ status: 'ok' }));
}Advanced Usage
Sharing Types Between Frontend and Backend
// backend/app.ts
export const app = new Hono()
.get('/users/:id', (c) => c.json({ id: c.req.param('id'), name: 'User' }))
.post('/users', async (c) => {
const body = await c.req.json<{ name: string }>();
return c.json({ id: '1', ...body });
});
export type AppType = typeof app;
// frontend/api.ts
import type { AppType } from '../backend/app';
import { honoFetcher } from '@firtoz/hono-fetcher';
export const api = honoFetcher<AppType>((url, init) => {
return fetch(`https://api.example.com${url}`, init);
});Error Handling
try {
const response = await api.post({
url: '/users',
body: { name: 'John' }
});
if (!response.ok) {
const error = await response.json();
console.error('API error:', error);
return;
}
const user = await response.json();
console.log('Created user:', user);
} catch (error) {
console.error('Network error:', error);
}Middleware and Authentication
const createAuthenticatedFetcher = <T extends Hono>(token: string) => {
return honoFetcher<T>((url, init) => {
return fetch(`https://api.example.com${url}`, {
...init,
headers: {
...init?.headers,
'Authorization': `Bearer ${token}`
}
});
});
};
const api = createAuthenticatedFetcher<typeof app>(userToken);License
MIT
Contributing
See CONTRIBUTING.md for details on how to contribute to this package.
