Repository: unjs/h3 (ts)

Issue:
Cannot track status codes
### Environment

- Node Version:     v20.12.2
- H3 Version:     1.11.1

### Reproduction

See https://stackblitz.com/edit/github-d9z5tl?file=app.ts

### Describe the bug

Forwarded from https://github.com/nuxt/nuxt/issues/27248

When I return an error in `eventHandler`'s using `createError`, global hooks `onBeforeResponse` and `onAfterResponse` behave unexpectedly:

- `onBeforeResponse` always returns `res.statusCode` equal to 200
- `onAfterResponse` does not work in case of an error in the handler

This does not track the actual status codes that users may encounter. For example, in my app I cannot collect statistics in Prometheus metrics.

```js
export const app = createApp({
  onBeforeResponse: (event) => {
    // will always be 200
    console.log('[beforeResponse]', event.node.res.statusCode);
  },
  onAfterResponse: (event) => {
    // not called
    console.log('[afterResponse]', event.node.res.statusCode);
  },
});

const router = createRouter();
app.use(router);

router.get(
  '/',
  eventHandler((event) => {
    return createError({ statusCode: 404 });
  })
);
```


### Additional context

_No response_

### Logs

_No response_

Pull request:
fix(node): make sure `onBeforeResponse` and `onAfterResponse` are called with error code
<!---
☝️ PR title should follow conventional commits (https://conventionalcommits.org) 
-->

### 🔗 Linked issue

Closes #754 

### ❓ Type of change

<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->

- [ ] 📖 Documentation (updates to the documentation, readme, or JSdoc annotations)
- [x] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [x] 👌 Enhancement (improving an existing functionality like performance)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] I have linked an issue or discussion.
- [ ] I have updated the documentation accordingly.


Patch:
diff --git a/src/adapters/node.ts b/src/adapters/node.ts
index 62680e4..1d91d73 100644
--- a/src/adapters/node.ts
+++ b/src/adapters/node.ts
@@ -6,6 +6,7 @@ import { App } from "../app";
 import { createError, isError, sendError } from "../error";
 import { createEvent, eventHandler, isEventHandler } from "../event";
 import { EventHandler, EventHandlerResponse } from "../types";
+import { setResponseStatus } from "../utils";
 
 // Node.js
 export type {
@@ -62,6 +63,9 @@ export function toNodeListener(app: App): NodeListener {
         error.unhandled = true;
       }
 
+      // #754 Make sure hooks see correct status code and message
+      setResponseStatus(event, error.statusCode, error.statusMessage);
+
       if (app.options.onError) {
         await app.options.onError(error, event);
       }
@@ -71,7 +75,14 @@ export function toNodeListener(app: App): NodeListener {
       if (error.unhandled || error.fatal) {
         console.error("[h3]", error.fatal ? "[fatal]" : "[unhandled]", error);
       }
+
+      if (app.options.onBeforeResponse && !event._onBeforeResponseCalled) {
+        await app.options.onBeforeResponse(event, { body: error });
+      }
       await sendError(event, error, !!app.options.debug);
+      if (app.options.onAfterResponse && !event._onAfterResponseCalled) {
+        await app.options.onAfterResponse(event, { body: error });
+      }
     }
   };
   return toNodeHandle;
diff --git a/src/app.ts b/src/app.ts
index 2a8d6fe..1ea5774 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -179,10 +179,12 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) {
       if (_body !== undefined) {
         const _response = { body: _body };
         if (options.onBeforeResponse) {
+          event._onBeforeResponseCalled = true;
           await options.onBeforeResponse(event, _response);
         }
         await handleHandlerResponse(event, _response.body, spacing);
         if (options.onAfterResponse) {
+          event._onAfterResponseCalled = true;
           await options.onAfterResponse(event, _response);
         }
         return;
@@ -191,6 +193,7 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) {
       // Already handled
       if (event.handled) {
         if (options.onAfterResponse) {
+          event._onAfterResponseCalled = true;
           await options.onAfterResponse(event, undefined);
         }
         return;
@@ -205,6 +208,7 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) {
     }
 
     if (options.onAfterResponse) {
+      event._onAfterResponseCalled = true;
       await options.onAfterResponse(event, undefined);
     }
   });
