碎碎念
在访问网站时,使用 HTTPS 非常重要。HTTPS 协议不仅可以确保数据传输的安全性,还可以防止中间人攻击和数据篡改等安全问题。任何没有 SSL 证书的内容都可能会被拒绝访问。因此,在配置 WebSocket 服务器时,我们也需要确保其支持 HTTPS。
在这种情况下,直接运行 HOST=0.0.0.0 PORT=9000 npx y-websocket
将无法正常工作,因为它不支持 HTTPS。接下来,我们将介绍如何通过创建一个支持 HTTPS 的 WebSocket 服务器来解决这个问题。
解决方法
创建 HTTPS Y-WebSocket 服务器
首先,我们需要创建一个文件 secure-server.js
,该文件将用于配置 HTTPS 和 WebSocket 服务器:
// fileName:secure-server.js
#!/usr/bin/env node
const WebSocket = require('ws')
const https = require('https')
const fs = require('fs')
const number = require('lib0/number')
const wss = new WebSocket.Server({ noServer: true })
const setupWSConnection = require('./utils.cjs').setupWSConnection
const host = process.env.HOST || '0.0.0.0'
const port = number.parseInt(process.env.PORT || '9000')
// 加载SSL证书和私钥
const server = https.createServer({
cert: fs.readFileSync('/ssl/cert.pem'),
key: fs.readFileSync('/ssl/cert.key')
}, (_request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' })
response.end('okay')
})
wss.on('connection', setupWSConnection)
server.on('upgrade', (request, socket, head) => {
// You may check auth of request here..
// Call `wss.HandleUpgrade` *after* you checked whether the client has access
// (e.g. by checking cookies, or url parameters).
// See https://github.com/websockets/ws#client-authentication
wss.handleUpgrade(request, socket, head, /** @param {any} ws */ ws => {
wss.emit('connection', ws, request)
})
})
server.listen(port, host, () => {
console.log(`running at 'https://${host}' on port ${port}`)
})
然后,就是callback.cjs
const http = require('http')
const number = require('lib0/number')
const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null
const CALLBACK_TIMEOUT = number.parseInt(process.env.CALLBACK_TIMEOUT || '5000')
const CALLBACK_OBJECTS = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {}
exports.isCallbackSet = !!CALLBACK_URL
/**
* @param {Uint8Array} update
* @param {any} origin
* @param {import('./utils.cjs').WSSharedDoc} doc
*/
exports.callbackHandler = (update, origin, doc) => {
const room = doc.name
const dataToSend = {
room,
data: {}
}
const sharedObjectList = Object.keys(CALLBACK_OBJECTS)
sharedObjectList.forEach(sharedObjectName => {
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName]
dataToSend.data[sharedObjectName] = {
type: sharedObjectType,
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON()
}
})
CALLBACK_URL && callbackRequest(CALLBACK_URL, CALLBACK_TIMEOUT, dataToSend)
}
/**
* @param {URL} url
* @param {number} timeout
* @param {Object} data
*/
const callbackRequest = (url, timeout, data) => {
data = JSON.stringify(data)
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
timeout,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
}
const req = http.request(options)
req.on('timeout', () => {
console.warn('Callback request timed out.')
req.abort()
})
req.on('error', (e) => {
console.error('Callback request error.', e)
req.abort()
})
req.write(data)
req.end()
}
/**
* @param {string} objName
* @param {string} objType
* @param {import('./utils.cjs').WSSharedDoc} doc
*/
const getContent = (objName, objType, doc) => {
switch (objType) {
case 'Array': return doc.getArray(objName)
case 'Map': return doc.getMap(objName)
case 'Text': return doc.getText(objName)
case 'XmlFragment': return doc.getXmlFragment(objName)
case 'XmlElement': return doc.getXmlElement(objName)
default : return {}
}
}
最后,就是utils.cjs
const Y = require('yjs')
const syncProtocol = require('y-protocols/sync')
const awarenessProtocol = require('y-protocols/awareness')
const encoding = require('lib0/encoding')
const decoding = require('lib0/decoding')
const map = require('lib0/map')
const debounce = require('lodash.debounce')
const callbackHandler = require('./callback.cjs').callbackHandler
const isCallbackSet = require('./callback.cjs').isCallbackSet
const CALLBACK_DEBOUNCE_WAIT = parseInt(process.env.CALLBACK_DEBOUNCE_WAIT || '2000')
const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000')
const wsReadyStateConnecting = 0
const wsReadyStateOpen = 1
const wsReadyStateClosing = 2 // eslint-disable-line
const wsReadyStateClosed = 3 // eslint-disable-line
// disable gc when using snapshots!
const gcEnabled = process.env.GC !== 'false' && process.env.GC !== '0'
const persistenceDir = process.env.YPERSISTENCE
/**
* @type {{bindState: function(string,WSSharedDoc):void, writeState:function(string,WSSharedDoc):Promise<any>, provider: any}|null}
*/
let persistence = null
if (typeof persistenceDir === 'string') {
console.info('Persisting documents to "' + persistenceDir + '"')
// @ts-ignore
const LeveldbPersistence = require('y-leveldb').LeveldbPersistence
const ldb = new LeveldbPersistence(persistenceDir)
persistence = {
provider: ldb,
bindState: async (docName, ydoc) => {
const persistedYdoc = await ldb.getYDoc(docName)
const newUpdates = Y.encodeStateAsUpdate(ydoc)
ldb.storeUpdate(docName, newUpdates)
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc))
ydoc.on('update', update => {
ldb.storeUpdate(docName, update)
})
},
writeState: async (_docName, _ydoc) => {}
}
}
/**
* @param {{bindState: function(string,WSSharedDoc):void,
* writeState:function(string,WSSharedDoc):Promise<any>,provider:any}|null} persistence_
*/
exports.setPersistence = persistence_ => {
persistence = persistence_
}
/**
* @return {null|{bindState: function(string,WSSharedDoc):void,
* writeState:function(string,WSSharedDoc):Promise<any>}|null} used persistence layer
*/
exports.getPersistence = () => persistence
/**
* @type {Map<string,WSSharedDoc>}
*/
const docs = new Map()
// exporting docs so that others can use it
exports.docs = docs
const messageSync = 0
const messageAwareness = 1
// const messageAuth = 2
/**
* @param {Uint8Array} update
* @param {any} _origin
* @param {WSSharedDoc} doc
* @param {any} _tr
*/
const updateHandler = (update, _origin, doc, _tr) => {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
syncProtocol.writeUpdate(encoder, update)
const message = encoding.toUint8Array(encoder)
doc.conns.forEach((_, conn) => send(doc, conn, message))
}
/**
* @type {(ydoc: Y.Doc) => Promise<void>}
*/
let contentInitializor = _ydoc => Promise.resolve()
/**
* This function is called once every time a Yjs document is created. You can
* use it to pull data from an external source or initialize content.
*
* @param {(ydoc: Y.Doc) => Promise<void>} f
*/
exports.setContentInitializor = (f) => {
contentInitializor = f
}
class WSSharedDoc extends Y.Doc {
/**
* @param {string} name
*/
constructor (name) {
super({ gc: gcEnabled })
this.name = name
/**
* Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed
* @type {Map<Object, Set<number>>}
*/
this.conns = new Map()
/**
* @type {awarenessProtocol.Awareness}
*/
this.awareness = new awarenessProtocol.Awareness(this)
this.awareness.setLocalState(null)
/**
* @param {{ added: Array<number>, updated: Array<number>, removed: Array<number> }} changes
* @param {Object | null} conn Origin is the connection that made the change
*/
const awarenessChangeHandler = ({ added, updated, removed }, conn) => {
const changedClients = added.concat(updated, removed)
if (conn !== null) {
const connControlledIDs = /** @type {Set<number>} */ (this.conns.get(conn))
if (connControlledIDs !== undefined) {
added.forEach(clientID => { connControlledIDs.add(clientID) })
removed.forEach(clientID => { connControlledIDs.delete(clientID) })
}
}
// broadcast awareness update
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients))
const buff = encoding.toUint8Array(encoder)
this.conns.forEach((_, c) => {
send(this, c, buff)
})
}
this.awareness.on('update', awarenessChangeHandler)
this.on('update', /** @type {any} */ (updateHandler))
if (isCallbackSet) {
this.on('update', /** @type {any} */ (debounce(
callbackHandler,
CALLBACK_DEBOUNCE_WAIT,
{ maxWait: CALLBACK_DEBOUNCE_MAXWAIT }
)))
}
this.whenInitialized = contentInitializor(this)
}
}
exports.WSSharedDoc = WSSharedDoc
/**
* Gets a Y.Doc by name, whether in memory or on disk
*
* @param {string} docname - the name of the Y.Doc to find or create
* @param {boolean} gc - whether to allow gc on the doc (applies only when created)
* @return {WSSharedDoc}
*/
const getYDoc = (docname, gc = true) => map.setIfUndefined(docs, docname, () => {
const doc = new WSSharedDoc(docname)
doc.gc = gc
if (persistence !== null) {
persistence.bindState(docname, doc)
}
docs.set(docname, doc)
return doc
})
exports.getYDoc = getYDoc
/**
* @param {any} conn
* @param {WSSharedDoc} doc
* @param {Uint8Array} message
*/
const messageListener = (conn, doc, message) => {
try {
const encoder = encoding.createEncoder()
const decoder = decoding.createDecoder(message)
const messageType = decoding.readVarUint(decoder)
switch (messageType) {
case messageSync:
encoding.writeVarUint(encoder, messageSync)
syncProtocol.readSyncMessage(decoder, encoder, doc, conn)
// If the `encoder` only contains the type of reply message and no
// message, there is no need to send the message. When `encoder` only
// contains the type of reply, its length is 1.
if (encoding.length(encoder) > 1) {
send(doc, conn, encoding.toUint8Array(encoder))
}
break
case messageAwareness: {
awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn)
break
}
}
} catch (err) {
console.error(err)
// @ts-ignore
doc.emit('error', [err])
}
}
/**
* @param {WSSharedDoc} doc
* @param {any} conn
*/
const closeConn = (doc, conn) => {
if (doc.conns.has(conn)) {
/**
* @type {Set<number>}
*/
// @ts-ignore
const controlledIds = doc.conns.get(conn)
doc.conns.delete(conn)
awarenessProtocol.removeAwarenessStates(doc.awareness, Array.from(controlledIds), null)
if (doc.conns.size === 0 && persistence !== null) {
// if persisted, we store state and destroy ydocument
persistence.writeState(doc.name, doc).then(() => {
doc.destroy()
})
docs.delete(doc.name)
}
}
conn.close()
}
/**
* @param {WSSharedDoc} doc
* @param {import('ws').WebSocket} conn
* @param {Uint8Array} m
*/
const send = (doc, conn, m) => {
if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) {
closeConn(doc, conn)
}
try {
conn.send(m, {}, err => { err != null && closeConn(doc, conn) })
} catch (e) {
closeConn(doc, conn)
}
}
const pingTimeout = 30000
/**
* @param {import('ws').WebSocket} conn
* @param {import('http').IncomingMessage} req
* @param {any} opts
*/
exports.setupWSConnection = (conn, req, { docName = (req.url || '').slice(1).split('?')[0], gc = true } = {}) => {
conn.binaryType = 'arraybuffer'
// get doc, initialize if it does not exist yet
const doc = getYDoc(docName, gc)
doc.conns.set(conn, new Set())
// listen and reply to events
conn.on('message', /** @param {ArrayBuffer} message */ message => messageListener(conn, doc, new Uint8Array(message)))
// Check if connection is still alive
let pongReceived = true
const pingInterval = setInterval(() => {
if (!pongReceived) {
if (doc.conns.has(conn)) {
closeConn(doc, conn)
}
clearInterval(pingInterval)
} else if (doc.conns.has(conn)) {
pongReceived = false
try {
conn.ping()
} catch (e) {
closeConn(doc, conn)
clearInterval(pingInterval)
}
}
}, pingTimeout)
conn.on('close', () => {
closeConn(doc, conn)
clearInterval(pingInterval)
})
conn.on('pong', () => {
pongReceived = true
})
// put the following in a variables in a block so the interval handlers don't keep in in
// scope
{
// send sync step 1
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
syncProtocol.writeSyncStep1(encoder, doc)
send(doc, conn, encoding.toUint8Array(encoder))
const awarenessStates = doc.awareness.getStates()
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())))
send(doc, conn, encoding.toUint8Array(encoder))
}
}
}
以上是参考 yjs/y-websocket-server 的代码,因为这样可以解决我代码的问题。
创建系统服务
为了确保我们的 WebSocket 服务器能够在系统启动时自动运行,我们需要创建一个 systemd 服务文件 y-websocket.service
:
[Unit]
Description=Y-WebSocket Server Service
After=network.target
[Service]
User=root
Group=root
WorkingDirectory=/path/to/WorkingDirectory
Environment="HOST=0.0.0.0" "PORT=9000" "SSL_CERT=/path/to/cert.pem" "SSL_KEY=/path/to/key.pem"
; 或者是 Environment="HOST=0.0.0.0" "PORT=9000" "SSL_CERT=/path/to/cert.pem" "SSL_KEY=/path/to/cert.key"
ExecStart=/usr/bin/node /path/to/secure-server.js
Restart=always
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=y-websocket-server
[Install]
WantedBy=multi-user.target
这个服务文件定义了 WebSocket 服务器的启动和运行方式。通过设置环境变量,我们可以灵活地配置服务器的主机、端口和 SSL 证书路径。
启动服务
最后,我们需要运行以下指令来启动并启用服务:
sudo systemctl daemon-reload
sudo systemctl restart y-websocket.service
sudo systemctl enable y-websocket.service
通过以上步骤,我们创建了一个支持 HTTPS 的 WebSocket 服务器,并确保其在系统启动时自动运行。这不仅提高了服务器的安全性,还简化了服务器的管理和维护。
此后,我们就可以使用wss:host:port
来访问Y-WebSocket Server