@danmat/query-server 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,6 +70,25 @@ Builds an `Accept-Query` header value from the media types you accept (strings a
70
70
 
71
71
  Returns a copy of `response` with the `Accept-Query` header set — handy on both success and `415` responses.
72
72
 
73
+ ### `conditional(request, response): Promise<Response>`
74
+
75
+ HTTP revalidation for QUERY results: attaches a strong `ETag`, and returns `304 Not Modified` when the request's `If-None-Match` already matches (echoing `Content-Location`/`Cache-Control`/`Vary`). Only applied to 2xx responses. QUERY is safe and cacheable, so this is exactly the method conditional requests are meant for.
76
+
77
+ ```ts
78
+ return conditional(
79
+ request,
80
+ withContentLocation(withAcceptQuery(Response.json(results), ACCEPTED), request.url),
81
+ );
82
+ ```
83
+
84
+ ### `withContentLocation(response, location): Response`
85
+
86
+ Returns a copy of `response` with a `Content-Location` header (the URL identifying the returned representation).
87
+
88
+ ### `etagFor(body): Promise<string>`
89
+
90
+ Computes a strong `ETag` (quoted SHA-256/base64url) for a body — equal bytes yield equal tags. Used by `conditional`, exposed for custom flows.
91
+
73
92
  ### `QueryRequestError`
74
93
 
75
94
  `Error` subclass with `status: number`, `headers: Record<string,string>`, and `toResponse(): Response`.
package/dist/index.cjs CHANGED
@@ -93,14 +93,71 @@ function withAcceptQuery(response, mediaTypes) {
93
93
  headers
94
94
  });
95
95
  }
96
+ function withContentLocation(response, location) {
97
+ const headers = new Headers(response.headers);
98
+ headers.set("content-location", location.toString());
99
+ return new Response(response.body, {
100
+ status: response.status,
101
+ statusText: response.statusText,
102
+ headers
103
+ });
104
+ }
105
+ async function getSubtle() {
106
+ const globalCrypto = globalThis.crypto;
107
+ if (globalCrypto?.subtle) return globalCrypto.subtle;
108
+ const specifier = "node:crypto";
109
+ const nodeCrypto = await import(specifier);
110
+ return nodeCrypto.webcrypto.subtle;
111
+ }
112
+ function base64url(buffer) {
113
+ const bytes = new Uint8Array(buffer);
114
+ let binary = "";
115
+ for (const byte of bytes) binary += String.fromCharCode(byte);
116
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
117
+ }
118
+ async function etagFor(body) {
119
+ const bytes = body == null ? new ArrayBuffer(0) : await new Response(body).arrayBuffer();
120
+ const digest = await (await getSubtle()).digest("SHA-256", bytes);
121
+ return `"${base64url(digest)}"`;
122
+ }
123
+ function ifNoneMatchSatisfied(ifNoneMatch, etag) {
124
+ if (!ifNoneMatch) return false;
125
+ if (ifNoneMatch.trim() === "*") return true;
126
+ const strip = (tag) => tag.trim().replace(/^W\//, "");
127
+ const target = strip(etag);
128
+ return ifNoneMatch.split(",").some((tag) => strip(tag) === target);
129
+ }
130
+ async function conditional(request, response) {
131
+ if (!response.ok) return response;
132
+ const buffer = await response.clone().arrayBuffer();
133
+ const etag = response.headers.get("etag") ?? await etagFor(buffer);
134
+ if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
135
+ const headers2 = new Headers({ etag });
136
+ for (const name of ["content-location", "cache-control", "vary"]) {
137
+ const value = response.headers.get(name);
138
+ if (value) headers2.set(name, value);
139
+ }
140
+ return new Response(null, { status: 304, headers: headers2 });
141
+ }
142
+ const headers = new Headers(response.headers);
143
+ headers.set("etag", etag);
144
+ return new Response(buffer, {
145
+ status: response.status,
146
+ statusText: response.statusText,
147
+ headers
148
+ });
149
+ }
96
150
 
97
151
  exports.QUERY_METHOD = QUERY_METHOD;
98
152
  exports.QueryRequestError = QueryRequestError;
99
153
  exports.acceptQueryHeader = acceptQueryHeader;
100
154
  exports.assertQueryRequest = assertQueryRequest;
101
155
  exports.checkQueryRequest = checkQueryRequest;
156
+ exports.conditional = conditional;
157
+ exports.etagFor = etagFor;
102
158
  exports.isQueryRequest = isQueryRequest;
103
159
  exports.readQueryJson = readQueryJson;
104
160
  exports.withAcceptQuery = withAcceptQuery;
161
+ exports.withContentLocation = withContentLocation;
105
162
  //# sourceMappingURL=index.cjs.map
