OnlineChat - Egg+Webpack+Socket.IO

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 content

1
2
3
4
5
6
7
8
9
10
11
12
'use strict';
const path = require('path');
module.exports = appInfo => {
const config = exports = {};

// use for cookie sign key, should change to your own and keep security
config.keys = appInfo.name + 'your_keys';

// add your config here

return config;
}

middleware

1
2
3
4
5
6
7
8
9
10
// add your middleware
config.middleware = ['permission','middleware2'];
// your middleware config, which will be the param 'options' you can access later
config.permission = {
excludeUrl: {
'ALL': ['/', '/login', '/register'],
'POST': [],
'GET': ['/register/checkUserid'],
},
}

plugin

  • ejs
1
2
3
4
5
6
7
// egg-view-view plugin
config.view = {
mapping: {
'.html': 'ejs',
},
defaultViewEngine: 'ejs'
};
  • mongoose
1
2
3
4
5
6
7
// egg-mongoose plugin, [What is egg-mongoose](https://www.npmjs.com/package/egg-mongoose)
config.mongoose = {
client: {
url: 'mongodb://127.0.0.1/chat',
options: {},
},
};
  • egg security and session
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// egg security solutions, see [egg Security](https://eggjs.org/en/core/security.html) for detail
// you have to send csrftoken before your request
config.security = {
csrf: {
headerName: 'x-csrf-token', // 通过 header 传递 CSRF token 的默认字段为 x-csrf-token
},
};

config.session = {
key: 'EGG_SESS',
maxAge: 24 * 3600 * 1000, // 1 天
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) {
// these HTTP methods do not require CSRF protection
return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}
$.ajaxSetup({
beforeSend: function (xhr, settings) {
if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
xhr.setRequestHeader('x-csrf-token', csrftoken);
}
},
});
  • socket.io
1
2
3
4
5
6
7
8
9
// see [egg Socket.IO](https://eggjs.org/en/tutorials/socketio.html) for detail
config.io = {
namespace: {
'/allChat': {
connectionMiddleware: ['auth'],
packetMiddleware: [],
}
}
};
  • global config
1
2
3
4
5
6
7
8
9
// define global configuration and variabels yourself
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,
};

Upload File and Form

use Formidable 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
// app/controller/xxx.js
const formidable = require('formidable');
const path = require('path');

// It's ok use formidable to wait the `form` end event to send response, but wrong in egg.
// You have to return a promise.

// handle function
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');
})
});
}

// usage
// const result = await this.formParse(ctx.req, filename, config);

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
// take login for example

// app/model/user.js
module.exports = app => {
const mongoose = app.mongoose;
const Schema = mongoose.Schema;

const UserSchema = new Schema({
_id: {
type: String,
unique: true,
// mongoose aliases
alias: 'userid',
},
username: String,
password: String,
};
};

// app/service/user.js
const Service = require('egg').Service;
class UserService extends Service {
async findOneByUserid(userid) {
let docs = await this.ctx.model.User.findOne({
_id: userid,
});
if (docs) {
// mongoose virtuals
return docs.toObject({
virtuals: true
});
}
return docs;
}
}

// app/controller/user.js
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',
},
});
// you can use _id or userid in getter and setter
// assume `doc` is the query doc
console.log(doc.toObject({ virtuals: true })); // { _id: 'xxx', userid: 'xxx' }
console.log(doc.userid); // "xxx"

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
// take `User` model and `Message` model for example,
// which user have more than one messages,
// `Message` have attributes that ref to `_id` alias `userid` here.

// app/model/message.js
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);

// now you can access user infomation while query message
// Message.find(citerion).populate('from','userid username').populate('to','userid username');
}

Find by pagination

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// in app/service/message.js
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 by date desc
.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
// ctx.request.ip

// use socket.io to get ip address
// socket.handshake.address
// maybe you need to parse the IP address
parseIPAddress(ip) {
if (ip.indexOf('::ffff:') !== -1) {
ip = ip.substring(7);
}
return ip;
}

Socket.IO

use socket.io in egg

  1. controller extends app.controller
  2. middleware
1
2
3
4
5
6
7
8
9
// app/io/middleware/auth.js
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
// by the socket object, get the connector id
const sid = socket.id;

// get online user socketid in room by the namespace adapter, except current connector id
nsp.adapter.clients(rooms, (err, clients) => {
//clients is an socketid arrs
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
// sender
let userinfo = await service.user.findOneByUserid(ctx.session.user.userid);
message.from = {
userid: userinfo.userid,
username: userinfo.username,
};
// receiver
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);
// 监听自身 id 以实现 p2p 通讯
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

  1. the socktid get from Namespace.adapter.clients contain the room #room at the head.

  2. 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
}
}
},
},

extract-text-webpack-plugin

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
//module rules
{
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') // '~@/logo.png' 这种写法,会去找src/img/logo.png这个文件
}
},
},
'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
"_": '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,
//1 上 2 右 3 下 4左
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('加载中');
//加载完成后释放 等待30s
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);
}
});
//30s后停止
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);
});
}
})
}

Scroll To Bottom

1
2
3
4
5
6
const scrollToBottom = () => {
let scrollHeight = $("#listPanel")[0].scrollHeight - $("#listPanel")[0].clientHeight;
$("#listPanel").animate({
scrollTop: scrollHeight
}, 300);
};
上一篇
下一篇