RedwoodJS Realtime

To try out RedwoodJS Realtime, visit: RedwoodJS Realtime Demo.

Also, you can clone the Realtime Demo App repository and run yarn rw dev.


Note: The following is legacy information. For up-to-date information, see: the RedwoodJS Docs

Important:

This is an experimental preview feature not currently in any release. It is targeted for v6. It also requires a serverful deployment – it it is not designed for serverless deployments to Vercel, Netlify or AWS Serverless, etc.

Note

These docs are a work in progress and subject to change. Final docs will be released at a later date.

It is intended to develop in the open and share the direction of the Redwood Roadmap under Full-stack Table Stakes: add “missing” self-hosted features.


RedwoodJS offers a real-time solution without having to create your own implementation.

Real-time allows an application to:

  • respond to an event (e.g. AddProductToCart, NewUserNotification)
  • respond to a data change (e.g. Post 123’s title updated)

and have the latest data reflected.

Overview

https://link.excalidraw.com/readonly/8lKtrm4kEfXSxJZV9Cbu

Use Cases

  • Application Alerts and Messages
  • User Notifications
  • Live Charts
  • Location updates
  • Auction bid updates
  • Messaging

Considerations

To ensure your application responds in real-time, it’s important to consider the following factors:

  • How frequently do your users require information updates?
    • Determine the value of “real-time” versus “near real-time” to your users. Do they need to know in less than 1-2 seconds, or is 10, 30, or 60 seconds acceptable for them to receive updates?
    • Consider the criticality of the data update. Is it low, such as a change in shipment status, or higher, such as a change in stock price for an investment app?
    • Consider the cost of maintaining connections and tracking updates across your user base. Is the infrastructure cost justifiable?
    • If you don’t require “real” real-time, consider polling for data updates on a reasonable interval. According to Apollo, in most cases, your client should not use subscriptions to stay up to date with your backend. Instead, you should poll intermittently with queries or re-execute queries on demand when a user performs a relevant action, such as clicking a button.
  • How are you deploying? Serverless or serverful?
    • Real-time options depend on your deployment method.
    • If you are using a serverless architecture, your application cannot maintain a stateful connection to your users’ applications. Therefore, it’s not easy to “push,” “publish,” or “stream” data updates to the web client.
      • In this case, you may need to look for third-party solutions that manage the infrastructure to maintain such stateful connections to your web client, such as Supabase Realtime, SendBird, Pusher, or consider creating your own AWS SNS-based functionality.

Realtime with GraphQL

RedwoodJS offers a first-class developer experience for real-time updates with GraphQL.

In GraphQL, there are two options for real-time updates: live queries and subscriptions. Subscriptions are part of the GraphQL specification, whereas live queries are not.

Regardless of the implementation chosen, a stateful server and store are needed to track changes, invalidation, or who wants to be informed about the change.

Subscriptions

RedwoodJS has a first-class developer experience for GraphQL subscriptions.

Subscribe to Events

  • Granular information on what data changed
  • Why has the data changed?
  • Spec compliant

Example

  1. I subscribed to "NewBid”
  2. "NewBid”
  3. A bid was added to Auction 1
  4. A “NewBid” event to Auction 1 is published
  5. I find out

Live Queries

RedwoodJS has made it super easy to add live queries to your GraphQL server! You can push new data to your clients automatically once the data selected by a GraphQL operation becomes stale by annotating your query operation with the @live directive.

The invalidation mechanism is based on GraphQL ID fields and schema coordinates. Once a query operation has been invalidated, the query is re-executed, and the result is pushed to the client.

Listen for Data Changes

  • I’m not interested in what exactly changed it.
  • Just give me the data.
  • This is not part of the GraphQL specification.
  • There can be multiple root fields.

Example

  1. I listen for changes to Auction 1.
  2. A bid was placed on Auction 1.
  3. The information for Auction 1 is no longer valid.
  4. I am notified.

Technology Notes

We live in a connected world where real-time needs are ever-growing, especially when it comes to the internet, the world’s most connected environment. In the realm of real-time, two main players come to mind: WebSockets and Server-Sent Events.

Choosing Server-Sent Events (abbr. SSE) for your next real-time driven project might sound appealing for several reasons, from simplicity to acceptance. However, you’ll soon discover that SSE has a limitation on the maximum number of open connections when used with HTTP/1 powered servers.

When used over HTTP/2, the maximum number of simultaneous HTTP streams is negotiated between the server and the client (defaults to 100). However, when not using HTTP/2, SSE suffers from a limitation on the maximum number of open connections. This can be especially painful when opening multiple tabs, as the limit is per browser and set to a very low number (6). Unfortunately, this issue has been marked as “Won’t fix” in both Chrome and Firefox. The limit is per browser + domain, meaning that you can open 6 SSE connections across all tabs to www.example1.com, and another 6 SSE connections to www.example2.com. (source: Stackoverflow)

