@inkly/graphql-ws
v0.0.0
Published
Server-side graphql-transport-ws subprotocol for inkly — serve GraphQL subscriptions to the graphql-ws client ecosystem.
Readme
@inkly/graphql-ws
Server-side graphql-transport-ws subprotocol for inkly -- serve GraphQL
subscriptions (and queries/mutations) over WebSocket to ANY client in the
graphql-ws ecosystem: Apollo Client,
urql, the graphql-ws JS client, and others.
Protocol reference: https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
Why this package
The graphql-ws client ecosystem speaks the graphql-transport-ws subprotocol.
This package is a thin server-side implementation of that protocol on top of
inkly's runtime-agnostic Connection/AdapterHandlers transport seam, so the
same handler code runs on Node.js, Deno, Bun, Cloudflare Workers, and anywhere
else inkly runs.
This is not the legacy subscriptions-transport-ws protocol. It
implements the modern graphql-transport-ws one.
Install
pnpm add @inkly/graphql-ws graphqlgraphql (>=16) is a peer dependency and must be installed separately.
Quickstart
import { WebSocketServer } from "ws";
import { randomUUID } from "node:crypto";
import type { Connection } from "@inkly/protocol";
import {
createGraphQLWsHandlers,
GRAPHQL_TRANSPORT_WS_PROTOCOL,
} from "@inkly/graphql-ws";
import { schema } from "./my-schema";
const handlers = createGraphQLWsHandlers({ schema });
const wss = new WebSocketServer({
port: 4000,
handleProtocols: () => GRAPHQL_TRANSPORT_WS_PROTOCOL,
});
wss.on("connection", (ws, req) => {
const conn: Connection = {
id: randomUUID(),
request: {
url: `ws://localhost${req.url ?? "/"}`,
headers: new Headers(req.headers as Record<string, string>),
method: req.method ?? "GET",
},
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
raw: ws,
};
ws.on("message", (data) =>
handlers.message(conn, data instanceof Buffer ? new Uint8Array(data) : String(data)),
);
ws.on("close", (code, reason) =>
handlers.close(conn, { code, reason: reason.toString() || undefined }),
);
ws.on("error", (err) => handlers.error(conn, err));
void handlers.open(conn);
});On the client (graphql-ws)
import { createClient } from "graphql-ws";
const client = createClient({ url: "ws://localhost:4000" });
// Subscription
const unsubscribe = client.subscribe(
{ query: "subscription { countdown(from: 5) }" },
{
next: (data) => console.log(data),
error: console.error,
complete: () => console.log("done"),
},
);Subprotocol identifier
import { GRAPHQL_TRANSPORT_WS_PROTOCOL } from "@inkly/graphql-ws";
// "graphql-transport-ws"Pass this string to handleProtocols (ws), acceptedProtocols (Deno), or
your platform's equivalent to negotiate the protocol during the WebSocket
handshake.
Serving note
inkly's built-in WebSocket adapters negotiate the inkly.v1 subprotocol,
so they cannot directly serve graphql-ws clients. Use the ws package (or a
platform-native WebSocket server) with handleProtocols pointing to
GRAPHQL_TRANSPORT_WS_PROTOCOL, and wire the socket events to
handlers.open / message / close / error as shown above. See
examples/node-server.ts for a full working example.
API
GRAPHQL_TRANSPORT_WS_PROTOCOL
const GRAPHQL_TRANSPORT_WS_PROTOCOL: "graphql-transport-ws"createGraphQLWsHandlers(options)
Returns an AdapterHandlers object (open, message, close, error)
that implements the graphql-transport-ws protocol.
GraphQLWsOptions
| Option | Type | Default | Description |
|-----------------------------|----------------------------------------------------------|----------|------------------------------------------------------------------------------------------------------|
| schema | GraphQLSchema | required | Schema to execute operations against. |
| context | (conn, initPayload) => unknown \| Promise<unknown> | -- | Build the GraphQL context value. Called once per connection after connection_init succeeds. |
| onConnect | (conn, initPayload) => boolean \| void \| Promise<...> | -- | Return false or throw to reject the connection (close 4403). |
| rootValue | unknown | -- | Passed as rootValue to every operation. |
| connectionInitWaitTimeout | number | 3000 | Milliseconds to wait for connection_init; closes with 4408 on timeout. Set 0 to disable. |
| execute | typeof gqlExecute | -- | Override the execute function for queries and mutations. |
| subscribe | typeof gqlSubscribe | -- | Override the subscribe function for subscriptions. |
Close codes
| Code | Reason | Trigger |
|------|---------------------------------------|------------------------------------------------------|
| 4400 | Invalid message received / reason | Malformed JSON, missing fields, unknown message type |
| 4401 | Unauthorized | subscribe before connection_init was acked |
| 4403 | Forbidden | onConnect returned false or threw |
| 4408 | Connection initialisation timeout | connection_init not received within the timeout |
| 4409 | Subscriber for <id> already exists | Duplicate active subscription id |
| 4429 | Too many initialisation requests | Second connection_init on the same connection |
Caveats
- Direct JSON, no inkly codec. This package speaks the
graphql-transport-wsJSON wire format directly and does NOT use inkly's own contract/codec system. It reuses inkly only for the runtime-agnosticConnectiontransport seam. - Auth. Use
onConnectfor token validation andcontextto inject user info into resolvers. - Subprotocol negotiation. You must negotiate
graphql-transport-wsat the HTTP upgrade yourself (e.g. viahandleProtocolsinws); inkly's built-in adapters negotiateinkly.v1only. - No server-initiated ping. Clients may send
pingand receivepong. Server-to-client pings are not currently emitted automatically (though the spec permits them; you can callconn.send(JSON.stringify({type:"ping"}))at any time).
See docs/overview.md for protocol message tables and close-code details,
and examples/node-server.ts for a complete Node.js example.
