UNPKG

5.5 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { PromiseDelegate } from '@lumino/coreutils';
5import { IWindowResolver } from './tokens';
6
7/**
8 * A concrete implementation of a window name resolver.
9 */
10export class WindowResolver implements IWindowResolver {
11 /**
12 * The resolved window name.
13 *
14 * #### Notes
15 * If the `resolve` promise has not resolved, the behavior is undefined.
16 */
17 get name(): string {
18 return this._name;
19 }
20
21 /**
22 * Resolve a window name to use as a handle among shared resources.
23 *
24 * @param candidate - The potential window name being resolved.
25 *
26 * #### Notes
27 * Typically, the name candidate should be a JupyterLab workspace name or
28 * an empty string if there is no workspace.
29 *
30 * If the returned promise rejects, a window name cannot be resolved without
31 * user intervention, which typically means navigation to a new URL.
32 */
33 resolve(candidate: string): Promise<void> {
34 return Private.resolve(candidate).then(name => {
35 this._name = name;
36 });
37 }
38
39 private _name: string;
40}
41
42/*
43 * A namespace for private module data.
44 */
45namespace Private {
46 /**
47 * The internal prefix for private local storage keys.
48 */
49 const PREFIX = '@jupyterlab/statedb:StateDB';
50
51 /**
52 * The local storage beacon key.
53 */
54 const BEACON = `${PREFIX}:beacon`;
55
56 /**
57 * The timeout (in ms) to wait for beacon responders.
58 *
59 * #### Notes
60 * This value is a whole number between 200 and 500 in order to prevent
61 * perfect timeout collisions between multiple simultaneously opening windows
62 * that have the same URL. This is an edge case because multiple windows
63 * should not ordinarily share the same URL, but it can be contrived.
64 */
65 const TIMEOUT = Math.floor(200 + Math.random() * 300);
66
67 /**
68 * The local storage window key.
69 */
70 const WINDOW = `${PREFIX}:window`;
71
72 /**
73 * Current beacon request
74 *
75 * #### Notes
76 * We keep track of the current request so that we can ignore our own beacon
77 * requests. This is to work around a bug in Safari, where Safari sometimes
78 * triggers local storage events for changes made by the current tab. See
79 * https://github.com/jupyterlab/jupyterlab/issues/6921#issuecomment-540817283
80 * for more details.
81 */
82 let currentBeaconRequest: string | null = null;
83
84 /**
85 * A potential preferred default window name.
86 */
87 let candidate: string | null = null;
88
89 /**
90 * The window name promise.
91 */
92 const delegate = new PromiseDelegate<string>();
93
94 /**
95 * The known window names.
96 */
97 const known: { [window: string]: null } = {};
98
99 /**
100 * The window name.
101 */
102 let name: string | null = null;
103
104 /**
105 * Whether the name resolution has completed.
106 */
107 let resolved = false;
108
109 /**
110 * Start the storage event handler.
111 */
112 function initialize(): void {
113 // Listen to all storage events for beacons and window names.
114 window.addEventListener('storage', (event: StorageEvent) => {
115 const { key, newValue } = event;
116
117 // All the keys we care about have values.
118 if (newValue === null) {
119 return;
120 }
121
122 // If the beacon was fired, respond with a ping.
123 if (
124 key === BEACON &&
125 newValue !== currentBeaconRequest &&
126 candidate !== null
127 ) {
128 ping(resolved ? name : candidate);
129 return;
130 }
131
132 // If the window name is resolved, bail.
133 if (resolved || key !== WINDOW) {
134 return;
135 }
136
137 const reported = newValue.replace(/\-\d+$/, '');
138
139 // Store the reported window name.
140 known[reported] = null;
141
142 // If a reported window name and candidate collide, reject the candidate.
143 if (!candidate || candidate in known) {
144 reject();
145 }
146 });
147 }
148
149 /**
150 * Ping peers with payload.
151 */
152 function ping(payload: string | null): void {
153 if (payload === null) {
154 return;
155 }
156
157 const { localStorage } = window;
158
159 localStorage.setItem(WINDOW, `${payload}-${new Date().getTime()}`);
160 }
161
162 /**
163 * Reject the candidate.
164 */
165 function reject(): void {
166 resolved = true;
167 currentBeaconRequest = null;
168 delegate.reject(`Window name candidate "${candidate}" already exists`);
169 }
170
171 /**
172 * Returns a promise that resolves with the window name used for restoration.
173 */
174 export function resolve(potential: string): Promise<string> {
175 if (resolved) {
176 return delegate.promise;
177 }
178
179 // Set the local candidate.
180 candidate = potential;
181
182 if (candidate in known) {
183 reject();
184 return delegate.promise;
185 }
186
187 const { localStorage, setTimeout } = window;
188
189 // Wait until other windows have reported before claiming the candidate.
190 setTimeout(() => {
191 if (resolved) {
192 return;
193 }
194
195 // If the window name has not already been resolved, check one last time
196 // to confirm it is not a duplicate before resolving.
197 if (!candidate || candidate in known) {
198 return reject();
199 }
200
201 resolved = true;
202 currentBeaconRequest = null;
203 delegate.resolve((name = candidate));
204 ping(name);
205 }, TIMEOUT);
206
207 // Fire the beacon to collect other windows' names.
208 currentBeaconRequest = `${Math.random()}-${new Date().getTime()}`;
209 localStorage.setItem(BEACON, currentBeaconRequest);
210
211 return delegate.promise;
212 }
213
214 // Initialize the storage listener at runtime.
215 (() => {
216 initialize();
217 })();
218}