├── CHECKS ├── Procfile ├── .yarnrc ├── server ├── models │ ├── schemas.names.js │ ├── user.model.js │ ├── message.model.js │ ├── base.schema.js │ └── channel.model.js ├── routes │ └── index.route.js ├── graphql │ ├── executableSchema.js │ ├── setupFunctions.js │ ├── index.js │ ├── schema.graphql │ ├── subscriptionsServer.js │ ├── helpers.js │ └── resolvers.js └── helpers │ └── APIError.js ├── .env.example ├── .gitattributes ├── config ├── winston.js └── express.js ├── .editorconfig ├── webpack.config.js ├── app.json ├── .babelrc ├── index.js ├── .gitignore ├── README.md ├── LICENSE └── package.json /CHECKS: -------------------------------------------------------------------------------- 1 | /api/health-check OK -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn prod 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix false 2 | -------------------------------------------------------------------------------- /server/models/schemas.names.js: -------------------------------------------------------------------------------- 1 | export default { 2 | User: 'User', 3 | Message: 'Message', 4 | Channel: 'Channel' 5 | } 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MONGO_URL= 2 | MONGO_PORT= 3 | OPTICS_API_KEY= 4 | NODE_ENV= 5 | PORT=3010 6 | WS_PORT= 7 | WS_PROTOCOL= 8 | HOST= 9 | MONGOOSE_DEBUG= 10 | BABEL_DISABLE_CACHE=1 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Convert text file line endings to lf 2 | * text=auto 3 | *.js text 4 | # Denote all files that are truly binary and should not be modified. 5 | *.mp4 binary 6 | *.jpg binary 7 | -------------------------------------------------------------------------------- /server/routes/index.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | const router = express.Router() 4 | 5 | router.get('/health-check', (req, res) => 6 | res.send('OK') 7 | ) 8 | export default router 9 | -------------------------------------------------------------------------------- /config/winston.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston' 2 | 3 | const logger = new (winston.Logger)({ 4 | transports: [ 5 | new (winston.transports.Console)({ 6 | json: true, 7 | colorize: true 8 | }) 9 | ] 10 | }) 11 | 12 | export default logger 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: { 3 | // YOU NEED TO SET libraryTarget: 'commonjs2' 4 | libraryTarget: 'commonjs2' 5 | }, 6 | module: { 7 | loaders: [ 8 | { 9 | test: /\.(graphql|gql)$/, 10 | exclude: /node_modules/, 11 | loader: 'graphql-tag/loader' 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/graphql/executableSchema.js: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from 'graphql-tools' 2 | import OpticsAgent from 'optics-agent' 3 | 4 | import Resolvers from './resolvers' 5 | import Schema from './schema.graphql' 6 | 7 | const schema = makeExecutableSchema({ 8 | typeDefs: Schema, 9 | resolvers: Resolvers, 10 | logger: { 11 | log (e) { console.log('[GraphQL Log]:', e) } 12 | } 13 | }) 14 | 15 | OpticsAgent.instrumentSchema(schema) 16 | 17 | export default schema 18 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-chat-graphql-server", 3 | "description": "Apollo Chat GraphQL Server", 4 | "repository": "https://github.com/gaston23/apollo-chat-graphql-server", 5 | "scripts": { 6 | }, 7 | "env": { 8 | "NPM_CONFIG_PRODUCTION": { 9 | "description": "Indicate npm that it needs to install all dependencies, including dev.", 10 | "value": false 11 | } 12 | }, 13 | "buildpacks": [ 14 | { 15 | "url": "https://github.com/heroku/heroku-buildpack-nodejs" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2017", 4 | "es2015", 5 | "stage-0", 6 | "stage-1", 7 | "stage-2", 8 | "stage-3" 9 | ], 10 | "plugins": [ 11 | "transform-promise-to-bluebird", 12 | "babel-plugin-inline-import", 13 | "transform-decorators", 14 | "transform-runtime", 15 | "transform-decorators-legacy", 16 | "transform-class-properties", 17 | [ 18 | "babel-plugin-webpack-loaders", 19 | { 20 | "config": "./webpack.config.js", 21 | "verbose": true, 22 | } 23 | ] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /server/graphql/setupFunctions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | onMessageAdded: (options, args, subscriptionName) => ({ 4 | onMessageAdded: { 5 | filter: ({ message }, ctx) => message.channel.id === args.channelID 6 | } 7 | }), 8 | 9 | onMemberJoin: (options, args, subscriptionName) => ({ 10 | onMemberJoin: { 11 | filter: ({ channel }, ctx) => channel.id === args.channelID 12 | } 13 | }), 14 | 15 | onTypingIndicatorChanged: (options, args, subscriptionName) => ({ 16 | onTypingIndicatorChanged: { 17 | filter: ({ channel }, ctx) => channel.id === args.channelID 18 | } 19 | }) 20 | 21 | } 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | // config should be imported before importing any other file 4 | import app from './config/express' 5 | 6 | Promise.config({ 7 | warnings: true, 8 | longStackTraces: true, 9 | cancellation: true, 10 | monitoring: true 11 | }) 12 | 13 | // plugin bluebird promise in mongoose 14 | mongoose.Promise = Promise 15 | 16 | // connect to mongo db 17 | const mongoUri = process.env.MONGO_URL 18 | mongoose.connect(mongoUri, { server: { socketOptions: { keepAlive: 1 } } }) 19 | mongoose.connection.on('error', () => { 20 | throw new Error(`unable to connect to database: ${mongoUri}`) 21 | }) 22 | 23 | console.log('Process!', {process: process}) 24 | 25 | export default app 26 | -------------------------------------------------------------------------------- /server/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import SchemasNames from './schemas.names' 3 | import BaseSchema from './base.schema' 4 | 5 | const SCHEMA_NAME = SchemasNames.User 6 | 7 | const Schema = new mongoose.Schema({ 8 | username: { 9 | type: String, 10 | required: true 11 | }, 12 | ip: { 13 | type: String 14 | }, 15 | mobileNumber: { 16 | type: String 17 | }, 18 | createdAt: { 19 | type: Date, 20 | default: Date.now 21 | }, 22 | lastLoginAt: { 23 | type: Date 24 | }, 25 | channels: [{ 26 | type: mongoose.Schema.Types.ObjectId, 27 | ref: SchemasNames.Channel 28 | }] 29 | }) 30 | 31 | class SchemaExtension extends BaseSchema { 32 | } 33 | Schema.loadClass(SchemaExtension) 34 | 35 | export default mongoose.model(SCHEMA_NAME, Schema) 36 | -------------------------------------------------------------------------------- /server/models/message.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import SchemasNames from './schemas.names' 3 | import BaseSchema from './base.schema' 4 | 5 | const SCHEMA_NAME = SchemasNames.Message 6 | 7 | const Schema = new mongoose.Schema({ 8 | text: { 9 | type: String, 10 | required: true 11 | }, 12 | createdAt: { 13 | type: Date, 14 | default: Date.now, 15 | required: true 16 | }, 17 | createdBy: { 18 | type: mongoose.Schema.Types.ObjectId, 19 | ref: SchemasNames.User, 20 | required: true 21 | }, 22 | channel: { 23 | type: mongoose.Schema.Types.ObjectId, 24 | ref: SchemasNames.Channel, 25 | required: true 26 | } 27 | }) 28 | 29 | class SchemaExtension extends BaseSchema { 30 | } 31 | Schema.loadClass(SchemaExtension) 32 | 33 | export default mongoose.model(SCHEMA_NAME, Schema) 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # IDE 12 | .idea 13 | 14 | # OS generated files 15 | .DS_Store 16 | .DS_Store? 17 | ._* 18 | .Spotlight-V100 19 | ehthumbs.db 20 | Icon? 21 | Thumbs.db 22 | 23 | # Babel ES6 compiles files 24 | dist 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directory 42 | node_modules 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | # .env 51 | .env 52 | .env.dev 53 | .env.production 54 | 55 | dist 56 | public -------------------------------------------------------------------------------- /server/graphql/index.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import { graphiqlExpress, graphqlExpress } from 'graphql-server-express' 3 | import OpticsAgent from 'optics-agent' 4 | 5 | import schema from './executableSchema' 6 | 7 | import subscriptionServer, { 8 | SUBSCRIPTIONS_ENDPOINT 9 | } from './subscriptionsServer' 10 | 11 | export default ({ app }) => { 12 | app.use(OpticsAgent.middleware()) 13 | 14 | subscriptionServer({ schema }) 15 | 16 | const graphqlExpressResponse = graphqlExpress(request => ({ 17 | schema, 18 | formatError: error => ({ 19 | message: error.message, 20 | locations: error.locations, 21 | stack: error.stack, 22 | path: error.path 23 | }), 24 | context: { 25 | request, 26 | opticsContext: OpticsAgent.context(request) 27 | } // user: request.session.user 28 | })) 29 | app.use('/graphql', bodyParser.json(), graphqlExpressResponse) 30 | 31 | app.use('/graphiql', graphiqlExpress({ 32 | endpointURL: '/graphql', 33 | subscriptionsEndpoint: SUBSCRIPTIONS_ENDPOINT 34 | })) 35 | } 36 | -------------------------------------------------------------------------------- /server/helpers/APIError.js: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | 3 | /** 4 | * @extends Error 5 | */ 6 | class ExtendableError extends Error { 7 | constructor (message, status, isPublic) { 8 | super(message) 9 | this.name = this.constructor.name 10 | this.message = message 11 | this.status = status 12 | this.isPublic = isPublic 13 | this.isOperational = true // This is required since bluebird 4 doesn't append it anymore. 14 | Error.captureStackTrace(this, this.constructor.name) 15 | } 16 | } 17 | 18 | /** 19 | * Class representing an API error. 20 | * @extends ExtendableError 21 | */ 22 | class APIError extends ExtendableError { 23 | /** 24 | * Creates an API error. 25 | * @param {string} message - Error message. 26 | * @param {number} status - HTTP status code of error. 27 | * @param {boolean} isPublic - Whether the message should be visible to user or not. 28 | */ 29 | constructor (message, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false) { 30 | super(message, status, isPublic) 31 | } 32 | } 33 | 34 | export default APIError 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Apollo Chat Server](https://cloud.githubusercontent.com/assets/637225/25879422/06326596-3508-11e7-946a-02719aba0ee7.png) 2 | 3 | # Apollo Chat GraphQL (server) 4 | 5 | [Client Code: gaston23/apollo-chat-graphql-client](http://github.com/gaston23/apollo-chat-graphql-client) 6 | 7 | ### DEMO Server Endpoints 8 | 9 | - GraphQL Apollo: [https://apollo-chat-api.black.uy/graphql](https://apollo-chat-api.black.uy/graphql) 10 | - GraphiQL: [https://apollo-chat-api.black.uy/graphiql](https://apollo-chat-api.black.uy/graphiql) 11 | - Subscriptions WS (unsecure): ws://apollo-chat-api.black.uy:8080/ 12 | - Subscriptions WSS (secure): wss://apollo-chat-api.black.uy:8433/ 13 | - CDN: We're using Cloudflare for everything (HTTP, HTTPS, WS, WSS). 14 | 15 | ### What's in it? 16 | TODO 17 | 18 | ### To run 19 | TODO 20 | 21 | ### To build the production package 22 | TODO 23 | 24 | ### Contribute 25 | Please contribute to the project if you know how to make it better, including this README :) 26 | 27 | ### Creator 28 | Gaston Morixe [gaston@black.uy](mailto:gaston@black.uy) 29 | [Black Experiments black.uy](http://black.uy) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Gaston Morixe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/models/base.schema.js: -------------------------------------------------------------------------------- 1 | import APIError from '../helpers/APIError' 2 | 3 | export default class { 4 | static async findOneOrCreate (condition, doc) { 5 | try { 6 | const channel = await this.findOne(condition).exec() 7 | if (channel) { 8 | return { found: Promise.resolve(channel) } 9 | } 10 | const newChannel = await this.create(doc) 11 | return { new: Promise.resolve(newChannel) } 12 | } catch (error) { 13 | const err = new APIError(`Fatal Error: ${error.message}.`) 14 | return Promise.reject(err) 15 | } 16 | } 17 | 18 | static async get (id) { 19 | try { 20 | const model = await this.findById(id).exec() 21 | if (model) { 22 | return model 23 | } 24 | const err = new APIError('No such instance exists!') 25 | return Promise.reject(err) 26 | } catch (error) { 27 | const err = new APIError(`Fatal Error: ${error.message}.`) 28 | return Promise.reject(err) 29 | } 30 | } 31 | 32 | static async list ({ skip = 0, limit = 50 } = {}) { 33 | return this.find() 34 | .sort({ createdAt: -1 }) 35 | .skip(+skip) 36 | .limit(+limit) 37 | .exec() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Date 2 | 3 | type User { 4 | id: ID! 5 | lastIP: String 6 | lastLoginAt: Date 7 | username: String! 8 | createdAt: Date! 9 | channels: [Channel] 10 | } 11 | 12 | type Channel { 13 | id: ID! 14 | name: String! 15 | createdAt: Date! 16 | participants: [User] 17 | participantCount: Int 18 | messages(limit: Int!): [Message] 19 | } 20 | 21 | type Message { 22 | id: ID! 23 | indexInChannel: Int 24 | createdAt: Date! 25 | createdBy: User! 26 | channel: Channel! 27 | text: String! 28 | } 29 | 30 | type JoinResult { 31 | user: User! 32 | channel: Channel! 33 | } 34 | 35 | type Query { 36 | channels: [Channel] 37 | channel( 38 | channelID: ID! 39 | ): Channel 40 | messagesForChannel( 41 | channelID: ID! 42 | ): [Message] 43 | users: [User] 44 | user(id: ID!): User 45 | } 46 | 47 | type Mutation { 48 | typing( 49 | userID: ID!, 50 | channelID: ID! 51 | ): Boolean 52 | join( 53 | username: String!, 54 | channelName: String! 55 | ): JoinResult 56 | messageNew( 57 | channelID: ID!, 58 | userID: ID!, 59 | text: String! 60 | ): Message 61 | } 62 | 63 | type Subscription { 64 | onChannelAdded: Channel 65 | onTypingIndicatorChanged( 66 | channelID: ID! 67 | ): [User] 68 | onMessageAdded( 69 | channelID: ID! 70 | ): Message 71 | onMemberJoin( 72 | channelID: ID! 73 | ): User 74 | } 75 | 76 | schema { 77 | query: Query 78 | mutation: Mutation 79 | subscription: Subscription 80 | } 81 | -------------------------------------------------------------------------------- /server/graphql/subscriptionsServer.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | /* eslint no-underscore-dangle: 0 */ 3 | 4 | import { createServer } from 'http' 5 | import { PubSub, SubscriptionManager } from 'graphql-subscriptions' 6 | import { SubscriptionServer } from 'subscriptions-transport-ws' 7 | 8 | import setupFunctions from './setupFunctions' 9 | 10 | const EventEmitter = require('events') 11 | EventEmitter.defaultMaxListeners = 5000 // TODO Use redis transport 12 | 13 | export const pubsub = new PubSub() 14 | 15 | export const SUBSCRIPTIONS_ENDPOINT = `${process.env.WS_PROTOCOL}://${process.env.HOST}:${process.env.WS_PORT}` 16 | 17 | export default ({ schema }) => { 18 | const WS_PORT = process.env.WS_PORT 19 | 20 | const subscriptionManager = new SubscriptionManager({ 21 | schema, 22 | pubsub, 23 | setupFunctions 24 | }) 25 | 26 | const websocketServer = createServer((request, response) => { 27 | response.writeHead(404) 28 | response.end() 29 | }) 30 | 31 | websocketServer.listen(WS_PORT, () => { 32 | console.log(`🌎 WS Server is now running on ${SUBSCRIPTIONS_ENDPOINT}`) 33 | }) 34 | 35 | process.on('SIGINT', () => { 36 | console.log('Bye from WS 👋 SIGINT') 37 | websocketServer.close() 38 | }) 39 | 40 | const subscriptionServer = new SubscriptionServer( // eslint-disable-line 41 | { 42 | onConnect: async (connectionParams, ws) => { 43 | console.log('✅ SubscriptionServer onConnect 🌏!', ws._socket.remoteAddress, ws._socket.remotePort) 44 | }, 45 | onSubscribe: async (message, params, wsRequest) => { 46 | console.log('✅ SubscriptionServer onSubscribe 😄') 47 | return Promise.resolve(params) 48 | }, 49 | onUnsubscribe: () => { 50 | console.log('✅ SubscriptionServer onUnsubscribe 👋') 51 | }, 52 | onDisconnect: (webSocket) => { 53 | console.log('✅ SubscriptionServer onDisconnect ❌') 54 | }, 55 | subscriptionManager 56 | }, 57 | { 58 | server: websocketServer, 59 | path: '/' 60 | } 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /server/graphql/helpers.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user.model' 2 | import Channel from '../models/channel.model' 3 | 4 | import APIError from '../helpers/APIError' 5 | import { pubsub } from './subscriptionsServer' 6 | 7 | export function delay (ctx) { 8 | return (ctx && 9 | ctx.request && 10 | ctx.request.query && 11 | ctx.request.query.delay && 12 | Number(ctx.request.query.delay)) || 0 13 | } 14 | 15 | class TypingIndicatorsTimers { 16 | typingIndicatorsTimers = {} 17 | 18 | key ({ channelID, userID }) { 19 | return `c:${channelID}|u:${userID}` 20 | } 21 | 22 | getTimer ({ 23 | channelID, userID 24 | }) { 25 | const key = this.key({ channelID, userID }) 26 | return this.typingIndicatorsTimers[key] 27 | } 28 | 29 | cancel ({ channelID, userID }) { 30 | const timer = this.getTimer({ channelID, userID }) 31 | if (timer) { 32 | timer.cancel() 33 | } 34 | } 35 | 36 | async publishChangeToSubscription ({ channelID }) { 37 | const channel = await Channel.findById(channelID) 38 | .populate('typingParticipants.participant') 39 | .exec() 40 | pubsub.publish('onTypingIndicatorChanged', { channel }) 41 | } 42 | 43 | createTimer ({ 44 | userID, 45 | channelID 46 | }) { 47 | return Promise 48 | .delay(1000) // buffer typings 49 | .then(async () => { 50 | await Channel.removeTyping({ 51 | userID, 52 | channelID 53 | }) 54 | this.removeTypingIndicator({ 55 | userID, 56 | channelID 57 | }) 58 | this.publishChangeToSubscription({channelID}) 59 | }) 60 | } 61 | 62 | async addTypingIndicator ({ 63 | channelID, 64 | userID, 65 | skipLivePush 66 | }) { 67 | this.cancel({ userID, channelID }) 68 | 69 | const timerPromise = this.createTimer({ userID, channelID }) 70 | const key = this.key({ channelID, userID }) 71 | this.typingIndicatorsTimers = { 72 | ...this.typingIndicatorsTimers, 73 | [key]: timerPromise 74 | } 75 | 76 | if (!skipLivePush) { 77 | this.publishChangeToSubscription({ 78 | channelID 79 | }) 80 | } 81 | } 82 | 83 | removeTypingIndicator ({ 84 | channelID, 85 | userID 86 | }) { 87 | const key = this.key({ channelID, userID }) 88 | const { [key]: oldTimer, ...newTimers } = this.typingIndicatorsTimers 89 | this.typingIndicatorsTimers = newTimers 90 | } 91 | } 92 | 93 | export const typingIndicatorsTimers = new TypingIndicatorsTimers() 94 | 95 | export const createOrFindChannel = function ({ name }) { 96 | return Channel 97 | .findOneOrCreate({ name }, { name }) 98 | .then((payload) => { 99 | if (payload.found) { 100 | pubsub.publish('onChannelAdded', payload.found) 101 | return payload.found 102 | } 103 | return payload.new 104 | }) 105 | } 106 | 107 | export const createOrFindUser = async function ({ query, update }) { 108 | try { 109 | const options = { 110 | upsert: true, 111 | new: true, 112 | setDefaultsOnInsert: true 113 | } 114 | let user = await User.findOneAndUpdate(query, update, options) 115 | return user 116 | } catch (error) { 117 | const err = new APIError(`Fatal Error: ${error.message}.`) 118 | return Promise.reject(err) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /config/express.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import logger from 'morgan' 3 | import bodyParser from 'body-parser' 4 | import cookieParser from 'cookie-parser' 5 | import compress from 'compression' 6 | import methodOverride from 'method-override' 7 | import cors from 'cors' 8 | import httpStatus from 'http-status' 9 | import expressWinston from 'express-winston' 10 | import expressValidation from 'express-validation' 11 | import helmet from 'helmet' 12 | import winstonInstance from './winston' 13 | import routes from '../server/routes/index.route' 14 | import APIError from '../server/helpers/APIError' 15 | import graphql from '../server/graphql/' 16 | 17 | const app = express() 18 | 19 | if (process.env.NODE_ENV === 'development') { 20 | app.use(logger('dev')) 21 | } 22 | 23 | // parse body params and attache them to req.body 24 | app.use(bodyParser.json()) 25 | app.use(bodyParser.urlencoded({ extended: true })) 26 | 27 | app.use(cookieParser()) 28 | app.use(compress()) 29 | app.use(methodOverride()) 30 | 31 | // secure apps by setting various HTTP headers 32 | app.use(helmet()) 33 | 34 | // enable CORS - Cross Origin Resource Sharing 35 | app.use(cors()) 36 | 37 | // enable detailed API logging in dev env 38 | if (process.env.NODE_ENV === 'development') { 39 | expressWinston.requestWhitelist.push('body') 40 | expressWinston.responseWhitelist.push('body') 41 | app.use(expressWinston.logger({ 42 | winstonInstance, 43 | meta: true, // optional: log meta data about request (defaults to true) 44 | msg: 'HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms', 45 | colorStatus: true // Color the status code (default green, 3XX cyan, 4XX yellow, 5XX red). 46 | })) 47 | } 48 | 49 | // graphql 50 | graphql({ app }) 51 | 52 | // mount all routes on /api path 53 | app.use('/api', routes) 54 | 55 | // if error is not an instanceOf APIError, convert it. 56 | app.use((err, req, res, next) => { 57 | if (err instanceof expressValidation.ValidationError) { 58 | // validation error contains errors which is an array of error each containing message[] 59 | const unifiedErrorMessage = err.errors.map(error => error.messages.join('. ')).join(' and ') 60 | const error = new APIError(unifiedErrorMessage, err.status, true) 61 | return next(error) 62 | } else if (!(err instanceof APIError)) { 63 | const apiError = new APIError(err.message, err.status, err.isPublic) 64 | return next(apiError) 65 | } 66 | return next(err) 67 | }) 68 | 69 | // catch 404 and forward to error handler 70 | app.use((req, res, next) => { 71 | const err = new APIError('API not found', httpStatus.NOT_FOUND) 72 | return next(err) 73 | }) 74 | 75 | // log error in winston transports except when executing test suite 76 | // if (process.env.NODE_ENV !== 'test') { 77 | // app.use(expressWinston.errorLogger({ 78 | // winstonInstance 79 | // })) 80 | // } 81 | 82 | // error handler, send stacktrace only during development 83 | app.use((err, req, res, next) => // eslint-disable-line no-unused-vars 84 | res.status(err.status).json({ 85 | message: err.isPublic ? err.message : httpStatus[err.status], 86 | stack: process.env.NODE_ENV === 'development' ? err.stack : {} 87 | }) 88 | ) 89 | 90 | const server = app.listen(process.env.PORT, () => { 91 | console.info(`🌎 Apollo HTTP Server started on port ${process.env.PORT}`) 92 | }) 93 | 94 | process.on('SIGINT', () => { 95 | console.log('Bye from Express Server 👋 SIGINT') 96 | server.close() 97 | }) 98 | 99 | export default app 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-chat-graphql-server", 3 | "version": "1.0.0", 4 | "description": "Apollo Chat GraphQL Server", 5 | "author": "Gaston Morixe ", 6 | "main": "index.js", 7 | "private": false, 8 | "engines": { 9 | "node": ">=7.10.0", 10 | "npm": ">=4.5.0", 11 | "yarn": ">=0.23.4" 12 | }, 13 | "standard": { 14 | "parser": "babel-eslint", 15 | "ignore": [] 16 | }, 17 | "scripts": { 18 | "lint": "standard | snazzy", 19 | "lint:fix": "standard --fix | snazzy", 20 | "build": "babel ./ -d dist", 21 | "serv": "node dist/index.js", 22 | "prod": "better-npm-run prod", 23 | "dev": "better-npm-run dev", 24 | "precommit": "npm run lint:fix" 25 | }, 26 | "betterScripts": { 27 | "dev": { 28 | "command": "nodemon index.js --watch api --exec babel-node", 29 | "env": { 30 | "NODE_ENV": "development", 31 | "BABEL_DISABLE_CACHE": 1 32 | } 33 | }, 34 | "prod": { 35 | "command": "babel-node ./index.js", 36 | "env": { 37 | "BABEL_DISABLE_CACHE": 1, 38 | "NODE_ENV": "production" 39 | } 40 | } 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git@github.com:gaston23/apollo-chat-graphql-server.git" 45 | }, 46 | "keywords": [ 47 | "express", 48 | "node", 49 | "node.js", 50 | "mongodb", 51 | "mongoose", 52 | "es6", 53 | "apollo", 54 | "graphql" 55 | ], 56 | "dependencies": { 57 | "ajv": "5.0.1", 58 | "babel-plugin-inline-import": "^2.0.4", 59 | "babel-plugin-transform-decorators": "^6.24.1", 60 | "bluebird": "^3.5.0", 61 | "body-parser": "^1.17.1", 62 | "compression": "^1.6.2", 63 | "cookie-parser": "^1.4.3", 64 | "cors": "^2.8.3", 65 | "debug": "^2.4.5", 66 | "express": "^4.15.2", 67 | "express-graphql": "^0.6.4", 68 | "express-jwt": "^5.3.0", 69 | "express-validation": "^1.0.2", 70 | "express-winston": "^2.4.0", 71 | "graphql": "^0.9.6", 72 | "graphql-server-express": "^0.7.2", 73 | "graphql-subscriptions": "^0.3.1", 74 | "graphql-tools": "^0.11.0", 75 | "helmet": "3.5.0", 76 | "http-status": "^1.0.1", 77 | "kexec": "3.0.0", 78 | "method-override": "^2.3.5", 79 | "mongoose": "^4.9.7", 80 | "morgan": "^1.8.1", 81 | "optics-agent": "^1.1.2", 82 | "subscriptions-transport-ws": "^0.6.0", 83 | "winston": "^2.3.1" 84 | }, 85 | "devDependencies": { 86 | "babel-cli": "^6.24.1", 87 | "babel-core": "^6.24.1", 88 | "babel-eslint": "^7.2.3", 89 | "babel-loader": "^7.0.0", 90 | "babel-plugin-add-module-exports": "^0.2.1", 91 | "babel-plugin-transform-class-properties": "^6.22.0", 92 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 93 | "babel-plugin-transform-promise-to-bluebird": "1.1.1", 94 | "babel-plugin-transform-runtime": "^6.22.0", 95 | "babel-plugin-webpack-loaders": "0.9.0", 96 | "babel-preset-env": "^1.4.0", 97 | "babel-preset-es2015": "^6.24.1", 98 | "babel-preset-es2017": "^6.24.1", 99 | "babel-preset-react": "^6.23.0", 100 | "babel-preset-stage-0": "^6.24.1", 101 | "babel-preset-stage-1": "^6.24.1", 102 | "babel-preset-stage-2": "^6.24.1", 103 | "babel-preset-stage-3": "^6.24.1", 104 | "better-npm-run": "^*", 105 | "file-loader": "0.11.1", 106 | "graphql-tag": "2.0.0", 107 | "nodemon": "1.11.0", 108 | "rollup-plugin-buble": "0.15.0", 109 | "snazzy": "^7.0.0", 110 | "standard": "^*", 111 | "url-loader": "^0.5.8", 112 | "webpack": "2.4.1" 113 | }, 114 | "license": "MIT" 115 | } 116 | -------------------------------------------------------------------------------- /server/models/channel.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import SchemasNames from './schemas.names' 3 | import BaseSchema from './base.schema' 4 | import Message from './message.model' 5 | 6 | const SCHEMA_NAME = SchemasNames.Channel 7 | 8 | const Schema = new mongoose.Schema({ 9 | name: { 10 | type: String, 11 | required: true 12 | }, 13 | createdAt: { 14 | type: Date, 15 | default: Date.now, 16 | required: true 17 | }, 18 | createdBy: { 19 | type: mongoose.Schema.Types.ObjectId, 20 | ref: SchemasNames.User 21 | }, 22 | typingParticipants: [{ 23 | _id: false, 24 | when: { type: Date, default: Date.now, required: true }, 25 | participant: { 26 | type: mongoose.Schema.Types.ObjectId, 27 | ref: SchemasNames.User, 28 | required: true 29 | } 30 | }] 31 | }) 32 | 33 | class SchemaExtension extends BaseSchema { 34 | get participantCount () { 35 | return Message.aggregate([ 36 | { 37 | $match: { 38 | channel: mongoose.Types.ObjectId(this.id) 39 | } 40 | }, 41 | { 42 | $replaceRoot: { 43 | newRoot: { userID: '$createdBy' } 44 | } 45 | }, 46 | { 47 | $group: { 48 | _id: '$userID' 49 | } 50 | }, 51 | { 52 | $count: 'count' 53 | } 54 | ]).then(r => { 55 | return r && r[0] && r[0]['count'] 56 | }) 57 | } 58 | 59 | get participants () { 60 | return Message.aggregate([ 61 | { 62 | $match: { channel: mongoose.Types.ObjectId(this.id) } 63 | }, 64 | { 65 | $replaceRoot: { newRoot: { userID: '$createdBy' } } 66 | }, 67 | { 68 | $group: { 69 | _id: '$userID', 70 | userID: { $first: '$$ROOT' } 71 | } 72 | }, 73 | { 74 | $lookup: { 75 | from: 'users', 76 | localField: '_id', 77 | foreignField: '_id', 78 | as: 'createdBy' 79 | } 80 | }, 81 | { 82 | $unwind: '$createdBy' 83 | }, 84 | { 85 | $replaceRoot: { newRoot: '$createdBy' } 86 | }, 87 | { 88 | $addFields: { id: '$_id' } 89 | } 90 | ]) 91 | } 92 | 93 | static async setTypingToNow ({ 94 | channelID, 95 | userID 96 | }) { 97 | // TODO Try Catch ? 98 | const batchUpdate = await this.bulkWrite([ 99 | { 100 | updateOne: { 101 | filter: { 102 | _id: channelID 103 | }, 104 | update: { 105 | $pull: { 106 | typingParticipants: { 107 | participant: userID 108 | } 109 | } 110 | } 111 | } 112 | }, 113 | { 114 | updateOne: { 115 | filter: { 116 | _id: channelID 117 | }, 118 | update: { 119 | $addToSet: { 120 | typingParticipants: { 121 | participant: userID 122 | } 123 | } 124 | } 125 | } 126 | } 127 | ])// .exec() 128 | return batchUpdate 129 | } 130 | 131 | static async removeTyping ({ 132 | channelID, 133 | userID 134 | }) { 135 | // TODO Try Catch ? 136 | const update = await this.update( 137 | { 138 | _id: channelID 139 | }, 140 | { 141 | $pull: { 142 | typingParticipants: { 143 | participant: userID 144 | } 145 | } 146 | }).exec() 147 | console.log('UPDATE 🎃', update) 148 | return update 149 | } 150 | } 151 | Schema.loadClass(SchemaExtension) 152 | 153 | export default mongoose.model(SCHEMA_NAME, Schema) 154 | -------------------------------------------------------------------------------- /server/graphql/resolvers.js: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from 'graphql' 2 | import { Kind } from 'graphql/language' 3 | 4 | import { pubsub } from './subscriptionsServer' 5 | import APIError from '../helpers/APIError' 6 | 7 | import User from '../models/user.model' 8 | import Channel from '../models/channel.model' 9 | import Message from '../models/message.model' 10 | 11 | import { 12 | createOrFindChannel, 13 | createOrFindUser, 14 | delay, 15 | typingIndicatorsTimers 16 | } from './helpers' 17 | 18 | export default { 19 | Query: { 20 | 21 | users (_, args, ctx) { 22 | return User.list() // { limit: 100, skip: 0 } 23 | }, 24 | user (_, { id }, ctx) { 25 | return User.get(id) 26 | }, 27 | 28 | channels (root, { 29 | limit = 100, 30 | skip = 0 31 | }, ctx) { 32 | return Channel.find() 33 | .sort({ createdAt: -1 }) 34 | .skip(skip) 35 | .limit(limit) 36 | .populate({ 37 | path: 'messages', 38 | options: { 39 | limit: 2 40 | } 41 | }) 42 | .exec() 43 | }, 44 | channel (_, { channelID }, ctx) { 45 | return Channel.findById(channelID).exec() 46 | }, 47 | 48 | async messagesForChannel (_, { 49 | channelID, 50 | limit = 20, 51 | skip = 0 52 | }, ctx) { 53 | try { 54 | await Promise.delay(delay(ctx)) 55 | const channel = await Channel.get(channelID) 56 | return Message.find({ channel: channel.id }) 57 | .populate('channel') 58 | .populate('createdBy') 59 | .sort({ createdAt: -1 }) 60 | .skip(skip) 61 | .limit(limit) 62 | .exec() 63 | .then(msgs => msgs.reverse()) 64 | } catch (error) { 65 | const err = new APIError(`Fatal Error: ${error.message}.`) 66 | return Promise.reject(err) 67 | } 68 | } 69 | 70 | }, 71 | Mutation: { 72 | 73 | async typing (_, { channelID, userID }) { 74 | try { 75 | const result = await Channel.setTypingToNow({ 76 | userID, 77 | channelID 78 | }) 79 | const skipLivePush = result.modifiedCount === 2 80 | typingIndicatorsTimers.addTypingIndicator({ 81 | userID, 82 | channelID, 83 | skipLivePush 84 | }) 85 | return true 86 | } catch (error) { 87 | const err = new APIError(`Fatal Error: ${error.message}.`) 88 | return Promise.reject(err) 89 | } 90 | }, 91 | 92 | async join (_, { channelName, username }, ctx) { 93 | try { 94 | await Promise.delay(delay(ctx)) 95 | const [user, channel] = await Promise.all([ 96 | createOrFindUser({ 97 | query: { username }, 98 | update: { 99 | lastIP: ctx.request._remoteAddress, 100 | lastLoginAt: Date.now() 101 | } 102 | }), 103 | createOrFindChannel({ name: channelName }) 104 | ]) 105 | pubsub.publish('onMemberJoin', { member: user, channel }) 106 | return { 107 | channel, 108 | user 109 | } 110 | } catch (error) { 111 | const err = new APIError(`Fatal Error: ${error.message}.`) 112 | return Promise.reject(err) 113 | } 114 | }, 115 | 116 | async messageNew (_, { channelID, userID, text }, ctx) { 117 | try { 118 | await Promise.delay(delay(ctx)) 119 | const [channel, user] = await Promise.all([ 120 | Channel.get(channelID), 121 | User.get(userID) 122 | ]) 123 | return Message.create({ channel, createdBy: user, text }) 124 | .then((message) => { 125 | pubsub.publish('onMessageAdded', { message }) 126 | return message 127 | }) 128 | } catch (error) { 129 | const err = new APIError(`Fatal Error: ${error.message}.`) 130 | return Promise.reject(err) 131 | } 132 | } 133 | 134 | }, 135 | Subscription: { 136 | onChannelAdded (channel, args, ctx) { 137 | return channel 138 | }, 139 | onTypingIndicatorChanged ({ channel }, args, ctx) { 140 | const participants = channel.typingParticipants.map(p => p.participant) 141 | return participants 142 | }, 143 | onMemberJoin ({ member }, args, ctx) { 144 | return member 145 | }, 146 | onMessageAdded ({ message }, args, ctx) { 147 | return message 148 | } 149 | }, 150 | Date: new GraphQLScalarType({ 151 | name: 'Date', 152 | description: 'Date custom scalar type', 153 | parseValue (value) { 154 | return new Date(value) // from the client 155 | }, 156 | serialize (value) { 157 | return value.getTime() // to the client 158 | }, 159 | parseLiteral (ast) { 160 | if (ast.kind === Kind.INT) { 161 | return parseInt(ast.value, 10) // cast value is always in string format 162 | } 163 | return null 164 | } 165 | }) 166 | } 167 | --------------------------------------------------------------------------------