<NME> http_request.ts
<BEF> /**
 * @license
 * Copyright 2016 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {CancellationToken, uncancelableToken} from 'neuroglancer/util/cancellation';
import {simpleStringHash} from 'neuroglancer/util/hash';
import {Uint64} from 'neuroglancer/util/uint64';

export type RequestModifier = (request: XMLHttpRequest) => void;

export const URL_SYMBOL = Symbol('url');
export const METHOD_SYMBOL = Symbol('method');

export class HttpError extends Error {
  method: string;
  url: string;
  code: number;
  statusMessage: string;

  constructor(method: string, url: string, code: number, statusMessage: string) {
    let message = `${method} ${JSON.stringify(url)} resulted in HTTP error ${code}`;
    if (statusMessage) {
      message += `: ${statusMessage}`;
    }
    message += '.';
    super(message);
    this.name = 'HttpError';
    this.message = message;
    this.method = method;
    this.url = url;
    this.code = code;
    this.statusMessage = statusMessage;
  }

  static fromXhr(xhr: XMLHttpRequest) {
    return new HttpError(
        (<any>xhr)[METHOD_SYMBOL], (<any>xhr)[URL_SYMBOL], xhr.status, xhr.statusText);
  }
}

export function openHttpRequest(url: string, method = 'GET') {
  let xhr = new XMLHttpRequest();
  (<any>xhr)[METHOD_SYMBOL] = method;
  (<any>xhr)[URL_SYMBOL] = url;
  xhr.open(method, url);
  return xhr;
}

export function pickShard(baseUrls: string|string[], path: string) {
  if (Array.isArray(baseUrls)) {
    let numShards = baseUrls.length;
    let shard = numShards === 1 ? 0 : Math.abs(simpleStringHash(path)) % numShards;
    return baseUrls[shard] + path;
  }
  return baseUrls + path;
}

export function openShardedHttpRequest(baseUrls: string|string[], path: string, method = 'GET') {
  let xhr = new XMLHttpRequest();
  const url = pickShard(baseUrls, path);
  (<any>xhr)[METHOD_SYMBOL] = method;
  (<any>xhr)[URL_SYMBOL] = url;
  xhr.open(method, url);
  return xhr;
}

export function sendHttpRequest(
    xhr: XMLHttpRequest, responseType: 'arraybuffer',
    token?: CancellationToken): Promise<ArrayBuffer>;
export function sendHttpRequest(
    xhr: XMLHttpRequest, responseType: 'json', token?: CancellationToken): Promise<any>;
export function sendHttpRequest(
    xhr: XMLHttpRequest, responseType: XMLHttpRequestResponseType, token?: CancellationToken): any;
export function sendHttpRequest(
    xhr: XMLHttpRequest, responseType: XMLHttpRequestResponseType,
    token: CancellationToken = uncancelableToken) {
  xhr.responseType = responseType;
  return new Promise((resolve, reject) => {
    const abort = () => {
      xhr.abort();
    };
    token.add(abort);
    xhr.onloadend = function(this: XMLHttpRequest) {
      let status = this.status;
      token.remove(abort);
      if (status >= 200 && status < 300) {
        resolve(this.response);
      } else {
        reject(HttpError.fromXhr(xhr));
      }
    };
    xhr.send();
  });
}

const tempUint64 = new Uint64();

export function setByteRange(xhr: XMLHttpRequest, startOffset: Uint64, endOffset: Uint64) {
  Uint64.decrement(tempUint64, endOffset);
  xhr.setRequestHeader('Range', `bytes=${startOffset}-${tempUint64}`);
}

export function sendHttpJsonPostRequest(
    xhr: XMLHttpRequest, payload: any, responseType: 'arraybuffer',
    token?: CancellationToken): Promise<ArrayBuffer>;
export function sendHttpJsonPostRequest(
    xhr: XMLHttpRequest, payload: any, responseType: 'json',
    token?: CancellationToken): Promise<any>;
export function sendHttpJsonPostRequest(
    xhr: XMLHttpRequest, payload: any, responseType: XMLHttpRequestResponseType,
    token?: CancellationToken): any;

export function sendHttpJsonPostRequest(
    xhr: XMLHttpRequest, payload: any, responseType: XMLHttpRequestResponseType,
    token: CancellationToken = uncancelableToken) {
  xhr.responseType = responseType;
  xhr.setRequestHeader('Content-Type', `application/json`);
  return new Promise((resolve, reject) => {
    const abort = () => {
      xhr.abort();
    };
    token.add(abort);
    xhr.onloadend = function(this: XMLHttpRequest) {
      let status = this.status;
      token.remove(abort);
      if (status >= 200 && status < 300) {
        resolve(this.response);
      } else {
        reject(HttpError.fromXhr(xhr));
      }
    };
    xhr.send(JSON.stringify(payload));
  });
}

/**
 * Parses a URL that may have a special protocol designation into a list of base URLs and a path.
 *
 * If the protocol is 'http' or 'https', the input string is returned as a single base URL, with an
 * empty path.
 *
 * Additionally, 'gs://bucket/path' is supported for accessing Google Storage buckets.
 */
