Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor GraphQL Server and CreateYoga to Support "api serve" with Fastify Server #8339

Merged

Conversation

dthyresson
Copy link
Contributor

@dthyresson dthyresson commented May 16, 2023

As part of the effort to have fastify plugins for running the redwood api and web servers, this PR refactors parts of the graphql-server package so it too can be used in a more "serverful" manner.

And, in doing so, begin the support for "RedwoodJS Realtime" via GraphQL subscriptions and Live Queries when deployed with a stateful server and memory stores.

This PR:

  • creates of the createYoga portion on the createGraphQLHandler so it can be used by both the serverless function graphql and a
  • new Fastify "plugin" that creates a server and registers yoga as a handler
  • adds a new allowGraphiQL option because when running even in dev as yarn rw api serve this is considered production and both introspection and the playground are disabled. Now, you can control both and allow the playground to be used with rw ap serve
  • updates the server file template to include a yoga server to experiment with

Use

The server.ts here creates a server and also sets up subscription and live query support (in the playground only, there is no web side support yet to actually listen or subscribe).

Currently, the types and resolvers are added to the RedwoodJS GraphQL schema via the extra schemaOptions option when merging the schema from the sdls, services and directives.

// api/src/server.ts

import path from 'path'

import { useLiveQuery } from '@envelop/live-query'
import chalk from 'chalk'
import { config } from 'dotenv-defaults'
import Fastify from 'fastify'
import { OperationTypeNode } from 'graphql'

import {
  coerceRootPath,
  redwoodFastifyWeb,
  redwoodFastifyAPI,
  redwoodFastifyGraphQLServer,
  DEFAULT_REDWOOD_FASTIFY_CONFIG,
} from '@redwoodjs/fastify'
import { getPaths, getConfig } from '@redwoodjs/project-config'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { liveQueryStore, liveSchema } from './graphql/liveSchema'
import { subscriptions, pubSub } from './graphql/subscriptions'
import { logger } from './lib/logger'

async function serve() {
  // Load .env files
  const redwoodProjectPaths = getPaths()
  const redwoodConfig = getConfig()

  const apiRootPath = coerceRootPath(redwoodConfig.web.apiUrl)
  const port = redwoodConfig.web.port

  const tsServer = Date.now()

  config({
    path: path.join(redwoodProjectPaths.base, '.env'),
    defaults: path.join(redwoodProjectPaths.base, '.env.defaults'),
    multiline: true,
  })

  console.log(chalk.italic.dim('Starting API and Web Servers...'))

  // Configure Fastify
  const fastify = Fastify({
    ...DEFAULT_REDWOOD_FASTIFY_CONFIG,
  })

  await fastify.register(redwoodFastifyWeb)

  await fastify.register(redwoodFastifyAPI, {
    redwood: {
      apiRootPath,
    },
  })

  await fastify.register(redwoodFastifyGraphQLServer, {
    loggerConfig: {
      logger: logger,
      options: { query: true, data: true, level: 'trace' },
    },
    graphiQLEndpoint: '/yoga',
    sdls,
    services,
    directives,
    allowedOperations: [
      OperationTypeNode.SUBSCRIPTION,
      OperationTypeNode.QUERY,
      OperationTypeNode.MUTATION,
    ],
    allowIntrospection: true,
    allowGraphiQL: true,
    schemaOptions: { ...subscriptions, ...liveSchema },
    context: { pubSub },
    extraPlugins: [useLiveQuery({ liveQueryStore })],
  })

  // Start

  fastify.listen({ port })

  fastify.ready(() => {
    console.log(chalk.italic.dim('Took ' + (Date.now() - tsServer) + ' ms'))
    const on = chalk.magenta(`http://localhost:${port}${apiRootPath}`)
    const webServer = chalk.green(`http://localhost:${port}`)
    const apiServer = chalk.magenta(`http://localhost:${port}`)
    console.log(`Web server started on ${webServer}`)
    console.log(`API serving from ${apiServer}`)
    console.log(`API listening on ${on}`)
    const graphqlEnd = chalk.magenta(`${apiRootPath}graphql`)
    console.log(`GraphQL serverless function endpoint at ${graphqlEnd}`)
  })

  process.on('exit', () => {
    fastify.close()
  })
}

serve()

Note: I just realized that this example doesn't mix both LQ and Subs due to the way I set the schemaOptions. But both do work independently. Hence, why need a way to merge these into there schema in the next iteration.

Important here is to "allow" subscriptions, introspection and graphiql.

// api/src/graphql/liveSchema.ts

import { astFromDirective } from '@graphql-tools/utils'
import { GraphQLLiveDirective } from '@n1ru4l/graphql-live-query'
import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store'

export const liveQueryStore = new InMemoryLiveQueryStore()

type Bid = {
  amount: number
}

