@quasar/app-vite
Version:
Quasar Framework App CLI with Vite
747 lines (642 loc) • 19.1 kB
JavaScript
const portNameRE = /^background$|^app$|^content@/
const { runtime } = process.env.TARGET === 'firefox' ? browser : chrome
/**
* @param {number} max
* @returns {number}
*/
function getRandomId (max) {
return Math.floor(Math.random() * max)
}
/**
* @typedef Message
* @property {string} from
* @property {string} to
* @property {string} event
* @property {any} payload
*/
export class BexBridge {
// Public properties
/** @type {string} */
portName = null
/** @type {boolean} */
isConnected = false
/** @type {{ type: 'on' | 'once', callback: (message: Message) => void }[]} */
listeners = {}
/** @type {{ [portName: string]: chrome.runtime.Port }} */
portMap = {}
/** @type {string[]} */
portList = []
/** @type {{ [id: string]: { portName: string, resolve: (payload: any) => void, reject: (err: any) => void } }} */
messageMap = {}
/** @type {{ [id: string]: { portName: string, number: number, messageType: string, messageProps: any, payload: any[] } }} */
chunkMap = {}
// Private properties
/** @type {'background' | 'content' | 'app'} */
#type
/** @type {boolean} */
#debug = false
/** @type {string} */
#banner
/**
* @param {{ type: 'background' | 'content' | 'app', name?: string, debug?: boolean }} options
*/
constructor ({ type, name = '', debug }) {
this.portName = type
this.#type = type
if (type === 'content') {
/**
* There can be multiple instances of the same content script
* but for different tabs, so we need to differentiate them.
*
* Generating an easy to handle id for the content script.
*/
this.portName = `${ type }@${ name }-${ getRandomId(10_000) }`
}
this.#banner = `[QBex|${ this.portName }]`
this.#debug = debug === true
if (type !== 'background') {
this.on('@quasar:ports', ({ payload }) => {
this.portList = payload.portList
if (payload.removed !== void 0) {
this.#cleanupPort(payload.removed)
}
})
return
}
/**
* Else we're the background script
*/
this.isConnected = true
const onPacket = this.#onPacket.bind(this)
runtime.onConnect.addListener(port => {
// if it's not a bridge port on the other end,
// then ignore it
if (portNameRE.test(port.name) === false) return
if (this.portMap[ port.name ] !== void 0) {
this.warn(
`Connection with "${ port.name }" already exists.`
+ ' Disconnecting the previous one and connecting the new one.'
)
this.portMap[ port.name ].disconnect()
this.#cleanupPort(port.name)
}
this.portMap[ port.name ] = port
port.onMessage.addListener(onPacket)
port.onDisconnect.addListener(() => {
port.onMessage.removeListener(onPacket)
this.#cleanupPort(port.name)
this.log(`Closed connection with ${ port.name }.`)
this.#onPortChange({ removed: port.name })
})
this.log(`Opened connection with ${ port.name }.`)
this.#onPortChange({ added: port.name })
})
}
/**
* @returns {Promise<void>}
*/
connectToBackground () {
if (this.#type === 'background') {
return Promise.reject('The background script itself does not need to connect')
}
if (this.isConnected === true) {
return Promise.reject('The bridge is already connected')
}
const portToBackground = runtime.connect({ name: this.portName })
return new Promise((resolve, reject) => {
const onPacket = packet => {
if (this.isConnected === false) {
/**
* We rely on the fact that upon connection is established
* the background script will send a @quasar:ports event
*/
this.isConnected = true
this.log('Connected to the background script.')
this.portMap = { background: portToBackground }
resolve()
}
this.#onPacket(packet)
}
const onDisconnect = () => {
if (runtime.lastError?.message?.indexOf('Could not establish connection') !== -1) {
this.isConnected = false
portToBackground.onMessage.removeListener(onPacket)
portToBackground.onMessage.removeListener(onDisconnect)
reject('Could not connect to the background script.')
return
}
this.isConnected = false
for (const id in this.messageMap) {
const item = this.messageMap[ id ]
item.reject('Connection was closed')
}
this.portMap = {}
this.portList = []
this.messageMap = {}
this.chunkMap = {}
this.log('Closed connection with the background script.')
}
portToBackground.onMessage.addListener(onPacket)
portToBackground.onDisconnect.addListener(onDisconnect)
})
}
/**
* @returns {Promise<void>}
*/
disconnectFromBackground () {
if (this.#type === 'background') {
return Promise.reject('Background script does not need to disconnect')
}
if (this.isConnected === false) {
return Promise.reject('Tried to disconnect from the background script but the port was not connected')
}
this.portMap.background.disconnect()
delete this.portMap.background
this.isConnected = false
return Promise.resolve()
}
/**
* @param {string} event
* @param {(message: Message) => void} callback
*/
on (event, callback) {
if (!event) {
this.warn('Tried add listener but no event specified.')
return
}
if (typeof callback !== 'function') {
this.warn('Tried add listener but no valid callback function specified.')
return
}
const target = this.listeners[ event ] || (this.listeners[ event ] = [])
target.push({ type: 'on', callback })
this.log(`Added a listener for event: "${ event }".`)
}
/**
* @param {string} event
* @param {(message: Message) => void} callback
*/
once (event, callback) {
if (!event) {
this.warn('Tried add listener but no event specified.')
return
}
if (typeof callback !== 'function') {
this.warn('Tried add listener but no valid callback function specified.')
return
}
const target = this.listeners[ event ] || (this.listeners[ event ] = [])
target.push({ type: 'once', callback })
this.log(`Added a one-time listener for event: "${ event }".`)
}
/**
* @param {string} event
* @param {(message: Message) => void} callback
*/
off (event, callback) {
if (!event) {
this.warn('Tried to remove listeners but no event specified.')
return
}
const list = this.listeners[ event ]
if (list === void 0) {
this.warn(`Tried to remove listener for "${ event }" event but there is no such listener attached.`)
return
}
if (callback === void 0) {
if (event.startsWith('@quasar:')) {
// ensure we don't remove internal listeners
this.listeners[ event ] = [ list[ 0 ] ]
}
else {
delete this.listeners[ event ]
}
this.log(`Stopped listening for "${ event }".`)
return
}
if (typeof callback !== 'function') {
this.warn('Tried to remove listener but the callback specified is not a function.')
return
}
const liveEvents = list.filter(entry => entry.callback !== callback)
if (liveEvents.length !== 0) {
this.listeners[ event ] = liveEvents
this.log(`Removed a listener for: "${ event }".`)
}
else {
delete this.listeners[ event ]
this.log(`Stopped listening for: "${ event }".`)
}
}
/**
* @param {{ event: string, to: string, payload: any } | undefined} param
* @returns {Promise<any>} response payload
*/
async send ({ event, to, payload } = {}) {
if (this.isConnected === false) {
throw new Error('Tried to send message but the bridge is not connected. Please connect it first.')
}
if (!event) {
throw new Error('Tried to send message with no "event" prop specified')
}
if (!to) {
throw new Error('Tried to send message with no "to" prop specified')
}
if (this.portList.includes(to) === false) {
throw new Error(
this.#type === 'background'
? `Tried to send message to "${ to }" but there is no such port registered`
: `Tried to send message to "${ to }" but the port to background is not available to send through`
)
}
const id = getRandomId(1_000_000)
await this.#sendMessage({
id,
to,
payload,
messageType: 'event-send',
messageProps: { event }
})
if (this.portList.includes(to) === false) {
throw new Error(`Connection to "${ to }" was closed while waiting for a response`)
}
return new Promise((resolve, reject) => {
this.messageMap[ id ] = {
portName: to,
resolve: responsePayload => {
delete this.messageMap[ id ]
resolve(responsePayload)
},
reject: err => {
delete this.messageMap[ id ]
reject(err)
}
}
})
}
/**
* @param {boolean} value
*/
setDebug (value) {
this.#debug = value === true
}
log (...args) {
if (this.#debug !== true || args.length === 0) return
const lastArg = args[ args.length - 1 ]
if (lastArg !== void 0 && Object(lastArg) === lastArg) {
const log = `${ this.#banner } ${ args.slice(0, -1).join(' ') } (click to expand)`
console.groupCollapsed(log)
console.dir(lastArg)
console.groupEnd(log)
}
else {
console.log(this.#banner, ...args)
}
}
warn (...args) {
if (args.length === 0) return
const lastArg = args[ args.length - 1 ]
if (lastArg !== void 0 && Object(lastArg) === lastArg) {
console.warn(this.#banner, ...args.slice(0, -1))
const group = 'The above warning details (click to expand)'
console.groupCollapsed(group)
console.dir(lastArg)
console.groupEnd(group)
}
else {
console.warn(this.#banner, ...args)
}
}
/**
* Should be used only by the background script
* @param {{ added?: string } | { removed?: string }} reason
*/
#onPortChange (reason) {
this.portList = Object.keys(this.portMap)
const list = [ 'background', ...this.portList ]
for (const portName of this.portList) {
this.send({
event: '@quasar:ports',
to: portName,
payload: {
portList: list.filter(name => name !== portName),
...reason
}
}).catch(err => {
this.warn(
`Failed to inform "${ portName }" about the port list.`,
err
)
})
}
}
/**
* @param {Message} message
*/
async #triggerMessageEvent (message) {
const list = this.listeners[ message.event ]
if (list === void 0) return
const plural = list.length > 1 ? 's' : ''
this.log(
`Triggering ${ list.length } listener${ plural } for event: "${ message.event }".`,
{ message, listeners: list }
)
let responsePayload
for (const { type, callback } of list.slice(0)) {
if (type === 'once') {
this.off(message.event, callback)
}
try {
if (responsePayload === void 0) {
const value = callback(message)
responsePayload = value instanceof Promise
? await value
: value
}
else {
callback(message)
}
}
catch (err) {
this.warn(
`Error while triggering listener${ plural } for event: "${ message.event }".`,
{ error: err, message, listener: { type, callback } }
)
return Promise.reject(err)
}
}
return responsePayload
}
/**
* @param {string} portName
*/
#cleanupPort (portName) {
for (const id in this.chunkMap) {
const packet = this.chunkMap[ id ]
if (packet.portName === portName) {
delete this.chunkMap[ id ]
}
}
for (const id in this.messageMap) {
const packet = this.messageMap[ id ]
if (packet.portName === portName) {
packet.reject('Connection was closed')
}
}
delete this.portMap[ portName ]
}
#onPacket (packet) {
/**
* if it's not a packet sent by this bridge
* then ignore it
*/
if (
Object(packet) !== packet
|| packet.id === void 0
|| packet.from === void 0
|| packet.to === void 0
|| packet.type === void 0
) {
this.log(
'Received a message that does not appear to be emitted by a Quasar bridge or is malformed.',
packet
)
return
}
this.log(
`Received message of type "${ packet.type }" from "${ packet.from }".`,
packet
)
/**
* if the packet is not addressed to this bridge
* then forward it to the target
*/
if (packet.to !== this.portName) {
this.#sendPacket(packet).catch(err => {
this.warn(
`Failed to forward message of type "${ packet.type }" from "${ packet.from }" to "${ packet.to }".`,
err
)
this.#sendMessage({
id: packet.id,
to: packet.from,
messageType: 'event-response',
messageProps: {
error: {
message: err.message,
stack: err.stack || 'no stack available'
},
quiet: true
}
})
})
return
}
if (packet.type === 'full') {
this.#onMessage({
id: packet.id,
from: packet.from,
to: packet.to,
payload: packet.payload,
type: packet.messageType,
props: packet.messageProps
})
return
}
if (packet.type === 'chunk') {
const chunk = this.chunkMap[ packet.id ]
if (chunk === void 0) {
if (packet.chunkIndex !== void 0) {
this.warn(
'Received an unregistered chunk.',
packet
)
return
}
this.chunkMap[ packet.id ] = {
portName: packet.from,
number: packet.chunksNumber,
messageType: packet.messageType,
messageProps: packet.messageProps,
payload: []
}
return
}
// if we received an unexpected chunk
if (packet.chunkIndex !== chunk.payload.length) {
this.warn(
'Received an out of order chunk.',
packet
)
// free up resources
delete this.chunkMap[ packet.id ]
return
}
chunk.payload.push(packet.payload)
// if we received all chunks...
if (packet.chunkIndex === chunk.number - 1) {
delete this.chunkMap[ packet.id ]
this.#onMessage({
id: packet.id,
from: packet.from,
to: packet.to,
payload: chunk.payload,
type: chunk.messageType,
props: chunk.messageProps
})
}
return
}
if (packet.type === 'chunk-abort') {
delete this.chunkMap[ packet.id ]
return
}
this.warn(
`Received an unknown message type: "${ packet.type }".`
)
}
#sendPacket (packet) {
this.log(
packet.from === this.portName
? `Sending message of type "${ packet.type }" to "${ packet.to }".`
: `Forwarding message of type "${ packet.type }" from "${ packet.from }" to "${ packet.to }".`
,
packet
)
const port = this.#type === 'background'
? this.portMap[ packet.to ]
: this.portMap.background
if (this.portList.includes(packet.to) === false) {
return Promise.reject(
`Tried to send message of type "${ packet.type }" to "${ packet.to }" but there is no such port registered`
)
}
if (port === void 0) {
return Promise.reject(
this.#type === 'background'
? `Tried to send message of type "${ packet.type }" to "${ packet.to }" but the port is not available`
: `Tried to send message of type "${ packet.type }" to "${ packet.to }" but the port to background is not available to forward through`
)
}
try {
port.postMessage(packet)
}
catch (err) {
this.warn(
`Failed to send message to "${ packet.to }".`,
err
)
return Promise.reject(err)
}
return Promise.resolve()
}
/**
* @param {{ id?: number, to: string, payload: any, messageType: "event-send" | "event-response", messageProps: any }} param
*/
#sendMessage ({
id = getRandomId(1_000_000),
to,
payload,
messageType,
messageProps
}) {
if (Array.isArray(payload) === false) {
return this.#sendPacket({
id,
from: this.portName,
to,
type: 'full',
payload,
messageType,
messageProps
})
}
let promise = this.#sendPacket({
id,
from: this.portName,
to,
type: 'chunk',
chunksNumber: payload.length,
messageType,
messageProps
})
for (let i = 0; i < payload.length; i++) {
promise = promise.then(() => this.#sendPacket({
id,
from: this.portName,
to,
type: 'chunk',
payload: payload[ i ],
chunkIndex: i
}))
}
return promise.catch(err => {
this.#sendPacket({
id,
from: this.portName,
to,
type: 'chunk-abort'
}).catch(err => {
this.warn(
`Failed to send a chunk-abort message to "${ to }".`,
err
)
})
return Promise.reject(err)
})
}
#onMessage (message) {
if (message.type === 'event-response') {
const target = this.messageMap[ message.id ]
if (target === void 0) {
if (message.props.quiet !== true) {
this.warn(
`Received a response for an unknown message id: "${ message.id }".`,
message
)
}
return
}
if (message.props.error !== void 0) {
target.reject(message.props.error)
}
else {
target.resolve(message.payload)
}
return
}
if (message.type === 'event-send') {
this.#triggerMessageEvent({
from: message.from,
to: message.to,
event: message.props.event,
payload: message.payload
}).then(returnPayload => {
this.#sendMessage({
id: message.id,
to: message.from,
payload: returnPayload,
messageType: 'event-response',
messageProps: {}
})
}).catch(err => {
this.#sendMessage({
id: message.id,
to: message.from,
messageType: 'event-response',
messageProps: {
error: {
message: err.message,
stack: err.stack || 'no stack available'
}
}
})
})
return
}
this.warn(
`Received a message with unknown type: "${ message.type }".`,
message
)
}
}