1gr14/igrich/
  • Menu
    • Home
    • Start0
    • Support
    • Education
    • Group
    • Blog
    • Author
  • Community
    • Discord
    • Telegram
  • Open Source
    • Point0
    • Route0
    • Error0
    • Flat
    • Agents Party
  • Account
    • Sign In
    • Sign Up
1gr14/igrich/
Building open-source software for the glory of the Lord Jesus Christ ☦️
With love for developers of all backgrounds around the world ❤️
Terms of ServicePrivacy PolicySergei Dmitriev 2026 😎

Realtime in Point0: channels, spaces and handlers, typed end to end

Aug 12, 2026#point0#realtime#typescript

It usually goes like this. You have a fullstack project and everything in it is fine. Types run end to end (tRPC, or generated from OpenAPI), auth is there, the features work. Then you decide to add realtime: a notification about a new post in the feed, a chat between users, an interactive board. And a whole new layer of abstractions shows up, one where you reinvent everything the project already has, only differently. From then on you maintain two separate systems.

Point0 has four new realtime points (structural units, the same kind of thing as pages, layouts, queries and mutations): channel, space, client handler, server handler. Almost any realtime feature is built out of those four, the code stays short, and it reads the way it works. These points carry the same properties as every other point:

  • server and client code live in one file, and the compiler cuts the client code out of the server build and the server code out of the client build;
  • types run end to end, inferred from the framework's own generics, with no code generation.

Below I go through it on examples and explain the paradigm, so that you can build any realtime app on it.

The paradigm

The examples make the paradigm obvious, but until you have seen them it reads heavy. And without it the examples are hard to follow. So skim this now and come back to it after the examples. Every paragraph here links into the matching section of the socket documentation, which says the same things in full detail.

One WebSocket per client application. Everything else is abstractions on top of it. Top to bottom.

A channel is the connection. The client connects through the server's .connector, which returns the connection's identity. It is stored on the server, never visible to the client, and present in every later action on the channel (every handler, joiner and selection reads it). Every live connection is a connectionId. The connect itself is an ordinary HTTP request, so headers, cookies and middleware work as everywhere else. Only the messages after it travel the socket.

A space grows out of a channel. It is a family of rooms of one shape. The client enters through the server's .joiner, which decides which rooms it gets. Or the server enrolls connections into rooms on its own, with no client ask, through .enroller. A room is the unit of addressing, a pub/sub topic that pushes target by name.

A server handler is a client to server call. The client sends a typed message with sendToServer, the server answers in .serverReply, and on the client all of that can look like an ordinary query or mutation.

A client handler is a server to client push. The server sends with sendToClient to a target (a room, a connection, a selection), subscribed components receive the message and can answer back.

On the client all of it is held by hooks and components (useConnection, <Connection>, useMembership, .with(channel)), the socket reconnects on its own, and a resumable channel makes reconnects cheap. Push delivery is best-effort by design, the truth lives in queries.

On one process everything runs in local memory. For several processes you plug a backplane into the engine: Redis by URL, Postgres, a Redis client you already run, or any KV with pub/sub. The server also has admin commands (kick, kill, refresh) and enumerations for who is connected right now, and exposes metrics.

Setup

In every example below I assume you already have auth and Prisma. Where prisma comes from, how getUserFromRequest works, what AppError is: none of that matters here, it is your ordinary code, the code that was in the project before sockets.

Sockets are off by default and turn on with one line in the engine config. While they are off, their code does not reach the browser at all.

// engine.ts
export const engine = Engine.create({
  server: { socket: true },
})

The channel is declared once per application.

// lib/channel.ts
import { root } from '@/lib/root'
import { getUserFromRequest } from '@/lib/auth' // your ordinary auth, the same one HTTP uses

