@nifrajs/core 0.1.0-alpha.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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/errors.d.ts +21 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +26 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/index.d.ts +26 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +21 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/internal/brand.d.ts +11 -0
  12. package/dist/internal/brand.d.ts.map +1 -0
  13. package/dist/internal/brand.js +10 -0
  14. package/dist/internal/brand.js.map +1 -0
  15. package/dist/internal/secret.d.ts +9 -0
  16. package/dist/internal/secret.d.ts.map +1 -0
  17. package/dist/internal/secret.js +19 -0
  18. package/dist/internal/secret.js.map +1 -0
  19. package/dist/router/router.d.ts +61 -0
  20. package/dist/router/router.d.ts.map +1 -0
  21. package/dist/router/router.js +324 -0
  22. package/dist/router/router.js.map +1 -0
  23. package/dist/schema/standard.d.ts +46 -0
  24. package/dist/schema/standard.d.ts.map +1 -0
  25. package/dist/schema/standard.js +20 -0
  26. package/dist/schema/standard.js.map +1 -0
  27. package/dist/seo.d.ts +48 -0
  28. package/dist/seo.d.ts.map +1 -0
  29. package/dist/seo.js +109 -0
  30. package/dist/seo.js.map +1 -0
  31. package/dist/server/body.d.ts +39 -0
  32. package/dist/server/body.d.ts.map +1 -0
  33. package/dist/server/body.js +85 -0
  34. package/dist/server/body.js.map +1 -0
  35. package/dist/server/context.d.ts +111 -0
  36. package/dist/server/context.d.ts.map +1 -0
  37. package/dist/server/context.js +2 -0
  38. package/dist/server/context.js.map +1 -0
  39. package/dist/server/contract.d.ts +142 -0
  40. package/dist/server/contract.d.ts.map +1 -0
  41. package/dist/server/contract.js +59 -0
  42. package/dist/server/contract.js.map +1 -0
  43. package/dist/server/cookies.d.ts +41 -0
  44. package/dist/server/cookies.d.ts.map +1 -0
  45. package/dist/server/cookies.js +181 -0
  46. package/dist/server/cookies.js.map +1 -0
  47. package/dist/server/logger.d.ts +49 -0
  48. package/dist/server/logger.d.ts.map +1 -0
  49. package/dist/server/logger.js +125 -0
  50. package/dist/server/logger.js.map +1 -0
  51. package/dist/server/registry.d.ts +46 -0
  52. package/dist/server/registry.d.ts.map +1 -0
  53. package/dist/server/registry.js +2 -0
  54. package/dist/server/registry.js.map +1 -0
  55. package/dist/server/server.d.ts +528 -0
  56. package/dist/server/server.d.ts.map +1 -0
  57. package/dist/server/server.js +1660 -0
  58. package/dist/server/server.js.map +1 -0
  59. package/dist/server/sse.d.ts +54 -0
  60. package/dist/server/sse.d.ts.map +1 -0
  61. package/dist/server/sse.js +126 -0
  62. package/dist/server/sse.js.map +1 -0
  63. package/dist/server/webhook.d.ts +42 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +149 -0
  66. package/dist/server/webhook.js.map +1 -0
  67. package/dist/server/websocket.d.ts +152 -0
  68. package/dist/server/websocket.d.ts.map +1 -0
  69. package/dist/server/websocket.js +178 -0
  70. package/dist/server/websocket.js.map +1 -0
  71. package/dist/server/ws-bun.d.ts +27 -0
  72. package/dist/server/ws-bun.d.ts.map +1 -0
  73. package/dist/server/ws-bun.js +82 -0
  74. package/dist/server/ws-bun.js.map +1 -0
  75. package/dist/server/ws-hook.d.ts +36 -0
  76. package/dist/server/ws-hook.d.ts.map +1 -0
  77. package/dist/server/ws-hook.js +18 -0
  78. package/dist/server/ws-hook.js.map +1 -0
  79. package/dist/ws.d.ts +19 -0
  80. package/dist/ws.d.ts.map +1 -0
  81. package/dist/ws.js +26 -0
  82. package/dist/ws.js.map +1 -0
  83. package/package.json +63 -0
  84. package/src/errors.ts +41 -0
  85. package/src/index.ts +115 -0
  86. package/src/internal/brand.ts +11 -0
  87. package/src/internal/secret.ts +24 -0
  88. package/src/router/router.ts +429 -0
  89. package/src/schema/standard.ts +76 -0
  90. package/src/seo.ts +155 -0
  91. package/src/server/body.ts +89 -0
  92. package/src/server/context.ts +124 -0
  93. package/src/server/contract.ts +209 -0
  94. package/src/server/cookies.ts +198 -0
  95. package/src/server/logger.ts +167 -0
  96. package/src/server/registry.ts +63 -0
  97. package/src/server/server.ts +2479 -0
  98. package/src/server/sse.ts +161 -0
  99. package/src/server/webhook.ts +207 -0
  100. package/src/server/websocket.ts +308 -0
  101. package/src/server/ws-bun.ts +106 -0
  102. package/src/server/ws-hook.ts +53 -0
  103. package/src/ws.ts +40 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The Standard Schema v1 interface (https://standardschema.dev), vendored as
3
+ * types + a tiny runtime helper so any compliant validator — zod, valibot,
4
+ * arktype, … — validates requests without coupling the framework to one lib.
5
+ * The spec is MIT-licensed and explicitly designed to be copied.
6
+ */
7
+
8
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
9
+ readonly "~standard": StandardSchemaProps<Input, Output>
10
+ }
11
+
12
+ export interface StandardSchemaProps<Input = unknown, Output = Input> {
13
+ readonly version: 1
14
+ readonly vendor: string
15
+ readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>
16
+ readonly types?: StandardTypes<Input, Output> | undefined
17
+ }
18
+
19
+ export type StandardResult<Output> = StandardSuccess<Output> | StandardFailure
20
+
21
+ export interface StandardSuccess<Output> {
22
+ readonly value: Output
23
+ readonly issues?: undefined
24
+ }
25
+
26
+ export interface StandardFailure {
27
+ readonly issues: ReadonlyArray<StandardIssue>
28
+ }
29
+
30
+ export interface StandardIssue {
31
+ readonly message: string
32
+ readonly path?: ReadonlyArray<PropertyKey | StandardPathSegment> | undefined
33
+ }
34
+
35
+ export interface StandardPathSegment {
36
+ readonly key: PropertyKey
37
+ }
38
+
39
+ export interface StandardTypes<Input = unknown, Output = Input> {
40
+ readonly input: Input
41
+ readonly output: Output
42
+ }
43
+
44
+ export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
45
+ Schema["~standard"]["types"]
46
+ >["output"]
47
+
48
+ export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
49
+ Schema["~standard"]["types"]
50
+ >["input"]
51
+
52
+ export type ValidationOutcome<Output> =
53
+ | { readonly ok: true; readonly value: Output }
54
+ | { readonly ok: false; readonly issues: ReadonlyArray<StandardIssue> }
55
+
56
+ function normalizeStandardResult<Output>(
57
+ result: StandardResult<Output>,
58
+ ): ValidationOutcome<Output> {
59
+ if (result.issues !== undefined) {
60
+ return { ok: false, issues: result.issues }
61
+ }
62
+ return { ok: true, value: result.value }
63
+ }
64
+
65
+ /** Run a Standard Schema and normalize the result. Sync validators stay sync; async validators are awaited. */
66
+ export function validateStandard<Schema extends StandardSchemaV1>(
67
+ schema: Schema,
68
+ value: unknown,
69
+ ): ValidationOutcome<InferOutput<Schema>> | Promise<ValidationOutcome<InferOutput<Schema>>> {
70
+ const result = schema["~standard"].validate(value)
71
+ return result instanceof Promise
72
+ ? result.then((settled) =>
73
+ normalizeStandardResult(settled as StandardResult<InferOutput<Schema>>),
74
+ )
75
+ : normalizeStandardResult(result as StandardResult<InferOutput<Schema>>)
76
+ }
package/src/seo.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `sitemap.xml` + `robots.txt` builders — pure, edge-safe string generators (no I/O, no runtime deps).
3
+ * Wire them to a route on any runtime:
4
+ *
5
+ * app.get("/sitemap.xml", () =>
6
+ * new Response(sitemap([{ url: "/" }, { url: "/about", priority: 0.8 }], { hostname: "https://x.com" }), {
7
+ * headers: { "content-type": "application/xml; charset=utf-8" },
8
+ * }))
9
+ *
10
+ * app.get("/robots.txt", () =>
11
+ * new Response(robots({ rules: [{ userAgent: "*", disallow: ["/admin"] }], sitemap: "https://x.com/sitemap.xml" }), {
12
+ * headers: { "content-type": "text/plain; charset=utf-8" },
13
+ * }))
14
+ */
15
+
16
+ const XML_ESCAPES: Readonly<Record<string, string>> = {
17
+ "&": "&amp;",
18
+ "<": "&lt;",
19
+ ">": "&gt;",
20
+ '"': "&quot;",
21
+ "'": "&apos;",
22
+ }
23
+
24
+ /** Escape the five XML metacharacters — `<loc>` and `<lastmod>` carry app- or DB-derived strings. */
25
+ function escapeXml(value: string): string {
26
+ return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] as string)
27
+ }
28
+
29
+ export type SitemapChangeFreq =
30
+ | "always"
31
+ | "hourly"
32
+ | "daily"
33
+ | "weekly"
34
+ | "monthly"
35
+ | "yearly"
36
+ | "never"
37
+
38
+ const CHANGE_FREQS: ReadonlySet<string> = new Set([
39
+ "always",
40
+ "hourly",
41
+ "daily",
42
+ "weekly",
43
+ "monthly",
44
+ "yearly",
45
+ "never",
46
+ ])
47
+
48
+ export interface SitemapEntry {
49
+ /** An absolute URL, or a path that `hostname` will make absolute. */
50
+ readonly url: string
51
+ /** Last-modified — a `Date` (serialized as ISO 8601) or a pre-formatted W3C-datetime string. */
52
+ readonly lastmod?: string | Date
53
+ readonly changefreq?: SitemapChangeFreq
54
+ /** Crawl priority, `0.0`–`1.0`. */
55
+ readonly priority?: number
56
+ }
57
+
58
+ export interface SitemapOptions {
59
+ /** Prepended to path-only `url`s (e.g. `"https://example.com"`). Sitemaps require absolute URLs. */
60
+ readonly hostname?: string
61
+ }
62
+
63
+ /** sitemaps.org caps a single sitemap file at 50,000 URLs. Beyond this, split into a sitemap index. */
64
+ const SITEMAP_MAX_URLS = 50_000
65
+
66
+ /** Build a `<urlset>` sitemap XML document from `entries`. Throws on out-of-spec input (dev-time data). */
67
+ export function sitemap(entries: readonly SitemapEntry[], options: SitemapOptions = {}): string {
68
+ if (entries.length > SITEMAP_MAX_URLS) {
69
+ throw new RangeError(
70
+ `sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit — split into a sitemap index`,
71
+ )
72
+ }
73
+ const base = options.hostname?.replace(/\/+$/, "")
74
+ const urls = entries.map((entry) => {
75
+ const parts = [`<loc>${escapeXml(resolveLoc(entry.url, base))}</loc>`]
76
+ if (entry.lastmod !== undefined) {
77
+ const value = entry.lastmod instanceof Date ? entry.lastmod.toISOString() : entry.lastmod
78
+ parts.push(`<lastmod>${escapeXml(value)}</lastmod>`)
79
+ }
80
+ if (entry.changefreq !== undefined) {
81
+ if (!CHANGE_FREQS.has(entry.changefreq)) {
82
+ throw new RangeError(`sitemap: invalid changefreq "${entry.changefreq}"`)
83
+ }
84
+ parts.push(`<changefreq>${entry.changefreq}</changefreq>`)
85
+ }
86
+ if (entry.priority !== undefined) {
87
+ if (!Number.isFinite(entry.priority) || entry.priority < 0 || entry.priority > 1) {
88
+ throw new RangeError(`sitemap: priority must be between 0.0 and 1.0, got ${entry.priority}`)
89
+ }
90
+ parts.push(`<priority>${entry.priority.toFixed(1)}</priority>`)
91
+ }
92
+ return ` <url>${parts.join("")}</url>`
93
+ })
94
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`
95
+ }
96
+
97
+ function resolveLoc(url: string, base: string | undefined): string {
98
+ if (/^https?:\/\//i.test(url)) return url // already absolute
99
+ if (base === undefined) return url // relative — caller's choice (sitemaps prefer absolute)
100
+ return `${base}${url.startsWith("/") ? "" : "/"}${url}`
101
+ }
102
+
103
+ export interface RobotsRule {
104
+ /** A single user-agent token (`"*"`, `"Googlebot"`, …) or several sharing the same directives. */
105
+ readonly userAgent: string | readonly string[]
106
+ readonly allow?: readonly string[]
107
+ readonly disallow?: readonly string[]
108
+ /** Seconds between requests (honored by some crawlers). */
109
+ readonly crawlDelay?: number
110
+ }
111
+
112
+ export interface RobotsOptions {
113
+ readonly rules: readonly RobotsRule[]
114
+ /** One or more absolute `Sitemap:` URLs. */
115
+ readonly sitemap?: string | readonly string[]
116
+ /** A `Host:` directive (preferred mirror). */
117
+ readonly host?: string
118
+ }
119
+
120
+ /** robots.txt is line-oriented, so a newline in any value could inject a forged directive. Flatten it. */
121
+ function sanitizeLine(value: string): string {
122
+ return value.replace(/[\r\n]+/g, " ").trim()
123
+ }
124
+
125
+ /** Build a `robots.txt` body from grouped rules plus optional `Sitemap:`/`Host:` lines. */
126
+ export function robots(options: RobotsOptions): string {
127
+ const lines: string[] = []
128
+ for (const rule of options.rules) {
129
+ const agents = typeof rule.userAgent === "string" ? [rule.userAgent] : rule.userAgent
130
+ if (agents.length === 0) {
131
+ throw new RangeError("robots: each rule needs at least one userAgent")
132
+ }
133
+ for (const agent of agents) lines.push(`User-agent: ${sanitizeLine(agent)}`)
134
+ for (const path of rule.allow ?? []) lines.push(`Allow: ${sanitizeLine(path)}`)
135
+ for (const path of rule.disallow ?? []) lines.push(`Disallow: ${sanitizeLine(path)}`)
136
+ if (rule.crawlDelay !== undefined) {
137
+ if (!Number.isFinite(rule.crawlDelay) || rule.crawlDelay < 0) {
138
+ throw new RangeError(
139
+ `robots: crawlDelay must be a non-negative number, got ${rule.crawlDelay}`,
140
+ )
141
+ }
142
+ lines.push(`Crawl-delay: ${rule.crawlDelay}`)
143
+ }
144
+ lines.push("") // blank line between groups
145
+ }
146
+ const sitemaps =
147
+ options.sitemap === undefined
148
+ ? []
149
+ : typeof options.sitemap === "string"
150
+ ? [options.sitemap]
151
+ : options.sitemap
152
+ for (const url of sitemaps) lines.push(`Sitemap: ${sanitizeLine(url)}`)
153
+ if (options.host !== undefined) lines.push(`Host: ${sanitizeLine(options.host)}`)
154
+ return `${lines.join("\n").trim()}\n`
155
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Bounded request-body reading — the single source of truth for nifra's body-size cap. A lying or
3
+ * absent `Content-Length` can't force us to buffer an oversized payload: a declared length over the
4
+ * cap is rejected *before* buffering, and a chunked / length-less body is aborted mid-stream once the
5
+ * running byte count exceeds the cap. Shared by the server's schema path, `c.boundedBody`, and
6
+ * `verifyWebhook` so they all enforce the same guarantee.
7
+ */
8
+
9
+ interface BodySource {
10
+ readonly headers: Pick<Headers, "get">
11
+ readonly body: ReadableStream<Uint8Array> | null
12
+ arrayBuffer(): Promise<ArrayBuffer>
13
+ }
14
+
15
+ export function parseContentLength(value: string): number | undefined {
16
+ if (value.length === 0) return undefined
17
+ let length = 0
18
+ for (let i = 0; i < value.length; i++) {
19
+ const digit = value.charCodeAt(i) - 48
20
+ if (digit < 0 || digit > 9) return undefined
21
+ length = length * 10 + digit
22
+ if (length > Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY
23
+ }
24
+ return length
25
+ }
26
+
27
+ /** The shared streaming byte-cap loop: read until done, or cancel + 413 once over `maxBytes`.
28
+ * A single-chunk body (the common case for small chunked payloads) returns the runtime's own
29
+ * chunk directly — no chunk array, no copy-merge. */
30
+ export async function drainCapped(
31
+ body: ReadableStream<Uint8Array>,
32
+ maxBytes: number,
33
+ ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 413 }> {
34
+ const reader = body.getReader()
35
+ let first: Uint8Array | undefined
36
+ let rest: Uint8Array[] | undefined
37
+ let total = 0
38
+ for (;;) {
39
+ const { done, value } = await reader.read()
40
+ if (done) break
41
+ total += value.byteLength
42
+ if (total > maxBytes) {
43
+ await reader.cancel()
44
+ return { ok: false, status: 413 }
45
+ }
46
+ if (first === undefined) {
47
+ first = value
48
+ } else {
49
+ rest ??= []
50
+ rest.push(value)
51
+ }
52
+ }
53
+ if (rest === undefined) return { ok: true, bytes: first ?? new Uint8Array(0) }
54
+ const merged = new Uint8Array(total)
55
+ merged.set(first as Uint8Array, 0)
56
+ let offset = (first as Uint8Array).byteLength
57
+ for (const chunk of rest) {
58
+ merged.set(chunk, offset)
59
+ offset += chunk.byteLength
60
+ }
61
+ return { ok: true, bytes: merged }
62
+ }
63
+
64
+ /**
65
+ * Read a request body as **bytes**, capped at `maxBytes`. Rejects a `Content-Length` over the cap
66
+ * before buffering (`413`) and a malformed `Content-Length` (`400`); a chunked / length-less body
67
+ * falls through to the streaming byte-cap guard. Fast path: a non-chunked request with a
68
+ * `Content-Length` within the cap is read via native `arrayBuffer()` (framing-bounded by the runtime's
69
+ * HTTP server), skipping the manual stream loop.
70
+ */
71
+ export async function readBoundedBytes(
72
+ req: BodySource,
73
+ maxBytes: number,
74
+ ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 400 | 413 }> {
75
+ const declared = req.headers.get("content-length")
76
+ if (declared !== null) {
77
+ // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A non-numeric
78
+ // / negative / fractional value is malformed → 400, rather than falling through to the streaming
79
+ // guard (an UPPER-bound cap only, so a lying SMALLER length would otherwise be read in full).
80
+ const length = parseContentLength(declared)
81
+ if (length === undefined) return { ok: false, status: 400 }
82
+ if (length > maxBytes) return { ok: false, status: 413 }
83
+ const chunked = req.headers.get("transfer-encoding") !== null
84
+ if (!chunked) return { ok: true, bytes: new Uint8Array(await req.arrayBuffer()) }
85
+ }
86
+ const body = req.body
87
+ if (body === null) return { ok: true, bytes: new Uint8Array(0) }
88
+ return drainCapped(body, maxBytes)
89
+ }
@@ -0,0 +1,124 @@
1
+ import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
2
+ import type { CookieOptions } from "./cookies.ts"
3
+
4
+ /** Flattens an intersection into a single object type for readable hovers. */
5
+ export type Prettify<T> = { [K in keyof T]: T[K] } & {}
6
+
7
+ /**
8
+ * Extracts `:param` and trailing `*wildcard` names from a route-path literal into
9
+ * a string→string record: `/users/:id/posts/:postId` → `{ id: string; postId:
10
+ * string }`, `/files/*path` → `{ path: string }`, `/files/*` → `{ "*": string }`.
11
+ * A non-literal `string` path widens to `Record<string, string>`.
12
+ */
13
+ type RawParams<Path extends string> = string extends Path
14
+ ? Record<string, string>
15
+ : Path extends `${infer _Start}:${infer Param}/${infer Rest}`
16
+ ? Record<Param, string> & RawParams<`/${Rest}`>
17
+ : Path extends `${infer _Start}:${infer Param}`
18
+ ? Record<Param, string>
19
+ : Path extends `${infer _Start}*${infer Wild}`
20
+ ? Record<Wild extends "" ? "*" : Wild, string>
21
+ : Record<never, string>
22
+
23
+ export type Params<Path extends string> = Prettify<RawParams<Path>>
24
+
25
+ /** Per-route input schemas. Each is any Standard Schema (zod/valibot/arktype/…). */
26
+ export interface RouteSchema {
27
+ readonly body?: StandardSchemaV1
28
+ readonly query?: StandardSchemaV1
29
+ /** Optional **response contract**. When declared: the handler's return is type-checked against it
30
+ * (the implementation can't drift from the contract), the typed client sees THIS as the response type
31
+ * (the contract — not the handler's incidental return), and it's emitted into OpenAPI/JSON-Schema so
32
+ * tooling and coding agents can read the exact shape. It is **not** validated at runtime (zero
33
+ * hot-path cost) — purely a compile-time + introspection contract. */
34
+ readonly response?: StandardSchemaV1
35
+ }
36
+
37
+ /** The validated body type, or `undefined` when no body schema is declared. */
38
+ type BodyOf<S extends RouteSchema> = S extends { body: infer B extends StandardSchemaV1 }
39
+ ? InferOutput<B>
40
+ : undefined
41
+
42
+ /** The validated query type, or raw `URLSearchParams` when no query schema is declared. */
43
+ type QueryOf<S extends RouteSchema> = S extends { query: infer Q extends StandardSchemaV1 }
44
+ ? InferOutput<Q>
45
+ : URLSearchParams
46
+
47
+ /** Mutable response controls a handler may write to before returning. */
48
+ export interface ResponseControls {
49
+ status?: number
50
+ readonly headers: Record<string, string>
51
+ /**
52
+ * Queue a `Set-Cookie` on the response. Defaults are **secure-by-default** —
53
+ * `HttpOnly; Secure; SameSite=Lax; Path=/` — overridable per call (pass `{ secure: false }` for
54
+ * local http dev). Multiple calls set multiple cookies (they're merged via a `Headers` object, so
55
+ * they don't collapse the way the plain `headers` Record would). Sign the value with `signValue`
56
+ * for tamper-evidence (what `@nifrajs/auth` sessions do).
57
+ */
58
+ cookie(name: string, value: string, options?: CookieOptions): void
59
+ /** Queue a cookie deletion (`Max-Age=0` + a past `Expires`). Match the `path`/`domain` the cookie
60
+ * was set with, or the browser keeps it. */
61
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void
62
+ }
63
+
64
+ /**
65
+ * Runtime platform inputs, passed as `app.fetch(request, platform)`. Edge adapters (e.g.
66
+ * Cloudflare Workers) supply `env` (bindings) + `waitUntil`; Bun/Node/Deno omit them. Optional +
67
+ * runtime-neutral, so `app.fetch` stays a Web-standard handler.
68
+ */
69
+ export interface Platform<Env = unknown> {
70
+ /** Platform bindings (KV/D1/secrets on Workers). Typed as `Env` when the app declares them via
71
+ * `server<Env>()`; otherwise `unknown` — validate before use. */
72
+ readonly env?: Env
73
+ /** Extend the response's lifetime for background work (Workers `ctx.waitUntil`). */
74
+ readonly waitUntil?: (promise: Promise<unknown>) => void
75
+ }
76
+
77
+ /**
78
+ * Handler context. `params` are inferred from the path; `body` and `query` are
79
+ * the validated outputs of their schemas when declared (else `undefined` /
80
+ * raw `URLSearchParams`).
81
+ */
82
+ export interface Context<Path extends string = string, S extends RouteSchema = RouteSchema> {
83
+ readonly req: Request
84
+ readonly params: Params<Path>
85
+ readonly query: QueryOf<S>
86
+ readonly body: BodyOf<S>
87
+ /** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
88
+ * first access + cached. Signed cookies arrive as `value.signature` — verify with `unsignValue`. */
89
+ readonly cookies: Readonly<Record<string, string>>
90
+ readonly set: ResponseControls
91
+ /**
92
+ * Aborts when the server's `requestTimeoutMs` elapses (and never, when no timeout
93
+ * is configured). Pass it to cancellation-aware work — DB drivers, `fetch` — so a
94
+ * timed-out request stops doing work instead of running on after the 503.
95
+ */
96
+ readonly signal: AbortSignal
97
+ /**
98
+ * Platform bindings from `app.fetch(request, { env })` — Workers `env` (KV/D1/secrets), etc.
99
+ * `undefined` off-edge (Bun/Node/Deno). Declare the shape with `server<Env>()` to read it typed
100
+ * (`c.env: Env`); otherwise `unknown`. Validate at the trust boundary before use either way.
101
+ */
102
+ readonly env: unknown
103
+ /**
104
+ * Schedule background work. On edge runtimes it extends the response lifetime (Workers
105
+ * `ctx.waitUntil`); off-edge it runs the promise fire-and-forget (a rejection is swallowed,
106
+ * never an unhandled rejection).
107
+ */
108
+ readonly waitUntil: (promise: Promise<unknown>) => void
109
+ /**
110
+ * Read the raw request body as bytes, **capped** at `maxBytes` (default: the server's
111
+ * `maxBodyBytes`). For routes with **no body schema** — raw bodies, file uploads,
112
+ * BYO-validation — where the automatic schema cap doesn't apply. An over-cap body (by
113
+ * `Content-Length` or while streaming) throws a flat `413`; a malformed `Content-Length`
114
+ * throws `400`. Consumes the body stream (call once). Pass a larger `maxBytes` for an
115
+ * upload route, a smaller one to tighten a specific endpoint.
116
+ */
117
+ readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
118
+ /**
119
+ * `boundedBody` + `JSON.parse` (UTF-8). Same cap + `413`/`400` semantics; additionally
120
+ * throws a flat `400` on invalid JSON. Use for a schema-less JSON endpoint that still
121
+ * wants the body bounded.
122
+ */
123
+ readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
124
+ }
@@ -0,0 +1,209 @@
1
+ import { RouteConfigError } from "../errors.ts"
2
+ import { METHODS, type Method } from "../router/router.ts"
3
+ import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
4
+ import type { Context, Params, RouteSchema } from "./context.ts"
5
+ import type { OutputOf } from "./registry.ts"
6
+ import { Server } from "./server.ts"
7
+
8
+ /** An additional (non-success) response a contract operation can document, e.g. a `404`. */
9
+ export interface ResponseDef {
10
+ readonly description?: string
11
+ /** Response body schema (any Standard Schema; a `t` schema yields full JSON Schema in OpenAPI). */
12
+ readonly schema?: StandardSchemaV1
13
+ /** Content type. Default `application/json`. */
14
+ readonly contentType?: string
15
+ }
16
+
17
+ /**
18
+ * One operation in a contract. Input schemas are any Standard Schema; `response` is optional.
19
+ *
20
+ * The fields below `response` are **optional OpenAPI metadata** — they don't affect runtime
21
+ * validation or the inferred handler types, they enrich the document `toOpenAPI` emits. A contract is
22
+ * the natural home for them: it's the versionable description of the API, decoupled from the impl.
23
+ */
24
+ export interface OperationDef {
25
+ readonly method: Method
26
+ readonly path: string
27
+ readonly body?: StandardSchemaV1
28
+ readonly query?: StandardSchemaV1
29
+ readonly response?: StandardSchemaV1
30
+ /** Short summary (OpenAPI `summary`). */
31
+ readonly summary?: string
32
+ /** Longer description (OpenAPI `description`, CommonMark). */
33
+ readonly description?: string
34
+ /** Grouping tags (OpenAPI `tags`). */
35
+ readonly tags?: readonly string[]
36
+ /** Mark the operation deprecated. */
37
+ readonly deprecated?: boolean
38
+ /** Security requirements (names ref `securitySchemes`); `[]` = explicitly public. Omit ⇒ inherit the document default. */
39
+ readonly security?: ReadonlyArray<Readonly<Record<string, readonly string[]>>>
40
+ /** Request body content type. Default `application/json`. */
41
+ readonly requestContentType?: string
42
+ /** Content type of the success (`200`) response. Default `application/json`. */
43
+ readonly responseContentType?: string
44
+ /** Additional responses by status code, e.g. `{ "404": { description: "Not found" } }`. */
45
+ readonly responses?: Readonly<Record<string, ResponseDef>>
46
+ }
47
+
48
+ /** A contract: named operations. Names are the handler keys and OpenAPI operationIds. */
49
+ export type ContractShape = Record<string, OperationDef>
50
+
51
+ type OpBody<O extends OperationDef> = O extends { body: infer B extends StandardSchemaV1 }
52
+ ? InferOutput<B>
53
+ : never
54
+ type OpQuery<O extends OperationDef> = O extends { query: infer Q extends StandardSchemaV1 }
55
+ ? InferOutput<Q>
56
+ : never
57
+ type OpResponse<O extends OperationDef> = O extends { response: infer R extends StandardSchemaV1 }
58
+ ? InferOutput<R>
59
+ : unknown
60
+
61
+ /**
62
+ * RouteInfo as a *decoupled consumer* sees it from the contract alone: the
63
+ * `output` is the declared `response` schema's type, or `unknown` when none is
64
+ * declared (the consumer can't know the response without the server). Path/method
65
+ * levels are mutable and fields are `readonly` — matching the inline registry, so
66
+ * the two are mutually assignable.
67
+ */
68
+ type RouteInfoForOp<O extends OperationDef> = {
69
+ readonly params: Params<O["path"]>
70
+ readonly query: OpQuery<O>
71
+ readonly body: OpBody<O>
72
+ readonly output: OpResponse<O>
73
+ }
74
+
75
+ /** Re-key the name-keyed ops into the `path → method → RouteInfo` registry. */
76
+ export type RegistryFor<C extends ContractShape> = {
77
+ [P in C[keyof C]["path"]]: {
78
+ [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: RouteInfoForOp<C[K]>
79
+ }
80
+ }
81
+
82
+ /** The schema shape an op contributes to its handler context (mirrors inline `RouteSchema`). */
83
+ type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends StandardSchemaV1 }
84
+ ? { body: B }
85
+ : Record<never, never>) &
86
+ (O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>)
87
+
88
+ /**
89
+ * The handler context for an op — identical to the inline `Context<Path, S>`, so a
90
+ * handler written for an inline route type-checks unchanged under `implement`
91
+ * (the graduation guarantee).
92
+ */
93
+ export type ContextForOp<O extends OperationDef> = Context<O["path"], SchemaForOp<O> & RouteSchema>
94
+
95
+ type MaybePromise<T> = T | Promise<T>
96
+
97
+ /**
98
+ * What a handler may return for an op. When the op declares a `response`, the return is constrained to
99
+ * that contract shape (or a raw `Response`) — so an `implement`ed backend can't drift from the response
100
+ * the contract's client was built against. With no `response` it's unconstrained (`unknown`), identical
101
+ * to before. Purely type-level — erased at compile time, zero runtime cost — and mirrors the inline
102
+ * route's `ResponseOf` constraint, so a handler graduates inline↔contract unchanged.
103
+ */
104
+ type HandlerReturnForOp<O extends OperationDef> = O extends {
105
+ response: infer R extends StandardSchemaV1
106
+ }
107
+ ? Response | InferOutput<R>
108
+ : unknown
109
+
110
+ /** The handlers `implement` requires: one per operation, typed from the op's input + response contract. */
111
+ export type HandlersFor<C extends ContractShape> = {
112
+ [K in keyof C]: (context: ContextForOp<C[K]>) => MaybePromise<HandlerReturnForOp<C[K]>>
113
+ }
114
+
115
+ const METHOD_SET: ReadonlySet<string> = new Set(METHODS)
116
+
117
+ /**
118
+ * Define a standalone, versionable contract. Identity at runtime (it returns the
119
+ * contract for type inference via the `const` type parameter, which preserves the
120
+ * path/method literals) plus boot-time (L2) validation: each operation must use a
121
+ * known method, a path starting with `/`, and no two operations may share a
122
+ * `(method, path)`. Deeper path validation (param names, wildcard position) runs
123
+ * when the contract is `implement`ed.
124
+ */
125
+ export function defineContract<const C extends ContractShape>(contract: C): C {
126
+ const seen = new Set<string>()
127
+ for (const [name, op] of Object.entries(contract)) {
128
+ const method = op.method.toUpperCase()
129
+ if (!METHOD_SET.has(method)) {
130
+ throw new RouteConfigError(
131
+ "INVALID_METHOD",
132
+ `operation "${name}": unsupported method "${op.method}"`,
133
+ )
134
+ }
135
+ if (op.path.length === 0 || op.path.charCodeAt(0) !== 47 /* "/" */) {
136
+ throw new RouteConfigError(
137
+ "INVALID_PATH",
138
+ `operation "${name}": path must start with "/": "${op.path}"`,
139
+ )
140
+ }
141
+ const key = `${method} ${op.path}`
142
+ if (seen.has(key)) {
143
+ throw new RouteConfigError("DUPLICATE_ROUTE", `duplicate operation route: ${key}`)
144
+ }
145
+ seen.add(key)
146
+ }
147
+ return contract
148
+ }
149
+
150
+ type AnyFn = (...args: never[]) => unknown
151
+
152
+ /**
153
+ * The registry produced by `implement`: input from the contract op; `output` is the declared `response`
154
+ * contract when present (it wins — exactly as in the inline path), else the bound HANDLER's return — so
155
+ * the implemented server stays route-for-route identical to the equivalent inline server (the
156
+ * mode-conformance guarantee), and a contract-typed client and a `typeof app`-typed client agree.
157
+ */
158
+ export type RegistryFromImpl<C extends ContractShape, H extends HandlersFor<C>> = {
159
+ [P in C[keyof C]["path"]]: {
160
+ [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: {
161
+ readonly params: Params<C[K]["path"]>
162
+ readonly query: OpQuery<C[K]>
163
+ readonly body: OpBody<C[K]>
164
+ readonly output: C[K] extends { response: infer R extends StandardSchemaV1 }
165
+ ? InferOutput<R>
166
+ : H[K] extends AnyFn
167
+ ? OutputOf<H[K]>
168
+ : unknown
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Bind handlers to a contract, producing a real {@link Server} you can `.listen()`
175
+ * or `.fetch()`. Each op is registered through the same path as the inline
176
+ * builder, so the result is identical to writing the routes inline — handlers
177
+ * lift over **unchanged** ("graduation"), and body/query schemas validate at the
178
+ * request boundary exactly as in inline mode.
179
+ */
180
+ export function implement<const C extends ContractShape, H extends HandlersFor<C>>(
181
+ contract: C,
182
+ handlers: H,
183
+ ): Server<RegistryFromImpl<C, H>> {
184
+ const app = new Server()
185
+ for (const [name, op] of Object.entries(contract)) {
186
+ // body/query are validated at the request boundary; `response` is a type + introspection contract
187
+ // ONLY — never validated at runtime and never read on the request hot path (the lifecycle reads
188
+ // schema.body/query by name; the `bare`/bodyOnly/queryOnly fast-path gates ignore `response`). Built
189
+ // once here at bind time, not per request — it just carries a reference to the op's existing schema.
190
+ // A response-less op still yields `undefined`, byte-identical to before.
191
+ const schema: RouteSchema | undefined =
192
+ op.body !== undefined || op.query !== undefined || op.response !== undefined
193
+ ? {
194
+ ...(op.body !== undefined ? { body: op.body } : {}),
195
+ ...(op.query !== undefined ? { query: op.query } : {}),
196
+ ...(op.response !== undefined ? { response: op.response } : {}),
197
+ }
198
+ : undefined
199
+ app.register(
200
+ op.method,
201
+ op.path,
202
+ schema,
203
+ handlers[name as keyof H] as (context: never) => unknown,
204
+ )
205
+ }
206
+ // Runtime registered exactly the contract's routes through the inline path; the
207
+ // registry type is computed from the contract inputs + handler return types.
208
+ return app as unknown as Server<RegistryFromImpl<C, H>>
209
+ }