aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.gitignore3
-rw-r--r--README.md63
-rw-r--r--deno.json4
-rw-r--r--deno.lock288
-rwxr-xr-xentry.ts4
-rwxr-xr-xsource/http.ts3
-rwxr-xr-xsource/utility/types.ts6
-rw-r--r--source/ws.ts99
-rw-r--r--subscription-example.ts59
9 files changed, 252 insertions, 277 deletions
diff --git a/.gitignore b/.gitignore
index 5dde04d..703a35d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
# OS garbage
.DS_Store
Thumbs.db
+
+# Directories
+.claude
diff --git a/README.md b/README.md
index 7f75f7b..1173194 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/deno.json b/deno.json
index 5ea31de..dc8e5bf 100644
--- a/deno.json
+++ b/deno.json
@@ -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"
}
diff --git a/deno.lock b/deno.lock
index 4e680a4..ebfe5b7 100644
--- a/deno.lock
+++ b/deno.lock
@@ -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"
]
diff --git a/entry.ts b/entry.ts
index 54c1b3a..6c43287 100755
--- a/entry.ts
+++ b/entry.ts
@@ -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 ***/