Bug 1083410 - Obtain a web manifest. r=mconley.
authorMarcos Caceres <marcos@marcosc.com>
Wed, 22 Apr 2015 19:46:00 +0200
changeset 240719 b90ed17f245dbe7da75228e82c3a6fd0af1f06a5
parent 240682 2274f5b28b31b43d083e00edbd680884e4749654
child 240720 e73f7d6a138df135082601eb1c47e3ef2a90c477
push id28644
push userryanvm@gmail.com
push dateThu, 23 Apr 2015 21:10:29 +0000
treeherdermozilla-central@22a157f7feb7 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmconley
bugs1083410
milestone40.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1083410 - Obtain a web manifest. r=mconley.
browser/base/content/browser.js
dom/ipc/jar.mn
dom/ipc/manifestMessages.js
dom/manifest/ManifestObtainer.jsm
dom/manifest/moz.build
dom/manifest/test/browser.ini
dom/manifest/test/browser_ManifestObtainer_obtain.js
dom/manifest/test/common.js
dom/manifest/test/manifestLoader.html
dom/manifest/test/mochitest.ini
dom/manifest/test/resource.sjs
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -945,16 +945,17 @@ var gBrowserInit = {
     BrowserOnClick.init();
     DevEdition.init();
     AboutPrivateBrowsingListener.init();
 
     let mm = window.getGroupMessageManager("browsers");
     mm.loadFrameScript("chrome://browser/content/tab-content.js", true);
     mm.loadFrameScript("chrome://browser/content/content.js", true);
     mm.loadFrameScript("chrome://browser/content/content-UITour.js", true);
+    mm.loadFrameScript("chrome://global/content/manifestMessages.js", true);
 
     window.messageManager.addMessageListener("Browser:LoadURI", RedirectLoad);
 
     // initialize observers and listeners
     // and give C++ access to gBrowser
     XULBrowserWindow.init();
     window.QueryInterface(Ci.nsIInterfaceRequestor)
           .getInterface(nsIWebNavigation)
--- a/dom/ipc/jar.mn
+++ b/dom/ipc/jar.mn
@@ -4,11 +4,12 @@
 
 toolkit.jar:
         content/global/test-ipc.xul (test.xul)
         content/global/remote-test-ipc.js (remote-test.js)
         content/global/BrowserElementChild.js (../browser-element/BrowserElementChild.js)
         content/global/BrowserElementChildPreload.js (../browser-element/BrowserElementChildPreload.js)
         content/global/BrowserElementPanning.js (../browser-element/BrowserElementPanning.js)
 *       content/global/BrowserElementPanningAPZDisabled.js (../browser-element/BrowserElementPanningAPZDisabled.js)
+        content/global/manifestMessages.js (manifestMessages.js)
         content/global/PushServiceChildPreload.js (../push/PushServiceChildPreload.js)
         content/global/preload.js (preload.js)
         content/global/post-fork-preload.js (post-fork-preload.js)
new file mode 100644
--- /dev/null
+++ b/dom/ipc/manifestMessages.js
@@ -0,0 +1,119 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.*/
+/*
+ * Manifest obtainer frame script implementation of:
+ * http://w3c.github.io/manifest/#obtaining
+ *
+ * It searches a top-level browsing context for
+ * a <link rel=manifest> element. Then fetches
+ * and processes the linked manifest.
+ *
+ * BUG: https://bugzilla.mozilla.org/show_bug.cgi?id=1083410
+ * exported ManifestObtainer
+ */
+/*globals content, ManifestProcessor, XPCOMUtils, sendAsyncMessage, addMessageListener, Components*/
+'use strict';
+const {
+  utils: Cu
+} = Components;
+
+Cu.import('resource://gre/modules/XPCOMUtils.jsm');
+
+XPCOMUtils.defineLazyModuleGetter(this, 'ManifestProcessor',
+  'resource://gre/modules/ManifestProcessor.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'ManifestObtainer',
+  'resource://gre/modules/ManifestObtainer.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'BrowserUtils',
+  'resource://gre/modules/BrowserUtils.jsm');
+
+addMessageListener('DOM:ManifestObtainer:Obtain', (aMsg) => {
+  fetchManifest()
+    .then(
+      manifest => sendAsyncMessage('DOM:ManifestObtainer:Obtain', {
+        success: true,
+        result: manifest,
+        msgId: aMsg.data.msgId
+      }),
+      error => sendAsyncMessage('DOM:ManifestObtainer:Obtain', {
+        success: false,
+        result: cloneError(error),
+        msgId: aMsg.data.msgId
+      })
+    );
+});
+
+function cloneError(aError) {
+  const clone = {
+    'fileName': String(aError.fileName),
+    'lineNumber': String(aError.lineNumber),
+    'columnNumber': String(aError.columnNumber),
+    'stack': String(aError.stack),
+    'message': String(aError.message),
+    'name': String(aError.name)
+  };
+  return clone;
+}
+
+function fetchManifest() {
+  const manifestQuery = 'link[rel~="manifest"]';
+  return new Promise((resolve, reject) => {
+    if (!content || content.top !== content) {
+      let msg = 'Content window must be a top-level browsing context.';
+      return reject(new Error(msg));
+    }
+    const elem = content.document.querySelector(manifestQuery);
+    if (!elem || !elem.getAttribute('href')) {
+      let msg = 'No manifest to fetch.';
+      return reject(new Error(msg));
+    }
+    // Will throw on "about:blank" and possibly other invalid URIs.
+    const manifestURL = new content.URL(elem.href, elem.baseURI);
+    const reqInit = {};
+    switch (elem.crossOrigin) {
+      case 'use-credentials':
+        reqInit.credentials = 'include';
+        reqInit.mode = 'cors';
+        break;
+      case 'anonymous':
+        reqInit.credentials = 'omit';
+        reqInit.mode = 'cors';
+        break;
+      default:
+        reqInit.credentials = 'same-origin';
+        reqInit.mode = 'no-cors';
+        break;
+    }
+    const req = new content.Request(manifestURL, reqInit);
+    req.setContext('manifest');
+    content
+      .fetch(req)
+      .then(resp => processResponse(resp, content))
+      .then(resolve)
+      .catch(reject);
+  });
+}
+
+function processResponse(aResp, aContentWindow) {
+  const manifestURL = aResp.url;
+  return new Promise((resolve, reject) => {
+    const badStatus = aResp.status < 200 || aResp.status >= 300;
+    if (aResp.type === 'error' || badStatus) {
+      let msg =
+        `Fetch error: ${aResp.status} - ${aResp.statusText} at ${aResp.url}`;
+      return reject(new Error(msg));
+    }
+    aResp
+      .text()
+      .then((text) => {
+        const args = {
+          jsonText: text,
+          manifestURL: manifestURL,
+          docURL: aContentWindow.location.href
+        };
+        const processor = new ManifestProcessor();
+        const manifest = processor.process(args);
+        resolve(Cu.cloneInto(manifest, content));
+      }, reject);
+  });
+}
new file mode 100644
--- /dev/null
+++ b/dom/manifest/ManifestObtainer.jsm
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * ManifestObtainer is an implementation of:
+ * http://w3c.github.io/manifest/#obtaining
+ *
+ * Exposes public method `.obtainManifest(browserWindow)`, which returns
+ * a promise. If successful, you get back a manifest (string).
+ *
+ * For e10s compat, this JSM relies on the following to do
+ * the nessesary IPC:
+ *   dom/ipc/manifestMessages.js
+ *
+ * whose internal URL is:
+ *   'chrome://global/content/manifestMessages.js'
+ *
+ * Which is injected into every browser instance via browser.js.
+ *
+ * BUG: https://bugzilla.mozilla.org/show_bug.cgi?id=1083410
+ * exported ManifestObtainer
+ */
+'use strict';
+this.EXPORTED_SYMBOLS = ['ManifestObtainer'];
+
+const MSG_KEY = 'DOM:ManifestObtainer:Obtain';
+let messageCounter = 0;
+// FIXME: Ideally, we would store a reference to the
+//        message manager in a weakmap instead of needing a
+//        browserMap. However, trying to store a messageManager
+//        results in a TypeError because of:
+//        https://bugzilla.mozilla.org/show_bug.cgi?id=888600
+const browsersMap = new WeakMap();
+
+function ManifestObtainer() {}
+
+ManifestObtainer.prototype = {
+  obtainManifest(aBrowserWindow) {
+    if (!aBrowserWindow) {
+      const err = new TypeError('Invalid input. Expected xul browser.');
+      return Promise.reject(err);
+    }
+    const mm = aBrowserWindow.messageManager;
+    const onMessage = function(aMsg) {
+      const msgId = aMsg.data.msgId;
+      const {
+        resolve, reject
+      } = browsersMap.get(aBrowserWindow).get(msgId);
+      browsersMap.get(aBrowserWindow).delete(msgId);
+      // If we we've processed all messages,
+      // stop listening.
+      if (!browsersMap.get(aBrowserWindow).size) {
+        browsersMap.delete(aBrowserWindow);
+        mm.removeMessageListener(MSG_KEY, onMessage);
+      }
+      if (aMsg.data.success) {
+        return resolve(aMsg.data.result);
+      }
+      reject(toError(aMsg.data.result));
+    };
+    // If we are not already listening for messages
+    // start listening.
+    if (!browsersMap.has(aBrowserWindow)) {
+      browsersMap.set(aBrowserWindow, new Map());
+      mm.addMessageListener(MSG_KEY, onMessage);
+    }
+    return new Promise((resolve, reject) => {
+      const msgId = messageCounter++;
+      browsersMap.get(aBrowserWindow).set(msgId, {
+        resolve: resolve,
+        reject: reject
+      });
+      mm.sendAsyncMessage(MSG_KEY, {
+        msgId: msgId
+      });
+    });
+
+    function toError(aErrorClone) {
+      const error = new Error();
+      Object.getOwnPropertyNames(aErrorClone)
+        .forEach(name => error[name] = aErrorClone[name]);
+      return error;
+    }
+  }
+};
--- a/dom/manifest/moz.build
+++ b/dom/manifest/moz.build
@@ -1,12 +1,13 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
+    'ManifestObtainer.jsm',
     'ManifestProcessor.jsm',
 ]
 
 MOCHITEST_MANIFESTS += ['test/mochitest.ini']
-# BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/manifest/test/browser.ini
@@ -0,0 +1,2 @@
+[DEFAULT]
+[browser_ManifestObtainer_obtain.js]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/manifest/test/browser_ManifestObtainer_obtain.js
@@ -0,0 +1,210 @@
+//Used by JSHint:
+/*global Cu, ManifestObtainer, BrowserTestUtils, add_task, SpecialPowers, todo_is, gBrowser, Assert*/
+'use strict';
+Cu.import('resource://gre/modules/ManifestObtainer.jsm', this);
+requestLongerTimeout(4); // e10s tests take time.
+const defaultURL =
+  'http://example.org/tests/dom/manifest/test/resource.sjs';
+const remoteURL =
+  'http://mochi.test:8888/tests/dom/manifest/test/resource.sjs';
+const tests = [
+  // Fetch tests.
+  {
+    expected: 'Manifest is first `link` where @rel contains token manifest.',
+    get tabURL() {
+      let query = [
+        `body=<h1>${this.expected}</h1>`,
+        'Content-Type=text/html; charset=utf-8',
+      ];
+      const URL = `${defaultURL}?${query.join('&')}`;
+      return URL;
+    },
+    run(manifest) {
+      Assert.strictEqual(manifest.name, 'pass-1', this.expected);
+    },
+    testData: `
+      <link rel="manifesto" href='${defaultURL}?body={"name":"fail"}'>
+      <link rel="foo bar manifest bar test" href='${defaultURL}?body={"name":"pass-1"}'>
+      <link rel="manifest" href='${defaultURL}?body={"name":"fail"}'>`
+  }, {
+    expected: 'Manifest is first `link` where @rel contains token manifest.',
+    get tabURL() {
+      let query = [
+        `body=<h1>${this.expected}</h1>`,
+        'Content-Type=text/html; charset=utf-8',
+      ];
+      const URL = `${defaultURL}?${query.join('&')}`;
+      return URL;
+    },
+    run(manifest) {
+      Assert.strictEqual(manifest.name, 'pass-2', this.expected);
+    },
+    testData: `
+      <link rel="foo bar manifest bar test" href='resource.sjs?body={"name":"pass-2"}'>
+      <link rel="manifest" href='resource.sjs?body={"name":"fail"}'>
+      <link rel="manifest foo bar test" href='resource.sjs?body={"name":"fail"}'>`
+  }, {
+    expected: 'By default, manifest load cross-origin.',
+    get tabURL() {
+      let query = [
+        `body=<h1>${this.expected}</h1>`,
+        'Content-Type=text/html; charset=utf-8',
+      ];
+      const URL = `${defaultURL}?${query.join('&')}`;
+      return URL;
+    },
+    run(manifest) {
+      // Waiting on https://bugzilla.mozilla.org/show_bug.cgi?id=1130924
+      todo_is(manifest.name, 'pass-3', this.expected);
+    },
+    testData: `<link rel="manifest" href='${remoteURL}?body={"name":"pass-3"}'>`
+  },
+  // CORS Tests.
+  {
+    expected: 'CORS enabled, manifest must be fetched.',
+    get tabURL() {
+      let query = [
+        `body=<h1>${this.expected}</h1>`,
+        'Content-Type=text/html; charset=utf-8',
+      ];
+      const URL = `${defaultURL}?${query.join('&')}`;
+      return URL;
+    },
+    run(manifest) {
+      Assert.strictEqual(manifest.name, 'pass-4', this.expected);
+    },
+    get testData() {
+      const body = 'body={"name": "pass-4"}';
+      const CORS =
+        `Access-Control-Allow-Origin=${new URL(this.tabURL).origin}`;
+      const link =
+        `<link
+        crossorigin=anonymous
+        rel="manifest"
+        href='${remoteURL}?${body}&${CORS}'>`;
+      return link;
+    }
+  }, {
+    expected: 'Fetch blocked by CORS - origin does not match.',
+    get tabURL() {
+      let query = [
+        `body=<h1>${this.expected}</h1>`,
+        'Content-Type=text/html; charset=utf-8',
+      ];
+      const URL = `${defaultURL}?${query.join('&')}`;
+      return URL;
+    },
+    run(err) {
+      Assert.strictEqual(err.name, 'TypeError', this.expected);
+    },
+    get testData() {
+      const body = 'body={"name": "fail"}';
+      const CORS = 'Access-Control-Allow-Origin=http://not-here';
+      const link =
+        `<link
+        crossorigin
+        rel="manifest"
+        href='${remoteURL}?${body}&${CORS}'>`;
+      return link;
+    }
+  },
+];
+
+add_task(function*() {
+  yield new Promise(resolve => {
+    SpecialPowers.pushPrefEnv({
+      'set': [
+        ['dom.fetch.enabled', true]
+      ]
+    }, resolve);
+  });
+  for (let test of tests) {
+    let tabOptions = {
+      gBrowser: gBrowser,
+      url: test.tabURL,
+    };
+    yield BrowserTestUtils.withNewTab(
+      tabOptions,
+      browser => testObtainingManifest(browser, test)
+    );
+  }
+
+  function* testObtainingManifest(aBrowser, aTest) {
+    const obtainer = new ManifestObtainer();
+    aBrowser.contentWindowAsCPOW.document.head.innerHTML = aTest.testData;
+    try {
+      const manifest = yield obtainer.obtainManifest(aBrowser);
+      aTest.run(manifest);
+    } catch (e) {
+      aTest.run(e);
+    }
+  }
+});
+
+/*
+ * e10s race condition tests
+ * Open a bunch of tabs and load manifests
+ * in each tab. They should all return pass.
+ */
+add_task(function*() {
+  const obtainer = new ManifestObtainer();
+  const defaultPath = '/tests/dom/manifest/test/manifestLoader.html';
+  const tabURLs = [
+    `http://test:80${defaultPath}`,
+    `http://mochi.test:8888${defaultPath}`,
+    `http://test1.mochi.test:8888${defaultPath}`,
+    `http://sub1.test1.mochi.test:8888${defaultPath}`,
+    `http://sub2.xn--lt-uia.mochi.test:8888${defaultPath}`,
+    `http://test2.mochi.test:8888${defaultPath}`,
+    `http://example.org:80${defaultPath}`,
+    `http://test1.example.org:80${defaultPath}`,
+    `http://test2.example.org:80${defaultPath}`,
+    `http://sub1.test1.example.org:80${defaultPath}`,
+    `http://sub1.test2.example.org:80${defaultPath}`,
+    `http://sub2.test1.example.org:80${defaultPath}`,
+    `http://sub2.test2.example.org:80${defaultPath}`,
+    `http://example.org:8000${defaultPath}`,
+    `http://test1.example.org:8000${defaultPath}`,
+    `http://test2.example.org:8000${defaultPath}`,
+    `http://sub1.test1.example.org:8000${defaultPath}`,
+    `http://sub1.test2.example.org:8000${defaultPath}`,
+    `http://sub2.test1.example.org:8000${defaultPath}`,
+    `http://sub2.test2.example.org:8000${defaultPath}`,
+    `http://example.com:80${defaultPath}`,
+    `http://www.example.com:80${defaultPath}`,
+    `http://test1.example.com:80${defaultPath}`,
+    `http://test2.example.com:80${defaultPath}`,
+    `http://sub1.test1.example.com:80${defaultPath}`,
+    `http://sub1.test2.example.com:80${defaultPath}`,
+    `http://sub2.test1.example.com:80${defaultPath}`,
+    `http://sub2.test2.example.com:80${defaultPath}`,
+  ];
+  // Open tabs an collect corresponding browsers
+  let browsers = [
+    for (url of tabURLs) gBrowser.addTab(url).linkedBrowser
+  ];
+  // Once all the pages have loaded, run a bunch of tests in "parallel".
+  yield Promise.all((
+    for (browser of browsers) BrowserTestUtils.browserLoaded(browser)
+  ));
+  // Flood random browsers with requests. Once promises settle, check that
+  // responses all pass.
+  const results = yield Promise.all((
+    for (browser of randBrowsers(browsers, 1000)) obtainer.obtainManifest(browser)
+  ));
+  const expected = 'Expect every manifest to have name equal to `pass`.';
+  const pass = results.every(manifest => manifest.name === 'pass');
+  Assert.ok(pass, expected);
+  //cleanup
+  browsers
+    .map(browser => gBrowser.getTabForBrowser(browser))
+    .forEach(tab => gBrowser.removeTab(tab));
+
+  //Helper generator, spits out random browsers
+  function* randBrowsers(aBrowsers, aMax) {
+    for (let i = 0; i < aMax; i++) {
+      const randNum = Math.round(Math.random() * (aBrowsers.length - 1));
+      yield aBrowsers[randNum];
+    }
+  }
+});
--- a/dom/manifest/test/common.js
+++ b/dom/manifest/test/common.js
@@ -1,17 +1,17 @@
 /**
  * Common infrastructure for manifest tests.
  **/
 
 'use strict';
 const bsp = SpecialPowers.Cu.import('resource://gre/modules/ManifestProcessor.jsm'),
   processor = new bsp.ManifestProcessor(),
   manifestURL = new URL(document.location.origin + '/manifest.json'),
