maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
264 lines (239 loc) • 12 kB
text/typescript
import {createActors} from './actor.ts';
import {getGlobalWorkerPool} from './global_worker_pool.ts';
import {GLOBAL_DISPATCHER_ID, makeRequest} from './ajax.ts';
import {MessageType} from './actor_messages.ts';
import {type ErrorEvent, Evented, type Event} from './evented.ts';
import {warnOnce} from './util.ts';
import {isAbortError, throwIfAborted} from './abort_error.ts';
import type {Actor, MessageHandler} from './actor.ts';
import type {WorkerPool} from './worker_pool.ts';
import type {RequestResponseMessageMap} from './actor_messages.ts';
export type GlobalDispatcherEventType = {
/**
* Fires each time the global workers are created, including the first. Removing the last map
* terminates them unless `prewarm()` holds them, so a listener here is the place to restore
* state that lives in the workers rather than in any one map.
*
* Message handlers registered through {@link getGlobalDispatcher} and scripts loaded through
* {@link importScriptInWorkers} come back on their own and do not need this.
*
* @example
* ```ts
* getGlobalDispatcher().on('workerscreate', () => {
* getGlobalDispatcher().broadcast('my-plugin-config', {tiles: 'https://example.com/{z}/{x}/{y}.png'});
* });
* ```
*/
workerscreate: Event;
error: ErrorEvent;
};
/**
* A handler for a message that is not one of the built-in {@link MessageType}s, e.g. a message that is sent by a plugin.
*/
export type CustomMessageHandler<TData, TResult> = (mapId: string | number, params: TData, abortController?: AbortController) => Promise<TResult>;
/**
* How long the workers get to answer a ping before they are considered terminated. The ping
* queues ahead of tile work on return to the foreground, so the budget only needs to cover one
* in-flight tile parse on slow hardware. A false timeout restarts healthy workers and drops map
* state, so err on the large side.
*/
const PING_TIMEOUT_MS = 500;
/**
* The dispatcher shared across all map instances, see {@link getGlobalDispatcher}. Unlike a map's
* dispatcher it never claims the workers: it builds actors for whichever workers the pool is
* running, and discards them when the pool terminates those workers.
*/
export class GlobalDispatcher extends Evented<GlobalDispatcherEventType> {
/** Every script url {@link importScriptInWorkers} has sent, so they can be sent again to new workers. */
private importedScriptUrls = new Set<string>();
private messageHandlers: Record<string, MessageHandler<MessageType>> = {};
/** The actors on the pool's current workers, built on first use and cleared when those workers terminate. */
private actorsPromise: Promise<Actor[]> | undefined;
private workersAbortController: AbortController | undefined;
private workerPool: WorkerPool;
private watchingWorkers = false;
private ensureWorkersPromise: Promise<void> | undefined;
/**
* Builds actors on the pool's workers as soon as they exist, including workers started before
* construction, e.g. by prewarm(), so that messages the workers send find their handlers.
*/
constructor(workerPool: WorkerPool) {
super();
this.workerPool = workerPool;
workerPool.on('create', () => this.buildActorsInBackground());
workerPool.on('terminate', () => this.handleWorkersTerminated());
if (workerPool.isStarted()) this.buildActorsInBackground();
}
private async buildActorsInBackground(): Promise<void> {
try {
await this.getActors();
} catch (error) {
if (!isAbortError(error)) warnOnce(`Failed to connect to the workers: ${error}`);
}
}
private handleWorkersTerminated(): void {
this.workersAbortController?.abort(new Error('The workers were terminated while a message was in flight.'));
this.actorsPromise = undefined;
}
/** Returns the actors on the pool's current workers, starting the workers if none are running. */
private getActors(): Promise<Actor[]> {
this.actorsPromise ??= this.buildActors();
return this.actorsPromise;
}
/**
* Builds an actor per worker with the registered message handlers, and sends the imported
* scripts ahead of anything else, so a message for a script never reaches a worker before it.
* Rejects with an `AbortError` if the workers terminate before they are created.
*/
private async buildActors(): Promise<Actor[]> {
const abortController = new AbortController();
this.workersAbortController = abortController;
const workers = await this.workerPool.start();
throwIfAborted(abortController.signal);
const {actors, workerErrorSubscriptions} = createActors(workers, GLOBAL_DISPATCHER_ID, this);
abortController.signal.addEventListener('abort', () => {
for (const actor of actors) actor.remove();
for (const subscription of workerErrorSubscriptions) subscription.unsubscribe();
}, {once: true});
for (const actor of actors) {
for (const [type, handler] of Object.entries(this.messageHandlers)) {
actor.registerMessageHandler(type as MessageType, handler);
}
}
for (const url of this.importedScriptUrls) {
this.importScriptInto(actors, abortController, url);
}
this.fire('workerscreate');
return actors;
}
/** Returns the actors already built or being built, without starting any workers. */
private async getBuiltActors(): Promise<Actor[]> {
try {
return (await this.actorsPromise) ?? [];
} catch {
// The workers terminated first, and the next actors pick up messageHandlers.
return [];
}
}
private async importScriptInto(actors: Actor[], abortController: AbortController, url: string): Promise<void> {
try {
await Promise.all(actors.map(actor => actor.sendAsync({type: MessageType.importScript, data: url}, abortController)));
} catch (error) {
warnOnce(`Failed to import script ${url} into the workers: ${error}`);
}
}
/**
* Broadcast a message to all Workers.
* The type can be any string, to allow sending messages that are handled by a script that was imported using {@link importScriptInWorkers}.
*/
broadcast<T extends MessageType>(type: T, data: RequestResponseMessageMap[T][0]): Promise<Array<RequestResponseMessageMap[T][1]>>;
broadcast<TData = any, TResult = any>(type: string, data: TData): Promise<TResult[]>;
async broadcast(type: string, data: any): Promise<unknown[]> {
const actorsPromise = this.getActors();
// Read before awaiting: once these workers terminate, the field holds the next workers' controller.
const abortController = this.workersAbortController;
const actors = await actorsPromise;
throwIfAborted(abortController.signal);
return Promise.all(actors.map(actor => actor.sendAsync({type: type as MessageType, data}, abortController)));
}
/**
* Registers a handler for messages that the workers send to this dispatcher.
* The type can be any string, to allow handling messages that are sent by a script that was imported using {@link importScriptInWorkers}.
*/
registerMessageHandler<T extends MessageType>(type: T, handler: MessageHandler<T>): Promise<void>;
registerMessageHandler<TData = any, TResult = any>(type: string, handler: CustomMessageHandler<TData, TResult>): Promise<void>;
async registerMessageHandler(type: string, handler: CustomMessageHandler<any, any>): Promise<void> {
this.messageHandlers[type] = handler;
for (const actor of await this.getBuiltActors()) {
actor.registerMessageHandler(type as MessageType, handler);
}
}
async unregisterMessageHandler(type: MessageType | string): Promise<void> {
delete this.messageHandlers[type];
for (const actor of await this.getBuiltActors()) {
actor.unregisterMessageHandler(type as MessageType);
}
}
/**
* See {@link importScriptInWorkers}. A url may be broadcast more than once, e.g. when this
* call is what creates the workers; the worker runs each url only once.
*/
async importScript(workerUrl: string): Promise<void> {
this.importedScriptUrls.add(workerUrl);
await this.broadcast(MessageType.importScript, workerUrl);
}
/**
* Pings the workers on every return to the foreground, restarting them if the browser
* terminated them while the page was hidden, as iOS does under memory pressure. Browsers fire no
* event for the termination, so the ping is the only way to notice it. Each map then
* recovers through the same contract as a lost WebGL context, see `Map._handleWorkersRestarted`.
*/
watchWorkers(): void {
if (this.watchingWorkers || typeof document === 'undefined') return;
this.watchingWorkers = true;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this.ensureWorkers();
}, false);
}
/**
* Restarts the workers if they don't answer a ping, for apps that learn of a return to the
* foreground some other way. Calls made while a check is running share that check.
*/
ensureWorkers(): Promise<void> {
this.ensureWorkersPromise ??= this.ensureWorkersInternal();
return this.ensureWorkersPromise;
}
private async ensureWorkersInternal(): Promise<void> {
try {
if (!await this.ping()) await this.workerPool.restart();
} finally {
this.ensureWorkersPromise = undefined;
}
}
/**
* Pings the workers, resolving false only on silence: any answer, even an error, proves they
* are alive. Resolves true when no workers exist, since there is nothing to restart.
*/
async ping(timeoutMs: number = PING_TIMEOUT_MS): Promise<boolean> {
if (!this.workerPool.isStarted()) return true;
let timer: ReturnType<typeof setTimeout>;
try {
return await Promise.race([
this.answersPing(),
new Promise<boolean>((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); })
]);
} finally {
clearTimeout(timer);
}
}
/** Resolves true once the workers answer a ping, whether with a result or an error. */
private async answersPing(): Promise<boolean> {
try {
await this.broadcast(MessageType.ping, undefined);
} catch {
// An error is still an answer.
}
return true;
}
}
let globalDispatcher: GlobalDispatcher;
/**
* This function is used to get the global dispatcher that is shared across all maps instances.
* It is used by the main thread to send messages to the workers, and by the workers to send messages back to the main thread.
* If you import a script into the worker and need to send a message to the workers to pass some parameters for example,
* you can use this function to get the global dispatcher and send a message to the workers.
*
* The same dispatcher is returned for the life of the page, so it is safe to keep, and the message
* handlers registered on it come back on the workers that replace terminated ones. Configuration
* you broadcast into the workers does not, so send that from a `workerscreate` listener.
* @returns The global dispatcher instance.
*/
export function getGlobalDispatcher(): GlobalDispatcher {
if (!globalDispatcher) {
globalDispatcher = new GlobalDispatcher(getGlobalWorkerPool());
globalDispatcher.registerMessageHandler(MessageType.getResource, (_mapId, params, abortController) => {
return makeRequest(params, abortController);
});
}
return globalDispatcher;
}