├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── src ├── index.spec.ts └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "plugin:@typescript-eslint/recommended", 5 | "prettier/@typescript-eslint", 6 | "plugin:prettier/recommended", 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, 10 | sourceType: "module", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | .DS_Store 4 | npm-debug.log 5 | dist/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | notifications: 6 | email: 7 | on_success: never 8 | on_failure: change 9 | 10 | node_js: 11 | - "12" 12 | - stable 13 | 14 | after_script: 15 | - npm install coveralls@2 16 | - cat ./coverage/lcov.info | coveralls 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Blake Embrey (hello@blakeembrey.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Worker GraphQL 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM downloads][downloads-image]][downloads-url] 5 | [![Build status][travis-image]][travis-url] 6 | [![Test coverage][coveralls-image]][coveralls-url] 7 | 8 | > GraphQL server for worker environments (e.g. Cloudflare Workers). 9 | 10 | GraphQL on Workers was inspired by [this blog post](https://blog.cloudflare.com/building-a-graphql-server-on-the-edge-with-cloudflare-workers/), but using Apollo Server has a [massive bundle size](https://github.com/apollographql/apollo-server/issues/1572) or can't bundle due to dependency on `graphql-upload` (a node.js dependency). Using `worker-graphql` resulted in a build of < 50KB. 11 | 12 | ## Installation 13 | 14 | ``` 15 | npm install @borderless/worker-graphql --save 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```ts 21 | import { processGraphQL } from "@borderless/worker-graphql"; 22 | import { makeExecutableSchema } from "graphql-tools"; 23 | 24 | const schema = makeExecutableSchema({ 25 | typeDefs: ` 26 | type Query { 27 | hello: String 28 | } 29 | `, 30 | resolvers: { 31 | Query: { 32 | hello: () => "Hello world!", 33 | }, 34 | }, 35 | }); 36 | 37 | // Wrap `processGraphQL` with CORS support. 38 | const handler = async (req: Request) => { 39 | if (req.method.toUpperCase() === "OPTIONS") { 40 | return new Response(null, { 41 | status: 204, 42 | headers: { 43 | "Access-Control-Allow-Methods": "GET,POST", 44 | "Access-Control-Allow-Headers": 45 | req.headers.get("Access-Control-Request-Headers") || "Content-Type", 46 | "Access-Control-Allow-Origin": "*", 47 | }, 48 | }); 49 | } 50 | 51 | const res = await processGraphQL(req, { schema }); 52 | res.headers.set("Access-Control-Allow-Origin", "*"); 53 | return res; 54 | }; 55 | 56 | addEventListener("fetch", (event) => { 57 | event.respondWith(handler(event.request)); 58 | }); 59 | ``` 60 | 61 | ## License 62 | 63 | MIT 64 | 65 | [npm-image]: https://img.shields.io/npm/v/@borderless/worker-graphql.svg?style=flat 66 | [npm-url]: https://npmjs.org/package/@borderless/worker-graphql 67 | [downloads-image]: https://img.shields.io/npm/dm/@borderless/worker-graphql.svg?style=flat 68 | [downloads-url]: https://npmjs.org/package/@borderless/worker-graphql 69 | [travis-image]: https://img.shields.io/travis/borderless/worker-graphql.svg?style=flat 70 | [travis-url]: https://travis-ci.org/borderless/worker-graphql 71 | [coveralls-image]: https://img.shields.io/coveralls/borderless/worker-graphql.svg?style=flat 72 | [coveralls-url]: https://coveralls.io/r/borderless/worker-graphql?branch=master 73 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@borderless/worker-graphql", 3 | "version": "1.0.4", 4 | "description": "GraphQL server for worker environments (e.g. Cloudflare Workers)", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist/" 9 | ], 10 | "scripts": { 11 | "prettier": "prettier --write", 12 | "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --quiet --fix", 13 | "format": "npm run prettier -- \"{.,src/**}/*.{js,jsx,ts,tsx,json,css,md,yml,yaml}\"", 14 | "build": "rimraf dist && tsc", 15 | "specs": "jest --coverage", 16 | "test": "npm run -s lint && npm run -s build && npm run -s specs", 17 | "prepare": "npm run build" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git://github.com/borderless/worker-graphql.git" 22 | }, 23 | "keywords": [ 24 | "worker", 25 | "fetch", 26 | "service", 27 | "graphql", 28 | "http", 29 | "cloudflare" 30 | ], 31 | "author": { 32 | "name": "Blake Embrey", 33 | "email": "hello@blakeembrey.com", 34 | "url": "http://blakeembrey.me" 35 | }, 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/borderless/worker-graphql/issues" 39 | }, 40 | "homepage": "https://github.com/borderless/worker-graphql", 41 | "jest": { 42 | "roots": [ 43 | "/src/" 44 | ], 45 | "transform": { 46 | "\\.tsx?$": "ts-jest" 47 | }, 48 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(tsx?|jsx?)$", 49 | "moduleFileExtensions": [ 50 | "ts", 51 | "tsx", 52 | "js", 53 | "jsx", 54 | "json" 55 | ] 56 | }, 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "lint-staged" 60 | } 61 | }, 62 | "lint-staged": { 63 | "*.{js,jsx,ts,tsx,json,css,md,yml,yaml}": "npm run prettier" 64 | }, 65 | "publishConfig": { 66 | "access": "public" 67 | }, 68 | "engines": { 69 | "node": ">=10" 70 | }, 71 | "devDependencies": { 72 | "@graphql-tools/schema": "^6.2.4", 73 | "@types/jest": "^26.0.14", 74 | "@types/node": "^14.11.10", 75 | "@typescript-eslint/eslint-plugin": "^4.4.1", 76 | "@typescript-eslint/parser": "^4.4.1", 77 | "cross-fetch": "^3.0.6", 78 | "eslint": "^7.11.0", 79 | "eslint-config-prettier": "^6.9.0", 80 | "eslint-plugin-prettier": "^3.1.2", 81 | "graphql": "^15.3.0", 82 | "husky": "^4.2.3", 83 | "jest": "^26.5.3", 84 | "lint-staged": "^10.0.8", 85 | "prettier": "^2.1.2", 86 | "rimraf": "^3.0.0", 87 | "ts-jest": "^26.4.1", 88 | "typescript": "^4.0.3" 89 | }, 90 | "peerDependencies": { 91 | "graphql": ">=14" 92 | }, 93 | "dependencies": { 94 | "@types/content-type": "^1.1.3", 95 | "byte-length": "^1.0.2", 96 | "content-type": "^1.0.4" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from "@graphql-tools/schema"; 2 | import { processGraphQL } from "."; 3 | 4 | import "cross-fetch/polyfill"; 5 | 6 | const schema = makeExecutableSchema({ 7 | typeDefs: ` 8 | type Query { 9 | hello: String 10 | echo(arg: String!): String! 11 | } 12 | `, 13 | resolvers: { 14 | Query: { 15 | hello: () => "Hello world!", 16 | echo: (_, { arg }: { arg: string }) => arg, 17 | }, 18 | }, 19 | }); 20 | 21 | const SIMPLE_QUERY = "{ hello }"; 22 | const ARGS_QUERY = "query ($arg: String!) { echo(arg: $arg) }"; 23 | 24 | describe("worker graphql", () => { 25 | const origin = `http://example.com/`; 26 | 27 | describe("GET", () => { 28 | it("should respond to get request", async () => { 29 | const req = new Request( 30 | `${origin}?query=${encodeURIComponent(SIMPLE_QUERY)}` 31 | ); 32 | const res = await processGraphQL(req, { schema }); 33 | 34 | expect(await res.json()).toEqual({ data: { hello: "Hello world!" } }); 35 | }); 36 | 37 | it("should handle variables in url", async () => { 38 | const url = new URL(origin); 39 | url.searchParams.set("query", ARGS_QUERY); 40 | url.searchParams.set("variables", JSON.stringify({ arg: "test" })); 41 | 42 | const req = new Request(url.href); 43 | const res = await processGraphQL(req, { schema }); 44 | 45 | expect(await res.json()).toEqual({ data: { echo: "test" } }); 46 | }); 47 | }); 48 | 49 | describe("POST", () => { 50 | it("should handle JSON", async () => { 51 | const req = new Request(origin, { 52 | method: "POST", 53 | body: JSON.stringify({ 54 | query: ARGS_QUERY, 55 | variables: { arg: "JSON" }, 56 | }), 57 | headers: { 58 | "Content-Type": "application/json", 59 | }, 60 | }); 61 | 62 | const res = await processGraphQL(req, { schema }); 63 | 64 | expect(await res.json()).toEqual({ data: { echo: "JSON" } }); 65 | }); 66 | 67 | it("should handle URL form encoded", async () => { 68 | const req = new Request(origin, { 69 | method: "POST", 70 | body: new URLSearchParams({ 71 | query: ARGS_QUERY, 72 | variables: JSON.stringify({ arg: "URL" }), 73 | }), 74 | }); 75 | 76 | const res = await processGraphQL(req, { schema }); 77 | 78 | expect(await res.json()).toEqual({ data: { echo: "URL" } }); 79 | }); 80 | 81 | it("should handle graphql query", async () => { 82 | const req = new Request(origin, { 83 | method: "POST", 84 | body: SIMPLE_QUERY, 85 | headers: { 86 | "Content-Type": "application/graphql", 87 | }, 88 | }); 89 | 90 | const res = await processGraphQL(req, { schema }); 91 | 92 | expect(await res.json()).toEqual({ data: { hello: "Hello world!" } }); 93 | }); 94 | 95 | it("should error on missing content type", async () => { 96 | const req = new Request(origin, { 97 | method: "POST", 98 | }); 99 | 100 | const res = await processGraphQL(req, { schema }); 101 | 102 | expect(await res.json()).toHaveProperty("errors"); 103 | }); 104 | 105 | it("should error on unknown content type", async () => { 106 | const req = new Request(origin, { 107 | method: "POST", 108 | headers: { 109 | "Content-Type": "foo/bar", 110 | }, 111 | }); 112 | 113 | const res = await processGraphQL(req, { schema }); 114 | 115 | expect(await res.json()).toHaveProperty("errors"); 116 | }); 117 | 118 | it("should handle content type errors", async () => { 119 | const req = new Request(origin, { 120 | method: "POST", 121 | headers: { 122 | "Content-Type": "bad-type", 123 | }, 124 | }); 125 | 126 | const res = await processGraphQL(req, { schema }); 127 | 128 | expect(res.status).toEqual(400); 129 | }); 130 | }); 131 | 132 | describe("unknown method", () => { 133 | it("should respond with 405", async () => { 134 | const req = new Request(origin, { method: "DELETE" }); 135 | const res = await processGraphQL(req, { schema }); 136 | 137 | expect(res.status).toEqual(405); 138 | }); 139 | }); 140 | 141 | describe("CORS example", () => { 142 | const handler = async (req: Request) => { 143 | if (req.method.toUpperCase() === "OPTIONS") { 144 | return new Response(null, { 145 | status: 204, 146 | headers: { 147 | "Access-Control-Allow-Methods": "GET,POST", 148 | "Access-Control-Allow-Headers": 149 | req.headers.get("Access-Control-Request-Headers") || 150 | "Content-Type", 151 | "Access-Control-Allow-Origin": "*", 152 | }, 153 | }); 154 | } 155 | 156 | const res = await processGraphQL(req, { schema }); 157 | res.headers.set("Access-Control-Allow-Origin", "*"); 158 | return res; 159 | }; 160 | 161 | it("should respond to OPTIONS", async () => { 162 | const req = new Request(origin, { method: "OPTIONS" }); 163 | const res = await handler(req); 164 | 165 | expect(res.status).toEqual(204); 166 | expect(res.headers.get("Access-Control-Allow-Origin")).toEqual("*"); 167 | }); 168 | 169 | it("should set origin access control header", async () => { 170 | const req = new Request( 171 | `${origin}?query=${encodeURIComponent(SIMPLE_QUERY)}` 172 | ); 173 | const res = await handler(req); 174 | 175 | expect(res.status).toEqual(200); 176 | expect(res.headers.get("Access-Control-Allow-Origin")).toEqual("*"); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { byteLength } from "byte-length"; 2 | import { graphql, GraphQLArgs, GraphQLError } from "graphql"; 3 | import { parse } from "content-type"; 4 | 5 | /** 6 | * GraphQL args provided by the HTTP request. 7 | */ 8 | export type RequestArgs = Pick< 9 | GraphQLArgs, 10 | "source" | "operationName" | "variableValues" 11 | >; 12 | 13 | /** 14 | * Parse parameters from URL search parameters. 15 | */ 16 | function getArgsFromParams(params: URLSearchParams): RequestArgs { 17 | const variables = params.get("variables"); 18 | 19 | return { 20 | source: params.get("query") || "", 21 | operationName: params.get("operationName"), 22 | variableValues: variables ? JSON.parse(variables) : undefined, 23 | }; 24 | } 25 | 26 | /** 27 | * Get variables from URL (e.g. GET request). 28 | */ 29 | export async function getArgsFromURL(req: Request): Promise { 30 | const url = new URL(req.url); 31 | return getArgsFromParams(url.searchParams); 32 | } 33 | 34 | /** 35 | * Get variables from HTTP body (e.g. POST request). 36 | */ 37 | export async function getArgsFromBody(req: Request): Promise { 38 | const contentType = req.headers.get("Content-Type"); 39 | if (contentType === null) { 40 | return { source: "" }; 41 | } 42 | 43 | const media = parse(contentType); 44 | 45 | if (media.type === "application/graphql") { 46 | const body = await req.text(); 47 | 48 | return { source: body }; 49 | } 50 | 51 | if (media.type === "application/json") { 52 | const body = await req.json(); 53 | 54 | return { 55 | source: typeof body.query === "string" ? body.query : "", 56 | operationName: 57 | typeof body.operationName === "string" ? body.operationName : null, 58 | variableValues: body.variables, 59 | }; 60 | } 61 | 62 | if (media.type === "application/x-www-form-urlencoded") { 63 | const body = await req.text(); 64 | const params = new URLSearchParams(body); 65 | return getArgsFromParams(params); 66 | } 67 | 68 | return { source: "" }; 69 | } 70 | 71 | /** 72 | * Execute the GraphQL schema. 73 | */ 74 | async function exec(options: GraphQLArgs & ProcessOptions) { 75 | const { data, errors } = await graphql(options); 76 | 77 | const body = JSON.stringify({ 78 | data, 79 | errors: errors?.map((x) => 80 | options.formatError ? options.formatError(x) : x 81 | ), 82 | }); 83 | 84 | return new Response(body, { 85 | headers: { 86 | "Content-Type": "application/json", 87 | "Content-Length": String(byteLength(body)), 88 | }, 89 | }); 90 | } 91 | 92 | /** 93 | * Configuration options for processing GraphQL. 94 | */ 95 | export type ProcessOptions = { 96 | formatError?: (error: GraphQLError) => { message: string }; 97 | }; 98 | 99 | /** 100 | * Configuration options for handler. 101 | */ 102 | export type Options = Omit & ProcessOptions; 103 | 104 | /** 105 | * Process GraphQL request using the URL (e.g. GET). 106 | */ 107 | export async function processGraphQLFromURL(req: Request, args: Options) { 108 | return exec({ ...args, ...(await getArgsFromURL(req)) }); 109 | } 110 | 111 | /** 112 | * Process GraphQL request using the request body (e.g. POST). 113 | */ 114 | export async function processGraphQLFromBody(req: Request, args: Options) { 115 | return exec({ ...args, ...(await getArgsFromBody(req)) }); 116 | } 117 | 118 | /** 119 | * Create a request handler for GraphQL. 120 | */ 121 | export async function processGraphQL( 122 | req: Request, 123 | args: Options 124 | ): Promise { 125 | const method = req.method.toUpperCase(); 126 | 127 | try { 128 | if (method === "GET") { 129 | return await processGraphQLFromURL(req, args); 130 | } 131 | 132 | if (method === "POST") { 133 | return await processGraphQLFromBody(req, args); 134 | } 135 | } catch (err) { 136 | return new Response(null, { status: 400 }); 137 | } 138 | 139 | return new Response(null, { 140 | status: 405, 141 | headers: { Allow: "GET,POST" }, 142 | }); 143 | } 144 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": ["ES2019", "WebWorker"], 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | "module": "commonjs", 8 | "strict": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "inlineSources": true 12 | } 13 | } 14 | --------------------------------------------------------------------------------