Skip to content

Commit

Permalink
add HttpMiddleware.cors (#3051)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart authored Jun 23, 2024
1 parent 75e718e commit b77fb0a
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/lucky-lemons-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/platform": patch
---

add HttpMiddleware.cors
15 changes: 15 additions & 0 deletions packages/platform/src/HttpMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,18 @@ export const searchParamsParser: <E, R>(
E,
Exclude<R, ServerRequest.ParsedSearchParams>
> = internal.searchParamsParser

/**
* @since 1.0.0
* @category constructors
*/
export const cors: (
options?: {
readonly allowedOrigins?: ReadonlyArray<string> | undefined
readonly allowedMethods?: ReadonlyArray<string> | undefined
readonly allowedHeaders?: ReadonlyArray<string> | undefined
readonly exposedHeaders?: ReadonlyArray<string> | undefined
readonly maxAge?: number | undefined
readonly credentials?: boolean | undefined
} | undefined
) => <E, R>(httpApp: App.Default<E, R>) => App.Default<E, R> = internal.cors
106 changes: 106 additions & 0 deletions packages/platform/src/internal/httpMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HttpApp } from "@effect/platform"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as FiberRef from "effect/FiberRef"
Expand All @@ -6,11 +7,13 @@ import { globalValue } from "effect/GlobalValue"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import type * as Predicate from "effect/Predicate"
import type { ReadonlyRecord } from "effect/Record"
import * as Headers from "../Headers.js"
import type * as App from "../HttpApp.js"
import type * as Middleware from "../HttpMiddleware.js"
import * as ServerError from "../HttpServerError.js"
import * as ServerRequest from "../HttpServerRequest.js"
import * as ServerResponse from "../HttpServerResponse.js"
import type { HttpServerResponse } from "../HttpServerResponse.js"
import * as TraceContext from "../HttpTraceContext.js"

Expand Down Expand Up @@ -202,3 +205,106 @@ export const searchParamsParser = <E, R>(httpApp: App.Default<E, R>) =>
Context.add(context, ServerRequest.ParsedSearchParams, params)
) as any
})

/** @internal */
export const cors = (options?: {
readonly allowedOrigins?: ReadonlyArray<string> | undefined
readonly allowedMethods?: ReadonlyArray<string> | undefined
readonly allowedHeaders?: ReadonlyArray<string> | undefined
readonly exposedHeaders?: ReadonlyArray<string> | undefined
readonly maxAge?: number | undefined
readonly credentials?: boolean | undefined
}) => {
const opts = {
allowedOrigins: ["*"],
allowedMethods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"],
allowedHeaders: [],
exposedHeaders: [],
credentials: false,
...options
}

const isAllowedOrigin = (origin: string) => opts.allowedOrigins.includes(origin)

const allowOrigin = (originHeader: string): ReadonlyRecord<string, string> | undefined => {
if (opts.allowedOrigins.length === 0) {
return { "access-control-allow-origin": "*" }
}

if (opts.allowedOrigins.length === 1) {
return {
"access-control-allow-origin": opts.allowedOrigins[0],
vary: "Origin"
}
}

if (isAllowedOrigin(originHeader)) {
return {
"access-control-allow-origin": originHeader,
vary: "Origin"
}
}

return undefined
}

const allowMethods = opts.allowedMethods.length > 0
? { "access-control-allow-methods": opts.allowedMethods.join(", ") }
: undefined

const allowCredentials = opts.credentials
? { "access-control-allow-credentials": "true" }
: undefined

const allowHeaders = (
accessControlRequestHeaders: string | undefined
): ReadonlyRecord<string, string> | undefined => {
if (opts.allowedHeaders.length === 0 && accessControlRequestHeaders) {
return {
vary: "Access-Control-Request-Headers",
"access-control-allow-headers": accessControlRequestHeaders
}
}

if (opts.allowedHeaders) {
return {
"access-control-allow-headers": opts.allowedHeaders.join(",")
}
}

return undefined
}

const exposeHeaders = opts.exposedHeaders.length > 0
? { "access-control-expose-headers": opts.exposedHeaders.join(",") }
: undefined

const maxAge = opts.maxAge
? { "access-control-max-age": opts.maxAge.toString() }
: undefined

return <E, R>(httpApp: HttpApp.Default<E, R>): HttpApp.Default<E, R> =>
Effect.withFiberRuntime((fiber) => {
const context = fiber.getFiberRef(FiberRef.currentContext)
const request = Context.unsafeGet(
context,
ServerRequest.HttpServerRequest
)
const origin = request.headers["origin"]
const accessControlRequestHeaders = request.headers["access-control-request-headers"]
const corsHeaders = Headers.unsafeFromRecord({
...allowOrigin(origin),
...allowCredentials,
...exposeHeaders
})
if (request.method === "OPTIONS") {
Object.assign(corsHeaders, {
...allowMethods,
...allowHeaders(accessControlRequestHeaders),
...maxAge
})
return Effect.succeed(ServerResponse.empty({ status: 204, headers: corsHeaders }))
}
return Effect.map(httpApp, ServerResponse.setHeaders(corsHeaders))
})
}
3 changes: 1 addition & 2 deletions packages/platform/src/internal/httpRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,7 @@ class RouterImpl<E = never, R = never> extends Effectable.StructuralClass<
}
}
toString() {
// TODO: remove any when fix lands
return (Inspectable as any).format(this)
return Inspectable.format(this)
}
[Inspectable.NodeInspectSymbol]() {
return this.toJSON()
Expand Down

0 comments on commit b77fb0a

Please sign in to comment.