深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
中
ESC

Adding a Real-Time WebSocket Live Chat System to My Hexo Blog

English 阅读中文版

Preface

As a tech blogger, I’ve always wanted to establish a more direct connection with my readers. Although a message board is a traditional asynchronous form of communication, the immediacy and interactivity of real-time chat is something a message board can’t replace. After some research and development, I added a complete WebSocket real-time live chat system to my blog.

This article will document in detail the whole system’s design ideas, technology choices, implementation process, and the pitfalls I hit. I hope it helps developers with similar needs.

Requirements analysis

Before I started coding, I clarified the core requirements of the system:

Functional requirements

  1. Two-sided support

    • Visitor side: anonymous access, chat without registration
    • Admin side: I need to receive messages in real time and reply
  2. Messaging features

    • Real-time send/receive of text messages
    • Image message support (base64 encoded)
    • Message history (the conversation isn’t lost when changing pages)
  3. Status display

    • Admin online/offline status
    • “Other party is typing” hint
    • Connection status display
  4. Interaction design

    • Floating message bubble in the bottom-right corner
    • A choice popup: chat online / leave a message
    • Available site-wide (not just the résumé page)

Non-functional requirements

  • Security: prevent XSS and CSRF, be CSP-compliant
  • Performance: messages arrive instantly, support reconnection
  • Compatibility: support mainstream browsers
  • Simple deployment: a single binary + Nginx

Technical architecture

Overall architecture

┌─────────────────────────────────────────────────────────────┐
│ Frontend layer (Hexo) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Message bubble button │ │ Choice popup │ │ WebSocket chat window │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
└─────────┼─────────────────┼───────────────────┼───────────┘
│ │ │
└─────────────────┴───────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Nginx reverse proxy │
│ (SSL termination / WebSocket upgrade / static assets)│
└─────────────────────────┬───────────────────────────────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ blogapi.awen.me │ │ www.awen.me │
│ (Go + WebSocket) │ │ (Hexo static site) │
│ - JWT auth │ │ │
│ - Message storage │ │ │
│ - Online status management │ │ │
└─────────────────────┘ └─────────────────────┘

Tech stack choices

Layer Technology Reason for the choice
Frontend Hexo + EJS The blog itself is Hexo; just modify the theme directly
Backend Go + Gin Excellent performance, good WebSocket support, simple deployment
Database MySQL Stores message history and session info
Cache Redis Admin online status, session management
Deployment Nginx + Systemd A mature production-environment solution

Backend implementation (Go)

Project structure

blog_api/
├── internal/
│ ├── handlers/
│ │ ├── chat_ws.go # WebSocket core logic
│ │ └── chat_admin.go # Admin-side interfaces
│ ├── models/
│ │ ├── chat_session.go # Session model
│ │ └── chat_message.go # Message model
│ ├── middleware/
│ │ └── auth.go # JWT auth
│ └── ws/
│ └── hub.go # WebSocket connection management
├── cmd/
│ └── main.go
└── config.yaml

WebSocket Hub design

It uses the classic “Hub” pattern to manage all connections:

// internal/ws/hub.go
type Hub struct {
// Registration request channel
Register chan *Client
// Unregistration request channel
Unregister chan *Client
// All client connections
Clients map[string]*Client // session_id -> Client
// Admin connections (supports multi-device login)
AdminClients map[uint]*Client // user_id -> Client
// Message broadcast channel
Broadcast chan *Message
}

type Client struct {
Hub *Hub
Conn *websocket.Conn
Send chan []byte
SessionID string
IsAdmin bool
UserID uint
}

Design highlights:

  • Visitors are identified by session_id, so refreshing the page doesn’t lose the conversation
  • Admins are identified by user_id, supporting multiple devices online at once
  • Channels are used for concurrency-safe connection management

Message protocol design

JSON is used as the message format:

type Message struct {
Type string `json:"type"` // message/image/typing/admin_status
From string `json:"from"` // visitor/admin/system
Content string `json:"content"` // text content or image base64
SessionID string `json:"session_id"`
HasImage bool `json:"has_image"`
CreatedAt time.Time `json:"created_at"`
}