diff --git a/src/event/event.ts b/src/event/event.ts
index c0aa5f4..1852634 100644
--- a/src/event/event.ts
+++ b/src/event/event.ts
@@ -34,6 +34,10 @@ export class H3Event<
   // Response
   _handled = false;
 
+  // Hooks
+  _onBeforeResponseCalled: boolean | undefined;
+  _onAfterResponseCalled: boolean | undefined;
+
   constructor(req: NodeIncomingMessage, res: NodeServerResponse) {
     this.node = { req, res };
   }


Test patch:
diff --git a/test/app.test.ts b/test/app.test.ts
index 67f6d37..a8d722c 100644
--- a/test/app.test.ts
+++ b/test/app.test.ts
@@ -395,10 +395,82 @@ describe("app", () => {
     expect(onRequest).toHaveBeenCalledTimes(1);
     expect(onRequest.mock.calls[0][0].path).toBe("/foo");
 
+    expect(onError).toHaveBeenCalledTimes(0);
+
     expect(onBeforeResponse).toHaveBeenCalledTimes(1);
     expect(onBeforeResponse.mock.calls[0][1].body).toBe("Hello World!");
 
     expect(onAfterResponse).toHaveBeenCalledTimes(1);
     expect(onAfterResponse.mock.calls[0][1].body).toBe("Hello World!");
   });
+
+  it("сalls onRequest and onResponse when an exception is thrown", async () => {
+    app.use(
+      eventHandler(() => {
+        throw createError({
+          statusCode: 503,
+        });
+      }),
+    );
+    await request.get("/foo");
+
+    expect(onRequest).toHaveBeenCalledTimes(1);
+    expect(onRequest.mock.calls[0][0].path).toBe("/foo");
+
+    expect(onError).toHaveBeenCalledTimes(1);
+    expect(onError.mock.calls[0][0].statusCode).toBe(503);
+    expect(onError.mock.calls[0][1].path).toBe("/foo");
+
+    expect(onBeforeResponse).toHaveBeenCalledTimes(1);
+    expect(onBeforeResponse.mock.calls[0][0].node.res.statusCode).toBe(503);
+
+    expect(onAfterResponse).toHaveBeenCalledTimes(1);
+    expect(onAfterResponse.mock.calls[0][0].node.res.statusCode).toBe(503);
+  });
+
+  it("calls onRequest and onResponse when an error is returned", async () => {
+    app.use(
+      eventHandler(() => {
+        return createError({
+          statusCode: 404,
+        });
+      }),
+    );
+    await request.get("/foo");
+
+    expect(onRequest).toHaveBeenCalledTimes(1);
+    expect(onRequest.mock.calls[0][0].path).toBe("/foo");
+
+    expect(onError).toHaveBeenCalledTimes(1);
+
+    expect(onBeforeResponse).toHaveBeenCalledTimes(1);
+    expect(onBeforeResponse.mock.calls[0][0].node.res.statusCode).toBe(404);
+
+    expect(onAfterResponse).toHaveBeenCalledTimes(1);
+    expect(onAfterResponse.mock.calls[0][0].node.res.statusCode).toBe(404);
+  });
+
+  it("calls onRequest and onResponse when an unhandled error occurs", async () => {
+    app.use(
+      eventHandler((event) => {
+        // @ts-expect-error
+        return event.unknown.property;
+      }),
+    );
+    await request.get("/foo");
+
+    expect(onRequest).toHaveBeenCalledTimes(1);
+    expect(onRequest.mock.calls[0][0].path).toBe("/foo");
+
+    expect(onError).toHaveBeenCalledTimes(1);
+    expect(onError.mock.calls[0][0].statusCode).toBe(500);
+    expect(onError.mock.calls[0][0].cause).toBeInstanceOf(TypeError);
+    expect(onError.mock.calls[0][1].path).toBe("/foo");
+
+    expect(onBeforeResponse).toHaveBeenCalledTimes(1);
+    expect(onBeforeResponse.mock.calls[0][0].node.res.statusCode).toBe(500);
+
+    expect(onAfterResponse).toHaveBeenCalledTimes(1);
+    expect(onAfterResponse.mock.calls[0][0].node.res.statusCode).toBe(500);
+  });
 });