PushBroadcastService.sys.mjs (8695B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 JSONFile: "resource://gre/modules/JSONFile.sys.mjs", 8 PushService: "resource://gre/modules/PushService.sys.mjs", 9 }); 10 11 // BroadcastService is exported for test purposes. 12 // We are supposed to ignore any updates with this version. 13 const DUMMY_VERSION_STRING = "____NOP____"; 14 15 ChromeUtils.defineLazyGetter(lazy, "console", () => { 16 return console.createInstance({ 17 maxLogLevelPref: "dom.push.loglevel", 18 prefix: "BroadcastService", 19 }); 20 }); 21 22 class InvalidSourceInfo extends Error { 23 constructor(message) { 24 super(message); 25 this.name = "InvalidSourceInfo"; 26 } 27 } 28 29 const BROADCAST_SERVICE_VERSION = 1; 30 31 export var BroadcastService = class { 32 constructor(pushService, path) { 33 this.PHASES = { 34 HELLO: "hello", 35 REGISTER: "register", 36 BROADCAST: "broadcast", 37 }; 38 39 this.pushService = pushService; 40 this.jsonFile = new lazy.JSONFile({ 41 path, 42 dataPostProcessor: this._initializeJSONFile, 43 }); 44 this.initializePromise = this.jsonFile.load(); 45 } 46 47 /** 48 * Convert the listeners from our on-disk format to the format 49 * needed by a hello message. 50 */ 51 async getListeners() { 52 await this.initializePromise; 53 return Object.entries(this.jsonFile.data.listeners).reduce( 54 (acc, [k, v]) => { 55 acc[k] = v.version; 56 return acc; 57 }, 58 {} 59 ); 60 } 61 62 _initializeJSONFile(data) { 63 if (!data.version) { 64 data.version = BROADCAST_SERVICE_VERSION; 65 } 66 if (!data.hasOwnProperty("listeners")) { 67 data.listeners = {}; 68 } 69 return data; 70 } 71 72 /** 73 * Reset to a state akin to what you would get in a new profile. 74 * In particular, wipe anything from storage. 75 * 76 * Used mainly for testing. 77 */ 78 async _resetListeners() { 79 await this.initializePromise; 80 this.jsonFile.data = this._initializeJSONFile({}); 81 this.initializePromise = Promise.resolve(); 82 } 83 84 /** 85 * Ensure that a sourceInfo is correct (has the expected fields). 86 */ 87 _validateSourceInfo(sourceInfo) { 88 const { moduleURI, symbolName } = sourceInfo; 89 if (typeof moduleURI !== "string") { 90 throw new InvalidSourceInfo( 91 `moduleURI must be a string (got ${typeof moduleURI})` 92 ); 93 } 94 if (typeof symbolName !== "string") { 95 throw new InvalidSourceInfo( 96 `symbolName must be a string (got ${typeof symbolName})` 97 ); 98 } 99 } 100 101 /** 102 * Add an entry for a given listener if it isn't present, or update 103 * one if it is already present. 104 * 105 * Note that this means only a single listener can be set for a 106 * given subscription. This is a limitation in the current API that 107 * stems from the fact that there can only be one source of truth 108 * for the subscriber's version. As a workaround, you can define a 109 * listener which calls multiple other listeners. 110 * 111 * @param {string} broadcastId The broadcastID to listen for 112 * @param {string} version The most recent version we have for 113 * updates from this broadcastID 114 * @param {object} sourceInfo A description of the handler for 115 * updates on this broadcastID 116 */ 117 async addListener(broadcastId, version, sourceInfo) { 118 lazy.console.info( 119 "addListener: adding listener", 120 broadcastId, 121 version, 122 sourceInfo 123 ); 124 await this.initializePromise; 125 this._validateSourceInfo(sourceInfo); 126 if (typeof version !== "string") { 127 throw new TypeError("version should be a string"); 128 } 129 if (!version) { 130 throw new TypeError("version should not be an empty string"); 131 } 132 133 const isNew = !this.jsonFile.data.listeners.hasOwnProperty(broadcastId); 134 const oldVersion = 135 !isNew && this.jsonFile.data.listeners[broadcastId].version; 136 if (!isNew && oldVersion != version) { 137 lazy.console.warn( 138 "Versions differ while adding listener for", 139 broadcastId, 140 ". Got", 141 version, 142 "but JSON file says", 143 oldVersion, 144 "." 145 ); 146 } 147 148 // Update listeners before telling the pushService to subscribe, 149 // in case it would disregard the update in the small window 150 // between getting listeners and setting state to RUNNING. 151 // 152 // Keep the old version (if we have it) because Megaphone is 153 // really the source of truth for the current version of this 154 // broadcaster, and the old version is whatever we've either 155 // gotten from Megaphone or what we've told to Megaphone and 156 // haven't been corrected. 157 this.jsonFile.data.listeners[broadcastId] = { 158 version: oldVersion || version, 159 sourceInfo, 160 }; 161 this.jsonFile.saveSoon(); 162 163 if (isNew) { 164 await this.pushService.subscribeBroadcast(broadcastId, version); 165 } 166 } 167 168 /** 169 * Call the listeners of the specified broadcasts. 170 * 171 * @param {Array<object>} broadcasts Map between broadcast ids and versions. 172 * @param {object} context Additional information about the context in which the 173 * broadcast notification was originally received. This is transmitted to listeners. 174 * @param {string} context.phase One of `BroadcastService.PHASES` 175 */ 176 async receivedBroadcastMessage(broadcasts, context) { 177 lazy.console.info("receivedBroadcastMessage:", broadcasts, context); 178 await this.initializePromise; 179 for (const broadcastId in broadcasts) { 180 const version = broadcasts[broadcastId]; 181 if (version === DUMMY_VERSION_STRING) { 182 lazy.console.info( 183 "Ignoring", 184 version, 185 "because it's the dummy version" 186 ); 187 continue; 188 } 189 // We don't know this broadcastID. This is probably a bug? 190 if (!this.jsonFile.data.listeners.hasOwnProperty(broadcastId)) { 191 lazy.console.warn( 192 "receivedBroadcastMessage: unknown broadcastId", 193 broadcastId 194 ); 195 continue; 196 } 197 198 const { sourceInfo } = this.jsonFile.data.listeners[broadcastId]; 199 try { 200 this._validateSourceInfo(sourceInfo); 201 } catch (e) { 202 lazy.console.error( 203 "receivedBroadcastMessage: malformed sourceInfo", 204 sourceInfo, 205 e 206 ); 207 continue; 208 } 209 210 const { moduleURI, symbolName } = sourceInfo; 211 212 let module; 213 try { 214 module = ChromeUtils.importESModule(moduleURI); 215 } catch (e) { 216 lazy.console.error( 217 "receivedBroadcastMessage: couldn't invoke", 218 broadcastId, 219 "because import of module", 220 moduleURI, 221 "failed", 222 e 223 ); 224 continue; 225 } 226 227 if (!module[symbolName]) { 228 lazy.console.error( 229 "receivedBroadcastMessage: couldn't invoke", 230 broadcastId, 231 "because module", 232 moduleURI, 233 "missing attribute", 234 symbolName 235 ); 236 continue; 237 } 238 239 const handler = module[symbolName]; 240 241 if (!handler.receivedBroadcastMessage) { 242 lazy.console.error( 243 "receivedBroadcastMessage: couldn't invoke", 244 broadcastId, 245 "because handler returned by", 246 `${moduleURI}.${symbolName}`, 247 "has no receivedBroadcastMessage method" 248 ); 249 continue; 250 } 251 252 try { 253 await handler.receivedBroadcastMessage(version, broadcastId, context); 254 } catch (e) { 255 lazy.console.error( 256 "receivedBroadcastMessage: handler for", 257 broadcastId, 258 "threw error:", 259 e 260 ); 261 continue; 262 } 263 264 // Broadcast message applied successfully. Update the version we 265 // received if it's different than the one we had. We don't 266 // enforce an ordering here (i.e. we use != instead of <) 267 // because we don't know what the ordering of the service's 268 // versions is going to be. 269 if (this.jsonFile.data.listeners[broadcastId].version != version) { 270 this.jsonFile.data.listeners[broadcastId].version = version; 271 this.jsonFile.saveSoon(); 272 } 273 } 274 } 275 276 // For test only. 277 _saveImmediately() { 278 return this.jsonFile._save(); 279 } 280 }; 281 282 function initializeBroadcastService() { 283 // Fallback path for xpcshell tests. 284 let path = "broadcast-listeners.json"; 285 try { 286 if (PathUtils.profileDir) { 287 // Real path for use in a real profile. 288 path = PathUtils.join(PathUtils.profileDir, path); 289 } 290 } catch (e) {} 291 return new BroadcastService(lazy.PushService, path); 292 } 293 294 export var pushBroadcastService = initializeBroadcastService();