├── 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 |
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 |
16 |
17 |
18 |
19 | Table of Contents
20 |
21 |
22 | About The Project
23 |
26 |
27 |
28 | Getting Started
29 |
33 |
34 | Contributing
35 | Contact
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 |
--------------------------------------------------------------------------------