@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.
Files changed (196) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +71 -5
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +90 -25
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +6 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +52 -0
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +7 -2
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-program.d.ts +1 -1
  49. package/dist/internal/route-program.d.ts.map +1 -1
  50. package/dist/internal/route-program.js +59 -16
  51. package/dist/internal/route-program.js.map +1 -1
  52. package/dist/reconciliation-worker.d.ts.map +1 -1
  53. package/dist/reconciliation-worker.js +12 -2
  54. package/dist/reconciliation-worker.js.map +1 -1
  55. package/dist/reflection.d.ts +53 -2
  56. package/dist/reflection.d.ts.map +1 -1
  57. package/dist/reflection.js +352 -5
  58. package/dist/reflection.js.map +1 -1
  59. package/dist/router/pattern.d.ts +1 -1
  60. package/dist/router/pattern.d.ts.map +1 -1
  61. package/dist/router/pattern.js +6 -3
  62. package/dist/router/pattern.js.map +1 -1
  63. package/dist/security/baseline.d.ts +3 -0
  64. package/dist/security/baseline.d.ts.map +1 -1
  65. package/dist/security/baseline.js +9 -5
  66. package/dist/security/baseline.js.map +1 -1
  67. package/dist/server/auth.d.ts +32 -3
  68. package/dist/server/auth.d.ts.map +1 -1
  69. package/dist/server/auth.js +23 -0
  70. package/dist/server/auth.js.map +1 -1
  71. package/dist/server/body.d.ts +7 -0
  72. package/dist/server/body.d.ts.map +1 -1
  73. package/dist/server/body.js +34 -1
  74. package/dist/server/body.js.map +1 -1
  75. package/dist/server/client-ip.d.ts +3 -0
  76. package/dist/server/client-ip.d.ts.map +1 -1
  77. package/dist/server/client-ip.js.map +1 -1
  78. package/dist/server/context.d.ts +2 -1
  79. package/dist/server/context.d.ts.map +1 -1
  80. package/dist/server/idempotency-lane.d.ts +6 -3
  81. package/dist/server/idempotency-lane.d.ts.map +1 -1
  82. package/dist/server/idempotency-lane.js +38 -17
  83. package/dist/server/idempotency-lane.js.map +1 -1
  84. package/dist/server/install.d.ts +2 -0
  85. package/dist/server/install.d.ts.map +1 -1
  86. package/dist/server/install.js +2 -0
  87. package/dist/server/install.js.map +1 -1
  88. package/dist/server/logger.d.ts.map +1 -1
  89. package/dist/server/logger.js +3 -1
  90. package/dist/server/logger.js.map +1 -1
  91. package/dist/server/methods.d.ts +1 -0
  92. package/dist/server/methods.d.ts.map +1 -1
  93. package/dist/server/methods.js +1 -0
  94. package/dist/server/methods.js.map +1 -1
  95. package/dist/server/node-outcome.d.ts.map +1 -1
  96. package/dist/server/node-outcome.js +29 -11
  97. package/dist/server/node-outcome.js.map +1 -1
  98. package/dist/server/platform-adapters.d.ts.map +1 -1
  99. package/dist/server/platform-adapters.js +3 -2
  100. package/dist/server/platform-adapters.js.map +1 -1
  101. package/dist/server/request-context.d.ts +2 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/request-context.js +4 -1
  104. package/dist/server/request-context.js.map +1 -1
  105. package/dist/server/respond.d.ts +44 -11
  106. package/dist/server/respond.d.ts.map +1 -1
  107. package/dist/server/respond.js +192 -79
  108. package/dist/server/respond.js.map +1 -1
  109. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  110. package/dist/server/response-observer-runtime.js +10 -41
  111. package/dist/server/response-observer-runtime.js.map +1 -1
  112. package/dist/server/runtime-core.d.ts +2 -0
  113. package/dist/server/runtime-core.d.ts.map +1 -1
  114. package/dist/server/runtime-core.js +2 -0
  115. package/dist/server/runtime-core.js.map +1 -1
  116. package/dist/server/server-types.d.ts +29 -5
  117. package/dist/server/server-types.d.ts.map +1 -1
  118. package/dist/server/server.d.ts +44 -31
  119. package/dist/server/server.d.ts.map +1 -1
  120. package/dist/server/server.js +444 -321
  121. package/dist/server/server.js.map +1 -1
  122. package/dist/server/sse.d.ts +11 -1
  123. package/dist/server/sse.d.ts.map +1 -1
  124. package/dist/server/sse.js +18 -3
  125. package/dist/server/sse.js.map +1 -1
  126. package/dist/server/transport-lane.d.ts +7 -1
  127. package/dist/server/transport-lane.d.ts.map +1 -1
  128. package/dist/server/transport-lane.js.map +1 -1
  129. package/dist/server/webhook.d.ts +5 -2
  130. package/dist/server/webhook.d.ts.map +1 -1
  131. package/dist/server/webhook.js +17 -1
  132. package/dist/server/webhook.js.map +1 -1
  133. package/dist/server/websocket.d.ts +22 -14
  134. package/dist/server/websocket.d.ts.map +1 -1
  135. package/dist/server/websocket.js +21 -0
  136. package/dist/server/websocket.js.map +1 -1
  137. package/dist/server/ws-hook.d.ts +7 -1
  138. package/dist/server/ws-hook.d.ts.map +1 -1
  139. package/dist/server.d.ts +3 -2
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +2 -1
  142. package/dist/server.js.map +1 -1
  143. package/dist/single-copy.d.ts +6 -0
  144. package/dist/single-copy.d.ts.map +1 -1
  145. package/dist/single-copy.js +20 -1
  146. package/dist/single-copy.js.map +1 -1
  147. package/dist/tool-contract.d.ts.map +1 -1
  148. package/dist/tool-contract.js +71 -40
  149. package/dist/tool-contract.js.map +1 -1
  150. package/dist/ws.d.ts.map +1 -1
  151. package/dist/ws.js +3 -1
  152. package/dist/ws.js.map +1 -1
  153. package/package.json +1 -1
  154. package/src/assurance.ts +180 -31
  155. package/src/capabilities.ts +59 -0
  156. package/src/durable-effect.ts +1 -1
  157. package/src/durable-saga.ts +94 -10
  158. package/src/edge-kit.ts +7 -2
  159. package/src/effect-scope.ts +15 -5
  160. package/src/errors.ts +1 -0
  161. package/src/evidence.ts +12 -3
  162. package/src/index.ts +6 -1
  163. package/src/internal/durable-shared.ts +25 -37
  164. package/src/internal/durable-types.ts +3 -0
  165. package/src/internal/hook-origins.ts +153 -0
  166. package/src/internal/route-assurance.ts +32 -18
  167. package/src/internal/route-program.ts +98 -44
  168. package/src/reconciliation-worker.ts +10 -2
  169. package/src/reflection.ts +455 -4
  170. package/src/router/pattern.ts +6 -3
  171. package/src/security/baseline.ts +10 -5
  172. package/src/server/auth.ts +45 -3
  173. package/src/server/body.ts +34 -1
  174. package/src/server/client-ip.ts +3 -0
  175. package/src/server/context.ts +2 -1
  176. package/src/server/idempotency-lane.ts +64 -22
  177. package/src/server/install.ts +3 -0
  178. package/src/server/logger.ts +3 -1
  179. package/src/server/methods.ts +1 -0
  180. package/src/server/node-outcome.ts +34 -12
  181. package/src/server/platform-adapters.ts +3 -2
  182. package/src/server/request-context.ts +5 -0
  183. package/src/server/respond.ts +222 -69
  184. package/src/server/response-observer-runtime.ts +16 -38
  185. package/src/server/runtime-core.ts +2 -0
  186. package/src/server/server-types.ts +29 -5
  187. package/src/server/server.ts +659 -530
  188. package/src/server/sse.ts +84 -55
  189. package/src/server/transport-lane.ts +7 -1
  190. package/src/server/webhook.ts +22 -3
  191. package/src/server/websocket.ts +44 -5
  192. package/src/server/ws-hook.ts +5 -0
  193. package/src/server.ts +5 -0
  194. package/src/single-copy.ts +20 -1
  195. package/src/tool-contract.ts +67 -39
  196. 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
