@danmat/query-fetch 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,7 @@ Scripted `fetch(url, { method: "QUERY", body })` already works in modern runtime
39
39
  - ✅ **Enforces `Content-Type`** — the RFC requires servers to reject a QUERY whose body has no content type. We throw *before* the round-trip instead of letting you debug a `400`.
40
40
  - ✅ **Transparent `POST` fallback** — servers that don't understand QUERY yet respond `405`/`501`; we automatically retry as `POST` and advertise the original method via `X-HTTP-Method-Override` so override-aware backends still route it correctly.
41
41
  - ✅ **`Accept` negotiation** — pass a media type (or list) to negotiate the response format the RFC's `Accept-Query` dance is built around.
42
+ - ✅ **Safe automatic retry** (opt-in) — QUERY is *idempotent by definition*, so retrying transient failures is safe here in a way it never is for `POST`. Exponential backoff + jitter, honours `Retry-After`.
42
43
  - ✅ **Redirect-safe** — the RFC's `303 See Other` indirect-result pattern is handled by `fetch`'s own redirect following; nothing surprising here.
43
44
  - ✅ **Zero dependencies, fully typed, tree-shakeable**, dual ESM/CJS.
44
45
 
@@ -69,12 +70,59 @@ await query("https://api.example.com/search", {
69
70
  });
70
71
  ```
71
72
 
73
+ ### Automatic retry (safe, because QUERY is idempotent)
74
+
75
+ RFC 10008 defines QUERY as safe and idempotent — so unlike `POST`, retrying a
76
+ failed request can't cause a double-effect. Opt in with a count, or an object
77
+ for full control:
78
+
79
+ ```ts
80
+ // Retry up to 3 times with exponential backoff + jitter.
81
+ await query(url, { json, retry: 3 });
82
+
83
+ // Full control.
84
+ await query(url, {
85
+ json,
86
+ retry: {
87
+ retries: 5,
88
+ minDelay: 200, // base backoff (ms)
89
+ maxDelay: 10_000,
90
+ factor: 2,
91
+ jitter: true,
92
+ respectRetryAfter: true, // honour Retry-After on 429/503
93
+ retryOn: ({ response, error }) =>
94
+ Boolean(error) || (response?.status ?? 0) >= 500,
95
+ onRetry: ({ attempt, delay }) => console.warn(`retry #${attempt} in ${delay}ms`),
96
+ },
97
+ });
98
+ ```
99
+
100
+ By default it retries network errors and `408/425/429/500/502/503/504`, and
101
+ does **not** retry aborts. Retry is off unless you set it. (Retries reuse a
102
+ buffered body — a string, bytes, or `json`; a streaming body is sent once.)
103
+
72
104
  ### Opt out of the POST fallback
73
105
 
74
106
  ```ts
75
107
  await query(url, { json, fallbackToPost: false });
76
108
  ```
77
109
 
110
+ ### Force POST override for an origin you know rejects QUERY
111
+
112
+ The automatic fallback only triggers when the server *answers* `405`/`501`. Some
113
+ origins reject QUERY *before* that — a legacy proxy, or a cross-origin server whose
114
+ CORS allows POST but not QUERY yet — so the QUERY throws (or its preflight fails)
115
+ with no status to react to. When you already know an origin is like that, skip the
116
+ doomed QUERY and POST with the override from the start:
117
+
118
+ ```ts
119
+ await query(url, { json, transport: "post-override" });
120
+ ```
121
+
122
+ This is a deliberate, per-call opt-in. The library never switches to it on its own,
123
+ because a POST is treated as unsafe and uncacheable by intermediaries unless the
124
+ server honors the override — so the choice stays yours.
125
+
78
126
  ### Bring your own `fetch`
79
127
 
80
128
  ```ts
