@homeflare/kit 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +2 -2
- package/dist/openapi.d.ts +38 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +30 -0
- package/dist/openapi.js.map +10 -0
- package/package.json +21 -1
- package/src/index.ts +3 -2
- package/src/openapi.ts +59 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,22 @@ config.DEBUG; // boolean — 'false' parses as false, not as a truthy string
|
|
|
28
28
|
`parseEnv` throws `EnvError` on the first key that is missing or malformed. The error
|
|
29
29
|
names the key and never the value.
|
|
30
30
|
|
|
31
|
+
## OpenAPI (Workers / HTTP apps)
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createOpenApiApp } from '@homeflare/kit/openapi';
|
|
35
|
+
import { z } from '@hono/zod-openapi';
|
|
36
|
+
|
|
37
|
+
const app = createOpenApiApp();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
⛔ A **subpath**, never the main entry — Hono must not land in a Node script that only
|
|
41
|
+
wanted `parseEnv`. Peers you provide: `hono` · `@hono/zod-openapi` · `zod`. Import `z`
|
|
42
|
+
from `@hono/zod-openapi`, not from `zod`, or `.openapi()` is missing.
|
|
43
|
+
|
|
44
|
+
The document and the request share one schema. A parallel OpenAPI registry that does not
|
|
45
|
+
validate is two truths.
|
|
46
|
+
|
|
31
47
|
## Development
|
|
32
48
|
|
|
33
49
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THIS ENTRYPOINT STAYS RUNTIME-NEUTRAL. Bun is how the kit is authored, built and
|
|
5
5
|
* tested; it is not what consumers run. Consumers ship this code to Cloudflare
|
|
6
|
-
* Workers, so anything reaching for `bun:*`, `node:fs
|
|
7
|
-
* its own subpath export (`@homeflare/kit/<area>`), never here.
|
|
6
|
+
* Workers, so anything reaching for `bun:*`, `node:fs`, Hono or a filesystem belongs
|
|
7
|
+
* behind its own subpath export (`@homeflare/kit/<area>`), never here.
|
|
8
|
+
* OpenAPIHono is `@homeflare/kit/openapi`.
|
|
8
9
|
* The failure this prevents is a deploy-time one: a `bun:sqlite` import that resolves
|
|
9
10
|
* fine on a laptop and fails only in workerd, where the stack trace names the bundler
|
|
10
11
|
* rather than this file.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;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.
|
|
2
|
+
var VERSION = "0.3.0";
|
|
3
3
|
// src/env.ts
|
|
4
4
|
class EnvError extends Error {
|
|
5
5
|
key;
|
|
@@ -136,5 +136,5 @@ export {
|
|
|
136
136
|
retryAfterMs
|
|
137
137
|
};
|
|
138
138
|
|
|
139
|
-
//# debugId=
|
|
139
|
+
//# debugId=11C15953201F245E64756E2164756E21
|
|
140
140
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"version": 3,
|
|
3
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.
|
|
5
|
+
"// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.3.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
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, type KyInstance, type Options, TimeoutError } 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
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"
|
|
10
10
|
],
|
|
11
11
|
"mappings": "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",
|
|
12
|
-
"debugId": "
|
|
12
|
+
"debugId": "11C15953201F245E64756E2164756E21",
|
|
13
13
|
"names": []
|
|
14
14
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPIHono with one validation hook — the document and the request share a schema.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY @hono/zod-openapi AND NOT HAND-ROLLED CHECKS. A Worker that validates in the
|
|
5
|
+
* handler and generates OpenAPI from a parallel registry has two truths. They agree
|
|
6
|
+
* only while somebody keeps them in step; a wrong-typed field silently becomes
|
|
7
|
+
* `undefined` and the caller is told nothing. zod-openapi generates the document from
|
|
8
|
+
* the same schema that validates the request.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ THIS IS A SUBPATH (`@homeflare/kit/openapi`), NEVER THE MAIN ENTRY. Hono is a
|
|
11
|
+
* Worker/HTTP framework. A Node script depending on `@homeflare/kit` must not resolve
|
|
12
|
+
* it. Import this only from a Worker or an HTTP app.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Import `z` from `@hono/zod-openapi`, not from `zod`. The OpenAPI helpers
|
|
15
|
+
* (`.openapi()`) live on that `z`. `import { z } from 'zod'` typechecks until a
|
|
16
|
+
* route needs `.openapi()` and then fails with a missing method.
|
|
17
|
+
*/
|
|
18
|
+
import { OpenAPIHono } from '@hono/zod-openapi';
|
|
19
|
+
import type { ZodError } from 'zod';
|
|
20
|
+
export type OpenApiApp = OpenAPIHono;
|
|
21
|
+
export interface OpenApiAppOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Refuse unrecognised keys. Default true — a typo that reports success is the
|
|
24
|
+
* worst answer: the client believes it saved.
|
|
25
|
+
*/
|
|
26
|
+
readonly strict?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* How a failed validation reaches the caller.
|
|
30
|
+
*
|
|
31
|
+
* ⛔ NOT THE RAW ZodError. The default hook returns a JSON-encoded string of a JSON
|
|
32
|
+
* array, naming internal paths. A UI that reads `{ error }` cannot act on that.
|
|
33
|
+
* ★ It names the field: "title: expected string" is something a person can fix.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readableZodError(error: ZodError): string;
|
|
36
|
+
/** One OpenAPIHono with a readable defaultHook, shared by every route. */
|
|
37
|
+
export declare function createOpenApiApp(options?: OpenApiAppOptions): OpenApiApp;
|
|
38
|
+
//# sourceMappingURL=openapi.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAC;AAEpC,MAAM,MAAM,UAAU,GAAG,WAAW,CAAC;AAErC,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,CAUxD;AAED,0EAA0E;AAC1E,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,UAAU,CAQxE"}
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/openapi.ts
|
|
2
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
3
|
+
function readableZodError(error) {
|
|
4
|
+
const first = error.issues[0];
|
|
5
|
+
if (first === undefined)
|
|
6
|
+
return "invalid request";
|
|
7
|
+
const field = first.path.filter((part) => part !== undefined).join(".");
|
|
8
|
+
const keys = first.keys;
|
|
9
|
+
if (keys !== undefined && keys.length > 0) {
|
|
10
|
+
return `unknown field${keys.length > 1 ? "s" : ""}: ${keys.join(", ")}`;
|
|
11
|
+
}
|
|
12
|
+
return field.length > 0 ? `${field}: ${first.message}` : first.message;
|
|
13
|
+
}
|
|
14
|
+
function createOpenApiApp(options) {
|
|
15
|
+
return new OpenAPIHono({
|
|
16
|
+
strict: options?.strict ?? true,
|
|
17
|
+
defaultHook: (result, c) => {
|
|
18
|
+
if (result.success)
|
|
19
|
+
return;
|
|
20
|
+
return c.json({ error: readableZodError(result.error) }, 400);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
createOpenApiApp,
|
|
26
|
+
readableZodError
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
//# debugId=9E5BC0B4FF32266064756E2164756E21
|
|
30
|
+
//# sourceMappingURL=openapi.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/openapi.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * OpenAPIHono with one validation hook — the document and the request share a schema.\n *\n * ★ WHY @hono/zod-openapi AND NOT HAND-ROLLED CHECKS. A Worker that validates in the\n * handler and generates OpenAPI from a parallel registry has two truths. They agree\n * only while somebody keeps them in step; a wrong-typed field silently becomes\n * `undefined` and the caller is told nothing. zod-openapi generates the document from\n * the same schema that validates the request.\n *\n * ⛔ THIS IS A SUBPATH (`@homeflare/kit/openapi`), NEVER THE MAIN ENTRY. Hono is a\n * Worker/HTTP framework. A Node script depending on `@homeflare/kit` must not resolve\n * it. Import this only from a Worker or an HTTP app.\n *\n * ⚠️ Import `z` from `@hono/zod-openapi`, not from `zod`. The OpenAPI helpers\n * (`.openapi()`) live on that `z`. `import { z } from 'zod'` typechecks until a\n * route needs `.openapi()` and then fails with a missing method.\n */\nimport { OpenAPIHono } from '@hono/zod-openapi';\nimport type { ZodError } from 'zod';\n\nexport type OpenApiApp = OpenAPIHono;\n\nexport interface OpenApiAppOptions {\n /**\n * Refuse unrecognised keys. Default true — a typo that reports success is the\n * worst answer: the client believes it saved.\n */\n readonly strict?: boolean;\n}\n\n/**\n * How a failed validation reaches the caller.\n *\n * ⛔ NOT THE RAW ZodError. The default hook returns a JSON-encoded string of a JSON\n * array, naming internal paths. A UI that reads `{ error }` cannot act on that.\n * ★ It names the field: \"title: expected string\" is something a person can fix.\n */\nexport function readableZodError(error: ZodError): string {\n const first = error.issues[0];\n if (first === undefined) return 'invalid request';\n const field = first.path.filter((part) => part !== undefined).join('.');\n // ⚠️ An unrecognised key has an EMPTY path — the key lives on the issue, not the path.\n const keys = (first as { keys?: string[] }).keys;\n if (keys !== undefined && keys.length > 0) {\n return `unknown field${keys.length > 1 ? 's' : ''}: ${keys.join(', ')}`;\n }\n return field.length > 0 ? `${field}: ${first.message}` : first.message;\n}\n\n/** One OpenAPIHono with a readable defaultHook, shared by every route. */\nexport function createOpenApiApp(options?: OpenApiAppOptions): OpenApiApp {\n return new OpenAPIHono({\n strict: options?.strict ?? true,\n defaultHook: (result, c) => {\n if (result.success) return undefined;\n return c.json({ error: readableZodError(result.error) }, 400);\n },\n });\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";AAiBA;AAoBO,SAAS,gBAAgB,CAAC,OAAyB;AAAA,EACxD,MAAM,QAAQ,MAAM,OAAO;AAAA,EAC3B,IAAI,UAAU;AAAA,IAAW,OAAO;AAAA,EAChC,MAAM,QAAQ,MAAM,KAAK,OAAO,CAAC,SAAS,SAAS,SAAS,EAAE,KAAK,GAAG;AAAA,EAEtE,MAAM,OAAQ,MAA8B;AAAA,EAC5C,IAAI,SAAS,aAAa,KAAK,SAAS,GAAG;AAAA,IACzC,OAAO,gBAAgB,KAAK,SAAS,IAAI,MAAM,OAAO,KAAK,KAAK,IAAI;AAAA,EACtE;AAAA,EACA,OAAO,MAAM,SAAS,IAAI,GAAG,UAAU,MAAM,YAAY,MAAM;AAAA;AAI1D,SAAS,gBAAgB,CAAC,SAAyC;AAAA,EACxE,OAAO,IAAI,YAAY;AAAA,IACrB,QAAQ,SAAS,UAAU;AAAA,IAC3B,aAAa,CAAC,QAAQ,MAAM;AAAA,MAC1B,IAAI,OAAO;AAAA,QAAS;AAAA,MACpB,OAAO,EAAE,KAAK,EAAE,OAAO,iBAAiB,OAAO,KAAK,EAAE,GAAG,GAAG;AAAA;AAAA,EAEhE,CAAC;AAAA;",
|
|
8
|
+
"debugId": "9E5BC0B4FF32266064756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Runtime-neutral HomeFlare primitives: typed env parsing, actionable errors, and an HTTP client.",
|
|
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",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./dist/index.d.ts",
|
|
26
26
|
"default": "./dist/index.js"
|
|
27
27
|
},
|
|
28
|
+
"./openapi": {
|
|
29
|
+
"types": "./dist/openapi.d.ts",
|
|
30
|
+
"default": "./dist/openapi.js"
|
|
31
|
+
},
|
|
28
32
|
"./package.json": "./package.json"
|
|
29
33
|
},
|
|
30
34
|
"publishConfig": {
|
|
@@ -32,5 +36,21 @@
|
|
|
32
36
|
},
|
|
33
37
|
"dependencies": {
|
|
34
38
|
"ky": "2.1.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@hono/zod-openapi": "^1.5.0",
|
|
42
|
+
"hono": "^4.13.0",
|
|
43
|
+
"zod": "^4.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@hono/zod-openapi": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"hono": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"zod": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
35
55
|
}
|
|
36
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THIS ENTRYPOINT STAYS RUNTIME-NEUTRAL. Bun is how the kit is authored, built and
|
|
5
5
|
* tested; it is not what consumers run. Consumers ship this code to Cloudflare
|
|
6
|
-
* Workers, so anything reaching for `bun:*`, `node:fs
|
|
7
|
-
* its own subpath export (`@homeflare/kit/<area>`), never here.
|
|
6
|
+
* Workers, so anything reaching for `bun:*`, `node:fs`, Hono or a filesystem belongs
|
|
7
|
+
* behind its own subpath export (`@homeflare/kit/<area>`), never here.
|
|
8
|
+
* OpenAPIHono is `@homeflare/kit/openapi`.
|
|
8
9
|
* The failure this prevents is a deploy-time one: a `bun:sqlite` import that resolves
|
|
9
10
|
* fine on a laptop and fails only in workerd, where the stack trace names the bundler
|
|
10
11
|
* rather than this file.
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPIHono with one validation hook — the document and the request share a schema.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY @hono/zod-openapi AND NOT HAND-ROLLED CHECKS. A Worker that validates in the
|
|
5
|
+
* handler and generates OpenAPI from a parallel registry has two truths. They agree
|
|
6
|
+
* only while somebody keeps them in step; a wrong-typed field silently becomes
|
|
7
|
+
* `undefined` and the caller is told nothing. zod-openapi generates the document from
|
|
8
|
+
* the same schema that validates the request.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ THIS IS A SUBPATH (`@homeflare/kit/openapi`), NEVER THE MAIN ENTRY. Hono is a
|
|
11
|
+
* Worker/HTTP framework. A Node script depending on `@homeflare/kit` must not resolve
|
|
12
|
+
* it. Import this only from a Worker or an HTTP app.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Import `z` from `@hono/zod-openapi`, not from `zod`. The OpenAPI helpers
|
|
15
|
+
* (`.openapi()`) live on that `z`. `import { z } from 'zod'` typechecks until a
|
|
16
|
+
* route needs `.openapi()` and then fails with a missing method.
|
|
17
|
+
*/
|
|
18
|
+
import { OpenAPIHono } from '@hono/zod-openapi';
|
|
19
|
+
import type { ZodError } from 'zod';
|
|
20
|
+
|
|
21
|
+
export type OpenApiApp = OpenAPIHono;
|
|
22
|
+
|
|
23
|
+
export interface OpenApiAppOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Refuse unrecognised keys. Default true — a typo that reports success is the
|
|
26
|
+
* worst answer: the client believes it saved.
|
|
27
|
+
*/
|
|
28
|
+
readonly strict?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How a failed validation reaches the caller.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ NOT THE RAW ZodError. The default hook returns a JSON-encoded string of a JSON
|
|
35
|
+
* array, naming internal paths. A UI that reads `{ error }` cannot act on that.
|
|
36
|
+
* ★ It names the field: "title: expected string" is something a person can fix.
|
|
37
|
+
*/
|
|
38
|
+
export function readableZodError(error: ZodError): string {
|
|
39
|
+
const first = error.issues[0];
|
|
40
|
+
if (first === undefined) return 'invalid request';
|
|
41
|
+
const field = first.path.filter((part) => part !== undefined).join('.');
|
|
42
|
+
// ⚠️ An unrecognised key has an EMPTY path — the key lives on the issue, not the path.
|
|
43
|
+
const keys = (first as { keys?: string[] }).keys;
|
|
44
|
+
if (keys !== undefined && keys.length > 0) {
|
|
45
|
+
return `unknown field${keys.length > 1 ? 's' : ''}: ${keys.join(', ')}`;
|
|
46
|
+
}
|
|
47
|
+
return field.length > 0 ? `${field}: ${first.message}` : first.message;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One OpenAPIHono with a readable defaultHook, shared by every route. */
|
|
51
|
+
export function createOpenApiApp(options?: OpenApiAppOptions): OpenApiApp {
|
|
52
|
+
return new OpenAPIHono({
|
|
53
|
+
strict: options?.strict ?? true,
|
|
54
|
+
defaultHook: (result, c) => {
|
|
55
|
+
if (result.success) return undefined;
|
|
56
|
+
return c.json({ error: readableZodError(result.error) }, 400);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
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.
|
|
2
|
+
export const VERSION: string = '0.3.0';
|