-  docURL = new URL('', document.location.origin),
+  docURL = document.location,
   seperators = '\u2028\u2029\u0020\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000',
   lineTerminators = '\u000D\u000A\u2028\u2029',
   whiteSpace = `${seperators}${lineTerminators}`,
   typeTests = [1, null, {},
     [], false
   ],
   data = {
     jsonText: '{}',
new file mode 100644
--- /dev/null
+++ b/dom/manifest/test/manifestLoader.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<meta charset=utf-8>
+<!--
+Uses resource.sjs to load a Web Manifest that can be loaded cross-origin.
+-->
+<link rel="manifest" href='resource.sjs?body={"name":"pass"}&amp;Access-Control-Allow-Origin=*'>
+<h1>Manifest loader</h1>
+<p>Uses resource.sjs to load a Web Manifest that can be loaded cross-origin. The manifest looks like this:</p>
+<pre>
+{
+	"name":"pass"
+}
+</pre>
--- a/dom/manifest/test/mochitest.ini
+++ b/dom/manifest/test/mochitest.ini
@@ -1,12 +1,13 @@
 [DEFAULT]
-
 support-files =
 	common.js
+	resource.sjs
+	manifestLoader.html
 
 [test_IconsProcessor_density.html]
 [test_IconsProcessor_sizes.html]
 [test_IconsProcessor_src.html]
 [test_IconsProcessor_type.html]
 [test_ManifestProcessor_display.html]
 [test_ManifestProcessor_icons.html]
 [test_ManifestProcessor_JSON.html]
new file mode 100644
--- /dev/null
+++ b/dom/manifest/test/resource.sjs
@@ -0,0 +1,93 @@
+/* Generic responder that composes a response from
+ * the query string of a request.
+ *
+ * It reserves some special prop names:
+ *  - body: get's used as the response body
+ *  - statusCode: override the 200 OK response code
+ *    (response text is set automatically)
+ *
+ * Any property names it doesn't know about get converted into
+ * HTTP headers.
+ *
+ * For example:
+ *  http://test/resource.sjs?Content-Type=text/html&body=<h1>hello</h1>&Hello=hi
+ *
+ * Outputs:
+ * HTTP/1.1 200 OK
+ * Content-Type: text/html
+ * Hello: hi
+ * <h1>hello</h1>
+ */
+//global handleRequest
+'use strict';
+const HTTPStatus = new Map([
+  [100, 'Continue'],
+  [101, 'Switching Protocol'],
+  [200, 'OK'],
+  [201, 'Created'],
+  [202, 'Accepted'],
+  [203, 'Non-Authoritative Information'],
+  [204, 'No Content'],
+  [205, 'Reset Content'],
+  [206, 'Partial Content'],
+  [300, 'Multiple Choice'],
+  [301, 'Moved Permanently'],
+  [302, 'Found'],
+  [303, 'See Other'],
+  [304, 'Not Modified'],
+  [305, 'Use Proxy'],
+  [306, 'unused'],
+  [307, 'Temporary Redirect'],
+  [308, 'Permanent Redirect'],
+  [400, 'Bad Request'],
+  [401, 'Unauthorized'],
+  [402, 'Payment Required'],
+  [403, 'Forbidden'],
+  [404, 'Not Found'],
+  [405, 'Method Not Allowed'],
+  [406, 'Not Acceptable'],
+  [407, 'Proxy Authentication Required'],
+  [408, 'Request Timeout'],
+  [409, 'Conflict'],
+  [410, 'Gone'],
+  [411, 'Length Required'],
+  [412, 'Precondition Failed'],
+  [413, 'Request Entity Too Large'],
+  [414, 'Request-URI Too Long'],
+  [415, 'Unsupported Media Type'],
+  [416, 'Requested Range Not Satisfiable'],
+  [417, 'Expectation Failed'],
+  [500, 'Internal Server Error'],
+  [501, 'Not Implemented'],
+  [502, 'Bad Gateway'],
+  [503, 'Service Unavailable'],
+  [504, 'Gateway Timeout'],
+  [505, 'HTTP Version Not Supported']
+]);
+
+function handleRequest(request, response) {
+  const queryMap = createQueryMap(request);
+  if (queryMap.has('statusCode')) {
+    let statusCode = parseInt(queryMap.get('statusCode'));
+    let statusText = HTTPStatus.get(statusCode);
+    queryMap.delete('statusCode');
+    response.setStatusLine('1.1', statusCode, statusText);
+  }
+  if (queryMap.has('body')) {
+    let body = queryMap.get('body') || '';
+    queryMap.delete('body');
+    response.write(body);
+  }
+  for (let [key, value] of queryMap) {
+    response.setHeader(key, value);
+  }
+
+  function createQueryMap(request) {
+    const queryMap = new Map();
+    request.queryString.split('&')
+      //split on first "="
+      .map((component) => component.split(/=(.+)?/))
+      .forEach(pair => queryMap.set(pair[0], decodeURIComponent(pair[1])));
+    return queryMap;
+  }
+}