UNPKG

agljs

Version:

AGL.js is a lightweight framework for integrating web apps with Epic's Active Guidelines platform, using modern JavaScript features. Manage action queuing, event handling, subscriptions, and state within Epic Hyperspace.

272 lines (235 loc) 9.97 kB
/*! * agljs * Author: Joshua Faulkenberry * License: Kopimi * Copyright 2025 */ export default class AGL { static #instance: AGL | null = null #active: boolean | null = null // null = initializing, true = active, false = inactive #debug: boolean = false #timeout: number = 2000 #handshake: Promise<boolean> | null = null #queue: (() => Promise<void>)[] = [] #processing: boolean = false #details: AGLDetails = { availableActions: [], interfaceVersion: null, readOnly: false, token: null } #callbacks: Partial<Record<keyof AGLEvents, (...args: any) => void>> = {} #subscriptions: AGLConfig['subscribe'] = {} #defaultPrefix = 'Epic.Clinical.Informatics.Web.' #errorCodes: Record<number, string> = { 5: 'Action in progress. This means that two messages were posted back-to-back without waiting for a response from the first.', 7: 'An action was posted which requires a token, but no token was provided.', 9: 'An action was attempted which does not exist.', 15: 'An action is not allowed during closing.', 16: 'An invalid SubscriptionRequest was sent.', 18: 'A browser launch was attempted for a URL that is not allowlisted.' } constructor(config: AGLConfig = {}) { const { debug = this.#debug, timeout = this.#timeout, subscribe = {}, ...callbacks } = config let initial = true if (!AGL.#instance) AGL.#instance = this else { console.warn('[AGL] Reconfiguring existing instance. Previous settings may be overwritten.'); initial = false } AGL.#instance.#debug = debug AGL.#instance.#timeout = timeout; (Object.keys(callbacks) as Array<keyof AGLConfig>) .forEach((key) => { if (key.startsWith('on')) { const eventName = key.replace(/^on/, '').toLowerCase() as keyof AGLEvents AGL.#instance!.#callbacks[eventName] = (callbacks as any)[key] as AGLEvents[typeof eventName] } }) if (initial) { this.#subscriptions = subscribe if (window !== window.parent) { const args: Record<string, any> = {} if (Object.keys(this.#subscriptions).length) { args['SubscriptionRequests'] = [] for (const [key, value] of Object.entries(this.#subscriptions)) args['SubscriptionRequests'].push({ EventName: key, EventArgs: value }) } this.#handshake = this.#_do('InitiateHandshake') .then(() => { this.#active = true this.#log('Handshake complete, AGL is initialized') console.log('[AGL] Initialized...') return true }) .catch(err => { this.#active = false console.error('[AGL] Handshake failed:', err.message) return false }) } else { this.#active = false console.log('[AGL] Not in Epic...') this.#log('AGL is not initialized') } } else return AGL.#instance } public get active(): Promise<boolean> | boolean { return this.#active !== null ? this.#active : this.#handshake as Promise<boolean> } public get details() { return this.#details } public set debug(value: boolean) { this.#debug = value } public on<EventName extends keyof AGLEvents>( eventName: EventName, callback: AGLEvents[EventName] ): this { this.#callbacks[eventName] = callback return this } public async do( action: string, args: Record<string, unknown> | null = null, haltOnError: boolean = false ): Promise<boolean> { return this.#active !== true ? Promise.resolve(false) : this.#_enqueue(() => this.#_do(action, args), haltOnError) } #_do(action: string, args: Record<string, unknown> | null = null): Promise<boolean> { const prefix = action.indexOf('.') === -1 ? this.#defaultPrefix : '' if (action !== 'InitiateHandshake' && !this.#details.availableActions.includes(prefix + action)) { const error = new Error(`Invalid action: ${action}`) if (this.#callbacks.error) { this.#callbacks.error({ message: error.message, details: [] }) return Promise.resolve(false) } return Promise.reject(error) } return new Promise((resolve, reject) => { const msg: Record<string, any> = { token: this.#details.token, action: prefix + action } if (args) msg.args = args const listener = (event: MessageEvent) => { const { success, error } = this.#processor(event) window.removeEventListener('message', listener) if (success) resolve(true) else if (this.#callbacks.error) { this.#callbacks.error(error || { message: 'Unknown error', details: [] }) resolve(false) } else reject(new Error(error?.message || 'Unknown error occurred')) } window.addEventListener('message', listener) this.#log('Sending message:', msg) window.parent.postMessage(msg, '*') setTimeout(() => { window.removeEventListener('message', listener) const timeoutError = new Error('Timeout waiting for response') if (this.#callbacks.error) { this.#callbacks.error({ message: 'Timeout waiting for response', details: [`Action: ${msg.action}`, `Timeout: ${this.#timeout}ms`] }) resolve(false) } else reject(timeoutError) }, this.#timeout) }) } #processor(event: MessageEvent): { success: boolean, error?: Parameters<AGLEvents['error']>[0] } { this.#log('Received message:', event.data) if (!event.data || typeof event.data !== 'object' || !('token' in event.data || 'actions' in event.data)) { this.#log('Invalid message received:', event.data) return { success: false, error: { message: 'Invalid message format', details: [] } } } let success = false, error for (const type in event.data) { const res = this.#handle(type)(event.data[type], event.data) if (type === 'actionExecuted') success = res as boolean else if (type === 'error') error = res as Parameters<AGLEvents['error']>[0] } if (error) { this.#log('Error received:', error) this.#callbacks.error?.(error) } return { success, error } } #handle(type: string): ((p: unknown, d: Record<string, any>) => void | boolean | Parameters<AGLEvents['error']>[0]) { const handlers: Record<string, (p: unknown, d: Record<string, any>) => void | boolean | Parameters<AGLEvents['error']>[0]> = { actionExecuted: (p, d) => 'token' in d ? true : (p as boolean), actions: (p, d) => { this.#details.availableActions.push(...(p as string[])) }, error: (p, d) => { const error: Parameters<AGLEvents['error']>[0] = { message: p as string, details: [] } if (d.errorCodes && d.errorCodes.length) error.details = this.#callbacks.error ? d.errorCodes : (d.errorCodes as number[]).map(code => this.#errorCodes[code] ?? `Unknown error code: ${code}`) return error }, EventName: (p, d) => { this.#log('AGL event received:', p) this.#callbacks.aglEvent?.({ name: p as string, args: d.EventArgs ?? null }) }, history: (p, d) => { this.#log('History navigation event:', p) this.#callbacks.navigate?.({ direction: p as 'Back' | 'Forward' }) }, historyPackage: (p, d) => { this.#log('Received historyPackage:', p) const { state, fromHibernation } = p as { state: string, fromHibernation: boolean } this.#callbacks.reload?.({ state, fromHibernation }) }, isContextReadOnly: (p, d) => { this.#details.readOnly = p as boolean }, subscriptionResults: (p, d) => { this.#log('Subscription results:', p) this.#callbacks.subscribed?.(p as unknown) }, token: (p, d) => { this.#details.token = p as string }, version: (p, d) => { this.#details.interfaceVersion = p as string }, } return handlers[type] ?? ((p, d) => { this.#details[type] = p }) } #_enqueue(action: () => Promise<boolean>, haltOnError: boolean = false): Promise<boolean> { const processQueue = () => { if (this.#processing || this.#queue.length === 0) return this.#processing = true this.#queue.shift()!().finally(() => { this.#processing = false processQueue() }) } return new Promise((resolve, reject) => { this.#queue.push(async () => { try { const result = await action() resolve(result) } catch (error) { reject(error) if (haltOnError) { console.warn('[AGL] Queue stopped due to error:', error) this.#queue = [] } } finally { processQueue() } }) !this.#processing && processQueue() }) } #log(...args: any[]) { this.#debug && console.debug('[AGL]', ...args) } }