Skip to content

Server

The server module creates a Socket.IO server with a full GraphQL execution pipeline, including live query support and schema transformation.

import { createServer } from '@requence/socketql/server'
const { server, namespace, liveQueryStore, attach, addSchema } = createServer({
path: '/ws/',
transports: ['websocket'],
})

For multi-instance deployments with Redis, see the Redis adapter section below.

There are two ways to register GraphQL schemas.

Pass schemas directly when creating the server using the schemas option. Use defineSchema for type-safe schema definitions:

import { createServer, defineSchema } from '@requence/socketql/server'
const helloSchema = defineSchema({
typeDefs: /* GraphQL */ `
type Query {
hello: String! @live
}
`,
resolvers: {
Query: {
hello: () => 'world',
},
},
})
const { attach } = createServer({
path: '/ws/',
schemas: [helloSchema],
})

Alternatively, use addSchema to register schemas dynamically after server creation:

const { addSchema, attach } = createServer({ path: '/ws/' })
addSchema({
typeDefs: /* GraphQL */ `
type Query {
hello: String! @live
}
`,
resolvers: {
Query: {
hello: () => 'world',
},
},
})
import { createServer as createHTTPServer } from 'node:http'
const httpServer = createHTTPServer()
attach(httpServer)
httpServer.listen(4000)

Import from @requence/socketql/server/bun instead — it replaces attach with a handler() method:

import { createServer, defineSchema } from '@requence/socketql/server/bun'
const mySchema = defineSchema({ typeDefs, resolvers })
const { handler } = createServer({
path: '/ws/',
schemas: [mySchema],
})
export default { port: 4000, ...handler() }

By default, SocketQL serves GraphQL exclusively over WebSocket. Use httpHandler() to expose a standard HTTP POST /graphql endpoint for queries and mutations.

const { httpHandler } = createServer({ path: '/ws/', schemas: [mySchema] })
app.post('/graphql', httpHandler())

The handler is compatible with any framework that uses a Hono-style context object (c.req.json(), c.req.raw).

Extend the GraphQL context per HTTP request using extendContext. This receives the raw Request object, so you can extract auth tokens, cookies, or other headers:

app.post('/graphql', httpHandler({
extendContext: async (req) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '')
const session = await verifySession(token)
return { userId: session.userId }
},
}))

By default, the HTTP endpoint uses the same schema as the WebSocket server. Use mapSchema to transform or filter the schema for HTTP — for example, to expose only a subset of queries and mutations.

app.post('/graphql', httpHandler({
mapSchema: (schema) => {
// Return a transformed schema — only @http-marked fields will be accessible
return myFilteredSchema(schema)
},
}))

The mapSchema callback receives the full compiled GraphQLSchema and must return a GraphQLSchema. The result is cached after the first request.

A common pattern is to mark specific fields with a custom @http directive and filter the schema to only expose those over HTTP.

Define the directive in your schema:

directive @http on FIELD_DEFINITION
type Query {
publicStatus: Status! @http
users: [User!]! @live
dashboard: Dashboard! @live
}
type Mutation {
webhookCallback(input: WebhookInput!): Boolean! @http
updateUser(input: UpdateUserInput!): User!
}

Then filter the schema using mapSchema with @graphql-tools/utils:

import { MapperKind, getDirective, mapSchema } from '@graphql-tools/utils'
app.post('/graphql', httpHandler({
mapSchema: (schema) =>
mapSchema(schema, {
[MapperKind.QUERY_ROOT_FIELD]: (fieldConfig) => {
return getDirective(schema, fieldConfig, 'http')?.length
? fieldConfig
: null
},
[MapperKind.MUTATION_ROOT_FIELD]: (fieldConfig) => {
return getDirective(schema, fieldConfig, 'http')?.length
? fieldConfig
: null
},
}),
}))

In this setup, only publicStatus and webhookCallback are accessible over HTTP. All other fields are stripped from the HTTP schema and won’t appear in introspection.

The GraphQL context is a discriminated union based on the transport field. This means resolvers can narrow the context type to access transport-specific properties.

When a query arrives over WebSocket, the context includes the Socket.IO socket and namespace:

createServer({
extendContext: ({ socket }) => ({
userId: socket.handshake.auth.userId,
}),
})

When a query arrives over HTTP (via httpHandler), the context does not include socket or namespace. Use the extendContext option on httpHandler to add per-request context:

app.post('/graphql', httpHandler({
extendContext: async (req) => ({
userId: await getUserFromHeaders(req),
}),
}))

Use context.transport to differentiate between transports in your resolvers:

const resolvers = {
Query: {
me: (_, __, context) => {
if (context.transport === 'websocket') {
// context.socket and context.namespace are available
console.log('WebSocket client:', context.socket.id)
}
if (context.transport === 'http') {
// context.socket and context.namespace do not exist
}
return getUser(context.userId)
},
},
}

The exported types WebSocketGraphQLContext and HttpGraphQLContext can be used directly for type-safe resolver signatures:

import type {
GraphQLContext,
WebSocketGraphQLContext,
HttpGraphQLContext,
} from '@requence/socketql/server'

Called when a client connects, before any GraphQL operations are processed. Use this to authenticate connections.

To reject a connection, throw a ConnectionRejectedError. The client will receive a connect_error event with the error message and no server-side log is produced:

import { ConnectionRejectedError, createServer } from '@requence/socketql/server'
createServer({
onConnect: async (socket) => {
const token = socket.handshake.auth?.token
if (!token || !(await isValid(token))) {
throw new ConnectionRejectedError('Unauthorized')
}
socket.data.userId = getUserId(token)
},
})

ConnectionRejectedError accepts an optional data object as the second argument. This is sent to the client alongside the error message, allowing you to pass structured context:

throw new ConnectionRejectedError('Subscription expired', {
code: 'SUBSCRIPTION_EXPIRED',
retryable: false,
})

On the client, the error is received as a ConnectionError with a data property:

client.onConnectError((err) => {
err.message // "Subscription expired"
err.data // { code: "SUBSCRIPTION_EXPIRED", retryable: false }
})
createServer({
onDisconnect: (socket, reason) => {
console.log('disconnected:', socket.id, reason)
},
})

For multi-instance deployments, use createRedisAdapter from @requence/socketql/server/redis. This provides both Socket.IO adapter coordination and Redis-backed live query invalidation across instances.

Install the required peer dependencies:

Terminal window
npm add ioredis @socket.io/redis-adapter
import { createServer } from '@requence/socketql/server'
import { createRedisAdapter } from '@requence/socketql/server/redis'
const redis = createRedisAdapter({ url: 'redis://localhost:6379' })
const { attach } = createServer({
path: '/ws/',
redis,
schemas: [mySchema],
})