SSE vs WebSocket

Advantages of SSE over WebSockets

  • Transported over simple HTTP instead of a custom protocol
  • Built in support for re-connection and event-id Simpler protocol
  • No trouble with corporate firewalls doing packet inspection

Advantages of WebSockets over SSE

  • Real time, two directional communication.
  • Lower latency

“Stores”

  • In Memory
  • Persisted / Redis

RedwoodJS Project Setup

Setup Server

  • yarn rw exp setup-server-file

Setup Realtime

  • Note: this may be a setup command that will do some of the following
  • Add realtime lib
    • yarn rw setup realtime
    • yarn rw setup realtime —redis
    • yarn rw setup realtime —type=subs, lq — redis
// api/src/lib/realtime.ts

import { createPubSub, InMemoryLiveQueryStore } from '@redwoodjs/graphql-server'

export const liveQueryStore = new InMemoryLiveQueryStore()

// Note: may look like this if we setup with some out of the box subscriptions
// and maybe subscription generator will add to types?
export type PubSubChannels = {
  newMessage: [payload: { from: string; body: string }]
}

// note: may be untyped or the above PubSubChannels is just empty
export const pubSub = createPubSub<PubSubChannels>()
  • Add a api/src/subscriptions/ directory
  • Configure Server GraphQL
// api/src/server.ts

import { pubSub, liveQueryStore } from './lib/realtime'

// ...

await fastify.register(redwoodFastifyGraphQLServer, {
    loggerConfig: {
      logger: logger,
      options: { query: true, data: true, level: 'trace' },
    },
    graphiQLEndpoint: '/.redwood/functions/graphql',
    sdls,
    services,
    directives,
    allowIntrospection: true,
    allowGraphiQL: true,
    realtime: {
      subscriptions: { subscriptions, pubSub },
      liveQueries: { liveQueryStore },
    },
  })
  • note graphiQLEndpoint setting may change; may need one for playground and one for the server endpoint
  • web toml Graphqlapi url → not functions
  • may want to rename since the “i” is hard to see

How to Implement a Subscription

Note: we may implement a yarn rw exp generate-subscription command.

Countdown Example

  • There are no external SDL schema types or service resolvers in this example; all type a resolvers are self-contained in this single Subscription implementation file.
  • The schema defines the countdown subscription type.
  • Upon subscribing, the countdown will begin
// api/src/subscriptions/countdown/countdown.ts

import gql from 'graphql-tag'

export const schema = gql`
  type Subscription {
     countdown: Boolean!
  }
`

const resolvers = {
  countdown: {
    async *subscribe(_, { from, interval }) {
      for (let i = from; i >= 0; i--) {
        await new Promise((resolve) => setTimeout(resolve, interval ?? 1000))
        yield { countdown: true }
      }
    },
  },
}

export default resolvers

New Message Example

  • Here we have the SDL to define the Message type, a query to fwtch the message fro a given room, and the input type and mutation to send a Message to a roomId.
// api/src/graphql/rooms.sdl.ts

export const schema = gql`
  type Message {
    from: String
    body: String
  }

  type Query {
    room(id: ID!): [Message!]! @skipAuth
  }

  input SendMessageInput {
    roomId: ID!
    from: String!
    body: String!
  }

  type Mutation {
    send(input: SendMessageInput!): Message! @skipAuth
  }
`

  • The services here return the id for a room and send publishes a Message on the the pubSub channel. Note that pubSub is available on the context on the service.
// api/src/services/rooms/rooms.ts

export const room = ({ id }) => [id]

export const send = async (
  { input }: { input: { roomId: string; from: string; to: string } },
  { context }: { context: { pubSub } }
) => {
  const { roomId, ...newMessage } = input

  context.pubSub.publish(`NewMessage:${roomId}`, newMessage)

  return input
}
  • The subscription defines the schema and its resolver.
  • One may subscribe to the newMessage for a roomId which gets handles in the subscribe generated function for the newMessage resolver that subscribes to the roomId.
  • When the message is published by the send mutation, the newMessage resolve function returns the payload, i.e. the message’s from and body values.

/// api/src/subscriptions/newMessage/newMessage.ts
import gql from 'graphql-tag'

import { logger } from 'src/lib/logger'

export const schema = gql`
  type Subscription {
    newMessage(roomId: ID!): Message!
  }
`

