@firtoz/hono-fetcher 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,19 +131,13 @@ export class ChatRoomDO extends DurableObject {
131
131
  }
132
132
  }
133
133
 
134
- // In your worker
134
+ // `using api` disposes the stub. Returning the DO `Response` directly is a common pass-through pattern;
135
+ // if you consume the body in this worker instead, also dispose the RPC result (e.g. `using res`).
136
+ // See https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
135
137
  export default {
136
138
  async fetch(request: Request, env: Env): Promise<Response> {
137
- // Option 1: From a stub (using new getByName API)
138
- const stub = env.CHAT_ROOM.getByName('room-1');
139
- const api = honoDoFetcher(stub);
140
-
141
- // Option 2: Directly with name (recommended)
142
- const api2 = honoDoFetcherWithName(env.CHAT_ROOM, 'room-1');
143
-
144
- // Use it!
145
- const response = await api.get({ url: '/messages' });
146
- return response;
139
+ using api = honoDoFetcherWithName(env.CHAT_ROOM, 'room-1');
140
+ return api.get({ url: '/messages' });
147
141
  }
148
142
  };
149
143
  ```
@@ -414,44 +408,67 @@ See the [ZodWebSocketClient documentation](#) for more details on type-safe WebS
414
408
 
415
409
  ## Durable Objects API
416
410
 
411
+ All of these return a fetcher that is also a **`Disposable`** for the **stub**: **`api[Symbol.dispose]()`** releases the Durable Object stub only.
412
+
413
+ **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](#durable-object-stubs-and-disposal) and the [RPC lifecycle](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/) doc.
414
+
417
415
  ### `honoDoFetcher<T>(stub)`
418
416
 
419
417
  Creates a typed fetcher for a Durable Object stub with support for both HTTP and WebSocket connections.
420
418
 
419
+ **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).
420
+
421
421
  ```typescript
422
- const stub = env.MY_DO.getByName('example');
423
- const api = honoDoFetcher(stub);
422
+ using api = honoDoFetcher(env.MY_DO.getByName('example'));
424
423
 
425
- // HTTP requests
426
- await api.get({ url: '/status' });
424
+ // HTTP — dispose each RPC Response (stub disposal alone is not enough)
425
+ using res = await api.get({ url: '/status' });
426
+ const data = await res.json();
427
427
 
428
- // WebSocket connections
429
- const wsResp = await api.websocket({ url: '/ws' });
428
+ // WebSocket
429
+ using wsRes = await api.websocket({ url: '/ws' });
430
+ wsRes.webSocket?.accept();
430
431
  ```
431
432
 
432
433
  ### `honoDoFetcherWithName<T>(namespace, name)`
433
434
 
434
- Convenience method to create a fetcher from a namespace and name.
435
+ Convenience method to create a fetcher from a namespace and name. Uses a single stub internally and wires disposal to that stub.
436
+
437
+ **Returns:** `TypedDoFetcher<DurableObjectStub<T>> & Disposable`
435
438
 
436
439
  ```typescript
437
- const api = honoDoFetcherWithName(env.MY_DO, 'example');
440
+ using api = honoDoFetcherWithName(env.MY_DO, 'example');
438
441
 
439
- // HTTP
440
- await api.get({ url: '/status' });
442
+ using res = await api.get({ url: '/status' });
443
+ await res.json();
441
444
 
442
- // WebSocket
443
- await api.websocket({ url: '/chat' });
445
+ using wsRes = await api.websocket({ url: '/chat' });
446
+ wsRes.webSocket?.accept();
444
447
  ```
445
448
 
446
449
  ### `honoDoFetcherWithId<T>(namespace, id)`
447
450
 
448
451
  Convenience method to create a fetcher from a namespace and hex ID string.
449
452
 
453
+ **Returns:** `TypedDoFetcher<DurableObjectStub<T>> & Disposable`
454
+
450
455
  ```typescript
