@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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/brand.d.ts +11 -0
- package/dist/internal/brand.d.ts.map +1 -0
- package/dist/internal/brand.js +10 -0
- package/dist/internal/brand.js.map +1 -0
- package/dist/internal/secret.d.ts +9 -0
- package/dist/internal/secret.d.ts.map +1 -0
- package/dist/internal/secret.js +19 -0
- package/dist/internal/secret.js.map +1 -0
- package/dist/router/router.d.ts +61 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +324 -0
- package/dist/router/router.js.map +1 -0
- package/dist/schema/standard.d.ts +46 -0
- package/dist/schema/standard.d.ts.map +1 -0
- package/dist/schema/standard.js +20 -0
- package/dist/schema/standard.js.map +1 -0
- package/dist/seo.d.ts +48 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +109 -0
- package/dist/seo.js.map +1 -0
- package/dist/server/body.d.ts +39 -0
- package/dist/server/body.d.ts.map +1 -0
- package/dist/server/body.js +85 -0
- package/dist/server/body.js.map +1 -0
- package/dist/server/context.d.ts +111 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +2 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/contract.d.ts +142 -0
- package/dist/server/contract.d.ts.map +1 -0
- package/dist/server/contract.js +59 -0
- package/dist/server/contract.js.map +1 -0
- package/dist/server/cookies.d.ts +41 -0
- package/dist/server/cookies.d.ts.map +1 -0
- package/dist/server/cookies.js +181 -0
- package/dist/server/cookies.js.map +1 -0
- package/dist/server/logger.d.ts +49 -0
- package/dist/server/logger.d.ts.map +1 -0
- package/dist/server/logger.js +125 -0
- package/dist/server/logger.js.map +1 -0
- package/dist/server/registry.d.ts +46 -0
- package/dist/server/registry.d.ts.map +1 -0
- package/dist/server/registry.js +2 -0
- package/dist/server/registry.js.map +1 -0
- package/dist/server/server.d.ts +528 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +1660 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/sse.d.ts +54 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +126 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/webhook.d.ts +42 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +149 -0
- package/dist/server/webhook.js.map +1 -0
- package/dist/server/websocket.d.ts +152 -0
- package/dist/server/websocket.d.ts.map +1 -0
- package/dist/server/websocket.js +178 -0
- package/dist/server/websocket.js.map +1 -0
- package/dist/server/ws-bun.d.ts +27 -0
- package/dist/server/ws-bun.d.ts.map +1 -0
- package/dist/server/ws-bun.js +82 -0
- package/dist/server/ws-bun.js.map +1 -0
- package/dist/server/ws-hook.d.ts +36 -0
- package/dist/server/ws-hook.d.ts.map +1 -0
- package/dist/server/ws-hook.js +18 -0
- package/dist/server/ws-hook.js.map +1 -0
- package/dist/ws.d.ts +19 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +26 -0
- package/dist/ws.js.map +1 -0
- package/package.json +63 -0
- package/src/errors.ts +41 -0
- package/src/index.ts +115 -0
- package/src/internal/brand.ts +11 -0
- package/src/internal/secret.ts +24 -0
- package/src/router/router.ts +429 -0
- package/src/schema/standard.ts +76 -0
- package/src/seo.ts +155 -0
- package/src/server/body.ts +89 -0
- package/src/server/context.ts +124 -0
- package/src/server/contract.ts +209 -0
- package/src/server/cookies.ts +198 -0
- package/src/server/logger.ts +167 -0
- package/src/server/registry.ts +63 -0
- package/src/server/server.ts +2479 -0
- package/src/server/sse.ts +161 -0
- package/src/server/webhook.ts +207 -0
- package/src/server/websocket.ts +308 -0
- package/src/server/ws-bun.ts +106 -0
- package/src/server/ws-hook.ts +53 -0
- 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
|
+
"&": "&",
|
|
18
|
+
"<": "<",
|
|
19
|
+
">": ">",
|
|
20
|
+
'"': """,
|
|
21
|
+
"'": "'",
|
|
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
|
+
}
|