next-action-handler 1.0.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 ADDED
@@ -0,0 +1,66 @@
1
+ # next-action-handler
2
+
3
+ Install a ready-to-use `next-safe-action` handler setup into your Next.js project.
4
+
5
+ ## Usage
6
+
7
+ Run the installer from the root of your project:
8
+
9
+ ```bash
10
+ npx next-action-handler@latest add
11
+ ```
12
+
13
+ Using `@latest` makes `npx` fetch the newest published version from npm.
14
+ The command installs the full handler setup at once, with no component selection.
15
+
16
+ ## What gets installed
17
+
18
+ The CLI copies the TypeScript source files into your project while preserving the folder structure:
19
+
20
+ ```text
21
+ lib/next-action-handler/
22
+ ```
23
+
24
+ Install path detection uses this order:
25
+
26
+ 1. If `lib/` exists, files are installed to `lib/next-action-handler/`.
27
+ 2. If `app/lib/` exists, files are installed to `app/lib/next-action-handler/`.
28
+ 3. Otherwise, `lib/next-action-handler/` is created at the project root.
29
+
30
+ If a file already exists, the CLI prompts you to overwrite or skip it. In non-interactive environments, existing files are skipped.
31
+
32
+ ## Dependencies
33
+
34
+ The CLI reads your project's `package.json`, detects missing packages, and installs only the packages you do not already have.
35
+
36
+ Required packages:
37
+
38
+ - `better-auth`
39
+ - `next-safe-action`
40
+ - `pino`
41
+ - `pino-pretty`
42
+ - `server-only`
43
+ - `zod`
44
+
45
+ The package manager is detected from lockfiles:
46
+
47
+ - `bun.lockb` or `bun.lock` -> `bun add`
48
+ - `pnpm-lock.yaml` -> `pnpm add`
49
+ - `yarn.lock` -> `yarn add`
50
+ - `package-lock.json` or `npm-shrinkwrap.json` -> `npm install`
51
+ - no lockfile -> `npm install`
52
+
53
+ ## Auth helper requirement
54
+
55
+ `safe-action.ts` preserves this import:
56
+
57
+ ```ts
58
+ import { requireUser } from "../auth-helpers";
59
+ ```
60
+
61
+ Your project must provide a `requireUser` export at the matching location:
62
+
63
+ - `lib/auth-helpers.ts` when installed to `lib/next-action-handler/`
64
+ - `app/lib/auth-helpers.ts` when installed to `app/lib/next-action-handler/`
65
+
66
+ `requireUser` should return the authenticated user or throw when the request is not authenticated.
package/cli/index.js ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const childProcess = require("child_process");
6
+
7
+ const REQUIRED_PACKAGES = [
8
+ "better-auth",
9
+ "next-safe-action",
10
+ "pino",
11
+ "pino-pretty",
12
+ "server-only",
13
+ "zod",
14
+ ];
15
+
16
+ const USAGE = "Usage: npx next-action-handler@latest add";
17
+
18
+ function main() {
19
+ const command = process.argv[2];
20
+
21
+ if (command !== "add") {
22
+ console.error(USAGE);
23
+ process.exit(1);
24
+ }
25
+
26
+ const projectRoot = process.cwd();
27
+ const sourceRoot = path.resolve(__dirname, "..", "src");
28
+
29
+ console.log("next-action-handler installer");
30
+
31
+ if (!fs.existsSync(sourceRoot)) {
32
+ fail("Could not find the bundled src/ directory.");
33
+ }
34
+
35
+ const installRoot = resolveInstallRoot(projectRoot);
36
+ console.log("Installing to " + path.relative(projectRoot, installRoot));
37
+
38
+ copySourceFiles(sourceRoot, installRoot);
39
+ installMissingDependencies(projectRoot);
40
+
41
+ console.log("");
42
+ console.log("Success! next-action-handler installed at " + path.relative(projectRoot, installRoot));
43
+ }
44
+
45
+ function resolveInstallRoot(projectRoot) {
46
+ const rootLib = path.join(projectRoot, "lib");
47
+ const appLib = path.join(projectRoot, "app", "lib");
48
+
49
+ if (isDirectory(rootLib)) {
50
+ return path.join(rootLib, "next-action-handler");
51
+ }
52
+
53
+ if (isDirectory(appLib)) {
54
+ return path.join(appLib, "next-action-handler");
55
+ }
56
+
57
+ return path.join(rootLib, "next-action-handler");
58
+ }
59
+
60
+ function copySourceFiles(sourceRoot, installRoot) {
61
+ const files = collectFiles(sourceRoot);
62
+
63
+ fs.mkdirSync(installRoot, { recursive: true });
64
+
65
+ for (const sourceFile of files) {
66
+ const relativePath = path.relative(sourceRoot, sourceFile);
67
+ const targetFile = path.join(installRoot, relativePath);
68
+
69
+ if (fs.existsSync(targetFile) && !shouldOverwrite(relativePath)) {
70
+ console.log("Skipped " + relativePath);
71
+ continue;
72
+ }
73
+
74
+ fs.mkdirSync(path.dirname(targetFile), { recursive: true });
75
+ fs.copyFileSync(sourceFile, targetFile);
76
+ console.log("✅ " + relativePath);
77
+ }
78
+ }
79
+
80
+ function collectFiles(directory) {
81
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
82
+ const files = [];
83
+
84
+ for (const entry of entries) {
85
+ const fullPath = path.join(directory, entry.name);
86
+
87
+ if (entry.isDirectory()) {
88
+ files.push.apply(files, collectFiles(fullPath));
89
+ continue;
90
+ }
91
+
92
+ if (entry.isFile()) {
93
+ files.push(fullPath);
94
+ }
95
+ }
96
+
97
+ return files.sort();
98
+ }
99
+
100
+ function shouldOverwrite(relativePath) {
101
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
102
+ return false;
103
+ }
104
+
105
+ while (true) {
106
+ const answer = prompt(relativePath + " already exists. Overwrite or skip? [o/s] ");
107
+ const normalized = answer.trim().toLowerCase();
108
+
109
+ if (
110
+ normalized === "o" ||
111
+ normalized === "overwrite" ||
112
+ normalized === "y" ||
113
+ normalized === "yes"
114
+ ) {
115
+ return true;
116
+ }
117
+
118
+ if (
119
+ normalized === "" ||
120
+ normalized === "s" ||
121
+ normalized === "skip" ||
122
+ normalized === "n" ||
123
+ normalized === "no"
124
+ ) {
125
+ return false;
126
+ }
127
+
128
+ console.log("Please type overwrite or skip.");
129
+ }
130
+ }
131
+
132
+ function prompt(question) {
133
+ const buffer = Buffer.alloc(1024);
134
+
135
+ fs.writeSync(1, question);
136
+
137
+ try {
138
+ const bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
139
+ return buffer.toString("utf8", 0, bytesRead);
140
+ } catch (error) {
141
+ return "";
142
+ }
143
+ }
144
+
145
+ function installMissingDependencies(projectRoot) {
146
+ const packageJsonPath = path.join(projectRoot, "package.json");
147
+
148
+ if (!fs.existsSync(packageJsonPath)) {
149
+ fail("Could not find package.json in " + projectRoot + ". Run this command from your project root.");
150
+ }
151
+
152
+ const packageJson = readPackageJson(packageJsonPath);
153
+ const missingPackages = REQUIRED_PACKAGES.filter(function (packageName) {
154
+ return !hasPackage(packageJson, packageName);
155
+ });
156
+
157
+ if (missingPackages.length === 0) {
158
+ console.log("All dependencies are already installed.");
159
+ return;
160
+ }
161
+
162
+ const packageManager = detectPackageManager(projectRoot);
163
+ const installCommand = getInstallCommand(packageManager, missingPackages);
164
+
165
+ console.log("Installing missing dependencies: " + missingPackages.join(", "));
166
+ console.log("Running " + packageManager + " " + installCommand.join(" "));
167
+
168
+ const result = childProcess.spawnSync(packageManager, installCommand, {
169
+ cwd: projectRoot,
170
+ stdio: "inherit",
171
+ shell: process.platform === "win32",
172
+ });
173
+
174
+ if (result.error) {
175
+ fail("Failed to run " + packageManager + ": " + result.error.message);
176
+ }
177
+
178
+ if (result.status !== 0) {
179
+ fail(packageManager + " exited with status " + result.status + ".");
180
+ }
181
+ }
182
+
183
+ function readPackageJson(packageJsonPath) {
184
+ try {
185
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
186
+ } catch (error) {
187
+ fail("Could not read " + packageJsonPath + ": " + error.message);
188
+ }
189
+ }
190
+
191
+ function hasPackage(packageJson, packageName) {
192
+ return Boolean(
193
+ (packageJson.dependencies && packageJson.dependencies[packageName]) ||
194
+ (packageJson.devDependencies && packageJson.devDependencies[packageName]) ||
195
+ (packageJson.peerDependencies && packageJson.peerDependencies[packageName]) ||
196
+ (packageJson.optionalDependencies && packageJson.optionalDependencies[packageName])
197
+ );
198
+ }
199
+
200
+ function detectPackageManager(projectRoot) {
201
+ if (fs.existsSync(path.join(projectRoot, "bun.lockb")) || fs.existsSync(path.join(projectRoot, "bun.lock"))) {
202
+ return "bun";
203
+ }
204
+
205
+ if (fs.existsSync(path.join(projectRoot, "pnpm-lock.yaml"))) {
206
+ return "pnpm";
207
+ }
208
+
209
+ if (fs.existsSync(path.join(projectRoot, "yarn.lock"))) {
210
+ return "yarn";
211
+ }
212
+
213
+ return "npm";
214
+ }
215
+
216
+ function getInstallCommand(packageManager, packages) {
217
+ if (packageManager === "npm") {
218
+ return ["install"].concat(packages);
219
+ }
220
+
221
+ return ["add"].concat(packages);
222
+ }
223
+
224
+ function isDirectory(filePath) {
225
+ try {
226
+ return fs.statSync(filePath).isDirectory();
227
+ } catch (error) {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ function fail(message) {
233
+ console.error("Error: " + message);
234
+ process.exit(1);
235
+ }
236
+
237
+ main();
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "next-action-handler",
3
+ "version": "1.0.0",
4
+ "description": "CLI installer for next-action-handler TypeScript source files.",
5
+ "bin": {
6
+ "next-action-handler": "./cli/index.js"
7
+ },
8
+ "files": [
9
+ "cli/",
10
+ "src/"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "check": "node --check cli/index.js"
17
+ },
18
+ "keywords": [
19
+ "next",
20
+ "server-actions",
21
+ "next-safe-action",
22
+ "cli"
23
+ ],
24
+ "license": "MIT"
25
+ }
@@ -0,0 +1,57 @@
1
+ import { isAPIError } from "better-auth/api";
2
+
3
+ import {
4
+ BadRequestError,
5
+ InternalServerError,
6
+ UnauthorizedError,
7
+ type ActionError,
8
+ } from "./errors";
9
+
10
+ type BetterAuthApiError = {
11
+ message?: string;
12
+ statusCode?: number;
13
+ };
14
+
15
+ type BetterAuthErrorOptions = {
16
+ enumerationSafe?: boolean;
17
+ genericMessage?: string;
18
+ };
19
+
20
+ export function fromBetterAuthError(
21
+ error: unknown,
22
+ options: BetterAuthErrorOptions = {},
23
+ ): ActionError {
24
+ if (!isAPIError(error)) {
25
+ return new InternalServerError(
26
+ options.genericMessage ?? "Something went wrong",
27
+ error,
28
+ );
29
+ }
30
+
31
+ const apiError = error as BetterAuthApiError;
32
+
33
+ const message = options.enumerationSafe
34
+ ? (options.genericMessage ?? "Invalid credentials")
35
+ : (apiError.message ?? "Something went wrong");
36
+
37
+ switch (apiError.statusCode) {
38
+ case 400:
39
+ return new BadRequestError(message, error);
40
+
41
+ case 401:
42
+ return new UnauthorizedError(message, error);
43
+
44
+ default:
45
+ return new InternalServerError(message, error);
46
+ }
47
+ }
48
+
49
+ // use for catch better auth error
50
+
51
+ // throw fromBetterAuthError(
52
+ // error,
53
+ // {
54
+ // enumerationSafe: true,
55
+ // genericMessage: "Invalid credentials",
56
+ // },
57
+ // );
@@ -0,0 +1,8 @@
1
+ import { DatabaseError } from "./errors";
2
+
3
+ export function toDatabaseError(
4
+ error: unknown,
5
+ message = "Database operation failed",
6
+ ): DatabaseError {
7
+ return new DatabaseError(message, error);
8
+ }
@@ -0,0 +1,136 @@
1
+ export const ERROR_CODES = [
2
+ "BAD_REQUEST",
3
+ "VALIDATION_ERROR",
4
+
5
+ "UNAUTHORIZED",
6
+ "FORBIDDEN",
7
+
8
+ "NOT_FOUND",
9
+
10
+ "RATE_LIMITED",
11
+
12
+ "DATABASE_ERROR",
13
+ "INTERNAL_SERVER_ERROR",
14
+ ] as const;
15
+
16
+ export type ErrorCode = (typeof ERROR_CODES)[number];
17
+
18
+ export class ActionError extends Error {
19
+ public readonly code: ErrorCode;
20
+ public readonly expose: boolean;
21
+ public readonly cause?: unknown;
22
+
23
+ constructor({
24
+ message,
25
+ code,
26
+ expose = true,
27
+ cause,
28
+ }: {
29
+ message: string;
30
+ code: ErrorCode;
31
+ expose?: boolean;
32
+ cause?: unknown;
33
+ }) {
34
+ super(message);
35
+
36
+ this.name = new.target.name;
37
+
38
+ this.code = code;
39
+ this.expose = expose;
40
+ this.cause = cause;
41
+
42
+ Object.setPrototypeOf(this, new.target.prototype);
43
+
44
+ Error.captureStackTrace?.(this, new.target);
45
+ }
46
+ }
47
+
48
+ export class BadRequestError extends ActionError {
49
+ constructor(message = "Bad request", cause?: unknown) {
50
+ super({
51
+ message,
52
+ code: "BAD_REQUEST",
53
+ cause,
54
+ });
55
+ }
56
+ }
57
+
58
+ export class ValidationError extends ActionError {
59
+ public readonly fields?: Record<string, string[]>;
60
+
61
+ constructor(
62
+ message = "Invalid input",
63
+ fields?: Record<string, string[]>,
64
+ cause?: unknown,
65
+ ) {
66
+ super({
67
+ message,
68
+ code: "VALIDATION_ERROR",
69
+ cause,
70
+ });
71
+
72
+ this.fields = fields;
73
+ }
74
+ }
75
+
76
+ export class UnauthorizedError extends ActionError {
77
+ constructor(message = "Unauthorized", cause?: unknown) {
78
+ super({
79
+ message,
80
+ code: "UNAUTHORIZED",
81
+ cause,
82
+ });
83
+ }
84
+ }
85
+
86
+ export class ForbiddenError extends ActionError {
87
+ constructor(message = "Forbidden", cause?: unknown) {
88
+ super({
89
+ message,
90
+ code: "FORBIDDEN",
91
+ cause,
92
+ });
93
+ }
94
+ }
95
+
96
+ export class NotFoundError extends ActionError {
97
+ constructor(message = "Resource not found", cause?: unknown) {
98
+ super({
99
+ message,
100
+ code: "NOT_FOUND",
101
+ cause,
102
+ });
103
+ }
104
+ }
105
+
106
+ export class RateLimitError extends ActionError {
107
+ constructor(message = "Too many requests", cause?: unknown) {
108
+ super({
109
+ message,
110
+ code: "RATE_LIMITED",
111
+ cause,
112
+ });
113
+ }
114
+ }
115
+
116
+ export class DatabaseError extends ActionError {
117
+ constructor(message = "Database operation failed", cause?: unknown) {
118
+ super({
119
+ message,
120
+ code: "DATABASE_ERROR",
121
+ expose: false,
122
+ cause,
123
+ });
124
+ }
125
+ }
126
+
127
+ export class InternalServerError extends ActionError {
128
+ constructor(message = "Something went wrong", cause?: unknown) {
129
+ super({
130
+ message,
131
+ code: "INTERNAL_SERVER_ERROR",
132
+ expose: false,
133
+ cause,
134
+ });
135
+ }
136
+ }
@@ -0,0 +1,28 @@
1
+ import { ActionError, InternalServerError } from "./errors";
2
+
3
+ import type { NormalizedError } from "../types";
4
+
5
+ export function normalizeError(error: unknown): NormalizedError {
6
+ if (error instanceof ActionError) {
7
+ return {
8
+ code: error.code,
9
+ message: error.message,
10
+ expose: error.expose,
11
+
12
+ cause: error.cause,
13
+ stack: error.stack,
14
+ };
15
+ }
16
+
17
+ const internalError = new InternalServerError("Something went wrong", error);
18
+ const unknownCause = error instanceof Error ? error : undefined;
19
+
20
+ return {
21
+ code: internalError.code,
22
+ message: internalError.message,
23
+ expose: internalError.expose,
24
+
25
+ cause: unknownCause,
26
+ stack: unknownCause?.stack,
27
+ };
28
+ }
@@ -0,0 +1,29 @@
1
+ import pino from "pino";
2
+ import type { ErrorCode } from "../error/errors";
3
+
4
+ export const ERROR_LOG_LEVEL: Record<ErrorCode, "warn" | "error"> = {
5
+ BAD_REQUEST: "warn",
6
+ VALIDATION_ERROR: "warn",
7
+ UNAUTHORIZED: "warn",
8
+ FORBIDDEN: "warn",
9
+ NOT_FOUND: "warn",
10
+ RATE_LIMITED: "warn",
11
+
12
+ DATABASE_ERROR: "error",
13
+ INTERNAL_SERVER_ERROR: "error",
14
+ };
15
+
16
+ export const logger = pino(
17
+ process.env.NODE_ENV === "development"
18
+ ? {
19
+ transport: {
20
+ target: "pino-pretty",
21
+ options: {
22
+ colorize: true,
23
+ translateTime: "SYS:yyyy-mm-dd HH:MM:ss",
24
+ ignore: "pid,hostname",
25
+ },
26
+ },
27
+ }
28
+ : {}
29
+ );
@@ -0,0 +1,119 @@
1
+ import { ERROR_LOG_LEVEL, logger } from "./logger-config";
2
+ import { ActionError } from "../error/errors";
3
+ import type { NormalizedError } from "../types";
4
+
5
+ type BaseLogOptions = {
6
+ action: string;
7
+ };
8
+
9
+ type LogMeta = Record<string, unknown>;
10
+
11
+ type ActionLogMessageOptions = BaseLogOptions & {
12
+ message: string;
13
+ meta?: LogMeta;
14
+ };
15
+
16
+ type ActionExecutionLogOptions = BaseLogOptions & {
17
+ durationMs: number;
18
+ message?: string;
19
+ meta?: LogMeta;
20
+ };
21
+
22
+ type ActionErrorLogOptions = BaseLogOptions & {
23
+ error: NormalizedError;
24
+ };
25
+
26
+ function buildCauseMeta(
27
+ cause: NormalizedError["cause"],
28
+ isDevelopment: boolean,
29
+ ) {
30
+ if (cause instanceof Error) {
31
+ const isSafeCauseMessage =
32
+ cause instanceof ActionError ? cause.expose : false;
33
+
34
+ return {
35
+ name: cause.name,
36
+ message: isDevelopment || isSafeCauseMessage ? cause.message : undefined,
37
+ stack: isDevelopment ? cause.stack : undefined,
38
+ };
39
+ }
40
+
41
+ if (isDevelopment) {
42
+ return cause;
43
+ }
44
+
45
+ return undefined;
46
+ }
47
+
48
+ export function logWarn({ action, message, meta }: ActionLogMessageOptions) {
49
+ logger.warn(
50
+ {
51
+ action,
52
+ ...meta,
53
+ },
54
+ message,
55
+ );
56
+ }
57
+
58
+ export function logError({ action, message, meta }: ActionLogMessageOptions) {
59
+ logger.error(
60
+ {
61
+ action,
62
+ ...meta,
63
+ },
64
+ message,
65
+ );
66
+ }
67
+
68
+ export function logInfo({
69
+ action,
70
+ message,
71
+ durationMs,
72
+ meta,
73
+ }: ActionExecutionLogOptions) {
74
+ logger.info(
75
+ {
76
+ action,
77
+ durationMs,
78
+ ...meta,
79
+ },
80
+ message,
81
+ );
82
+ }
83
+
84
+ export function logActionExecution({
85
+ action,
86
+ durationMs,
87
+ message = "Action executed successfully",
88
+ meta,
89
+ }: ActionExecutionLogOptions) {
90
+ logInfo({
91
+ action,
92
+ durationMs,
93
+ message,
94
+ meta,
95
+ });
96
+ }
97
+
98
+ export function logActionError({ action, error }: ActionErrorLogOptions) {
99
+ const level = ERROR_LOG_LEVEL[error.code];
100
+ const isDevelopment = process.env.NODE_ENV === "development";
101
+
102
+ const logMeta = {
103
+ errorCode: error.code,
104
+ stack: isDevelopment ? error.stack : undefined,
105
+ cause: buildCauseMeta(error.cause, isDevelopment),
106
+ };
107
+
108
+ const payload = {
109
+ action,
110
+ ...logMeta,
111
+ };
112
+
113
+ if (level === "error") {
114
+ logger.error(payload, error.message);
115
+ return;
116
+ }
117
+
118
+ logger.warn(payload, error.message);
119
+ }
@@ -0,0 +1,55 @@
1
+ import "server-only";
2
+
3
+ import z from "zod";
4
+
5
+ import {
6
+ createSafeActionClient,
7
+ DEFAULT_SERVER_ERROR_MESSAGE,
8
+ } from "next-safe-action";
9
+
10
+ import { logActionError, logActionExecution } from "./log/logger";
11
+
12
+ import { normalizeError } from "./error/normalize-error";
13
+ import { requireUser } from "../auth-helpers";
14
+
15
+ export const actionClient = createSafeActionClient({
16
+ defineMetadataSchema: () =>
17
+ z.object({
18
+ actionName: z.string(),
19
+ }),
20
+
21
+ handleServerError(error, ctx) {
22
+ const normalized = normalizeError(error);
23
+
24
+ logActionError({
25
+ action: ctx.metadata.actionName,
26
+ error: normalized,
27
+ });
28
+
29
+ return {
30
+ code: normalized.code,
31
+
32
+ message: normalized.expose
33
+ ? normalized.message
34
+ : DEFAULT_SERVER_ERROR_MESSAGE,
35
+ };
36
+ },
37
+ }).use(async ({ next, metadata }) => {
38
+ const startedAt = Date.now();
39
+
40
+ const result = await next();
41
+
42
+ if (!result.serverError) {
43
+ logActionExecution({
44
+ action: metadata.actionName,
45
+ durationMs: Date.now() - startedAt,
46
+ });
47
+ }
48
+
49
+ return result;
50
+ });
51
+
52
+ export const authedActionClient = actionClient.use(async ({ next }) => {
53
+ const user = await requireUser();
54
+ return next({ ctx: { user } });
55
+ });
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ErrorCode } from "./error/errors";
2
+
3
+ export type PublicServerError = {
4
+ code: ErrorCode;
5
+ message: string;
6
+ };
7
+
8
+ export type NormalizedError = {
9
+ code: ErrorCode;
10
+ message: string;
11
+ expose: boolean;
12
+ cause?: unknown;
13
+ stack?: string;
14
+ };