@jupyterlab/apputils
Version:
JupyterLab - Application Utilities
219 lines (183 loc) • 5.5 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { PromiseDelegate } from '@lumino/coreutils';
import { IWindowResolver } from './tokens';
/**
* A concrete implementation of a window name resolver.
*/
export class WindowResolver implements IWindowResolver {
/**
* The resolved window name.
*
* #### Notes
* If the `resolve` promise has not resolved, the behavior is undefined.
*/
get name(): string {
return this._name;
}
/**
* Resolve a window name to use as a handle among shared resources.
*
* @param candidate - The potential window name being resolved.
*
* #### Notes
* Typically, the name candidate should be a JupyterLab workspace name or
* an empty string if there is no workspace.
*
* If the returned promise rejects, a window name cannot be resolved without
* user intervention, which typically means navigation to a new URL.
*/
resolve(candidate: string): Promise<void> {
return Private.resolve(candidate).then(name => {
this._name = name;
});
}
private _name: string;
}
/*
* A namespace for private module data.
*/
namespace Private {
/**
* The internal prefix for private local storage keys.
*/
const PREFIX = '@jupyterlab/statedb:StateDB';
/**
* The local storage beacon key.
*/
const BEACON = `${PREFIX}:beacon`;
/**
* The timeout (in ms) to wait for beacon responders.
*
* #### Notes
* This value is a whole number between 200 and 500 in order to prevent
* perfect timeout collisions between multiple simultaneously opening windows
* that have the same URL. This is an edge case because multiple windows
* should not ordinarily share the same URL, but it can be contrived.
*/
const TIMEOUT = Math.floor(200 + Math.random() * 300);
/**
* The local storage window key.
*/
const WINDOW = `${PREFIX}:window`;
/**
* Current beacon request
*
* #### Notes
* We keep track of the current request so that we can ignore our own beacon
* requests. This is to work around a bug in Safari, where Safari sometimes
* triggers local storage events for changes made by the current tab. See
* https://github.com/jupyterlab/jupyterlab/issues/6921#issuecomment-540817283
* for more details.
*/
let currentBeaconRequest: string | null = null;
/**
* A potential preferred default window name.
*/
let candidate: string | null = null;
/**
* The window name promise.
*/
const delegate = new PromiseDelegate<string>();
/**
* The known window names.
*/
const known: { [window: string]: null } = {};
/**
* The window name.
*/
let name: string | null = null;
/**
* Whether the name resolution has completed.
*/
let resolved = false;
/**
* Start the storage event handler.
*/
function initialize(): void {
// Listen to all storage events for beacons and window names.
window.addEventListener('storage', (event: StorageEvent) => {
const { key, newValue } = event;
// All the keys we care about have values.
if (newValue === null) {
return;
}
// If the beacon was fired, respond with a ping.
if (
key === BEACON &&
newValue !== currentBeaconRequest &&
candidate !== null
) {
ping(resolved ? name : candidate);
return;
}
// If the window name is resolved, bail.
if (resolved || key !== WINDOW) {
return;
}
const reported = newValue.replace(/\-\d+$/, '');
// Store the reported window name.
known[reported] = null;
// If a reported window name and candidate collide, reject the candidate.
if (!candidate || candidate in known) {
reject();
}
});
}
/**
* Ping peers with payload.
*/
function ping(payload: string | null): void {
if (payload === null) {
return;
}
const { localStorage } = window;
localStorage.setItem(WINDOW, `${payload}-${new Date().getTime()}`);
}
/**
* Reject the candidate.
*/
function reject(): void {
resolved = true;
currentBeaconRequest = null;
delegate.reject(`Window name candidate "${candidate}" already exists`);
}
/**
* Returns a promise that resolves with the window name used for restoration.
*/
export function resolve(potential: string): Promise<string> {
if (resolved) {
return delegate.promise;
}
// Set the local candidate.
candidate = potential;
if (candidate in known) {
reject();
return delegate.promise;
}
const { localStorage, setTimeout } = window;
// Wait until other windows have reported before claiming the candidate.
setTimeout(() => {
if (resolved) {
return;
}
// If the window name has not already been resolved, check one last time
// to confirm it is not a duplicate before resolving.
if (!candidate || candidate in known) {
return reject();
}
resolved = true;
currentBeaconRequest = null;
delegate.resolve((name = candidate));
ping(name);
}, TIMEOUT);
// Fire the beacon to collect other windows' names.
currentBeaconRequest = `${Math.random()}-${new Date().getTime()}`;
localStorage.setItem(BEACON, currentBeaconRequest);
return delegate.promise;
}
// Initialize the storage listener at runtime.
(() => {
initialize();
})();
}