@nifrajs/core 5.10.0 → 5.12.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 (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/edge-kit.d.ts +3 -2
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +3 -2
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/guard-deny.d.ts +38 -0
  7. package/dist/guard-deny.d.ts.map +1 -0
  8. package/dist/guard-deny.js +43 -0
  9. package/dist/guard-deny.js.map +1 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/internal/capability-runtime.d.ts.map +1 -1
  15. package/dist/internal/capability-runtime.js +3 -2
  16. package/dist/internal/capability-runtime.js.map +1 -1
  17. package/dist/internal/route-execution.d.ts +6 -4
  18. package/dist/internal/route-execution.d.ts.map +1 -1
  19. package/dist/internal/route-execution.js.map +1 -1
  20. package/dist/internal/route-lanes.d.ts +4 -4
  21. package/dist/internal/route-lanes.d.ts.map +1 -1
  22. package/dist/internal/route-lanes.js +37 -37
  23. package/dist/internal/route-lanes.js.map +1 -1
  24. package/dist/manifest.d.ts.map +1 -1
  25. package/dist/manifest.js +2 -1
  26. package/dist/manifest.js.map +1 -1
  27. package/dist/server/body.d.ts +2 -0
  28. package/dist/server/body.d.ts.map +1 -1
  29. package/dist/server/body.js +2 -0
  30. package/dist/server/body.js.map +1 -1
  31. package/dist/server/core-copies.js +1 -1
  32. package/dist/server/idle-timeout.d.ts +1 -1
  33. package/dist/server/idle-timeout.d.ts.map +1 -1
  34. package/dist/server/idle-timeout.js.map +1 -1
  35. package/dist/server/json-response.d.ts +14 -0
  36. package/dist/server/json-response.d.ts.map +1 -1
  37. package/dist/server/json-response.js +205 -0
  38. package/dist/server/json-response.js.map +1 -1
  39. package/dist/server/server-types.d.ts +7 -1
  40. package/dist/server/server-types.d.ts.map +1 -1
  41. package/dist/server/server.d.ts +16 -24
  42. package/dist/server/server.d.ts.map +1 -1
  43. package/dist/server/server.js +285 -140
  44. package/dist/server/server.js.map +1 -1
  45. package/dist/server/static-response.d.ts +13 -0
  46. package/dist/server/static-response.d.ts.map +1 -0
  47. package/dist/server/static-response.js +28 -0
  48. package/dist/server/static-response.js.map +1 -0
  49. package/dist/server/websocket.d.ts +23 -7
  50. package/dist/server/websocket.d.ts.map +1 -1
  51. package/dist/server/websocket.js +8 -3
  52. package/dist/server/websocket.js.map +1 -1
  53. package/dist/server/ws-adapter-view.d.ts +3 -3
  54. package/dist/server/ws-adapter-view.d.ts.map +1 -1
  55. package/dist/server/ws-bun.d.ts +4 -4
  56. package/dist/server/ws-bun.d.ts.map +1 -1
  57. package/dist/server/ws-bun.js +12 -5
  58. package/dist/server/ws-bun.js.map +1 -1
  59. package/dist/server/ws-hook.d.ts +4 -4
  60. package/dist/server/ws-hook.d.ts.map +1 -1
  61. package/dist/server.d.ts +2 -1
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +1 -0
  64. package/dist/server.js.map +1 -1
  65. package/dist/single-copy-register.js +5 -3
  66. package/dist/single-copy-register.js.map +1 -1
  67. package/dist/single-copy.d.ts +1 -1
  68. package/dist/single-copy.d.ts.map +1 -1
  69. package/dist/single-copy.js +49 -2
  70. package/dist/single-copy.js.map +1 -1
  71. package/dist/tool-contract.d.ts +5 -0
  72. package/dist/tool-contract.d.ts.map +1 -1
  73. package/dist/tool-contract.js +6 -3
  74. package/dist/tool-contract.js.map +1 -1
  75. package/dist/ws.d.ts +1 -1
  76. package/dist/ws.d.ts.map +1 -1
  77. package/dist/ws.js.map +1 -1
  78. package/package.json +6 -1
  79. package/src/edge-kit.ts +3 -2
  80. package/src/guard-deny.ts +61 -0
  81. package/src/index.ts +3 -1
  82. package/src/internal/capability-runtime.ts +3 -2
  83. package/src/internal/route-execution.ts +6 -4
  84. package/src/internal/route-lanes.ts +44 -44
  85. package/src/manifest.ts +2 -1
  86. package/src/server/body.ts +3 -0
  87. package/src/server/core-copies.ts +1 -1
  88. package/src/server/idle-timeout.ts +2 -4
  89. package/src/server/json-response.ts +209 -0
  90. package/src/server/server-types.ts +7 -1
  91. package/src/server/server.ts +414 -189
  92. package/src/server/static-response.ts +36 -0
  93. package/src/server/websocket.ts +29 -8
  94. package/src/server/ws-adapter-view.ts +3 -3
  95. package/src/server/ws-bun.ts +15 -9
  96. package/src/server/ws-hook.ts +4 -3
  97. package/src/server.ts +2 -0
  98. package/src/single-copy-register.ts +5 -3
  99. package/src/single-copy.ts +51 -3
  100. package/src/tool-contract.ts +13 -3
  101. package/src/ws.ts +1 -0
@@ -0,0 +1,36 @@
1
+ export interface StaticResponseDefinition {
2
+ readonly status: number
3
+ readonly statusText: string
4
+ readonly headers: Headers
5
+ }
6
+
7
+ const definitions = new WeakMap<object, StaticResponseDefinition>()
8
+
9
+ /**
10
+ * Declare a bodyless response with fixed status and headers.
11
+ * Caller-supplied Content-Length and Transfer-Encoding are rejected.
12
+ */
13
+ export function staticResponse(init?: ResponseInit): () => Response {
14
+ const normalized = new Response(null, init)
15
+ for (const name of ["content-length", "transfer-encoding"]) {
16
+ if (normalized.headers.has(name)) {
17
+ throw new TypeError(`staticResponse cannot set the framing header "${name}"`)
18
+ }
19
+ }
20
+ const definition: StaticResponseDefinition = Object.freeze({
21
+ status: normalized.status,
22
+ statusText: normalized.statusText,
23
+ headers: new Headers(normalized.headers),
24
+ })
25
+ const handler = (): Response => createStaticResponse(definition)
26
+ definitions.set(handler, definition)
27
+ return Object.freeze(handler)
28
+ }
29
+
30
+ export function staticResponseDefinition(handler: unknown): StaticResponseDefinition | undefined {
31
+ return typeof handler === "function" ? definitions.get(handler) : undefined
32
+ }
33
+
34
+ export function createStaticResponse(definition: StaticResponseDefinition): Response {
35
+ return new Response(null, definition)
36
+ }
@@ -51,13 +51,30 @@ export interface NifraWebSocket<Data = unknown> {
51
51
  readonly raw: unknown
52
52
  }
53
53
 
54
+ /**
55
+ * Per-server topic membership and broadcast adapter. The default {@link TopicRegistry} keeps sockets
56
+ * in this process; a custom adapter can bridge those operations to a shared fan-out service. The
57
+ * membership methods are synchronous because socket callbacks are synchronous; `publish` may be
58
+ * asynchronous, in which case callers can await `app.publish()`.
59
+ */
60
+ export interface TopicAdapter {
61
+ /** Add an open socket to a topic; ignore `readyState >= 2` and make repeats idempotent. */
62
+ subscribe(topic: string, ws: NifraWebSocket): void
63
+ /** Remove a socket from one topic; repeated unsubscriptions must be idempotent. */
64
+ unsubscribe(topic: string, ws: NifraWebSocket): void
65
+ /** Drop every topic membership for a socket when it closes. */
66
+ unsubscribeAll(ws: NifraWebSocket): void
67
+ /** Broadcast a frame to the topic; asynchronous fan-out is returned to `app.publish()`. */
68
+ publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void | Promise<void>
69
+ }
70
+
54
71
  /**
55
72
  * In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** -
56
73
  * topics live in this process's memory, so a multi-instance deploy (multiple servers behind a load
57
- * balancer) needs an external fan-out (Redis pub/sub, a Cloudflare Durable Object, NATS, …) bridged to
58
- * `app.publish`. The same registry instance is shared by an app's connections and its `publish`.
74
+ * balancer) needs a {@link TopicAdapter} backed by a shared fan-out. The same registry instance is
75
+ * shared by an app's connections and its `publish`.
59
76
  */
60
- export class TopicRegistry {
77
+ export class TopicRegistry implements TopicAdapter {
61
78
  private readonly topics = new Map<string, Set<NifraWebSocket>>()
62
79
  private readonly memberships = new WeakMap<NifraWebSocket, Set<string>>()
63
80
 
@@ -234,8 +251,8 @@ export type WebSocketUpgradeOutcome =
234
251
  readonly kind: "upgrade"
235
252
  readonly handler: WebSocketHandler
236
253
  readonly data: unknown
237
- /** The app's pub/sub registry - the adapter wires `ws.subscribe` + close-cleanup to it. */
238
- readonly pubsub: TopicRegistry
254
+ /** The app's topic adapter - the adapter wires `ws.subscribe` + close-cleanup to it. */
255
+ readonly pubsub: TopicAdapter
239
256
  /** The installed runtime's {@link attachWebSocket}, carried on the outcome so an adapter can wire
240
257
  * a standard socket without a static import of the WS implementation (which would defeat the
241
258
  * `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */
@@ -259,7 +276,7 @@ export type WsAttach = (
259
276
  socket: StandardWebSocket,
260
277
  handler: WebSocketHandler,
261
278
  data: unknown,
262
- options: { openNow: boolean; pubsub: TopicRegistry; maxPayloadBytes?: number | undefined },
279
+ options: { openNow: boolean; pubsub: TopicAdapter; maxPayloadBytes?: number | undefined },
263
280
  ) => NifraWebSocket
264
281
 
265
282
  /**
@@ -299,7 +316,7 @@ export function attachWebSocket<
299
316
  socket: StandardWebSocket,
300
317
  handler: WebSocketHandler<Data, Env, Schema, Send>,
301
318
  data: unknown,
302
- options: { openNow: boolean; pubsub: TopicRegistry; maxPayloadBytes?: number | undefined },
319
+ options: { openNow: boolean; pubsub: TopicAdapter; maxPayloadBytes?: number | undefined },
303
320
  ): NifraWebSocket<Data> {
304
321
  const { pubsub } = options
305
322
  let ws!: NifraWebSocket<Data>
@@ -372,7 +389,11 @@ export function attachWebSocket<
372
389
  socket.addEventListener(
373
390
  "close",
374
391
  (event: { readonly code?: number; readonly reason?: string }) => {
375
- pubsub.unsubscribeAll(ws) // drop topic subscriptions so the registry never holds a dead socket
392
+ try {
393
+ pubsub.unsubscribeAll(ws)
394
+ } catch (error) {
395
+ reportError(error)
396
+ }
376
397
  const [code, reason] = sentClose ?? [event.code ?? 1005, event.reason ?? ""]
377
398
  safe(() => handler.close?.(ws, code, reason))
378
399
  },
@@ -4,7 +4,7 @@
4
4
  * and outcome types carry the schemas and codecs the adapters never touch.
5
5
  */
6
6
 
7
- import type { TopicRegistry, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
7
+ import type { TopicAdapter, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
8
8
 
9
9
  /** A received frame, normalized: text → `string`, binary → `Uint8Array`. */
10
10
  export type NifraWsData = string | Uint8Array
@@ -20,7 +20,7 @@ export interface NifraWs {
20
20
  readonly raw: unknown
21
21
  }
22
22
 
23
- /** The `TopicRegistry` surface the adapter wires `ws.subscribe` to. */
23
+ /** The socket-membership surface the adapter wires `ws.subscribe` to. */
24
24
  export interface WsPubSub {
25
25
  subscribe(topic: string, ws: NifraWs): void
26
26
  unsubscribe(topic: string, ws: NifraWs): void
@@ -59,5 +59,5 @@ type Fits<View, Actual extends View> = Actual
59
59
  export type CoreFitsAdapterView = [
60
60
  Fits<WsUpgradeOutcome, WebSocketUpgradeOutcome>,
61
61
  Fits<NifraWsHandler, WebSocketHandler>,
62
- Fits<WsPubSub, TopicRegistry>,
62
+ Fits<WsPubSub, TopicAdapter>,
63
63
  ]
@@ -10,7 +10,7 @@ import {
10
10
  createWebSocketSender,
11
11
  exceedsPayloadCap,
12
12
  type NifraWebSocket,
13
- type TopicRegistry,
13
+ type TopicAdapter,
14
14
  type WebSocketData,
15
15
  type WebSocketHandler,
16
16
  } from "./websocket.ts"
@@ -20,8 +20,8 @@ import type { BunWsHandlers } from "./ws-hook.ts"
20
20
  * `upgrade()`-seeded user data, and the memoized portable {@link NifraWebSocket} wrapper. */
21
21
  export interface BunWsData {
22
22
  readonly handler: WebSocketHandler
23
- /** Mounted/modern upgrades carry this registry; old/manual fixtures may omit it. */
24
- readonly pubsub?: TopicRegistry
23
+ /** Mounted/modern upgrades carry their topic adapter; old/manual fixtures may omit it. */
24
+ readonly pubsub?: TopicAdapter
25
25
  /** The inbound cap of the app this socket belongs to (the upgrade outcome's). Absent, the
26
26
  * listening app's applies. */
27
27
  readonly maxPayloadBytes?: number | undefined
@@ -79,18 +79,18 @@ function toBinaryData(message: ArrayBuffer | Uint8Array): Uint8Array {
79
79
  *
80
80
  * `nativePubsub` selects the topic backend: in native mode `subscribe`/`unsubscribe` go straight to
81
81
  * Bun's own (uWebSockets) pub/sub so a broadcast via the Bun server's `publish` reaches this socket
82
- * with no per-connection JS loop; otherwise they use the runtime-neutral {@link TopicRegistry}. Native
82
+ * with no per-connection JS loop; otherwise they use the configured topic adapter. Native
83
83
  * mode is used only when no route validates outbound frames, so raw and validated sends are identical.
84
84
  */
85
85
  function wrapBunSocket(
86
86
  raw: BunSocket,
87
- topics: TopicRegistry,
87
+ topics: TopicAdapter,
88
88
  handler: WebSocketHandler,
89
89
  nativePubsub: boolean,
90
90
  ): NifraWebSocket {
91
91
  let ws!: NifraWebSocket
92
- // Mounted child apps carry their own TopicRegistry in the upgrade outcome. Keep the argument as a
93
- // fallback for old/manual Bun upgrades, but use the per-connection registry for composed mounts.
92
+ // Mounted child apps carry their own topic adapter in the upgrade outcome. Keep the argument as a
93
+ // fallback for old/manual Bun upgrades, but use the per-connection adapter for composed mounts.
94
94
  const pubsub = raw.data.pubsub ?? topics
95
95
  const reportError = (error: unknown): void => reportWsError(error, ws, handler)
96
96
  const send = createWebSocketSender(
@@ -167,7 +167,7 @@ const BUN_TOO_BIG_REASON = "Received too big message"
167
167
  * `maxPayloadLength`; a socket whose app declares less is closed with `1009` here.
168
168
  */
169
169
  export function createBunWsHandlers(
170
- topics: TopicRegistry,
170
+ topics: TopicAdapter,
171
171
  nativePubsub = false,
172
172
  app?: object,
173
173
  ): BunWsHandlers {
@@ -209,7 +209,13 @@ export function createBunWsHandlers(
209
209
  const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
210
210
  // Registry mode: drop topic subscriptions so it never holds a dead socket. Native mode: Bun
211
211
  // unsubscribes the socket from every topic on close, so there is nothing to sweep here.
212
- if (!nativePubsub) (ws.data.pubsub ?? topics).unsubscribeAll(nifra)
212
+ if (!nativePubsub) {
213
+ try {
214
+ ;(ws.data.pubsub ?? topics).unsubscribeAll(nifra)
215
+ } catch (error) {
216
+ reportWsError(error, nifra, ws.data.handler)
217
+ }
218
+ }
213
219
  // Bun's own maxPayloadLength reports 1006 here, whether it dropped the connection (up to 1.4.3)
214
220
  // or sent the peer a 1009 (from 1.4.4); the handler gets the 1009 every other payload cap reports.
215
221
  const oversize = code === 1006 && reason === BUN_TOO_BIG_REASON
@@ -12,13 +12,14 @@ import type { ProtoPoisoning } from "./proto-guard.ts"
12
12
  import type {
13
13
  NifraWebSocket,
14
14
  StandardWebSocket,
15
+ TopicAdapter,
15
16
  TopicRegistry,
16
17
  WebSocketContext,
17
18
  WebSocketHandler,
18
19
  WsSender,
19
20
  } from "./websocket.ts"
20
21
 
21
- /** The Bun serve `websocket` callback trio, closed over one app's `TopicRegistry`. The `ws`
22
+ /** The Bun serve `websocket` callback trio, closed over one app's topic adapter. The `ws`
22
23
  * params are `unknown` so `Bun.*` types never leak into the public `.d.ts`; the implementation
23
24
  * narrows to its structural `BunSocket` view. */
24
25
  export interface BunWsHandlers {
@@ -43,13 +44,13 @@ export interface WsRuntime {
43
44
  /** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
44
45
  * `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). `app` is
45
46
  * the listening server, whose mount table names the payload caps the listener has to admit. */
46
- bunHandlers(topics: TopicRegistry, nativePubsub?: boolean, app?: object): BunWsHandlers
47
+ bunHandlers(topics: TopicAdapter, nativePubsub?: boolean, app?: object): BunWsHandlers
47
48
  /** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
48
49
  attach(
49
50
  socket: StandardWebSocket,
50
51
  handler: WebSocketHandler,
51
52
  data: unknown,
52
- options: { openNow: boolean; pubsub: TopicRegistry },
53
+ options: { openNow: boolean; pubsub: TopicAdapter },
53
54
  ): NifraWebSocket
54
55
  /** `webSocketSender` - `ws.send` for an adapter that wires its own sockets (Node, Deno). */
55
56
  sender: WsSender
package/src/server.ts CHANGED
@@ -177,9 +177,11 @@ export type {
177
177
  SSEStream,
178
178
  TypedSSEStream,
179
179
  } from "./server/sse.ts"
180
+ export { staticResponse } from "./server/static-response.ts"
180
181
  export type {
181
182
  NifraWebSocket,
182
183
  StandardWebSocket,
184
+ TopicAdapter,
183
185
  WebSocketContext,
184
186
  WebSocketData,
185
187
  WebSocketHandler,
@@ -14,10 +14,12 @@
14
14
  * A resolver only affects imports that have not resolved yet, and a module's own imports resolve
15
15
  * before its body runs. Registering from inside application code would therefore miss exactly the
16
16
  * imports it exists to redirect. `preload` runs before the entry point, which is the only moment that
17
- * covers the whole graph.
17
+ * covers the whole graph. When Bun starts a filtered workspace script below the app root, the
18
+ * registrar finds the nearest ancestor package declaring `nifra.singleCopy` and passes that root to
19
+ * the resolver instead of planning against the workspace package's manifest.
18
20
  *
19
- * `nifra check` reads this specifier out of `bunfig.toml` as the proof that the runtime arm is armed,
20
- * so the string above is a contract rather than a suggestion.
21
+ * `nifra check` recognizes this public specifier and verifies checkout-local relative preloads before
22
+ * treating them as proof that the runtime arm is armed.
21
23
  *
22
24
  * A declared package that cannot be collapsed (two versions, or a file with no counterpart in the
23
25
  * app's copy) prints one warning per package naming both copies. Set
@@ -130,7 +130,7 @@ export interface SingleCopyPlan {
130
130
  }
131
131
 
132
132
  export interface SingleCopyOptions {
133
- /** The app root whose copy wins. Defaults to `process.cwd()`. */
133
+ /** The app root whose copy wins. Defaults to its nearest declaration, or `process.cwd()` if none. */
134
134
  readonly cwd?: string
135
135
  /** Package names or `@scope/*` patterns. Defaults to the `nifra.singleCopy` declaration. */
136
136
  readonly packages?: readonly string[]
@@ -179,6 +179,21 @@ const singleCopyField = (cwd: string): unknown => {
179
179
  return isRecord(nifra) ? nifra.singleCopy : undefined
180
180
  }
181
181
 
182
+ /** Find the declaring app when a Bun workspace script runs from a package directory. */
183
+ const singleCopyProjectRoot = (cwd: string): string => {
184
+ const start = resolve(cwd)
185
+ let current = start
186
+ for (let depth = 0; depth < MAX_ANCESTOR_DIRS; depth++) {
187
+ const pkg = readJsonSync(join(current, "package.json"))
188
+ const nifra = pkg?.nifra
189
+ if (isRecord(nifra) && Object.hasOwn(nifra, "singleCopy")) return realpathOrSelf(current)
190
+ const parent = dirname(current)
191
+ if (parent === current) break
192
+ current = parent
193
+ }
194
+ return realpathOrSelf(start)
195
+ }
196
+
182
197
  /**
183
198
  * The declaration, read from `package.json` - deliberately NOT from `nifra.config.ts`.
184
199
  *
@@ -266,6 +281,34 @@ function preloadEntries(value: string): readonly string[] {
266
281
  return entries
267
282
  }
268
283
 
284
+ /** Accept the public entry, or a relative source preload that is itself the registrar. */
285
+ function isSingleCopyRegistrar(entry: string, cwd: string): boolean {
286
+ if (entry === SINGLE_COPY_REGISTER_SPECIFIER) return true
287
+ if (!entry.startsWith(".")) return false
288
+ let source: string
289
+ try {
290
+ source = readFileSync(resolve(cwd, entry), "utf8")
291
+ } catch {
292
+ return false
293
+ }
294
+ const lines = source
295
+ .replaceAll("\r\n", "\n")
296
+ .split("\n")
297
+ .map((line) => line.trim())
298
+ const importsRegistrar = lines.some((line) => {
299
+ const statement = line.endsWith(";") ? line.slice(0, -1) : line
300
+ return (
301
+ statement === 'import { registerSingleCopy } from "./single-copy"' ||
302
+ statement === "import { registerSingleCopy } from './single-copy'"
303
+ )
304
+ })
305
+ const callsRegistrar = lines.some((line) => {
306
+ const statement = line.endsWith(";") ? line.slice(0, -1) : line
307
+ return statement === "registerSingleCopy()"
308
+ })
309
+ return importsRegistrar && callsRegistrar
310
+ }
311
+
269
312
  /**
270
313
  * Read the runtime proof out of `bunfig.toml`.
271
314
  *
@@ -294,7 +337,10 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
294
337
  section = heading.trim()
295
338
  continue
296
339
  }
297
- if (value === undefined || !preloadEntries(value).includes(SINGLE_COPY_REGISTER_SPECIFIER))
340
+ if (
341
+ value === undefined ||
342
+ !preloadEntries(value).some((entry) => isSingleCopyRegistrar(entry, cwd))
343
+ )
298
344
  continue
299
345
  if (section === "test") test = true
300
346
  else if (section === "") run = true
@@ -726,7 +772,9 @@ const strictFromEnv = (): boolean => {
726
772
  * anything loads, and a `no-counterpart` file fails its own import.
727
773
  */
728
774
  export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan {
729
- const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
775
+ // Bun runs `--filter` scripts from the workspace package directory. Resolve the declaration root
776
+ // first so the preload still pins packages against the application tree that owns bunfig.toml.
777
+ const root = realpathOrSelf(resolve(options.cwd ?? singleCopyProjectRoot(process.cwd())))
730
778
  const strict = options.strict ?? (readSingleCopyStrict(root) || strictFromEnv())
731
779
  const warned = processSet(WARNED)
732
780
  const emit = (key: string, message: string): void => {
@@ -84,6 +84,11 @@ export interface ToolContractOptions<
84
84
  readonly description: string
85
85
  readonly input: InputSchema
86
86
  readonly output: OutputSchema
87
+ /**
88
+ * The capability a caller must hold to run the tool. Defaults to `tool.execute.<name>`, valid for
89
+ * any name made of lowercase segments joined by `.`, `-` or `_` (`list_orders`); a name with an
90
+ * empty segment (`a__b`, a trailing `-`) derives none and must pass one.
91
+ */
87
92
  readonly capability?: string
88
93
  readonly approval?: ToolApprovalPolicy
89
94
  readonly idempotency?: ToolIdempotencyPolicy<Input>
@@ -398,8 +403,13 @@ export function defineTool<
398
403
  if (!isStandardSchema(options.input) || !isStandardSchema(options.output)) {
399
404
  throw new TypeError("tool contract: input and output must implement Standard Schema")
400
405
  }
401
- if (options.capability !== undefined && !validCapabilityId(options.capability)) {
402
- throw new TypeError(`tool contract: invalid capability ${JSON.stringify(options.capability)}`)
406
+ const capability = options.capability ?? `${DEFAULT_CAPABILITY}.${options.name}`
407
+ if (typeof capability !== "string" || !validCapabilityId(capability)) {
408
+ throw new TypeError(
409
+ options.capability === undefined
410
+ ? `tool contract: name ${JSON.stringify(options.name)} derives no valid capability; pass \`capability\``
411
+ : `tool contract: invalid capability ${JSON.stringify(options.capability)}`,
412
+ )
403
413
  }
404
414
  const approval = validateApprovalPolicy(options.approval ?? { kind: "none" })
405
415
  const cost =
@@ -415,7 +425,7 @@ export function defineTool<
415
425
  description: options.description.trim(),
416
426
  input: options.input,
417
427
  output: options.output,
418
- capability: options.capability ?? `${DEFAULT_CAPABILITY}.${options.name}`,
428
+ capability,
419
429
  approval,
420
430
  ...(options.idempotency === undefined ? {} : { idempotency: options.idempotency }),
421
431
  ...(cost === undefined ? {} : { cost }),
package/src/ws.ts CHANGED
@@ -61,6 +61,7 @@ export function websocket(): IdentityPlugin {
61
61
  export type {
62
62
  NifraWebSocket,
63
63
  StandardWebSocket,
64
+ TopicAdapter,
64
65
  WebSocketContext,
65
66
  WebSocketData,
66
67
  WebSocketHandler,