Core handler implementation

// Visitor connection (anonymous)
func (h *ChatWSHandler) VisitorConnect(c *gin.Context) {
// Upgrade HTTP to WebSocket
conn, err := h.Upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}

// Get or create the session
sessionID := c.Query("session_id")
if sessionID == "" {
sessionID = generateSessionID()
}

client := &ws.Client{
Hub: h.Hub,
Conn: conn,
Send: make(chan []byte, 256),
SessionID: sessionID,
IsAdmin: false,
}

// Register with the Hub
h.Hub.Register <- client

// Start the read/write goroutines
go client.WritePump()
go client.ReadPump()

// Send a connection-success message
client.Send <- (&Message{
Type: "connected",
SessionID: sessionID,
Content: "连接成功",
}).ToJSON()
}

// Admin connection (auth required)
func (h *ChatWSHandler) AdminConnect(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}

conn, err := h.Upgrader.Upgrade(c.Writer, c.Request, nil)
// ... similar to the visitor connection, but IsAdmin = true
}

Special handling for JWT auth

When a WebSocket connects, the browser can’t set custom headers, so the token is passed via a query parameter:

func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")

// WebSocket connections fall back to the query param
if token == "" && c.Query("token") != "" {
token = "Bearer " + c.Query("token")
}

// Parse the JWT ...
c.Set("userID", userID)
c.Next()
}
}

Frontend connection example:

const token = localStorage.getItem('admin_token');
const ws = new WebSocket(`wss://blogapi.awen.me/api/v1/chat/ws/admin?token=${token}`);

Database models

// Session table
type ChatSession struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"uniqueIndex;size:64"` // visitor identifier
Source string `gorm:"size:100"` // source page title
URL string `gorm:"size:500"` // source page URL
Status int `gorm:"default:1"` // 1: active 2: closed
CreatedAt time.Time
UpdatedAt time.Time
}

// Message table
type ChatMessage struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"index;size:64"`
Type int `gorm:"default:1"` // 1: visitor 2: admin
Content string `gorm:"type:text"`
HasImage bool `gorm:"default:false"`
IsRead bool `gorm:"default:false"`
CreatedAt time.Time
}

Frontend implementation (Hexo theme modifications)

Overall approach

Hexo is a static blog and can’t run server-side code directly. My approach:

  1. Layout layer: inject a global chat component into layout.ejs
  2. Style isolation: use a Shadow DOM-like idea, prefixing CSS class names to avoid conflicts
  3. CDN resources: use inline SVG for icons, no external font dependency

Component structure

layout.ejs
├── Message bubble button (message-bubble)
├── Choice popup (contact-choice-modal)
│ ├── Chat online button
│ └── Leave a message button
├── WebSocket chat window (chat-modal)
│ ├── Chat header (avatar, status)
│ ├── Message list
│ └── Input box + image sending
└── Message form popup (message-modal)

Key code implementation

1. Dynamically creating the chat window

To avoid polluting the HTML structure, the chat window is created entirely dynamically with JavaScript:

(function() {
// Create the chat dialog
var chatModal = document.createElement('div');
chatModal.className = 'chat-modal';
chatModal.id = 'chatModal';
chatModal.innerHTML = `
<div class="chat-overlay"></div>
<div class="chat-dialog">
<div class="chat-header">
<div class="chat-header-info">
<div class="chat-avatar">
<img src="https://file.awen.me/blog/avatar.jpg?e=2106345600&st=0ezB6OTKOaPSAtjzocSAhQ" alt="博主">
</div>
<div class="chat-header-text">
<h3>方文俊</h3>
<p id="chatStatusText">连接中...</p>
</div>
</div>
<button type="button" class="chat-close" id="chatClose">...</button>
</div>
<div class="chat-body" id="chatBody">...</div>
<div class="chat-footer">...</div>
</div>
`;
document.body.appendChild(chatModal);
})();

2. WebSocket connection management

var ws = null;
var sessionID = localStorage.getItem('chat_session_id');
var reconnectTimer = null;

