@nifrajs/core 1.13.0 → 2.0.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 +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +107 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +214 -831
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +77 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -133
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +402 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin definers and their types. A plugin is a function that augments an app (calling
|
|
3
|
+
* `use`/`derive`/`decorate` and/or registering routes) and returns it; these helpers attach a name
|
|
4
|
+
* for idempotent dedupe and pin the type-threading so `.use()` preserves the caller's typed server.
|
|
5
|
+
*/
|
|
6
|
+
import type { AnyServer } from "./server.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A nifra **plugin**: a function that augments an app - calling `use`/`derive`/`decorate` and/or
|
|
10
|
+
* registering routes - and returns it. Because `derive`/`decorate` are type-threaded, an **inline**
|
|
11
|
+
* `app.use((a) => a.derive(...).decorate(...))` carries the added context to handlers defined after
|
|
12
|
+
* it (the `use` overload is generic over the concrete `this`). Wrap with {@link definePlugin} to
|
|
13
|
+
* attach a name for idempotent dedupe (applying the same named plugin twice - e.g. transitively - is
|
|
14
|
+
* a no-op).
|
|
15
|
+
*/
|
|
16
|
+
export type NifraPlugin<In extends AnyServer = AnyServer, Out extends AnyServer = In> = ((
|
|
17
|
+
app: In,
|
|
18
|
+
) => Out) & { readonly pluginName?: string }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A named type-identity plugin built with {@link defineIdentityPlugin}. It returns the same concrete
|
|
22
|
+
* server type it receives, preserving the caller's typed registry and context across `.use()` while
|
|
23
|
+
* still allowing the plugin to register runtime hooks or handlers.
|
|
24
|
+
*/
|
|
25
|
+
export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
|
|
26
|
+
readonly pluginName?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Name + ergonomics for a plugin that **adds typed context** (`derive`/`decorate`). `app.use(myPlugin)`
|
|
31
|
+
* applies it once; a second `use` of the same name is skipped (idempotent), so plugins can depend on each
|
|
32
|
+
* other without double-registering hooks.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
|
|
36
|
+
* app.use(requestId) // downstream handlers see c.requestId
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
|
|
40
|
+
* NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
|
|
41
|
+
* `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
|
|
42
|
+
* and your whole typed client silently collapses to `any` - no type error, no runtime error.
|
|
43
|
+
*/
|
|
44
|
+
export function definePlugin<In extends AnyServer, Out extends AnyServer>(
|
|
45
|
+
name: string,
|
|
46
|
+
apply: (app: In) => Out,
|
|
47
|
+
): NifraPlugin<In, Out> {
|
|
48
|
+
return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Define a type-**identity** plugin: it registers routes/hooks as a side effect but returns the app with
|
|
53
|
+
* its `Registry` + `Context` UNCHANGED. Use this (not {@link definePlugin}) for any plugin that doesn't
|
|
54
|
+
* add context types - e.g. one mounting an auth handler. It threads the caller's *concrete* server type
|
|
55
|
+
* through `use`, so routes declared after `app.use(plugin)` keep their types.
|
|
56
|
+
*
|
|
57
|
+
* Why a dedicated helper: `definePlugin((app) => app)` infers `app: Server<any, any>`, so `use` returns
|
|
58
|
+
* `Server<any, any>` and the whole typed client collapses to `any`. The explicit generic return type here
|
|
59
|
+
* (which a plain `Object.assign` can't preserve) is what keeps `use` returning the precise server type.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* export const audit = defineIdentityPlugin("audit", (app) => app.onResponse(logResponse))
|
|
63
|
+
* const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function defineIdentityPlugin(
|
|
67
|
+
name: string,
|
|
68
|
+
apply: <S extends AnyServer>(app: S) => S,
|
|
69
|
+
): IdentityPlugin {
|
|
70
|
+
return Object.assign(apply, { pluginName: name }) as IdentityPlugin
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Alias of {@link defineIdentityPlugin} with a name that says what it's FOR: a plugin that **mounts
|
|
75
|
+
* routes/hooks but adds no context type** (an auth router, an audit logger). Use this - not
|
|
76
|
+
* {@link definePlugin} - for any such plugin, or the typed client silently collapses to `any`. The
|
|
77
|
+
* "identity" in {@link defineIdentityPlugin} refers to the type-identity it preserves; `defineRouterPlugin`
|
|
78
|
+
* is the same thing under a clearer name.
|
|
79
|
+
*
|
|
80
|
+
* Mount routes as a **side effect**, then return the app unchanged (registering via `.get`/`.post` would
|
|
81
|
+
* change the type away from the identity `S`; the mounted routes run but aren't in the caller's typed
|
|
82
|
+
* registry - that's the trade that keeps everything else typed):
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* export const scim = defineRouterPlugin("scim", (app) => {
|
|
86
|
+
* app.get("/scim/v2/Users", listUsers) // side effect: mounted at runtime
|
|
87
|
+
* return app // return S unchanged → routes added after .use(scim) stay typed
|
|
88
|
+
* })
|
|
89
|
+
* const api = server().get("/a", h).use(scim).get("/b", h) // /a AND /b stay typed
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export const defineRouterPlugin: typeof defineIdentityPlugin = defineIdentityPlugin
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-string and urlencoded-form parsing. Pure request-shaping helpers with no server internals:
|
|
3
|
+
* the fast scanner for `c.query`, the repeated-key -> `string[]` promotion shared by query + form,
|
|
4
|
+
* and the byte-capped HTML-form reader. Split out of the server kernel so the file stays one domain.
|
|
5
|
+
*/
|
|
6
|
+
import { readBoundedBytes } from "./body.ts"
|
|
7
|
+
import { jsonError, urlPartsOf } from "./http.ts"
|
|
8
|
+
import type { RequestSource } from "./server.ts"
|
|
9
|
+
|
|
10
|
+
// The query string ("?a=1", or "" when absent) - for lazily building `c.query` only when read.
|
|
11
|
+
// The fragment (after the first `#`) bounds the search: a `?` that appears only inside the fragment
|
|
12
|
+
// is NOT a query (matches WHATWG). Fragments never reach the server in `req.url`, so this is purely
|
|
13
|
+
// for provable equivalence with `new URL(req.url).search`.
|
|
14
|
+
export function searchOf(url: string): string {
|
|
15
|
+
return urlPartsOf(url).search
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A query value: a single occurrence is a string; a repeated key promotes to a string[] so an
|
|
19
|
+
* array query schema (`t.array(t.string())`) can validate `?tag=a&tag=b` - last-wins silently
|
|
20
|
+
* dropped values before (audit 2026-06). Single-occurrence keys stay plain strings, so existing
|
|
21
|
+
* `t.string()` schemas are untouched; a repeated key against a string schema now FAILS validation
|
|
22
|
+
* (an explicit 400 beats silently picking one). */
|
|
23
|
+
export type QueryValue = string | string[]
|
|
24
|
+
|
|
25
|
+
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
26
|
+
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key - the
|
|
27
|
+
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
28
|
+
* case (direct assignment on a null-proto object creates an own property). */
|
|
29
|
+
function setQueryValue(out: Record<string, QueryValue>, key: string, value: string): void {
|
|
30
|
+
const existing = out[key]
|
|
31
|
+
if (existing === undefined) {
|
|
32
|
+
out[key] = value
|
|
33
|
+
} else if (typeof existing === "string") {
|
|
34
|
+
out[key] = [existing, value]
|
|
35
|
+
} else {
|
|
36
|
+
existing.push(value)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function queryObjectFallback(search: string): Record<string, QueryValue> {
|
|
41
|
+
// Manual iteration instead of Object.fromEntries: repeated keys must promote to arrays
|
|
42
|
+
// (fromEntries is last-wins), and __proto__ needs the same own-property guard.
|
|
43
|
+
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
44
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
45
|
+
setQueryValue(out, key, value)
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the plain object passed to query schemas. Plain ASCII-ish queries avoid
|
|
52
|
+
* `URLSearchParams` + iterator allocations; encoded queries fall back to the Web API so `+`,
|
|
53
|
+
* percent-decoding, malformed escapes, and empty-key behavior stay exact. Repeated keys promote
|
|
54
|
+
* to `string[]` on BOTH paths (see {@link setQueryValue}).
|
|
55
|
+
*/
|
|
56
|
+
// Shared empty result for no-query requests: frozen + null-prototype (same shape contract as the
|
|
57
|
+
// populated path), allocated once instead of per request.
|
|
58
|
+
const EMPTY_QUERY = Object.freeze(Object.create(null)) as Record<string, QueryValue>
|
|
59
|
+
|
|
60
|
+
export function queryObjectOf(search: string): Record<string, QueryValue> {
|
|
61
|
+
const start = search.charCodeAt(0) === 63 /* ? */ ? 1 : 0
|
|
62
|
+
if (start >= search.length) return EMPTY_QUERY
|
|
63
|
+
|
|
64
|
+
for (let i = start; i < search.length; i++) {
|
|
65
|
+
const c = search.charCodeAt(i)
|
|
66
|
+
if (c === 37 /* % */ || c === 43 /* + */) return queryObjectFallback(search)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
70
|
+
let pos = start
|
|
71
|
+
while (pos <= search.length) {
|
|
72
|
+
const amp = search.indexOf("&", pos)
|
|
73
|
+
const end = amp === -1 ? search.length : amp
|
|
74
|
+
if (end > pos) {
|
|
75
|
+
const eq = search.indexOf("=", pos)
|
|
76
|
+
const split = eq !== -1 && eq < end ? eq : end
|
|
77
|
+
const key = search.slice(pos, split)
|
|
78
|
+
const value = split === end ? "" : search.slice(split + 1, end)
|
|
79
|
+
setQueryValue(out, key, value)
|
|
80
|
+
}
|
|
81
|
+
if (amp === -1) break
|
|
82
|
+
pos = amp + 1
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `application/x-www-form-urlencoded`, with or without a charset suffix. */
|
|
88
|
+
export function isUrlEncodedForm(contentType: string): boolean {
|
|
89
|
+
return (
|
|
90
|
+
contentType === "application/x-www-form-urlencoded" ||
|
|
91
|
+
contentType.startsWith("application/x-www-form-urlencoded;")
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const FORM_DECODER = new TextDecoder()
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read an HTML-form body (urlencoded) into the plain object a body schema validates - same byte
|
|
99
|
+
* cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
|
|
100
|
+
* repeated-key -> `string[]` promotion as query parsing, same `__proto__` guard.
|
|
101
|
+
*/
|
|
102
|
+
export async function readBoundedForm(
|
|
103
|
+
req: RequestSource,
|
|
104
|
+
maxBytes: number,
|
|
105
|
+
): Promise<Record<string, QueryValue> | Response> {
|
|
106
|
+
const read = await readBoundedBytes(req, maxBytes)
|
|
107
|
+
if (!read.ok) {
|
|
108
|
+
return read.status === 413
|
|
109
|
+
? jsonError(413, "payload_too_large")
|
|
110
|
+
: jsonError(400, "invalid_content_length")
|
|
111
|
+
}
|
|
112
|
+
const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
|
|
113
|
+
// URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
|
|
114
|
+
// it never throws on junk input, so no try/catch is needed here.
|
|
115
|
+
for (const [key, value] of new URLSearchParams(FORM_DECODER.decode(read.bytes))) {
|
|
116
|
+
setQueryValue(out, key, value)
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
package/src/server/registry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
|
|
1
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
|
|
2
2
|
import type { Params, RouteSchema } from "./context.ts"
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -16,12 +16,17 @@ export interface RouteInfo {
|
|
|
16
16
|
readonly query: unknown
|
|
17
17
|
readonly body: unknown
|
|
18
18
|
readonly output: unknown
|
|
19
|
-
/**
|
|
20
|
-
* route declares none.
|
|
19
|
+
/** The route's declared error bodies as a status-keyed record (from `schema.errors`, e.g.
|
|
20
|
+
* `{ 404: NotFound }`); `unknown` when the route declares none. The typed client turns this into
|
|
21
|
+
* a status-discriminated failure union. */
|
|
21
22
|
readonly errors?: unknown
|
|
22
23
|
/** The SSE event payload type (from `schema.sse`, declared via `app.sse()`); `never` for ordinary
|
|
23
24
|
* routes. The typed client keys `.subscribe()` availability and its event type off this. */
|
|
24
25
|
readonly sse?: unknown
|
|
26
|
+
/** The WebSocket message contract (from `app.ws()`'s `messageSchema`/`sendSchema`): `in` is what the
|
|
27
|
+
* client may send, `out` what the server pushes. Present only on WS entries (method key `"WS"`);
|
|
28
|
+
* the typed client keys `.ws()` availability and its frame types off this. */
|
|
29
|
+
readonly ws?: unknown
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** The accumulated, type-level map of every route on a Server: path → method → RouteInfo. */
|
|
@@ -46,12 +51,13 @@ type RegistryOutput<S extends RouteSchema, Output> = S extends {
|
|
|
46
51
|
? InferOutput<R>
|
|
47
52
|
: Output
|
|
48
53
|
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
54
|
+
/** A route's declared error-response body types as a STATUS-KEYED record (`schema.errors` →
|
|
55
|
+
* `{ 404: NotFound, 409: Conflict }`), so the typed client can discriminate the failure body by
|
|
56
|
+
* `status`, not just surface a union. `unknown` when the route declares no `errors`. */
|
|
51
57
|
type RegistryErrors<S extends RouteSchema> = S extends {
|
|
52
58
|
errors: infer E extends Record<number, StandardSchemaV1>
|
|
53
59
|
}
|
|
54
|
-
? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
|
|
60
|
+
? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
|
|
55
61
|
: unknown
|
|
56
62
|
|
|
57
63
|
/** The SSE event payload type from a route's `sse` schema; `never` for ordinary routes. */
|
|
@@ -75,6 +81,30 @@ export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
|
|
|
75
81
|
Response
|
|
76
82
|
>
|
|
77
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The registry entry for a WebSocket route (stored under the pseudo-method key `"WS"`, so WS routes
|
|
86
|
+
* ride the same path → method map as HTTP routes - no extra Server generic). `in` is the client →
|
|
87
|
+
* server frame type: the `messageSchema`'s INPUT side, since that is what goes on the wire before
|
|
88
|
+
* validation transforms it. `out` is the server → client frame type from `sendSchema` (a type-level
|
|
89
|
+
* contract; the server's own sends are not runtime-validated). Either is `unknown` when undeclared.
|
|
90
|
+
*/
|
|
91
|
+
export type WsRouteInfoFor<
|
|
92
|
+
Path extends string,
|
|
93
|
+
In extends StandardSchemaV1 | undefined,
|
|
94
|
+
Out extends StandardSchemaV1 | undefined,
|
|
95
|
+
> = {
|
|
96
|
+
readonly params: Params<Path>
|
|
97
|
+
readonly query: never
|
|
98
|
+
readonly body: never
|
|
99
|
+
readonly output: never
|
|
100
|
+
readonly errors: unknown
|
|
101
|
+
readonly sse: never
|
|
102
|
+
readonly ws: {
|
|
103
|
+
readonly in: In extends StandardSchemaV1 ? InferInput<In> : unknown
|
|
104
|
+
readonly out: Out extends StandardSchemaV1 ? InferOutput<Out> : unknown
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
78
108
|
/**
|
|
79
109
|
* Merge a new route into the registry, combining methods that share a path.
|
|
80
110
|
*
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime request context (`c`): the `RawContext` implementation the handler receives, its lazy
|
|
3
|
+
* `c.set` response-controls backing, and the bounded body readers behind `c.boundedBody`/`c.boundedJson`.
|
|
4
|
+
* Type-imports the kernel's spine (never its values) and pulls its primitives from `runtime-core`, so it
|
|
5
|
+
* sits below the server module in the graph.
|
|
6
|
+
*/
|
|
7
|
+
import type { RequestBudget } from "../budget.ts"
|
|
8
|
+
import { drainCapped, parseContentLength, readBoundedBytes } from "./body.ts"
|
|
9
|
+
import type { Platform } from "./context.ts"
|
|
10
|
+
import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
|
|
11
|
+
import { jsonError } from "./http.ts"
|
|
12
|
+
import { searchOf } from "./query.ts"
|
|
13
|
+
import {
|
|
14
|
+
CONTEXT_SEARCH,
|
|
15
|
+
CONTEXT_SET,
|
|
16
|
+
fallbackWaitUntil,
|
|
17
|
+
getNeverAbortSignal,
|
|
18
|
+
getUnboundedRequestBudget,
|
|
19
|
+
headerOf,
|
|
20
|
+
requestOf,
|
|
21
|
+
TEXT_DECODER,
|
|
22
|
+
} from "./runtime-core.ts"
|
|
23
|
+
import type { CtxSet, RawContext, RequestSource } from "./server.ts"
|
|
24
|
+
|
|
25
|
+
/** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
|
|
26
|
+
* argless `new Date()`. */
|
|
27
|
+
const EPOCH = new Date(0)
|
|
28
|
+
|
|
29
|
+
class LazyResponseControls implements CtxSet {
|
|
30
|
+
status?: number
|
|
31
|
+
_headers?: Record<string, string>
|
|
32
|
+
_cookies?: string[]
|
|
33
|
+
|
|
34
|
+
get headers(): Record<string, string> {
|
|
35
|
+
this._headers ??= {}
|
|
36
|
+
return this._headers
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
cookie(name: string, value: string, options?: CookieOptions): void {
|
|
40
|
+
// Secure-by-default: HttpOnly + Secure + SameSite=Lax + Path=/, overridable per call.
|
|
41
|
+
const merged: CookieOptions = {
|
|
42
|
+
httpOnly: true,
|
|
43
|
+
secure: true,
|
|
44
|
+
sameSite: "lax",
|
|
45
|
+
path: "/",
|
|
46
|
+
...options,
|
|
47
|
+
}
|
|
48
|
+
this._cookies ??= []
|
|
49
|
+
this._cookies.push(serializeCookie(name, value, merged))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
53
|
+
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
54
|
+
this._cookies ??= []
|
|
55
|
+
this._cookies.push(
|
|
56
|
+
serializeCookie(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Coerce `c.json`/`c.text`'s second arg - a status number (the common case) or a full `ResponseInit`. */
|
|
62
|
+
function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
|
|
63
|
+
return typeof init === "number" ? { status: init } : init
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class RequestContext implements RawContext {
|
|
67
|
+
// `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
|
|
68
|
+
// constructor initializes only the eager request state, while lazy fields remain absent until used.
|
|
69
|
+
// Not `readonly`: the lifecycle replaces it with the validated/coerced value when a `params` schema is
|
|
70
|
+
// declared (`c.params` stays `readonly` to handlers via the `Context` interface).
|
|
71
|
+
declare params: Record<string, string>
|
|
72
|
+
declare body: unknown
|
|
73
|
+
private declare searchValue: string | undefined
|
|
74
|
+
private declare signalValue: AbortSignal | undefined
|
|
75
|
+
private declare budgetValue: RequestBudget | undefined
|
|
76
|
+
private declare platformValue: Platform | undefined
|
|
77
|
+
|
|
78
|
+
private declare setValue: CtxSet | undefined
|
|
79
|
+
private declare queryValue: unknown
|
|
80
|
+
private declare queryReady: boolean
|
|
81
|
+
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
82
|
+
private declare readonly source: RequestSource
|
|
83
|
+
private declare readonly maxBodyBytes: number
|
|
84
|
+
|
|
85
|
+
constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
|
|
86
|
+
constructor(
|
|
87
|
+
source: RequestSource,
|
|
88
|
+
params: Record<string, string>,
|
|
89
|
+
search: string | undefined,
|
|
90
|
+
signal: AbortSignal,
|
|
91
|
+
budget: RequestBudget,
|
|
92
|
+
platform: Platform | undefined,
|
|
93
|
+
maxBodyBytes: number,
|
|
94
|
+
)
|
|
95
|
+
constructor(
|
|
96
|
+
source: RequestSource,
|
|
97
|
+
params: Record<string, string>,
|
|
98
|
+
searchOrMaxBodyBytes: string | number | undefined,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
budget?: RequestBudget,
|
|
101
|
+
platform?: Platform,
|
|
102
|
+
maxBodyBytes?: number,
|
|
103
|
+
) {
|
|
104
|
+
this.source = source
|
|
105
|
+
this.params = params
|
|
106
|
+
if (typeof searchOrMaxBodyBytes === "number") {
|
|
107
|
+
this.maxBodyBytes = searchOrMaxBodyBytes
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
|
|
111
|
+
this.signalValue = signal
|
|
112
|
+
this.budgetValue = budget
|
|
113
|
+
if (platform !== undefined) this.platformValue = platform
|
|
114
|
+
this.maxBodyBytes = maxBodyBytes as number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static native(
|
|
118
|
+
source: RequestSource,
|
|
119
|
+
params: Record<string, string>,
|
|
120
|
+
maxBodyBytes: number,
|
|
121
|
+
): RequestContext {
|
|
122
|
+
return new RequestContext(source, params, maxBodyBytes)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
[CONTEXT_SET](): CtxSet | undefined {
|
|
126
|
+
return this.setValue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get [CONTEXT_SEARCH](): string {
|
|
130
|
+
this.searchValue ??= searchOf(this.source.url)
|
|
131
|
+
return this.searchValue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get set(): CtxSet {
|
|
135
|
+
this.setValue ??= new LazyResponseControls()
|
|
136
|
+
return this.setValue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get signal(): AbortSignal {
|
|
140
|
+
return this.signalValue ?? getNeverAbortSignal()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get budget(): RequestBudget {
|
|
144
|
+
return this.budgetValue ?? getUnboundedRequestBudget()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get env(): unknown {
|
|
148
|
+
return this.platformValue?.env
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get clientIp(): string | undefined {
|
|
152
|
+
// The server resolves the trust declaration into `platform.clientIp` before the context is built,
|
|
153
|
+
// so this getter just surfaces the already-derived value (raw socket peer by default).
|
|
154
|
+
return this.platformValue?.clientIp
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get waitUntil(): (promise: Promise<unknown>) => void {
|
|
158
|
+
return this.platformValue?.waitUntil ?? fallbackWaitUntil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get req(): Request {
|
|
162
|
+
return requestOf(this.source)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get request(): Request {
|
|
166
|
+
return requestOf(this.source)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
json(body: unknown, init?: ResponseInit | number): Response {
|
|
170
|
+
return Response.json(body, statusInit(init))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
text(body: string, init?: ResponseInit | number): Response {
|
|
174
|
+
const i = statusInit(init)
|
|
175
|
+
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
176
|
+
if (i?.headers !== undefined) return new Response(body, i)
|
|
177
|
+
return new Response(body, { ...i, headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get query(): unknown {
|
|
181
|
+
if (!this.queryReady) {
|
|
182
|
+
this.queryValue = new URLSearchParams(this[CONTEXT_SEARCH])
|
|
183
|
+
this.queryReady = true
|
|
184
|
+
}
|
|
185
|
+
return this.queryValue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
set query(v: unknown) {
|
|
189
|
+
this.queryValue = v
|
|
190
|
+
this.queryReady = true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get cookies(): Readonly<Record<string, string>> {
|
|
194
|
+
this.cookiesValue ??= parseCookies(headerOf(this.source, "cookie"))
|
|
195
|
+
return this.cookiesValue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
boundedBody(maxBytes?: number): Promise<Uint8Array> {
|
|
199
|
+
return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
|
|
203
|
+
return readBoundedJsonBodyOrThrow<T>(this.source, this.maxBodyBytes, maxBytes)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
|
|
208
|
+
* `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
|
|
209
|
+
* The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
|
|
210
|
+
async function readBoundedBodyOrThrow(
|
|
211
|
+
req: RequestSource,
|
|
212
|
+
maxBodyBytes: number,
|
|
213
|
+
maxBytes?: number,
|
|
214
|
+
): Promise<Uint8Array> {
|
|
215
|
+
const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
|
|
216
|
+
if (r.ok) return r.bytes
|
|
217
|
+
throw r.status === 413
|
|
218
|
+
? jsonError(413, "payload_too_large")
|
|
219
|
+
: jsonError(400, "invalid_content_length")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Backs `c.boundedJson`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
|
|
223
|
+
async function readBoundedJsonBodyOrThrow<T>(
|
|
224
|
+
req: RequestSource,
|
|
225
|
+
maxBodyBytes: number,
|
|
226
|
+
maxBytes?: number,
|
|
227
|
+
): Promise<T> {
|
|
228
|
+
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
|
|
229
|
+
if (parsed instanceof Response) throw parsed
|
|
230
|
+
return parsed as T
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`.
|
|
235
|
+
* Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
|
|
236
|
+
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
237
|
+
*/
|
|
238
|
+
export async function readBoundedJsonSource(
|
|
239
|
+
req: RequestSource,
|
|
240
|
+
maxBytes: number,
|
|
241
|
+
): Promise<unknown | Response> {
|
|
242
|
+
const declared = headerOf(req, "content-length")
|
|
243
|
+
if (declared !== null) {
|
|
244
|
+
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
245
|
+
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
246
|
+
// "abc"->NaN, "-5", "1.5", "1e3", "0x10") is malformed -> 400, rather than silently falling
|
|
247
|
+
// through to the streaming guard - which is an UPPER-bound cap only, so a lying SMALLER length
|
|
248
|
+
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
249
|
+
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
250
|
+
const length = parseContentLength(declared)
|
|
251
|
+
if (length === undefined) return jsonError(400, "invalid_content_length")
|
|
252
|
+
if (length > maxBytes) return jsonError(413, "payload_too_large")
|
|
253
|
+
const chunked = headerOf(req, "transfer-encoding") !== null
|
|
254
|
+
if (!chunked) {
|
|
255
|
+
try {
|
|
256
|
+
return await req.json()
|
|
257
|
+
} catch {
|
|
258
|
+
return jsonError(400, "invalid_json")
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const body = req.body
|
|
263
|
+
if (body === null) return jsonError(400, "invalid_json")
|
|
264
|
+
const drained = await drainCapped(body, maxBytes)
|
|
265
|
+
if (!drained.ok) return jsonError(413, "payload_too_large")
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(TEXT_DECODER.decode(drained.bytes))
|
|
268
|
+
} catch {
|
|
269
|
+
return jsonError(400, "invalid_json")
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response construction from a handler's return value: the header/cookie init, the fused-lane
|
|
3
|
+
* responders, the fast JSON path, and cookie append. Imports only the runtime-core primitives + the
|
|
4
|
+
* spine types, so the wire-response format lives in one module distinct from the request engine.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
CONTEXT_SET,
|
|
8
|
+
EMPTY_RESPONSE_CONTROLS,
|
|
9
|
+
type HandlerResult,
|
|
10
|
+
isResponseResult,
|
|
11
|
+
} from "./runtime-core.ts"
|
|
12
|
+
import type { CtxSet, RawContext } from "./server.ts"
|
|
13
|
+
|
|
14
|
+
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
15
|
+
* gets no `headers` at all. Cookies force a `Headers` object - multiple `Set-Cookie`s can't live in a
|
|
16
|
+
* `Record<string,string>` (the 2nd would overwrite the 1st), so they're `append`ed individually. */
|
|
17
|
+
function headersInit(set: CtxSet): Record<string, string> | Headers | undefined {
|
|
18
|
+
const cookies = set._cookies
|
|
19
|
+
if (cookies === undefined || cookies.length === 0) return set._headers
|
|
20
|
+
const headers = new Headers(set._headers)
|
|
21
|
+
for (const cookie of cookies) headers.append("set-cookie", cookie)
|
|
22
|
+
return headers
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
26
|
+
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
27
|
+
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
28
|
+
const JSON_CT_HEADERS = new Headers({
|
|
29
|
+
"content-type": "application/json;charset=utf-8",
|
|
30
|
+
})
|
|
31
|
+
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
32
|
+
|
|
33
|
+
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
34
|
+
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
35
|
+
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
36
|
+
export function fusedRespondNoSet(result: unknown): Response {
|
|
37
|
+
if (
|
|
38
|
+
result !== undefined &&
|
|
39
|
+
!(result instanceof Response) &&
|
|
40
|
+
typeof result === "object" &&
|
|
41
|
+
result !== null &&
|
|
42
|
+
!isResponseResult(result)
|
|
43
|
+
) {
|
|
44
|
+
return Response.json(result)
|
|
45
|
+
}
|
|
46
|
+
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) -> the fast
|
|
50
|
+
* JSON respond; touched -> the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
51
|
+
export function fusedRespond(result: unknown, ctx: RawContext): Response {
|
|
52
|
+
const set = ctx[CONTEXT_SET]()
|
|
53
|
+
if (set === undefined) return fusedRespondNoSet(result)
|
|
54
|
+
return toResponse(result as HandlerResult, set)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toResponse(result: HandlerResult, set: CtxSet): Response {
|
|
58
|
+
if (isResponseResult(result)) {
|
|
59
|
+
return appendCookiesToResponse(result.toResponse(), set)
|
|
60
|
+
}
|
|
61
|
+
if (result instanceof Response) {
|
|
62
|
+
return appendCookiesToResponse(result, set)
|
|
63
|
+
}
|
|
64
|
+
const headers = headersInit(set)
|
|
65
|
+
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
66
|
+
if (headers === undefined && result !== undefined) {
|
|
67
|
+
// Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
|
|
68
|
+
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
|
|
69
|
+
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
70
|
+
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
71
|
+
const body = JSON.stringify(result) as string | undefined
|
|
72
|
+
if (body !== undefined) {
|
|
73
|
+
return new Response(
|
|
74
|
+
body,
|
|
75
|
+
status === 200 ? JSON_INIT_200 : { status, headers: JSON_CT_HEADERS },
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const init: ResponseInit = headers === undefined ? { status } : { status, headers }
|
|
80
|
+
return result === undefined ? new Response(null, init) : Response.json(result, init)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function appendCookiesToResponse(response: Response, set: CtxSet): Response {
|
|
84
|
+
// A handler may queue cookies (`c.set.cookie` - e.g. a session cookie) AND return its own Response
|
|
85
|
+
// (e.g. `redirect("/")` after login). Cookies accumulate additively, so append them to the
|
|
86
|
+
// returned Response - otherwise the canonical set-session-then-redirect pattern would silently drop
|
|
87
|
+
// the cookie. (Other `c.set` fields stay the returned Response's own concern.)
|
|
88
|
+
const cookies = set._cookies
|
|
89
|
+
if (cookies !== undefined && cookies.length > 0) {
|
|
90
|
+
for (const cookie of cookies) response.headers.append("set-cookie", cookie)
|
|
91
|
+
}
|
|
92
|
+
return response
|
|
93
|
+
}
|