Skip to content

Commit

Permalink
feature: Support defer and stream GraphQL Directives in RedwoodRealti…
Browse files Browse the repository at this point in the history
…me (#9235)

Stream and defer are directives that allow you to improve latency for
clients by sending data the most important data as soon as it's ready.

As applications grow, the GraphQL operation documents can get bigger.

The server will only send the response back once all the data requested
in the query is ready. However, not all requested data is of equal
importance, and the client may not need all of the data at once.

See:
https://the-guild.dev/graphql/yoga-server/docs/features/defer-stream

This PR:

* adds useDeferStream plugin to useRedwoodRealtime
* adds option to enable plugin
* fixes useRedwoodError on Execute where the result only includes
result.data but not other result info like incremental or path
information that a Repeater ReadableStream needs
* adds Repeater to write safe async resolvers:
https://the-guild.dev/graphql/yoga-server/docs/features/defer-stream#writing-safe-stream-resolvers

### TODO:

- [x] document
- [x] add examples to realtime
- [x] change countdown subscription to use Repeater
- [x] check if directives are supported in React with Apollo Client
- note: defer supported by Apollo Client, stream is not
- [x] check the client gql use of queries works with code gen
- [ ] test error/exception handling in a Repeater push

### Examples

```graphql
export const schema = gql`
  type Query {
    alphabet: [String!]! @skipAuth
    """
    A field that resolves fast.
    """
    fastField: String! @skipAuth

    """
    A field that resolves slowly.
    Maybe you want to @defer this field ;)
    """
    slowField(waitFor: Int! = 5000): String @skipAuth
  }
`
```

and services:

```ts
import { Repeater } from '@redwoodjs/realtime'

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

export const alphabet = async () => {
  return new Repeater<string>(async (push, stop) => {
    const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
    const publish = () => {
      const value = values.shift()

      if (value) {
        logger.debug({ value }, 'publishing')

        push(value)
      }

      if (values.length === 0) {
        stop()
      }
    }

    const interval = setInterval(publish, 1000)

    stop.then(() => {
      logger.debug('cancel')
      clearInterval(interval)
    })

    publish()
  })
}

const wait = (time: number) =>
  new Promise((resolve) => setTimeout(resolve, time))

export const fastField = async () => {
  await wait(100)
  return 'I am speedyyy'
}

export const slowField = async (_, { waitFor = 5000 }) => {
  await wait(waitFor)
  console.log('slowField')
  return 'I am slowey'
}
```


https://github.com/redwoodjs/redwood/assets/1051633/020812ed-81fd-41fb-9066-2493820da1ba

### What does incremental stream look like?

When making the request with the `@stream` directive:

```bash
curl -g -X POST \
  -H "accept:multipart/mixed" \
  -H "content-type: application/json" \
  -d '{"query":"query StreamAlphabet { alphabet @stream }"}' \
  http://localhost:8911/graphql
```

Here you see the initial response has `[]` for alphabet data.

Then on each push to the Repeater, an `incremental` update the the list
of letters is sent.

The stream ends when `hasNext` is `false`:

```bash
* Connected to localhost (127.0.0.1) port 8911 (#0)
> POST /graphql HTTP/1.1
> Host: localhost:8911
> User-Agent: curl/8.1.2
> accept:multipart/mixed
> content-type: application/json
> Content-Length: 53
>
< HTTP/1.1 200 OK
< connection: keep-alive
< content-type: multipart/mixed; boundary="-"
< transfer-encoding: chunked
< Date: Mon, 25 Sep 2023 18:47:57 GMT
<
---
Content-Type: application/json; charset=utf-8
Content-Length: 39

{"data":{"alphabet":[]},"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["a"],"path":["alphabet",0]}],"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["b"],"path":["alphabet",1]}],"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["c"],"path":["alphabet",2]}],"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["d"],"path":["alphabet",3]}],"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["e"],"path":["alphabet",4]}],"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["f"],"path":["alphabet",5]}],"hasNext":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 70

{"incremental":[{"items":["g"],"path":["alphabet",6]}],"hasNext":true}
---
...

---
Content-Type: application/json; charset=utf-8
Content-Length: 17

{"hasNext":false}
-----

```

---------

Co-authored-by: Josh GM Walker <[email protected]>
  • Loading branch information
2 people authored and jtoar committed Nov 1, 2023
1 parent 1b187aa commit 3d22dc7
Show file tree
Hide file tree
Showing 15 changed files with 966 additions and 8 deletions.
709 changes: 709 additions & 0 deletions docs/docs/realtime.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ module.exports = {
'prerender',
'project-configuration-dev-test-build',
'redwoodrecord',
'realtime',
'router',
'schema-relations',
'security',
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/commands/experimental/setupRealtimeHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,126 @@ export async function handler({ force, includeExamples, verbose }) {
]
},
},