@@ -97,6 +145,8 @@ Performs a QUERY request. `options` extends `RequestInit` (so `signal`, `credent
97
145
  | `accept` | `string \| string[]` | — | Sets the `Accept` header. |
98
146
  | `fallbackToPost` | `boolean` | `true` | Retry as `POST` on `405`/`501`. |
99
147
  | `methodOverrideHeader` | `string \| false` | `"X-HTTP-Method-Override"` | Header advertising the original method on fallback. |
148
+ | `transport` | `"query" \| "post-override"` | `"query"` | `"post-override"` sends `POST` + override from the start, for an origin you already know rejects QUERY. |
149
+ | `retry` | `number \| RetryOptions` | off | Auto-retry transient failures (safe: QUERY is idempotent). |
100
150
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. |
101
151
 
102
152
  ### `queryJson<T>(input, options?): Promise<{ data: T; response: Response }>`
@@ -111,7 +161,7 @@ Thrown for construction-time problems (a body without a content type, no availab
111
161
 
112
162
  QUERY is a **Proposed Standard** (June 2026). Two things to know:
113
163
 
114
- - **CORS:** QUERY is not a CORS-safelisted method, so a cross-origin QUERY triggers a preflight. Your server must handle `OPTIONS` accordingly.
164
+ - **CORS:** QUERY is not a CORS-safelisted method, so a cross-origin QUERY triggers a preflight, and the browser blocks it unless the server returns `QUERY` in `Access-Control-Allow-Methods`. When it doesn't, `fetch` throws with no status, so the automatic `405`/`501` fallback can't see it. For an origin you know is in that state, pass `transport: "post-override"` to POST from the start — provided that server allows `POST` (and the override header) via CORS. There is no client-only way around a server that CORS-blocks both.
115
165
  - **Spec churn:** browser-integration details (method normalization, caching) are still being ironed out in [whatwg/fetch#1938](https://github.com/whatwg/fetch/issues/1938). This library tracks runtime behavior as it ships.
116
166
 
117
167
  ## The `@danmat` QUERY suite
package/dist/index.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  var METHOD_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([405, 501]);
5
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
5
6
  var DEFAULT_OVERRIDE_HEADER = "X-HTTP-Method-Override";
6
7
  var QueryError = class _QueryError extends Error {
7
8
  name = "QueryError";
@@ -42,6 +43,70 @@ function prepare(options) {
42
43
  }
43
44
  return { headers, body };
44
45
  }
46
+ function isAbortError(error) {
47
+ return typeof error === "object" && error !== null && error.name === "AbortError";
48
+ }
49
+ function defaultRetryOn(context) {
50
+ if (context.error !== void 0) return !isAbortError(context.error);
51
+ if (context.response) return RETRYABLE_STATUSES.has(context.response.status);
52
+ return false;
53
+ }
54
+ function resolveRetry(retry) {
55
+ if (retry === void 0) return null;
56
+ const opts = typeof retry === "number" ? { retries: retry } : retry;
57
+ if (!opts.retries || opts.retries < 1) return null;
58
+ return {
59
+ retries: opts.retries,
60
+ minDelay: opts.minDelay ?? 200,
61
+ maxDelay: opts.maxDelay ?? 1e4,
62
+ factor: opts.factor ?? 2,
63
+ jitter: opts.jitter ?? true,
64
+ respectRetryAfter: opts.respectRetryAfter ?? true,
65
+ retryOn: opts.retryOn ?? defaultRetryOn,
66
+ onRetry: opts.onRetry
67
+ };
68
+ }
69
+ function parseRetryAfter(value) {
70
+ if (!value) return null;
71
+ const seconds = Number(value);
72
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
73
+ const date = Date.parse(value);
74
+ if (Number.isNaN(date)) return null;
75
+ return Math.max(0, date - Date.now());
76
+ }
77
+ function backoffDelay(cfg, attemptIndex, response) {
78
+ if (cfg.respectRetryAfter && response) {
79
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
80
+ if (retryAfter !== null) return retryAfter;
81
+ }
82
+ const base = Math.min(
83
+ cfg.maxDelay,
84
+ cfg.minDelay * cfg.factor ** attemptIndex
85
+ );
86
+ return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;
87
+ }
88
+ function abortError() {
89
+ const error = new Error("The operation was aborted.");
90
+ error.name = "AbortError";
91
+ return error;
92
+ }
93
+ function sleep(ms, signal) {
94
+ return new Promise((resolve, reject) => {
95
+ if (signal?.aborted) {
96
+ reject(abortError());
97
+ return;
98
+ }
99
+ const timer = setTimeout(resolve, ms);
100
+ signal?.addEventListener(
101
+ "abort",
102
+ () => {
103
+ clearTimeout(timer);
104
+ reject(abortError());
105
+ },
106
+ { once: true }
107
+ );
108
+ });
109
+ }
45
110
  async function query(input, options = {}) {
46
111
  const doFetch = resolveFetch(options.fetch);
47
112
  const { headers, body } = prepare(options);
@@ -50,31 +115,63 @@ async function query(input, options = {}) {
50
115
  json: _json,
51
116
  contentType: _contentType,
52
117
  accept: _accept,
118
+ retry: _retry,
53
119
  fallbackToPost = true,
54
120
  methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,
121
+ transport = "query",
55
122
  headers: _headers,
56
123
  body: _body,
57
124
  ...init
58
125
  } = options;
59
- const response = await doFetch(input, {
60
- ...init,
61
- method: "QUERY",
62
- headers,
63
- body
64
- });
65
- if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
66
- return response;
67
- }
68
- const fallbackHeaders = new Headers(headers);
69
- if (methodOverrideHeader) {
70
- fallbackHeaders.set(methodOverrideHeader, "QUERY");
126
+ const postOverride = () => {
127
+ const overrideHeaders = new Headers(headers);
128
+ if (methodOverrideHeader) {
129
+ overrideHeaders.set(methodOverrideHeader, "QUERY");
130
+ }
131
+ return doFetch(input, {
132
+ ...init,
133
+ method: "POST",
134
+ headers: overrideHeaders,
135
+ body
136
+ });
137
+ };
138
+ const attempt = async () => {
139
+ if (transport === "post-override") return postOverride();
140
+ const response = await doFetch(input, {
141
+ ...init,
142
+ method: "QUERY",
143
+ headers,
144
+ body
145
+ });
146
+ if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
147
+ return response;
148
+ }
149
+ return postOverride();
150
+ };
151
+ const retryCfg = resolveRetry(options.retry);
152
+ if (!retryCfg) return attempt();
153
+ const signal = init.signal ?? null;
154
+ for (let i = 0; ; i++) {
155
+ let response;
156
+ let caught;
157
+ try {
158
+ response = await attempt();
159
+ } catch (error) {
160
+ caught = error;
161
+ }
162
+ const isLastAttempt = i >= retryCfg.retries;
163
+ const context = { attempt: i + 1, response, error: caught };
164
+ if (caught !== void 0) {
165
+ if (isLastAttempt || isAbortError(caught) || !await retryCfg.retryOn(context)) {
166
+ throw caught;
167
+ }
168
+ } else if (isLastAttempt || !await retryCfg.retryOn(context)) {
169
+ return response;
170
+ }
171
+ const delay = backoffDelay(retryCfg, i, response);
172
+ retryCfg.onRetry?.({ ...context, delay });
173
+ await sleep(delay, signal);
71
174
  }
72
- return doFetch(input, {
73
- ...init,
74
- method: "POST",
75
- headers: fallbackHeaders,
76
- body
77
- });
78
175
  }
79
176
  async function queryJson(input, options = {}) {
80
177
  const response = await query(input, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n\n // The server doesn't speak QUERY — retry as POST, optionally advertising the\n // original method so override-aware servers can still treat it as a query.\n const fallbackHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n fallbackHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: fallbackHeaders,\n body,\n });\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Statuses worth retrying for an idempotent request. */\nconst RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Details about a failed attempt, passed to retry callbacks. */\nexport interface RetryContext {\n /** 1-based number of the retry about to happen (or being decided). */\n attempt: number;\n /** The response received, when the failure was an HTTP status. */\n response?: Response;\n /** The error thrown, when the failure was a network/abort error. */\n error?: unknown;\n}\n\n/** Controls automatic retries. See {@link QueryOptions.retry}. */\nexport interface RetryOptions {\n /** Maximum number of retries after the initial attempt. */\n retries: number;\n /** Base backoff delay in milliseconds. Default `200`. */\n minDelay?: number;\n /** Maximum backoff delay in milliseconds. Default `10000`. */\n maxDelay?: number;\n /** Exponential backoff multiplier. Default `2`. */\n factor?: number;\n /** Apply random jitter to spread retries out. Default `true`. */\n jitter?: boolean;\n /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */\n respectRetryAfter?: boolean;\n /**\n * Decide whether a failed attempt should be retried. By default, network\n * errors and the statuses 408/425/429/500/502/503/504 are retried, while\n * aborts are not. QUERY is idempotent, so retrying is safe.\n */\n retryOn?: (context: RetryContext) => boolean | Promise<boolean>;\n /** Called before each backoff wait, with the chosen delay. */\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * How to reach a server that may not support QUERY.\n *\n * - `\"query\"` (default): send a real QUERY, and — when {@link fallbackToPost}\n * is on — fall back to POST only if the server *answers* `405`/`501`.\n * - `\"post-override\"`: skip QUERY entirely and send POST from the start, with\n * the intended method advertised via {@link methodOverrideHeader}. Use this\n * for an origin you already know rejects QUERY *before* a clean status comes\n * back — a legacy proxy, or a cross-origin server whose CORS allows POST but\n * not QUERY yet — where a real QUERY would only fail after a wasted round\n * trip (or a CORS preflight the client can't see). This is a deliberate\n * opt-in: the request goes out as a POST, so intermediaries treat it as\n * unsafe and uncacheable unless the server honors the override. The library\n * never switches to this on its own — you decide, per call.\n */\n transport?: \"query\" | \"post-override\";\n\n /**\n * Automatically retry transient failures. Pass a number for \"retry N times\n * with sensible backoff\", or a {@link RetryOptions} object for full control.\n * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.\n *\n * Retries reuse a buffered body (a string, bytes, or `json`); a streaming\n * body can only be sent once and won't be retried.\n */\n retry?: number | RetryOptions;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\ninterface ResolvedRetry {\n retries: number;\n minDelay: number;\n maxDelay: number;\n factor: number;\n jitter: boolean;\n respectRetryAfter: boolean;\n retryOn: (context: RetryContext) => boolean | Promise<boolean>;\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\nfunction isAbortError(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { name?: string }).name === \"AbortError\"\n );\n}\n\nfunction defaultRetryOn(context: RetryContext): boolean {\n if (context.error !== undefined) return !isAbortError(context.error);\n if (context.response) return RETRYABLE_STATUSES.has(context.response.status);\n return false;\n}\n\nfunction resolveRetry(\n retry: number | RetryOptions | undefined,\n): ResolvedRetry | null {\n if (retry === undefined) return null;\n const opts = typeof retry === \"number\" ? { retries: retry } : retry;\n if (!opts.retries || opts.retries < 1) return null;\n return {\n retries: opts.retries,\n minDelay: opts.minDelay ?? 200,\n maxDelay: opts.maxDelay ?? 10_000,\n factor: opts.factor ?? 2,\n jitter: opts.jitter ?? true,\n respectRetryAfter: opts.respectRetryAfter ?? true,\n retryOn: opts.retryOn ?? defaultRetryOn,\n onRetry: opts.onRetry,\n };\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null;\n const seconds = Number(value);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);\n const date = Date.parse(value);\n if (Number.isNaN(date)) return null;\n return Math.max(0, date - Date.now());\n}\n\nfunction backoffDelay(\n cfg: ResolvedRetry,\n attemptIndex: number,\n response?: Response,\n): number {\n if (cfg.respectRetryAfter && response) {\n const retryAfter = parseRetryAfter(response.headers.get(\"retry-after\"));\n if (retryAfter !== null) return retryAfter;\n }\n const base = Math.min(\n cfg.maxDelay,\n cfg.minDelay * cfg.factor ** attemptIndex,\n );\n return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;\n}\n\nfunction abortError(): Error {\n const error = new Error(\"The operation was aborted.\");\n error.name = \"AbortError\";\n return error;\n}\n\nfunction sleep(ms: number, signal?: AbortSignal | null): Promise<void> {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError());\n return;\n }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer);\n reject(abortError());\n },\n { once: true },\n );\n });\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * retry: 3, // safe: QUERY is idempotent\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n retry: _retry,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n transport = \"query\",\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n // POST carrying the original method in an override header, for a server that\n // doesn't speak QUERY. Shared by the explicit `transport: \"post-override\"`\n // mode and the automatic 405/501 fallback below.\n const postOverride = (): Promise<Response> => {\n const overrideHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n overrideHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: overrideHeaders,\n body,\n });\n };\n\n const attempt = async (): Promise<Response> => {\n // Caller already knows this origin won't accept a QUERY: don't waste the\n // round trip, POST with the override from the start.\n if (transport === \"post-override\") return postOverride();\n\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n // The server *answered* that it doesn't speak QUERY — fall back to POST,\n // advertising the original method so override-aware servers still treat it\n // as one. Only fires on an explicit 405/501, never on an opaque throw.\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n return postOverride();\n };\n\n const retryCfg = resolveRetry(options.retry);\n if (!retryCfg) return attempt();\n\n const signal = init.signal ?? null;\n for (let i = 0; ; i++) {\n let response: Response | undefined;\n let caught: unknown;\n try {\n response = await attempt();\n } catch (error) {\n caught = error;\n }\n\n const isLastAttempt = i >= retryCfg.retries;\n const context: RetryContext = { attempt: i + 1, response, error: caught };\n\n if (caught !== undefined) {\n if (\n isLastAttempt ||\n isAbortError(caught) ||\n !(await retryCfg.retryOn(context))\n ) {\n throw caught;\n }\n } else if (isLastAttempt || !(await retryCfg.retryOn(context))) {\n return response as Response;\n }\n\n const delay = backoffDelay(retryCfg, i, response);\n retryCfg.onRetry?.({ ...context, delay });\n await sleep(delay, signal);\n }\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -14,6 +14,40 @@ declare class QueryError extends Error {
14
14
  name: string;
15
15
  constructor(message: string);
16
16
  }