function connectWebSocket() {
var wsUrl = 'wss://blogapi.awen.me/api/v1/chat/ws';
var params = [];

if (sessionID) {
params.push('session_id=' + encodeURIComponent(sessionID));
}
params.push('source=' + encodeURIComponent(document.title));
params.push('url=' + encodeURIComponent(window.location.href));

ws = new WebSocket(wsUrl + '?' + params.join('&'));

ws.onopen = function() {
console.log('[WebSocket] 已连接');
updateConnectionStatus(true);
};

ws.onmessage = function(event) {
var msg = JSON.parse(event.data);
handleMessage(msg);
};

ws.onclose = function() {
console.log('[WebSocket] 连接断开,3秒后重连');
updateConnectionStatus(false);
reconnectTimer = setTimeout(connectWebSocket, 3000);
};

ws.onerror = function(error) {
console.error('[WebSocket] 错误:', error);
};
}

3. Message handling logic

function handleMessage(msg) {
switch (msg.type) {
case 'connected':
// Save the session ID
sessionID = msg.session_id;
localStorage.setItem('chat_session_id', sessionID);
// Load the history
loadChatHistory(sessionID);
break;

case 'message':
if (msg.from === 'admin') {
addMessage(msg.content, false); // false = message from the other party
playNotificationSound();
}
break;

case 'image':
if (msg.from === 'admin') {
addImageMessage(msg.content, false);
playNotificationSound();
}
break;

case 'typing':
showTypingStatus();
break;

case 'admin_status':
updateAdminStatus(msg.content === 'online');
break;
}
}

4. Image sending implementation

Images are transmitted base64-encoded; although this increases size, it’s good enough for a chat scenario:

function sendImage(file) {
if (file.size > 5 * 1024 * 1024) {
showToast('图片大小不能超过 5MB', 'error');
return;
}

var reader = new FileReader();
reader.onload = function(e) {
var base64 = e.target.result;

// Show it locally first
addImageMessage(base64, true);

// Send it to the server
ws.send(JSON.stringify({
type: 'image',
content: base64
}));
};
reader.readAsDataURL(file);
}

CSS styling design

Written in Stylus, with dark mode support:

/* Chat popup */
.chat-modal
position fixed
top 0
left 0
right 0
bottom 0
z-index 9999
display flex
align-items center
justify-content center
opacity 0
pointer-events none
transition opacity 0.3s ease

&.active
opacity 1
pointer-events auto

.chat-dialog
width 90%
max-width 480px
height 600px
max-height 80vh
background var(--card-bg)
border-radius 20px
box-shadow 0 20px 60px rgba(0, 0, 0, 0.3)
display flex
flex-direction column
overflow hidden

/* Dark mode adaptation */
[data-theme="dark"] .chat-dialog
background #1e1e1e

[data-theme="dark"] .chat-bubble
background #2a2a2a
color #e0e0e0

Admin-side implementation

The admin side needs:

  1. Login authentication
  2. Visitor list (who’s chatting with me)
  3. Real-time message send/receive
  4. Quick replies

Admin-side interface

I made it a standalone single-page app, placed in source/admin/index.html, deployed along with GitHub Pages or Hexo:

<!-- Simplified structure -->
<div class="admin-layout">
<aside class="visitor-list">
<div class="visitor-item active" data-session="xxx">
<img src="avatar.png" class="visitor-avatar">
<div class="visitor-info">
<h4>访客 #1234</h4>
<p>在线</p>
</div>
<span class="unread-badge">3</span>
</div>
</aside>

<main class="chat-area">
<div class="chat-messages" id="messages"></div>
<div class="chat-input">
<input type="text" id="msgInput" placeholder="输入回复...">
<button id="sendBtn">发送</button>
</div>
</main>
</div>

Login flow