type Auction = {
  id: string
  title: string
  bids: Bid[]
}

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

export const liveSchema = {
  typeDefs: [
    /* GraphQL */ `
      type Query {
        auction(id: ID!): Auction
      }
      type Auction {
        id: ID!
        title: String!
        highestBid: Bid
        bids: [Bid!]!
      }
      type Bid {
        amount: Int!
      }
      type Mutation {
        bid(input: BidInput!): Bid
      }
      input BidInput {
        auctionId: ID!
        amount: Int!
      }
    `,
    astFromDirective(GraphQLLiveDirective),
  ],
  resolvers: {
    Query: {
      auction: (_, { id }) => auctions.find((a) => a.id === id),
    },
    Mutation: {
      bid: async (_, { input }) => {
        const { auctionId, amount } = input

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

        const bid = { amount }

        auctions[index].bids.push(bid)

        liveQueryStore.invalidate(`Auction:${auctionId}`)

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

        return max
      },
    },
  },
}

and Subscriptions

// api/src/graphql/subscriptions.ts

import { createPubSub } from '@graphql-yoga/node'

export const pubSub = createPubSub<{
  newMessage: [payload: { from: string; body: string }]
}>()

export const subscriptions = {
  typeDefs: [
    /* GraphQL */ `
      type Subscription {
        countdown(from: Int!, interval: Int!): Int!
      }
      type Query {
        room(id: ID!): [Message!]!
      }

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

      type Subscription {
        newMessage(roomId: ID!): Message!
      }

      type Message {
        from: String
        body: String
      }

      input SendMessageInput {
        roomId: ID!
        from: String!
        body: String!
      }
    `,
  ],
  resolvers: {
    Query: {
      room: () => [],
    },
    Mutation: {
      send: (_, { input }, { pubSub }) => {
        const { roomId, ...newMessage } = input
        console.log('newMessage', newMessage)
        console.log('roomId', roomId)

        pubSub.publish('newMessage', roomId, newMessage)

        return newMessage
      },
    },
    Subscription: {
      countdown: {
        async *subscribe(_, { from, interval }) {
          for (let i = from; i >= 0; i--) {
            await new Promise((resolve) =>
              setTimeout(resolve, interval ?? 1000)
            )
            yield { countdown: i }
          }
        },
      },
      newMessage: {
        subscribe: (_, { roomId }, { pubSub }) =>
          pubSub.subscribe('newMessage', roomId),
        resolve: (payload) => payload,
      },
    },
  },
}

Next Steps

There are many next steps to support realtime and GraphQL Subscriptions and Live Queries.

  • figure out web side needs for Apollo Client
  • What is the DX and how best to organize Subscription and LiveQuery types and resolvers

@dthyresson dthyresson marked this pull request as ready for review May 16, 2023 18:19
@dthyresson dthyresson changed the title DRAFT: Refactor GraphQL Server and CreateYoga to Support "api serve" with Fastify Server Refactor GraphQL Server and CreateYoga to Support "api serve" with Fastify Server May 16, 2023
@dthyresson dthyresson force-pushed the dt-refactor-graphql-function-yoga branch from 7555591 to 66706f0 Compare May 17, 2023 10:33
@dthyresson dthyresson added release:docs This PR only updates docs release:experiment and removed release:docs This PR only updates docs labels May 17, 2023
@dthyresson
Copy link
Contributor Author

Have started the work to merge in project subscriptions into the over schema and resolvers.

@dthyresson
Copy link
Contributor Author

dthyresson commented May 18, 2023

I have subscriptions and live queries running in a test app with this PR.

Server File Setup

Note here that you'll import a glob of subscriptions just like you'd do with RedwoodDirectives.

import path from 'path'

import { useLiveQuery } from '@envelop/live-query'
import { astFromDirective } from '@graphql-tools/utils'
import { GraphQLLiveDirective } from '@n1ru4l/graphql-live-query'
import chalk from 'chalk'
import { config } from 'dotenv-defaults'
import Fastify from 'fastify'
import { OperationTypeNode } from 'graphql'

import {
  coerceRootPath,
  redwoodFastifyWeb,
  redwoodFastifyAPI,
  redwoodFastifyGraphQLServer,
  DEFAULT_REDWOOD_FASTIFY_CONFIG,
} from '@redwoodjs/fastify'
import { getPaths, getConfig } from '@redwoodjs/project-config'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'
import subscriptions from 'src/subscriptions/**/*.{js,ts}'

import { liveQueryStore } from 'src/services/auctions/auctions'