17
+ /** Details about a failed attempt, passed to retry callbacks. */
18
+ interface RetryContext {
19
+ /** 1-based number of the retry about to happen (or being decided). */
20
+ attempt: number;
21
+ /** The response received, when the failure was an HTTP status. */
22
+ response?: Response;
23
+ /** The error thrown, when the failure was a network/abort error. */
24
+ error?: unknown;
25
+ }
26
+ /** Controls automatic retries. See {@link QueryOptions.retry}. */
27
+ interface RetryOptions {
28
+ /** Maximum number of retries after the initial attempt. */
29
+ retries: number;
30
+ /** Base backoff delay in milliseconds. Default `200`. */
31
+ minDelay?: number;
32
+ /** Maximum backoff delay in milliseconds. Default `10000`. */
33
+ maxDelay?: number;
34
+ /** Exponential backoff multiplier. Default `2`. */
35
+ factor?: number;
36
+ /** Apply random jitter to spread retries out. Default `true`. */
37
+ jitter?: boolean;
38
+ /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */
39
+ respectRetryAfter?: boolean;
40
+ /**
41
+ * Decide whether a failed attempt should be retried. By default, network
42
+ * errors and the statuses 408/425/429/500/502/503/504 are retried, while
43
+ * aborts are not. QUERY is idempotent, so retrying is safe.
44
+ */
45
+ retryOn?: (context: RetryContext) => boolean | Promise<boolean>;
46
+ /** Called before each backoff wait, with the chosen delay. */
47
+ onRetry?: (context: RetryContext & {
48
+ delay: number;
49
+ }) => void;
50
+ }
17
51
  /** Options for {@link query}. Extends `RequestInit` minus the fields we own. */