export function parseSpecialUrl(url: string): [string[], string] {
  const urlProtocolPattern = /^([^:\/]+):\/\/([^\/]+)(\/.*)?$/;
  let match = url.match(urlProtocolPattern);
  if (match === null) {
  if (typeof endOffset === 'number') {
    endOffsetStr = `${endOffset - 1}`;
  const protocol = match[1];
  if (protocol === 'gs') {
    const bucket = match[2];
    const baseUrls = [
      `https://storage.googleapis.com/${bucket}`,
    ];
    let path = match[3];
    if (path === undefined) path = '';
    return [baseUrls, path];
  }
  return [[url], ''];
}
  let match = url.match(urlProtocolPattern);
  if (match === null) {
    throw new Error(`Invalid URL: ${JSON.stringify(url)}`);
  }
  return {protocol: match[1], host: match[2], path: match[3]};
}

export function isNotFoundError(e: any) {
  if (!(e instanceof HttpError)) return false;
  // Treat CORS errors (0) or 403 as not found.  S3 returns 403 if the file does not exist because
  // permissions are per-file.
  return (e.status === 0 || e.status === 403 || e.status === 404);
}

<MSG> refactor(http_request): use fetch API in place of XMLHttpRequest

<DFF> @@ -15,145 +15,103 @@
  */
 
 import {CancellationToken, uncancelableToken} from 'neuroglancer/util/cancellation';
-import {simpleStringHash} from 'neuroglancer/util/hash';
 import {Uint64} from 'neuroglancer/util/uint64';
 
-export type RequestModifier = (request: XMLHttpRequest) => void;
-
-export const URL_SYMBOL = Symbol('url');
-export const METHOD_SYMBOL = Symbol('method');
-
 export class HttpError extends Error {
-  method: string;
   url: string;
-  code: number;
-  statusMessage: string;
+  status: number;
+  statusText: string;
 
-  constructor(method: string, url: string, code: number, statusMessage: string) {
-    let message = `${method} ${JSON.stringify(url)} resulted in HTTP error ${code}`;
-    if (statusMessage) {
-      message += `: ${statusMessage}`;
+  constructor(url: string, status: number, statusText: string) {
+    let message = `Fetching ${JSON.stringify(url)} resulted in HTTP error ${status}`;
+    if (statusText) {
+      message += `: ${statusText}`;
     }
     message += '.';
     super(message);
     this.name = 'HttpError';
     this.message = message;
-    this.method = method;
     this.url = url;
-    this.code = code;
-    this.statusMessage = statusMessage;
+    this.status = status;
+    this.statusText = statusText;
   }
 
-  static fromXhr(xhr: XMLHttpRequest) {
-    return new HttpError(
-        (<any>xhr)[METHOD_SYMBOL], (<any>xhr)[URL_SYMBOL], xhr.status, xhr.statusText);
+  static fromResponse(response: Response) {
+    return new HttpError(response.url, response.status, response.statusText);
   }
 }
 
-export function openHttpRequest(url: string, method = 'GET') {
-  let xhr = new XMLHttpRequest();
-  (<any>xhr)[METHOD_SYMBOL] = method;
-  (<any>xhr)[URL_SYMBOL] = url;
-  xhr.open(method, url);
-  return xhr;
-}
-
-export function pickShard(baseUrls: string|string[], path: string) {
-  if (Array.isArray(baseUrls)) {
-    let numShards = baseUrls.length;
-    let shard = numShards === 1 ? 0 : Math.abs(simpleStringHash(path)) % numShards;
-    return baseUrls[shard] + path;
+/**
+ * Issues a `fetch` request.
+ *
+ * If the request fails due to an HTTP status outside `[200, 300)`, throws an `HttpError`.  If the
+ * request fails due to a network or CORS restriction, throws an `HttpError` with a `status` of `0`.
+ */
+export async function fetchOk(input: RequestInfo, init?: RequestInit): Promise<Response> {
+  let response: Response;
+  try {
+    response = await fetch(input, init);
+  } catch (error) {
+    if (error instanceof TypeError) {
+      throw new HttpError('', 0, '');
+    }
+    throw error;
   }
-  return baseUrls + path;
+  if (!response.ok) throw HttpError.fromResponse(response);
+  return response;
 }
 
-export function openShardedHttpRequest(baseUrls: string|string[], path: string, method = 'GET') {
-  let xhr = new XMLHttpRequest();
-  const url = pickShard(baseUrls, path);
-  (<any>xhr)[METHOD_SYMBOL] = method;
-  (<any>xhr)[URL_SYMBOL] = url;
-  xhr.open(method, url);
-  return xhr;
+export function responseArrayBuffer(response: Response): Promise<ArrayBuffer> {
+  return response.arrayBuffer();
 }
 
-export function sendHttpRequest(
-    xhr: XMLHttpRequest, responseType: 'arraybuffer',
-    token?: CancellationToken): Promise<ArrayBuffer>;
-export function sendHttpRequest(
-    xhr: XMLHttpRequest, responseType: 'json', token?: CancellationToken): Promise<any>;
-export function sendHttpRequest(
-    xhr: XMLHttpRequest, responseType: XMLHttpRequestResponseType, token?: CancellationToken): any;
-export function sendHttpRequest(
-    xhr: XMLHttpRequest, responseType: XMLHttpRequestResponseType,
-    token: CancellationToken = uncancelableToken) {
-  xhr.responseType = responseType;
-  return new Promise((resolve, reject) => {
-    const abort = () => {
-      xhr.abort();
-    };
-    token.add(abort);
-    xhr.onloadend = function(this: XMLHttpRequest) {
-      let status = this.status;
-      token.remove(abort);
-      if (status >= 200 && status < 300) {
-        resolve(this.response);
-      } else {
-        reject(HttpError.fromXhr(xhr));
-      }
-    };
-    xhr.send();
-  });
+export function responseJson(response: Response): Promise<any> {
+  return response.json();
 }
 
-const tempUint64 = new Uint64();
+export type ResponseTransform<T> = (response: Response) => Promise<T>;
 
-export function setByteRange(xhr: XMLHttpRequest, startOffset: Uint64, endOffset: Uint64) {
-  Uint64.decrement(tempUint64, endOffset);
-  xhr.setRequestHeader('Range', `bytes=${startOffset}-${tempUint64}`);
+/**
+ * Issues a `fetch` request in the same way as `fetchOk`, and returns the result of the promise
+ * returned by `transformResponse`.
+ *
+ * Additionally, the request may be cancelled through `cancellationToken`.
+ *
+ * The `transformResponse` function should not do anything with the `Response` object after its
+ * result becomes ready; otherwise, cancellation may not work as expected.
+ */
+export async function cancellableFetchOk<T>(
+    input: RequestInfo, init: RequestInit, transformResponse: ResponseTransform<T>,
+    cancellationToken: CancellationToken = uncancelableToken): Promise<T> {
+  if (cancellationToken === uncancelableToken) {
+    const response = await fetchOk(input, init);
+    return await transformResponse(response);
+  }
+  const abortController = new AbortController();
+  const unregisterCancellation = cancellationToken.add(() => abortController.abort());
+  try {
+    const response = await fetchOk(input, init);
+    return await transformResponse(response);
+  } finally {
+    unregisterCancellation();
+  }
 }
 
-export function sendHttpJsonPostRequest(
-    xhr: XMLHttpRequest, payload: any, responseType: 'arraybuffer',
-    token?: CancellationToken): Promise<ArrayBuffer>;
-export function sendHttpJsonPostRequest(
-    xhr: XMLHttpRequest, payload: any, responseType: 'json',
-    token?: CancellationToken): Promise<any>;
-export function sendHttpJsonPostRequest(
-    xhr: XMLHttpRequest, payload: any, responseType: XMLHttpRequestResponseType,
-    token?: CancellationToken): any;
+const tempUint64 = new Uint64();
 
-export function sendHttpJsonPostRequest(
-    xhr: XMLHttpRequest, payload: any, responseType: XMLHttpRequestResponseType,
-    token: CancellationToken = uncancelableToken) {
-  xhr.responseType = responseType;
-  xhr.setRequestHeader('Content-Type', `application/json`);
-  return new Promise((resolve, reject) => {
-    const abort = () => {
-      xhr.abort();
-    };
-    token.add(abort);
-    xhr.onloadend = function(this: XMLHttpRequest) {
-      let status = this.status;
-      token.remove(abort);
-      if (status >= 200 && status < 300) {
-        resolve(this.response);
-      } else {
-        reject(HttpError.fromXhr(xhr));
-      }
-    };
-    xhr.send(JSON.stringify(payload));
-  });
+export function getByteRangeHeader(startOffset: Uint64, endOffset: Uint64) {
+  Uint64.decrement(tempUint64, endOffset);
+  return {'Range': `bytes=${startOffset}-${tempUint64}`};
 }
 
 /**
- * Parses a URL that may have a special protocol designation into a list of base URLs and a path.
+ * Parses a URL that may have a special protocol designation into a real URL.
  *
- * If the protocol is 'http' or 'https', the input string is returned as a single base URL, with an
- * empty path.
+ * If the protocol is 'http' or 'https', the input string is returned as is.
  *
- * Additionally, 'gs://bucket/path' is supported for accessing Google Storage buckets.
+ * The special 'gs://bucket/path' syntax is supported for accessing Google Storage buckets.
  */
-export function parseSpecialUrl(url: string): [string[], string] {
+export function parseSpecialUrl(url: string): string {
   const urlProtocolPattern = /^([^:\/]+):\/\/([^\/]+)(\/.*)?$/;
   let match = url.match(urlProtocolPattern);
   if (match === null) {
@@ -162,12 +120,9 @@ export function parseSpecialUrl(url: string): [string[], string] {
   const protocol = match[1];
   if (protocol === 'gs') {
     const bucket = match[2];
-    const baseUrls = [
-      `https://storage.googleapis.com/${bucket}`,
-    ];
     let path = match[3];
     if (path === undefined) path = '';
-    return [baseUrls, path];
+    return `https://storage.googleapis.com/${bucket}${path}`;
   }
-  return [[url], ''];
+  return url;
 }

refactor(http_request): use fetch API in place of XMLHttpRequest