@danmat/query-fetch 0.1.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/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/index.cjs +96 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +87 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Matthew
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @danmat/query-fetch
|
|
2
|
+
|
|
3
|
+
[](https://github.com/DanMat/query-fetch/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@danmat/query-fetch)
|
|
5
|
+
[](https://bundlephobia.com/package/@danmat/query-fetch)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
A tiny, **dependency-free** client for the HTTP **QUERY** method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)) — the request that is *safe and idempotent like `GET`*, but *carries a body like `POST`*, and *caches like neither before it could*.
|
|
9
|
+
|
|
10
|
+
Built on native `fetch`. Works in Node 18+, Deno, Bun, Cloudflare Workers, and the browser.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { query } from "@danmat/query-fetch";
|
|
14
|
+
|
|
15
|
+
const res = await query("https://api.example.com/search", {
|
|
16
|
+
json: { filter: { status: "active" }, sort: "-createdAt", limit: 50 },
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Why QUERY?
|
|
21
|
+
|
|
22
|
+
For years you had two bad options for a search endpoint:
|
|
23
|
+
|
|
24
|
+
- **`GET` with a query string** — safe, idempotent, cacheable… but your filter blows past URL length limits and leaks into logs.
|
|
25
|
+
- **`POST` with a body** — room for a rich query… but it's neither safe, idempotent, nor cacheable, so proxies and clients treat it as a state change.
|
|
26
|
+
|
|
27
|
+
`QUERY` is the missing third option: a body-carrying request that intermediaries may cache and clients may safely retry. This library handles the sharp edges the spec introduces.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install @danmat/query-fetch
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What it does for you
|
|
36
|
+
|
|
37
|
+
Scripted `fetch(url, { method: "QUERY", body })` already works in modern runtimes — but the *semantics* of RFC 10008 are on you. This library covers them:
|
|
38
|
+
|
|
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
|
+
- ✅ **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
|
+
- ✅ **`Accept` negotiation** — pass a media type (or list) to negotiate the response format the RFC's `Accept-Query` dance is built around.
|
|
42
|
+
- ✅ **Redirect-safe** — the RFC's `303 See Other` indirect-result pattern is handled by `fetch`'s own redirect following; nothing surprising here.
|
|
43
|
+
- ✅ **Zero dependencies, fully typed, tree-shakeable**, dual ESM/CJS.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### JSON queries
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { queryJson } from "@danmat/query-fetch";
|
|
51
|
+
|
|
52
|
+
const { data, response } = await queryJson<{ total: number }>(
|
|
53
|
+
"https://api.example.com/search",
|
|
54
|
+
{ json: { q: "http query method" } },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
console.log(data.total, response.headers.get("age"));
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`queryJson` sets `Accept: application/json`, throws on a non-2xx status, and returns the parsed body alongside the raw `Response`.
|
|
61
|
+
|
|
62
|
+
### Raw bodies with an explicit content type
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
await query("https://api.example.com/search", {
|
|
66
|
+
body: "SELECT * WHERE status = 'active'",
|
|
67
|
+
contentType: "application/sql",
|
|
68
|
+
accept: "application/json",
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Opt out of the POST fallback
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
await query(url, { json, fallbackToPost: false });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Bring your own `fetch`
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { fetch as undiciFetch } from "undici";
|
|
82
|
+
|
|
83
|
+
await query(url, { json, fetch: undiciFetch });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
### `query(input, options?): Promise<Response>`
|
|
89
|
+
|
|
90
|
+
Performs a QUERY request. `options` extends `RequestInit` (so `signal`, `credentials`, `redirect`, etc. all work), minus `method` and with a richer `body`:
|
|
91
|
+
|
|
92
|
+
| Option | Type | Default | Description |
|
|
93
|
+
| --- | --- | --- | --- |
|
|
94
|
+
| `body` | `BodyInit \| null` | — | Raw query body. Pair with `contentType`. |
|
|
95
|
+
| `json` | `unknown` | — | Value serialized to JSON; sets `application/json`. |
|
|
96
|
+
| `contentType` | `string` | — | MIME type of `body`. Required when a body is present. |
|
|
97
|
+
| `accept` | `string \| string[]` | — | Sets the `Accept` header. |
|
|
98
|
+
| `fallbackToPost` | `boolean` | `true` | Retry as `POST` on `405`/`501`. |
|
|
99
|
+
| `methodOverrideHeader` | `string \| false` | `"X-HTTP-Method-Override"` | Header advertising the original method on fallback. |
|
|
100
|
+
| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. |
|
|
101
|
+
|
|
102
|
+
### `queryJson<T>(input, options?): Promise<{ data: T; response: Response }>`
|
|
103
|
+
|
|
104
|
+
`query` + JSON parsing + a non-2xx guard.
|
|
105
|
+
|
|
106
|
+
### `QueryError`
|
|
107
|
+
|
|
108
|
+
Thrown for construction-time problems (a body without a content type, no available `fetch`) and non-2xx responses in `queryJson`.
|
|
109
|
+
|
|
110
|
+
## Caveats & status
|
|
111
|
+
|
|
112
|
+
QUERY is a **Proposed Standard** (June 2026). Two things to know:
|
|
113
|
+
|
|
114
|
+
- **CORS:** QUERY is not a CORS-safelisted method, so a cross-origin QUERY triggers a preflight. Your server must handle `OPTIONS` accordingly.
|
|
115
|
+
- **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
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
[MIT](./LICENSE) © Dan Matthew
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var METHOD_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([405, 501]);
|
|
5
|
+
var DEFAULT_OVERRIDE_HEADER = "X-HTTP-Method-Override";
|
|
6
|
+
var QueryError = class _QueryError extends Error {
|
|
7
|
+
name = "QueryError";
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
Object.setPrototypeOf(this, _QueryError.prototype);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
function resolveFetch(custom) {
|
|
14
|
+
const impl = custom ?? globalThis.fetch;
|
|
15
|
+
if (typeof impl !== "function") {
|
|
16
|
+
throw new QueryError(
|
|
17
|
+
"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return impl;
|
|
21
|
+
}
|
|
22
|
+
function prepare(options) {
|
|
23
|
+
const headers = new Headers(options.headers);
|
|
24
|
+
let body = options.body;
|
|
25
|
+
if (body == null && options.json !== void 0) {
|
|
26
|
+
body = JSON.stringify(options.json);
|
|
27
|
+
if (!headers.has("content-type")) {
|
|
28
|
+
headers.set("content-type", "application/json");
|
|
29
|
+
}
|
|
30
|
+
} else if (options.contentType) {
|
|
31
|
+
headers.set("content-type", options.contentType);
|
|
32
|
+
}
|
|
33
|
+
const hasBody = body != null;
|
|
34
|
+
if (hasBody && !headers.has("content-type")) {
|
|
35
|
+
throw new QueryError(
|
|
36
|
+
"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly."
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (options.accept !== void 0) {
|
|
40
|
+
const accept = Array.isArray(options.accept) ? options.accept.join(", ") : options.accept;
|
|
41
|
+
headers.set("accept", accept);
|
|
42
|
+
}
|
|
43
|
+
return { headers, body };
|
|
44
|
+
}
|
|
45
|
+
async function query(input, options = {}) {
|
|
46
|
+
const doFetch = resolveFetch(options.fetch);
|
|
47
|
+
const { headers, body } = prepare(options);
|
|
48
|
+
const {
|
|
49
|
+
fetch: _fetch,
|
|
50
|
+
json: _json,
|
|
51
|
+
contentType: _contentType,
|
|
52
|
+
accept: _accept,
|
|
53
|
+
fallbackToPost = true,
|
|
54
|
+
methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,
|
|
55
|
+
headers: _headers,
|
|
56
|
+
body: _body,
|
|
57
|
+
...init
|
|
58
|
+
} = 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");
|
|
71
|
+
}
|
|
72
|
+
return doFetch(input, {
|
|
73
|
+
...init,
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: fallbackHeaders,
|
|
76
|
+
body
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function queryJson(input, options = {}) {
|
|
80
|
+
const response = await query(input, {
|
|
81
|
+
accept: "application/json",
|
|
82
|
+
...options
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new QueryError(
|
|
86
|
+
`QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return { data: await response.json(), response };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
exports.QueryError = QueryError;
|
|
93
|
+
exports.query = query;
|
|
94
|
+
exports.queryJson = queryJson;
|
|
95
|
+
//# sourceMappingURL=index.cjs.map
|
|
96
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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\n 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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @danmat/query-fetch
|
|
3
|
+
*
|
|
4
|
+
* A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the
|
|
5
|
+
* safe, idempotent request that carries a body like POST but caches like GET.
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.rfc-editor.org/rfc/rfc10008
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Error thrown when a QUERY request cannot be constructed according to the
|
|
11
|
+
* requirements of RFC 10008 (e.g. a body with no `Content-Type`).
|
|
12
|
+
*/
|
|
13
|
+
declare class QueryError extends Error {
|
|
14
|
+
name: string;
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
17
|
+
/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */
|
|
18
|
+
interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
|
|
19
|
+
/**
|
|
20
|
+
* The raw query body. Pair with {@link QueryOptions.contentType}. For JSON
|
|
21
|
+
* payloads prefer {@link QueryOptions.json}, which sets the content type for
|
|
22
|
+
* you.
|
|
23
|
+
*/
|
|
24
|
+
body?: BodyInit | null;
|
|
25
|
+
/**
|
|
26
|
+
* Convenience: a value serialized to JSON and sent as
|
|
27
|
+
* `application/json`. Ignored if {@link QueryOptions.body} is set.
|
|
28
|
+
*/
|
|
29
|
+
json?: unknown;
|
|
30
|
+
/**
|
|
31
|
+
* MIME type of the query body. **Required** whenever a body is present —
|
|
32
|
+
* RFC 10008 mandates that servers reject a QUERY with a missing or
|
|
33
|
+
* inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.
|
|
34
|
+
*/
|
|
35
|
+
contentType?: string;
|
|
36
|
+
/** Media type(s) acceptable in the response. Sets the `Accept` header. */
|
|
37
|
+
accept?: string | string[];
|
|
38
|
+
/**
|
|
39
|
+
* Retry as `POST` when the server reports it does not support QUERY
|
|
40
|
+
* (HTTP 405 or 501). Defaults to `true`.
|
|
41
|
+
*/
|
|
42
|
+
fallbackToPost?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Header name used to advertise the original method on a POST fallback so
|
|
45
|
+
* that override-aware servers can still route it as a QUERY. Set to `false`
|
|
46
|
+
* to disable. Defaults to `"X-HTTP-Method-Override"`.
|
|
47
|
+
*/
|
|
48
|
+
methodOverrideHeader?: string | false;
|
|
49
|
+
/**
|
|
50
|
+
* A `fetch` implementation to use instead of the global. Handy for testing
|
|
51
|
+
* or for runtimes that expose `fetch` on a client rather than globally.
|
|
52
|
+
*/
|
|
53
|
+
fetch?: typeof fetch;
|
|
54
|
+
}
|
|
55
|
+
/** The result of {@link queryJson}: a parsed body plus the originating response. */
|
|
56
|
+
interface QueryJsonResult<T> {
|
|
57
|
+
data: T;
|
|
58
|
+
response: Response;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Perform an HTTP QUERY request (RFC 10008).
|
|
62
|
+
*
|
|
63
|
+
* QUERY is safe and idempotent like GET, but carries a request body like POST,
|
|
64
|
+
* making it ideal for large or structured queries that don't fit in a URL.
|
|
65
|
+
*
|
|
66
|
+
* Redirects — including the RFC's `303 See Other` indirect-result pattern — are
|
|
67
|
+
* handled by the underlying `fetch` per the request's `redirect` mode (default
|
|
68
|
+
* `"follow"`), so no special handling is needed here.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* const res = await query("https://api.example.com/search", {
|
|
73
|
+
* json: { filter: { status: "active" }, sort: "-createdAt" },
|
|
74
|
+
* accept: "application/json",
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare function query(input: string | URL, options?: QueryOptions): Promise<Response>;
|
|
79
|
+
/**
|
|
80
|
+
* Like {@link query}, but parses the response body as JSON.
|
|
81
|
+
*
|
|
82
|
+
* Throws {@link QueryError} on a non-2xx response so callers don't silently
|
|
83
|
+
* parse an error page.
|
|
84
|
+
*/
|
|
85
|
+
declare function queryJson<T = unknown>(input: string | URL, options?: QueryOptions): Promise<QueryJsonResult<T>>;
|
|
86
|
+
|
|
87
|
+
export { QueryError, type QueryJsonResult, type QueryOptions, query, queryJson };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @danmat/query-fetch
|
|
3
|
+
*
|
|
4
|
+
* A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the
|
|
5
|
+
* safe, idempotent request that carries a body like POST but caches like GET.
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.rfc-editor.org/rfc/rfc10008
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Error thrown when a QUERY request cannot be constructed according to the
|
|
11
|
+
* requirements of RFC 10008 (e.g. a body with no `Content-Type`).
|
|
12
|
+
*/
|
|
13
|
+
declare class QueryError extends Error {
|
|
14
|
+
name: string;
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
17
|
+
/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */
|
|
18
|
+
interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
|
|
19
|
+
/**
|
|
20
|
+
* The raw query body. Pair with {@link QueryOptions.contentType}. For JSON
|
|
21
|
+
* payloads prefer {@link QueryOptions.json}, which sets the content type for
|
|
22
|
+
* you.
|
|
23
|
+
*/
|
|
24
|
+
body?: BodyInit | null;
|
|
25
|
+
/**
|
|
26
|
+
* Convenience: a value serialized to JSON and sent as
|
|
27
|
+
* `application/json`. Ignored if {@link QueryOptions.body} is set.
|
|
28
|
+
*/
|
|
29
|
+
json?: unknown;
|
|
30
|
+
/**
|
|
31
|
+
* MIME type of the query body. **Required** whenever a body is present —
|
|
32
|
+
* RFC 10008 mandates that servers reject a QUERY with a missing or
|
|
33
|
+
* inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.
|
|
34
|
+
*/
|
|
35
|
+
contentType?: string;
|
|
36
|
+
/** Media type(s) acceptable in the response. Sets the `Accept` header. */
|
|
37
|
+
accept?: string | string[];
|
|
38
|
+
/**
|
|
39
|
+
* Retry as `POST` when the server reports it does not support QUERY
|
|
40
|
+
* (HTTP 405 or 501). Defaults to `true`.
|
|
41
|
+
*/
|
|
42
|
+
fallbackToPost?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Header name used to advertise the original method on a POST fallback so
|
|
45
|
+
* that override-aware servers can still route it as a QUERY. Set to `false`
|
|
46
|
+
* to disable. Defaults to `"X-HTTP-Method-Override"`.
|
|
47
|
+
*/
|
|
48
|
+
methodOverrideHeader?: string | false;
|
|
49
|
+
/**
|
|
50
|
+
* A `fetch` implementation to use instead of the global. Handy for testing
|
|
51
|
+
* or for runtimes that expose `fetch` on a client rather than globally.
|
|
52
|
+
*/
|
|
53
|
+
fetch?: typeof fetch;
|
|
54
|
+
}
|
|
55
|
+
/** The result of {@link queryJson}: a parsed body plus the originating response. */
|
|
56
|
+
interface QueryJsonResult<T> {
|
|
57
|
+
data: T;
|
|
58
|
+
response: Response;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Perform an HTTP QUERY request (RFC 10008).
|
|
62
|
+
*
|
|
63
|
+
* QUERY is safe and idempotent like GET, but carries a request body like POST,
|
|
64
|
+
* making it ideal for large or structured queries that don't fit in a URL.
|
|
65
|
+
*
|
|
66
|
+
* Redirects — including the RFC's `303 See Other` indirect-result pattern — are
|
|
67
|
+
* handled by the underlying `fetch` per the request's `redirect` mode (default
|
|
68
|
+
* `"follow"`), so no special handling is needed here.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* const res = await query("https://api.example.com/search", {
|
|
73
|
+
* json: { filter: { status: "active" }, sort: "-createdAt" },
|
|
74
|
+
* accept: "application/json",
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare function query(input: string | URL, options?: QueryOptions): Promise<Response>;
|
|
79
|
+
/**
|
|
80
|
+
* Like {@link query}, but parses the response body as JSON.
|
|
81
|
+
*
|
|
82
|
+
* Throws {@link QueryError} on a non-2xx response so callers don't silently
|
|
83
|
+
* parse an error page.
|
|
84
|
+
*/
|
|
85
|
+
declare function queryJson<T = unknown>(input: string | URL, options?: QueryOptions): Promise<QueryJsonResult<T>>;
|
|
86
|
+
|
|
87
|
+
export { QueryError, type QueryJsonResult, type QueryOptions, query, queryJson };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var METHOD_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([405, 501]);
|
|
3
|
+
var DEFAULT_OVERRIDE_HEADER = "X-HTTP-Method-Override";
|
|
4
|
+
var QueryError = class _QueryError extends Error {
|
|
5
|
+
name = "QueryError";
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
Object.setPrototypeOf(this, _QueryError.prototype);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
function resolveFetch(custom) {
|
|
12
|
+
const impl = custom ?? globalThis.fetch;
|
|
13
|
+
if (typeof impl !== "function") {
|
|
14
|
+
throw new QueryError(
|
|
15
|
+
"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return impl;
|
|
19
|
+
}
|
|
20
|
+
function prepare(options) {
|
|
21
|
+
const headers = new Headers(options.headers);
|
|
22
|
+
let body = options.body;
|
|
23
|
+
if (body == null && options.json !== void 0) {
|
|
24
|
+
body = JSON.stringify(options.json);
|
|
25
|
+
if (!headers.has("content-type")) {
|
|
26
|
+
headers.set("content-type", "application/json");
|
|
27
|
+
}
|
|
28
|
+
} else if (options.contentType) {
|
|
29
|
+
headers.set("content-type", options.contentType);
|
|
30
|
+
}
|
|
31
|
+
const hasBody = body != null;
|
|
32
|
+
if (hasBody && !headers.has("content-type")) {
|
|
33
|
+
throw new QueryError(
|
|
34
|
+
"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (options.accept !== void 0) {
|
|
38
|
+
const accept = Array.isArray(options.accept) ? options.accept.join(", ") : options.accept;
|
|
39
|
+
headers.set("accept", accept);
|
|
40
|
+
}
|
|
41
|
+
return { headers, body };
|
|
42
|
+
}
|
|
43
|
+
async function query(input, options = {}) {
|
|
44
|
+
const doFetch = resolveFetch(options.fetch);
|
|
45
|
+
const { headers, body } = prepare(options);
|
|
46
|
+
const {
|
|
47
|
+
fetch: _fetch,
|
|
48
|
+
json: _json,
|
|
49
|
+
contentType: _contentType,
|
|
50
|
+
accept: _accept,
|
|
51
|
+
fallbackToPost = true,
|
|
52
|
+
methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,
|
|
53
|
+
headers: _headers,
|
|
54
|
+
body: _body,
|
|
55
|
+
...init
|
|
56
|
+
} = 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");
|
|
69
|
+
}
|
|
70
|
+
return doFetch(input, {
|
|
71
|
+
...init,
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: fallbackHeaders,
|
|
74
|
+
body
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function queryJson(input, options = {}) {
|
|
78
|
+
const response = await query(input, {
|
|
79
|
+
accept: "application/json",
|
|
80
|
+
...options
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new QueryError(
|
|
84
|
+
`QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return { data: await response.json(), response };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { QueryError, query, queryJson };
|
|
91
|
+
//# sourceMappingURL=index.js.map
|
|
92
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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\n 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"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danmat/query-fetch",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"http",
|
|
7
|
+
"query",
|
|
8
|
+
"query-method",
|
|
9
|
+
"rfc10008",
|
|
10
|
+
"rfc-10008",
|
|
11
|
+
"fetch",
|
|
12
|
+
"safe-method",
|
|
13
|
+
"idempotent",
|
|
14
|
+
"search",
|
|
15
|
+
"api-client"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./dist/index.cjs",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"coverage": "vitest run --coverage",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"author": "Dan Matthew <dannymatthew@gmail.com>",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/DanMat/query-fetch.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/DanMat/query-fetch/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/DanMat/query-fetch#readme",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
59
|
+
"tsup": "^8.3.5",
|
|
60
|
+
"typescript": "^5.7.2",
|
|
61
|
+
"vitest": "^2.1.8"
|
|
62
|
+
}
|
|
63
|
+
}
|