18
52
  interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
19
53
  /**
@@ -46,6 +80,31 @@ interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
46
80
  * to disable. Defaults to `"X-HTTP-Method-Override"`.
47
81
  */
48
82
  methodOverrideHeader?: string | false;
83
+ /**
84
+ * How to reach a server that may not support QUERY.
85
+ *
86
+ * - `"query"` (default): send a real QUERY, and — when {@link fallbackToPost}
87
+ * is on — fall back to POST only if the server *answers* `405`/`501`.
88
+ * - `"post-override"`: skip QUERY entirely and send POST from the start, with
89
+ * the intended method advertised via {@link methodOverrideHeader}. Use this
90
+ * for an origin you already know rejects QUERY *before* a clean status comes
91
+ * back — a legacy proxy, or a cross-origin server whose CORS allows POST but
92
+ * not QUERY yet — where a real QUERY would only fail after a wasted round
93
+ * trip (or a CORS preflight the client can't see). This is a deliberate
94
+ * opt-in: the request goes out as a POST, so intermediaries treat it as
95
+ * unsafe and uncacheable unless the server honors the override. The library
96
+ * never switches to this on its own — you decide, per call.
97
+ */
98
+ transport?: "query" | "post-override";
99
+ /**
100
+ * Automatically retry transient failures. Pass a number for "retry N times
101
+ * with sensible backoff", or a {@link RetryOptions} object for full control.
102
+ * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.
103
+ *
104
+ * Retries reuse a buffered body (a string, bytes, or `json`); a streaming
105
+ * body can only be sent once and won't be retried.
106
+ */
107
+ retry?: number | RetryOptions;
49
108
  /**
50
109
  * A `fetch` implementation to use instead of the global. Handy for testing
51
110
  * or for runtimes that expose `fetch` on a client rather than globally.
@@ -72,6 +131,7 @@ interface QueryJsonResult<T> {
72
131
  * const res = await query("https://api.example.com/search", {
73
132
  * json: { filter: { status: "active" }, sort: "-createdAt" },
74
133
  * accept: "application/json",
134
+ * retry: 3, // safe: QUERY is idempotent
75
135
  * });
76
136
  * ```
77
137
  */