// Log in to get the JWT
async function login(username, password) {
const res = await fetch('https://blogapi.awen.me/api/v1/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});

const data = await res.json();
localStorage.setItem('admin_token', data.token);
connectAdminWebSocket(data.token);
}

// Connect the admin-side WebSocket
function connectAdminWebSocket(token) {
const ws = new WebSocket(
`wss://blogapi.awen.me/api/v1/chat/ws/admin?token=${token}`
);

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'visitor_list') {
updateVisitorList(msg.data);
} else if (msg.type === 'message') {
appendMessage(msg);
}
};
}

Deployment configuration

Nginx configuration

The key is WebSocket upgrade support:

# /etc/nginx/conf.d/blog-api.conf

server {
listen 443 ssl http2;
server_name blogapi.awen.me;

ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;

# WebSocket endpoint
location /api/v1/chat/ws {
proxy_pass http://localhost:8083;
proxy_http_version 1.1;

# Key: the Upgrade header
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# Timeout settings
proxy_read_timeout 86400;
}

# Other APIs
location /api/ {
proxy_pass http://localhost:8083;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

Systemd service

# /etc/systemd/system/blog-api.service
[Unit]
Description=Blog API Service
After=network.target

[Service]
Type=simple
User=fangwenjun
WorkingDirectory=/opt/blog-api
ExecStart=/opt/blog-api/blog-api
Restart=always
RestartSec=5
Environment="ADMIN_INITIAL_PASSWORD=xxx"
Environment="DB_HOST=localhost"
Environment="JWT_SECRET=xxx"

[Install]
WantedBy=multi-user.target

Pitfall log

1. CSP blocks the WebSocket connection

Problem: The browser reports an error

Connecting to 'wss://blogapi.awen.me/api/v1/chat/ws' violates 
the following Content Security Policy directive:
"connect-src 'self' https://blogapi.awen.me ..."

Cause: The CSP connect-src only allows https://, not wss://

Fix:

<meta http-equiv="Content-Security-Policy" 
content="...; connect-src 'self' https://blogapi.awen.me wss://blogapi.awen.me; ...">

2. The popup can’t be closed

Problem: Clicking the overlay or the close button doesn’t make the choice popup disappear

Cause: openChoiceModal() manually sets style.display = 'block', but closeChoiceModal() only removes the CSS class and doesn’t clear the inline style

Fix:

function closeChoiceModal() {
contactChoiceModal.classList.remove('active');
contactChoiceModal.style.display = ''; // clear the inline style
document.body.style.overflow = '';
}

3. Duplicate code on the résumé page

Problem: At first, chat was only implemented on the résumé page; other pages showed “please go to the résumé page”

Fix: Extract the chat code into layout.ejs so it’s shared site-wide

4. WebSocket auth issue

Problem: When a WebSocket connects, it can’t carry headers, so the JWT can’t be passed

Fix: Pass the token via a query parameter, and make the backend middleware handle it compatibly:

if token == "" && c.Query("token") != "" {
token = "Bearer " + c.Query("token")
}

Results

Visitor-side experience

  1. On any page, click the bubble in the bottom-right corner
  2. Choose “Chat online” or “Leave a message”
  3. The chat window pops up and automatically connects the WebSocket
  4. Messages are sent/received in real time, with image support

Admin-side experience

  1. Visit https://www.awen.me/admin
  2. After logging in, the WebSocket connects automatically
  3. Visitor list on the left, chat window on the right
  4. Red-dot reminders for unread messages

Summary

This development effort to add a real-time chat system to my blog taught me a lot:

  1. WebSocket practice: from theory to deployment, understanding connection management, heartbeat keep-alive, and reconnection mechanisms
  2. Frontend-backend separation: the combination of a Hexo static site + a Go API is very flexible
  3. Security considerations: CSP, XSS protection, and JWT auth are all indispensable
  4. User experience: details like the message bubble, status hints, and history records matter a lot

The full code is open-sourced. If you’d also like to add similar functionality to your blog, feel free to refer to:

If you have any questions, you can reach me directly via the chat button at the top of the article 😄


Reference resources


That’s the end of this article—thanks for reading!

文章作者:阿文
文章链接: https://www.awen.me/post/30e1469d.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客!
本文于 2026-03-02 18:30 发布,已超过半年(228天),请注意甄别内容是否已过期。

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️