@chris.troutner/ipfs-message-port-client
Version:
IPFS client library for accessing IPFS node over message port
217 lines (194 loc) • 6.03 kB
JavaScript
// @ts-nocheck
'use strict'
const { decodeError } = require('@chris.troutner/ipfs-message-port-protocol/src/error')
const { DisconnectError, TimeoutError, AbortError } = require('./error')
/**
* RPC Transport over `MessagePort` that can execute queries. It takes care of
* executing queries by issuing a message with unique ID and fullfilling a
* query when corresponding response message is received. It also makes sure
* that aborted / timed out queries are cancelled as needed.
*
* It is expected that there will be at most one transport for a message port
* instance.
*
*/
module.exports = class MessageTransport {
/**
* Create transport for the underlying message port.
*
* @param {MessagePort} [port]
*/
constructor (port) {
this.port = null
// Assigining a random enough identifier to the transport, to ensure that
// query.id will be unique when multiple tabs are communicating with a
// a server in the SharedWorker.
this.id = Math.random()
.toString(32)
.slice(2)
// Local unique id on the transport which is incremented for each query.
this.nextID = 0
// Dictionary of pending requests
/** @type {Record<string, Query<any, any>>} */
this.queries = Object.create(null)
// If port is provided connect this transport to it. If not transport can
// queue queries and execute those once it's connected.
if (port) {
this.connect(port)
}
}
/**
* Executes given query with this transport and returns promise for it's
* result. Promise fails with an error if query fails.
*
* @template I, O
* @param {Query<I, O>} query
* @returns {Promise<O>}
*/
execute (query) {
const id = `${this.id}@${this.nextID++}`
this.queries[id] = query
// If query has a timeout set a timer.
if (query.timeout > 0 && query.timeout < Infinity) {
query.timerID = setTimeout(MessageTransport.timeout, query.timeout, this, id)
}
if (query.signal) {
query.signal.addEventListener('abort', () => this.abort(id), {
once: true
})
}
// If transport is connected (it has port) post a query, otherwise it
// will remain in the pending queries queue.
if (this.port) {
MessageTransport.postQuery(this.port, id, query)
}
return query.result
}
/**
* Connects this transport to the given message port. Throws `Error` if
* transport is already connected. All the pending queries will be executed
* as connection occurs.
*
* @param {MessagePort} port
*/
connect (port) {
if (this.port) {
throw new Error('Transport is already open')
} else {
this.port = port
this.port.addEventListener('message', this)
this.port.start()
// Go ever pending queries (that were submitted before transport was
// connected) and post them. This loop is safe because messages will not
// arrive while this loop is running so no mutation can occur.
for (const [id, query] of Object.entries(this.queries)) {
MessageTransport.postQuery(port, id, query)
}
}
}
/**
* Disconnects this transport. This will cause all the pending queries
* to be aborted and undelying message port to be closed.
*
* Once disconnected transport can not be reconnected back.
*/
disconnect () {
const error = new DisconnectError()
for (const [id, query] of Object.entries(this.queries)) {
query.fail(error)
this.abort(id)
}
// Note that reference to port is kept that ensures that attempt to
// reconnect will throw an error.
if (this.port) {
this.port.removeEventListener('message', this)
this.port.close()
}
}
/**
* Invoked on query timeout. If query is still pending it will fail and
* abort message will be send to a the server.
*
* @param {MessageTransport} self
* @param {string} id
*/
static timeout (self, id) {
const { queries } = self
const query = queries[id]
if (query) {
delete queries[id]
query.fail(new TimeoutError('request timed out'))
if (self.port) {
self.port.postMessage({ type: 'abort', id })
}
}
}
/**
* Aborts this query by failing with `AbortError` and sending an abort message
* to the server. If query is no longer pending this has no effect.
*
* @param {string} id
*/
abort (id) {
const { queries } = this
const query = queries[id]
if (query) {
delete queries[id]
query.fail(new AbortError())
if (this.port) {
this.port.postMessage({ type: 'abort', id })
}
if (query.timerID != null) {
clearTimeout(query.timerID)
}
}
}
/**
* Sends a given `query` with a given `id` over the message channel.
*
* @param {MessagePort} port
* @param {string} id
* @param {Query<any, any>} query
*/
static postQuery (port, id, query) {
port.postMessage(
{
type: 'query',
namespace: query.namespace,
method: query.method,
id,
input: query.toJSON()
},
// @ts-ignore - TS seems to want second arg to postMessage to not be undefined
[...new Set(query.transfer() || [])]
)
}
/**
* Handler is invoked when message on the message port is received.
*
* @param {MessageEvent} event
*/
handleEvent (event) {
const { id, result } = event.data
const query = this.queries[id]
// If query with a the given ID is found it is completed with the result,
// otherwise it is cancelled.
// Note: query may not be found when it was aborted on the client and at the
// same time server posted response.
if (query) {
delete this.queries[id]
if (result.ok) {
query.succeed(result.value)
} else {
query.fail(decodeError(result.error))
}
if (query.timerID != null) {
clearTimeout(query.timerID)
}
}
}
}
/**
* @template I,O
* @typedef {import('./query')<I, O>} Query
*/