@@ -84,4 +144,4 @@ declare function query(input: string | URL, options?: QueryOptions): Promise<Res
84
144
  */
85
145
  declare function queryJson<T = unknown>(input: string | URL, options?: QueryOptions): Promise<QueryJsonResult<T>>;
86
146
 
87
- export { QueryError, type QueryJsonResult, type QueryOptions, query, queryJson };
147
+ export { QueryError, type QueryJsonResult, type QueryOptions, type RetryContext, type RetryOptions, query, queryJson };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,40 @@ declare class QueryError extends Error {
14
14
  name: string;
15
15
  constructor(message: string);
16
16
  }
17
+ /** Details about a failed attempt, passed to retry callbacks. */
18
+ interface RetryContext {
19
+ /** 1-based number of the retry about to happen (or being decided). */
20
+ attempt: number;
21
+ /** The response received, when the failure was an HTTP status. */
22
+ response?: Response;
23
+ /** The error thrown, when the failure was a network/abort error. */
24
+ error?: unknown;
25
+ }
26
+ /** Controls automatic retries. See {@link QueryOptions.retry}. */
27
+ interface RetryOptions {
28
+ /** Maximum number of retries after the initial attempt. */
29
+ retries: number;
30
+ /** Base backoff delay in milliseconds. Default `200`. */
31
+ minDelay?: number;
32
+ /** Maximum backoff delay in milliseconds. Default `10000`. */
33
+ maxDelay?: number;
34
+ /** Exponential backoff multiplier. Default `2`. */
35
+ factor?: number;
36
+ /** Apply random jitter to spread retries out. Default `true`. */
37
+ jitter?: boolean;
38
+ /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */
39
+ respectRetryAfter?: boolean;
40
+ /**
41
+ * Decide whether a failed attempt should be retried. By default, network
42
+ * errors and the statuses 408/425/429/500/502/503/504 are retried, while
43
+ * aborts are not. QUERY is idempotent, so retrying is safe.
44
+ */
45
+ retryOn?: (context: RetryContext) => boolean | Promise<boolean>;
46
+ /** Called before each backoff wait, with the chosen delay. */
47
+ onRetry?: (context: RetryContext & {
48
+ delay: number;
49
+ }) => void;
50
+ }
17
51
  /** Options for {@link query}. Extends `RequestInit` minus the fields we own. */
18
52
  interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
19
53
  /**
@@ -46,6 +80,31 @@ interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
46
80
  * to disable. Defaults to `"X-HTTP-Method-Override"`.
47
81
  */
48
82
  methodOverrideHeader?: string | false;
