All files / src/helpers backgroundFetch.ts

100% Statements 57/57
100% Branches 22/22
100% Functions 3/3
100% Lines 57/57

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 1471x 1x 1x 1x   1x                                                                           1x                       1x 4x 4x 1x 1x 3x 4x 1x 1x 1x 4x 1x 1x 1x 1x 1x 1x 3x 3x   1x         1x 9x 9x 9x 8x 8x 6x 6x   9x   1x                                                             1x 1x 9x 9x 9x 9x 9x   9x   9x 6x 6x 6x 6x 6x 6x   3x 9x 1x 2x 9x 9x  
// Client side of the background-worker fetch proxy. The matching message handler
// lives in `src/service-worker.ts`. Routing a request through the service worker
// sidesteps the page CORS restrictions that apply to extension pages (e.g. the side
// panel), as long as the target host is granted in the manifest `host_permissions`.
 
import { MESSAGE_TYPE } from '@/constants/common';
 
/**
 * Serializable subset of `RequestInit` accepted by {@link backgroundFetch}. Only
 * structured-cloneable fields are supported; `body` must already be a string (callers
 * stringify JSON themselves).
 * @category Helpers
 * @source
 */
export interface BackgroundFetchInit {
  method?: string;
  headers?: HeadersInit;
  body?: string;
  credentials?: RequestCredentials;
  referrer?: string;
  mode?: RequestMode;
  redirect?: RequestRedirect;
}
 
/** Message sent to the service worker. Mirrors the contract documented in the worker. */
interface BackgroundFetchMessage {
  type: typeof MESSAGE_TYPE.BACKGROUND_FETCH;
  url: string;
  init?: Omit<BackgroundFetchInit, 'headers'> & { headers?: Record<string, string> };
}
 
/** Successful serialized response returned by the worker. */
interface BackgroundFetchSuccess {
  ok: boolean;
  status: number;
  statusText: string;
  headers: Record<string, string>;
  body: string;
}
 
/** Result the worker sends back: a serialized response or an error envelope. */
type BackgroundFetchResult = BackgroundFetchSuccess | { error: string };
 
/**
 * Flattens any `HeadersInit` form into a plain, structured-cloneable record so it
 * survives `chrome.runtime.sendMessage`.
 * @param headers - Headers as a `Headers` instance, entry array, or plain object.
 * @returns A plain `Record<string, string>`, or `undefined` when no headers were given.
 * @example
 * ```typescript
 * normalizeHeaders(new Headers({ "x-a": "1" })); // => { "x-a": "1" }
 * normalizeHeaders([["x-b", "2"]]);              // => { "x-b": "2" }
 * normalizeHeaders(undefined);                   // => undefined
 * ```
 * @source
 */
function normalizeHeaders(headers?: HeadersInit): Record<string, string> | undefined {
  if (headers === undefined) {
    return undefined;
  }
  const result: Record<string, string> = {};
  if (headers instanceof Headers) {
    headers.forEach((value, key) => {
      result[key] = value;
    });
  } else if (Array.isArray(headers)) {
    for (const [key, value] of headers) {
      result[key] = value;
    }
  } else {
    Object.assign(result, headers);
  }
  return result;
}
 
/**
 * Narrows a `chrome.runtime.sendMessage` reply to a successful proxied response.
 * @param result - The unknown value returned by the service worker.
 * @returns `true` when `result` is a {@link BackgroundFetchSuccess}.
 * @source
 */
function isBackgroundFetchSuccess(result: unknown): result is BackgroundFetchSuccess {
  return (
    typeof result === 'object' &&
    result !== null &&
    'body' in result &&
    'status' in result &&
    'headers' in result
  );
}
 
/**
 * Performs an HTTP request from the extension's background service worker instead of the
 * calling context, then reconstructs a real {@link Response} from the serialized reply.
 *
 * Use this when a page-context `fetch` is blocked by CORS: the worker is exempt from page
 * CORS as long as the target host is listed in the manifest `host_permissions`. The
 * returned `Response` works like a normal one for text/JSON bodies (`.ok`, `.status`,
 * `.text()`, `.json()`); binary bodies are not supported.
 *
 * @category Helpers
 * @param url - The absolute URL to request.
 * @param init - Optional serializable request options (see {@link BackgroundFetchInit}).
 * @returns A `Response` reconstructed from the worker's reply.
 * @throws Error if the worker reports a fetch failure or returns an unrecognized reply.
 * @example
 * ```typescript
 * // GET reading text
 * const res = await backgroundFetch("https://example.com/");
 * const html = await res.text();
 *
 * // POST with a JSON body reading JSON
 * const res2 = await backgroundFetch("https://api.example.com/search", {
 *   method: "POST",
 *   headers: { "content-type": "application/json" },
 *   body: JSON.stringify({ q: "acid" }),
 * });
 * if (res2.ok) {
 *   const data = await res2.json();
 * }
 * ```
 * @source
 */
export async function backgroundFetch(url: string, init?: BackgroundFetchInit): Promise<Response> {
  const message: BackgroundFetchMessage = {
    type: MESSAGE_TYPE.BACKGROUND_FETCH,
    url,
    init: init ? { ...init, headers: normalizeHeaders(init.headers) } : undefined,
  };
 
  const result: BackgroundFetchResult = await chrome.runtime.sendMessage(message);
 
  if (isBackgroundFetchSuccess(result)) {
    return new Response(result.body, {
      status: result.status,
      statusText: result.statusText,
      headers: result.headers,
    });
  }
 
  const reason =
    typeof result === 'object' && result !== null && 'error' in result
      ? result.error
      : 'Unknown background fetch failure';
  throw new Error(`backgroundFetch| ${reason}`);
}