@nifrajs/core 5.5.1 → 5.7.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/CHANGELOG.md +246 -0
- package/README.md +5 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +15 -1
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +1 -0
- package/dist/binary.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -0
- package/dist/edge-kit.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 +4 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +4 -0
- package/dist/evidence.js.map +1 -1
- package/dist/file-type.d.ts +30 -0
- package/dist/file-type.d.ts.map +1 -0
- package/dist/file-type.js +118 -0
- package/dist/file-type.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +2 -0
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +12 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/internal/route-program.d.ts +11 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +133 -85
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reflection.d.ts +19 -1
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +46 -7
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -0
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js +1 -0
- package/dist/response-observer.js.map +1 -1
- package/dist/server/answer-step.d.ts +27 -0
- package/dist/server/answer-step.d.ts.map +1 -0
- package/dist/server/answer-step.js +33 -0
- package/dist/server/answer-step.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +4 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +9 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/base64-answers.d.ts +5 -0
- package/dist/server/base64-answers.d.ts.map +1 -0
- package/dist/server/base64-answers.js +132 -0
- package/dist/server/base64-answers.js.map +1 -0
- package/dist/server/before-handler.d.ts +10 -0
- package/dist/server/before-handler.d.ts.map +1 -0
- package/dist/server/before-handler.js +12 -0
- package/dist/server/before-handler.js.map +1 -0
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +8 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts.map +1 -1
- package/dist/server/body-parser.js +2 -5
- package/dist/server/body-parser.js.map +1 -1
- package/dist/server/body.d.ts +24 -8
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +86 -33
- package/dist/server/body.js.map +1 -1
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +5 -2
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +6 -17
- package/dist/server/http.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/json-response.d.ts +3 -0
- package/dist/server/json-response.d.ts.map +1 -0
- package/dist/server/json-response.js +14 -0
- package/dist/server/json-response.js.map +1 -0
- package/dist/server/json-text.d.ts +16 -0
- package/dist/server/json-text.d.ts.map +1 -0
- package/dist/server/json-text.js +34 -0
- package/dist/server/json-text.js.map +1 -0
- package/dist/server/multipart.d.ts +3 -0
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +226 -97
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/node-outcome.d.ts +5 -0
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +3 -4
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +39 -4
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -14
- package/dist/server/query.js.map +1 -1
- package/dist/server/repeated-prefix.d.ts +17 -0
- package/dist/server/repeated-prefix.d.ts.map +1 -0
- package/dist/server/repeated-prefix.js +17 -0
- package/dist/server/repeated-prefix.js.map +1 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +5 -5
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +3 -4
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +45 -37
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +12 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +137 -9
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/route-template.d.ts +10 -0
- package/dist/server/route-template.d.ts.map +1 -0
- package/dist/server/route-template.js +21 -0
- package/dist/server/route-template.js.map +1 -0
- package/dist/server/runtime-core.d.ts +7 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +10 -2
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +17 -8
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +27 -12
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +326 -108
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +7 -5
- package/dist/server/sse.js.map +1 -1
- package/dist/server/text-slots.d.ts +8 -0
- package/dist/server/text-slots.d.ts.map +1 -0
- package/dist/server/text-slots.js +19 -0
- package/dist/server/text-slots.js.map +1 -0
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +24 -19
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +18 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +45 -5
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +7 -4
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +5 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +26 -17
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-cli.d.ts +64 -0
- package/dist/tool-cli.d.ts.map +1 -0
- package/dist/tool-cli.js +495 -0
- package/dist/tool-cli.js.map +1 -0
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +11 -1
- package/src/assurance.ts +1 -0
- package/src/binary.ts +17 -0
- package/src/edge-kit.ts +1 -0
- package/src/errors.ts +1 -0
- package/src/evidence.ts +8 -0
- package/src/file-type.ts +130 -0
- package/src/index.ts +3 -1
- package/src/internal/hook-origins.ts +10 -0
- package/src/internal/route-program.ts +261 -93
- package/src/reflection.ts +71 -7
- package/src/response-observer.ts +6 -0
- package/src/server/answer-step.ts +68 -0
- package/src/server/bare-error-lane.ts +9 -4
- package/src/server/base64-answers.ts +138 -0
- package/src/server/before-handler.ts +14 -0
- package/src/server/body-lane.ts +19 -9
- package/src/server/body-parser.ts +1 -5
- package/src/server/body.ts +96 -36
- package/src/server/core-copies.ts +5 -2
- package/src/server/http.ts +6 -15
- package/src/server/install.ts +3 -0
- package/src/server/json-response.ts +13 -0
- package/src/server/json-text.ts +34 -0
- package/src/server/multipart.ts +232 -100
- package/src/server/node-outcome.ts +4 -5
- package/src/server/proto-guard.ts +39 -4
- package/src/server/query.ts +7 -13
- package/src/server/repeated-prefix.ts +25 -0
- package/src/server/request-context.ts +5 -5
- package/src/server/respond.ts +47 -30
- package/src/server/response-contract-lane.ts +132 -9
- package/src/server/route-template.ts +24 -0
- package/src/server/runtime-core.ts +12 -2
- package/src/server/server-types.ts +18 -6
- package/src/server/server.ts +361 -112
- package/src/server/sse.ts +8 -6
- package/src/server/text-slots.ts +21 -0
- package/src/server/webhook.ts +29 -24
- package/src/server/websocket.ts +57 -8
- package/src/server/ws-bun.ts +7 -3
- package/src/server.ts +2 -0
- package/src/single-copy.ts +31 -15
- package/src/tool-cli.ts +584 -0
- package/src/transport-codec.ts +3 -1
package/src/server/sse.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* }, { keepAlive: 15_000 }))
|
|
23
23
|
*/
|
|
24
24
|
import { INSTALL_SSE } from "./install.ts"
|
|
25
|
+
import { jsonText } from "./json-text.ts"
|
|
25
26
|
import type { IdentityPlugin } from "./plugin.ts"
|
|
26
27
|
import type { AnyServer } from "./server.ts"
|
|
27
28
|
import type { SseRuntime } from "./sse-hook.ts"
|
|
@@ -66,7 +67,7 @@ export interface SSEInit {
|
|
|
66
67
|
* Bytes of sent events one client may leave unread, default 1 MiB. Past it the client is dropped:
|
|
67
68
|
* `signal` aborts, the queued frames are released, and later sends are no-ops, so a reader that
|
|
68
69
|
* 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
|
+
* the client can read. A single frame over the cap is also refused; `Infinity` turns the limit off.
|
|
70
71
|
*/
|
|
71
72
|
readonly maxBufferedBytes?: number
|
|
72
73
|
}
|
|
@@ -92,7 +93,7 @@ export interface TypedSSEStream<Event> {
|
|
|
92
93
|
export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event> {
|
|
93
94
|
return {
|
|
94
95
|
send(event, init = {}) {
|
|
95
|
-
stream.send({ ...init, data:
|
|
96
|
+
stream.send({ ...init, data: jsonText(event) })
|
|
96
97
|
},
|
|
97
98
|
comment(text) {
|
|
98
99
|
stream.send({ comment: text })
|
|
@@ -158,9 +159,10 @@ export function sse(
|
|
|
158
159
|
const stream: SSEStream = {
|
|
159
160
|
send(message) {
|
|
160
161
|
if (closed) return
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
162
|
+
const frame = encoder.encode(formatMessage(message))
|
|
163
|
+
// Check the incoming frame too: a lone oversized event has no later send to trigger
|
|
164
|
+
// overflow detection. Erroring the body releases its queue and stops the producer.
|
|
165
|
+
if (frame.byteLength > (controller.desiredSize ?? 0)) {
|
|
164
166
|
closed = true
|
|
165
167
|
teardown()
|
|
166
168
|
const reason = new Error("sse_client_too_slow")
|
|
@@ -168,7 +170,7 @@ export function sse(
|
|
|
168
170
|
controller.error(reason)
|
|
169
171
|
return
|
|
170
172
|
}
|
|
171
|
-
controller.enqueue(
|
|
173
|
+
controller.enqueue(frame)
|
|
172
174
|
},
|
|
173
175
|
close,
|
|
174
176
|
signal: stopped.signal,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "../schema/standard.ts"
|
|
2
|
+
import type { RouteSchema } from "./context.ts"
|
|
3
|
+
|
|
4
|
+
// What a schema offers for a slot whose values arrive as text: `@nifrajs/schema` sets it on a
|
|
5
|
+
// `t.object` (and any `t` schema that does not coerce) to the coercing form of the same schema.
|
|
6
|
+
const TEXT_SLOT = Symbol.for("nifra.schema.textSlot")
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `schema` with its `query`, `headers` and `cookies` schemas swapped for the text form each offers, or
|
|
10
|
+
* `schema` itself when none offers one. A query string, header values and a `Cookie` header are
|
|
11
|
+
* always text, so a schema that reads a boolean or a number there can only validate its coercing form.
|
|
12
|
+
*/
|
|
13
|
+
export function withTextSlots(schema: RouteSchema | undefined): RouteSchema | undefined {
|
|
14
|
+
let out = schema
|
|
15
|
+
for (const slot of ["query", "headers", "cookies"] as const) {
|
|
16
|
+
const validate = out?.[slot]?.["~standard"].validate
|
|
17
|
+
const form: StandardSchemaV1 | undefined = validate && Reflect.get(validate, TEXT_SLOT)
|
|
18
|
+
if (form !== undefined) out = { ...out, [slot]: form }
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
package/src/server/webhook.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* this reads the body bounded (DoS guard, shared with `c.boundedBody`) and verifies before handing
|
|
5
5
|
* back the raw text for the handler to parse with its own schema.
|
|
6
6
|
*
|
|
7
|
-
* Verification is **constant-time**: the
|
|
8
|
-
* (WebCrypto)
|
|
9
|
-
* can't be discovered through timing
|
|
7
|
+
* Verification is **constant-time**: the expected HMAC is computed once per secret with
|
|
8
|
+
* `crypto.subtle.sign` (WebCrypto) and compared to each signature the header lists without an early
|
|
9
|
+
* exit - so a wrong signature can't be discovered through timing, and a header listing many
|
|
10
|
+
* signatures costs no extra HMAC over the body. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
|
|
10
11
|
*
|
|
11
12
|
* Presets cover Stripe (`t=…,v1=…` with a replay-window check) and GitHub (`sha256=…`); `generic`
|
|
12
13
|
* takes an explicit header + encoding for any other provider.
|
|
@@ -48,6 +49,7 @@ export type WebhookFailureReason =
|
|
|
48
49
|
| "malformed_signature"
|
|
49
50
|
| "payload_too_large"
|
|
50
51
|
| "invalid_content_length"
|
|
52
|
+
| "invalid_body"
|
|
51
53
|
|
|
52
54
|
/** Verified ⇒ the raw `payload` text (parse it with your schema). Rejected ⇒ a stable `reason`. */
|
|
53
55
|
export type WebhookResult =
|
|
@@ -74,7 +76,7 @@ const importHmacKey = (secret: string): Promise<CryptoKey> => {
|
|
|
74
76
|
TEXT.encode(secret),
|
|
75
77
|
{ name: "HMAC", hash: "SHA-256" },
|
|
76
78
|
false,
|
|
77
|
-
["
|
|
79
|
+
["sign"],
|
|
78
80
|
)
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -120,17 +122,26 @@ const decodeSignature = (
|
|
|
120
122
|
? fromBase64Url(value)
|
|
121
123
|
: fromBase64(value)
|
|
122
124
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
125
|
+
/** The HMAC-SHA256 of `payload` under each of `keys`. */
|
|
126
|
+
const expectedMacs = (keys: readonly CryptoKey[], payload: Uint8Array): Promise<Uint8Array[]> =>
|
|
127
|
+
Promise.all(
|
|
128
|
+
keys.map(
|
|
129
|
+
async (key) =>
|
|
130
|
+
new Uint8Array(await crypto.subtle.sign("HMAC", key, payload as Uint8Array<ArrayBuffer>)),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** True iff `sig` equals one of `macs`. Every byte of a same-length MAC is visited; a MAC's length is
|
|
135
|
+
* fixed (32 bytes), so a length mismatch reveals nothing. */
|
|
136
|
+
function matchesAny(macs: readonly Uint8Array[], sig: Uint8Array): boolean {
|
|
137
|
+
let matched = false
|
|
138
|
+
for (const mac of macs) {
|
|
139
|
+
if (mac.length !== sig.length) continue
|
|
140
|
+
let diff = 0
|
|
141
|
+
for (let i = 0; i < mac.length; i++) diff |= (mac[i] ?? 0) ^ (sig[i] ?? 0)
|
|
142
|
+
if (diff === 0) matched = true
|
|
132
143
|
}
|
|
133
|
-
return
|
|
144
|
+
return matched
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
// Parse a Stripe `Stripe-Signature` header: `t=<unix>,v1=<hex>[,v1=<hex>…]` (other schemes ignored).
|
|
@@ -193,10 +204,7 @@ export async function verifyWebhook(
|
|
|
193
204
|
|
|
194
205
|
const read = await readBoundedBytes(req, maxBytes)
|
|
195
206
|
if (!read.ok) {
|
|
196
|
-
return {
|
|
197
|
-
ok: false,
|
|
198
|
-
reason: read.status === 413 ? "payload_too_large" : "invalid_content_length",
|
|
199
|
-
}
|
|
207
|
+
return { ok: false, reason: read.error }
|
|
200
208
|
}
|
|
201
209
|
const payload = new TextDecoder().decode(read.bytes)
|
|
202
210
|
|
|
@@ -209,14 +217,11 @@ export async function verifyWebhook(
|
|
|
209
217
|
if (preset.timestamped) {
|
|
210
218
|
const parsed = parseStripeHeader(headerValue)
|
|
211
219
|
if (parsed === null) return { ok: false, reason: "malformed_signature" }
|
|
212
|
-
const
|
|
220
|
+
const macs = await expectedMacs(keys, TEXT.encode(`${parsed.t}.${payload}`))
|
|
213
221
|
let matched = false
|
|
214
222
|
for (const v1 of parsed.v1) {
|
|
215
223
|
const sig = decodeSignature(v1, encoding)
|
|
216
|
-
if (sig !== null && (
|
|
217
|
-
matched = true
|
|
218
|
-
break
|
|
219
|
-
}
|
|
224
|
+
if (sig !== null && matchesAny(macs, sig)) matched = true
|
|
220
225
|
}
|
|
221
226
|
if (!matched) return { ok: false, reason: "invalid_signature" }
|
|
222
227
|
// The signature covers `t`, so `t` is authentic once matched - now enforce the replay window.
|
|
@@ -232,6 +237,6 @@ export async function verifyWebhook(
|
|
|
232
237
|
prefix !== "" && headerValue.startsWith(prefix) ? headerValue.slice(prefix.length) : headerValue
|
|
233
238
|
const sig = decodeSignature(raw, encoding)
|
|
234
239
|
if (sig === null) return { ok: false, reason: "malformed_signature" }
|
|
235
|
-
if (await
|
|
240
|
+
if (matchesAny(await expectedMacs(keys, read.bytes), sig)) return { ok: true, payload }
|
|
236
241
|
return { ok: false, reason: "invalid_signature" }
|
|
237
242
|
}
|
package/src/server/websocket.ts
CHANGED
|
@@ -18,7 +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
|
+
import { isResponseResult, type StatusResponse } from "./runtime-core.ts"
|
|
22
22
|
|
|
23
23
|
type MaybePromise<T> = T | Promise<T>
|
|
24
24
|
|
|
@@ -35,7 +35,9 @@ type WsMessageInput<Schema extends StandardSchemaV1 | undefined> = Schema extend
|
|
|
35
35
|
export interface NifraWebSocket<Data = unknown> {
|
|
36
36
|
/** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */
|
|
37
37
|
send(data: string | ArrayBufferView | ArrayBuffer): void
|
|
38
|
-
/** Close the connection
|
|
38
|
+
/** Close the connection, with a code an endpoint may send (1000-1003, 1007-1014, 3000-4999;
|
|
39
|
+
* 1000 when none is given, on every runtime) and a reason of at most 123 UTF-8 bytes. Any other
|
|
40
|
+
* close throws a `RangeError`, and the socket stays open. */
|
|
39
41
|
close(code?: number, reason?: string): void
|
|
40
42
|
/** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */
|
|
41
43
|
readonly readyState: number
|
|
@@ -170,7 +172,7 @@ export interface WebSocketHandler<
|
|
|
170
172
|
* `data: undefined`. A thrown `Response` rejects with it; any other throw goes to the route's
|
|
171
173
|
* `onError` hooks, then a flat 500.
|
|
172
174
|
*/
|
|
173
|
-
upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response>
|
|
175
|
+
upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response | StatusResponse>
|
|
174
176
|
/**
|
|
175
177
|
* Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame.
|
|
176
178
|
* Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a
|
|
@@ -211,6 +213,9 @@ export interface WebSocketHandler<
|
|
|
211
213
|
issues: ReadonlyArray<StandardIssue>,
|
|
212
214
|
raw: WebSocketData,
|
|
213
215
|
): MaybePromise<void>
|
|
216
|
+
/** The connection closed. `code` and `reason` are the server's own close when it closed first
|
|
217
|
+
* (`ws.close()`, or 1009 for a message over the cap), the peer's when the peer did, and 1006 when
|
|
218
|
+
* the connection dropped without a close frame. */
|
|
214
219
|
close?(ws: NifraWebSocket<Data>, code: number, reason: string): MaybePromise<void>
|
|
215
220
|
error?(ws: NifraWebSocket<Data>, error: unknown): MaybePromise<void>
|
|
216
221
|
}
|
|
@@ -314,9 +319,20 @@ export function attachWebSocket<
|
|
|
314
319
|
handler.transport,
|
|
315
320
|
reportError,
|
|
316
321
|
)
|
|
322
|
+
// The runtime's close event carries the peer's echo, which may drop the reason; `close` is given
|
|
323
|
+
// the server's own close when the server closed first, as on Bun.
|
|
324
|
+
let sentClose: readonly [number, string] | undefined
|
|
325
|
+
// A close with no code is 1000 here as on Bun, whose close() cannot send one without a status.
|
|
326
|
+
const closeSocket = (code = 1000, reason?: string): void => {
|
|
327
|
+
if (sentClose === undefined && socket.readyState <= 1) sentClose = [code, reason ?? ""]
|
|
328
|
+
socket.close(code, reason)
|
|
329
|
+
}
|
|
317
330
|
ws = {
|
|
318
331
|
send,
|
|
319
|
-
close: (code, reason) =>
|
|
332
|
+
close: (code, reason) => {
|
|
333
|
+
assertCloseArgs(code, reason)
|
|
334
|
+
closeSocket(code, reason)
|
|
335
|
+
},
|
|
320
336
|
get readyState() {
|
|
321
337
|
return socket.readyState
|
|
322
338
|
},
|
|
@@ -342,7 +358,7 @@ export function attachWebSocket<
|
|
|
342
358
|
// The runtime hands over each message whole, so this bounds what reaches the handler, not what
|
|
343
359
|
// the runtime buffered to deliver it.
|
|
344
360
|
if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
|
|
345
|
-
|
|
361
|
+
closeSocket(1009, "message too large")
|
|
346
362
|
return
|
|
347
363
|
}
|
|
348
364
|
safe(() =>
|
|
@@ -357,7 +373,8 @@ export function attachWebSocket<
|
|
|
357
373
|
"close",
|
|
358
374
|
(event: { readonly code?: number; readonly reason?: string }) => {
|
|
359
375
|
pubsub.unsubscribeAll(ws) // drop topic subscriptions so the registry never holds a dead socket
|
|
360
|
-
|
|
376
|
+
const [code, reason] = sentClose ?? [event.code ?? 1005, event.reason ?? ""]
|
|
377
|
+
safe(() => handler.close?.(ws, code, reason))
|
|
361
378
|
},
|
|
362
379
|
)
|
|
363
380
|
socket.addEventListener("error", (event: unknown) => reportError(event))
|
|
@@ -372,7 +389,8 @@ export function attachWebSocket<
|
|
|
372
389
|
const WS_MESSAGE_DECODER = new TextDecoder()
|
|
373
390
|
|
|
374
391
|
/** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
|
|
375
|
-
* 1-3 bytes (a surrogate pair 4 for its 2
|
|
392
|
+
* 1-3 bytes (a surrogate pair 4 for its 2, a lone surrogate the 3 of U+FFFD), so only a length
|
|
393
|
+
* between those bounds is counted. */
|
|
376
394
|
export function exceedsPayloadCap(
|
|
377
395
|
message: string | ArrayBuffer | Uint8Array,
|
|
378
396
|
cap: number,
|
|
@@ -384,12 +402,43 @@ export function exceedsPayloadCap(
|
|
|
384
402
|
let bytes = 0
|
|
385
403
|
for (let i = 0; i < units; i++) {
|
|
386
404
|
const unit = message.charCodeAt(i)
|
|
387
|
-
|
|
405
|
+
if (unit >= 0xd800 && unit <= 0xdbff && (message.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
|
|
406
|
+
bytes += 4
|
|
407
|
+
i++
|
|
408
|
+
} else bytes += unit < 0x80 ? 1 : unit < 0x800 ? 2 : 3
|
|
388
409
|
if (bytes > cap) return true
|
|
389
410
|
}
|
|
390
411
|
return false
|
|
391
412
|
}
|
|
392
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Refuse a close RFC 6455 forbids before the runtime sees it: a code outside 1000-1003, 1007-1014 and
|
|
416
|
+
* 3000-4999, a reason over 123 UTF-8 bytes, or a reason without a code. Runtimes disagreed on such a
|
|
417
|
+
* close - Bun and Deno sent it, Node's `ws` threw after marking the socket closing, which left it
|
|
418
|
+
* half-open, and workerd threw - so every adapter throws this `RangeError` first and the socket stays
|
|
419
|
+
* open.
|
|
420
|
+
*/
|
|
421
|
+
export function assertCloseArgs(code: number | undefined, reason: string | undefined): void {
|
|
422
|
+
if (code === undefined) {
|
|
423
|
+
if (reason !== undefined && reason !== "")
|
|
424
|
+
throw new RangeError("ws.close(): a reason needs a code")
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
if (
|
|
428
|
+
!Number.isInteger(code) ||
|
|
429
|
+
!(
|
|
430
|
+
(code >= 1000 && code <= 1003) ||
|
|
431
|
+
(code >= 1007 && code <= 1014) ||
|
|
432
|
+
(code >= 3000 && code <= 4999)
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
throw new RangeError(
|
|
436
|
+
`ws.close(): ${code} is not a code a server may send (1000-1003, 1007-1014, 3000-4999)`,
|
|
437
|
+
)
|
|
438
|
+
if (reason !== undefined && exceedsPayloadCap(reason, 123))
|
|
439
|
+
throw new RangeError("ws.close(): the reason is over 123 UTF-8 bytes")
|
|
440
|
+
}
|
|
441
|
+
|
|
393
442
|
type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
|
|
394
443
|
|
|
395
444
|
const OUTBOUND_VALIDATION_ERROR = "outbound WebSocket frame failed sendSchema validation"
|
package/src/server/ws-bun.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
assertCloseArgs,
|
|
9
10
|
createWebSocketSender,
|
|
10
11
|
exceedsPayloadCap,
|
|
11
12
|
type NifraWebSocket,
|
|
@@ -103,7 +104,10 @@ function wrapBunSocket(
|
|
|
103
104
|
)
|
|
104
105
|
ws = {
|
|
105
106
|
send,
|
|
106
|
-
close: (code, reason) =>
|
|
107
|
+
close: (code, reason) => {
|
|
108
|
+
assertCloseArgs(code, reason)
|
|
109
|
+
raw.close(code, reason)
|
|
110
|
+
},
|
|
107
111
|
get readyState() {
|
|
108
112
|
return raw.readyState
|
|
109
113
|
},
|
|
@@ -206,8 +210,8 @@ export function createBunWsHandlers(
|
|
|
206
210
|
// Registry mode: drop topic subscriptions so it never holds a dead socket. Native mode: Bun
|
|
207
211
|
// unsubscribes the socket from every topic on close, so there is nothing to sweep here.
|
|
208
212
|
if (!nativePubsub) (ws.data.pubsub ?? topics).unsubscribeAll(nifra)
|
|
209
|
-
// Bun's own maxPayloadLength
|
|
210
|
-
// the handler gets the 1009 every other payload cap reports.
|
|
213
|
+
// Bun's own maxPayloadLength reports 1006 here, whether it dropped the connection (up to 1.4.3)
|
|
214
|
+
// or sent the peer a 1009 (from 1.4.4); the handler gets the 1009 every other payload cap reports.
|
|
211
215
|
const oversize = code === 1006 && reason === BUN_TOO_BIG_REASON
|
|
212
216
|
const closeCode = oversize ? 1009 : code
|
|
213
217
|
const closeReason = oversize ? "message too large" : reason
|
package/src/server.ts
CHANGED
|
@@ -46,6 +46,7 @@ export {
|
|
|
46
46
|
markAuthenticatedRequest,
|
|
47
47
|
rejected,
|
|
48
48
|
} from "./server/auth.ts"
|
|
49
|
+
export { beforeHandler } from "./server/before-handler.ts"
|
|
49
50
|
export type {
|
|
50
51
|
Context,
|
|
51
52
|
Params,
|
|
@@ -107,6 +108,7 @@ export type {
|
|
|
107
108
|
} from "./server/registry.ts"
|
|
108
109
|
export { replacedRequestOf } from "./server/request-lineage.ts"
|
|
109
110
|
export { writableResponse } from "./server/respond.ts"
|
|
111
|
+
export { routeTemplateOf } from "./server/route-template.ts"
|
|
110
112
|
export {
|
|
111
113
|
type PlainRender,
|
|
112
114
|
type ResponseResult,
|
package/src/single-copy.ts
CHANGED
|
@@ -78,13 +78,9 @@ export const IDENTITY_SENSITIVE_PACKAGES: readonly string[] = [
|
|
|
78
78
|
/** Files a redirect may rewrite. Everything else in a package (json, css, wasm) loads unchanged. */
|
|
79
79
|
const REDIRECTABLE = /\.(?:m|c)?[jt]sx?$/
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (path.endsWith(".tsx")) return "tsx"
|
|
85
|
-
if (path.endsWith(".jsx")) return "jsx"
|
|
86
|
-
return /\.[mc]?ts$/.test(path) ? "ts" : "js"
|
|
87
|
-
}
|
|
81
|
+
/** Appended to a linked file the load hook hands back to Bun untouched; see the hook. */
|
|
82
|
+
const OWN_FILE_QUERY = "?nifra-single-copy"
|
|
83
|
+
|
|
88
84
|
/** Runaway guards. A real `node_modules` top level is hundreds of entries, a repo walk is a few. */
|
|
89
85
|
const MAX_SCANNED_ENTRIES = 4_096
|
|
90
86
|
const MAX_ANCESTOR_DIRS = 16
|
|
@@ -233,6 +229,11 @@ export interface SingleCopyRegistration {
|
|
|
233
229
|
readonly run: boolean
|
|
234
230
|
/** `[test] preload`: covers `bun test`, the surface that runs app sources unbundled. */
|
|
235
231
|
readonly test: boolean
|
|
232
|
+
/**
|
|
233
|
+
* Tables whose `preload` names the registrar although Bun reads it from neither place: below
|
|
234
|
+
* `[install]` the line is `install.preload`, so `bun run` loads every copy while the file looks armed.
|
|
235
|
+
*/
|
|
236
|
+
readonly ignored?: readonly string[]
|
|
236
237
|
/** The `bunfig.toml` that was read, if one exists. */
|
|
237
238
|
readonly config?: string
|
|
238
239
|
}
|
|
@@ -286,6 +287,7 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
|
|
|
286
287
|
let section = ""
|
|
287
288
|
let run = false
|
|
288
289
|
let test = false
|
|
290
|
+
const ignored: string[] = []
|
|
289
291
|
for (const line of text.split(/\r?\n/)) {
|
|
290
292
|
const match = PRELOAD_ASSIGNMENT.exec(line)
|
|
291
293
|
if (match?.groups === undefined) continue
|
|
@@ -298,8 +300,9 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
|
|
|
298
300
|
continue
|
|
299
301
|
if (section === "test") test = true
|
|
300
302
|
else if (section === "") run = true
|
|
303
|
+
else if (!ignored.includes(section)) ignored.push(section)
|
|
301
304
|
}
|
|
302
|
-
return { run, test, config }
|
|
305
|
+
return ignored.length === 0 ? { run, test, config } : { run, test, ignored, config }
|
|
303
306
|
}
|
|
304
307
|
|
|
305
308
|
/** The repository a linked checkout resolves its own dependencies from: its nearest `.git` ancestor.
|
|
@@ -598,6 +601,8 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
598
601
|
// hook has to return contents for everything it matches, and re-emitting a CommonJS entry as an
|
|
599
602
|
// ES module is how `Export named 'useEffect' not found` happens.
|
|
600
603
|
const filter = new RegExp(`^(?:${roots})[/\\\\].*${REDIRECTABLE.source}`)
|
|
604
|
+
// `Bun.plugin` leaves `config` unset although the type says otherwise; only `Bun.build` sets it.
|
|
605
|
+
const bundling = build.config !== undefined
|
|
601
606
|
build.onLoad({ filter }, (args) => {
|
|
602
607
|
for (const redirect of plan.redirects) {
|
|
603
608
|
const target = counterpart(redirect, args.path)
|
|
@@ -616,10 +621,13 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
616
621
|
detail: `${relative(owner.from, args.path)} has no file at the same path in the app's copy, so it loads from the linked copy and its module state is not shared`,
|
|
617
622
|
})
|
|
618
623
|
}
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
624
|
+
// The file loads as it is, in its own module format. A bundler takes `undefined` for that. A
|
|
625
|
+
// runtime `onLoad` must return an object, and source returned from here loads as an ES module,
|
|
626
|
+
// which empties a CommonJS file's exports - so the runtime gets a re-export of the file under a
|
|
627
|
+
// query the filter's `$` cannot match, which Bun then loads natively from its own directory.
|
|
628
|
+
// There its `__filename` and `import.meta.url` carry the query; the bundler cannot resolve it.
|
|
629
|
+
if (bundling) return undefined
|
|
630
|
+
return { contents: reexport(`${args.path}${OWN_FILE_QUERY}`), loader: "js" }
|
|
623
631
|
})
|
|
624
632
|
},
|
|
625
633
|
}
|
|
@@ -646,12 +654,20 @@ const processSet = (key: symbol): Set<string> => {
|
|
|
646
654
|
const copyLine = (label: string, path: string | undefined, version: string | undefined): string =>
|
|
647
655
|
` ${label}: ${path ?? "(not installed for the app)"}${version === undefined ? "" : ` (${version})`}`
|
|
648
656
|
|
|
649
|
-
/**
|
|
657
|
+
/**
|
|
658
|
+
* One report per package: both copies, both versions, the reason, and the fix. A version skew comes
|
|
659
|
+
* from the plan, which reads installs, so it says installed: nothing may ever load that copy, and a
|
|
660
|
+
* runtime `onLoad` cannot watch for it without re-emitting CommonJS files as ES modules.
|
|
661
|
+
*/
|
|
650
662
|
const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string => {
|
|
651
663
|
const first = skips[0]
|
|
652
664
|
const reason = first?.reason ?? "version-skew"
|
|
665
|
+
const effect =
|
|
666
|
+
reason === "version-skew"
|
|
667
|
+
? "a second copy is installed at another version, and whatever loads it does not share this copy's module state."
|
|
668
|
+
: "a second copy loads, so its module state is not shared."
|
|
653
669
|
const lines = [
|
|
654
|
-
`[nifra] single-copy: ${name} is NOT deduplicated (${reason}) -
|
|
670
|
+
`[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - ${effect}`,
|
|
655
671
|
copyLine("app copy", first?.to, first?.toVersion),
|
|
656
672
|
]
|
|
657
673
|
for (const skip of skips) {
|
|
@@ -683,7 +699,7 @@ const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
|
|
|
683
699
|
if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
|
|
684
700
|
const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
|
|
685
701
|
return [
|
|
686
|
-
`[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each
|
|
702
|
+
`[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each is installed at another version, and whatever loads one does not share the app copy's module state.`,
|
|
687
703
|
copyLine(
|
|
688
704
|
"app copies",
|
|
689
705
|
first.to === undefined ? undefined : modulesDirOf(first.to),
|