@socketsupply/socket
Version:
A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.
586 lines (489 loc) • 13.8 kB
JavaScript
/* global CloseEvent, ErrorEvent, MessageEvent, WebSocket */
import diagnostics from '../diagnostics.js'
import { sleep } from '../timers.js'
import client from '../application/client.js'
import hooks from '../hooks.js'
import ipc from '../ipc.js'
import gc from '../gc.js'
/**
* Predicate state to determine if application is paused.
* @type {boolean}
*/
let isApplicationPaused = false
/**
* The default Conduit port
* @type {number}
*/
let defaultConduitPort = globalThis.__args.conduit || 0
/**
* @typedef {{ options: object, payload: Uint8Array }} ReceiveMessage
* @typedef {function(Error?, ReceiveCallback | undefined)} ReceiveCallback
* @typedef {{ id?: string|BigInt|number, reconnect?: {} }} ConduitOptions
* @typedef {{ isActive: boolean, handles: { ids: string[], count: number }}} ConduitDiagnostics
* @typedef {{ isActive: boolean, port: number }} ConduitStatus
*/
export const DEFALUT_MAX_RECONNECT_RETRIES = 32
export const DEFAULT_MAX_RECONNECT_TIMEOUT = 256
/**
* A pool of known `Conduit` instances.
* @type {Set<Conduit>}
*/
export const pool = new Set()
// reconnect when application resumes
hooks.onApplicationResume(() => {
isApplicationPaused = false
for (const conduit of pool) {
// @ts-ignore
if (conduit?.shouldReconnect) {
// @ts-ignore
conduit.reconnect()
} else {
pool.delete(conduit)
}
}
})
hooks.onApplicationPause(() => {
isApplicationPaused = true
for (const conduit of pool) {
if (conduit) {
// @ts-ignore
conduit.isConnecting = false
// @ts-ignore
conduit.isActive = false
// @ts-ignore
if (conduit.socket && conduit.socket.readyState === WebSocket.OPEN) {
// @ts-ignore
conduit.socket?.close()
}
// @ts-ignore
conduit.socket = null
}
}
})
/**
* A container for managing a WebSocket connection to the internal runtime
* Conduit WebSocket server.
*/
export class Conduit extends EventTarget {
/**
* The global `Conduit` port
* @type {number}
*/
static get port () { return defaultConduitPort }
static set port (port) {
if (port && typeof port === 'number') {
defaultConduitPort = port
}
}
/**
* Returns diagnostics information about the conduit server
* @return {Promise<ConduitDiagnostics>}
*/
static async diagnostics () {
const query = await diagnostics.runtime.query()
// @ts-ignore
return query.conduit
}
/**
* Returns the current Conduit server status
* @return {Promise<ConduitStatus>}
*/
static async status () {
const result = await ipc.request('internal.conduit.status')
if (result.err) {
throw result.err
}
return {
port: result.data.port || 0,
isActive: result.data.isActive || false
}
}
/**
* Waits for conduit to be active
* @param {{ maxQueriesForStatus?: number }=} [options]
* @return {Promise}
*/
static async waitForActiveState (options = null) {
const maxQueriesForStatus = options?.maxQueriesForStatus ?? Infinity
let queriesForStatus = 0
while (queriesForStatus++ < maxQueriesForStatus) {
const status = await Conduit.status()
if (status.isActive) {
break
}
await sleep(256)
}
}
/**
* @type {boolean}
*/
shouldReconnect = true
/**
* @type {boolean}
*/
isConnecting = false
/**
* @type {boolean}
*/
isActive = false
/**
* @type {WebSocket?}
*/
socket = null
/**
* @type {number}
*/
port = 0
/**
* @type {number?}
*/
id = null
/**
* @private
* @type {function(MessageEvent)}
*/
#onmessage = null
/**
* @private
* @type {function(CloseEvent)}
*/
#onclose = null
/**
* @private
* @type {function(ErrorEvent)}
*/
#onerror = null
/**
* @type {function(Event)}
*/
#onopen = null
/**
* Creates an instance of Conduit.
*
* @param {object} params - The parameters for the Conduit.
* @param {string} params.id - The ID for the connection.
* @param {string} params.method - The method to use for the connection.
*/
constructor ({ id }) {
super()
this.id = id
// @ts-ignore
this.port = this.constructor.port
this.connect()
pool.add(this)
gc.ref(this)
}
/**
* The URL string for the WebSocket server.
* @type {string}
*/
get url () {
return `ws://localhost:${this.port}/${this.id}/${client.top.id}`
}
/**
* @type {function(MessageEvent)}
*/
get onmessage () { return this.#onmessage }
set onmessage (onmessage) {
if (typeof this.#onmessage === 'function') {
this.removeEventListener('message', this.#onmessage)
}
this.#onmessage = null
if (typeof onmessage === 'function') {
this.#onmessage = onmessage
this.addEventListener('message', onmessage)
}
}
/**
* @type {function(ErrorEvent)}
*/
get onerror () { return this.#onerror }
set onerror (onerror) {
if (typeof this.#onerror === 'function') {
this.removeEventListener('error', this.#onerror)
}
this.#onerror = null
if (typeof onerror === 'function') {
this.#onerror = onerror
this.addEventListener('error', onerror)
}
}
/**
* @type {function(CloseEvent)}
*/
get onclose () { return this.#onclose }
set onclose (onclose) {
if (typeof this.#onclose === 'function') {
this.removeEventListener('close', this.#onclose)
}
this.#onclose = null
if (typeof onclose === 'function') {
this.#onclose = onclose
this.addEventListener('close', onclose)
}
}
/**
* @type {function(Event)}
*/
get onopen () { return this.#onopen }
set onopen (onopen) {
if (typeof this.#onopen === 'function') {
this.removeEventListener('open', this.#onopen)
}
this.#onopen = null
if (typeof onopen === 'function') {
this.#onopen = onopen
this.addEventListener('open', onopen)
}
}
/**
* Connects the underlying conduit `WebSocket`.
* @param {function(Error?)=} [callback]
* @return {Promise<Conduit>}
*/
async connect (callback = null) {
if (this.isConnecting) {
callback(new Error('Application is connecting'))
return this
}
if (isApplicationPaused) {
callback(new Error('Application is paused'))
return this
}
if (this.socket) {
this.socket.close()
}
// reset
this.socket = null
this.isActive = false
this.isConnecting = true
// @ts-ignore
const resolvers = Promise.withResolvers()
const result = await ipc.request('internal.conduit.start')
if (result.err) {
if (typeof callback === 'function') {
callback(result.err)
return this
} else {
throw result.err
}
}
await Conduit.waitForActiveState()
if (isApplicationPaused) {
callback(new Error('Application is paused'))
return this
}
this.port = result.data.port
this.socket = new WebSocket(this.url)
this.socket.binaryType = 'arraybuffer'
this.socket.onerror = (e) => {
this.socket = null
this.isActive = false
this.isConnecting = false
this.dispatchEvent(new ErrorEvent('error', e))
if (typeof callback === 'function') {
callback(e.error || new Error('Failed to connect Conduit'))
callback = null
}
if (!isApplicationPaused) {
resolvers.reject(e.error ?? new Error())
}
}
this.socket.onclose = (e) => {
this.socket = null
this.isConnecting = false
this.isActive = false
this.dispatchEvent(new CloseEvent('close', e))
if (this.shouldReconnect && !isApplicationPaused) {
this.reconnect()
}
}
this.socket.onopen = (e) => {
this.isActive = true
this.isConnecting = false
this.dispatchEvent(new Event('open', e))
if (typeof callback === 'function') {
callback(null)
callback = null
}
resolvers.resolve()
}
this.socket.onmessage = (e) => {
this.isActive = true
this.isConnecting = false
this.dispatchEvent(new MessageEvent('message', e))
}
await resolvers.promise
return this
}
/**
* Reconnects a `Conduit` socket.
* @param {{retries?: number, timeout?: number}} [options]
* @return {Promise<Conduit>}
*/
async reconnect (options = null) {
if (this.isConnecting) {
return this
}
const retries = options?.retries ?? DEFALUT_MAX_RECONNECT_RETRIES
const timeout = options?.timeout ?? DEFAULT_MAX_RECONNECT_TIMEOUT
return await new Promise((resolve, reject) => {
queueMicrotask(() => {
const promise = this.connect((err) => {
if (err) {
this.isActive = false
if (retries > 0) {
setTimeout(() => this.reconnect({
retries: retries - 1,
timeout
}), timeout)
}
}
})
return promise.then(resolve, reject)
})
})
}
/**
* Encodes a single header into a Uint8Array.
*
* @private
* @param {string} key - The header key.
* @param {string} value - The header value.
* @returns {Uint8Array} The encoded header.
*/
encodeOption (key, value) {
const keyLength = key.length
const keyBuffer = new TextEncoder().encode(key)
const valueBuffer = new TextEncoder().encode(value)
const valueLength = valueBuffer.length
const buffer = new ArrayBuffer(1 + keyLength + 2 + valueLength)
const view = new DataView(buffer)
view.setUint8(0, keyLength)
new Uint8Array(buffer, 1, keyLength).set(keyBuffer)
view.setUint16(1 + keyLength, valueLength, false)
new Uint8Array(buffer, 3 + keyLength, valueLength).set(valueBuffer)
return new Uint8Array(buffer)
}
/**
* Encodes options and payload into a single Uint8Array message.
*
* @private
* @param {object} options - The options to encode.
* @param {Uint8Array} payload - The payload to encode.
* @returns {Uint8Array} The encoded message.
*/
encodeMessage (options, payload) {
const headerBuffers = Object.entries(options)
.map(([key, value]) => this.encodeOption(key, value))
const totalOptionLength = headerBuffers.reduce((sum, buf) => sum + buf.length, 0)
const bodyLength = payload.length
const buffer = new ArrayBuffer(1 + totalOptionLength + 2 + bodyLength)
const view = new DataView(buffer)
view.setUint8(0, headerBuffers.length)
let offset = 1
headerBuffers.forEach(headerBuffer => {
new Uint8Array(buffer, offset, headerBuffer.length).set(headerBuffer)
offset += headerBuffer.length
})
view.setUint16(offset, bodyLength, false)
offset += 2
new Uint8Array(buffer, offset, bodyLength).set(payload)
return new Uint8Array(buffer)
}
/**
* Decodes a Uint8Array message into options and payload.
* @param {Uint8Array} data - The data to decode.
* @return {ReceiveMessage} The decoded message containing options and payload.
* @throws Will throw an error if the data is invalid.
*/
decodeMessage (data) {
const view = new DataView(data.buffer)
const numOpts = view.getUint8(0)
let offset = 1
const options = {}
for (let i = 0; i < numOpts; i++) {
const keyLength = view.getUint8(offset)
offset += 1
const key = new TextDecoder().decode(new Uint8Array(data.buffer, offset, keyLength))
offset += keyLength
const valueLength = view.getUint16(offset, false)
offset += 2
const valueBuffer = new Uint8Array(data.buffer, offset, valueLength)
offset += valueLength
const value = new TextDecoder().decode(valueBuffer)
options[key] = value
}
const bodyLength = view.getUint16(offset, false)
offset += 2
const payload = new Uint8Array(data.buffer, offset, bodyLength)
return { options, payload }
}
/**
* Registers a callback to handle incoming messages.
* The callback will receive an error object and an object containing
* decoded options and payload.
* @param {ReceiveCallback} callback - The callback function to handle incoming messages.
*/
receive (callback) {
this.addEventListener('error', (event) => {
// @ts-ignore
callback(event.error || new Error())
})
this.addEventListener('message', (event) => {
// @ts-ignore
const data = new Uint8Array(event.data)
callback(null, this.decodeMessage(data))
})
}
/**
* Sends a message with the specified options and payload over the
* WebSocket connection.
* @param {object} options - The options to send.
* @param {Uint8Array=} [payload] - The payload to send.
* @return {boolean}
*/
send (options, payload = null) {
if (isApplicationPaused || !this.isActive) {
return false
}
if (!payload) {
payload = new Uint8Array(0)
}
if (
this.socket !== null &&
this.socket instanceof WebSocket &&
this.socket.readyState === WebSocket.OPEN
) {
this.socket.send(this.encodeMessage(options, payload))
return true
}
return false
}
/**
* Closes the WebSocket connection, preventing reconnects.
*/
close () {
this.shouldReconnect = false
if (this.socket) {
this.socket.close()
this.socket = null
}
pool.delete(this)
}
/**
* Implements `gc.finalizer` for gc'd resource cleanup.
* @ignore
* @return {gc.Finalizer}
*/
[gc.finalizer] () {
return {
args: [this.socket],
handle (socket) {
if (socket?.readyState === WebSocket.OPEN) {
socket.close()
}
}
}
}
}