451
- const api = honoDoFetcherWithId(env.MY_DO, 'abc123...');
452
- await api.get({ url: '/status' });
456
+ using api = honoDoFetcherWithId(env.MY_DO, 'abc123...');
457
+ using res = await api.get({ url: '/status' });
458
+ await res.json();
453
459
  ```
454
460
 
461
+ ### Durable Object stubs and disposal
462
+
463
+ 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](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/)** documentation.
464
+
465
+ - **Stub (`using api = honoDoFetcherWithName(...)`):** Releases the **stub** when the block ends. This does **not** release RPC **`Response`** objects from individual requests.
466
+ - **Response (HTTP / WebSocket upgrade):** On a **real stub** (`TypedDoFetcher`), TypeScript types those results as **`Disposable`** so **`using res`** / **`using wsRes`** is valid. At runtime, **`[Symbol.dispose]`** is present when Workers RPC attaches it (often in production); **minimal `fetch`-only mocks** are typed as **non-**`Disposable` **`Response`**s because disposers are usually absent. Prefer **`using res`** when types allow it; otherwise call **`res[Symbol.dispose]()`** or use a **`DisposableStack`** as described in Cloudflare’s **[Workers RPC lifecycle](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/)** documentation. Reading the body does **not** implicitly dispose the RPC result in this library.
467
+ - **Returning `Response` to the client:** **Do not** use **`using res`** on a response you **`return`** from your Worker—disposal can run on scope exit before the runtime finishes serving it. Return the value directly; see **[Workers RPC lifecycle](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/)** for pass-through patterns.
468
+ - **Vite SSR, some Miniflare setups, or test mocks** may expose stubs or **`Response`** objects without **`Symbol.dispose`**; there is nothing to call in that case.
469
+ - **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 with **`console.error`**. It does **not** rethrow, so your original error is not masked by a `SuppressedError`.
470
+ - **TypeScript `using`:** Add **`"ESNext.Disposable"`** to the `compilerOptions.lib` array in your **`tsconfig.json`** (alongside your existing libs) so `using` and `Disposable` type-check. TypeScript 5.2+ is required for `using`. That applies to **`TypedDoFetcher`** (full **`DurableObjectStub`** path): **`RpcDisposableJsonResponse`** / **`Response & Disposable`** on **`websocket`**. **`Pick<stub, "fetch">`** clients get ordinary **`TypedHonoFetcher`** return types without **`Disposable`** on responses. Cloudflare’s runtime rules for RPC disposal are documented under **[Workers RPC lifecycle](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/)**.
471
+
455
472
  ## Type Exports
456
473
 
457
474
  ### `TypedHonoFetcher<T>`
@@ -477,6 +494,10 @@ const response: JsonResponse<{ id: string }> = await api.get({ url: '/user' });
477
494
  const data = await response.json(); // Type: { id: string }
478
495
  ```
479
496
 
