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/bin/cli.mjs ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
9
+
10
+ const CYAN = "\x1b[36m";
11
+ const GREEN = "\x1b[32m";
12
+ const YELLOW = "\x1b[33m";
13
+ const RED = "\x1b[31m";
14
+ const DIM = "\x1b[2m";
15
+ const BOLD = "\x1b[1m";
16
+ const RESET = "\x1b[0m";
17
+
18
+ function log(msg) {
19
+ console.log(msg);
20
+ }
21
+
22
+ function success(msg) {
23
+ console.log(`${GREEN}✓${RESET} ${msg}`);
24
+ }
25
+
26
+ function warn(msg) {
27
+ console.log(`${YELLOW}⚠${RESET} ${msg}`);
28
+ }
29
+
30
+ function error(msg) {
31
+ console.error(`${RED}✗${RESET} ${msg}`);
32
+ }
33
+
34
+ const HELP_TEXT = `
35
+ ${BOLD}next-zero-rpc${RESET} — Type-safe fetch for Next.js
36
+
37
+ ${BOLD}Usage:${RESET}
38
+ npx next-zero-rpc Install files into your Next.js project
39
+ npx next-zero-rpc --force Overwrite existing files
40
+ npx next-zero-rpc --help Show this help message
41
+
42
+ ${BOLD}What it does:${RESET}
43
+ Copies 4 files into ${CYAN}lib/next-zero-rpc/${RESET} (or ${CYAN}src/lib/next-zero-rpc/${RESET} if src/ exists):
44
+ • apiClient.ts — Type-safe fetch wrapper (1.8 KB runtime)
45
+ • apiRegistry.ts — Auto-generated route type registry
46
+ • responses.ts — Error/success response helpers
47
+ • update-api-registry.mjs — Code generator + Next.js plugin
48
+
49
+ ${DIM}Zero dependencies. Zero runtime overhead. Full type safety.${RESET}
50
+ `;
51
+
52
+ function detectProjectRoot() {
53
+ const cwd = process.cwd();
54
+
55
+ // Check for Next.js indicators
56
+ const hasPackageJson = fs.existsSync(path.join(cwd, "package.json"));
57
+ if (!hasPackageJson) {
58
+ error("No package.json found. Run this from your Next.js project root.");
59
+ process.exit(1);
60
+ }
61
+
62
+ const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
63
+ const allDeps = {
64
+ ...packageJson.dependencies,
65
+ ...packageJson.devDependencies,
66
+ };
67
+
68
+ if (!allDeps.next) {
69
+ error("This doesn't appear to be a Next.js project (no 'next' in dependencies).");
70
+ process.exit(1);
71
+ }
72
+
73
+ // Detect src directory — works with or without it
74
+ const hasSrc = fs.existsSync(path.join(cwd, "src"));
75
+
76
+ return { root: cwd, hasSrc };
77
+ }
78
+
79
+ async function init(flags) {
80
+ const force = flags.includes("--force");
81
+
82
+ log("");
83
+ log(`${BOLD}${CYAN}next-zero-rpc${RESET} ${DIM}v${getVersion()}${RESET}`);
84
+ log("");
85
+
86
+ const { root, hasSrc } = detectProjectRoot();
87
+ const baseDir = hasSrc ? "src" : ".";
88
+ const targetDir = path.join(root, baseDir, "lib", "next-zero-rpc");
89
+ const configImportPrefix = hasSrc ? "./src" : ".";
90
+
91
+ log(`${DIM}Detected project layout: ${hasSrc ? "src/" : "root (no src/)"} ${RESET}`);
92
+ log("");
93
+
94
+ // Create target directory
95
+ fs.mkdirSync(targetDir, { recursive: true });
96
+
97
+ const files = ["apiClient.ts", "apiRegistry.ts", "responses.ts", "update-api-registry.mjs"];
98
+
99
+ let skipped = 0;
100
+ let written = 0;
101
+
102
+ for (const file of files) {
103
+ const src = path.join(TEMPLATES_DIR, file);
104
+ const dest = path.join(targetDir, file);
105
+
106
+ if (fs.existsSync(dest) && !force) {
107
+ warn(`${file} already exists ${DIM}(use --force to overwrite)${RESET}`);
108
+ skipped++;
109
+ continue;
110
+ }
111
+
112
+ fs.copyFileSync(src, dest);
113
+ success(`${file}`);
114
+ written++;
115
+ }
116
+
117
+ // Run update-api-registry once to populate the registry with existing routes
118
+ try {
119
+ const registryScript = path.join(targetDir, "update-api-registry.mjs");
120
+ const { updateApiRegistry } = await import(registryScript);
121
+ updateApiRegistry();
122
+ success("API registry updated");
123
+ } catch (e) {
124
+ warn(`Could not auto-update registry: ${e.message}`);
125
+ }
126
+
127
+ // Add "infer-api" script to package.json (safely, no overwrite)
128
+ try {
129
+ const pkgPath = path.join(root, "package.json");
130
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
131
+ const scriptCmd = `node ${baseDir === "." ? "" : baseDir + "/"}lib/next-zero-rpc/update-api-registry.mjs`;
132
+
133
+ if (!pkg.scripts) pkg.scripts = {};
134
+
135
+ if (pkg.scripts["infer-api"]) {
136
+ log(`${DIM}infer-api script already exists in package.json${RESET}`);
137
+ } else {
138
+ pkg.scripts["infer-api"] = scriptCmd;
139
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
140
+ success(`Added ${BOLD}"infer-api"${RESET}${GREEN} script to package.json${RESET}`);
141
+ }
142
+ } catch (e) {
143
+ warn(`Could not update package.json: ${e.message}`);
144
+ }
145
+
146
+ log("");
147
+
148
+ if (written === 0 && skipped > 0) {
149
+ log(`${DIM}All files already exist. Use ${YELLOW}--force${RESET}${DIM} to overwrite.${RESET}`);
150
+ log("");
151
+ return;
152
+ }
153
+
154
+ // Print next steps
155
+ log(`${BOLD}Next steps:${RESET}`);
156
+ log("");
157
+ log(` ${CYAN}1.${RESET} Update your ${BOLD}next.config.ts${RESET}:`);
158
+ log("");
159
+ log(
160
+ ` ${DIM}import { withApiRegistry } from "${configImportPrefix}/lib/next-zero-rpc/update-api-registry.mjs";${RESET}`,
161
+ );
162
+ log(` ${DIM}export default withApiRegistry(nextConfig);${RESET}`);
163
+ log("");
164
+ log(` ${CYAN}2.${RESET} Use ${BOLD}apiFetch${RESET} in your client components:`);
165
+ log("");
166
+ log(` ${DIM}import { apiFetch } from "@/lib/next-zero-rpc/apiClient";${RESET}`);
167
+ log("");
168
+ log(
169
+ ` ${DIM}const [data, err] = await apiFetch("/api/users/123", { method: "GET" });${RESET}`,
170
+ );
171
+ log("");
172
+ log(
173
+ ` ${CYAN}3.${RESET} Use ${BOLD}createApiSuccess${RESET} / ${BOLD}createApiError${RESET} in your route handlers:`,
174
+ );
175
+ log("");
176
+ log(
177
+ ` ${DIM}import { createApiSuccess, createApiError } from "@/lib/next-zero-rpc/responses";${RESET}`,
178
+ );
179
+ log("");
180
+ log(`${DIM}The registry auto-updates when you create/delete route.ts files.${RESET}`);
181
+ log("");
182
+ }
183
+
184
+ function getVersion() {
185
+ try {
186
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
187
+ return pkg.version;
188
+ } catch {
189
+ return "0.0.0";
190
+ }
191
+ }
192
+
193
+ // --- CLI Entry ---
194
+ const args = process.argv.slice(2);
195
+ const command = args[0];
196
+
197
+ if (command === "--help" || command === "-h") {
198
+ log(HELP_TEXT);
199
+ process.exit(0);
200
+ }
201
+
202
+ // Default: run init (with or without "init" subcommand)
203
+ if (!command || command === "init") {
204
+ init(args.slice(command === "init" ? 1 : 0));
205
+ } else if (command === "--force") {
206
+ // Allow: npx next-zero-rpc --force
207
+ init(args);
208
+ } else {
209
+ error(`Unknown command: ${command}`);
210
+ log(`Run ${CYAN}npx next-zero-rpc --help${RESET} for usage.`);
211
+ process.exit(1);
212
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "next-zero-rpc",
3
+ "version": "0.1.0",
4
+ "description": "Type-safe fetch for Next.js — zero runtime, zero config, zero dependencies.",
5
+ "keywords": [
6
+ "nextjs",
7
+ "typescript",
8
+ "rpc",
9
+ "type-safe",
10
+ "fetch",
11
+ "api",
12
+ "codegen"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "caocchinh",
16
+ "type": "module",
17
+ "bin": {
18
+ "next-zero-rpc": "bin/cli.mjs"
19
+ },
20
+ "files": [
21
+ "bin/",
22
+ "templates/"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/caocchinh/next-zero-rpc"
27
+ },
28
+ "homepage": "https://github.com/caocchinh/next-zero-rpc#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/caocchinh/next-zero-rpc/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }
@@ -0,0 +1,114 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { CheckPath, FindMatchingRoute, KnownRoutes } from "./apiRegistry";
3
+ import { ApiErrorPayload, ErrorCode, isApiErrorPayload } from "./responses";
4
+
5
+ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
6
+
7
+ // ─── Type Inference ─────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Infer the success response type from an API route handler function.
11
+ * Filters out ApiErrorPayload to only return the success payload.
12
+ */
13
+ type InferSuccessApiResponse<T, E = never> = T extends (...args: never[]) => infer R
14
+ ? Extract<Awaited<R>, NextResponse<unknown>> extends NextResponse<infer U>
15
+ ? Exclude<U, E>
16
+ : never
17
+ : never;
18
+
19
+ type InferErrorApiResponse<T, E = never> = T extends (...args: never[]) => infer R
20
+ ? Extract<Awaited<R>, NextResponse<unknown>> extends NextResponse<infer U>
21
+ ? Extract<U, E>
22
+ : never
23
+ : never;
24
+
25
+ type ResolveRoute<Path extends string> =
26
+ FindMatchingRoute<Path> extends keyof KnownRoutes ? FindMatchingRoute<Path> : never;
27
+
28
+ type RouteMethods<Path extends string> = Extract<keyof KnownRoutes[ResolveRoute<Path>], HttpMethod>;
29
+
30
+ type RouteSuccessResult<Path extends string, M extends HttpMethod> = InferSuccessApiResponse<
31
+ KnownRoutes[ResolveRoute<Path>][M & keyof KnownRoutes[ResolveRoute<Path>]],
32
+ ApiErrorPayload<ErrorCode>
33
+ >;
34
+
35
+ type RouteErrorResult<Path extends string, M extends HttpMethod> = InferErrorApiResponse<
36
+ KnownRoutes[ResolveRoute<Path>][M & keyof KnownRoutes[ResolveRoute<Path>]],
37
+ ApiErrorPayload<ErrorCode>
38
+ >;
39
+
40
+ export async function apiFetch<
41
+ Path extends string,
42
+ Method extends RouteMethods<Path> = RouteMethods<Path>,
43
+ >(
44
+ path: Path extends CheckPath<Path> ? Path : CheckPath<Path>,
45
+ options: RequestInit & { method: Method },
46
+ ): Promise<
47
+ | [RouteSuccessResult<Path, Method>, null]
48
+ | [null, RouteErrorResult<Path, Method> | ApiErrorPayload<"system:unknown-error">]
49
+ >;
50
+
51
+ export async function apiFetch(
52
+ path: string,
53
+ options: RequestInit & { method: string },
54
+ ): Promise<unknown> {
55
+ try {
56
+ const res = await fetch(path, options);
57
+
58
+ // 1. Read the body as text first to safely handle empty responses.
59
+ const text = await res.text();
60
+
61
+ let payload;
62
+ // An empty HTTP body resolves to an empty string "" (falsy).
63
+ // Valid JSON primitives like `0`, `null`, `false`, or `""` serialize to
64
+ // length > 0 strings (e.g. `"0"`, `"null"`, `'""'`), which are all truthy.
65
+ // This perfectly catches empty responses (like 204) while preserving valid JSON.
66
+ if (!text) {
67
+ payload = undefined;
68
+ } else {
69
+ // 2. Parse the payload safely based on Content-Type
70
+ const contentType = res.headers.get("content-type");
71
+ if (contentType && contentType.includes("application/json")) {
72
+ try {
73
+ payload = JSON.parse(text);
74
+ } catch {
75
+ return [
76
+ null,
77
+ {
78
+ code: "system:unknown-error",
79
+ message: "Server returned malformed JSON.",
80
+ },
81
+ ];
82
+ }
83
+ } else {
84
+ // For non-JSON responses, return the raw text
85
+ payload = text;
86
+ }
87
+ }
88
+
89
+ // 3. Strict error validation
90
+ if (!res.ok) {
91
+ return [
92
+ null,
93
+ isApiErrorPayload(payload)
94
+ ? payload
95
+ : {
96
+ code: "system:unknown-error",
97
+ message: "An error occurred but the server returned an unrecognized format.",
98
+ },
99
+ ];
100
+ }
101
+
102
+ // 4. Return the full response payload directly to the developer
103
+ return [payload, null];
104
+ } catch (error) {
105
+ // Network errors (like offline), CORS errors, etc.
106
+ return [
107
+ null,
108
+ {
109
+ code: "system:unknown-error",
110
+ message: error instanceof Error ? error.message : "A network error occurred.",
111
+ },
112
+ ];
113
+ }
114
+ }
@@ -0,0 +1,50 @@
1
+ // --- BEGIN GENERATED API REGISTRY ---
2
+ // This section is auto-generated. Do not edit manually.
3
+ // Run your dev server or `node lib/next-zero-rpc/update-api-registry.mjs` to regenerate.
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
6
+ export type KnownRoutes = {
7
+ // Routes will be auto-populated here
8
+ };
9
+ // --- END GENERATED API REGISTRY ---
10
+
11
+ type Split<S extends string> = S extends `${infer Head}/${infer Tail}`
12
+ ? [Head, ...Split<Tail>]
13
+ : [S];
14
+
15
+ type MatchSegment<P extends string, K extends string> = K extends `[${string}]`
16
+ ? P extends ""
17
+ ? false
18
+ : true
19
+ : K extends P
20
+ ? true
21
+ : false;
22
+
23
+ type MatchSegments<P extends string[], K extends string[]> = K extends []
24
+ ? P extends []
25
+ ? true
26
+ : false
27
+ : K extends [`[...${string}]`]
28
+ ? true
29
+ : [P, K] extends [
30
+ [infer PH extends string, ...infer PT extends string[]],
31
+ [infer KH extends string, ...infer KT extends string[]],
32
+ ]
33
+ ? MatchSegment<PH, KH> extends true
34
+ ? MatchSegments<PT, KT>
35
+ : false
36
+ : false;
37
+
38
+ type StripQuery<Path extends string> = Path extends `${infer Base}?${string}` ? Base : Path;
39
+
40
+ export type FindMatchingRoute<Path extends string> = {
41
+ [K in keyof KnownRoutes & string]: MatchSegments<Split<StripQuery<Path>>, Split<K>> extends true
42
+ ? K
43
+ : never;
44
+ }[keyof KnownRoutes & string];
45
+
46
+ export type CheckPath<Path extends string> = Path extends ""
47
+ ? keyof KnownRoutes
48
+ : FindMatchingRoute<Path> extends never
49
+ ? keyof KnownRoutes
50
+ : Path;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * next-zero-rpc — Response helpers for API routes and server actions.
3
+ * Customize the error codes below to match your application's domain.
4
+ */
5
+
6
+ import { NextResponse } from "next/server";
7
+
8
+ type PrefixedError<T extends string> = `${T}:${string}`;
9
+
10
+ // ─── Define your error codes here ───────────────────────────────────────────
11
+ // Add, remove, or rename error codes to match your application's domain.
12
+ // Each array must satisfy the PrefixedError<"prefix"> constraint.
13
+
14
+ export const SYSTEM_ERRORS = [
15
+ "system:internal-server-error",
16
+ "system:unknown-error",
17
+ "system:database-error",
18
+ "system:timeout",
19
+ "system:service-unavailable",
20
+ "system:maintenance-mode",
21
+ "system:configuration-error",
22
+ ] as const satisfies PrefixedError<"system">[];
23
+
24
+ export const AUTH_ERRORS = [
25
+ "auth:unauthorized",
26
+ "auth:forbidden",
27
+ "auth:not-logged-in",
28
+ "auth:token-expired",
29
+ "auth:invalid-token",
30
+ "auth:session-expired",
31
+ "auth:insufficient-permissions",
32
+ "auth:account-locked",
33
+ "auth:account-disabled",
34
+ "auth:email-not-verified",
35
+ ] as const satisfies PrefixedError<"auth">[];
36
+
37
+ export const VALIDATION_ERRORS = [
38
+ "validation:missing-required-fields",
39
+ "validation:invalid-payload",
40
+ "validation:rate-limit-exceeded",
41
+ "validation:invalid-format",
42
+ "validation:invalid-type",
43
+ "validation:out-of-range",
44
+ "validation:too-short",
45
+ "validation:too-long",
46
+ "validation:duplicate-entry",
47
+ "validation:invalid-enum-value",
48
+ ] as const satisfies PrefixedError<"validation">[];
49
+
50
+ export const RESOURCE_ERRORS = [
51
+ "resource:not-found",
52
+ "resource:already-exists",
53
+ "resource:conflict",
54
+ "resource:gone",
55
+ "resource:locked",
56
+ "resource:immutable",
57
+ ] as const satisfies PrefixedError<"resource">[];
58
+
59
+ export const NETWORK_ERRORS = [
60
+ "network:timeout",
61
+ "network:external-service-error",
62
+ "network:dns-resolution-failed",
63
+ "network:connection-refused",
64
+ ] as const satisfies PrefixedError<"network">[];
65
+
66
+ export const UPLOAD_ERRORS = [
67
+ "upload:file-too-large",
68
+ "upload:invalid-file-type",
69
+ "upload:upload-failed",
70
+ "upload:quota-exceeded",
71
+ ] as const satisfies PrefixedError<"upload">[];
72
+
73
+ // ─── Combine all error codes ────────────────────────────────────────────────
74
+ // Add your custom error arrays here when you create them.
75
+
76
+ export const ERROR_CODES = [
77
+ ...SYSTEM_ERRORS,
78
+ ...AUTH_ERRORS,
79
+ ...VALIDATION_ERRORS,
80
+ ...RESOURCE_ERRORS,
81
+ ...NETWORK_ERRORS,
82
+ ...UPLOAD_ERRORS,
83
+ ] as const;
84
+
85
+ const ERROR_CODE_SET: ReadonlySet<string> = new Set(ERROR_CODES);
86
+
87
+ // ─── HTTP Status Codes ──────────────────────────────────────────────────────
88
+
89
+ export const HTTP_STATUS_SUCCESS = {
90
+ OK: 200,
91
+ CREATED: 201,
92
+ ACCEPTED: 202,
93
+ NO_CONTENT: 204,
94
+ } as const;
95
+
96
+ export const HTTP_STATUS_ERROR = {
97
+ BAD_REQUEST: 400,
98
+ UNAUTHORIZED: 401,
99
+ FORBIDDEN: 403,
100
+ NOT_FOUND: 404,
101
+ METHOD_NOT_ALLOWED: 405,
102
+ NOT_ACCEPTABLE: 406,
103
+ CONFLICT: 409,
104
+ PAYLOAD_TOO_LARGE: 413,
105
+ UNPROCESSABLE_ENTITY: 422,
106
+ TOO_MANY_REQUESTS: 429,
107
+ INTERNAL_SERVER_ERROR: 500,
108
+ BAD_GATEWAY: 502,
109
+ SERVICE_UNAVAILABLE: 503,
110
+ GATEWAY_TIMEOUT: 504,
111
+ } as const;
112
+
113
+ // ─── Derived Types ──────────────────────────────────────────────────────────
114
+
115
+ export type ErrorCode = (typeof ERROR_CODES)[number];
116
+
117
+ export type SuccessHttpStatusCode = (typeof HTTP_STATUS_SUCCESS)[keyof typeof HTTP_STATUS_SUCCESS];
118
+ export type ErrorHttpStatusCode = (typeof HTTP_STATUS_ERROR)[keyof typeof HTTP_STATUS_ERROR];
119
+
120
+ // ─── Payload Types ──────────────────────────────────────────────────────────
121
+
122
+ export interface ApiErrorPayload<C extends ErrorCode> {
123
+ code: C;
124
+ details?: Record<string, string[]>;
125
+ message?: string;
126
+ }
127
+
128
+ // ─── API Route Helpers ──────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Create a consistent API error response.
132
+ *
133
+ * @example
134
+ * return createApiError("auth:unauthorized", HTTP_STATUS_ERROR.UNAUTHORIZED);
135
+ * return createApiError("auth:unauthorized", 401, undefined, "Custom message");
136
+ */
137
+ export function createApiError<C extends ErrorCode>(
138
+ code: C,
139
+ statusCode: ErrorHttpStatusCode,
140
+ details?: Record<string, string[]>,
141
+ message?: string,
142
+ ): NextResponse<ApiErrorPayload<C>> {
143
+ return NextResponse.json(
144
+ {
145
+ code,
146
+ details,
147
+ message,
148
+ },
149
+ {
150
+ status: statusCode,
151
+ },
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Create a consistent API success response.
157
+ *
158
+ * @example
159
+ * return createApiSuccess({ users: [...] });
160
+ * return createApiSuccess(undefined, HTTP_STATUS_SUCCESS.NO_CONTENT);
161
+ */
162
+ export function createApiSuccess<T>(
163
+ data: T,
164
+ statusCode?: Exclude<SuccessHttpStatusCode, typeof HTTP_STATUS_SUCCESS.NO_CONTENT>,
165
+ ): NextResponse<T>;
166
+ export function createApiSuccess(
167
+ data?: undefined,
168
+ statusCode?: typeof HTTP_STATUS_SUCCESS.NO_CONTENT,
169
+ ): NextResponse<undefined>;
170
+ export function createApiSuccess<T>(
171
+ data?: T,
172
+ statusCode?: SuccessHttpStatusCode,
173
+ ): NextResponse<T | undefined> {
174
+ if (data === undefined || statusCode === HTTP_STATUS_SUCCESS.NO_CONTENT) {
175
+ return new NextResponse(null, { status: statusCode ?? 204 });
176
+ }
177
+
178
+ return NextResponse.json(data, { status: statusCode ?? 200 });
179
+ }
180
+
181
+ /**
182
+ * Type guard to check if an unknown payload is an ApiErrorPayload.
183
+ */
184
+ export function isApiErrorPayload(payload: unknown): payload is ApiErrorPayload<ErrorCode> {
185
+ if (!payload || typeof payload !== "object") return false;
186
+
187
+ const p = payload as Record<string, unknown>;
188
+
189
+ return typeof p.code === "string" && ERROR_CODE_SET.has(p.code);
190
+ }
191
+
192
+ // ─── Server Action Helpers ──────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Error object structure for server actions.
196
+ */
197
+ export interface ServiceError {
198
+ message: string;
199
+ code: ErrorCode;
200
+ details?: Record<string, string[]>;
201
+ }
202
+
203
+ /**
204
+ * Tuple type for server action responses: [data, error]
205
+ */
206
+ type ServiceResponse<S, E = ServiceError> = [S, null] | [null, E];
207
+
208
+ /**
209
+ * Create a consistent server action error response.
210
+ *
211
+ * @example
212
+ * return createServiceError("validation:invalid-payload");
213
+ * return createServiceError("validation:invalid-payload", undefined, "Custom message");
214
+ */
215
+ export function createServiceError(
216
+ code: ErrorCode,
217
+ details?: Record<string, string[]>,
218
+ message?: string,
219
+ ): ServiceResponse<null, ServiceError> {
220
+ return [
221
+ null,
222
+ {
223
+ code,
224
+ details,
225
+ message: message ?? code,
226
+ },
227
+ ];
228
+ }
229
+
230
+ /**
231
+ * Create a consistent server action success response.
232
+ *
233
+ * @example
234
+ * return createServiceSuccess({ id: "123", name: "John" });
235
+ * return createServiceSuccess(); // void success → [undefined, null]
236
+ */
237
+ export function createServiceSuccess<T>(data?: T): ServiceResponse<T | undefined, null> {
238
+ return [data, null];
239
+ }
240
+
241
+ // ─── Exhaustive Check ───────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Compile-time exhaustiveness guard for switch/if-else chains.
245
+ * Place in the `default` branch — TypeScript will error if any case is unhandled.
246
+ *
247
+ * @example
248
+ * const code = err.code;
249
+ * switch (code) {
250
+ * case "auth:forbidden": …; break;
251
+ * case "system:unknown-error": …; break;
252
+ * default: assertNever(code);
253
+ * }
254
+ */
255
+ export function assertNever(value: never): never {
256
+ throw new Error(`Unhandled discriminant: ${String(value)}`);
257
+ }