├── .gitignore ├── .prettierrc ├── .yarnclean_ ├── Dockerfile ├── README.md ├── lerna.json ├── package.json ├── packages ├── backend │ ├── .gitignore │ ├── README.md │ ├── codegen.yml │ ├── package.json │ ├── patches │ │ ├── graphql-live-subscriptions+1.4.2.patch │ │ └── subscriptions-transport-ws+0.9.16.patch │ ├── schema-loader.js │ ├── src │ │ ├── @types.ts │ │ ├── graphql-live-subscriptions.d.ts │ │ ├── graphql │ │ │ ├── __generated__ │ │ │ │ └── graphql-types.ts │ │ │ ├── modules │ │ │ │ ├── live.ts │ │ │ │ └── message.ts │ │ │ └── schema.ts │ │ ├── index.ts │ │ └── register-fake-users.ts │ ├── tsconfig.json │ └── yarn.lock └── frontend │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── relay.config.js │ ├── schema.graphql │ ├── src │ ├── __generated__ │ │ ├── appQuery.graphql.ts │ │ ├── appSubscription.graphql.ts │ │ ├── chatMessage_message.graphql.ts │ │ ├── chat_app.graphql.ts │ │ └── messageAddMutation.graphql.ts │ ├── app.tsx │ ├── chat-message.tsx │ ├── chat.tsx │ ├── index.tsx │ ├── jsonpatch.ts │ ├── message-add-mutation.ts │ ├── react-app-env.d.ts │ ├── relay-environment.ts │ ├── setupProxy.js │ ├── setupTests.ts │ └── styles.css │ ├── tsconfig.json │ └── yarn.lock └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.yarnclean_: -------------------------------------------------------------------------------- 1 | # This file is only used by the docker build for slimming down the image size :) 2 | 3 | # test directories 4 | __tests__ 5 | test 6 | tests 7 | powered-test 8 | 9 | # asset directories 10 | docs 11 | doc 12 | website 13 | images 14 | assets 15 | 16 | # examples 17 | example 18 | examples 19 | 20 | # code coverage directories 21 | coverage 22 | .nyc_output 23 | 24 | # build scripts 25 | Makefile 26 | Gulpfile.js 27 | Gruntfile.js 28 | 29 | # configs 30 | appveyor.yml 31 | circle.yml 32 | codeship-services.yml 33 | codeship-steps.yml 34 | wercker.yml 35 | .tern-project 36 | .gitattributes 37 | .editorconfig 38 | .*ignore 39 | .eslintrc 40 | .jshintrc 41 | .flowconfig 42 | .documentup.json 43 | .yarn-metadata.json 44 | .travis.yml 45 | 46 | # misc 47 | *.md 48 | *.yml 49 | .github 50 | 51 | *.ts 52 | *.map 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-slim as frontend-builder 2 | 3 | WORKDIR /usr/context 4 | 5 | COPY packages/frontend/package.json . 6 | COPY packages/frontend/yarn.lock . 7 | RUN yarn install --frozen-lockfile 8 | 9 | COPY packages/frontend/tsconfig.json . 10 | COPY packages/frontend/src/ ./src/ 11 | COPY packages/frontend/public/ ./public/ 12 | 13 | RUN yarn build 14 | 15 | FROM node:12-slim as backend-builder 16 | 17 | WORKDIR /usr/context 18 | 19 | COPY packages/backend/package.json . 20 | COPY packages/backend/yarn.lock . 21 | RUN yarn install --frozen-lockfile 22 | 23 | COPY packages/backend/tsconfig.json . 24 | COPY packages/backend/src/ ./src/ 25 | 26 | RUN yarn build 27 | 28 | FROM node:12-slim as backend-dependency-builder 29 | 30 | WORKDIR /usr/context 31 | COPY packages/backend/package.json . 32 | COPY packages/backend/yarn.lock . 33 | COPY ./packages/backend/patches ./patches 34 | RUN yarn install --frozen-lockfile 35 | RUN npm prune --production 36 | COPY ./.yarnclean_ ./.yarnclean 37 | RUN yarn autoclean --force 38 | RUN find . -type d -empty -delete 39 | 40 | FROM m03geek/alpine-node:milli-12 as application 41 | 42 | WORKDIR /usr/app 43 | 44 | ARG NODE_ENV="production" 45 | ENV NODE_ENV="production" 46 | COPY --from=backend-builder /usr/context/package.json ./ 47 | COPY --from=backend-dependency-builder /usr/context/node_modules/ ./node_modules/ 48 | 49 | COPY --from=backend-builder /usr/context/build/ ./lib 50 | COPY --from=frontend-builder /usr/context/build ./public/ 51 | 52 | CMD ["node", "lib/index.js"] 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Live Chat 2 | 3 | Simple Web Chat Application based on https://github.com/D1plo1d/graphql-live-subscriptions 4 | 5 | ![demo](https://user-images.githubusercontent.com/14338007/77768455-cb594880-7042-11ea-95c7-89d98a80f6b1.gif) 6 | 7 | ## Stack Overview 8 | 9 | ### Frontend 10 | 11 | - TypeScript 12 | - React 13 | - Relay 14 | 15 | ### Backend 16 | 17 | - GraphQL (Live) Subscriptions 18 | - GraphQL Codegen 19 | - Express 20 | - TypeScript 21 | - Immer.js 22 | 23 | ## How can I run it? 24 | 25 | ### Locally (Development Mode) 26 | 27 | ```bash 28 | yarn install 29 | yarn lerna bootstrap 30 | yarn lerna run start 31 | ``` 32 | 33 | Visit `http://127.0.0.1:3000`. 34 | 35 | ### Docker (Production Mode) 36 | 37 | ```bash 38 | docker build -t graphql-live-chat . 39 | docker run -p 8080:3001 graphql-live-chat 40 | ``` 41 | 42 | Visit `http://127.0.0.0.1:8080`. 43 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "0.0.0", 4 | "npmClient": "yarn", 5 | "useWorkspaces": false 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-live-chat", 3 | "private": true, 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "lerna": "3.20.2", 9 | "prettier": "2.0.2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /packages/backend/README.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | 3 | ## Commands 4 | 5 | ### Generate GraphQL Types 6 | 7 | `yarn graphql-codegen` 8 | -------------------------------------------------------------------------------- /packages/backend/codegen.yml: -------------------------------------------------------------------------------- 1 | schema: 2 | "./src/graphql/modules/*.ts": 3 | loader: ./schema-loader.js 4 | config: 5 | contextType: "../../@types#IContextType" 6 | rootValueType: "../../@types#RootValueType" 7 | nonOptionalTypename: true 8 | noSchemaStitching: true 9 | avoidOptionals: true 10 | immutableTypes: true 11 | scalars: 12 | JSON: "unknown" 13 | mappers: 14 | Message: "../modules/message#IMessageType" 15 | LiveSubscription: "../../@types#RootValueType" 16 | 17 | generates: 18 | "./src/graphql/__generated__/graphql-types.ts": 19 | plugins: 20 | - "typescript" 21 | - "typescript-resolvers" 22 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphql-live-chat/backend", 3 | "version": "0.0.0", 4 | "description": "> TODO: description", 5 | "author": "n1ru4l ", 6 | "homepage": "", 7 | "license": "MIT", 8 | "files": [ 9 | "lib" 10 | ], 11 | "publishConfig": { 12 | "registry": "https://registry.yarnpkg.com" 13 | }, 14 | "scripts": { 15 | "test": "echo \"Error: run tests from root\" && exit 1", 16 | "start": "ts-node-dev src/index.ts", 17 | "postinstall": "yarn patch-package", 18 | "build": "yarn tsc" 19 | }, 20 | "dependencies": { 21 | "express": "4.17.1", 22 | "faker": "4.1.0", 23 | "graphql": "14.6.0", 24 | "graphql-live-subscriptions": "1.4.2", 25 | "graphql-subscriptions": "1.1.0", 26 | "graphql-tools": "4.0.7", 27 | "graphql-type-json": "0.3.1", 28 | "immer": "8.0.1", 29 | "subscriptions-transport-ws": "0.9.16", 30 | "uuid": "7.0.2" 31 | }, 32 | "devDependencies": { 33 | "@graphql-codegen/cli": "1.13.1", 34 | "@graphql-codegen/typescript": "1.13.1", 35 | "@graphql-codegen/typescript-resolvers": "1.13.1", 36 | "@graphql-toolkit/schema-merging": "0.9.10", 37 | "@types/express": "4.17.3", 38 | "@types/faker": "4.1.11", 39 | "@types/graphql-type-json": "0.3.2", 40 | "@types/node": "12.12.31", 41 | "@types/uuid": "7.0.2", 42 | "@types/ws": "7.2.3", 43 | "merge-graphql-schemas": "1.7.6", 44 | "patch-package": "6.2.1", 45 | "ts-node-dev": "1.0.0-pre.44", 46 | "typescript": "3.8.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/backend/patches/graphql-live-subscriptions+1.4.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/ReactiveTree.js b/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/ReactiveTree.js 2 | index 2b154c4..c51776c 100644 3 | --- a/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/ReactiveTree.js 4 | +++ b/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/ReactiveTree.js 5 | @@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", { 6 | }); 7 | exports.default = exports.createReactiveTreeInner = void 0; 8 | 9 | -var _execute = require("graphql/execution/execute"); 10 | +var _path = require("graphql/jsutils/Path"); 11 | 12 | var _collectSubFields = _interopRequireDefault(require("../util/collectSubFields")); 13 | 14 | @@ -67,8 +67,8 @@ const ReactiveTree = ({ 15 | }); 16 | const queryFieldDef = liveDataType.getFields().query; 17 | let graphqlPath; 18 | - graphqlPath = (0, _execute.addPath)(undefined, subscriptionName); 19 | - graphqlPath = (0, _execute.addPath)(graphqlPath, 'query'); 20 | + graphqlPath = (0, _path.addPath)(undefined, subscriptionName); 21 | + graphqlPath = (0, _path.addPath)(graphqlPath, 'query'); 22 | const sourceRootConfig = { 23 | // all ReactiveNodes that are source roots in the current query in the order 24 | // that they are initially resolved which is the same order they will 25 | diff --git a/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateChildNodes.js b/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateChildNodes.js 26 | index 7421408..f32df51 100644 27 | --- a/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateChildNodes.js 28 | +++ b/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateChildNodes.js 29 | @@ -7,8 +7,10 @@ exports.default = void 0; 30 | 31 | var _graphql = require("graphql"); 32 | 33 | +var _path = require("graphql/jsutils/Path"); 34 | var _execute = require("graphql/execution/execute"); 35 | 36 | + 37 | var _collectSubFields = _interopRequireDefault(require("../util/collectSubFields")); 38 | 39 | var _ReactiveNode = require("./ReactiveNode"); 40 | @@ -57,7 +59,7 @@ const updateChildNodes = reactiveNode => { 41 | 42 | Object.entries(fields).forEach(([childResponseName, childFieldNodes]) => { 43 | const childFieldDef = (0, _execute.getFieldDef)(schema, type, childFieldNodes[0].name.value); 44 | - const childPath = (0, _execute.addPath)(graphqlPath, childResponseName); 45 | + const childPath = (0, _path.addPath)(graphqlPath, childResponseName); 46 | const childReactiveNode = (0, _ReactiveNode.createNode)({ 47 | exeContext, 48 | parentType: type, 49 | diff --git a/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateListChildNodes.js b/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateListChildNodes.js 50 | index 524f01f..b2675da 100644 51 | --- a/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateListChildNodes.js 52 | +++ b/node_modules/graphql-live-subscriptions/dist/queryExecutors/reactiveTree/updateListChildNodes.js 53 | @@ -7,7 +7,7 @@ exports.default = exports.ADD = exports.REMOVE = void 0; 54 | 55 | var _graphql = require("graphql"); 56 | 57 | -var _execute = require("graphql/execution/execute"); 58 | +var _path = require("graphql/jsutils/Path"); 59 | 60 | var _listDiff = _interopRequireDefault(require("@d1plo1d/list-diff2")); 61 | 62 | @@ -63,7 +63,7 @@ const updateListChildNodes = reactiveNode => { 63 | parentType: reactiveNode.type, 64 | type: reactiveNode.type.ofType, 65 | fieldNodes, 66 | - graphqlPath: (0, _execute.addPath)(graphqlPath, move.index), 67 | + graphqlPath: (0, _path.addPath)(graphqlPath, move.index), 68 | sourceRootConfig 69 | }); // add the child at it's index 70 | 71 | diff --git a/node_modules/graphql-live-subscriptions/dist/subscribeToLiveData.js b/node_modules/graphql-live-subscriptions/dist/subscribeToLiveData.js 72 | index aa8dd1f..5e6ac63 100644 73 | --- a/node_modules/graphql-live-subscriptions/dist/subscribeToLiveData.js 74 | +++ b/node_modules/graphql-live-subscriptions/dist/subscribeToLiveData.js 75 | @@ -135,8 +135,8 @@ const subscribeToLiveData = ({ 76 | 77 | connectionPubSub.unsubscribe = subID => { 78 | originalUnsubscribe(subID); 79 | - eventEmitter.removeEventListener('update', onUpdate); 80 | - eventEmitter.removeEventListener('patch', onPatch); 81 | + eventEmitter.removeListener('update', onUpdate); 82 | + eventEmitter.removeListener('patch', onPatch); 83 | }; 84 | 85 | setImmediate(async () => { 86 | diff --git a/node_modules/graphql-live-subscriptions/src/queryExecutors/reactiveTree/ReactiveTree.js b/node_modules/graphql-live-subscriptions/src/queryExecutors/reactiveTree/ReactiveTree.js 87 | index f549654..0ad4d3e 100644 88 | --- a/node_modules/graphql-live-subscriptions/src/queryExecutors/reactiveTree/ReactiveTree.js 89 | +++ b/node_modules/graphql-live-subscriptions/src/queryExecutors/reactiveTree/ReactiveTree.js 90 | @@ -1,8 +1,8 @@ 91 | -import { addPath } from 'graphql/execution/execute' 92 | +import { addPath } from 'graphql/jsutils/Path' 93 | import collectSubFields from '../util/collectSubFields' 94 | 95 | import * as ReactiveNode from './ReactiveNode' 96 | - 97 | +console.log("aaaa",addPath) 98 | export const createReactiveTreeInner = (opts) => { 99 | const { 100 | exeContext, 101 | -------------------------------------------------------------------------------- /packages/backend/patches/subscriptions-transport-ws+0.9.16.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/subscriptions-transport-ws/dist/server.d.ts b/node_modules/subscriptions-transport-ws/dist/server.d.ts 2 | index 8ffafe2..e6efca2 100644 3 | --- a/node_modules/subscriptions-transport-ws/dist/server.d.ts 4 | +++ b/node_modules/subscriptions-transport-ws/dist/server.d.ts 5 | @@ -44,7 +44,7 @@ export declare type SubscribeFunction = (schema: GraphQLSchema, document: Docume 6 | [key: string]: any; 7 | }, operationName?: string, fieldResolver?: GraphQLFieldResolver, subscribeFieldResolver?: GraphQLFieldResolver) => AsyncIterator | Promise | ExecutionResult>; 8 | export interface ServerOptions { 9 | - rootValue?: any; 10 | + rootValue?: any | (() => any); 11 | schema?: GraphQLSchema; 12 | execute?: ExecuteFunction; 13 | subscribe?: SubscribeFunction; 14 | diff --git a/node_modules/subscriptions-transport-ws/dist/server.js b/node_modules/subscriptions-transport-ws/dist/server.js 15 | index 730c585..e3e0115 100644 16 | --- a/node_modules/subscriptions-transport-ws/dist/server.js 17 | +++ b/node_modules/subscriptions-transport-ws/dist/server.js 18 | @@ -196,7 +196,8 @@ var SubscriptionServer = (function () { 19 | if (_this.subscribe && is_subscriptions_1.isASubscriptionOperation(document, params.operationName)) { 20 | executor = _this.subscribe; 21 | } 22 | - executionPromise = Promise.resolve(executor(params.schema, document, _this.rootValue, params.context, params.variables, params.operationName)); 23 | + var rootValue = typeof _this.rootValue === "function" ? _this.rootValue() : _this.rootValue 24 | + executionPromise = Promise.resolve(executor(params.schema, document, rootValue, params.context, params.variables, params.operationName)); 25 | } 26 | return executionPromise.then(function (executionResult) { return ({ 27 | executionIterable: iterall_1.isAsyncIterable(executionResult) ? 28 | -------------------------------------------------------------------------------- /packages/backend/schema-loader.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const { buildSchema } = require("graphql"); 5 | const { mergeTypes } = require("merge-graphql-schemas"); 6 | 7 | // TODO: use AST parser instead 8 | const REGEX = () => /export const typeDefs = \/\* GraphQL \*\/ `([^`]*)`/s; 9 | 10 | /** 11 | * Custom schema loader for modules located under src/graphql/modules 12 | * Each file must have a typeDefs export. 13 | */ 14 | module.exports = (filePaths) => { 15 | if (Array.isArray(filePaths) === false) { 16 | filePaths = [filePaths]; 17 | } 18 | const schemaParts = []; 19 | for (const filePath of filePaths) { 20 | const contents = fs.readFileSync(filePath, "utf-8"); 21 | const result = REGEX().exec(contents); 22 | if (!result) { 23 | throw new Error(`Invalid '${filePath}'. exports no schema definition.`); 24 | } 25 | const [, schema] = result; 26 | schemaParts.push(schema); 27 | } 28 | return buildSchema(mergeTypes(schemaParts)); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/backend/src/@types.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { IMessageType } from "./graphql/modules/message"; 3 | 4 | type DeepReadonly = T extends (infer R)[] 5 | ? DeepReadonlyArray 6 | : T extends Function 7 | ? T 8 | : T extends object 9 | ? DeepReadonlyObject 10 | : T; 11 | 12 | interface DeepReadonlyArray extends ReadonlyArray> {} 13 | 14 | type DeepReadonlyObject = { 15 | readonly [P in keyof T]: DeepReadonly; 16 | }; 17 | 18 | export type IRootValueType = { 19 | messages: IMessageType[]; 20 | }; 21 | 22 | export type RootValueType = IRootValueType; 23 | 24 | export type IGetRootValueType = () => RootValueType; 25 | 26 | export type IContextType = { 27 | eventEmitter: EventEmitter; 28 | mutateState: (producer: (root: IRootValueType) => void) => void; 29 | addMessage: (message: IMessageType) => void; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/backend/src/graphql-live-subscriptions.d.ts: -------------------------------------------------------------------------------- 1 | declare module "graphql-live-subscriptions" { 2 | import { EventEmitter } from "events"; 3 | import { GraphQLObjectType, GraphQLType } from "graphql"; 4 | 5 | type ILiveSubscriptionTypeDefOptions = { 6 | type?: string; 7 | queryType?: string; 8 | subscriptionName?: string; 9 | }; 10 | declare function liveSubscriptionTypeDef( 11 | options: ILiveSubscriptionTypeDefOptions 12 | ): GraphQLObjectType; 13 | 14 | type ISubscribeToLiveDataOptions = { 15 | initialState: (source: TRoot, args: any, context: IContextType) => any; 16 | eventEmitter: ( 17 | source: TRoot, 18 | args: any, 19 | context: IContextType 20 | ) => EventEmitter; 21 | sourceRoots: { 22 | [typeName: string]: string[]; 23 | }; 24 | }; 25 | declare function subscribeToLiveData( 26 | options: ISubscribeToLiveDataOptions 27 | ): AsyncIterator; 28 | 29 | declare var GraphQLLiveData: (opts: { 30 | name: string; 31 | type: GraphQLType; 32 | resumption?: boolean; 33 | }) => GraphQLObjectType; 34 | 35 | export { liveSubscriptionTypeDef, subscribeToLiveData, GraphQLLiveData }; 36 | } 37 | 38 | // declare module "graphql-type-json/RFC6902Operation" { 39 | // import { GraphQLObjectType } from "graphql"; 40 | 41 | // declare var RFC6902Operation: GraphQLObjectType; 42 | // export default RFC6902Operation; 43 | // } 44 | -------------------------------------------------------------------------------- /packages/backend/src/graphql/__generated__/graphql-types.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql'; 2 | import { IMessageType } from '../modules/message'; 3 | import { RootValueType, IContextType } from '../../@types'; 4 | export type Maybe = T | null; 5 | export type RequireFields = { [X in Exclude]?: T[X] } & { [P in K]-?: NonNullable }; 6 | /** All built-in and custom scalars, mapped to their actual values */ 7 | export type Scalars = { 8 | ID: string; 9 | String: string; 10 | Boolean: boolean; 11 | Int: number; 12 | Float: number; 13 | JSON: unknown; 14 | }; 15 | 16 | 17 | export type LiveSubscription = { 18 | readonly __typename: 'LiveSubscription'; 19 | readonly query: Maybe; 20 | readonly patch: Maybe>; 21 | }; 22 | 23 | export type Message = { 24 | readonly __typename: 'Message'; 25 | readonly id: Scalars['ID']; 26 | readonly authorName: Scalars['String']; 27 | readonly rawContent: Scalars['String']; 28 | readonly createdAt: Scalars['String']; 29 | }; 30 | 31 | export type MessageAddInput = { 32 | readonly authorName: Scalars['String']; 33 | readonly rawContent: Scalars['String']; 34 | }; 35 | 36 | export type Mutation = { 37 | readonly __typename: 'Mutation'; 38 | readonly messageAdd: Maybe; 39 | }; 40 | 41 | 42 | export type MutationMessageAddArgs = { 43 | input: MessageAddInput; 44 | }; 45 | 46 | export type Query = { 47 | readonly __typename: 'Query'; 48 | readonly messages: ReadonlyArray; 49 | }; 50 | 51 | export type Rfc6902Operation = { 52 | readonly __typename: 'RFC6902Operation'; 53 | readonly op: Scalars['String']; 54 | readonly path: Scalars['String']; 55 | readonly from: Maybe; 56 | readonly value: Maybe; 57 | }; 58 | 59 | export type Subscription = { 60 | readonly __typename: 'Subscription'; 61 | readonly live: Maybe; 62 | }; 63 | 64 | 65 | 66 | export type ResolverTypeWrapper = Promise | T; 67 | 68 | export type Resolver = ResolverFn; 69 | 70 | export type ResolverFn = ( 71 | parent: TParent, 72 | args: TArgs, 73 | context: TContext, 74 | info: GraphQLResolveInfo 75 | ) => Promise | TResult; 76 | 77 | export type SubscriptionSubscribeFn = ( 78 | parent: TParent, 79 | args: TArgs, 80 | context: TContext, 81 | info: GraphQLResolveInfo 82 | ) => AsyncIterator | Promise>; 83 | 84 | export type SubscriptionResolveFn = ( 85 | parent: TParent, 86 | args: TArgs, 87 | context: TContext, 88 | info: GraphQLResolveInfo 89 | ) => TResult | Promise; 90 | 91 | export interface SubscriptionSubscriberObject { 92 | subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>; 93 | resolve?: SubscriptionResolveFn; 94 | } 95 | 96 | export interface SubscriptionResolverObject { 97 | subscribe: SubscriptionSubscribeFn; 98 | resolve: SubscriptionResolveFn; 99 | } 100 | 101 | export type SubscriptionObject = 102 | | SubscriptionSubscriberObject 103 | | SubscriptionResolverObject; 104 | 105 | export type SubscriptionResolver = 106 | | ((...args: any[]) => SubscriptionObject) 107 | | SubscriptionObject; 108 | 109 | export type TypeResolveFn = ( 110 | parent: TParent, 111 | context: TContext, 112 | info: GraphQLResolveInfo 113 | ) => Maybe | Promise>; 114 | 115 | export type isTypeOfResolverFn = (obj: T, info: GraphQLResolveInfo) => boolean | Promise; 116 | 117 | export type NextResolverFn = () => Promise; 118 | 119 | export type DirectiveResolverFn = ( 120 | next: NextResolverFn, 121 | parent: TParent, 122 | args: TArgs, 123 | context: TContext, 124 | info: GraphQLResolveInfo 125 | ) => TResult | Promise; 126 | 127 | /** Mapping between all available schema types and the resolvers types */ 128 | export type ResolversTypes = { 129 | Query: ResolverTypeWrapper, 130 | Message: ResolverTypeWrapper, 131 | ID: ResolverTypeWrapper, 132 | String: ResolverTypeWrapper, 133 | Mutation: ResolverTypeWrapper, 134 | MessageAddInput: MessageAddInput, 135 | Boolean: ResolverTypeWrapper, 136 | Subscription: ResolverTypeWrapper, 137 | LiveSubscription: ResolverTypeWrapper, 138 | RFC6902Operation: ResolverTypeWrapper, 139 | JSON: ResolverTypeWrapper, 140 | }; 141 | 142 | /** Mapping between all available schema types and the resolvers parents */ 143 | export type ResolversParentTypes = { 144 | Query: RootValueType, 145 | Message: IMessageType, 146 | ID: Scalars['ID'], 147 | String: Scalars['String'], 148 | Mutation: RootValueType, 149 | MessageAddInput: MessageAddInput, 150 | Boolean: Scalars['Boolean'], 151 | Subscription: RootValueType, 152 | LiveSubscription: RootValueType, 153 | RFC6902Operation: Rfc6902Operation, 154 | JSON: Scalars['JSON'], 155 | }; 156 | 157 | export interface JsonScalarConfig extends GraphQLScalarTypeConfig { 158 | name: 'JSON' 159 | } 160 | 161 | export type LiveSubscriptionResolvers = { 162 | query: Resolver, ParentType, ContextType>, 163 | patch: Resolver>, ParentType, ContextType>, 164 | __isTypeOf?: isTypeOfResolverFn, 165 | }; 166 | 167 | export type MessageResolvers = { 168 | id: Resolver, 169 | authorName: Resolver, 170 | rawContent: Resolver, 171 | createdAt: Resolver, 172 | __isTypeOf?: isTypeOfResolverFn, 173 | }; 174 | 175 | export type MutationResolvers = { 176 | messageAdd: Resolver, ParentType, ContextType, RequireFields>, 177 | }; 178 | 179 | export type QueryResolvers = { 180 | messages: Resolver, ParentType, ContextType>, 181 | }; 182 | 183 | export type Rfc6902OperationResolvers = { 184 | op: Resolver, 185 | path: Resolver, 186 | from: Resolver, ParentType, ContextType>, 187 | value: Resolver, ParentType, ContextType>, 188 | __isTypeOf?: isTypeOfResolverFn, 189 | }; 190 | 191 | export type SubscriptionResolvers = { 192 | live: SubscriptionResolver, "live", ParentType, ContextType>, 193 | }; 194 | 195 | export type Resolvers = { 196 | JSON: GraphQLScalarType, 197 | LiveSubscription: LiveSubscriptionResolvers, 198 | Message: MessageResolvers, 199 | Mutation: MutationResolvers, 200 | Query: QueryResolvers, 201 | RFC6902Operation: Rfc6902OperationResolvers, 202 | Subscription: SubscriptionResolvers, 203 | }; 204 | 205 | 206 | /** 207 | * @deprecated 208 | * Use "Resolvers" root object instead. If you wish to get "IResolvers", add "typesPrefix: I" to your config. 209 | */ 210 | export type IResolvers = Resolvers; 211 | -------------------------------------------------------------------------------- /packages/backend/src/graphql/modules/live.ts: -------------------------------------------------------------------------------- 1 | import { subscribeToLiveData } from "graphql-live-subscriptions"; 2 | import GraphQLJSON from "graphql-type-json"; 3 | import { SubscriptionResolvers } from "../__generated__/graphql-types"; 4 | import { RootValueType, IContextType, IGetRootValueType } from "../../@types"; 5 | 6 | export const typeDefs = /* GraphQL */ ` 7 | scalar JSON 8 | 9 | type RFC6902Operation { 10 | op: String! 11 | path: String! 12 | from: String 13 | value: JSON 14 | } 15 | 16 | type LiveSubscription { 17 | query: Query 18 | patch: [RFC6902Operation!] 19 | } 20 | 21 | type Subscription { 22 | live: LiveSubscription 23 | } 24 | 25 | type Mutation 26 | type Query 27 | `; 28 | 29 | const live: SubscriptionResolvers["live"] = { 30 | subscribe: subscribeToLiveData({ 31 | initialState: (root) => root, 32 | eventEmitter: (root, args, context) => context.eventEmitter, 33 | sourceRoots: {}, 34 | }), 35 | resolve: (root: RootValueType) => root, 36 | }; 37 | 38 | export const resolvers = { 39 | JSON: GraphQLJSON, 40 | Subscription: { live }, 41 | }; 42 | -------------------------------------------------------------------------------- /packages/backend/src/graphql/modules/message.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageResolvers, 3 | QueryResolvers, 4 | MutationResolvers, 5 | } from "../__generated__/graphql-types"; 6 | import { v4 as uuidV4 } from "uuid"; 7 | 8 | export const typeDefs = /* GraphQL */ ` 9 | type Message { 10 | id: ID! 11 | authorName: String! 12 | rawContent: String! 13 | createdAt: String! 14 | } 15 | 16 | input MessageAddInput { 17 | authorName: String! 18 | rawContent: String! 19 | } 20 | 21 | type Subscription 22 | 23 | type Query { 24 | messages: [Message!]! 25 | } 26 | 27 | type Mutation { 28 | messageAdd(input: MessageAddInput!): Boolean 29 | } 30 | `; 31 | 32 | export type IMessageType = { 33 | id: string; 34 | authorName: string; 35 | rawContent: string; 36 | createdAt: Date; 37 | }; 38 | 39 | const Message: MessageResolvers = { 40 | id: (message) => message.id, 41 | authorName: (message) => message.authorName, 42 | rawContent: (message) => message.rawContent, 43 | createdAt: (message) => String(message.createdAt), 44 | }; 45 | 46 | const messageAdd: MutationResolvers["messageAdd"] = (_, args, context) => { 47 | context.addMessage({ 48 | id: uuidV4(), 49 | authorName: args.input.authorName, 50 | rawContent: args.input.rawContent, 51 | createdAt: new Date(), 52 | }); 53 | return null; 54 | }; 55 | 56 | const Mutation = { messageAdd }; 57 | 58 | const messages: QueryResolvers["messages"] = (root) => root.messages; 59 | 60 | const Query = { 61 | messages, 62 | }; 63 | 64 | export const resolvers = { 65 | Message, 66 | Mutation, 67 | Query, 68 | }; 69 | -------------------------------------------------------------------------------- /packages/backend/src/graphql/schema.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from "graphql-tools"; 2 | import { mergeResolvers } from "@graphql-toolkit/schema-merging"; 3 | import { mergeTypeDefs } from "@graphql-toolkit/schema-merging"; 4 | import * as LiveModule from "./modules/live"; 5 | import * as MessageModule from "./modules/message"; 6 | 7 | export const schema = makeExecutableSchema({ 8 | typeDefs: mergeTypeDefs([MessageModule.typeDefs, LiveModule.typeDefs]), 9 | resolvers: mergeResolvers([ 10 | MessageModule.resolvers, 11 | LiveModule.resolvers, 12 | ]), 13 | }); 14 | -------------------------------------------------------------------------------- /packages/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as gql from "graphql"; 2 | import { EventEmitter } from "events"; 3 | import { SubscriptionServer } from "subscriptions-transport-ws"; 4 | import { produce } from "immer"; 5 | import express = require("express"); 6 | import { IRootValueType, IContextType } from "./@types"; 7 | import { schema } from "./graphql/schema"; 8 | import { registerFakeUsers } from "./register-fake-users"; 9 | import { IMessageType } from "./graphql/modules/message"; 10 | 11 | const app = express(); 12 | 13 | const HOST = process.env.HOST || "0.0.0.0"; 14 | const PORT = parseInt(process.env.port || "3001", 10); 15 | 16 | app.use(express.static("public")); 17 | app.all("/", (_, response) => { 18 | response.redirect("/"); 19 | }); 20 | 21 | const httpServer = app.listen(PORT, HOST, () => { 22 | console.log(`Listening on http://${HOST}:${PORT}.`); 23 | }); 24 | 25 | let rootValue: IRootValueType = { 26 | messages: [], 27 | }; 28 | 29 | const context: IContextType = { 30 | eventEmitter: new EventEmitter(), 31 | mutateState: (producer) => { 32 | rootValue = produce(rootValue, producer); 33 | context.eventEmitter.emit("update", { nextState: rootValue }); 34 | }, 35 | addMessage: (message: IMessageType) => { 36 | context.mutateState((root) => { 37 | if (root.messages.length > 100) { 38 | root.messages.splice(0, 1); 39 | } 40 | root.messages.push(message); 41 | }); 42 | }, 43 | }; 44 | 45 | if (process.env.NODE_ENV === "development") { 46 | registerFakeUsers({ context }); 47 | } 48 | 49 | const subscriptionServer = new SubscriptionServer( 50 | { 51 | execute: gql.execute, 52 | subscribe: gql.subscribe, 53 | schema: schema, 54 | rootValue: () => rootValue, 55 | onConnect: () => context, 56 | }, 57 | { 58 | server: httpServer, 59 | path: "/graphql", 60 | } 61 | ); 62 | 63 | const shutdownHandler = (() => { 64 | let isInvoked = false; 65 | return () => { 66 | if (isInvoked === true) return; 67 | isInvoked = true; 68 | 69 | subscriptionServer.close(); 70 | 71 | httpServer.close((err) => { 72 | if (err) { 73 | console.error(err); 74 | process.exitCode = 1; 75 | } 76 | }); 77 | }; 78 | })(); 79 | 80 | const errorExitHandler = (() => { 81 | let isInvoked = false; 82 | return () => { 83 | if (isInvoked === true) return; 84 | isInvoked = true; 85 | 86 | process.exitCode = 1; 87 | shutdownHandler(); 88 | 89 | setTimeout(() => { 90 | process.exit(1); 91 | }, 5000).unref(); 92 | }; 93 | })(); 94 | 95 | process.on("uncaughtException", () => { 96 | errorExitHandler(); 97 | }); 98 | 99 | process.on("unhandledRejection", () => { 100 | errorExitHandler(); 101 | }); 102 | 103 | process.on("SIGINT", shutdownHandler); 104 | -------------------------------------------------------------------------------- /packages/backend/src/register-fake-users.ts: -------------------------------------------------------------------------------- 1 | import { IContextType } from "./@types"; 2 | import * as faker from "faker"; 3 | 4 | const getRandomInt = (max: number, min = 0) => { 5 | min = Math.ceil(min); 6 | max = Math.floor(max); 7 | return Math.floor(Math.random() * (max - min + 1)) + min; 8 | }; 9 | 10 | export const registerFakeUsers = ({ context }: { context: IContextType }) => { 11 | let counter = 0; 12 | const addRandomMessage = () => { 13 | counter = counter + 1; 14 | context.addMessage({ 15 | id: String(counter), 16 | authorName: faker.internet.userName(), 17 | rawContent: faker.lorem.sentences(getRandomInt(3, 1)), 18 | createdAt: new Date(), 19 | }); 20 | }; 21 | 22 | setInterval(addRandomMessage, 2000).unref(); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["esnext"], 5 | "skipLibCheck": true, 6 | "outDir": "./build", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "strict": true 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /packages/frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphql-live-chat/frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test", 9 | "eject": "react-scripts eject", 10 | "relay": "relay-compiler --src ./src --schema ./schema.graphql --language typescript --artifactDirectory ./src/__generated__" 11 | }, 12 | "eslintConfig": { 13 | "extends": "react-app" 14 | }, 15 | "browserslist": { 16 | "production": [ 17 | ">0.2%", 18 | "not dead", 19 | "not op_mini all" 20 | ], 21 | "development": [ 22 | "last 1 chrome version", 23 | "last 1 firefox version", 24 | "last 1 safari version" 25 | ] 26 | }, 27 | "devDependencies": { 28 | "@testing-library/jest-dom": "4.2.4", 29 | "@testing-library/react": "9.3.2", 30 | "@testing-library/user-event": "7.1.2", 31 | "@types/jest": "24.0.0", 32 | "@types/node": "12.0.0", 33 | "@types/react": "16.9.0", 34 | "@types/react-dom": "16.9.0", 35 | "@types/react-relay": "7.0.3", 36 | "@types/relay-runtime": "8.0.7", 37 | "babel-plugin-relay": "9.0.0", 38 | "graphql": "14.6.0", 39 | "http-proxy-middleware": "1.0.3", 40 | "react": "16.13.1", 41 | "react-dom": "16.13.1", 42 | "react-relay": "9.0.0", 43 | "react-scripts": "3.4.1", 44 | "relay-compiler": "^9.0.0", 45 | "relay-compiler-language-typescript": "12.0.0", 46 | "relay-config": "^9.0.0", 47 | "relay-runtime": "9.0.0", 48 | "subscriptions-transport-ws": "0.9.16", 49 | "typescript": "3.7.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1ru4l/graphql-live-chat/27a0b902e9098c7cc0f92ccd6f36f7d7e28a9677/packages/frontend/public/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1ru4l/graphql-live-chat/27a0b902e9098c7cc0f92ccd6f36f7d7e28a9677/packages/frontend/public/logo192.png -------------------------------------------------------------------------------- /packages/frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1ru4l/graphql-live-chat/27a0b902e9098c7cc0f92ccd6f36f7d7e28a9677/packages/frontend/public/logo512.png -------------------------------------------------------------------------------- /packages/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/frontend/relay.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // ... 3 | // Configuration options accepted by the `relay-compiler` command-line tool and `babel-plugin-relay`. 4 | src: "./src", 5 | schema: "./data/schema.graphql", 6 | exclude: ["**/node_modules/**", "**/__mocks__/**", "**/__generated__/**"], 7 | }; 8 | -------------------------------------------------------------------------------- /packages/frontend/schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: RootMutationType 4 | subscription: Subscription 5 | } 6 | 7 | scalar JSON 8 | 9 | type LiveSubscription { 10 | query: Query 11 | patch: [RFC6902Operation!] 12 | } 13 | 14 | type Message { 15 | id: ID! 16 | authorName: String! 17 | rawContent: String! 18 | createdAt: String! 19 | } 20 | 21 | input MessageAddInput { 22 | authorName: String! 23 | rawContent: String! 24 | } 25 | 26 | type RFC6902Operation { 27 | op: String! 28 | path: String! 29 | from: String 30 | value: JSON 31 | } 32 | 33 | type RootMutationType { 34 | messageAdd(input: MessageAddInput): Boolean 35 | } 36 | 37 | type Query { 38 | messages: [Message!]! 39 | } 40 | 41 | type Subscription { 42 | live: LiveSubscription 43 | } 44 | -------------------------------------------------------------------------------- /packages/frontend/src/__generated__/appQuery.graphql.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /* @relayHash 22a6e1a1ee42b78d55a4e269b7c4880d */ 4 | 5 | import { ConcreteRequest } from "relay-runtime"; 6 | import { FragmentRefs } from "relay-runtime"; 7 | export type appQueryVariables = {}; 8 | export type appQueryResponse = { 9 | readonly " $fragmentRefs": FragmentRefs<"chat_app">; 10 | }; 11 | export type appQuery = { 12 | readonly response: appQueryResponse; 13 | readonly variables: appQueryVariables; 14 | }; 15 | 16 | 17 | 18 | /* 19 | query appQuery { 20 | ...chat_app 21 | } 22 | 23 | fragment chatMessage_message on Message { 24 | id 25 | authorName 26 | rawContent 27 | createdAt 28 | } 29 | 30 | fragment chat_app on Query { 31 | messages { 32 | id 33 | ...chatMessage_message 34 | } 35 | } 36 | */ 37 | 38 | const node: ConcreteRequest = { 39 | "kind": "Request", 40 | "fragment": { 41 | "kind": "Fragment", 42 | "name": "appQuery", 43 | "type": "Query", 44 | "metadata": null, 45 | "argumentDefinitions": [], 46 | "selections": [ 47 | { 48 | "kind": "FragmentSpread", 49 | "name": "chat_app", 50 | "args": null 51 | } 52 | ] 53 | }, 54 | "operation": { 55 | "kind": "Operation", 56 | "name": "appQuery", 57 | "argumentDefinitions": [], 58 | "selections": [ 59 | { 60 | "kind": "LinkedField", 61 | "alias": null, 62 | "name": "messages", 63 | "storageKey": null, 64 | "args": null, 65 | "concreteType": "Message", 66 | "plural": true, 67 | "selections": [ 68 | { 69 | "kind": "ScalarField", 70 | "alias": null, 71 | "name": "id", 72 | "args": null, 73 | "storageKey": null 74 | }, 75 | { 76 | "kind": "ScalarField", 77 | "alias": null, 78 | "name": "authorName", 79 | "args": null, 80 | "storageKey": null 81 | }, 82 | { 83 | "kind": "ScalarField", 84 | "alias": null, 85 | "name": "rawContent", 86 | "args": null, 87 | "storageKey": null 88 | }, 89 | { 90 | "kind": "ScalarField", 91 | "alias": null, 92 | "name": "createdAt", 93 | "args": null, 94 | "storageKey": null 95 | } 96 | ] 97 | } 98 | ] 99 | }, 100 | "params": { 101 | "operationKind": "query", 102 | "name": "appQuery", 103 | "id": null, 104 | "text": "query appQuery {\n ...chat_app\n}\n\nfragment chatMessage_message on Message {\n id\n authorName\n rawContent\n createdAt\n}\n\nfragment chat_app on Query {\n messages {\n id\n ...chatMessage_message\n }\n}\n", 105 | "metadata": {} 106 | } 107 | }; 108 | (node as any).hash = 'ee5ac318cee3817f2b2011324b7a22eb'; 109 | export default node; 110 | -------------------------------------------------------------------------------- /packages/frontend/src/__generated__/appSubscription.graphql.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /* @relayHash d5b221445c6fa0ba811a4f14629058b9 */ 4 | 5 | import { ConcreteRequest } from "relay-runtime"; 6 | import { FragmentRefs } from "relay-runtime"; 7 | export type appSubscriptionVariables = {}; 8 | export type appSubscriptionResponse = { 9 | readonly live: { 10 | readonly query: { 11 | readonly " $fragmentRefs": FragmentRefs<"chat_app">; 12 | } | null; 13 | readonly patch: ReadonlyArray<{ 14 | readonly op: string; 15 | readonly path: string; 16 | readonly from: string | null; 17 | readonly value: unknown | null; 18 | }> | null; 19 | } | null; 20 | }; 21 | export type appSubscription = { 22 | readonly response: appSubscriptionResponse; 23 | readonly variables: appSubscriptionVariables; 24 | }; 25 | 26 | 27 | 28 | /* 29 | subscription appSubscription { 30 | live { 31 | query { 32 | ...chat_app 33 | } 34 | patch { 35 | op 36 | path 37 | from 38 | value 39 | } 40 | } 41 | } 42 | 43 | fragment chatMessage_message on Message { 44 | id 45 | authorName 46 | rawContent 47 | createdAt 48 | } 49 | 50 | fragment chat_app on Query { 51 | messages { 52 | id 53 | ...chatMessage_message 54 | } 55 | } 56 | */ 57 | 58 | const node: ConcreteRequest = (function(){ 59 | var v0 = { 60 | "kind": "LinkedField", 61 | "alias": null, 62 | "name": "patch", 63 | "storageKey": null, 64 | "args": null, 65 | "concreteType": "RFC6902Operation", 66 | "plural": true, 67 | "selections": [ 68 | { 69 | "kind": "ScalarField", 70 | "alias": null, 71 | "name": "op", 72 | "args": null, 73 | "storageKey": null 74 | }, 75 | { 76 | "kind": "ScalarField", 77 | "alias": null, 78 | "name": "path", 79 | "args": null, 80 | "storageKey": null 81 | }, 82 | { 83 | "kind": "ScalarField", 84 | "alias": null, 85 | "name": "from", 86 | "args": null, 87 | "storageKey": null 88 | }, 89 | { 90 | "kind": "ScalarField", 91 | "alias": null, 92 | "name": "value", 93 | "args": null, 94 | "storageKey": null 95 | } 96 | ] 97 | }; 98 | return { 99 | "kind": "Request", 100 | "fragment": { 101 | "kind": "Fragment", 102 | "name": "appSubscription", 103 | "type": "Subscription", 104 | "metadata": null, 105 | "argumentDefinitions": [], 106 | "selections": [ 107 | { 108 | "kind": "LinkedField", 109 | "alias": null, 110 | "name": "live", 111 | "storageKey": null, 112 | "args": null, 113 | "concreteType": "LiveSubscription", 114 | "plural": false, 115 | "selections": [ 116 | { 117 | "kind": "LinkedField", 118 | "alias": null, 119 | "name": "query", 120 | "storageKey": null, 121 | "args": null, 122 | "concreteType": "Query", 123 | "plural": false, 124 | "selections": [ 125 | { 126 | "kind": "FragmentSpread", 127 | "name": "chat_app", 128 | "args": null 129 | } 130 | ] 131 | }, 132 | (v0/*: any*/) 133 | ] 134 | } 135 | ] 136 | }, 137 | "operation": { 138 | "kind": "Operation", 139 | "name": "appSubscription", 140 | "argumentDefinitions": [], 141 | "selections": [ 142 | { 143 | "kind": "LinkedField", 144 | "alias": null, 145 | "name": "live", 146 | "storageKey": null, 147 | "args": null, 148 | "concreteType": "LiveSubscription", 149 | "plural": false, 150 | "selections": [ 151 | { 152 | "kind": "LinkedField", 153 | "alias": null, 154 | "name": "query", 155 | "storageKey": null, 156 | "args": null, 157 | "concreteType": "Query", 158 | "plural": false, 159 | "selections": [ 160 | { 161 | "kind": "LinkedField", 162 | "alias": null, 163 | "name": "messages", 164 | "storageKey": null, 165 | "args": null, 166 | "concreteType": "Message", 167 | "plural": true, 168 | "selections": [ 169 | { 170 | "kind": "ScalarField", 171 | "alias": null, 172 | "name": "id", 173 | "args": null, 174 | "storageKey": null 175 | }, 176 | { 177 | "kind": "ScalarField", 178 | "alias": null, 179 | "name": "authorName", 180 | "args": null, 181 | "storageKey": null 182 | }, 183 | { 184 | "kind": "ScalarField", 185 | "alias": null, 186 | "name": "rawContent", 187 | "args": null, 188 | "storageKey": null 189 | }, 190 | { 191 | "kind": "ScalarField", 192 | "alias": null, 193 | "name": "createdAt", 194 | "args": null, 195 | "storageKey": null 196 | } 197 | ] 198 | } 199 | ] 200 | }, 201 | (v0/*: any*/) 202 | ] 203 | } 204 | ] 205 | }, 206 | "params": { 207 | "operationKind": "subscription", 208 | "name": "appSubscription", 209 | "id": null, 210 | "text": "subscription appSubscription {\n live {\n query {\n ...chat_app\n }\n patch {\n op\n path\n from\n value\n }\n }\n}\n\nfragment chatMessage_message on Message {\n id\n authorName\n rawContent\n createdAt\n}\n\nfragment chat_app on Query {\n messages {\n id\n ...chatMessage_message\n }\n}\n", 211 | "metadata": {} 212 | } 213 | }; 214 | })(); 215 | (node as any).hash = 'b700ddbdde79149f6a52c03f3c25de94'; 216 | export default node; 217 | -------------------------------------------------------------------------------- /packages/frontend/src/__generated__/chatMessage_message.graphql.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | 4 | import { ReaderFragment } from "relay-runtime"; 5 | import { FragmentRefs } from "relay-runtime"; 6 | export type chatMessage_message = { 7 | readonly id: string; 8 | readonly authorName: string; 9 | readonly rawContent: string; 10 | readonly createdAt: string; 11 | readonly " $refType": "chatMessage_message"; 12 | }; 13 | export type chatMessage_message$data = chatMessage_message; 14 | export type chatMessage_message$key = { 15 | readonly " $data"?: chatMessage_message$data; 16 | readonly " $fragmentRefs": FragmentRefs<"chatMessage_message">; 17 | }; 18 | 19 | 20 | 21 | const node: ReaderFragment = { 22 | "kind": "Fragment", 23 | "name": "chatMessage_message", 24 | "type": "Message", 25 | "metadata": null, 26 | "argumentDefinitions": [], 27 | "selections": [ 28 | { 29 | "kind": "ScalarField", 30 | "alias": null, 31 | "name": "id", 32 | "args": null, 33 | "storageKey": null 34 | }, 35 | { 36 | "kind": "ScalarField", 37 | "alias": null, 38 | "name": "authorName", 39 | "args": null, 40 | "storageKey": null 41 | }, 42 | { 43 | "kind": "ScalarField", 44 | "alias": null, 45 | "name": "rawContent", 46 | "args": null, 47 | "storageKey": null 48 | }, 49 | { 50 | "kind": "ScalarField", 51 | "alias": null, 52 | "name": "createdAt", 53 | "args": null, 54 | "storageKey": null 55 | } 56 | ] 57 | }; 58 | (node as any).hash = '961fc1422ef53556ea46829957905b07'; 59 | export default node; 60 | -------------------------------------------------------------------------------- /packages/frontend/src/__generated__/chat_app.graphql.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | 4 | import { ReaderFragment } from "relay-runtime"; 5 | import { FragmentRefs } from "relay-runtime"; 6 | export type chat_app = { 7 | readonly messages: ReadonlyArray<{ 8 | readonly id: string; 9 | readonly " $fragmentRefs": FragmentRefs<"chatMessage_message">; 10 | }>; 11 | readonly " $refType": "chat_app"; 12 | }; 13 | export type chat_app$data = chat_app; 14 | export type chat_app$key = { 15 | readonly " $data"?: chat_app$data; 16 | readonly " $fragmentRefs": FragmentRefs<"chat_app">; 17 | }; 18 | 19 | 20 | 21 | const node: ReaderFragment = { 22 | "kind": "Fragment", 23 | "name": "chat_app", 24 | "type": "Query", 25 | "metadata": null, 26 | "argumentDefinitions": [], 27 | "selections": [ 28 | { 29 | "kind": "LinkedField", 30 | "alias": null, 31 | "name": "messages", 32 | "storageKey": null, 33 | "args": null, 34 | "concreteType": "Message", 35 | "plural": true, 36 | "selections": [ 37 | { 38 | "kind": "ScalarField", 39 | "alias": null, 40 | "name": "id", 41 | "args": null, 42 | "storageKey": null 43 | }, 44 | { 45 | "kind": "FragmentSpread", 46 | "name": "chatMessage_message", 47 | "args": null 48 | } 49 | ] 50 | } 51 | ] 52 | }; 53 | (node as any).hash = '64d7e086c03c981baa98a88cc55d1fb7'; 54 | export default node; 55 | -------------------------------------------------------------------------------- /packages/frontend/src/__generated__/messageAddMutation.graphql.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /* @relayHash fda5a148c1642f758ac96a49b33af268 */ 4 | 5 | import { ConcreteRequest } from "relay-runtime"; 6 | export type MessageAddInput = { 7 | authorName: string; 8 | rawContent: string; 9 | }; 10 | export type messageAddMutationVariables = { 11 | input: MessageAddInput; 12 | }; 13 | export type messageAddMutationResponse = { 14 | readonly messageAdd: boolean | null; 15 | }; 16 | export type messageAddMutation = { 17 | readonly response: messageAddMutationResponse; 18 | readonly variables: messageAddMutationVariables; 19 | }; 20 | 21 | 22 | 23 | /* 24 | mutation messageAddMutation( 25 | $input: MessageAddInput! 26 | ) { 27 | messageAdd(input: $input) 28 | } 29 | */ 30 | 31 | const node: ConcreteRequest = (function(){ 32 | var v0 = [ 33 | { 34 | "kind": "LocalArgument", 35 | "name": "input", 36 | "type": "MessageAddInput!", 37 | "defaultValue": null 38 | } 39 | ], 40 | v1 = [ 41 | { 42 | "kind": "ScalarField", 43 | "alias": null, 44 | "name": "messageAdd", 45 | "args": [ 46 | { 47 | "kind": "Variable", 48 | "name": "input", 49 | "variableName": "input" 50 | } 51 | ], 52 | "storageKey": null 53 | } 54 | ]; 55 | return { 56 | "kind": "Request", 57 | "fragment": { 58 | "kind": "Fragment", 59 | "name": "messageAddMutation", 60 | "type": "RootMutationType", 61 | "metadata": null, 62 | "argumentDefinitions": (v0/*: any*/), 63 | "selections": (v1/*: any*/) 64 | }, 65 | "operation": { 66 | "kind": "Operation", 67 | "name": "messageAddMutation", 68 | "argumentDefinitions": (v0/*: any*/), 69 | "selections": (v1/*: any*/) 70 | }, 71 | "params": { 72 | "operationKind": "mutation", 73 | "name": "messageAddMutation", 74 | "id": null, 75 | "text": "mutation messageAddMutation(\n $input: MessageAddInput!\n) {\n messageAdd(input: $input)\n}\n", 76 | "metadata": {} 77 | } 78 | }; 79 | })(); 80 | (node as any).hash = '07c75ee8e12b1d2e1d8c858e2fe16bb4'; 81 | export default node; 82 | -------------------------------------------------------------------------------- /packages/frontend/src/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import graphql from "babel-plugin-relay/macro"; 3 | import Chat from "./chat"; 4 | import { QueryRenderer, requestSubscription } from "react-relay"; 5 | import { appSubscription } from "./__generated__/appSubscription.graphql"; 6 | import { appQuery } from "./__generated__/appQuery.graphql"; 7 | import { applyPatch } from "./jsonpatch"; 8 | import { useMessageAddMutation } from "./message-add-mutation"; 9 | import { useEnvironment } from "./relay-environment"; 10 | 11 | const AppSubscription = graphql` 12 | subscription appSubscription { 13 | live { 14 | query { 15 | ...chat_app 16 | } 17 | patch { 18 | op 19 | path 20 | from 21 | value 22 | } 23 | } 24 | } 25 | `; 26 | 27 | const AppQuery = graphql` 28 | query appQuery { 29 | ...chat_app 30 | } 31 | `; 32 | 33 | const Container: React.FC<{}> = ({ children }) => ( 34 |
42 | {children} 43 |
44 | ); 45 | 46 | const useLocalStorageState = ( 47 | identifier: string 48 | ): [string, React.Dispatch>] => { 49 | const [state, setState] = React.useState( 50 | () => window.localStorage.getItem(identifier) || "anon" 51 | ); 52 | React.useEffect(() => { 53 | setState((state) => window.localStorage.getItem(identifier) || state); 54 | }, [identifier]); 55 | 56 | React.useEffect(() => { 57 | const eventListener = (event: StorageEvent) => { 58 | if ( 59 | !event.storageArea || 60 | event.storageArea !== localStorage || 61 | event.key !== identifier || 62 | !event.newValue 63 | ) { 64 | return; 65 | } 66 | setState(event.newValue); 67 | }; 68 | window.addEventListener("storage", eventListener, false); 69 | return () => window.removeEventListener("storage", eventListener); 70 | }, [identifier]); 71 | 72 | const prevState = React.useRef(state); 73 | React.useEffect(() => { 74 | if (prevState.current !== state) { 75 | window.localStorage.setItem(identifier, state); 76 | } 77 | }, [identifier, state]); 78 | 79 | return [state, setState]; 80 | }; 81 | 82 | const ChatInput: React.FC<{}> = () => { 83 | const messageAdd = useMessageAddMutation(); 84 | const authorNameInputRef = React.useRef(null); 85 | const [userName, setUserName] = useLocalStorageState("chat.userName"); 86 | 87 | const onKeyDown = React.useCallback( 88 | (ev: React.KeyboardEvent) => { 89 | if (!authorNameInputRef.current) return; 90 | if (ev.key !== "Enter") return; 91 | messageAdd({ 92 | authorName: userName, 93 | rawContent: authorNameInputRef.current.value, 94 | }); 95 | authorNameInputRef.current.value = ""; 96 | }, 97 | [userName] 98 | ); 99 | 100 | const onChangeUserName = React.useCallback( 101 | (ev: React.ChangeEvent) => { 102 | setUserName(ev.target.value || "anon"); 103 | }, 104 | [setUserName] 105 | ); 106 | 107 | const onSubmit = useCallback( 108 | (ev: React.FormEvent) => ev.preventDefault(), 109 | [] 110 | ); 111 | 112 | return ( 113 |
117 |
118 | 126 |
127 |
128 | 140 |
141 |
142 | 145 |
146 |
147 | ); 148 | }; 149 | 150 | export const App: React.FC<{}> = () => { 151 | const environment = useEnvironment(); 152 | React.useEffect(() => { 153 | requestSubscription(environment, { 154 | subscription: AppSubscription, 155 | variables: {}, 156 | updater: (store) => { 157 | const rootField = store.getRootField("live"); 158 | if (!rootField) return; 159 | const patch = rootField.getLinkedRecords("patch"); 160 | if (patch) applyPatch(store, patch as any); 161 | }, 162 | }); 163 | }, [environment]); 164 | 165 | return ( 166 | 167 | query={AppQuery} 168 | environment={environment} 169 | variables={{}} 170 | render={({ error, props }) => { 171 | if (error) { 172 | return
Error!
; 173 | } 174 | if (!props) { 175 | return
Loading...
; 176 | } 177 | return ( 178 | 179 | 180 | 181 | 182 | ); 183 | }} 184 | /> 185 | ); 186 | }; 187 | -------------------------------------------------------------------------------- /packages/frontend/src/chat-message.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createFragmentContainer } from "react-relay"; 3 | import graphql from "babel-plugin-relay/macro"; 4 | import { chatMessage_message } from "./__generated__/chatMessage_message.graphql"; 5 | 6 | const ChatMessage: React.FC<{ message: chatMessage_message }> = React.memo( 7 | ({ message }) => { 8 | return ( 9 |
  • 10 |
    11 | {message.authorName} 12 |
    13 |
    {message.rawContent}
    14 |
  • 15 | ); 16 | } 17 | ); 18 | 19 | export default createFragmentContainer(ChatMessage, { 20 | message: graphql` 21 | fragment chatMessage_message on Message { 22 | id 23 | authorName 24 | rawContent 25 | createdAt 26 | } 27 | `, 28 | }); 29 | -------------------------------------------------------------------------------- /packages/frontend/src/chat.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createFragmentContainer } from "react-relay"; 3 | import graphql from "babel-plugin-relay/macro"; 4 | import { chat_app } from "./__generated__/chat_app.graphql"; 5 | import ChatMessage from "./chat-message"; 6 | 7 | const ChatWindow: React.FC<{ app: chat_app }> = ({ app }) => { 8 | const ref = React.useRef(null); 9 | const [follow, setFollow] = React.useState(true); 10 | React.useEffect(() => { 11 | if (!ref.current) return; 12 | if (follow === true) { 13 | ref.current.scrollTop = ref.current.scrollHeight; 14 | } 15 | }, [app.messages, follow]); 16 | return ( 17 |
      { 21 | if (!ref.current) return; 22 | const target: HTMLElement = ref.current; 23 | if (target.scrollTop !== target.scrollHeight - target.clientHeight) { 24 | setFollow(false); 25 | } else { 26 | setFollow(true); 27 | } 28 | }} 29 | > 30 | {app.messages.map((message) => ( 31 | 32 | ))} 33 |
    34 | ); 35 | }; 36 | 37 | export default createFragmentContainer(ChatWindow, { 38 | app: graphql` 39 | fragment chat_app on Query { 40 | messages { 41 | id 42 | ...chatMessage_message 43 | } 44 | } 45 | `, 46 | }); 47 | -------------------------------------------------------------------------------- /packages/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { App } from "./app"; 4 | import "./styles.css"; 5 | import { EnvironmentContext, createEnvironment } from "./relay-environment"; 6 | 7 | const client = createEnvironment(); 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById("root") 16 | ); 17 | -------------------------------------------------------------------------------- /packages/frontend/src/jsonpatch.ts: -------------------------------------------------------------------------------- 1 | import { RecordSourceSelectorProxy } from "relay-runtime"; 2 | 3 | type IOperation = { 4 | op: string; 5 | path: string; 6 | from: string; 7 | value: any; 8 | }; 9 | 10 | export const applyPatch = (store: RecordSourceSelectorProxy, patch: any) => { 11 | const operations: IOperation[] = []; 12 | for (const operationRecordProxy of patch) { 13 | const operation = { 14 | op: operationRecordProxy.getValue("op"), 15 | path: operationRecordProxy.getValue("path"), 16 | from: operationRecordProxy.getValue("from"), 17 | value: operationRecordProxy.getValue("value"), 18 | }; 19 | 20 | operations.push(operation); 21 | } 22 | for (const operation of operations) { 23 | applyOperation(store, operation); 24 | } 25 | }; 26 | 27 | export const applyOperation = ( 28 | store: RecordSourceSelectorProxy, 29 | operation: IOperation 30 | ) => { 31 | if (operation.op === "replace") { 32 | // Currently only supports paths of array/element/property 33 | const path = operation.path.split("/").filter((item) => item !== ""); 34 | 35 | const list: any = store.getRoot().getLinkedRecords(path[0]); 36 | 37 | if (list && list[path[1]]) list[path[1]].setValue(operation.value, path[2]); 38 | } else if (operation.op === "remove") { 39 | // Currently only supports paths of array/element/property 40 | const path = operation.path.split("/").filter((item) => item !== ""); 41 | const list: any = store.getRoot().getLinkedRecords(path[0]); 42 | if (list && list[path[1]]) { 43 | const dataID = list[path[1]].getDataID(); 44 | if (dataID) store.delete(dataID); 45 | } 46 | } else if (operation.op === "add") { 47 | // Currently only supports paths of array/element/property 48 | const path = operation.path.split("/").filter((item) => item !== ""); 49 | const list = store.getRoot().getLinkedRecords(path[0]); 50 | if (list) { 51 | if (store.get(operation.value.id)) { 52 | // in case the websocket connection is lost and re-established the entry could already exist inside the cache 53 | return; 54 | } 55 | const newRecord = store.create(operation.value.id /* dataID */, "Jedi"); 56 | for (const key in operation.value) { 57 | // issue https://github.com/facebook/relay/issues/2441 58 | if ( 59 | // @ts-ignore 60 | typeof operation.value[key] !== "array" && 61 | typeof operation.value[key] !== "object" && 62 | operation.value[key] !== null 63 | ) { 64 | newRecord.setValue(operation.value[key], key); 65 | } 66 | } 67 | 68 | const newRecords = [...list, newRecord].filter((item) => item); 69 | store.getRoot().setLinkedRecords(newRecords, path[0]); // 70 | } 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /packages/frontend/src/message-add-mutation.ts: -------------------------------------------------------------------------------- 1 | import { useEnvironment } from "./relay-environment"; 2 | import { commitMutation } from "react-relay"; 3 | import { useCallback } from "react"; 4 | import graphql from "babel-plugin-relay/macro"; 5 | 6 | const MessageAddMutationDocument = graphql` 7 | mutation messageAddMutation($input: MessageAddInput!) { 8 | messageAdd(input: $input) 9 | } 10 | `; 11 | 12 | export const useMessageAddMutation = () => { 13 | const environment = useEnvironment(); 14 | return useCallback( 15 | (input: { rawContent: string; authorName: string }) => { 16 | commitMutation(environment, { 17 | mutation: MessageAddMutationDocument, 18 | variables: { input }, 19 | }); 20 | }, 21 | [environment] 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | module "babel-plugin-relay/macro" { 4 | export default (str: TemplateStringsArray) => any; 5 | } 6 | -------------------------------------------------------------------------------- /packages/frontend/src/relay-environment.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Environment, 4 | Network, 5 | RecordSource, 6 | Store, 7 | FetchFunction, 8 | SubscribeFunction, 9 | Observable, 10 | GraphQLResponse, 11 | } from "relay-runtime"; 12 | import { SubscriptionClient } from "subscriptions-transport-ws"; 13 | import { RelayModernEnvironment } from "relay-runtime/lib/store/RelayModernEnvironment"; 14 | 15 | const fetchQuery = (client: SubscriptionClient): FetchFunction => ( 16 | operation, 17 | variables 18 | ) => { 19 | if (!operation.text) throw new Error("Missing document."); 20 | const { text: query } = operation; 21 | 22 | return new Promise((resolve, reject) => { 23 | const subscription = client 24 | .request({ 25 | query, 26 | variables, 27 | }) 28 | .subscribe({ 29 | next: (value) => { 30 | resolve(value as GraphQLResponse); 31 | subscription.unsubscribe(); 32 | }, 33 | error: reject, 34 | }); 35 | }); 36 | }; 37 | 38 | const getWebSocketProtocol = (location: Location) => 39 | location.protocol === "http:" ? "ws" : "wss"; 40 | 41 | const getWebSocketUrl = (location: Location) => 42 | // prettier-ignore 43 | `${getWebSocketProtocol(location)}://${location.host}/graphql`; 44 | 45 | // @source https://github.com/facebook/relay/issues/2967#issuecomment-567355735 46 | const setupSubscription = ( 47 | subscriptionClient: SubscriptionClient 48 | ): SubscribeFunction => (request, variables) => { 49 | if (!request.text) throw new Error("Missing document."); 50 | const { text: query } = request; 51 | 52 | return Observable.create((sink) => { 53 | const c = subscriptionClient 54 | .request({ query, variables }) 55 | .subscribe(sink as any); 56 | return c as any; 57 | }); 58 | }; 59 | 60 | export const createEnvironment = () => { 61 | const client = new SubscriptionClient(getWebSocketUrl(window.location), { 62 | reconnect: true, 63 | reconnectionAttempts: 100000, 64 | }); 65 | 66 | return new Environment({ 67 | network: Network.create(fetchQuery(client), setupSubscription(client)), 68 | store: new Store(new RecordSource()), 69 | }); 70 | }; 71 | 72 | export const EnvironmentContext = React.createContext( 73 | null 74 | ); 75 | 76 | export const useEnvironment = (): RelayModernEnvironment => { 77 | const environment = React.useContext(EnvironmentContext); 78 | if (!environment) throw new Error("Missing Environment"); 79 | return environment; 80 | }; 81 | -------------------------------------------------------------------------------- /packages/frontend/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { createProxyMiddleware } = require("http-proxy-middleware"); 4 | 5 | module.exports = (app) => { 6 | app.use( 7 | createProxyMiddleware("/graphql", { 8 | target: "http://127.0.0.1:3001", 9 | changeOrigin: true, 10 | ws: true, 11 | }) 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /packages/frontend/src/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | font-family: -apple-system, BlinkMacSystemFont, sans-serif; 13 | } 14 | 15 | ul { 16 | list-style: none; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react", 20 | "allowJs": true 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------