Loading files
/*
* Copyright (c) 2026 Shinya Ishikawa
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
import ChatWorker from 'ChatWorker'
import JSONBase64Parser from 'JSONBase64Parser'
import TextEncoder from 'text/encoder'
const text = Object.freeze({ binary: false })
const MAX_QUEUED_BYTES = 64 * 1024
export default class ServerChatWebSocketWorker extends ChatWorker {
#buffers = []
#queuedBytes = 0
#socketIO = null
#state = 0
#writable = 0
#encoder = new TextEncoder()
constructor(options) {
super(options)
this.ws = null
this.secure = true
this.host = ''
this.path = '/'
this.port = 443
this.headers = []
this.outputMinimum = (options.outputSampleRate ?? 24000) >> 1
this.silence = new ArrayBuffer(this.outputMinimum)
}
close() {
const ws = this.ws
this.ws = null
this.#buffers = []
this.#queuedBytes = 0
this.#socketIO = null
this.#state = 0
this.#writable = 0
try {
ws?.close()
} catch {
// The transport may already be closed.
}
}
connect(message) {
super.connect(message)
this.parser = new JSONBase64Parser(this, this.outputBuffer, 2, this.outputMinimum)
this.parser.barrier = message.barrier
const network = this.secure ? device.network.wss : device.network.ws
const WebSocketClient = network.io ?? device.network.ws.io
this.#socketIO = WebSocketClient
this.ws = new WebSocketClient({
...network,
host: this.host,
path: this.path,
port: this.port,
headers: this.headers,
onClose: () => {},
onControl: (opcode, data) => {
switch (opcode) {
case WebSocketClient.close: {
const bytes = new Uint8Array(data)
const code = bytes.length >= 2 ? (bytes[0] << 8) | bytes[1] : 1006
const reason = bytes.length > 2 ? String.fromArrayBuffer(bytes.buffer.slice(2)) : 'connection closed'
if (code !== 1000) this.postMessage({ id: 'failed', string: reason })
else this.postMessage({ id: 'disconnected' })
this.close()
break
}
case WebSocketClient.ping:
case WebSocketClient.pong:
break
}
},
onError: (error) => {
const detail = error?.message ?? error
this.postMessage({ id: 'failed', string: detail ? `network error: ${detail}` : 'network error' })
this.close()
},
onReadable: (count, options) => {
const buffer = this.ws.read(count)
this.#open()
if (this.#state === 1) this.read(buffer, options)
},
onWritable: (count) => {
if (!count) return
this.#writable = count
this.#open()
this.flushWrites()
},
})
}
disconnect() {
const WebSocketClient = this.#socketIO
if (!this.ws || !WebSocketClient) return
const code = 1000
this.write(Uint8Array.of(code >> 8, code & 0xff), {
opcode: WebSocketClient.close,
})
this.#state = 2
}
isBase64() {
return false
}
onBase64(offset, size) {
this.postMessage({ id: 'receiveAudio', offset, size })
}
onJSON(json) {
const type = json.type
const handlers = this.eventHandlers
if (typeof type === 'string' && handlers?.[type] === true && typeof this[type] === 'function') {
this[type](json)
}
}
onOpen() {}
#open() {
if (this.#state !== 0) return
this.#state = 1
this.onOpen()
}
read(data, options) {
this.parser.read(data)
if (options.more) return
this.onJSON(this.parser.result)
this.parser.reset()
}
sendAudio(message) {
try {
const samples = new Uint8Array(this.inputBuffer, message.offset, message.size)
this.sendAudioBuffer(samples)
} finally {
// A worker-side failure must not leave the CoreS3 audio producer permanently in flight.
this.postMessage({ id: 'audioConsumed' })
}
}
sendAudioBuffer(samples) {
const string = samples.toBase64()
const data = new Uint8Array(this.audioPrefix.length + string.length + this.audioSuffix.length)
data.set(this.audioPrefix)
this.#encoder.encodeInto(string, data.subarray(this.audioPrefix.length))
data.set(this.audioSuffix, this.audioPrefix.length + string.length)
this.write(data, text)
}
sendJSON(json) {
this.write(ArrayBuffer.fromString(JSON.stringify(json)), text)
}
write(data, options) {
if (!this.ws) return
if (this.#buffers.length) {
this.enqueue(data, options)
return
}
const writable = this.#writable
if (data.byteLength <= writable) {
this.#writable = this.ws.write(data, options)
return
}
if (writable > 0) {
this.#writable = this.ws.write(data.slice(0, writable), {
...options,
more: true,
})
this.enqueue(data.slice(writable), options)
return
}
this.enqueue(data, options)
}
enqueue(data, options) {
if (this.#queuedBytes + data.byteLength > MAX_QUEUED_BYTES) {
this.postMessage({ id: 'failed', string: 'websocket backpressure' })
this.close()
return
}
this.#buffers.push({ data, options })
this.#queuedBytes += data.byteLength
}
flushWrites() {
while (this.#buffers.length && this.ws) {
const buffer = this.#buffers[0]
const data = buffer.data
const writable = this.#writable
if (data.byteLength <= writable) {
this.#writable = this.ws.write(data, buffer.options)
this.#queuedBytes -= data.byteLength
this.#buffers.shift()
continue
}
if (writable > 0) {
this.#writable = this.ws.write(data.slice(0, writable), {
...buffer.options,
more: true,
})
buffer.data = data.slice(writable)
this.#queuedBytes -= writable
}
break
}
}
}
c44ec7454This file is pinned to the selected release. Download the original to open it in your design tool.