@lumino/messaging
Version:
Lumino Message Passing
642 lines (585 loc) • 18.2 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module messaging
*/
import { ArrayExt, every, retro, some } from '@lumino/algorithm';
import { LinkedList } from '@lumino/collections';
/**
* A message which can be delivered to a message handler.
*
* #### Notes
* This class may be subclassed to create complex message types.
*/
export class Message {
/**
* Construct a new message.
*
* @param type - The type of the message.
*/
constructor(type: string) {
this.type = type;
}
/**
* The type of the message.
*
* #### Notes
* The `type` of a message should be related directly to its actual
* runtime type. This means that `type` can and will be used to cast
* the message to the relevant derived `Message` subtype.
*/
readonly type: string;
/**
* Test whether the message is conflatable.
*
* #### Notes
* Message conflation is an advanced topic. Most message types will
* not make use of this feature.
*
* If a conflatable message is posted to a handler while another
* conflatable message of the same `type` has already been posted
* to the handler, the `conflate()` method of the existing message
* will be invoked. If that method returns `true`, the new message
* will not be enqueued. This allows messages to be compressed, so
* that only a single instance of the message type is processed per
* cycle, no matter how many times messages of that type are posted.
*
* Custom message types may reimplement this property.
*
* The default implementation is always `false`.
*/
get isConflatable(): boolean {
return false;
}
/**
* Conflate this message with another message of the same `type`.
*
* @param other - A conflatable message of the same `type`.
*
* @returns `true` if the message was successfully conflated, or
* `false` otherwise.
*
* #### Notes
* Message conflation is an advanced topic. Most message types will
* not make use of this feature.
*
* This method is called automatically by the message loop when the
* given message is posted to the handler paired with this message.
* This message will already be enqueued and conflatable, and the
* given message will have the same `type` and also be conflatable.
*
* This method should merge the state of the other message into this
* message as needed so that when this message is finally delivered
* to the handler, it receives the most up-to-date information.
*
* If this method returns `true`, it signals that the other message
* was successfully conflated and that message will not be enqueued.
*
* If this method returns `false`, the other message will be enqueued
* for normal delivery.
*
* Custom message types may reimplement this method.
*
* The default implementation always returns `false`.
*/
conflate(other: Message): boolean {
return false;
}
}
/**
* A convenience message class which conflates automatically.
*
* #### Notes
* Message conflation is an advanced topic. Most user code will not
* make use of this class.
*
* This message class is useful for creating message instances which
* should be conflated, but which have no state other than `type`.
*
* If conflation of stateful messages is required, a custom `Message`
* subclass should be created.
*/
export class ConflatableMessage extends Message {
/**
* Test whether the message is conflatable.
*
* #### Notes
* This property is always `true`.
*/
get isConflatable(): boolean {
return true;
}
/**
* Conflate this message with another message of the same `type`.
*
* #### Notes
* This method always returns `true`.
*/
conflate(other: ConflatableMessage): boolean {
return true;
}
}
/**
* An object which handles messages.
*
* #### Notes
* A message handler is a simple way of defining a type which can act
* upon on a large variety of external input without requiring a large
* abstract API surface. This is particularly useful in the context of
* widget frameworks where the number of distinct message types can be
* unbounded.
*/
export interface IMessageHandler {
/**
* Process a message sent to the handler.
*
* @param msg - The message to be processed.
*/
processMessage(msg: Message): void;
}
/**
* An object which intercepts messages sent to a message handler.
*
* #### Notes
* A message hook is useful for intercepting or spying on messages
* sent to message handlers which were either not created by the
* consumer, or when subclassing the handler is not feasible.
*
* If `messageHook` returns `false`, no other message hooks will be
* invoked and the message will not be delivered to the handler.
*
* If all installed message hooks return `true`, the message will
* be delivered to the handler for processing.
*
* **See also:** {@link MessageLoop.installMessageHook} and {@link MessageLoop.removeMessageHook}
*/
export interface IMessageHook {
/**
* Intercept a message sent to a message handler.
*
* @param handler - The target handler of the message.
*
* @param msg - The message to be sent to the handler.
*
* @returns `true` if the message should continue to be processed
* as normal, or `false` if processing should cease immediately.
*/
messageHook(handler: IMessageHandler, msg: Message): boolean;
}
/**
* A type alias for message hook object or function.
*
* #### Notes
* The signature and semantics of a message hook function are the same
* as the `messageHook` method of {@link IMessageHook}.
*/
export type MessageHook =
| IMessageHook
| ((handler: IMessageHandler, msg: Message) => boolean);
/**
* The namespace for the global singleton message loop.
*/
export namespace MessageLoop {
/**
* A function that cancels the pending loop task; `null` if unavailable.
*/
let pending: (() => void) | null = null;
/**
* Schedules a function for invocation as soon as possible asynchronously.
*
* @param fn The function to invoke when called back.
*
* @returns An anonymous function that will unschedule invocation if possible.
*/
const schedule = (
resolved =>
(fn: () => unknown): (() => void) => {
let rejected = false;
resolved.then(() => !rejected && fn());
return () => {
rejected = true;
};
}
)(Promise.resolve());
/**
* Send a message to a message handler to process immediately.
*
* @param handler - The handler which should process the message.
*
* @param msg - The message to deliver to the handler.
*
* #### Notes
* The message will first be sent through any installed message hooks
* for the handler. If the message passes all hooks, it will then be
* delivered to the `processMessage` method of the handler.
*
* The message will not be conflated with pending posted messages.
*
* Exceptions in hooks and handlers will be caught and logged.
*/
export function sendMessage(handler: IMessageHandler, msg: Message): void {
// Lookup the message hooks for the handler.
let hooks = messageHooks.get(handler);
// Handle the common case of no installed hooks.
if (!hooks || hooks.length === 0) {
invokeHandler(handler, msg);
return;
}
// Invoke the message hooks starting with the newest first.
let passed = every(retro(hooks), hook => {
return hook ? invokeHook(hook, handler, msg) : true;
});
// Invoke the handler if the message passes all hooks.
if (passed) {
invokeHandler(handler, msg);
}
}
/**
* Post a message to a message handler to process in the future.
*
* @param handler - The handler which should process the message.
*
* @param msg - The message to post to the handler.
*
* #### Notes
* The message will be conflated with the pending posted messages for
* the handler, if possible. If the message is not conflated, it will
* be queued for normal delivery on the next cycle of the event loop.
*
* Exceptions in hooks and handlers will be caught and logged.
*/
export function postMessage(handler: IMessageHandler, msg: Message): void {
// Handle the common case of a non-conflatable message.
if (!msg.isConflatable) {
enqueueMessage(handler, msg);
return;
}
// Conflate the message with an existing message if possible.
let conflated = some(messageQueue, posted => {
if (posted.handler !== handler) {
return false;
}
if (!posted.msg) {
return false;
}
if (posted.msg.type !== msg.type) {
return false;
}
if (!posted.msg.isConflatable) {
return false;
}
return posted.msg.conflate(msg);
});
// Enqueue the message if it was not conflated.
if (!conflated) {
enqueueMessage(handler, msg);
}
}
/**
* Install a message hook for a message handler.
*
* @param handler - The message handler of interest.
*
* @param hook - The message hook to install.
*
* #### Notes
* A message hook is invoked before a message is delivered to the
* handler. If the hook returns `false`, no other hooks will be
* invoked and the message will not be delivered to the handler.
*
* The most recently installed message hook is executed first.
*
* If the hook is already installed, this is a no-op.
*/
export function installMessageHook(
handler: IMessageHandler,
hook: MessageHook
): void {
// Look up the hooks for the handler.
let hooks = messageHooks.get(handler);
// Bail early if the hook is already installed.
if (hooks && hooks.indexOf(hook) !== -1) {
return;
}
// Add the hook to the end, so it will be the first to execute.
if (!hooks) {
messageHooks.set(handler, [hook]);
} else {
hooks.push(hook);
}
}
/**
* Remove an installed message hook for a message handler.
*
* @param handler - The message handler of interest.
*
* @param hook - The message hook to remove.
*
* #### Notes
* It is safe to call this function while the hook is executing.
*
* If the hook is not installed, this is a no-op.
*/
export function removeMessageHook(
handler: IMessageHandler,
hook: MessageHook
): void {
// Lookup the hooks for the handler.
let hooks = messageHooks.get(handler);
// Bail early if the hooks do not exist.
if (!hooks) {
return;
}
// Lookup the index of the hook and bail if not found.
let i = hooks.indexOf(hook);
if (i === -1) {
return;
}
// Clear the hook and schedule a cleanup of the array.
hooks[i] = null;
scheduleCleanup(hooks);
}
/**
* Clear all message data associated with a message handler.
*
* @param handler - The message handler of interest.
*
* #### Notes
* This will clear all posted messages and hooks for the handler.
*/
export function clearData(handler: IMessageHandler): void {
// Lookup the hooks for the handler.
let hooks = messageHooks.get(handler);
// Clear all messsage hooks for the handler.
if (hooks && hooks.length > 0) {
ArrayExt.fill(hooks, null);
scheduleCleanup(hooks);
}
// Clear all posted messages for the handler.
for (const posted of messageQueue) {
if (posted.handler === handler) {
posted.handler = null;
posted.msg = null;
}
}
}
/**
* Process the pending posted messages in the queue immediately.
*
* #### Notes
* This function is useful when posted messages must be processed immediately.
*
* This function should normally not be needed, but it may be
* required to work around certain browser idiosyncrasies.
*
* Recursing into this function is a no-op.
*/
export function flush(): void {
// Bail if recursion is detected or if there is no pending task.
if (flushGuard || pending === null) {
return;
}
// Unschedule the pending loop task.
pending();
pending = null;
// Run the message loop within the recursion guard.
flushGuard = true;
runMessageLoop();
flushGuard = false;
}
/**
* A type alias for the exception handler function.
*/
export type ExceptionHandler = (err: Error) => void;
/**
* Get the message loop exception handler.
*
* @returns The current exception handler.
*
* #### Notes
* The default exception handler is `console.error`.
*/
export function getExceptionHandler(): ExceptionHandler {
return exceptionHandler;
}
/**
* Set the message loop exception handler.
*
* @param handler - The function to use as the exception handler.
*
* @returns The old exception handler.
*
* #### Notes
* The exception handler is invoked when a message handler or a
* message hook throws an exception.
*/
export function setExceptionHandler(
handler: ExceptionHandler
): ExceptionHandler {
let old = exceptionHandler;
exceptionHandler = handler;
return old;
}
/**
* A type alias for a posted message pair.
*/
type PostedMessage = { handler: IMessageHandler | null; msg: Message | null };
/**
* The queue of posted message pairs.
*/
const messageQueue = new LinkedList<PostedMessage>();
/**
* A mapping of handler to array of installed message hooks.
*/
const messageHooks = new WeakMap<
IMessageHandler,
Array<MessageHook | null>
>();
/**
* A set of message hook arrays which are pending cleanup.
*/
const dirtySet = new Set<Array<MessageHook | null>>();
/**
* The message loop exception handler.
*/
let exceptionHandler: ExceptionHandler = (err: Error) => {
console.error(err);
};
/**
* A guard flag to prevent flush recursion.
*/
let flushGuard = false;
/**
* Invoke a message hook with the specified handler and message.
*
* Returns the result of the hook, or `true` if the hook throws.
*
* Exceptions in the hook will be caught and logged.
*/
function invokeHook(
hook: MessageHook,
handler: IMessageHandler,
msg: Message
): boolean {
let result = true;
try {
if (typeof hook === 'function') {
result = hook(handler, msg);
} else {
result = hook.messageHook(handler, msg);
}
} catch (err) {
exceptionHandler(err);
}
return result;
}
/**
* Invoke a message handler with the specified message.
*
* Exceptions in the handler will be caught and logged.
*/
function invokeHandler(handler: IMessageHandler, msg: Message): void {
try {
handler.processMessage(msg);
} catch (err) {
exceptionHandler(err);
}
}
/**
* Add a message to the end of the message queue.
*
* This will automatically schedule a run of the message loop.
*/
function enqueueMessage(handler: IMessageHandler, msg: Message): void {
// Add the posted message to the queue.
messageQueue.addLast({ handler, msg });
// Bail if a loop task is already pending.
if (pending !== null) {
return;
}
// Schedule a run of the message loop.
pending = schedule(runMessageLoop);
}
/**
* Run an iteration of the message loop.
*
* This will process all pending messages in the queue. If a message
* is added to the queue while the message loop is running, it will
* be processed on the next cycle of the loop.
*/
function runMessageLoop(): void {
// Clear the task so the next loop can be scheduled.
pending = null;
// If the message queue is empty, there is nothing else to do.
if (messageQueue.isEmpty) {
return;
}
// Add a sentinel value to the end of the queue. The queue will
// only be processed up to the sentinel. Messages posted during
// this cycle will execute on the next cycle.
let sentinel: PostedMessage = { handler: null, msg: null };
messageQueue.addLast(sentinel);
// Enter the message loop.
// eslint-disable-next-line no-constant-condition
while (true) {
// Remove the first posted message in the queue.
let posted = messageQueue.removeFirst()!;
// If the value is the sentinel, exit the loop.
if (posted === sentinel) {
return;
}
// Dispatch the message if it has not been cleared.
if (posted.handler && posted.msg) {
sendMessage(posted.handler, posted.msg);
}
}
}
/**
* Schedule a cleanup of a message hooks array.
*
* This will add the array to the dirty set and schedule a deferred
* cleanup of the array contents. On cleanup, any `null` hook will
* be removed from the array.
*/
function scheduleCleanup(hooks: Array<MessageHook | null>): void {
if (dirtySet.size === 0) {
schedule(cleanupDirtySet);
}
dirtySet.add(hooks);
}
/**
* Cleanup the message hook arrays in the dirty set.
*
* This function should only be invoked asynchronously, when the
* stack frame is guaranteed to not be on the path of user code.
*/
function cleanupDirtySet(): void {
dirtySet.forEach(cleanupHooks);
dirtySet.clear();
}
/**
* Cleanup the dirty hooks in a message hooks array.
*
* This will remove any `null` hook from the array.
*
* This function should only be invoked asynchronously, when the
* stack frame is guaranteed to not be on the path of user code.
*/
function cleanupHooks(hooks: Array<MessageHook | null>): void {
ArrayExt.removeAllWhere(hooks, isNull);
}
/**
* Test whether a value is `null`.
*/
function isNull<T>(value: T | null): boolean {
return value === null;
}
}