@littlejay/http 0.1.0 → 0.1.1
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 +12 -1
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
ESM bounded JSON transport for browsers and Node with platform-compatible fetch, AbortController, and Web Streams, prepared for public npm distribution. No React, CommonJS entry point, or CSS setup is required. The repository validates Node 26.13.0; other runtimes need consumer verification. Public exports and declarations are listed in [package.json](package.json).
|
|
4
4
|
|
|
5
|
+
OpenCode and Codex: have the consuming project's `AGENTS.md` explicitly request this installed README for transport tasks. See the [shared setup/template](https://github.com/jordan-a-young/little-jay/blob/main/docs/CONSUMER-AGENTS.md); installation alone does not activate agent instructions.
|
|
6
|
+
|
|
5
7
|
## Imports
|
|
6
8
|
|
|
7
9
|
```ts
|
|
@@ -32,15 +34,24 @@ The default whole-operation timeout is 10,000 ms; `timeoutMs` must be an integer
|
|
|
32
34
|
|
|
33
35
|
Success requires UTF-8 JSON (`application/json` or `application/*+json`). Empty/204 bodies, invalid UTF-8/JSON, and decoder failures are errors. Cancellation/deadlines cover fetching, reading, and awaiting an async decoder, but cannot interrupt synchronous JS or stop an already-running decoder. Keep decoders bounded, side-effect-free, and responsible for their own resources.
|
|
34
36
|
|
|
37
|
+
The charset may be absent or quoted/unquoted `UTF-8`. Quoted extension parameters respect backslash escapes and may contain semicolons or charset-like text: `application/json; profile="https://example.test/schema;charset=utf-16"` is accepted. An actual unsupported charset parameter, including one after a quoted extension, or an unterminated quoted value produces `content_type`.
|
|
38
|
+
|
|
35
39
|
`HttpError.code` is `aborted`, `timeout`, `network`, `status`, `size`, `content_type`, `json`, or `decode`; `status` is available for response-related failures. Messages are fixed and omit URL, headers, bodies, abort reasons, and decoder diagnostics. A size failure takes precedence over an unsuccessful HTTP status when its body exceeds the limit. Do not expect an original cause or raw server message.
|
|
36
40
|
|
|
37
41
|
## Application policy
|
|
38
42
|
|
|
39
43
|
Applications own method/body/headers, credentials, authentication/CSRF, redirect and cache policy, domain validation, retries, and query caches. This transport does not retry writes or install a cache. Defaults of the selected fetch implementation apply when request options are omitted. Use application cancellation signals and map safe error codes to accessible user-facing copy; never expose raw diagnostics or secrets. This package renders no UI and supplies no live announcements or theme.
|
|
40
44
|
|
|
45
|
+
## Optimization guidance
|
|
46
|
+
|
|
47
|
+
- Set response-byte and timeout budgets for the endpoint instead of increasing limits to hide slow/oversized responses. `fetchJson` buffers and parses a complete bounded JSON value; it is not an incremental JSON, download, or event-stream API. Use pagination or a suitable streaming API for large/unbounded data, without removing validation or bounds.
|
|
48
|
+
- Pass the caller's `signal`, including a query library's supplied signal, so abandoned work can stop. Keep decoding bounded and side-effect-free; a timeout cannot interrupt synchronous parsing/validation or forcibly stop an already-running decoder.
|
|
49
|
+
- Reuse the application's existing request cache/lifecycle layer, such as TanStack Query when already present. Choose query keys, stale windows, deduplication, invalidation and read retries there; do not add a second cache or a retry loop around mutations. Reuse the platform fetch implementation rather than adding a client just to call this helper.
|
|
50
|
+
- Measure request count, transferred/decoded bytes, end-to-end latency and decoding cost for a representative flow before and after changes. Do not log private URLs, headers, payloads or decoder diagnostics as benchmark evidence. Test cancellation and failure paths as well as success.
|
|
51
|
+
|
|
41
52
|
## Verification
|
|
42
53
|
|
|
43
|
-
Use the application's tests with injected fetch/streams to check decoding, limits, cancellation, safe errors, and any retry/cache policy. Validate required Web APIs in the deployment runtime. The detailed maintainer contract is `docs/HTTP.md` in the
|
|
54
|
+
Use the application's tests with injected fetch/streams to check decoding, limits, cancellation, safe errors, and any retry/cache policy. Validate required Web APIs in the deployment runtime. The detailed maintainer contract is `docs/HTTP.md` in the source repository, not a file shipped with this package.
|
|
44
55
|
|
|
45
56
|
## License
|
|
46
57
|
|
package/dist/index.js
CHANGED
|
@@ -28,7 +28,24 @@ async function cancelBody(body) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
function isJsonContentType(header) {
|
|
31
|
-
const
|
|
31
|
+
const contentType = (header ?? "").toLowerCase();
|
|
32
|
+
const sections = [];
|
|
33
|
+
let sectionStart = 0;
|
|
34
|
+
let quoted = false;
|
|
35
|
+
let escaped = false;
|
|
36
|
+
for (let index = 0; index < contentType.length; index++) {
|
|
37
|
+
const character = contentType[index];
|
|
38
|
+
if (escaped) escaped = false;
|
|
39
|
+
else if (quoted && character === "\\") escaped = true;
|
|
40
|
+
else if (character === "\"") quoted = !quoted;
|
|
41
|
+
else if (!quoted && character === ";") {
|
|
42
|
+
sections.push(contentType.slice(sectionStart, index));
|
|
43
|
+
sectionStart = index + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (quoted) return false;
|
|
47
|
+
sections.push(contentType.slice(sectionStart));
|
|
48
|
+
const [mediaType, ...parameters] = sections;
|
|
32
49
|
if (!/^application\/(?:json|[a-z0-9!#$&^_.+-]+\+json)$/.test(mediaType?.trim() ?? "")) return false;
|
|
33
50
|
return parameters.every((parameter) => {
|
|
34
51
|
const [name, value, extra] = parameter.trim().split("=");
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["const messages = {\n aborted: \"Request canceled\",\n timeout: \"Request timed out\",\n network: \"Network request failed\",\n status: \"HTTP request failed\",\n size: \"Response exceeds the byte limit\",\n content_type: \"Expected a UTF-8 JSON response\",\n json: \"Invalid JSON response\",\n decode: \"Response did not match the expected schema\",\n} as const;\n\nexport type HttpErrorCode = keyof typeof messages;\n\n/** Safe diagnostics only: no URL, response body, headers, abort reason, or original cause. */\nexport class HttpError extends Error {\n readonly code: HttpErrorCode;\n readonly status?: number;\n\n constructor(code: HttpErrorCode, status?: number) {\n super(messages[code]);\n this.name = \"HttpError\";\n this.code = code;\n this.status = status;\n }\n}\n\nexport type FetchJsonOptions<T> = {\n decode: (value: unknown) => T | Promise<T>;\n fetch?: typeof globalThis.fetch;\n signal?: AbortSignal;\n timeoutMs?: number;\n maxResponseBytes?: number;\n request?: Omit<RequestInit, \"signal\">;\n};\n\nasync function cancelBody(\n body: ReadableStream<Uint8Array> | ReadableStreamDefaultReader<Uint8Array>,\n) {\n try {\n await body.cancel();\n } catch {\n return;\n }\n}\n\nfunction isJsonContentType(header: string | null) {\n const
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["const messages = {\n aborted: \"Request canceled\",\n timeout: \"Request timed out\",\n network: \"Network request failed\",\n status: \"HTTP request failed\",\n size: \"Response exceeds the byte limit\",\n content_type: \"Expected a UTF-8 JSON response\",\n json: \"Invalid JSON response\",\n decode: \"Response did not match the expected schema\",\n} as const;\n\nexport type HttpErrorCode = keyof typeof messages;\n\n/** Safe diagnostics only: no URL, response body, headers, abort reason, or original cause. */\nexport class HttpError extends Error {\n readonly code: HttpErrorCode;\n readonly status?: number;\n\n constructor(code: HttpErrorCode, status?: number) {\n super(messages[code]);\n this.name = \"HttpError\";\n this.code = code;\n this.status = status;\n }\n}\n\nexport type FetchJsonOptions<T> = {\n decode: (value: unknown) => T | Promise<T>;\n fetch?: typeof globalThis.fetch;\n signal?: AbortSignal;\n timeoutMs?: number;\n maxResponseBytes?: number;\n request?: Omit<RequestInit, \"signal\">;\n};\n\nasync function cancelBody(\n body: ReadableStream<Uint8Array> | ReadableStreamDefaultReader<Uint8Array>,\n) {\n try {\n await body.cancel();\n } catch {\n return;\n }\n}\n\nfunction isJsonContentType(header: string | null) {\n const contentType = (header ?? \"\").toLowerCase();\n const sections: string[] = [];\n let sectionStart = 0;\n let quoted = false;\n let escaped = false;\n for (let index = 0; index < contentType.length; index++) {\n const character = contentType[index];\n if (escaped) {\n escaped = false;\n } else if (quoted && character === \"\\\\\") {\n escaped = true;\n } else if (character === '\"') {\n quoted = !quoted;\n } else if (!quoted && character === \";\") {\n sections.push(contentType.slice(sectionStart, index));\n sectionStart = index + 1;\n }\n }\n if (quoted) return false;\n sections.push(contentType.slice(sectionStart));\n const [mediaType, ...parameters] = sections;\n if (!/^application\\/(?:json|[a-z0-9!#$&^_.+-]+\\+json)$/.test(mediaType?.trim() ?? \"\"))\n return false;\n return parameters.every((parameter) => {\n const [name, value, extra] = parameter.trim().split(\"=\");\n return (\n name?.trim() !== \"charset\" ||\n (extra === undefined && /^(?:utf-8|\"utf-8\")$/.test(value?.trim() ?? \"\"))\n );\n });\n}\n\n/** One bounded JSON request. Apps own URLs, credentials, cache policy, retries, and decoding. */\nexport async function fetchJson<T>(url: string | URL, options: FetchJsonOptions<T>): Promise<T> {\n const { decode, signal, timeoutMs = 10_000, maxResponseBytes = 1_048_576 } = options;\n if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)\n throw new TypeError(\"timeoutMs must be an integer between 1 and 2147483647\");\n if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1)\n throw new TypeError(\"maxResponseBytes must be a positive safe integer\");\n if (signal?.aborted) throw new HttpError(\"aborted\");\n\n const controller = new AbortController();\n let failure: HttpError | undefined;\n let rejectAbort: (error: HttpError) => void = () => {};\n const aborted = new Promise<never>((_resolve, reject) => {\n rejectAbort = reject;\n });\n function abort(code: \"aborted\" | \"timeout\") {\n if (failure) return;\n failure = new HttpError(code);\n rejectAbort(failure);\n controller.abort();\n }\n const onAbort = () => abort(\"aborted\");\n signal?.addEventListener(\"abort\", onAbort, { once: true });\n const timer = setTimeout(() => abort(\"timeout\"), timeoutMs);\n let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;\n\n async function request() {\n const response = await (options.fetch ?? globalThis.fetch)(url, {\n ...options.request,\n signal: controller.signal,\n });\n if (failure) {\n if (response.body) void cancelBody(response.body);\n throw failure;\n }\n reader = response.body?.getReader();\n const chunks: Uint8Array[] = [];\n let byteLength = 0;\n if (reader) {\n while (true) {\n const chunk = await reader.read();\n if (failure) throw failure;\n if (chunk.done) break;\n byteLength += chunk.value.byteLength;\n if (byteLength > maxResponseBytes) throw new HttpError(\"size\", response.status);\n if (response.ok && chunk.value.byteLength > 0) chunks.push(chunk.value);\n }\n }\n if (!response.ok) throw new HttpError(\"status\", response.status);\n if (!isJsonContentType(response.headers.get(\"content-type\")))\n throw new HttpError(\"content_type\", response.status);\n const bytes = new Uint8Array(byteLength);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n let value: unknown;\n try {\n value = JSON.parse(new TextDecoder(\"utf-8\", { fatal: true }).decode(bytes));\n } catch {\n throw new HttpError(\"json\", response.status);\n }\n try {\n return await decode(value);\n } catch {\n throw new HttpError(\"decode\", response.status);\n }\n }\n\n try {\n return await Promise.race([request(), aborted]);\n } catch (error) {\n if (failure) throw failure;\n if (error instanceof HttpError) throw error;\n throw new HttpError(\"network\");\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", onAbort);\n if (reader) {\n void cancelBody(reader);\n reader.releaseLock();\n }\n }\n}\n"],"mappings":"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"}
|