forked from jupyterlab/jupyterlab
/
windowresolver.ts
233 lines (196 loc) · 5.59 KB
/
windowresolver.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { PromiseDelegate, Token } from '@phosphor/coreutils';
/* tslint:disable */
/**
* The default window resolver token.
*/
export const IWindowResolver = new Token<IWindowResolver>(
'@jupyterlab/apputils:IWindowResolver'
);
/* tslint:enable */
/**
* The description of a window name resolver.
*/
export interface IWindowResolver {
/**
* A window name to use as a handle among shared resources.
*/
readonly name: string;
}
/**
* A concrete implementation of a window name resolver.
*/
export class WindowResolver implements IWindowResolver {
/**
* The resolved window name.
*/
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 | null = null;
}
/*
* A namespace for private module data.
*/
namespace Private {
/**
* The internal prefix for private local storage keys.
*/
const PREFIX = '@jupyterlab/coreutils: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.
*/
let delegate = new PromiseDelegate<string>();
/**
* The known window names.
*/
let 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 in known) {
reject();
}
});
}
/**
* Ping peers with payload.
*/
function ping(payload: string): 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 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();
})();
}