Skip to main content

Socket.IO

Every project speaks the Socket.IO protocol at https://<ref>.api.snoutdata.com/realtime/v1/socket.io/. The official socket.io-client connects to it as it is, over long-polling first and then a websocket, exactly as it would to a Node server.

What is different is where the server's code lives. In a Node Socket.IO server you write io.on('connection', ...) and socket.on('send', ...) in JavaScript, in a process you keep running. Here each of those is a SQL function in your database, registered with realtime.on(), run as the user who sent the event, in one transaction with what it writes. There is no process of yours to host, scale or restart, and a room message is announced only if the write behind it committed.

It is built on Realtime handlers, and comes with the paid plans like they do.

Connecting​

import { io } from 'socket.io-client'

const socket = io('https://<ref>.api.snoutdata.com', {
path: '/realtime/v1/socket.io/',
query: { apikey: '<your anon key>' },
auth: { token: session.access_token, room: '42' },
})

socket.on('connect', () => console.log('connected as', socket.id))
socket.on('connect_error', (err) => console.log(err.message))
  • query.apikey is the project's anon key. It says which project, not who the user is.
  • auth.token is the signed-in user's access token. Without it the socket is anonymous (the anon role). Everything else in auth is handed to your connection handler.
  • A browser on another origin needs nothing extra: the door answers CORS for every origin.

Your server, in SQL​

A Node Socket.IO server and what it becomes:

In a Node serverHere
io.use(...) and io.on('connection', socket => ...)realtime.on('connection', 'fn(jsonb)', '/'). It gets auth (without the token); raising refuses the connection
socket.on('send', (data, ack) => ...)realtime.on('send', 'fn(jsonb)', '/'). It gets the first argument; what it returns is the ack
socket.join(room), socket.leave(room)realtime.join(room), realtime.leave(room), inside a handler
io.to(room).emit('message', data)realtime.emit(room, 'message', data)
socket.to(room).emit(...) (everyone but the sender)realtime.emit(room, 'message', data, true)
socket.emit(...) (back to the sender)realtime.emit('socket:' || realtime.socket_id(), 'message', data)
socket.on('disconnect', ...)realtime.on('disconnect', 'fn(jsonb)', '/')
socket.data.userauth.uid(), and your row-level security applies
io.emit(...) from a REST controller or a jobrealtime.send(payload, event, room) from any SQL, a trigger, or a Snout Function
The Redis adapter, for more than one serverNot needed

The last argument of realtime.on() is the namespace: '/' is the default one, '/chat' another, and '*' (the default) every namespace. Inside a handler, realtime.topic() is the namespace, realtime.event() the event, realtime.args() every argument the client sent as a JSON array, and realtime.socket_id() the socket.id the client sees.

A chat, end to end​

The server, all of it:

create table public.messages (
id bigint generated always as identity primary key,
room text not null,
body text not null,
author uuid not null default auth.uid(),
sent_at timestamptz not null default now()
);
alter table public.messages enable row level security;
create policy "members write as themselves" on public.messages
for insert to authenticated with check (author = auth.uid());
create policy "members read" on public.messages for select to authenticated using (true);

-- Who is in which room, so leaving can tell the right room.
create table public.chat_online (
socket_id text primary key,
room text not null,
who uuid not null default auth.uid()
);
alter table public.chat_online enable row level security;
create policy "your own sockets" on public.chat_online for all to authenticated
using (who = auth.uid()) with check (who = auth.uid());

-- Who may connect, and to which room.
create function public.chat_connect(info jsonb) returns void
language plpgsql as $$
begin
if info->>'room' is null then
raise exception 'pick a room';
end if;
insert into public.chat_online (socket_id, room) values (realtime.socket_id(), info->>'room');
perform realtime.join('room:' || (info->>'room'));
perform realtime.emit('room:' || (info->>'room'), 'joined', jsonb_build_object('who', auth.uid()), true);
end $$;

-- A message: stored as the sender, then sent to the room, then acknowledged.
create function public.chat_send(msg jsonb) returns jsonb
language plpgsql as $$
declare
saved public.messages;
begin
insert into public.messages (room, body) values (msg->>'room', msg->>'body')
returning * into saved;
perform realtime.emit('room:' || saved.room, 'message', to_jsonb(saved), true);
return jsonb_build_object('id', saved.id, 'sent_at', saved.sent_at);
end $$;

-- Gone: tell the room it was in.
create function public.chat_leave(nothing jsonb) returns void
language plpgsql as $$
declare
left_room text;
begin
delete from public.chat_online where socket_id = realtime.socket_id() returning room into left_room;
if left_room is not null then
perform realtime.emit('room:' || left_room, 'left', jsonb_build_object('who', auth.uid()));
end if;
end $$;

