Repository: unjs/h3 (ts)

Issue:
Merge set cookie value for same name multiple times
### Environment

Nuxt 3.10.3 with Nitro 2.9.1
Node 20.10.0

### Reproduction

```js
setCookie(e, 'session', '123', { httpOnly: true })
// ...
setCookie(e, 'session', '123', { httpOnly: true, maxAge: /* 1m */ 60 * 60 * 24 * 30 })
```



### Describe the bug

will actually set cookie twice:
```http
set-cookie: session=123; Path=/; HttpOnly
set-cookie: session=123; Max-Age=2592000; Path=/; HttpOnly
```


### Additional context

_No response_

### Logs

_No response_

Pull request description:
fix(setCookie): properly merge and dedup `set-cookie` header
resolves #705 for h3 v1 (backporting #830)

> Unique key generation was not restoring maxAge as the cookie parser was not restoring options (maxAge).
> Using new parseSetCookie from cookie-es 1.2 we can properly merge

This PR also drops dependency on ohash v1

Patch:
diff --git a/docs/2.utils/98.advanced.md b/docs/2.utils/98.advanced.md
index f1357ee..02b8111 100644
--- a/docs/2.utils/98.advanced.md
+++ b/docs/2.utils/98.advanced.md
@@ -52,7 +52,7 @@ Get a cookie value by name.
 
 Parse the request to get HTTP Cookie header string and return an object of all cookie name-value pairs.
 
-### `setCookie(event, name, value, serializeOptions?)`
+### `setCookie(event, name, value, serializeOptions)`
 
 Set a cookie value by name.
 
diff --git a/package.json b/package.json
index d0e4dbc..0aab3df 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,6 @@
     "destr": "^2.0.3",
     "iron-webcrypto": "^1.2.1",
     "node-mock-http": "^1.0.0",
-    "ohash": "^1.1.4",
     "radix3": "^1.1.2",
     "ufo": "^1.5.4",
     "uncrypto": "^0.1.3"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1391d35..0200ff1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,9 +29,6 @@ importers:
       node-mock-http:
         specifier: ^1.0.0
         version: 1.0.0
-      ohash:
-        specifier: ^1.1.4
-        version: 1.1.4
       radix3:
         specifier: ^1.1.2
         version: 1.1.2
diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts
index bb46b31..5aa8d77 100644
--- a/src/utils/cookie.ts
+++ b/src/utils/cookie.ts
@@ -1,7 +1,11 @@
-import { parse, serialize } from "cookie-es";
-import { objectHash } from "ohash";
 import type { CookieSerializeOptions } from "cookie-es";
 import type { H3Event } from "../event";
+import {
+  parse as parseCookie,
+  serialize as serializeCookie,
+  parseSetCookie,
+} from "cookie-es";
+import { getDistinctCookieKey } from "./internal/cookie";
 
 /**
  * Parse the request to get HTTP Cookie header string and return an object of all cookie name-value pairs.
@@ -12,7 +16,7 @@ import type { H3Event } from "../event";
  * ```
  */
 export function parseCookies(event: H3Event): Record<string, string> {
-  return parse(event.node.req.headers.cookie || "");
+  return parseCookie(event.node.req.headers.cookie || "");
 }
 
 /**
@@ -42,20 +46,38 @@ export function setCookie(
   event: H3Event,
   name: string,
   value: string,
-  serializeOptions?: CookieSerializeOptions,
+  serializeOptions: CookieSerializeOptions = {},
 ) {
-  serializeOptions = { path: "/", ...serializeOptions };
-  const cookieStr = serialize(name, value, serializeOptions);
-  let setCookies = event.node.res.getHeader("set-cookie");
-  if (!Array.isArray(setCookies)) {
-    setCookies = [setCookies as any];
+  // Apply default path
+  if (!serializeOptions.path) {
+    serializeOptions = { path: "/", ...serializeOptions };
   }
 
-  const _optionsHash = objectHash(serializeOptions);
-  setCookies = setCookies.filter((cookieValue: string) => {
-    return cookieValue && _optionsHash !== objectHash(parse(cookieValue));
-  });
-  event.node.res.setHeader("set-cookie", [...setCookies, cookieStr]);
+  // Serialize cookie
+  const newCookie = serializeCookie(name, value, serializeOptions);
+
+  // Check and add only not any other set-cookie headers already set
+  // const currentCookies = event.response.headers.getSetCookie();
+  const currentCookies = splitCookiesString(
+    event.node.res.getHeader("set-cookie") as string | string[],
+  );
+  if (currentCookies.length === 0) {
+    event.node.res.setHeader("set-cookie", newCookie);
+    return;
+  }
+
+  // Merge and deduplicate unique set-cookie headers
+  const newCookieKey = getDistinctCookieKey(name, serializeOptions);
+  event.node.res.removeHeader("set-cookie");
+  for (const cookie of currentCookies) {
+    const parsed = parseSetCookie(cookie);
+    const key = getDistinctCookieKey(parsed.name, parsed);
+    if (key === newCookieKey) {
+      continue;
+    }
+    event.node.res.appendHeader("set-cookie", cookie);
+  }
+  event.node.res.appendHeader("set-cookie", newCookie);
 }
 
 /**
diff --git a/src/utils/internal/cookie.ts b/src/utils/internal/cookie.ts
new file mode 100644
index 0000000..de27da8
--- /dev/null
+++ b/src/utils/internal/cookie.ts
@@ -0,0 +1,15 @@
+import type { CookieSerializeOptions, SetCookie } from "cookie-es";
+
+export function getDistinctCookieKey(
+  name: string,
+  opts: CookieSerializeOptions | SetCookie,
+) {
+  return [
+    name,
+    opts.domain || "",
+    opts.path || "/",
+    Boolean(opts.secure),
+    Boolean(opts.httpOnly),
+    Boolean(opts.sameSite),
+  ].join(";");
+}


Test patch:
diff --git a/test/cookie.test.ts b/test/cookie.test.ts
index 3b56955..83a6547 100644
--- a/test/cookie.test.ts
+++ b/test/cookie.test.ts
@@ -101,5 +101,24 @@ describe("", () => {
       ]);
       expect(result.text).toBe("200");
     });
+
+    it("can merge unique cookies", async () => {
+      app.use(
+        "/",
+        eventHandler((event) => {
+          setCookie(event, "session", "123", { httpOnly: true });
+          setCookie(event, "session", "123", {
+            httpOnly: true,
+            maxAge: 60 * 60 * 24 * 30,
+          });
+          return "200";
+        }),
+      );
+      const result = await request.get("/");
+      expect(result.headers["set-cookie"]).toEqual([
+        "session=123; Max-Age=2592000; Path=/; HttpOnly",
+      ]);
+      expect(result.text).toBe("200");
+    });
   });
 });