{
title: 'Adding Defer example queries ...',
enabled: () => includeExamples,
task: () => {
// sdl

const exampleSdlTemplateContent = fs.readFileSync(
path.resolve(
__dirname,
'templates',
'defer',
'fastAndSlowFields',
`fastAndSlowFields.sdl.template`
),
'utf-8'
)

const sdlFile = path.join(
redwoodPaths.api.graphql,
`fastAndSlowFields.sdl.${isTypeScriptProject() ? 'ts' : 'js'}`
)

const sdlContent = ts
? exampleSdlTemplateContent
: transformTSToJS(sdlFile, exampleSdlTemplateContent)

// service

const exampleServiceTemplateContent = fs.readFileSync(
path.resolve(
__dirname,
'templates',
'defer',
'fastAndSlowFields',
`fastAndSlowFields.ts.template`
),
'utf-8'
)
const serviceFile = path.join(
redwoodPaths.api.services,
'fastAndSlowFields',
`fastAndSlowFields.${isTypeScriptProject() ? 'ts' : 'js'}`
)

const serviceContent = ts
? exampleServiceTemplateContent
: transformTSToJS(serviceFile, exampleServiceTemplateContent)

// write all files
return [
writeFile(sdlFile, sdlContent, {
overwriteExisting: force,
}),
writeFile(serviceFile, serviceContent, {
overwriteExisting: force,
}),
]
},
},

{
title: 'Adding Stream example queries ...',
enabled: () => includeExamples,
task: () => {
// sdl

const exampleSdlTemplateContent = fs.readFileSync(
path.resolve(
__dirname,
'templates',
'stream',
'alphabet',
`alphabet.sdl.template`
),
'utf-8'
)

const sdlFile = path.join(
redwoodPaths.api.graphql,
`alphabet.sdl.${isTypeScriptProject() ? 'ts' : 'js'}`
)

const sdlContent = ts
? exampleSdlTemplateContent
: transformTSToJS(sdlFile, exampleSdlTemplateContent)

// service

const exampleServiceTemplateContent = fs.readFileSync(
path.resolve(
__dirname,
'templates',
'stream',
'alphabet',
`alphabet.ts.template`
),
'utf-8'
)
const serviceFile = path.join(
redwoodPaths.api.services,
'alphabet',
`alphabet.${isTypeScriptProject() ? 'ts' : 'js'}`
)

const serviceContent = ts
? exampleServiceTemplateContent
: transformTSToJS(serviceFile, exampleServiceTemplateContent)

// write all files
return [
writeFile(sdlFile, sdlContent, {
overwriteExisting: force,
}),
writeFile(serviceFile, serviceContent, {
overwriteExisting: force,
}),
]
},
},
{
title: 'Adding config to redwood.toml...',
task: (_ctx, task) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const schema = gql`
type Query {
"""
A field that resolves fast.
"""
fastField: String! @skipAuth

"""
A field that resolves slowly.
Maybe you want to @defer this field ;)
"""
slowField(waitFor: Int! = 5000): String @skipAuth
}
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { logger } from 'src/lib/logger'

const wait = (time: number) =>
new Promise((resolve) => setTimeout(resolve, time))

export const fastField = async () => {
return 'I am fast'
}

export const slowField = async (_, { waitFor = 5000 }) => {
logger.debug('waiting on slowField')
await wait(waitFor)
return 'I am slow'
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import subscriptions from 'src/subscriptions/**/*.{js,ts}'
* Redwood Realtime
* - uses a publish/subscribe model to broadcast data to clients.
* - uses a store to persist Live Query and Subscription data.
* - and enable defer and stream directives to improve latency
* for clients by sending data the most important data as soon as it's ready.
*
* Redwood Realtime supports in-memory and Redis stores:
* - In-memory stores are useful for development and testing.
Expand All @@ -39,4 +41,6 @@ export const realtime: RedwoodRealtimeOptions = {
// if using a Redis store
// store: { redis: { publishClient, subscribeClient } },
},
// To enable defer and streaming, set to true.
// enableDeferStream: true,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const schema = gql`
type Query {
"""
A field that spells out the letters of the alphabet
Maybe you want to @stream this field ;)
"""
alphabet: [String!]! @skipAuth
}
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Repeater } from '@redwoodjs/realtime'

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

export const alphabet = async () => {
return new Repeater<string>(async (push, stop) => {
const letters = 'abcdefghijklmnopqrstuvwxyz'.split('')

const publish = () => {
const letter = letters.shift()

if (letter) {
logger.debug({ letter }, 'publishing letter...')
push(letter)
}

if (letters.length === 0) {
stop()
}
}

const interval = setInterval(publish, 1000)

stop.then(() => {
logger.debug('cancel')
clearInterval(interval)
})

publish()
})
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import gql from 'graphql-tag'

import { Repeater } from '@redwoodjs/realtime'

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

export const schema = gql`
type Subscription {
countdown(from: Int!, interval: Int!): Int! @requireAuth
Expand All @@ -15,14 +19,39 @@ export const schema = gql`
*/
const countdown = {
countdown: {
async *subscribe(_, { from = 100, interval = 10 }) {
while (from >= 0) {
yield { countdown: from }
// pause for 1/4 second
await new Promise((resolve) => setTimeout(resolve, 250))
from -= interval
subscribe: (
_,
{
from = 100,
interval = 10,
}: {
from: number
interval: number
}
},
) =>
new Repeater((push, stop) => {
function decrement() {
from -= interval

if (from < 0) {
logger.debug({ from }, 'stopping as countdown is less than 0')
stop()
}

logger.debug({ from }, 'pushing countdown value ...')
push(from)
}

decrement()

const delay = setInterval(decrement, 500)

stop.then(() => {
clearInterval(delay)
logger.debug('stopping countdown')
})
}),
resolve: (payload: number) => payload,
},
}

Expand Down
6 changes: 5 additions & 1 deletion packages/graphql-server/src/plugins/useRedwoodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ export const useRedwoodError = (
}
})

