xo-bus
Version:
Cross Origin Event Bus
288 lines (250 loc) • 6.77 kB
text/typescript
let idSeq = 0
function nextId() {
return idSeq ++
}
// padding with 0
function p(n: number, size=2) {
return (n/Math.pow(10, size)).toFixed(10).substring(2, 2+size)
}
/**
* Simple UUID
*/
function UUID(contextId: number) {
let id = 'Cn-xxxx-MMddhhmmss'
id = id.replace('n', p(contextId, 4))
const now = new Date()
id = id.replace('MM', p(now.getMonth()))
id = id.replace('dd', p(now.getDate()))
id = id.replace('hh', p(now.getHours()))
id = id.replace('mm', p(now.getMinutes()))
id = id.replace('ss', p(now.getSeconds()))
// 4 random ch
const length = Math.pow(26, 4)
let randOffset = Math.floor(Math.random() * length)
let text = ''
while (randOffset > 0) {
const alpha = randOffset % 26
text += String.fromCharCode(65 + alpha)
randOffset = Math.floor(randOffset / 26)
}
id = id.replace('xxxx', text)
return id
}
class OutPort {
outPort
constructor(outPort, callback) {
this.outPort = outPort
this.outPort.onmessage = callback
}
send(msg) {
this.outPort.postMessage(msg)
}
}
class EventBusController {
outPorts: Map<any, OutPort>
observer
constructor() {
this.outPorts = new Map()
this.observer = new MutationObserver(this.onObservaedChange.bind(this))
this.observer.observe(document.body, {subtree: true, childList: true})
this.addChild(window)
}
onObservaedChange(change) {
for (const rec of change) {
if (rec.removedNodes) {
for (const n of rec.removedNodes) {
if (n.tagName === 'IFRAME') {
this.removeChild(n)
}
}
}
}
}
addChild(context) {
this.initChildContext(context)
}
removeChild(context) {
this.outPorts.delete(context)
}
private broadcast(msg) {
for (const out of this.outPorts.values()) {
out.send(msg)
}
}
private initChildContext(context) {
const channel = new MessageChannel()
if (context === window) {
context.postMessage({ type: 'init', id: nextId() }, '*', [channel.port2]);
} else {
context.contentWindow.postMessage({ type: 'init', id: nextId() }, '*', [channel.port2]);
}
const port = new OutPort(channel.port1, this.onControllerEvent.bind(this))
this.outPorts.set(context, port)
}
private onControllerEvent(e) {
const { data } = e
this.broadcast(data)
}
}
type ICallback = (any) => void
type IResponder = (...any) => Promise<any>
interface IEventBus {
send: (any) => void
request: (key: string, args: any) => Promise<any>
registerService: (key: string, IResponder) => void
unregisterService: (key: string) => void
subscribe: (evName: string, ICallback) => void
unsubscribe: (evName: string, ICallback) => void
}
interface IOption {
callback?: ICallback
name?: string
}
type IServiceFn = (any) => Promise<any>
type IRequestFn = (string, any) => Promise<any>
export class CrossOriginEventBus implements IEventBus {
private id: number // given by host after handshake
private callback?: (any) => void
private outPort: OutPort
private _controller?: EventBusController
private name: string
private subscriptions: Map<string, ICallback[]>
private tasks: Map<string, ICallback[]>
private services: Map<string, IServiceFn>
private pendingTasks: Function[]
private state: string
constructor({callback, name}: IOption = {} ) {
if (this.isParent) {
this._controller = new EventBusController()
}
this.callback = callback
this.name = this.name || location.host
this.subscriptions = new Map()
this.tasks = new Map()
this.services = new Map()
this.pendingTasks = []
this.state = 'uninitialized'
window.addEventListener('message', this.handshake)
}
private get isParent () : boolean {
return window.parent === window
}
private get isReady () : boolean {
return this.state === 'ready'
}
private get controller () {
if (this.isParent) {
return this._controller
} else {
throw "Controller only exists in the parent window"
}
}
handshake = (e) => {
const { type, id } = e.data
if (type === 'init') {
this.id = id
this.outPort = new OutPort(e.ports[0], this.onMessage.bind(this))
this.outPort.send({
type: 'ready',
id,
})
window.removeEventListener('message', this.handshake)
this.state = 'ready'
this.clearBuffers()
}
}
clearBuffers() {
while (this.pendingTasks.length) {
this.pendingTasks.pop()()
}
}
private onMessage(e) {
const { type } = e.data
switch (type) {
case 'response': {
const { uuid, response } = e.data
if (this.tasks.has(uuid)) {
this.tasks.get(uuid)[0](response)
this.tasks.delete(uuid)
}
break
}
case 'request': {
const { key, uuid, payload } = e.data
if (this.services.has(key)) {
const serviceFn = this.services.get(key)
const doRespondFail = this.doRespond.bind(this, uuid, false)
const res = serviceFn(payload)
if (!res || typeof res.then !== 'function') {
this.doRespond(uuid, true, res)
} else {
res.then(this.doRespond.bind(this, uuid, true), doRespondFail)
.catch(doRespondFail)
}
}
break
}
}
if (this.subscriptions.has(type)) {
this.subscriptions.get(type).forEach(cb => cb(e))
}
this.callback && this.callback(e)
}
private doRespond(uuid, success, response) {
this.send({
type: 'response',
uuid,
response,
success
})
}
send(msg) {
const req = () => this.outPort.send(msg)
if (this.isReady) {
req()
} else {
this.pendingTasks.push(req.bind(this))
}
}
request(key: string, args: any) {
const uuid = UUID(this.id)
const req = () => {
this.send({
type: 'request',
key,
uuid,
name: this.name,
payload: args
})
}
if (this.isReady) {
req()
} else {
this.pendingTasks.push(req.bind(this))
}
return new Promise((resolve, reject) => {
this.tasks.set(uuid, [resolve])
})
}
registerService(key, responder) {
this.services.set(key, responder)
}
unregisterService(key) {
this.services.delete(key)
}
subscribe(evName, cb) {
if (!this.subscriptions.has(evName)) {
this.subscriptions.set(evName, [])
}
this.subscriptions.get(evName).push(cb)
}
unsubscribe(evName, cb) {
if (!this.subscriptions.has(evName)) {
return false
}
const idx = this.subscriptions.get(evName).indexOf(cb)
if (idx >= 0) {
this.subscriptions.get(evName).splice(idx, 1)
}
}
}