@homeflare/kit 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/dist/index.d.ts CHANGED
@@ -17,4 +17,5 @@ export { VERSION } from './version.ts';
17
17
  export { EnvError, isUnset, parseEnv, type EnvSchema, type EnvSpec } from './env.ts';
18
18
  export { KIND_DESCRIPTIONS, KitError, kindForStatus, type ErrorKind, type KitErrorOptions, } from './errors.ts';
19
19
  export { client, http, HTTPError, TimeoutError, type KyInstance, type Options } from './http.ts';
20
+ export { MAX_RETRY_WAIT_MS, rateLimitAware, retryAfterMs, type HeaderBag } from './rate-limit.ts';
20
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,QAAQ,EACR,aAAa,EACb,KAAK,SAAS,EACd,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,QAAQ,EACR,aAAa,EACb,KAAK,SAAS,EACd,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AACjG,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "0.1.1";
2
+ var VERSION = "0.2.0";
3
3
  // src/env.ts
4
4
  class EnvError extends Error {
5
5
  key;
@@ -96,19 +96,45 @@ var http = ky.extend({
96
96
  function client(baseUrl, options) {
97
97
  return http.extend({ ...options, baseUrl });
98
98
  }
99
+ // src/rate-limit.ts
100
+ var MAX_RETRY_WAIT_MS = 30000;
101
+ function asMs(raw) {
102
+ if (raw === null)
103
+ return null;
104
+ const n = Number(raw.trim());
105
+ return Number.isFinite(n) && n >= 0 ? Math.min(Math.round(n * 1000), MAX_RETRY_WAIT_MS) : null;
106
+ }
107
+ function retryAfterMs(headers) {
108
+ return asMs(headers.get("x-ratelimit-reset-after")) ?? asMs(headers.get("retry-after"));
109
+ }
110
+ var honourResetAfter = async ({ response }) => {
111
+ if (response.status !== 429)
112
+ return response;
113
+ const wait = asMs(response.headers.get("x-ratelimit-reset-after"));
114
+ if (wait === null)
115
+ return response;
116
+ await new Promise((resolve) => setTimeout(resolve, wait));
117
+ return response;
118
+ };
119
+ var rateLimitAware = {
120
+ hooks: { afterResponse: [honourResetAfter] }
121
+ };
99
122
  export {
100
123
  EnvError,
101
124
  HTTPError,
102
125
  KIND_DESCRIPTIONS,
103
126
  KitError,
127
+ MAX_RETRY_WAIT_MS,
104
128
  TimeoutError,
105
129
  VERSION,
106
130
  client,
107
131
  http,
108
132
  isUnset,
109
133
  kindForStatus,
110
- parseEnv
134
+ parseEnv,
135
+ rateLimitAware,
136
+ retryAfterMs
111
137
  };
112
138
 
113
- //# debugId=A55C522EE736511464756E2164756E21
139
+ //# debugId=62891601CED5455464756E2164756E21
114
140
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/version.ts", "../src/env.ts", "../src/errors.ts", "../src/http.ts"],
3
+ "sources": ["../src/version.ts", "../src/env.ts", "../src/errors.ts", "../src/http.ts", "../src/rate-limit.ts"],
4
4
  "sourcesContent": [
5
- "// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.1.1';\n",
5
+ "// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.2.0';\n",
6
6
  "/**\n * Environment parsing — the one thing every HomeFlare app does before anything else.\n *\n * ★ WHY THIS EXISTS RATHER THAN zod. A Worker reads its config from a plain object\n * (`env`), not from `process.env`, and it does so on every request. Pulling a schema\n * library in for that costs bundle size on a hot path to validate perhaps six keys.\n * This is deliberately small and dependency-free; when a shape genuinely needs\n * unions, refinement or codecs, reach for zod in the consuming app instead of\n * growing this.\n *\n * ⚠️ THIS VALIDATES SHAPE, NOT SECRECY. A value arriving here has already been handed\n * over by the platform — a Worker binding, an OpenBao-minted lease. Nothing in this\n * file should ever log a value, and callers should not either: a thrown error names\n * the KEY that was wrong and never what it contained.\n */\n\n/** How one environment key is read, and what it becomes. */\nexport type EnvSpec =\n | { readonly type: 'string'; readonly optional?: boolean; readonly default?: string }\n | { readonly type: 'number'; readonly optional?: boolean; readonly default?: number }\n | { readonly type: 'boolean'; readonly optional?: boolean; readonly default?: boolean };\n\n/** A description of every key an app needs, keyed by the name it appears under. */\nexport type EnvSchema = Readonly<Record<string, EnvSpec>>;\n\n/** What a schema produces once parsed: each key narrowed to its declared type. */\ntype Parsed<S extends EnvSchema> = {\n readonly [K in keyof S]: S[K] extends { type: 'number' }\n ? number\n : S[K] extends { type: 'boolean' }\n ? boolean\n : string;\n};\n\n/** Raised when a key is missing or cannot be read as its declared type. */\nexport class EnvError extends Error {\n /** The offending key. Never the value — see the file header. */\n readonly key: string;\n\n constructor(key: string, reason: string) {\n super(`env ${key}: ${reason}`);\n this.name = 'EnvError';\n this.key = key;\n }\n}\n\n/**\n * ⛔ THE THREE VALUES THAT MEAN \"ABSENT\", AND WHY IT IS NOT JUST THE EMPTY STRING.\n * An unbound workerd binding does NOT arrive as undefined — `fromEnvironment` produces\n * the four-character STRING \"null\", because the binding is present and String(null) is\n * truthy. Code that checks only for '' therefore accepts it, and the request goes out\n * as `Authorization: Bearer null`.\n * 🔴 MEASURED IN PRODUCTION, 2026-09-02. Twenty hand-rolled clients; this guard was in\n * four of them. The other sixteen surfaced a bare 401 from upstream — which reads as\n * \"the token is wrong\" and sends an operator to rotate a perfectly good credential.\n * The litellm server spent a day authenticating with nothing while /health answered 200.\n * Ported from an internal HTTP client, where this lesson was paid for once.\n */\nexport function isUnset(value: string): boolean {\n return value === '' || value === 'null' || value === 'undefined';\n}\n\n/**\n * ⚠️ 'false' IS TRUTHY AS A STRING, and that is the bug this function exists to stop.\n * `Boolean(env.FEATURE)` on the string 'false' is `true`, so a flag turned off in a\n * dashboard stays on in production and nothing in the logs says why. Only the listed\n * spellings are accepted; anything else throws rather than guessing.\n */\nconst TRUE = new Set(['1', 'true', 'yes', 'on']);\nconst FALSE = new Set(['0', 'false', 'no', 'off']);\n\n/**\n * Read `source` against `schema`, throwing `EnvError` on the first key that is missing\n * or malformed.\n *\n * ★ THROWS RATHER THAN RETURNING A RESULT, deliberately: this runs once at startup or\n * at the top of a fetch handler, where a misconfigured deployment should fail loudly\n * and immediately rather than produce a half-configured app that fails later somewhere\n * less obvious.\n */\nexport function parseEnv<const S extends EnvSchema>(\n schema: S,\n source: Readonly<Record<string, unknown>>,\n): Parsed<S> {\n const out: Record<string, string | number | boolean> = {};\n\n for (const key of Object.keys(schema)) {\n const spec = schema[key] as EnvSpec;\n const raw = source[key];\n\n // ⛔ isUnset, not `=== ''`. See its comment: \"null\" is what an unbound workerd\n // binding actually looks like, and treating it as a value is a day-long debug.\n if (raw === undefined || raw === null || isUnset(String(raw).trim())) {\n if (spec.default !== undefined) {\n out[key] = spec.default;\n continue;\n }\n if (spec.optional === true) continue;\n throw new EnvError(key, 'required but not set');\n }\n\n // ⚠️ Trimmed: a rendered env file with a trailing newline is the common case, and an\n // untrimmed token fails upstream with the same unhelpful 401.\n const text = String(raw).trim();\n\n if (spec.type === 'number') {\n const n = Number(text);\n // ⚠️ Number('') is 0 and Number(' ') is 0; the empty check above is what makes\n // this safe. NaN is the only remaining failure and it is caught here.\n if (!Number.isFinite(n)) throw new EnvError(key, 'expected a number');\n out[key] = n;\n } else if (spec.type === 'boolean') {\n const lowered = text.toLowerCase();\n if (TRUE.has(lowered)) out[key] = true;\n else if (FALSE.has(lowered)) out[key] = false;\n else throw new EnvError(key, 'expected one of 1/true/yes/on or 0/false/no/off');\n } else {\n out[key] = text;\n }\n }\n\n return out as Parsed<S>;\n}\n",
7
7
  "/**\n * Errors a CALLER can act on — an agent, a retry loop, or a person reading a log.\n *\n * ★ PORTED FROM AN INTERNAL MCP TOOLKIT, where 34 packages proved\n * the shape. Generalised: that version is MCP-specific (`HouseToolError`), this\n * one is not, so a Worker or a script can use the same vocabulary.\n *\n * ⛔ THE RULE THIS ENFORCES: a failure must never be silent, and never ambiguous with a\n * DIFFERENT failure. Both halves were learned from incidents, not from principle:\n * - An Access provider read its token at module scope and, when that failed, silently\n * omitted the auth header. Cloudflare answered a bare 401 — indistinguishable from a\n * bad credential — and sent an operator to debug the wrong subsystem. Omitting is\n * worse than throwing.\n * - A gate reported success on a corrupted file because it compared the file to itself.\n * A check that cannot fail is worse than no check, because it is trusted.\n *\n * ⚠️ THE AUDIENCE MAY BE AN AGENT, not a human with a debugger. An agent cannot open a\n * dashboard or ask a colleague, so everything it needs to pick its next action must be\n * in the error. One that reads \"permission denied\" retries forever; one that reads\n * \"the token lacks Grafana Read; ask an operator to widen the grant\" stops and reports.\n */\n\n/** Which class of failure this is. Callers branch on it, so the set stays small. */\nexport type ErrorKind =\n /** Not who it claims, or claims nothing. Retrying unchanged fails. */\n | 'unauthenticated'\n /** Known, but lacks the grant. ⛔ NEVER retry — a human must widen a scope. */\n | 'forbidden'\n /** The upstream said no. The caller may be able to fix its arguments and retry. */\n | 'upstream_rejected'\n /**\n * Over a rate limit. The IDENTICAL call succeeds after the stated delay.\n * 🔴 A DISTINCT KIND BECAUSE IT WAS LANDING AS `bad_arguments`, measured 2026-09-07 in\n * production: a 429 mapped to \"your arguments are wrong\", so a throttled agent\n * rewrote a correct call, was throttled again, and concluded the tool was broken.\n * This is the refusal agents meet most — Cloudflare frontier models are 20 rpm.\n */\n | 'rate_limited'\n /** Could not be reached at all. Usually transient; a retry may work. */\n | 'upstream_unreachable'\n /** Called with arguments it cannot accept. Correct them and retry. */\n | 'bad_arguments'\n /** We are misconfigured. ⛔ A caller cannot fix this; it must report it. */\n | 'misconfigured';\n\n/**\n * What each kind means, for anything that has to explain them.\n *\n * ⛔ TYPED AS Record<ErrorKind, string>, WHICH IS THE POINT: adding a kind above without\n * describing it here is a COMPILE ERROR. A hand-kept list beside a union goes stale,\n * and a documented error contract with a missing case is worse than none — it is\n * trusted.\n */\nexport const KIND_DESCRIPTIONS: Record<ErrorKind, string> = {\n unauthenticated: 'The credential was missing, malformed, or rejected.',\n forbidden: 'The credential is valid but lacks the required grant. Do not retry.',\n upstream_rejected: 'The upstream refused the request as made.',\n rate_limited: 'Throttled. The identical call succeeds after the stated delay.',\n upstream_unreachable: 'The upstream could not be reached. A retry may succeed.',\n bad_arguments: 'The arguments were not acceptable. Correct them and retry.',\n misconfigured: 'This service is misconfigured and cannot serve the request.',\n};\n\nexport interface KitErrorOptions {\n /** Which system failed. ⛔ Name it — \"request failed\" tells a caller nothing. */\n readonly system: string;\n /** What would make it work. This is what stops an agent retrying forever. */\n readonly remedy?: string;\n /** For `rate_limited`: how long to wait, in milliseconds. */\n readonly retryAfterMs?: number;\n readonly cause?: unknown;\n}\n\n/** A failure with enough shape for a caller to decide what to do next. */\nexport class KitError extends Error {\n readonly kind: ErrorKind;\n readonly system: string;\n readonly remedy: string | undefined;\n readonly retryAfterMs: number | undefined;\n\n constructor(kind: ErrorKind, message: string, options: KitErrorOptions) {\n super(`${options.system}: ${message}`, { cause: options.cause });\n this.name = 'KitError';\n this.kind = kind;\n this.system = options.system;\n this.remedy = options.remedy;\n this.retryAfterMs = options.retryAfterMs;\n }\n\n /** True when retrying the identical call could plausibly succeed. */\n get retryable(): boolean {\n return this.kind === 'rate_limited' || this.kind === 'upstream_unreachable';\n }\n}\n\n/**\n * Map an HTTP status to a kind.\n *\n * ⚠️ 429 IS CHECKED EXPLICITLY, not left to fall through. It used to reach\n * `upstream_rejected` here and `bad_arguments` elsewhere — both of which tell a caller\n * to CHANGE ITS ARGUMENTS, which is precisely wrong for a throttle.\n */\nexport function kindForStatus(status: number): ErrorKind {\n if (status === 401) return 'unauthenticated';\n if (status === 403) return 'forbidden';\n if (status === 429) return 'rate_limited';\n if (status >= 500) return 'upstream_unreachable';\n return 'upstream_rejected';\n}\n",
8
- "/**\n * HTTP client — a configured `ky`, not a wrapper around it.\n *\n * ★ WHY ky AND NOT A HAND-ROLLED fetch+retry. Retry, timeout and backoff are each easy\n * to write and easy to write subtly wrong (retrying a POST, retrying a 4xx, a timeout\n * that leaks the AbortController). ky is zero-dependency, TypeScript-native and\n * fetch-based — measured 2026-09-15: `npm view ky dependencies` is empty — so it adds\n * nothing to a consumer's tree.\n * ⚠️ ky is described as browser-first, which matters only in that it assumes a global\n * `fetch`. workerd, Node 18+ and Bun all provide one, so there is nothing to polyfill.\n * ⛔ NOT ofetch (pulls destr + node-fetch-native) and NOT wretch, which is BROKEN on\n * Workers out of the box: Cloudflare's Response has no `.type` and wretch throws\n * reading it, needing a middleware patch to work at all.\n *\n * ★ THIS MODULE IS DELIBERATELY THIN. It re-exports ky's own types rather than inventing\n * parallel ones, so a caller who needs something not covered here uses ky directly and\n * loses nothing.\n */\nimport ky, { HTTPError, TimeoutError, type KyInstance, type Options } from 'ky';\n\nexport { HTTPError, TimeoutError };\nexport type { KyInstance, Options };\n\n/**\n * The house defaults.\n *\n * ⚠️ ky retries only IDEMPOTENT methods by default (GET/PUT/HEAD/DELETE/OPTIONS/TRACE) —\n * POST is excluded, which is the behaviour you want and the one a hand-rolled retry\n * usually gets wrong. Retried status codes are 408/413/429/500/502/503/504.\n * ⚠️ `timeout` is PER ATTEMPT, not for the whole call. With limit 3 and a 10s timeout the\n * worst case is ~30s plus backoff — shorten both for a Worker on a request path, where\n * the CPU-time limit is unforgiving.\n */\nexport const http: KyInstance = ky.extend({\n retry: { limit: 3, backoffLimit: 3_000 },\n timeout: 10_000,\n});\n\n/**\n * A client bound to one base URL, for talking to a specific service.\n *\n * const linear = client('https://api.linear.app', { headers: { authorization } });\n * const issues = await linear.get('/graphql').json<Issues>();\n *\n * ⚠️ `baseUrl`, NOT `prefixUrl`. ky 2.x renamed the option and changed its semantics:\n * `baseUrl` resolves per the URL standard, so a leading slash is fine and an absolute\n * input bypasses the base. (ky 1.x had `prefixUrl`, which threw on a leading slash —\n * measured 2026-09-15, the old name is a type error, not a silent no-op.)\n */\nexport function client(baseUrl: string, options?: Options): KyInstance {\n return http.extend({ ...options, baseUrl });\n}\n"
8
+ "/**\n * HTTP client — a configured `ky`, not a wrapper around it.\n *\n * ★ WHY ky AND NOT A HAND-ROLLED fetch+retry. Retry, timeout and backoff are each easy\n * to write and easy to write subtly wrong (retrying a POST, retrying a 4xx, a timeout\n * that leaks the AbortController). ky is zero-dependency, TypeScript-native and\n * fetch-based — measured 2026-09-15: `npm view ky dependencies` is empty — so it adds\n * nothing to a consumer's tree.\n * ⚠️ ky is described as browser-first, which matters only in that it assumes a global\n * `fetch`. workerd, Node 18+ and Bun all provide one, so there is nothing to polyfill.\n * ⛔ NOT ofetch (pulls destr + node-fetch-native) and NOT wretch, which is BROKEN on\n * Workers out of the box: Cloudflare's Response has no `.type` and wretch throws\n * reading it, needing a middleware patch to work at all.\n *\n * ★ THIS MODULE IS DELIBERATELY THIN. It re-exports ky's own types rather than inventing\n * parallel ones, so a caller who needs something not covered here uses ky directly and\n * loses nothing.\n */\nimport ky, { HTTPError, TimeoutError, type KyInstance, type Options } from 'ky';\n\nexport { HTTPError, TimeoutError };\nexport type { KyInstance, Options };\n\n/**\n * The house defaults.\n *\n * ⚠️ ky retries only IDEMPOTENT methods by default (GET/PUT/HEAD/DELETE/OPTIONS/TRACE) —\n * POST is excluded, which is the behaviour you want and the one a hand-rolled retry\n * usually gets wrong. Retried status codes are 408/413/429/500/502/503/504.\n * ⚠️ `timeout` is PER ATTEMPT, not for the whole call. With limit 3 and a 10s timeout the\n * worst case is ~30s plus backoff — shorten both for a Worker on a request path, where\n * the CPU-time limit is unforgiving.\n */\nexport const http: KyInstance = ky.extend({\n retry: { limit: 3, backoffLimit: 3_000 },\n timeout: 10_000,\n});\n\n/**\n * A client bound to one base URL, for talking to a specific service.\n *\n * const linear = client('https://api.linear.app', { headers: { authorization } });\n * const issues = await linear.get('/graphql').json<Issues>();\n *\n * ⚠️ `baseUrl`, NOT `prefixUrl`. ky 2.x renamed the option and changed its semantics:\n * `baseUrl` resolves per the URL standard, so a leading slash is fine and an absolute\n * input bypasses the base. (ky 1.x had `prefixUrl`, which threw on a leading slash —\n * measured 2026-09-15, the old name is a type error, not a silent no-op.)\n */\nexport function client(baseUrl: string, options?: Options): KyInstance {\n return http.extend({ ...options, baseUrl });\n}\n",
9
+ "/**\n * Rate-limit headers the platform does not honour on its own.\n *\n * ★ WHY THIS IS A ky HOOK AND NOT A RETRY IMPLEMENTATION. ky already retries only\n * idempotent methods, honours `Retry-After` in both seconds and HTTP-date form, caps\n * with `maxRetryAfter`, and adds jitter — all of which a hand-rolled version has to get\n * right and usually does not. Reimplementing it to gain one header would trade a\n * maintained retry engine for a bespoke one.\n *\n * 🔴 BUT ONE HEADER IS MISSING, AND IT IS THE ONE THAT HURT. Measured 2026-09-15 against\n * ky 2.1.0 with a local server:\n * retry-after: 1 -> waited 1006ms (honoured)\n * x-ratelimit-reset-after: 1 -> waited 303ms (IGNORED — ky's own backoff)\n * no header at all -> waited 303ms\n * ky reads `X-RateLimit-Retry-After` and `RateLimit-Reset`, but not Discord's\n * `x-ratelimit-reset-after`, which carries FRACTIONAL seconds and is the more precise\n * of the two when both appear.\n *\n * 🔴 WHY THAT MATTERS MORE THAN IT LOOKS. A 429 that is not waited out properly becomes a\n * retry storm, and a retry storm escalates from one throttled ROUTE to a throttled\n * TOKEN. Where a token is shared — a Discord bot and its MCP server on the same\n * credential — the storm takes down the thing that was not even failing.\n */\nimport type { AfterResponseHook, Options } from 'ky';\n\n/** Anything with a case-insensitive `get`. Headers satisfies this; so does a test stub. */\nexport type HeaderBag = { readonly get: (name: string) => string | null };\n\n/** ⚠️ Capped: an uncapped sleep on a global 429 can be an hour, which reads as a hang. */\nexport const MAX_RETRY_WAIT_MS = 30_000;\n\nfunction asMs(raw: string | null): number | null {\n if (raw === null) return null;\n const n = Number(raw.trim());\n // ⚠️ Fractional seconds are the point — Discord sends 0.153, not 1.\n return Number.isFinite(n) && n >= 0 ? Math.min(Math.round(n * 1000), MAX_RETRY_WAIT_MS) : null;\n}\n\n/**\n * How long the server asked us to wait, in ms, or null if it did not say.\n *\n * ★ Prefers `x-ratelimit-reset-after` over `retry-after`: when a server sends both, the\n * fractional one is more precise, and rounding 0.15s up to 1s wastes throughput.\n */\nexport function retryAfterMs(headers: HeaderBag): number | null {\n return asMs(headers.get('x-ratelimit-reset-after')) ?? asMs(headers.get('retry-after'));\n}\n\n/**\n * ky options that honour `x-ratelimit-reset-after`.\n *\n * const discord = client(base, rateLimitAware);\n *\n * ⛔ The hook only SLEEPS; it never swallows the response. ky still decides whether to\n * retry and still throws `HTTPError` on a final 429, so a caller that has run out of\n * attempts sees the failure rather than a silent success.\n */\n/**\n * ⚠️ Typed as AfterResponseHook explicitly. isolatedDeclarations requires every exported\n * symbol to carry a type, and an inline arrow inside an `Options` literal does not\n * infer its parameters from the surrounding annotation.\n */\n// ⚠️ ONE `state` OBJECT, not (request, options, response). ky 2.x changed the hook\n// signature; the 1.x three-argument form is a type error rather than a silent no-op —\n// measured 2026-09-15, and the typechecker is what caught it.\nconst honourResetAfter: AfterResponseHook = async ({ response }) => {\n if (response.status !== 429) return response;\n\n const wait = asMs(response.headers.get('x-ratelimit-reset-after'));\n // ⚠️ Only act when ky would NOT: it already handles plain `retry-after`, and sleeping\n // twice for one 429 doubles the wait for no benefit.\n if (wait === null) return response;\n\n // ⛔ setTimeout, NOT Bun.sleep. This package is runtime-neutral: consumers run it on\n // workerd and Node, where `Bun` does not exist. A bun-only API here resolves fine on\n // a laptop and throws in production.\n await new Promise<void>((resolve) => setTimeout(resolve, wait));\n return response;\n};\n\nexport const rateLimitAware: Options = {\n hooks: { afterResponse: [honourResetAfter] },\n};\n"
9
10
  ],
10
- "mappings": "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",
11
- "debugId": "A55C522EE736511464756E2164756E21",
11
+ "mappings": "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",
12
+ "debugId": "62891601CED5455464756E2164756E21",
12
13
  "names": []
13
14
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Rate-limit headers the platform does not honour on its own.
3
+ *
4
+ * ★ WHY THIS IS A ky HOOK AND NOT A RETRY IMPLEMENTATION. ky already retries only
5
+ * idempotent methods, honours `Retry-After` in both seconds and HTTP-date form, caps
6
+ * with `maxRetryAfter`, and adds jitter — all of which a hand-rolled version has to get
7
+ * right and usually does not. Reimplementing it to gain one header would trade a
8
+ * maintained retry engine for a bespoke one.
9
+ *
10
+ * 🔴 BUT ONE HEADER IS MISSING, AND IT IS THE ONE THAT HURT. Measured 2026-09-15 against
11
+ * ky 2.1.0 with a local server:
12
+ * retry-after: 1 -> waited 1006ms (honoured)
13
+ * x-ratelimit-reset-after: 1 -> waited 303ms (IGNORED — ky's own backoff)
14
+ * no header at all -> waited 303ms
15
+ * ky reads `X-RateLimit-Retry-After` and `RateLimit-Reset`, but not Discord's
16
+ * `x-ratelimit-reset-after`, which carries FRACTIONAL seconds and is the more precise
17
+ * of the two when both appear.
18
+ *
19
+ * 🔴 WHY THAT MATTERS MORE THAN IT LOOKS. A 429 that is not waited out properly becomes a
20
+ * retry storm, and a retry storm escalates from one throttled ROUTE to a throttled
21
+ * TOKEN. Where a token is shared — a Discord bot and its MCP server on the same
22
+ * credential — the storm takes down the thing that was not even failing.
23
+ */
24
+ import type { Options } from 'ky';
25
+ /** Anything with a case-insensitive `get`. Headers satisfies this; so does a test stub. */
26
+ export type HeaderBag = {
27
+ readonly get: (name: string) => string | null;
28
+ };
29
+ /** ⚠️ Capped: an uncapped sleep on a global 429 can be an hour, which reads as a hang. */
30
+ export declare const MAX_RETRY_WAIT_MS = 30000;
31
+ /**
32
+ * How long the server asked us to wait, in ms, or null if it did not say.
33
+ *
34
+ * ★ Prefers `x-ratelimit-reset-after` over `retry-after`: when a server sends both, the
35
+ * fractional one is more precise, and rounding 0.15s up to 1s wastes throughput.
36
+ */
37
+ export declare function retryAfterMs(headers: HeaderBag): number | null;
38
+ export declare const rateLimitAware: Options;
39
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAqB,OAAO,EAAE,MAAM,IAAI,CAAC;AAErD,2FAA2F;AAC3F,MAAM,MAAM,SAAS,GAAG;IAAE,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE1E,0FAA0F;AAC1F,eAAO,MAAM,iBAAiB,QAAS,CAAC;AASxC;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CAE9D;AAkCD,eAAO,MAAM,cAAc,EAAE,OAE5B,CAAC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A client for ONE upstream API, carrying that upstream's quirks so no caller re-derives
3
+ * them.
4
+ *
5
+ * ★ WHY THIS EXISTS ON TOP OF `client()`. Thirty hand-built clients across an MCP fleet
6
+ * each re-derived the same five things: read an env var, decide a base URL, assert a
7
+ * credential, issue the request, turn a bad status into an actionable error. Written
8
+ * thirty times, written thirty ways — and the divergence was not cosmetic.
9
+ *
10
+ * ★ ky IS THE TRANSPORT, NOT REPLACED. Retries, timeouts, `Retry-After` and jitter are
11
+ * ky's job and it does them well. This adds only what ky has no opinion about: where
12
+ * the URL and credential come from, which auth scheme this upstream wants, and what a
13
+ * given status means HERE.
14
+ *
15
+ * ⛔ READS ONLY. `upstream()` issues GET and nothing else. Every write-capable service
16
+ * should run with its OWN credential, so widening this must be a deliberate, separate
17
+ * act — see `writableUpstream()`. A `method` parameter would make that a one-word edit
18
+ * in a pull request nobody reviews closely.
19
+ */
20
+ import type { KyInstance, Options } from 'ky';
21
+ export interface UpstreamConfig {
22
+ /** Lowercase system name. Appears in every error this client raises. */
23
+ readonly system: string;
24
+ /** Env var holding the base URL, e.g. `GRAFANA_URL`. */
25
+ readonly urlVar: string;
26
+ /** Used when `urlVar` is unset. */
27
+ readonly defaultUrl: string;
28
+ /** Env var holding the credential. Omit for an upstream that needs none. */
29
+ readonly tokenVar?: string;
30
+ /**
31
+ * How the credential becomes headers. Defaults to `Bearer`.
32
+ * ⚠️ NOT EVERY UPSTREAM TAKES Bearer. Django REST Framework wants `Token <value>`, and
33
+ * measured, Bearer returns 401 there. A wrong SCHEME and a wrong CREDENTIAL are
34
+ * indistinguishable in the response, which is why this is config and not a guess.
35
+ */
36
+ readonly authHeader?: (token: string) => Record<string, string>;
37
+ /** Prefix every path, e.g. Grafana documents its paths relative to `/api`. */
38
+ readonly pathPrefix?: string;
39
+ /** Say where the secret comes FROM. Appended to the "no credential" error. */
40
+ readonly tokenRemedy?: string;
41
+ /** Where the env lives. Defaults to `process.env`; pass a Worker's `env` binding. */
42
+ readonly env?: Readonly<Record<string, unknown>>;
43
+ /** Extra ky options — timeout, retry limits, headers. */
44
+ readonly options?: Options;
45
+ }
46
+ /** A read-only client for one upstream. ⛔ GET only — see the file header. */
47
+ export declare function upstream(config: UpstreamConfig): Pick<KyInstance, 'get'>;
48
+ /**
49
+ * A client that may also write.
50
+ *
51
+ * ⛔ DELIBERATELY A SEPARATE FUNCTION, NOT A FLAG. A write-capable service should run
52
+ * with its own credential and its own deployment, so reaching for writes is a visible
53
+ * choice at the call site and in review — not a boolean somebody flipped.
54
+ */
55
+ export declare function writableUpstream(config: UpstreamConfig): KyInstance;
56
+ //# sourceMappingURL=upstream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upstream.d.ts","sourceRoot":"","sources":["../src/upstream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,IAAI,CAAC;AAM9C,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,mCAAmC;IACnC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChE,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,qFAAqF;IACrF,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,yDAAyD;IACzD,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AA+DD,6EAA6E;AAC7E,wBAAgB,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,CAQxE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,GAAG,UAAU,CAEnE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/kit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Runtime-neutral HomeFlare primitives: env parsing, errors, logging, HTTP.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/kit#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
package/src/index.ts CHANGED
@@ -24,3 +24,4 @@ export {
24
24
  type KitErrorOptions,
25
25
  } from './errors.ts';
26
26
  export { client, http, HTTPError, TimeoutError, type KyInstance, type Options } from './http.ts';
27
+ export { MAX_RETRY_WAIT_MS, rateLimitAware, retryAfterMs, type HeaderBag } from './rate-limit.ts';
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Rate-limit headers the platform does not honour on its own.
3
+ *
4
+ * ★ WHY THIS IS A ky HOOK AND NOT A RETRY IMPLEMENTATION. ky already retries only
5
+ * idempotent methods, honours `Retry-After` in both seconds and HTTP-date form, caps
6
+ * with `maxRetryAfter`, and adds jitter — all of which a hand-rolled version has to get
7
+ * right and usually does not. Reimplementing it to gain one header would trade a
8
+ * maintained retry engine for a bespoke one.
9
+ *
10
+ * 🔴 BUT ONE HEADER IS MISSING, AND IT IS THE ONE THAT HURT. Measured 2026-09-15 against
11
+ * ky 2.1.0 with a local server:
12
+ * retry-after: 1 -> waited 1006ms (honoured)
13
+ * x-ratelimit-reset-after: 1 -> waited 303ms (IGNORED — ky's own backoff)
14
+ * no header at all -> waited 303ms
15
+ * ky reads `X-RateLimit-Retry-After` and `RateLimit-Reset`, but not Discord's
16
+ * `x-ratelimit-reset-after`, which carries FRACTIONAL seconds and is the more precise
17
+ * of the two when both appear.
18
+ *
19
+ * 🔴 WHY THAT MATTERS MORE THAN IT LOOKS. A 429 that is not waited out properly becomes a
20
+ * retry storm, and a retry storm escalates from one throttled ROUTE to a throttled
21
+ * TOKEN. Where a token is shared — a Discord bot and its MCP server on the same
22
+ * credential — the storm takes down the thing that was not even failing.
23
+ */
24
+ import type { AfterResponseHook, Options } from 'ky';
25
+
26
+ /** Anything with a case-insensitive `get`. Headers satisfies this; so does a test stub. */
27
+ export type HeaderBag = { readonly get: (name: string) => string | null };
28
+
29
+ /** ⚠️ Capped: an uncapped sleep on a global 429 can be an hour, which reads as a hang. */
30
+ export const MAX_RETRY_WAIT_MS = 30_000;
31
+
32
+ function asMs(raw: string | null): number | null {
33
+ if (raw === null) return null;
34
+ const n = Number(raw.trim());
35
+ // ⚠️ Fractional seconds are the point — Discord sends 0.153, not 1.
36
+ return Number.isFinite(n) && n >= 0 ? Math.min(Math.round(n * 1000), MAX_RETRY_WAIT_MS) : null;
37
+ }
38
+
39
+ /**
40
+ * How long the server asked us to wait, in ms, or null if it did not say.
41
+ *
42
+ * ★ Prefers `x-ratelimit-reset-after` over `retry-after`: when a server sends both, the
43
+ * fractional one is more precise, and rounding 0.15s up to 1s wastes throughput.
44
+ */
45
+ export function retryAfterMs(headers: HeaderBag): number | null {
46
+ return asMs(headers.get('x-ratelimit-reset-after')) ?? asMs(headers.get('retry-after'));
47
+ }
48
+
49
+ /**
50
+ * ky options that honour `x-ratelimit-reset-after`.
51
+ *
52
+ * const discord = client(base, rateLimitAware);
53
+ *
54
+ * ⛔ The hook only SLEEPS; it never swallows the response. ky still decides whether to
55
+ * retry and still throws `HTTPError` on a final 429, so a caller that has run out of
56
+ * attempts sees the failure rather than a silent success.
57
+ */
58
+ /**
59
+ * ⚠️ Typed as AfterResponseHook explicitly. isolatedDeclarations requires every exported
60
+ * symbol to carry a type, and an inline arrow inside an `Options` literal does not
61
+ * infer its parameters from the surrounding annotation.
62
+ */
63
+ // ⚠️ ONE `state` OBJECT, not (request, options, response). ky 2.x changed the hook
64
+ // signature; the 1.x three-argument form is a type error rather than a silent no-op —
65
+ // measured 2026-09-15, and the typechecker is what caught it.
66
+ const honourResetAfter: AfterResponseHook = async ({ response }) => {
67
+ if (response.status !== 429) return response;
68
+
69
+ const wait = asMs(response.headers.get('x-ratelimit-reset-after'));
70
+ // ⚠️ Only act when ky would NOT: it already handles plain `retry-after`, and sleeping
71
+ // twice for one 429 doubles the wait for no benefit.
72
+ if (wait === null) return response;
73
+
74
+ // ⛔ setTimeout, NOT Bun.sleep. This package is runtime-neutral: consumers run it on
75
+ // workerd and Node, where `Bun` does not exist. A bun-only API here resolves fine on
76
+ // a laptop and throws in production.
77
+ await new Promise<void>((resolve) => setTimeout(resolve, wait));
78
+ return response;
79
+ };
80
+
81
+ export const rateLimitAware: Options = {
82
+ hooks: { afterResponse: [honourResetAfter] },
83
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A client for ONE upstream API, carrying that upstream's quirks so no caller re-derives
3
+ * them.
4
+ *
5
+ * ★ WHY THIS EXISTS ON TOP OF `client()`. Thirty hand-built clients across an MCP fleet
6
+ * each re-derived the same five things: read an env var, decide a base URL, assert a
7
+ * credential, issue the request, turn a bad status into an actionable error. Written
8
+ * thirty times, written thirty ways — and the divergence was not cosmetic.
9
+ *
10
+ * ★ ky IS THE TRANSPORT, NOT REPLACED. Retries, timeouts, `Retry-After` and jitter are
11
+ * ky's job and it does them well. This adds only what ky has no opinion about: where
12
+ * the URL and credential come from, which auth scheme this upstream wants, and what a
13
+ * given status means HERE.
14
+ *
15
+ * ⛔ READS ONLY. `upstream()` issues GET and nothing else. Every write-capable service
16
+ * should run with its OWN credential, so widening this must be a deliberate, separate
17
+ * act — see `writableUpstream()`. A `method` parameter would make that a one-word edit
18
+ * in a pull request nobody reviews closely.
19
+ */
20
+ import type { KyInstance, Options } from 'ky';
21
+ import { isUnset } from './env.ts';
22
+ import { KitError } from './errors.ts';
23
+ import { client } from './http.ts';
24
+ import { rateLimitAware } from './rate-limit.ts';
25
+
26
+ export interface UpstreamConfig {
27
+ /** Lowercase system name. Appears in every error this client raises. */
28
+ readonly system: string;
29
+ /** Env var holding the base URL, e.g. `GRAFANA_URL`. */
30
+ readonly urlVar: string;
31
+ /** Used when `urlVar` is unset. */
32
+ readonly defaultUrl: string;
33
+ /** Env var holding the credential. Omit for an upstream that needs none. */
34
+ readonly tokenVar?: string;
35
+ /**
36
+ * How the credential becomes headers. Defaults to `Bearer`.
37
+ * ⚠️ NOT EVERY UPSTREAM TAKES Bearer. Django REST Framework wants `Token <value>`, and
38
+ * measured, Bearer returns 401 there. A wrong SCHEME and a wrong CREDENTIAL are
39
+ * indistinguishable in the response, which is why this is config and not a guess.
40
+ */
41
+ readonly authHeader?: (token: string) => Record<string, string>;
42
+ /** Prefix every path, e.g. Grafana documents its paths relative to `/api`. */
43
+ readonly pathPrefix?: string;
44
+ /** Say where the secret comes FROM. Appended to the "no credential" error. */
45
+ readonly tokenRemedy?: string;
46
+ /** Where the env lives. Defaults to `process.env`; pass a Worker's `env` binding. */
47
+ readonly env?: Readonly<Record<string, unknown>>;
48
+ /** Extra ky options — timeout, retry limits, headers. */
49
+ readonly options?: Options;
50
+ }
51
+
52
+ const bearer = (token: string): Record<string, string> => ({ authorization: `Bearer ${token}` });
53
+
54
+ function readEnv(config: UpstreamConfig, name: string): string {
55
+ const source =
56
+ config.env ??
57
+ (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
58
+ {};
59
+ return String(source[name] ?? '').trim();
60
+ }
61
+
62
+ /**
63
+ * ⛔ FAIL CLOSED ON A MISSING CREDENTIAL, AND SAY WHY. Omitting the header instead is
64
+ * worse: the upstream answers a bare 401, which is indistinguishable from a bad
65
+ * credential and sends an operator to rotate a perfectly good secret.
66
+ * ⚠️ The remedy distinguishes the two ways this actually happens, because the fix differs:
67
+ * an EMPTY value means the secret did not render (a denied vault path writes nothing
68
+ * rather than erroring — check the grant, not whether the file exists), while "null"
69
+ * means the binding name does not match what the deployment writes.
70
+ */
71
+ function credential(config: UpstreamConfig): string {
72
+ if (config.tokenVar === undefined) return '';
73
+
74
+ const value = readEnv(config, config.tokenVar);
75
+ if (isUnset(value)) {
76
+ throw new KitError('misconfigured', `no ${config.tokenVar} in the environment`, {
77
+ system: config.system,
78
+ remedy:
79
+ `An EMPTY value means the secret did not render — a denied secret path does not ` +
80
+ `error, it writes nothing, so check the grant rather than the file existing. ` +
81
+ `"null" means the binding name does not match what the deployment writes. ` +
82
+ (config.tokenRemedy ?? ''),
83
+ });
84
+ }
85
+ return value;
86
+ }
87
+
88
+ /** The base URL, trailing slashes stripped so joining a path cannot double one. */
89
+ function baseUrl(config: UpstreamConfig): string {
90
+ return (readEnv(config, config.urlVar) || config.defaultUrl).replace(/\/+$/, '');
91
+ }
92
+
93
+ /** ⚠️ Built per call, never captured: a Worker isolate is reused, so a value read at
94
+ * module scope pins whatever the first request happened to see. */
95
+ function optionsFor(config: UpstreamConfig): Options {
96
+ const headers: Record<string, string> = { accept: 'application/json' };
97
+ if (config.tokenVar !== undefined) {
98
+ Object.assign(headers, (config.authHeader ?? bearer)(credential(config)));
99
+ }
100
+
101
+ return {
102
+ ...rateLimitAware,
103
+ ...config.options,
104
+ headers: { ...headers, ...config.options?.headers },
105
+ // ⛔ NEVER FOLLOW A REDIRECT. An unauthenticated request is often answered with a 302
106
+ // to a login page; following it returns HTML with status 200, which reads as a
107
+ // broken API rather than as "you are not authenticated".
108
+ // ⚠️ Django's APPEND_SLASH does the same for a slash-less path.
109
+ redirect: 'manual',
110
+ };
111
+ }
112
+
113
+ /** A read-only client for one upstream. ⛔ GET only — see the file header. */
114
+ export function upstream(config: UpstreamConfig): Pick<KyInstance, 'get'> {
115
+ const prefix = config.pathPrefix ?? '';
116
+ const instance = client(baseUrl(config), optionsFor(config));
117
+
118
+ return {
119
+ get: ((path: string, options?: Options) =>
120
+ instance.get(`${prefix}${path}`, options)) as KyInstance['get'],
121
+ };
122
+ }
123
+
124
+ /**
125
+ * A client that may also write.
126
+ *
127
+ * ⛔ DELIBERATELY A SEPARATE FUNCTION, NOT A FLAG. A write-capable service should run
128
+ * with its own credential and its own deployment, so reaching for writes is a visible
129
+ * choice at the call site and in review — not a boolean somebody flipped.
130
+ */
131
+ export function writableUpstream(config: UpstreamConfig): KyInstance {
132
+ return client(`${baseUrl(config)}${config.pathPrefix ?? ''}`, optionsFor(config));
133
+ }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.
2
- export const VERSION: string = '0.1.1';
2
+ export const VERSION: string = '0.2.0';