@nifrajs/core 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/assurance.d.ts +48 -4
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +87 -9
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +54 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +90 -0
- package/dist/binary.js.map +1 -0
- package/dist/budget.d.ts +1 -1
- package/dist/budget.js +1 -1
- package/dist/capabilities.d.ts +19 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +60 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +1 -1
- package/dist/classification.js +1 -1
- package/dist/diff.d.ts +6 -6
- package/dist/diff.js +6 -6
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +36 -70
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts +2 -2
- package/dist/durable-execution.js +2 -2
- package/dist/idempotency.d.ts +3 -3
- package/dist/idempotency.js +4 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/internal/brand.d.ts +1 -1
- package/dist/internal/brand.js +1 -1
- package/dist/internal/route-catalog.d.ts +58 -0
- package/dist/internal/route-catalog.d.ts.map +1 -0
- package/dist/internal/route-catalog.js +58 -0
- package/dist/internal/route-catalog.js.map +1 -0
- package/dist/internal/route-execution.d.ts +75 -0
- package/dist/internal/route-execution.d.ts.map +1 -0
- package/dist/internal/route-execution.js +2 -0
- package/dist/internal/route-execution.js.map +1 -0
- package/dist/internal/same-origin.d.ts +29 -0
- package/dist/internal/same-origin.d.ts.map +1 -0
- package/dist/internal/same-origin.js +47 -0
- package/dist/internal/same-origin.js.map +1 -0
- package/dist/internal/secret.d.ts +1 -1
- package/dist/internal/secret.js +1 -1
- package/dist/ledger.d.ts +9 -9
- package/dist/ledger.js +5 -5
- package/dist/reflection.d.ts +1 -1
- package/dist/router/pattern.d.ts +26 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +164 -8
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +52 -4
- package/dist/router/router.js.map +1 -1
- package/dist/schema/standard.d.ts +2 -2
- package/dist/schema/standard.js +2 -2
- package/dist/seo.d.ts +3 -3
- package/dist/seo.js +4 -4
- package/dist/server/body.d.ts +2 -2
- package/dist/server/body.js +2 -2
- package/dist/server/context.d.ts +18 -18
- package/dist/server/contract.d.ts +7 -7
- package/dist/server/contract.js +3 -3
- package/dist/server/cookies.d.ts +4 -4
- package/dist/server/cookies.js +8 -8
- package/dist/server/edge.d.ts +83 -0
- package/dist/server/edge.d.ts.map +1 -0
- package/dist/server/edge.js +71 -0
- package/dist/server/edge.js.map +1 -0
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts +7 -7
- package/dist/server/logger.js +4 -4
- package/dist/server/registry.d.ts +5 -5
- package/dist/server/response-contract-lane.d.ts +91 -0
- package/dist/server/response-contract-lane.d.ts.map +1 -0
- package/dist/server/response-contract-lane.js +81 -0
- package/dist/server/response-contract-lane.js.map +1 -0
- package/dist/server/server.d.ts +52 -91
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +130 -186
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +4 -4
- package/dist/server/sse.js +7 -7
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.js +5 -5
- package/dist/server/websocket.d.ts +19 -19
- package/dist/server/websocket.js +9 -9
- package/dist/server/ws-bun.d.ts +4 -4
- package/dist/server/ws-bun.js +3 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +13 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +48 -9
- package/dist/transport-codec.js.map +1 -1
- package/package.json +12 -2
- package/src/assurance.ts +154 -8
- package/src/binary.ts +128 -0
- package/src/budget.ts +1 -1
- package/src/capabilities.ts +71 -9
- package/src/classification.ts +1 -1
- package/src/diff.ts +8 -8
- package/src/durable-adapters.ts +40 -70
- package/src/durable-execution.ts +2 -2
- package/src/idempotency.ts +4 -4
- package/src/index.ts +3 -3
- package/src/internal/brand.ts +1 -1
- package/src/internal/route-catalog.ts +94 -0
- package/src/internal/route-execution.ts +106 -0
- package/src/internal/same-origin.ts +43 -0
- package/src/internal/secret.ts +1 -1
- package/src/ledger.ts +11 -11
- package/src/reflection.ts +1 -1
- package/src/router/pattern.ts +179 -8
- package/src/router/router.ts +80 -6
- package/src/schema/standard.ts +2 -2
- package/src/seo.ts +6 -6
- package/src/server/body.ts +2 -2
- package/src/server/context.ts +18 -18
- package/src/server/contract.ts +13 -13
- package/src/server/cookies.ts +8 -8
- package/src/server/edge.ts +150 -0
- package/src/server/install.ts +5 -0
- package/src/server/logger.ts +8 -8
- package/src/server/registry.ts +5 -5
- package/src/server/response-contract-lane.ts +164 -0
- package/src/server/server.ts +159 -366
- package/src/server/sse.ts +11 -11
- package/src/server/webhook.ts +5 -5
- package/src/server/websocket.ts +22 -22
- package/src/server/ws-bun.ts +4 -4
- package/src/server.ts +8 -5
- package/src/transport-codec.ts +63 -10
package/src/router/router.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { RouteConfigError } from "../errors.ts"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type CompiledRoutePattern,
|
|
4
|
+
compareMixedPartsSpecificity,
|
|
5
|
+
compileRoutePattern,
|
|
6
|
+
type MixedPart,
|
|
7
|
+
mixedSegmentSource,
|
|
8
|
+
} from "./pattern.ts"
|
|
3
9
|
|
|
4
10
|
/** HTTP methods the router accepts. */
|
|
5
11
|
export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
|
|
@@ -29,7 +35,7 @@ export type RouterMatch<T> =
|
|
|
29
35
|
/**
|
|
30
36
|
* Payload at a terminal node: per-method handlers plus the ordered param names
|
|
31
37
|
* for this path shape. Bundling them means "a node that has handlers always has
|
|
32
|
-
* param names" is enforced by the type, not by a runtime invariant
|
|
38
|
+
* param names" is enforced by the type, not by a runtime invariant - which
|
|
33
39
|
* removes the otherwise-dead defensive branches in `find`.
|
|
34
40
|
*/
|
|
35
41
|
interface Terminal<T> {
|
|
@@ -68,16 +74,43 @@ interface RouteNode<T> {
|
|
|
68
74
|
readonly staticChildren: Map<string, RouteNode<T>>
|
|
69
75
|
/** Single `:param` child, if any. */
|
|
70
76
|
paramChild: RouteNode<T> | undefined
|
|
71
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Part-literal-part-param children (`:key.txt`), ordered by specificity.
|
|
79
|
+
*
|
|
80
|
+
* Unlike `paramChild` there can legitimately be SEVERAL at one level - `/:id.txt` and `/:id.json`
|
|
81
|
+
* are different shapes, not a conflict - so this is a list rather than a slot, and it must be tried
|
|
82
|
+
* in specificity order rather than registration order.
|
|
83
|
+
*
|
|
84
|
+
* Left `undefined` unless a mixed route is registered, so an app that never uses one pays nothing:
|
|
85
|
+
* no allocation, and one `undefined` check on the match path.
|
|
86
|
+
*/
|
|
87
|
+
mixedChildren: MixedChild<T>[] | undefined
|
|
88
|
+
/** Catch-all `*` child, if any - always terminal. */
|
|
72
89
|
wildcardChild: RouteNode<T> | undefined
|
|
73
90
|
/** Set once this node terminates one or more routes. */
|
|
74
91
|
terminal: Terminal<T> | undefined
|
|
75
92
|
}
|
|
76
93
|
|
|
94
|
+
interface MixedChild<T> {
|
|
95
|
+
/** Anchored matcher for ONE segment, with a capture group per parameter. */
|
|
96
|
+
readonly regex: RegExp
|
|
97
|
+
/**
|
|
98
|
+
* How many values this segment pushes. A mixed segment pushes N, not one, so a failed branch must
|
|
99
|
+
* pop exactly N to keep `paramValues` aligned with `paramNames`.
|
|
100
|
+
*/
|
|
101
|
+
readonly arity: number
|
|
102
|
+
/** Parsed shape used by the same total specificity comparator as the browser router. */
|
|
103
|
+
readonly parts: readonly MixedPart[]
|
|
104
|
+
readonly node: RouteNode<T>
|
|
105
|
+
/** The pattern source, so re-registering the same shape reuses its node. */
|
|
106
|
+
readonly source: string
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
function createNode<T>(): RouteNode<T> {
|
|
78
110
|
return {
|
|
79
111
|
staticChildren: new Map(),
|
|
80
112
|
paramChild: undefined,
|
|
113
|
+
mixedChildren: undefined,
|
|
81
114
|
wildcardChild: undefined,
|
|
82
115
|
terminal: undefined,
|
|
83
116
|
}
|
|
@@ -96,7 +129,7 @@ function sameNames(a: readonly string[], b: readonly string[]): boolean {
|
|
|
96
129
|
* instead of pre-splitting it into an array. `start` is the index of the first
|
|
97
130
|
* char of the current segment; the segment runs to the next `/` (or end). Tries
|
|
98
131
|
* static, then param, then wildcard; if a static branch dead-ends, param/wildcard
|
|
99
|
-
* are still attempted
|
|
132
|
+
* are still attempted - so `/a/b/c` and `/a/:x/d` coexist and `/a/b/d` resolves
|
|
100
133
|
* to the param route. Fills `paramValues` along the successful path; abandoned
|
|
101
134
|
* branches are popped. Avoiding the split keeps `find` allocation-light: only the
|
|
102
135
|
* segment substrings needed for map lookups (and matched param values) are cut.
|
|
@@ -123,10 +156,32 @@ function matchNode<T>(
|
|
|
123
156
|
}
|
|
124
157
|
}
|
|
125
158
|
|
|
159
|
+
// Mixed sits between static and param: it pins literal text, so it is more specific than a bare
|
|
160
|
+
// `:param`, and less than an exact segment. Guarded on `undefined` so a mixed-free route table
|
|
161
|
+
// never enters this branch.
|
|
162
|
+
const mixedChildren = node.mixedChildren
|
|
163
|
+
if (mixedChildren !== undefined && seg.length > 0) {
|
|
164
|
+
for (const child of mixedChildren) {
|
|
165
|
+
const matched = child.regex.exec(seg)
|
|
166
|
+
if (matched === null) continue
|
|
167
|
+
// Push left-to-right, matching the order `paramNames` was built in.
|
|
168
|
+
for (let i = 1; i <= child.arity; i++) paramValues.push(matched[i] as string)
|
|
169
|
+
if (isLast) {
|
|
170
|
+
if (child.node.terminal !== undefined) return child.node.terminal
|
|
171
|
+
} else {
|
|
172
|
+
const found = matchNode(child.node, path, end + 1, len, paramValues)
|
|
173
|
+
if (found !== undefined) return found
|
|
174
|
+
}
|
|
175
|
+
// Backtrack: a mixed segment pushed `arity` values, not one, so unwind exactly that many or
|
|
176
|
+
// every later param reads a value belonging to an abandoned branch.
|
|
177
|
+
for (let i = 0; i < child.arity; i++) paramValues.pop()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
const paramChild = node.paramChild
|
|
127
|
-
// An empty segment (`/users//posts`) never matches a `:param`
|
|
182
|
+
// An empty segment (`/users//posts`) never matches a `:param` - matching would hand the handler
|
|
128
183
|
// `id: ""` and downstream code a `WHERE id = ''` class of bug. Peers (Hono, Elysia, Next) 404
|
|
129
|
-
// here too; a static "" child can still match (none is ever registered
|
|
184
|
+
// here too; a static "" child can still match (none is ever registered - INVALID_PATH guards).
|
|
130
185
|
if (paramChild !== undefined && seg.length > 0) {
|
|
131
186
|
paramValues.push(seg)
|
|
132
187
|
if (isLast) {
|
|
@@ -250,6 +305,25 @@ export class Router<T> {
|
|
|
250
305
|
} else if (segment.kind === "wildcard") {
|
|
251
306
|
node.wildcardChild ??= createNode<T>()
|
|
252
307
|
node = node.wildcardChild
|
|
308
|
+
} else if (segment.kind === "mixed") {
|
|
309
|
+
const source = mixedSegmentSource(segment.parts)
|
|
310
|
+
node.mixedChildren ??= []
|
|
311
|
+
let child = node.mixedChildren.find((entry) => entry.source === source)
|
|
312
|
+
if (child === undefined) {
|
|
313
|
+
child = {
|
|
314
|
+
regex: new RegExp(`^${source}$`),
|
|
315
|
+
arity: segment.parts.reduce((n, part) => (part.t === "param" ? n + 1 : n), 0),
|
|
316
|
+
parts: segment.parts,
|
|
317
|
+
node: createNode<T>(),
|
|
318
|
+
source,
|
|
319
|
+
}
|
|
320
|
+
node.mixedChildren.push(child)
|
|
321
|
+
// Most literal text first. Registration order must not decide which of `/:id.txt` and
|
|
322
|
+
// `/:id.json` wins, and re-sorting on insert (a boot-time cost) keeps the match path a
|
|
323
|
+
// straight scan.
|
|
324
|
+
node.mixedChildren.sort((a, b) => compareMixedPartsSpecificity(a.parts, b.parts))
|
|
325
|
+
}
|
|
326
|
+
node = child.node
|
|
253
327
|
} else {
|
|
254
328
|
let child = node.staticChildren.get(segment.value)
|
|
255
329
|
if (child === undefined) {
|
package/src/schema/standard.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Standard Schema v1 interface (https://standardschema.dev), vendored as
|
|
3
|
-
* types + a tiny runtime helper so any compliant validator
|
|
4
|
-
* arktype, …
|
|
3
|
+
* types + a tiny runtime helper so any compliant validator - zod, valibot,
|
|
4
|
+
* arktype, … - validates requests without coupling the framework to one lib.
|
|
5
5
|
* The spec is MIT-licensed and explicitly designed to be copied.
|
|
6
6
|
*/
|
|
7
7
|
|
package/src/seo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `sitemap.xml` + `robots.txt` builders
|
|
2
|
+
* `sitemap.xml` + `robots.txt` builders - pure, edge-safe string generators (no I/O, no runtime deps).
|
|
3
3
|
* Wire them to a route on any runtime:
|
|
4
4
|
*
|
|
5
5
|
* app.get("/sitemap.xml", () =>
|
|
@@ -21,7 +21,7 @@ const XML_ESCAPES: Readonly<Record<string, string>> = {
|
|
|
21
21
|
"'": "'",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Escape the five XML metacharacters
|
|
24
|
+
/** Escape the five XML metacharacters - `<loc>` and `<lastmod>` carry app- or DB-derived strings. */
|
|
25
25
|
function escapeXml(value: string): string {
|
|
26
26
|
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] as string)
|
|
27
27
|
}
|
|
@@ -48,10 +48,10 @@ const CHANGE_FREQS: ReadonlySet<string> = new Set([
|
|
|
48
48
|
export interface SitemapEntry {
|
|
49
49
|
/** An absolute URL, or a path that `hostname` will make absolute. */
|
|
50
50
|
readonly url: string
|
|
51
|
-
/** Last-modified
|
|
51
|
+
/** Last-modified - a `Date` (serialized as ISO 8601) or a pre-formatted W3C-datetime string. */
|
|
52
52
|
readonly lastmod?: string | Date
|
|
53
53
|
readonly changefreq?: SitemapChangeFreq
|
|
54
|
-
/** Crawl priority, `0.0
|
|
54
|
+
/** Crawl priority, `0.0`-`1.0`. */
|
|
55
55
|
readonly priority?: number
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -67,7 +67,7 @@ const SITEMAP_MAX_URLS = 50_000
|
|
|
67
67
|
export function sitemap(entries: readonly SitemapEntry[], options: SitemapOptions = {}): string {
|
|
68
68
|
if (entries.length > SITEMAP_MAX_URLS) {
|
|
69
69
|
throw new RangeError(
|
|
70
|
-
`sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit
|
|
70
|
+
`sitemap: ${entries.length} entries exceeds the ${SITEMAP_MAX_URLS}-URL per-file limit - split into a sitemap index`,
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
const base = options.hostname?.replace(/\/+$/, "")
|
|
@@ -96,7 +96,7 @@ export function sitemap(entries: readonly SitemapEntry[], options: SitemapOption
|
|
|
96
96
|
|
|
97
97
|
function resolveLoc(url: string, base: string | undefined): string {
|
|
98
98
|
if (/^https?:\/\//i.test(url)) return url // already absolute
|
|
99
|
-
if (base === undefined) return url // relative
|
|
99
|
+
if (base === undefined) return url // relative - caller's choice (sitemaps prefer absolute)
|
|
100
100
|
return `${base}${url.startsWith("/") ? "" : "/"}${url}`
|
|
101
101
|
}
|
|
102
102
|
|
package/src/server/body.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded request-body reading
|
|
2
|
+
* Bounded request-body reading - the single source of truth for nifra's body-size cap. A lying or
|
|
3
3
|
* absent `Content-Length` can't force us to buffer an oversized payload: a declared length over the
|
|
4
4
|
* cap is rejected *before* buffering, and a chunked / length-less body is aborted mid-stream once the
|
|
5
5
|
* running byte count exceeds the cap. Shared by the server's schema path, `c.boundedBody`, and
|
|
@@ -26,7 +26,7 @@ export function parseContentLength(value: string): number | undefined {
|
|
|
26
26
|
|
|
27
27
|
/** The shared streaming byte-cap loop: read until done, or cancel + 413 once over `maxBytes`.
|
|
28
28
|
* A single-chunk body (the common case for small chunked payloads) returns the runtime's own
|
|
29
|
-
* chunk directly
|
|
29
|
+
* chunk directly - no chunk array, no copy-merge. */
|
|
30
30
|
export async function drainCapped(
|
|
31
31
|
body: ReadableStream<Uint8Array>,
|
|
32
32
|
maxBytes: number,
|
package/src/server/context.ts
CHANGED
|
@@ -68,12 +68,12 @@ export interface RouteSchema {
|
|
|
68
68
|
* Dedupe retries of this (mutating) route on an `Idempotency-Key` header. The first request runs and
|
|
69
69
|
* its response is stored; a retry with the same key replays that response without re-running the
|
|
70
70
|
* handler, and a key reused with a different body is rejected (409). A missing key on an
|
|
71
|
-
* idempotency-required route fails closed (400). Off the hot path
|
|
71
|
+
* idempotency-required route fails closed (400). Off the hot path - routes without it are unchanged.
|
|
72
72
|
*/
|
|
73
73
|
readonly idempotency?: IdempotencyConfig
|
|
74
74
|
/**
|
|
75
75
|
* Highest data-sensitivity this route's response body carries (`public` | `pii` | `secret`). A
|
|
76
|
-
* declarative, compile-time + introspection fact
|
|
76
|
+
* declarative, compile-time + introspection fact - never validated at runtime. Reflected for tooling,
|
|
77
77
|
* recorded in the capability lockfile (so a route that starts returning PII is a reviewable change),
|
|
78
78
|
* and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
|
|
79
79
|
*/
|
|
@@ -110,20 +110,20 @@ export interface RouteSchema {
|
|
|
110
110
|
readonly query?: StandardSchemaV1
|
|
111
111
|
/** Optional **response contract**. When declared: the handler's return is type-checked against it
|
|
112
112
|
* (the implementation can't drift from the contract), the typed client sees THIS as the response type
|
|
113
|
-
* (the contract
|
|
113
|
+
* (the contract - not the handler's incidental return), and it's emitted into OpenAPI/JSON-Schema so
|
|
114
114
|
* tooling and coding agents can read the exact shape. It is **not** validated at runtime (zero
|
|
115
|
-
* hot-path cost)
|
|
115
|
+
* hot-path cost) - purely a compile-time + introspection contract. */
|
|
116
116
|
readonly response?: StandardSchemaV1
|
|
117
117
|
/**
|
|
118
118
|
* Optional **error-response contract**: a map of HTTP status code → the Standard Schema of that error's
|
|
119
119
|
* body. Declares a route's failure modes so they flow into OpenAPI (as non-2xx `responses`), the
|
|
120
|
-
* `/llms.txt` context, and other introspection
|
|
120
|
+
* `/llms.txt` context, and other introspection - the whole contract, not just the happy path, is legible
|
|
121
121
|
* to tooling and coding agents. Like `response`, it is **not** validated at runtime (zero hot-path cost);
|
|
122
122
|
* it's a compile-time + introspection contract. Example: `{ 404: NotFound, 409: Conflict }`. */
|
|
123
123
|
readonly errors?: Readonly<Record<number, StandardSchemaV1>>
|
|
124
124
|
/**
|
|
125
125
|
* Optional **SSE event contract**: the Standard Schema of each event's `data` payload on a
|
|
126
|
-
* streaming route (declared via `app.sse()`). Marks the route as a typed event stream
|
|
126
|
+
* streaming route (declared via `app.sse()`). Marks the route as a typed event stream - the
|
|
127
127
|
* typed client grows a `.subscribe()` for it, and the schema flows into reflection. Like
|
|
128
128
|
* `response`, it is **not** validated at runtime (zero hot-path cost); the server-side
|
|
129
129
|
* `stream.send()` is compile-time-checked against it instead.
|
|
@@ -135,7 +135,7 @@ export interface RouteSchema {
|
|
|
135
135
|
* - a **`Response`** → returned as-is, short-circuiting the route (custom error envelope, redirect, …).
|
|
136
136
|
* - **any other value** → treated as a repaired payload and **re-validated once** against the same
|
|
137
137
|
* schema. If it now passes, the handler runs with it; if it still fails, the original `422` stands.
|
|
138
|
-
* Re-validation means a bad return can't bypass the schema
|
|
138
|
+
* Re-validation means a bad return can't bypass the schema - the trust boundary holds.
|
|
139
139
|
* - **`undefined`** → give up; the original validation `422` is returned unchanged.
|
|
140
140
|
* A route hook **overrides** the app-wide default set via `server({ onValidationError })`; return
|
|
141
141
|
* `undefined` to fall through to the plain `422` even when an app default exists. Typical uses: a custom
|
|
@@ -171,8 +171,8 @@ export interface ResponseControls {
|
|
|
171
171
|
status?: number
|
|
172
172
|
readonly headers: Record<string, string>
|
|
173
173
|
/**
|
|
174
|
-
* Queue a `Set-Cookie` on the response. Defaults are **secure-by-default**
|
|
175
|
-
* `HttpOnly; Secure; SameSite=Lax; Path=/`
|
|
174
|
+
* Queue a `Set-Cookie` on the response. Defaults are **secure-by-default** -
|
|
175
|
+
* `HttpOnly; Secure; SameSite=Lax; Path=/` - overridable per call (pass `{ secure: false }` for
|
|
176
176
|
* local http dev). Multiple calls set multiple cookies (they're merged via a `Headers` object, so
|
|
177
177
|
* they don't collapse the way the plain `headers` Record would). Sign the value with `signValue`
|
|
178
178
|
* for tamper-evidence (what `@nifrajs/auth` sessions do).
|
|
@@ -190,7 +190,7 @@ export interface ResponseControls {
|
|
|
190
190
|
*/
|
|
191
191
|
export interface Platform<Env = unknown> {
|
|
192
192
|
/** Platform bindings (KV/D1/secrets on Workers). Typed as `Env` when the app declares them via
|
|
193
|
-
* `server<Env>()`; otherwise `unknown`
|
|
193
|
+
* `server<Env>()`; otherwise `unknown` - validate before use. */
|
|
194
194
|
readonly env?: Env
|
|
195
195
|
/** Extend the response's lifetime for background work (Workers `ctx.waitUntil`). */
|
|
196
196
|
readonly waitUntil?: (promise: Promise<unknown>) => void
|
|
@@ -208,7 +208,7 @@ export interface Platform<Env = unknown> {
|
|
|
208
208
|
export interface Context<Path extends string = string, S extends RouteSchema = RouteSchema> {
|
|
209
209
|
readonly req: Request
|
|
210
210
|
/**
|
|
211
|
-
* Alias of {@link req}
|
|
211
|
+
* Alias of {@link req} - the same `Request`. Page loaders/actions receive their request as `request`;
|
|
212
212
|
* this alias lets route handlers and loaders share one name, so `c.request` and `ctx.req` both work
|
|
213
213
|
* everywhere (no more `c.req` vs `ctx.request` mismatch).
|
|
214
214
|
*/
|
|
@@ -217,12 +217,12 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
217
217
|
readonly query: QueryOf<S>
|
|
218
218
|
readonly body: BodyOf<S>
|
|
219
219
|
/** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
|
|
220
|
-
* first access + cached. Signed cookies arrive as `value.signature`
|
|
220
|
+
* first access + cached. Signed cookies arrive as `value.signature` - verify with `unsignValue`. */
|
|
221
221
|
readonly cookies: Readonly<Record<string, string>>
|
|
222
222
|
readonly set: ResponseControls
|
|
223
223
|
/**
|
|
224
224
|
* Aborts when the server's `requestTimeoutMs` elapses (and never, when no timeout
|
|
225
|
-
* is configured). Pass it to cancellation-aware work
|
|
225
|
+
* is configured). Pass it to cancellation-aware work - DB drivers, `fetch` - so a
|
|
226
226
|
* timed-out request stops doing work instead of running on after the 503.
|
|
227
227
|
*/
|
|
228
228
|
readonly signal: AbortSignal
|
|
@@ -232,14 +232,14 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
232
232
|
*/
|
|
233
233
|
readonly budget: RequestBudget
|
|
234
234
|
/**
|
|
235
|
-
* Platform bindings from `app.fetch(request, { env })`
|
|
235
|
+
* Platform bindings from `app.fetch(request, { env })` - Workers `env` (KV/D1/secrets), etc.
|
|
236
236
|
* `undefined` off-edge (Bun/Node/Deno). Declare the shape with `server<Env>()` to read it typed
|
|
237
237
|
* (`c.env: Env`); otherwise `unknown`. Validate at the trust boundary before use either way.
|
|
238
238
|
*/
|
|
239
239
|
readonly env: unknown
|
|
240
240
|
/**
|
|
241
241
|
* The caller's IP. By default the raw socket peer the serving adapter observed (`listen()`,
|
|
242
|
-
* `@nifrajs/node`, `@nifrajs/deno`)
|
|
242
|
+
* `@nifrajs/node`, `@nifrajs/deno`) - the one address a client cannot forge - or `undefined` when the
|
|
243
243
|
* runtime exposes no socket (Workers) and no trust is declared. Behind a proxy/CDN, set the
|
|
244
244
|
* `clientIp` server option (`{ trustedHops }` or `{ header }`) to derive the real caller from the
|
|
245
245
|
* forwarding chain as far as you trust it; unset, no forwarded header is ever believed. Safe to key
|
|
@@ -254,8 +254,8 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
254
254
|
readonly waitUntil: (promise: Promise<unknown>) => void
|
|
255
255
|
/**
|
|
256
256
|
* Read the raw request body as bytes, **capped** at `maxBytes` (default: the server's
|
|
257
|
-
* `maxBodyBytes`). For routes with **no body schema**
|
|
258
|
-
* BYO-validation
|
|
257
|
+
* `maxBodyBytes`). For routes with **no body schema** - raw bodies, file uploads,
|
|
258
|
+
* BYO-validation - where the automatic schema cap doesn't apply. An over-cap body (by
|
|
259
259
|
* `Content-Length` or while streaming) throws a flat `413`; a malformed `Content-Length`
|
|
260
260
|
* throws `400`. Consumes the body stream (call once). Pass a larger `maxBytes` for an
|
|
261
261
|
* upload route, a smaller one to tighten a specific endpoint.
|
|
@@ -269,7 +269,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
269
269
|
readonly boundedJson: <T = unknown>(maxBytes?: number) => Promise<T>
|
|
270
270
|
/**
|
|
271
271
|
* Build a JSON `Response`. Pass a status number or a full `ResponseInit` as the second arg. `return` it
|
|
272
|
-
* from a handler, or `throw` it from `derive`/`beforeHandle` to short-circuit
|
|
272
|
+
* from a handler, or `throw` it from `derive`/`beforeHandle` to short-circuit - both work:
|
|
273
273
|
* `throw c.json({ error: "unauthorized" }, 401)`. A terser alternative to
|
|
274
274
|
* `new Response(JSON.stringify(...), { status, headers: { "content-type": "application/json" } })`.
|
|
275
275
|
*/
|
package/src/server/contract.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ResponseDef {
|
|
|
19
19
|
/**
|
|
20
20
|
* One operation in a contract. Input schemas are any Standard Schema; `response` is optional.
|
|
21
21
|
*
|
|
22
|
-
* The fields below `response` are **optional OpenAPI metadata**
|
|
22
|
+
* The fields below `response` are **optional OpenAPI metadata** - they don't affect runtime
|
|
23
23
|
* validation or the inferred handler types, they enrich the document `toOpenAPI` emits. A contract is
|
|
24
24
|
* the natural home for them: it's the versionable description of the API, decoupled from the impl.
|
|
25
25
|
*/
|
|
@@ -101,7 +101,7 @@ type OpErrors<O extends OperationDef> = O extends {
|
|
|
101
101
|
* RouteInfo as a *decoupled consumer* sees it from the contract alone: the
|
|
102
102
|
* `output` is the declared `response` schema's type, or `unknown` when none is
|
|
103
103
|
* declared (the consumer can't know the response without the server). Path/method
|
|
104
|
-
* levels are mutable and fields are `readonly`
|
|
104
|
+
* levels are mutable and fields are `readonly` - matching the inline registry, so
|
|
105
105
|
* the two are mutually assignable.
|
|
106
106
|
*/
|
|
107
107
|
type RouteInfoForOp<O extends OperationDef> = {
|
|
@@ -109,10 +109,10 @@ type RouteInfoForOp<O extends OperationDef> = {
|
|
|
109
109
|
readonly query: OpQuery<O>
|
|
110
110
|
readonly body: OpBody<O>
|
|
111
111
|
readonly output: OpResponse<O>
|
|
112
|
-
// The error union from the op's non-2xx `responses`
|
|
112
|
+
// The error union from the op's non-2xx `responses` - so a decoupled contract client sees typed error
|
|
113
113
|
// bodies, just like an inline route's `errors`. `unknown` when the op declares no error responses.
|
|
114
114
|
readonly errors: OpErrors<O>
|
|
115
|
-
// Contract operations cannot declare an SSE event contract (yet)
|
|
115
|
+
// Contract operations cannot declare an SSE event contract (yet) - `never` keeps the contract
|
|
116
116
|
// registry mutually assignable with the inline registry's RouteInfoFor (mode conformance).
|
|
117
117
|
readonly sse: never
|
|
118
118
|
}
|
|
@@ -132,7 +132,7 @@ type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends St
|
|
|
132
132
|
(O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* The handler context for an op
|
|
135
|
+
* The handler context for an op - identical to the inline `Context<Path, S>`, so a
|
|
136
136
|
* handler written for an inline route type-checks unchanged under `implement`
|
|
137
137
|
* (the graduation guarantee).
|
|
138
138
|
*/
|
|
@@ -142,9 +142,9 @@ type MaybePromise<T> = T | Promise<T>
|
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* What a handler may return for an op. When the op declares a `response`, the return is constrained to
|
|
145
|
-
* that contract shape (or a raw `Response`)
|
|
145
|
+
* that contract shape (or a raw `Response`) - so an `implement`ed backend can't drift from the response
|
|
146
146
|
* the contract's client was built against. With no `response` it's unconstrained (`unknown`), identical
|
|
147
|
-
* to before. Purely type-level
|
|
147
|
+
* to before. Purely type-level - erased at compile time, zero runtime cost - and mirrors the inline
|
|
148
148
|
* route's `ResponseOf` constraint, so a handler graduates inline↔contract unchanged.
|
|
149
149
|
*/
|
|
150
150
|
type HandlerReturnForOp<O extends OperationDef> = O extends {
|
|
@@ -202,7 +202,7 @@ type AnyFn = (...args: never[]) => unknown
|
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
204
|
* The registry produced by `implement`: input from the contract op; `output` is the declared `response`
|
|
205
|
-
* contract when present (it wins
|
|
205
|
+
* contract when present (it wins - exactly as in the inline path), else the bound HANDLER's return - so
|
|
206
206
|
* the implemented server stays route-for-route identical to the equivalent inline server (the
|
|
207
207
|
* mode-conformance guarantee), and a contract-typed client and a `typeof app`-typed client agree.
|
|
208
208
|
*/
|
|
@@ -221,10 +221,10 @@ export type RegistryFromImpl<
|
|
|
221
221
|
: H[K] extends AnyFn
|
|
222
222
|
? OutputOf<H[K]>
|
|
223
223
|
: unknown
|
|
224
|
-
// The error union from the op's non-2xx `responses` (see OpErrors)
|
|
224
|
+
// The error union from the op's non-2xx `responses` (see OpErrors) - a graduated contract client sees
|
|
225
225
|
// the same typed error bodies as the inline `errors` path.
|
|
226
226
|
readonly errors: OpErrors<C[K]>
|
|
227
|
-
// Mirrors RouteInfoForOp: no SSE contract on contract ops (yet)
|
|
227
|
+
// Mirrors RouteInfoForOp: no SSE contract on contract ops (yet) - mode conformance holds.
|
|
228
228
|
readonly sse: never
|
|
229
229
|
}
|
|
230
230
|
}
|
|
@@ -233,7 +233,7 @@ export type RegistryFromImpl<
|
|
|
233
233
|
/**
|
|
234
234
|
* Bind handlers to a contract, producing a real {@link Server} you can `.listen()`
|
|
235
235
|
* or `.fetch()`. Each op is registered through the same path as the inline
|
|
236
|
-
* builder, so the result is identical to writing the routes inline
|
|
236
|
+
* builder, so the result is identical to writing the routes inline - handlers
|
|
237
237
|
* lift over **unchanged** ("graduation"), and body/query schemas validate at the
|
|
238
238
|
* request boundary exactly as in inline mode.
|
|
239
239
|
*
|
|
@@ -259,9 +259,9 @@ export function implement<
|
|
|
259
259
|
const target = (app ?? new Server()) as Server<R, Ctx>
|
|
260
260
|
const routes = Object.entries(contract).map(([name, op]) => {
|
|
261
261
|
// body/query are validated at the request boundary; `response` is a type + introspection contract
|
|
262
|
-
// ONLY
|
|
262
|
+
// ONLY - never validated at runtime and never read on the request hot path (the lifecycle reads
|
|
263
263
|
// schema.body/query by name; the `bare`/bodyOnly/queryOnly fast-path gates ignore `response`). Built
|
|
264
|
-
// once here at bind time, not per request
|
|
264
|
+
// once here at bind time, not per request - it just carries a reference to the op's existing schema.
|
|
265
265
|
// A response-less op still yields `undefined`, byte-identical to before.
|
|
266
266
|
const schema: RouteSchema | undefined =
|
|
267
267
|
op.params !== undefined ||
|
package/src/server/cookies.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cookie primitives
|
|
2
|
+
* Cookie primitives - parse a request `Cookie` header, serialize a `Set-Cookie`, and sign/verify a
|
|
3
3
|
* value with HMAC-SHA256 via **WebCrypto** (`crypto.subtle`), so they're portable across Bun, Node,
|
|
4
4
|
* Deno, and workerd with no `node:crypto` dependency. Pure + runtime-agnostic: `c.cookies` (read) and
|
|
5
5
|
* `c.set.cookie` (write) build on these, and `@nifrajs/auth` builds sessions on top.
|
|
@@ -22,9 +22,9 @@ export interface CookieOptions {
|
|
|
22
22
|
|
|
23
23
|
// RFC 6265 cookie-name token: visible ASCII minus separators/whitespace/controls.
|
|
24
24
|
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
25
|
-
const MAX_COOKIE_BYTES = 4096 // browsers cap a cookie near 4 KB
|
|
25
|
+
const MAX_COOKIE_BYTES = 4096 // browsers cap a cookie near 4 KB - reject oversized before the wire
|
|
26
26
|
|
|
27
|
-
/** True if `v` contains a character illegal in a cookie attribute (control char or `;` separator)
|
|
27
|
+
/** True if `v` contains a character illegal in a cookie attribute (control char or `;` separator) -
|
|
28
28
|
* the header-injection guard for dev-supplied `Path`/`Domain`. */
|
|
29
29
|
const hasIllegalChar = (v: string): boolean => {
|
|
30
30
|
for (let i = 0; i < v.length; i++) {
|
|
@@ -35,7 +35,7 @@ const hasIllegalChar = (v: string): boolean => {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const safeDecode = (v: string): string => {
|
|
38
|
-
// No `%` ⇒ nothing to decode
|
|
38
|
+
// No `%` ⇒ nothing to decode - skip the call AND its try frame (profiled: cookie values are
|
|
39
39
|
// overwhelmingly plain; this was ~3% of a realistic request).
|
|
40
40
|
if (!v.includes("%")) return v
|
|
41
41
|
try {
|
|
@@ -46,7 +46,7 @@ const safeDecode = (v: string): string => {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** Parse a request `Cookie` header into a name→value map (values URL-decoded). Unparseable pairs are
|
|
49
|
-
* skipped rather than throwing
|
|
49
|
+
* skipped rather than throwing - a junk `Cookie` header shouldn't fail the request.
|
|
50
50
|
*
|
|
51
51
|
* Hand-rolled index walk instead of `split(";")` + `trim()` chains: those allocated an array + up
|
|
52
52
|
* to three substrings per pair and showed up at ~6% of a realistic (auth + cookie) request. Same
|
|
@@ -97,9 +97,9 @@ export function parseCookies(header: string | null | undefined): Record<string,
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Serialize a `Set-Cookie` header value. Pure
|
|
100
|
+
* Serialize a `Set-Cookie` header value. Pure - applies **no** security defaults (the caller, e.g.
|
|
101
101
|
* `c.set.cookie`, layers `HttpOnly`/`Secure`/`SameSite` on). Throws on an invalid cookie name, a
|
|
102
|
-
* header-injecting `Path`/`Domain`, a non-integer `maxAge`, or an oversized result
|
|
102
|
+
* header-injecting `Path`/`Domain`, a non-integer `maxAge`, or an oversized result - a serialization
|
|
103
103
|
* bug should fail loudly, not silently emit a cookie the browser drops.
|
|
104
104
|
*/
|
|
105
105
|
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
@@ -170,7 +170,7 @@ const fromBase64Url = (s: string): Uint8Array<ArrayBuffer> | null => {
|
|
|
170
170
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
171
171
|
return bytes
|
|
172
172
|
} catch {
|
|
173
|
-
return null // not valid base64
|
|
173
|
+
return null // not valid base64 - treat as a bad signature
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edge adapter: a nifra app presented as a Cloudflare-Workers-shaped `ExportedHandler`.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not in the server kernel
|
|
5
|
+
*
|
|
6
|
+
* `toFetchHandler` never touches the `Server` class. It takes a STRUCTURAL app - anything with `fetch`, and
|
|
7
|
+
* optionally `resolveWebSocketUpgrade` - which is what lets it wrap an app it has no access to the
|
|
8
|
+
* internals of, and what makes it a separate concern from the kernel rather than a method on it. It sat
|
|
9
|
+
* in `server.ts` for no reason other than that both were written the same week.
|
|
10
|
+
*
|
|
11
|
+
* The types here are all structural too (`ExecutionContext`, `ScheduledController`,
|
|
12
|
+
* `DurableObjectNamespaceLike`), so `@nifrajs/core` declares the Workers surface it needs instead of
|
|
13
|
+
* depending on `@cloudflare/workers-types`. The real Workers types satisfy them.
|
|
14
|
+
*
|
|
15
|
+
* The `MaybePromise` import is type-only and erased, so there is no runtime cycle back into the kernel.
|
|
16
|
+
*/
|
|
17
|
+
import type { Platform } from "./context.ts"
|
|
18
|
+
import type { MaybePromise } from "./server.ts"
|
|
19
|
+
import type { StandardWebSocket, WebSocketUpgradeOutcome } from "./websocket.ts"
|
|
20
|
+
|
|
21
|
+
/** A Cloudflare Workers-style execution context (the `fetch` 3rd arg). Structural - only
|
|
22
|
+
* `waitUntil` is used; declared here so `@nifrajs/core` needs no Workers type dependency. */
|
|
23
|
+
export interface ExecutionContext {
|
|
24
|
+
waitUntil(promise: Promise<unknown>): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A Cloudflare Workers-style scheduled (cron) controller. Structural - no Workers type dependency. */
|
|
28
|
+
export interface ScheduledController {
|
|
29
|
+
/** Epoch ms the run was scheduled for. */
|
|
30
|
+
readonly scheduledTime: number
|
|
31
|
+
/** The matching cron expression from `wrangler.toml` `[triggers]`. */
|
|
32
|
+
readonly cron: string
|
|
33
|
+
/** Tell the platform not to retry this run on failure. */
|
|
34
|
+
noRetry(): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A nifra cron handler: the platform controller + the same typed `env`/`waitUntil` nifra threads into
|
|
38
|
+
* request handlers. Schedule background work with `waitUntil` so it outlives the trigger. */
|
|
39
|
+
export type ScheduledHandler<Env = unknown> = (
|
|
40
|
+
controller: ScheduledController,
|
|
41
|
+
context: { readonly env: Env; waitUntil(promise: Promise<unknown>): void },
|
|
42
|
+
) => MaybePromise<void>
|
|
43
|
+
|
|
44
|
+
/** Cloudflare's `WebSocketPair` - feature-detected (absent off Workers). Yields `{ 0: client, 1: server }`. */
|
|
45
|
+
type WebSocketPairCtor = new () => {
|
|
46
|
+
readonly 0: unknown
|
|
47
|
+
readonly 1: StandardWebSocket & { accept(): void }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Structural view of a Cloudflare Durable Object namespace binding - keeps `@cloudflare/workers-types`
|
|
51
|
+
* out of `@nifrajs/core`. The real `DurableObjectNamespace` satisfies it. */
|
|
52
|
+
export interface DurableObjectNamespaceLike {
|
|
53
|
+
idFromName(name: string): unknown
|
|
54
|
+
get(id: unknown): { fetch(request: Request): Promise<Response> }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The single hub Durable Object id nifra routes WS upgrades to (one hub per app - see `@nifrajs/workers`). */
|
|
58
|
+
const NIFRA_WS_HUB_ID = "nifra-ws-hub"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Adapt a nifra app to an edge "ExportedHandler" - use it as a Cloudflare Workers (or any
|
|
62
|
+
* `fetch(request, env, ctx)` runtime) default export. It threads `env` + `ctx.waitUntil` into the
|
|
63
|
+
* nifra Context, so handlers read `c.env` and schedule background work via `c.waitUntil`:
|
|
64
|
+
*
|
|
65
|
+
* export default toFetchHandler(app)
|
|
66
|
+
*
|
|
67
|
+
* Pass `{ scheduled }` to also export a Workers cron handler (for a `[triggers]` schedule) - it
|
|
68
|
+
* receives the platform controller plus the same typed `env`/`waitUntil`:
|
|
69
|
+
*
|
|
70
|
+
* export default toFetchHandler(app, {
|
|
71
|
+
* scheduled: (controller, { env, waitUntil }) =>
|
|
72
|
+
* waitUntil(env.KV.put("last-run", String(controller.scheduledTime))),
|
|
73
|
+
* })
|
|
74
|
+
*
|
|
75
|
+
* No Workers-only deps - `app.fetch` stays a portable Web-standard handler; this is the thin
|
|
76
|
+
* shim from the platform's 3-arg `fetch`/`scheduled` to it.
|
|
77
|
+
*/
|
|
78
|
+
export function toFetchHandler<Env = unknown>(
|
|
79
|
+
app: {
|
|
80
|
+
fetch(request: Request, platform?: Platform<Env>): MaybePromise<Response>
|
|
81
|
+
resolveWebSocketUpgrade?(
|
|
82
|
+
request: Request,
|
|
83
|
+
platform?: Platform<Env>,
|
|
84
|
+
): MaybePromise<WebSocketUpgradeOutcome>
|
|
85
|
+
},
|
|
86
|
+
options?: {
|
|
87
|
+
scheduled?: ScheduledHandler<Env>
|
|
88
|
+
/**
|
|
89
|
+
* Route WebSocket upgrades to a Durable Object that holds the connections and runs the app's
|
|
90
|
+
* pub/sub - enabling cross-connection broadcast (`app.publish`) on Cloudflare Workers, where a
|
|
91
|
+
* stateless isolate can't. Pass the DO namespace binding from `env`; pair with `@nifrajs/workers`'
|
|
92
|
+
* `createWebSocketHub(app)` (the DO class) + a `wrangler.toml` binding. Without this, WS upgrades use
|
|
93
|
+
* a per-connection `WebSocketPair` (no broadcast).
|
|
94
|
+
*/
|
|
95
|
+
webSocketHub?: (env: Env) => DurableObjectNamespaceLike
|
|
96
|
+
},
|
|
97
|
+
): {
|
|
98
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): MaybePromise<Response>
|
|
99
|
+
scheduled?(controller: ScheduledController, env: Env, ctx: ExecutionContext): MaybePromise<void>
|
|
100
|
+
} {
|
|
101
|
+
const scheduled = options?.scheduled
|
|
102
|
+
const webSocketHub = options?.webSocketHub
|
|
103
|
+
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
104
|
+
return {
|
|
105
|
+
fetch: (request, env, ctx) => {
|
|
106
|
+
const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
|
|
107
|
+
// WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
|
|
108
|
+
// connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
|
|
109
|
+
// resolves the route and rejects non-WS paths. See `@nifrajs/workers`' `createWebSocketHub`.
|
|
110
|
+
if (
|
|
111
|
+
webSocketHub !== undefined &&
|
|
112
|
+
request.headers.get("upgrade")?.toLowerCase() === "websocket"
|
|
113
|
+
) {
|
|
114
|
+
const ns = webSocketHub(env)
|
|
115
|
+
return ns.get(ns.idFromName(NIFRA_WS_HUB_ID)).fetch(request)
|
|
116
|
+
}
|
|
117
|
+
// Workers WebSockets: a WS upgrade for a registered route becomes a `WebSocketPair` + a 101.
|
|
118
|
+
// Feature-detected, so non-Workers edge runtimes (which lack `WebSocketPair` - e.g. Deno Deploy
|
|
119
|
+
// uses `@nifrajs/deno`'s `Deno.upgradeWebSocket`) simply fall through to the normal `fetch`.
|
|
120
|
+
const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor }).WebSocketPair
|
|
121
|
+
if (resolveWs !== undefined && Pair !== undefined) {
|
|
122
|
+
const accept = (outcome: WebSocketUpgradeOutcome): MaybePromise<Response> => {
|
|
123
|
+
if (outcome.kind === "pass") return app.fetch(request, platform)
|
|
124
|
+
if (outcome.kind === "reject") return outcome.response
|
|
125
|
+
const pair = new Pair()
|
|
126
|
+
const server = pair[1]
|
|
127
|
+
server.accept()
|
|
128
|
+
// The outcome carries the installed runtime's `attach`, so `toFetchHandler` (a standalone
|
|
129
|
+
// function, no access to the app's private runtime) wires the socket without a static WS import.
|
|
130
|
+
outcome.attach(server, outcome.handler, outcome.data, {
|
|
131
|
+
openNow: true,
|
|
132
|
+
pubsub: outcome.pubsub,
|
|
133
|
+
})
|
|
134
|
+
// `webSocket` is a Workers-only `ResponseInit` field (absent from the standard type), and a
|
|
135
|
+
// 101 status is only valid on the Workers runtime - both gated by the `Pair` feature check.
|
|
136
|
+
return new Response(null, { status: 101, webSocket: pair[0] } as unknown as ResponseInit)
|
|
137
|
+
}
|
|
138
|
+
const out = resolveWs(request, platform)
|
|
139
|
+
return out instanceof Promise ? out.then(accept) : accept(out)
|
|
140
|
+
}
|
|
141
|
+
return app.fetch(request, platform)
|
|
142
|
+
},
|
|
143
|
+
...(scheduled !== undefined
|
|
144
|
+
? {
|
|
145
|
+
scheduled: (controller: ScheduledController, env: Env, ctx: ExecutionContext) =>
|
|
146
|
+
scheduled(controller, { env, waitUntil: (promise) => ctx.waitUntil(promise) }),
|
|
147
|
+
}
|
|
148
|
+
: {}),
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/server/install.ts
CHANGED
|
@@ -26,3 +26,8 @@ export const INSTALL_NODE_DIRECT: unique symbol = Symbol.for("@nifrajs/core/inst
|
|
|
26
26
|
|
|
27
27
|
/** @internal Install the WebSocket runtime on a server (called by the `websocket()` plugin). */
|
|
28
28
|
export const INSTALL_WS: unique symbol = Symbol.for("@nifrajs/core/install-ws")
|
|
29
|
+
|
|
30
|
+
/** @internal Install the response-contract runtime on a server (called by the `responseContract()` plugin). */
|
|
31
|
+
export const INSTALL_RESPONSE_CONTRACT: unique symbol = Symbol.for(
|
|
32
|
+
"@nifrajs/core/install-response-contract",
|
|
33
|
+
)
|