export const appChannel = root.lets
  .channel()
  .connector(async ({ request }) => {
    // the connect is an ordinary HTTP request: cookies, headers, middleware all apply
    const user = await getUserFromRequest(request)
    // whatever the connector returns IS the connection's identity. It stays on the server,
    // the client never sees it, and it is available in every later action on this connection.
    // The shape is yours: put anything serializable in there, a role, a plan, a UI language.
    // No type is declared anywhere, it is inferred from right here
    return user
      ? { authorized: true as const, id: user.id, name: user.name }
      : { authorized: false as const, id: null, name: null }
  })
  .channel()

The as const on the flag is not decoration: without it TypeScript widens true to boolean, the union stops being discriminated, and identity.id does not narrow to a string after the check. Nothing else to write, the identity type spreads to every connector, joiner and handler on its own.

We keep the channel open to everyone, guests included. Watching is open, and every action is gated where it runs: on the server, where the identity can be trusted. If the whole app is closed, the connector can throw and there will be no connection at all.

The connection is held at the app root. One per tab, reused by every space on every page.

// app.client.tsx
<appChannel.Connection>
  <RouterRoutes />
</appChannel.Connection>

That is all the ceremony. Features from here on.

Example 1. Notifying everyone about a new post in the feed

Say you run a site with a feed of posts. People write posts, and you want everyone else who is online to see a notification the moment one appears.

No rooms are needed here: the audience is "everyone connected". So one client handler growing straight out of the channel is enough.

// pages/feed.tsx

// server to client. The schema describes the payload, the client reads it typed
export const postAddedHandler = appChannel.lets
  .clientHandler()
  .serverSend(
    z.object({
      id: z.string(),
      title: z.string(),
      authorId: z.string(),
      authorName: z.string(),
    }),
  )
  .clientHandler()

// your ordinary HTTP mutation that creates a post. Realtime added one line to it
export const postCreateMutation = root.lets
  .mutation()
  .input(z.object({ title: z.string().min(1) }))
  .loader(async ({ input, request }) => {
    const user = await getUserFromRequest(request)
    if (!user) throw new AppError('Sign in first', { status: 401 })

    const post = await prisma.post.create({
      data: { title: input.title, authorId: user.id },
    })

    // with no target the push goes to every connection of the channel, guests included.
    // We do not await it: delivery is a signal, not a part of the transaction
    void postAddedHandler.sendToClient({
      id: post.id,
      title: post.title,
      authorId: user.id,
      authorName: user.name,
    })

    return post
  })
  .mutation()

The post form is an ordinary form with an ordinary mutation, sockets do not touch it:

const PostForm = () => {
  const create = postCreateMutation.useMutation()
  const [title, setTitle] = useState('')

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        void create
          .mutateAsync({ title })
          .then(() => setTitle(''))
          .catch((error) => alert(error.message))
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button disabled={create.isPending}>Publish</button>
    </form>
  )
}

On the client the subscription is one hook. No join, no rooms, no manual connecting: the app root already holds the connection.

const NewPostsBanner = () => {
  const { user } = useUser() // your ordinary client-side auth hook
  const [fresh, setFresh] = useState<{ id: string; title: string }[]>([])

  // message is typed by the .serverSend schema, with no code generation
  postAddedHandler.useOnMessageFromServer(({ message }) => {
    if (message.authorId === user?.id) return // my own post, no banner for me
    setFresh((prev) => [message, ...prev])
  })

  if (fresh.length === 0) return null

  return (
    <button
      onClick={() => {
        void feedQuery.invalidateQuery() // the truth lives in the query, the push only said it went stale
        setFresh([])
      }}
    >
      New posts: {fresh.length}. Show
    </button>
  )
}

This shows the main rule of delivery: a push is a signal, "something moved, look again", and never the only copy of the data. The copy is in the database and is read by an ordinary query. That is why a lost push (the client was reconnecting, the server was redeployed) costs nothing: the next refetch brings the truth anyway.

A word about that if (message.authorId === user?.id) return line. The server can exclude an addressee too, a push takes a target:

// do not do this
void postAddedHandler.sendToClient(payload, {
  $identity: { id: { $ne: user.id } },
})