// be certain to return the complete result
// and not just the data or the errors
// because defer, stream and AsyncIterator results
// need to be returned as is
setResult({
data: result.data,
...result,
errors,
extensions: result.extensions || {},
})
Expand Down
1 change: 1 addition & 0 deletions packages/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@envelop/live-query": "6.0.0",
"@graphql-tools/schema": "10.0.0",
"@graphql-tools/utils": "10.0.1",
"@graphql-yoga/plugin-defer-stream": "2.0.4",
"@graphql-yoga/plugin-graphql-sse": "2.0.4",
"@graphql-yoga/redis-event-target": "2.0.0",
"@graphql-yoga/subscription": "4.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/realtime/src/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
RedisLiveQueryStore,
liveQueryStore,
pubSub,
Repeater,
} from './plugins/useRedwoodRealtime'

export type {
Expand Down
7 changes: 7 additions & 0 deletions packages/realtime/src/graphql/plugins/useRedwoodRealtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Plugin } from '@envelop/core'
import { useLiveQuery } from '@envelop/live-query'
import { mergeSchemas } from '@graphql-tools/schema'
import { astFromDirective } from '@graphql-tools/utils'
import { useDeferStream } from '@graphql-yoga/plugin-defer-stream'
import { useGraphQLSSE } from '@graphql-yoga/plugin-graphql-sse'
import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'
import type { CreateRedisEventTargetArgs } from '@graphql-yoga/redis-event-target'
Expand All @@ -12,6 +13,8 @@ import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store'
import type { execute as defaultExecute } from 'graphql'
import { print } from 'graphql'

export { Repeater } from 'graphql-yoga'

/**
* We want SubscriptionsGlobs type to be an object with this shape:
*
Expand Down Expand Up @@ -60,6 +63,7 @@ export type SubscribeClientType = CreateRedisEventTargetArgs['subscribeClient']
*
*/
export type RedwoodRealtimeOptions = {
enableDeferStream?: boolean
liveQueries?: {
/**
* @description Redwood Realtime supports in-memory and Redis stores.
Expand Down Expand Up @@ -232,6 +236,9 @@ export const useRedwoodRealtime = (options: RedwoodRealtimeOptions): Plugin => {
if (subscriptionsEnabled) {
addPlugin(useGraphQLSSE() as Plugin<object>)
}
if (options.enableDeferStream) {
addPlugin(useDeferStream() as Plugin<object>)
}
},
onContextBuilding() {
return ({ extendContext }) => {
Expand Down
1 change: 1 addition & 0 deletions packages/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
RedisLiveQueryStore,
liveQueryStore,
pubSub,
Repeater,
} from './graphql'

export type {
Expand Down
13 changes: 13 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4440,6 +4440,18 @@ __metadata:
languageName: node
linkType: hard

"@graphql-yoga/plugin-defer-stream@npm:2.0.4":
version: 2.0.4
resolution: "@graphql-yoga/plugin-defer-stream@npm:2.0.4"
dependencies:
"@graphql-tools/utils": ^10.0.0
peerDependencies:
graphql: ^15.2.0 || ^16.0.0
graphql-yoga: ^4.0.4
checksum: d402809bb5ef9bdb1aea3376bc18d756246852326a7d630930d0ea1630ebdca2e82d61bfa5123efaa69514ae37f3e02c20043a2512b82000262ffb3e33b17596
languageName: node
linkType: hard

"@graphql-yoga/plugin-graphql-sse@npm:2.0.4":
version: 2.0.4
resolution: "@graphql-yoga/plugin-graphql-sse@npm:2.0.4"
Expand Down Expand Up @@ -8928,6 +8940,7 @@ __metadata:
"@envelop/types": 4.0.0
"@graphql-tools/schema": 10.0.0
"@graphql-tools/utils": 10.0.1
"@graphql-yoga/plugin-defer-stream": 2.0.4
"@graphql-yoga/plugin-graphql-sse": 2.0.4
"@graphql-yoga/redis-event-target": 2.0.0
"@graphql-yoga/subscription": 4.0.0
Expand Down

0 comments on commit 3d22dc7

Please sign in to comment.