resource.sys.mjs (8259B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 import { Log } from "resource://gre/modules/Log.sys.mjs"; 8 9 import { Observers } from "resource://services-common/observers.sys.mjs"; 10 import { CommonUtils } from "resource://services-common/utils.sys.mjs"; 11 import { Utils } from "resource://services-sync/util.sys.mjs"; 12 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; 13 14 /* 15 * Resource represents a remote network resource, identified by a URI. 16 * Create an instance like so: 17 * 18 * let resource = new Resource("http://foobar.com/path/to/resource"); 19 * 20 * The 'resource' object has the following methods to issue HTTP requests 21 * of the corresponding HTTP methods: 22 * 23 * get(callback) 24 * put(data, callback) 25 * post(data, callback) 26 * delete(callback) 27 */ 28 export function Resource(uri) { 29 this._log = Log.repository.getLogger(this._logName); 30 this._log.manageLevelFromPref("services.sync.log.logger.network.resources"); 31 this.uri = uri; 32 this._headers = {}; 33 } 34 35 // (static) Caches the latest server timestamp (X-Weave-Timestamp header). 36 Resource.serverTime = null; 37 38 XPCOMUtils.defineLazyPreferenceGetter( 39 Resource, 40 "SEND_VERSION_INFO", 41 "services.sync.sendVersionInfo", 42 true 43 ); 44 Resource.prototype = { 45 _logName: "Sync.Resource", 46 47 /** 48 * Callback to be invoked at request time to add authentication details. 49 * If the callback returns a promise, it will be awaited upon. 50 * 51 * By default, a global authenticator is provided. If this is set, it will 52 * be used instead of the global one. 53 */ 54 authenticator: null, 55 56 // Wait 5 minutes before killing a request. 57 ABORT_TIMEOUT: 300000, 58 59 // Headers to be included when making a request for the resource. 60 // Note: Header names should be all lower case, there's no explicit 61 // check for duplicates due to case! 62 get headers() { 63 return this._headers; 64 }, 65 set headers(_) { 66 throw new Error("headers can't be mutated directly. Please use setHeader."); 67 }, 68 setHeader(header, value) { 69 this._headers[header.toLowerCase()] = value; 70 }, 71 72 // URI representing this resource. 73 get uri() { 74 return this._uri; 75 }, 76 set uri(value) { 77 if (typeof value == "string") { 78 this._uri = CommonUtils.makeURI(value); 79 } else { 80 this._uri = value; 81 } 82 }, 83 84 // Get the string representation of the URI. 85 get spec() { 86 if (this._uri) { 87 return this._uri.spec; 88 } 89 return null; 90 }, 91 92 /** 93 * @param {string} method HTTP method 94 * @returns {Headers} 95 */ 96 async _buildHeaders(method) { 97 const headers = new Headers(this._headers); 98 99 if (Resource.SEND_VERSION_INFO) { 100 headers.append("user-agent", Utils.userAgent); 101 } 102 103 if (this.authenticator) { 104 const result = await this.authenticator(this, method); 105 if (result && result.headers) { 106 for (const [k, v] of Object.entries(result.headers)) { 107 headers.append(k.toLowerCase(), v); 108 } 109 } 110 } else { 111 this._log.debug("No authenticator found."); 112 } 113 114 // PUT and POST are treated differently because they have payload data. 115 if (("PUT" == method || "POST" == method) && !headers.has("content-type")) { 116 headers.append("content-type", "text/plain"); 117 } 118 119 if (this._log.level <= Log.Level.Trace) { 120 for (const [k, v] of headers) { 121 if (k == "authorization" || k == "x-client-state") { 122 this._log.trace(`HTTP Header ${k}: ***** (suppressed)`); 123 } else { 124 this._log.trace(`HTTP Header ${k}: ${v}`); 125 } 126 } 127 } 128 129 if (!headers.has("accept")) { 130 headers.append("accept", "application/json;q=0.9,*/*;q=0.2"); 131 } 132 133 return headers; 134 }, 135 136 /** 137 * @param {string} method HTTP method 138 * @param {string} data HTTP body 139 * @param {object} signal AbortSignal instance 140 * @returns {Request} 141 */ 142 async _createRequest(method, data, signal) { 143 const headers = await this._buildHeaders(method); 144 const init = { 145 cache: "no-store", // No cache. 146 headers, 147 method, 148 signal, 149 mozErrors: true, // Return nsresult error codes instead of a generic 150 // NetworkError when fetch rejects. 151 }; 152 153 if (data) { 154 if (!(typeof data == "string" || data instanceof String)) { 155 data = JSON.stringify(data); 156 } 157 this._log.debug(`${method} Length: ${data.length}`); 158 this._log.trace(`${method} Body: ${data}`); 159 init.body = data; 160 } 161 return new Request(this.uri.spec, init); 162 }, 163 164 /** 165 * @param {string} method HTTP method 166 * @param {string} [data] HTTP body 167 * @returns {Response} 168 */ 169 async _doRequest(method, data = null) { 170 const controller = new AbortController(); 171 const request = await this._createRequest(method, data, controller.signal); 172 const responsePromise = fetch(request); // Rejects on network failure. 173 let didTimeout = false; 174 const timeoutId = setTimeout(() => { 175 didTimeout = true; 176 this._log.error( 177 `Request timed out after ${this.ABORT_TIMEOUT}ms. Aborting.` 178 ); 179 controller.abort(); 180 }, this.ABORT_TIMEOUT); 181 let response; 182 try { 183 response = await responsePromise; 184 } catch (e) { 185 this._log.warn(`${method} request to ${this.uri.spec} failed`, e); 186 if (!didTimeout) { 187 throw e; 188 } 189 throw Components.Exception( 190 "Request aborted (timeout)", 191 Cr.NS_ERROR_NET_TIMEOUT 192 ); 193 } finally { 194 clearTimeout(timeoutId); 195 } 196 return this._processResponse(response, method); 197 }, 198 199 async _processResponse(response, method) { 200 const data = await response.text(); 201 this._logResponse(response, method, data); 202 this._processResponseHeaders(response); 203 204 const ret = { 205 data, 206 url: response.url, 207 status: response.status, 208 success: response.ok, 209 headers: {}, 210 }; 211 for (const [k, v] of response.headers) { 212 ret.headers[k] = v; 213 } 214 215 // Make a lazy getter to convert the json response into an object. 216 // Note that this can cause a parse error to be thrown far away from the 217 // actual fetch, so be warned! 218 ChromeUtils.defineLazyGetter(ret, "obj", () => { 219 try { 220 return JSON.parse(ret.data); 221 } catch (ex) { 222 this._log.warn("Got exception parsing response body", ex); 223 // Stringify to avoid possibly printing non-printable characters. 224 this._log.debug( 225 "Parse fail: Response body starts", 226 (ret.data + "").slice(0, 100) 227 ); 228 throw ex; 229 } 230 }); 231 232 return ret; 233 }, 234 235 _logResponse(response, method, data) { 236 const { status, ok: success, url } = response; 237 238 // Log the status of the request. 239 this._log.debug( 240 `${method} ${success ? "success" : "fail"} ${status} ${url}` 241 ); 242 243 // Additionally give the full response body when Trace logging. 244 if (this._log.level <= Log.Level.Trace) { 245 this._log.trace(`${method} body`, data); 246 } 247 248 if (!success) { 249 this._log.warn( 250 `${method} request to ${url} failed with status ${status}` 251 ); 252 } 253 }, 254 255 _processResponseHeaders({ headers, ok: success }) { 256 if (headers.has("x-weave-timestamp")) { 257 Resource.serverTime = parseFloat(headers.get("x-weave-timestamp")); 258 } 259 // This is a server-side safety valve to allow slowing down 260 // clients without hurting performance. 261 if (headers.has("x-weave-backoff")) { 262 let backoff = headers.get("x-weave-backoff"); 263 this._log.debug(`Got X-Weave-Backoff: ${backoff}`); 264 Observers.notify("weave:service:backoff:interval", parseInt(backoff, 10)); 265 } 266 267 if (success && headers.has("x-weave-quota-remaining")) { 268 Observers.notify( 269 "weave:service:quota:remaining", 270 parseInt(headers.get("x-weave-quota-remaining"), 10) 271 ); 272 } 273 }, 274 275 get() { 276 return this._doRequest("GET"); 277 }, 278 279 put(data) { 280 return this._doRequest("PUT", data); 281 }, 282 283 post(data) { 284 return this._doRequest("POST", data); 285 }, 286 287 delete() { 288 return this._doRequest("DELETE"); 289 }, 290 };