A key with $ means a Mongo-style selection (run by sift), a key without $ means an exact address. And that is the whole difference: an exact address lands in a pub/sub topic, one publish for any number of receivers, while a selection walks every connection on every process. To keep one person from seeing their own post, we turn a cheap broadcast into a scan over the entire connection registry.

So the right version is the one in the component above: put authorId into the message and compare it on the client with the current user. The client has enough of its own data, useUser is already in the project. The built-in echo suppression works exactly the same way, by the way: except by connectionId does not remove the frame from the fan-out, it reaches the client and the client drops it.

$identity selections stay for what they were made for: the rare admin fan-out, where a scan is affordable.

Example 2. An interactive board with many participants

There is a board people draw on, in this example they just place dots. The board is ephemeral, you only see the dots that appeared after you connected. Everyone can watch, only signed-in people can place dots.

Here we do need a room, because the drawing people form a group the server pushes to. There is only one room, shared, so the space takes no room shape generic.

// pages/board.tsx

const dotSchema = z.object({ x: z.number(), y: z.number(), userId: z.string() })
type Dot = z.infer<typeof dotSchema>

// with no generic the room shape is the empty object. That is a special case: such a space
// has exactly one room in the whole project. In the next examples a real data structure
// takes its place, and there will be many rooms
export const boardSpace = appChannel.lets
  .space()
  // .joiner is what makes a space enterable from the client at all.
  // We admit everyone, guests included: watching the board is open
  .joiner(() => ({}))
  .space()

// client to server: I clicked at (x, y)
export const dotPutHandler = boardSpace.lets
  .serverHandler()
  .clientSend(z.object({ x: z.number(), y: z.number() }))
  .serverReply(async ({ input, identity }) => {
    // the gate lives here, on the server, where the identity can be trusted.
    // A disabled cursor on the client is a courtesy, not the rule
    if (!identity.authorized) {
      throw new AppError('Sign in to draw', { status: 401 })
    }
    const dot = { x: input.x, y: input.y, userId: identity.id }
    void dotAddedHandler.sendToClient(dot) // a bare send = everyone in the space
    return dot
  })
  .serverHandler()

// server to client: a dot appeared on the board
export const dotAddedHandler = boardSpace.lets
  .clientHandler()
  .serverSend(dotSchema)
  .clientHandler()

The component enters the room while it is on screen, and listens for pushes.

const Board = () => {
  const membership = boardSpace.useMembership()
  const [dots, setDots] = useState<Dot[]>([])

  dotAddedHandler(membership).useOnMessageFromServer(({ message }) => {
    setDots((prev) => [...prev, message])
  })

  return (
    <div
      className="board"
      onClick={(e) => {
        const box = e.currentTarget.getBoundingClientRect()
        void dotPutHandler(membership)
          .sendToServer({
            x: (e.clientX - box.left) / box.width,
            y: (e.clientY - box.top) / box.height,
          })
          .catch((error) => alert(error.message)) // the server error arrives here, typed
      }}
    >
      {dots.map((dot, i) => (
        <span
          key={i}
          style={{ left: `${dot.x * 100}%`, top: `${dot.y * 100}%` }}
        />
      ))}
    </div>
  )
}

Note there is no database here at all, and that is not a simplification for the article. The dots live in component state because the board is ephemeral by definition. If you wanted to keep them, you would add prisma.dot.create to .serverReply and an ordinary query for the history, exactly as in the next example.

The membership in dotPutHandler(membership) is the address: a space handler always sends into a room. This membership holds one room, so it can be passed in place of it.

A fair question: if there is a single room anyone can enter, why have it at all, the channel does the same thing. Because entering a room is a subscription the client controls. Until a user opens the board they have not entered the room, and dot pushes do not reach them at all. Opened it, entered, receiving. Left the page, the component unmounted, the subscription is gone. A channel handler cannot do that: the channel is one per app, and its pushes reach everyone, always.

Example 3.1. A shared chat between users

