next-zero-rpc 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 caocchinh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,527 @@
1
+ # next-zero-rpc
2
+
3
+ Type-safe fetch for Next.js — zero runtime, zero config, zero dependencies.
4
+
5
+ ```bash
6
+ npx next-zero-rpc init
7
+ ```
8
+
9
+ **That's it.** Four files. Full type safety. 1.8 KB runtime.
10
+
11
+ ## What it does
12
+
13
+ `next-zero-rpc` gives you **compile-time type-safe `fetch`** for your Next.js App Router API routes — without changing how you write backends.
14
+
15
+ ```typescript
16
+ // ✅ Full autocomplete on paths, methods, and response types
17
+ const [data, err] = await apiFetch("/api/users/123", { method: "GET" });
18
+
19
+ // ✅ TypeScript errors on invalid paths
20
+ const [data, err] = await apiFetch("/api/typo", { method: "GET" }); // ← compile error
21
+
22
+ // ✅ TypeScript errors on invalid methods
23
+ const [data, err] = await apiFetch("/api/users/123", { method: "DELETE" }); // ← error if DELETE not exported
24
+
25
+ // ✅ Error type narrowing — err.code autocompletes only the errors THIS route can return
26
+ if (err) {
27
+ const code = err.code;
28
+ switch (code) {
29
+ case "auth:forbidden": // ← only if this route uses createApiError("auth:forbidden", ...)
30
+ case "system:database-error": // ← only if this route uses createApiError("system:database-error", ...)
31
+ case "system:unknown-error": // ← always included as a fallback from apiFetch itself
32
+ break;
33
+ default:
34
+ assertNever(code); // ← TypeScript errors if you miss a case
35
+ }
36
+ }
37
+ ```
38
+
39
+ ### How it compares
40
+
41
+ | Feature | next-zero-rpc | tRPC | raw fetch |
42
+ | ------------------------- | -------------------- | ----------- | --------- |
43
+ | Type-safe paths | ✅ | ✅ | ❌ |
44
+ | Type-safe responses | ✅ | ✅ | ❌ |
45
+ | Type-safe methods | ✅ | N/A | ❌ |
46
+ | **Error type narrowing** | ✅ | ❌ | ❌ |
47
+ | Zero runtime cost | ✅ (1.8 KB minified) | ❌ (~14 KB) | ✅ |
48
+ | Zero config | ✅ | ❌ | ✅ |
49
+ | Standard API routes | ✅ | ❌ | ✅ |
50
+ | Dynamic params `[id]` | ✅ | ✅ | N/A |
51
+ | Catch-all `[...slug]` | ✅ | ✅ | N/A |
52
+ | Go-style error handling | ✅ | ❌ | ❌ |
53
+ | Exhaustive error checking | ✅ | ❌ | ❌ |
54
+ | Server action helpers | ✅ | ❌ | N/A |
55
+ | Dependencies | 0 | 5+ | 0 |
56
+
57
+ ## Philosophy
58
+
59
+ **You own the code, not the library.** `next-zero-rpc` is not a locked-in framework—it's a philosophy, a paradigm, and a set of methods for doing things. When you run `init`, we give you four files. From that moment on, they are _yours_ to modify, extend, or delete.
60
+
61
+ - **Zero vendor lock-in** — There is no black-box `node_modules` dependency dictating your architecture. You own the fetch client, the error codes, and the registry generator.
62
+ - **Zero boilerplate** — You write standard Next.js API route handlers using simple response helpers — no decorators, no schema registrations, no complex abstractions. The codegen reads what already exists and builds the type bridge automatically.
63
+ - **Not a framework** — It's a type bridge. It infers what your route handlers return and gives your client code full type safety over those responses.
64
+ - **Validation is yours** — Input validation (Zod, Valibot, Arktype, manual checks) stays inside your route handler where it belongs. This library doesn't impose a validation layer — that's a feature, not a gap.
65
+ - **Non-invasive** — Unlike tRPC or ts-rest, you don't adopt a new API definition pattern. Your routes are regular Next.js routes. The library is invisible.
66
+
67
+ ## Setup
68
+
69
+ ### 1. Install
70
+
71
+ ```bash
72
+ npx next-zero-rpc init
73
+ ```
74
+
75
+ This copies 4 files into `lib/next-zero-rpc/` (or `src/lib/next-zero-rpc/` if your project uses the `src/` directory):
76
+
77
+ | File | Purpose | Ships to browser? |
78
+ | ------------------------- | ----------------------------------- | ---------------------- |
79
+ | `apiClient.ts` | Type-safe fetch wrapper | ✅ (0.6 KB minified) |
80
+ | `apiRegistry.ts` | Auto-generated route type registry | ❌ (types only) |
81
+ | `responses.ts` | Error/success helpers + error codes | ✅ (1.2 KB minified)\* |
82
+ | `update-api-registry.mjs` | Code generator + Next.js plugin | ❌ (dev only) |
83
+
84
+ _\* Only the `isApiErrorPayload` type guard and `ERROR_CODES` set are bundled to the client. The server helpers are dropped._
85
+
86
+ The CLI also:
87
+
88
+ - Runs the registry generator once to detect existing routes
89
+ - Adds an `"infer-api"` script to your `package.json` for manual regeneration
90
+
91
+ ### 2. Add the plugin to `next.config.ts`
92
+
93
+ ```typescript
94
+ // If using src/ directory:
95
+ import { withApiRegistry } from "./src/lib/next-zero-rpc/update-api-registry.mjs";
96
+
97
+ // If NOT using src/ directory:
98
+ import { withApiRegistry } from "./lib/next-zero-rpc/update-api-registry.mjs";
99
+
100
+ const nextConfig = {};
101
+
102
+ export default withApiRegistry(nextConfig);
103
+ ```
104
+
105
+ The plugin auto-updates `apiRegistry.ts` whenever you create, modify, or delete `route.ts` files during development. In production builds, it runs once and skips the file watcher.
106
+
107
+ ### 3. Use it
108
+
109
+ #### Route handlers — `createApiSuccess` / `createApiError`
110
+
111
+ ```typescript
112
+ // app/api/users/[userId]/route.ts
113
+ import {
114
+ createApiSuccess,
115
+ createApiError,
116
+ HTTP_STATUS_ERROR,
117
+ HTTP_STATUS_SUCCESS,
118
+ } from "@/lib/next-zero-rpc/responses";
119
+
120
+ export async function GET(req: Request, { params }: { params: Promise<{ userId: string }> }) {
121
+ const { userId } = await params;
122
+ const user = await db.users.find(userId);
123
+
124
+ if (!user) {
125
+ return createApiError("resource:not-found", HTTP_STATUS_ERROR.NOT_FOUND);
126
+ }
127
+
128
+ return createApiSuccess({ id: user.id, name: user.name });
129
+ }
130
+
131
+ export async function DELETE(req: Request, { params }: { params: Promise<{ userId: string }> }) {
132
+ const { userId } = await params;
133
+ await db.users.delete(userId);
134
+
135
+ // 204 No Content — no body per HTTP spec
136
+ return createApiSuccess();
137
+ }
138
+ ```
139
+
140
+ #### Client components — `apiFetch`
141
+
142
+ ```typescript
143
+ "use client";
144
+ import { apiFetch } from "@/lib/next-zero-rpc/apiClient";
145
+
146
+ const [data, err] = await apiFetch("/api/users/123", { method: "GET" });
147
+
148
+ if (err) {
149
+ console.error(err.code, err.message); // err.code is narrowed to only this route's errors
150
+ } else {
151
+ console.log(data.name); // ← fully typed, payload returned directly
152
+ }
153
+ ```
154
+
155
+ ## Features
156
+
157
+ ### Error Type Narrowing
158
+
159
+ The standout feature: when you check `err.code` after an `apiFetch` call, TypeScript narrows the union to **only the error codes that specific route handler can actually return** — not every error code in the system.
160
+
161
+ ```typescript
162
+ // Route handler returns createApiError("auth:unauthorized", 401)
163
+ // or createApiError("validation:missing-required-fields", 400)
164
+
165
+ const [data, err] = await apiFetch("/api/auth/login", { method: "POST" });
166
+
167
+ if (err) {
168
+ // err.code is: "auth:unauthorized" | "validation:missing-required-fields" | "validation:invalid-payload" | "system:unknown-error"
169
+ // NOT the full 37+ error code union — only what this route can produce
170
+ // (system:unknown-error is always included as a fallback from apiFetch itself)
171
+ }
172
+ ```
173
+
174
+ This works because:
175
+
176
+ 1. `createApiError` is generic: `createApiError<C extends ErrorCode>(code: C, ...) → NextResponse<ApiErrorPayload<C>>`
177
+ 2. TypeScript infers the literal `C` from each call site in your handler
178
+ 3. `InferErrorApiResponse` extracts the union of all `ApiErrorPayload<C>` types from the handler's return type
179
+ 4. The client sees only those specific error codes
180
+
181
+ ### Go-Style Tuple Returns
182
+
183
+ Every `apiFetch` call returns `[data, null]` on success or `[null, error]` on failure — never throws.
184
+
185
+ ```typescript
186
+ const [data, err] = await apiFetch("/api/orders/checkout", {
187
+ method: "POST",
188
+ body: JSON.stringify({ ticketId: "abc", quantity: 2 }),
189
+ });
190
+
191
+ if (err) {
192
+ // Handle error — err is fully typed with route-specific error codes
193
+ return;
194
+ }
195
+
196
+ // data is fully typed — no casting needed
197
+ console.log(data.orderId, data.status);
198
+ ```
199
+
200
+ ### Exhaustive Error Checking
201
+
202
+ Use `assertNever` to guarantee you handle every possible error code at compile time:
203
+
204
+ ```typescript
205
+ import { assertNever } from "@/lib/next-zero-rpc/responses";
206
+
207
+ const [data, err] = await apiFetch("/api/users/123", { method: "GET" });
208
+
209
+ if (err) {
210
+ const code = err.code;
211
+ switch (code) {
212
+ case "system:database-error":
213
+ showToast("Database error, please try again");
214
+ break;
215
+ case "system:unknown-error":
216
+ showToast("Something went wrong");
217
+ break;
218
+ default:
219
+ assertNever(code); // ← TypeScript errors if you miss a case
220
+ }
221
+ return;
222
+ }
223
+ ```
224
+
225
+ ### Dynamic Route Matching
226
+
227
+ The type system supports Next.js dynamic segments and catch-all routes:
228
+
229
+ ```typescript
230
+ // Dynamic segments: [id], [userId], [slug]
231
+ const [data, err] = await apiFetch("/api/users/abc-123", { method: "GET" });
232
+
233
+ // Catch-all segments: [...catchall]
234
+ const [data, err] = await apiFetch("/api/extreme/org1/projects/proj1/tasks/a/b/c", {
235
+ method: "POST",
236
+ });
237
+
238
+ // Query strings are stripped before matching
239
+ const [data, err] = await apiFetch("/api/users/123?include=profile", { method: "GET" });
240
+ ```
241
+
242
+ ### Route Groups Support
243
+
244
+ Next.js route groups like `(groupName)` are natively supported. They are automatically ignored in the URL path mapping and the generated TypeScript types, perfectly matching Next.js behavior:
245
+
246
+ ```typescript
247
+ // File: app/api/(admin)/users/route.ts
248
+
249
+ // The fetch URL correctly skips the (admin) group
250
+ const [data, err] = await apiFetch("/api/users", { method: "GET" });
251
+ ```
252
+
253
+ > [!NOTE]
254
+ > **Conflict-safe:** You don't have to worry about multiple route groups accidentally mapping to the exact same generated TypeScript identifier. Next.js enforces uniqueness during development and at build time. It will automatically raise a `Conflicting routes at /api/...` error if you try to create two identical endpoints (e.g., `(admin)/users/route.ts` and `(public)/users/route.ts`).
255
+
256
+ ### HTTP Method Validation
257
+
258
+ TypeScript only allows methods that your route handler actually exports:
259
+
260
+ ```typescript
261
+ // If route.ts only exports GET and POST:
262
+ await apiFetch("/api/auth/login", { method: "POST" }); // ✅
263
+ await apiFetch("/api/auth/login", { method: "GET" }); // ✅ (if exported)
264
+ await apiFetch("/api/auth/login", { method: "DELETE" }); // ❌ compile error
265
+ ```
266
+
267
+ ### Function Overloading on `createApiSuccess`
268
+
269
+ `createApiSuccess` uses TypeScript function overloading for precise return types:
270
+
271
+ ```typescript
272
+ // With data → NextResponse<T>
273
+ return createApiSuccess({ id: "123", name: "John" });
274
+
275
+ // Without data (204) → NextResponse<undefined>
276
+ return createApiSuccess();
277
+
278
+ // Without data (explicit 200 OK) → NextResponse<undefined>
279
+ return createApiSuccess(undefined, HTTP_STATUS_SUCCESS.OK);
280
+
281
+ // The overload enforces: if statusCode is NO_CONTENT, data must be undefined
282
+ return createApiSuccess({ id: "123" }, HTTP_STATUS_SUCCESS.NO_CONTENT); // ← type error
283
+ ```
284
+
285
+ ### Safely Handling Empty Responses
286
+
287
+ The `apiClient.ts` uses an incredibly robust approach to handle empty API responses, leveraging how Next.js and JavaScript parse JSON primitives.
288
+
289
+ If a route needs to return a `204 No Content` or an empty `200 OK`, `createApiSuccess(undefined)` intelligently evaluates to `new NextResponse(null)` to completely strip the HTTP body, avoiding Next.js `Response.json(undefined)` serialization crashes.
290
+
291
+ On the client side, `apiFetch` reads `res.text()` before attempting to parse JSON. Because valid JSON primitives like `0`, `null`, `false`, or `""` serialize into strings with a length > 0 (e.g. `"0"`, `'""'`), they evaluate to _truthy_ and are safely passed into `JSON.parse`. Only a genuinely empty HTTP body resolves to an empty string (`""`), which evaluates to _falsy_, allowing the client to safely resolve `payload = undefined` without throwing a JSON syntax error.
292
+
293
+ ## API Reference
294
+
295
+ ### `responses.ts`
296
+
297
+ #### Error Codes
298
+
299
+ Error codes follow a `prefix:description` convention enforced by the `PrefixedError<T>` template literal type. The built-in categories are:
300
+
301
+ | Category | Prefix | Examples |
302
+ | -------------- | ------------- | ---------------------------------------------------------- |
303
+ | System | `system:` | `system:internal-server-error`, `system:database-error` |
304
+ | Authentication | `auth:` | `auth:unauthorized`, `auth:token-expired` |
305
+ | Validation | `validation:` | `validation:invalid-payload`, `validation:duplicate-entry` |
306
+ | Resource | `resource:` | `resource:not-found`, `resource:already-exists` |
307
+ | Network | `network:` | `network:timeout`, `network:connection-refused` |
308
+ | Upload | `upload:` | `upload:file-too-large`, `upload:quota-exceeded` |
309
+
310
+ To add a custom category:
311
+
312
+ ```typescript
313
+ export const PAYMENT_ERRORS = [
314
+ "payment:card-declined",
315
+ "payment:insufficient-funds",
316
+ "payment:expired-card",
317
+ ] as const satisfies PrefixedError<"payment">[];
318
+
319
+ // Then add to ERROR_CODES:
320
+ export const ERROR_CODES = [
321
+ ...SYSTEM_ERRORS,
322
+ ...AUTH_ERRORS,
323
+ ...VALIDATION_ERRORS,
324
+ ...RESOURCE_ERRORS,
325
+ ...NETWORK_ERRORS,
326
+ ...UPLOAD_ERRORS,
327
+ ...PAYMENT_ERRORS, // ← add here
328
+ ] as const;
329
+ ```
330
+
331
+ #### HTTP Status Constants
332
+
333
+ Pre-defined, typed HTTP status code objects to prevent magic numbers:
334
+
335
+ ```typescript
336
+ HTTP_STATUS_SUCCESS.OK; // 200
337
+ HTTP_STATUS_SUCCESS.CREATED; // 201
338
+ HTTP_STATUS_SUCCESS.ACCEPTED; // 202
339
+ HTTP_STATUS_SUCCESS.NO_CONTENT; // 204
340
+
341
+ HTTP_STATUS_ERROR.BAD_REQUEST; // 400
342
+ HTTP_STATUS_ERROR.UNAUTHORIZED; // 401
343
+ HTTP_STATUS_ERROR.FORBIDDEN; // 403
344
+ HTTP_STATUS_ERROR.NOT_FOUND; // 404
345
+ HTTP_STATUS_ERROR.METHOD_NOT_ALLOWED; // 405
346
+ HTTP_STATUS_ERROR.NOT_ACCEPTABLE; // 406
347
+ HTTP_STATUS_ERROR.CONFLICT; // 409
348
+ HTTP_STATUS_ERROR.PAYLOAD_TOO_LARGE; // 413
349
+ HTTP_STATUS_ERROR.UNPROCESSABLE_ENTITY; // 422
350
+ HTTP_STATUS_ERROR.TOO_MANY_REQUESTS; // 429
351
+ HTTP_STATUS_ERROR.INTERNAL_SERVER_ERROR; // 500
352
+ HTTP_STATUS_ERROR.BAD_GATEWAY; // 502
353
+ HTTP_STATUS_ERROR.SERVICE_UNAVAILABLE; // 503
354
+ HTTP_STATUS_ERROR.GATEWAY_TIMEOUT; // 504
355
+ ```
356
+
357
+ #### Types
358
+
359
+ | Type | Description |
360
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------- |
361
+ | `ErrorCode` | Union of all error code string literals |
362
+ | `SuccessHttpStatusCode` | `200 \| 201 \| 202 \| 204` |
363
+ | `ErrorHttpStatusCode` | `400 \| 401 \| 403 \| ... \| 504` |
364
+ | `ApiErrorPayload<C>` | `{ code: C; details?: Record<string, string[]>; message?: string }` — generic over the specific error code |
365
+ | `ServiceError` | `{ code: ErrorCode; message: string; details?: ... }` |
366
+ | `ServiceResponse<S, E>` | `[S, null] \| [null, E]` — Go-style tuple for server actions |
367
+
368
+ #### Functions
369
+
370
+ | Function | Signature | Description |
371
+ | ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------- |
372
+ | `createApiError<C>` | `(code: C, statusCode, details?, message?) → NextResponse<ApiErrorPayload<C>>` | Create a typed API error response |
373
+ | `createApiSuccess<T>` | `(data: T, statusCode?) → NextResponse<T>` | Create a typed API success response |
374
+ | `createApiSuccess` | `(undefined, NO_CONTENT) → NextResponse<undefined>` | Overload for 204 No Content |
375
+ | `isApiErrorPayload` | `(payload: unknown) → payload is ApiErrorPayload<ErrorCode>` | Runtime type guard for error payloads |
376
+ | `createServiceError` | `(code, details?, message?) → [null, ServiceError]` | Go-style error for server actions |
377
+ | `createServiceSuccess<T>` | `(data?: T) → [T \| undefined, null]` | Go-style success for server actions |
378
+ | `assertNever` | `(value: never) → never` | Compile-time exhaustiveness guard |
379
+
380
+ ### `apiClient.ts`
381
+
382
+ #### `apiFetch<Path, Method>(path, options)`
383
+
384
+ Type-safe fetch wrapper that returns Go-style `[data, error]` tuples.
385
+
386
+ **Type parameters:**
387
+
388
+ - `Path` — validated against `KnownRoutes` at compile time
389
+ - `Method` — restricted to only the HTTP methods exported by the matched route
390
+
391
+ **Returns:** `Promise<[SuccessType, null] | [null, ErrorType | ApiFetchError]>`
392
+
393
+ - **Success:** `[data, null]` where `data` is inferred from the route handler's `createApiSuccess` calls
394
+ - **Error:** `[null, error]` where `error.code` is narrowed to only the error codes used in that route's `createApiError` calls, plus `"system:unknown-error"` as a fallback
395
+ - **204 No Content:** `[undefined, null]`
396
+
397
+ **Runtime behavior:**
398
+
399
+ 1. Handles `204 No Content` — returns `[undefined, null]` without parsing body
400
+ 2. Parses JSON responses based on `Content-Type` header
401
+ 3. Falls back to text for non-JSON responses
402
+ 4. Validates errors via `isApiErrorPayload` runtime type guard
403
+ 5. Catches network errors (offline, CORS) and wraps them as `system:unknown-error`
404
+
405
+ ### `apiRegistry.ts`
406
+
407
+ Auto-generated file containing:
408
+
409
+ 1. **`KnownRoutes`** — A type map from route path strings to their `typeof` module types
410
+ 2. **Path matching types** — Recursive template literal types that resolve runtime paths (with dynamic segments) to their registry entries:
411
+ - `Split<S>` — Splits a path string into a tuple of segments
412
+ - `MatchSegment<P, K>` — Matches a runtime segment against a route pattern segment (supports `[param]`)
413
+ - `MatchSegments<P, K>` — Recursively matches all segments (supports `[...catchall]`)
414
+ - `StripQuery<Path>` — Strips query string before matching
415
+ - `FindMatchingRoute<Path>` — Resolves a runtime path to its `KnownRoutes` key
416
+ - `CheckPath<Path>` — Validates a path at compile time, falling back to autocomplete hints on mismatch
417
+
418
+ ### `update-api-registry.mjs`
419
+
420
+ Code generator and Next.js plugin:
421
+
422
+ - **`updateApiRegistry()`** — Scans `app/api/` for `route.ts` files, generates type imports and the `KnownRoutes` map
423
+ - **`withApiRegistry(nextConfig)`** — Next.js plugin that runs the generator on startup and watches for changes in development mode
424
+ - Automatically detects `src/` vs root directory layout
425
+ - Groups imports and type entries by top-level API directory
426
+ - De-duplicates watcher setup via `globalThis.__apiRegistryWatcherSetup`
427
+ - Debounces file system events (100ms) to prevent redundant regeneration
428
+
429
+ ## How it works
430
+
431
+ ```
432
+ ┌─────────────────────────────────────────────────────────────────┐
433
+ │ Build / Dev Time │
434
+ │ │
435
+ │ app/api/***/route.ts ──→ update-api-registry.mjs │
436
+ │ │ │
437
+ │ ▼ │
438
+ │ apiRegistry.ts │
439
+ │ (KnownRoutes type map) │
440
+ │ │ │
441
+ │ ▼ │
442
+ │ apiClient.ts ◄──── TypeScript infers paths, methods, │
443
+ │ success types, AND error types │
444
+ └─────────────────────────────────────────────────────────────────┘
445
+
446
+ ┌───────────────────────────────────────────────────────────────────┐
447
+ │ Runtime (1.8 KB) │
448
+ │ │
449
+ │ apiFetch("/api/users/123", { method: "GET" }) │
450
+ │ │ │
451
+ │ ▼ │
452
+ │ fetch(path, options) │
453
+ │ │ │
454
+ │ ├─ 204? → [undefined, null] │
455
+ │ ├─ JSON + ok? → [payload, null] │
456
+ │ ├─ JSON + !ok? → [null, ApiErrorPayload] (narrowed) │
457
+ │ ├─ non-JSON? → [text, null] │
458
+ │ └─ network fail? → [null, { code: "system:unknown-error" }] │
459
+ └───────────────────────────────────────────────────────────────────┘
460
+ ```
461
+
462
+ 1. The `withApiRegistry` plugin scans your `app/api/` directory for `route.ts` files
463
+ 2. It generates `import type * as ...` statements in `apiRegistry.ts` mapping each route path to its module type
464
+ 3. TypeScript infers the available methods and response types from each route's exports
465
+ 4. `createApiError<C>` preserves the literal error code `C` in the return type, enabling per-route error narrowing
466
+ 5. `apiFetch` uses these types to validate paths, methods, and infer both success AND error shapes at compile time
467
+ 6. At runtime, `apiFetch` is just a thin `fetch` wrapper with Go-style `[data, error]` returns
468
+
469
+ The registry auto-updates when you create, modify, or delete route files during development.
470
+
471
+ ## Server Actions / Service Layer
472
+
473
+ For server-side code (server actions, service functions), use the Go-style service helpers:
474
+
475
+ ```typescript
476
+ // services/user.ts
477
+ import { createServiceError, createServiceSuccess } from "@/lib/next-zero-rpc/responses";
478
+
479
+ export async function getUser(userId: string) {
480
+ const user = await db.users.find(userId);
481
+
482
+ if (!user) {
483
+ return createServiceError("resource:not-found", undefined, "User not found");
484
+ }
485
+
486
+ return createServiceSuccess({ id: user.id, name: user.name });
487
+ }
488
+
489
+ // Usage in a server action or component:
490
+ const [user, err] = await getUser("123");
491
+
492
+ if (err) {
493
+ console.error(err.code, err.message);
494
+ return;
495
+ }
496
+
497
+ console.log(user.name);
498
+ ```
499
+
500
+ ## Customization
501
+
502
+ Since you own all the source files, you can customize anything:
503
+
504
+ - **Add error codes** — Add new arrays in `responses.ts` with the `PrefixedError<"prefix">` constraint
505
+ - **Add auth headers** — Modify `apiFetch` to inject tokens automatically
506
+ - **Add request body types** — Extend the type inference in `apiClient.ts`
507
+ - **Change the output path** — Update `REGISTRY_FILE` in `update-api-registry.mjs`
508
+ - **Override the generator** — The `withApiRegistry` plugin and `updateApiRegistry()` function are fully yours to modify
509
+
510
+ ## CLI Usage
511
+
512
+ ```bash
513
+ npx next-zero-rpc # Install files (same as init)
514
+ npx next-zero-rpc init # Install files into your project
515
+ npx next-zero-rpc --force # Overwrite existing files
516
+ npx next-zero-rpc --help # Show help
517
+ ```
518
+
519
+ ## Requirements
520
+
521
+ - Next.js (App Router)
522
+ - TypeScript
523
+ - Node.js ≥ 18
524
+
525
+ ## License
526
+
527
+ MIT