const resolvers = {
  newMessage: {
    subscribe: (_, { roomId }, { pubSub }) => {
      logger.debug({ roomId }, 'newMessage subscription')
      return pubSub.subscribe(`NewMessage:${roomId}`)
    },
    resolve: (payload) => {
      logger.debug({ payload }, 'newMessage subscription resolve')

      return payload
    },
  },
}

export default resolvers
  • When you mutate to send a message to a room, the NewMessage event is published with the message. It is sent to those who have subscribed to newMessage events for that room. You can subscribe to these events using the useSubscription hook.
// web/src/pages/RoomPage/RoomPage.tsx

import { useSubscription } from '@redwoodjs/web'

const NEW_MESSAGE_SUBSCRIPTION = gql`
  subscription ListenForNewMessages($id: ID!) {
    newMessage(roomId: $id) {
      body
      from
    }
  }
`

function RenderNewMessage({ newMessage }) {
  return (
    newMessage && (
      <div>
        <h4>New Message: {newMessage.body}</h4>
        <h5>From: {newMessage.from}</h5>
      </div>
    )
  )
}

function ListenForNewMessages({ id }) {
  const { data, loading } = useSubscription(NEW_MESSAGE_SUBSCRIPTION, {
    variables: { id },
  })
  return <h4>New Message: {!loading && RenderNewMessage(data)}</h4>
}

const RoomPage = ({ id }) => {
  return (
    <>
      <h1>Room {id}</h1>
      <ListenForNewMessages id={id} />
    </>
  )
}

export default RoomPage

How To Implement a Live Query

  • One implements a LiveQuery as one wuld any GraphQL query with the added step to invalidate the key when the data changes.
  • Here we have the SDL that defines the Auction and Bid types and the operations to query an auction with its bids and the mutation to bid on the auction.
  • When implementing your schema be sure to use uuid and ID resource identifiers

Note: Considering

  • add feature to realtime plugin to auto invalidate on mutations
  • add option to cell generator to add @live to operation in cell generator
// api/src/graphql/auctions.sdl.ts

export const schema = gql`
  type Query {
    auction(id: ID!): Auction @skipAuth
  }

  type Auction {
    id: ID!
    title: String!
    highestBid: Bid
    bids: [Bid!]!
  }

  type Bid {
    amount: Int!
  }

  type Mutation {
    bid(input: BidInput!): Bid @skipAuth
  }

  input BidInput {
    auctionId: ID!
    amount: Int!
  }
`
  • The auctionsservice implements
    • a collection of auction data
    • a resolver to fetch the data for an auction by id
    • a way to bid on an auction which will invalidate the auction by its key in the liveQueryStore obtained from the context
    • a resolver on Auction to determine its highestBid
  • When a bid mutation is made to an auction, all cell queries listening to that auction via the @live directive will get the updated auction, bid and highest bid data.
  • A InMemoryLiveQueryStore instance tracks, invalidates and re-executes registered live query operations.
  • The store will keep track of all root query field coordinates (e.g. Query.todos) and global resource identifiers (e.g. Todo:1). The store can than be notified to re-execute live query operations that select a given root query field or resource identifier by calling the invalidatemethod with the corresponding values.
  • A resource identifier is composed out of the typename and the actual resolved id value separated by a colon, but can be customized. For ensuring that the store keeps track of all your query resources you should always select the id field on your object types. The store will only keep track of fields with the name id and the type ID! (GraphQLNonNull(GraphQLID)).
// api/src/services/auctions/auctions.ts

import { logger } from 'src/lib/logger'

const auctions = [
  { id: '1', title: 'Digital-only PS5', bids: [{ amount: 100 }] },
]

export const auction = async ({ id }) => {
  const foundAuction = auctions.find((a) => a.id === id)
  logger.debug({ id, auction: foundAuction }, 'auction')
  return foundAuction
}

export const bid = async ({ input }, { context }) => {
  const { auctionId, amount } = input

  const index = auctions.findIndex((a) => a.id === auctionId)

  const bid = { amount }

  auctions[index].bids.push(bid)
  logger.debug({ auctionId, bid }, 'Added bid to auction')

  const key = `Auction:${auctionId}`
  context.liveQueryStore.invalidate(key)

  logger.debug({ key }, 'Invalidated auction key in liveQueryStore')

  return bid
}

export const Auction = {
  highestBid: (obj, { root }) => {
    const [max] = root.bids.sort((a, b) => b.amount - a.amount)

    logger.debug({ obj, root }, 'highestBid')

    return max
  },
}
  • When querying the Auction, you can use a standard RedwoodJS Cell that makes a query to fetch the auction by an id.
  • To make this a “live query”, use the @live query directive.
  • Whenever the Auction queried data changes (i.e, when invalidated in the bid mutation) the query will refresh and return any updated data to your Success component.