497
+ ### `RpcDisposableJsonResponse<T>` / `BaseDisposableTypedHonoFetcher<T>` / `HonoDoFetcherStubInput`
498
+
499
+ **`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](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/)** (official disposal / `using` / `DisposableStack` guidance).
500
+
480
501
  ### `WebSocketConfig`
481
502
 
482
503
  Configuration options for WebSocket connections.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/hono-fetcher",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Type-safe Hono API client with full TypeScript inference for routes, params, and payloads",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -1,6 +1,10 @@
1
1
  import type { Hono, Schema } from "hono";
2
2
  import type { ExtractSchema } from "hono/types";
3
- import { honoFetcher, type TypedHonoFetcher } from "./honoFetcher";
3
+ import {
4
+ honoFetcher,
5
+ type BaseDisposableTypedHonoFetcher,
6
+ type TypedHonoFetcher,
7
+ } from "./honoFetcher";
4
8
 
5
9
  const DUMMY_URL = "http://dummy-url";
6
10
 
@@ -24,18 +28,94 @@ export type DOStubSchema<T extends DurableObjectStub> =
24
28
  : never
25
29
  : never;
26
30
 
27
- export type TypedDoFetcher<T extends DurableObjectStub> = TypedHonoFetcher<
28
- // biome-ignore lint/suspicious/noExplicitAny: Generic parameter needs flexibility
29
- Hono<any, DOStubSchema<T>>
30
- >;
31
+ /**
32
+ * Fetcher for a **real** `DurableObjectStub`: HTTP results are {@link RpcDisposableJsonResponse}
33
+ * and `websocket` returns `Response & Disposable`, matching Workers RPC when the runtime attaches
34
+ * disposers. Use with {@link honoDoFetcher} / {@link honoDoFetcherWithName} / {@link honoDoFetcherWithId}
35
+ * when `T` is a full stub—not with a minimal `Pick<stub, "fetch">` mock (that path uses plain
36
+ * {@link TypedHonoFetcher} responses instead).
37
+ *
38
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
39
+ */
40
+ export type TypedDoFetcher<T extends DurableObjectStub> =
41
+ BaseDisposableTypedHonoFetcher<
42
+ // biome-ignore lint/suspicious/noExplicitAny: Generic parameter needs flexibility
43
+ Hono<any, DOStubSchema<T>>
44
+ >;
45
+
46
+ /**
47
+ * Argument to {@link honoDoFetcher}: a **full** {@link DurableObjectStub} (production) or a minimal
48
+ * **`{ fetch }`** mock. Only the full stub is typed as {@link TypedDoFetcher} with disposable RPC
49
+ * responses; **`Pick<stub, "fetch">`** is typed as {@link TypedHonoFetcher} for `Hono` with ordinary
50
+ * `JsonResponse` / `Response` (no `Disposable` on results—matches mocks without `Symbol.dispose`).
51
+ *
52
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
53
+ */
54
+ export type HonoDoFetcherStubInput =
55
+ | DurableObjectStub<DOWithHonoApp>
56
+ | Pick<DurableObjectStub<DOWithHonoApp>, "fetch">;
57
+
58
+ function withStubDispose<
59
+ TStub extends Pick<DurableObjectStub<DOWithHonoApp>, "fetch">,
60
+ TS extends Schema,
61
+ >(
62
+ stub: TStub,
63
+ // biome-ignore lint/suspicious/noExplicitAny: Matches honoFetcher generic pattern for schema-driven apps
64
+ api: TypedHonoFetcher<Hono<any, TS>>,
65
+ // biome-ignore lint/suspicious/noExplicitAny: Matches honoFetcher generic pattern for schema-driven apps
66
+ ): TypedHonoFetcher<Hono<any, TS>> & Disposable {
67
+ return Object.assign(api, {
68
+ [Symbol.dispose]() {
69
+ // Stubs may omit Symbol.dispose (e.g. Vite mocks); DurableObjectStub types may not list it.
70
+ const disposeFn = Reflect.get(stub, Symbol.dispose);
71
+ if (typeof disposeFn !== "function") {
72
+ return;
73
+ }
74
+ try {
75
+ disposeFn.call(stub);
76
+ } catch (e) {
77
+ console.error(
78
+ "[@firtoz/hono-fetcher] Durable Object stub dispose failed",
79
+ e,
80
+ );
81
+ }
82
+ },
83
+ });
84
+ }
31
85
 
32
- export const honoDoFetcher = <const T extends DurableObjectStub<DOWithHonoApp>>(
86
+ /**
87
+ * Typed fetcher for a Durable Object stub.
88
+ *
89
+ * - **Full `DurableObjectStub`:** return type is {@link TypedDoFetcher} **`& Disposable`** — each
90
+ * HTTP/WebSocket result is typed as disposable (`RpcDisposableJsonResponse` / `Response & Disposable`)
91
+ * so **`using res = await …`** type-checks when `"ESNext.Disposable"` is in `lib`, matching Workers RPC
92
+ * when the runtime attaches `[Symbol.dispose]` (see `@see` below).
93
+ * - **`Pick<stub, "fetch">` only (e.g. tests):** return type is **`TypedHonoFetcher<Hono> & Disposable`**
94
+ * — same **`JsonResponse` / `Response`** shapes as {@link honoFetcher}; results are **not** typed as
95
+ * `Disposable` so typings are not faked for mocks that lack RPC disposers.
96
+ *
97
+ * Disposing only the fetcher (`using api = …`) releases the **stub**; RPC **`Response`** disposal
98
+ * (when applicable) is separate — prefer **`using res`**, **`res[Symbol.dispose]()`**, or **`DisposableStack`**.
99
+ *
100
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
101
+ */
102
+ export const honoDoFetcher = <const T extends HonoDoFetcherStubInput>(
33
103
  durableObject: T,
34
- ): TypedDoFetcher<T> => {
104
+ ): T extends DurableObjectStub<DOWithHonoApp>
105
+ ? TypedDoFetcher<T> & Disposable
106
+ : TypedHonoFetcher<Hono> & Disposable => {
107
+ type OutSchema =
108
+ T extends DurableObjectStub<DOWithHonoApp> ? DOStubSchema<T> : Schema;
35
109
  // biome-ignore lint/suspicious/noExplicitAny: Generic parameter needs flexibility
36
- return honoFetcher<Hono<any, DOStubSchema<T>>>((url, init) => {
110
+ const api = honoFetcher<Hono<any, OutSchema>>((url, init) => {
37
111
  return durableObject.fetch(`${DUMMY_URL}${url}`, init);
38
112
  });
113
+ return withStubDispose(
114
+ durableObject,
115
+ api,
116
+ ) as T extends DurableObjectStub<DOWithHonoApp>
117
+ ? TypedDoFetcher<T> & Disposable
118
+ : TypedHonoFetcher<Hono> & Disposable;
39
119
  };
40
120
 
41
121
  export const honoDoFetcherWithName = <
@@ -43,8 +123,11 @@ export const honoDoFetcherWithName = <
43
123
  >(
44
124
  namespace: DurableObjectNamespace<T>,
45
125
  name: string,
46
- ): TypedDoFetcher<DurableObjectStub<T>> => {
47
- return honoDoFetcher(namespace.getByName(name));
126
+ ): TypedDoFetcher<DurableObjectStub<T>> & Disposable => {
127
+ return honoDoFetcher(namespace.getByName(name)) as TypedDoFetcher<
128
+ DurableObjectStub<T>
129
+ > &
130
+ Disposable;
48
131
  };
49
132
 
50
133
  export const honoDoFetcherWithId = <
@@ -52,6 +135,8 @@ export const honoDoFetcherWithId = <
52
135
  >(
53
136
  namespace: DurableObjectNamespace<T>,
54
137
  id: string,
55
- ): TypedDoFetcher<DurableObjectStub<T>> => {
56
- return honoDoFetcher(namespace.get(namespace.idFromString(id)));
138
+ ): TypedDoFetcher<DurableObjectStub<T>> & Disposable => {
139
+ return honoDoFetcher(
140
+ namespace.get(namespace.idFromString(id)),
141
+ ) as TypedDoFetcher<DurableObjectStub<T>> & Disposable;
57
142
  };
@@ -29,6 +29,17 @@ export type JsonResponse<T> = Omit<Response, "json"> & {
29
29
  json: () => Promise<T>;
30
30
  };
31
31
 
32
+ /**
33
+ * {@link JsonResponse} intersected with `Disposable` for Workers RPC: `Response`
34
+ * values from `DurableObjectStub#fetch()` may implement `[Symbol.dispose]` even
35
+ * though `Fetcher.fetch` is still typed as `Promise<Response>`. Use with
36
+ * {@link BaseDisposableTypedHonoFetcher} (and `TypedDoFetcher` from `./honoDoFetcher`) so
37
+ * `using resp = await api.get(...)` type-checks when `"ESNext.Disposable"` is in `lib`.
38
+ *
39
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
40
+ */
41
+ export type RpcDisposableJsonResponse<T> = JsonResponse<T> & Disposable;
42
+
32
43
  type HasPathParams<T extends string> = T extends `${string}:${string}`
