UNPKG

11.8 kBJavaScriptView Raw
1import {unescapeKey} from './utils/paths.js';
2import _ from 'lodash';
3
4const MIN_WORKER_VERSION = '2.3.0';
5
6
7class Snapshot {
8 constructor({path, value, exists, writeSerial}) {
9 this._path = path;
10 this._value = value;
11 this._exists = value === undefined ? exists || false : value !== null;
12 this._writeSerial = writeSerial;
13 }
14
15 get path() {
16 return this._path;
17 }
18
19 get exists() {
20 return this._exists;
21 }
22
23 get value() {
24 if (this._value === undefined) throw new Error('Value omitted from snapshot');
25 return this._value;
26 }
27
28 get key() {
29 if (this._key === undefined) this._key = unescapeKey(this._path.replace(/.*\//, ''));
30 return this._key;
31 }
32
33 get writeSerial() {
34 return this._writeSerial;
35 }
36}
37
38
39export default class Bridge {
40 constructor(webWorker) {
41 this._idCounter = 0;
42 this._deferreds = {};
43 this._suspended = false;
44 this._servers = {};
45 this._callbacks = {};
46 this._log = _.noop;
47 this._inboundMessages = [];
48 this._outboundMessages = [];
49 this._flushMessageQueue = this._flushMessageQueue.bind(this);
50 this._port = webWorker.port || webWorker;
51 this._shared = !!webWorker.port;
52 Object.seal(this);
53 this._port.onmessage = this._receive.bind(this);
54 window.addEventListener('unload', () => {this._send({msg: 'destroy'});});
55 }
56
57 init(webWorker, config) {
58 const items = [];
59 try {
60 const storage = window.localStorage || window.sessionStorage;
61 if (!storage) throw new Error('localStorage and sessionStorage not available');
62 for (let i = 0; i < storage.length; i++) {
63 const key = storage.key(i);
64 items.push({key, value: storage.getItem(key)});
65 }
66 } catch (e) {
67 // Some browsers don't like us accessing local storage -- nothing we can do.
68 }
69 return this._send({msg: 'init', storage: items, config}).then(response => {
70 const workerVersion = response.version.match(/^(\d+)\.(\d+)\.(\d+)(-.*)?$/);
71 if (workerVersion) {
72 const minVersion = MIN_WORKER_VERSION.match(/^(\d+)\.(\d+)\.(\d+)(-.*)?$/);
73 // Major version must match precisely, minor and patch must be greater than or equal.
74 const sufficient = workerVersion[1] === minVersion[1] && (
75 workerVersion[2] > minVersion[2] ||
76 workerVersion[2] === minVersion[2] && workerVersion[3] >= minVersion[3]
77 );
78 if (!sufficient) {
79 return Promise.reject(new Error(
80 `Incompatible Firetruss worker version: ${response.version} ` +
81 `(${MIN_WORKER_VERSION} or better required)`
82 ));
83 }
84 }
85 return response;
86 });
87 }
88
89 suspend(suspended) {
90 if (suspended === undefined) suspended = true;
91 if (this._suspended === suspended) return;
92 this._suspended = suspended;
93 if (!suspended) {
94 this._receiveMessages(this._inboundMessages);
95 this._inboundMessages = [];
96 if (this._outboundMessages.length) Promise.resolve().then(this._flushMessageQueue);
97 }
98 }
99
100 enableLogging(fn) {
101 if (fn) {
102 if (fn === true) {
103 fn = console.log.bind(console);
104 this._send({msg: 'enableFirebaseLogging', value: true});
105 }
106 this._log = fn;
107 } else {
108 this._send({msg: 'enableFirebaseLogging', value: false});
109 this._log = _.noop;
110 }
111 }
112
113 _send(message) {
114 message.id = ++this._idCounter;
115 let promise;
116 if (message.oneWay) {
117 promise = Promise.resolve();
118 } else {
119 promise = new Promise((resolve, reject) => {
120 this._deferreds[message.id] = {resolve, reject};
121 });
122 const deferred = this._deferreds[message.id];
123 deferred.promise = promise;
124 promise.sent = new Promise(resolve => {
125 deferred.resolveSent = resolve;
126 });
127 deferred.params = message;
128 }
129 if (!this._outboundMessages.length && !this._suspended) {
130 Promise.resolve().then(this._flushMessageQueue);
131 }
132 this._log('send:', message);
133 this._outboundMessages.push(message);
134 return promise;
135 }
136
137 _flushMessageQueue() {
138 try {
139 this._port.postMessage(this._outboundMessages);
140 this._outboundMessages = [];
141 } catch (e) {
142 e.extra = {messages: this._outboundMessages};
143 throw e;
144 }
145 }
146
147 _receive(event) {
148 if (this._suspended) {
149 this._inboundMessages = this._inboundMessages.concat(event.data);
150 } else {
151 this._receiveMessages(event.data);
152 }
153 }
154
155 _receiveMessages(messages) {
156 for (const message of messages) {
157 this._log('recv:', message);
158 const fn = this[message.msg];
159 if (!_.isFunction(fn)) throw new Error('Unknown message: ' + message.msg);
160 fn.call(this, message);
161 }
162 }
163
164 bindExposedFunction(name) {
165 return (function() {
166 return this._send({msg: 'call', name, args: Array.prototype.slice.call(arguments)});
167 }).bind(this);
168 }
169
170 resolve(message) {
171 const deferred = this._deferreds[message.id];
172 if (!deferred) throw new Error('Received resolution to inexistent Firebase call');
173 delete this._deferreds[message.id];
174 deferred.resolve(message.result);
175 }
176
177 reject(message) {
178 const deferred = this._deferreds[message.id];
179 if (!deferred) throw new Error('Received rejection of inexistent Firebase call');
180 delete this._deferreds[message.id];
181 deferred.reject(errorFromJson(message.error, deferred.params));
182 }
183
184 updateLocalStorage({items}) {
185 try {
186 const storage = window.localStorage || window.sessionStorage;
187 for (const item of items) {
188 if (item.value === null) {
189 storage.removeItem(item.key);
190 } else {
191 storage.setItem(item.key, item.value);
192 }
193 }
194 } catch (e) {
195 // If we're denied access, there's nothing we can do.
196 }
197 }
198
199 trackServer(rootUrl) {
200 if (this._servers.hasOwnProperty(rootUrl)) return Promise.resolve();
201 const server = this._servers[rootUrl] = {authListeners: []};
202 const authCallbackId = this._registerCallback(this._authCallback.bind(this, server));
203 this._send({msg: 'onAuth', url: rootUrl, callbackId: authCallbackId});
204 }
205
206 _authCallback(server, auth) {
207 server.auth = auth;
208 for (const listener of server.authListeners) listener(auth);
209 }
210
211 onAuth(rootUrl, callback, context) {
212 const listener = callback.bind(context);
213 listener.callback = callback;
214 listener.context = context;
215 this._servers[rootUrl].authListeners.push(listener);
216 listener(this.getAuth(rootUrl));
217 }
218
219 offAuth(rootUrl, callback, context) {
220 const authListeners = this._servers[rootUrl].authListeners;
221 for (let i = 0; i < authListeners.length; i++) {
222 const listener = authListeners[i];
223 if (listener.callback === callback && listener.context === context) {
224 authListeners.splice(i, 1);
225 break;
226 }
227 }
228 }
229
230 getAuth(rootUrl) {
231 return this._servers[rootUrl].auth;
232 }
233
234 authWithCustomToken(url, authToken) {
235 return this._send({msg: 'authWithCustomToken', url, authToken});
236 }
237
238 authAnonymously(url) {
239 return this._send({msg: 'authAnonymously', url});
240 }
241
242 unauth(url) {
243 return this._send({msg: 'unauth', url});
244 }
245
246 set(url, value, writeSerial) {return this._send({msg: 'set', url, value, writeSerial});}
247 update(url, value, writeSerial) {return this._send({msg: 'update', url, value, writeSerial});}
248
249 once(url, writeSerial) {
250 return this._send({msg: 'once', url, writeSerial}).then(snapshot => new Snapshot(snapshot));
251 }
252
253 on(listenerKey, url, spec, eventType, snapshotCallback, cancelCallback, context, options) {
254 const handle = {
255 listenerKey, eventType, snapshotCallback, cancelCallback, context,
256 params: {msg: 'on', listenerKey, url, spec, eventType, options}
257 };
258 const callback = this._onCallback.bind(this, handle);
259 this._registerCallback(callback, handle);
260 // Keep multiple IDs to allow the same snapshotCallback to be reused.
261 snapshotCallback.__callbackIds = snapshotCallback.__callbackIds || [];
262 snapshotCallback.__callbackIds.push(handle.id);
263 this._send({
264 msg: 'on', listenerKey, url, spec, eventType, callbackId: handle.id, options
265 }).catch(error => {
266 callback(error);
267 });
268 }
269
270 off(listenerKey, url, spec, eventType, snapshotCallback, context) {
271 const idsToDeregister = [];
272 let callbackId;
273 if (snapshotCallback) {
274 callbackId = this._findAndRemoveCallbackId(
275 snapshotCallback, handle => _.isMatch(handle, {listenerKey, eventType, context})
276 );
277 if (!callbackId) return Promise.resolve(); // no-op, never registered or already deregistered
278 idsToDeregister.push(callbackId);
279 } else {
280 for (const id of _.keys(this._callbacks)) {
281 const handle = this._callbacks[id];
282 if (handle.listenerKey === listenerKey && (!eventType || handle.eventType === eventType)) {
283 idsToDeregister.push(id);
284 }
285 }
286 }
287 // Nullify callbacks first, then deregister after off() is complete. We don't want any
288 // callbacks in flight from the worker to be invoked while the off() is processing, but we don't
289 // want them to throw an exception either.
290 for (const id of idsToDeregister) this._nullifyCallback(id);
291 return this._send({msg: 'off', listenerKey, url, spec, eventType, callbackId}).then(() => {
292 for (const id of idsToDeregister) this._deregisterCallback(id);
293 });
294 }
295
296 _onCallback(handle, error, snapshotJson) {
297 if (error) {
298 this._deregisterCallback(handle.id);
299 const e = errorFromJson(error, handle.params);
300 if (handle.cancelCallback) {
301 handle.cancelCallback.call(handle.context, e);
302 } else {
303 console.error(e);
304 }
305 } else {
306 handle.snapshotCallback.call(handle.context, new Snapshot(snapshotJson));
307 }
308 }
309
310 transaction(url, oldValue, relativeUpdates, writeSerial) {
311 return this._send(
312 {msg: 'transaction', url, oldValue, relativeUpdates, writeSerial}
313 ).then(result => {
314 if (result.snapshots) {
315 result.snapshots = _.map(result.snapshots, jsonSnapshot => new Snapshot(jsonSnapshot));
316 }
317 return result;
318 });
319 }
320
321 onDisconnect(url, method, value) {
322 return this._send({msg: 'onDisconnect', url, method, value});
323 }
324
325 bounceConnection() {
326 return this._send({msg: 'bounceConnection'});
327 }
328
329 callback({id, args}) {
330 const handle = this._callbacks[id];
331 if (!handle) throw new Error('Unregistered callback: ' + id);
332 handle.callback.apply(null, args);
333 }
334
335 _registerCallback(callback, handle) {
336 handle = handle || {};
337 handle.callback = callback;
338 handle.id = `cb${++this._idCounter}`;
339 this._callbacks[handle.id] = handle;
340 return handle.id;
341 }
342
343 _nullifyCallback(id) {
344 this._callbacks[id].callback = _.noop;
345 }
346
347 _deregisterCallback(id) {
348 delete this._callbacks[id];
349 }
350
351 _findAndRemoveCallbackId(callback, predicate) {
352 if (!callback.__callbackIds) return;
353 let i = 0;
354 while (i < callback.__callbackIds.length) {
355 const id = callback.__callbackIds[i];
356 const handle = this._callbacks[id];
357 if (!handle) {
358 callback.__callbackIds.splice(i, 1);
359 continue;
360 }
361 if (predicate(handle)) {
362 callback.__callbackIds.splice(i, 1);
363 return id;
364 }
365 i += 1;
366 }
367 }
368}
369
370
371function errorFromJson(json, params) {
372 if (!json || _.isError(json)) return json;
373 const error = new Error(json.message);
374 error.params = params;
375 for (const propertyName in json) {
376 if (propertyName === 'message' || !json.hasOwnProperty(propertyName)) continue;
377 try {
378 error[propertyName] = json[propertyName];
379 } catch (e) {
380 error.extra = error.extra || {};
381 error.extra[propertyName] = json[propertyName];
382 }
383 }
384 return error;
385}