Repository: unjs/h3 (ts)

Issue:
serveStatic's fallthrough option does not function as intended (possible fix included)
### Environment

- pnpm 9.10.0
- NodeJS 20.16.0

### Reproduction

```ts
import { createApp, defineEventHandler, serveStatic } from "h3";
import { readFile, stat } from "fs/promises";
import { join } from "path";

export const app = createApp();

const publicDir = "public";

app.use(
  "/",
  defineEventHandler(async (event) => {
    return await serveStatic(event, {
      getContents: async (id) => await readFile(join(publicDir, id)),
      getMeta: async (id) => {
        const stats = await stat(join(publicDir, id)).catch(() => undefined);

        if (!stats || !stats.isFile()) {
          return;
        }

        return {
          size: stats.size,
          mtime: stats.mtime,
        };
      },
      fallthrough: true,
    });
  })
);

app.use(
  "/",
  defineEventHandler((event) => {
    return "Hello world";
  })
);

```

### Describe the bug

Adding `fallthrough: true` option to serveStatic does not pass the request to the next event handler/middleware and returns `false` which h3 interprets as json.

I think I identified the offending code in `src/utils/static.ts`, removing it fixes the problem but I don't know if it breaks anything else yet.
on line 24: `return false` should be `return` or `return undefined`
same thing on line 65

Refer to the [registering event handlers section](https://h3.unjs.io/guide/app#registering-event-handlers) in the app instance docs.

I can submit a PR with a test and the fix if needed.

### Additional context

_No response_

### Logs

_No response_

Pull request:
fix(serveStatic): fall through to the next handler when `fallthrough` is set to true
<!--
PLEASE DO THIS BEFORE SUBMITTING A PR

1) Make sure there is an issue covering the problem or idea first. If not, please create one. Reference it in the PR via "resolves #12312312" 
2) Please keep your changes minimal and split them if you need to.
3) Ensure there is a minimal reproduction attached for bug fixes.

This will greatly help speed up the review process.

Thanks for your contribution ❤️
-->

Resolves #904, includes a new test suite "serve static with fallthrough".
Minimal reproduction is included with the original issue.


