@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.
774 lines (690 loc) • 19.3 kB
JavaScript
/* global ApplicationURLEvent */
/**
* @module hooks
*
* An interface for registering callbacks for various hooks in
* the runtime.
*
* Example usage:
* ```js
* import hooks from 'socket:hooks'
*
* hooks.onLoad(() => {
* // called when the runtime context has loaded,
* // but not ready or initialized
* })
*
* hooks.onReady(() => {
* // called when the runtime is ready, but not initialized
* })
*
* hooks.onInit(() => {
* // called when the runtime is initialized
* })
*
* hooks.onError((event) => {
* // called when 'error', 'messageerror', and 'unhandledrejection' error
* // events are dispatched on the global object
* })
*
* hooks.onData((event) => {
* // called when 'data' events are dispatched on the global object
* })
*
* hooks.onMessage((event) => {
* // called when 'message' events are dispatched on the global object
* })
*
* hooks.onOnline((event) => {
* // called when 'online' events are dispatched on the global object
* })
*
* hooks.onOffline((event) => {
* // called when 'offline' events are dispatched on the global object
* })
*
* hooks.onLanguageChange((event) => {
* // called when 'languagechange' events are dispatched on the global object
* })
*
* hooks.onPermissionChange((event) => {
* // called when 'permissionchange' events are dispatched on the global object
* })
*
* hooks.onNotificationResponse((event) => {
* // called when 'notificationresponse' events are dispatched on the global object
* })
*
* hooks.onNotificationPresented((event) => {
* // called when 'notificationpresented' events are dispatched on the global object
* })
*
* hooks.onApplicationURL((event) => {
* // called when 'applicationurl' events are dispatched on the global object
* })
*
* hooks.onApplicationResume((event) => {
* // called when 'applicationresume' events are dispatched on the global object
* })
*
* hooks.onApplicationPause((event) => {
* // called when 'applicationpause' events are dispatched on the global object
* })
* ```
*/
import { Event, CustomEvent, ErrorEvent, MessageEvent } from './events.js'
import { toProperCase } from './util.js'
import location from './location.js'
/**
* @typedef {{ signal?: AbortSignal }} WaitOptions
*/
function addEventListener (target, type, callback, ...args) {
target.addEventListener(type, callback, ...args)
}
function addEventListenerOnce (target, type, callback) {
target.addEventListener(type, callback, { once: true })
}
async function waitForEvent (target, type, timeout = -1) {
return await new Promise((resolve) => {
if (timeout > -1) {
setTimeout(resolve, timeout)
}
addEventListenerOnce(target, type, resolve)
})
}
function dispatchEvent (target, event) {
queueMicrotask(() => target.dispatchEvent(event))
}
function dispatchInitEvent (target) {
dispatchEvent(target, new InitEvent())
}
function dispatchLoadEvent (target) {
dispatchEvent(target, new LoadEvent())
}
function dispatchReadyEvent (target) {
dispatchEvent(target, new ReadyEvent())
}
function proxyGlobalEvents (global, target) {
for (const type of GLOBAL_EVENTS) {
const globalObject = GLOBAL_TOP_LEVEL_EVENTS.includes(type)
? global.top ?? global
: global
addEventListener(globalObject, type, (event) => {
const { type, data, detail = null, error } = event
const { origin } = location
if (type === 'applicationurl') {
dispatchEvent(target, new ApplicationURLEvent(type, {
origin,
data: event.data,
url: event.url.toString()
}))
} else if (type === 'error' || error) {
const { message, filename = import.meta.url || globalThis.location.href } = error || {}
dispatchEvent(target, new ErrorEvent(type, {
message,
filename,
error,
detail,
origin
}))
} else if (data || type === 'message') {
dispatchEvent(target, new MessageEvent(type, event))
} else if (detail) {
dispatchEvent(target, new CustomEvent(type, event))
} else {
dispatchEvent(target, new Event(type, event))
}
})
}
}
// state
let isGlobalLoaded = false
export const RUNTIME_INIT_EVENT_NAME = '__runtime_init__'
export const GLOBAL_EVENTS = [
RUNTIME_INIT_EVENT_NAME,
'applicationurl',
'applicationpause',
'applicationresume',
'data',
'error',
'init',
'languagechange',
'load',
'message',
'messageerror',
'notificationpresented',
'notificationresponse',
'offline',
'online',
'permissionchange',
'unhandledrejection'
]
const GLOBAL_TOP_LEVEL_EVENTS = [
'applicationurl',
'applicationpause',
'applicationresume',
'data',
'languagechange',
'notificationpresented',
'notificationresponse',
'offline',
'online',
'permissionchange',
'unhandledrejection'
]
/**
* An event dispatched when the runtime has been initialized.
*/
export class InitEvent extends Event {
constructor () { super('init') }
}
/**
* An event dispatched when the runtime global has been loaded.
*/
export class LoadEvent extends Event {
constructor () { super('load') }
}
/**
* An event dispatched when the runtime is considered ready.
*/
export class ReadyEvent extends Event {
constructor () { super('ready') }
}
/**
* An event dispatched when the runtime has been initialized.
*/
export class RuntimeInitEvent extends Event {
constructor () { super(RUNTIME_INIT_EVENT_NAME) }
}
/**
* An interface for registering callbacks for various hooks in
* the runtime.
*/
// eslint-disable-next-line new-parens
export class Hooks extends EventTarget {
/**
* @ignore
*/
static GLOBAL_EVENTS = GLOBAL_EVENTS
/**
* @ignore
*/
static InitEvent = InitEvent
/**
* @ignore
*/
static LoadEvent = LoadEvent
/**
* @ignore
*/
static ReadyEvent = ReadyEvent
/**
* @ignore
*/
static RuntimeInitEvent = RuntimeInitEvent
/**
* `Hooks` class constructor
* @ignore
*/
constructor () {
super()
this.#init()
}
/**
* An array of all global events listened to in various hooks
*/
get globalEvents () {
return GLOBAL_EVENTS
}
/**
* Reference to global object
* @type {object}
*/
get global () {
return globalThis
}
/**
* Returns `document` in global.
* @type {Document}
*/
get document () {
return this.global.document ?? null
}
/**
* Returns `document` in global.
* @type {Window}
*/
get window () {
return this.global.window ?? null
}
/**
* Predicate for determining if the global document is ready.
* @type {boolean}
*/
get isDocumentReady () {
return this.document?.readyState === 'complete'
}
/**
* Predicate for determining if the global object is ready.
* @type {boolean}
*/
get isGlobalReady () {
return isGlobalLoaded
}
/**
* Predicate for determining if the runtime is ready.
* @type {boolean}
*/
get isRuntimeReady () {
return Boolean(globalThis.__RUNTIME_INIT_NOW__)
}
/**
* Predicate for determining if everything is ready.
* @type {boolean}
*/
get isReady () {
return this.isRuntimeReady && (this.isWorkerContext || this.isDocumentReady)
}
/**
* Predicate for determining if the runtime is working online.
* @type {boolean}
*/
get isOnline () {
return this.global.navigator?.onLine || false
}
/**
* Predicate for determining if the runtime is in a Worker context.
* @type {boolean}
*/
get isWorkerContext () {
return Boolean(!this.document && !this.global.window && this.global.self)
}
/**
* Predicate for determining if the runtime is in a Window context.
* @type {boolean}
*/
get isWindowContext () {
return Boolean(this.document && this.global.window)
}
/**
* Internal hooks initialization.
* Event order:
* 1. 'DOMContentLoaded'
* 2. 'load'
* 3. 'init'
* @ignore
* @private
*/
async #init () {
const { isWorkerContext, document, global } = this
const readyState = document?.readyState
proxyGlobalEvents(global, this)
// if runtime is initialized, then 'DOMContentLoaded' (document),
// 'load' (window), and the 'init' (window) events have all been dispatched
// prior to hook initialization
if (this.isRuntimeReady) {
dispatchLoadEvent(this)
dispatchInitEvent(this)
dispatchReadyEvent(this)
return
}
addEventListenerOnce(global, RUNTIME_INIT_EVENT_NAME, () => {
dispatchInitEvent(this)
dispatchReadyEvent(this)
})
if (!isWorkerContext && readyState !== 'complete') {
const pending = []
pending.push(waitForEvent(global, 'load', 500))
if (document) {
pending.push(waitForEvent(document, 'DOMContentLoaded'))
}
await Promise.race(pending)
}
isGlobalLoaded = true
dispatchLoadEvent(this)
}
/**
* Wait for a hook event to occur.
* @template {Event | T extends Event}
* @param {string|function} nameOrFunction
* @param {WaitOptions=} [options]
* @return {Promise<T>}
*/
async wait (nameOrFunction, options = null) {
const signal = options?.signal ?? null
if (typeof nameOrFunction === 'string') {
const name = /** @type {string} */ (nameOrFunction)
const method = `on${toProperCase(name.toLowerCase())}`
if (typeof this[method] === 'function') {
return await new Promise((resolve) => {
const removeEventListener = this[method](resolve)
if (signal?.aborted) {
removeEventListener()
} else if (signal) {
addEventListenerOnce(signal, 'abort', removeEventListener)
}
})
}
} else if (typeof nameOrFunction === 'function') {
const descriptor = Object.getOwnPropertyDescriptor(
this.constructor.prototype,
/** @type {function} */ (nameOrFunction).name
)
if (descriptor?.value === nameOrFunction) {
return await new Promise((resolve) => {
const removeEventListener = /** @type {function} */ (nameOrFunction)
.call(this, resolve)
if (signal?.aborted) {
removeEventListener()
} else if (signal) {
addEventListenerOnce(signal, 'abort', removeEventListener)
}
})
}
}
throw new TypeError(`${nameOrFunction} is not a valid hook to wait for`)
}
/**
* Wait for the global Window, Document, and Runtime to be ready.
* The callback function is called exactly once.
* @param {function} callback
* @return {function}
*/
onReady (callback) {
if (this.isReady) {
const timeout = setTimeout(callback)
return () => clearTimeout(timeout)
} else {
addEventListenerOnce(this, 'ready', callback)
return () => this.removeEventListener('ready', callback)
}
}
/**
* Wait for the global Window and Document to be ready. The callback
* function is called exactly once.
* @param {function} callback
* @return {function}
*/
onLoad (callback) {
if (this.isGlobalReady) {
const timeout = setTimeout(callback)
return () => clearTimeout(timeout)
} else {
addEventListenerOnce(this, 'load', callback)
return () => this.removeEventListener('load', callback)
}
}
/**
* Wait for the runtime to be ready. The callback
* function is called exactly once.
* @param {function} callback
* @return {function}
*/
onInit (callback) {
if (this.isRuntimeReady) {
const timeout = setTimeout(callback)
return () => clearTimeout(timeout)
} else {
addEventListenerOnce(this, 'init', callback)
return () => this.removeEventListener('init', callback)
}
}
/**
* Calls callback when a global exception occurs.
* 'error', 'messageerror', and 'unhandledrejection' events are handled here.
* @param {function} callback
* @return {function}
*/
onError (callback) {
this.addEventListener('error', callback)
this.addEventListener('messageerror', callback)
this.addEventListener('unhandledrejection', callback)
return () => {
this.removeEventListener('error', callback)
this.removeEventListener('messageerror', callback)
this.removeEventListener('unhandledrejection', callback)
}
}
/**
* Subscribes to the global data pipe calling callback when
* new data is emitted on the global Window.
* @param {function} callback
* @return {function}
*/
onData (callback) {
this.addEventListener('data', callback)
return () => this.removeEventListener('data', callback)
}
/**
* Subscribes to global messages likely from an external `postMessage`
* invocation.
* @param {function} callback
* @return {function}
*/
onMessage (callback) {
this.addEventListener('message', callback)
return () => this.removeEventListener('message', callback)
}
/**
* Calls callback when runtime is working online.
* @param {function} callback
* @return {function}
*/
onOnline (callback) {
this.addEventListener('online', callback)
return () => this.removeEventListener('online', callback)
}
/**
* Calls callback when runtime is not working online.
* @param {function} callback
* @return {function}
*/
onOffline (callback) {
this.addEventListener('offline', callback)
return () => this.removeEventListener('offline', callback)
}
/**
* Calls callback when runtime user preferred language has changed.
* @param {function} callback
* @return {function}
*/
onLanguageChange (callback) {
this.addEventListener('languagechange', callback)
return () => this.removeEventListener('languagechange', callback)
}
/**
* Calls callback when an application permission has changed.
* @param {function} callback
* @return {function}
*/
onPermissionChange (callback) {
this.addEventListener('permissionchange', callback)
return () => this.removeEventListener('permissionchange', callback)
}
/**
* Calls callback in response to a displayed `Notification`.
* @param {function} callback
* @return {function}
*/
onNotificationResponse (callback) {
this.addEventListener('notificationresponse', callback)
return () => this.removeEventListener('notificationresponse', callback)
}
/**
* Calls callback when a `Notification` is presented.
* @param {function} callback
* @return {function}
*/
onNotificationPresented (callback) {
this.addEventListener('notificationpresented', callback)
return () => this.removeEventListener('notificationpresented', callback)
}
/**
* Calls callback when a `ApplicationURL` is opened.
* @param {function} callback
* @return {function}
*/
onApplicationURL (callback) {
this.addEventListener('applicationurl', callback)
return () => this.removeEventListener('applicationurl', callback)
}
/**
* Calls callback when an `ApplicationPause` is dispatched.
* @param {function} callback
* @return {function}
*/
onApplicationPause (callback) {
this.addEventListener('applicationpause', callback)
return () => this.removeEventListener('applicationpause', callback)
}
/**
* Calls callback when an `ApplicationResume` is dispatched.
* @param {function} callback
* @return {function}
*/
onApplicationResume (callback) {
this.addEventListener('applicationresume', callback)
return () => this.removeEventListener('applicationresume', callback)
}
}
/**
* `Hooks` single instance.
* @ignore
*/
const hooks = new Hooks()
/**
* Wait for a hook event to occur.
* @template {Event | T extends Event}
* @param {string|function} nameOrFunction
* @return {Promise<T>}
*/
export async function wait (nameOrFunction) {
return await hooks.wait(nameOrFunction)
}
/**
* Wait for the global Window, Document, and Runtime to be ready.
* The callback function is called exactly once.
* @param {function} callback
* @return {function}
*/
export function onReady (callback) {
return hooks.onReady(callback)
}
/**
* Wait for the global Window and Document to be ready. The callback
* function is called exactly once.
* @param {function} callback
* @return {function}
*/
export function onLoad (callback) {
return hooks.onLoad(callback)
}
/**
* Wait for the runtime to be ready. The callback
* function is called exactly once.
* @param {function} callback
* @return {function}
*/
export function onInit (callback) {
return hooks.onInit(callback)
}
/**
* Calls callback when a global exception occurs.
* 'error', 'messageerror', and 'unhandledrejection' events are handled here.
* @param {function} callback
* @return {function}
*/
export function onError (callback) {
return hooks.onError(callback)
}
/**
* Subscribes to the global data pipe calling callback when
* new data is emitted on the global Window.
* @param {function} callback
* @return {function}
*/
export function onData (callback) {
return hooks.onData(callback)
}
/**
* Subscribes to global messages likely from an external `postMessage`
* invocation.
* @param {function} callback
* @return {function}
*/
export function onMessage (callback) {
return hooks.onMessage(callback)
}
/**
* Calls callback when runtime is working online.
* @param {function} callback
* @return {function}
*/
export function onOnline (callback) {
return hooks.onOnline(callback)
}
/**
* Calls callback when runtime is not working online.
* @param {function} callback
* @return {function}
*/
export function onOffline (callback) {
return hooks.onOffline(callback)
}
/**
* Calls callback when runtime user preferred language has changed.
* @param {function} callback
* @return {function}
*/
export function onLanguageChange (callback) {
return hooks.onLanguageChange(callback)
}
/**
* Calls callback when an application permission has changed.
* @param {function} callback
* @return {function}
*/
export function onPermissionChange (callback) {
return hooks.onPermissionChange(callback)
}
/**
* Calls callback in response to a presented `Notification`.
* @param {function} callback
* @return {function}
*/
export function onNotificationResponse (callback) {
return hooks.onNotificationResponse(callback)
}
/**
* Calls callback when a `Notification` is presented.
* @param {function} callback
* @return {function}
*/
export function onNotificationPresented (callback) {
return hooks.onNotificationPresented(callback)
}
/**
* Calls callback when a `ApplicationURL` is opened.
* @param {function} callback
* @return {function}
*/
export function onApplicationURL (callback) {
return hooks.onApplicationURL(callback)
}
/**
* Calls callback when a `ApplicationPause` is dispatched.
* @param {function} callback
* @return {function}
*/
export function onApplicationPause (callback) {
return hooks.onApplicationPause(callback)
}
/**
* Calls callback when a `ApplicationResume` is dispatched.
* @param {function} callback
* @return {function}
*/
export function onApplicationResume (callback) {
return hooks.onApplicationResume(callback)
}
export default hooks