There is a chat, messages are stored in the database. Opening it loads the history, after that new messages arrive as they come. Only signed-in people can read it.

Every chat is a room. The room shape is declared at the space opener, roughly the way a component declares its props, and everything down the chain knows about it.

// pages/chat.tsx

const messageSchema = z.object({
  id: z.number(),
  chatId: z.string(),
  authorId: z.string(),
  text: z.string(),
  createdAt: z.date(),
})

export const chatSpace = appChannel.lets
  // the generic is the room shape. A room can be any object your domain likes,
  // { chatId } here is just one special case
  .space<{ chatId: string }>()
  .input(z.object({ chatId: z.string() })) // this is what the client passes to join, not the room
  .joiner(async ({ input, identity }) => {
    // entering the room IS the gate on reading the chat
    if (!identity.authorized) {
      throw new AppError('Sign in first', { status: 401 })
    }
    // the return is checked against the room shape from the generic.
    // An extra key here is a type error, not a detail: a room's serialization
    // is its address, so with an extra key this would be a different room
    return { chatId: input.chatId }
  })
  .space()

// client to server: persist the message, then fan it out to the room
export const messageSendHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ text: z.string().min(1).max(1000) }))
  .serverReply(async ({ input, identity, room }) => {
    // the joiner never let a guest into this room, but TypeScript does not know that,
    // and one more check on the server is no sin
    if (!identity.authorized) {
      throw new AppError('Sign in first', { status: 401 })
    }
    const message = await prisma.message.create({
      data: { text: input.text, chatId: room.chatId, authorId: identity.id },
    })
    // an exact room address, a pub/sub topic, not a walk over connections
    void messageAddedHandler.sendToClient(message, { room })
    return message // this is what the sender gets back from its sendToServer
  })
  .serverHandler()

// server to client: a new message in the room
export const messageAddedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  .clientHandler()

// the history is an ordinary HTTP query and the source of truth. It has its own check:
// the socket and HTTP are two different doors, and both need locking
export const messagesQuery = root.lets
  .query()
  .input(z.object({ chatId: z.string() }))
  .loader(async ({ input, request }) => {
    const user = await getUserFromRequest(request)
    if (!user) throw new AppError('Sign in first', { status: 401 })
    return {
      messages: await prisma.message.findMany({
        where: { chatId: input.chatId },
        orderBy: { id: 'asc' },
        take: 100,
      }),
    }
  })
  .query()

The component: enter the room, read the history from the query, and let the push write the new message straight into that same query's cache.

