@nifrajs/core 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/errors.d.ts +21 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +26 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/index.d.ts +26 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +21 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/internal/brand.d.ts +11 -0
  12. package/dist/internal/brand.d.ts.map +1 -0
  13. package/dist/internal/brand.js +10 -0
  14. package/dist/internal/brand.js.map +1 -0
  15. package/dist/internal/secret.d.ts +9 -0
  16. package/dist/internal/secret.d.ts.map +1 -0
  17. package/dist/internal/secret.js +19 -0
  18. package/dist/internal/secret.js.map +1 -0
  19. package/dist/router/router.d.ts +61 -0
  20. package/dist/router/router.d.ts.map +1 -0
  21. package/dist/router/router.js +324 -0
  22. package/dist/router/router.js.map +1 -0
  23. package/dist/schema/standard.d.ts +46 -0
  24. package/dist/schema/standard.d.ts.map +1 -0
  25. package/dist/schema/standard.js +20 -0
  26. package/dist/schema/standard.js.map +1 -0
  27. package/dist/seo.d.ts +48 -0
  28. package/dist/seo.d.ts.map +1 -0
  29. package/dist/seo.js +109 -0
  30. package/dist/seo.js.map +1 -0
  31. package/dist/server/body.d.ts +39 -0
  32. package/dist/server/body.d.ts.map +1 -0
  33. package/dist/server/body.js +85 -0
  34. package/dist/server/body.js.map +1 -0
  35. package/dist/server/context.d.ts +111 -0
  36. package/dist/server/context.d.ts.map +1 -0
  37. package/dist/server/context.js +2 -0
  38. package/dist/server/context.js.map +1 -0
  39. package/dist/server/contract.d.ts +142 -0
  40. package/dist/server/contract.d.ts.map +1 -0
  41. package/dist/server/contract.js +59 -0
  42. package/dist/server/contract.js.map +1 -0
  43. package/dist/server/cookies.d.ts +41 -0
  44. package/dist/server/cookies.d.ts.map +1 -0
  45. package/dist/server/cookies.js +181 -0
  46. package/dist/server/cookies.js.map +1 -0
  47. package/dist/server/logger.d.ts +49 -0
  48. package/dist/server/logger.d.ts.map +1 -0
  49. package/dist/server/logger.js +125 -0
  50. package/dist/server/logger.js.map +1 -0
  51. package/dist/server/registry.d.ts +46 -0
  52. package/dist/server/registry.d.ts.map +1 -0
  53. package/dist/server/registry.js +2 -0
  54. package/dist/server/registry.js.map +1 -0
  55. package/dist/server/server.d.ts +528 -0
  56. package/dist/server/server.d.ts.map +1 -0
  57. package/dist/server/server.js +1660 -0
  58. package/dist/server/server.js.map +1 -0
  59. package/dist/server/sse.d.ts +54 -0
  60. package/dist/server/sse.d.ts.map +1 -0
  61. package/dist/server/sse.js +126 -0
  62. package/dist/server/sse.js.map +1 -0
  63. package/dist/server/webhook.d.ts +42 -0
  64. package/dist/server/webhook.d.ts.map +1 -0
  65. package/dist/server/webhook.js +149 -0
  66. package/dist/server/webhook.js.map +1 -0
  67. package/dist/server/websocket.d.ts +152 -0
  68. package/dist/server/websocket.d.ts.map +1 -0
  69. package/dist/server/websocket.js +178 -0
  70. package/dist/server/websocket.js.map +1 -0
  71. package/dist/server/ws-bun.d.ts +27 -0
  72. package/dist/server/ws-bun.d.ts.map +1 -0
  73. package/dist/server/ws-bun.js +82 -0
  74. package/dist/server/ws-bun.js.map +1 -0
  75. package/dist/server/ws-hook.d.ts +36 -0
  76. package/dist/server/ws-hook.d.ts.map +1 -0
  77. package/dist/server/ws-hook.js +18 -0
  78. package/dist/server/ws-hook.js.map +1 -0
  79. package/dist/ws.d.ts +19 -0
  80. package/dist/ws.d.ts.map +1 -0
  81. package/dist/ws.js +26 -0
  82. package/dist/ws.js.map +1 -0
  83. package/package.json +63 -0
  84. package/src/errors.ts +41 -0
  85. package/src/index.ts +115 -0
  86. package/src/internal/brand.ts +11 -0
  87. package/src/internal/secret.ts +24 -0
  88. package/src/router/router.ts +429 -0
  89. package/src/schema/standard.ts +76 -0
  90. package/src/seo.ts +155 -0
  91. package/src/server/body.ts +89 -0
  92. package/src/server/context.ts +124 -0
  93. package/src/server/contract.ts +209 -0
  94. package/src/server/cookies.ts +198 -0
  95. package/src/server/logger.ts +167 -0
  96. package/src/server/registry.ts +63 -0
  97. package/src/server/server.ts +2479 -0
  98. package/src/server/sse.ts +161 -0
  99. package/src/server/webhook.ts +207 -0
  100. package/src/server/websocket.ts +308 -0
  101. package/src/server/ws-bun.ts +106 -0
  102. package/src/server/ws-hook.ts +53 -0
  103. package/src/ws.ts +40 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Server-Sent Events — a portable `text/event-stream` response helper.
