@kobzi/gmfetch
v1.4.0
Published
Drop-in fetch() replacement for userscripts, backed by GM_xmlhttpRequest. Bypasses CORS, forbidden headers, and cookie restrictions.
Maintainers
Readme
gmFetch
Drop-in fetch() replacement for userscripts, powered by GM_xmlhttpRequest. Supports cross-origin requests, forbidden headers, cookie injection, proxies, streaming, and upload progress while preserving familiar Fetch API ergonomics.
Available in three variants:
- Full (~3.3 KB min, ~1.7 KB gzip) — closely aligned with Fetch spec, SRI, streaming, cache modes, GM options
- Lite (~2.1 KB min, ~1.1 KB gzip) — core fetch semantics, AbortSignal, forbidden headers, throttle-immune
gm.timeout+gm.onprogress - Micro (~1.0 KB min, ~0.65 KB gzip) — absolute minimum for simple GET/POST, no abort, no timeout
// Full
import gmFetch from "@kobzi/gmfetch";
// Lite
import gmFetch from "@kobzi/gmfetch/lite";
// Micro
import gmFetch from "@kobzi/gmfetch/micro";
// IIFE (classic userscript)
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmfetch@latest/dist/gmFetch.iife.min.js
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmfetch@latest/dist/gmFetch.lite.iife.min.js
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmfetch@latest/dist/gmFetch.micro.iife.min.jsconst r = await gmFetch("https://api.example.com/data");
const data = await r.json();Full vs Lite vs Micro
| Feature | Full | Lite | Micro |
|---|:---:|:---:|:---:|
| Request normalisation (URL, method, body) | ✓ | ✓ | ✓ |
| Native Response (ok, json, blob, clone) | ✓ | ✓ | ✓ |
| Response url | ✓ | ✓ | ✓ |
| credentials → anonymous mapping | ✓ | ✓ | ✓ |
| redirect passthrough | ✓ | ✓ | ✓ |
| Binary body support | ✓ | ✓ | ✓ |
| Text body sent as-is (no forced Blob) | ✓ | ✓ | ✓ |
| status:0 → TypeError | ✓ | ✓ | ✓ |
| AbortSignal / AbortController | ✓ | ✓ | ✗ |
| Forbidden headers preservation | ✓ | ✓ | ✗ |
| RFC 7230 header folding | ✓ | ✓ | ✗ |
| Response type / redirected | ✓ | ✓ | ✓ |
| Set-Cookie access (getSetCookie) | ✓ | ✓ | ✓ |
| Error semantics (DOMException types) | ✓ | ✓ | ✗ |
| Cache mode mapping | ✓ | ✗ | ✗ |
| ReadableStream response | ✓ | ✗ | ✗ |
| SRI integrity verification | ✓ | ✗ | ✗ |
| GM options (cookie, proxy, fetch, etc.) | ✓ | ⚠️ timeout + onprogress only | ✗ |
| Upload/download progress | ✓ | ⚠️ download only | ✗ |
| Error .cause with raw GM event | ✓ | ✓ | ✓ |
| redirect: "manual" Location preserved | ✓ | ✓ | ✓ |
| Mid-download network error → rejection | ✓ | ✓ | ✓ |
Use Micro when: simple GET/POST, grab JSON, size is everything, no abort needed.
Use Lite when: need AbortSignal, forbidden headers (Cookie/UA), proper error handling.
Use Full when: need GM-specific features, SRI, streaming, cache control, progress events.
Installation
The import / @require lines at the top are all you need. Requirements: @grant GM_xmlhttpRequest (or GM.xmlHttpRequest), plus @connect <domain> for cross-origin. The IIFE exposes gmFetch as a global — pin a version (@kobzi/[email protected]) for stability.
Compatibility
| Engine | Support |
|---|---|
| Tampermonkey 4.x+ | Full. gm.proxy needs 5.5+ (FF). gm.cookiePartition needs 5.2+. |
| Violentmonkey 2.13+ | Works. No gm.proxy/gm.cookiePartition/gm.fetch. |
| Greasemonkey 4.x | Partial. Uses GM.xmlHttpRequest. No streaming, no redirect/nocache/revalidate/anonymous/cookie/proxy/fetch/maxRedirects. |
Runtime: crypto.subtle (for SRI, full only).
API
function gmFetch(input: RequestInfo | URL, init?: GmFetchInit): Promise<Response>Signature matches window.fetch(). Full and lite extend init with an optional gm field:
interface GmFetchInit extends RequestInit {
gm?: GmOptions; // full: everything below
}
interface GmFetchLiteInit extends RequestInit {
gm?: { timeout?: number; onprogress?: (ev) => void }; // lite: this subset only
}Standard RequestInit fields
| Field | Full | Lite | Micro | Behaviour |
|---|:---:|:---:|:---:|---|
| method | ✓ | ✓ | ✓ | As-is. CONNECT/TRACE/TRACK rejected per spec. |
| headers | ✓ | ✓ | ✓ | Full/Lite: forbidden headers preserved (plain object/tuples). Micro: normalised via Request only. |
| body | ✓ | ✓ | ✓ | Text (string/URLSearchParams) sent as-is; other types buffered as Blob with binary: true. |
| credentials | ✓ | ✓ | ✓ | "omit" → anonymous: true. Others use GM defaults. |
| cache | ✓ | ✗ | ✗ | "no-store"/"reload" → nocache. "no-cache" → revalidate. "only-if-cached" → rejected. |
| redirect | ✓ | ✓ | ✓ | "follow", "error", "manual" passed to GM. |
| signal | ✓ | ✓ | ✗ | AbortSignal with reason propagation. Cancels GM request. |
| integrity | ✓ | ✗ | ✗ | SRI verification (sha256/384/512). |
GmOptions — the gm field
Only whitelisted keys are forwarded (protects internal callbacks). Full supports all of the below; lite keeps just timeout and onprogress:
| Field | Description |
|---|---|
| cookie | Patch cookies into request set (additive, not replacing). |
| cookiePartition | CHIPS: { topLevelSite: "https://..." }. TM 5.2+. |
| fetch | Background fetch via TM service worker (Chrome MV3). |
| proxy | { type, host, port, username?, password? }. TM 5.5+, Firefox. |
| user / password | HTTP Basic Auth. |
| timeout | Ms. Immune to tab throttling. 0 = none. |
| maxRedirects | Max redirects to follow. 0 = don't follow. TM 6180+. |
| onprogress | Download progress callback. |
| onloadstart | Load-start callback. |
| onuploadprogress | Upload progress callback. Not available in native fetch. TM 4.x+. |
| overrideMimeType | Force response MIME (e.g. "text/html; charset=gbk"). |
| context | Arbitrary value attached to GM progress events as event.context (shared-callback dispatch). |
Request body
The body is sent in the format that matches its type, so servers receive what they expect:
| Body type | Sent as | binary |
|---|---|:---:|
| string (e.g. JSON.stringify(...)) | text, as-is | false |
| URLSearchParams | text, as-is | false |
| Blob / File | buffered Blob | true |
| ArrayBuffer / TypedArray / DataView | buffered Blob | true |
| FormData | buffered Blob (multipart) | true |
Text bodies are forwarded directly rather than wrapped in a Blob with binary: true. This matters because some servers reject or mishandle binary blob uploads when they expect a plain JSON/form payload. The Content-Type computed by the Request constructor is always included in the request headers regardless of body type.
// Sent as a normal JSON text body, not a binary blob
await gmFetch("https://api.example.com/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "thing" }),
});Empty bodies are omitted entirely (no empty Blob or empty string is dispatched). Applies to all three variants.
Firefox note: body detection is based on the original
init.body, not onRequest.body. Some userscript engines (notably Firefox) exposeRequest.bodyasnulleven when a body was supplied; relying on it would silently drop POST payloads. Fixed in 1.3.1.
Headers
Pass as plain object or array of tuples to preserve forbidden headers:
await gmFetch("https://example.com", {
headers: {
"Cookie": "session=abc",
"User-Agent": "Custom/1.0",
"Referer": "https://other.com",
},
});⚠️
new Headers({ Cookie: "x" })strips forbidden headers at construction. Use plain objects.
Response Set-Cookie
const r = await gmFetch("https://example.com/login", { method: "POST" });
const cookies = r.headers.getSetCookie(); // ["session=abc; HttpOnly", ...]Note: Set-Cookie availability depends on userscript engine and browser. Tampermonkey exposes it; other engines may vary.
Cookies in one table
Reading response cookies (getSetCookie(), above) works in all variants; sending them needs full/lite (micro's Request constructor strips them):
| Goal | Use | Variant |
|---|---|---|
| Send exact cookies, ignore browser session | credentials: "omit" + headers: { Cookie: "..." } | full, lite |
| Add cookies on top of browser session | gm: { cookie: "..." } (additive) | full |
| Browser session as-is | default (nothing) | all |
Timeouts and abort
// AbortSignal (standard) — works in both full and lite.
// ⚠️ Rides page timers, which browsers throttle in background tabs — a "5 s" timeout
// can take a minute+ in an inactive tab.
await gmFetch(url, { signal: AbortSignal.timeout(5000) });
// gm.timeout — enforced by the GM layer, immune to tab throttling (full and lite)
await gmFetch(url, { gm: { timeout: 5000 } });
// Manual abort
const ctrl = new AbortController();
gmFetch(url, { signal: ctrl.signal });
ctrl.abort();All produce DOMException with name "TimeoutError" or "AbortError" (full and lite only — micro has no abort/timeout support).
Errors
| Cause | Error |
|---|---|
| GM not granted | DOMException("...", "NotFoundError") (full/lite). Micro throws a plain TypeError. |
| Abort / signal | DOMException("...", "AbortError") or signal's reason |
| Timeout | DOMException("...", "TimeoutError") |
| Network / DNS / @connect | TypeError("Failed to fetch") |
| status: 0 | TypeError("Failed to fetch") |
| SRI mismatch (full) | TypeError("gmFetch: integrity mismatch") |
| only-if-cached (full) | TypeError("gmFetch: only-if-cached unsupported") |
The message is kept spec-generic (native fetch never leaks network failure details). For debugging, network errors (onerror and status: 0) attach the raw GM event on error.cause in all variants, so you can inspect status, statusText, finalUrl, responseHeaders, etc.:
try {
await gmFetch("https://example.com");
} catch (e) {
console.error(e.message); // "Failed to fetch"
console.error(e.cause?.error); // GM-provided detail, if any
console.error(e.cause?.finalUrl);
}Micro's
causeis set ononerrorandstatus: 0; a missing grant still surfaces as a plainTypeError(noNotFoundErrorDOMException like full/lite).
Examples
POST JSON and forbidden-header examples live in Request body and Headers above.
Progress reporting (full)
// Download progress
await gmFetch("https://example.com/big.zip", {
gm: {
onprogress: ({ loaded, total, lengthComputable }) => {
if (lengthComputable) console.log(`Download: ${(loaded / total * 100).toFixed(1)}%`);
},
},
});
// Upload progress — not available in native fetch!
await gmFetch("https://example.com/upload", {
method: "POST",
body: largeBlob,
gm: {
onuploadprogress: ({ loaded, total }) => {
console.log(`Upload: ${(loaded / total * 100).toFixed(1)}%`);
},
},
});Streaming (full)
const r = await gmFetch("https://example.com/stream");
const reader = r.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
process(value);
}Proxy (full, Firefox, TM 5.5+)
await gmFetch("https://example.com", {
gm: { proxy: { type: "socks", host: "127.0.0.1", port: 9050, proxyDNS: true } },
});SRI integrity (full)
const r = await gmFetch("https://cdn.example.com/lib.js", {
integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb",
});Everything together (CDN/IIFE)
// ==UserScript==
// @name Scraper
// @grant GM_xmlhttpRequest
// @connect api.example.com
// @require https://cdn.jsdelivr.net/npm/@kobzi/gmfetch@latest/dist/gmFetch.iife.min.js
// ==/UserScript==
(async () => {
const r = await gmFetch("https://api.example.com/data", {
method: "POST",
headers: { "Cookie": "auth=abc", "User-Agent": "Bot/1.0" },
body: JSON.stringify({ query: "test" }),
cache: "no-store",
gm: { timeout: 15_000 }, // throttle-immune — fires on time even in background tabs
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
})();Swap the @require for the lite build and drop cache — the rest works unchanged.
Limitations
Silently ignored (no GM equivalent): mode, referrer, referrerPolicy, keepalive, priority, window. Note: the Referer header can still be set manually via headers: { "Referer": "..." } — only the automatic policy fields are ignored.
Not supported (GM_xmlhttpRequest limitation):
duplex: "half"— upload streaming is not possible; body is always fully buffered before sending.response.trailer— HTTP trailers are not exposed by GM.- Request body streaming — bodies are never streamed. Text bodies (string,
URLSearchParams) are passed through as-is; binary bodies (Blob,ArrayBuffer, typed arrays,FormData) are buffered to a Blob before dispatch.
Spec-divergent:
redirect: "manual"— returns 3xx with readableLocationheader (spec says opaque response withstatus: 0). GM gives more info than spec allows.cache: "force-cache"— falls back to default (no GM equivalent)credentials: "same-origin"— behaves like"include"(GM is privileged)response.type— always"basic"(GM bypasses CORS entirely)response.clone()— works for blob responses; may fail for streaming responses depending on TM/browser implementation details.
Building
npm install
npm run buildOutput (dist/): gmFetch[.lite|.micro].esm.min.js + .iife.min.js, plus matching .d.ts type declarations.
Sizes (esbuild + terser, minified — v1.4.0 IIFE, the builds @require uses; ESM is within a few bytes):
| Variant | Raw | Gzip | |---|---:|---:| | Full | 3374 B (3.30 KB) | 1775 B | | Lite | 2108 B (2.06 KB) | 1146 B | | Micro | 1066 B (1.04 KB) | 666 B |
Pipeline: esbuild (bundle + minify, target es2024) → terser (3-pass compress + toplevel mangle).
The build targets ES2024 (modern browsers). If you need to support older environments, fork and change --target in package.json scripts.
Zero runtime dependencies. Dev: esbuild + terser + typescript.
TypeScript
// Full
import gmFetch, {
type GmFetchInit,
type GmOptions,
type GmProxyConfig,
type GmProgressEvent,
} from "@kobzi/gmfetch";
// Lite
import gmFetch, { type GmFetchLiteInit } from "@kobzi/gmfetch/lite";
// Micro
import gmFetch, { type GmFetchMicroInit } from "@kobzi/gmfetch/micro";Requires lib: ["ES2024", "DOM"]. For IIFE usage, add a .d.ts with declare function gmFetch(...).
Background
Inspired by @sec-ant/gm-fetch and @trim21/gm-fetch. This library goes further — carefully aligned Fetch semantics, preserved forbidden headers, full GM API surface, SRI integrity, and a lite variant for size-conscious scripts.
Comparison
How gmFetch stacks up against other GM_xmlhttpRequest-based fetch wrappers. In the
@kobzi column, the letters say which of our variants ship the feature:
F = full · L = lite · M = micro.
| Feature | @kobzi | @sec-ant | @trim21 | gmxhr-fetch | @uwx/gm-fetch | |---|:---:|:---:|:---:|:---:|:---:| | Size (IIFE, min) | 1.0–3.3 KB | 1.9 KB | 2.1 KB | 0.9 KB | 12.4 KB | | AbortSignal + cleanup | F·L | ⚠️ leak | ⚠️ leak | ✗ | ✗ | | signal.reason propagation | F·L | ✗ | ✗ | ✗ | ✗ | | status:0 → TypeError | F·L·M | ✗ | ✗ | ✗ | ✗ | | Forbidden request headers | F·L | ✗ | ✗ | ✗ | ✗ | | Set-Cookie access | F·L·M | ✓ | ✗ | ✗ | ✗ | | Binary body (no corruption) | F·L·M | ✓ | ✗ | ✗ | ✓ | | Response url/type/redirected | F·L·M | ✓ | ⚠️ inverted | ✗ | ✓ | | Cache mode mapping | F | ⚠️ partial | ✗ | ✗ | ✗ | | ReadableStream response | F | ✓ | ✗ | ✗ | ✗ | | SRI integrity | F | ✗ | ✗ | ✗ | ✗ | | GM options (proxy, progress, etc.) | F ² | ✗ | ✗ | ✗ | ✗ | | TypeScript types | F·L·M | ✓ | ✓ | ✗ | ✓ | | Last updated | 2026 | 2025 | 2025 | 2022 | 2020 |
² lite keeps the timeout + onprogress subset
Spec fields with no GM equivalent (mode, referrer, keepalive, priority,
duplex, opaque-redirect, response.trailer) are unsupported across all of these
libraries — see Limitations. Notable non-table differences: @trim21
corrupts binary bodies (reads via .text()), @sec-ant depends on vite-plugin-monkey,
and @uwx ships a custom Response class at 4× our full size.
Security model
gmFetch runs through the userscript manager's privileged networking layer. This means:
- CORS restrictions do not apply
- Forbidden request headers can be sent freely
- Cookies may be injected or observed across origins
- Requests bypass page-level CSP and fetch restrictions
Users are responsible for respecting website policies, privacy, and applicable laws.
Changelog
1.4.0
- Fix (lite + micro): a network failure mid-download used to resolve with a silently truncated (empty) body — the promise was fulfilled at
HEADERS_RECEIVEDand the error had nowhere to go. Resolution now waits for the complete body (onload) and rejects withTypeError("Failed to fetch")on mid-download errors. The reason for the early resolve — preserving theLocationheader of aredirect: "manual"3xx, which some engines drop by DONE — is kept via a headers snapshot atHEADERS_RECEIVED(the same strategy the full variant always used). - Fix (micro): POST bodies were silently dropped in Firefox userscript engines (body presence gated on
Request.body, which Firefox exposes asnull). The 1.3.1 fix now applies to micro too. - Fix (all variants): null-body statuses (
204/205/304) no longer throw inside the response callback (which left the promise permanently pending in lite/micro) when the engine hands over an empty-but-not-null blob. - Added (lite):
gm.timeout— a timeout enforced by the GM layer, immune to background-tab timer throttling (anAbortSignal.timeout(5000)in an inactive tab can take a minute+; this one can't) — andgm.onprogressfor download progress. ~90 B, both unavailable in any competing wrapper of this size class. - Added (micro): network errors now carry the raw GM event on
error.cause(finalUrl, statusText, error detail), same as full/lite; responseSet-Cookieis now readable viar.headers.getSetCookie()(the unfilteredHeadersis re-stamped onto theResponse, same trick full/lite use);response.type("basic") andresponse.redirectedare stamped too — fullResponseproperty parity with lite. - Internal: micro sends
binary: trueonly when the payload actually is a Blob, and drops its redundantsettledflag (a promise ignores repeat settle calls; micro has no cleanup tied to settling); thedeliverBody/bodyReadymachinery is gone from lite and micro (smaller bundles — the additions above fit well within what this freed). - Fix (full): aborting the signal after a streaming response resolved now cancels the underlying GM request (native-fetch parity: an abort cancels body reads too). Blob-mode behaviour unchanged.
- Added (full):
gm.contextpassthrough — the last remainingGM_xmlhttpRequestdata option missing from the whitelist. - Build: terser gains
ecma=2020,unsafe_arrows,unsafe_methods; newnpm run build:sizeprints raw + gzip for all three IIFE bundles (also runs at the end ofnpm run build).
1.3.2
- Internal: merged the string/
URLSearchParamsbody branches inreadBody(full + lite) — no behaviour change, slightly smaller output (full 3349 B, lite 2021 B IIFE). - Docs: refreshed size figures, collapsed the duplicated feature/comparison tables into one competitor-only comparison.
1.3.1
- Fix (full + lite): POST bodies were silently dropped in Firefox userscript engines because body presence was detected via
Request.body(which Firefox exposes asnulleven when a body is supplied). Detection now uses the originalinit.body. Endpoints that returnedHTTP 400on bodyless POSTs work again.
License
MIT.
