diff --git a/.changeset/cuddly-dodos-arrive.md b/.changeset/cuddly-dodos-arrive.md new file mode 100644 index 0000000000..df027f613f --- /dev/null +++ b/.changeset/cuddly-dodos-arrive.md @@ -0,0 +1,6 @@ +--- +"@effect/platform-node": patch +"@effect/platform": patch +--- + +use ReadonlyRecord for storing cookies diff --git a/.changeset/eight-seals-tie.md b/.changeset/eight-seals-tie.md new file mode 100644 index 0000000000..b283d3281a --- /dev/null +++ b/.changeset/eight-seals-tie.md @@ -0,0 +1,7 @@ +--- +"@effect/platform-node": patch +"@effect/platform-bun": patch +"@effect/platform": patch +--- + +add set-cookie headers in Http.response.toWeb diff --git a/packages/platform-bun/src/internal/http/server.ts b/packages/platform-bun/src/internal/http/server.ts index 7f31b9bf4c..22bf22ddec 100644 --- a/packages/platform-bun/src/internal/http/server.ts +++ b/packages/platform-bun/src/internal/http/server.ts @@ -126,8 +126,7 @@ const makeResponse = (request: ServerRequest.ServerRequest, response: ServerResp } if (!Cookies.isEmpty(response.cookies)) { - const toSet = Cookies.toSetCookieHeaders(response.cookies) - for (const header of toSet) { + for (const header of Cookies.toSetCookieHeaders(response.cookies)) { fields.headers.append("set-cookie", header) } } diff --git a/packages/platform-node/test/HttpServer.test.ts b/packages/platform-node/test/HttpServer.test.ts index 4b7b36de79..bc5eac5e94 100644 --- a/packages/platform-node/test/HttpServer.test.ts +++ b/packages/platform-node/test/HttpServer.test.ts @@ -604,20 +604,19 @@ describe("HttpServer", () => { const client = yield* _(makeClient) const res = yield* _(HttpC.request.get("/home"), client, Effect.scoped) assert.deepStrictEqual( - res.cookies.cookies[0].toJSON(), - Http.cookies.unsafeMakeCookie("test", "value").toJSON() - ) - assert.deepStrictEqual( - res.cookies.cookies[1].toJSON(), - Http.cookies.unsafeMakeCookie("test2", "value2", { - httpOnly: true, - secure: true, - sameSite: "lax", - partitioned: true, - path: "/", - domain: "example.com", - expires: new Date(2022, 1, 1, 0, 0, 0, 0), - maxAge: Duration.minutes(5) + res.cookies.toJSON(), + Http.cookies.fromReadonlyRecord({ + test: Http.cookies.unsafeMakeCookie("test", "value"), + test2: Http.cookies.unsafeMakeCookie("test2", "value2", { + httpOnly: true, + secure: true, + sameSite: "lax", + partitioned: true, + path: "/", + domain: "example.com", + expires: new Date(2022, 1, 1, 0, 0, 0, 0), + maxAge: Duration.minutes(5) + }) }).toJSON() ) }).pipe(Effect.scoped, runPromise)) diff --git a/packages/platform/src/Http/Cookies.ts b/packages/platform/src/Http/Cookies.ts index f945628669..bddf8fb200 100644 --- a/packages/platform/src/Http/Cookies.ts +++ b/packages/platform/src/Http/Cookies.ts @@ -8,7 +8,7 @@ import * as Inspectable from "effect/Inspectable" import * as Option from "effect/Option" import { type Pipeable, pipeArguments } from "effect/Pipeable" import * as Predicate from "effect/Predicate" -import * as ReadonlyArray from "effect/ReadonlyArray" +import * as ReadonlyRecord from "effect/ReadonlyRecord" import type * as Types from "effect/Types" import { TypeIdError } from "../Error.js" @@ -36,7 +36,7 @@ export const isCookies = (u: unknown): u is Cookies => Predicate.hasProperty(u, */ export interface Cookies extends Pipeable, Inspectable.Inspectable { readonly [TypeId]: TypeId - readonly cookies: ReadonlyArray + readonly cookies: ReadonlyRecord.ReadonlyRecord } /** @@ -103,7 +103,7 @@ const Proto: Omit = { toJSON(this: Cookies) { return { _id: "@effect/platform/Http/Cookies", - cookies: this.cookies.map((cookie) => cookie.toJSON()) + cookies: ReadonlyRecord.map(this.cookies, (cookie) => cookie.toJSON()) } }, pipe() { @@ -117,12 +117,26 @@ const Proto: Omit = { * @since 1.0.0 * @category constructors */ -export const fromIterable = (cookies: Iterable): Cookies => { +export const fromReadonlyRecord = (cookies: ReadonlyRecord.ReadonlyRecord): Cookies => { const self = Object.create(Proto) - self.cookies = ReadonlyArray.fromIterable(cookies) + self.cookies = cookies return self } +/** + * Create a Cookies object from an Iterable + * + * @since 1.0.0 + * @category constructors + */ +export const fromIterable = (cookies: Iterable): Cookies => { + const record: Record = {} + for (const cookie of cookies) { + record[cookie.name] = cookie + } + return fromReadonlyRecord(record) +} + /** * Create a Cookies object from a set of Set-Cookie headers * @@ -285,7 +299,7 @@ export const empty: Cookies = fromIterable([]) * @since 1.0.0 * @category refinements */ -export const isEmpty = (self: Cookies): boolean => self.cookies.length === 0 +export const isEmpty = (self: Cookies): boolean => ReadonlyRecord.isEmptyRecord(self.cookies) // eslint-disable-next-line no-control-regex const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/ @@ -362,7 +376,7 @@ export const unsafeMakeCookie = ( * @since 1.0.0 * @category combinators */ -export const append: { +export const setCookie: { (cookie: Cookie): (self: Cookies) => Cookies ( self: Cookies, @@ -370,7 +384,12 @@ export const append: { ): Cookies } = dual( 2, - (self: Cookies, cookie: Cookie) => fromIterable([...self.cookies, cookie]) + (self: Cookies, cookie: Cookie) => + fromReadonlyRecord(ReadonlyRecord.set( + self.cookies, + cookie.name, + cookie + )) ) /** @@ -379,16 +398,19 @@ export const append: { * @since 1.0.0 * @category combinators */ -export const appendAll: { +export const setAllCookie: { (cookies: Iterable): (self: Cookies) => Cookies ( self: Cookies, cookies: Iterable ): Cookies -} = dual(2, (self: Cookies, cookies: Iterable) => - fromIterable( - ReadonlyArray.appendAll(self.cookies, cookies) - )) +} = dual(2, (self: Cookies, cookies: Iterable) => { + const record = { ...self.cookies } + for (const cookie of cookies) { + record[cookie.name] = cookie + } + return fromReadonlyRecord(record) +}) /** * Combine two Cookies objects, removing duplicates from the first @@ -402,12 +424,11 @@ export const merge: { self: Cookies, that: Cookies ): Cookies -} = dual(2, (self: Cookies, that: Cookies) => { - const cookies = self.cookies.filter((c) => !that.cookies.some((c2) => c2.name === c.name)) - // eslint-disable-next-line no-restricted-syntax - cookies.push(...that.cookies) - return fromIterable(cookies) -}) +} = dual(2, (self: Cookies, that: Cookies) => + fromReadonlyRecord({ + ...self.cookies, + ...that.cookies + })) /** * Remove a cookie by name @@ -421,10 +442,7 @@ export const remove: { self: Cookies, name: string ): Cookies -} = dual(2, (self: Cookies, cookie: Cookie) => - fromIterable( - self.cookies.filter((c) => c.name !== cookie.name) - )) +} = dual(2, (self: Cookies, name: string) => fromReadonlyRecord(ReadonlyRecord.remove(self.cookies, name))) /** * Add a cookie to a Cookies object @@ -432,7 +450,7 @@ export const remove: { * @since 1.0.0 * @category combinators */ -export const add: { +export const set: { ( name: string, value: string, @@ -449,7 +467,7 @@ export const add: { (self: Cookies, name: string, value: string, options?: Cookie["options"]) => Either.map( makeCookie(name, value, options), - (cookie) => fromIterable([...self.cookies, cookie]) + (cookie) => fromReadonlyRecord(ReadonlyRecord.set(self.cookies, name, cookie)) ) ) @@ -459,7 +477,7 @@ export const add: { * @since 1.0.0 * @category combinators */ -export const unsafeAdd: { +export const unsafeSet: { ( name: string, value: string, @@ -474,7 +492,11 @@ export const unsafeAdd: { } = dual( (args) => isCookies(args[0]), (self: Cookies, name: string, value: string, options?: Cookie["options"]) => - append(self, unsafeMakeCookie(name, value, options)) + fromReadonlyRecord(ReadonlyRecord.set( + self.cookies, + name, + unsafeMakeCookie(name, value, options) + )) ) /** @@ -483,7 +505,7 @@ export const unsafeAdd: { * @since 1.0.0 * @category combinators */ -export const addAll: { +export const setAll: { ( cookies: Iterable ): (self: Cookies) => Either.Either @@ -497,15 +519,15 @@ export const addAll: { self: Cookies, cookies: Iterable ): Either.Either => { - const toAdd: Array = [] + const record: Record = { ...self.cookies } for (const [name, value, options] of cookies) { const either = makeCookie(name, value, options) if (Either.isLeft(either)) { return either as Either.Left } - toAdd.push(either.right) + record[name] = either.right } - return Either.right(appendAll(self, toAdd)) + return Either.right(fromReadonlyRecord(record)) } ) @@ -515,7 +537,7 @@ export const addAll: { * @since 1.0.0 * @category combinators */ -export const unsafeAddAll: { +export const unsafeSetAll: { ( cookies: Iterable ): (self: Cookies) => Cookies @@ -528,7 +550,7 @@ export const unsafeAddAll: { ( self: Cookies, cookies: Iterable - ): Cookies => Either.getOrThrow(addAll(self, cookies)) + ): Cookies => Either.getOrThrow(setAll(self, cookies)) ) /** @@ -616,7 +638,7 @@ export function serializeCookie(self: Cookie): string { * @category encoding */ export const toCookieHeader = (self: Cookies): string => - self.cookies.map((cookie) => `${cookie.name}=${cookie.valueEncoded}`).join("; ") + Object.values(self.cookies).map((cookie) => `${cookie.name}=${cookie.valueEncoded}`).join("; ") /** * To record @@ -626,8 +648,9 @@ export const toCookieHeader = (self: Cookies): string => */ export const toRecord = (self: Cookies): Record => { const record: Record = {} - for (let index = 0; index < self.cookies.length; index++) { - const cookie = self.cookies[index] + const cookies = Object.values(self.cookies) + for (let index = 0; index < cookies.length; index++) { + const cookie = cookies[index] record[cookie.name] = cookie.value } return record @@ -639,7 +662,7 @@ export const toRecord = (self: Cookies): Record => { * @since 1.0.0 * @category encoding */ -export const toSetCookieHeaders = (self: Cookies): Array => self.cookies.map(serializeCookie) +export const toSetCookieHeaders = (self: Cookies): Array => Object.values(self.cookies).map(serializeCookie) /** * Parse a cookie header into a record of key-value pairs diff --git a/packages/platform/src/internal/http/serverResponse.ts b/packages/platform/src/internal/http/serverResponse.ts index 517df871d1..85917df2fc 100644 --- a/packages/platform/src/internal/http/serverResponse.ts +++ b/packages/platform/src/internal/http/serverResponse.ts @@ -65,6 +65,7 @@ class ServerResponseImpl extends Effectable.StructuralClass( (args) => Cookies.isCookies(args[0]), (self, name, value, options) => - Effect.map(Cookies.add(self.cookies, name, value, options), (cookies) => + Effect.map(Cookies.set(self.cookies, name, value, options), (cookies) => new ServerResponseImpl( self.status, self.statusText, @@ -352,7 +353,7 @@ export const unsafeSetCookie = dual< self.status, self.statusText, self.headers, - Cookies.unsafeAdd(self.cookies, name, value, options), + Cookies.unsafeSet(self.cookies, name, value, options), self.body ) ) @@ -387,7 +388,7 @@ export const setCookies = dual< >( 2, (self, cookies) => - Effect.map(Cookies.addAll(self.cookies, cookies), (cookies) => + Effect.map(Cookies.setAll(self.cookies, cookies), (cookies) => new ServerResponseImpl( self.status, self.statusText, @@ -413,7 +414,7 @@ export const unsafeSetCookies = dual< self.status, self.statusText, self.headers, - Cookies.unsafeAddAll(self.cookies, cookies), + Cookies.unsafeSetAll(self.cookies, cookies), self.body ) ) @@ -485,11 +486,18 @@ export const setBody = dual< /** @internal */ export const toWeb = (response: ServerResponse.ServerResponse, withoutBody = false): Response => { + const headers = new globalThis.Headers(response.headers) + if (!Cookies.isEmpty(response.cookies)) { + const toAdd = Cookies.toSetCookieHeaders(response.cookies) + for (const header of toAdd) { + headers.append("set-cookie", header) + } + } if (withoutBody) { return new Response(undefined, { status: response.status, statusText: response.statusText as string, - headers: response.headers + headers }) } const body = response.body @@ -498,7 +506,7 @@ export const toWeb = (response: ServerResponse.ServerResponse, withoutBody = fal return new Response(undefined, { status: response.status, statusText: response.statusText as string, - headers: response.headers + headers }) } case "Uint8Array": @@ -506,21 +514,21 @@ export const toWeb = (response: ServerResponse.ServerResponse, withoutBody = fal return new Response(body.body as any, { status: response.status, statusText: response.statusText, - headers: response.headers + headers }) } case "FormData": { return new Response(body.formData as any, { status: response.status, statusText: response.statusText, - headers: response.headers + headers }) } case "Stream": { return new Response(Stream.toReadableStream(body.stream), { status: response.status, statusText: response.statusText, - headers: response.headers + headers }) } } diff --git a/packages/platform/test/Http/App.test.ts b/packages/platform/test/Http/App.test.ts index cddb884a5c..213ee9b715 100644 --- a/packages/platform/test/Http/App.test.ts +++ b/packages/platform/test/Http/App.test.ts @@ -12,6 +12,23 @@ describe("Http/App", () => { }) }) + test("cookies", async () => { + const handler = Http.app.toWebHandler( + Http.response.unsafeJson({ foo: "bar" }).pipe( + Http.response.unsafeSetCookie("foo", "bar"), + Http.response.unsafeSetCookie("test", "123", { secure: true, httpOnly: true, sameSite: "strict" }) + ) + ) + const response = await handler(new Request("http://localhost:3000/")) + assert.deepStrictEqual(response.headers.getSetCookie(), [ + "foo=bar", + "test=123; HttpOnly; Secure; SameSite=Strict" + ]) + assert.deepStrictEqual(await response.json(), { + foo: "bar" + }) + }) + test("stream", async () => { const handler = Http.app.toWebHandler(Http.response.stream(Stream.make("foo", "bar").pipe(Stream.encodeText))) const response = await handler(new Request("http://localhost:3000/"))