Skip to content

Commit c15359b

Browse files
dddddl12TheEdoRan
andauthored
fix: handle framework errors correctly (#320)
Co-authored-by: Edoardo Ranghieri <[email protected]>
1 parent 8e70182 commit c15359b

File tree

3 files changed

+118
-49
lines changed

3 files changed

+118
-49
lines changed

Diff for: packages/next-safe-action/src/__tests__/middleware.test.ts

+66
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-floating-promises */
22

3+
import { redirect } from "next/navigation";
34
import assert from "node:assert";
45
import { test } from "node:test";
56
import { z } from "zod";
@@ -12,6 +13,7 @@ import {
1213
returnValidationErrors,
1314
} from "..";
1415
import { zodAdapter } from "../adapters/zod";
16+
import { FrameworkErrorHandler } from "../next/errors";
1517

1618
const ac = createSafeActionClient({
1719
validationAdapter: zodAdapter(),
@@ -293,6 +295,70 @@ test("server validation errors in execution result from middleware are correct",
293295
assert.deepStrictEqual(middlewareResult, expectedResult);
294296
});
295297

298+
test("framework error result from middleware is correct", async () => {
299+
let middlewareResult = {};
300+
301+
const action = ac
302+
.schema(async () =>
303+
z.object({
304+
username: z.string(),
305+
})
306+
)
307+
.bindArgsSchemas([z.object({ age: z.number().positive() })])
308+
.use(async ({ next }) => {
309+
// Await action execution.
310+
const res = await next();
311+
middlewareResult = res;
312+
return res;
313+
})
314+
.action(async () => {
315+
redirect("/newPath");
316+
});
317+
318+
const inputs = [{ age: 30 }, { username: "johndoe" }] as const;
319+
await action(...inputs).catch((e) => {
320+
if (!FrameworkErrorHandler.isFrameworkError(e)) {
321+
throw e;
322+
}
323+
});
324+
325+
const expectedResult = {
326+
success: true,
327+
ctx: {
328+
foo: "bar",
329+
},
330+
data: undefined,
331+
parsedInput: {
332+
username: "johndoe",
333+
},
334+
bindArgsParsedInputs: [
335+
{
336+
age: 30,
337+
},
338+
],
339+
};
340+
341+
assert.deepStrictEqual(middlewareResult, expectedResult);
342+
});
343+
344+
test("framework error is thrown within middleware", async () => {
345+
const action = ac
346+
.use(async () => {
347+
redirect("/newPath");
348+
})
349+
.action(async () => {
350+
return null;
351+
});
352+
353+
await assert.rejects(
354+
async () => await action(),
355+
(e) => {
356+
return FrameworkErrorHandler.isFrameworkError(e);
357+
},
358+
"A framework error to be thrown"
359+
);
360+
});
361+
296362
// Flattened validation errors shape.
297363

298364
const flac = createSafeActionClient({

Diff for: packages/next-safe-action/src/action-builder.ts

+16-30
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,7 @@ import type {
1212
ServerCodeFn,
1313
StateServerCodeFn,
1414
} from "./index.types";
15-
import {
16-
isForbiddenError,
17-
isFrameworkError,
18-
isNotFoundError,
19-
isRedirectError,
20-
isUnauthorizedError,
21-
} from "./next/errors";
15+
import { FrameworkErrorHandler } from "./next/errors";
2216
import { DEFAULT_SERVER_ERROR_MESSAGE, isError, winningBoolean } from "./utils";
2317
import {
2418
ActionMetadataValidationError,
@@ -87,7 +81,7 @@ export function actionBuilder<
8781
type PrevResult = SafeActionResult<ServerError, IS, BAS, CVE, CBAVE, Data> | undefined;
8882
let prevResult: PrevResult | undefined = undefined;
8983
const parsedInputDatas: any[] = [];
90-
let frameworkError: Error | null = null;
84+
const frameworkErrorHandler = new FrameworkErrorHandler();
9185

9286
if (withState) {
9387
// Previous state is placed between bind args and main arg inputs, so it's always at the index of
@@ -105,7 +99,7 @@ export function actionBuilder<
10599

106100
// Execute the middleware stack.
107101
const executeMiddlewareStack = async (idx = 0) => {
108-
if (frameworkError) {
102+
if (frameworkErrorHandler.error) {
109103
return;
110104
}
111105

@@ -136,7 +130,7 @@ export function actionBuilder<
136130
await executeMiddlewareStack(idx + 1);
137131
return middlewareResult;
138132
},
139-
});
133+
}).catch((e) => frameworkErrorHandler.handleError(e));
140134
// Action function.
141135
} else {
142136
// Validate the client inputs in parallel.
@@ -236,10 +230,10 @@ export function actionBuilder<
236230
scfArgs[1] = { prevResult: structuredClone(prevResult!) };
237231
}
238232

239-
const data = await serverCodeFn(...scfArgs);
233+
const data = await serverCodeFn(...scfArgs).catch((e) => frameworkErrorHandler.handleError(e));
240234

241235
// If a `outputSchema` is passed, validate the action return value.
242-
if (typeof args.outputSchema !== "undefined") {
236+
if (typeof args.outputSchema !== "undefined" && !frameworkErrorHandler.error) {
243237
const parsedData = await args.validationAdapter.validate(args.outputSchema, data);
244238

245239
if (!parsedData.success) {
@@ -253,14 +247,6 @@ export function actionBuilder<
253247
middlewareResult.bindArgsParsedInputs = parsedInputDatas.slice(0, -1);
254248
}
255249
} catch (e: unknown) {
256-
// next/navigation functions work by throwing an error that will be
257-
// processed internally by Next.js.
258-
if (isFrameworkError(e)) {
259-
middlewareResult.success = true;
260-
frameworkError = e;
261-
return;
262-
}
263-
264250
// If error is `ActionServerValidationError`, return `validationErrors` as if schema validation would fail.
265251
if (e instanceof ActionServerValidationError) {
266252
const ve = e.validationErrors as ValidationErrors<IS>;
@@ -298,7 +284,7 @@ export function actionBuilder<
298284
const callbackPromises: (Promise<unknown> | undefined)[] = [];
299285

300286
// If an internal framework error occurred, throw it, so it will be processed by Next.js.
301-
if (frameworkError) {
287+
if (frameworkErrorHandler.error) {
302288
callbackPromises.push(
303289
utils?.onSuccess?.({
304290
data: undefined,
@@ -308,10 +294,10 @@ export function actionBuilder<
308294
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
309295
parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer<IS> : undefined,
310296
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
311-
hasRedirected: isRedirectError(frameworkError),
312-
hasNotFound: isNotFoundError(frameworkError),
313-
hasForbidden: isForbiddenError(frameworkError),
314-
hasUnauthorized: isUnauthorizedError(frameworkError),
297+
hasRedirected: frameworkErrorHandler.isRedirectError,
298+
hasNotFound: frameworkErrorHandler.isNotFoundError,
299+
hasForbidden: frameworkErrorHandler.isForbiddenError,
300+
hasUnauthorized: frameworkErrorHandler.isUnauthorizedError,
315301
})
316302
);
317303

@@ -322,16 +308,16 @@ export function actionBuilder<
322308
clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn<IS> : undefined,
323309
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
324310
result: {},
325-
hasRedirected: isRedirectError(frameworkError),
326-
hasNotFound: isNotFoundError(frameworkError),
327-
hasForbidden: isForbiddenError(frameworkError),
328-
hasUnauthorized: isUnauthorizedError(frameworkError),
311+
hasRedirected: frameworkErrorHandler.isRedirectError,
312+
hasNotFound: frameworkErrorHandler.isNotFoundError,
313+
hasForbidden: frameworkErrorHandler.isForbiddenError,
314+
hasUnauthorized: frameworkErrorHandler.isUnauthorizedError,
329315
})
330316
);
331317

332318
await Promise.all(callbackPromises);
333319

334-
throw frameworkError;
320+
throw frameworkErrorHandler.error;
335321
}
336322

337323
const actionResult: SafeActionResult<ServerError, IS, BAS, CVE, CBAVE, Data> = {};

Diff for: packages/next-safe-action/src/next/errors/index.ts

+36-19
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,45 @@
11
import { isBailoutToCSRError } from "./bailout-to-csr";
22
import { isDynamicUsageError } from "./dynamic-usage";
3-
import {
4-
getAccessFallbackHTTPStatus,
5-
isHTTPAccessFallbackError,
6-
type HTTPAccessFallbackError,
7-
} from "./http-access-fallback";
3+
import { getAccessFallbackHTTPStatus, isHTTPAccessFallbackError } from "./http-access-fallback";
84
import { isPostpone } from "./postpone";
5+
import { isRedirectError } from "./redirect";
96
import { isNextRouterError } from "./router";
107

11-
export function isNotFoundError(error: unknown): error is HTTPAccessFallbackError {
12-
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 404;
13-
}
8+
export class FrameworkErrorHandler {
9+
#frameworkError: Error | undefined;
1410

15-
export function isForbiddenError(error: unknown): error is HTTPAccessFallbackError {
16-
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 403;
17-
}
11+
static isFrameworkError(error: unknown): error is Error {
12+
return isNextRouterError(error) || isBailoutToCSRError(error) || isDynamicUsageError(error) || isPostpone(error);
13+
}
1814

19-
export function isUnauthorizedError(error: unknown): error is HTTPAccessFallbackError {
20-
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 401;
21-
}
15+
handleError(e: unknown) {
16+
// next/navigation functions work by throwing an error that will be
17+
// processed internally by Next.js.
18+
if (FrameworkErrorHandler.isFrameworkError(e)) {
19+
this.#frameworkError = e;
20+
return;
21+
}
2222

23-
// Next.js error handling
24-
export function isFrameworkError(error: unknown): error is Error {
25-
return isNextRouterError(error) || isBailoutToCSRError(error) || isDynamicUsageError(error) || isPostpone(error);
26-
}
23+
throw e;
24+
}
25+
26+
get error() {
27+
return this.#frameworkError;
28+
}
2729

28-
export { isRedirectError } from "./redirect";
30+
get isRedirectError() {
31+
return isRedirectError(this.#frameworkError);
32+
}
33+
34+
get isNotFoundError() {
35+
return isHTTPAccessFallbackError(this.#frameworkError) && getAccessFallbackHTTPStatus(this.#frameworkError) === 404;
36+
}
37+
38+
get isForbiddenError() {
39+
return isHTTPAccessFallbackError(this.#frameworkError) && getAccessFallbackHTTPStatus(this.#frameworkError) === 403;
40+
}
41+
42+
get isUnauthorizedError() {
43+
return isHTTPAccessFallbackError(this.#frameworkError) && getAccessFallbackHTTPStatus(this.#frameworkError) === 401;
44+
}
45+
}

0 commit comments

Comments
 (0)