106
163
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["formatAcceptQuery","negotiateQuery"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-server\n *\n * Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),\n * built on Web-standard `Request`/`Response` — so they work in Hono, Deno,\n * Bun, Cloudflare Workers, and Node (via a web adapter).\n *\n * Validate incoming QUERY requests, enforce the RFC's `Content-Type`\n * requirement, negotiate the accepted query formats, and advertise them with\n * the `Accept-Query` response header.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\nimport {\n formatAcceptQuery,\n negotiateQuery,\n type MediaRangeInput,\n} from \"@danmat/accept-query\";\n\n/** The QUERY method name. */\nexport const QUERY_METHOD = \"QUERY\";\n\nexport interface QueryRequestOptions {\n /**\n * Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as\n * QUERY requests — the fallback that clients like `@danmat/query-fetch` use\n * for servers that don't route QUERY natively. Default `true`.\n */\n allowMethodOverride?: boolean;\n}\n\nexport interface QueryValidationOptions extends QueryRequestOptions {\n /**\n * Media types this server accepts as query bodies. When provided, requests\n * with an unsupported `Content-Type` are rejected with `415` and an\n * `Accept-Query` header advertising these types.\n */\n accept?: MediaRangeInput[];\n}\n\n/**\n * An error describing why a request is not a valid QUERY, carrying the HTTP\n * status and headers that should be sent in response.\n */\nexport class QueryRequestError extends Error {\n override name = \"QueryRequestError\";\n readonly status: number;\n readonly headers: Record<string, string>;\n\n constructor(message: string, status: number, headers: Record<string, string> = {}) {\n super(message);\n this.status = status;\n this.headers = headers;\n Object.setPrototypeOf(this, QueryRequestError.prototype);\n }\n\n /** Render this error as a JSON `Response` with the appropriate status/headers. */\n toResponse(): Response {\n return new Response(JSON.stringify({ error: this.message }), {\n status: this.status,\n headers: { \"content-type\": \"application/json\", ...this.headers },\n });\n }\n}\n\n/** Whether a request should be handled as a QUERY (honoring method override). */\nexport function isQueryRequest(\n request: Request,\n options: QueryRequestOptions = {},\n): boolean {\n const { allowMethodOverride = true } = options;\n const method = request.method.toUpperCase();\n if (method === QUERY_METHOD) return true;\n if (allowMethodOverride && method === \"POST\") {\n return (\n (request.headers.get(\"x-http-method-override\") ?? \"\").toUpperCase() ===\n QUERY_METHOD\n );\n }\n return false;\n}\n\n/** Build an `Accept-Query` header value from the media types a server accepts. */\nexport function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string {\n return formatAcceptQuery(mediaTypes);\n}\n\nfunction isAccepted(contentType: string, accept: MediaRangeInput[]): boolean {\n return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;\n}\n\n/**\n * Validate that `request` is a well-formed QUERY per RFC 10008. Throws a\n * {@link QueryRequestError} (with the right HTTP status) when it isn't:\n *\n * - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)\n * - missing `Content-Type` → `400 Bad Request`\n * - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`\n */\nexport function assertQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): void {\n const { accept, allowMethodOverride = true } = options;\n\n if (!isQueryRequest(request, { allowMethodOverride })) {\n throw new QueryRequestError(\n `Expected a ${QUERY_METHOD} request but received ${request.method}.`,\n 405,\n { allow: QUERY_METHOD },\n );\n }\n\n const contentType = request.headers.get(\"content-type\");\n if (!contentType) {\n throw new QueryRequestError(\n \"A QUERY request must include a Content-Type (RFC 10008).\",\n 400,\n );\n }\n\n if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {\n throw new QueryRequestError(\n `Unsupported query media type \"${contentType}\".`,\n 415,\n { \"accept-query\": acceptQueryHeader(accept) },\n );\n }\n}\n\n/**\n * Non-throwing companion to {@link assertQueryRequest}. Returns an error\n * `Response` to send back, or `null` if the request is valid.\n *\n * @example\n * ```ts\n * const bad = checkQueryRequest(request, { accept: [\"application/json\"] });\n * if (bad) return bad;\n * const query = await readQueryJson(request);\n * ```\n */\nexport function checkQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): Response | null {\n try {\n assertQueryRequest(request, options);\n return null;\n } catch (error) {\n if (error instanceof QueryRequestError) return error.toResponse();\n throw error;\n }\n}\n\n/**\n * Read a QUERY request body as JSON, guarding the content type. Throws a\n * {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).\n */\nexport async function readQueryJson<T = unknown>(\n request: Request,\n): Promise<T> {\n const contentType = request.headers.get(\"content-type\") ?? \"\";\n if (!/\\bjson\\b/i.test(contentType)) {\n throw new QueryRequestError(\n `Expected a JSON query body but Content-Type was \"${contentType || \"absent\"}\".`,\n 415,\n );\n }\n try {\n return (await request.json()) as T;\n } catch {\n throw new QueryRequestError(\"Query body is not valid JSON.\", 400);\n }\n}\n\n/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */\nexport function withAcceptQuery(\n response: Response,\n mediaTypes: MediaRangeInput[],\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"accept-query\", acceptQueryHeader(mediaTypes));\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["formatAcceptQuery","negotiateQuery","headers"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-server\n *\n * Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),\n * built on Web-standard `Request`/`Response` — so they work in Hono, Deno,\n * Bun, Cloudflare Workers, and Node (via a web adapter).\n *\n * Validate incoming QUERY requests, enforce the RFC's `Content-Type`\n * requirement, negotiate the accepted query formats, and advertise them with\n * the `Accept-Query` response header.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\nimport {\n formatAcceptQuery,\n type MediaRangeInput,\n negotiateQuery,\n} from \"@danmat/accept-query\";\n\n/** The QUERY method name. */\nexport const QUERY_METHOD = \"QUERY\";\n\nexport interface QueryRequestOptions {\n /**\n * Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as\n * QUERY requests — the fallback that clients like `@danmat/query-fetch` use\n * for servers that don't route QUERY natively. Default `true`.\n */\n allowMethodOverride?: boolean;\n}\n\nexport interface QueryValidationOptions extends QueryRequestOptions {\n /**\n * Media types this server accepts as query bodies. When provided, requests\n * with an unsupported `Content-Type` are rejected with `415` and an\n * `Accept-Query` header advertising these types.\n */\n accept?: MediaRangeInput[];\n}\n\n/**\n * An error describing why a request is not a valid QUERY, carrying the HTTP\n * status and headers that should be sent in response.\n */\nexport class QueryRequestError extends Error {\n override name = \"QueryRequestError\";\n readonly status: number;\n readonly headers: Record<string, string>;\n\n constructor(\n message: string,\n status: number,\n headers: Record<string, string> = {},\n ) {\n super(message);\n this.status = status;\n this.headers = headers;\n Object.setPrototypeOf(this, QueryRequestError.prototype);\n }\n\n /** Render this error as a JSON `Response` with the appropriate status/headers. */\n toResponse(): Response {\n return new Response(JSON.stringify({ error: this.message }), {\n status: this.status,\n headers: { \"content-type\": \"application/json\", ...this.headers },\n });\n }\n}\n\n/** Whether a request should be handled as a QUERY (honoring method override). */\nexport function isQueryRequest(\n request: Request,\n options: QueryRequestOptions = {},\n): boolean {\n const { allowMethodOverride = true } = options;\n const method = request.method.toUpperCase();\n if (method === QUERY_METHOD) return true;\n if (allowMethodOverride && method === \"POST\") {\n return (\n (request.headers.get(\"x-http-method-override\") ?? \"\").toUpperCase() ===\n QUERY_METHOD\n );\n }\n return false;\n}\n\n/** Build an `Accept-Query` header value from the media types a server accepts. */\nexport function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string {\n return formatAcceptQuery(mediaTypes);\n}\n\nfunction isAccepted(contentType: string, accept: MediaRangeInput[]): boolean {\n return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;\n}\n\n/**\n * Validate that `request` is a well-formed QUERY per RFC 10008. Throws a\n * {@link QueryRequestError} (with the right HTTP status) when it isn't:\n *\n * - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)\n * - missing `Content-Type` → `400 Bad Request`\n * - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`\n */\nexport function assertQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): void {\n const { accept, allowMethodOverride = true } = options;\n\n if (!isQueryRequest(request, { allowMethodOverride })) {\n throw new QueryRequestError(\n `Expected a ${QUERY_METHOD} request but received ${request.method}.`,\n 405,\n { allow: QUERY_METHOD },\n );\n }\n\n const contentType = request.headers.get(\"content-type\");\n if (!contentType) {\n throw new QueryRequestError(\n \"A QUERY request must include a Content-Type (RFC 10008).\",\n 400,\n );\n }\n\n if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {\n throw new QueryRequestError(\n `Unsupported query media type \"${contentType}\".`,\n 415,\n { \"accept-query\": acceptQueryHeader(accept) },\n );\n }\n}\n\n/**\n * Non-throwing companion to {@link assertQueryRequest}. Returns an error\n * `Response` to send back, or `null` if the request is valid.\n *\n * @example\n * ```ts\n * const bad = checkQueryRequest(request, { accept: [\"application/json\"] });\n * if (bad) return bad;\n * const query = await readQueryJson(request);\n * ```\n */\nexport function checkQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): Response | null {\n try {\n assertQueryRequest(request, options);\n return null;\n } catch (error) {\n if (error instanceof QueryRequestError) return error.toResponse();\n throw error;\n }\n}\n\n/**\n * Read a QUERY request body as JSON, guarding the content type. Throws a\n * {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).\n */\nexport async function readQueryJson<T = unknown>(request: Request): Promise<T> {\n const contentType = request.headers.get(\"content-type\") ?? \"\";\n if (!/\\bjson\\b/i.test(contentType)) {\n throw new QueryRequestError(\n `Expected a JSON query body but Content-Type was \"${contentType || \"absent\"}\".`,\n 415,\n );\n }\n try {\n return (await request.json()) as T;\n } catch {\n throw new QueryRequestError(\"Query body is not valid JSON.\", 400);\n }\n}\n\n/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */\nexport function withAcceptQuery(\n response: Response,\n mediaTypes: MediaRangeInput[],\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"accept-query\", acceptQueryHeader(mediaTypes));\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\n/** Return a copy of `response` with a `Content-Location` header. */\nexport function withContentLocation(\n response: Response,\n location: string | URL,\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"content-location\", location.toString());\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nasync function getSubtle(): Promise<SubtleCrypto> {\n const globalCrypto = (globalThis as { crypto?: Crypto }).crypto;\n if (globalCrypto?.subtle) return globalCrypto.subtle;\n // Older Node without a global `crypto`; never reached in browsers/workers.\n const specifier = \"node:crypto\";\n const nodeCrypto = (await import(specifier)) as {\n webcrypto: { subtle: SubtleCrypto };\n };\n return nodeCrypto.webcrypto.subtle;\n}\n\nfunction base64url(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer);\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\n/**\n * Compute a strong `ETag` (a quoted SHA-256/base64url digest) for a body. A\n * QUERY response's representation is identified by its bytes, so equal bodies\n * yield equal tags.\n */\nexport async function etagFor(\n body: BodyInit | null | undefined,\n): Promise<string> {\n const bytes =\n body == null ? new ArrayBuffer(0) : await new Response(body).arrayBuffer();\n const digest = await (await getSubtle()).digest(\"SHA-256\", bytes);\n return `\"${base64url(digest)}\"`;\n}\n\n/** RFC 9110 weak comparison: does `ifNoneMatch` match `etag`? */\nfunction ifNoneMatchSatisfied(\n ifNoneMatch: string | null,\n etag: string,\n): boolean {\n if (!ifNoneMatch) return false;\n if (ifNoneMatch.trim() === \"*\") return true;\n const strip = (tag: string) => tag.trim().replace(/^W\\//, \"\");\n const target = strip(etag);\n return ifNoneMatch.split(\",\").some((tag) => strip(tag) === target);\n}\n\n/**\n * Add HTTP revalidation to a response: attach a strong `ETag`, and when the\n * request's `If-None-Match` already matches, short-circuit to `304 Not\n * Modified`. Safe for QUERY, which is idempotent and cacheable.\n *\n * Only applied to successful (2xx) responses; others pass through unchanged.\n *\n * @example\n * ```ts\n * return conditional(request, withAcceptQuery(Response.json(results), ACCEPTED));\n * ```\n */\nexport async function conditional(\n request: Request,\n response: Response,\n): Promise<Response> {\n if (!response.ok) return response;\n\n const buffer = await response.clone().arrayBuffer();\n const etag = response.headers.get(\"etag\") ?? (await etagFor(buffer));\n\n if (ifNoneMatchSatisfied(request.headers.get(\"if-none-match\"), etag)) {\n const headers = new Headers({ etag });\n // A 304 carries no body but should echo the caching-relevant headers.\n for (const name of [\"content-location\", \"cache-control\", \"vary\"]) {\n const value = response.headers.get(name);\n if (value) headers.set(name, value);\n }\n return new Response(null, { status: 304, headers });\n }\n\n const headers = new Headers(response.headers);\n headers.set(\"etag\", etag);\n return new Response(buffer, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -76,5 +76,26 @@ declare function checkQueryRequest(request: Request, options?: QueryValidationOp
76
76
  declare function readQueryJson<T = unknown>(request: Request): Promise<T>;
77
77
  /** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */
78
78
  declare function withAcceptQuery(response: Response, mediaTypes: MediaRangeInput[]): Response;
79
+ /** Return a copy of `response` with a `Content-Location` header. */
80
+ declare function withContentLocation(response: Response, location: string | URL): Response;
81
+ /**
82
+ * Compute a strong `ETag` (a quoted SHA-256/base64url digest) for a body. A
83
+ * QUERY response's representation is identified by its bytes, so equal bodies
84
+ * yield equal tags.
85
+ */
86
+ declare function etagFor(body: BodyInit | null | undefined): Promise<string>;
87
+ /**
88
+ * Add HTTP revalidation to a response: attach a strong `ETag`, and when the
89
+ * request's `If-None-Match` already matches, short-circuit to `304 Not
90
+ * Modified`. Safe for QUERY, which is idempotent and cacheable.
91
+ *
92
+ * Only applied to successful (2xx) responses; others pass through unchanged.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * return conditional(request, withAcceptQuery(Response.json(results), ACCEPTED));
97
+ * ```
98
+ */
99
+ declare function conditional(request: Request, response: Response): Promise<Response>;
79
100
 
80
- export { QUERY_METHOD, QueryRequestError, type QueryRequestOptions, type QueryValidationOptions, acceptQueryHeader, assertQueryRequest, checkQueryRequest, isQueryRequest, readQueryJson, withAcceptQuery };
101
+ export { QUERY_METHOD, QueryRequestError, type QueryRequestOptions, type QueryValidationOptions, acceptQueryHeader, assertQueryRequest, checkQueryRequest, conditional, etagFor, isQueryRequest, readQueryJson, withAcceptQuery, withContentLocation };
package/dist/index.d.ts CHANGED
@@ -76,5 +76,26 @@ declare function checkQueryRequest(request: Request, options?: QueryValidationOp
76
76
  declare function readQueryJson<T = unknown>(request: Request): Promise<T>;
77
77
  /** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */
78
78
  declare function withAcceptQuery(response: Response, mediaTypes: MediaRangeInput[]): Response;
79
+ /** Return a copy of `response` with a `Content-Location` header. */
80
+ declare function withContentLocation(response: Response, location: string | URL): Response;
81
+ /**
82
+ * Compute a strong `ETag` (a quoted SHA-256/base64url digest) for a body. A
83
+ * QUERY response's representation is identified by its bytes, so equal bodies
84
+ * yield equal tags.
85
+ */
86
+ declare function etagFor(body: BodyInit | null | undefined): Promise<string>;
87
+ /**
88
+ * Add HTTP revalidation to a response: attach a strong `ETag`, and when the
89
+ * request's `If-None-Match` already matches, short-circuit to `304 Not
90
+ * Modified`. Safe for QUERY, which is idempotent and cacheable.
91
+ *
92
+ * Only applied to successful (2xx) responses; others pass through unchanged.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * return conditional(request, withAcceptQuery(Response.json(results), ACCEPTED));
97
+ * ```
98
+ */
99
+ declare function conditional(request: Request, response: Response): Promise<Response>;
79
100
 
80
- export { QUERY_METHOD, QueryRequestError, type QueryRequestOptions, type QueryValidationOptions, acceptQueryHeader, assertQueryRequest, checkQueryRequest, isQueryRequest, readQueryJson, withAcceptQuery };
101
+ export { QUERY_METHOD, QueryRequestError, type QueryRequestOptions, type QueryValidationOptions, acceptQueryHeader, assertQueryRequest, checkQueryRequest, conditional, etagFor, isQueryRequest, readQueryJson, withAcceptQuery, withContentLocation };
package/dist/index.js CHANGED
@@ -91,7 +91,61 @@ function withAcceptQuery(response, mediaTypes) {
91
91
  headers
92
92
  });
93
93
  }
