@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/server/sse.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-Sent Events
|
|
2
|
+
* Server-Sent Events - a portable `text/event-stream` response helper.
|
|
3
3
|
*
|
|
4
4
|
* `sse(c, run)` returns a streaming `Response` a handler can return directly. The `run` callback
|
|
5
5
|
* receives a stream it pushes messages to; the connection stays open until `run`'s promise resolves,
|
|
6
6
|
* it calls `stream.close()`, or the client disconnects (`c.req.signal`). Works on every nifra runtime
|
|
7
|
-
* (Bun, Node via `@nifrajs/node`, Deno, Cloudflare/Vercel edge)
|
|
7
|
+
* (Bun, Node via `@nifrajs/node`, Deno, Cloudflare/Vercel edge) - it only uses a Web `ReadableStream`.
|
|
8
8
|
*
|
|
9
|
-
* // finite stream
|
|
9
|
+
* // finite stream - run resolves, the connection closes
|
|
10
10
|
* app.get("/ticks", (c) => sse(c, async (stream) => {
|
|
11
11
|
* for (let i = 0; i < 5; i++) {
|
|
12
12
|
* stream.send({ event: "tick", data: String(i) })
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* }
|
|
15
15
|
* }))
|
|
16
16
|
*
|
|
17
|
-
* // event-driven
|
|
17
|
+
* // event-driven - keep `run` pending until the client leaves
|
|
18
18
|
* app.get("/feed", (c) => sse(c, (stream) => {
|
|
19
19
|
* const off = bus.subscribe((e) => stream.send({ data: JSON.stringify(e) }))
|
|
20
20
|
* return new Promise<void>((resolve) =>
|
|
@@ -43,11 +43,11 @@ export interface SSEStream {
|
|
|
43
43
|
send(message: SSEMessage): void
|
|
44
44
|
/** End the stream now. */
|
|
45
45
|
close(): void
|
|
46
|
-
/** Aborts when the client disconnects
|
|
46
|
+
/** Aborts when the client disconnects - use it to tear down subscriptions/loops. */
|
|
47
47
|
readonly signal: AbortSignal
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** Minimal context shape `sse` needs
|
|
50
|
+
/** Minimal context shape `sse` needs - the live request, for its client-disconnect signal. */
|
|
51
51
|
export interface SSEContext {
|
|
52
52
|
readonly req: Request
|
|
53
53
|
}
|
|
@@ -63,7 +63,7 @@ export interface SSEInit {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* The stream handed to an `app.sse()` handler: `send` takes the route's TYPED event payload and
|
|
66
|
-
* serializes it (JSON) into the SSE `data:` field
|
|
66
|
+
* serializes it (JSON) into the SSE `data:` field - the compile-time half of the `sse` contract.
|
|
67
67
|
*/
|
|
68
68
|
export interface TypedSSEStream<Event> {
|
|
69
69
|
/** Send one typed event. `init` sets the optional SSE frame fields (`event`, `id`, `retry`). */
|
|
@@ -72,7 +72,7 @@ export interface TypedSSEStream<Event> {
|
|
|
72
72
|
comment(text: string): void
|
|
73
73
|
/** End the stream now. */
|
|
74
74
|
close(): void
|
|
75
|
-
/** Aborts when the client disconnects
|
|
75
|
+
/** Aborts when the client disconnects - use it to tear down subscriptions/loops. */
|
|
76
76
|
readonly signal: AbortSignal
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -93,7 +93,7 @@ export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event>
|
|
|
93
93
|
const CRLF = /[\r\n]/g
|
|
94
94
|
const LINE_SPLIT = /\r\n|\r|\n/
|
|
95
95
|
|
|
96
|
-
/** `event:`/`id:`/`comment` must be single-line
|
|
96
|
+
/** `event:`/`id:`/`comment` must be single-line - stripping CR/LF prevents SSE frame injection. */
|
|
97
97
|
function formatMessage(m: SSEMessage): string {
|
|
98
98
|
let frame = ""
|
|
99
99
|
if (m.comment !== undefined) frame += `: ${m.comment.replace(CRLF, "")}\n`
|
|
@@ -139,7 +139,7 @@ export function sse(
|
|
|
139
139
|
try {
|
|
140
140
|
controller.close()
|
|
141
141
|
} catch {
|
|
142
|
-
// Already closed by the runtime's cancel()
|
|
142
|
+
// Already closed by the runtime's cancel() - safe to ignore.
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
const stream: SSEStream = {
|
|
@@ -180,7 +180,7 @@ export function sse(
|
|
|
180
180
|
})
|
|
181
181
|
},
|
|
182
182
|
cancel() {
|
|
183
|
-
// Consumer/runtime canceled (client gone)
|
|
183
|
+
// Consumer/runtime canceled (client gone) - pending sends become no-ops.
|
|
184
184
|
closed = true
|
|
185
185
|
teardown()
|
|
186
186
|
},
|
package/src/server/webhook.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Webhook signature verification
|
|
2
|
+
* Webhook signature verification - **read the raw body, verify the HMAC, *then* parse**. A handler
|
|
3
3
|
* that `JSON.parse`s a webhook before checking the signature is trusting an unauthenticated payload;
|
|
4
4
|
* this reads the body bounded (DoS guard, shared with `c.boundedBody`) and verifies before handing
|
|
5
5
|
* back the raw text for the handler to parse with its own schema.
|
|
6
6
|
*
|
|
7
7
|
* Verification is **constant-time**: the provider's signature is fed to `crypto.subtle.verify`
|
|
8
|
-
* (WebCrypto), which checks the HMAC without a byte-by-byte string compare
|
|
8
|
+
* (WebCrypto), which checks the HMAC without a byte-by-byte string compare - so a wrong signature
|
|
9
9
|
* can't be discovered through timing. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
|
|
10
10
|
*
|
|
11
11
|
* Presets cover Stripe (`t=…,v1=…` with a replay-window check) and GitHub (`sha256=…`); `generic`
|
|
@@ -15,7 +15,7 @@ import { requireSecretBytes } from "../internal/secret.ts"
|
|
|
15
15
|
import { readBoundedBytes } from "./body.ts"
|
|
16
16
|
|
|
17
17
|
const TEXT = new TextEncoder()
|
|
18
|
-
const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB
|
|
18
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB - webhook payloads are small; cap the raw read.
|
|
19
19
|
const DEFAULT_TOLERANCE_SECONDS = 300 // 5-min replay window for timestamped schemes (Stripe's default).
|
|
20
20
|
|
|
21
21
|
export type WebhookProvider = "stripe" | "github" | "generic"
|
|
@@ -136,7 +136,7 @@ function parseStripeHeader(value: string): { t: number; v1: string[] } | null {
|
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
138
|
* Verify a webhook request's signature and return its raw payload. Reads `req.body` (bounded), so the
|
|
139
|
-
* body is consumed
|
|
139
|
+
* body is consumed - parse the returned `payload`, don't re-read the request.
|
|
140
140
|
*
|
|
141
141
|
* @param secret the signing secret (or an array, to accept either during a secret rotation).
|
|
142
142
|
*
|
|
@@ -189,7 +189,7 @@ export async function verifyWebhook(
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
if (!matched) return { ok: false, reason: "invalid_signature" }
|
|
192
|
-
// The signature covers `t`, so `t` is authentic once matched
|
|
192
|
+
// The signature covers `t`, so `t` is authentic once matched - now enforce the replay window.
|
|
193
193
|
const now = options.now ?? Math.floor(Date.now() / 1000)
|
|
194
194
|
const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS
|
|
195
195
|
if (Math.abs(now - parsed.t) > tolerance) {
|
package/src/server/websocket.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebSocket seam
|
|
2
|
+
* WebSocket seam - portable types shared by `@nifrajs/core`'s `app.ws()` and every serving adapter
|
|
3
3
|
* (Bun `listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler` on Workers). No runtime code here, so
|
|
4
4
|
* it stays edge-safe and dependency-free; each adapter implements {@link NifraWebSocket} over its
|
|
5
5
|
* runtime's native socket and dispatches to the handler.
|
|
6
6
|
*
|
|
7
|
-
* A WS upgrade can't go through `app.fetch` (which has no socket)
|
|
7
|
+
* A WS upgrade can't go through `app.fetch` (which has no socket) - it's adapter-integrated. The
|
|
8
8
|
* adapter calls `app.resolveWebSocketUpgrade(req)` (runs the route's `upgrade()` guard in a real
|
|
9
9
|
* request context), then performs the runtime upgrade with the returned data.
|
|
10
10
|
*/
|
|
@@ -33,22 +33,22 @@ type WsMessageInput<Schema extends StandardSchemaV1 | undefined> = Schema extend
|
|
|
33
33
|
export interface NifraWebSocket<Data = unknown> {
|
|
34
34
|
/** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */
|
|
35
35
|
send(data: string | ArrayBufferView | ArrayBuffer): void
|
|
36
|
-
/** Close the connection (optional code 1000
|
|
36
|
+
/** Close the connection (optional code 1000-4999 + short reason). */
|
|
37
37
|
close(code?: number, reason?: string): void
|
|
38
38
|
/** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */
|
|
39
39
|
readonly readyState: number
|
|
40
|
-
/** Join a pub/sub topic
|
|
40
|
+
/** Join a pub/sub topic - `app.publish(topic, data)` then reaches this connection. Idempotent. */
|
|
41
41
|
subscribe(topic: string): void
|
|
42
42
|
/** Leave a pub/sub topic. Idempotent. (All topics are dropped automatically on close.) */
|
|
43
43
|
unsubscribe(topic: string): void
|
|
44
|
-
/** Per-connection state
|
|
44
|
+
/** Per-connection state - seeded by `upgrade()`, mutable for the connection's lifetime. */
|
|
45
45
|
data: Data
|
|
46
46
|
/** Escape hatch to the runtime's native socket (Bun `ServerWebSocket`, Web `WebSocket`, `ws`). */
|
|
47
47
|
readonly raw: unknown
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only**
|
|
51
|
+
* In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** -
|
|
52
52
|
* topics live in this process's memory, so a multi-instance deploy (multiple servers behind a load
|
|
53
53
|
* balancer) needs an external fan-out (Redis pub/sub, a Cloudflare Durable Object, NATS, …) bridged to
|
|
54
54
|
* `app.publish`. The same registry instance is shared by an app's connections and its `publish`.
|
|
@@ -76,12 +76,12 @@ export class TopicRegistry {
|
|
|
76
76
|
const subs = this.topics.get(topic)
|
|
77
77
|
if (subs !== undefined) {
|
|
78
78
|
subs.delete(ws)
|
|
79
|
-
if (subs.size === 0) this.topics.delete(topic) // reclaim empty topics
|
|
79
|
+
if (subs.size === 0) this.topics.delete(topic) // reclaim empty topics - no unbounded growth
|
|
80
80
|
}
|
|
81
81
|
this.memberships.get(ws)?.delete(topic)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/** Drop every subscription for a connection
|
|
84
|
+
/** Drop every subscription for a connection - called on close so a registry never leaks dead sockets. */
|
|
85
85
|
unsubscribeAll(ws: NifraWebSocket): void {
|
|
86
86
|
const mine = this.memberships.get(ws)
|
|
87
87
|
if (mine === undefined) return
|
|
@@ -110,7 +110,7 @@ export class TopicRegistry {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* The request-context subset the `upgrade()` guard sees
|
|
113
|
+
* The request-context subset the `upgrade()` guard sees - the same lazy accessors a route handler's
|
|
114
114
|
* `c` has (cookies/headers/env are read straight off the upgrade request). Structurally a slice of the
|
|
115
115
|
* core `RawContext`, so the real context object satisfies it.
|
|
116
116
|
*/
|
|
@@ -134,7 +134,7 @@ export interface WebSocketHandler<
|
|
|
134
134
|
Send extends StandardSchemaV1 | undefined = undefined,
|
|
135
135
|
> {
|
|
136
136
|
/**
|
|
137
|
-
* Cross-site WebSocket hijacking (CSWSH) guard
|
|
137
|
+
* Cross-site WebSocket hijacking (CSWSH) guard - checked BEFORE `upgrade()`. A browser does not
|
|
138
138
|
* apply CORS to WebSocket handshakes and DOES send the page's cookies, so without an Origin check
|
|
139
139
|
* any site can open an authenticated socket to your app. Set this to lock the route to known
|
|
140
140
|
* origins: a string allow-list (exact `Origin` header match) or a predicate. A request whose
|
|
@@ -145,11 +145,11 @@ export interface WebSocketHandler<
|
|
|
145
145
|
* whose host differs from the request's) is rejected with `403`. Non-browser clients (no `Origin`) and
|
|
146
146
|
* same-origin browsers pass. Set this to allow specific cross-origin clients, or `() => true` for a
|
|
147
147
|
* genuinely public socket. Non-browser clients can spoof `Origin`, so this is a browser-CSWSH defense,
|
|
148
|
-
* not authentication
|
|
148
|
+
* not authentication - pair it with auth in `upgrade()`.
|
|
149
149
|
*/
|
|
150
150
|
allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
|
|
151
151
|
/**
|
|
152
|
-
* Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins})
|
|
152
|
+
* Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) - the
|
|
153
153
|
* place to authenticate or rate-limit. Return the initial per-connection `data` (→ `ws.data`), or a
|
|
154
154
|
* `Response` to **reject** the upgrade (the client never connects). Omit to accept with
|
|
155
155
|
* `data: undefined`. A thrown error rejects with a flat 500.
|
|
@@ -188,10 +188,10 @@ export interface WebSocketHandler<
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
* The outcome of `app.resolveWebSocketUpgrade(req)`
|
|
192
|
-
* - `pass`
|
|
193
|
-
* - `reject`
|
|
194
|
-
* - `upgrade`
|
|
191
|
+
* The outcome of `app.resolveWebSocketUpgrade(req)` - for serving adapters:
|
|
192
|
+
* - `pass` - not a WS upgrade for a registered WS route; handle as a normal HTTP request.
|
|
193
|
+
* - `reject` - a WS route matched but `upgrade()` rejected (or the path was malformed); return `response`.
|
|
194
|
+
* - `upgrade` - perform the runtime upgrade, then dispatch the native socket's events to `handler`,
|
|
195
195
|
* seeding `ws.data` with `data`.
|
|
196
196
|
*/
|
|
197
197
|
export type WebSocketUpgradeOutcome =
|
|
@@ -201,7 +201,7 @@ export type WebSocketUpgradeOutcome =
|
|
|
201
201
|
readonly kind: "upgrade"
|
|
202
202
|
readonly handler: WebSocketHandler
|
|
203
203
|
readonly data: unknown
|
|
204
|
-
/** The app's pub/sub registry
|
|
204
|
+
/** The app's pub/sub registry - the adapter wires `ws.subscribe` + close-cleanup to it. */
|
|
205
205
|
readonly pubsub: TopicRegistry
|
|
206
206
|
/** The installed runtime's {@link attachWebSocket}, carried on the outcome so an adapter can wire
|
|
207
207
|
* a standard socket without a static import of the WS implementation (which would defeat the
|
|
@@ -218,7 +218,7 @@ export type WsAttach = (
|
|
|
218
218
|
) => NifraWebSocket
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
|
-
* A standard server-side `WebSocket`
|
|
221
|
+
* A standard server-side `WebSocket` - the half returned by Deno's `Deno.upgradeWebSocket` and the
|
|
222
222
|
* Workers `WebSocketPair`. {@link attachWebSocket} wires one to a nifra handler, so the Deno and Workers
|
|
223
223
|
* bridges share all the dispatch/normalization/error-isolation logic (only the upgrade call differs).
|
|
224
224
|
*/
|
|
@@ -235,14 +235,14 @@ function toBinary(raw: unknown): Uint8Array {
|
|
|
235
235
|
if (raw instanceof Uint8Array) return raw
|
|
236
236
|
if (raw instanceof ArrayBuffer) return new Uint8Array(raw)
|
|
237
237
|
if (ArrayBuffer.isView(raw)) return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
238
|
-
return new Uint8Array(0) // unknown (e.g. an unconverted Blob)
|
|
238
|
+
return new Uint8Array(0) // unknown (e.g. an unconverted Blob) - adapters set binaryType to avoid this
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
242
|
* Wire a standard server-side `WebSocket` to a nifra {@link WebSocketHandler}, returning the portable
|
|
243
243
|
* {@link NifraWebSocket}. Shared by the Deno and Workers bridges. `openNow` fires `open` immediately
|
|
244
244
|
* (Workers, where the socket is already open after `accept()`); otherwise `open` waits for the socket's
|
|
245
|
-
* `open` event (Deno). Lifecycle callbacks are error-isolated
|
|
245
|
+
* `open` event (Deno). Lifecycle callbacks are error-isolated - a throw (or async rejection) routes to
|
|
246
246
|
* `error()` and never tears the process down; binary frames are normalized to `Uint8Array`.
|
|
247
247
|
*/
|
|
248
248
|
export function attachWebSocket(
|
|
@@ -269,7 +269,7 @@ export function attachWebSocket(
|
|
|
269
269
|
const r = handler.error(ws, error)
|
|
270
270
|
if (r instanceof Promise) r.catch(() => {})
|
|
271
271
|
} catch {
|
|
272
|
-
/* the error handler itself failed
|
|
272
|
+
/* the error handler itself failed - last resort, swallow */
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
const safe = (call: () => MaybePromise<void>): void => {
|
|
@@ -306,7 +306,7 @@ export function attachWebSocket(
|
|
|
306
306
|
const WS_MESSAGE_DECODER = new TextDecoder()
|
|
307
307
|
|
|
308
308
|
/**
|
|
309
|
-
* If the handler declares a `messageSchema`, return a copy whose `message` validates each frame
|
|
309
|
+
* If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
|
|
310
310
|
* parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
|
|
311
311
|
* `onInvalidMessage` on failure. Returns the handler unchanged when no schema is set. Called once at
|
|
312
312
|
* `app.ws()` registration, so every adapter dispatches validated messages with no per-adapter code.
|
package/src/server/ws-bun.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bun WebSocket plumbing
|
|
2
|
+
* Bun WebSocket plumbing - the `websocket` dispatcher config `listen()` hands to Bun's serve when
|
|
3
3
|
* the app has WS routes. Lives behind the `@nifrajs/core/ws` subpath (via `ws-hook.ts`) so the base
|
|
4
4
|
* bundle of a no-WebSocket app never ships it; `server.ts` only imports the **types** from here
|
|
5
|
-
* (erased). No `Bun.*` calls here
|
|
5
|
+
* (erased). No `Bun.*` calls here - the runtime seam stays in `server.ts` (see runtime-boundary.test).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { NifraWebSocket, TopicRegistry, WebSocketData, WebSocketHandler } from "./websocket.ts"
|
|
@@ -16,7 +16,7 @@ export interface BunWsData {
|
|
|
16
16
|
nifra?: NifraWebSocket
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Structural view of Bun's `ServerWebSocket`
|
|
19
|
+
/** Structural view of Bun's `ServerWebSocket` - keeps `Bun.*` types out of the public `.d.ts`
|
|
20
20
|
* (`listen()` casts for the same reason). The real socket satisfies this. */
|
|
21
21
|
export interface BunSocket {
|
|
22
22
|
send(data: string | ArrayBufferView | ArrayBuffer, compress?: boolean): number
|
|
@@ -80,7 +80,7 @@ function dispatchWsCallback(
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** The shared Bun `websocket` dispatcher config for one app
|
|
83
|
+
/** The shared Bun `websocket` dispatcher config for one app - each connection's
|
|
84
84
|
* `ws.data.handler` is the matched route's handler, set by `server.upgrade`. */
|
|
85
85
|
export function createBunWsHandlers(topics: TopicRegistry): BunWsHandlers {
|
|
86
86
|
return {
|
package/src/server.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
|
12
12
|
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
13
|
+
export { isSameOriginRequest } from "./internal/same-origin.ts"
|
|
13
14
|
export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
|
|
14
15
|
export type {
|
|
15
16
|
InferInput,
|
|
@@ -35,6 +36,13 @@ export {
|
|
|
35
36
|
signValue,
|
|
36
37
|
unsignValue,
|
|
37
38
|
} from "./server/cookies.ts"
|
|
39
|
+
export {
|
|
40
|
+
type DurableObjectNamespaceLike,
|
|
41
|
+
type ExecutionContext,
|
|
42
|
+
type ScheduledController,
|
|
43
|
+
type ScheduledHandler,
|
|
44
|
+
toFetchHandler,
|
|
45
|
+
} from "./server/edge.ts"
|
|
38
46
|
export {
|
|
39
47
|
commonSecretPatterns,
|
|
40
48
|
jsonLogger,
|
|
@@ -49,11 +57,9 @@ export {
|
|
|
49
57
|
type AdmissionController,
|
|
50
58
|
type AdmissionDecision,
|
|
51
59
|
type AnyServer,
|
|
52
|
-
type DurableObjectNamespaceLike,
|
|
53
60
|
defineIdentityPlugin,
|
|
54
61
|
definePlugin,
|
|
55
62
|
defineRouterPlugin,
|
|
56
|
-
type ExecutionContext,
|
|
57
63
|
type Handler,
|
|
58
64
|
type IdentityPlugin,
|
|
59
65
|
type McpPromptDescriptor,
|
|
@@ -67,13 +73,10 @@ export {
|
|
|
67
73
|
type ResponseFinalization,
|
|
68
74
|
type RouteDescriptor,
|
|
69
75
|
type RunningServer,
|
|
70
|
-
type ScheduledController,
|
|
71
|
-
type ScheduledHandler,
|
|
72
76
|
Server,
|
|
73
77
|
type ServerOptions,
|
|
74
78
|
server,
|
|
75
79
|
type ToolAnnotations,
|
|
76
|
-
toFetchHandler,
|
|
77
80
|
} from "./server/server.ts"
|
|
78
81
|
export type {
|
|
79
82
|
SSEContext,
|
package/src/transport-codec.ts
CHANGED
|
@@ -19,12 +19,31 @@ export interface TransportCodec {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export class TransportCodecError extends TypeError {
|
|
22
|
-
constructor(message: string) {
|
|
23
|
-
super(message)
|
|
22
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
23
|
+
super(message, options)
|
|
24
24
|
this.name = "TransportCodecError"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Run a decode step and normalize whatever it throws into {@link TransportCodecError}.
|
|
30
|
+
*
|
|
31
|
+
* Decoding is the one place this module touches attacker-controlled bytes, and the primitives it
|
|
32
|
+
* delegates to raise their own native types - `JSON.parse` throws `SyntaxError`, a BYO codec throws
|
|
33
|
+
* whatever it likes. Every other failure here is a `TransportCodecError`, so letting those escape
|
|
34
|
+
* would mean a malformed payload - the single most likely hostile input - is the one case that slips
|
|
35
|
+
* past a caller catching the documented error type. The original is kept as `cause`, so the parse
|
|
36
|
+
* failure stays diagnosable; a codec already speaking the contract is re-thrown untouched.
|
|
37
|
+
*/
|
|
38
|
+
function decodeOrThrow<T>(decode: () => T, message: string): T {
|
|
39
|
+
try {
|
|
40
|
+
return decode()
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
if (cause instanceof TransportCodecError) throw cause
|
|
43
|
+
throw new TransportCodecError(message, { cause })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
function maxBytesOf(options: TransportDecodeOptions): number {
|
|
29
48
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
|
|
30
49
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
@@ -171,15 +190,26 @@ function appendVary(current: string | null, name: string): string {
|
|
|
171
190
|
return values.includes(name.toLowerCase()) ? current : `${current}, ${name}`
|
|
172
191
|
}
|
|
173
192
|
|
|
174
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Read a response body into memory, refusing to exceed `maxBytes`.
|
|
195
|
+
*
|
|
196
|
+
* Bounded while STREAMING rather than after the fact: the `content-length` shortcut only helps when a
|
|
197
|
+
* sender declares one, so the loop also counts as it goes and cancels the reader the moment the total
|
|
198
|
+
* passes the limit. A cap that buffers first and complains afterwards has already spent the memory it
|
|
199
|
+
* was meant to protect.
|
|
200
|
+
*
|
|
201
|
+
* Exported so the client can bound a plain-text body with the same reader it bounds JSON with, rather
|
|
202
|
+
* than growing a second copy of this loop that could drift from it.
|
|
203
|
+
*/
|
|
204
|
+
export async function readBoundedBytes(
|
|
175
205
|
response: Response,
|
|
176
206
|
options: TransportDecodeOptions,
|
|
177
|
-
): Promise<
|
|
207
|
+
): Promise<Uint8Array> {
|
|
178
208
|
const maxBytes = maxBytesOf(options)
|
|
179
209
|
const declared = response.headers.get("content-length")
|
|
180
210
|
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
|
|
181
211
|
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
182
|
-
if (response.body === null) return
|
|
212
|
+
if (response.body === null) return new Uint8Array(0)
|
|
183
213
|
const reader = response.body.getReader()
|
|
184
214
|
const chunks: Uint8Array[] = []
|
|
185
215
|
let total = 0
|
|
@@ -203,7 +233,18 @@ async function readBoundedText(
|
|
|
203
233
|
bytes.set(chunk, offset)
|
|
204
234
|
offset += chunk.byteLength
|
|
205
235
|
}
|
|
206
|
-
return
|
|
236
|
+
return bytes
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function readBoundedText(
|
|
240
|
+
response: Response,
|
|
241
|
+
options: TransportDecodeOptions,
|
|
242
|
+
): Promise<string> {
|
|
243
|
+
const bytes = await readBoundedBytes(response, options)
|
|
244
|
+
return decodeOrThrow(
|
|
245
|
+
() => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
246
|
+
"transport payload is not valid UTF-8",
|
|
247
|
+
)
|
|
207
248
|
}
|
|
208
249
|
|
|
209
250
|
export async function decodeTransportResponse(
|
|
@@ -213,7 +254,11 @@ export async function decodeTransportResponse(
|
|
|
213
254
|
): Promise<unknown> {
|
|
214
255
|
const codec = registry.forContentType(response.headers.get("content-type"))
|
|
215
256
|
const text = await readBoundedText(response, options)
|
|
216
|
-
|
|
257
|
+
if (text === "") return undefined
|
|
258
|
+
return decodeOrThrow(
|
|
259
|
+
() => codec.decode(text),
|
|
260
|
+
`malformed transport body for codec ${codec.id}@${codec.version}`,
|
|
261
|
+
)
|
|
217
262
|
}
|
|
218
263
|
|
|
219
264
|
interface TransportFrame {
|
|
@@ -235,7 +280,10 @@ export function decodeTransportFrame(
|
|
|
235
280
|
options: TransportDecodeOptions = {},
|
|
236
281
|
): unknown {
|
|
237
282
|
assertBounded(frame, options)
|
|
238
|
-
const envelope =
|
|
283
|
+
const envelope = decodeOrThrow(
|
|
284
|
+
() => JSON.parse(frame),
|
|
285
|
+
"malformed transport frame",
|
|
286
|
+
) as Partial<TransportFrame>
|
|
239
287
|
if (
|
|
240
288
|
envelope === null ||
|
|
241
289
|
typeof envelope !== "object" ||
|
|
@@ -245,6 +293,11 @@ export function decodeTransportFrame(
|
|
|
245
293
|
) {
|
|
246
294
|
throw new TransportCodecError("malformed transport frame")
|
|
247
295
|
}
|
|
248
|
-
|
|
249
|
-
|
|
296
|
+
const payload = envelope.payload
|
|
297
|
+
assertBounded(payload, options)
|
|
298
|
+
const codec = registry.byIdentity(envelope.codec, envelope.version as number)
|
|
299
|
+
return decodeOrThrow(
|
|
300
|
+
() => codec.decode(payload),
|
|
301
|
+
`malformed transport frame payload for codec ${codec.id}@${codec.version}`,
|
|
302
|
+
)
|
|
250
303
|
}
|