├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── user_story.md └── workflows │ ├── algolia.yml │ ├── build-and-release.yml │ ├── ci.yml │ ├── codeql-analysis.yml │ └── website.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .releaserc ├── .yarn └── releases │ └── yarn-4.0.2.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── PROTOCOL.md ├── README.md ├── package.json ├── rollup.config.ts ├── scripts ├── esm-post-process.mjs └── post-gendocs.mjs ├── src ├── client.ts ├── common.ts ├── handler.ts ├── index.ts ├── parser.ts ├── use │ ├── express.ts │ ├── fastify.ts │ ├── fetch.ts │ ├── http.ts │ ├── http2.ts │ └── koa.ts └── utils.ts ├── tests ├── __snapshots__ │ ├── handler.test.ts.snap │ └── parser.test.ts.snap ├── client.test.ts ├── fixtures │ └── simple.ts ├── handler.test.ts ├── parser.test.ts ├── use.test.ts └── utils │ ├── testkit.ts │ ├── tfetch.ts │ ├── thandler.ts │ ├── tserver.ts │ └── tsubscribe.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json ├── typedoc.js ├── website ├── next-env.d.ts ├── next-sitemap.config.cjs ├── next.config.mjs ├── package.json ├── postcss.config.cjs ├── scripts │ └── algolia-sync.mjs ├── src │ ├── index.tsx │ └── pages │ │ ├── _app.tsx │ │ ├── _meta.ts │ │ ├── get-started.mdx │ │ ├── index.mdx │ │ └── recipes.mdx ├── tailwind.config.cjs ├── theme.config.tsx └── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('eslint').Linter.Config} 3 | */ 4 | const opts = { 5 | env: { 6 | es2020: true, 7 | node: true, 8 | jest: true, 9 | }, 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'prettier', 14 | ], 15 | rules: { 16 | // unused vars will be handled by the TS compiler 17 | '@typescript-eslint/no-unused-vars': 'off', 18 | '@typescript-eslint/ban-ts-comment': [ 19 | 'error', 20 | { 21 | 'ts-expect-error': 'allow-with-description', 22 | }, 23 | ], 24 | }, 25 | }; 26 | module.exports = opts; 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: enisdenjo 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Screenshot** 10 | Visualising is always helpful. 11 | 12 | **Expected Behaviour** 13 | I expected it to do _this_ - 14 | 15 | **Actual Behaviour** 16 | but instead it did _this_. 17 | 18 | **Debug Information** 19 | Help us debug the bug? 20 | 21 | **Further Information** 22 | Anything else you might find helpful. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/user_story.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 User story 3 | about: Suggest a feature in a form of user story 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ### Story 10 | 11 | As a _user or client or server_ 12 | 13 | I want _some feature_ 14 | 15 | So that _some value_ 16 | 17 | ### Acceptance criteria 18 | 19 | - _user or client or server type_ is able to... 20 | - _user or client or server type_ can add... 21 | - _user or client or server type_ can remove... 22 | -------------------------------------------------------------------------------- /.github/workflows/algolia.yml: -------------------------------------------------------------------------------- 1 | name: Algolia 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | sync: 10 | name: Sync 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Set up node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version-file: .nvmrc 19 | cache: yarn 20 | - name: Install 21 | run: yarn install 22 | - name: Generate documentation 23 | run: yarn gendocs 24 | - name: Build website 25 | run: yarn workspace website build 26 | - name: Sync 27 | working-directory: website 28 | run: node scripts/algolia-sync.mjs 29 | env: 30 | ALGOLIA_APP_ID: ANRJKXZTRW 31 | ALGOLIA_INDEX_NAME: searchv2_main 32 | ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }} 33 | SITE_URL: https://the-guild.dev/graphql/sse 34 | - name: Upload lockfile 35 | uses: actions/upload-artifact@v3 36 | with: 37 | name: algolia-lockfile 38 | path: website/algolia-lockfile.json 39 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Set up node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version-file: .nvmrc 16 | cache: yarn 17 | - name: Install 18 | run: yarn install 19 | - name: Build 20 | run: yarn build 21 | - name: Upload build 22 | uses: actions/upload-artifact@v3 23 | with: 24 | name: build 25 | path: | 26 | lib 27 | umd 28 | 29 | changelog: 30 | name: Changelog 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v3 35 | with: 36 | fetch-depth: 0 # necessary for correct changelog 37 | - name: Set up node 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version-file: .nvmrc 41 | cache: yarn 42 | - name: Install 43 | run: yarn install 44 | - name: Changelog 45 | run: | 46 | yarn semantic-release \ 47 | -p @semantic-release/release-notes-generator \ 48 | --dry-run \ 49 | | tee changelog.out 50 | sed -n '/#/,$p' changelog.out >> $GITHUB_STEP_SUMMARY 51 | 52 | release: 53 | name: Release 54 | runs-on: ubuntu-latest 55 | needs: [build, changelog] 56 | environment: npm 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v3 60 | with: 61 | token: ${{ secrets.GH_TOKEN }} 62 | fetch-depth: 0 # necessary for correct CHANGELOGS 63 | - name: Set up node 64 | uses: actions/setup-node@v3 65 | with: 66 | node-version-file: .nvmrc 67 | cache: yarn 68 | - name: Install 69 | run: yarn install 70 | - name: Download build 71 | uses: actions/download-artifact@v3 72 | with: 73 | name: build 74 | - name: Release 75 | env: 76 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 77 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 78 | run: yarn release 79 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | check: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | check: 17 | - format 18 | - lint 19 | - type 20 | name: Check ${{ matrix.check }} 21 | runs-on: ubuntu-latest 22 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | - name: Set up Node 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version-file: .nvmrc 30 | cache: yarn 31 | - name: Install 32 | run: yarn install 33 | - name: Check 34 | run: yarn workspaces foreach -A -p -v run check:${{ matrix.check }} 35 | 36 | test: 37 | name: Test 38 | runs-on: ubuntu-latest 39 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v3 43 | - name: Set up Node 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version-file: .nvmrc 47 | cache: yarn 48 | - name: Install 49 | run: yarn install 50 | - name: Test 51 | run: yarn test 52 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL analysis' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | branches: 10 | - master 11 | schedule: 12 | - cron: '0 23 * * 0' 13 | 14 | jobs: 15 | analyze: 16 | name: Analyze 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | - name: Initialize 22 | uses: github/codeql-action/init@v2 23 | with: 24 | languages: javascript 25 | - name: Perform analysis 26 | uses: github/codeql-action/analyze@v2 27 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | paths: 11 | - website/** 12 | 13 | jobs: 14 | deploy: 15 | name: Deploy 16 | runs-on: ubuntu-latest 17 | if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | - name: Set up node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version-file: .nvmrc 25 | cache: yarn 26 | - name: Install 27 | run: yarn install 28 | - name: Generate documentation 29 | run: yarn gendocs 30 | - name: Deploy 31 | uses: the-guild-org/shared-config/website-cf@main 32 | env: 33 | NEXT_BASE_PATH: ${{ github.ref == 'refs/heads/master' && '/graphql/sse' || '' }} 34 | SITE_URL: ${{ github.ref == 'refs/heads/master' && 'https://the-guild.dev/graphql/sse' || '' }} 35 | with: 36 | cloudflareApiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 37 | cloudflareAccountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 38 | githubToken: ${{ secrets.GITHUB_TOKEN }} 39 | projectName: graphql-sse 40 | prId: ${{ github.event.pull_request.number }} 41 | websiteDirectory: website 42 | buildScript: yarn build 43 | artifactDir: out 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | lib/ 4 | umd/ 5 | dist/ 6 | out/ 7 | .vscode/ 8 | .yarn/* 9 | !.yarn/releases/ 10 | .idea/ 11 | website/.next/ 12 | website/src/pages/docs/ 13 | website/out/ 14 | website/algolia-lockfile.json 15 | tsconfig.tsbuildinfo 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v21 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | lib 3 | umd 4 | dist 5 | out 6 | website/src/pages/docs 7 | website/algolia-lockfile.json 8 | CHANGELOG.md 9 | .next 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/changelog", 6 | [ 7 | "@semantic-release/npm", 8 | { 9 | "tarballDir": "dist" 10 | } 11 | ], 12 | [ 13 | "@semantic-release/git", 14 | { 15 | "message": "chore(release): 🎉 ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 16 | } 17 | ], 18 | [ 19 | "@semantic-release/github", 20 | { 21 | "assets": "dist/*.tgz", 22 | "failComment": false, 23 | "failTitle": false, 24 | "labels": false 25 | } 26 | ] 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.0.2.cjs 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.5.4](https://github.com/enisdenjo/graphql-sse/compare/v2.5.3...v2.5.4) (2025-01-10) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **client:** Conversion to async iterable from Response.body ([#114](https://github.com/enisdenjo/graphql-sse/issues/114)) ([f2adc92](https://github.com/enisdenjo/graphql-sse/commit/f2adc9241944b3b6fc6b6d201bc97aca9f131df7)) 7 | 8 | ## [2.5.3](https://github.com/enisdenjo/graphql-sse/compare/v2.5.2...v2.5.3) (2024-03-27) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **use/fastify:** Include middleware headers ([134c1b0](https://github.com/enisdenjo/graphql-sse/commit/134c1b022599e5c403d7204eda61106bac34216a)), closes [#91](https://github.com/enisdenjo/graphql-sse/issues/91) 14 | 15 | ## [2.5.2](https://github.com/enisdenjo/graphql-sse/compare/v2.5.1...v2.5.2) (2023-12-20) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * remove package.json workspaces entry in release ([c6dc093](https://github.com/enisdenjo/graphql-sse/commit/c6dc0933dc776657adaaa7b9a6183839bca35836)) 21 | 22 | ## [2.5.1](https://github.com/enisdenjo/graphql-sse/compare/v2.5.0...v2.5.1) (2023-12-14) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **use/koa:** Use parsed body from request ([#87](https://github.com/enisdenjo/graphql-sse/issues/87)) ([b290b90](https://github.com/enisdenjo/graphql-sse/commit/b290b90920f98236963b6e6ddfd095e19254634c)) 28 | 29 | # [2.5.0](https://github.com/enisdenjo/graphql-sse/compare/v2.4.1...v2.5.0) (2023-12-13) 30 | 31 | 32 | ### Features 33 | 34 | * **use/koa:** expose full Koa context to options ([#86](https://github.com/enisdenjo/graphql-sse/issues/86)) ([b37a6f9](https://github.com/enisdenjo/graphql-sse/commit/b37a6f92f32ac15bb40df2753545c48b054141de)) 35 | 36 | ## [2.4.1](https://github.com/enisdenjo/graphql-sse/compare/v2.4.0...v2.4.1) (2023-12-13) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * Add koa exports to package.json ([#85](https://github.com/enisdenjo/graphql-sse/issues/85)) ([e99cf99](https://github.com/enisdenjo/graphql-sse/commit/e99cf99d6c011e931353fdf876f91989e6cd3d70)) 42 | 43 | # [2.4.0](https://github.com/enisdenjo/graphql-sse/compare/v2.3.0...v2.4.0) (2023-11-29) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **client:** Use closures instead of bindings (with `this`) ([8ecdf3c](https://github.com/enisdenjo/graphql-sse/commit/8ecdf3cffb5e013466defbbb131b7faeb39ec27a)) 49 | 50 | 51 | ### Features 52 | 53 | * **client:** Event listeners for both operation modes ([#84](https://github.com/enisdenjo/graphql-sse/issues/84)) ([6274f44](https://github.com/enisdenjo/graphql-sse/commit/6274f44983e3c5ca7e343c880d10faf928597848)) 54 | 55 | # [2.3.0](https://github.com/enisdenjo/graphql-sse/compare/v2.2.3...v2.3.0) (2023-09-07) 56 | 57 | 58 | ### Features 59 | 60 | * **handler:** Use Koa ([#80](https://github.com/enisdenjo/graphql-sse/issues/80)) ([283b453](https://github.com/enisdenjo/graphql-sse/commit/283b453bd41abbd14c88f5076bf9149b3104ffd9)), closes [#78](https://github.com/enisdenjo/graphql-sse/issues/78) 61 | 62 | ## [2.2.3](https://github.com/enisdenjo/graphql-sse/compare/v2.2.2...v2.2.3) (2023-08-23) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * **use/http,use/http2,use/express,use/fastify:** Check `writable` instead of `closed` before writing to response ([3c71f69](https://github.com/enisdenjo/graphql-sse/commit/3c71f69262a5b30f74e5d743c8b9415bbb4b1ce9)), closes [#69](https://github.com/enisdenjo/graphql-sse/issues/69) 68 | 69 | ## [2.2.2](https://github.com/enisdenjo/graphql-sse/compare/v2.2.1...v2.2.2) (2023-08-22) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * **use/http,use/http2,use/express,use/fastify:** Handle cases where response's `close` event is late ([#75](https://github.com/enisdenjo/graphql-sse/issues/75)) ([4457cba](https://github.com/enisdenjo/graphql-sse/commit/4457cba74ab0f4474b648b2b3d75f88edcb1fe9b)), closes [#69](https://github.com/enisdenjo/graphql-sse/issues/69) 75 | 76 | ## [2.2.1](https://github.com/enisdenjo/graphql-sse/compare/v2.2.0...v2.2.1) (2023-07-31) 77 | 78 | 79 | ### Bug Fixes 80 | 81 | * **handler:** Always include the `data` field in stream messages ([#71](https://github.com/enisdenjo/graphql-sse/issues/71)) ([4643c9a](https://github.com/enisdenjo/graphql-sse/commit/4643c9a093345db5ddc4fcf082e1d8ff5bfc3ec0)) 82 | 83 | # [2.2.0](https://github.com/enisdenjo/graphql-sse/compare/v2.1.4...v2.2.0) (2023-06-22) 84 | 85 | 86 | ### Features 87 | 88 | * **client:** Async iterator for subscriptions ([#66](https://github.com/enisdenjo/graphql-sse/issues/66)) ([fb8bf11](https://github.com/enisdenjo/graphql-sse/commit/fb8bf1145943feb8ef808f52a022e2f67e49e577)) 89 | 90 | ## [2.1.4](https://github.com/enisdenjo/graphql-sse/compare/v2.1.3...v2.1.4) (2023-06-12) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * Request parameters `query` field can only be a string ([16c9600](https://github.com/enisdenjo/graphql-sse/commit/16c9600b37ca21d6cfa3fa745bd41887b98589fd)), closes [#65](https://github.com/enisdenjo/graphql-sse/issues/65) 96 | 97 | ## [2.1.3](https://github.com/enisdenjo/graphql-sse/compare/v2.1.2...v2.1.3) (2023-05-15) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * **client:** Respect retry attempts when server goes away after connecting in single connection mode ([#59](https://github.com/enisdenjo/graphql-sse/issues/59)) ([e895c5b](https://github.com/enisdenjo/graphql-sse/commit/e895c5bedc868fcae344acd60b25d89ba5a7eda4)), closes [#55](https://github.com/enisdenjo/graphql-sse/issues/55) 103 | * **handler:** Detect `ExecutionArgs` in `onSubscribe` return value ([a16b921](https://github.com/enisdenjo/graphql-sse/commit/a16b921682523c6f102471ab29f347c14483de5c)), closes [#58](https://github.com/enisdenjo/graphql-sse/issues/58) 104 | 105 | ## [2.1.2](https://github.com/enisdenjo/graphql-sse/compare/v2.1.1...v2.1.2) (2023-05-10) 106 | 107 | 108 | ### Bug Fixes 109 | 110 | * **client:** Respect retry attempts when server goes away after connecting ([#57](https://github.com/enisdenjo/graphql-sse/issues/57)) ([75c9f17](https://github.com/enisdenjo/graphql-sse/commit/75c9f17040c0f0242247ac722cde2e9032a878e3)), closes [#55](https://github.com/enisdenjo/graphql-sse/issues/55) 111 | 112 | 113 | ### Reverts 114 | 115 | * Revert "Revert "docs: website (#50)"" ([0e4f4b5](https://github.com/enisdenjo/graphql-sse/commit/0e4f4b5655e1c8bf518b9a400734ec7ee5d5578b)), closes [#50](https://github.com/enisdenjo/graphql-sse/issues/50) 116 | 117 | ## [2.1.1](https://github.com/enisdenjo/graphql-sse/compare/v2.1.0...v2.1.1) (2023-03-31) 118 | 119 | 120 | ### Bug Fixes 121 | 122 | * Add file extensions to imports/exports in ESM type definitions ([bbf23b1](https://github.com/enisdenjo/graphql-sse/commit/bbf23b175412d7bf91c7dd9ad0fb290e53916f1d)) 123 | 124 | # [2.1.0](https://github.com/enisdenjo/graphql-sse/compare/v2.0.0...v2.1.0) (2023-02-17) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * **use/express,use/fastify:** Resolve body if previously parsed ([6573e94](https://github.com/enisdenjo/graphql-sse/commit/6573e94cd20283e05bb7b3e890c0930bf1b72a16)) 130 | 131 | 132 | ### Features 133 | 134 | * **handler:** Export handler options type for each integration ([2a2e517](https://github.com/enisdenjo/graphql-sse/commit/2a2e51739db88d20ca9dc55236d6ae5c9a155b0f)) 135 | 136 | # [2.0.0](https://github.com/enisdenjo/graphql-sse/compare/v1.3.2...v2.0.0) (2022-12-20) 137 | 138 | 139 | ### Features 140 | 141 | * **handler:** Server and environment agnostic handler ([#37](https://github.com/enisdenjo/graphql-sse/issues/37)) ([22cf03d](https://github.com/enisdenjo/graphql-sse/commit/22cf03d3c214019a3bf538742bbceac766c17353)) 142 | 143 | 144 | ### BREAKING CHANGES 145 | 146 | * **handler:** The handler is now server agnostic and can run _anywhere_ 147 | 148 | - Core of `graphql-sse` is now server agnostic and as such offers a handler that implements a generic request/response model 149 | - Handler does not await for whole operation to complete anymore. Only the processing part (parsing, validating and executing) 150 | - GraphQL context is now typed 151 | - Hook arguments have been changed, they're not providing the Node native req/res anymore - they instead provide the generic request/response 152 | - `onSubscribe` hook can now return an execution result too (useful for caching for example) 153 | - Throwing in `onNext` and `onComplete` hooks will bubble the error to the returned iterator 154 | 155 | ### Migration 156 | 157 | Even though the core of graphql-sse is now completely server agnostic, there are adapters to ease the integration with existing solutions. Migrating is actually not a headache! 158 | 159 | Beware that the adapters **don't** handle internal errors, it's your responsibility to take care of that and behave accordingly. 160 | 161 | #### [`http`](https://nodejs.org/api/http.html) 162 | 163 | ```diff 164 | import http from 'http'; 165 | - import { createHandler } from 'graphql-sse'; 166 | + import { createHandler } from 'graphql-sse/lib/use/http'; 167 | 168 | // Create the GraphQL over SSE handler 169 | const handler = createHandler({ schema }); 170 | 171 | // Create an HTTP server using the handler on `/graphql/stream` 172 | const server = http.createServer((req, res) => { 173 | if (req.url.startsWith('/graphql/stream')) { 174 | return handler(req, res); 175 | } 176 | res.writeHead(404).end(); 177 | }); 178 | 179 | server.listen(4000); 180 | console.log('Listening to port 4000'); 181 | ``` 182 | 183 | #### [`http2`](https://nodejs.org/api/http2.html) 184 | 185 | ```diff 186 | import fs from 'fs'; 187 | import http2 from 'http2'; 188 | - import { createHandler } from 'graphql-sse'; 189 | + import { createHandler } from 'graphql-sse/lib/use/http2'; 190 | 191 | // Create the GraphQL over SSE handler 192 | const handler = createHandler({ schema }); 193 | 194 | // Create an HTTP server using the handler on `/graphql/stream` 195 | const server = http.createServer((req, res) => { 196 | if (req.url.startsWith('/graphql/stream')) { 197 | return handler(req, res); 198 | } 199 | res.writeHead(404).end(); 200 | }); 201 | 202 | server.listen(4000); 203 | console.log('Listening to port 4000'); 204 | ``` 205 | 206 | #### [`express`](https://expressjs.com/) 207 | 208 | ```diff 209 | import express from 'express'; // yarn add express 210 | - import { createHandler } from 'graphql-sse'; 211 | + import { createHandler } from 'graphql-sse/lib/use/express'; 212 | 213 | // Create the GraphQL over SSE handler 214 | const handler = createHandler({ schema }); 215 | 216 | // Create an express app 217 | const app = express(); 218 | 219 | // Serve all methods on `/graphql/stream` 220 | app.use('/graphql/stream', handler); 221 | 222 | server.listen(4000); 223 | console.log('Listening to port 4000'); 224 | ``` 225 | 226 | #### [`fastify`](https://www.fastify.io/) 227 | 228 | ```diff 229 | import Fastify from 'fastify'; // yarn add fastify 230 | - import { createHandler } from 'graphql-sse'; 231 | + import { createHandler } from 'graphql-sse/lib/use/fastify'; 232 | 233 | // Create the GraphQL over SSE handler 234 | const handler = createHandler({ schema }); 235 | 236 | // Create a fastify app 237 | const fastify = Fastify(); 238 | 239 | // Serve all methods on `/graphql/stream` 240 | fastify.all('/graphql/stream', handler); 241 | 242 | fastify.listen({ port: 4000 }); 243 | console.log('Listening to port 4000'); 244 | ``` 245 | 246 | ## [1.3.2](https://github.com/enisdenjo/graphql-sse/compare/v1.3.1...v1.3.2) (2022-12-06) 247 | 248 | 249 | ### Bug Fixes 250 | 251 | * **handler:** Correct typings and support for http2 ([08d6ca3](https://github.com/enisdenjo/graphql-sse/commit/08d6ca39d4cd178b9262af342266bb8fbd9268d1)), closes [#38](https://github.com/enisdenjo/graphql-sse/issues/38) 252 | 253 | ## [1.3.1](https://github.com/enisdenjo/graphql-sse/compare/v1.3.0...v1.3.1) (2022-12-05) 254 | 255 | 256 | ### Bug Fixes 257 | 258 | * **client:** Abort request when reporting error ([91057bd](https://github.com/enisdenjo/graphql-sse/commit/91057bd58a9edd7c83b2af4bb368511bdcf312b4)) 259 | * **client:** Operation requests are of application/json content-type ([0084de7](https://github.com/enisdenjo/graphql-sse/commit/0084de7c7f55c77c9b9156b98c264b90f49bf2b2)) 260 | 261 | # [1.3.0](https://github.com/enisdenjo/graphql-sse/compare/v1.2.5...v1.3.0) (2022-07-20) 262 | 263 | 264 | ### Features 265 | 266 | * **client:** Accept `referrer` and `referrerPolicy` fetch options ([#32](https://github.com/enisdenjo/graphql-sse/issues/32)) ([dbaa90a](https://github.com/enisdenjo/graphql-sse/commit/dbaa90af1ab5cad06f03deef422c2216397498f1)) 267 | 268 | ## [1.2.5](https://github.com/enisdenjo/graphql-sse/compare/v1.2.4...v1.2.5) (2022-07-17) 269 | 270 | 271 | ### Bug Fixes 272 | 273 | * **client:** Leverage active streams for reliable network error retries ([607b468](https://github.com/enisdenjo/graphql-sse/commit/607b4684b99284108ba4cd4d9c79f0a56bd16fbd)) 274 | 275 | ## [1.2.4](https://github.com/enisdenjo/graphql-sse/compare/v1.2.3...v1.2.4) (2022-07-01) 276 | 277 | 278 | ### Bug Fixes 279 | 280 | * Add types path to package.json `exports` ([44f95b6](https://github.com/enisdenjo/graphql-sse/commit/44f95b63c47f1faf06dfa671c6a4507b4ce7768b)) 281 | 282 | ## [1.2.3](https://github.com/enisdenjo/graphql-sse/compare/v1.2.2...v1.2.3) (2022-06-13) 283 | 284 | 285 | ### Bug Fixes 286 | 287 | * **client:** Retry if connection is closed while having active streams ([83a0178](https://github.com/enisdenjo/graphql-sse/commit/83a0178d6964fa0ff889de1db9be062212f4474e)), closes [#28](https://github.com/enisdenjo/graphql-sse/issues/28) 288 | 289 | ## [1.2.2](https://github.com/enisdenjo/graphql-sse/compare/v1.2.1...v1.2.2) (2022-06-09) 290 | 291 | 292 | ### Bug Fixes 293 | 294 | * **client:** Network errors during event emission contain the keyword "stream" in Firefox ([054f16b](https://github.com/enisdenjo/graphql-sse/commit/054f16b7bfb7cc6928dd538de3029719591c9ebe)) 295 | 296 | ## [1.2.1](https://github.com/enisdenjo/graphql-sse/compare/v1.2.0...v1.2.1) (2022-06-09) 297 | 298 | 299 | ### Bug Fixes 300 | 301 | * **client:** Retry network errors even if they occur during event emission ([489b1b0](https://github.com/enisdenjo/graphql-sse/commit/489b1b01d89881724ab8bf4dee3d1e395089101d)), closes [#27](https://github.com/enisdenjo/graphql-sse/issues/27) 302 | 303 | 304 | ### Performance Improvements 305 | 306 | * **client:** Avoid recreating result variables when reading the response stream ([16f6a6c](https://github.com/enisdenjo/graphql-sse/commit/16f6a6c5ec77f63d19afda1c847e965a12513fc7)) 307 | 308 | # [1.2.0](https://github.com/enisdenjo/graphql-sse/compare/v1.1.0...v1.2.0) (2022-04-14) 309 | 310 | 311 | ### Bug Fixes 312 | 313 | * **client:** TypeScript generic for ensuring proper arguments when using "single connection mode" ([be2ae7d](https://github.com/enisdenjo/graphql-sse/commit/be2ae7daa789e7c430b147b15c67551311de11b9)) 314 | 315 | 316 | ### Features 317 | 318 | * **client:** Inspect incoming messages through `ClientOptions.onMessage` ([496e74b](https://github.com/enisdenjo/graphql-sse/commit/496e74b0a0b5b3382253f7ffb7edd2b5a2da05d1)), closes [#20](https://github.com/enisdenjo/graphql-sse/issues/20) 319 | 320 | # [1.1.0](https://github.com/enisdenjo/graphql-sse/compare/v1.0.6...v1.1.0) (2022-03-09) 321 | 322 | 323 | ### Features 324 | 325 | * **client:** Add `credentials` property for requests ([79d0266](https://github.com/enisdenjo/graphql-sse/commit/79d0266d81e2ec78df917c1e068d04db768b9315)) 326 | * **client:** Add `lazyCloseTimeout` as a close timeout after last operation completes ([16e5e31](https://github.com/enisdenjo/graphql-sse/commit/16e5e3151439a64abe1134c3367c629a66daf989)), closes [#17](https://github.com/enisdenjo/graphql-sse/issues/17) 327 | 328 | ## [1.0.6](https://github.com/enisdenjo/graphql-sse/compare/v1.0.5...v1.0.6) (2021-11-18) 329 | 330 | 331 | ### Bug Fixes 332 | 333 | * **client:** Avoid bundling DOM types, have the implementor supply his own `Response` type ([98780c0](https://github.com/enisdenjo/graphql-sse/commit/98780c08843e4fdd119726ebab2a8eb3edbdee68)) 334 | * **handler:** Support generics for requests and responses ([9ab10c0](https://github.com/enisdenjo/graphql-sse/commit/9ab10c0ca1db5e58b8b5da514852f917e0c9366b)) 335 | 336 | ## [1.0.5](https://github.com/enisdenjo/graphql-sse/compare/v1.0.4...v1.0.5) (2021-11-02) 337 | 338 | 339 | ### Bug Fixes 340 | 341 | * **client:** Should not call complete after subscription error ([d8b7634](https://github.com/enisdenjo/graphql-sse/commit/d8b76346832101fa293e55b621ce753f7e1d59e1)) 342 | * **handler:** Use 3rd `body` argument only if is object or string ([2062579](https://github.com/enisdenjo/graphql-sse/commit/20625792644590b5e2c03af7e7615b5aca4a31d1)) 343 | 344 | ## [1.0.4](https://github.com/enisdenjo/graphql-sse/compare/v1.0.3...v1.0.4) (2021-09-08) 345 | 346 | 347 | ### Bug Fixes 348 | 349 | * Define graphql execution results ([89da803](https://github.com/enisdenjo/graphql-sse/commit/89da8038f983719b5cda3635652157e39ed0ee4d)) 350 | * **server:** Operation result can be async generator or iterable ([24b6078](https://github.com/enisdenjo/graphql-sse/commit/24b60780ed21c6d119677049a25189af32759a5a)) 351 | 352 | ## [1.0.3](https://github.com/enisdenjo/graphql-sse/compare/v1.0.2...v1.0.3) (2021-08-26) 353 | 354 | 355 | ### Bug Fixes 356 | 357 | * Bump `graphql` version to v16 in package.json ([af219f9](https://github.com/enisdenjo/graphql-sse/commit/af219f90ffcdb7019fe1e086d92a01bd98905869)) 358 | 359 | ## [1.0.2](https://github.com/enisdenjo/graphql-sse/compare/v1.0.1...v1.0.2) (2021-08-26) 360 | 361 | 362 | ### Bug Fixes 363 | 364 | * Add support for `graphql@v16` ([89367f2](https://github.com/enisdenjo/graphql-sse/commit/89367f23a3f41f0e802cfbf70aa3d24dfa21e302)) 365 | 366 | ## [1.0.1](https://github.com/enisdenjo/graphql-sse/compare/v1.0.0...v1.0.1) (2021-08-23) 367 | 368 | 369 | ### Bug Fixes 370 | 371 | * Prefer `X-GraphQL-Event-Stream-Token` header name for clarity ([9aaa0a9](https://github.com/enisdenjo/graphql-sse/commit/9aaa0a92fefd26df8e93fc3ec709113a03677350)) 372 | 373 | # 1.0.0 (2021-08-21) 374 | 375 | 376 | ### Features 377 | 378 | * Client ([#3](https://github.com/enisdenjo/graphql-sse/issues/3)) ([754487d](https://github.com/enisdenjo/graphql-sse/commit/754487dbc83b352ab1d86fdc1a5953df0a9c3f22)) 379 | * Server request handler ([#2](https://github.com/enisdenjo/graphql-sse/issues/2)) ([8381796](https://github.com/enisdenjo/graphql-sse/commit/838179673ad38e077e59b7738524652e4602633e)) 380 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at badurinadenis@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `graphql-sse` 2 | 3 | ## Reporting Issues 4 | 5 | Issues are for bugs or features. Please consider the following checklist. 6 | 7 | ### Checklist 8 | 9 | - [x] Does your title concisely summarize the problem? 10 | - [x] Is it possible for you to include a minimal, reproducable example? Is it an [SSCCE](http://sscce.org)? 11 | - [x] What OS and browser are you using? What versions? 12 | - [x] Did you use a Issue Template? 13 | 14 | ### General Guidelines 15 | 16 | - **Keep it concise.** Longer issues are harder to understand. 17 | - **Keep it focused.** Solving three vague problems is harder than solving one specific problem. 18 | - **Be friendly!** Everyone is working hard and trying to be effective. 19 | 20 | ## [Git Commit Guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) 21 | 22 | This repository follows the [Angular Commit Message Conventions](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) for auto-generating the documentation and keeping readability high. 23 | 24 | ## Comments in Code 25 | 26 | We use a convention for comments in code: 27 | {NOTE/FIXME/TODO}-{initials}-{YYMMDD} descriptive text 28 | 29 | so for example: // TODO-db-19001 leaving a todo note here for the next guy (or future me) 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Denis Badurina 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # GraphQL over Server-Sent Events Protocol 2 | 3 | ## Introduction 4 | 5 | We live in a connected world where real-time needs are ever-growing. Especially in the world's most connected "world", the internet. When talking about real-time, there are two main players in the game: [WebSockets](https://datatracker.ietf.org/doc/html/rfc6455) and [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html). 6 | 7 | Using Server-Sent Events (abbr. SSE) for your next real-time driven endeavour sounds appealing because of many reasons spanning from simplicity to acceptance. However, you're soon to find out that SSE suffers from a limitation to the maximum number of open connections when dealing with HTTP/1 powered servers (more details below). 8 | 9 | This documents aims to elevate HTTP/1 limitations through a "single connection mode" and back up HTTP/2+ powered servers using the "distinct connections mode" with a unified GraphQL over SSE transport protocol. 10 | 11 | ## Distinct connections mode 12 | 13 | Operation requests need to conform to the [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md), with two key observations: 14 | 15 | 1. The `Content-Type` MUST always be `text/event-stream` as per the [Server-Sent Events spec](https://www.w3.org/TR/eventsource/#text-event-stream). 16 | 1. Validation steps that run before execution of the GraphQL operation MUST report errors through an accepted SSE connection by emitting `next` events that contain the errors in the data field. 17 |
One reason being, the server should agree with the client's `Accept` header when deciding about the response's `Content-Type`. 18 |
Additionally, responding with a `400` (Bad Request) will cause the user agent to fail the connection. In some cases, like with the browser's native `EventSource`, the error event will hold no meaningful information helping to understand the validation issue(s). 19 | 20 | Streaming operations, such as `subscriptions` or directives like `@stream` and `@defer`, are terminated/completed by having the client simply close the SSE connection. 21 | 22 | The execution result is streamed through the established SSE connection. 23 | 24 | ### Event stream 25 | 26 | To be interpreted as the event stream following the [Server-Sent Events spec](https://www.w3.org/TR/eventsource/#event-stream-interpretation). 27 | 28 | #### `next` event 29 | 30 | Operation execution result(s) from the source stream. After all results have been emitted, the `complete` event will follow indicating stream completion. 31 | 32 | ```typescript 33 | import { ExecutionResult } from 'graphql'; 34 | 35 | interface NextMessage { 36 | event: 'next'; 37 | data: ExecutionResult; 38 | } 39 | ``` 40 | 41 | #### `complete` event 42 | 43 | Indicates that the requested operation execution has completed. 44 | 45 | ```typescript 46 | interface CompleteMessage { 47 | event: 'complete'; 48 | } 49 | ``` 50 | 51 | > [!IMPORTANT] 52 | > Include an empty `data: ` field when sending the message to a client that uses [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). If the field is omitted, the complete event won't trigger the listener. 53 | 54 | ## Single connection mode 55 | 56 | > When **not used over HTTP/2**, SSE suffers from a limitation to the maximum number of open connections, which can be specially painful when opening various tabs as the limit is per browser and set to a very low number (6). The issue has been marked as "Won't fix" in [Chrome](https://bugs.chromium.org/p/chromium/issues/detail?id=275955) and [Firefox](https://bugzilla.mozilla.org/show_bug.cgi?id=906896). This limit is per browser + domain, so that means that you can open 6 SSE connections across all of the tabs to `www.example1.com` and another 6 SSE connections to `www.example2.com`. (from [Stackoverflow](https://stackoverflow.com/a/5326159/1905229)). When using HTTP/2, the maximum number of simultaneous HTTP streams is negotiated between the server and the client (defaults to 100). 57 | 58 | [Reference: WebAPIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) 59 | 60 | Having aforementioned limitations in mind, a "single connection mode" is proposed. In this mode, a single established SSE connection transmits **all** results from the server while separate HTTP requests dictate the behaviour. 61 | 62 | Additionally, due to various limitations with the browser's native [`EventSource` interface](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), like the lack of supplying custom headers or vague connection error information, a "reservation" tactic is RECOMMENDED. This means that the client requests an SSE connection reservation from the server through a regular HTTP request which is later fulfiled with the actual SSE connection matching the reservation requirements. 63 | 64 | ### Making reservations 65 | 66 | The client requests a reservation for an incoming SSE connection through a `PUT` HTTP request. Since this is a regular HTTP request, it may transmit authentication details however the implementor sees fit. 67 | 68 | The server accepts the reservation request by responding with `201` (Created) and a reservation token in the body of the response. This token is then presented alongside the incoming SSE connection as an entrance ticket. If using the [`EventSource` interface](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), the token may be encoded in the URL's search parameters. 69 | 70 | The reservation token MUST accompany future HTTP requests to aid the server with the stream matching process. Token SHOULD be transmitted by the client through either: 71 | 72 | - A header value `X-GraphQL-Event-Stream-Token` 73 | - A search parameter `token` 74 | 75 | For security reasons, **only one** SSE connection can fulfil a reservation at a time, there MUST never be multiple SSE connections behind a single reservation. 76 | 77 | ### Executing operations 78 | 79 | While having a single SSE connection (or reservation), separate HTTP requests solicit GraphQL operations conforming to the [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md) with **only one difference**: successful responses (execution results) get accepted with a `202` (Accepted) and are then streamed through the single SSE connection. Validation issues and other request problems are handled as documented in the [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md). 80 | 81 | Since the client holds the task of publishing the SSE messages to the relevant listeners through a single connection, an operation ID identifying the messages destinations is necessary. The unique operation ID SHOULD be sent through the `extensions` parameter of the GraphQL reqeust inside the `operationId` field. This operation ID accompanies the SSE messages for destination discovery (more details below). 82 | 83 | The HTTP request MUST contain the matching reservation token. 84 | 85 | ### Stopping streaming operations 86 | 87 | Streaming operations, such as `subscriptions` or directives like `@stream` and `@defer`, must have a termination/completion mechanism. This is done by sending a `DELETE` HTTP request encoding the unique operation ID inside the URL's search parameters behind the `operationId` key (ex. `DELETE: www.example.com/?operationId=`). 88 | 89 | The HTTP request MUST contain the matching reservation token. 90 | 91 | ### Event stream 92 | 93 | To be interpreted as the event stream following the [Server-Sent Events spec](https://www.w3.org/TR/eventsource/#event-stream-interpretation). 94 | 95 | #### `next` event 96 | 97 | Operation execution result(s) from the source stream created by the binding GraphQL over HTTP request. After all results have been emitted, the `complete` event will follow indicating stream completion. 98 | 99 | ```typescript 100 | import { ExecutionResult } from 'graphql'; 101 | 102 | interface NextMessage { 103 | event: 'next'; 104 | data: { 105 | id: ''; 106 | payload: ExecutionResult; 107 | }; 108 | } 109 | ``` 110 | 111 | #### `complete` event 112 | 113 | Indicates that the requested operation execution has completed. 114 | 115 | ```typescript 116 | interface CompleteMessage { 117 | event: 'complete'; 118 | data: { 119 | id: ''; 120 | }; 121 | } 122 | ``` 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

graphql-sse

5 | 6 |
Zero-dependency, HTTP/1 safe, simple, GraphQL over Server-Sent Events Protocol server and client.
7 | 8 | [![Continuous integration](https://github.com/enisdenjo/graphql-sse/workflows/Continuous%20integration/badge.svg)](https://github.com/enisdenjo/graphql-sse/actions?query=workflow%3A%22Continuous+integration%22) [![graphql-sse](https://img.shields.io/npm/v/graphql-sse.svg?label=graphql-sse&logo=npm)](https://www.npmjs.com/package/graphql-sse) 9 | 10 | Use [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) instead? Check out [graphql-ws](https://github.com/enisdenjo/graphql-ws)! 11 | 12 |
13 |
14 | 15 | ## [Get started](https://the-guild.dev/graphql/sse/get-started) 16 | 17 | Swiftly start with the [get started guide on the website](https://the-guild.dev/graphql/sse/get-started). 18 | 19 | ## [Recipes](https://the-guild.dev/graphql/sse/recipes) 20 | 21 | Short and concise code snippets for starting with common use-cases. [Available on the website.](https://the-guild.dev/graphql/sse/recipes) 22 | 23 | 24 | 25 | ## [Documentation](https://the-guild.dev/graphql/sse/docs) 26 | 27 | Auto-generated by [TypeDoc](https://typedoc.org) and then [rendered on the website](https://the-guild.dev/graphql/sse/docs). 28 | 29 | ## [How does it work?](PROTOCOL.md) 30 | 31 | Read about the exact transport intricacies used by the library in the [GraphQL over Server-Sent Events Protocol document](PROTOCOL.md). 32 | 33 | ## [Want to help?](CONTRIBUTING.md) 34 | 35 | File a bug, contribute with code, or improve documentation? Read up on our guidelines for [contributing](CONTRIBUTING.md) and drive development with `yarn test --watch` away! 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-sse", 3 | "version": "2.5.4", 4 | "description": "Zero-dependency, HTTP/1 safe, simple, GraphQL over Server-Sent Events Protocol server and client", 5 | "keywords": [ 6 | "graphql", 7 | "client", 8 | "relay", 9 | "express", 10 | "apollo", 11 | "server", 12 | "sse", 13 | "transport", 14 | "server-sent-events", 15 | "observables", 16 | "subscriptions", 17 | "fastify" 18 | ], 19 | "author": "Denis Badurina ", 20 | "license": "MIT", 21 | "homepage": "https://github.com/enisdenjo/graphql-sse#readme", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/enisdenjo/graphql-sse.git" 25 | }, 26 | "engines": { 27 | "node": ">=12" 28 | }, 29 | "packageManager": "yarn@4.0.2", 30 | "main": "lib/index.js", 31 | "module": "lib/index.mjs", 32 | "browser": "umd/graphql-sse.js", 33 | "exports": { 34 | ".": { 35 | "require": "./lib/index.js", 36 | "import": "./lib/index.mjs", 37 | "types": "./lib/index.d.ts", 38 | "browser": "./umd/graphql-sse.js" 39 | }, 40 | "./lib/use/fetch": { 41 | "types": "./lib/use/fetch.d.ts", 42 | "require": "./lib/use/fetch.js", 43 | "import": "./lib/use/fetch.mjs" 44 | }, 45 | "./lib/use/http": { 46 | "types": "./lib/use/http.d.ts", 47 | "require": "./lib/use/http.js", 48 | "import": "./lib/use/http.mjs" 49 | }, 50 | "./lib/use/http2": { 51 | "types": "./lib/use/http2.d.ts", 52 | "require": "./lib/use/http2.js", 53 | "import": "./lib/use/http2.mjs" 54 | }, 55 | "./lib/use/express": { 56 | "types": "./lib/use/express.d.ts", 57 | "require": "./lib/use/express.js", 58 | "import": "./lib/use/express.mjs" 59 | }, 60 | "./lib/use/fastify": { 61 | "types": "./lib/use/fastify.d.ts", 62 | "require": "./lib/use/fastify.js", 63 | "import": "./lib/use/fastify.mjs" 64 | }, 65 | "./lib/use/koa": { 66 | "types": "./lib/use/koa.d.ts", 67 | "require": "./lib/use/koa.js", 68 | "import": "./lib/use/koa.mjs" 69 | }, 70 | "./package.json": "./package.json" 71 | }, 72 | "types": "lib/index.d.ts", 73 | "files": [ 74 | "lib", 75 | "umd", 76 | "README.md", 77 | "LICENSE.md", 78 | "PROTOCOL.md" 79 | ], 80 | "sideEffects": [ 81 | "umd/*" 82 | ], 83 | "publishConfig": { 84 | "access": "public" 85 | }, 86 | "scripts": { 87 | "check:format": "prettier --check .", 88 | "format": "yarn check:format --write", 89 | "check:lint": "eslint 'src'", 90 | "check:type": "tsc --noEmit", 91 | "test": "vitest", 92 | "build:esm": "tsc -b tsconfig.esm.json && node scripts/esm-post-process.mjs", 93 | "build:cjs": "tsc -b tsconfig.cjs.json", 94 | "build:umd": "rollup --configPlugin typescript --config rollup.config.ts", 95 | "build": "yarn build:esm && yarn build:cjs && yarn build:umd", 96 | "prepack": "npm pkg delete workspaces", 97 | "postpack": "npm pkg set 'workspaces[]=website'", 98 | "release": "semantic-release", 99 | "gendocs": "typedoc --options typedoc.js src/ && node scripts/post-gendocs.mjs" 100 | }, 101 | "peerDependencies": { 102 | "graphql": ">=0.11 <=16" 103 | }, 104 | "devDependencies": { 105 | "@rollup/plugin-terser": "^0.4.4", 106 | "@rollup/plugin-typescript": "^11.1.6", 107 | "@semantic-release/changelog": "^6.0.3", 108 | "@semantic-release/git": "^10.0.1", 109 | "@types/eslint": "^8.56.2", 110 | "@types/eventsource": "^1.1.15", 111 | "@types/express": "^4.17.21", 112 | "@types/glob": "^8.1.0", 113 | "@types/koa": "^2.14.0", 114 | "@types/koa-bodyparser": "^4.3.12", 115 | "@types/koa-mount": "^4.0.5", 116 | "@typescript-eslint/eslint-plugin": "^6.19.0", 117 | "@typescript-eslint/parser": "^6.19.0", 118 | "eslint": "^8.56.0", 119 | "eslint-config-prettier": "^9.1.0", 120 | "eventsource": "^2.0.2", 121 | "express": "^4.18.2", 122 | "fastify": "^4.25.2", 123 | "glob": "^10.3.10", 124 | "graphql": "^16.8.1", 125 | "koa": "^2.15.0", 126 | "koa-bodyparser": "^4.4.1", 127 | "koa-mount": "^4.0.0", 128 | "prettier": "^3.2.2", 129 | "rollup": "^4.9.5", 130 | "rollup-plugin-gzip": "^3.1.1", 131 | "semantic-release": "^23.0.0", 132 | "tslib": "^2.6.2", 133 | "typedoc": "^0.25.7", 134 | "typedoc-plugin-markdown": "^3.17.1", 135 | "typescript": "^5.3.3", 136 | "vitest": "^1.2.0" 137 | }, 138 | "workspaces": [ 139 | "website" 140 | ] 141 | } 142 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import terser from '@rollup/plugin-terser'; 3 | import gzip from 'rollup-plugin-gzip'; 4 | 5 | export default { 6 | input: './src/client.ts', 7 | plugins: [typescript()], 8 | output: [ 9 | { 10 | file: './umd/graphql-sse.js', 11 | format: 'umd', 12 | name: 'graphqlSse', 13 | }, 14 | { 15 | file: './umd/graphql-sse.min.js', 16 | format: 'umd', 17 | name: 'graphqlSse', 18 | plugins: [terser()], 19 | }, 20 | { 21 | file: './umd/graphql-sse.min.js', // gzip plugin will add the .gz extension 22 | format: 'umd', 23 | name: 'graphqlSse', 24 | plugins: [terser(), gzip()], 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /scripts/esm-post-process.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import { glob, globIterate } from 'glob'; 4 | 5 | const rootDir = 'lib'; 6 | 7 | (async () => { 8 | const matches = await glob(`${rootDir}/**/*.js`); 9 | 10 | for (const path of matches) { 11 | await buildEsm(path); 12 | } 13 | 14 | // we delete after build to not mess with import/export statement replacer 15 | for (const path of matches) { 16 | await fs.unlink(path); 17 | } 18 | })(); 19 | 20 | (async () => { 21 | for await (const path of globIterate(`${rootDir}/**/*.d.ts`)) { 22 | await buildEsm(path); 23 | } 24 | 25 | // we dont delete raw d.ts files, they're still needed for imports/exports 26 | })(); 27 | 28 | /** 29 | * @param {string} filePath 30 | */ 31 | async function buildEsm(filePath) { 32 | const pathParts = filePath.split('.'); 33 | const fileExt = pathParts.pop(); 34 | 35 | const file = await fs.readFile(path.join(process.cwd(), filePath)); 36 | let content = file.toString(); 37 | 38 | if (fileExt === 'js' || fileExt === 'ts') { 39 | // add .mjs to all import/export statements, also in the type definitions 40 | for (const match of content.matchAll(/from '(\.?\.\/[^']*)'/g)) { 41 | const [statement, relImportPath] = match; 42 | const absImportPath = path.resolve( 43 | process.cwd(), 44 | path.dirname(filePath), 45 | relImportPath, 46 | ); 47 | 48 | try { 49 | await fs.stat(absImportPath + '.js'); 50 | 51 | // file import 52 | content = content.replace(statement, `from '${relImportPath}.mjs'`); 53 | } catch { 54 | // directory import 55 | content = content.replace( 56 | statement, 57 | `from '${relImportPath}/index.mjs'`, 58 | ); 59 | } 60 | } 61 | } 62 | 63 | // write to file with prepended "m" in extension (.js -> .mjs, .ts -> .mts) 64 | const esmFilePath = pathParts.join('.') + '.m' + fileExt; 65 | await fs.writeFile(esmFilePath, content); 66 | } 67 | -------------------------------------------------------------------------------- /scripts/post-gendocs.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fsp from 'fs/promises'; 3 | 4 | const docsDir = path.join('website', 'src', 'pages', 'docs'); 5 | 6 | (async function main() { 7 | await fixLinksInDir(docsDir); 8 | await createRecursiveMetaFiles(docsDir); 9 | })().catch((err) => { 10 | console.error(err); 11 | process.exit(1); 12 | }); 13 | 14 | /** 15 | * Fixes links in markdown files by removing the `.md` extension. 16 | * 17 | * @param {string} dirPath 18 | */ 19 | async function fixLinksInDir(dirPath) { 20 | for (const file of await fsp.readdir(dirPath, { withFileTypes: true })) { 21 | const filePath = path.join(dirPath, file.name); 22 | if (file.isDirectory()) { 23 | // recursively fix links in files 24 | fixLinksInDir(filePath); 25 | continue; 26 | } 27 | if (!file.name.endsWith('.md')) { 28 | continue; 29 | } 30 | const contents = await fsp.readFile(filePath); 31 | const src = contents.toString(); 32 | await fsp.writeFile( 33 | filePath, 34 | src 35 | // @ts-expect-error we're running this on modern node 36 | .replaceAll('.md', ''), 37 | ); 38 | } 39 | } 40 | 41 | /** 42 | * Creates the `_meta.json` metadata file for Next.js. 43 | * 44 | * @param {string} dirPath 45 | */ 46 | async function createRecursiveMetaFiles(dirPath) { 47 | /** @type {Record} */ 48 | const meta = {}; 49 | 50 | const files = await fsp.readdir(dirPath, { withFileTypes: true }); 51 | if (files.find((file) => file.name === 'index.md')) { 52 | meta.index = 'Home'; 53 | } 54 | for (const file of files) { 55 | const filePath = path.join(dirPath, file.name); 56 | if (file.isDirectory()) { 57 | // uppercase first character 58 | meta[file.name] = file.name.charAt(0).toUpperCase() + file.name.slice(1); 59 | 60 | createRecursiveMetaFiles(filePath); 61 | continue; 62 | } 63 | if (!file.name.endsWith('.md')) { 64 | continue; 65 | } 66 | if (file.name === 'index.md') { 67 | continue; 68 | } 69 | const nameNoExt = file.name.slice(0, -3); 70 | meta[nameNoExt] = nameNoExt 71 | // @ts-expect-error we're running this on modern node 72 | .replaceAll('_', '/'); 73 | } 74 | 75 | await fsp.writeFile( 76 | path.join(dirPath, '_meta.ts'), 77 | 'export default ' + JSON.stringify(meta, null, ' ') + '\n', 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * common 4 | * 5 | */ 6 | 7 | import type { GraphQLError } from 'graphql'; 8 | import { isObject } from './utils'; 9 | 10 | /** 11 | * Header key through which the event stream token is transmitted 12 | * when using the client in "single connection mode". 13 | * 14 | * Read more: https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#single-connection-mode 15 | * 16 | * @category Common 17 | */ 18 | export const TOKEN_HEADER_KEY = 'x-graphql-event-stream-token' as const; 19 | 20 | /** 21 | * URL query parameter key through which the event stream token is transmitted 22 | * when using the client in "single connection mode". 23 | * 24 | * Read more: https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#single-connection-mode 25 | * 26 | * @category Common 27 | */ 28 | export const TOKEN_QUERY_KEY = 'token' as const; 29 | 30 | /** 31 | * Parameters for GraphQL's request for execution. 32 | * 33 | * Reference: https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#request 34 | * 35 | * @category Common 36 | */ 37 | export interface RequestParams { 38 | operationName?: string; 39 | query: string; 40 | variables?: Record; 41 | extensions?: Record; 42 | } 43 | 44 | /** 45 | * Represents a message in an event stream. 46 | * 47 | * Read more: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format 48 | * 49 | * @category Common 50 | */ 51 | export interface StreamMessage { 52 | // id?: string; might be used in future releases for connection recovery 53 | event: E; 54 | data: ForID extends true ? StreamDataForID : StreamData; 55 | // retry?: number; ignored since graphql-sse implements custom retry strategies 56 | } 57 | 58 | /** @category Common */ 59 | export type StreamEvent = 'next' | 'complete'; 60 | 61 | /** @category Common */ 62 | export function validateStreamEvent(e: unknown): StreamEvent { 63 | e = e as StreamEvent; 64 | if (e !== 'next' && e !== 'complete') 65 | throw new Error(`Invalid stream event "${e}"`); 66 | return e; 67 | } 68 | 69 | /** @category Common */ 70 | export function print( 71 | msg: StreamMessage, 72 | ): string { 73 | let str = `event: ${msg.event}\ndata:`; 74 | if (msg.data) { 75 | str += ' '; 76 | str += JSON.stringify(msg.data); 77 | } 78 | str += '\n\n'; 79 | return str; 80 | } 81 | 82 | /** @category Common */ 83 | export interface ExecutionResult< 84 | Data = Record, 85 | Extensions = Record, 86 | > { 87 | errors?: ReadonlyArray; 88 | data?: Data | null; 89 | hasNext?: boolean; 90 | extensions?: Extensions; 91 | } 92 | 93 | /** @category Common */ 94 | export interface ExecutionPatchResult< 95 | Data = unknown, 96 | Extensions = Record, 97 | > { 98 | errors?: ReadonlyArray; 99 | data?: Data | null; 100 | path?: ReadonlyArray; 101 | label?: string; 102 | hasNext: boolean; 103 | extensions?: Extensions; 104 | } 105 | 106 | /** @category Common */ 107 | export type StreamData = E extends 'next' 108 | ? ExecutionResult | ExecutionPatchResult 109 | : E extends 'complete' 110 | ? null 111 | : never; 112 | 113 | /** @category Common */ 114 | export type StreamDataForID = E extends 'next' 115 | ? { id: string; payload: ExecutionResult | ExecutionPatchResult } 116 | : E extends 'complete' 117 | ? { id: string } 118 | : never; 119 | 120 | /** @category Common */ 121 | export function parseStreamData( 122 | e: E, 123 | data: string, 124 | ) { 125 | if (data) { 126 | try { 127 | data = JSON.parse(data); 128 | } catch { 129 | throw new Error('Invalid stream data'); 130 | } 131 | } 132 | 133 | if (e === 'next' && !data) 134 | throw new Error('Stream data must be an object for "next" events'); 135 | 136 | return (data || null) as ForID extends true 137 | ? StreamDataForID 138 | : StreamData; 139 | } 140 | 141 | /** 142 | * A representation of any set of values over any amount of time. 143 | * 144 | * @category Common 145 | */ 146 | export interface Sink { 147 | /** Next value arriving. */ 148 | next(value: T): void; 149 | /** An error that has occurred. This function "closes" the sink. */ 150 | error(error: unknown): void; 151 | /** The sink has completed. This function "closes" the sink. */ 152 | complete(): void; 153 | } 154 | 155 | /** 156 | * Checks whether the provided value is an async iterable. 157 | * 158 | * @category Common 159 | */ 160 | export function isAsyncIterable(val: unknown): val is AsyncIterable { 161 | return typeof Object(val)[Symbol.asyncIterator] === 'function'; 162 | } 163 | 164 | /** 165 | * Checks whether the provided value is an async generator. 166 | * 167 | * @category Common 168 | */ 169 | export function isAsyncGenerator(val: unknown): val is AsyncGenerator { 170 | return ( 171 | isObject(val) && 172 | typeof Object(val)[Symbol.asyncIterator] === 'function' && 173 | typeof val.return === 'function' && 174 | typeof val.throw === 'function' && 175 | typeof val.next === 'function' 176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * handler 4 | * 5 | */ 6 | 7 | import { 8 | ExecutionArgs, 9 | getOperationAST, 10 | GraphQLSchema, 11 | OperationTypeNode, 12 | parse, 13 | validate as graphqlValidate, 14 | execute as graphqlExecute, 15 | subscribe as graphqlSubscribe, 16 | DocumentNode, 17 | } from 'graphql'; 18 | import { isObject } from './utils'; 19 | import { 20 | ExecutionResult, 21 | ExecutionPatchResult, 22 | RequestParams, 23 | TOKEN_HEADER_KEY, 24 | TOKEN_QUERY_KEY, 25 | print, 26 | isAsyncGenerator, 27 | isAsyncIterable, 28 | } from './common'; 29 | 30 | /** 31 | * The incoming request headers the implementing server should provide. 32 | * 33 | * @category Server 34 | */ 35 | export interface RequestHeaders { 36 | get: (key: string) => string | null | undefined; 37 | } 38 | 39 | /** 40 | * Server agnostic request interface containing the raw request 41 | * which is server dependant. 42 | * 43 | * @category Server 44 | */ 45 | export interface Request { 46 | readonly method: string; 47 | readonly url: string; 48 | readonly headers: RequestHeaders; 49 | /** 50 | * Parsed request body or a parser function. 51 | * 52 | * If the provided function throws, the error message "Unparsable JSON body" will 53 | * be in the erroneous response. 54 | */ 55 | readonly body: 56 | | string 57 | | Record 58 | | null 59 | | (() => 60 | | string 61 | | Record 62 | | null 63 | | Promise | null>); 64 | /** 65 | * The raw request itself from the implementing server. 66 | */ 67 | readonly raw: Raw; 68 | /** 69 | * Context value about the incoming request, you're free to pass any information here. 70 | * 71 | * Intentionally not readonly because you're free to mutate it whenever you want. 72 | */ 73 | context: Context; 74 | } 75 | 76 | /** 77 | * The response headers that get returned from graphql-sse. 78 | * 79 | * @category Server 80 | */ 81 | export type ResponseHeaders = { 82 | accept?: string; 83 | allow?: string; 84 | 'content-type'?: string; 85 | } & Record; 86 | 87 | /** 88 | * Server agnostic response body returned from `graphql-sse` needing 89 | * to be coerced to the server implementation in use. 90 | * 91 | * When the body is a string, it is NOT a GraphQL response. 92 | * 93 | * @category Server 94 | */ 95 | export type ResponseBody = string | AsyncGenerator; 96 | 97 | /** 98 | * Server agnostic response options (ex. status and headers) returned from 99 | * `graphql-sse` needing to be coerced to the server implementation in use. 100 | * 101 | * @category Server 102 | */ 103 | export interface ResponseInit { 104 | readonly status: number; 105 | readonly statusText: string; 106 | readonly headers?: ResponseHeaders; 107 | } 108 | 109 | /** 110 | * Server agnostic response returned from `graphql-sse` containing the 111 | * body and init options needing to be coerced to the server implementation in use. 112 | * 113 | * @category Server 114 | */ 115 | export type Response = readonly [body: ResponseBody | null, init: ResponseInit]; 116 | 117 | /** 118 | * A concrete GraphQL execution context value type. 119 | * 120 | * Mainly used because TypeScript collapses unions 121 | * with `any` or `unknown` to `any` or `unknown`. So, 122 | * we use a custom type to allow definitions such as 123 | * the `context` server option. 124 | * 125 | * @category Server 126 | */ 127 | export type OperationContext = 128 | | Record 129 | | symbol 130 | | number 131 | | string 132 | | boolean 133 | | undefined 134 | | null; 135 | 136 | /** @category Server */ 137 | export type OperationArgs = 138 | ExecutionArgs & { contextValue: Context }; 139 | 140 | /** @category Server */ 141 | export type OperationResult = 142 | | Promise< 143 | | AsyncGenerator 144 | | AsyncIterable 145 | | ExecutionResult 146 | > 147 | | AsyncGenerator 148 | | AsyncIterable 149 | | ExecutionResult; 150 | 151 | /** @category Server */ 152 | export interface HandlerOptions< 153 | RequestRaw = unknown, 154 | RequestContext = unknown, 155 | Context extends OperationContext = undefined, 156 | > { 157 | /** 158 | * A custom GraphQL validate function allowing you to apply your 159 | * own validation rules. 160 | */ 161 | validate?: typeof graphqlValidate; 162 | /** 163 | * Is the `execute` function from GraphQL which is 164 | * used to execute the query and mutation operations. 165 | */ 166 | execute?: (args: OperationArgs) => OperationResult; 167 | /** 168 | * Is the `subscribe` function from GraphQL which is 169 | * used to execute the subscription operation. 170 | */ 171 | subscribe?: (args: OperationArgs) => OperationResult; 172 | /** 173 | * The GraphQL schema on which the operations will 174 | * be executed and validated against. 175 | * 176 | * If a function is provided, it will be called on every 177 | * subscription request allowing you to manipulate schema 178 | * dynamically. 179 | * 180 | * If the schema is left undefined, you're trusted to 181 | * provide one in the returned `ExecutionArgs` from the 182 | * `onSubscribe` callback. 183 | */ 184 | schema?: 185 | | GraphQLSchema 186 | | (( 187 | req: Request, 188 | args: Pick< 189 | OperationArgs, 190 | 'contextValue' | 'operationName' | 'document' | 'variableValues' 191 | >, 192 | ) => Promise | GraphQLSchema); 193 | /** 194 | * Authenticate the client. Returning a string indicates that the client 195 | * is authenticated and the request is ready to be processed. 196 | * 197 | * A distinct token of type string must be supplied to enable the "single connection mode". 198 | * 199 | * Providing `null` as the token will completely disable the "single connection mode" 200 | * and all incoming requests will always use the "distinct connection mode". 201 | * 202 | * @default 'req.headers["x-graphql-event-stream-token"] || req.url.searchParams["token"] || generateRandomUUID()' // https://gist.github.com/jed/982883 203 | */ 204 | authenticate?: ( 205 | req: Request, 206 | ) => 207 | | Promise 208 | | Response 209 | | string 210 | | undefined 211 | | null; 212 | /** 213 | * Called when a new event stream is connecting BEFORE it is accepted. 214 | * By accepted, its meant the server processed the request and responded 215 | * with a 200 (OK), alongside flushing the necessary event stream headers. 216 | */ 217 | onConnect?: ( 218 | req: Request, 219 | ) => 220 | | Promise 221 | | Response 222 | | null 223 | | undefined 224 | | void; 225 | /** 226 | * A value which is provided to every resolver and holds 227 | * important contextual information like the currently 228 | * logged in user, or access to a database. 229 | * 230 | * Note that the context function is invoked on each operation only once. 231 | * Meaning, for subscriptions, only at the point of initialising the subscription; 232 | * not on every subscription event emission. Read more about the context lifecycle 233 | * in subscriptions here: https://github.com/graphql/graphql-js/issues/894. 234 | * 235 | * If you don't provide the context context field, but have a context - you're trusted to 236 | * provide one in `onSubscribe`. 237 | */ 238 | context?: 239 | | Context 240 | | (( 241 | req: Request, 242 | params: RequestParams, 243 | ) => Promise | Context); 244 | /** 245 | * The subscribe callback executed right after processing the request 246 | * before proceeding with the GraphQL operation execution. 247 | * 248 | * If you return `ExecutionArgs` from the callback, it will be used instead of 249 | * trying to build one internally. In this case, you are responsible for providing 250 | * a ready set of arguments which will be directly plugged in the operation execution. 251 | * 252 | * Omitting the fields `contextValue` from the returned `ExecutionArgs` will use the 253 | * provided `context` option, if available. 254 | * 255 | * If you want to respond to the client with a custom status or body, 256 | * you should do so using the provided `res` argument which will stop 257 | * further execution. 258 | * 259 | * Useful for preparing the execution arguments following a custom logic. A typical 260 | * use-case is persisted queries. You can identify the query from the request parameters 261 | * and supply the appropriate GraphQL operation execution arguments. 262 | */ 263 | onSubscribe?: ( 264 | req: Request, 265 | params: RequestParams, 266 | ) => 267 | | Promise | void> 268 | | Response 269 | | OperationResult 270 | | OperationArgs 271 | | void; 272 | /** 273 | * Executed after the operation call resolves. For streaming 274 | * operations, triggering this callback does not necessarily 275 | * mean that there is already a result available - it means 276 | * that the subscription process for the stream has resolved 277 | * and that the client is now subscribed. 278 | * 279 | * The `OperationResult` argument is the result of operation 280 | * execution. It can be an iterator or already a value. 281 | * 282 | * If you want the single result and the events from a streaming 283 | * operation, use the `onNext` callback. 284 | * 285 | * If `onSubscribe` returns an `OperationResult`, this hook 286 | * will NOT be called. 287 | */ 288 | onOperation?: ( 289 | ctx: Context, 290 | req: Request, 291 | args: ExecutionArgs, 292 | result: OperationResult, 293 | ) => Promise | OperationResult | void; 294 | /** 295 | * Executed after an operation has emitted a result right before 296 | * that result has been sent to the client. 297 | * 298 | * Results from both single value and streaming operations will 299 | * invoke this callback. 300 | * 301 | * Use this callback if you want to format the execution result 302 | * before it reaches the client. 303 | * 304 | * @param req - Always the request that contains the GraphQL operation. 305 | */ 306 | onNext?: ( 307 | ctx: Context, 308 | req: Request, 309 | result: ExecutionResult | ExecutionPatchResult, 310 | ) => 311 | | Promise 312 | | ExecutionResult 313 | | ExecutionPatchResult 314 | | void; 315 | /** 316 | * The complete callback is executed after the operation 317 | * has completed and the client has been notified. 318 | * 319 | * Since the library makes sure to complete streaming 320 | * operations even after an abrupt closure, this callback 321 | * will always be called. 322 | * 323 | * @param req - Always the request that contains the GraphQL operation. 324 | */ 325 | onComplete?: ( 326 | ctx: Context, 327 | req: Request, 328 | ) => Promise | void; 329 | } 330 | 331 | /** 332 | * The ready-to-use handler. Simply plug it in your favourite fetch-enabled HTTP 333 | * framework and enjoy. 334 | * 335 | * Errors thrown from **any** of the provided options or callbacks (or even due to 336 | * library misuse or potential bugs) will reject the handler's promise. They are 337 | * considered internal errors and you should take care of them accordingly. 338 | * 339 | * @category Server 340 | */ 341 | export type Handler = ( 342 | req: Request, 343 | ) => Promise; 344 | 345 | /** 346 | * Makes a Protocol compliant HTTP GraphQL server handler. The handler can 347 | * be used with your favourite server library. 348 | * 349 | * Read more about the Protocol in the PROTOCOL.md documentation file. 350 | * 351 | * @category Server 352 | */ 353 | export function createHandler< 354 | RequestRaw = unknown, 355 | RequestContext = unknown, 356 | Context extends OperationContext = undefined, 357 | >( 358 | options: HandlerOptions, 359 | ): Handler { 360 | const { 361 | validate = graphqlValidate, 362 | execute = graphqlExecute, 363 | subscribe = graphqlSubscribe, 364 | schema, 365 | authenticate = function extractOrCreateStreamToken(req) { 366 | const headerToken = req.headers.get(TOKEN_HEADER_KEY); 367 | if (headerToken) 368 | return Array.isArray(headerToken) ? headerToken.join('') : headerToken; 369 | 370 | const urlToken = new URL( 371 | req.url ?? '', 372 | 'http://localhost/', 373 | ).searchParams.get(TOKEN_QUERY_KEY); 374 | if (urlToken) return urlToken; 375 | 376 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 377 | const r = (Math.random() * 16) | 0, 378 | v = c == 'x' ? r : (r & 0x3) | 0x8; 379 | return v.toString(16); 380 | }); 381 | }, 382 | onConnect, 383 | context, 384 | onSubscribe, 385 | onOperation, 386 | onNext, 387 | onComplete, 388 | } = options; 389 | 390 | interface Stream { 391 | /** 392 | * Does the stream have an open connection to some client. 393 | */ 394 | readonly open: boolean; 395 | /** 396 | * If the operation behind an ID is an `AsyncIterator` - the operation 397 | * is streaming; on the contrary, if the operation is `null` - it is simply 398 | * a reservation, meaning - the operation resolves to a single result or is still 399 | * pending/being prepared. 400 | */ 401 | ops: Record< 402 | string, 403 | AsyncGenerator | AsyncIterable | null 404 | >; 405 | /** 406 | * Use this connection for streaming. 407 | */ 408 | subscribe(): AsyncGenerator; 409 | /** 410 | * Stream from provided execution result to used connection. 411 | */ 412 | from( 413 | ctx: Context, 414 | req: Request, 415 | result: 416 | | AsyncGenerator 417 | | AsyncIterable 418 | | ExecutionResult 419 | | ExecutionPatchResult, 420 | opId: string | null, 421 | ): void; 422 | } 423 | const streams: Record = {}; 424 | function createStream(token: string | null): Stream { 425 | const ops: Record< 426 | string, 427 | AsyncGenerator | AsyncIterable | null 428 | > = {}; 429 | 430 | let pinger: ReturnType; 431 | const msgs = (() => { 432 | const pending: string[] = []; 433 | const deferred = { 434 | done: false, 435 | error: null as unknown, 436 | resolve: () => { 437 | // noop 438 | }, 439 | }; 440 | 441 | async function dispose() { 442 | clearInterval(pinger); 443 | 444 | // make room for another potential stream while this one is being disposed 445 | if (typeof token === 'string') delete streams[token]; 446 | 447 | // complete all operations and flush messages queue before ending the stream 448 | for (const op of Object.values(ops)) { 449 | if (isAsyncGenerator(op)) { 450 | await op.return(undefined); 451 | } 452 | } 453 | } 454 | 455 | const iterator = (async function* iterator() { 456 | for (;;) { 457 | if (!pending.length) { 458 | // only wait if there are no pending messages available 459 | await new Promise((resolve) => (deferred.resolve = resolve)); 460 | } 461 | // first flush 462 | while (pending.length) { 463 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 464 | yield pending.shift()!; 465 | } 466 | // then error 467 | if (deferred.error) { 468 | throw deferred.error; 469 | } 470 | // or complete 471 | if (deferred.done) { 472 | return; 473 | } 474 | } 475 | })(); 476 | 477 | iterator.throw = async (err) => { 478 | if (!deferred.done) { 479 | deferred.done = true; 480 | deferred.error = err; 481 | deferred.resolve(); 482 | await dispose(); 483 | } 484 | return { done: true, value: undefined }; 485 | }; 486 | 487 | iterator.return = async () => { 488 | if (!deferred.done) { 489 | deferred.done = true; 490 | deferred.resolve(); 491 | await dispose(); 492 | } 493 | return { done: true, value: undefined }; 494 | }; 495 | 496 | return { 497 | next(msg: string) { 498 | pending.push(msg); 499 | deferred.resolve(); 500 | }, 501 | iterator, 502 | }; 503 | })(); 504 | 505 | let subscribed = false; 506 | return { 507 | get open() { 508 | return subscribed; 509 | }, 510 | ops, 511 | subscribe() { 512 | subscribed = true; 513 | 514 | // write an empty message because some browsers (like Firefox and Safari) 515 | // dont accept the header flush 516 | msgs.next(':\n\n'); 517 | 518 | // ping client every 12 seconds to keep the connection alive 519 | pinger = setInterval(() => msgs.next(':\n\n'), 12_000); 520 | 521 | return msgs.iterator; 522 | }, 523 | from(ctx, req, result, opId) { 524 | (async () => { 525 | if (isAsyncIterable(result)) { 526 | /** multiple emitted results */ 527 | for await (let part of result) { 528 | const maybeResult = await onNext?.(ctx, req, part); 529 | if (maybeResult) part = maybeResult; 530 | msgs.next( 531 | print({ 532 | event: 'next', 533 | data: opId 534 | ? { 535 | id: opId, 536 | payload: part, 537 | } 538 | : part, 539 | }), 540 | ); 541 | } 542 | } else { 543 | /** single emitted result */ 544 | const maybeResult = await onNext?.(ctx, req, result); 545 | if (maybeResult) result = maybeResult; 546 | msgs.next( 547 | print({ 548 | event: 'next', 549 | data: opId 550 | ? { 551 | id: opId, 552 | payload: result, 553 | } 554 | : result, 555 | }), 556 | ); 557 | } 558 | 559 | msgs.next( 560 | print({ 561 | event: 'complete', 562 | data: opId ? { id: opId } : null, 563 | }), 564 | ); 565 | 566 | await onComplete?.(ctx, req); 567 | 568 | if (!opId) { 569 | // end on complete when no operation id is present 570 | // because distinct event streams are used for each operation 571 | await msgs.iterator.return(); 572 | } else { 573 | delete ops[opId]; 574 | } 575 | })().catch(msgs.iterator.throw); 576 | }, 577 | }; 578 | } 579 | 580 | async function prepare( 581 | req: Request, 582 | params: RequestParams, 583 | ): Promise OperationResult }> { 584 | let args: OperationArgs; 585 | 586 | const onSubscribeResult = await onSubscribe?.(req, params); 587 | if (isResponse(onSubscribeResult)) return onSubscribeResult; 588 | else if ( 589 | isExecutionResult(onSubscribeResult) || 590 | isAsyncIterable(onSubscribeResult) 591 | ) 592 | return { 593 | // even if the result is already available, use 594 | // context because onNext and onComplete needs it 595 | ctx: (typeof context === 'function' 596 | ? await context(req, params) 597 | : context) as Context, 598 | perform() { 599 | return onSubscribeResult; 600 | }, 601 | }; 602 | else if (onSubscribeResult) args = onSubscribeResult; 603 | else { 604 | // you either provide a schema dynamically through 605 | // `onSubscribe` or you set one up during the server setup 606 | if (!schema) throw new Error('The GraphQL schema is not provided'); 607 | 608 | const { operationName, variables } = params; 609 | let query: DocumentNode; 610 | 611 | try { 612 | query = parse(params.query); 613 | } catch (err) { 614 | return [ 615 | JSON.stringify({ 616 | errors: [ 617 | err instanceof Error 618 | ? { 619 | message: err.message, 620 | // TODO: stack might leak sensitive information 621 | // stack: err.stack, 622 | } 623 | : err, 624 | ], 625 | }), 626 | { 627 | status: 400, 628 | statusText: 'Bad Request', 629 | headers: { 'content-type': 'application/json; charset=utf-8' }, 630 | }, 631 | ]; 632 | } 633 | 634 | const argsWithoutSchema = { 635 | operationName, 636 | document: query, 637 | variableValues: variables, 638 | contextValue: (typeof context === 'function' 639 | ? await context(req, params) 640 | : context) as Context, 641 | }; 642 | args = { 643 | ...argsWithoutSchema, 644 | schema: 645 | typeof schema === 'function' 646 | ? await schema(req, argsWithoutSchema) 647 | : schema, 648 | }; 649 | } 650 | 651 | let operation: OperationTypeNode; 652 | try { 653 | const ast = getOperationAST(args.document, args.operationName); 654 | if (!ast) throw null; 655 | operation = ast.operation; 656 | } catch { 657 | return [ 658 | JSON.stringify({ 659 | errors: [{ message: 'Unable to detect operation AST' }], 660 | }), 661 | { 662 | status: 400, 663 | statusText: 'Bad Request', 664 | headers: { 'content-type': 'application/json; charset=utf-8' }, 665 | }, 666 | ]; 667 | } 668 | 669 | // mutations cannot happen over GETs as per the spec 670 | // Read more: https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#get 671 | if (operation === 'mutation' && req.method === 'GET') { 672 | return [ 673 | JSON.stringify({ 674 | errors: [{ message: 'Cannot perform mutations over GET' }], 675 | }), 676 | { 677 | status: 405, 678 | statusText: 'Method Not Allowed', 679 | headers: { 680 | allow: 'POST', 681 | 'content-type': 'application/json; charset=utf-8', 682 | }, 683 | }, 684 | ]; 685 | } 686 | 687 | // we validate after injecting the context because the process of 688 | // reporting the validation errors might need the supplied context value 689 | const validationErrs = validate(args.schema, args.document); 690 | if (validationErrs.length) { 691 | if (req.headers.get('accept') === 'text/event-stream') { 692 | // accept the request and emit the validation error in event streams, 693 | // promoting graceful GraphQL error reporting 694 | // Read more: https://www.w3.org/TR/eventsource/#processing-model 695 | // Read more: https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#document-validation 696 | return { 697 | ctx: args.contextValue, 698 | perform() { 699 | return { errors: validationErrs }; 700 | }, 701 | }; 702 | } 703 | 704 | return [ 705 | JSON.stringify({ errors: validationErrs }), 706 | { 707 | status: 400, 708 | statusText: 'Bad Request', 709 | headers: { 'content-type': 'application/json; charset=utf-8' }, 710 | }, 711 | ]; 712 | } 713 | 714 | return { 715 | ctx: args.contextValue, 716 | async perform() { 717 | const result = await (operation === 'subscription' 718 | ? subscribe(args) 719 | : execute(args)); 720 | const maybeResult = await onOperation?.( 721 | args.contextValue, 722 | req, 723 | args, 724 | result, 725 | ); 726 | if (maybeResult) return maybeResult; 727 | return result; 728 | }, 729 | }; 730 | } 731 | 732 | return async function handler(req) { 733 | const token = await authenticate(req); 734 | if (isResponse(token)) return token; 735 | 736 | // TODO: make accept detection more resilient 737 | const accept = req.headers.get('accept') || '*/*'; 738 | 739 | const stream = typeof token === 'string' ? streams[token] : null; 740 | 741 | if (accept === 'text/event-stream') { 742 | const maybeResponse = await onConnect?.(req); 743 | if (isResponse(maybeResponse)) return maybeResponse; 744 | 745 | // if event stream is not registered, process it directly. 746 | // this means that distinct connections are used for graphql operations 747 | if (!stream) { 748 | const paramsOrResponse = await parseReq(req); 749 | if (isResponse(paramsOrResponse)) return paramsOrResponse; 750 | const params = paramsOrResponse; 751 | 752 | const distinctStream = createStream(null); 753 | 754 | // reserve space for the operation 755 | distinctStream.ops[''] = null; 756 | 757 | const prepared = await prepare(req, params); 758 | if (isResponse(prepared)) return prepared; 759 | 760 | const result = await prepared.perform(); 761 | if (isAsyncIterable(result)) distinctStream.ops[''] = result; 762 | 763 | distinctStream.from(prepared.ctx, req, result, null); 764 | return [ 765 | distinctStream.subscribe(), 766 | { 767 | status: 200, 768 | statusText: 'OK', 769 | headers: { 770 | connection: 'keep-alive', 771 | 'cache-control': 'no-cache', 772 | 'content-encoding': 'none', 773 | 'content-type': 'text/event-stream; charset=utf-8', 774 | }, 775 | }, 776 | ]; 777 | } 778 | 779 | // open stream cant exist, only one per token is allowed 780 | if (stream.open) { 781 | return [ 782 | JSON.stringify({ errors: [{ message: 'Stream already open' }] }), 783 | { 784 | status: 409, 785 | statusText: 'Conflict', 786 | headers: { 787 | 'content-type': 'application/json; charset=utf-8', 788 | }, 789 | }, 790 | ]; 791 | } 792 | 793 | return [ 794 | stream.subscribe(), 795 | { 796 | status: 200, 797 | statusText: 'OK', 798 | headers: { 799 | connection: 'keep-alive', 800 | 'cache-control': 'no-cache', 801 | 'content-encoding': 'none', 802 | 'content-type': 'text/event-stream; charset=utf-8', 803 | }, 804 | }, 805 | ]; 806 | } 807 | 808 | // if there us no token supplied, exclusively use the "distinct connection mode" 809 | if (typeof token !== 'string') { 810 | return [null, { status: 404, statusText: 'Not Found' }]; 811 | } 812 | 813 | // method PUT prepares a stream for future incoming connections 814 | if (req.method === 'PUT') { 815 | if (!['*/*', 'text/plain'].includes(accept)) { 816 | return [null, { status: 406, statusText: 'Not Acceptable' }]; 817 | } 818 | 819 | // streams mustnt exist if putting new one 820 | if (stream) { 821 | return [ 822 | JSON.stringify({ 823 | errors: [{ message: 'Stream already registered' }], 824 | }), 825 | { 826 | status: 409, 827 | statusText: 'Conflict', 828 | headers: { 829 | 'content-type': 'application/json; charset=utf-8', 830 | }, 831 | }, 832 | ]; 833 | } 834 | 835 | streams[token] = createStream(token); 836 | 837 | return [ 838 | token, 839 | { 840 | status: 201, 841 | statusText: 'Created', 842 | headers: { 843 | 'content-type': 'text/plain; charset=utf-8', 844 | }, 845 | }, 846 | ]; 847 | } else if (req.method === 'DELETE') { 848 | // method DELETE completes an existing operation streaming in streams 849 | 850 | // streams must exist when completing operations 851 | if (!stream) { 852 | return [ 853 | JSON.stringify({ 854 | errors: [{ message: 'Stream not found' }], 855 | }), 856 | { 857 | status: 404, 858 | statusText: 'Not Found', 859 | headers: { 860 | 'content-type': 'application/json; charset=utf-8', 861 | }, 862 | }, 863 | ]; 864 | } 865 | 866 | const opId = new URL(req.url ?? '', 'http://localhost/').searchParams.get( 867 | 'operationId', 868 | ); 869 | if (!opId) { 870 | return [ 871 | JSON.stringify({ 872 | errors: [{ message: 'Operation ID is missing' }], 873 | }), 874 | { 875 | status: 400, 876 | statusText: 'Bad Request', 877 | headers: { 878 | 'content-type': 'application/json; charset=utf-8', 879 | }, 880 | }, 881 | ]; 882 | } 883 | 884 | const op = stream.ops[opId]; 885 | if (isAsyncGenerator(op)) op.return(undefined); 886 | delete stream.ops[opId]; // deleting the operation means no further activity should take place 887 | 888 | return [ 889 | null, 890 | { 891 | status: 200, 892 | statusText: 'OK', 893 | }, 894 | ]; 895 | } else if (req.method !== 'GET' && req.method !== 'POST') { 896 | // only POSTs and GETs are accepted at this point 897 | return [ 898 | null, 899 | { 900 | status: 405, 901 | statusText: 'Method Not Allowed', 902 | headers: { 903 | allow: 'GET, POST, PUT, DELETE', 904 | }, 905 | }, 906 | ]; 907 | } else if (!stream) { 908 | // for all other requests, streams must exist to attach the result onto 909 | return [ 910 | JSON.stringify({ 911 | errors: [{ message: 'Stream not found' }], 912 | }), 913 | { 914 | status: 404, 915 | statusText: 'Not Found', 916 | headers: { 917 | 'content-type': 'application/json; charset=utf-8', 918 | }, 919 | }, 920 | ]; 921 | } 922 | 923 | if (!['*/*', 'application/*', 'application/json'].includes(accept)) { 924 | return [ 925 | null, 926 | { 927 | status: 406, 928 | statusText: 'Not Acceptable', 929 | }, 930 | ]; 931 | } 932 | 933 | const paramsOrResponse = await parseReq(req); 934 | if (isResponse(paramsOrResponse)) return paramsOrResponse; 935 | const params = paramsOrResponse; 936 | 937 | const opId = String(params.extensions?.operationId ?? ''); 938 | if (!opId) { 939 | return [ 940 | JSON.stringify({ 941 | errors: [{ message: 'Operation ID is missing' }], 942 | }), 943 | { 944 | status: 400, 945 | statusText: 'Bad Request', 946 | headers: { 947 | 'content-type': 'application/json; charset=utf-8', 948 | }, 949 | }, 950 | ]; 951 | } 952 | if (opId in stream.ops) { 953 | return [ 954 | JSON.stringify({ 955 | errors: [{ message: 'Operation with ID already exists' }], 956 | }), 957 | { 958 | status: 409, 959 | statusText: 'Conflict', 960 | headers: { 961 | 'content-type': 'application/json; charset=utf-8', 962 | }, 963 | }, 964 | ]; 965 | } 966 | 967 | // reserve space for the operation through ID 968 | stream.ops[opId] = null; 969 | 970 | const prepared = await prepare(req, params); 971 | if (isResponse(prepared)) return prepared; 972 | 973 | // operation might have completed before prepared 974 | if (!(opId in stream.ops)) { 975 | return [ 976 | null, 977 | { 978 | status: 204, 979 | statusText: 'No Content', 980 | }, 981 | ]; 982 | } 983 | 984 | const result = await prepared.perform(); 985 | 986 | // operation might have completed before performed 987 | if (!(opId in stream.ops)) { 988 | if (isAsyncGenerator(result)) result.return(undefined); 989 | if (!(opId in stream.ops)) { 990 | return [ 991 | null, 992 | { 993 | status: 204, 994 | statusText: 'No Content', 995 | }, 996 | ]; 997 | } 998 | } 999 | 1000 | if (isAsyncIterable(result)) stream.ops[opId] = result; 1001 | 1002 | // streaming to an empty reservation is ok (will be flushed on connect) 1003 | stream.from(prepared.ctx, req, result, opId); 1004 | 1005 | return [null, { status: 202, statusText: 'Accepted' }]; 1006 | }; 1007 | } 1008 | 1009 | async function parseReq( 1010 | req: Request, 1011 | ): Promise { 1012 | const params: Partial = {}; 1013 | try { 1014 | switch (true) { 1015 | case req.method === 'GET': { 1016 | try { 1017 | const [, search] = req.url.split('?'); 1018 | const searchParams = new URLSearchParams(search); 1019 | params.operationName = searchParams.get('operationName') ?? undefined; 1020 | params.query = searchParams.get('query') ?? undefined; 1021 | const variables = searchParams.get('variables'); 1022 | if (variables) params.variables = JSON.parse(variables); 1023 | const extensions = searchParams.get('extensions'); 1024 | if (extensions) params.extensions = JSON.parse(extensions); 1025 | } catch { 1026 | throw new Error('Unparsable URL'); 1027 | } 1028 | break; 1029 | } 1030 | case req.method === 'POST' && 1031 | req.headers.get('content-type')?.includes('application/json'): { 1032 | if (!req.body) { 1033 | throw new Error('Missing body'); 1034 | } 1035 | const body = 1036 | typeof req.body === 'function' ? await req.body() : req.body; 1037 | const data = typeof body === 'string' ? JSON.parse(body) : body; 1038 | if (!isObject(data)) { 1039 | throw new Error('JSON body must be an object'); 1040 | } 1041 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. 1042 | params.operationName = data.operationName as any; 1043 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. 1044 | params.query = data.query as any; 1045 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. 1046 | params.variables = data.variables as any; 1047 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Any is ok because values will be checked below. 1048 | params.extensions = data.extensions as any; 1049 | break; 1050 | } 1051 | default: 1052 | return [ 1053 | null, 1054 | { 1055 | status: 415, 1056 | statusText: 'Unsupported Media Type', 1057 | }, 1058 | ]; 1059 | } 1060 | 1061 | if (params.query == null) throw new Error('Missing query'); 1062 | if (typeof params.query !== 'string') throw new Error('Invalid query'); 1063 | if ( 1064 | params.variables != null && 1065 | (typeof params.variables !== 'object' || Array.isArray(params.variables)) 1066 | ) { 1067 | throw new Error('Invalid variables'); 1068 | } 1069 | if ( 1070 | params.extensions != null && 1071 | (typeof params.extensions !== 'object' || 1072 | Array.isArray(params.extensions)) 1073 | ) { 1074 | throw new Error('Invalid extensions'); 1075 | } 1076 | 1077 | // request parameters are checked and now complete 1078 | return params as RequestParams; 1079 | } catch (err) { 1080 | return [ 1081 | JSON.stringify({ 1082 | errors: [ 1083 | err instanceof Error 1084 | ? { 1085 | message: err.message, 1086 | // TODO: stack might leak sensitive information 1087 | // stack: err.stack, 1088 | } 1089 | : err, 1090 | ], 1091 | }), 1092 | { 1093 | status: 400, 1094 | statusText: 'Bad Request', 1095 | headers: { 'content-type': 'application/json; charset=utf-8' }, 1096 | }, 1097 | ]; 1098 | } 1099 | } 1100 | 1101 | function isResponse(val: unknown): val is Response { 1102 | // TODO: comprehensive check 1103 | return Array.isArray(val); 1104 | } 1105 | 1106 | export function isExecutionResult(val: unknown): val is ExecutionResult { 1107 | return ( 1108 | isObject(val) && 1109 | ('data' in val || ('data' in val && val.data == null && 'errors' in val)) 1110 | ); 1111 | } 1112 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './handler'; 3 | export * from './client'; 4 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * parser 4 | * 5 | */ 6 | 7 | import { 8 | StreamMessage, 9 | validateStreamEvent, 10 | parseStreamData, 11 | StreamEvent, 12 | } from './common'; 13 | 14 | enum ControlChars { 15 | NewLine = 10, 16 | CarriageReturn = 13, 17 | Space = 32, 18 | Colon = 58, 19 | } 20 | 21 | /** 22 | * HTTP response chunk parser for graphql-sse's event stream messages. 23 | * 24 | * Reference: https://github.com/Azure/fetch-event-source/blob/main/src/parse.ts 25 | * 26 | * @private 27 | */ 28 | export function createParser(): ( 29 | chunk: Uint8Array, 30 | ) => StreamMessage[] | void { 31 | let buffer: Uint8Array | undefined; 32 | let position: number; // current read position 33 | let fieldLength: number; // length of the `field` portion of the line 34 | let discardTrailingNewline = false; 35 | let message = { event: '', data: '' }; 36 | let pending: StreamMessage[] = []; 37 | const decoder = new TextDecoder(); 38 | 39 | return function parse(chunk) { 40 | if (buffer === undefined) { 41 | buffer = chunk; 42 | position = 0; 43 | fieldLength = -1; 44 | } else { 45 | const concat = new Uint8Array(buffer.length + chunk.length); 46 | concat.set(buffer); 47 | concat.set(chunk, buffer.length); 48 | buffer = concat; 49 | } 50 | 51 | const bufLength = buffer.length; 52 | let lineStart = 0; // index where the current line starts 53 | while (position < bufLength) { 54 | if (discardTrailingNewline) { 55 | if (buffer[position] === ControlChars.NewLine) { 56 | lineStart = ++position; // skip to next char 57 | } 58 | discardTrailingNewline = false; 59 | } 60 | 61 | // look forward until the end of line 62 | let lineEnd = -1; // index of the \r or \n char 63 | for (; position < bufLength && lineEnd === -1; ++position) { 64 | switch (buffer[position]) { 65 | case ControlChars.Colon: 66 | if (fieldLength === -1) { 67 | // first colon in line 68 | fieldLength = position - lineStart; 69 | } 70 | break; 71 | // \r case below should fallthrough to \n: 72 | case ControlChars.CarriageReturn: 73 | discardTrailingNewline = true; 74 | // eslint-disable-next-line no-fallthrough 75 | case ControlChars.NewLine: 76 | lineEnd = position; 77 | break; 78 | } 79 | } 80 | 81 | if (lineEnd === -1) { 82 | // end of the buffer but the line hasn't ended 83 | break; 84 | } else if (lineStart === lineEnd) { 85 | // empty line denotes end of incoming message 86 | if (message.event || message.data) { 87 | // NOT a server ping (":\n\n") 88 | if (!message.event) throw new Error('Missing message event'); 89 | const event = validateStreamEvent(message.event); 90 | const data = parseStreamData(event, message.data); 91 | pending.push({ 92 | event, 93 | data, 94 | }); 95 | message = { event: '', data: '' }; 96 | } 97 | } else if (fieldLength > 0) { 98 | // end of line indicates message 99 | const line = buffer.subarray(lineStart, lineEnd); 100 | 101 | // exclude comments and lines with no values 102 | // line is of format ":" or ": " 103 | // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation 104 | const field = decoder.decode(line.subarray(0, fieldLength)); 105 | const valueOffset = 106 | fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1); 107 | const value = decoder.decode(line.subarray(valueOffset)); 108 | 109 | switch (field) { 110 | case 'event': 111 | message.event = value; 112 | break; 113 | case 'data': 114 | // append the new value if the message has data 115 | message.data = message.data ? message.data + '\n' + value : value; 116 | break; 117 | } 118 | } 119 | 120 | // next line 121 | lineStart = position; 122 | fieldLength = -1; 123 | } 124 | 125 | if (lineStart === bufLength) { 126 | // finished reading 127 | buffer = undefined; 128 | const messages = [...pending]; 129 | pending = []; 130 | return messages; 131 | } else if (lineStart !== 0) { 132 | // create a new view into buffer beginning at lineStart so we don't 133 | // need to copy over the previous lines when we get the new chunk 134 | buffer = buffer.subarray(lineStart); 135 | position -= lineStart; 136 | } 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/use/express.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import { 3 | createHandler as createRawHandler, 4 | HandlerOptions as RawHandlerOptions, 5 | OperationContext, 6 | } from '../handler'; 7 | 8 | /** 9 | * @category Server/express 10 | */ 11 | export interface RequestContext { 12 | res: Response; 13 | } 14 | 15 | /** 16 | * @category Server/express 17 | */ 18 | export type HandlerOptions = 19 | RawHandlerOptions; 20 | 21 | /** 22 | * The ready-to-use handler for [express](https://expressjs.com). 23 | * 24 | * Errors thrown from the provided options or callbacks (or even due to 25 | * library misuse or potential bugs) will reject the handler or bubble to the 26 | * returned iterator. They are considered internal errors and you should take care 27 | * of them accordingly. 28 | * 29 | * For production environments, its recommended not to transmit the exact internal 30 | * error details to the client, but instead report to an error logging tool or simply 31 | * the console. 32 | * 33 | * ```ts 34 | * import express from 'express'; // yarn add express 35 | * import { createHandler } from 'graphql-sse/lib/use/express'; 36 | * import { schema } from './my-graphql'; 37 | * 38 | * const handler = createHandler({ schema }); 39 | * 40 | * const app = express(); 41 | * 42 | * app.use('/graphql/stream', async (req, res) => { 43 | * try { 44 | * await handler(req, res); 45 | * } catch (err) { 46 | * console.error(err); 47 | * res.writeHead(500).end(); 48 | * } 49 | * }); 50 | * 51 | * server.listen(4000); 52 | * console.log('Listening to port 4000'); 53 | * ``` 54 | * 55 | * @category Server/express 56 | */ 57 | export function createHandler( 58 | options: HandlerOptions, 59 | ): (req: Request, res: Response) => Promise { 60 | const handler = createRawHandler(options); 61 | return async function handleRequest(req, res) { 62 | const [body, init] = await handler({ 63 | method: req.method, 64 | url: req.url, 65 | headers: { 66 | get(key) { 67 | const header = req.headers[key]; 68 | return Array.isArray(header) ? header.join('\n') : header; 69 | }, 70 | }, 71 | body: () => 72 | new Promise((resolve, reject) => { 73 | if (req.body) { 74 | // body was parsed by middleware 75 | return resolve(req.body); 76 | } 77 | 78 | let body = ''; 79 | req.on('data', (chunk) => (body += chunk)); 80 | req.once('error', reject); 81 | req.once('end', () => { 82 | req.off('error', reject); 83 | resolve(body); 84 | }); 85 | }), 86 | raw: req, 87 | context: { res }, 88 | }); 89 | 90 | res.writeHead(init.status, init.statusText, init.headers); 91 | 92 | if (!body || typeof body === 'string') { 93 | return new Promise((resolve) => res.end(body, () => resolve())); 94 | } 95 | 96 | res.once('close', body.return); 97 | for await (const value of body) { 98 | const closed = await new Promise((resolve, reject) => { 99 | if (!res.writable) { 100 | // response's close event might be late 101 | resolve(true); 102 | } else { 103 | res.write(value, (err) => (err ? reject(err) : resolve(false))); 104 | } 105 | }); 106 | if (closed) { 107 | break; 108 | } 109 | } 110 | res.off('close', body.return); 111 | return new Promise((resolve) => res.end(resolve)); 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /src/use/fastify.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest, FastifyReply } from 'fastify'; 2 | import { 3 | createHandler as createRawHandler, 4 | HandlerOptions as RawHandlerOptions, 5 | OperationContext, 6 | } from '../handler'; 7 | 8 | /** 9 | * @category Server/fastify 10 | */ 11 | export interface RequestContext { 12 | reply: FastifyReply; 13 | } 14 | 15 | /** 16 | * @category Server/fastify 17 | */ 18 | export type HandlerOptions = 19 | RawHandlerOptions; 20 | 21 | /** 22 | * The ready-to-use handler for [fastify](https://www.fastify.io). 23 | * 24 | * Errors thrown from the provided options or callbacks (or even due to 25 | * library misuse or potential bugs) will reject the handler or bubble to the 26 | * returned iterator. They are considered internal errors and you should take care 27 | * of them accordingly. 28 | * 29 | * For production environments, its recommended not to transmit the exact internal 30 | * error details to the client, but instead report to an error logging tool or simply 31 | * the console. 32 | * 33 | * ```ts 34 | * import Fastify from 'fastify'; // yarn add fastify 35 | * import { createHandler } from 'graphql-sse/lib/use/fastify'; 36 | * 37 | * const handler = createHandler({ schema }); 38 | * 39 | * const fastify = Fastify(); 40 | * 41 | * fastify.all('/graphql/stream', async (req, reply) => { 42 | * try { 43 | * await handler(req, reply); 44 | * } catch (err) { 45 | * console.error(err); 46 | * reply.code(500).send(); 47 | * } 48 | * }); 49 | * 50 | * fastify.listen({ port: 4000 }); 51 | * console.log('Listening to port 4000'); 52 | * ``` 53 | * 54 | * @category Server/fastify 55 | */ 56 | export function createHandler( 57 | options: HandlerOptions, 58 | ): (req: FastifyRequest, reply: FastifyReply) => Promise { 59 | const handler = createRawHandler(options); 60 | return async function handleRequest(req, reply) { 61 | const [body, init] = await handler({ 62 | method: req.method, 63 | url: req.url, 64 | headers: { 65 | get(key) { 66 | const header = reply.getHeader(key) ?? req.headers[key]; 67 | return Array.isArray(header) ? header.join('\n') : String(header); 68 | }, 69 | }, 70 | body: () => 71 | new Promise((resolve, reject) => { 72 | if (req.body) { 73 | // body was parsed by middleware 74 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- even if fastify incorrectly parsed the body, we cannot re-read it 75 | return resolve(req.body as any); 76 | } 77 | 78 | let body = ''; 79 | req.raw.on('data', (chunk) => (body += chunk)); 80 | req.raw.once('error', reject); 81 | req.raw.once('end', () => { 82 | req.raw.off('error', reject); 83 | resolve(body); 84 | }); 85 | }), 86 | raw: req, 87 | context: { reply }, 88 | }); 89 | 90 | const middlewareHeaders: Record = {}; 91 | for (const [key, val] of Object.entries(reply.getHeaders())) { 92 | middlewareHeaders[key] = Array.isArray(val) 93 | ? val.join('\n') 94 | : String(val); 95 | } 96 | reply.raw.writeHead(init.status, init.statusText, { 97 | ...middlewareHeaders, 98 | ...init.headers, 99 | }); 100 | 101 | if (!body || typeof body === 'string') { 102 | return new Promise((resolve) => 103 | reply.raw.end(body, () => resolve()), 104 | ); 105 | } 106 | 107 | reply.raw.once('close', body.return); 108 | for await (const value of body) { 109 | const closed = await new Promise((resolve, reject) => { 110 | if (!reply.raw.writable) { 111 | // response's close event might be late 112 | resolve(true); 113 | } else { 114 | reply.raw.write(value, (err) => (err ? reject(err) : resolve(false))); 115 | } 116 | }); 117 | if (closed) { 118 | break; 119 | } 120 | } 121 | reply.raw.off('close', body.return); 122 | return new Promise((resolve) => reply.raw.end(resolve)); 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /src/use/fetch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createHandler as createRawHandler, 3 | HandlerOptions as RawHandlerOptions, 4 | OperationContext, 5 | } from '../handler'; 6 | 7 | /** 8 | * @category Server/fetch 9 | */ 10 | export interface RequestContext { 11 | Response: typeof Response; 12 | ReadableStream: typeof ReadableStream; 13 | TextEncoder: typeof TextEncoder; 14 | } 15 | 16 | /** 17 | * @category Server/fetch 18 | */ 19 | export type HandlerOptions = 20 | RawHandlerOptions; 21 | 22 | /** 23 | * The ready-to-use fetch handler. To be used with your favourite fetch 24 | * framework, in a lambda function, or have deploy to the edge. 25 | * 26 | * Errors thrown from the provided options or callbacks (or even due to 27 | * library misuse or potential bugs) will reject the handler or bubble to the 28 | * returned iterator. They are considered internal errors and you should take care 29 | * of them accordingly. 30 | * 31 | * For production environments, its recommended not to transmit the exact internal 32 | * error details to the client, but instead report to an error logging tool or simply 33 | * the console. 34 | * 35 | * ```ts 36 | * import { createHandler } from 'graphql-sse/lib/use/fetch'; 37 | * import { schema } from './my-graphql'; 38 | * 39 | * const handler = createHandler({ schema }); 40 | * 41 | * export async function fetch(req: Request): Promise { 42 | * try { 43 | * return await handler(req); 44 | * } catch (err) { 45 | * console.error(err); 46 | * return new Response(null, { status: 500 }); 47 | * } 48 | * } 49 | * ``` 50 | * 51 | * @category Server/fetch 52 | */ 53 | export function createHandler( 54 | options: HandlerOptions, 55 | reqCtx: Partial = {}, 56 | ): (req: Request) => Promise { 57 | const api: RequestContext = { 58 | Response: reqCtx.Response || Response, 59 | TextEncoder: reqCtx.TextEncoder || TextEncoder, 60 | ReadableStream: reqCtx.ReadableStream || ReadableStream, 61 | }; 62 | 63 | const handler = createRawHandler(options); 64 | return async function handleRequest(req) { 65 | const [resp, init] = await handler({ 66 | method: req.method, 67 | url: req.url, 68 | headers: req.headers, 69 | body: () => req.text(), 70 | raw: req, 71 | context: api, 72 | }); 73 | 74 | if (!resp || typeof resp === 'string') { 75 | return new api.Response(resp, init); 76 | } 77 | 78 | let cancelled = false; 79 | const enc = new api.TextEncoder(); 80 | const stream = new api.ReadableStream({ 81 | async pull(controller) { 82 | const { done, value } = await resp.next(); 83 | if (value != null) { 84 | controller.enqueue(enc.encode(value)); 85 | } 86 | if (done) { 87 | controller.close(); 88 | } 89 | }, 90 | async cancel(e) { 91 | cancelled = true; 92 | await resp.return(e); 93 | }, 94 | }); 95 | 96 | if (req.signal.aborted) { 97 | // TODO: can this check be before the readable stream is created? 98 | // it's possible that the request was aborted before listening 99 | resp.return(undefined); 100 | } else { 101 | // make sure to connect the signals as well 102 | req.signal.addEventListener('abort', () => { 103 | if (!cancelled) { 104 | resp.return(); 105 | } 106 | }); 107 | } 108 | 109 | return new api.Response(stream, init); 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/use/http.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from 'http'; 2 | import { 3 | createHandler as createRawHandler, 4 | HandlerOptions as RawHandlerOptions, 5 | OperationContext, 6 | } from '../handler'; 7 | 8 | /** 9 | * @category Server/http 10 | */ 11 | export interface RequestContext { 12 | res: ServerResponse; 13 | } 14 | 15 | /** 16 | * @category Server/http 17 | */ 18 | export type HandlerOptions = 19 | RawHandlerOptions; 20 | 21 | /** 22 | * The ready-to-use handler for Node's [http](https://nodejs.org/api/http.html). 23 | * 24 | * Errors thrown from the provided options or callbacks (or even due to 25 | * library misuse or potential bugs) will reject the handler or bubble to the 26 | * returned iterator. They are considered internal errors and you should take care 27 | * of them accordingly. 28 | * 29 | * For production environments, its recommended not to transmit the exact internal 30 | * error details to the client, but instead report to an error logging tool or simply 31 | * the console. 32 | * 33 | * ```ts 34 | * import http from 'http'; 35 | * import { createHandler } from 'graphql-sse/lib/use/http'; 36 | * import { schema } from './my-graphql'; 37 | * 38 | * const handler = createHandler({ schema }); 39 | * 40 | * const server = http.createServer(async (req, res) => { 41 | * try { 42 | * await handler(req, res); 43 | * } catch (err) { 44 | * console.error(err); 45 | * res.writeHead(500).end(); 46 | * } 47 | * }); 48 | * 49 | * server.listen(4000); 50 | * console.log('Listening to port 4000'); 51 | * ``` 52 | * 53 | * @category Server/http 54 | */ 55 | export function createHandler( 56 | options: HandlerOptions, 57 | ): (req: IncomingMessage, res: ServerResponse) => Promise { 58 | const handler = createRawHandler(options); 59 | return async function handleRequest(req, res) { 60 | const [body, init] = await handler({ 61 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The method will always be available with http requests. 62 | method: req.method!, 63 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The url will always be available with http requests. 64 | url: req.url!, 65 | headers: { 66 | get(key) { 67 | const header = req.headers[key]; 68 | return Array.isArray(header) ? header.join('\n') : header; 69 | }, 70 | }, 71 | body: () => 72 | new Promise((resolve, reject) => { 73 | let body = ''; 74 | req.on('data', (chunk) => (body += chunk)); 75 | req.once('error', reject); 76 | req.once('end', () => { 77 | req.off('error', reject); 78 | resolve(body); 79 | }); 80 | }), 81 | raw: req, 82 | context: { res }, 83 | }); 84 | 85 | res.writeHead(init.status, init.statusText, init.headers); 86 | 87 | if (!body || typeof body === 'string') { 88 | return new Promise((resolve) => res.end(body, () => resolve())); 89 | } 90 | 91 | res.once('close', body.return); 92 | for await (const value of body) { 93 | const closed = await new Promise((resolve, reject) => { 94 | if (!res.writable) { 95 | // response's close event might be late 96 | resolve(true); 97 | } else { 98 | res.write(value, (err) => (err ? reject(err) : resolve(false))); 99 | } 100 | }); 101 | if (closed) { 102 | break; 103 | } 104 | } 105 | res.off('close', body.return); 106 | return new Promise((resolve) => res.end(resolve)); 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/use/http2.ts: -------------------------------------------------------------------------------- 1 | import type { Http2ServerRequest, Http2ServerResponse } from 'http2'; 2 | import { 3 | createHandler as createRawHandler, 4 | HandlerOptions as RawHandlerOptions, 5 | OperationContext, 6 | } from '../handler'; 7 | 8 | /** 9 | * @category Server/http2 10 | */ 11 | export interface RequestContext { 12 | res: Http2ServerResponse; 13 | } 14 | 15 | /** 16 | * @category Server/http2 17 | */ 18 | export type HandlerOptions = 19 | RawHandlerOptions; 20 | 21 | /** 22 | * The ready-to-use handler for Node's [http](https://nodejs.org/api/http2.html). 23 | * 24 | * Errors thrown from the provided options or callbacks (or even due to 25 | * library misuse or potential bugs) will reject the handler or bubble to the 26 | * returned iterator. They are considered internal errors and you should take care 27 | * of them accordingly. 28 | * 29 | * For production environments, its recommended not to transmit the exact internal 30 | * error details to the client, but instead report to an error logging tool or simply 31 | * the console. 32 | * 33 | * ```ts 34 | * import http from 'http2'; 35 | * import { createHandler } from 'graphql-sse/lib/use/http2'; 36 | * import { schema } from './my-graphql'; 37 | * 38 | * const handler = createHandler({ schema }); 39 | * 40 | * const server = http.createServer(async (req, res) => { 41 | * try { 42 | * await handler(req, res); 43 | * } catch (err) { 44 | * console.error(err); 45 | * res.writeHead(500).end(); 46 | * } 47 | * }); 48 | * 49 | * server.listen(4000); 50 | * console.log('Listening to port 4000'); 51 | * ``` 52 | * 53 | * @category Server/http2 54 | */ 55 | export function createHandler( 56 | options: HandlerOptions, 57 | ): (req: Http2ServerRequest, res: Http2ServerResponse) => Promise { 58 | const handler = createRawHandler(options); 59 | return async function handleRequest(req, res) { 60 | const [body, init] = await handler({ 61 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The method will always be available with http requests. 62 | method: req.method!, 63 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The url will always be available with http requests. 64 | url: req.url!, 65 | headers: { 66 | get(key) { 67 | const header = req.headers[key]; 68 | return Array.isArray(header) ? header.join('\n') : header; 69 | }, 70 | }, 71 | body: () => 72 | new Promise((resolve, reject) => { 73 | let body = ''; 74 | req.on('data', (chunk) => (body += chunk)); 75 | req.once('error', reject); 76 | req.once('end', () => { 77 | req.off('error', reject); 78 | resolve(body); 79 | }); 80 | }), 81 | raw: req, 82 | context: { res }, 83 | }); 84 | 85 | res.writeHead(init.status, init.statusText, init.headers); 86 | 87 | if (!body || typeof body === 'string') { 88 | return new Promise((resolve) => 89 | res.end(body || '', () => resolve()), 90 | ); 91 | } 92 | 93 | res.once('close', body.return); 94 | for await (const value of body) { 95 | const closed = await new Promise((resolve, reject) => { 96 | if (!res.writable) { 97 | // response's close event might be late 98 | resolve(true); 99 | } else { 100 | res.write(value, (err) => (err ? reject(err) : resolve(false))); 101 | } 102 | }); 103 | if (closed) { 104 | break; 105 | } 106 | } 107 | res.off('close', body.return); 108 | return new Promise((resolve) => res.end(resolve)); 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /src/use/koa.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Middleware, 3 | ParameterizedContext, 4 | DefaultState, 5 | DefaultContext, 6 | } from 'koa'; 7 | import type { IncomingMessage } from 'http'; 8 | import { 9 | createHandler as createRawHandler, 10 | HandlerOptions as RawHandlerOptions, 11 | OperationContext, 12 | } from '../handler'; 13 | 14 | /** 15 | * Handler options when using the koa adapter. 16 | * 17 | * @category Server/koa 18 | */ 19 | export type HandlerOptions< 20 | Context extends OperationContext = undefined, 21 | KoaState = DefaultState, 22 | KoaContext = DefaultContext, 23 | > = RawHandlerOptions< 24 | IncomingMessage, 25 | ParameterizedContext, 26 | Context 27 | >; 28 | 29 | type WithPossibleBody = { body?: string | Record }; 30 | 31 | /** 32 | * The ready-to-use handler for [Koa](https://expressjs.com). 33 | * 34 | * Errors thrown from the provided options or callbacks (or even due to 35 | * library misuse or potential bugs) will reject the handler or bubble to the 36 | * returned iterator. They are considered internal errors and you should take care 37 | * of them accordingly. 38 | * 39 | * For production environments, its recommended not to transmit the exact internal 40 | * error details to the client, but instead report to an error logging tool or simply 41 | * the console. 42 | * 43 | * ```js 44 | * import Koa from 'koa'; // yarn add koa 45 | * import mount from 'koa-mount'; // yarn add koa-mount 46 | * import { createHandler } from 'graphql-sse/lib/use/koa'; 47 | * import { schema } from './my-graphql'; 48 | * 49 | * const app = new Koa(); 50 | * app.use( 51 | * mount('/graphql/stream', async (ctx, next) => { 52 | * try { 53 | * await handler(ctx, next); 54 | * } catch (err) { 55 | * console.error(err); 56 | * ctx.response.status = 500; 57 | * ctx.response.message = 'Internal Server Error'; 58 | * } 59 | * }), 60 | * ); 61 | * 62 | * app.listen({ port: 4000 }); 63 | * console.log('Listening to port 4000'); 64 | * ``` 65 | * 66 | * @category Server/koa 67 | */ 68 | export function createHandler< 69 | Context extends OperationContext = undefined, 70 | KoaState = DefaultState, 71 | KoaContext = DefaultContext, 72 | >( 73 | options: HandlerOptions, 74 | ): Middleware { 75 | const handler = createRawHandler(options); 76 | return async function requestListener(ctx) { 77 | const [body, init] = await handler({ 78 | url: ctx.url, 79 | method: ctx.method, 80 | headers: { 81 | get(key) { 82 | const header = ctx.headers[key]; 83 | return Array.isArray(header) ? header.join('\n') : header; 84 | }, 85 | }, 86 | body: () => { 87 | // in case koa has a body parser 88 | const body = 89 | (ctx.request as WithPossibleBody).body || 90 | (ctx.req as WithPossibleBody).body; 91 | if (body) { 92 | return body; 93 | } 94 | 95 | return new Promise((resolve) => { 96 | let body = ''; 97 | ctx.req.on('data', (chunk) => (body += chunk)); 98 | ctx.req.on('end', () => resolve(body)); 99 | }); 100 | }, 101 | raw: ctx.req, 102 | context: ctx, 103 | }); 104 | ctx.response.status = init.status; 105 | ctx.response.message = init.statusText; 106 | if (init.headers) { 107 | for (const [name, value] of Object.entries(init.headers)) { 108 | ctx.response.set(name, value); 109 | } 110 | } 111 | 112 | if (!body || typeof body === 'string') { 113 | ctx.body = body; 114 | return; 115 | } 116 | 117 | ctx.res.once('close', body.return); 118 | for await (const value of body) { 119 | const closed = await new Promise((resolve, reject) => { 120 | if (!ctx.res.writable) { 121 | // response's close event might be late 122 | resolve(true); 123 | } else { 124 | ctx.res.write(value, (err) => (err ? reject(err) : resolve(false))); 125 | } 126 | }); 127 | if (closed) { 128 | break; 129 | } 130 | } 131 | ctx.res.off('close', body.return); 132 | return new Promise((resolve) => ctx.res.end(resolve)); 133 | }; 134 | } 135 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * utils 4 | * 5 | */ 6 | 7 | /** @private */ 8 | export function isObject(val: unknown): val is Record { 9 | return typeof val === 'object' && val !== null; 10 | } 11 | -------------------------------------------------------------------------------- /tests/__snapshots__/handler.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`distinct connections mode > should stream query operations to connected event stream and then disconnect 1`] = ` 4 | ": 5 | 6 | " 7 | `; 8 | 9 | exports[`distinct connections mode > should stream query operations to connected event stream and then disconnect 2`] = ` 10 | "event: next 11 | data: {"data":{"getValue":"value"}} 12 | 13 | " 14 | `; 15 | 16 | exports[`distinct connections mode > should stream query operations to connected event stream and then disconnect 3`] = ` 17 | "event: complete 18 | data: 19 | 20 | " 21 | `; 22 | 23 | exports[`distinct connections mode > should stream query operations to connected event stream and then disconnect 4`] = ` 24 | ": 25 | 26 | " 27 | `; 28 | 29 | exports[`distinct connections mode > should stream query operations to connected event stream and then disconnect 5`] = ` 30 | "event: next 31 | data: {"data":{"getValue":"value"}} 32 | 33 | " 34 | `; 35 | 36 | exports[`distinct connections mode > should stream query operations to connected event stream and then disconnect 6`] = ` 37 | "event: complete 38 | data: 39 | 40 | " 41 | `; 42 | 43 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 1`] = ` 44 | ": 45 | 46 | " 47 | `; 48 | 49 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 2`] = ` 50 | "event: next 51 | data: {"data":{"greetings":"Hi"}} 52 | 53 | " 54 | `; 55 | 56 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 3`] = ` 57 | "event: next 58 | data: {"data":{"greetings":"Bonjour"}} 59 | 60 | " 61 | `; 62 | 63 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 4`] = ` 64 | "event: next 65 | data: {"data":{"greetings":"Hola"}} 66 | 67 | " 68 | `; 69 | 70 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 5`] = ` 71 | "event: next 72 | data: {"data":{"greetings":"Ciao"}} 73 | 74 | " 75 | `; 76 | 77 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 6`] = ` 78 | "event: next 79 | data: {"data":{"greetings":"Zdravo"}} 80 | 81 | " 82 | `; 83 | 84 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 7`] = ` 85 | "event: complete 86 | data: 87 | 88 | " 89 | `; 90 | 91 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 8`] = ` 92 | ": 93 | 94 | " 95 | `; 96 | 97 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 9`] = ` 98 | "event: next 99 | data: {"data":{"greetings":"Hi"}} 100 | 101 | " 102 | `; 103 | 104 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 10`] = ` 105 | "event: next 106 | data: {"data":{"greetings":"Bonjour"}} 107 | 108 | " 109 | `; 110 | 111 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 11`] = ` 112 | "event: next 113 | data: {"data":{"greetings":"Hola"}} 114 | 115 | " 116 | `; 117 | 118 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 12`] = ` 119 | "event: next 120 | data: {"data":{"greetings":"Ciao"}} 121 | 122 | " 123 | `; 124 | 125 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 13`] = ` 126 | "event: next 127 | data: {"data":{"greetings":"Zdravo"}} 128 | 129 | " 130 | `; 131 | 132 | exports[`distinct connections mode > should stream subscription operations to connected event stream and then disconnect 14`] = ` 133 | "event: complete 134 | data: 135 | 136 | " 137 | `; 138 | 139 | exports[`single connection mode > should stream subscription operations to connected event stream 2`] = ` 140 | "event: next 141 | data: {"id":"1","payload":{"data":{"greetings":"Hi"}}} 142 | 143 | " 144 | `; 145 | 146 | exports[`single connection mode > should stream subscription operations to connected event stream 3`] = ` 147 | "event: next 148 | data: {"id":"1","payload":{"data":{"greetings":"Bonjour"}}} 149 | 150 | " 151 | `; 152 | 153 | exports[`single connection mode > should stream subscription operations to connected event stream 4`] = ` 154 | "event: next 155 | data: {"id":"1","payload":{"data":{"greetings":"Hola"}}} 156 | 157 | " 158 | `; 159 | 160 | exports[`single connection mode > should stream subscription operations to connected event stream 5`] = ` 161 | "event: next 162 | data: {"id":"1","payload":{"data":{"greetings":"Ciao"}}} 163 | 164 | " 165 | `; 166 | 167 | exports[`single connection mode > should stream subscription operations to connected event stream 6`] = ` 168 | "event: next 169 | data: {"id":"1","payload":{"data":{"greetings":"Zdravo"}}} 170 | 171 | " 172 | `; 173 | 174 | exports[`single connection mode > should stream subscription operations to connected event stream 7`] = ` 175 | "event: complete 176 | data: {"id":"1"} 177 | 178 | " 179 | `; 180 | -------------------------------------------------------------------------------- /tests/__snapshots__/parser.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`should accept valid events only 1`] = `[Error: Invalid stream event "done"]`; 4 | 5 | exports[`should accept valid events only 2`] = `[Error: Invalid stream event "value"]`; 6 | 7 | exports[`should ignore comments 1`] = ` 8 | [ 9 | { 10 | "data": { 11 | "iAm": "data", 12 | }, 13 | "event": "next", 14 | }, 15 | ] 16 | `; 17 | 18 | exports[`should parse chunked message 1`] = ` 19 | [ 20 | { 21 | "data": { 22 | "iAm": "data", 23 | }, 24 | "event": "next", 25 | }, 26 | ] 27 | `; 28 | 29 | exports[`should parse message whose lines are separated by \\r\\n 1`] = ` 30 | [ 31 | { 32 | "data": { 33 | "iAm": "data", 34 | }, 35 | "event": "next", 36 | }, 37 | ] 38 | `; 39 | 40 | exports[`should parse message with prepended ping 1`] = ` 41 | [ 42 | { 43 | "data": { 44 | "iAm": "data", 45 | }, 46 | "event": "next", 47 | }, 48 | ] 49 | `; 50 | 51 | exports[`should parse multiple messages from one chunk 1`] = ` 52 | [ 53 | { 54 | "data": {}, 55 | "event": "next", 56 | }, 57 | { 58 | "data": { 59 | "no": "data", 60 | }, 61 | "event": "next", 62 | }, 63 | ] 64 | `; 65 | 66 | exports[`should parse multiple messages from one chunk 2`] = ` 67 | [ 68 | { 69 | "data": { 70 | "almost": "done", 71 | }, 72 | "event": "next", 73 | }, 74 | { 75 | "data": {}, 76 | "event": "complete", 77 | }, 78 | ] 79 | `; 80 | 81 | exports[`should parse whole message 1`] = ` 82 | [ 83 | { 84 | "data": { 85 | "iAm": "data", 86 | }, 87 | "event": "next", 88 | }, 89 | ] 90 | `; 91 | 92 | exports[`should parse whole message 2`] = ` 93 | [ 94 | { 95 | "data": { 96 | "iAm": "data", 97 | }, 98 | "event": "next", 99 | }, 100 | ] 101 | `; 102 | 103 | exports[`should parse whole message 3`] = ` 104 | [ 105 | { 106 | "data": null, 107 | "event": "complete", 108 | }, 109 | ] 110 | `; 111 | -------------------------------------------------------------------------------- /tests/fixtures/simple.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLSchema, 3 | GraphQLObjectType, 4 | GraphQLString, 5 | GraphQLNonNull, 6 | GraphQLSchemaConfig, 7 | } from 'graphql'; 8 | 9 | // use for dispatching a `pong` to the `ping` subscription 10 | const pendingPongs: Record = {}; 11 | const pongListeners: Record void) | undefined> = {}; 12 | export function pong(key: string): void { 13 | if (pongListeners[key]) { 14 | pongListeners[key]?.(false); 15 | } else { 16 | const pending = pendingPongs[key]; 17 | pendingPongs[key] = pending ? pending + 1 : 1; 18 | } 19 | } 20 | 21 | export const schemaConfig: GraphQLSchemaConfig = { 22 | query: new GraphQLObjectType({ 23 | name: 'Query', 24 | fields: { 25 | getValue: { 26 | type: new GraphQLNonNull(GraphQLString), 27 | resolve: () => 'value', 28 | }, 29 | getMultiline: { 30 | type: new GraphQLNonNull(GraphQLString), 31 | resolve: () => 'some\n\nthing', 32 | }, 33 | }, 34 | }), 35 | subscription: new GraphQLObjectType({ 36 | name: 'Subscription', 37 | fields: { 38 | greetings: { 39 | type: new GraphQLNonNull(GraphQLString), 40 | subscribe: async function* () { 41 | for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { 42 | yield { greetings: hi }; 43 | } 44 | }, 45 | }, 46 | slowGreetings: { 47 | type: new GraphQLNonNull(GraphQLString), 48 | subscribe: async function* () { 49 | for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { 50 | yield { slowGreetings: hi }; 51 | await new Promise((resolve) => setTimeout(resolve, 10)); 52 | } 53 | }, 54 | }, 55 | ping: { 56 | type: new GraphQLNonNull(GraphQLString), 57 | args: { 58 | key: { 59 | type: new GraphQLNonNull(GraphQLString), 60 | }, 61 | }, 62 | subscribe: function (_src, args) { 63 | const key = args.key; 64 | return { 65 | [Symbol.asyncIterator]() { 66 | return this; 67 | }, 68 | async next() { 69 | if ((pendingPongs[key] ?? 0) > 0) { 70 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 71 | pendingPongs[key]!--; 72 | return { value: { ping: 'pong' } }; 73 | } 74 | if ( 75 | await new Promise((resolve) => (pongListeners[key] = resolve)) 76 | ) 77 | return { done: true }; 78 | return { value: { ping: 'pong' } }; 79 | }, 80 | async return() { 81 | pongListeners[key]?.(true); 82 | delete pongListeners[key]; 83 | return { done: true }; 84 | }, 85 | async throw() { 86 | throw new Error('Ping no gusta'); 87 | }, 88 | }; 89 | }, 90 | }, 91 | throwing: { 92 | type: new GraphQLNonNull(GraphQLString), 93 | subscribe: async function () { 94 | throw new Error('Kaboom!'); 95 | }, 96 | }, 97 | }, 98 | }), 99 | }; 100 | 101 | export const schema = new GraphQLSchema(schemaConfig); 102 | -------------------------------------------------------------------------------- /tests/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vitest } from 'vitest'; 2 | import { parse, execute } from 'graphql'; 3 | import { 4 | createTHandler, 5 | assertString, 6 | assertAsyncGenerator, 7 | } from './utils/thandler'; 8 | import { schema } from './fixtures/simple'; 9 | import { TOKEN_HEADER_KEY } from '../src/common'; 10 | 11 | it('should only accept valid accept headers', async () => { 12 | const { handler } = createTHandler(); 13 | 14 | let [body, init] = await handler('PUT'); 15 | assertString(body); 16 | const token = body; 17 | 18 | [body, init] = await handler('GET', { 19 | headers: { 20 | accept: 'gibberish', 21 | [TOKEN_HEADER_KEY]: token, 22 | }, 23 | }); 24 | expect(init.status).toBe(406); 25 | 26 | [body, init] = await handler('GET', { 27 | headers: { 28 | accept: 'application/json', 29 | [TOKEN_HEADER_KEY]: token, 30 | }, 31 | }); 32 | expect(init.status).toBe(400); 33 | expect(init.headers?.['content-type']).toBe( 34 | 'application/json; charset=utf-8', 35 | ); 36 | expect(body).toMatchInlineSnapshot( 37 | `"{"errors":[{"message":"Missing query"}]}"`, 38 | ); 39 | 40 | [body, init] = await handler('GET', { 41 | headers: { 42 | accept: 'text/event-stream', 43 | }, 44 | }); 45 | expect(init.status).toBe(400); 46 | expect(init.headers?.['content-type']).toBe( 47 | 'application/json; charset=utf-8', 48 | ); 49 | expect(body).toMatchInlineSnapshot( 50 | `"{"errors":[{"message":"Missing query"}]}"`, 51 | ); 52 | }); 53 | 54 | it.each(['authenticate', 'onConnect', 'onSubscribe', 'context', 'onOperation'])( 55 | 'should bubble %s errors to the handler', 56 | async (hook) => { 57 | const err = new Error('hang hang'); 58 | const { handler } = createTHandler({ 59 | [hook]() { 60 | throw err; 61 | }, 62 | }); 63 | 64 | await expect( 65 | handler('POST', { 66 | headers: { 67 | accept: 'text/event-stream', 68 | }, 69 | body: { query: '{ getValue }' }, 70 | }), 71 | ).rejects.toBe(err); 72 | }, 73 | ); 74 | 75 | it.each(['onNext', 'onComplete'])( 76 | 'should bubble %s errors to the response body iterator', 77 | async (hook) => { 78 | const err = new Error('hang hang'); 79 | const { handler } = createTHandler({ 80 | [hook]() { 81 | throw err; 82 | }, 83 | }); 84 | 85 | const [stream, init] = await handler('POST', { 86 | headers: { 87 | accept: 'text/event-stream', 88 | }, 89 | body: { query: '{ getValue }' }, 90 | }); 91 | expect(init.status).toBe(200); 92 | assertAsyncGenerator(stream); 93 | 94 | await expect( 95 | (async () => { 96 | for await (const _ of stream) { 97 | // wait 98 | } 99 | })(), 100 | ).rejects.toBe(err); 101 | }, 102 | ); 103 | 104 | it('should bubble onNext errors to the response body iterator even if late', async () => { 105 | const err = new Error('hang hang'); 106 | let i = 0; 107 | const { handler } = createTHandler({ 108 | onNext() { 109 | i++; 110 | if (i > 3) { 111 | throw err; 112 | } 113 | }, 114 | }); 115 | 116 | const [stream, init] = await handler('POST', { 117 | headers: { 118 | accept: 'text/event-stream', 119 | }, 120 | body: { query: 'subscription { greetings }' }, 121 | }); 122 | expect(init.status).toBe(200); 123 | assertAsyncGenerator(stream); 124 | 125 | await expect( 126 | (async () => { 127 | for await (const _ of stream) { 128 | // wait 129 | } 130 | })(), 131 | ).rejects.toBe(err); 132 | }); 133 | 134 | it('should detect execution args in onSubscribe return value', async () => { 135 | const executeFn = vitest.fn(execute); 136 | const executionArgs = { 137 | schema, 138 | document: parse('{ getValue }'), 139 | contextValue: undefined, 140 | }; 141 | const { handler } = createTHandler({ 142 | execute: executeFn, 143 | onSubscribe() { 144 | return executionArgs; 145 | }, 146 | }); 147 | 148 | const [stream, init] = await handler('POST', { 149 | headers: { 150 | accept: 'text/event-stream', 151 | }, 152 | body: { query: 'subscription { greetings }' }, 153 | }); 154 | expect(init.status).toBe(200); 155 | assertAsyncGenerator(stream); 156 | 157 | await stream.next(); // ping 158 | expect(stream.next()).resolves.toMatchInlineSnapshot(` 159 | { 160 | "done": false, 161 | "value": "event: next 162 | data: {"data":{"getValue":"value"}} 163 | 164 | ", 165 | } 166 | `); 167 | 168 | expect(executeFn).toHaveBeenCalledWith(executionArgs); 169 | }); 170 | 171 | describe('single connection mode', () => { 172 | it('should respond with 404s when token was not previously registered', async () => { 173 | const { handler } = createTHandler(); 174 | 175 | let [body, init] = await handler('POST', { 176 | headers: { 177 | [TOKEN_HEADER_KEY]: '0', 178 | }, 179 | }); 180 | expect(init.status).toBe(404); 181 | expect(init.headers?.['content-type']).toBe( 182 | 'application/json; charset=utf-8', 183 | ); 184 | expect(body).toMatchInlineSnapshot( 185 | `"{"errors":[{"message":"Stream not found"}]}"`, 186 | ); 187 | 188 | const search = new URLSearchParams(); 189 | search.set('token', '0'); 190 | 191 | [body, init] = await handler('GET', { search }); 192 | expect(init.status).toBe(404); 193 | expect(init.headers?.['content-type']).toBe( 194 | 'application/json; charset=utf-8', 195 | ); 196 | expect(body).toMatchInlineSnapshot( 197 | `"{"errors":[{"message":"Stream not found"}]}"`, 198 | ); 199 | 200 | [body, init] = await handler('DELETE', { search }); 201 | expect(init.status).toBe(404); 202 | expect(init.headers?.['content-type']).toBe( 203 | 'application/json; charset=utf-8', 204 | ); 205 | expect(body).toMatchInlineSnapshot( 206 | `"{"errors":[{"message":"Stream not found"}]}"`, 207 | ); 208 | }); 209 | 210 | it('should get a token with PUT request', async () => { 211 | const { handler } = createTHandler({ 212 | authenticate() { 213 | return 'token'; 214 | }, 215 | }); 216 | 217 | const [body, init] = await handler('PUT'); 218 | expect(init.status).toBe(201); 219 | expect(init.headers?.['content-type']).toBe('text/plain; charset=utf-8'); 220 | expect(body).toBe('token'); 221 | }); 222 | 223 | it('should treat event streams without reservations as regular requests', async () => { 224 | const { handler } = createTHandler(); 225 | 226 | const [body, init] = await handler('GET', { 227 | headers: { 228 | [TOKEN_HEADER_KEY]: '0', 229 | accept: 'text/event-stream', 230 | }, 231 | }); 232 | expect(init.status).toBe(400); 233 | expect(body).toMatchInlineSnapshot( 234 | `"{"errors":[{"message":"Missing query"}]}"`, 235 | ); 236 | }); 237 | 238 | it('should allow event streams on reservations', async () => { 239 | const { handler } = createTHandler(); 240 | 241 | // token can be sent through headers 242 | let [token] = await handler('PUT'); 243 | assertString(token); 244 | let [stream, init] = await handler('GET', { 245 | headers: { 246 | [TOKEN_HEADER_KEY]: token, 247 | accept: 'text/event-stream', 248 | }, 249 | }); 250 | expect(init.status).toBe(200); 251 | assertAsyncGenerator(stream); 252 | stream.return(undefined); 253 | 254 | // token can be sent through url search param 255 | [token] = await handler('PUT'); 256 | assertString(token); 257 | const search = new URLSearchParams(); 258 | search.set('token', token); 259 | [stream, init] = await handler('GET', { 260 | search, 261 | headers: { 262 | accept: 'text/event-stream', 263 | }, 264 | }); 265 | expect(init.status).toBe(200); 266 | assertAsyncGenerator(stream); 267 | stream.return(undefined); 268 | }); 269 | 270 | it('should not allow operations without providing an operation id', async () => { 271 | const { handler } = createTHandler(); 272 | 273 | const [token] = await handler('PUT'); 274 | assertString(token); 275 | 276 | const [body, init] = await handler('POST', { 277 | headers: { [TOKEN_HEADER_KEY]: token }, 278 | body: { query: '{ getValue }' }, 279 | }); 280 | 281 | expect(init.status).toBe(400); 282 | expect(body).toMatchInlineSnapshot( 283 | `"{"errors":[{"message":"Operation ID is missing"}]}"`, 284 | ); 285 | }); 286 | 287 | it('should stream query operations to connected event stream', async () => { 288 | const { handler } = createTHandler(); 289 | 290 | const [token] = await handler('PUT'); 291 | assertString(token); 292 | 293 | const [stream] = await handler('POST', { 294 | headers: { 295 | [TOKEN_HEADER_KEY]: token, 296 | accept: 'text/event-stream', 297 | }, 298 | }); 299 | assertAsyncGenerator(stream); 300 | 301 | const [body, init] = await handler('POST', { 302 | headers: { [TOKEN_HEADER_KEY]: token }, 303 | body: { query: '{ getValue }', extensions: { operationId: '1' } }, 304 | }); 305 | expect(init.status).toBe(202); 306 | expect(body).toBeNull(); 307 | 308 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 309 | { 310 | "done": false, 311 | "value": ": 312 | 313 | ", 314 | } 315 | `); // ping 316 | 317 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 318 | { 319 | "done": false, 320 | "value": "event: next 321 | data: {"id":"1","payload":{"data":{"getValue":"value"}}} 322 | 323 | ", 324 | } 325 | `); 326 | 327 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 328 | { 329 | "done": false, 330 | "value": "event: complete 331 | data: {"id":"1"} 332 | 333 | ", 334 | } 335 | `); 336 | 337 | stream.return(undefined); 338 | }); 339 | 340 | it('should stream subscription operations to connected event stream', async () => { 341 | const { handler } = createTHandler(); 342 | 343 | const [token] = await handler('PUT'); 344 | assertString(token); 345 | 346 | const search = new URLSearchParams(); 347 | search.set('token', token); 348 | const [stream] = await handler('GET', { 349 | search, 350 | headers: { 351 | accept: 'text/event-stream', 352 | }, 353 | }); 354 | assertAsyncGenerator(stream); 355 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 356 | { 357 | "done": false, 358 | "value": ": 359 | 360 | ", 361 | } 362 | `); // ping 363 | 364 | const [_0, init] = await handler('POST', { 365 | headers: { 366 | [TOKEN_HEADER_KEY]: token, 367 | }, 368 | body: { 369 | query: 'subscription { greetings }', 370 | extensions: { operationId: '1' }, 371 | }, 372 | }); 373 | expect(init.status).toBe(202); 374 | 375 | for await (const msg of stream) { 376 | expect(msg).toMatchSnapshot(); 377 | 378 | if (msg.startsWith('event: complete')) { 379 | break; 380 | } 381 | } 382 | 383 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 384 | { 385 | "done": true, 386 | "value": undefined, 387 | } 388 | `); 389 | }); 390 | 391 | it.todo('should stream operations even if event stream connects late'); 392 | 393 | it('should report validation issues to operation request', async () => { 394 | const { handler } = createTHandler(); 395 | 396 | const [token] = await handler('PUT'); 397 | assertString(token); 398 | 399 | const search = new URLSearchParams(); 400 | search.set('token', token); 401 | const [stream] = await handler('GET', { 402 | search, 403 | headers: { 404 | accept: 'text/event-stream', 405 | }, 406 | }); 407 | assertAsyncGenerator(stream); 408 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 409 | { 410 | "done": false, 411 | "value": ": 412 | 413 | ", 414 | } 415 | `); // ping 416 | 417 | const [body, init] = await handler('POST', { 418 | headers: { 419 | [TOKEN_HEADER_KEY]: token, 420 | }, 421 | body: { 422 | query: 'subscription { notExists }', 423 | extensions: { operationId: '1' }, 424 | }, 425 | }); 426 | expect(init.status).toBe(400); 427 | expect(body).toMatchInlineSnapshot( 428 | `"{"errors":[{"message":"Cannot query field \\"notExists\\" on type \\"Subscription\\".","locations":[{"line":1,"column":16}]}]}"`, 429 | ); 430 | 431 | // stream remains open 432 | await expect( 433 | Promise.race([ 434 | stream.next(), 435 | await new Promise((resolve) => setTimeout(resolve, 20)), 436 | ]), 437 | ).resolves.toBeUndefined(); 438 | stream.return(undefined); 439 | }); 440 | 441 | it('should report subscription errors to the stream', async () => { 442 | const { handler } = createTHandler(); 443 | 444 | const [token] = await handler('PUT'); 445 | assertString(token); 446 | 447 | const search = new URLSearchParams(); 448 | search.set('token', token); 449 | const [stream] = await handler('GET', { 450 | search, 451 | headers: { 452 | accept: 'text/event-stream', 453 | }, 454 | }); 455 | assertAsyncGenerator(stream); 456 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 457 | { 458 | "done": false, 459 | "value": ": 460 | 461 | ", 462 | } 463 | `); // ping 464 | 465 | const [, init] = await handler('POST', { 466 | headers: { 467 | [TOKEN_HEADER_KEY]: token, 468 | }, 469 | body: { 470 | query: 'subscription { throwing }', 471 | extensions: { operationId: '1' }, 472 | }, 473 | }); 474 | expect(init.status).toBe(202); 475 | 476 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 477 | { 478 | "done": false, 479 | "value": "event: next 480 | data: {"id":"1","payload":{"errors":[{"message":"Kaboom!","locations":[{"line":1,"column":16}],"path":["throwing"]}]}} 481 | 482 | ", 483 | } 484 | `); 485 | 486 | stream.return(undefined); 487 | }); 488 | }); 489 | 490 | describe('distinct connections mode', () => { 491 | it('should stream query operations to connected event stream and then disconnect', async () => { 492 | const { handler } = createTHandler(); 493 | 494 | // GET 495 | const search = new URLSearchParams(); 496 | search.set('query', '{ getValue }'); 497 | let [stream, init] = await handler('GET', { 498 | search, 499 | headers: { 500 | accept: 'text/event-stream', 501 | }, 502 | }); 503 | expect(init.status).toBe(200); 504 | assertAsyncGenerator(stream); 505 | for await (const msg of stream) { 506 | expect(msg).toMatchSnapshot(); 507 | } 508 | 509 | // POST 510 | [stream, init] = await handler('POST', { 511 | headers: { 512 | accept: 'text/event-stream', 513 | }, 514 | body: { query: '{ getValue }' }, 515 | }); 516 | expect(init.status).toBe(200); 517 | assertAsyncGenerator(stream); 518 | for await (const msg of stream) { 519 | expect(msg).toMatchSnapshot(); 520 | } 521 | }); 522 | 523 | it('should stream subscription operations to connected event stream and then disconnect', async () => { 524 | const { handler } = createTHandler(); 525 | 526 | // GET 527 | const search = new URLSearchParams(); 528 | search.set('query', 'subscription { greetings }'); 529 | let [stream, init] = await handler('GET', { 530 | search, 531 | headers: { 532 | accept: 'text/event-stream', 533 | }, 534 | }); 535 | expect(init.status).toBe(200); 536 | assertAsyncGenerator(stream); 537 | for await (const msg of stream) { 538 | expect(msg).toMatchSnapshot(); 539 | } 540 | 541 | // POST 542 | [stream, init] = await handler('POST', { 543 | headers: { 544 | accept: 'text/event-stream', 545 | }, 546 | body: { query: 'subscription { greetings }' }, 547 | }); 548 | expect(init.status).toBe(200); 549 | assertAsyncGenerator(stream); 550 | for await (const msg of stream) { 551 | expect(msg).toMatchSnapshot(); 552 | } 553 | }); 554 | 555 | it('should report operation validation issues by streaming them', async () => { 556 | const { handler } = createTHandler(); 557 | 558 | const [stream, init] = await handler('POST', { 559 | headers: { 560 | accept: 'text/event-stream', 561 | }, 562 | body: { query: '{ notExists }' }, 563 | }); 564 | expect(init.status).toBe(200); 565 | assertAsyncGenerator(stream); 566 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 567 | { 568 | "done": false, 569 | "value": ": 570 | 571 | ", 572 | } 573 | `); // ping 574 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 575 | { 576 | "done": false, 577 | "value": "event: next 578 | data: {"errors":[{"message":"Cannot query field \\"notExists\\" on type \\"Query\\".","locations":[{"line":1,"column":3}]}]} 579 | 580 | ", 581 | } 582 | `); 583 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 584 | { 585 | "done": false, 586 | "value": "event: complete 587 | data: 588 | 589 | ", 590 | } 591 | `); 592 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 593 | { 594 | "done": true, 595 | "value": undefined, 596 | } 597 | `); 598 | }); 599 | 600 | it('should complete subscription operations after client disconnects', async () => { 601 | const { handler } = createTHandler(); 602 | 603 | const [stream, init] = await handler('POST', { 604 | headers: { 605 | accept: 'text/event-stream', 606 | }, 607 | body: { query: `subscription { ping(key: "${Math.random()}") }` }, 608 | }); 609 | expect(init.status).toBe(200); 610 | assertAsyncGenerator(stream); 611 | 612 | // simulate client disconnect in next tick 613 | setTimeout(() => stream.return(undefined), 0); 614 | 615 | for await (const _ of stream) { 616 | // loop must break for test to pass 617 | } 618 | }); 619 | 620 | it('should complete when stream ends before the subscription sent all events', async () => { 621 | const { handler } = createTHandler(); 622 | 623 | const [stream, init] = await handler('POST', { 624 | headers: { 625 | accept: 'text/event-stream', 626 | }, 627 | body: { query: `subscription { greetings }` }, 628 | }); 629 | expect(init.status).toBe(200); 630 | assertAsyncGenerator(stream); 631 | 632 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 633 | { 634 | "done": false, 635 | "value": ": 636 | 637 | ", 638 | } 639 | `); // ping 640 | 641 | for await (const msg of stream) { 642 | expect(msg).toMatchInlineSnapshot(` 643 | "event: next 644 | data: {"data":{"greetings":"Hi"}} 645 | 646 | " 647 | `); 648 | 649 | // return after first message (there are more) 650 | break; 651 | } 652 | 653 | // message was already queued up (pending), it's ok to have it 654 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 655 | { 656 | "done": false, 657 | "value": "event: next 658 | data: {"data":{"greetings":"Bonjour"}} 659 | 660 | ", 661 | } 662 | `); 663 | 664 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 665 | { 666 | "done": false, 667 | "value": "event: complete 668 | data: 669 | 670 | ", 671 | } 672 | `); 673 | await expect(stream.next()).resolves.toMatchInlineSnapshot(` 674 | { 675 | "done": true, 676 | "value": undefined, 677 | } 678 | `); 679 | }); 680 | }); 681 | -------------------------------------------------------------------------------- /tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { it } from 'vitest'; 2 | import { createParser } from '../src/parser'; 3 | 4 | const encoder = new TextEncoder(); 5 | 6 | it('should parse whole message', ({ expect }) => { 7 | const parse = createParser(); 8 | 9 | // with space 10 | expect( 11 | parse(encoder.encode('event: next\ndata: { "iAm": "data" }\n\n')), 12 | ).toMatchSnapshot(); 13 | 14 | // no space 15 | expect( 16 | parse(encoder.encode('event:next\ndata:{ "iAm": "data" }\n\n')), 17 | ).toMatchSnapshot(); 18 | 19 | // no data 20 | expect(parse(encoder.encode('event: complete\n\n'))).toMatchSnapshot(); 21 | }); 22 | 23 | it('should parse message with prepended ping', ({ expect }) => { 24 | const parse = createParser(); 25 | 26 | expect( 27 | parse(encoder.encode(':\n\nevent:next\ndata:{ "iAm": "data" }\n\n')), 28 | ).toMatchSnapshot(); 29 | }); 30 | 31 | it('should parse chunked message', ({ expect }) => { 32 | const parse = createParser(); 33 | 34 | parse(encoder.encode('even')); 35 | parse(encoder.encode('')); 36 | parse(encoder.encode('t: ne')); 37 | parse(encoder.encode('xt\nda')); 38 | parse(encoder.encode('ta:{')); 39 | parse(encoder.encode('')); 40 | parse(encoder.encode(' "iAm')); 41 | const msg = parse(encoder.encode('": "data" }\n\n')); 42 | 43 | expect(msg).toMatchSnapshot(); 44 | }); 45 | 46 | it('should parse message whose lines are separated by \\r\\n', ({ expect }) => { 47 | const parse = createParser(); 48 | 49 | const msg = parse( 50 | encoder.encode('event: next\r\ndata: { "iAm": "data" }\r\n\r\n'), 51 | ); 52 | 53 | expect(msg).toMatchSnapshot(); 54 | }); 55 | 56 | it('should ignore comments', ({ expect }) => { 57 | const parse = createParser(); 58 | 59 | const msg = parse( 60 | encoder.encode(': I am comment\nevent: next\ndata: { "iAm": "data" }\n\n'), 61 | ); 62 | 63 | expect(msg).toMatchSnapshot(); 64 | }); 65 | 66 | it('should accept valid events only', ({ expect }) => { 67 | expect(() => { 68 | const parse = createParser(); 69 | parse(encoder.encode('event: done\ndata: {}\n\n')); 70 | }).toThrowErrorMatchingSnapshot(); 71 | 72 | expect(() => { 73 | const parse = createParser(); 74 | parse(encoder.encode('event: value\ndata: {}\n\n')); 75 | }).toThrowErrorMatchingSnapshot(); 76 | 77 | expect(() => { 78 | const parse = createParser(); 79 | parse(encoder.encode('event: next\ndata: {}\n\n')); 80 | }).not.toThrow(); 81 | 82 | expect(() => { 83 | const parse = createParser(); 84 | parse(encoder.encode('event: complete\ndata: {}\n\n')); 85 | }).not.toThrow(); 86 | }); 87 | 88 | it('should ignore server pings', ({ expect }) => { 89 | const parse = createParser(); 90 | 91 | expect(parse(encoder.encode(':\n\n'))).toEqual([]); 92 | }); 93 | 94 | it('should parse multiple messages from one chunk', ({ expect }) => { 95 | const parse = createParser(); 96 | 97 | expect( 98 | parse( 99 | encoder.encode( 100 | 'event: next\ndata: {}\n\n' + 'event: next\ndata: { "no": "data" }\n\n', 101 | ), 102 | ), 103 | ).toMatchSnapshot(); 104 | 105 | expect( 106 | parse( 107 | encoder.encode( 108 | 'event: next\ndata: { "almost": "done" }\n\n' + 109 | 'event: complete\ndata: {}\n\n', 110 | ), 111 | ), 112 | ).toMatchSnapshot(); 113 | }); 114 | 115 | it('should parse with new lines in the data json', ({ expect }) => { 116 | const parse = createParser(); 117 | 118 | expect( 119 | parse( 120 | encoder.encode( 121 | `event: next\ndata: ${JSON.stringify({ multi: 'li\n\ne' })}\n\n`, 122 | ), 123 | ), 124 | ).toMatchInlineSnapshot(` 125 | [ 126 | { 127 | "data": { 128 | "multi": "li 129 | 130 | e", 131 | }, 132 | "event": "next", 133 | }, 134 | ] 135 | `); 136 | }); 137 | -------------------------------------------------------------------------------- /tests/use.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, it, expect } from 'vitest'; 2 | import net from 'net'; 3 | import http from 'http'; 4 | import express from 'express'; 5 | import Fastify from 'fastify'; 6 | import Koa from 'koa'; 7 | import mount from 'koa-mount'; 8 | import bodyparser from 'koa-bodyparser'; 9 | import { schema, pong } from './fixtures/simple'; 10 | 11 | import { createHandler as createHttpHandler } from '../src/use/http'; 12 | import { createHandler as createExpressHandler } from '../src/use/express'; 13 | import { createHandler as createFastifyHandler } from '../src/use/fastify'; 14 | import { createHandler as createKoaHandler } from '../src/use/koa'; 15 | 16 | type Dispose = () => Promise; 17 | 18 | const leftovers: Dispose[] = []; 19 | afterAll(async () => { 20 | while (leftovers.length > 0) { 21 | await leftovers.pop()?.(); 22 | } 23 | }); 24 | 25 | function makeDisposeForServer(server: http.Server): Dispose { 26 | const sockets = new Set(); 27 | server.on('connection', (socket) => { 28 | sockets.add(socket); 29 | socket.once('close', () => sockets.delete(socket)); 30 | }); 31 | 32 | const dispose = async () => { 33 | for (const socket of sockets) { 34 | socket.destroy(); 35 | } 36 | await new Promise((resolve) => server.close(() => resolve())); 37 | }; 38 | leftovers.push(dispose); 39 | 40 | return dispose; 41 | } 42 | 43 | function getStream(body: ReadableStream | null) { 44 | if (!body) { 45 | throw new Error('body cannot be empty'); 46 | } 47 | const reader = body.getReader(); 48 | return { 49 | async next(): Promise<{ done: true } | { done: false; value: string }> { 50 | const chunk = await reader.read(); 51 | if (chunk.done) { 52 | return { 53 | done: true, 54 | }; 55 | } 56 | return { 57 | done: false, 58 | value: Buffer.from(chunk.value).toString(), 59 | }; 60 | }, 61 | }; 62 | } 63 | 64 | it.each([ 65 | { 66 | name: 'http', 67 | startServer: async () => { 68 | const server = http.createServer(createHttpHandler({ schema })); 69 | server.listen(0); 70 | const port = (server.address() as net.AddressInfo).port; 71 | return [ 72 | `http://localhost:${port}`, 73 | makeDisposeForServer(server), 74 | ] as const; 75 | }, 76 | }, 77 | { 78 | name: 'express', 79 | startServer: async () => { 80 | const app = express(); 81 | app.all('/', createExpressHandler({ schema })); 82 | const server = app.listen(0); 83 | const port = (server.address() as net.AddressInfo).port; 84 | return [ 85 | `http://localhost:${port}`, 86 | makeDisposeForServer(server), 87 | ] as const; 88 | }, 89 | }, 90 | { 91 | name: 'fastify', 92 | startServer: async () => { 93 | const fastify = Fastify(); 94 | fastify.all('/', createFastifyHandler({ schema })); 95 | const url = await fastify.listen({ port: 0 }); 96 | return [url, makeDisposeForServer(fastify.server)] as const; 97 | }, 98 | }, 99 | { 100 | name: 'koa', 101 | startServer: async () => { 102 | const app = new Koa(); 103 | app.use(mount('/', createKoaHandler({ schema }))); 104 | const server = app.listen({ port: 0 }); 105 | const port = (server.address() as net.AddressInfo).port; 106 | return [ 107 | `http://localhost:${port}`, 108 | makeDisposeForServer(server), 109 | ] as const; 110 | }, 111 | }, 112 | { 113 | name: 'koa with bodyparser', 114 | startServer: async () => { 115 | const app = new Koa(); 116 | app.use(bodyparser()); 117 | app.use(mount('/', createKoaHandler({ schema }))); 118 | const server = app.listen({ port: 0 }); 119 | const port = (server.address() as net.AddressInfo).port; 120 | return [ 121 | `http://localhost:${port}`, 122 | makeDisposeForServer(server), 123 | ] as const; 124 | }, 125 | }, 126 | // no need to test fetch because the handler is pure (gets request, returns response) 127 | // { 128 | // name: 'fetch', 129 | // startServer: async () => { 130 | // // 131 | // }, 132 | // }, 133 | ])( 134 | 'should not write to stream after closed with $name handler', 135 | async ({ startServer }) => { 136 | const [url] = await startServer(); 137 | 138 | const pingKey = Math.random().toString(); 139 | 140 | const ctrl = new AbortController(); 141 | const res = await fetch(url, { 142 | signal: ctrl.signal, 143 | method: 'POST', 144 | headers: { 145 | accept: 'text/event-stream', 146 | 'content-type': 'application/json', 147 | }, 148 | body: JSON.stringify({ 149 | query: `subscription { ping(key: "${pingKey}") }`, 150 | }), 151 | }); 152 | 153 | const reader = getStream(res.body); 154 | 155 | await expect(reader.next()).resolves.toBeDefined(); // keepalive 156 | 157 | pong(pingKey); 158 | await expect(reader.next()).resolves.toEqual({ 159 | done: false, 160 | value: `event: next 161 | data: {"data":{"ping":"pong"}} 162 | 163 | `, 164 | }); 165 | 166 | ctrl.abort(); 167 | await expect(reader.next()).rejects.toThrowError( 168 | 'This operation was aborted', 169 | ); 170 | 171 | // wait for one tick 172 | await new Promise((resolve) => setTimeout(resolve, 0)); 173 | 174 | // issue ping 175 | pong(pingKey); 176 | 177 | // nothing should explode 178 | }, 179 | ); 180 | 181 | it("should include middleware headers with 'fastify' handler", async () => { 182 | const fastify = Fastify(); 183 | 184 | fastify.addHook('onRequest', (_, reply, done) => { 185 | reply.header('x-custom', 'cust'); 186 | done(); 187 | }); 188 | fastify.all( 189 | '/', 190 | createFastifyHandler({ 191 | schema, 192 | onConnect(req) { 193 | expect(req.headers.get('x-custom')).toBe('cust'); 194 | }, 195 | }), 196 | ); 197 | 198 | const url = await fastify.listen({ port: 0 }); 199 | makeDisposeForServer(fastify.server); 200 | 201 | const res = await fetch(url, { 202 | method: 'POST', 203 | headers: { 204 | accept: 'text/event-stream', 205 | 'content-type': 'application/json', 206 | }, 207 | body: JSON.stringify({ 208 | query: '{ getValue }', 209 | }), 210 | }); 211 | 212 | expect(res.ok).toBeTruthy(); 213 | expect(res.headers.get('x-custom')).toBe('cust'); 214 | }); 215 | -------------------------------------------------------------------------------- /tests/utils/testkit.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { HandlerOptions, OperationContext } from '../../src/handler'; 3 | 4 | export function sleep(ms: number) { 5 | return new Promise((resolve) => setTimeout(resolve, ms)); 6 | } 7 | 8 | export type OnOperationArgs< 9 | RequestRaw = unknown, 10 | RequestContext = unknown, 11 | Context extends OperationContext = undefined, 12 | > = Parameters< 13 | NonNullable< 14 | HandlerOptions['onOperation'] 15 | > 16 | >; 17 | 18 | export interface TestKit< 19 | RequestRaw = unknown, 20 | RequestContext = unknown, 21 | Context extends OperationContext = undefined, 22 | > { 23 | waitForOperation(): Promise< 24 | OnOperationArgs 25 | >; 26 | } 27 | 28 | export function injectTestKit< 29 | RequestRaw = unknown, 30 | RequestContext = unknown, 31 | Context extends OperationContext = undefined, 32 | >( 33 | opts: Partial> = {}, 34 | ): TestKit { 35 | const onOperation = 36 | queue>(); 37 | const origOnOperation = opts.onOperation; 38 | opts.onOperation = async (...args) => { 39 | onOperation.add(args); 40 | return origOnOperation?.(...args); 41 | }; 42 | 43 | return { 44 | waitForOperation() { 45 | return onOperation.next(); 46 | }, 47 | }; 48 | } 49 | 50 | export function queue(): { 51 | next(): Promise; 52 | add(val: T): void; 53 | } { 54 | const sy = Symbol(); 55 | const emitter = new EventEmitter(); 56 | const queue: T[] = []; 57 | return { 58 | async next() { 59 | while (queue.length) { 60 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- There will be something. 61 | return queue.shift()!; 62 | } 63 | return new Promise((resolve) => { 64 | emitter.once(sy, () => { 65 | resolve( 66 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- There will be something. 67 | queue.shift()!, 68 | ); 69 | }); 70 | }); 71 | }, 72 | add(val) { 73 | queue.push(val); 74 | emitter.emit(sy); 75 | }, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /tests/utils/tfetch.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '../fixtures/simple'; 2 | import { HandlerOptions } from '../../src/handler'; 3 | import { createHandler, RequestContext } from '../../src/use/fetch'; 4 | import { injectTestKit, queue, TestKit } from './testkit'; 5 | 6 | export interface TFetch extends TestKit { 7 | fetch(input: RequestInfo, init?: RequestInit): Promise; 8 | waitForRequest(): Promise; 9 | dispose(): Promise; 10 | } 11 | 12 | export function createTFetch( 13 | opts: Partial> = {}, 14 | ): TFetch { 15 | const testkit = injectTestKit(opts); 16 | const onRequest = queue(); 17 | const handler = createHandler({ 18 | schema, 19 | ...opts, 20 | }); 21 | const ctrls: AbortController[] = []; 22 | return { 23 | ...testkit, 24 | fetch: (input, init) => { 25 | const ctrl = new AbortController(); 26 | ctrls.push(ctrl); 27 | init?.signal?.addEventListener('abort', () => ctrl.abort()); 28 | const req = new Request(input, { 29 | ...init, 30 | signal: ctrl.signal, 31 | }); 32 | onRequest.add(req); 33 | return handler(req); 34 | }, 35 | waitForRequest() { 36 | return onRequest.next(); 37 | }, 38 | async dispose() { 39 | return new Promise((resolve) => { 40 | // dispose in next tick to allow pending fetches to complete 41 | setTimeout(() => { 42 | ctrls.forEach((ctrl) => ctrl.abort()); 43 | // finally resolve in next tick to flush the aborts 44 | setTimeout(resolve, 0); 45 | }, 0); 46 | }); 47 | }, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /tests/utils/thandler.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '../fixtures/simple'; 2 | import { 3 | createHandler, 4 | HandlerOptions, 5 | Response, 6 | Request, 7 | } from '../../src/handler'; 8 | import { isAsyncGenerator, RequestParams } from '../../src/common'; 9 | import { TestKit, injectTestKit, queue } from './testkit'; 10 | 11 | export interface THandler extends TestKit { 12 | handler( 13 | method: 'GET' | 'POST' | 'PUT' | 'DELETE', 14 | req?: { 15 | search?: URLSearchParams; 16 | headers?: Record; 17 | body?: RequestParams; 18 | }, 19 | ): Promise; 20 | waitForRequest(): Promise; 21 | } 22 | 23 | export function createTHandler(opts: Partial = {}): THandler { 24 | const testkit = injectTestKit(opts); 25 | const onRequest = queue(); 26 | const handler = createHandler({ 27 | schema, 28 | ...opts, 29 | }); 30 | return { 31 | ...testkit, 32 | handler: (method, treq) => { 33 | let url = 'http://localhost'; 34 | 35 | const search = treq?.search?.toString(); 36 | if (search) url += `?${search}`; 37 | 38 | const headers: Record = { 39 | 'content-type': treq?.body 40 | ? 'application/json; charset=utf-8' 41 | : undefined, 42 | ...treq?.headers, 43 | }; 44 | 45 | const body = (treq?.body as unknown as Record) || null; 46 | 47 | const req = { 48 | method, 49 | url, 50 | headers: { 51 | get(key: string) { 52 | return headers[key] || null; 53 | }, 54 | }, 55 | body, 56 | raw: null, 57 | context: null, 58 | }; 59 | onRequest.add(req); 60 | return handler(req); 61 | }, 62 | waitForRequest() { 63 | return onRequest.next(); 64 | }, 65 | }; 66 | } 67 | 68 | export function assertString(val: unknown): asserts val is string { 69 | if (typeof val !== 'string') { 70 | throw new Error( 71 | `Expected val to be a "string", got "${JSON.stringify(val)}"`, 72 | ); 73 | } 74 | } 75 | 76 | export function assertAsyncGenerator( 77 | val: unknown, 78 | ): asserts val is AsyncGenerator { 79 | if (!isAsyncGenerator(val)) { 80 | throw new Error( 81 | `Expected val to be an "AsyncGenerator", got "${JSON.stringify( 82 | val, 83 | )}"`, 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/utils/tserver.ts: -------------------------------------------------------------------------------- 1 | import { afterAll } from 'vitest'; 2 | import net from 'net'; 3 | import http from 'http'; 4 | 5 | const leftovers: Dispose[] = []; 6 | afterAll(async () => { 7 | while (leftovers.length > 0) { 8 | await leftovers.pop()?.(); 9 | } 10 | }); 11 | 12 | export type Dispose = () => Promise; 13 | 14 | export function startDisposableServer( 15 | server: http.Server, 16 | ): [url: string, port: number, dispose: Dispose] { 17 | const sockets = new Set(); 18 | server.on('connection', (socket) => { 19 | sockets.add(socket); 20 | socket.once('close', () => sockets.delete(socket)); 21 | }); 22 | 23 | const dispose = async () => { 24 | for (const socket of sockets) { 25 | socket.destroy(); 26 | } 27 | await new Promise((resolve) => server.close(() => resolve())); 28 | }; 29 | leftovers.push(dispose); 30 | 31 | server.listen(0); 32 | 33 | const { port } = server.address() as net.AddressInfo; 34 | const url = `http://localhost:${port}`; 35 | 36 | return [url, port, dispose]; 37 | } 38 | -------------------------------------------------------------------------------- /tests/utils/tsubscribe.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionResult } from 'graphql'; 2 | import { EventEmitter } from 'events'; 3 | import { Client } from '../../src/client'; 4 | import { RequestParams } from '../../src/common'; 5 | 6 | interface TSubscribe { 7 | waitForNext: () => Promise>; 8 | waitForError: () => Promise; 9 | throwOnError: () => Promise; 10 | waitForComplete: () => Promise; 11 | dispose: () => void; 12 | } 13 | 14 | export function tsubscribe< 15 | SingleConnection extends boolean = false, 16 | T = unknown, 17 | >(client: Client, payload: RequestParams): TSubscribe { 18 | const emitter = new EventEmitter(); 19 | const results: ExecutionResult[] = []; 20 | let error: unknown, 21 | completed = false; 22 | const dispose = client.subscribe(payload, { 23 | next: (value) => { 24 | results.push(value); 25 | emitter.emit('next'); 26 | }, 27 | error: (err) => { 28 | error = err; 29 | emitter.emit('err'); 30 | emitter.removeAllListeners(); 31 | }, 32 | complete: () => { 33 | completed = true; 34 | emitter.emit('complete'); 35 | emitter.removeAllListeners(); 36 | }, 37 | }); 38 | function waitForError() { 39 | return new Promise((resolve) => { 40 | function done() { 41 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 42 | resolve(error); 43 | } 44 | if (error) { 45 | done(); 46 | } else { 47 | emitter.once('err', done); 48 | } 49 | }); 50 | } 51 | return { 52 | waitForNext() { 53 | return new Promise((resolve) => { 54 | function done() { 55 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 56 | resolve(results.shift()!); 57 | } 58 | if (results.length) { 59 | done(); 60 | } else { 61 | emitter.once('next', done); 62 | } 63 | }); 64 | }, 65 | waitForError, 66 | throwOnError: () => 67 | waitForError().then((err) => { 68 | throw err; 69 | }), 70 | waitForComplete() { 71 | return new Promise((resolve) => { 72 | function done() { 73 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 74 | resolve(); 75 | } 76 | if (completed) { 77 | done(); 78 | } else { 79 | emitter.once('complete', done); 80 | } 81 | }); 82 | }, 83 | dispose, 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "rootDir": "./src", 6 | "outDir": "./lib", 7 | "declaration": false // already built by `tsconfig.esm.json` 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "es2015", 5 | "rootDir": "./src", 6 | "outDir": "./lib", 7 | "declaration": true 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es2019", 5 | "strict": true, 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "useUnknownInCatchVariables": false, 11 | "skipLibCheck": true, 12 | "checkJs": true, 13 | "jsx": "preserve" 14 | }, 15 | "include": [ 16 | "src", 17 | "tests", 18 | "scripts", 19 | "rollup.config.ts", 20 | "babel.config.js", 21 | "jest.config.js", 22 | ".eslintrc.js", 23 | "typedoc.js" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {Partial & Partial} 3 | */ 4 | const opts = { 5 | entryPointStrategy: 'expand', 6 | out: './website/src/pages/docs', 7 | readme: 'none', 8 | plugin: ['typedoc-plugin-markdown'], 9 | excludeExternals: true, 10 | excludePrivate: true, 11 | categorizeByGroup: false, // removes redundant category names in matching modules 12 | githubPages: false, 13 | exclude: ['**/index.ts', '**/utils.ts', '**/parser.ts', '**/tests/**/*'], 14 | entryDocument: 'index.md', 15 | hideInPageTOC: true, 16 | publicPath: '/docs/', 17 | hideBreadcrumbs: true, 18 | }; 19 | module.exports = opts; 20 | -------------------------------------------------------------------------------- /website/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /website/next-sitemap.config.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('next-sitemap').IConfig} 3 | */ 4 | const opts = { 5 | outDir: 'out', // next has to be built first 6 | siteUrl: process.env.SITE_URL || 'https://the-guild.dev/graphql/sse', 7 | generateIndexSitemap: false, 8 | exclude: ['*/_meta'], 9 | output: 'export', 10 | }; 11 | module.exports = opts; 12 | -------------------------------------------------------------------------------- /website/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-expect-error the guild's next config doesnt have types 2 | import { withGuildDocs } from '@theguild/components/next.config'; 3 | export default withGuildDocs({ 4 | output: 'export', 5 | }); 6 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "website", 4 | "scripts": { 5 | "check:format": "echo 'Check is done in root...'", 6 | "check:lint": "echo 'Check is done in root...'", 7 | "check:type": "tsc --noEmit", 8 | "start": "next", 9 | "build": "next build && next-sitemap --config next-sitemap.config.cjs" 10 | }, 11 | "dependencies": { 12 | "@theguild/components": "^6.4.0", 13 | "clsx": "^2.1.0", 14 | "next": "^14.1.4", 15 | "next-sitemap": "^4.2.3", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-icons": "^5.0.1" 19 | }, 20 | "devDependencies": { 21 | "@theguild/algolia": "^2.1.0", 22 | "@theguild/tailwind-config": "^0.3.1", 23 | "@types/react": "^18.2.48", 24 | "typescript": "^5.3.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /website/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | // @ts-expect-error module does exist 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const config = require('@theguild/tailwind-config/postcss.config'); 4 | module.exports = config; 5 | -------------------------------------------------------------------------------- /website/scripts/algolia-sync.mjs: -------------------------------------------------------------------------------- 1 | import { indexToAlgolia } from '@theguild/algolia'; 2 | 3 | const source = 'SSE'; 4 | 5 | const domain = process.env.SITE_URL; 6 | if (!domain) { 7 | throw new Error('Missing domain'); 8 | } 9 | 10 | indexToAlgolia({ 11 | nextra: { 12 | docsBaseDir: 'src/pages/', 13 | source, 14 | domain, 15 | sitemapXmlPath: 'out/sitemap.xml', 16 | }, 17 | source, 18 | domain, 19 | sitemapXmlPath: 'out/sitemap.xml', 20 | lockfilePath: 'algolia-lockfile.json', 21 | dryMode: process.env.ALGOLIA_DRY_RUN === 'true', 22 | }); 23 | -------------------------------------------------------------------------------- /website/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import { FiGithub, FiPlay } from 'react-icons/fi'; 4 | import { 5 | TbArrowMergeBoth, 6 | TbArrowUpCircle, 7 | TbChefHat, 8 | TbPlugConnected, 9 | TbRepeat, 10 | TbServer, 11 | } from 'react-icons/tb'; 12 | import { BsBoxSeam } from 'react-icons/bs'; 13 | import { FaCreativeCommonsZero } from 'react-icons/fa'; 14 | import { Anchor, Image } from '@theguild/components'; 15 | import Link from 'next/link'; 16 | 17 | const classes = { 18 | button: { 19 | gray: 'inline-block bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 text-gray-600 px-6 py-3 rounded-lg font-medium shadow-sm', 20 | emerald: 21 | 'inline-block bg-emerald-200 hover:bg-emerald-300 dark:bg-emerald-800 dark:text-emerald-300 dark:hover:bg-emerald-700 text-emerald-600 px-6 py-3 rounded-lg font-medium shadow-sm', 22 | }, 23 | link: { 24 | blue: 'text-blue-600 hover:text-blue-800 dark:hover:text-blue-400', 25 | emerald: 26 | 'text-emerald-600 hover:text-emerald-800 dark:hover:text-emerald-400', 27 | }, 28 | }; 29 | 30 | const gradients: [string, string][] = [ 31 | ['#06b6d4', '#0e7490'], // cyan 32 | ['#f97316', '#c2410c'], // orange 33 | ['#10b981', '#047857'], // emerald 34 | ]; 35 | 36 | function pickGradient(i: number) { 37 | const gradient = gradients[i % gradients.length]; 38 | if (!gradient) { 39 | throw new Error('No gradient found'); 40 | } 41 | return gradient; 42 | } 43 | 44 | export function Index() { 45 | return ( 46 | <> 47 | 48 |
49 |

50 | GraphQL SSE 51 |

52 |

53 | Zero-dependency, HTTP/1 safe, simple, spec compliant server and 54 | client 55 |

56 |
57 | 64 | Get Started 65 | 66 | 73 | Recipes 74 | 75 | 82 | GitHub 83 | 84 |
85 |
86 |
87 | 88 | 89 | 94 |

95 | As a reference implementation, the library is fully compliant 96 | with the{' '} 97 | 101 | GraphQL over SSE (Server-Sent Events) spec 102 | 103 |

104 |
105 | , 110 | title: 'Single connection mode', 111 | description: 112 | 'Safe for HTTP/1 servers and subscription heavy apps', 113 | }, 114 | { 115 | icon: , 116 | title: 'Distinct connection mode', 117 | description: 118 | 'Each connection is a subscription on its own', 119 | }, 120 | { 121 | icon: , 122 | title: 'Upgrade over regular SSE', 123 | description: 124 | 'Contains improvements for clarity and stability', 125 | }, 126 | ]} 127 | /> 128 |
129 | 130 | } 131 | /> 132 |
133 | 134 | 135 | 140 |

