28. Socket.IO integration
SocketIOPlugin sends subscription.update messages after resource changes.
Messages identify a changed resource; clients fetch records through the resource
API using their own fields, includes and authorization context.
Runnable public example
This example deliberately exposes a public countries resource. It uses a local in-memory database and accepts anonymous subscriptions.
import { JsonRestApi } from 'json-rest-api'
import { RestApiPlugin, RestApiKnexPlugin, ExpressPlugin, SocketIOPlugin } from 'json-rest-api'
import { createServer } from 'node:http'
import express from 'express'
import knex from 'knex'
const db = knex({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true
})
const api = new JsonRestApi({ name: 'socket-example' })
const app = express()
await api.use(RestApiPlugin)
await api.use(RestApiKnexPlugin, { knex: db })
await api.use(ExpressPlugin, { mountPath: '/api' })
await api.use(SocketIOPlugin, { subscriptions: { maxPerSocket: 20 } })
await api.addResource('countries', {
schema: { id: { type: 'id' }, name: { type: 'string', required: true, search: true } }
})
await api.resources.countries.createKnexTable()
api.http.express.mount(app)
const server = createServer(app)
await api.startSocketServer(server)
server.listen(3000)
Install socket.io in the server application and socket.io-client in the
client. Close the Socket.IO server and database during application shutdown:
await new Promise(resolve => api.io.close(resolve))
await db.destroy()
Socket.IO closes the HTTP server passed to startSocketServer. Closing that
server also initiates graceful shutdown of the Redis clients created by the
plugin and clears api.io and its internal server/client references. Database
ownership remains with the application; ordinary resource calls still work
after Socket.IO shuts down. Do not separately quit those plugin-created Redis
clients after closing Socket.IO.
Only one Socket.IO startup may be active per API instance. Overlapping or
duplicate starts reject instead of replacing the active server. With Redis
enabled, both connections must succeed before Socket.IO attaches to the HTTP
server. Failed Redis startup closes partial connections, preserves the caller’s
HTTP listeners and authentication configuration, and permits a subsequent retry.
Redis’s configured reconnection policy is retained; set
redis.socket.reconnectStrategy: false when startup should fail on its first
connection error rather than retry.
The Socket.IO path defaults to the HTTP connector mount path plus /socket.io,
or /socket.io without a connector. Override it with
startSocketServer(server, { path: '/events/socket.io' }). HTTP resource routes
continue to use their configured API prefix.
Subscribe and fetch
After the connection is established:
import { io } from 'socket.io-client'
const socket = io('http://localhost:3000', { path: '/api/socket.io' })
socket.on('connect', async () => {
const result = await socket.timeout(5000).emitWithAck('subscribe', {
resource: 'countries',
subscriptionId: 'country-list',
filters: { name: 'Australia' }
})
if (result.error) console.error(result.error)
})
socket.on('subscription.update', change => {
// Invalidate the affected view, then fetch through your normal API client.
console.log(change.resource, change.id, change.action)
})
Subscription input accepts these fields:
| Field | Meaning |
|---|---|
resource |
Required declared resource name |
subscriptionId |
Optional nonempty string; omitted IDs are generated UUIDs |
filters |
Optional object validated with the resource’s search schema |
An acknowledgement is either { success: true, data } or { error: { code,
message } }. Successful data contains subscriptionId, resource, normalized
filters and status: 'active'. Without an acknowledgement callback, subscribe
emits subscription.created or subscription.error instead.
include and fields are not subscription options: notifications never return
full records. Keep those options in the client’s subsequent GET/query. Unknown
options receive UNSUPPORTED_OPTION; malformed filter containers receive
INVALID_FILTERS instead of becoming unfiltered subscriptions.
IDs must be unique among the socket’s active subscriptions. Reusing one returns
SUBSCRIPTION_EXISTS; unsubscribe before replacing it. The default limit is
100 active subscriptions per socket, controlled by subscriptions.maxPerSocket.
Concurrent async admission hooks cannot exceed the configured limit.
To remove a subscription:
await socket.timeout(5000).emitWithAck('unsubscribe', { subscriptionId: 'country-list' })
The connection stays subscribed to a resource while it has other subscriptions for that resource. Every matching subscription receives its own update, with its own subscription ID.
Unsubscribe removes the logical subscription immediately and waits for any
required room departure before acknowledging success. Room joins and departures
are ordered per socket, so a concurrent new subscription retains its membership.
If the adapter rejects departure, the acknowledgement contains UNSUBSCRIBE_ERROR;
the logical subscription remains removed and cannot receive notifications. The
failure does not imply that adapter room cleanup succeeded. A later subscription
can join normally; disconnect also ends the socket’s room membership.
After reconnecting, submit the desired subscriptions again:
const result = await socket.timeout(5000).emitWithAck('restore-subscriptions', {
subscriptions: [{ resource: 'countries', subscriptionId: 'country-list' }]
})
// result.restored contains successful IDs; result.failed contains per-entry errors.
Restoration uses the same validation, permission, uniqueness and limit checks as subscribe. It does not replay changes missed during disconnection.
Notification shape
{
"type": "resource.updated",
"resource": "countries",
"id": "42",
"action": "patch",
"subscriptionId": "country-list",
"meta": { "timestamp": "2026-09-09T00:00:00.000Z" }
}
| Change | type |
action |
|---|---|---|
| POST or PUT-create | resource.created |
post or put |
| PATCH or PUT replacement | resource.updated |
patch or put |
| Relationship POST/PATCH/DELETE | resource.updated |
patch |
| Resource DELETE | resource.deleted |
delete |
IDs are strings. Deletion also includes deletedRecord: { id }, without deleted
attributes. Reverse relationship changes notify the parent and the changed
child resources. A relationship PATCH’s nested resource PATCH provides its
parent update; it is not emitted a second time by the outer relationship hook.
Eligibility uses the resource’s ordinary SQL query before and after each write,
inside that write’s transaction. The changed ID is constrained internally; it
need not be a public search field, and a client’s ID filter cannot replace it.
A record entering or leaving the query result invalidates that subscription.
Deletion uses the before-write result. Custom applyFilter(query, value) search
fields work directly; remove any Socket.IO-only filterRecord callback. There
is no separate JavaScript predicate to maintain.
These queries run normal query permissions, hooks, filters, row policies and autofilters. They use subscriber context, never the writer’s context. Query failures are logged and count as nonmatches; a successful match before or after is sufficient. Applications should account for these additional queries and ensure query hooks can run inside the write transaction.
Authentication and permissions
For an application with authenticated users, install the plugin with a server-owned authentication function:
await api.use(SocketIOPlugin, {
auth: {
requireAuth: true,
authenticate: async ({ socket }) => {
const identity = await verifyToken(socket.handshake.auth?.token)
return { userId: identity.id, roles: identity.roles, workspaceId: identity.workspaceId, groups: identity.groups }
}
}
})
verifyToken is application code. A failed authentication rejects the connection.
Handshake fields do not become trusted identity unless the application explicitly
enables allowClientProvidedAuth; the default is false. anonymousContext can
supply a deliberate public context. onAuthenticationFailed can observe a
failure; an error from that observer does not authorize the connection.
Resource query permission is checked when subscribing and again before each notification. A failing notification permission check suppresses that message and is logged. It does not undo the committed write. Authentication is performed when connecting, so applications that revoke sessions must also invalidate or disconnect their active sockets as appropriate.
A subscriptionFilters hook can add filters and set trusted query context.
Populate the same application context your row policies and autofilters need.
Hook arguments use the resource runtime envelope:
await api.customize({ hooks: {
subscriptionFilters: {
functionName: 'limit-subscriptions',
handler: ({ context }) => {
const { subscription, auth } = context
subscription.context = {
scopeValues: { workspaceId: auth.workspaceId },
visibility: { groups: auth.groups }
}
}
}
} })
The context property defaults to {} and must be an object. It is supplied to
both admission and notification query permission checks; auth always comes
from the stored subscription identity. Use JSON-serializable context for Redis
adapter transport. Clients cannot supply context in subscribe/restore messages.
The resulting filters are validated again, and the hook cannot change the
resource or subscription ID. Replacing a subscription with the same public ID
creates a new generation: it receives subsequent changes only, not messages
queued for the removed subscription.
Admission failures are logged with operation: 'subscribe', phase: 'admission'
and the resolved scopeName, including individual failures while restoring
subscriptions. After resolving a resource, the diagnostic formatter redacts
structured fields marked hidden or normallyHidden in that resource’s schema.
Unknown resources have no resource-specific field policy. Diagnostic formatting
does not mutate the original error, and a failed log sink does not replace the
subscription rejection. This does not redact secrets embedded in arbitrary
error-message text; avoid putting secret values in messages.
Transactions, Redis and verification limits
For individual resource and relationship operations, notifications wait for commit of the library-owned transaction. Writes that roll back, including finish-hook failures after events were queued, emit no successful change. A rejected call after commit can still have emitted notifications. These paths are tested on both SQLite storage modes over WebSocket and HTTP polling, including relationship changes and child rollback.
Library writes now require the handle from api.transaction when joining an
outer unit. Resource and atomic-bulk child completion hooks run after confirmed
owner completion; non-atomic bulk retains one outcome per entry. Rolled-back
units do not flush queued notifications. Explicit callbacks are tested with
repeated changes to the same record, callback/caught-write/caught-SQL failures,
and rejected completion hooks on both storage modes and all three databases.
Direct raw transaction completion is no longer a supported library-write
notification path.
Notifications are per-operation invalidations. A sequential create, update and delete in one committed callback emits those three notices in order, even though the final row is absent. Several updates to one row are not coalesced. No notice is emitted while the callback remains pending. The helper awaits completion hooks; after a confirmed commit, a completion error cannot undo stored changes or notifications already emitted. A later operation’s completion chain can flush the transaction’s queue if an earlier chain failed before delivery. This does not guarantee delivery when every applicable chain is interrupted or the connection fails.
Once queue draining begins, failure while delivering one queued notice does not
prevent attempts for later notices. The call rejects with the first delivery
failure and a committed outcome; it does not roll back stored data. The context
of the operation draining the queue retains each failed attempt as
{ phase: 'socketioBroadcast', broadcastIndex, error }, where broadcastIndex
is zero-based in that transaction’s captured queue. Keep that operation context
when you need these diagnostics, just as for file cleanup diagnostics.
The queue is consumed once. Failed notices are not automatically replayed by later completion chains. One notice can fail after delivery to some recipients; the plugin cannot establish who received it or undo those deliveries. Clients must be able to refetch authoritative data. Continuing with later notices is a best-effort attempt, not a delivery acknowledgement or durable retry mechanism.
With overlapping writes, notification order follows their capture in the Socket.IO finish hook, which can differ from call-start order. Completion hook chains follow transaction enlistment order (reverse order on rollback). Await writes sequentially when their ordering matters to the application.
Socket.IO preserves message ordering but does not provide durable delivery by default. Clients can miss notifications while disconnected; refetch on reconnect. See Socket.IO delivery guarantees.
Optional Redis setup accepts the options used by Redis’s createClient, for
example startSocketServer(server, { redis: { url: 'redis://localhost:6379' } }).
Install redis and @socket.io/redis-adapter when using it. Subscription state
survives the JSON encoding used by the adapter’s fetchSockets responses.
The real Redis tests exercise two Socket.IO servers
sharing SQLite, in both storage modes, over polling and WebSocket. They cover
committed CRUD and relationship notifications, row visibility, rollback and
replacement of queued subscriptions. Further cases drop each publisher/subscriber
connection on each server and verify delivery after reconnection. Startup tests
cover bad credentials, missing sockets, exhausted retries, partial connection
failure, preserved authentication and overlapping starts. Shutdown tests cover
Socket.IO close, HTTP close and close during Redis reconnection.
These tests do not establish durable delivery during an outage, Redis Cluster or Sentinel failover, or bounded cleanup when a network connection remains open but never answers. Redis shutdown drains queued commands; connection and retry timeouts remain part of the supplied Redis configuration.
Socket.IO transport CORS is configured separately through
startSocketServer(server, { cors: ... }) or transport.cors in plugin options.
It does not replace the HTTP CORS plugin or authentication.