94
+ function withContentLocation(response, location) {
95
+ const headers = new Headers(response.headers);
96
+ headers.set("content-location", location.toString());
97
+ return new Response(response.body, {
98
+ status: response.status,
99
+ statusText: response.statusText,
100
+ headers
101
+ });
102
+ }
103
+ async function getSubtle() {
104
+ const globalCrypto = globalThis.crypto;
105
+ if (globalCrypto?.subtle) return globalCrypto.subtle;
106
+ const specifier = "node:crypto";
107
+ const nodeCrypto = await import(specifier);
108
+ return nodeCrypto.webcrypto.subtle;
109
+ }
110
+ function base64url(buffer) {
111
+ const bytes = new Uint8Array(buffer);
112
+ let binary = "";
113
+ for (const byte of bytes) binary += String.fromCharCode(byte);
114
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
115
+ }
116
+ async function etagFor(body) {
117
+ const bytes = body == null ? new ArrayBuffer(0) : await new Response(body).arrayBuffer();
118
+ const digest = await (await getSubtle()).digest("SHA-256", bytes);
119
+ return `"${base64url(digest)}"`;
120
+ }
121
+ function ifNoneMatchSatisfied(ifNoneMatch, etag) {
122
+ if (!ifNoneMatch) return false;
123
+ if (ifNoneMatch.trim() === "*") return true;
124
+ const strip = (tag) => tag.trim().replace(/^W\//, "");
125
+ const target = strip(etag);
126
+ return ifNoneMatch.split(",").some((tag) => strip(tag) === target);
127
+ }
128
+ async function conditional(request, response) {
129
+ if (!response.ok) return response;
130
+ const buffer = await response.clone().arrayBuffer();
131
+ const etag = response.headers.get("etag") ?? await etagFor(buffer);
132
+ if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
133
+ const headers2 = new Headers({ etag });
134
+ for (const name of ["content-location", "cache-control", "vary"]) {
135
+ const value = response.headers.get(name);
136
+ if (value) headers2.set(name, value);
137
+ }
138
+ return new Response(null, { status: 304, headers: headers2 });
139
+ }
140
+ const headers = new Headers(response.headers);
141
+ headers.set("etag", etag);
142
+ return new Response(buffer, {
143
+ status: response.status,
144
+ statusText: response.statusText,
145
+ headers
146
+ });
147
+ }
94
148
 