83
+ /**
84
+ * How to reach a server that may not support QUERY.
85
+ *
86
+ * - `"query"` (default): send a real QUERY, and — when {@link fallbackToPost}
87
+ * is on — fall back to POST only if the server *answers* `405`/`501`.
88
+ * - `"post-override"`: skip QUERY entirely and send POST from the start, with
89
+ * the intended method advertised via {@link methodOverrideHeader}. Use this
90
+ * for an origin you already know rejects QUERY *before* a clean status comes
91
+ * back — a legacy proxy, or a cross-origin server whose CORS allows POST but
92
+ * not QUERY yet — where a real QUERY would only fail after a wasted round
93
+ * trip (or a CORS preflight the client can't see). This is a deliberate
94
+ * opt-in: the request goes out as a POST, so intermediaries treat it as
95
+ * unsafe and uncacheable unless the server honors the override. The library
96
+ * never switches to this on its own — you decide, per call.
97
+ */
98
+ transport?: "query" | "post-override";
99
+ /**
100
+ * Automatically retry transient failures. Pass a number for "retry N times
101
+ * with sensible backoff", or a {@link RetryOptions} object for full control.
102
+ * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.
103
+ *
104
+ * Retries reuse a buffered body (a string, bytes, or `json`); a streaming
105
+ * body can only be sent once and won't be retried.
106
+ */
107
+ retry?: number | RetryOptions;
49
108
  /**
50
109
  * A `fetch` implementation to use instead of the global. Handy for testing
51
110
  * or for runtimes that expose `fetch` on a client rather than globally.
@@ -72,6 +131,7 @@ interface QueryJsonResult<T> {
72
131
  * const res = await query("https://api.example.com/search", {
73
132
  * json: { filter: { status: "active" }, sort: "-createdAt" },
74
133
  * accept: "application/json",
134
+ * retry: 3, // safe: QUERY is idempotent
75
135
  * });
76
136
  * ```
77
137
  */
@@ -84,4 +144,4 @@ declare function query(input: string | URL, options?: QueryOptions): Promise<Res
84
144
  */
85
145
  declare function queryJson<T = unknown>(input: string | URL, options?: QueryOptions): Promise<QueryJsonResult<T>>;
86
146
 
87
- export { QueryError, type QueryJsonResult, type QueryOptions, query, queryJson };
147
+ export { QueryError, type QueryJsonResult, type QueryOptions, type RetryContext, type RetryOptions, query, queryJson };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
2
  var METHOD_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([405, 501]);
3
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
3
4
  var DEFAULT_OVERRIDE_HEADER = "X-HTTP-Method-Override";
4
5
  var QueryError = class _QueryError extends Error {
5
6
  name = "QueryError";
@@ -40,6 +41,70 @@ function prepare(options) {
40
41
  }
41
42
  return { headers, body };
42
43
  }
44
+ function isAbortError(error) {
45
+ return typeof error === "object" && error !== null && error.name === "AbortError";
46
+ }
47
+ function defaultRetryOn(context) {
48
+ if (context.error !== void 0) return !isAbortError(context.error);
49
+ if (context.response) return RETRYABLE_STATUSES.has(context.response.status);
50
+ return false;
51
+ }
52
+ function resolveRetry(retry) {
53
+ if (retry === void 0) return null;
54
+ const opts = typeof retry === "number" ? { retries: retry } : retry;
55
+ if (!opts.retries || opts.retries < 1) return null;
56
+ return {
57
+ retries: opts.retries,
58
+ minDelay: opts.minDelay ?? 200,
59
+ maxDelay: opts.maxDelay ?? 1e4,
60
+ factor: opts.factor ?? 2,
61
+ jitter: opts.jitter ?? true,
62
+ respectRetryAfter: opts.respectRetryAfter ?? true,
63
+ retryOn: opts.retryOn ?? defaultRetryOn,
64
+ onRetry: opts.onRetry
65
+ };
66
+ }
67
+ function parseRetryAfter(value) {
68
+ if (!value) return null;
69
+ const seconds = Number(value);
70
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
71
+ const date = Date.parse(value);
72
+ if (Number.isNaN(date)) return null;
73
+ return Math.max(0, date - Date.now());
74
+ }
75
+ function backoffDelay(cfg, attemptIndex, response) {
76
+ if (cfg.respectRetryAfter && response) {
77
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
78
+ if (retryAfter !== null) return retryAfter;
79
+ }
80
+ const base = Math.min(
81
+ cfg.maxDelay,
82
+ cfg.minDelay * cfg.factor ** attemptIndex
83
+ );
84
+ return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;
85
+ }
86
+ function abortError() {
87
+ const error = new Error("The operation was aborted.");
88
+ error.name = "AbortError";
89
+ return error;
90
+ }
91
+ function sleep(ms, signal) {
92
+ return new Promise((resolve, reject) => {
93
+ if (signal?.aborted) {
94
+ reject(abortError());
95
+ return;
96
+ }
97
+ const timer = setTimeout(resolve, ms);
98
+ signal?.addEventListener(
99
+ "abort",
100
+ () => {
101
+ clearTimeout(timer);
102
+ reject(abortError());
103
+ },
104
+ { once: true }
105
+ );
106
+ });
107
+ }
43
108
  async function query(input, options = {}) {
44
109
  const doFetch = resolveFetch(options.fetch);
45
110
  const { headers, body } = prepare(options);
@@ -48,31 +113,63 @@ async function query(input, options = {}) {
48
113
  json: _json,
49
114
  contentType: _contentType,
50
115
  accept: _accept,
116
+ retry: _retry,
51
117
  fallbackToPost = true,
52
118
  methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,
119
+ transport = "query",
53
120
  headers: _headers,
54
121
  body: _body,
55
122
  ...init
56
123
  } = options;
57
- const response = await doFetch(input, {
58
- ...init,
59
- method: "QUERY",
60
- headers,
61
- body
62
- });
63
- if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
64
- return response;
65
- }
66
- const fallbackHeaders = new Headers(headers);
67
- if (methodOverrideHeader) {
68
- fallbackHeaders.set(methodOverrideHeader, "QUERY");
124
+ const postOverride = () => {
125
+ const overrideHeaders = new Headers(headers);
126
+ if (methodOverrideHeader) {
127
+ overrideHeaders.set(methodOverrideHeader, "QUERY");
128
+ }
129
+ return doFetch(input, {
130
+ ...init,
131
+ method: "POST",
132
+ headers: overrideHeaders,
133
+ body
134
+ });
135
+ };
136
+ const attempt = async () => {
137
+ if (transport === "post-override") return postOverride();
138
+ const response = await doFetch(input, {
139
+ ...init,
140
+ method: "QUERY",
141
+ headers,
142
+ body
143
+ });
144
+ if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
145
+ return response;
146
+ }
147
+ return postOverride();
148
+ };
149
+ const retryCfg = resolveRetry(options.retry);
150
+ if (!retryCfg) return attempt();
151
+ const signal = init.signal ?? null;
152
+ for (let i = 0; ; i++) {
153
+ let response;
154
+ let caught;
155
+ try {
156
+ response = await attempt();
157
+ } catch (error) {
158
+ caught = error;
159
+ }
160
+ const isLastAttempt = i >= retryCfg.retries;
161
+ const context = { attempt: i + 1, response, error: caught };
162
+ if (caught !== void 0) {
163
+ if (isLastAttempt || isAbortError(caught) || !await retryCfg.retryOn(context)) {
164
+ throw caught;
165
+ }
166
+ } else if (isLastAttempt || !await retryCfg.retryOn(context)) {
167
+ return response;
168
+ }
169
+ const delay = backoffDelay(retryCfg, i, response);
170
+ retryCfg.onRetry?.({ ...context, delay });
171
+ await sleep(delay, signal);
69
172
  }