// auction cell

import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'

export const QUERY = gql`
  query LiveAuctionQuery($id: ID!) @live {
    auction(id: $id) {
      bids {
        amount
      }
      id
      title
      highestBid {
        amount
      }
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }: CellFailureProps) => (
  <div style={{ color: 'red' }}>Error: {JSON.stringify(error)}</div>
)

export const Success = ({ auction }: CellSuccessProps) => {
  return <div>{JSON.stringify(auction)}</div>
}

How It Works

  • useRedwoodRealtime

    • It is automatically added to GraphQL Yoga plugins if realtime is configured.
      • import { context } from '@redwoodjs/graphql-server’
    • It places any stores, such as the liveQueryStore, and pubSub transports for managing subscriptions and invalidation into the context so that they can be used in services or anywhere that the RedwoodJS GraphQL context is available.
  • Simple GraphQL Server Configuration

        realtime: {
          subscriptions: { subscriptions, pubSub },
          liveQueries: { liveQueryStore },
        },
    
  • The RedwoodJS GraphQL Server now creates a Subscription for each subscription file defined in api/src/subscriptions, based on its schema declaration and resolvers, when it assembles the schema from sdl types, services, and directives.

  • This feature automatically adds the @live query directive to the executable schema and generated schema for the web side.

  • SSE and text/event-stream

More Info

For more information on RedwoodJS’s real-time solutions, there are several resources available.

7 Likes

Thanks a lot for this effort! I am really looking forward to this feature set. Read about @live query and subscriptions, too, before. It seems both will be supported and I think its great to have both options. Especially since they probably both will perform differently. @live as more of a convenience approach and subscriptions for more fine grained control. First I thought @live is enough, but reading more about it, subscriptions definitely have their justification.

From your post I could not fully distinguish, if both approaches will be supported and which code examples belong to which approach. There seems to be quite some boilerplate that will need to be documented properly. I am wondering if it would be possible to follow the progress within its own repository? For me it would be easier to try out the code. Or I would just need to try to replicate your explanations. But I am open to give some feedback. Just need to find the time - still have some other PR open in redwood repository :roll_eyes:

1 Like

Awesome work.

I have been running on serverless, but have looked at what things would look like in server land and trying to do this myself.
For now I used Ably to get live events with websockets and it was quite effortless to get up and running.

Thanks for the considerations part, I did not know, that apollo actively advises against it.

Which made me read up a bit more and end up on Queries - Apollo GraphQL Docs which raises the question if Cells should be able to poll? Or do I get the connectino between useQuery and Cells wrong?

Yes… You can set the polling up in the beforeQueey of a Cell/ Cells | RedwoodJS Docs

1 Like

Weird, can you find that via search? Seems like that category is not indexed?

Our Algolia indexing is busted right now… :cry:

1 Like

Hi, using latest canary (7.0.0-canary.402), i got 4 red squiggly lines on server.ts. this didn’t happen before:

  1. on line with DEFAULT_REDWOOD_FASTIFY_CONFIG:
  // Configure Fastify
  const fastify = Fastify({
    ...DEFAULT_REDWOOD_FASTIFY_CONFIG,
  })

type error:

Types of property 'logger' are incompatible.
Two similar types have a property logger which is different, making them incompatible.

can be worked around with copy the original definition from redwood source like so, but i don’t think this is the right solution, it should be just work:

const DEFAULT_REDWOOD_FASTIFY_CONFIG: FastifyServerOptions = {
  requestTimeout: 15_000,
  logger: {
    // Note: If running locally using `yarn rw serve` you may want to adust
    // the default non-development level to `info`
    level:
      process.env.LOG_LEVEL ?? process.env.NODE_ENV === 'development'
        ? 'debug'
        : 'warn',
  },
}
  1. on line with await fastify.register(redwoodFastifyWeb)

  2. on line with await fastify.register(redwoodFastifyAPI)

  3. on line with await fastify.register(redwoodFastifyGraphQLServer)

To reproduce, just install new redwood app, then yarn rw setup exp server-file, then yarn rw setup exp realtime, then open vs-code and open the newly generated server.ts

i am not TS expert, appreciate any help. thanks

Hi, @dthyresson , how can we set the name of the key ? the example code above seems to only assign key name when invalidating, but not when querying.

oh nevermind @dthyresson, i found it. we don’t specify the key, the graphql server does that. i found it when using GraphiQL interface

Correct. The “key” is purely for invalidation – meaning when invalidated the query refreshes automatically.

It isn’t a key like a cache key or something that stores the result.

In your example, the live query is “listening” to see if the “Query.nodeStatus” key is invalidated or the NodeStatus for id 2 is.

1 Like