33
44
  ? true
34
45
  : false;
@@ -111,6 +122,16 @@ type SchemaOutput<
111
122
  ? JsonResponse<HonoSchema<T>[M][SchemaPath][DollarM]["output"]>
112
123
  : never;
113
124
 
125
+ type DoSchemaOutput<
126
+ T extends Hono,
127
+ M extends HttpMethod,
128
+ SchemaPath extends string & keyof HonoSchema<T>[M],
129
+ DollarM extends `$${M}` & keyof HonoSchema<T>[M][SchemaPath] = `$${M}` &
130
+ keyof HonoSchema<T>[M][SchemaPath],
131
+ > = "output" extends keyof HonoSchema<T>[M][SchemaPath][DollarM]
132
+ ? RpcDisposableJsonResponse<HonoSchema<T>[M][SchemaPath][DollarM]["output"]>
133
+ : never;
134
+
114
135
  type BodyParams<
115
136
  TApp extends Hono,
116
137
  TMethod extends HttpMethod,
@@ -171,6 +192,38 @@ export type BaseTypedHonoFetcher<T extends Hono> = {
171
192
  {}
172
193
  : { websocket: TypedWebSocketFetcher<T> });
173
194
 
195
+ type TypedDisposableMethodFetcher<T extends Hono, M extends HttpMethod> = <
196
+ SchemaPath extends string & keyof HonoSchema<T>[M],
197
+ >(
198
+ request: {
199
+ url: SchemaPath;
200
+ } & FetcherParams<SchemaPath> &
201
+ (M extends "get" | "delete" ? EmptyObject : BodyParams<T, M, SchemaPath>),
202
+ ) => Promise<DoSchemaOutput<T, M, SchemaPath>>;
203
+
204
+ export type TypedDisposableWebSocketFetcher<T extends Hono> = <
205
+ SchemaPath extends string & keyof HonoSchema<T>["get"],
206
+ >(
207
+ request: {
208
+ url: SchemaPath;
209
+ config?: WebSocketConfig;
210
+ } & FetcherParams<SchemaPath>,
211
+ ) => Promise<Response & Disposable>;
212
+
213
+ /**
214
+ * Same shape as {@link BaseTypedHonoFetcher} but HTTP methods return
215
+ * {@link RpcDisposableJsonResponse} and `websocket` returns `Response & Disposable`
216
+ * so `using` on RPC results type-checks for Durable Object clients.
217
+ *
218
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
219
+ */
220
+ export type BaseDisposableTypedHonoFetcher<T extends Hono> = {
221
+ [M in AvailableMethods<T>]: TypedDisposableMethodFetcher<T, M>;
222
+ } & (keyof HonoSchema<T>["get"] extends never
223
+ ? // biome-ignore lint/complexity/noBannedTypes: We really do want an empty object if the get method is not available
224
+ {}
225
+ : { websocket: TypedDisposableWebSocketFetcher<T> });
226
+
174
227
  const createMethodFetcher = <T extends Hono, M extends HttpMethod>(
175
228
  fetcher: (
176
229
  request: string,
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  type DOSchemaMap,
7
7
  type DOStubSchema,
8
8
  type DOWithHonoApp,
9
+ type HonoDoFetcherStubInput,
9
10
  honoDoFetcher,
10
11
  honoDoFetcherWithId,
11
12
  honoDoFetcherWithName,
@@ -13,6 +14,7 @@ export {
13
14
  } from "./honoDoFetcher";
14
15
  // Core fetcher functionality
15
16
  export {
17
+ type BaseDisposableTypedHonoFetcher,
16
18
  type BaseTypedHonoFetcher,
17
19
  type HonoFetcherQueryParamValue,
18
20
  type HonoFetcherQueryParams,
@@ -21,6 +23,8 @@ export {
21
23
  honoFetcher,
22
24
  type JsonResponse,
23
25
  type ParsePathParams,
26
+ type RpcDisposableJsonResponse,
27
+ type TypedDisposableWebSocketFetcher,
24
28
  type TypedHonoFetcher,
25
29
  type TypedWebSocketFetcher,
26
30
  type WebSocketConfig,