95
- export { QUERY_METHOD, QueryRequestError, acceptQueryHeader, assertQueryRequest, checkQueryRequest, isQueryRequest, readQueryJson, withAcceptQuery };
149
+ export { QUERY_METHOD, QueryRequestError, acceptQueryHeader, assertQueryRequest, checkQueryRequest, conditional, etagFor, isQueryRequest, readQueryJson, withAcceptQuery, withContentLocation };
96
150
  //# sourceMappingURL=index.js.map
97
151
  //# sourceMappingURL=index.js.map
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-server\n *\n * Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),\n * built on Web-standard `Request`/`Response` — so they work in Hono, Deno,\n * Bun, Cloudflare Workers, and Node (via a web adapter).\n *\n * Validate incoming QUERY requests, enforce the RFC's `Content-Type`\n * requirement, negotiate the accepted query formats, and advertise them with\n * the `Accept-Query` response header.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\nimport {\n formatAcceptQuery,\n negotiateQuery,\n type MediaRangeInput,\n} from \"@danmat/accept-query\";\n\n/** The QUERY method name. */\nexport const QUERY_METHOD = \"QUERY\";\n\nexport interface QueryRequestOptions {\n /**\n * Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as\n * QUERY requests — the fallback that clients like `@danmat/query-fetch` use\n * for servers that don't route QUERY natively. Default `true`.\n */\n allowMethodOverride?: boolean;\n}\n\nexport interface QueryValidationOptions extends QueryRequestOptions {\n /**\n * Media types this server accepts as query bodies. When provided, requests\n * with an unsupported `Content-Type` are rejected with `415` and an\n * `Accept-Query` header advertising these types.\n */\n accept?: MediaRangeInput[];\n}\n\n/**\n * An error describing why a request is not a valid QUERY, carrying the HTTP\n * status and headers that should be sent in response.\n */\nexport class QueryRequestError extends Error {\n override name = \"QueryRequestError\";\n readonly status: number;\n readonly headers: Record<string, string>;\n\n constructor(message: string, status: number, headers: Record<string, string> = {}) {\n super(message);\n this.status = status;\n this.headers = headers;\n Object.setPrototypeOf(this, QueryRequestError.prototype);\n }\n\n /** Render this error as a JSON `Response` with the appropriate status/headers. */\n toResponse(): Response {\n return new Response(JSON.stringify({ error: this.message }), {\n status: this.status,\n headers: { \"content-type\": \"application/json\", ...this.headers },\n });\n }\n}\n\n/** Whether a request should be handled as a QUERY (honoring method override). */\nexport function isQueryRequest(\n request: Request,\n options: QueryRequestOptions = {},\n): boolean {\n const { allowMethodOverride = true } = options;\n const method = request.method.toUpperCase();\n if (method === QUERY_METHOD) return true;\n if (allowMethodOverride && method === \"POST\") {\n return (\n (request.headers.get(\"x-http-method-override\") ?? \"\").toUpperCase() ===\n QUERY_METHOD\n );\n }\n return false;\n}\n\n/** Build an `Accept-Query` header value from the media types a server accepts. */\nexport function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string {\n return formatAcceptQuery(mediaTypes);\n}\n\nfunction isAccepted(contentType: string, accept: MediaRangeInput[]): boolean {\n return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;\n}\n\n/**\n * Validate that `request` is a well-formed QUERY per RFC 10008. Throws a\n * {@link QueryRequestError} (with the right HTTP status) when it isn't:\n *\n * - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)\n * - missing `Content-Type` → `400 Bad Request`\n * - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`\n */\nexport function assertQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): void {\n const { accept, allowMethodOverride = true } = options;\n\n if (!isQueryRequest(request, { allowMethodOverride })) {\n throw new QueryRequestError(\n `Expected a ${QUERY_METHOD} request but received ${request.method}.`,\n 405,\n { allow: QUERY_METHOD },\n );\n }\n\n const contentType = request.headers.get(\"content-type\");\n if (!contentType) {\n throw new QueryRequestError(\n \"A QUERY request must include a Content-Type (RFC 10008).\",\n 400,\n );\n }\n\n if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {\n throw new QueryRequestError(\n `Unsupported query media type \"${contentType}\".`,\n 415,\n { \"accept-query\": acceptQueryHeader(accept) },\n );\n }\n}\n\n/**\n * Non-throwing companion to {@link assertQueryRequest}. Returns an error\n * `Response` to send back, or `null` if the request is valid.\n *\n * @example\n * ```ts\n * const bad = checkQueryRequest(request, { accept: [\"application/json\"] });\n * if (bad) return bad;\n * const query = await readQueryJson(request);\n * ```\n */\nexport function checkQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): Response | null {\n try {\n assertQueryRequest(request, options);\n return null;\n } catch (error) {\n if (error instanceof QueryRequestError) return error.toResponse();\n throw error;\n }\n}\n\n/**\n * Read a QUERY request body as JSON, guarding the content type. Throws a\n * {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).\n */\nexport async function readQueryJson<T = unknown>(\n request: Request,\n): Promise<T> {\n const contentType = request.headers.get(\"content-type\") ?? \"\";\n if (!/\\bjson\\b/i.test(contentType)) {\n throw new QueryRequestError(\n `Expected a JSON query body but Content-Type was \"${contentType || \"absent\"}\".`,\n 415,\n );\n }\n try {\n return (await request.json()) as T;\n } catch {\n throw new QueryRequestError(\"Query body is not valid JSON.\", 400);\n }\n}\n\n/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */\nexport function withAcceptQuery(\n response: Response,\n mediaTypes: MediaRangeInput[],\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"accept-query\", acceptQueryHeader(mediaTypes));\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["headers"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @danmat/query-server\n *\n * Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),\n * built on Web-standard `Request`/`Response` — so they work in Hono, Deno,\n * Bun, Cloudflare Workers, and Node (via a web adapter).\n *\n * Validate incoming QUERY requests, enforce the RFC's `Content-Type`\n * requirement, negotiate the accepted query formats, and advertise them with\n * the `Accept-Query` response header.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\nimport {\n formatAcceptQuery,\n type MediaRangeInput,\n negotiateQuery,\n} from \"@danmat/accept-query\";\n\n/** The QUERY method name. */\nexport const QUERY_METHOD = \"QUERY\";\n\nexport interface QueryRequestOptions {\n /**\n * Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as\n * QUERY requests — the fallback that clients like `@danmat/query-fetch` use\n * for servers that don't route QUERY natively. Default `true`.\n */\n allowMethodOverride?: boolean;\n}\n\nexport interface QueryValidationOptions extends QueryRequestOptions {\n /**\n * Media types this server accepts as query bodies. When provided, requests\n * with an unsupported `Content-Type` are rejected with `415` and an\n * `Accept-Query` header advertising these types.\n */\n accept?: MediaRangeInput[];\n}\n\n/**\n * An error describing why a request is not a valid QUERY, carrying the HTTP\n * status and headers that should be sent in response.\n */\nexport class QueryRequestError extends Error {\n override name = \"QueryRequestError\";\n readonly status: number;\n readonly headers: Record<string, string>;\n\n constructor(\n message: string,\n status: number,\n headers: Record<string, string> = {},\n ) {\n super(message);\n this.status = status;\n this.headers = headers;\n Object.setPrototypeOf(this, QueryRequestError.prototype);\n }\n\n /** Render this error as a JSON `Response` with the appropriate status/headers. */\n toResponse(): Response {\n return new Response(JSON.stringify({ error: this.message }), {\n status: this.status,\n headers: { \"content-type\": \"application/json\", ...this.headers },\n });\n }\n}\n\n/** Whether a request should be handled as a QUERY (honoring method override). */\nexport function isQueryRequest(\n request: Request,\n options: QueryRequestOptions = {},\n): boolean {\n const { allowMethodOverride = true } = options;\n const method = request.method.toUpperCase();\n if (method === QUERY_METHOD) return true;\n if (allowMethodOverride && method === \"POST\") {\n return (\n (request.headers.get(\"x-http-method-override\") ?? \"\").toUpperCase() ===\n QUERY_METHOD\n );\n }\n return false;\n}\n\n/** Build an `Accept-Query` header value from the media types a server accepts. */\nexport function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string {\n return formatAcceptQuery(mediaTypes);\n}\n\nfunction isAccepted(contentType: string, accept: MediaRangeInput[]): boolean {\n return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;\n}\n\n/**\n * Validate that `request` is a well-formed QUERY per RFC 10008. Throws a\n * {@link QueryRequestError} (with the right HTTP status) when it isn't:\n *\n * - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)\n * - missing `Content-Type` → `400 Bad Request`\n * - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`\n */\nexport function assertQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): void {\n const { accept, allowMethodOverride = true } = options;\n\n if (!isQueryRequest(request, { allowMethodOverride })) {\n throw new QueryRequestError(\n `Expected a ${QUERY_METHOD} request but received ${request.method}.`,\n 405,\n { allow: QUERY_METHOD },\n );\n }\n\n const contentType = request.headers.get(\"content-type\");\n if (!contentType) {\n throw new QueryRequestError(\n \"A QUERY request must include a Content-Type (RFC 10008).\",\n 400,\n );\n }\n\n if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {\n throw new QueryRequestError(\n `Unsupported query media type \"${contentType}\".`,\n 415,\n { \"accept-query\": acceptQueryHeader(accept) },\n );\n }\n}\n\n/**\n * Non-throwing companion to {@link assertQueryRequest}. Returns an error\n * `Response` to send back, or `null` if the request is valid.\n *\n * @example\n * ```ts\n * const bad = checkQueryRequest(request, { accept: [\"application/json\"] });\n * if (bad) return bad;\n * const query = await readQueryJson(request);\n * ```\n */\nexport function checkQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): Response | null {\n try {\n assertQueryRequest(request, options);\n return null;\n } catch (error) {\n if (error instanceof QueryRequestError) return error.toResponse();\n throw error;\n }\n}\n\n/**\n * Read a QUERY request body as JSON, guarding the content type. Throws a\n * {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).\n */\nexport async function readQueryJson<T = unknown>(request: Request): Promise<T> {\n const contentType = request.headers.get(\"content-type\") ?? \"\";\n if (!/\\bjson\\b/i.test(contentType)) {\n throw new QueryRequestError(\n `Expected a JSON query body but Content-Type was \"${contentType || \"absent\"}\".`,\n 415,\n );\n }\n try {\n return (await request.json()) as T;\n } catch {\n throw new QueryRequestError(\"Query body is not valid JSON.\", 400);\n }\n}\n\n/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */\nexport function withAcceptQuery(\n response: Response,\n mediaTypes: MediaRangeInput[],\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"accept-query\", acceptQueryHeader(mediaTypes));\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\n/** Return a copy of `response` with a `Content-Location` header. */\nexport function withContentLocation(\n response: Response,\n location: string | URL,\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"content-location\", location.toString());\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nasync function getSubtle(): Promise<SubtleCrypto> {\n const globalCrypto = (globalThis as { crypto?: Crypto }).crypto;\n if (globalCrypto?.subtle) return globalCrypto.subtle;\n // Older Node without a global `crypto`; never reached in browsers/workers.\n const specifier = \"node:crypto\";\n const nodeCrypto = (await import(specifier)) as {\n webcrypto: { subtle: SubtleCrypto };\n };\n return nodeCrypto.webcrypto.subtle;\n}\n\nfunction base64url(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer);\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\n/**\n * Compute a strong `ETag` (a quoted SHA-256/base64url digest) for a body. A\n * QUERY response's representation is identified by its bytes, so equal bodies\n * yield equal tags.\n */\nexport async function etagFor(\n body: BodyInit | null | undefined,\n): Promise<string> {\n const bytes =\n body == null ? new ArrayBuffer(0) : await new Response(body).arrayBuffer();\n const digest = await (await getSubtle()).digest(\"SHA-256\", bytes);\n return `\"${base64url(digest)}\"`;\n}\n\n/** RFC 9110 weak comparison: does `ifNoneMatch` match `etag`? */\nfunction ifNoneMatchSatisfied(\n ifNoneMatch: string | null,\n etag: string,\n): boolean {\n if (!ifNoneMatch) return false;\n if (ifNoneMatch.trim() === \"*\") return true;\n const strip = (tag: string) => tag.trim().replace(/^W\\//, \"\");\n const target = strip(etag);\n return ifNoneMatch.split(\",\").some((tag) => strip(tag) === target);\n}\n\n/**\n * Add HTTP revalidation to a response: attach a strong `ETag`, and when the\n * request's `If-None-Match` already matches, short-circuit to `304 Not\n * Modified`. Safe for QUERY, which is idempotent and cacheable.\n *\n * Only applied to successful (2xx) responses; others pass through unchanged.\n *\n * @example\n * ```ts\n * return conditional(request, withAcceptQuery(Response.json(results), ACCEPTED));\n * ```\n */\nexport async function conditional(\n request: Request,\n response: Response,\n): Promise<Response> {\n if (!response.ok) return response;\n\n const buffer = await response.clone().arrayBuffer();\n const etag = response.headers.get(\"etag\") ?? (await etagFor(buffer));\n\n if (ifNoneMatchSatisfied(request.headers.get(\"if-none-match\"), etag)) {\n const headers = new Headers({ etag });\n // A 304 carries no body but should echo the caching-relevant headers.\n for (const name of [\"content-location\", \"cache-control\", \"vary\"]) {\n const value = response.headers.get(name);\n if (value) headers.set(name, value);\n }\n return new Response(null, { status: 304, headers });\n }\n\n const headers = new Headers(response.headers);\n headers.set(\"etag\", etag);\n return new Response(buffer, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmat/query-server",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008): validate requests, negotiate content types, and advertise Accept-Query. Built on Web-standard Request/Response.",
5
5
  "keywords": [
6
6
  "http",
@@ -12,7 +12,11 @@
12
12
  "middleware",
13
13
  "accept-query",
14
14
  "hono",
15
- "content-negotiation"
15
+ "content-negotiation",
16
+ "etag",
17
+ "caching",
18
+ "conditional-requests",
19
+ "revalidation"
16
20
  ],