Patch:
diff --git a/src/utils/static.ts b/src/utils/static.ts
index dbb5b6a..001876e 100644
--- a/src/utils/static.ts
+++ b/src/utils/static.ts
@@ -10,14 +10,14 @@ export async function serveStatic(
   options: ServeStaticOptions,
 ): Promise<false | undefined | null | BodyInit> {
   if (event.req.method !== "GET" && event.req.method !== "HEAD") {
-    if (!options.fallthrough) {
-      event.res.headers.set("allow", "GET, HEAD");
-      throw createError({
-        statusMessage: "Method Not Allowed",
-        statusCode: 405,
-      });
+    if (options.fallthrough) {
+      return;
     }
-    return false;
+    event.res.headers.set("allow", "GET, HEAD");
+    throw createError({
+      statusMessage: "Method Not Allowed",
+      statusCode: 405,
+    });
   }
 
   const originalId = decodeURI(
@@ -52,13 +52,13 @@ export async function serveStatic(
   }
 
   if (!meta) {
-    if (!options.fallthrough) {
-      throw createError({
-        statusMessage: "Cannot find static asset " + id,
-        statusCode: 404,
-      });
+    if (options.fallthrough) {
+      return;
     }
-    return false;
+    throw createError({
+      statusMessage: "Cannot find static asset " + id,
+      statusCode: 404,
+    });
   }
 
   if (meta.etag && !event.res.headers.has("etag")) {


Test patch:
diff --git a/test/static.test.ts b/test/static.test.ts
index 4bd5f6c..5d89021 100644
--- a/test/static.test.ts
+++ b/test/static.test.ts
@@ -1,5 +1,5 @@
 import { beforeEach, vi } from "vitest";
-import { serveStatic } from "../src";
+import { serveStatic, type ServeStaticOptions } from "../src";
 import { describeMatrix } from "./_setup";
 
 describeMatrix("serve static", (t, { it, expect }) => {
@@ -100,3 +100,113 @@ describeMatrix("serve static", (t, { it, expect }) => {
     expect(res.status).toEqual(405);
   });
 });
+
+describeMatrix("serve static with fallthrough", (t, { it, expect }) => {
+  beforeEach(() => {
+    const serveStaticOptions = {
+      getContents: vi.fn((id) => {
+        return id.includes("404") || id.includes("fallthrough")
+          ? undefined
+          : `asset:${id}`;
+      }),
+      getMeta: vi.fn((id) =>
+        id.includes("404") || id.includes("fallthrough")
+          ? undefined
+          : {
+              type: "text/plain",
+              encoding: "utf8",
+              etag: "w/123",
+              mtime: 1_700_000_000_000,
+              path: id,
+              size: `asset:${id}`.length,
+            },
+      ),
+      indexNames: ["/index.html"],
+      encodings: { gzip: ".gz", br: ".br" },
+      fallthrough: true,
+    } satisfies ServeStaticOptions;
+
+    t.app.use("/**", (event) => {
+      return serveStatic(event, serveStaticOptions);
+    });
+
+    t.app.use("/**", (event) => {
+      if (event.path.includes("404")) {
+        event.res.status = 404;
+      }
+      return { fallthroughTest: "passing" };
+    });
+  });
+
+  const expectedHeaders = {
+    "content-type": "text/plain",
+    etag: "w/123",
+    "content-encoding": "utf8",
+    "last-modified": new Date(1_700_000_000_000).toUTCString(),
+    vary: "accept-encoding",
+  };
+
+  it("Can serve asset (GET)", async () => {
+    const res = await t.fetch("/test.png", {
+      headers: {
+        "if-none-match": "w/456",
+        "if-modified-since": new Date(1_700_000_000_000 - 1).toUTCString(),
+        "accept-encoding": "gzip, br",
+      },
+    });
+
+    expect(res.status).toEqual(200);
+    expect(await res.text()).toBe("asset:/test.png.gz");
+    expect(Object.fromEntries(res.headers)).toMatchObject(expectedHeaders);
+    expect(res.headers.get("content-length")).toBe("18");
+  });
+
+  it("Can serve asset (HEAD)", async () => {
+    const headRes = await t.fetch("/test.png", {
+      method: "HEAD",
+      headers: {
+        "if-none-match": "w/456",
+        "if-modified-since": new Date(1_700_000_000_000 - 1).toUTCString(),
+        "accept-encoding": "gzip, br",
+      },
+    });
+
+    expect(headRes.status).toEqual(200);
+    expect(await headRes.text()).toBe("");
+    expect(Object.fromEntries(headRes.headers)).toMatchObject(expectedHeaders);
+    expect(headRes.headers.get("content-length")).toBe("18");
+  });
+
+  it("Handles cache (if-none-match)", async () => {
+    const res = await t.fetch("/test.png", {
+      headers: { "if-none-match": "w/123" },
+    });
+    expect(res.headers.get("etag")).toBe(expectedHeaders.etag);
+    expect(res.status).toEqual(304);
+    expect(await res.text()).toBe("");
+  });
+
+  it("Handles cache (if-modified-since)", async () => {
+    const res = await t.fetch("/test.png", {
+      headers: {
+        "if-modified-since": new Date(1_700_000_000_001).toUTCString(),
+      },
+    });
+    expect(res.status).toEqual(304);
+    expect(await res.text()).toBe("");
+  });
+
+  it("Returns 404 if not found", async () => {
+    const res = await t.fetch("/404/test.png");
+    expect(res.status).toEqual(404);
+
+    const headRes = await t.fetch("/404/test.png", { method: "HEAD" });
+    expect(headRes.status).toEqual(404);
+  });
+
+  it("Falls through to the next handler", async () => {
+    const res = await t.fetch("/fallthrough/test.png");
+    expect(res.status).toEqual(200);
+    expect(await res.json()).toEqual({ fallthroughTest: "passing" });
+  });
+});
diff --git a/test/unit/encoding.test.ts b/test/unit/encoding.test.ts
new file mode 100644
index 0000000..af32676
--- /dev/null
+++ b/test/unit/encoding.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+import {
+  base64Encode,
+  base64Decode,
+  validateBinaryLike,
+} from "../../src/utils/internal/encoding";
+
+describe("encoding utilities", () => {
+  describe("base64Encode", () => {
+    it("should encode a string to base64url", () => {
+      const input = "hello world";
+      const expected = "aGVsbG8gd29ybGQ";
+      expect(base64Encode(input)).toBe(expected);
+    });
+
+    it("should encode a Uint8Array to base64url", () => {
+      const input = new Uint8Array([104, 101, 108, 108, 111]);
+      const expected = "aGVsbG8";
+      expect(base64Encode(input)).toBe(expected);
+    });
+
+    it("should encode an ArrayBuffer to base64url", () => {
+      const input = new Uint8Array([104, 101, 108, 108, 111]).buffer;
+      const expected = "aGVsbG8";
+      expect(base64Encode(input)).toBe(expected);
+    });
+  });
+
+  describe("base64Decode", () => {
+    it("should decode a base64url string to Uint8Array", () => {
+      const input = "aGVsbG8gd29ybGQ";
+      const expected = new Uint8Array([
+        104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100,
+      ]);
+      expect(base64Decode(input)).toEqual(expected);
+    });
+
+    it("should handle padding-less base64url strings", () => {
+      const input = "aGVsbG8";
+      const expected = new Uint8Array([104, 101, 108, 108, 111]);
+      expect(base64Decode(input)).toEqual(expected);
+    });
+  });
+
+  describe("validateBinaryLike", () => {
+    it("should convert a string to Uint8Array", () => {
+      const input = "hello";
+      const expected = new Uint8Array([104, 101, 108, 108, 111]);
+      expect(validateBinaryLike(input)).toEqual(expected);
+    });
+
+    it("should return the same Uint8Array if input is already Uint8Array", () => {
+      const input = new Uint8Array([104, 101, 108, 108, 111]);
+      expect(validateBinaryLike(input)).toBe(input);
+    });
+
+    it("should convert an ArrayBuffer to Uint8Array", () => {
+      const input = new Uint8Array([104, 101, 108, 108, 111]).buffer;
+      const expected = new Uint8Array([104, 101, 108, 108, 111]);
+      expect(validateBinaryLike(input)).toEqual(expected);
+    });
+
+    it("should throw an error for invalid input types", () => {
+      expect(() => validateBinaryLike(123)).toThrow(
+        "The input must be a Uint8Array, a string, or an ArrayBuffer.",
+      );
+    });
+  });
+});
diff --git a/test/utils.test.ts b/test/utils.test.ts
index bcb923c..e9103bc 100644
--- a/test/utils.test.ts
+++ b/test/utils.test.ts
@@ -7,6 +7,7 @@ import {
   getRequestURL,
   getRequestIP,
   getRequestFingerprint,
+  handleCacheHeaders,
 } from "../src";
 import { describeMatrix } from "./_setup";
 
@@ -297,4 +298,72 @@ describeMatrix("utils", (t, { it, describe, expect }) => {
       expect((await t.fetch("/post", { method: "HEAD" })).status).toBe(200);
     });
   });
+
+  describe("handleCacheHeaders", () => {
+    it("can handle cache headers", async () => {
+      t.app.use((event) => {
+        handleCacheHeaders(event, {
+          maxAge: 60,
+          modifiedTime: new Date("2021-01-01"),
+        });
+        return "ok";
+      });
+      const res = await t.fetch("/");
+      expect(res.headers.get("cache-control")).toBe(
+        "public, max-age=60, s-maxage=60",
+      );
+      expect(res.headers.get("last-modified")).toBe(
+        "Fri, 01 Jan 2021 00:00:00 GMT",
+      );
+      expect(await res.text()).toBe("ok");
+    });
+
+    it("can handle cache headers with etag", async () => {
+      t.app.use((event) => {
+        handleCacheHeaders(event, {
+          maxAge: 60,
+          etag: "123",
+        });
+        return "ok";
+      });
+      const res = await t.fetch("/");
+      expect(res.headers.get("cache-control")).toBe(
+        "public, max-age=60, s-maxage=60",
+      );
+      expect(res.headers.get("etag")).toBe("123");
+      expect(await res.text()).toBe("ok");
+    });
+
+    it("can handle cache headers with if-none-match", async () => {
+      t.app.use((event) => {
+        handleCacheHeaders(event, {
+          maxAge: 60,
+          etag: "123",
+        });
+        return "ok";
+      });
+      const res = await t.fetch("/", {
+        headers: {
+          "if-none-match": "123",
+        },
+      });
+      expect(res.status).toBe(304);
+    });
+
+    it("can handle cache headers with if-modified-since", async () => {
+      t.app.use((event) => {
+        handleCacheHeaders(event, {
+          maxAge: 60,
+          modifiedTime: new Date("2021-01-01"),
+        });
+        return "ok";
+      });
+      const res = await t.fetch("/", {
+        headers: {
+          "if-modified-since": "Fri, 01 Jan 2021 00:00:00 GMT",
+        },
+      });
+      expect(res.status).toBe(304);
+    });
+  });
 });