grant select, insert, delete on public.chat_online to authenticated;
grant select, insert on public.messages to authenticated;
revoke execute on function public.chat_connect(jsonb), public.chat_send(jsonb), public.chat_leave(jsonb) from public, anon;
grant execute on function public.chat_connect(jsonb), public.chat_send(jsonb), public.chat_leave(jsonb) to authenticated;

select realtime.on('connection', 'public.chat_connect(jsonb)', '/');
select realtime.on('send', 'public.chat_send(jsonb)', '/');
select realtime.on('disconnect', 'public.chat_leave(jsonb)', '/');

The client:

socket.on('message', (m) => render(m))
socket.on('joined', ({ who }) => showJoined(who))
socket.on('left', ({ who }) => showLeft(who))

const ack = await socket.emitWithAck('send', { room: '42', body: 'hello' })
if (ack.error) {
showError(ack.error.message)
} else {
markSent(ack.id)
}

Acknowledgements and errors​

An emit that asks for an acknowledgement gets exactly one:

  • The handler's return value, as the single argument. A void handler acks with null.
  • { error: { code, message } } when it did not complete. code is the SQLSTATE your function raised (P0001 for raise exception 'room is full', with that sentence as the message), unauthorized (a grant or a policy refused it), timeout (past 5 seconds), no_handler (nothing is registered for that event), payload_size_exceeded or binary_not_supported.

An emit with no acknowledgement and no handler is dropped, as a Node server with no listener for it drops it. A handler that fails rolls back: nothing it wrote is kept and nothing it emitted is sent. Failures are in snoutdata realtime logs, with the event, the function and the reason.

A connection handler that raises is the connect_error: err.message is its message, and err.data.code its code.

Rooms​

  • Only a handler puts a socket in a room, as only the server can in Socket.IO. A client cannot join one by asking.
  • Every socket starts in socket:<its id>, so a handler can reach one client.
  • A room is a Realtime topic. realtime.send(payload, 'news', 'room:42') from a trigger reaches every socket in room:42, and so does a broadcast from a channel named room:42; an emit to the room reaches those channels too.
  • Rooms are shared across namespaces. room:42 joined from / and from /chat is one topic.

What is different from a Node server​

  • No server-side JavaScript. Everything your handlers do is SQL. Logic that needs npm, an outbound HTTP call or a long computation belongs in a Snout Function your client calls, which can then realtime.send() to the room.
  • Binary from a client is refused, with binary_not_supported: send bytes as base64 in JSON, or upload them to Storage and send the path. Binary to a client works: realtime.send(bytea, event, room) arrives as a Buffer or ArrayBuffer.
  • One handler per event and namespace. There is no chain of listeners for one event.
  • No server-side listing of sockets (fetchSockets, in(room).disconnectSockets), and no volatile or compress flags.
  • Handlers run one at a time per socket, in the order the client sent them, as a single Node process would run them, each for at most 5 seconds.
  • The project's Realtime limits apply as they do to channels: messages a second, payload size, concurrent clients and clients from one address are the plan's, counted together with the project's channels.

Moving a Socket.IO server here​

  1. Move the database. If it is Postgres, it is a pg_dump and snoutdata db restore --file; your ORM needs only the new DATABASE_URL (moving a database).
  2. Turn each listener into a function. Take one socket.on('event', (data, ack) => ...) at a time: what it validates becomes a raise, what it writes becomes an insert (row-level security now does the "is this user allowed" check your code did), what it emits becomes realtime.emit(), and what it passed to ack becomes the return value.
  3. Turn the connection middleware into the connection handler: reading the token is done for you (auth.uid()), so what is left is usually "which rooms does this user belong in".
  4. Replace emits from elsewhere in your backend (a REST endpoint that tells a room something) with realtime.send() in the same SQL that made the change, or a trigger on the table.
  5. Point the client at the project: the URL, path: '/realtime/v1/socket.io/', the anon key in query.apikey and the user's token in auth.token. Nothing else in the client changes.

What needs a container: a process that holds a connection of its own open, such as a client logged in to a messaging network, a broker subscription or a game loop. On a paid plan it can run as a Snout Container, your own image running all the time beside the project. Make it small either way: its state in your project's tables rather than on its disk, realtime.send() (or a broadcast over HTTP) to reach your users, and a table it watches for the work your handlers queue for it. Everything else moves to handlers.

Also read​