17
21
  "type": "module",
18
22
  "main": "./dist/index.cjs",
@@ -39,7 +43,10 @@
39
43
  "test:watch": "vitest",
40
44
  "coverage": "vitest run --coverage",
41
45
  "typecheck": "tsc --noEmit",
42
- "prepublishOnly": "npm run build"
46
+ "prepublishOnly": "npm run build",
47
+ "lint": "biome check .",
48
+ "format": "biome check --write .",
49
+ "prepare": "husky"
43
50
  },
44
51
  "author": "Dan Matthew <dannymatthew@gmail.com>",
45
52
  "license": "MIT",
@@ -58,9 +65,15 @@
58
65
  "@danmat/accept-query": "^0.1.0"
59
66
  },
60
67
  "devDependencies": {
68
+ "@biomejs/biome": "^2.5.2",
61
69
  "@vitest/coverage-v8": "^2.1.8",
70
+ "husky": "^9.1.7",
71
+ "lint-staged": "^15.5.2",
62
72
  "tsup": "^8.3.5",
63
73
  "typescript": "^5.7.2",
64
74
  "vitest": "^2.1.8"
75
+ },
76
+ "lint-staged": {
77
+ "*.{ts,js,json,jsonc,md}": "biome check --write --no-errors-on-unmatched"
65
78
  }
66
79
  }