70
- return doFetch(input, {
71
- ...init,
72
- method: "POST",
73
- headers: fallbackHeaders,
74
- body
75
- });
76
173
  }
77
174
  async function queryJson(input, options = {}) {
78
175
  const response = await query(input, {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n\n // The server doesn't speak QUERY — retry as POST, optionally advertising the\n // original method so override-aware servers can still treat it as a query.\n const fallbackHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n fallbackHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: fallbackHeaders,\n body,\n });\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Statuses worth retrying for an idempotent request. */\nconst RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Details about a failed attempt, passed to retry callbacks. */\nexport interface RetryContext {\n /** 1-based number of the retry about to happen (or being decided). */\n attempt: number;\n /** The response received, when the failure was an HTTP status. */\n response?: Response;\n /** The error thrown, when the failure was a network/abort error. */\n error?: unknown;\n}\n\n/** Controls automatic retries. See {@link QueryOptions.retry}. */\nexport interface RetryOptions {\n /** Maximum number of retries after the initial attempt. */\n retries: number;\n /** Base backoff delay in milliseconds. Default `200`. */\n minDelay?: number;\n /** Maximum backoff delay in milliseconds. Default `10000`. */\n maxDelay?: number;\n /** Exponential backoff multiplier. Default `2`. */\n factor?: number;\n /** Apply random jitter to spread retries out. Default `true`. */\n jitter?: boolean;\n /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */\n respectRetryAfter?: boolean;\n /**\n * Decide whether a failed attempt should be retried. By default, network\n * errors and the statuses 408/425/429/500/502/503/504 are retried, while\n * aborts are not. QUERY is idempotent, so retrying is safe.\n */\n retryOn?: (context: RetryContext) => boolean | Promise<boolean>;\n /** Called before each backoff wait, with the chosen delay. */\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * How to reach a server that may not support QUERY.\n *\n * - `\"query\"` (default): send a real QUERY, and — when {@link fallbackToPost}\n * is on — fall back to POST only if the server *answers* `405`/`501`.\n * - `\"post-override\"`: skip QUERY entirely and send POST from the start, with\n * the intended method advertised via {@link methodOverrideHeader}. Use this\n * for an origin you already know rejects QUERY *before* a clean status comes\n * back — a legacy proxy, or a cross-origin server whose CORS allows POST but\n * not QUERY yet — where a real QUERY would only fail after a wasted round\n * trip (or a CORS preflight the client can't see). This is a deliberate\n * opt-in: the request goes out as a POST, so intermediaries treat it as\n * unsafe and uncacheable unless the server honors the override. The library\n * never switches to this on its own — you decide, per call.\n */\n transport?: \"query\" | \"post-override\";\n\n /**\n * Automatically retry transient failures. Pass a number for \"retry N times\n * with sensible backoff\", or a {@link RetryOptions} object for full control.\n * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.\n *\n * Retries reuse a buffered body (a string, bytes, or `json`); a streaming\n * body can only be sent once and won't be retried.\n */\n retry?: number | RetryOptions;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\ninterface ResolvedRetry {\n retries: number;\n minDelay: number;\n maxDelay: number;\n factor: number;\n jitter: boolean;\n respectRetryAfter: boolean;\n retryOn: (context: RetryContext) => boolean | Promise<boolean>;\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\nfunction isAbortError(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { name?: string }).name === \"AbortError\"\n );\n}\n\nfunction defaultRetryOn(context: RetryContext): boolean {\n if (context.error !== undefined) return !isAbortError(context.error);\n if (context.response) return RETRYABLE_STATUSES.has(context.response.status);\n return false;\n}\n\nfunction resolveRetry(\n retry: number | RetryOptions | undefined,\n): ResolvedRetry | null {\n if (retry === undefined) return null;\n const opts = typeof retry === \"number\" ? { retries: retry } : retry;\n if (!opts.retries || opts.retries < 1) return null;\n return {\n retries: opts.retries,\n minDelay: opts.minDelay ?? 200,\n maxDelay: opts.maxDelay ?? 10_000,\n factor: opts.factor ?? 2,\n jitter: opts.jitter ?? true,\n respectRetryAfter: opts.respectRetryAfter ?? true,\n retryOn: opts.retryOn ?? defaultRetryOn,\n onRetry: opts.onRetry,\n };\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null;\n const seconds = Number(value);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);\n const date = Date.parse(value);\n if (Number.isNaN(date)) return null;\n return Math.max(0, date - Date.now());\n}\n\nfunction backoffDelay(\n cfg: ResolvedRetry,\n attemptIndex: number,\n response?: Response,\n): number {\n if (cfg.respectRetryAfter && response) {\n const retryAfter = parseRetryAfter(response.headers.get(\"retry-after\"));\n if (retryAfter !== null) return retryAfter;\n }\n const base = Math.min(\n cfg.maxDelay,\n cfg.minDelay * cfg.factor ** attemptIndex,\n );\n return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;\n}\n\nfunction abortError(): Error {\n const error = new Error(\"The operation was aborted.\");\n error.name = \"AbortError\";\n return error;\n}\n\nfunction sleep(ms: number, signal?: AbortSignal | null): Promise<void> {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError());\n return;\n }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer);\n reject(abortError());\n },\n { once: true },\n );\n });\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * retry: 3, // safe: QUERY is idempotent\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n retry: _retry,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n transport = \"query\",\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n // POST carrying the original method in an override header, for a server that\n // doesn't speak QUERY. Shared by the explicit `transport: \"post-override\"`\n // mode and the automatic 405/501 fallback below.\n const postOverride = (): Promise<Response> => {\n const overrideHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n overrideHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: overrideHeaders,\n body,\n });\n };\n\n const attempt = async (): Promise<Response> => {\n // Caller already knows this origin won't accept a QUERY: don't waste the\n // round trip, POST with the override from the start.\n if (transport === \"post-override\") return postOverride();\n\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n // The server *answered* that it doesn't speak QUERY — fall back to POST,\n // advertising the original method so override-aware servers still treat it\n // as one. Only fires on an explicit 405/501, never on an opaque throw.\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n return postOverride();\n };\n\n const retryCfg = resolveRetry(options.retry);\n if (!retryCfg) return attempt();\n\n const signal = init.signal ?? null;\n for (let i = 0; ; i++) {\n let response: Response | undefined;\n let caught: unknown;\n try {\n response = await attempt();\n } catch (error) {\n caught = error;\n }\n\n const isLastAttempt = i >= retryCfg.retries;\n const context: RetryContext = { attempt: i + 1, response, error: caught };\n\n if (caught !== undefined) {\n if (\n isLastAttempt ||\n isAbortError(caught) ||\n !(await retryCfg.retryOn(context))\n ) {\n throw caught;\n }\n } else if (isLastAttempt || !(await retryCfg.retryOn(context))) {\n return response as Response;\n }\n\n const delay = backoffDelay(retryCfg, i, response);\n retryCfg.onRetry?.({ ...context, delay });\n await sleep(delay, signal);\n }\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmat/query-fetch",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the safe, idempotent request with a body. Content-Type enforcement, POST fallback, and Accept negotiation over native fetch.",
5
5
  "keywords": [
6
6
  "http",
@@ -12,7 +12,9 @@
12
12
  "safe-method",
13
13
  "idempotent",
14
14
  "search",
15
- "api-client"
15
+ "api-client",
16
+ "retry",
17
+ "backoff"
16
18
  ],
17
19
  "type": "module",
18
20
  "main": "./dist/index.cjs",