@nifrajs/core 4.0.2 → 5.0.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 +71 -5
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +90 -25
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +52 -0
- 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 +7 -2
- 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 +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- 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-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/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.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.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.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-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 +29 -5
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +44 -31
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +444 -321
- 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 +22 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +21 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +7 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- 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 +20 -1
- 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/ws.d.ts.map +1 -1
- package/dist/ws.js +3 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +180 -31
- package/src/capabilities.ts +59 -0
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +7 -2
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +6 -1
- 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-program.ts +98 -44
- 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.ts +34 -1
- package/src/server/client-ip.ts +3 -0
- package/src/server/context.ts +2 -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-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +29 -5
- package/src/server/server.ts +659 -530
- 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 +44 -5
- package/src/server/ws-hook.ts +5 -0
- package/src/server.ts +5 -0
- package/src/single-copy.ts +20 -1
- package/src/tool-contract.ts +67 -39
- package/src/ws.ts +4 -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
|
|
@@ -444,6 +456,33 @@ export function createWebSocketSender(
|
|
|
444
456
|
* `protoPoisoning` is the app's body policy: a frame with a `__proto__` key gets what a JSON body
|
|
445
457
|
* with one gets, and under `"reject"` reads as invalid JSON.
|
|
446
458
|
*/
|
|
459
|
+
/** `upgrade()`'s acceptance, wrapped so a handshake's route chain tells it from an early result such
|
|
460
|
+
* as a guard's `Response` or a rejected auth stage. */
|
|
461
|
+
class HandshakeAccepted {
|
|
462
|
+
readonly data: unknown
|
|
463
|
+
constructor(data: unknown) {
|
|
464
|
+
this.data = data
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const acceptHandshake = (value: unknown): unknown =>
|
|
469
|
+
value instanceof Response || isResponseResult(value) ? value : new HandshakeAccepted(value)
|
|
470
|
+
|
|
471
|
+
/** The step a handshake's route chain ends in: the route's `upgrade()`, or an acceptance without data. */
|
|
472
|
+
export function handshakeUpgradeStep(
|
|
473
|
+
handler: WebSocketHandler,
|
|
474
|
+
): (ctx: WebSocketContext) => unknown {
|
|
475
|
+
return (ctx) => {
|
|
476
|
+
const data = handler.upgrade?.(ctx)
|
|
477
|
+
return data instanceof Promise ? data.then(acceptHandshake) : acceptHandshake(data)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The acceptance a handshake's route chain ended in, or `undefined` when the chain refused it. */
|
|
482
|
+
export function acceptedHandshake(result: unknown): { readonly data: unknown } | undefined {
|
|
483
|
+
return result instanceof HandshakeAccepted ? result : undefined
|
|
484
|
+
}
|
|
485
|
+
|
|
447
486
|
export function wrapWebSocketMessageValidation(
|
|
448
487
|
handler: WebSocketHandler,
|
|
449
488
|
protoPoisoning: ProtoPoisoning = "reject",
|
package/src/server/ws-hook.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
NifraWebSocket,
|
|
14
14
|
StandardWebSocket,
|
|
15
15
|
TopicRegistry,
|
|
16
|
+
WebSocketContext,
|
|
16
17
|
WebSocketHandler,
|
|
17
18
|
} from "./websocket.ts"
|
|
18
19
|
|
|
@@ -29,6 +30,10 @@ export interface BunWsHandlers {
|
|
|
29
30
|
export interface WsRuntime {
|
|
30
31
|
/** `wrapWebSocketMessageValidation` - applied once at `app.ws()` registration. */
|
|
31
32
|
wrapHandler(handler: WebSocketHandler, protoPoisoning: ProtoPoisoning): WebSocketHandler
|
|
33
|
+
/** `handshakeUpgradeStep` - the step a handshake's route chain ends in. */
|
|
34
|
+
upgradeStep(handler: WebSocketHandler): (ctx: WebSocketContext) => unknown
|
|
35
|
+
/** `acceptedHandshake` - the acceptance a chain ended in, `undefined` when it refused. */
|
|
36
|
+
accepted(result: unknown): { readonly data: unknown } | undefined
|
|
32
37
|
/** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
|
|
33
38
|
createTopics(): TopicRegistry
|
|
34
39
|
/** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
|
package/src/server.ts
CHANGED
|
@@ -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,
|
package/src/single-copy.ts
CHANGED
|
@@ -77,6 +77,14 @@ export const IDENTITY_SENSITIVE_PACKAGES: readonly string[] = [
|
|
|
77
77
|
|
|
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
|
+
|
|
81
|
+
/** The loader a {@link REDIRECTABLE} file is parsed with. `.ts` is never `tsx`: under JSX rules a
|
|
82
|
+
* generic arrow (`<T>(x: T) => x`) reads as an unclosed element and swallows the file. */
|
|
83
|
+
function sourceLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
|
|
84
|
+
if (path.endsWith(".tsx")) return "tsx"
|
|
85
|
+
if (path.endsWith(".jsx")) return "jsx"
|
|
86
|
+
return /\.[mc]?ts$/.test(path) ? "ts" : "js"
|
|
87
|
+
}
|
|
80
88
|
/** Runaway guards. A real `node_modules` top level is hundreds of entries, a repo walk is a few. */
|
|
81
89
|
const MAX_SCANNED_ENTRIES = 4_096
|
|
82
90
|
const MAX_ANCESTOR_DIRS = 16
|
|
@@ -411,6 +419,17 @@ const declaredNames = (root: string, declared: readonly string[]): readonly stri
|
|
|
411
419
|
return [...names].sort()
|
|
412
420
|
}
|
|
413
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The installed package names the app's declaration covers: each exact name, and each package
|
|
424
|
+
* installed under a declared `@scope/*`. Empty without a declaration. A bundler that pins packages by
|
|
425
|
+
* name, such as Vite's `resolve.dedupe`, takes this list.
|
|
426
|
+
*/
|
|
427
|
+
export function singleCopyPackageNames(cwd: string): readonly string[] {
|
|
428
|
+
const root = realpathOrSelf(resolve(cwd))
|
|
429
|
+
const declared = readSingleCopyDeclaration(root)
|
|
430
|
+
return declared === undefined ? [] : declaredNames(root, declared)
|
|
431
|
+
}
|
|
432
|
+
|
|
414
433
|
/**
|
|
415
434
|
* Work out which foreign copies exist and which of them may be redirected.
|
|
416
435
|
*
|
|
@@ -569,7 +588,7 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
569
588
|
// A runtime `onLoad` must always return an object - returning nothing fails the load rather
|
|
570
589
|
// than falling through to the default. Hand back the file as it was, at its own path, so its
|
|
571
590
|
// relative imports keep resolving exactly as they would have.
|
|
572
|
-
return { contents: readFileSync(args.path, "utf8"), loader:
|
|
591
|
+
return { contents: readFileSync(args.path, "utf8"), loader: sourceLoader(args.path) }
|
|
573
592
|
})
|
|
574
593
|
},
|
|
575
594
|
}
|
package/src/tool-contract.ts
CHANGED
|
@@ -461,6 +461,25 @@ export async function executeTool<Input, Output>(
|
|
|
461
461
|
...(tool.cost === undefined ? {} : { cost: tool.cost }),
|
|
462
462
|
})
|
|
463
463
|
}
|
|
464
|
+
// A failed abandon leaves the key reserved until its lease lapses, as a failed renewal does; the
|
|
465
|
+
// call's own outcome still stands.
|
|
466
|
+
const abandon = async (): Promise<void> => {
|
|
467
|
+
if (reservation === undefined) return
|
|
468
|
+
try {
|
|
469
|
+
await options.idempotency?.abandon(reservationInput(reservation))
|
|
470
|
+
} catch {}
|
|
471
|
+
}
|
|
472
|
+
// Once the executor has run, the reservation is never released: a retry would repeat a side effect
|
|
473
|
+
// that may have committed. A store that throws on completion counts as one that refused it.
|
|
474
|
+
let executed = false
|
|
475
|
+
const complete = async (): Promise<boolean> => {
|
|
476
|
+
if (reservation === undefined) return true
|
|
477
|
+
try {
|
|
478
|
+
return (await options.idempotency?.complete(reservationInput(reservation))) === true
|
|
479
|
+
} catch {
|
|
480
|
+
return false
|
|
481
|
+
}
|
|
482
|
+
}
|
|
464
483
|
type ToolCallOutcome =
|
|
465
484
|
| { readonly ok: true; readonly dryRun: boolean; readonly output?: Output }
|
|
466
485
|
| { readonly ok: false; readonly dryRun: boolean; readonly error: ToolError }
|
|
@@ -625,22 +644,19 @@ export async function executeTool<Input, Output>(
|
|
|
625
644
|
record("idempotency", tool.idempotency === undefined ? "skipped" : "passed")
|
|
626
645
|
|
|
627
646
|
if (options.budget !== undefined && !options.budget.consume(tool.cost)) {
|
|
628
|
-
|
|
629
|
-
await options.idempotency?.abandon(reservationInput(reservation))
|
|
647
|
+
await abandon()
|
|
630
648
|
record("budget", "denied", "budget_exceeded")
|
|
631
649
|
return finish({ ok: false, dryRun, error: { code: "budget_exceeded", stage: "budget" } })
|
|
632
650
|
}
|
|
633
651
|
record("budget", tool.cost === undefined ? "skipped" : "passed")
|
|
634
652
|
|
|
635
653
|
if (signal.aborted) {
|
|
636
|
-
|
|
637
|
-
await options.idempotency?.abandon(reservationInput(reservation))
|
|
654
|
+
await abandon()
|
|
638
655
|
record("execution", "denied", "cancelled")
|
|
639
656
|
return finish({ ok: false, dryRun, error: { code: "cancelled", stage: "execution" } })
|
|
640
657
|
}
|
|
641
658
|
if (dryRun) {
|
|
642
|
-
|
|
643
|
-
await options.idempotency?.abandon(reservationInput(reservation))
|
|
659
|
+
await abandon()
|
|
644
660
|
record("execution", "dry-run")
|
|
645
661
|
return finish({ ok: true, dryRun })
|
|
646
662
|
}
|
|
@@ -651,21 +667,19 @@ export async function executeTool<Input, Output>(
|
|
|
651
667
|
dryRun,
|
|
652
668
|
...(tool.policy === undefined ? {} : { policy: tool.policy }),
|
|
653
669
|
})
|
|
670
|
+
executed = true
|
|
654
671
|
record("execution", "committed")
|
|
655
672
|
const validatedOutput = await validateStandard(tool.output, output)
|
|
656
673
|
if (!validatedOutput.ok) {
|
|
657
674
|
// The executor has already run. Keep the reservation completed even when its return shape is
|
|
658
675
|
// invalid, so a caller cannot retry and duplicate a side effect that may have committed.
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
error: { code: "idempotency_capacity", stage: "idempotency" },
|
|
667
|
-
})
|
|
668
|
-
}
|
|
676
|
+
if (!(await complete())) {
|
|
677
|
+
record("idempotency", "failed", "idempotency_capacity")
|
|
678
|
+
return finish({
|
|
679
|
+
ok: false,
|
|
680
|
+
dryRun,
|
|
681
|
+
error: { code: "idempotency_capacity", stage: "idempotency" },
|
|
682
|
+
})
|
|
669
683
|
}
|
|
670
684
|
record("output", "failed", "output_invalid")
|
|
671
685
|
return finish({
|
|
@@ -675,20 +689,17 @@ export async function executeTool<Input, Output>(
|
|
|
675
689
|
})
|
|
676
690
|
}
|
|
677
691
|
record("output", "passed")
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
error: { code: "idempotency_capacity", stage: "idempotency" },
|
|
686
|
-
})
|
|
687
|
-
}
|
|
692
|
+
if (!(await complete())) {
|
|
693
|
+
record("idempotency", "failed", "idempotency_capacity")
|
|
694
|
+
return finish({
|
|
695
|
+
ok: false,
|
|
696
|
+
dryRun,
|
|
697
|
+
error: { code: "idempotency_capacity", stage: "idempotency" },
|
|
698
|
+
})
|
|
688
699
|
}
|
|
689
700
|
return finish({ ok: true, dryRun, output: validatedOutput.value })
|
|
690
701
|
} catch (error) {
|
|
691
|
-
if (
|
|
702
|
+
if (!executed) await abandon()
|
|
692
703
|
const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
|
|
693
704
|
record("execution", "failed", code)
|
|
694
705
|
return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
|
|
@@ -788,18 +799,35 @@ export function toolHttpResult<Output>(result: ToolCallResult<Output>): Response
|
|
|
788
799
|
? { ok: true, output: result.output, dryRun: result.dryRun, evidence: result.evidence }
|
|
789
800
|
: { ok: false, error: result.error, dryRun: result.dryRun, evidence: result.evidence },
|
|
790
801
|
)
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
802
|
+
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
803
|
+
const status = result.ok ? 200 : toolErrorStatus(result.error.code)
|
|
804
|
+
// The idempotency refusals answer as the HTTP idempotency lane does, a retry included.
|
|
805
|
+
if (!result.ok && retryableIdempotency(result.error.code)) headers["retry-after"] = "1"
|
|
806
|
+
return new Response(body, { status, headers })
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function retryableIdempotency(code: ToolError["code"]): boolean {
|
|
810
|
+
return code === "idempotency_in_flight" || code === "idempotency_capacity"
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function toolErrorStatus(code: ToolError["code"]): number {
|
|
814
|
+
switch (code) {
|
|
815
|
+
case "input_invalid":
|
|
816
|
+
return 422
|
|
817
|
+
case "approval_required":
|
|
818
|
+
case "approval_denied":
|
|
819
|
+
case "capability_denied":
|
|
820
|
+
return 403
|
|
821
|
+
case "idempotency_duplicate":
|
|
822
|
+
case "idempotency_in_flight":
|
|
823
|
+
return 409
|
|
824
|
+
case "budget_exceeded":
|
|
825
|
+
return 429
|
|
826
|
+
case "idempotency_capacity":
|
|
827
|
+
return 503
|
|
828
|
+
default:
|
|
829
|
+
return 500
|
|
830
|
+
}
|
|
803
831
|
}
|
|
804
832
|
|
|
805
833
|
export function toolInputJsonSchema<Input, Output>(
|
package/src/ws.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { INSTALL_WS } from "./server/install.ts"
|
|
|
21
21
|
import type { IdentityPlugin } from "./server/plugin.ts"
|
|
22
22
|
import type { AnyServer } from "./server/server.ts"
|
|
23
23
|
import {
|
|
24
|
+
acceptedHandshake,
|
|
24
25
|
attachWebSocket,
|
|
26
|
+
handshakeUpgradeStep,
|
|
25
27
|
TopicRegistry,
|
|
26
28
|
wrapWebSocketMessageValidation,
|
|
27
29
|
} from "./server/websocket.ts"
|
|
@@ -30,6 +32,8 @@ import type { WsRuntime } from "./server/ws-hook.ts"
|
|
|
30
32
|
|
|
31
33
|
const WS_RUNTIME: WsRuntime = {
|
|
32
34
|
wrapHandler: wrapWebSocketMessageValidation,
|
|
35
|
+
upgradeStep: handshakeUpgradeStep,
|
|
36
|
+
accepted: acceptedHandshake,
|
|
33
37
|
createTopics: () => new TopicRegistry(),
|
|
34
38
|
bunHandlers: createBunWsHandlers,
|
|
35
39
|
attach: attachWebSocket,
|