diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 63 | ||||
| -rw-r--r-- | deno.json | 4 | ||||
| -rw-r--r-- | deno.lock | 288 | ||||
| -rwxr-xr-x | entry.ts | 4 | ||||
| -rwxr-xr-x | source/http.ts | 3 | ||||
| -rwxr-xr-x | source/utility/types.ts | 6 | ||||
| -rw-r--r-- | source/ws.ts | 99 | ||||
| -rw-r--r-- | subscription-example.ts | 59 |
9 files changed, 252 insertions, 277 deletions
@@ -1,3 +1,6 @@ # OS garbage .DS_Store Thumbs.db + +# Directories +.claude @@ -112,6 +112,22 @@ Companion knobs: - `enableExperimentalFragmentVariables()` - `resetCaches()` +### `GraphQLWS(options)` + +Returns a `(request: Request) => Response` handler that upgrades incoming requests to a WebSocket and speaks the [`graphql-transport-ws`](https://github.com/enisdenjo/graphql-ws) protocol. Pass it as `subscriptions` to `GraphQLHTTP` so HTTP and WS share one endpoint. + +Options: + +| Option | Type | Description | +| --------- | ------------------------------ | ------------------------------------------------------------------- | +| `context` | `(ctx) => Ctx \| Promise<Ctx>` | Builds the resolver context per WS connection. | +| `schema` | `GraphQLSchema` | Required. Executable schema. | +| `...` | `Partial<ServerOptions>` | Anything else `graphql-ws` accepts (`onConnect`, `onSubscribe`, …). | + +### `PubSub` + +Re-export of `graphql-subscriptions`'s in-memory pub/sub. Resolvers call `pubsub.asyncIterator(["EVENT"])` for `subscribe`; mutations call `pubsub.publish("EVENT", payload)`. For multi-process deployments swap in `graphql-redis-subscriptions` — same `PubSubEngine` interface. + ### `importQL(path)` Reads a `.graphql` file and resolves its imports. See [Loading schemas from `.graphql` files](#loading-schemas-from-graphql-files). @@ -146,6 +162,53 @@ We also export commonly imported GraphQL types: `DocumentNode`, `ExecutionResult Pinning `version` is recommended — without it the shell hits jsDelivr's `@latest` cache, which can lag behind new releases by ~12h. +## Subscriptions + +Pair `GraphQLWS` with a `PubSub` instance from `graphql-subscriptions` to serve subscriptions over WebSockets on the same endpoint as HTTP: + +```ts +import { + executeSchema, + gql, + GraphQLHTTP, + GraphQLWS, + PubSub +} from "@eol/gq"; + +const pubsub = new PubSub(); + +const schema = executeSchema({ + resolvers: { + Mutation: { + ping: (_, { msg }) => { + pubsub.publish("PING", { pinged: msg }); + return msg; + } + }, + Subscription: { + pinged: { subscribe: () => pubsub.asyncIterator(["PING"]) } + } + }, + typeDefs: gql` + type Mutation { ping(msg: String!): String } + type Subscription { pinged: String } + ` +}); + +const subscriptions = GraphQLWS({ schema }); + +Deno.serve( + { port: 4000 }, + GraphQLHTTP({ + graphiql: true, + schema, + subscriptions + }) +); +``` + +Clients connect WebSockets to the same URL using the `graphql-transport-ws` subprotocol. The bundled GraphiQL detects subscription operations and switches transports automatically. See `subscription-example.ts` for a runnable demo. + ## License MIT @@ -6,9 +6,11 @@ "@netopwibby/dedent": "jsr:@netopwibby/dedent@^0.1.0", "@std/path": "jsr:@std/path@^1.1.4", "graphql": "npm:graphql@^16.13.2", + "graphql-subscriptions": "npm:graphql-subscriptions@^2.0.0", + "graphql-ws": "npm:graphql-ws@^5.16.0", "xss": "npm:xss@^1.0.15" }, "license": "MIT", "name": "@eol/gq", - "version": "0.5.0" + "version": "0.6.0" } @@ -4,8 +4,9 @@ "jsr:@netopwibby/dedent@0.1": "0.1.0", "jsr:@std/internal@^1.0.12": "1.0.13", "jsr:@std/path@^1.1.4": "1.1.4", - "npm:@eeeooolll/graphiql@0.4.0": "0.4.0_svelte@5.55.5_@codemirror+lint@6.9.5", "npm:@graphql-tools/schema@^10.0.33": "10.0.33_graphql@16.13.2", + "npm:graphql-subscriptions@2": "2.0.0_graphql@16.13.2", + "npm:graphql-ws@^5.16.0": "5.16.2_graphql@16.13.2", "npm:graphql@^16.13.2": "16.13.2", "npm:xss@^1.0.15": "1.0.15" }, @@ -24,92 +25,6 @@ } }, "npm": { - "@codemirror/autocomplete@6.20.1": { - "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", - "dependencies": [ - "@codemirror/language", - "@codemirror/state", - "@codemirror/view", - "@lezer/common" - ] - }, - "@codemirror/commands@6.10.3": { - "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", - "dependencies": [ - "@codemirror/language", - "@codemirror/state", - "@codemirror/view", - "@lezer/common" - ] - }, - "@codemirror/lang-json@6.0.2": { - "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", - "dependencies": [ - "@codemirror/language", - "@lezer/json" - ] - }, - "@codemirror/language@6.12.3": { - "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", - "dependencies": [ - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", - "@lezer/lr", - "style-mod" - ] - }, - "@codemirror/lint@6.9.5": { - "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", - "dependencies": [ - "@codemirror/state", - "@codemirror/view", - "crelt" - ] - }, - "@codemirror/search@6.7.0": { - "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", - "dependencies": [ - "@codemirror/state", - "@codemirror/view", - "crelt" - ] - }, - "@codemirror/state@6.6.0": { - "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", - "dependencies": [ - "@marijn/find-cluster-break" - ] - }, - "@codemirror/view@6.41.1": { - "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", - "dependencies": [ - "@codemirror/state", - "crelt", - "style-mod", - "w3c-keyname" - ] - }, - "@eeeooolll/graphiql@0.4.0_svelte@5.55.5_@codemirror+lint@6.9.5": { - "integrity": "sha512-mUjq0sIFEy4OvHADmlrvbZET9LvgPfmKlnB13ao2fYQElHHilEtMDoN/09ATz2a5BAb5Zii07ZeTRNXsvWgAig==", - "dependencies": [ - "@codemirror/autocomplete", - "@codemirror/commands", - "@codemirror/lang-json", - "@codemirror/language", - "@codemirror/state", - "@codemirror/view", - "@inc/uchu", - "@lezer/highlight", - "cm6-graphql", - "codemirror", - "graphql", - "graphql-sse", - "graphql-ws", - "svelte" - ] - }, "@graphql-tools/merge@9.1.9_graphql@16.13.2": { "integrity": "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==", "dependencies": [ @@ -143,124 +58,15 @@ "graphql" ] }, - "@inc/uchu@2.2.0": { - "integrity": "sha512-AUx8lGbXYG7MemmYnpfpbqsTlnPt2n8prtVqDrPEqGPJ6K1DoIrqdt2HcHqjLv+vYq46rvwMEpY8/53V9xPSPw==" - }, - "@jridgewell/gen-mapping@0.3.13": { - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dependencies": [ - "@jridgewell/sourcemap-codec", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/remapping@2.3.5": { - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dependencies": [ - "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/resolve-uri@3.1.2": { - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" - }, - "@jridgewell/sourcemap-codec@1.5.5": { - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" - }, - "@jridgewell/trace-mapping@0.3.31": { - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dependencies": [ - "@jridgewell/resolve-uri", - "@jridgewell/sourcemap-codec" - ] - }, - "@lezer/common@1.5.2": { - "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==" - }, - "@lezer/highlight@1.2.3": { - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", - "dependencies": [ - "@lezer/common" - ] - }, - "@lezer/json@1.0.3": { - "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", - "dependencies": [ - "@lezer/common", - "@lezer/highlight", - "@lezer/lr" - ] - }, - "@lezer/lr@1.4.10": { - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", - "dependencies": [ - "@lezer/common" - ] - }, - "@marijn/find-cluster-break@1.0.2": { - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" - }, - "@sveltejs/acorn-typescript@1.0.9_acorn@8.16.0": { - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dependencies": [ - "acorn" - ] - }, - "@types/estree@1.0.8": { - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" - }, - "@types/trusted-types@2.0.7": { - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" - }, "@whatwg-node/promise-helpers@1.3.2": { "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dependencies": [ "tslib" ] }, - "acorn@8.16.0": { - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "bin": true - }, - "aria-query@5.3.1": { - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==" - }, - "axobject-query@4.1.0": { - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" - }, - "clsx@2.1.1": { - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" - }, - "cm6-graphql@0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2": { - "integrity": "sha512-FIAFHn6qyiXChTz3Pml0NgTM8LyyXs8QfP2iPG7MLA8Xi83WuVlkGG5PDs+DDeEVabHkLIZmcyNngQlxLXKk6A==", - "dependencies": [ - "@codemirror/autocomplete", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/state", - "@codemirror/view", - "@lezer/highlight", - "graphql", - "graphql-language-service" - ] - }, - "codemirror@6.0.2": { - "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "dependencies": [ - "@codemirror/autocomplete", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view" - ] - }, "commander@2.20.3": { "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, - "crelt@1.0.6": { - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" - }, "cross-inspect@1.0.1": { "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", "dependencies": [ @@ -270,39 +76,15 @@ "cssfilter@0.0.10": { "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==" }, - "debounce-promise@3.1.2": { - "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg==" - }, - "devalue@5.7.1": { - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==" - }, - "esm-env@1.2.2": { - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" - }, - "esrap@2.2.5": { - "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "graphql-language-service@5.5.0_graphql@16.13.2": { - "integrity": "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q==", + "graphql-subscriptions@2.0.0_graphql@16.13.2": { + "integrity": "sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==", "dependencies": [ - "debounce-promise", "graphql", - "nullthrows", - "vscode-languageserver-types" - ], - "bin": true - }, - "graphql-sse@2.6.0_graphql@16.13.2": { - "integrity": "sha512-BXT5Rjv9UFunjQsmN9WWEIq+TFNhgYibgwo1xkXLxzguQVyOd6paJ4v5DlL9K5QplS0w74bhF+aUiqaGXZBaug==", - "dependencies": [ - "graphql" + "iterall" ] }, - "graphql-ws@6.0.8_graphql@16.13.2": { - "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", + "graphql-ws@5.16.2_graphql@16.13.2": { + "integrity": "sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ==", "dependencies": [ "graphql" ] @@ -310,57 +92,12 @@ "graphql@16.13.2": { "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==" }, - "is-reference@3.0.3": { - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dependencies": [ - "@types/estree" - ] - }, - "locate-character@3.0.0": { - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" - }, - "magic-string@0.30.21": { - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "nullthrows@1.1.1": { - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" - }, - "style-mod@4.1.3": { - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" - }, - "svelte@5.55.5": { - "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", - "dependencies": [ - "@jridgewell/remapping", - "@jridgewell/sourcemap-codec", - "@sveltejs/acorn-typescript", - "@types/estree", - "@types/trusted-types", - "acorn", - "aria-query", - "axobject-query", - "clsx", - "devalue", - "esm-env", - "esrap", - "is-reference", - "locate-character", - "magic-string", - "zimmerframe" - ] + "iterall@1.3.0": { + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==" }, "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "vscode-languageserver-types@3.17.5": { - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" - }, - "w3c-keyname@2.2.8": { - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" - }, "xss@1.0.15": { "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", "dependencies": [ @@ -368,17 +105,16 @@ "cssfilter" ], "bin": true - }, - "zimmerframe@1.1.4": { - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, "workspace": { "dependencies": [ "jsr:@netopwibby/dedent@0.1", "jsr:@std/path@^1.1.4", - "npm:@eeeooolll/graphiql@0.4.0", + "npm:@eeeooolll/graphiql@0.4.2", "npm:@graphql-tools/schema@^10.0.33", + "npm:graphql-subscriptions@2", + "npm:graphql-ws@^5.16.0", "npm:graphql@^16.13.2", "npm:xss@^1.0.15" ] @@ -5,10 +5,12 @@ export * from "./source/common.ts"; export * from "./source/http.ts"; +export * from "./source/ws.ts"; export { makeExecutableSchema as executeSchema } from "@graphql-tools/schema"; export { gql } from "./source/gql.ts"; export { importQL } from "./source/import.ts"; +export { PubSub } from "graphql-subscriptions"; export type { DocumentNode, @@ -21,3 +23,5 @@ export type { GraphQLTypeResolver, Source } from "graphql"; + +export type { PubSubEngine } from "graphql-subscriptions"; diff --git a/source/http.ts b/source/http.ts index 7c747e1..c4a70c8 100755 --- a/source/http.ts +++ b/source/http.ts @@ -54,6 +54,9 @@ export function GraphQLHTTP< ...options }: GQLOptions<Ctx, Req>): GraphQLHandler<Req> { return async (request: Req) => { + if (options.subscriptions && request.headers.get("upgrade")?.toLowerCase() === "websocket") + return options.subscriptions(request as unknown as Request); + const accept = request.headers.get("Accept") || ""; const typeList = ["application/json", "text/html", "text/plain", "*/*"] diff --git a/source/utility/types.ts b/source/utility/types.ts index 0d8b443..2311c25 100755 --- a/source/utility/types.ts +++ b/source/utility/types.ts @@ -41,6 +41,12 @@ export interface GQLOptions<Context, Req extends GQLRequest = GQLRequest> extend playgroundOptions?: Omit<RenderPageOptions, "endpoint">; /** The executable schema to query against. */ schema: GraphQLSchema; + /** + * WebSocket subscription handler returned by `GraphQLWS()`. When set, any + * incoming request with `Upgrade: websocket` is delegated to it before HTTP + * content negotiation runs. + */ + subscriptions?: (request: Request) => Response; } /** A single GraphQL operation — either a `query` or a `mutation`, never both. */ diff --git a/source/ws.ts b/source/ws.ts new file mode 100644 index 0000000..fdd9fb1 --- /dev/null +++ b/source/ws.ts @@ -0,0 +1,99 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { makeServer, type ServerOptions } from "graphql-ws"; + +/*** UTILITY ------------------------------------------ ***/ + +import type { GraphQLSchema } from "graphql"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Configuration accepted by {@link GraphQLWS}. + * + * Wraps `graphql-ws`'s {@link ServerOptions} and requires a `schema`. Anything + * else accepted by `graphql-ws` (custom `execute`, `subscribe`, `onConnect`, + * `onSubscribe`, etc.) can be passed through. + */ +export type WSOptions<Ctx = unknown> = { + /** Builds the context value passed to resolvers. Runs per WS connection. */ + context?: (ctx: { connectionParams?: Record<string, unknown> }) => Ctx | Promise<Ctx>; + /** The executable schema to subscribe against. */ + schema: GraphQLSchema; +} & Partial<Omit<ServerOptions, "schema" | "context">>; + +/** + * Builds a WebSocket subscription handler that speaks the + * `graphql-transport-ws` protocol via `graphql-ws`. + * + * The returned function expects a Fetch `Request` with an `Upgrade: websocket` + * header. It performs the upgrade with `Deno.upgradeWebSocket` and hands the + * socket to `graphql-ws`. Pass the result as `subscriptions` to {@link GraphQLHTTP} + * so HTTP and WS share one endpoint. + * + * @example + * ```ts + * import { executeSchema, GraphQLHTTP, GraphQLWS, PubSub, gql } from "@eol/gq"; + * + * const pubsub = new PubSub(); + * + * const schema = executeSchema({ + * resolvers: { + * Subscription: { + * pinged: { subscribe: () => pubsub.asyncIterator(["PING"]) } + * } + * }, + * typeDefs: gql`type Subscription { pinged: String }` + * }); + * + * const subscriptions = GraphQLWS({ schema }); + * Deno.serve(GraphQLHTTP({ schema, subscriptions })); + * ``` + */ +export function GraphQLWS<Ctx = unknown>(options: WSOptions<Ctx>) { + const server = makeServer({ + ...options, + context: options.context as ServerOptions["context"], + schema: options.schema + }); + + return (request: Request): Response => { + const { response, socket } = Deno.upgradeWebSocket(request, { + protocol: "graphql-transport-ws" + }); + + socket.onopen = () => { + const closed = server.opened( + { + close: (code, reason) => socket.close(code, reason), + onMessage: (cb) => { + socket.onmessage = async (event) => { + try { + await cb( + typeof event.data === "string" ? + event.data : + await (event.data as Blob).text() + ); + } catch (err) { + socket.close(1011, (err as Error).message); + } + }; + }, + protocol: socket.protocol, + send: (data) => Promise.resolve(socket.send(data)) + }, + { + request, + socket + } + ); + + socket.onclose = (event) => closed(event.code, event.reason); + }; + + return response; + }; +} diff --git a/subscription-example.ts b/subscription-example.ts new file mode 100644 index 0000000..6dc1213 --- /dev/null +++ b/subscription-example.ts @@ -0,0 +1,59 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { + executeSchema, + gql, + GraphQLHTTP, + GraphQLWS, + PubSub +} from "./entry.ts"; + +const pubsub = new PubSub(); + +const schema = executeSchema({ + resolvers: { + Mutation: { + ping: (_: unknown, { msg }: { msg: string }) => { + pubsub.publish("PING", { pinged: msg }); + return msg; + } + }, + Query: { + hello: () => "world" + }, + Subscription: { + pinged: { + subscribe: () => pubsub.asyncIterator(["PING"]) + } + } + }, + typeDefs: gql` + type Query { hello: String } + type Mutation { ping(msg: String!): String } + type Subscription { pinged: String } + ` +}); + +const subscriptions = GraphQLWS({ schema }); + +const handler = GraphQLHTTP({ + graphiql: true, + playgroundOptions: { title: "Subscriptions Example" }, + schema, + subscriptions +}); + +/*** PROGRAM ------------------------------------------ ***/ + +Deno.serve({ port: 4000 }, handler); + +setInterval(() => { + pubsub.publish("PING", { pinged: `tick @ ${new Date().toISOString()}` }); +}, 2000); + + + +/*** deno run -A subscription-example.ts ***/ |