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 +21 -0
- package/README.md +527 -0
- package/bin/cli.mjs +212 -0
- package/package.json +35 -0
- package/templates/apiClient.ts +114 -0
- package/templates/apiRegistry.ts +50 -0
- package/templates/responses.ts +257 -0
- package/templates/update-api-registry.mjs +225 -0
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
|
+
}
|