- /** Enqueue an event frame. No-op once the stream is closed. */
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
- start(controller) {
132
- const close = (): void => {
133
- if (closed) return
134
- closed = true
135
- teardown()
136
- try {
137
- controller.close()
138
- } catch {
139
- // Already closed by the runtime's cancel() - safe to ignore.
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
- const stream: SSEStream = {
143
- send(message) {
144
- if (!closed) controller.enqueue(encoder.encode(formatMessage(message)))
145
- },
146
- close,
147
- signal: stopped.signal,
148
- }
149
-
150
- // Stop the moment the client goes away.
151
- if (requestSignal !== undefined) {
152
- if (requestSignal.aborted) {
153
- stopped.abort()
154
- close()
155
- return
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
- onAbort = () => {
158
- stopped.abort()
159
- close()
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
- requestSignal.addEventListener("abort", onAbort, { once: true })
162
- }
163
-
164
- if (init.keepAlive !== undefined && init.keepAlive > 0) {
165
- heartbeat = setInterval(() => stream.send({ comment: "" }), init.keepAlive)
166
- // Don't let the ping keep a Node process alive on its own.
167
- ;(heartbeat as { unref?: () => void }).unref?.()
168
- }
169
- // An async IIFE so a synchronous throw in `run` becomes a rejection we can surface.
170
- ;(async () => {
171
- await run(stream)
172
- })().then(close, (err: unknown) => {
173
- if (closed) return
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
- try {
177
- controller.error(err instanceof Error ? err : new Error("sse_producer_failed"))
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
- /** Maximum encoded request bytes. Keep aligned with `server({ maxBodyBytes })`. */
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
@@ -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
- /** How the signature is encoded in the header. Default `"hex"`. */
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 => (encoding === "hex" ? fromHex(value) : fromBase64(value))
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(
@@ -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
- * Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) - the
154
- * place to authenticate or rate-limit. Return the initial per-connection `data` (→ `ws.data`), or a
155
- * `Response` to **reject** the upgrade (the client never connects). Omit to accept with
156
- * `data: undefined`. A thrown error rejects with a flat 500.
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
- upgrade?(c: WebSocketContext<Env>): MaybePromise<Data | Response>
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",
@@ -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,
@@ -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: "js" }
591
+ return { contents: readFileSync(args.path, "utf8"), loader: sourceLoader(args.path) }
573
592
  })
574
593
  },
575
594
  }
@@ -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
- if (reservation !== undefined)
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
- if (reservation !== undefined)
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
- if (reservation !== undefined)
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 (reservation !== undefined) {
660
- const completed = await options.idempotency?.complete(reservationInput(reservation))
661
- if (completed !== true) {
662
- record("idempotency", "failed", "idempotency_capacity")
663
- return finish({
664
- ok: false,
665
- dryRun,
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 (reservation !== undefined) {
679
- const completed = await options.idempotency?.complete(reservationInput(reservation))
680
- if (completed !== true) {
681
- record("idempotency", "failed", "idempotency_capacity")
682
- return finish({
683
- ok: false,
684
- dryRun,
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 (reservation !== undefined) await options.idempotency?.abandon(reservationInput(reservation))
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 status = result.ok
792
- ? 200
793
- : result.error.code === "input_invalid"
794
- ? 422
795
- : result.error.code === "approval_required" ||
796
- result.error.code === "approval_denied" ||
797
- result.error.code === "capability_denied"
798
- ? 403
799
- : result.error.code === "budget_exceeded"
800
- ? 429
801
- : 500
802
- return new Response(body, { status, headers: { "content-type": "application/json" } })
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,