141 | Single library, but both the server and the client is included 142 |

143 |
144 | , 149 | title: 'Zero-dependency', 150 | description: 'Library contains everything it needs', 151 | }, 152 | { 153 | link: 'https://bundlephobia.com/package/graphql-sse', 154 | icon: , 155 | title: 'Still small bundle', 156 | description: 157 | 'Thanks to tree-shaking and module separation', 158 | }, 159 | { 160 | icon: , 161 | title: 'Smart retry strategies', 162 | description: 163 | 'Customize the reconnection process to your desires', 164 | }, 165 | { 166 | icon: , 167 | title: 'Run everywhere', 168 | description: 169 | 'Completely server agnostic, run them both anywhere', 170 | }, 171 | ]} 172 | /> 173 |
174 | 175 | } 176 | /> 177 |
178 | 179 | 180 | } 183 | title="Recipes" 184 | description={ 185 |
186 |

187 | Short and concise code snippets for starting with common 188 | use-cases 189 |

190 |
191 |
    192 |
  • 193 | 197 | Client usage with AsyncIterator 198 | 199 |
  • 200 |
  • 201 | 205 | Client usage with Relay 206 | 207 |
  • 208 |
  • 209 | 213 | Server handler usage with custom context value 214 | 215 |
  • 216 |
  • 217 | 221 | Server handler and client usage with persisted queries 222 | 223 |
  • 224 |
  • 225 | 232 | And many more... 233 | 234 |
  • 235 |
236 |
237 |
238 | } 239 | /> 240 |
241 | 242 | ); 243 | } 244 | 245 | function Wrapper({ children }: { children: React.ReactNode }) { 246 | return ( 247 |
257 | {children} 258 |
259 | ); 260 | } 261 | 262 | function Feature({ 263 | icon, 264 | title, 265 | description, 266 | children, 267 | image, 268 | gradient, 269 | flipped, 270 | }: { 271 | children?: React.ReactNode; 272 | icon?: React.ReactNode; 273 | title: string; 274 | description: React.ReactNode; 275 | highlights?: { 276 | title: string; 277 | description: React.ReactNode; 278 | icon?: React.ReactNode; 279 | }[]; 280 | image?: string; 281 | gradient: number; 282 | flipped?: boolean; 283 | }) { 284 | const [start, end] = pickGradient(gradient); 285 | 286 | return ( 287 |
288 |
294 |
300 | {icon && ( 301 |
302 | {icon} 303 |
304 | )} 305 |

314 | {title} 315 |

316 |
317 | {description} 318 |
319 |
320 | {image && ( 321 |
327 | {title} 334 |
335 | )} 336 |
337 | {children} 338 |
339 | ); 340 | } 341 | 342 | function FeatureHighlights({ 343 | highlights, 344 | textColor, 345 | }: { 346 | textColor?: string; 347 | highlights?: { 348 | title: string; 349 | description: React.ReactNode; 350 | icon?: React.ReactNode; 351 | link?: string; 352 | }[]; 353 | }) { 354 | if (!Array.isArray(highlights)) { 355 | return null; 356 | } 357 | 358 | return ( 359 | <> 360 | {highlights.map(({ title, description, icon, link }) => { 361 | const Comp = link ? Anchor : 'div'; 362 | return ( 363 | 369 | {icon && ( 370 |
374 | {icon} 375 |
376 | )} 377 |
378 |

382 | {title} 383 |

384 |

390 | {description} 391 |

392 |
393 |
394 | ); 395 | })} 396 | 397 | ); 398 | } 399 | -------------------------------------------------------------------------------- /website/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@theguild/components/style.css'; 2 | import { AppProps } from 'next/app'; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /website/src/pages/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: { 3 | display: 'hidden', 4 | theme: { 5 | layout: 'raw', 6 | }, 7 | }, 8 | 'get-started': 'Get Started', 9 | recipes: 'Recipes', 10 | docs: 'Documentation', 11 | }; 12 | -------------------------------------------------------------------------------- /website/src/pages/get-started.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from '@theguild/components'; 2 | 3 | # Get Started 4 | 5 | Zero-dependency, HTTP/1 safe, simple, [GraphQL over Server-Sent Events spec](https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md) server and client. 6 | 7 | For detailed documentation and API reference, check out the [documentation page](/docs). If you need short and concise code snippets for starting with common use-cases, the [recipes page](/recipes) is the right place for you. 8 | 9 | ## Install 10 | 11 | ```sh npm2yarn 12 | npm i graphql-sse 13 | ``` 14 | 15 | ## Create a GraphQL schema 16 | 17 | ```js 18 | import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql'; 19 | 20 | /** 21 | * Construct a GraphQL schema and define the necessary resolvers. 22 | * 23 | * type Query { 24 | * hello: String 25 | * } 26 | * type Subscription { 27 | * greetings: String 28 | * } 29 | */ 30 | const schema = new GraphQLSchema({ 31 | query: new GraphQLObjectType({ 32 | name: 'Query', 33 | fields: { 34 | hello: { 35 | type: GraphQLString, 36 | resolve: () => 'world', 37 | }, 38 | }, 39 | }), 40 | subscription: new GraphQLObjectType({ 41 | name: 'Subscription', 42 | fields: { 43 | greetings: { 44 | type: GraphQLString, 45 | subscribe: async function* () { 46 | for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { 47 | yield { greetings: hi }; 48 | } 49 | }, 50 | }, 51 | }, 52 | }), 53 | }); 54 | ``` 55 | 56 | ## Start the server 57 | 58 | ### With [`http`](https://nodejs.org/api/http.html) 59 | 60 | ```js 61 | import http from 'http'; 62 | import { createHandler } from 'graphql-sse/lib/use/http'; 63 | import { schema } from './previous-step'; 64 | 65 | // Create the GraphQL over SSE handler 66 | const handler = createHandler({ schema }); 67 | 68 | // Create an HTTP server using the handler on `/graphql/stream` 69 | const server = http.createServer((req, res) => { 70 | if (req.url.startsWith('/graphql/stream')) { 71 | return handler(req, res); 72 | } 73 | res.writeHead(404).end(); 74 | }); 75 | 76 | server.listen(4000); 77 | console.log('Listening to port 4000'); 78 | ``` 79 | 80 | ### With [`http2`](https://nodejs.org/api/http2.html) 81 | 82 | 83 | Browsers might complain about self-signed SSL/TLS certificates. [Help can be 84 | found on 85 | StackOverflow.](https://stackoverflow.com/questions/7580508/getting-chrome-to-accept-self-signed-localhost-certificate) 86 | 87 | 88 | ```shell 89 | $ openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ 90 | -keyout localhost-privkey.pem -out localhost-cert.pem 91 | ``` 92 | 93 | ```js 94 | import http2 from 'http2'; 95 | import { createHandler } from 'graphql-sse/lib/use/http2'; 96 | import { schema } from './previous-step'; 97 | 98 | // Create the GraphQL over SSE handler 99 | const handler = createHandler({ schema }); 100 | 101 | // Create an HTTP/2 server using the handler on `/graphql/stream` 102 | const server = http2.createSecureServer( 103 | { 104 | key: fs.readFileSync('localhost-privkey.pem'), 105 | cert: fs.readFileSync('localhost-cert.pem'), 106 | }, 107 | (req, res) => { 108 | if (req.url.startsWith('/graphql/stream')) { 109 | return handler(req, res); 110 | } 111 | return res.writeHead(404).end(); 112 | }, 113 | ); 114 | 115 | server.listen(4000); 116 | console.log('Listening to port 4000'); 117 | ``` 118 | 119 | ### With [`express`](https://expressjs.com) 120 | 121 | ```js 122 | import express from 'express'; // yarn add express 123 | import { createHandler } from 'graphql-sse/lib/use/express'; 124 | import { schema } from './previous-step'; 125 | 126 | // Create the GraphQL over SSE handler 127 | const handler = createHandler({ schema }); 128 | 129 | // Create an express app 130 | const app = express(); 131 | 132 | // Serve all methods on `/graphql/stream` 133 | app.use('/graphql/stream', handler); 134 | 135 | server.listen(4000); 136 | console.log('Listening to port 4000'); 137 | ``` 138 | 139 | ### With [`fastify`](https://www.fastify.io) 140 | 141 | ```js 142 | import Fastify from 'fastify'; // yarn add fastify 143 | import { createHandler } from 'graphql-sse/lib/use/fastify'; 144 | 145 | // Create the GraphQL over SSE handler 146 | const handler = createHandler({ schema }); 147 | 148 | // Create a fastify app 149 | const fastify = Fastify(); 150 | 151 | // Serve all methods on `/graphql/stream` 152 | fastify.all('/graphql/stream', handler); 153 | 154 | fastify.listen({ port: 4000 }); 155 | console.log('Listening to port 4000'); 156 | ``` 157 | 158 | #### With [`Koa`](https://koajs.com/) 159 | 160 | ```js 161 | import Koa from 'koa'; // yarn add koa 162 | import mount from 'koa-mount'; // yarn add koa-mount 163 | import { createHandler } from 'graphql-sse/lib/use/koa'; 164 | import { schema } from './previous-step'; 165 | 166 | // Create a Koa app 167 | const app = new Koa(); 168 | 169 | // Serve all methods on `/graphql/stream` 170 | app.use(mount('/graphql/stream', createHandler({ schema }))); 171 | 172 | app.listen({ port: 4000 }); 173 | console.log('Listening to port 4000'); 174 | ``` 175 | 176 | ### With [`Deno`](https://deno.land/) 177 | 178 | ```ts 179 | import { serve } from 'https://deno.land/std/http/server.ts'; 180 | import { createHandler } from 'https://esm.sh/graphql-sse/lib/use/fetch'; 181 | import { schema } from './previous-step'; 182 | 183 | // Create the GraphQL over SSE native fetch handler 184 | const handler = createHandler({ schema }); 185 | 186 | // Serve on `/graphql/stream` using the handler 187 | await serve( 188 | (req: Request) => { 189 | const [path, _search] = req.url.split('?'); 190 | if (path.endsWith('/graphql/stream')) { 191 | return handler(req); 192 | } 193 | return new Response(null, { status: 404 }); 194 | }, 195 | { 196 | port: 4000, // Listening to port 4000 197 | }, 198 | ); 199 | ``` 200 | 201 | ### With [`Bun@>=0.8.0`](https://bun.sh/) 202 | 203 | ```js 204 | import { createHandler } from 'graphql-sse/lib/use/fetch'; // bun install graphql-sse 205 | import { schema } from './previous-step'; 206 | 207 | // Create the GraphQL over SSE native fetch handler 208 | const handler = createHandler({ schema }); 209 | 210 | // Serve on `/graphql/stream` using the handler 211 | export default { 212 | port: 4000, // Listening to port 4000 213 | async fetch(req) { 214 | const [path, _search] = req.url.split('?'); 215 | if (path.endsWith('/graphql/stream')) { 216 | return await handler(req); 217 | } 218 | return new Response(null, { status: 404 }); 219 | }, 220 | }; 221 | ``` 222 | 223 | ## Use the client 224 | 225 | ```js 226 | import { createClient } from 'graphql-sse'; 227 | 228 | const client = createClient({ 229 | // singleConnection: true, preferred for HTTP/1 enabled servers and subscription heavy apps 230 | url: 'http://localhost:4000/graphql/stream', 231 | }); 232 | 233 | // query 234 | (async () => { 235 | const query = client.iterate({ 236 | query: '{ hello }', 237 | }); 238 | 239 | const { value } = await query.next(); 240 | expect(value).toEqual({ hello: 'world' }); 241 | })(); 242 | 243 | // subscription 244 | (async () => { 245 | const subscription = client.iterate({ 246 | query: 'subscription { greetings }', 247 | }); 248 | 249 | for await (const event of subscription) { 250 | expect(event).toEqual({ greetings: 'Hi' }); 251 | 252 | // complete a running subscription by breaking the iterator loop 253 | break; 254 | } 255 | })(); 256 | ``` 257 | -------------------------------------------------------------------------------- /website/src/pages/index.mdx: -------------------------------------------------------------------------------- 1 | # Home 2 | 3 | export { Index as default } from '../index'; 4 | -------------------------------------------------------------------------------- /website/src/pages/recipes.mdx: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | Short and concise code snippets for starting with common use-cases. 4 | 5 | ## Client Usage 6 | 7 | ### With [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) (distinct connections mode) 8 | 9 | The following example doesn't use the `graphql-sse` client and works only for the ["distinct connections mode" of the GraphQL over SSE spec](https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#distinct-connections-mode). This mode is the client default and should suffice for most of the use-cases. 10 | 11 | ```js 12 | const url = new URL('http://localhost:4000/graphql/stream'); 13 | url.searchParams.append('query', 'subscription { greetings }'); 14 | 15 | const source = new EventSource(url); 16 | 17 | source.addEventListener('next', ({ data }) => { 18 | console.log(data); 19 | // {"data":{"greetings":"Hi"}} 20 | // {"data":{"greetings":"Bonjour"}} 21 | // {"data":{"greetings":"Hola"}} 22 | // {"data":{"greetings":"Ciao"}} 23 | // {"data":{"greetings":"Zdravo"}} 24 | }); 25 | 26 | source.addEventListener('error', (e) => { 27 | console.error(e); 28 | }); 29 | 30 | source.addEventListener('complete', () => { 31 | source.close(); 32 | }); 33 | ``` 34 | 35 | ### With [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) 36 | 37 | ```ts 38 | import { createClient, RequestParams } from 'graphql-sse'; 39 | 40 | const client = createClient({ 41 | url: 'http://hey.there:4000/graphql/stream', 42 | }); 43 | 44 | (async () => { 45 | const query = client.iterate({ 46 | query: '{ hello }', 47 | }); 48 | 49 | try { 50 | const { value } = await query.next(); 51 | // next = value = { data: { hello: 'Hello World!' } } 52 | // complete 53 | } catch (err) { 54 | // error 55 | } 56 | })(); 57 | ``` 58 | 59 | ### With [AsyncIterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/asyncIterator) 60 | 61 | ```js 62 | import { createClient, RequestParams } from 'graphql-sse'; 63 | 64 | const client = createClient({ 65 | url: 'http://iterators.ftw:4000/graphql/stream', 66 | }); 67 | 68 | (async () => { 69 | const subscription = client.iterate({ 70 | query: 'subscription { greetings }', 71 | }); 72 | // "subscription.return()" to dispose 73 | 74 | for await (const result of subscription) { 75 | // next = result = { data: { greetings: 5x } } 76 | // "break" to dispose 77 | } 78 | // complete 79 | })(); 80 | ``` 81 | 82 | ### With [Observable](https://github.com/tc39/proposal-observable) 83 | 84 | ```js 85 | import { Observable } from 'relay-runtime'; 86 | // or 87 | import { Observable } from '@apollo/client/core'; 88 | // or 89 | import { Observable } from 'rxjs'; 90 | // or 91 | import Observable from 'zen-observable'; 92 | // or any other lib which implements Observables as per the ECMAScript proposal: https://github.com/tc39/proposal-observable 93 | 94 | const client = createClient({ 95 | url: 'http://graphql.loves:4000/observables', 96 | }); 97 | 98 | export function toObservable(operation) { 99 | return new Observable((observer) => 100 | client.subscribe(operation, { 101 | next: (data) => observer.next(data), 102 | error: (err) => observer.error(err), 103 | complete: () => observer.complete(), 104 | }), 105 | ); 106 | } 107 | 108 | const observable = toObservable({ query: `subscription { ping }` }); 109 | 110 | const subscription = observable.subscribe({ 111 | next: (data) => { 112 | expect(data).toBe({ data: { ping: 'pong' } }); 113 | }, 114 | }); 115 | 116 | // ⏱ 117 | 118 | subscription.unsubscribe(); 119 | ``` 120 | 121 | ### With [Relay](https://relay.dev) 122 | 123 | ```js 124 | import { 125 | Network, 126 | Observable, 127 | RequestParameters, 128 | Variables, 129 | } from 'relay-runtime'; 130 | import { createClient } from 'graphql-sse'; 131 | 132 | const subscriptionsClient = createClient({ 133 | url: 'http://i.love:4000/graphql/stream', 134 | headers: () => { 135 | const session = getSession(); 136 | if (!session) return {}; 137 | return { 138 | Authorization: `Bearer ${session.token}`, 139 | }; 140 | }, 141 | }); 142 | 143 | // yes, both fetch AND subscribe can be handled in one implementation 144 | function fetchOrSubscribe(operation: RequestParameters, variables: Variables) { 145 | return Observable.create((sink) => { 146 | if (!operation.text) { 147 | return sink.error(new Error('Operation text cannot be empty')); 148 | } 149 | return subscriptionsClient.subscribe( 150 | { 151 | operationName: operation.name, 152 | query: operation.text, 153 | variables, 154 | }, 155 | sink, 156 | ); 157 | }); 158 | } 159 | 160 | export const network = Network.create(fetchOrSubscribe, fetchOrSubscribe); 161 | ``` 162 | 163 | ### With [urql](https://formidable.com/open-source/urql/) 164 | 165 | ```js 166 | import { createClient, defaultExchanges, subscriptionExchange } from 'urql'; 167 | import { createClient as createSSEClient } from 'graphql-sse'; 168 | 169 | const sseClient = createSSEClient({ 170 | url: 'http://its.urql:4000/graphql/stream', 171 | }); 172 | 173 | export const client = createClient({ 174 | url: '/graphql/stream', 175 | exchanges: [ 176 | ...defaultExchanges, 177 | subscriptionExchange({ 178 | forwardSubscription(operation) { 179 | return { 180 | subscribe: (sink) => { 181 | const dispose = sseClient.subscribe(operation, sink); 182 | return { 183 | unsubscribe: dispose, 184 | }; 185 | }, 186 | }; 187 | }, 188 | }), 189 | ], 190 | }); 191 | ``` 192 | 193 | ### With [Apollo](https://www.apollographql.com) 194 | 195 | ```ts 196 | import { 197 | ApolloLink, 198 | Operation, 199 | FetchResult, 200 | Observable, 201 | } from '@apollo/client/core'; 202 | import { print, GraphQLError } from 'graphql'; 203 | import { createClient, ClientOptions, Client } from 'graphql-sse'; 204 | 205 | class SSELink extends ApolloLink { 206 | private client: Client; 207 | 208 | constructor(options: ClientOptions) { 209 | super(); 210 | this.client = createClient(options); 211 | } 212 | 213 | public request(operation: Operation): Observable { 214 | return new Observable((sink) => { 215 | return this.client.subscribe( 216 | { ...operation, query: print(operation.query) }, 217 | { 218 | next: sink.next.bind(sink), 219 | complete: sink.complete.bind(sink), 220 | error: sink.error.bind(sink), 221 | }, 222 | ); 223 | }); 224 | } 225 | } 226 | 227 | export const link = new SSELink({ 228 | url: 'http://where.is:4000/graphql/stream', 229 | headers: () => { 230 | const session = getSession(); 231 | if (!session) return {}; 232 | return { 233 | Authorization: `Bearer ${session.token}`, 234 | }; 235 | }, 236 | }); 237 | ``` 238 | 239 | ### With [TypedDocumentNode](https://github.com/dotansimha/graphql-typed-document-node) 240 | 241 | ```ts 242 | import { 243 | createClient, 244 | Client, 245 | ClientOptions, 246 | ExecutionResult, 247 | Sink, 248 | } from 'graphql-sse'; 249 | import { TypedDocumentNode } from '@graphql-typed-document-node/core'; // yarn add @graphql-typed-document-node/core 250 | import { print } from 'graphql'; 251 | 252 | export interface TypedClient 253 | extends Omit< 254 | Client, 255 | 'iterate' | 'subscribe' // we're replacing the `iterate` and `subscribe` implementations 256 | > { 257 | iterate< 258 | Result extends ExecutionResult, 259 | Variables extends Record, 260 | >( 261 | document: TypedDocumentNode, 262 | variables: Variables, 263 | ): AsyncIterableIterator; 264 | subscribe< 265 | Result extends ExecutionResult, 266 | Variables extends Record, 267 | >( 268 | document: TypedDocumentNode, 269 | variables: Variables, 270 | sink: Sink, 271 | ): () => void; 272 | } 273 | 274 | export function createTypedClient(options: ClientOptions): TypedClient { 275 | const client = createClient(options); 276 | return { 277 | ...client, 278 | iterate: (document, variables) => 279 | client.iterate({ 280 | query: print(document), 281 | variables, 282 | }) as any, 283 | subscribe: (document, variables, sink) => 284 | client.subscribe( 285 | { 286 | query: print(document), 287 | variables, 288 | }, 289 | sink, 290 | ), 291 | }; 292 | } 293 | ``` 294 | 295 | ### For HTTP/1 (aka. [single connection mode](https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#single-connection-mode)) 296 | 297 | ```js 298 | import { createClient } from 'graphql-sse'; 299 | 300 | export const client = createClient({ 301 | singleConnection: true, // this is literally it 😄 302 | url: 'http://use.single:4000/connection/graphql/stream', 303 | // lazy: true (default) -> connect on first subscribe and disconnect on last unsubscribe 304 | // lazy: false -> connect as soon as the client is created 305 | }); 306 | 307 | // The client will now run in a "single connection mode" mode. Meaning, 308 | // a single SSE connection will be used to transmit all operation results 309 | // while separate HTTP requests will be issued to dictate the behaviour. 310 | ``` 311 | 312 | ### With Custom Retry Timeout Strategy 313 | 314 | ```js 315 | import { createClient } from 'graphql-sse'; 316 | import { waitForHealthy } from './my-servers'; 317 | 318 | const url = 'http://i.want.retry:4000/control/graphql/stream'; 319 | 320 | export const client = createClient({ 321 | url, 322 | retryWait: async function waitForServerHealthyBeforeRetry() { 323 | // if you have a server healthcheck, you can wait for it to become 324 | // healthy before retrying after an abrupt disconnect (most commonly a restart) 325 | await waitForHealthy(url); 326 | 327 | // after the server becomes ready, wait for a second + random 1-4s timeout 328 | // (avoid DDoSing yourself) and try connecting again 329 | await new Promise((resolve) => 330 | setTimeout(resolve, 1000 + Math.random() * 3000), 331 | ); 332 | }, 333 | }); 334 | ``` 335 | 336 | ### With Logging of Incoming Messages 337 | 338 | import { Callout } from '@theguild/components'; 339 | 340 | 341 | Browsers don't show them in the DevTools. [Read 342 | more](https://github.com/enisdenjo/graphql-sse/issues/20). 343 | 344 | 345 | ```js 346 | import { createClient } from 'graphql-sse'; 347 | 348 | export const client = createClient({ 349 | url: 'http://let-me-see.messages:4000/graphql/stream', 350 | onMessage: console.log, 351 | }); 352 | ``` 353 | 354 | ### In Browser 355 | 356 | ```html 357 | 358 | 359 | 360 | 361 | GraphQL over Server-Sent Events 362 | 366 | 367 | 368 | 375 | 376 | 377 | ``` 378 | 379 | ### In Node.js 380 | 381 | ```js 382 | const ws = require('ws'); // yarn add ws 383 | const fetch = require('node-fetch'); // yarn add node-fetch 384 | const { AbortController } = require('node-abort-controller'); // (node < v15) yarn add node-abort-controller 385 | const Crypto = require('crypto'); 386 | const { createClient } = require('graphql-sse'); 387 | 388 | export const client = createClient({ 389 | url: 'http://no.browser:4000/graphql/stream', 390 | fetchFn: fetch, 391 | abortControllerImpl: AbortController, // node < v15 392 | /** 393 | * Generates a v4 UUID to be used as the ID. 394 | * Reference: https://gist.github.com/jed/982883 395 | */ 396 | generateID: () => 397 | ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => 398 | (c ^ (Crypto.randomBytes(1)[0] & (15 >> (c / 4)))).toString(16), 399 | ), 400 | }); 401 | ``` 402 | 403 | ## Server Handler Usage 404 | 405 | ### With Custom Authentication 406 | 407 | ```js 408 | import { createHandler } from 'graphql-sse'; 409 | import { 410 | schema, 411 | getOrCreateTokenFromCookies, 412 | customAuthenticationTokenDiscovery, 413 | processAuthorizationHeader, 414 | } from './my-graphql'; 415 | 416 | export const handler = createHandler({ 417 | schema, 418 | authenticate: async (req) => { 419 | let token = req.headers.get('x-graphql-event-stream-token'); 420 | if (token) { 421 | // When the client is working in a "single connection mode" 422 | // all subsequent requests for operations will have the 423 | // stream token set in the `X-GraphQL-Event-Stream-Token` header. 424 | // 425 | // It is considered safe to accept the header token always 426 | // because if a stream reservation does not exist, or is already 427 | // fulfilled, the handler itself will reject the request. 428 | // 429 | // Read more: https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#single-connection-mode 430 | return Array.isArray(token) ? token.join('') : token; 431 | } 432 | 433 | // It is necessary to generate a unique token when dealing with 434 | // clients that operate in the "single connection mode". The process 435 | // of generating the token is completely up to the implementor. 436 | token = getOrCreateTokenFromCookies(req); 437 | // or 438 | token = processAuthorizationHeader(req.headers.get('authorization')); 439 | // or 440 | token = await customAuthenticationTokenDiscovery(req); 441 | 442 | // Using the response argument the implementor may respond to 443 | // authentication issues however he sees fit. 444 | if (!token) { 445 | return [null, { status: 401, statusText: 'Unauthorized' }]; 446 | } 447 | 448 | // Clients that operate in "distinct connections mode" dont 449 | // need a unique stream token. It is completely ok to simply 450 | // return an empty string for authenticated clients. 451 | // 452 | // Read more: https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#distinct-connections-mode 453 | if ( 454 | req.method === 'POST' && 455 | req.headers.get('accept') === 'text/event-stream' 456 | ) { 457 | // "distinct connections mode" requests an event-stream with a POST 458 | // method. These two checks, together with the lack of `X-GraphQL-Event-Stream-Token` 459 | // header, are sufficient for accurate detection. 460 | return ''; // return token; is OK too 461 | } 462 | 463 | // On the other hand, clients operating in "single connection mode" 464 | // need a unique stream token which will be provided alongside the 465 | // incoming event stream request inside the `X-GraphQL-Event-Stream-Token` header. 466 | // 467 | // Read more: https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#single-connection-mode 468 | return token; 469 | }, 470 | }); 471 | ``` 472 | 473 | ### With Dynamic Schema 474 | 475 | ```js 476 | import { createHandler } from 'graphql-sse'; 477 | import { schema, checkIsAdmin, getDebugSchema } from './my-graphql'; 478 | 479 | export const handler = createHandler({ 480 | schema: async (req, executionArgsWithoutSchema) => { 481 | // will be called on every subscribe request 482 | // allowing you to dynamically supply the schema 483 | // using the depending on the provided arguments 484 | const isAdmin = await checkIsAdmin(req); 485 | if (isAdmin) return getDebugSchema(req, executionArgsWithoutSchema); 486 | return schema; 487 | }, 488 | }); 489 | ``` 490 | 491 | ### With Custom Context Value 492 | 493 | ```js 494 | import { createHandler } from 'graphql-sse'; 495 | import { schema, getDynamicContext } from './my-graphql'; 496 | 497 | export const handler = createHandler({ 498 | schema, 499 | // or static context by supplying the value direcly 500 | context: (req, args) => { 501 | return getDynamicContext(req, args); 502 | }, 503 | }); 504 | ``` 505 | 506 | ### With Custom Execution Arguments 507 | 508 | ```js 509 | import { parse } from 'graphql'; 510 | import { createHandler } from 'graphql-sse'; 511 | import { getSchema, myValidationRules } from './my-graphql'; 512 | 513 | export const handler = createHandler({ 514 | onSubscribe: async (req, params) => { 515 | const schema = await getSchema(req); 516 | return { 517 | schema, 518 | operationName: params.operationName, 519 | document: 520 | typeof params.query === 'string' ? parse(params.query) : params.query, 521 | variableValues: params.variables, 522 | contextValue: undefined, 523 | }; 524 | }, 525 | }); 526 | ``` 527 | 528 | ### With custom subscribe method that gracefully handles thrown errors 529 | 530 | `graphql-js` does not catch errors thrown from async iterables ([see issue](https://github.com/graphql/graphql-js/issues/4001)). This will bubble the error to the server handler and cause issues like writing headers after they've already been sent if not handled properly. 531 | 532 | Note that `graphql-sse` does not offer built-in handling of internal errors because there's no one-glove-fits-all. Errors are important and should be handled with care, exactly to the needs of the user - not the library author. 533 | 534 | Therefore, you may instead implement your own `subscribe` function that gracefully handles thrown errors. 535 | 536 | ```typescript 537 | import { subscribe } from 'graphql'; 538 | import { createHandler } from 'graphql-sse'; 539 | import { getSchema } from './my-graphql'; 540 | 541 | export const handler = createHandler({ 542 | async subscribe(...args) { 543 | const result = await subscribe(...args); 544 | if ('next' in result) { 545 | // is an async iterable, augment the next method to handle thrown errors 546 | const originalNext = result.next; 547 | result.next = () => 548 | originalNext().catch((err) => ({ value: { errors: [err] } })); 549 | } 550 | return result; 551 | }, 552 | }); 553 | ``` 554 | 555 | ## Server Handler and Client Usage 556 | 557 | ### With Persisted Queries 558 | 559 | ```ts filename="🛸 server" 560 | import { parse, ExecutionArgs } from 'graphql'; 561 | import { createHandler } from 'graphql-sse'; 562 | import { schema } from './my-graphql'; 563 | 564 | // a unique GraphQL execution ID used for representing 565 | // a query in the persisted queries store. when subscribing 566 | // you should use the `SubscriptionPayload.query` to transmit the id 567 | type QueryID = string; 568 | 569 | const queriesStore: Record = { 570 | iWantTheGreetings: { 571 | schema, // you may even provide different schemas in the queries store 572 | document: parse('subscription Greetings { greetings }'), 573 | }, 574 | }; 575 | 576 | export const handler = createHandler({ 577 | onSubscribe: (_req, params) => { 578 | const persistedQuery = 579 | queriesStore[String(params.extensions?.persistedQuery)]; 580 | if (persistedQuery) { 581 | return { 582 | ...persistedQuery, 583 | variableValues: params.variables, // use the variables from the client 584 | contextValue: undefined, 585 | }; 586 | } 587 | 588 | // for extra security only allow the queries from the store 589 | return [null, { status: 404, statusText: 'Not Found' }]; 590 | }, 591 | }); 592 | ``` 593 | 594 | ```ts filename="📺 client" 595 | import { createClient } from 'graphql-sse'; 596 | 597 | const client = createClient({ 598 | url: 'http://persisted.graphql:4000/queries', 599 | }); 600 | 601 | (async () => { 602 | const onNext = () => { 603 | /**/ 604 | }; 605 | 606 | await new Promise((resolve, reject) => { 607 | client.subscribe( 608 | { 609 | query: '', // query field is required, but you can leave it empty for persisted queries 610 | extensions: { 611 | persistedQuery: 'iWantTheGreetings', 612 | }, 613 | }, 614 | { 615 | next: onNext, 616 | error: reject, 617 | complete: resolve, 618 | }, 619 | ); 620 | }); 621 | 622 | expect(onNext).toBeCalledTimes(5); // greetings in 5 languages 623 | })(); 624 | ``` 625 | -------------------------------------------------------------------------------- /website/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | // @ts-expect-error module does not provide typings 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const config = require('@theguild/tailwind-config'); 4 | module.exports = config; 5 | -------------------------------------------------------------------------------- /website/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import { defineConfig, PRODUCTS } from '@theguild/components'; 2 | 3 | export default defineConfig({ 4 | websiteName: `GraphQL-SSE`, 5 | description: PRODUCTS.SSE.title, 6 | docsRepositoryBase: 'https://github.com/enisdenjo/graphql-sse', 7 | logo: PRODUCTS.SSE.logo({ className: 'w-9' }), 8 | }); 9 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "module": "esnext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "noEmit": true, 9 | "incremental": true, 10 | "isolatedModules": true 11 | }, 12 | "include": [ 13 | "src", 14 | "scripts", 15 | "next-env.d.ts", 16 | "next.config.mjs", 17 | "postcss.config.cjs", 18 | "tailwind.config.cjs", 19 | "theme.config.tsx", 20 | "next-sitemap.config.cjs" 21 | ], 22 | "exclude": ["node_modules"] 23 | } 24 | --------------------------------------------------------------------------------