Repository: unjs/h3 (ts)

Issue:
Allow hooking into responses
Related: https://github.com/unjs/nitro/issues/1396

Currently, it is not possible to hook into responses (that are handled by h3 not directly with `res.end`). In order to do this, we might refactor `app.ts` and allow hooking into directly returned responses and allow to hook into using custom callback `onBeforeResponse(event, { body })`.

This can be later used by higher order frameworks to support custom response rewrite handling and logging. 

At this point, i also like to gether some common usecase ideas before moving forward (mainly because it also adds some overhead even if callback is not used)

Pull request description:
feat(app): `onRequest`, `onBeforeResponse` and `onAfterResponse` global hooks
<!---
☝️ PR title should follow conventional commits (https://conventionalcommits.org) 
-->

### 🔗 Linked issue

Resolves #447

### ❓ 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)
- [ ] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality like performance)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [x] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description

This PR adds 3 new global hooks (options) `onRequest(event)`, `onBeforeResponse(event, { body })`, `onAfterResponse(event, { body }?)` useful for debugging and request/response interception.

Notes:
- All hooks support async but it is highly discouraged to avoid Promises and block all requests
- The `onResponse` hooks, only receive response with body when a handled directly returns it (not when using send* methods).
- Only one hook is supported in h3 level. Frameworks can leverage unjs/hookable to support more advanced integrations.


### 📝 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! -->

- [ ] I have linked an issue or discussion.
- [ ] I have updated the documentation accordingly.


Patch:
diff --git a/src/app.ts b/src/app.ts
index 97dc842..9d7c23e 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -49,6 +49,15 @@ export interface AppUse {
 export interface AppOptions {
   debug?: boolean;
   onError?: (error: H3Error, event: H3Event) => any;
+  onRequest?: (event: H3Event) => void | Promise<void>;
+  onBeforeResponse?: (
+    event: H3Event,
+    response: { body?: unknown }
+  ) => void | Promise<void>;
+  onAfterResponse?: (
+    event: H3Event,
+    response?: { body?: unknown }
+  ) => void | Promise<void>;
 }
 
 export interface App {
@@ -113,6 +122,11 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) {
     // Layer path is the path without the prefix
     let _layerPath: string;
 
+    // Call onRequest hook
+    if (options.onRequest) {
+      await options.onRequest(event);
+    }
+
     for (const layer of stack) {
       // 1. Remove prefix from path
       if (layer.route.length > 1) {
@@ -137,23 +151,37 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) {
       const val = await layer.handler(event);
 
       // 5. Try to handle return value
-      const handledVal =
-        val !== undefined && handleHandlerResponse(event, val, spacing);
-      if (handledVal !== false) {
-        return handledVal;
+      const _response = val === undefined ? undefined : { body: await val };
+      if (_response !== undefined) {
+        if (options.onBeforeResponse) {
+          await options.onBeforeResponse(event, _response);
+        }
+        await handleHandlerResponse(event, val, spacing);
+        if (options.onAfterResponse) {
+          await options.onAfterResponse(event, _response);
+        }
+        return;
       }
 
       // Already handled
       if (event.handled) {
+        if (options.onAfterResponse) {
+          await options.onAfterResponse(event, _response);
+        }
         return;
       }
     }
+
     if (!event.handled) {
       throw createError({
         statusCode: 404,
         statusMessage: `Cannot find any path matching ${event.path || "/"}.`,
       });
     }
+
+    if (options.onAfterResponse) {
+      await options.onAfterResponse(event, undefined);
+    }
   });
 }
 


Test patch:
diff --git a/test/app.test.ts b/test/app.test.ts
index 837fd6a..932b82c 100644
--- a/test/app.test.ts
+++ b/test/app.test.ts
@@ -1,6 +1,6 @@
 import { Readable, Transform } from "node:stream";
 import supertest, { SuperTest, Test } from "supertest";
-import { describe, it, expect, beforeEach } from "vitest";
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
 import {
   createApp,
   toNodeListener,
@@ -14,11 +14,26 @@ describe("app", () => {
   let app: App;
   let request: SuperTest<Test>;
 
+  const onRequest = vi.fn();
+  const onBeforeResponse = vi.fn();
+  const onAfterResponse = vi.fn();
+  const onError = vi.fn();
+
   beforeEach(() => {
-    app = createApp({ debug: true });
+    app = createApp({
+      debug: true,
+      onError,
+      onRequest,
+      onBeforeResponse,
+      onAfterResponse,
+    });
     request = supertest(toNodeListener(app));
   });
 
+  afterEach(() => {
+    vi.resetAllMocks();
+  });
+
   it("can return JSON directly", async () => {
     app.use(
       "/api",
@@ -372,4 +387,18 @@ describe("app", () => {
     const res = await request.get("/");
     expect(res.body).toEqual({ works: 1 });
   });
+
+  it("calls onRequest and onResponse", async () => {
+    app.use(() => Promise.resolve("Hello World!"));
+    await request.get("/foo");
+
+    expect(onRequest).toHaveBeenCalledTimes(1);
+    expect(onRequest.mock.calls[0][0].path).toBe("/foo");
+
+    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!");
+  });
 });