import { logger } from './lib/logger'
async function serve() {
  // Load .env files
  const redwoodProjectPaths = getPaths()
  const redwoodConfig = getConfig()

  const apiRootPath = coerceRootPath(redwoodConfig.web.apiUrl)
  const port = redwoodConfig.web.port

  const tsServer = Date.now()

  config({
    path: path.join(redwoodProjectPaths.base, '.env'),
    defaults: path.join(redwoodProjectPaths.base, '.env.defaults'),
    multiline: true,
  })

  console.log(chalk.italic.dim('Starting API and Web Servers...'))

  // Configure Fastify
  const fastify = Fastify({
    ...DEFAULT_REDWOOD_FASTIFY_CONFIG,
  })

  await fastify.register(redwoodFastifyWeb)

  await fastify.register(redwoodFastifyAPI, {
    redwood: {
      apiRootPath,
    },
  })

  await fastify.register(redwoodFastifyGraphQLServer, {
    loggerConfig: {
      logger: logger,
      options: { query: true, data: true, level: 'trace' },
    },
    graphiQLEndpoint: '/yoga',
    sdls,
    services,
    directives,
    subscriptions,
    allowedOperations: [
      OperationTypeNode.SUBSCRIPTION,
      OperationTypeNode.QUERY,
      OperationTypeNode.MUTATION,
    ],
    allowIntrospection: true,
    allowGraphiQL: true,
    schemaOptions: { typeDefs: [astFromDirective(GraphQLLiveDirective)] },
    extraPlugins: [useLiveQuery({ liveQueryStore })],
  })

  // Start

  fastify.listen({ port })

  fastify.ready(() => {
    console.log(chalk.italic.dim('Took ' + (Date.now() - tsServer) + ' ms'))
    const on = chalk.magenta(`http://localhost:${port}${apiRootPath}`)
    const webServer = chalk.green(`http://localhost:${port}`)
    const apiServer = chalk.magenta(`http://localhost:${port}`)
    console.log(`Web server started on ${webServer}`)
    console.log(`API serving from ${apiServer}`)
    console.log(`API listening on ${on}`)
    const graphqlEnd = chalk.magenta(`${apiRootPath}graphql`)
    console.log(`GraphQL serverless function endpoint at ${graphqlEnd}`)
  })

  process.on('exit', () => {
    fastify.close()
  })
}

serve()

Subscriptions

  • put in api/src/subscriptions like you would Redwood Directives
  • we have the concept of a redwoodSubscription and can make a generator for it like directives -- or g realtime and have the option to setup a subscription of live query perhaps

A subscription file can be

import gql from 'graphql-tag'

export const schema = gql`
  type Subscription {
    countdown(from: Int!, interval: Int!): Int!
  }
`

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

export default countdown

or for a newMessage

import { createPubSub } from '@graphql-yoga/node'
import gql from 'graphql-tag'

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

export const pubSubNewMessage = createPubSub<{
  newMessage: [payload: { from: string; body: string }]
}>()

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

const newMessage = {
  newMessage: {
    subscribe: (_, { roomId }) => {
      logger.debug({ roomId }, 'newMessage subscription')

      return pubSubNewMessage.subscribe(roomId)
    },
    resolve: (payload) => {
      logger.debug({ payload }, 'newMessage subscription resolve')

      return payload
    },
  },
}

export default newMessage

Define SDL types

Here the rooms.sdl.ts defines the Message type used in the subscription and the mutation to send a new message

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
  }
`

Services

The api/src/services/rooms/rooms gets in in memory pubSub from the subscription and when sending a new message mutation, publishes onto pub sub

import { logger } from 'src/lib/logger'
import { pubSubNewMessage } from 'src/subscriptions/newMessage/newMessage'

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

// important to be async!
export const send = async ({ input }) => {
  logger.debug({ input }, 'send input')

  const { roomId, ...newMessage } = input

  pubSubNewMessage.publish(roomId, newMessage)

  return input
}

Live Queries

The DX isn't 100% yet because the two parts to the server and yoga setup are:

  • adding the live query directive to the graphql schema (done via schemaOptions in this example)
  • adding the useLiveQuery plugin via extraPlugins
  • Note that this useLiveQuery is using the in memory store created in auctions
  • I could see a useRedwoodLiveQueryPlugin that does both
  • Otherwise, the sdl and services are pretty standard

SDL Types

Here is an auctions.sdl.ts with basic query and mutation for making a bid and getting an auction

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!
  }
`

Services

Here, there is a collection on auctions and can fetch an auction and make a bid on an auction.

When a bid is made, the Auction key is invalidated in the live query memory store.

import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store'

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

