Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cors utils #322

Merged
merged 18 commits into from
Feb 16, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ H3 has a concept of composable utilities that accept `event` (from `eventHandler
- `getSession(event, { password, name?, cookie?, seal?, crypto? })`
- `updateSession(event, { password, name?, cookie?, seal?, crypto? }), update)`
- `clearSession(event, { password, name?, cookie?, seal?, crypto? }))`
- `handleCors(options)` (see [h3-cors](https://github.com/NozomuIkuta/h3-cors) for more detail)
Copy link
Member Author

@NozomuIkuta NozomuIkuta Feb 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me consider it in another time how we can organize README, because it needs lots of words to explain CORS features. Or, we might want to create a Docus website for h3.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. If possible adding help in JSDocs would be the best. In the future we should generate README baased on JSDocs and add more. Link is good for me until then 👍🏼


👉 You can learn more about usage in [JSDocs Documentation](https://www.jsdocs.io/package/h3#package-functions).

Expand All @@ -168,9 +169,6 @@ Please check their READMEs for more details.

PRs are welcome to add your packages.

- [h3-cors](https://github.com/NozomuIkuta/h3-cors)
- `defineCorsEventHandler(options)`
- `isPreflight(event)`
- [h3-typebox](https://github.com/kevinmarrec/h3-typebox)
- `validateBody(event, schema)`
- `validateQuery(event, schema)`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"cookie-es": "^0.5.0",
"defu": "^6.1.2",
"destr": "^1.2.2",
"radix3": "^1.0.0",
"ufo": "^1.0.1",
Expand Down
9 changes: 3 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions src/utils/cors/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineEventHandler } from "../../event";
import {
resolveCorsOptions,
appendCorsPreflightHeaders,
appendCorsActualRequestHeaders,
isPreflight,
} from "./utils";
import type { CorsOptions } from "./types";

export function handleCors(options: CorsOptions) {
pi0 marked this conversation as resolved.
Show resolved Hide resolved
const {
preflight: { statusCode },
} = resolveCorsOptions(options);

return defineEventHandler((event) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle shouldn't create a wrapper handler but directly accept event to append required headers.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: c1eef73

if (isPreflight(event)) {
appendCorsPreflightHeaders(event, options);

event.node.res.statusCode = statusCode;
event.node.res.setHeader("Content-Length", "0");
event.node.res.end();
} else {
appendCorsActualRequestHeaders(event, options);
}
});
}
8 changes: 8 additions & 0 deletions src/utils/cors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { handleCors } from "./handler";
export {
isPreflight,
isAllowedOrigin,
appendCorsActualRequestHeaders,
appendCorsPreflightHeaders,
} from "./utils";
export * from "./types";
77 changes: 77 additions & 0 deletions src/utils/cors/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { HTTPMethod } from "../../types";

export interface CorsOptions {
origin?: "*" | "null" | (string | RegExp)[] | ((origin: string) => boolean);
methods?: "*" | HTTPMethod[];
allowHeaders?: "*" | string[];
exposeHeaders?: "*" | string[];
credentials?: boolean;
maxAge?: string | false;
preflight?: {
statusCode?: number;
};
}

// TODO: Define `ResolvedCorsOptions` as "deep required nonnullable" type of `CorsOptions`
export interface ResolvedCorsOptions {
origin: "*" | "null" | (string | RegExp)[] | ((origin: string) => boolean);
methods: "*" | HTTPMethod[];
allowHeaders: "*" | string[];
exposeHeaders: "*" | string[];
credentials: boolean;
maxAge: string | false;
preflight: {
statusCode: number;
};
}

export type EmptyHeader = Record<string, never>;

export type AccessControlAllowOriginHeader =
| {
"Access-Control-Allow-Origin": "*";
}
| {
"Access-Control-Allow-Origin": "null" | string;
Vary: "Origin";
}
| EmptyHeader;

export type AccessControlAllowMethodsHeader =
| {
"Access-Control-Allow-Methods": "*" | string;
}
| EmptyHeader;

export type AccessControlAllowCredentialsHeader =
| {
"Access-Control-Allow-Credentials": "true";
}
| EmptyHeader;

export type AccessControlAllowHeadersHeader =
| {
"Access-Control-Allow-Headers": "*" | string;
Vary: "Access-Control-Request-Headers";
}
| EmptyHeader;

export type AccessControlExposeHeadersHeader =
| {
"Access-Control-Expose-Headers": "*" | string;
}
| EmptyHeader;

export type AccessControlMaxAgeHeader =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are exposing this types to end user, prefix would be better. (Do we really need to expose all individuals?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: 052c215

I modified exports and make only CorsOptions public, which is useful to restrict argument type for CORS utilities (e.g. handleCors(event, corsOptions)).

| {
"Access-Control-Max-Age": string;
}
| EmptyHeader;

export type CorsHeaders =
| AccessControlAllowOriginHeader
| AccessControlAllowMethodsHeader
| AccessControlAllowCredentialsHeader
| AccessControlAllowHeadersHeader
| AccessControlExposeHeadersHeader
| AccessControlMaxAgeHeader;
198 changes: 198 additions & 0 deletions src/utils/cors/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { defu } from "defu";
import { appendHeaders } from "../response";
import { getMethod, getRequestHeaders, getRequestHeader } from "../request";
import type { H3Event } from "../../event";
import type {
CorsOptions,
ResolvedCorsOptions,
AccessControlAllowOriginHeader,
AccessControlAllowMethodsHeader,
AccessControlAllowCredentialsHeader,
AccessControlAllowHeadersHeader,
AccessControlExposeHeadersHeader,
AccessControlMaxAgeHeader,
} from "./types";

export function resolveCorsOptions(
options: CorsOptions = {}
): ResolvedCorsOptions {
const defaultOptions: ResolvedCorsOptions = {
origin: "*",
methods: "*",
allowHeaders: "*",
exposeHeaders: "*",
credentials: false,
maxAge: false,
preflight: {
statusCode: 204,
},
};

return defu(options, defaultOptions);
}

export function isPreflight(event: H3Event): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe isPreflightRequest?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: 4636416

const method = getMethod(event);
const origin = getRequestHeader(event, "origin");
const accessControlRequestMethod = getRequestHeader(
event,
"access-control-request-method"
);

return method === "OPTIONS" && !!origin && !!accessControlRequestMethod;
}

export function isAllowedOrigin(
origin: ReturnType<typeof getRequestHeaders>["origin"],
options: CorsOptions
): boolean {
pi0 marked this conversation as resolved.
Show resolved Hide resolved
const { origin: originOption } = options;

if (
!origin ||
!originOption ||
originOption === "*" ||
originOption === "null"
) {
return true;
}

if (Array.isArray(originOption)) {
return originOption.some((_origin) => {
if (_origin instanceof RegExp) {
return _origin.test(origin);
}

return origin === _origin;
});
}

return originOption(origin);
}

export function createOriginHeaders(
event: H3Event,
options: CorsOptions
): AccessControlAllowOriginHeader {
const { origin: originOption } = options;
const origin = getRequestHeader(event, "Origin");

if (!origin || !originOption || originOption === "*") {
return { "Access-Control-Allow-Origin": "*" };
}

if (typeof originOption === "string") {
return { "Access-Control-Allow-Origin": originOption, Vary: "Origin" };
}

return isAllowedOrigin(origin, options)
? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
: {};
}

export function createMethodsHeaders(
options: CorsOptions
): AccessControlAllowMethodsHeader {
const { methods } = options;

if (!methods) {
return {};
}

if (methods === "*") {
return { "Access-Control-Allow-Methods": "*" };
}

return methods.length > 0
? { "Access-Control-Allow-Methods": methods.join(",") }
: {};
}

export function createCredentialsHeaders(
options: CorsOptions
): AccessControlAllowCredentialsHeader {
const { credentials } = options;

if (credentials) {
return { "Access-Control-Allow-Credentials": "true" };
}

return {};
}

export function createAllowHeaderHeaders(
event: H3Event,
options: CorsOptions
): AccessControlAllowHeadersHeader {
const { allowHeaders } = options;

if (!allowHeaders || allowHeaders === "*" || allowHeaders.length === 0) {
const header = getRequestHeader(event, "access-control-request-headers");

return header
? {
"Access-Control-Allow-Headers": header,
Vary: "Access-Control-Request-Headers",
}
: {};
}

return {
"Access-Control-Allow-Headers": allowHeaders.join(","),
Vary: "Access-Control-Request-Headers",
};
}

export function createExposeHeaders(
options: CorsOptions
): AccessControlExposeHeadersHeader {
const { exposeHeaders } = options;

if (!exposeHeaders) {
return {};
}

if (exposeHeaders === "*") {
return { "Access-Control-Expose-Headers": exposeHeaders };
}

return { "Access-Control-Expose-Headers": exposeHeaders.join(",") };
}

export function createMaxAgeHeader(
options: CorsOptions
): AccessControlMaxAgeHeader {
const { maxAge } = options;

if (maxAge) {
return { "Access-Control-Max-Age": maxAge };
}

return {};
}

// TODO: Implemente e2e tests to improve code coverage
/* c8 ignore start */
export function appendCorsPreflightHeaders(
event: H3Event,
options: CorsOptions
) {
appendHeaders(event, createOriginHeaders(event, options));
appendHeaders(event, createCredentialsHeaders(options));
appendHeaders(event, createExposeHeaders(options));
appendHeaders(event, createMethodsHeaders(options));
appendHeaders(event, createAllowHeaderHeaders(event, options));
}
/* c8 ignore end */

// TODO: Implemente e2e tests to improve code coverage
/* c8 ignore start */
export function appendCorsActualRequestHeaders(
event: H3Event,
options: CorsOptions
) {
appendHeaders(event, createOriginHeaders(event, options));
appendHeaders(event, createCredentialsHeaders(options));
appendHeaders(event, createExposeHeaders(options));
}
/* c8 ignore end */
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./proxy";
export * from "./request";
export * from "./response";
export * from "./session";
export * from "./cors";
Loading