const Chat = ({ chatId }: { chatId: string }) => {
  const membership = chatSpace.useMembership({ chatId })
  const { data } = messagesQuery.useQuery({ chatId })
  const [text, setText] = useState('')

  messageAddedHandler(membership).useOnMessageFromServer(({ message }) => {
    // the push carried the ready data, so no request for it is needed
    messagesQuery.setQueryData({ chatId }, (old) => ({
      messages: [...(old?.messages ?? []), message],
    }))
  })

  return (
    <>
      <ul>
        {data?.messages.map((message) => (
          <li key={message.id}>{message.text}</li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          setText('')
          void messageSendHandler(membership)
            .sendToServer({ text })
            .catch((error) => alert(error.message))
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button disabled={membership.status !== 'joined'}>Send</button>
      </form>
    </>
  )
}

If a guest was not admitted, membership.status becomes error and membership.error holds the typed error from the joiner. There is no separate "I am not in this chat" state to invent, it is already there.

One question always comes up with chats: what about the messages sent while the client had no network. A push is one-way delivery, and if there is no socket at that moment the frame is simply lost. In the shape above there is one answer: refetch the history after reconnecting.

const membership = chatSpace.useMembership(
  { chatId },
  // entering the room happens again after every reconnect,
  // so this is also where we catch up
  { onEnter: () => void messagesQuery.invalidateQuery({ chatId }) },
)

It works, but it rereads the whole history after every network blink. Next I show how to avoid that.

Example 3.2. The same chat, without the pointless rereads

A reconnect is usually short: a subway, an elevator, wifi blinked. Two seconds of downtime with one message posted into the room. Rereading a hundred messages for its sake is silly, you just want to catch up on what you missed.

That is what the channel's resumable is for. On reconnect the client presents its connection key, and the server restores the connection from its own record: the same identity, the same rooms, the same connectionId. The connector and the joiners do not run, which means a redeploy does not hit the server with an avalanche of full connects.

// lib/channel.ts
export const appChannel = root.lets
  .channel()
  .connector(/* the same connector, nothing changes */)
  .channel({ resumable: true })

resumable on the channel alone is not enough: it restores the connection, not the pushes that flew into the gap. The buffer is turned on for the handler whose messages you do not want to lose.

export const messageAddedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  // the server keeps the last 128 frames of this handler per room
  // and replays them when the client comes back, in the original order
  .clientHandler({ resumable: true })

Now a client returning from a gap receives the missed messages as ordinary pushes. They land in the same useOnMessageFromServer and go into the query cache. No history refetch needed.

But the buffer is finite, and that matters: it lives in process memory. Half an hour offline, a redeploy, a buffer overflow, and there will be a hole after all. So Point0 does not make you guess, it tells you whether the gap was covered.

const membership = chatSpace.useMembership(
  { chatId },
  {
    onEnter: ({ gapless }) => {
      // gapless is the server's proof that nothing was lost:
      // either this is the first entry, or the buffer covered the whole gap.
      // With the proof we do nothing, the missed messages are already replayed.
      // Without it we honestly reread the history
      if (!gapless) void messagesQuery.invalidateQuery({ chatId })
    },
  },
)

One condition covers every case: the first entry, a short blink, a long offline stretch, a redeploy, a comeback after a kick. You do not keep track of what you might have missed, the server keeps it for you.

The truth about the messages still lives in the database, not in the buffer. The buffer is an optimization that removes needless requests in the common case, not a storage.

Example 4. Direct messages between users

Any signed-in user can write to another one. Messages are stored in the database. On top of that, the recipient should see a badge for a new message even when the conversation is not open.

The conversation room is a pair of users. A room can be any object you like, but its serialization is its address, so the member list has to be sorted: otherwise [a, b] and [b, a] would give two different rooms.

// pages/dm.tsx

// dm is a direct message, one user writing to another
const dmSchema = z.object({
  id: z.number(),
  fromId: z.string(),
  toId: z.string(),
  text: z.string(),
  createdAt: z.date(),
})

export const dmSpace = appChannel.lets
  .space<{ members: string[] }>()
  .input(z.object({ withUserId: z.string() }))
  .joiner(({ input, identity }) => {
    if (!identity.authorized) {
      throw new AppError('Sign in first', { status: 401 })
    }
    // sort is mandatory: a room is addressed by its serialization
    return { members: [identity.id, input.withUserId].sort() }
  })
  .space()

export const dmAddedHandler = dmSpace.lets
  .clientHandler()
  .serverSend(dmSchema)
  .clientHandler()

The badge is a second space, and you get into it in a completely different way. Until now the client entered every room itself, through .joiner: wanted in, entered, left the page, left the room. Here it is the other way around, .enroller does the work: the server enrolls the connection into the room the moment it connects to the channel, the client is not asked, and it cannot leave.

// a personal room per user
export const userSpace = appChannel.lets
  .space<{ userId: string }>()
  // .enroller runs on the server at connection setup, before
  // the client has rendered anything
  .enroller(({ identity }) =>
    // a guest has no personal room, and that is fine: return nothing, enroll into nothing
    identity.authorized ? { userId: identity.id } : undefined,
  )
  .space() // no .joiner, which means this space cannot be entered from the client at all

export const dmBadgeHandler = userSpace.lets
  .clientHandler()
  .serverSend(z.object({ fromUserId: z.string(), preview: z.string() }))
  .clientHandler()

Sending writes the message to the database and makes two pushes: one into the conversation room, so the other person sees the message in an open window, and one into the recipient's personal room, so the badge lights up on any page.

export const dmSendHandler = dmSpace.lets
  .serverHandler()
  .clientSend(z.object({ text: z.string().min(1) }))
  .serverReply(async ({ input, identity, room }) => {
    if (!identity.authorized) {
      throw new AppError('Sign in first', { status: 401 })
    }
    const toUserId =
      room.members.find((id) => id !== identity.id) ?? identity.id

    const dm = await prisma.dm.create({
      data: { text: input.text, fromId: identity.id, toId: toUserId },
    })

    void dmAddedHandler.sendToClient(dm, { room }) // into the conversation room
    void dmBadgeHandler.sendToClient(
      { fromUserId: identity.id, preview: dm.text.slice(0, 80) },
      { room: { userId: toUserId } }, // into the recipient's personal room
    )

    return dm
  })
  .serverHandler()

On the client the badge is a component you can hang in the header and forget. No join, no rooms in the client code: the server enrolled the connection already.

const DmBadge = () => {
  const [unread, setUnread] = useState(0)

  dmBadgeHandler.useOnMessageFromServer(({ message }) => {
    setUnread((n) => n + 1)
    toast(`${message.fromUserId}: ${message.preview}`)
  })

  return unread > 0 ? <span className="badge">{unread}</span> : null
}

The conversation itself is built like the chat from example 3.1, only the entry is by the other person:

const Dm = ({ withUserId }: { withUserId: string }) => {
  const membership = dmSpace.useMembership({ withUserId })
  const { data } = dmHistoryQuery.useQuery({ withUserId })
  const [text, setText] = useState('')

  dmAddedHandler(membership).useOnMessageFromServer(({ message }) => {
    dmHistoryQuery.setQueryData({ withUserId }, (old) => ({
      items: [...(old?.items ?? []), message],
    }))
  })

  return (
    <>
      <ul>
        {data?.items.map((dm) => (
          <li key={dm.id}>{dm.text}</li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          setText('')
          void dmSendHandler(membership)
            .sendToServer({ text })
            .catch((error) => alert(error.message))
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button disabled={membership.status !== 'joined'}>Send</button>
      </form>
    </>
  )
}

The difference between .joiner and .enroller is not cosmetic here. The client entered the conversation room itself and can leave it by closing that tab. An enrollment is a guarantee: leave() on the personal room does nothing, and neither does a hand-built frame, only the server can take the connection out of it. That is why a push into the personal room can be relied on: while the connection is open, it provably sits in its own room.

The initial unread count, of course, is read from the database by an ordinary query, and pushes only bump the counter while the tab is open.

Example 5. A site where every request travels the WebSocket

Nothing stops you from dropping HTTP calls to your API entirely and running everything through the socket that is already open. If you can afford a connection per active user, the interface gets noticeably faster: no TLS handshake, no headers, the connection is already warm.

For that a server handler can declare what it will be for the client: an ordinary query, an infinite query, or a mutation. One line in the chain, and instead of sendToServer the handler grows the familiar set of hooks.

// pages/todos.tsx

// client to server, but it looks like a query: the same react-query, only the transport is the socket
export const todosHandler = appChannel.lets
  .serverHandler()
  .clientSend(z.object({ onlyOpen: z.boolean() }))
  .serverReply(async ({ input, identity }) => {
    if (!identity.authorized) {
      throw new AppError('Sign in first', { status: 401 })
    }
    return {
      todos: await prisma.todo.findMany({
        where: {
          userId: identity.id,
          done: input.onlyOpen ? false : undefined,
        },
      }),
    }
  })
  .query() // that is the whole declaration
  .serverHandler()

// and this one is a mutation. Mutation is the default, .mutation() can be omitted
export const todoAddHandler = appChannel.lets
  .serverHandler()
  .clientSend(z.object({ title: z.string().min(1) }))
  .serverReply(async ({ input, identity }) => {
    if (!identity.authorized) {
      throw new AppError('Sign in first', { status: 401 })
    }
    const todo = await prisma.todo.create({
      data: { title: input.title, userId: identity.id },
    })
    // to every tab of this user, through their personal room from example 4.
    // An exact address, no walking over connections
    void todosChangedHandler.sendToClient(undefined, {
      room: { userId: identity.id },
    })
    return todo
  })
  .mutation()
  .serverHandler()

// a bare trigger with no payload: "the data changed, read it again".
// It grows from userSpace, that is from the user's personal room (example 4)
export const todosChangedHandler = userSpace.lets
  .clientHandler()
  .clientHandler()

On the client this is the ordinary react-query you are used to.

const Todos = () => {
  const { data, isPending } = todosHandler.useSocketQuery({ onlyOpen: true })
  const add = todoAddHandler.useSocketMutation()

  todosChangedHandler.useOnMessageFromServer(() => {
    void todosHandler.invalidateSocketQuery(true) // the refetch leaves over the socket
  })

  if (isPending) return <Spinner />

  return (
    <>
      {(data?.todos ?? []).map((todo) => (
        <div key={todo.id}>{todo.title}</div>
      ))}
      <button onClick={() => void add.mutateAsync({ title: 'New task' })}>
        Add
      </button>
    </>
  )
}

The cache, invalidation, statuses and react-query devtools work as usual. The only difference is that the request leaves as a frame in an open socket instead of a separate HTTP request.

Two honest caveats. First, socket queries take no part in SSR, because there is no connection on the server, so the data of the first paint is better left to ordinary queries and the socket is for what lives after hydration. Second, a connection per active user is memory on the server, and on several processes you will need a backplane.

What I left out

To keep this from turning into the documentation, quite a lot stayed outside.

Presence, that is "who is in the room right now", is built from the enumerations (space.memberships.server.list) and the server-side enter and leave events. The ready recipe is in the documentation and in the example app.

Admin commands: kill closes connections, space.kick removes them from rooms, space.enroll does the opposite, refresh re-judges the identity without dropping the socket, amendIdentity patches it in place. All of them take the same target dictionary the pushes use.

Several processes: the backplane is plugged into the engine config. For Redis it is one line with a URL. There are ready adapters for Postgres (a LISTEN/NOTIFY bus, when there is no Redis in the project), ioredis and node-redis. Or your own object of five functions, if you run something else.

The resumable from example 3.2 has knobs I did not touch: how long the connection record lives, how many frames and bytes to keep in the buffer, whether a given space can opt out of restoring, and what to do with a partially covered gap (for a stream of document patches, say, a tail without its head is useless and is better withheld entirely).

There is also collecting client answers to a push, replying early from .serverReply, streaming LLM tokens through a client handler, guards and audit per message, origin restrictions for the handshake, metrics. All of it is in the socket documentation.

Wrapping up

Realtime in Point0 is not a separate subsystem with its own auth, its own types and its own routing. It is four new kinds of points that live next to pages and queries, read the same identity as the rest of the server, and are typed the same way as everything else. A feature file reads top to bottom: the room, the handlers, the component.

Sockets are the youngest part of the framework, and I say so in the documentation too: the API design has settled and I am not going to change it, while what is underneath still needs a refactor. If something breaks, open an issue.

A working app with all of this: examples/socket, where the chat, the board, the notifications and presence each live in their own file. And if you got here without knowing the framework itself, start with the Point0 overview.

  • Socket documentation
  • The whole example app
  • All Point0 documentation
  • GitHub repository
All articles

Extra

Community

Questions and chat — English on Discord, Russian on Telegram
DiscordTelegram

Social

Videos and posts from around the web
YouTubeTwitter

Private group

A paid community with mentorship, where everyone builds their own IT product
About the group

Start0

The SaaS boilerplate on Point0 — the fastest way to start your own product
Explore Start0

Comments

to leave a comment
No comments yet. Be the first.