@nifrajs/core 4.0.2 → 5.1.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 +6 -2
- package/dist/assurance.d.ts +86 -6
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +186 -95
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +53 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +8 -3
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- 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.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +21 -6
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +139 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +45 -10
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +67 -32
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +481 -334
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +33 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +47 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +15 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +4 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +55 -6
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +4 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +299 -111
- package/src/capabilities.ts +62 -1
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +8 -3
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +7 -2
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +98 -44
- package/src/internal/same-origin.ts +14 -1
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body-lane.ts +36 -1
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +8 -5
- package/src/server/context.ts +2 -1
- package/src/server/cookies.ts +12 -1
- package/src/server/edge.ts +2 -1
- package/src/server/http.ts +7 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-contract-lane.ts +162 -10
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +45 -10
- package/src/server/server.ts +707 -539
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +83 -18
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +14 -2
- package/src/server.ts +6 -1
- package/src/single-copy.ts +55 -5
- package/src/tool-contract.ts +67 -39
- package/src/wire.ts +14 -0
- package/src/ws.ts +6 -0
package/src/server/sse.ts
CHANGED
|
@@ -39,7 +39,10 @@ export interface SSEMessage {
|
|
|
39
39
|
|
|
40
40
|
/** The stream handed to the `run` callback. */
|
|
41
41
|
export interface SSEStream {
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Enqueue an event frame. No-op once the stream is closed, including after a client that fell
|
|
44
|
+
* more than `maxBufferedBytes` behind was dropped.
|
|
45
|
+
*/
|
|
43
46
|
send(message: SSEMessage): void
|
|
44
47
|
/** End the stream now. */
|
|
45
48
|
close(): void
|
|
@@ -59,8 +62,17 @@ export interface SSEInit {
|
|
|
59
62
|
readonly headers?: ConstructorParameters<typeof Headers>[0]
|
|
60
63
|
/** Send a `: ` comment ping every N ms so idle proxies don't drop the connection. */
|
|
61
64
|
readonly keepAlive?: number
|
|
65
|
+
/**
|
|
66
|
+
* Bytes of sent events one client may leave unread, default 1 MiB. Past it the client is dropped:
|
|
67
|
+
* `signal` aborts, the queued frames are released, and later sends are no-ops, so a reader that
|
|
68
|
+
* stalls cannot hold server memory without limit. Raise it for a burst larger than this sent before
|
|
69
|
+
* the client can read; `Infinity` turns the limit off.
|
|
70
|
+
*/
|
|
71
|
+
readonly maxBufferedBytes?: number
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024
|
|
75
|
+
|
|
64
76
|
/**
|
|
65
77
|
* The stream handed to an `app.sse()` handler: `send` takes the route's TYPED event payload and
|
|
66
78
|
* serializes it (JSON) into the SSE `data:` field - the compile-time half of the `sse` contract.
|
|
@@ -111,6 +123,9 @@ export function sse(
|
|
|
111
123
|
run: (stream: SSEStream) => void | Promise<void>,
|
|
112
124
|
init: SSEInit = {},
|
|
113
125
|
): Response {
|
|
126
|
+
const maxBuffered = init.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES
|
|
127
|
+
if (!(maxBuffered >= 0))
|
|
128
|
+
throw new RangeError("sse maxBufferedBytes must be a non-negative number")
|
|
114
129
|
const encoder = new TextEncoder()
|
|
115
130
|
const requestSignal: AbortSignal | undefined = c.req?.signal
|
|
116
131
|
// The producer's signal also aborts when the runtime cancels the body: a writer that finds the
|
|
@@ -127,66 +142,80 @@ export function sse(
|
|
|
127
142
|
}
|
|
128
143
|
}
|
|
129
144
|
|
|
130
|
-
const body = new ReadableStream<Uint8Array>(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
const body = new ReadableStream<Uint8Array>(
|
|
146
|
+
{
|
|
147
|
+
start(controller) {
|
|
148
|
+
const close = (): void => {
|
|
149
|
+
if (closed) return
|
|
150
|
+
closed = true
|
|
151
|
+
teardown()
|
|
152
|
+
try {
|
|
153
|
+
controller.close()
|
|
154
|
+
} catch {
|
|
155
|
+
// Already closed by the runtime's cancel() - safe to ignore.
|
|
156
|
+
}
|
|
140
157
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
const stream: SSEStream = {
|
|
159
|
+
send(message) {
|
|
160
|
+
if (closed) return
|
|
161
|
+
// The queue only shrinks as the runtime writes to the socket, so a backlog past the cap
|
|
162
|
+
// is a client that stopped reading. Erroring the body releases the queue and the socket.
|
|
163
|
+
if ((controller.desiredSize ?? 0) < 0) {
|
|
164
|
+
closed = true
|
|
165
|
+
teardown()
|
|
166
|
+
const reason = new Error("sse_client_too_slow")
|
|
167
|
+
stopped.abort(reason)
|
|
168
|
+
controller.error(reason)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
controller.enqueue(encoder.encode(formatMessage(message)))
|
|
172
|
+
},
|
|
173
|
+
close,
|
|
174
|
+
signal: stopped.signal,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Stop the moment the client goes away.
|
|
178
|
+
if (requestSignal !== undefined) {
|
|
179
|
+
if (requestSignal.aborted) {
|
|
180
|
+
stopped.abort()
|
|
181
|
+
close()
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
onAbort = () => {
|
|
185
|
+
stopped.abort()
|
|
186
|
+
close()
|
|
187
|
+
}
|
|
188
|
+
requestSignal.addEventListener("abort", onAbort, { once: true })
|
|
156
189
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
190
|
+
|
|
191
|
+
if (init.keepAlive !== undefined && init.keepAlive > 0) {
|
|
192
|
+
heartbeat = setInterval(() => stream.send({ comment: "" }), init.keepAlive)
|
|
193
|
+
// Don't let the ping keep a Node process alive on its own.
|
|
194
|
+
;(heartbeat as { unref?: () => void }).unref?.()
|
|
160
195
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
196
|
+
// An async IIFE so a synchronous throw in `run` becomes a rejection we can surface.
|
|
197
|
+
;(async () => {
|
|
198
|
+
await run(stream)
|
|
199
|
+
})().then(close, (err: unknown) => {
|
|
200
|
+
if (closed) return
|
|
201
|
+
closed = true
|
|
202
|
+
teardown()
|
|
203
|
+
try {
|
|
204
|
+
controller.error(err instanceof Error ? err : new Error("sse_producer_failed"))
|
|
205
|
+
} catch {
|
|
206
|
+
// Stream already torn down.
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
cancel() {
|
|
211
|
+
// Consumer/runtime canceled (client gone) - pending sends become no-ops.
|
|
174
212
|
closed = true
|
|
175
213
|
teardown()
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
} catch {
|
|
179
|
-
// Stream already torn down.
|
|
180
|
-
}
|
|
181
|
-
})
|
|
182
|
-
},
|
|
183
|
-
cancel() {
|
|
184
|
-
// Consumer/runtime canceled (client gone) - pending sends become no-ops.
|
|
185
|
-
closed = true
|
|
186
|
-
teardown()
|
|
187
|
-
stopped.abort()
|
|
214
|
+
stopped.abort()
|
|
215
|
+
},
|
|
188
216
|
},
|
|
189
|
-
|
|
217
|
+
{ highWaterMark: maxBuffered, size: (chunk) => chunk?.byteLength ?? 0 },
|
|
218
|
+
)
|
|
190
219
|
|
|
191
220
|
const headers = new Headers(init.headers)
|
|
192
221
|
headers.set("content-type", "text/event-stream; charset=utf-8")
|
|
@@ -27,7 +27,13 @@ export interface TransportRuntime {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface TransportCodecsOptions {
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Maximum encoded request bytes. Default 1,000,000. Keep aligned with `server({ maxBodyBytes })`.
|
|
32
|
+
*
|
|
33
|
+
* The plugin decodes a codec body in an `onRequest` hook, before routing, so a route's own
|
|
34
|
+
* `bodyLimit` and its `authenticate()` stages run only after the decode. This cap is the only
|
|
35
|
+
* bound on that work: set it to the largest codec body any route should accept.
|
|
36
|
+
*/
|
|
31
37
|
readonly maxBytes?: number
|
|
32
38
|
/**
|
|
33
39
|
* Prototype-poisoning policy for decoded request bodies, mirroring
|
package/src/server/webhook.ts
CHANGED
|
@@ -19,14 +19,17 @@ const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB - webhook payloads are small; cap
|
|
|
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"
|
|
22
|
-
export type SignatureEncoding = "hex" | "base64"
|
|
22
|
+
export type SignatureEncoding = "hex" | "base64" | "base64url"
|
|
23
23
|
|
|
24
24
|
export interface VerifyWebhookOptions {
|
|
25
25
|
/** Known-provider preset. Default `"generic"` (which requires {@link header}). */
|
|
26
26
|
readonly provider?: WebhookProvider
|
|
27
27
|
/** Header carrying the signature. Required for `generic`; overrides the preset otherwise. */
|
|
28
28
|
readonly header?: string
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* How the signature is encoded in the header. Default `"hex"`. `"base64url"` accepts the padded and
|
|
31
|
+
* unpadded forms and nothing outside its alphabet.
|
|
32
|
+
*/
|
|
30
33
|
readonly encoding?: SignatureEncoding
|
|
31
34
|
/** A prefix stripped from the header value before decoding, e.g. `"sha256="`. */
|
|
32
35
|
readonly prefix?: string
|
|
@@ -96,10 +99,26 @@ const fromBase64 = (value: string): Uint8Array<ArrayBuffer> | null => {
|
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/
|
|
103
|
+
|
|
104
|
+
const fromBase64Url = (value: string): Uint8Array<ArrayBuffer> | null => {
|
|
105
|
+
if (!BASE64URL.test(value)) return null
|
|
106
|
+
const unpadded = value.replace(/=+$/, "")
|
|
107
|
+
// Padding only ever completes the last quartet; a lone trailing character encodes no byte.
|
|
108
|
+
if (unpadded.length % 4 === 1) return null
|
|
109
|
+
if (unpadded.length !== value.length && value.length % 4 !== 0) return null
|
|
110
|
+
return fromBase64(unpadded.replaceAll("-", "+").replaceAll("_", "/"))
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
const decodeSignature = (
|
|
100
114
|
value: string,
|
|
101
115
|
encoding: SignatureEncoding,
|
|
102
|
-
): Uint8Array<ArrayBuffer> | null =>
|
|
116
|
+
): Uint8Array<ArrayBuffer> | null =>
|
|
117
|
+
encoding === "hex"
|
|
118
|
+
? fromHex(value)
|
|
119
|
+
: encoding === "base64url"
|
|
120
|
+
? fromBase64Url(value)
|
|
121
|
+
: fromBase64(value)
|
|
103
122
|
|
|
104
123
|
/** True iff `sig` is a valid HMAC-SHA256 of `payload` under any of `keys` (constant-time per check). */
|
|
105
124
|
async function verifyAny(
|
package/src/server/websocket.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import { validateStandard } from "../schema/standard.ts"
|
|
19
19
|
import { decodeTransportFrame, type TransportCodecRegistry } from "../transport-codec.ts"
|
|
20
20
|
import { guardDecodedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
21
|
+
import { isResponseResult } from "./runtime-core.ts"
|
|
21
22
|
|
|
22
23
|
type MaybePromise<T> = T | Promise<T>
|
|
23
24
|
|
|
@@ -133,6 +134,7 @@ export interface WebSocketHandler<
|
|
|
133
134
|
Env = unknown,
|
|
134
135
|
Schema extends StandardSchemaV1 | undefined = undefined,
|
|
135
136
|
Send extends StandardSchemaV1 | undefined = undefined,
|
|
137
|
+
Extra = unknown,
|
|
136
138
|
> {
|
|
137
139
|
/**
|
|
138
140
|
* Cross-site WebSocket hijacking (CSWSH) guard - checked BEFORE `upgrade()`. A browser does not
|
|
@@ -150,12 +152,22 @@ export interface WebSocketHandler<
|
|
|
150
152
|
*/
|
|
151
153
|
allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
|
|
152
154
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
155
|
+
* The capability ids this socket's code may reach, exactly as an HTTP route's `capabilities`.
|
|
156
|
+
* `nifra capabilities check` compares them with the effects reachable from the module that
|
|
157
|
+
* registers the socket. The handshake context carries the guard, so a runtime beacon in `upgrade()`
|
|
158
|
+
* (or one that keeps `c`) fails closed on an undeclared capability.
|
|
157
159
|
*/
|
|
158
|
-
|
|
160
|
+
capabilities?: readonly string[]
|
|
161
|
+
/**
|
|
162
|
+
* Runs in the HTTP request context **before** the upgrade, last in the chain an HTTP `GET` to the
|
|
163
|
+
* same path runs: after {@link allowedOrigins}, then the `authenticate()` stages, `derive`s and
|
|
164
|
+
* `beforeHandle`s registered before this route, any of which can refuse the handshake. `c` carries
|
|
165
|
+
* what they derived. Return the initial per-connection `data` (→ `ws.data`), or a `Response` or
|
|
166
|
+
* `status(...)` to **reject** the upgrade (the client never connects). Omit to accept with
|
|
167
|
+
* `data: undefined`. A thrown `Response` rejects with it; any other throw goes to the route's
|
|
168
|
+
* `onError` hooks, then a flat 500.
|
|
169
|
+
*/
|
|
170
|
+
upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response>
|
|
159
171
|
/**
|
|
160
172
|
* Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame.
|
|
161
173
|
* Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a
|
|
@@ -221,8 +233,19 @@ export type WebSocketUpgradeOutcome =
|
|
|
221
233
|
* `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */
|
|
222
234
|
readonly attach: WsAttach
|
|
223
235
|
readonly maxPayloadBytes?: number
|
|
236
|
+
/** Builds `ws.send` over an adapter's native send, enforcing the route's {@link
|
|
237
|
+
* WebSocketHandler.validateSend}. Set on every outcome a nifra app produces; an adapter that
|
|
238
|
+
* wires its own sockets instead of calling `attach` (Node, Deno) builds `send` with it. */
|
|
239
|
+
readonly sender?: WsSender
|
|
224
240
|
}
|
|
225
241
|
|
|
242
|
+
/** The send-builder the upgrade outcome carries (the installed runtime's `sender`). */
|
|
243
|
+
export type WsSender = (
|
|
244
|
+
handler: WebSocketHandler,
|
|
245
|
+
send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
|
|
246
|
+
onError: (error: unknown) => void,
|
|
247
|
+
) => (payload: string | ArrayBufferView | ArrayBuffer) => void
|
|
248
|
+
|
|
226
249
|
/** The socket-wiring signature the upgrade outcome carries (the installed runtime's `attach`). */
|
|
227
250
|
export type WsAttach = (
|
|
228
251
|
socket: StandardWebSocket,
|
|
@@ -313,18 +336,11 @@ export function attachWebSocket<
|
|
|
313
336
|
socket.addEventListener("message", (event: { readonly data: unknown }) => {
|
|
314
337
|
const raw = event.data
|
|
315
338
|
const payload: WebSocketData = typeof raw === "string" ? raw : toBinary(raw)
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
typeof raw === "string"
|
|
322
|
-
? WS_PAYLOAD_ENCODER.encode(raw).byteLength
|
|
323
|
-
: (payload as Uint8Array).byteLength
|
|
324
|
-
if (payloadBytes > maxPayloadBytes) {
|
|
325
|
-
socket.close(1009, "message too large")
|
|
326
|
-
return
|
|
327
|
-
}
|
|
339
|
+
// The runtime hands over each message whole, so this bounds what reaches the handler, not what
|
|
340
|
+
// the runtime buffered to deliver it.
|
|
341
|
+
if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
|
|
342
|
+
socket.close(1009, "message too large")
|
|
343
|
+
return
|
|
328
344
|
}
|
|
329
345
|
safe(() =>
|
|
330
346
|
(
|
|
@@ -351,7 +367,25 @@ export function attachWebSocket<
|
|
|
351
367
|
}
|
|
352
368
|
|
|
353
369
|
const WS_MESSAGE_DECODER = new TextDecoder()
|
|
354
|
-
|
|
370
|
+
|
|
371
|
+
/** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
|
|
372
|
+
* 1-3 bytes (a surrogate pair 4 for its 2), so only a length between those bounds is counted. */
|
|
373
|
+
export function exceedsPayloadCap(
|
|
374
|
+
message: string | ArrayBuffer | Uint8Array,
|
|
375
|
+
cap: number,
|
|
376
|
+
): boolean {
|
|
377
|
+
if (typeof message !== "string") return message.byteLength > cap
|
|
378
|
+
const units = message.length
|
|
379
|
+
if (units > cap) return true
|
|
380
|
+
if (units * 3 <= cap) return false
|
|
381
|
+
let bytes = 0
|
|
382
|
+
for (let i = 0; i < units; i++) {
|
|
383
|
+
const unit = message.charCodeAt(i)
|
|
384
|
+
bytes += unit < 0x80 ? 1 : unit < 0x800 || (unit >= 0xd800 && unit <= 0xdfff) ? 2 : 3
|
|
385
|
+
if (bytes > cap) return true
|
|
386
|
+
}
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
355
389
|
|
|
356
390
|
type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
|
|
357
391
|
|
|
@@ -436,6 +470,10 @@ export function createWebSocketSender(
|
|
|
436
470
|
}
|
|
437
471
|
}
|
|
438
472
|
|
|
473
|
+
/** {@link createWebSocketSender} configured from a route's handler: the outcome's `sender`. */
|
|
474
|
+
export const webSocketSender: WsSender = (handler, send, onError) =>
|
|
475
|
+
createWebSocketSender(send, handler.sendSchema, handler.validateSend, handler.transport, onError)
|
|
476
|
+
|
|
439
477
|
/**
|
|
440
478
|
* If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
|
|
441
479
|
* parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
|
|
@@ -444,6 +482,33 @@ export function createWebSocketSender(
|
|
|
444
482
|
* `protoPoisoning` is the app's body policy: a frame with a `__proto__` key gets what a JSON body
|
|
445
483
|
* with one gets, and under `"reject"` reads as invalid JSON.
|
|
446
484
|
*/
|
|
485
|
+
/** `upgrade()`'s acceptance, wrapped so a handshake's route chain tells it from an early result such
|
|
486
|
+
* as a guard's `Response` or a rejected auth stage. */
|
|
487
|
+
class HandshakeAccepted {
|
|
488
|
+
readonly data: unknown
|
|
489
|
+
constructor(data: unknown) {
|
|
490
|
+
this.data = data
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const acceptHandshake = (value: unknown): unknown =>
|
|
495
|
+
value instanceof Response || isResponseResult(value) ? value : new HandshakeAccepted(value)
|
|
496
|
+
|
|
497
|
+
/** The step a handshake's route chain ends in: the route's `upgrade()`, or an acceptance without data. */
|
|
498
|
+
export function handshakeUpgradeStep(
|
|
499
|
+
handler: WebSocketHandler,
|
|
500
|
+
): (ctx: WebSocketContext) => unknown {
|
|
501
|
+
return (ctx) => {
|
|
502
|
+
const data = handler.upgrade?.(ctx)
|
|
503
|
+
return data instanceof Promise ? data.then(acceptHandshake) : acceptHandshake(data)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The acceptance a handshake's route chain ended in, or `undefined` when the chain refused it. */
|
|
508
|
+
export function acceptedHandshake(result: unknown): { readonly data: unknown } | undefined {
|
|
509
|
+
return result instanceof HandshakeAccepted ? result : undefined
|
|
510
|
+
}
|
|
511
|
+
|
|
447
512
|
export function wrapWebSocketMessageValidation(
|
|
448
513
|
handler: WebSocketHandler,
|
|
449
514
|
protoPoisoning: ProtoPoisoning = "reject",
|
package/src/server/ws-bun.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
createWebSocketSender,
|
|
10
|
+
exceedsPayloadCap,
|
|
10
11
|
type NifraWebSocket,
|
|
11
12
|
type TopicRegistry,
|
|
12
13
|
type WebSocketData,
|
|
@@ -20,6 +21,9 @@ export interface BunWsData {
|
|
|
20
21
|
readonly handler: WebSocketHandler
|
|
21
22
|
/** Mounted/modern upgrades carry this registry; old/manual fixtures may omit it. */
|
|
22
23
|
readonly pubsub?: TopicRegistry
|
|
24
|
+
/** The inbound cap of the app this socket belongs to (the upgrade outcome's). Absent, the
|
|
25
|
+
* listening app's applies. */
|
|
26
|
+
readonly maxPayloadBytes?: number | undefined
|
|
23
27
|
data: unknown
|
|
24
28
|
nifra?: NifraWebSocket
|
|
25
29
|
}
|
|
@@ -38,6 +42,31 @@ export interface BunSocket {
|
|
|
38
42
|
readonly data: BunWsData
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The largest `wsMaxPayloadBytes` among the nifra apps mounted under `app` that serve sockets, or
|
|
47
|
+
* `floor` when none declares more. Bun takes one frame limit per listener, so it has to admit every
|
|
48
|
+
* mounted app's cap. Reads each server's own mount table, as reflection does, so an app that never
|
|
49
|
+
* listens pays nothing for it; a child that is not a nifra server names no cap here.
|
|
50
|
+
*/
|
|
51
|
+
function mountedPayloadCeiling(app: object, floor: number, seen: Set<object>): number {
|
|
52
|
+
if (seen.has(app)) return floor
|
|
53
|
+
seen.add(app)
|
|
54
|
+
const mounts: unknown = Reflect.get(app, "fetchMounts")
|
|
55
|
+
if (!Array.isArray(mounts)) return floor
|
|
56
|
+
let ceiling = floor
|
|
57
|
+
for (const mount of mounts) {
|
|
58
|
+
const child: unknown = typeof mount === "object" && mount !== null ? mount.app : undefined
|
|
59
|
+
if (typeof child !== "object" || child === null) continue
|
|
60
|
+
const routes: unknown = Reflect.get(child, "wsRoutes")
|
|
61
|
+
const cap: unknown = Reflect.get(child, "wsMaxPayloadBytes")
|
|
62
|
+
if (Array.isArray(routes) && routes.length > 0 && typeof cap === "number" && cap > ceiling) {
|
|
63
|
+
ceiling = cap
|
|
64
|
+
}
|
|
65
|
+
ceiling = mountedPayloadCeiling(child, ceiling, seen)
|
|
66
|
+
}
|
|
67
|
+
return ceiling
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
/** Normalize a received binary frame to `Uint8Array` (Bun hands a `Buffer`, already one; an
|
|
42
71
|
* `ArrayBuffer` is wrapped). Text frames stay `string`. */
|
|
43
72
|
function toBinaryData(message: ArrayBuffer | Uint8Array): Uint8Array {
|
|
@@ -127,9 +156,21 @@ function dispatchWsCallback(
|
|
|
127
156
|
* `nativePubsub` (set by `listen()` when the app has no validated-send route) makes subscriptions use
|
|
128
157
|
* Bun's own pub/sub; there the registry holds nothing and Bun auto-drops native subscriptions on
|
|
129
158
|
* close, so the `unsubscribeAll` sweep is skipped.
|
|
159
|
+
*
|
|
160
|
+
* `app` is the listening server. Its `wsMaxPayloadBytes` and its mounted apps' set the listener's
|
|
161
|
+
* `maxPayloadLength`; a socket whose app declares less is closed with `1009` here.
|
|
130
162
|
*/
|
|
131
|
-
export function createBunWsHandlers(
|
|
163
|
+
export function createBunWsHandlers(
|
|
164
|
+
topics: TopicRegistry,
|
|
165
|
+
nativePubsub = false,
|
|
166
|
+
app?: object,
|
|
167
|
+
): BunWsHandlers {
|
|
168
|
+
const own: unknown = app === undefined ? undefined : Reflect.get(app, "wsMaxPayloadBytes")
|
|
169
|
+
const appCap = typeof own === "number" ? own : Number.POSITIVE_INFINITY
|
|
170
|
+
const listenerCap =
|
|
171
|
+
app === undefined ? appCap : mountedPayloadCeiling(app, appCap, new Set<object>())
|
|
132
172
|
return {
|
|
173
|
+
maxPayloadLength: listenerCap,
|
|
133
174
|
open: (raw) => {
|
|
134
175
|
const ws = raw as BunSocket
|
|
135
176
|
const nifra = wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
|
|
@@ -138,6 +179,12 @@ export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false)
|
|
|
138
179
|
},
|
|
139
180
|
message: (raw, message) => {
|
|
140
181
|
const ws = raw as BunSocket
|
|
182
|
+
// Only a socket whose app declares less than the listener admits pays for a size check.
|
|
183
|
+
const cap = ws.data.maxPayloadBytes ?? appCap
|
|
184
|
+
if (cap < listenerCap && exceedsPayloadCap(message, cap)) {
|
|
185
|
+
ws.close(1009, "message too large")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
141
188
|
const handler = ws.data.handler
|
|
142
189
|
const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, handler, nativePubsub)
|
|
143
190
|
const data: WebSocketData = typeof message === "string" ? message : toBinaryData(message)
|
package/src/server/ws-hook.ts
CHANGED
|
@@ -13,7 +13,9 @@ import type {
|
|
|
13
13
|
NifraWebSocket,
|
|
14
14
|
StandardWebSocket,
|
|
15
15
|
TopicRegistry,
|
|
16
|
+
WebSocketContext,
|
|
16
17
|
WebSocketHandler,
|
|
18
|
+
WsSender,
|
|
17
19
|
} from "./websocket.ts"
|
|
18
20
|
|
|
19
21
|
/** The Bun serve `websocket` callback trio, closed over one app's `TopicRegistry`. The `ws`
|
|
@@ -23,17 +25,25 @@ export interface BunWsHandlers {
|
|
|
23
25
|
open(ws: unknown): void
|
|
24
26
|
message(ws: unknown, message: string | ArrayBuffer | Uint8Array): void
|
|
25
27
|
close(ws: unknown, code: number, reason: string): void
|
|
28
|
+
/** The listener's `maxPayloadLength`: the largest `wsMaxPayloadBytes` among the app and the apps
|
|
29
|
+
* mounted on it. A socket whose app declares less checks its own cap per message. */
|
|
30
|
+
readonly maxPayloadLength: number
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/** What `@nifrajs/core/ws` registers: everything `server.ts` and `toFetchHandler` need at runtime. */
|
|
29
34
|
export interface WsRuntime {
|
|
30
35
|
/** `wrapWebSocketMessageValidation` - applied once at `app.ws()` registration. */
|
|
31
36
|
wrapHandler(handler: WebSocketHandler, protoPoisoning: ProtoPoisoning): WebSocketHandler
|
|
37
|
+
/** `handshakeUpgradeStep` - the step a handshake's route chain ends in. */
|
|
38
|
+
upgradeStep(handler: WebSocketHandler): (ctx: WebSocketContext) => unknown
|
|
39
|
+
/** `acceptedHandshake` - the acceptance a chain ended in, `undefined` when it refused. */
|
|
40
|
+
accepted(result: unknown): { readonly data: unknown } | undefined
|
|
32
41
|
/** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
|
|
33
42
|
createTopics(): TopicRegistry
|
|
34
43
|
/** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
|
|
35
|
-
* `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames).
|
|
36
|
-
|
|
44
|
+
* `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). `app` is
|
|
45
|
+
* the listening server, whose mount table names the payload caps the listener has to admit. */
|
|
46
|
+
bunHandlers(topics: TopicRegistry, nativePubsub?: boolean, app?: object): BunWsHandlers
|
|
37
47
|
/** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
|
|
38
48
|
attach(
|
|
39
49
|
socket: StandardWebSocket,
|
|
@@ -41,4 +51,6 @@ export interface WsRuntime {
|
|
|
41
51
|
data: unknown,
|
|
42
52
|
options: { openNow: boolean; pubsub: TopicRegistry },
|
|
43
53
|
): NifraWebSocket
|
|
54
|
+
/** `webSocketSender` - `ws.send` for an adapter that wires its own sockets (Node, Deno). */
|
|
55
|
+
sender: WsSender
|
|
44
56
|
}
|
package/src/server.ts
CHANGED
|
@@ -10,7 +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
|
+
export { isSameOriginForm, isSameOriginRequest } from "./internal/same-origin.ts"
|
|
14
14
|
export {
|
|
15
15
|
ROUTE_PATTERN_OVERLAP_MAX_STATES,
|
|
16
16
|
RoutePatternOverlapLimitError,
|
|
@@ -42,6 +42,8 @@ export {
|
|
|
42
42
|
type AuthHeaders,
|
|
43
43
|
type AuthMaybePromise,
|
|
44
44
|
authenticated,
|
|
45
|
+
isAuthenticatedRequest,
|
|
46
|
+
markAuthenticatedRequest,
|
|
45
47
|
rejected,
|
|
46
48
|
} from "./server/auth.ts"
|
|
47
49
|
export type {
|
|
@@ -104,6 +106,7 @@ export type {
|
|
|
104
106
|
RouteInfoFor,
|
|
105
107
|
} from "./server/registry.ts"
|
|
106
108
|
export { replacedRequestOf } from "./server/request-lineage.ts"
|
|
109
|
+
export { writableResponse } from "./server/respond.ts"
|
|
107
110
|
export {
|
|
108
111
|
type PlainRender,
|
|
109
112
|
type ResponseResult,
|
|
@@ -115,6 +118,7 @@ export {
|
|
|
115
118
|
type AdmissionDecision,
|
|
116
119
|
type AnyServer,
|
|
117
120
|
type ContextPlugin,
|
|
121
|
+
type DeclaredMountRoute,
|
|
118
122
|
type DefinePluginResult,
|
|
119
123
|
defineContextPlugin,
|
|
120
124
|
defineIdentityPlugin,
|
|
@@ -137,6 +141,7 @@ export {
|
|
|
137
141
|
type NodeResponseHook,
|
|
138
142
|
type NodeServeOutcome,
|
|
139
143
|
type OnRequestResult,
|
|
144
|
+
type OpaqueMountDeclaration,
|
|
140
145
|
type PluginTypeCollapsed,
|
|
141
146
|
type PromptArgument,
|
|
142
147
|
type PromptMessage,
|