@nifrajs/core 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/brand.d.ts +11 -0
- package/dist/internal/brand.d.ts.map +1 -0
- package/dist/internal/brand.js +10 -0
- package/dist/internal/brand.js.map +1 -0
- package/dist/internal/secret.d.ts +9 -0
- package/dist/internal/secret.d.ts.map +1 -0
- package/dist/internal/secret.js +19 -0
- package/dist/internal/secret.js.map +1 -0
- package/dist/router/router.d.ts +61 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +324 -0
- package/dist/router/router.js.map +1 -0
- package/dist/schema/standard.d.ts +46 -0
- package/dist/schema/standard.d.ts.map +1 -0
- package/dist/schema/standard.js +20 -0
- package/dist/schema/standard.js.map +1 -0
- package/dist/seo.d.ts +48 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +109 -0
- package/dist/seo.js.map +1 -0
- package/dist/server/body.d.ts +39 -0
- package/dist/server/body.d.ts.map +1 -0
- package/dist/server/body.js +85 -0
- package/dist/server/body.js.map +1 -0
- package/dist/server/context.d.ts +111 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +2 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/contract.d.ts +142 -0
- package/dist/server/contract.d.ts.map +1 -0
- package/dist/server/contract.js +59 -0
- package/dist/server/contract.js.map +1 -0
- package/dist/server/cookies.d.ts +41 -0
- package/dist/server/cookies.d.ts.map +1 -0
- package/dist/server/cookies.js +181 -0
- package/dist/server/cookies.js.map +1 -0
- package/dist/server/logger.d.ts +49 -0
- package/dist/server/logger.d.ts.map +1 -0
- package/dist/server/logger.js +125 -0
- package/dist/server/logger.js.map +1 -0
- package/dist/server/registry.d.ts +46 -0
- package/dist/server/registry.d.ts.map +1 -0
- package/dist/server/registry.js +2 -0
- package/dist/server/registry.js.map +1 -0
- package/dist/server/server.d.ts +528 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +1660 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/sse.d.ts +54 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +126 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/webhook.d.ts +42 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +149 -0
- package/dist/server/webhook.js.map +1 -0
- package/dist/server/websocket.d.ts +152 -0
- package/dist/server/websocket.d.ts.map +1 -0
- package/dist/server/websocket.js +178 -0
- package/dist/server/websocket.js.map +1 -0
- package/dist/server/ws-bun.d.ts +27 -0
- package/dist/server/ws-bun.d.ts.map +1 -0
- package/dist/server/ws-bun.js +82 -0
- package/dist/server/ws-bun.js.map +1 -0
- package/dist/server/ws-hook.d.ts +36 -0
- package/dist/server/ws-hook.d.ts.map +1 -0
- package/dist/server/ws-hook.js +18 -0
- package/dist/server/ws-hook.js.map +1 -0
- package/dist/ws.d.ts +19 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +26 -0
- package/dist/ws.js.map +1 -0
- package/package.json +63 -0
- package/src/errors.ts +41 -0
- package/src/index.ts +115 -0
- package/src/internal/brand.ts +11 -0
- package/src/internal/secret.ts +24 -0
- package/src/router/router.ts +429 -0
- package/src/schema/standard.ts +76 -0
- package/src/seo.ts +155 -0
- package/src/server/body.ts +89 -0
- package/src/server/context.ts +124 -0
- package/src/server/contract.ts +209 -0
- package/src/server/cookies.ts +198 -0
- package/src/server/logger.ts +167 -0
- package/src/server/registry.ts +63 -0
- package/src/server/server.ts +2479 -0
- package/src/server/sse.ts +161 -0
- package/src/server/webhook.ts +207 -0
- package/src/server/websocket.ts +308 -0
- package/src/server/ws-bun.ts +106 -0
- package/src/server/ws-hook.ts +53 -0
- package/src/ws.ts +40 -0
|
@@ -0,0 +1,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
|
+
}
|