3
+ *
4
+ * `sse(c, run)` returns a streaming `Response` a handler can return directly. The `run` callback
5
+ * receives a stream it pushes messages to; the connection stays open until `run`'s promise resolves,
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) — it only uses a Web `ReadableStream`.
8
+ *
9
+ * // finite stream — run resolves, the connection closes
10
+ * app.get("/ticks", (c) => sse(c, async (stream) => {
11
+ * for (let i = 0; i < 5; i++) {
12
+ * stream.send({ event: "tick", data: String(i) })
13
+ * await new Promise((r) => setTimeout(r, 1000))
14
+ * }
15
+ * }))
16
+ *
17
+ * // event-driven — keep `run` pending until the client leaves
18
+ * app.get("/feed", (c) => sse(c, (stream) => {
19
+ * const off = bus.subscribe((e) => stream.send({ data: JSON.stringify(e) }))
20
+ * return new Promise<void>((resolve) =>
21
+ * stream.signal.addEventListener("abort", () => { off(); resolve() }, { once: true }))
22
+ * }, { keepAlive: 15_000 }))
23
+ */
24
+
25
+ /** One SSE frame. Every field is optional; `data` may be multi-line (emitted as multiple `data:` lines). */
26
+ export interface SSEMessage {
27
+ readonly data?: string
28
+ readonly event?: string
29
+ readonly id?: string
30
+ /** Client reconnection delay hint, ms (coerced to an integer). */
31
+ readonly retry?: number
32
+ /** A `: comment` line (e.g. a keep-alive ping). */
33
+ readonly comment?: string
34
+ }
35
+
36
+ /** The stream handed to the `run` callback. */
37
+ export interface SSEStream {
38
+ /** Enqueue an event frame. No-op once the stream is closed. */
39
+ send(message: SSEMessage): void
40
+ /** End the stream now. */
41
+ close(): void
42
+ /** Aborts when the client disconnects — use it to tear down subscriptions/loops. */
43
+ readonly signal: AbortSignal
44
+ }
45
+
46
+ /** Minimal context shape `sse` needs — the live request, for its client-disconnect signal. */
47
+ export interface SSEContext {
48
+ readonly req: Request
49
+ }
50
+
51
+ export interface SSEInit {
52
+ readonly status?: number
53
+ // Derived from the `Headers` constructor so we don't depend on the DOM lib's `HeadersInit`
54
+ // (core is DOM-free for edge-safety).
55
+ readonly headers?: ConstructorParameters<typeof Headers>[0]
56
+ /** Send a `: ` comment ping every N ms so idle proxies don't drop the connection. */
57
+ readonly keepAlive?: number
58
+ }
59
+
60
+ const CRLF = /[\r\n]/g
61
+ const LINE_SPLIT = /\r\n|\r|\n/
62
+
63
+ /** `event:`/`id:`/`comment` must be single-line — stripping CR/LF prevents SSE frame injection. */
64
+ function formatMessage(m: SSEMessage): string {
65
+ let frame = ""
66
+ if (m.comment !== undefined) frame += `: ${m.comment.replace(CRLF, "")}\n`
67
+ if (m.event !== undefined) frame += `event: ${m.event.replace(CRLF, "")}\n`
68
+ if (m.id !== undefined) frame += `id: ${m.id.replace(CRLF, "")}\n`
69
+ if (m.retry !== undefined) frame += `retry: ${Math.trunc(m.retry)}\n`
70
+ if (m.data !== undefined) {
71
+ for (const line of m.data.split(LINE_SPLIT)) frame += `data: ${line}\n`
72
+ }
73
+ return `${frame}\n` // the blank line terminates the event
74
+ }
75
+
76
+ let neverAbort: AbortSignal | undefined
77
+ function getNeverAbort(): AbortSignal {
78
+ neverAbort ??= new AbortController().signal
79
+ return neverAbort
80
+ }
81
+
82
+ export function sse(
83
+ c: SSEContext,
84
+ run: (stream: SSEStream) => void | Promise<void>,
85
+ init: SSEInit = {},
86
+ ): Response {
87
+ const encoder = new TextEncoder()
88
+ const requestSignal: AbortSignal | undefined = c.req?.signal
89
+ let closed = false
90
+ let heartbeat: ReturnType<typeof setInterval> | undefined
91
+ let onAbort: (() => void) | undefined
92
+
93
+ const teardown = (): void => {
94
+ if (heartbeat !== undefined) clearInterval(heartbeat)
95
+ if (requestSignal !== undefined && onAbort !== undefined) {
96
+ requestSignal.removeEventListener("abort", onAbort)
97
+ }
98
+ }
99
+
100
+ const body = new ReadableStream<Uint8Array>({
101
+ start(controller) {
102
+ const close = (): void => {
103
+ if (closed) return
104
+ closed = true
105
+ teardown()
106
+ try {
107
+ controller.close()
108
+ } catch {
109
+ // Already closed by the runtime's cancel() — safe to ignore.
110
+ }
111
+ }
112
+ const stream: SSEStream = {
113
+ send(message) {
114
+ if (!closed) controller.enqueue(encoder.encode(formatMessage(message)))
115
+ },
116
+ close,
117
+ signal: requestSignal ?? getNeverAbort(),
118
+ }
119
+
120
+ // Stop the moment the client goes away.
121
+ if (requestSignal !== undefined) {
122
+ if (requestSignal.aborted) {
123
+ close()
124
+ return
125
+ }
126
+ onAbort = close
127
+ requestSignal.addEventListener("abort", onAbort, { once: true })
128
+ }
129
+
130
+ if (init.keepAlive !== undefined && init.keepAlive > 0) {
131
+ heartbeat = setInterval(() => stream.send({ comment: "" }), init.keepAlive)
132
+ // Don't let the ping keep a Node process alive on its own.
133
+ ;(heartbeat as { unref?: () => void }).unref?.()
134
+ }
135
+ // An async IIFE so a synchronous throw in `run` becomes a rejection we can surface.
136
+ ;(async () => {
137
+ await run(stream)
138
+ })().then(close, (err: unknown) => {
139
+ if (closed) return
140
+ closed = true
141
+ teardown()
142
+ try {
143
+ controller.error(err instanceof Error ? err : new Error("sse_producer_failed"))
144
+ } catch {
145
+ // Stream already torn down.
146
+ }
147
+ })
148
+ },
149
+ cancel() {
150
+ // Consumer/runtime canceled (client gone) — pending sends become no-ops.
151
+ closed = true
152
+ teardown()
153
+ },
154
+ })
155
+
156
+ const headers = new Headers(init.headers)
157
+ headers.set("content-type", "text/event-stream; charset=utf-8")
158
+ headers.set("cache-control", "no-cache, no-transform")
159
+ if (!headers.has("x-accel-buffering")) headers.set("x-accel-buffering", "no") // defeat proxy buffering
160
+ return new Response(body, { status: init.status ?? 200, headers })
161
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Webhook signature verification — **read the raw body, verify the HMAC, *then* parse**. A handler
3
+ * that `JSON.parse`s a webhook before checking the signature is trusting an unauthenticated payload;
4
+ * this reads the body bounded (DoS guard, shared with `c.boundedBody`) and verifies before handing
5
+ * back the raw text for the handler to parse with its own schema.
6
+ *
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 — so a wrong signature
9
+ * can't be discovered through timing. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
10
+ *
11
+ * Presets cover Stripe (`t=…,v1=…` with a replay-window check) and GitHub (`sha256=…`); `generic`
12
+ * takes an explicit header + encoding for any other provider.
13
+ */
14
+ import { requireSecretBytes } from "../internal/secret.ts"
15
+ import { readBoundedBytes } from "./body.ts"
16
+
17
+ const TEXT = new TextEncoder()
18
+ const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB — webhook payloads are small; cap the raw read.
19
+ const DEFAULT_TOLERANCE_SECONDS = 300 // 5-min replay window for timestamped schemes (Stripe's default).
20
+
21
+ export type WebhookProvider = "stripe" | "github" | "generic"
22
+ export type SignatureEncoding = "hex" | "base64"
23
+
24
+ export interface VerifyWebhookOptions {
25
+ /** Known-provider preset. Default `"generic"` (which requires {@link header}). */
26
+ readonly provider?: WebhookProvider
27
+ /** Header carrying the signature. Required for `generic`; overrides the preset otherwise. */
28
+ readonly header?: string
29
+ /** How the signature is encoded in the header. Default `"hex"`. */
30
+ readonly encoding?: SignatureEncoding
31
+ /** A prefix stripped from the header value before decoding, e.g. `"sha256="`. */
32
+ readonly prefix?: string
33
+ /** Max raw body bytes to read before rejecting (`payload_too_large`). Default 1 MiB. */
34
+ readonly maxBytes?: number
35
+ /** Replay window in seconds for timestamped schemes (Stripe). Default 300. */
36
+ readonly toleranceSeconds?: number
37
+ /** Current unix time (seconds); defaults to `Date.now()/1000`. Injectable for tests. */
38
+ readonly now?: number
39
+ }
40
+
41
+ export type WebhookFailureReason =
42
+ | "missing_signature"
43
+ | "invalid_signature"
44
+ | "timestamp_out_of_tolerance"
45
+ | "malformed_signature"
46
+ | "payload_too_large"
47
+ | "invalid_content_length"
48
+
49
+ /** Verified ⇒ the raw `payload` text (parse it with your schema). Rejected ⇒ a stable `reason`. */
50
+ export type WebhookResult =
51
+ | { readonly ok: true; readonly payload: string }
52
+ | { readonly ok: false; readonly reason: WebhookFailureReason }
53
+
54
+ interface Preset {
55
+ readonly header: string
56
+ readonly encoding: SignatureEncoding
57
+ readonly prefix: string
58
+ readonly timestamped: boolean
59
+ }
60
+
61
+ const PRESETS: Record<WebhookProvider, Preset> = {
62
+ stripe: { header: "stripe-signature", encoding: "hex", prefix: "", timestamped: true },
63
+ github: { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=", timestamped: false },
64
+ generic: { header: "", encoding: "hex", prefix: "", timestamped: false },
65
+ }
66
+
67
+ const importHmacKey = (secret: string): Promise<CryptoKey> => {
68
+ requireSecretBytes(secret, "webhook")
69
+ return crypto.subtle.importKey(
70
+ "raw",
71
+ TEXT.encode(secret),
72
+ { name: "HMAC", hash: "SHA-256" },
73
+ false,
74
+ ["verify"],
75
+ )
76
+ }
77
+
78
+ // Decoders return `Uint8Array<ArrayBuffer>` (not the generic `Uint8Array<ArrayBufferLike>`) so they
79
+ // satisfy WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics. `null` = the
80
+ // header value wasn't valid for the declared encoding (treated as a malformed signature, never a 500).
81
+ const fromHex = (hex: string): Uint8Array<ArrayBuffer> | null => {
82
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null
83
+ const out = new Uint8Array(hex.length / 2)
84
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
85
+ return out
86
+ }
87
+
88
+ const fromBase64 = (value: string): Uint8Array<ArrayBuffer> | null => {
89
+ try {
90
+ const bin = atob(value)
91
+ const out = new Uint8Array(bin.length)
92
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
93
+ return out
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ const decodeSignature = (
100
+ value: string,
101
+ encoding: SignatureEncoding,
102
+ ): Uint8Array<ArrayBuffer> | null => (encoding === "hex" ? fromHex(value) : fromBase64(value))
103
+
104
+ /** True iff `sig` is a valid HMAC-SHA256 of `payload` under any of `keys` (constant-time per check). */
105
+ async function verifyAny(
106
+ keys: readonly CryptoKey[],
107
+ sig: Uint8Array<ArrayBuffer>,
108
+ payload: Uint8Array,
109
+ ): Promise<boolean> {
110
+ for (const key of keys) {
111
+ if (await crypto.subtle.verify("HMAC", key, sig, payload as Uint8Array<ArrayBuffer>))
112
+ return true
113
+ }
114
+ return false
115
+ }
116
+
117
+ // Parse a Stripe `Stripe-Signature` header: `t=<unix>,v1=<hex>[,v1=<hex>…]` (other schemes ignored).
118
+ function parseStripeHeader(value: string): { t: number; v1: string[] } | null {
119
+ let t: number | null = null
120
+ const v1: string[] = []
121
+ for (const part of value.split(",")) {
122
+ const eq = part.indexOf("=")
123
+ if (eq < 1) continue
124
+ const k = part.slice(0, eq).trim()
125
+ const v = part.slice(eq + 1).trim()
126
+ if (k === "t") {
127
+ if (!/^\d+$/.test(v)) return null
128
+ t = Number(v)
129
+ } else if (k === "v1") {
130
+ v1.push(v)
131
+ }
132
+ }
133
+ if (t === null || v1.length === 0) return null
134
+ return { t, v1 }
135
+ }
136
+
137
+ /**
138
+ * Verify a webhook request's signature and return its raw payload. Reads `req.body` (bounded), so the
139
+ * body is consumed — parse the returned `payload`, don't re-read the request.
140
+ *
141
+ * @param secret the signing secret (or an array, to accept either during a secret rotation).
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * const r = await verifyWebhook(c.req, env.STRIPE_WEBHOOK_SECRET, { provider: "stripe" })
146
+ * if (!r.ok) return c.json({ ok: false, error: r.reason }, 400)
147
+ * const event = StripeEvent.parse(JSON.parse(r.payload)) // validate at the trust boundary
148
+ * ```
149
+ */
150
+ export async function verifyWebhook(
151
+ req: Request,
152
+ secret: string | readonly string[],
153
+ options: VerifyWebhookOptions = {},
154
+ ): Promise<WebhookResult> {
155
+ const provider = options.provider ?? "generic"
156
+ const preset = PRESETS[provider]
157
+ const headerName = (options.header ?? preset.header).toLowerCase()
158
+ if (headerName === "") {
159
+ throw new Error('[nifra] verifyWebhook: a `header` is required for provider "generic"')
160
+ }
161
+ const encoding = options.encoding ?? preset.encoding
162
+ const prefix = options.prefix ?? preset.prefix
163
+
164
+ const read = await readBoundedBytes(req, options.maxBytes ?? DEFAULT_MAX_BYTES)
165
+ if (!read.ok) {
166
+ return {
167
+ ok: false,
168
+ reason: read.status === 413 ? "payload_too_large" : "invalid_content_length",
169
+ }
170
+ }
171
+ const payload = new TextDecoder().decode(read.bytes)
172
+
173
+ const headerValue = req.headers.get(headerName)
174
+ if (headerValue === null || headerValue === "") return { ok: false, reason: "missing_signature" }
175
+
176
+ const secrets = typeof secret === "string" ? [secret] : secret
177
+ const keys = await Promise.all(secrets.map(importHmacKey))
178
+
179
+ if (preset.timestamped) {
180
+ const parsed = parseStripeHeader(headerValue)
181
+ if (parsed === null) return { ok: false, reason: "malformed_signature" }
182
+ const signedPayload = TEXT.encode(`${parsed.t}.${payload}`)
183
+ let matched = false
184
+ for (const v1 of parsed.v1) {
185
+ const sig = decodeSignature(v1, encoding)
186
+ if (sig !== null && (await verifyAny(keys, sig, signedPayload))) {
187
+ matched = true
188
+ break
189
+ }
190
+ }
191
+ if (!matched) return { ok: false, reason: "invalid_signature" }
192
+ // The signature covers `t`, so `t` is authentic once matched — now enforce the replay window.
193
+ const now = options.now ?? Math.floor(Date.now() / 1000)
194
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS
195
+ if (Math.abs(now - parsed.t) > tolerance) {
196
+ return { ok: false, reason: "timestamp_out_of_tolerance" }
197
+ }
198
+ return { ok: true, payload }
199
+ }
200
+
201
+ const raw =
202
+ prefix !== "" && headerValue.startsWith(prefix) ? headerValue.slice(prefix.length) : headerValue
203
+ const sig = decodeSignature(raw, encoding)
204
+ if (sig === null) return { ok: false, reason: "malformed_signature" }
205
+ if (await verifyAny(keys, sig, read.bytes)) return { ok: true, payload }
206
+ return { ok: false, reason: "invalid_signature" }
207
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * WebSocket seam — portable types shared by `@nifrajs/core`'s `app.ws()` and every serving adapter
3
+ * (Bun `listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler` on Workers). No runtime code here, so
4
+ * it stays edge-safe and dependency-free; each adapter implements {@link NifraWebSocket} over its
5
+ * runtime's native socket and dispatches to the handler.
6
+ *
7
+ * A WS upgrade can't go through `app.fetch` (which has no socket) — it's adapter-integrated. The
8
+ * adapter calls `app.resolveWebSocketUpgrade(req)` (runs the route's `upgrade()` guard in a real
9
+ * request context), then performs the runtime upgrade with the returned data.
10
+ */
11
+
12
+ import type {
13
+ InferOutput,
14
+ StandardIssue,
15
+ StandardSchemaV1,
16
+ ValidationOutcome,
17
+ } from "../schema/standard.ts"
18
+ import { validateStandard } from "../schema/standard.ts"
19
+
20
+ type MaybePromise<T> = T | Promise<T>
21
+
22
+ /** A received frame, normalized across runtimes: text → `string`, binary → `Uint8Array`. */
23
+ export type WebSocketData = string | Uint8Array
24
+
25
+ /** The value `message` receives: the schema's validated output when a `messageSchema` is set on the
26
+ * handler, otherwise the raw frame (`string | Uint8Array`). */
27
+ type WsMessageInput<Schema extends StandardSchemaV1 | undefined> = Schema extends StandardSchemaV1
28
+ ? InferOutput<Schema>
29
+ : WebSocketData
30
+
31
+ /** The portable socket handed to WS lifecycle callbacks. Each adapter wraps its native socket. */
32
+ export interface NifraWebSocket<Data = unknown> {
33
+ /** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */
34
+ send(data: string | ArrayBufferView | ArrayBuffer): void
35
+ /** Close the connection (optional code 1000–4999 + short reason). */
36
+ close(code?: number, reason?: string): void
37
+ /** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */
38
+ readonly readyState: number
39
+ /** Join a pub/sub topic — `app.publish(topic, data)` then reaches this connection. Idempotent. */
40
+ subscribe(topic: string): void
41
+ /** Leave a pub/sub topic. Idempotent. (All topics are dropped automatically on close.) */
42
+ unsubscribe(topic: string): void
43
+ /** Per-connection state — seeded by `upgrade()`, mutable for the connection's lifetime. */
44
+ data: Data
45
+ /** Escape hatch to the runtime's native socket (Bun `ServerWebSocket`, Web `WebSocket`, `ws`). */
46
+ readonly raw: unknown
47
+ }
48
+
49
+ /**
50
+ * In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** —
51
+ * topics live in this process's memory, so a multi-instance deploy (multiple servers behind a load
52
+ * balancer) needs an external fan-out (Redis pub/sub, a Cloudflare Durable Object, NATS, …) bridged to
53
+ * `app.publish`. The same registry instance is shared by an app's connections and its `publish`.
54
+ */
55
+ export class TopicRegistry {
56
+ private readonly topics = new Map<string, Set<NifraWebSocket>>()
57
+ private readonly memberships = new WeakMap<NifraWebSocket, Set<string>>()
58
+
59
+ subscribe(topic: string, ws: NifraWebSocket): void {
60
+ let subs = this.topics.get(topic)
61
+ if (subs === undefined) {
62
+ subs = new Set()
63
+ this.topics.set(topic, subs)
64
+ }
65
+ subs.add(ws)
66
+ let mine = this.memberships.get(ws)
67
+ if (mine === undefined) {
68
+ mine = new Set()
69
+ this.memberships.set(ws, mine)
70
+ }
71
+ mine.add(topic)
72
+ }
73
+
74
+ unsubscribe(topic: string, ws: NifraWebSocket): void {
75
+ const subs = this.topics.get(topic)
76
+ if (subs !== undefined) {
77
+ subs.delete(ws)
78
+ if (subs.size === 0) this.topics.delete(topic) // reclaim empty topics — no unbounded growth
79
+ }
80
+ this.memberships.get(ws)?.delete(topic)
81
+ }
82
+
83
+ /** Drop every subscription for a connection — called on close so a registry never leaks dead sockets. */
84
+ unsubscribeAll(ws: NifraWebSocket): void {
85
+ const mine = this.memberships.get(ws)
86
+ if (mine === undefined) return
87
+ for (const topic of mine) {
88
+ const subs = this.topics.get(topic)
89
+ if (subs !== undefined) {
90
+ subs.delete(ws)
91
+ if (subs.size === 0) this.topics.delete(topic)
92
+ }
93
+ }
94
+ this.memberships.delete(ws)
95
+ }
96
+
97
+ /** Send `data` to every connection subscribed to `topic`. Per-socket send errors are isolated. */
98
+ publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
99
+ const subs = this.topics.get(topic)
100
+ if (subs === undefined) return
101
+ for (const ws of subs) {
102
+ try {
103
+ ws.send(data)
104
+ } catch {
105
+ /* a single dead/closing socket must not abort the broadcast to the rest */
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The request-context subset the `upgrade()` guard sees — the same lazy accessors a route handler's
113
+ * `c` has (cookies/headers/env are read straight off the upgrade request). Structurally a slice of the
114
+ * core `RawContext`, so the real context object satisfies it.
115
+ */
116
+ export interface WebSocketContext<Env = unknown> {
117
+ readonly req: Request
118
+ readonly params: Record<string, string>
119
+ readonly query: unknown
120
+ readonly cookies: Readonly<Record<string, string>>
121
+ readonly env: Env
122
+ readonly signal: AbortSignal
123
+ readonly waitUntil: (promise: Promise<unknown>) => void
124
+ boundedBody(maxBytes?: number): Promise<Uint8Array>
125
+ boundedJson<T = unknown>(maxBytes?: number): Promise<T>
126
+ }
127
+
128
+ /** A WebSocket route's lifecycle. All callbacks optional; only `message` is needed for an echo. */
129
+ export interface WebSocketHandler<
130
+ Data = unknown,
131
+ Env = unknown,
132
+ Schema extends StandardSchemaV1 | undefined = undefined,
133
+ > {
134
+ /**
135
+ * Cross-site WebSocket hijacking (CSWSH) guard — checked BEFORE `upgrade()`. A browser does not
136
+ * apply CORS to WebSocket handshakes and DOES send the page's cookies, so without an Origin check
137
+ * any site can open an authenticated socket to your app. Set this to lock the route to known
138
+ * origins: a string allow-list (exact `Origin` header match) or a predicate. A request whose
139
+ * `Origin` doesn't match (or is absent, for the allow-list form) is rejected with `403` before any
140
+ * per-connection work. Omit for no built-in check (same as today — then enforce it yourself in
141
+ * `upgrade()`). Non-browser clients can spoof `Origin`, so this is a browser-CSWSH defense, not
142
+ * authentication — pair it with auth in `upgrade()`.
143
+ */
144
+ allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
145
+ /**
146
+ * Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) — the
147
+ * place to authenticate or rate-limit. Return the initial per-connection `data` (→ `ws.data`), or a
148
+ * `Response` to **reject** the upgrade (the client never connects). Omit to accept with
149
+ * `data: undefined`. A thrown error rejects with a flat 500.
150
+ */
151
+ upgrade?(c: WebSocketContext<Env>): MaybePromise<Data | Response>
152
+ /**
153
+ * Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame.
154
+ * Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a
155
+ * non-JSON or schema-invalid frame is routed to `onInvalidMessage` (or dropped if that's omitted).
156
+ */
157
+ messageSchema?: Schema
158
+ open?(ws: NifraWebSocket<Data>): MaybePromise<void>
159
+ message?(ws: NifraWebSocket<Data>, data: WsMessageInput<Schema>): MaybePromise<void>
160
+ /** Inbound frame that failed JSON parse or `messageSchema` validation (only fires when a schema is
161
+ * set). `raw` is the original frame; `issues` are the Standard Schema issues (one synthetic issue for
162
+ * a JSON parse failure). Omit to silently drop invalid frames. */
163
+ onInvalidMessage?(
164
+ ws: NifraWebSocket<Data>,
165
+ issues: ReadonlyArray<StandardIssue>,
166
+ raw: WebSocketData,
167
+ ): MaybePromise<void>
168
+ close?(ws: NifraWebSocket<Data>, code: number, reason: string): MaybePromise<void>
169
+ error?(ws: NifraWebSocket<Data>, error: unknown): MaybePromise<void>
170
+ }
171
+
172
+ /**
173
+ * The outcome of `app.resolveWebSocketUpgrade(req)` — for serving adapters:
174
+ * - `pass` — not a WS upgrade for a registered WS route; handle as a normal HTTP request.
175
+ * - `reject` — a WS route matched but `upgrade()` rejected (or the path was malformed); return `response`.
176
+ * - `upgrade` — perform the runtime upgrade, then dispatch the native socket's events to `handler`,
177
+ * seeding `ws.data` with `data`.
178
+ */
179
+ export type WebSocketUpgradeOutcome =
180
+ | { readonly kind: "pass" }
181
+ | { readonly kind: "reject"; readonly response: Response }
182
+ | {
183
+ readonly kind: "upgrade"
184
+ readonly handler: WebSocketHandler
185
+ readonly data: unknown
186
+ /** The app's pub/sub registry — the adapter wires `ws.subscribe` + close-cleanup to it. */
187
+ readonly pubsub: TopicRegistry
188
+ }
189
+
190
+ /**
191
+ * A standard server-side `WebSocket` — the half returned by Deno's `Deno.upgradeWebSocket` and the
192
+ * Workers `WebSocketPair`. {@link attachWebSocket} wires one to a nifra handler, so the Deno and Workers
193
+ * bridges share all the dispatch/normalization/error-isolation logic (only the upgrade call differs).
194
+ */
195
+ export interface StandardWebSocket {
196
+ send(data: string | ArrayBufferView | ArrayBuffer): void
197
+ close(code?: number, reason?: string): void
198
+ readonly readyState: number
199
+ binaryType?: string
200
+ addEventListener(type: string, listener: (event: never) => void): void
201
+ }
202
+
203
+ /** Normalize a received binary frame to `Uint8Array` (text frames stay `string`). */
204
+ function toBinary(raw: unknown): Uint8Array {
205
+ if (raw instanceof Uint8Array) return raw
206
+ if (raw instanceof ArrayBuffer) return new Uint8Array(raw)
207
+ if (ArrayBuffer.isView(raw)) return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
208
+ return new Uint8Array(0) // unknown (e.g. an unconverted Blob) — adapters set binaryType to avoid this
209
+ }
210
+
211
+ /**
212
+ * Wire a standard server-side `WebSocket` to a nifra {@link WebSocketHandler}, returning the portable
213
+ * {@link NifraWebSocket}. Shared by the Deno and Workers bridges. `openNow` fires `open` immediately
214
+ * (Workers, where the socket is already open after `accept()`); otherwise `open` waits for the socket's
215
+ * `open` event (Deno). Lifecycle callbacks are error-isolated — a throw (or async rejection) routes to
216
+ * `error()` and never tears the process down; binary frames are normalized to `Uint8Array`.
217
+ */
218
+ export function attachWebSocket(
219
+ socket: StandardWebSocket,
220
+ handler: WebSocketHandler,
221
+ data: unknown,
222
+ options: { openNow: boolean; pubsub: TopicRegistry },
223
+ ): NifraWebSocket {
224
+ const { pubsub } = options
225
+ const ws: NifraWebSocket = {
226
+ send: (payload) => socket.send(payload),
227
+ close: (code, reason) => socket.close(code, reason),
228
+ get readyState() {
229
+ return socket.readyState
230
+ },
231
+ subscribe: (topic) => pubsub.subscribe(topic, ws),
232
+ unsubscribe: (topic) => pubsub.unsubscribe(topic, ws),
233
+ data,
234
+ raw: socket,
235
+ }
236
+ const reportError = (error: unknown): void => {
237
+ if (handler.error === undefined) return
238
+ try {
239
+ const r = handler.error(ws, error)
240
+ if (r instanceof Promise) r.catch(() => {})
241
+ } catch {
242
+ /* the error handler itself failed — last resort, swallow */
243
+ }
244
+ }
245
+ const safe = (call: () => MaybePromise<void>): void => {
246
+ try {
247
+ const r = call()
248
+ if (r instanceof Promise) r.catch(reportError)
249
+ } catch (error) {
250
+ reportError(error)
251
+ }
252
+ }
253
+ // Deliver binary as ArrayBuffer (→ Uint8Array) rather than Blob, where the runtime allows it.
254
+ if ("binaryType" in socket) socket.binaryType = "arraybuffer"
255
+ socket.addEventListener("message", (event: { readonly data: unknown }) => {
256
+ const raw = event.data
257
+ const payload: WebSocketData = typeof raw === "string" ? raw : toBinary(raw)
258
+ safe(() => handler.message?.(ws, payload))
259
+ })
260
+ socket.addEventListener(
261
+ "close",
262
+ (event: { readonly code?: number; readonly reason?: string }) => {
263
+ pubsub.unsubscribeAll(ws) // drop topic subscriptions so the registry never holds a dead socket
264
+ safe(() => handler.close?.(ws, event.code ?? 1005, event.reason ?? ""))
265
+ },
266
+ )
267
+ socket.addEventListener("error", (event: unknown) => reportError(event))
268
+ if (options.openNow) {
269
+ safe(() => handler.open?.(ws))
270
+ } else {
271
+ socket.addEventListener("open", () => safe(() => handler.open?.(ws)))
272
+ }
273
+ return ws
274
+ }
275
+
276
+ const WS_MESSAGE_DECODER = new TextDecoder()
277
+
278
+ /**
279
+ * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame —
280
+ * parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
281
+ * `onInvalidMessage` on failure. Returns the handler unchanged when no schema is set. Called once at
282
+ * `app.ws()` registration, so every adapter dispatches validated messages with no per-adapter code.
283
+ */
284
+ export function wrapWebSocketMessageValidation(handler: WebSocketHandler): WebSocketHandler {
285
+ const schema = handler.messageSchema
286
+ if (schema === undefined) return handler
287
+ // The handler is type-erased here; the user's `message` really accepts the schema's validated output
288
+ // (typed at the real call site), so widen its param to `unknown` for the internal call.
289
+ const userMessage = handler.message as
290
+ | ((ws: NifraWebSocket, data: unknown) => MaybePromise<void>)
291
+ | undefined
292
+ const onInvalid = handler.onInvalidMessage
293
+ const validatingMessage = (ws: NifraWebSocket, raw: WebSocketData): MaybePromise<void> => {
294
+ let parsed: unknown
295
+ try {
296
+ parsed = JSON.parse(typeof raw === "string" ? raw : WS_MESSAGE_DECODER.decode(raw))
297
+ } catch {
298
+ return onInvalid?.(ws, [{ message: "invalid JSON" }], raw)
299
+ }
300
+ const outcome = validateStandard(schema, parsed)
301
+ const finish = (o: ValidationOutcome<unknown>): MaybePromise<void> =>
302
+ o.ok ? userMessage?.(ws, o.value) : onInvalid?.(ws, o.issues, raw)
303
+ return outcome instanceof Promise ? outcome.then(finish) : finish(outcome)
304
+ }
305
+ // The wrapped `message` takes the raw frame (adapters pass that) and produces the typed value
306
+ // internally; the stored handler is type-erased, so the signature is sound.
307
+ return { ...handler, message: validatingMessage } as WebSocketHandler
308
+ }