From 11de596751bcd229deaad4570ee386d95e4e3c5b Mon Sep 17 00:00:00 2001 From: Taesu Date: Sat, 9 May 2026 23:04:23 +0900 Subject: [PATCH] feat(core): add hooks.finally phase with try/finally semantics --- .changeset/hooks-finally-phase.md | 6 + docs/content/docs/concepts/hooks.mdx | 26 +- docs/content/docs/concepts/plugins.mdx | 12 +- .../src/api/to-auth-endpoints.test.ts | 49 +++ .../better-auth/src/api/to-auth-endpoints.ts | 381 ++++++++++-------- packages/core/src/types/init-options.ts | 5 + packages/core/src/types/plugin.ts | 10 + 7 files changed, 316 insertions(+), 173 deletions(-) create mode 100644 .changeset/hooks-finally-phase.md diff --git a/.changeset/hooks-finally-phase.md b/.changeset/hooks-finally-phase.md new file mode 100644 index 0000000000..9d97432b69 --- /dev/null +++ b/.changeset/hooks-finally-phase.md @@ -0,0 +1,6 @@ +--- +"better-auth": minor +"@better-auth/core": minor +--- + +Add `hooks.finally` phase that runs at the end of the request lifecycle even when an earlier phase throws. Plugin declaration order does not affect when finally hooks run, making them ideal for pipeline-end work like flushing accumulated state without depending on plugin position. diff --git a/docs/content/docs/concepts/hooks.mdx b/docs/content/docs/concepts/hooks.mdx index 8ba44fb3fa..6292b7d25f 100644 --- a/docs/content/docs/concepts/hooks.mdx +++ b/docs/content/docs/concepts/hooks.mdx @@ -1,6 +1,6 @@ --- title: Hooks -description: Learn how to use before and after hooks to customize endpoint behavior, modify requests and responses, handle cookies, throw errors, access auth context, and run background tasks. +description: Learn how to use before, after, and finally hooks to customize endpoint behavior, modify requests and responses, handle cookies, throw errors, access auth context, and run background tasks. --- Hooks in Better Auth let you "hook into" the lifecycle and execute custom logic. They provide a way to customize Better Auth's behavior without writing a full plugin. @@ -92,6 +92,30 @@ export const auth = betterAuth({ }); ``` +## Finally Hooks + +**Finally hooks** run at the end of every request, even when a `before` or `after` hook throws. They execute after all `before` and `after` hooks from every plugin, so plugin declaration order does not affect their timing. The handler receives no error argument and is invoked regardless of success or failure. + +Use them for pipeline-end side effects: incrementing metrics, finalizing telemetry spans, or syncing accumulated state into an external store. + +### Example: Count every request including failed ones + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; +import { metrics } from "@/lib/metrics"; + +export const auth = betterAuth({ + hooks: { + finally: createAuthMiddleware(async (ctx) => { + metrics.increment("auth.request", { path: ctx.path }); + }), + }, +}); +``` + +If a finally hook itself throws, that error supersedes any earlier request error. Wrap the side effect in `try / catch` if you want finally-side failures to stay silent. + ## Ctx When you call `createAuthMiddleware` a `ctx` object is passed that provides a lot of useful properties. Including: diff --git a/docs/content/docs/concepts/plugins.mdx b/docs/content/docs/concepts/plugins.mdx index f6043cf463..6a3fc02043 100644 --- a/docs/content/docs/concepts/plugins.mdx +++ b/docs/content/docs/concepts/plugins.mdx @@ -238,7 +238,11 @@ This will add an `age` field to the `user` table and all `user` returning endpoi ### Hooks -Hooks are used to run code before or after an action is performed, either from a client or directly on the server. You can add hooks to the server by passing a `hooks` object, which should contain `before` and `after` properties. +Hooks let you run code at three points in an endpoint's lifecycle, either from a client or directly on the server. Pass a `hooks` object containing any of `before`, `after`, or `finally` properties. + +* **`before`** runs before the endpoint handler. Use it to validate input, modify context, or short-circuit the request. +* **`after`** runs after the endpoint handler succeeds. Use it to read or modify the response. +* **`finally`** runs at the very end of the request, even when an earlier phase throws. Plugin declaration order does not affect when it runs. See the [Finally Hooks section in Hooks](/docs/concepts/hooks#finally-hooks) for the full semantic. ```ts title="plugin.ts" import { createAuthMiddleware } from "better-auth/api"; @@ -267,6 +271,12 @@ const myPlugin = () => { message: "Hello World" }) // if you want to modify the response }) + }], + finally: [{ + matcher: () => true, + handler: createAuthMiddleware(async (ctx) => { + // runs at the end of every request, even on error + }) }] } } satisfies BetterAuthPlugin diff --git a/packages/better-auth/src/api/to-auth-endpoints.test.ts b/packages/better-auth/src/api/to-auth-endpoints.test.ts index 1a7cb470d2..d78b21190d 100644 --- a/packages/better-auth/src/api/to-auth-endpoints.test.ts +++ b/packages/better-auth/src/api/to-auth-endpoints.test.ts @@ -1,3 +1,4 @@ +import type { BetterAuthPlugin } from "@better-auth/core"; import { createAuthEndpoint, createAuthMiddleware, @@ -1306,3 +1307,51 @@ describe("response headers on APIError", async () => { expect(merged?.get("set-cookie") ?? "").toMatch(/(^|,\s*)session=;/); }); }); + +describe("finally hook", async () => { + it("runs even when an earlier phase throws", async () => { + const ran: string[] = []; + const throwingPlugin = { + id: "throwing-after", + hooks: { + after: [ + { + matcher: (ctx) => ctx.path === "/sign-in/email", + handler: createAuthMiddleware(async () => { + throw new Error("boom"); + }), + }, + ], + }, + } satisfies BetterAuthPlugin; + const finallyPlugin = { + id: "records-finally", + hooks: { + finally: [ + { + matcher: (ctx) => ctx.path === "/sign-in/email", + handler: createAuthMiddleware(async () => { + ran.push("finally"); + }), + }, + ], + }, + } satisfies BetterAuthPlugin; + + const { auth, testUser } = await getTestInstance({ + plugins: [throwingPlugin, finallyPlugin], + }); + + await expect( + auth.api.signInEmail({ + body: { + email: testUser.email, + password: testUser.password, + }, + returnHeaders: true, + }), + ).rejects.toThrow("boom"); + + expect(ran).toEqual(["finally"]); + }); +}); diff --git a/packages/better-auth/src/api/to-auth-endpoints.ts b/packages/better-auth/src/api/to-auth-endpoints.ts index 59dc689609..30b6298f08 100644 --- a/packages/better-auth/src/api/to-auth-endpoints.ts +++ b/packages/better-auth/src/api/to-auth-endpoints.ts @@ -167,190 +167,211 @@ export function toAuthEndpoints>( }, async () => runWithEndpointContext(internalContext, async () => { - const { beforeHooks, afterHooks } = getHooks(authContext); - const before = await runBeforeHooks( - internalContext, - beforeHooks, - endpoint, - operationId, - ); - /** - * If `before.context` is returned, it should - * get merged with the original context - */ - if ( - "context" in before && - before.context && - typeof before.context === "object" - ) { - const { headers, ...rest } = before.context as { - headers: Headers; - }; + const { beforeHooks, afterHooks, finallyHooks } = + getHooks(authContext); + try { + const before = await runBeforeHooks( + internalContext, + beforeHooks, + endpoint, + operationId, + ); /** - * Headers should be merged differently - * so the hook doesn't override the whole - * header + * If `before.context` is returned, it should + * get merged with the original context */ - if (headers) { - headers.forEach((value, key) => { - (internalContext.headers as Headers).set(key, value); - }); - } - internalContext = defuReplaceArrays(rest, internalContext); - } else if (before) { - /* Return before hook response if it's anything other than a context return */ - return shouldReturnResponse - ? toResponse(before, { - headers: context?.headers, - }) - : context?.returnHeaders - ? { - headers: context?.headers, - response: before, - } - : before; - } - - internalContext.asResponse = false; - internalContext.returnHeaders = true; - internalContext.returnStatus = true; - const result = (await runWithEndpointContext( - internalContext, - () => - withSpan( - `handler ${route}`, - { - [ATTR_HTTP_ROUTE]: route, - [ATTR_OPERATION_ID]: operationId, - }, - () => (endpoint as any)(internalContext as any), - ), - ).catch((e: any) => { - if (isAPIError(e)) { + if ( + "context" in before && + before.context && + typeof before.context === "object" + ) { + const { headers, ...rest } = before.context as { + headers: Headers; + }; /** - * API Errors from response are caught - * and returned to hooks. - * - * Headers come from two sources that must both - * survive: - * - `kAPIErrorHeaderSymbol`: ctx.responseHeaders - * accumulated via c.setCookie / c.setHeader - * before the throw. - * - `e.headers`: explicit headers on the APIError - * (e.g. `location` from c.redirect). - * - * Start from the accumulated ctx headers, then - * apply e.headers on top — appending `set-cookie` - * and setting others — so explicit APIError - * headers override while cookies accumulate. + * Headers should be merged differently + * so the hook doesn't override the whole + * header */ - const ctxHeaders = ( - e as { - [kAPIErrorHeaderSymbol]?: Headers; - } - )[kAPIErrorHeaderSymbol]; - const errHeaders = e.headers ? new Headers(e.headers) : null; - let headers: Headers | null = null; - if (ctxHeaders || errHeaders) { - headers = new Headers(); - ctxHeaders?.forEach((value, key) => { - headers!.append(key, value); - }); - errHeaders?.forEach((value, key) => { - if (key.toLowerCase() === "set-cookie") { - headers!.append(key, value); - } else { - headers!.set(key, value); - } + if (headers) { + headers.forEach((value, key) => { + (internalContext.headers as Headers).set(key, value); }); } - return { - response: e, - status: e.statusCode, - headers, - }; + internalContext = defuReplaceArrays(rest, internalContext); + } else if (before) { + /* Return before hook response if it's anything other than a context return */ + return shouldReturnResponse + ? toResponse(before, { + headers: context?.headers, + }) + : context?.returnHeaders + ? { + headers: context?.headers, + response: before, + } + : before; } - throw e; - })) as { - headers: Headers; - response: any; - status: number; - }; - //if response object is returned we skip after hooks and post processing - if (result && result instanceof Response) { - return result; - } + internalContext.asResponse = false; + internalContext.returnHeaders = true; + internalContext.returnStatus = true; + const result = (await runWithEndpointContext( + internalContext, + () => + withSpan( + `handler ${route}`, + { + [ATTR_HTTP_ROUTE]: route, + [ATTR_OPERATION_ID]: operationId, + }, + () => (endpoint as any)(internalContext as any), + ), + ).catch((e: any) => { + if (isAPIError(e)) { + /** + * API Errors from response are caught + * and returned to hooks. + * + * Headers come from two sources that must both + * survive: + * - `kAPIErrorHeaderSymbol`: ctx.responseHeaders + * accumulated via c.setCookie / c.setHeader + * before the throw. + * - `e.headers`: explicit headers on the APIError + * (e.g. `location` from c.redirect). + * + * Start from the accumulated ctx headers, then + * apply e.headers on top — appending `set-cookie` + * and setting others — so explicit APIError + * headers override while cookies accumulate. + */ + const ctxHeaders = ( + e as { + [kAPIErrorHeaderSymbol]?: Headers; + } + )[kAPIErrorHeaderSymbol]; + const errHeaders = e.headers + ? new Headers(e.headers) + : null; + let headers: Headers | null = null; + if (ctxHeaders || errHeaders) { + headers = new Headers(); + ctxHeaders?.forEach((value, key) => { + headers!.append(key, value); + }); + errHeaders?.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") { + headers!.append(key, value); + } else { + headers!.set(key, value); + } + }); + } + return { + response: e, + status: e.statusCode, + headers, + }; + } + throw e; + })) as { + headers: Headers; + response: any; + status: number; + }; - internalContext.context.returned = result.response; - internalContext.context.responseHeaders = result.headers; + //if response object is returned we skip after hooks and post processing + if (result && result instanceof Response) { + return result; + } - const after = await runAfterHooks( - internalContext, - afterHooks, - endpoint, - operationId, - ); + internalContext.context.returned = result.response; + internalContext.context.responseHeaders = result.headers; - if (after.response) { - result.response = after.response; - } + const after = await runHookList( + internalContext, + afterHooks, + "after", + endpoint, + operationId, + ); - if ( - isAPIError(result.response) && - shouldPublishLog(authContext.logger.level, "debug") - ) { - // inherit stack from errorStack if debug mode is enabled - result.response.stack = result.response.errorStack; - } + if (after.response) { + result.response = after.response; + } - if (isAPIError(result.response) && !shouldReturnResponse) { + if ( + isAPIError(result.response) && + shouldPublishLog(authContext.logger.level, "debug") + ) { + // inherit stack from errorStack if debug mode is enabled + result.response.stack = result.response.errorStack; + } + + if (isAPIError(result.response) && !shouldReturnResponse) { + /** + * Non-response path: we re-throw the raw APIError + * to callers of `auth.api.*`. `result.headers` + * holds the merged ctx + explicit headers (see + * catch block above) — rewrite + * `kAPIErrorHeaderSymbol` with the merged set so + * downstream pipelines (e.g. better-call's + * response builder, or an outer hook catch) see + * the same headers we'd have written on the + * response. + */ + if (result.headers) { + Object.defineProperty( + result.response, + kAPIErrorHeaderSymbol, + { + enumerable: false, + configurable: true, + writable: false, + value: result.headers, + }, + ); + } + throw result.response; + } + + const response = shouldReturnResponse + ? toResponse(result.response, { + headers: result.headers, + status: result.status, + }) + : context?.returnHeaders + ? context?.returnStatus + ? { + headers: result.headers, + response: result.response, + status: result.status, + } + : { + headers: result.headers, + response: result.response, + } + : context?.returnStatus + ? { response: result.response, status: result.status } + : result.response; + return response; + } finally { /** - * Non-response path: we re-throw the raw APIError - * to callers of `auth.api.*`. `result.headers` - * holds the merged ctx + explicit headers (see - * catch block above) — rewrite - * `kAPIErrorHeaderSymbol` with the merged set so - * downstream pipelines (e.g. better-call's - * response builder, or an outer hook catch) see - * the same headers we'd have written on the - * response. + * Runs at the end of the request lifecycle even + * when an earlier phase throws. Mirrors `try / + * finally` so handlers observe the final + * `responseHeaders` regardless of plugin + * declaration order. */ - if (result.headers) { - Object.defineProperty( - result.response, - kAPIErrorHeaderSymbol, - { - enumerable: false, - configurable: true, - writable: false, - value: result.headers, - }, - ); - } - throw result.response; + await runHookList( + internalContext, + finallyHooks, + "finally", + endpoint, + operationId, + ); } - - const response = shouldReturnResponse - ? toResponse(result.response, { - headers: result.headers, - status: result.status, - }) - : context?.returnHeaders - ? context?.returnStatus - ? { - headers: result.headers, - response: result.response, - status: result.status, - } - : { - headers: result.headers, - response: result.response, - } - : context?.returnStatus - ? { response: result.response, status: result.status } - : result.response; - return response; }), ); }; @@ -441,9 +462,10 @@ async function runBeforeHooks( return { context: modifiedContext }; } -async function runAfterHooks( +async function runHookList( context: InternalContext, hooks: Hook[], + hookType: "after" | "finally", endpoint: Endpoint, operationId: string, ) { @@ -452,9 +474,9 @@ async function runAfterHooks( const hookSource = hooksSourceWeakMap.get(hook.handler) ?? "unknown"; const route = endpoint.path ?? "/:virtual"; const result = (await withSpan( - `hook after ${route} ${hookSource}`, + `hook ${hookType} ${route} ${hookSource}`, { - [ATTR_HOOK_TYPE]: "after", + [ATTR_HOOK_TYPE]: hookType, [ATTR_HTTP_ROUTE]: route, [ATTR_CONTEXT]: hookSource, [ATTR_OPERATION_ID]: operationId, @@ -513,6 +535,7 @@ function getHooks(authContext: AuthContext) { const plugins = authContext.options.plugins || []; const beforeHooks: Hook[] = []; const afterHooks: Hook[] = []; + const finallyHooks: Hook[] = []; const beforeHookHandler = authContext.options.hooks?.before; if (beforeHookHandler) { hooksSourceWeakMap.set(beforeHookHandler, "user"); @@ -529,6 +552,14 @@ function getHooks(authContext: AuthContext) { handler: afterHookHandler, }); } + const finallyHookHandler = authContext.options.hooks?.finally; + if (finallyHookHandler) { + hooksSourceWeakMap.set(finallyHookHandler, "user"); + finallyHooks.push({ + matcher: () => true, + handler: finallyHookHandler, + }); + } const pluginBeforeHooks = plugins.flatMap((plugin) => (plugin.hooks?.before ?? []).map((h) => { hooksSourceWeakMap.set(h.handler, `plugin:${plugin.id}`); @@ -541,15 +572,23 @@ function getHooks(authContext: AuthContext) { return h; }), ); + const pluginFinallyHooks = plugins.flatMap((plugin) => + (plugin.hooks?.finally ?? []).map((h) => { + hooksSourceWeakMap.set(h.handler, `plugin:${plugin.id}`); + return h; + }), + ); /** * Add plugin added hooks at last */ if (pluginBeforeHooks.length) beforeHooks.push(...pluginBeforeHooks); if (pluginAfterHooks.length) afterHooks.push(...pluginAfterHooks); + if (pluginFinallyHooks.length) finallyHooks.push(...pluginFinallyHooks); return { beforeHooks, afterHooks, + finallyHooks, }; } diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index db20a73c8c..d373999bb0 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -1566,6 +1566,11 @@ export type BetterAuthOptions = { * After a request is processed */ after?: AuthMiddleware; + /** + * Runs at the end of the request lifecycle, even when an + * earlier phase throws. Mirrors `try / finally` semantics. + */ + finally?: AuthMiddleware; } | undefined; /** diff --git a/packages/core/src/types/plugin.ts b/packages/core/src/types/plugin.ts index 1d7da84485..16c44a6a68 100644 --- a/packages/core/src/types/plugin.ts +++ b/packages/core/src/types/plugin.ts @@ -96,6 +96,16 @@ export type BetterAuthPlugin = BetterAuthPluginErrorCodePart & { matcher: (context: HookEndpointContext) => boolean; handler: AuthMiddleware; }[]; + /** + * Runs at the end of the request lifecycle, even when an + * earlier phase throws. Mirrors `try / finally` and + * `Promise.prototype.finally` semantics. Plugin declaration + * order does not affect drain timing. + */ + finally?: { + matcher: (context: HookEndpointContext) => boolean; + handler: AuthMiddleware; + }[]; } | undefined; /**