@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 +51 -1
- package/dist/index.cjs +115 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -1
- package/dist/index.d.ts +61 -1
- package/dist/index.js +115 -18
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
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
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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, {
|
package/dist/index.cjs.map
CHANGED
|
@@ -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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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",
|