See project PorYoung/allChat , an online chat web application based on egg.js and sockt.io.
Egg + Webpack + Socket.io Notes Directory Structure 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 - app - controller - extend - middleware - model - service - public - dist /* webpack output directory */ - io /* socket.io */ - controller - middleware - view - router.js - config - config.default.js - plugin.js - build /* webpack */ - src - webpack.config.js - ...
Egg Quick Usage 1 2 3 4 5 npm i egg-init -g egg-init egg-example --type=simple cd egg-example npm i npm run dev
The server listens on 7001.
See egg for more detail.
Config config/config.default.js
default content1 2 3 4 5 6 7 8 9 10 11 12 'use strict' ;const path = require ('path' );module .exports = appInfo => { const config = exports = {}; config.keys = appInfo.name + 'your_keys' ; return config; }
middleware 1 2 3 4 5 6 7 8 9 10 config.middleware = ['permission' ,'middleware2' ]; config.permission = { excludeUrl : { 'ALL' : ['/' , '/login' , '/register' ], 'POST' : [], 'GET' : ['/register/checkUserid' ], }, }
plugin
1 2 3 4 5 6 7 config.view = { mapping : { '.html' : 'ejs' , }, defaultViewEngine : 'ejs' };
1 2 3 4 5 6 7 config.mongoose = { client : { url : 'mongodb://127.0.0.1/chat' , options : {}, }, };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 config.security = { csrf : { headerName : 'x-csrf-token' , }, }; config.session = { key : 'EGG_SESS' , maxAge : 24 * 3600 * 1000 , httpOnly : true , encrypt : true , renew : true , };
use csrfAjax.js
to bind beforeSend event to ajax.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import Cookies from 'js-cookie' const csrftoken = Cookies .get ('csrfToken' );function csrfSafeMethod (method ) { return (/^(GET|HEAD|OPTIONS|TRACE)$/ .test (method)); } $.ajaxSetup ({ beforeSend : function (xhr, settings ) { if (!csrfSafeMethod (settings.type ) && !this .crossDomain ) { xhr.setRequestHeader ('x-csrf-token' , csrftoken); } }, });
1 2 3 4 5 6 7 8 9 config.io = { namespace : { '/allChat' : { connectionMiddleware : ['auth' ], packetMiddleware : [], } } };
1 2 3 4 5 6 7 8 9 config.appConfig = { defaultAvatarArr : ['/public/image/default_avatar.jpg' , '/public/image/default_avatar_1.jpg' , '/public/image/default_avatar_2.jpg' ,], imagePublicPath : '/public/image' , defaultChatRoom : 'default' , defaultChatRoomMax : 999 , messageSplitLimit : 8 , allowReconnectionTime : 10 * 1000 , };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const formidable = require ('formidable' );const path = require ('path' );async formParse (req, filename, config ) { const form = new formidable.IncomingForm (); return new Promise ((resolve, reject ) => { form.uploadDir = path.join (process.cwd (), 'app' , config.appConfig .imagePublicPath ); form.parse (req); form.on ('fileBegin' , (name, file ) => { file.name = filename file.path = path.join (process.cwd (), 'app' , config.appConfig .imagePublicPath , filename) }) form.on ('end' , () => { resolve (path.join (config.appConfig .imagePublicPath , filename)); }) form.on ('error' , (err ) => { reject ('-1' ); }) }); }
mongoose use mongoose
in egg 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 module .exports = app => {const mongoose = app.mongoose ;const Schema = mongoose.Schema ;const UserSchema = new Schema ({ _id : { type : String , unique : true , alias : 'userid' , }, username : String , password : String , }; }; const Service = require ('egg' ).Service ;class UserService extends Service { async findOneByUserid (userid ) { let docs = await this .ctx .model .User .findOne ({ _id : userid, }); if (docs) { return docs.toObject ({ virtuals : true }); } return docs; } } const Controller = require ('egg' ).Controller ;class UserController extends Controller { async login ( ) { const { ctx, service } = this ; let { userid, password, rememberMe } = ctx.request .body ; let userinfo = await service.user .findOneByUserid (userid); if (userinfo && userinfo.password == password) { ctx.session .user = Object .assign (userinfo, { ipAddress : ctx.request .ip }); if (rememberMe) ctx.session .maxAge = ms ('30d' ); return ctx.body = '1' ; } return ctx.body = '-1' ; } } module .exports = UserController ;
$or
, $and
1 2 3 4 5 6 7 8 9 10 11 model.find ({ $or :[{ criterion_1 : 1 },{ $and : [{ criterion_2 : 2 },{ criterion_3 : 3 }] }] });
alias
and virtuals
Aliaes and Virtuals for more detail.
1 2 3 4 5 6 7 8 9 10 11 const UserSchema = new Schema ({ _id : { type : String , unique : true , alias : 'userid' , }, }); console .log (doc.toObject ({ virtuals : true })); console .log (doc.userid );
Populate Populate for more detail. You must set ref
to _id
, other fields are not avalible;
Note: ObjectId, Number, String, and Buffer are valid for use as refs. However, you should use ObjectId unless you are an advanced user and have a good reason for doing so.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 module .exports = app => { const mongoose = app.mongoose ; const Schema = mongoose.Schema ; const MessageSchema = new Schema ({ from : { type : String , ref : 'User' , }, to : { type : String , ref : 'User' , }, }); return mongoose.model ('Message' , MessageSchema ); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 async findByPagination (criterion, limit, page ) { const total = await this .ctx .model .Message .count (criterion); const totalPageNum = parseInt (total / limit); if (page > totalPageNum) { return null ; } const start = limit * page; const queryArr = await this .ctx .model .Message .where (criterion) .sort ({ date : -1 }) .limit (limit) .skip (start) .populate ('from' ,'userid username avatar' ) .populate ('to' ,'userid username avatar' ); let resArr = []; queryArr.forEach ((doc )=> { resArr.push (doc.toObject ({virtuals : true })); }); return resArr; }
Helper get remote IP 1 2 3 4 5 6 7 8 9 10 11 parseIPAddress (ip ) { if (ip.indexOf ('::ffff:' ) !== -1 ) { ip = ip.substring (7 ); } return ip; }
Socket.IO use socket.io in egg
controller extends app.controller
middleware
1 2 3 4 5 6 7 8 9 module .exports = () => { return async (ctx, next) => { const { app, socket, logger, helper, service } = ctx; const sid = socket.id ; const nsp = app.io .of ('/allChat' ); const query = socket.handshake .query ; } }
get socketid 1 2 3 4 5 6 7 8 const sid = socket.id ;nsp.adapter .clients (rooms, (err, clients ) => { logger.info ('#online' , clients); }
disconnect or refresh event When user refresh current page in browser, it will trigger disconnection and leave event, then the join event. Try to use a timer
(setTimeout function ) to solve this problem, but it might not be a good solution.
See auth.js .
send message to users in room 1 socket.to (room).emit ('room_message' , message);
send private message to user 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let userinfo = await service.user .findOneByUserid (ctx.session .user .userid );message.from = { userid : userinfo.userid , username : userinfo.username , }; let toUserinfo = await service.user .findOneByUserid (message.to );if (!toUserinfo) { socket.emit (socket.id , helper.parseMsg ('warning' , { type : 'warning' , content : '该用户不见了=_=!' , })); } else { message.to = { userid : toUserinfo.userid , username : toUserinfo.username , socketid : toUserinfo.socketid , }; let messageParsed = helper.parseMsg ('private_message' , message); socket.to (message.to .socketid ).emit (message.to .socketid , messageParsed); }
use socket.io in front end instead of <script src="/socket.io/socket.io.js"></scrupt>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 const socketClient = require ('socket.io-client' );const allChat = socketClient (config.host .concat ('/allChat' ), { query : { room : config.socket .room , }, transports : ['websocket' ], }); allChat.on ("connect" , () => { const sid = allChat.id ; console .log ('#connected' , sid, allChat); allChat.on (sid, msg => { console .log ('#receive,' , msg); switch (msg.data .action ) { case 'deny' : case 'welcome' : case 'warning' : case 'private_message' : } }); }); allChat.on ('disconnect' , msg => { console .log ('#disconnect' , msg); }); allChat.on ('disconnecting' , () => { console .log ('#disconnecting' ); }); allChat.on ('error' , () => { console .log ('#error' ); });
Tips
the socktid
get from Namespace.adapter.clients
contain the room #room
at the head.
get room the current connector joined, Object.keys(socket.rooms)[0];
Webpack 4.0 you can use egg-webpack
in egg or use webpack-cli
.
see webpack.config.js .
spiltChunks 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 optimization : { splitChunks : { cacheGroups : { common : { name : "common" , chunks : "all" , minSize : 1 , priority : 0 , minChunks : 2 , }, vendors : { name : "vendors" , test : /[\\/]node_modules[\\/]/ , chunks : "all" , priority : 10 } } }, },
to support webpack 4.0, install extract-text-webpack-plugin@next
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 { test : /\.css/ , use : ExtractTextPlugin .extract ({ fallback : 'style-loader' , use : ['css-loader' ] }), }, { test : /\.(less)/ , use : ExtractTextPlugin .extract ({ fallback : 'style-loader' , use : [{ loader : 'css-loader' , options : { alias : { '@' : path.resolve (staticPath, 'image' ) } }, }, 'postcss-loader' , { loader : 'less-loader' , options : { lessPlugins : [ new LessPluginCleanCSS ({ advanced : true , }), ], }, } ] }), }, new ExtractTextPlugin ({ filename : 'css/[name].bundle-[hash].css' , }),
import jquery 1 2 3 4 5 6 7 new webpack.ProvidePlugin ({ "$" : "jquery" , "jQuery" : "jquery" , "window.jQuery" : "jquery" , "_" : 'underscore' }),
Utils TouchEvent 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 var myTouchEvent = function ( ) { var swip_time = 300 , swip_dis = 30 , point_start, point_end, time_start, time_end, result; if ("ontouchstart" in window ) { var startEvt = "touchstart" , moveEvt = "touchmove" , endEvt = "touchend" ; } else { var startEvt = "mousedown" , moveEvt = "mousemove" , endEvt = "mouseup" ; } var getPos = function (e ) { var touches = e.touches ; if (touches && touches[0 ]) { return { x : touches[0 ].clientX , y : touches[0 ].clientY }; } return { x : e.clientX , y : e.clientY }; } var getDistance = function (p1, p2 ) { return parseInt (Math .sqrt (Math .pow (Math .abs (p1.x - p2.x ), 2 ) + Math .pow (Math .abs (p1.y - p2.y ), 2 ))); } var getDirection = function (p1, p2 ) { var angle = Math .atan2 (p1.y - p2.y , p2.x - p1.x ) * 180 / Math .PI ; if (angle <= 45 && angle >= -45 ) return "right" ; if (angle >= 45 && angle <= 135 ) return "up" ; if (angle >= 135 || angle <= -135 ) return "left" ; if (angle <= -45 && angle >= -135 ) return "down" ; } var startEvtHandle = function (e ) { var pos = getPos (e); var touches = e.touches ; if (!touches || touches.length == 1 ) { point_start = getPos (e); time_start = new Date ().getTime (); } $("#notification" ).css ({ height : 0 , overflow : "hidden" }).html ("<i class='fa fa-spinner fa-pulse fa-2x fa-fw'></i><span class='sr-only'>释放加载更多</span>" ).show (); point_end = pos; } var transformYPre = 0 ; var moveEvtHandle = function (e ) { point_end = getPos (e); var y = point_end.y - point_start.y ; if (y > 0 && y > 80 ) { y = 80 ; } else if (y < 0 ) { y = 0 ; } transformYPre += y - transformYPre; $("#listPanel" ).css ({ transition : "all 1s" , transform : "translate3d(0," + transformYPre + "px,0)" }) $("#notification" ).css ({ transition : "all 1s" , height : transformYPre + "px" , lineHeight : transformYPre + "px" }) e.preventDefault (); } var endEvtHandle = function (e ) { time_end = new Date ().getTime (); var dis = getDistance (point_start, point_end); var time = time_end - time_start; if (dis >= swip_dis && time >= swip_time) { var dir = getDirection (point_start, point_end), disY = point_end.y - point_start.y , disX = point_end.x - point_start.x ; if (disY >= 80 && dir == "down" ) { result = 3 ; loadMessage (++page); console .log ('加载中' ); var timer = setInterval (function ( ) { if (loadMessageFlag) { $("#listPanel" ).css ({ transition : "all 1s" , transform : "translate3d(0,0,0)" }) if (loadMessageFlag == 1 ) $("#notification" ).html ("<i class='fa fa-check-circle-o fa-2x fa-fw' style='color: #00EE00'></i><span class='sr-only'>Success</span>" ); else if (loadMessageFlag == 2 ) $("#notification" ).html ("没有更多消息了=_=" ); loadMessageFlag = 0 ; setTimeout (function ( ) { $("#notification" ).css ({ height : "30px" , lineHeight : "30px" }).html ("" ).hide (); clearInterval (timer); }, 300 ); } }); setTimeout (function ( ) { clearInterval (timer); $("#notification" ).html ("<i class='fa fa-remove fa-4x fa-fw' style='color: #00EE00'></i><span class='sr-only'>Failed</span>" ); loadMessageFlag = false ; setTimeout (function ( ) { $("#notification" ).css ({ height : "30px" , lineHeight : "30px" }).html ("" ).hide (); }, 300 ); }, 31000 ); } else if (disX >= 80 && dir == "right" ) { result = 2 ; } else if (disX < -30 && dir == "left" ) { result = 4 ; } else if (disY < -30 && dir == "up" ) { $("#listPanel" ).scrollTop (parseInt (Math .abs (point_end.y - point_start.y ))); result = 1 ; } } else { $("#listPanel" ).css ({ transition : "all 1s" , transform : "translate3d(0,0,0)" }).animate ({ scrollTop : '30px' }, 300 ); $("#notification" ).css ({ height : "30px" , lineHeight : "30px" }).html ("" ).hide (); } } $("#listPanel" ).on (startEvt, function (e ) { if ($(this ).scrollTop () <= 0 ) { startEvtHandle (e); $(this ).on (moveEvt, moveEvtHandle); $(this ).on (endEvt, function (e ) { endEvtHandle (e); $(this ).off (moveEvt).off (endEvt); }); } }) }
1 2 3 4 5 6 const scrollToBottom = ( ) => { let scrollHeight = $("#listPanel" )[0 ].scrollHeight - $("#listPanel" )[0 ].clientHeight ; $("#listPanel" ).animate ({ scrollTop : scrollHeight }, 300 ); };