export const liveQueryStore = new InMemoryLiveQueryStore()

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 }) => {
  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}`
  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
  },
}

docs/docs/graphql.md Outdated Show resolved Hide resolved
@jtoar
Copy link
Contributor

jtoar commented May 18, 2023

@dthyresson and I talked about this one; we're synced up on concerns and next steps and want to get this into main so development can keep going on other milestones.

Copy link
Contributor Author

woohoo

@dthyresson dthyresson enabled auto-merge (squash) May 18, 2023 20:15
@dthyresson dthyresson merged commit b28669e into redwoodjs:main May 18, 2023
25 checks passed
@redwoodjs-bot redwoodjs-bot bot added this to the next-release milestone May 18, 2023
@Josh-Walker-GM Josh-Walker-GM removed their request for review May 18, 2023 21:02
dac09 added a commit to dac09/redwood that referenced this pull request May 19, 2023
…te-default

* 'main' of github.com:redwoodjs/redwood: (23 commits)
  chore(deps): update dependency @clerk/clerk-react to v4.16.2 (redwoodjs#8362)
  chore(package size): implement `findup-sync` in `@redwoodjs/project-config` (redwoodjs#8315)
  Refactor GraphQL Server and CreateYoga to Support "api serve" with Fastify Server (redwoodjs#8339)
  chore(deps): update dependency octokit to v2.0.15 (redwoodjs#8360)
  fix(coherence): correct doc links, add commas to template (redwoodjs#8351)
  Parse as int, fix jsdoc (redwoodjs#8357)
  Update forms.md (redwoodjs#8352)
  chore: update yarn.lock
  chore(release): update release command for minors
  chore(deps): update dependency rimraf to v5.0.1 (redwoodjs#8350)
  chore(deps): update dependency glob to v10.2.5 (redwoodjs#8349)
  feat(coherence deploy): add setup deploy coherence (redwoodjs#8234)
  fix(deps): update dependency listr2 to v6.6.0 (redwoodjs#8347)
  fix(deps): update dependency react-router-dom to v6.11.2 (redwoodjs#8345)
  fix(deps): update prisma monorepo to v4.14.1 (redwoodjs#8346)
  fix(deps): update dependency webpack to v5.83.1 (redwoodjs#8348)
  chore(deps): update dependency dependency-cruiser to v13 (redwoodjs#8322)
  chore(deps): update dependency @clerk/clerk-react to v4.16.1 (redwoodjs#8324)
  chore(deps): update dependency @clerk/types to v3.38.0 (redwoodjs#8325)
  chore(deps): update dependency nx to v16.2.1 (redwoodjs#8343)
  ...
jtoar added a commit that referenced this pull request May 19, 2023
…stify Server (#8339)

* Move makeMergedSchema

* refactor createGraphQLYoga

* Make a Fastify plugin for graphql yoga

* Set yoga options

* Adds graphql fastify plugin to template

* Whitespace

* Adds allowGraphiQL config setting and docs

* fastify gql plugin awaits

* Merge in subscriptions into schema from project

* Get to green on tests

* Fix subscriptions module api import

* Tests makeSubscriptions

* Fastify graphql plugin needed graphqlserver package

* remove unneeded comments

* Update docs/docs/graphql.md

* Adds HookHandlerDoneFunction

* Update packages/cli/src/commands/experimental/templates/server.ts.template

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>

---------

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>
@jtoar jtoar modified the milestones: next-release, temp, v6.0.0 Jun 2, 2023
jtoar added a commit that referenced this pull request Jun 6, 2023
Josh-Walker-GM added a commit that referenced this pull request Aug 14, 2023
…quest (#9039)

**Problem**
v6 has seen some performance issues. These appear to have sneaked in on
#8339. This just reverts the change by moving the creation of the yoga
object so that it isn't created on every request.

**Changes**
1. Moves yoga creation.

**Outstanding**
1. We still have a tiny bit of work to do analysing a slight performance
decline since 4.4.3
thedavidprice pushed a commit that referenced this pull request Aug 15, 2023
…quest (#9039)

**Problem**
v6 has seen some performance issues. These appear to have sneaked in on
#8339. This just reverts the change by moving the creation of the yoga
object so that it isn't created on every request.

**Changes**
1. Moves yoga creation.

**Outstanding**
1. We still have a tiny bit of work to do analysing a slight performance
decline since 4.4.3
thedavidprice pushed a commit that referenced this pull request Aug 17, 2023
…quest (#9039)

**Problem**
v6 has seen some performance issues. These appear to have sneaked in on
#8339. This just reverts the change by moving the creation of the yoga
object so that it isn't created on every request.

**Changes**
1. Moves yoga creation.

**Outstanding**
1. We still have a tiny bit of work to do analysing a slight performance
decline since 4.4.3
thedavidprice pushed a commit that referenced this pull request Aug 17, 2023
…quest (#9039)

**Problem**
v6 has seen some performance issues. These appear to have sneaked in on
#8339. This just reverts the change by moving the creation of the yoga
object so that it isn't created on every request.

**Changes**
1. Moves yoga creation.

**Outstanding**
1. We still have a tiny bit of work to do analysing a slight performance
decline since 4.4.3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants