├── apps ├── web │ ├── src │ │ ├── __generated__ │ │ │ └── .gitkeep │ │ ├── styles │ │ │ └── index.css │ │ ├── components │ │ │ ├── MessageAddMutation.ts │ │ │ ├── Layout.tsx │ │ │ ├── subscriptions │ │ │ │ ├── MessageAddedSubscription.ts │ │ │ │ └── useMessageAddedSubscription.ts │ │ │ ├── WooviAvatar.tsx │ │ │ ├── Message.tsx │ │ │ └── MessageList.tsx │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ └── index.tsx │ │ └── relay │ │ │ ├── ReactRelayContainer.tsx │ │ │ ├── environment.ts │ │ │ ├── websocket.ts │ │ │ ├── RelayHydrate.tsx │ │ │ └── network.ts │ ├── .eslintrc.js │ ├── .env.example │ ├── next.config.js │ ├── next-env.d.ts │ ├── tsconfig.json │ ├── relay.config.js │ ├── .gitignore │ ├── package.json │ ├── README.md │ └── data │ │ └── schema.graphql └── server │ ├── src │ ├── __tests__ │ │ └── basic.spec.ts │ ├── modules │ │ ├── pubSub │ │ │ ├── pubSubEvents.ts │ │ │ └── redisPubSub.ts │ │ ├── message │ │ │ ├── mutations │ │ │ │ ├── messageMutations.ts │ │ │ │ └── MessageAddMutation.ts │ │ │ ├── subscriptions │ │ │ │ ├── messageSubscriptions.ts │ │ │ │ └── MessageAddedSubscription.ts │ │ │ ├── MessageLoader.ts │ │ │ ├── MessageModel.ts │ │ │ ├── messageFields.ts │ │ │ └── MessageType.ts │ │ ├── error │ │ │ └── errorFields.ts │ │ ├── loader │ │ │ └── loaderRegister.ts │ │ └── node │ │ │ └── typeRegister.ts │ ├── server │ │ ├── getContext.ts │ │ ├── app.ts │ │ ├── wsServer.ts │ │ ├── websocketMiddleware.ts │ │ ├── createGraphqlWs.ts │ │ └── ws.ts │ ├── schema │ │ ├── MutationType.ts │ │ ├── QueryType.ts │ │ ├── SubscriptionType.ts │ │ └── schema.ts │ ├── database.ts │ ├── config.ts │ └── index.ts │ ├── .env.example │ ├── scripts │ └── updateSchema.ts │ ├── babelBarrel.js │ ├── jest.config.js │ ├── package.json │ └── schema │ └── schema.graphql ├── packages ├── ui │ ├── index.tsx │ ├── tsconfig.json │ ├── package.json │ └── src │ │ └── Logo.tsx ├── tsconfig │ ├── package.json │ ├── react-library.json │ ├── nextjs.json │ └── base.json └── eslint-config-custom │ ├── index.js │ └── package.json ├── pnpm-workspace.yaml ├── .npmrc ├── .eslintrc.js ├── junit.xml ├── turbo.json ├── docker-compose.yml ├── .gitignore ├── jest.config.js ├── package.json └── README.md /apps/web/src/__generated__/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/ui/index.tsx: -------------------------------------------------------------------------------- 1 | export { Logo } from './src/Logo'; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/*' 3 | - 'packages/*' -------------------------------------------------------------------------------- /apps/server/src/__tests__/basic.spec.ts: -------------------------------------------------------------------------------- 1 | it('basic', () => { 2 | expect(1).toBe(1); 3 | }) -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /apps/server/.env.example: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | MONGO_URI=mongodb://localhost/woovi-playground 3 | REDIS_HOST=redis://localhost -------------------------------------------------------------------------------- /apps/web/src/styles/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | background-color: #03d69d; 6 | } 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | node-linker=hoisted 3 | link-workspace-packages=true 4 | prefer-workspace-packages=true 5 | -------------------------------------------------------------------------------- /apps/server/src/modules/pubSub/pubSubEvents.ts: -------------------------------------------------------------------------------- 1 | export const PUB_SUB_EVENTS = { 2 | MESSAGE: { 3 | ADDED: 'MESSAGE:ADDED', 4 | }, 5 | } as const; 6 | -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql 2 | NEXT_PUBLIC_SUBSCRIPTIONS_ENDPOINT=ws://localhost:4000/graphql/ws -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@woovi-playground/tsconfig/react-library.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | transpilePackages: ['@woovi-playground/ui'], 4 | compiler: { 5 | relay: require('./relay.config'), 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/mutations/messageMutations.ts: -------------------------------------------------------------------------------- 1 | import { MessageAddMutation } from './MessageAddMutation'; 2 | 3 | export const messageMutations = { 4 | MessageAdd: MessageAddMutation, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/server/src/modules/pubSub/redisPubSub.ts: -------------------------------------------------------------------------------- 1 | import { RedisPubSub } from 'graphql-redis-subscriptions'; 2 | 3 | export const redisPubSub = new RedisPubSub({ 4 | connection: process.env.REDIS_HOST, 5 | }); 6 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@woovi-playground/tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/subscriptions/messageSubscriptions.ts: -------------------------------------------------------------------------------- 1 | import { MessageAddedSubscription } from './MessageAddedSubscription'; 2 | 3 | export const messageSubscriptions = { 4 | MessageAdded: MessageAddedSubscription, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@woovi-playground/tsconfig/nextjs.json", 3 | "include": [ 4 | "next-env.d.ts", 5 | "**/*.ts", 6 | "**/*.tsx", 7 | "../../packages/ui/src/Logo.tsx" 8 | ], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/server/src/modules/error/errorFields.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString } from 'graphql'; 2 | 3 | export const errorField = (key: string) => ({ 4 | [key]: { 5 | type: GraphQLString, 6 | resolve: async (obj: Record) => obj.error, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ['@woovi-playground/custom'], 5 | settings: { 6 | next: { 7 | rootDir: ['apps/*/'], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /apps/server/src/server/getContext.ts: -------------------------------------------------------------------------------- 1 | import { getDataloaders } from '../modules/loader/loaderRegister'; 2 | 3 | const getContext = () => { 4 | const dataloaders = getDataloaders(); 5 | 6 | return { 7 | dataloaders, 8 | } as const; 9 | }; 10 | 11 | export { getContext }; 12 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next", "turbo", "prettier"], 3 | rules: { 4 | "@next/next/no-html-link-for-pages": "off", 5 | }, 6 | parserOptions: { 7 | babelOptions: { 8 | presets: [require.resolve("next/babel")], 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "lib": ["ES2015"], 8 | "module": "ESNext", 9 | "target": "es6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/relay.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | src: './src', 3 | artifactDirectory: './src/__generated__', 4 | schema: './data/schema.graphql', 5 | exclude: [ 6 | '**/node_modules/**', 7 | '**/.next/**', 8 | '**/__mocks__/**', 9 | '**/__generated__/**', 10 | ], 11 | language: 'typescript', 12 | }; -------------------------------------------------------------------------------- /apps/server/src/schema/MutationType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { messageMutations } from '../modules/message/mutations/messageMutations'; 4 | 5 | export const MutationType = new GraphQLObjectType({ 6 | name: 'Mutation', 7 | fields: () => ({ 8 | ...messageMutations, 9 | }), 10 | }); 11 | -------------------------------------------------------------------------------- /apps/server/src/schema/QueryType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { messageConnectionField } from '../modules/message/messageFields'; 4 | 5 | export const QueryType = new GraphQLObjectType({ 6 | name: 'Query', 7 | fields: () => ({ 8 | ...messageConnectionField('messages'), 9 | }), 10 | }); 11 | -------------------------------------------------------------------------------- /apps/web/src/components/MessageAddMutation.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-relay'; 2 | 3 | export const MessageAdd = graphql` 4 | mutation MessageAddMutation($input: MessageAddInput!) { 5 | MessageAdd(input: $input) { 6 | message { 7 | id 8 | content 9 | createdAt 10 | } 11 | } 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /apps/server/src/schema/SubscriptionType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { messageSubscriptions } from '../modules/message/subscriptions/messageSubscriptions'; 4 | 5 | export const SubscriptionType = new GraphQLObjectType({ 6 | name: 'Subscription', 7 | fields: () => ({ 8 | ...messageSubscriptions, 9 | }), 10 | }); 11 | -------------------------------------------------------------------------------- /junit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /apps/server/src/database.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { config } from './config'; 4 | 5 | async function connectDatabase() { 6 | // eslint-disable-next-line 7 | mongoose.connection.on('close', () => 8 | console.log('Database connection closed.') 9 | ); 10 | 11 | await mongoose.connect(config.MONGO_URI); 12 | } 13 | 14 | export { connectDatabase }; 15 | -------------------------------------------------------------------------------- /apps/server/src/schema/schema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | 3 | import { QueryType } from './QueryType'; 4 | import { MutationType } from './MutationType'; 5 | import { SubscriptionType } from './SubscriptionType'; 6 | 7 | export const schema = new GraphQLSchema({ 8 | query: QueryType, 9 | mutation: MutationType, 10 | subscription: SubscriptionType, 11 | }); 12 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "pipeline": { 5 | "build": { 6 | "outputs": [".next/**", "!.next/cache/**"] 7 | }, 8 | "config:local": {}, 9 | "dev": { 10 | "cache": false, 11 | "persistent": true 12 | }, 13 | "lint": {}, 14 | "relay": {}, 15 | "schema": {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mongodb: 5 | image: mongo 6 | container_name: mongodb 7 | ports: 8 | - "27017:27017" 9 | environment: 10 | - MONGO_INITDB_DATABASE=woovi-playground 11 | restart: always 12 | 13 | redis: 14 | image: redis 15 | container_name: redis 16 | ports: 17 | - "6379:6379" 18 | restart: always 19 | -------------------------------------------------------------------------------- /apps/server/scripts/updateSchema.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { printSchema } from 'graphql/utilities'; 3 | import path from 'path'; 4 | import { promisify } from 'util'; 5 | 6 | import { schema } from '../src/schema/schema'; 7 | 8 | (async () => { 9 | await fs.writeFile( 10 | path.join(__dirname, '../schema/schema.graphql'), 11 | printSchema(schema) 12 | ); 13 | 14 | process.exit(0); 15 | })(); 16 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@woovi-playground/eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "eslint-config-next": "latest", 8 | "eslint-config-prettier": "^8.3.0", 9 | "eslint-plugin-react": "7.33.2", 10 | "eslint-config-turbo": "latest" 11 | }, 12 | "publishConfig": { 13 | "access": "public" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import dotenvSafe from 'dotenv-safe'; 4 | 5 | const cwd = process.cwd(); 6 | 7 | const root = path.join.bind(cwd); 8 | 9 | dotenvSafe.config({ 10 | path: root('.env'), 11 | sample: root('.env.example'), 12 | }); 13 | 14 | const ENV = process.env; 15 | 16 | const config = { 17 | PORT: ENV.PORT ?? 4000, 18 | MONGO_URI: ENV.MONGO_URI ?? '', 19 | }; 20 | 21 | export { config }; 22 | -------------------------------------------------------------------------------- /apps/web/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import type { AppProps } from 'next/app'; 3 | import '../styles/index.css'; 4 | 5 | import { ReactRelayContainer } from '../relay/ReactRelayContainer'; 6 | 7 | export default function App({ Component, pageProps }: AppProps) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from '@mui/material'; 2 | 3 | type LayoutProps = { 4 | children?: React.ReactNode; 5 | }; 6 | 7 | export const Layout = ({ children }: LayoutProps) => { 8 | return ( 9 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/web/src/components/subscriptions/MessageAddedSubscription.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-relay'; 2 | 3 | const MessageAdded = graphql` 4 | subscription MessageAddedSubscription($input: MessageAddedInput!, $connections: [ID!]!) { 5 | MessageAdded(input: $input) { 6 | message @appendNode(connections: $connections, edgeTypeName: "MessageEdge") { 7 | ...Message_message 8 | } 9 | } 10 | } 11 | `; 12 | 13 | export { MessageAdded }; -------------------------------------------------------------------------------- /apps/server/src/modules/loader/loaderRegister.ts: -------------------------------------------------------------------------------- 1 | const loaders: Record unknown> = {}; 2 | 3 | const registerLoader = (key: string, getLoader: () => unknown) => { 4 | loaders[key] = getLoader; 5 | }; 6 | 7 | const getDataloaders = (): Record unknown> => 8 | Object.keys(loaders).reduce( 9 | (prev, loaderKey) => ({ 10 | ...prev, 11 | [loaderKey]: loaders[loaderKey](), 12 | }), 13 | {} 14 | ); 15 | 16 | export { registerLoader, getDataloaders }; 17 | -------------------------------------------------------------------------------- /apps/web/src/components/WooviAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | import { Logo } from '@woovi-playground/ui'; 3 | 4 | export const WooviAvatar = () => { 5 | return ( 6 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | 25 | # local env files 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | 31 | # vercel 32 | .vercel 33 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@woovi-playground/ui", 3 | "version": "0.0.0", 4 | "main": "./index.tsx", 5 | "types": "./index.tsx", 6 | "license": "MIT", 7 | "scripts": { 8 | "lint": "eslint \"**/*.ts*\"" 9 | }, 10 | "devDependencies": { 11 | "@types/react": "^17.0.37", 12 | "@types/react-dom": "^17.0.11", 13 | "@woovi-playground/eslint-config-custom": "*", 14 | "@woovi-playground/tsconfig": "*", 15 | "eslint": "^7.32.0", 16 | "react": "^17.0.2", 17 | "typescript": "^4.5.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | 23 | # local env files 24 | .env 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | 30 | # turbo 31 | .turbo 32 | 33 | # vercel 34 | .vercel 35 | 36 | **/__generated__/*.ts -------------------------------------------------------------------------------- /apps/server/babelBarrel.js: -------------------------------------------------------------------------------- 1 | const babelJest = require('babel-jest'); 2 | 3 | const config = { 4 | presets: [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | node: 'current', 10 | }, 11 | }, 12 | ], 13 | '@babel/preset-typescript', 14 | ], 15 | plugins: [] 16 | }; 17 | 18 | const barrelConfig = { 19 | ...config, 20 | plugins: [ 21 | ...config.plugins, 22 | 'transform-barrels', 23 | ] 24 | } 25 | 26 | module.exports = babelJest.createTransformer(barrelConfig); 27 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/MessageLoader.ts: -------------------------------------------------------------------------------- 1 | import { createLoader } from '@entria/graphql-mongo-helpers'; 2 | 3 | import { registerLoader } from '../loader/loaderRegister'; 4 | 5 | import { Message } from './MessageModel'; 6 | 7 | const { Wrapper, getLoader, clearCache, load, loadAll } = createLoader({ 8 | model: Message, 9 | loaderName: 'MessageLoader', 10 | }); 11 | 12 | registerLoader('MessageLoader', getLoader); 13 | 14 | export const MessageLoader = { 15 | Message: Wrapper, 16 | getLoader, 17 | clearCache, 18 | load, 19 | loadAll, 20 | }; 21 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/MessageModel.ts: -------------------------------------------------------------------------------- 1 | import type { Document, Model } from 'mongoose'; 2 | import mongoose from 'mongoose'; 3 | 4 | const Schema = new mongoose.Schema( 5 | { 6 | content: { 7 | type: String, 8 | description: 'The content of the message', 9 | }, 10 | }, 11 | { 12 | collection: 'Message', 13 | timestamps: true, 14 | } 15 | ); 16 | 17 | export type IMessage = { 18 | content: string; 19 | createdAt: Date; 20 | updatedAt: Date; 21 | } & Document; 22 | 23 | export const Message: Model = mongoose.model('Message', Schema); 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const glob = require('glob'); 2 | 3 | const getProjects = () => { 4 | const projects = glob.sync(`apps/*/jest.config.js`); 5 | 6 | return projects; 7 | }; 8 | 9 | module.exports = { 10 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts|tsx)?$', 11 | projects: [...getProjects()], 12 | coverageProvider: 'v8', 13 | coverageReporters: ['lcov', 'html'], 14 | // move this to feature flag 15 | // reporters: [['jest-silent-reporter', { useDots: true }]], 16 | reporters: ['default', 'jest-junit', 'github-actions'], 17 | cacheDirectory: '.jest-cache', 18 | }; 19 | -------------------------------------------------------------------------------- /packages/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "allowJs": true, 7 | "declaration": false, 8 | "declarationMap": false, 9 | "incremental": true, 10 | "jsx": "preserve", 11 | "lib": ["dom", "dom.iterable", "esnext"], 12 | "module": "esnext", 13 | "noEmit": true, 14 | "resolveJsonModule": true, 15 | "strict": false, 16 | "target": "es5" 17 | }, 18 | "include": ["src", "next-env.d.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/relay/ReactRelayContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, useMemo } from 'react'; 2 | import { createEnvironment } from './environment'; 3 | import { NextPageWithLayout, RelayHydrate } from './RelayHydrate'; 4 | import { ReactRelayContext } from 'react-relay'; 5 | 6 | export function ReactRelayContainer({ 7 | Component, 8 | props, 9 | }: { 10 | Component: NextPageWithLayout; 11 | props: any; 12 | }) { 13 | const environment = useMemo(() => createEnvironment(), []); 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/messageFields.ts: -------------------------------------------------------------------------------- 1 | import { MessageType, MessageConnection } from './MessageType'; 2 | import { MessageLoader } from './MessageLoader'; 3 | import { connectionArgs } from 'graphql-relay'; 4 | 5 | export const messageField = (key: string) => ({ 6 | [key]: { 7 | type: MessageType, 8 | resolve: async (obj: Record, _, context) => 9 | MessageLoader.load(context, obj.message as string), 10 | }, 11 | }); 12 | 13 | export const messageConnectionField = (key: string) => ({ 14 | [key]: { 15 | type: MessageConnection.connectionType, 16 | args: { 17 | ...connectionArgs, 18 | }, 19 | resolve: async (_, args, context) => { 20 | return await MessageLoader.loadAll(context, args); 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /apps/server/jest.config.js: -------------------------------------------------------------------------------- 1 | const pack = require('./package.json'); 2 | 3 | const jestTransformer = () => { 4 | if (process.env.JEST_TRANSFORMER === 'babel-barrel') { 5 | // eslint-disable-next-line 6 | console.log('babel-barrel'); 7 | 8 | return { 9 | '^.+\\.(js|ts|tsx)?$': require.resolve('./babelBarrel'), 10 | } 11 | } 12 | 13 | // eslint-disable-next-line 14 | console.log('babel-jest'); 15 | 16 | return { 17 | '^.+\\.(js|ts|tsx)?$': 'babel-jest', 18 | }; 19 | }; 20 | 21 | module.exports = { 22 | displayName: pack.name, 23 | testPathIgnorePatterns: ['/node_modules/', './dist'], 24 | resetModules: false, 25 | transform: { 26 | ...jestTransformer(), 27 | }, 28 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts|tsx)?$', 29 | moduleFileExtensions: ['ts', 'js', 'tsx', 'json'], 30 | }; 31 | -------------------------------------------------------------------------------- /apps/web/src/components/subscriptions/useMessageAddedSubscription.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useSubscription } from 'react-relay'; 3 | import { GraphQLSubscriptionConfig } from 'relay-runtime'; 4 | import { 5 | MessageAddedSubscription, 6 | MessageAddedSubscription$variables, 7 | } from '../../__generated__/MessageAddedSubscription.graphql'; 8 | import { MessageAdded } from './MessageAddedSubscription'; 9 | 10 | const useMessageAddedSubscription = ( 11 | variables: MessageAddedSubscription$variables 12 | ) => { 13 | const newMessageConfig = useMemo< 14 | GraphQLSubscriptionConfig 15 | >( 16 | () => ({ 17 | subscription: MessageAdded, 18 | variables, 19 | }), 20 | [variables] 21 | ); 22 | 23 | useSubscription(newMessageConfig); 24 | }; 25 | 26 | export { useMessageAddedSubscription }; 27 | -------------------------------------------------------------------------------- /apps/web/src/relay/environment.ts: -------------------------------------------------------------------------------- 1 | import { Environment, RecordSource, Store } from 'relay-runtime'; 2 | 3 | import { createNetwork } from './network'; 4 | 5 | const IS_SERVER = typeof window === typeof undefined; 6 | const CLIENT_DEBUG = false; 7 | const SERVER_DEBUG = false; 8 | 9 | function createEnvironment() { 10 | const network = createNetwork(); 11 | const environment = new Environment({ 12 | network, 13 | store: new Store(new RecordSource(), {}), 14 | isServer: IS_SERVER, 15 | log(event) { 16 | if ((IS_SERVER && SERVER_DEBUG) || (!IS_SERVER && CLIENT_DEBUG)) { 17 | console.debug('[relay environment event]', event); 18 | } 19 | }, 20 | }); 21 | 22 | // @ts-ignore Private API Hackery? 🤷‍♂️ 23 | environment.getNetwork().responseCache = network.responseCache; 24 | 25 | return environment; 26 | } 27 | 28 | export { createEnvironment }; 29 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | 3 | import { app } from './server/app'; 4 | import { config } from './config'; 5 | import { connectDatabase } from './database'; 6 | import { createGraphqlWs } from './server/createGraphqlWs'; 7 | import { getContext } from './server/getContext'; 8 | import { schema } from './schema/schema'; 9 | import { wsServer } from './server/wsServer'; 10 | 11 | (async () => { 12 | await connectDatabase(); 13 | 14 | const server = http.createServer(app.callback()); 15 | 16 | // wsServer(server); 17 | 18 | createGraphqlWs(server, '/graphql/ws', { 19 | schema, 20 | context: getContext(), 21 | }); 22 | 23 | createGraphqlWs(server, '/console/graphql/ws', { 24 | schema, 25 | context: async () => getContext(), 26 | }); 27 | 28 | server.listen(config.PORT, () => { 29 | // eslint-disable-next-line 30 | console.log(`Server running on port:${config.PORT}`); 31 | }); 32 | })(); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "woovi-playground", 4 | "scripts": { 5 | "build": "turbo run build", 6 | "config:local": "turbo run config:local", 7 | "dev": "turbo run dev", 8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 9 | "lint": "turbo run lint", 10 | "relay": "turbo run relay", 11 | "schema": "turbo run schema", 12 | "compose:up": "docker-compose -f docker-compose.yml up -d", 13 | "compose-down": "docker-compose -f docker-compose down" 14 | }, 15 | "devDependencies": { 16 | "@babel/preset-env": "^7.23.8", 17 | "@babel/preset-typescript": "^7.23.3", 18 | "@woovi-playground/eslint-config-custom": "*", 19 | "autoprefixer": "^10.4.16", 20 | "babel-plugin-transform-barrels": "^1.0.10", 21 | "eslint": "^8.53.0", 22 | "jest": "^29.7.0", 23 | "jest-junit": "^16.0.0", 24 | "prettier": "^3.1.0", 25 | "turbo": "latest" 26 | }, 27 | "packageManager": "pnpm@10.0.0" 28 | } 29 | -------------------------------------------------------------------------------- /packages/ui/src/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const Logo = (props: SVGProps) => ( 4 | 12 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /apps/server/src/server/app.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import bodyParser from 'koa-bodyparser'; 3 | import cors from 'kcors'; 4 | import { graphqlHTTP } from 'koa-graphql'; 5 | import Router from 'koa-router'; 6 | import logger from 'koa-logger'; 7 | 8 | import { schema } from '../schema/schema'; 9 | import { getContext } from './getContext'; 10 | import { createWebsocketMiddleware } from './websocketMiddleware'; 11 | 12 | const app = new Koa(); 13 | 14 | app.use(cors({ origin: '*' })); 15 | app.use(logger()); 16 | app.use( 17 | bodyParser({ 18 | onerror(err, ctx) { 19 | ctx.throw(err, 422); 20 | }, 21 | }) 22 | ); 23 | 24 | app.use(createWebsocketMiddleware()); 25 | 26 | const routes = new Router(); 27 | 28 | // routes.all('/graphql/ws', wsServer); 29 | 30 | routes.all( 31 | '/graphql', 32 | graphqlHTTP(() => ({ 33 | schema, 34 | graphiql: true, 35 | context: getContext(), 36 | })) 37 | ); 38 | 39 | app.use(routes.routes()); 40 | app.use(routes.allowedMethods()); 41 | 42 | export { app }; 43 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/mutations/MessageAddMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { mutationWithClientMutationId, toGlobalId } from 'graphql-relay'; 3 | 4 | import { redisPubSub } from '../../pubSub/redisPubSub'; 5 | import { PUB_SUB_EVENTS } from '../../pubSub/pubSubEvents'; 6 | 7 | import { Message } from '../MessageModel'; 8 | import { messageField } from '../messageFields'; 9 | 10 | export type MessageAddInput = { 11 | content: string; 12 | }; 13 | 14 | const mutation = mutationWithClientMutationId({ 15 | name: 'MessageAdd', 16 | inputFields: { 17 | content: { 18 | type: new GraphQLNonNull(GraphQLString), 19 | }, 20 | }, 21 | mutateAndGetPayload: async (args: MessageAddInput) => { 22 | const message = await new Message({ 23 | content: args.content, 24 | }).save(); 25 | 26 | redisPubSub.publish(PUB_SUB_EVENTS.MESSAGE.ADDED, { 27 | message: message._id.toString(), 28 | }); 29 | 30 | return { 31 | message: message._id.toString(), 32 | }; 33 | }, 34 | outputFields: { 35 | ...messageField('message'), 36 | }, 37 | }); 38 | 39 | export const MessageAddMutation = { 40 | ...mutation, 41 | }; 42 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/subscriptions/MessageAddedSubscription.ts: -------------------------------------------------------------------------------- 1 | import { subscriptionWithClientId } from 'graphql-relay-subscription'; 2 | import { withFilter } from 'graphql-subscriptions'; 3 | 4 | import { messageField } from '../messageFields'; 5 | import { Message } from '../MessageModel'; 6 | import { redisPubSub } from '../../pubSub/redisPubSub'; 7 | import { PUB_SUB_EVENTS } from '../../pubSub/pubSubEvents'; 8 | 9 | type MessageAddedPayload = { 10 | message: string; 11 | }; 12 | 13 | const subscription = subscriptionWithClientId({ 14 | name: 'MessageAdded', 15 | subscribe: withFilter( 16 | () => redisPubSub.asyncIterator(PUB_SUB_EVENTS.MESSAGE.ADDED), 17 | async (payload: MessageAddedPayload, context) => { 18 | const message = await Message.findOne({ 19 | _id: payload.message, 20 | }); 21 | 22 | if (!message) { 23 | return false; 24 | } 25 | 26 | return true; 27 | } 28 | ), 29 | getPayload: async (obj: MessageAddedPayload) => ({ 30 | message: obj?.message, 31 | }), 32 | outputFields: { 33 | ...messageField('message'), 34 | }, 35 | }); 36 | 37 | export const MessageAddedSubscription = { 38 | ...subscription, 39 | }; 40 | -------------------------------------------------------------------------------- /apps/server/src/modules/message/MessageType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLString, GraphQLNonNull } from 'graphql'; 2 | import { globalIdField, connectionDefinitions } from 'graphql-relay'; 3 | import type { ConnectionArguments } from 'graphql-relay'; 4 | 5 | import { IMessage } from './MessageModel'; 6 | import { nodeInterface } from '../node/typeRegister'; 7 | import { registerTypeLoader } from '../node/typeRegister'; 8 | import { MessageLoader } from './MessageLoader'; 9 | 10 | const MessageType = new GraphQLObjectType({ 11 | name: 'Message', 12 | description: 'Represents a message', 13 | fields: () => ({ 14 | id: globalIdField('Message'), 15 | content: { 16 | type: GraphQLString, 17 | resolve: (message) => message.content, 18 | }, 19 | createdAt: { 20 | type: GraphQLString, 21 | resolve: (message) => message.createdAt.toISOString(), 22 | }, 23 | }), 24 | interfaces: () => [nodeInterface], 25 | }); 26 | 27 | const MessageConnection = connectionDefinitions({ 28 | name: 'Message', 29 | nodeType: MessageType, 30 | }); 31 | 32 | registerTypeLoader(MessageType, MessageLoader.load); 33 | 34 | export { MessageType, MessageConnection }; 35 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@woovi-playground/server", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "config:local": "cp .env.example .env", 7 | "dev": "tsx watch src/index.ts", 8 | "schema": "tsx scripts/updateSchema.ts && cp schema/schema.graphql ../web/data" 9 | }, 10 | "dependencies": { 11 | "@entria/graphql-mongo-helpers": "^1.1.2", 12 | "dataloader": "^2.2.2", 13 | "dotenv-safe": "^8.2.0", 14 | "graphql": "^16.8.1", 15 | "graphql-redis-subscriptions": "^2.6.0", 16 | "graphql-relay": "^0.10.0", 17 | "graphql-relay-subscription": "^1.0.0", 18 | "graphql-subscriptions": "^2.0.0", 19 | "graphql-ws": "^5.14.2", 20 | "kcors": "^2.2.2", 21 | "koa": "^2.14.2", 22 | "koa-bodyparser": "^4.4.0", 23 | "koa-graphql": "^0.12.0", 24 | "koa-logger": "^3.2.1", 25 | "koa-router": "^12.0.1", 26 | "mongoose": "^7.0.3", 27 | "ws": "^8.14.2" 28 | }, 29 | "devDependencies": { 30 | "@types/kcors": "^2.2.8", 31 | "@types/koa": "^2.13.12", 32 | "@types/koa-bodyparser": "^4.3.10", 33 | "@types/koa-logger": "^3.1.5", 34 | "@types/koa-router": "^7.4.8", 35 | "@types/node": "^17.0.12", 36 | "tsx": "^3.12.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/relay/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Observable, RequestParameters, Variables } from 'relay-runtime'; 2 | import { createClient } from 'graphql-ws'; 3 | 4 | const IS_SERVER = typeof window === typeof undefined; 5 | 6 | const SUBSCRIPTIONS_ENPOINT = process.env 7 | .NEXT_PUBLIC_SUBSCRIPTIONS_ENDPOINT as string; 8 | 9 | const subscriptionsClient = IS_SERVER 10 | ? null 11 | : createClient({ 12 | url: SUBSCRIPTIONS_ENPOINT, 13 | }); 14 | 15 | // both fetch and subscribe can be handled through one implementation 16 | // to understand why we return Observable, please see: https://github.com/enisdenjo/graphql-ws/issues/316#issuecomment-1047605774 17 | function subscribe( 18 | operation: RequestParameters, 19 | variables: Variables 20 | ): Observable { 21 | return Observable.create((sink) => { 22 | if (!subscriptionsClient) return; 23 | if (!operation.text) { 24 | return sink.error(new Error('Operation text cannot be empty')); 25 | } 26 | return subscriptionsClient.subscribe( 27 | { 28 | operationName: operation.name, 29 | query: operation.text, 30 | variables, 31 | }, 32 | sink 33 | ); 34 | }); 35 | } 36 | 37 | export { subscribe }; 38 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@woovi-playground/web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "config:local": "cp .env.example .env", 8 | "dev": "next dev", 9 | "lint": "next lint", 10 | "relay": "relay-compiler", 11 | "start": "next start" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.11.1", 15 | "@emotion/server": "^11.11.0", 16 | "@emotion/styled": "^11.11.0", 17 | "@mui/icons-material": "^5.14.18", 18 | "@mui/material": "^5.14.19", 19 | "@woovi-playground/ui": "*", 20 | "graphql-ws": "^5.14.2", 21 | "luxon": "^3.3.0", 22 | "next": "latest", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-relay": "^15.0.0", 26 | "relay-runtime": "^15.0.0" 27 | }, 28 | "devDependencies": { 29 | "@types/luxon": "^3.3.0", 30 | "@types/node": "^17.0.12", 31 | "@types/react": "^18.0.22", 32 | "@types/react-dom": "^18.0.7", 33 | "@types/react-relay": "^14.1.3", 34 | "@types/relay-runtime": "^14.1.10", 35 | "@woovi-playground/eslint-config-custom": "*", 36 | "@woovi-playground/tsconfig": "*", 37 | "relay-compiler": "^15.0.0", 38 | "typescript": "^4.5.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, Typography } from '@mui/material'; 2 | import { graphql, useFragment } from 'react-relay'; 3 | import { DateTime } from 'luxon'; 4 | 5 | import { WooviAvatar } from './WooviAvatar'; 6 | import { Message_message$key } from '../__generated__/Message_message.graphql'; 7 | 8 | type MessageProps = { 9 | message: Message_message$key; 10 | }; 11 | 12 | export const Message = (props: MessageProps) => { 13 | const message = useFragment( 14 | graphql` 15 | fragment Message_message on Message { 16 | content 17 | createdAt 18 | } 19 | `, 20 | props.message 21 | ); 22 | 23 | return ( 24 | 28 | 29 | 30 | 31 | Woovi Playground 32 | 33 | {DateTime.fromISO(message.createdAt).toFormat('dd/MM/yyyy HH:mm')} 34 | 35 | 36 | 37 | {message.content} 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /apps/server/src/server/wsServer.ts: -------------------------------------------------------------------------------- 1 | import { Server as WebSocketServer } from 'ws'; 2 | import { useServer } from 'graphql-ws/lib/use/ws'; 3 | import { schema } from '../schema/schema'; 4 | import { execute, parse, subscribe, validate } from 'graphql'; 5 | import { getContext } from './getContext'; 6 | import { Server } from 'http'; 7 | 8 | export const wsServer = (server: Server) => { 9 | const wss = new WebSocketServer({ 10 | server, 11 | path: '/graphql/ws', 12 | }); 13 | 14 | useServer( 15 | { 16 | schema, 17 | execute, 18 | subscribe, 19 | context: async () => getContext(), 20 | onConnect: () => { 21 | //eslint-disable-next-line 22 | console.log('Connected to Websocket'); 23 | 24 | return; 25 | }, 26 | onSubscribe: async (_, message) => { 27 | const { operationName, query, variables } = message.payload; 28 | 29 | const document = typeof query === 'string' ? parse(query) : query; 30 | 31 | const args = { 32 | schema, 33 | operationName, 34 | document, 35 | variableValues: variables, 36 | }; 37 | 38 | const validationErrors = validate(args.schema, args.document); 39 | 40 | if (validationErrors.length > 0) { 41 | return validationErrors; // return `GraphQLError[]` to send `ErrorMessage` and stop Subscription 42 | } 43 | 44 | return args; 45 | }, 46 | }, 47 | wss 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /apps/server/src/modules/node/typeRegister.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLTypeResolver } from 'graphql'; 2 | import { fromGlobalId, nodeDefinitions } from 'graphql-relay'; 3 | 4 | type Load = (context: unknown, id: string) => unknown; 5 | type TypeLoaders = { 6 | [key: string]: { 7 | type: GraphQLObjectType; 8 | load: Load; 9 | }; 10 | }; 11 | 12 | const getTypeRegister = () => { 13 | const typesLoaders: TypeLoaders = {}; 14 | 15 | const getTypesLoaders = () => typesLoaders; 16 | 17 | const registerTypeLoader = (type: GraphQLObjectType, load: Load) => { 18 | typesLoaders[type.name] = { 19 | type, 20 | load, 21 | }; 22 | 23 | return type; 24 | }; 25 | 26 | const { nodeField, nodesField, nodeInterface } = nodeDefinitions( 27 | (globalId: string, context: unknown) => { 28 | const { type, id } = fromGlobalId(globalId); 29 | 30 | const { load } = typesLoaders[type] || { load: null }; 31 | 32 | return (load && load(context, id)) || null; 33 | }, 34 | (obj: GraphQLTypeResolver) => { 35 | const { type } = typesLoaders[obj.constructor.name] || { type: null }; 36 | 37 | return type.name; 38 | } 39 | ); 40 | 41 | return { 42 | registerTypeLoader, 43 | getTypesLoaders, 44 | nodeField, 45 | nodesField, 46 | nodeInterface, 47 | }; 48 | }; 49 | 50 | const { registerTypeLoader, nodeInterface, nodeField, nodesField } = 51 | getTypeRegister(); 52 | 53 | export { registerTypeLoader, nodeInterface, nodeField, nodesField }; 54 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First, run the development server: 4 | 5 | ```bash 6 | pnpm dev 7 | ``` 8 | 9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 10 | 11 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 12 | 13 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 14 | 15 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /apps/web/src/relay/RelayHydrate.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React, { useMemo } from 'react'; 3 | import { useRelayEnvironment } from 'react-relay'; 4 | 5 | export type NextPageWithLayout = NextPage & { 6 | getLayout?: (page: React.ReactElement) => React.ReactNode; 7 | }; 8 | 9 | export const RelayHydrate = ({ 10 | Component, 11 | props, 12 | }: { 13 | Component: NextPageWithLayout; 14 | props: any; 15 | }) => { 16 | const environment = useRelayEnvironment(); 17 | 18 | const getLayout = Component.getLayout ?? ((page) => page); 19 | 20 | const transformedProps = useMemo(() => { 21 | if (props == null) { 22 | return props; 23 | } 24 | const { preloadedQueries, ...otherProps } = props; 25 | if (preloadedQueries == null) { 26 | return props; 27 | } 28 | 29 | const queryRefs: any = {}; 30 | for (const [queryName, { params, variables, response }] of Object.entries( 31 | preloadedQueries 32 | ) as any) { 33 | environment 34 | .getNetwork() 35 | // @ts-ignore - seems to be a private untyped api 🤷‍♂️ 36 | .responseCache.set(params.id, variables, response); 37 | // TODO: create using a function exported from react-relay package 38 | queryRefs[queryName] = { 39 | environment, 40 | fetchKey: params.id, 41 | fetchPolicy: 'store-or-network', 42 | isDisposed: false, 43 | name: params.name, 44 | kind: 'PreloadedQuery', 45 | variables, 46 | }; 47 | } 48 | 49 | return { ...otherProps, queryRefs }; 50 | }, [props]); 51 | 52 | return <>{getLayout()}; 53 | }; 54 | -------------------------------------------------------------------------------- /apps/server/src/server/websocketMiddleware.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | 3 | import WebSocket, { WebSocketServer as WSWebSocketServer } from 'ws'; 4 | 5 | // work with commonjs and esm 6 | const WebSocketServer = WSWebSocketServer; 7 | 8 | export const createWebsocketMiddleware = ( 9 | propertyName = 'ws', 10 | options = {} 11 | ) => { 12 | if (options instanceof http.Server) options = { server: options }; 13 | 14 | // const wsServers = new WeakMap(); 15 | const wsServers = {}; 16 | 17 | const getOrCreateWebsocketServer = (url: string) => { 18 | // const server = wsServers.get(url); 19 | const server = wsServers[url]; 20 | 21 | if (server) { 22 | return server; 23 | } 24 | 25 | const newServer = new WebSocketServer({ 26 | ...(options.wsOptions || {}), 27 | noServer: true, 28 | }); 29 | 30 | wsServers[url] = newServer; 31 | // wsServers.set(url, newServer); 32 | 33 | return newServer; 34 | }; 35 | 36 | const websocketMiddleware = async (ctx, next) => { 37 | const upgradeHeader = (ctx.request.headers.upgrade || '') 38 | .split(',') 39 | .map((s) => s.trim()); 40 | 41 | if (~upgradeHeader.indexOf('websocket')) { 42 | const wss = getOrCreateWebsocketServer(ctx.url); 43 | 44 | ctx[propertyName] = () => 45 | new Promise((resolve) => { 46 | wss.handleUpgrade( 47 | ctx.req, 48 | ctx.request.socket, 49 | Buffer.alloc(0), 50 | (ws) => { 51 | wss.emit('connection', ws, ctx.req); 52 | resolve(ws); 53 | } 54 | ); 55 | ctx.respond = false; 56 | }); 57 | ctx.wss = wss; 58 | } 59 | 60 | await next(); 61 | }; 62 | 63 | return websocketMiddleware; 64 | }; 65 | -------------------------------------------------------------------------------- /apps/web/src/components/MessageList.tsx: -------------------------------------------------------------------------------- 1 | import { Send } from '@mui/icons-material'; 2 | import { Box, IconButton, TextField } from '@mui/material'; 3 | import { useMutation } from 'react-relay'; 4 | import { useState } from 'react'; 5 | 6 | import { MessageAdd } from './MessageAddMutation'; 7 | import { MessageAddMutation } from '../__generated__/MessageAddMutation.graphql'; 8 | 9 | type MessageListProps = { 10 | children?: React.ReactNode; 11 | }; 12 | 13 | export const MessageList = ({ children }: MessageListProps) => { 14 | const [content, setContent] = useState(''); 15 | const [messageAdd, isPending] = useMutation(MessageAdd); 16 | 17 | const handleSubmit = (e) => { 18 | e.preventDefault(); 19 | 20 | messageAdd({ 21 | variables: { 22 | input: { 23 | content, 24 | }, 25 | }, 26 | }); 27 | 28 | setContent(''); 29 | }; 30 | 31 | return ( 32 | 41 | {children} 42 |
43 | 44 | setContent(e.target.value)} 51 | /> 52 | 61 | 62 | 63 | 64 |
65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /apps/web/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | 3 | import { Message } from '../components/Message'; 4 | import { Layout } from '../components/Layout'; 5 | import { MessageList } from '../components/MessageList'; 6 | import { getPreloadedQuery } from '../relay/network'; 7 | import { PreloadedQuery, graphql, usePreloadedQuery } from 'react-relay'; 8 | import pageQuery, { 9 | pages_PageQuery, 10 | } from '../__generated__/pages_PageQuery.graphql'; 11 | import { useMessageAddedSubscription } from '../components/subscriptions/useMessageAddedSubscription'; 12 | 13 | const IndexQuery = graphql` 14 | query pages_PageQuery($first: Int!, $after: String) { 15 | messages(first: $first, after: $after) @connection(key: "pages_messages") { 16 | __id 17 | edges { 18 | node { 19 | id 20 | ...Message_message 21 | } 22 | } 23 | } 24 | } 25 | `; 26 | 27 | type IndexProps = { 28 | queryRefs: { 29 | pageQueryRef: PreloadedQuery; 30 | }; 31 | }; 32 | 33 | const Index = ({ queryRefs }: IndexProps) => { 34 | const data = usePreloadedQuery( 35 | IndexQuery, 36 | queryRefs.pageQueryRef 37 | ); 38 | 39 | useMessageAddedSubscription({ 40 | connections: [data.messages?.__id], 41 | input: {}, 42 | }); 43 | 44 | return ( 45 | 46 | 47 | {data.messages.edges.map(({ node }) => ( 48 | 49 | ))} 50 | 51 | 52 | ); 53 | }; 54 | 55 | export const getServerSideProps: GetServerSideProps = async (context) => { 56 | return { 57 | props: { 58 | preloadedQueries: { 59 | pageQueryRef: await getPreloadedQuery(pageQuery, { 60 | first: 1, 61 | after: null, 62 | }), 63 | }, 64 | }, 65 | }; 66 | }; 67 | 68 | export default Index; 69 | -------------------------------------------------------------------------------- /apps/server/src/server/createGraphqlWs.ts: -------------------------------------------------------------------------------- 1 | import { Server as WebSocketServer } from 'ws'; 2 | import { useServer } from 'graphql-ws/lib/use/ws'; 3 | import { schema } from '../schema/schema'; 4 | import { GraphQLSchema, execute, parse, subscribe, validate } from 'graphql'; 5 | import { Server } from 'http'; 6 | import { parse as urlParse } from 'url'; 7 | 8 | type CreateGraphQLServerOptions = { 9 | schema: GraphQLSchema; 10 | context: (() => unknown) | Record; 11 | }; 12 | 13 | export const createGraphqlWs = ( 14 | server: Server, 15 | path: string, 16 | options: CreateGraphQLServerOptions 17 | ) => { 18 | const wss = new WebSocketServer({ 19 | noServer: true, 20 | }); 21 | 22 | useServer( 23 | { 24 | schema: options.schema, 25 | execute, 26 | subscribe, 27 | context: async () => { 28 | if (typeof options.context === 'function') { 29 | return options.context(); 30 | } 31 | 32 | return options.context; 33 | }, 34 | onConnect: () => { 35 | //eslint-disable-next-line 36 | console.log(`Connected to ${path} Websocket`); 37 | 38 | return; 39 | }, 40 | onSubscribe: async (_, message) => { 41 | const { operationName, query, variables } = message.payload; 42 | 43 | const document = typeof query === 'string' ? parse(query) : query; 44 | 45 | const args = { 46 | schema, 47 | operationName, 48 | document, 49 | variableValues: variables, 50 | }; 51 | 52 | const validationErrors = validate(args.schema, args.document); 53 | 54 | if (validationErrors.length > 0) { 55 | return validationErrors; // return `GraphQLError[]` to send `ErrorMessage` and stop Subscription 56 | } 57 | 58 | return args; 59 | }, 60 | }, 61 | wss 62 | ); 63 | 64 | server.on('upgrade', function upgrade(request, socket, head) { 65 | const { pathname } = urlParse(request.url); 66 | 67 | if (pathname === path) { 68 | wss.handleUpgrade(request, socket, head, function done(ws) { 69 | wss.emit('connection', ws, request); 70 | }); 71 | } 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /apps/server/src/server/ws.ts: -------------------------------------------------------------------------------- 1 | import { execute, subscribe, validate, parse } from 'graphql'; 2 | import { useServer } from 'graphql-ws/lib/use/ws'; 3 | 4 | import { schema } from '../schema/schema'; 5 | import { getContext } from './getContext'; 6 | 7 | export type ConnectionParams = { Authorization: string }; 8 | 9 | type WsContext = { 10 | connectionInitReceived: boolean; 11 | acknowledged: boolean; 12 | subscriptions: any; 13 | extra: { socket: [WebSocket]; request: any }; 14 | connectionParams: ConnectionParams; 15 | }; 16 | 17 | export const ws = async (ctx) => { 18 | if (ctx.wss) { 19 | // handle upgrade 20 | const client = await ctx.ws(); 21 | 22 | useServer( 23 | { 24 | schema, 25 | context: async (wsContext: WsContext) => getContext(), 26 | execute, 27 | subscribe, 28 | onConnect: async (wsContext: WsContext) => {}, 29 | onSubscribe: async (wsContext: WsContext, message) => { 30 | const { operationName, query, variables } = message.payload; 31 | 32 | const document = typeof query === 'string' ? parse(query) : query; 33 | 34 | const args = { 35 | schema, 36 | contextValue: {}, 37 | operationName, 38 | document, 39 | variableValues: variables, 40 | }; 41 | 42 | const validationErrors = validate(args.schema, args.document); 43 | 44 | if (validationErrors.length > 0) { 45 | return validationErrors; // return `GraphQLError[]` to send `ErrorMessage` and stop Subscription 46 | } 47 | 48 | return args; 49 | }, 50 | // onNext: async ({ connectionParams }) => { 51 | // const token = getTokenFromConnectionParams(connectionParams); 52 | 53 | // if (!(await isTokenValid(token))) { 54 | // return ctx.extra.socket.close(4403, 'Forbidden'); 55 | // } 56 | // }, 57 | // onError: (ctx, msg, errors) => { 58 | // console.error('Error', { ctx, msg, errors }); 59 | // }, 60 | // onComplete: (ctx, msg) => { 61 | // console.log('Complete', { ctx, msg }); 62 | // }, 63 | }, 64 | ctx.wss 65 | ); 66 | 67 | // connect to websocket 68 | ctx.wss.emit('connection', client, ctx.req); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /apps/web/data/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | messages( 3 | """Returns the items in the list that come after the specified cursor.""" 4 | after: String 5 | 6 | """Returns the first n items from the list.""" 7 | first: Int 8 | 9 | """Returns the items in the list that come before the specified cursor.""" 10 | before: String 11 | 12 | """Returns the last n items from the list.""" 13 | last: Int 14 | ): MessageConnection 15 | } 16 | 17 | """A connection to a list of items.""" 18 | type MessageConnection { 19 | """Information to aid in pagination.""" 20 | pageInfo: PageInfo! 21 | 22 | """A list of edges.""" 23 | edges: [MessageEdge] 24 | } 25 | 26 | """Information about pagination in a connection.""" 27 | type PageInfo { 28 | """When paginating forwards, are there more items?""" 29 | hasNextPage: Boolean! 30 | 31 | """When paginating backwards, are there more items?""" 32 | hasPreviousPage: Boolean! 33 | 34 | """When paginating backwards, the cursor to continue.""" 35 | startCursor: String 36 | 37 | """When paginating forwards, the cursor to continue.""" 38 | endCursor: String 39 | } 40 | 41 | """An edge in a connection.""" 42 | type MessageEdge { 43 | """The item at the end of the edge""" 44 | node: Message 45 | 46 | """A cursor for use in pagination""" 47 | cursor: String! 48 | } 49 | 50 | """Represents a message""" 51 | type Message implements Node { 52 | """The ID of an object""" 53 | id: ID! 54 | content: String 55 | createdAt: String 56 | } 57 | 58 | """An object with an ID""" 59 | interface Node { 60 | """The id of the object.""" 61 | id: ID! 62 | } 63 | 64 | type Mutation { 65 | MessageAdd(input: MessageAddInput!): MessageAddPayload 66 | } 67 | 68 | type MessageAddPayload { 69 | message: Message 70 | clientMutationId: String 71 | } 72 | 73 | input MessageAddInput { 74 | content: String! 75 | clientMutationId: String 76 | } 77 | 78 | type Subscription { 79 | MessageAdded(input: MessageAddedInput!): MessageAddedPayload 80 | } 81 | 82 | type MessageAddedPayload { 83 | message: Message 84 | clientSubscriptionId: String 85 | } 86 | 87 | input MessageAddedInput { 88 | clientSubscriptionId: String 89 | } -------------------------------------------------------------------------------- /apps/server/schema/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | messages( 3 | """Returns the items in the list that come after the specified cursor.""" 4 | after: String 5 | 6 | """Returns the first n items from the list.""" 7 | first: Int 8 | 9 | """Returns the items in the list that come before the specified cursor.""" 10 | before: String 11 | 12 | """Returns the last n items from the list.""" 13 | last: Int 14 | ): MessageConnection 15 | } 16 | 17 | """A connection to a list of items.""" 18 | type MessageConnection { 19 | """Information to aid in pagination.""" 20 | pageInfo: PageInfo! 21 | 22 | """A list of edges.""" 23 | edges: [MessageEdge] 24 | } 25 | 26 | """Information about pagination in a connection.""" 27 | type PageInfo { 28 | """When paginating forwards, are there more items?""" 29 | hasNextPage: Boolean! 30 | 31 | """When paginating backwards, are there more items?""" 32 | hasPreviousPage: Boolean! 33 | 34 | """When paginating backwards, the cursor to continue.""" 35 | startCursor: String 36 | 37 | """When paginating forwards, the cursor to continue.""" 38 | endCursor: String 39 | } 40 | 41 | """An edge in a connection.""" 42 | type MessageEdge { 43 | """The item at the end of the edge""" 44 | node: Message 45 | 46 | """A cursor for use in pagination""" 47 | cursor: String! 48 | } 49 | 50 | """Represents a message""" 51 | type Message implements Node { 52 | """The ID of an object""" 53 | id: ID! 54 | content: String 55 | createdAt: String 56 | } 57 | 58 | """An object with an ID""" 59 | interface Node { 60 | """The id of the object.""" 61 | id: ID! 62 | } 63 | 64 | type Mutation { 65 | MessageAdd(input: MessageAddInput!): MessageAddPayload 66 | } 67 | 68 | type MessageAddPayload { 69 | message: Message 70 | clientMutationId: String 71 | } 72 | 73 | input MessageAddInput { 74 | content: String! 75 | clientMutationId: String 76 | } 77 | 78 | type Subscription { 79 | MessageAdded(input: MessageAddedInput!): MessageAddedPayload 80 | } 81 | 82 | type MessageAddedPayload { 83 | message: Message 84 | clientSubscriptionId: String 85 | } 86 | 87 | input MessageAddedInput { 88 | clientSubscriptionId: String 89 | } -------------------------------------------------------------------------------- /apps/web/src/relay/network.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CacheConfig, 3 | ConcreteRequest, 4 | Network, 5 | QueryResponseCache, 6 | RequestParameters, 7 | Variables, 8 | } from 'relay-runtime'; 9 | import { subscribe } from './websocket'; 10 | 11 | const ONE_MINUTE_IN_MS = 60 * 1000; 12 | 13 | function createNetwork() { 14 | const responseCache = new QueryResponseCache({ 15 | size: 100, 16 | ttl: ONE_MINUTE_IN_MS, 17 | }); 18 | 19 | async function fetchResponse( 20 | operation: RequestParameters, 21 | variables: Variables, 22 | cacheConfig: CacheConfig 23 | ) { 24 | const { id } = operation; 25 | 26 | const isQuery = operation.operationKind === 'query'; 27 | const forceFetch = cacheConfig && cacheConfig.force; 28 | 29 | if (isQuery && !forceFetch) { 30 | const fromCache = responseCache.get(id as string, variables); 31 | if (fromCache != null) { 32 | return Promise.resolve(fromCache); 33 | } 34 | } 35 | 36 | return networkFetch(operation, variables); 37 | } 38 | 39 | const network = Network.create(fetchResponse, subscribe); 40 | // @ts-ignore Private API Hackery? 🤷‍♂️ 41 | network.responseCache = responseCache; 42 | return network; 43 | } 44 | /** 45 | * Relay requires developers to configure a "fetch" function that tells Relay how to load 46 | * the results of GraphQL queries from your server (or other data source). See more at 47 | * https://relay.dev/docs/en/quick-start-guide#relay-environment. 48 | */ 49 | 50 | const GRAPHQL_ENPOINT = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT as string; 51 | 52 | async function networkFetch( 53 | params: RequestParameters, 54 | variables: Variables, 55 | headers?: HeadersInit 56 | ) { 57 | // Fetch data from GraphQL API: 58 | const response = await fetch(GRAPHQL_ENPOINT, { 59 | method: 'POST', 60 | headers: { 61 | ...headers, 62 | 'Content-Type': 'application/json', 63 | }, 64 | body: JSON.stringify({ 65 | query: params.text, 66 | variables, 67 | }), 68 | }); 69 | 70 | // Get the response as JSON 71 | const json = await response.json(); 72 | 73 | // GraphQL returns exceptions (for example, a missing required variable) in the "errors" 74 | // property of the response. If any exceptions occurred when processing the request, 75 | // throw an error to indicate to the developer what went wrong. 76 | if (Array.isArray(json.errors)) { 77 | throw new Error( 78 | `Error fetching GraphQL query '${ 79 | params.name 80 | }' with variables '${JSON.stringify(variables)}': ${JSON.stringify( 81 | json.errors 82 | )}` 83 | ); 84 | } 85 | 86 | // Otherwise, return the full payload. 87 | return json; 88 | } 89 | 90 | async function getPreloadedQuery( 91 | { params }: ConcreteRequest, 92 | variables: Variables, 93 | headers?: HeadersInit 94 | ) { 95 | const response = await networkFetch(params, variables, headers); 96 | return { 97 | params, 98 | variables, 99 | response, 100 | }; 101 | } 102 | 103 | export { createNetwork, getPreloadedQuery }; 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 | 7 | Logo 8 | 9 | 10 |

Woovi Playground

11 | 12 |

13 | Report Bug 14 |

15 |
16 | 17 | 18 |
19 | Table of Contents 20 |
    21 |
  1. 22 | About The Project 23 | 26 |
  2. 27 |
  3. 28 | Getting Started 29 | 33 |
  4. 34 |
  5. Contributing
  6. 35 |
  7. Contact
  8. 36 |
37 |
38 | 39 | ### Built With 40 | 41 | [![Next][next.js]][next-url] 42 | [![React][react.js]][react-url] 43 | [![Node][node.js]][node-url] 44 | [![GraphQL][graphql]][graphql-url] 45 | [![MongoDB][mongodb]][mongodb-url] 46 | [![Koa][koa]][koa-url] 47 | 48 |

(back to top)

49 | 50 | 51 | 52 | ## Getting Started 53 | 54 | To get a local copy up and running follow these simple example steps. 55 | 56 | ### Prerequisites 57 | 58 | This is an example of how to list things you need to use the software and how to install them. 59 | 60 | - Node.js 61 | 62 | ```sh 63 | https://nodejs.org/en/download/ 64 | ``` 65 | 66 | - PNPM 67 | 68 | ```sh 69 | npm install pnpm -g 70 | ``` 71 | 72 | - Docker 73 | 74 | ```sh 75 | https://www.docker.com/get-started/ 76 | ``` 77 | 78 | ## Installation 79 | 80 | Clone the repo 81 | 82 | ```sh 83 | git clone https://github.com/entria/woovi-playground.git 84 | ``` 85 | 86 | 1. Install packages 87 | 88 | ```sh 89 | pnpm install 90 | ``` 91 | 92 | 2. Run the container(or stop it, if necessary): 93 | 94 | ```sh 95 | pnpm compose:up 96 | ``` 97 | 98 | 3. Setup Configuration 99 | 100 | ```sh 101 | pnpm config:local 102 | ``` 103 | 104 | 4. Run the relay 105 | 106 | ```sh 107 | pnpm relay 108 | ``` 109 | 110 | 5. Run the Project 111 | 112 | ```sh 113 | pnpm dev 114 | ``` 115 | 116 | 117 | 118 | ## Contributing 119 | 120 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 121 | 122 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 123 | Don't forget to give the project a star! Thanks again! 124 | 125 | 1. Fork the Project 126 | 2. Create your Feature Branch (`git checkout -b feature/amazing-feature`) 127 | 3. Commit your Changes (`git commit -m 'feat(amazing-feature): my feature is awesome'`) 128 | 4. Push to the Branch (`git push origin feature/amazing-feature`) 129 | 5. Open a Pull Request 130 | 131 |

(back to top)

132 | 133 | 134 | 135 | ## Contact 136 | 137 | Project Link: [https://github.com/entria/woovi-playground](https://github.com/entria/woovi-playground) 138 | 139 |

(back to top)

140 | 141 | 142 | 143 | 144 | [next.js]: https://img.shields.io/badge/Next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white 145 | [next-url]: https://nextjs.org/ 146 | [react.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB 147 | [react-url]: https://reactjs.org/ 148 | [node.js]: https://img.shields.io/badge/NodeJS-339933?style=for-the-badge&logo=nodedotjs&logoColor=white 149 | [node-url]: https://nodejs.org/ 150 | [graphql]: https://img.shields.io/badge/Graphql-E10098?style=for-the-badge&logo=graphql&logoColor=white 151 | [graphql-url]: https://graphql.org/ 152 | [mongodb]: https://img.shields.io/badge/MongoDB-47A248?style=for-the-badge&logo=mongodb&logoColor=white 153 | [mongodb-url]: https://mongodb.com 154 | [koa]: https://img.shields.io/badge/Koa-F9F9F9?style=for-the-badge&logo=koa&logoColor=33333D 155 | [koa-url]: https://koajs.com 156 | --------------------------------------------------------------------------------