stitchkit 0.48.0 → 0.49.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 (54) hide show
  1. package/README.md +1 -2
  2. package/dist/browser/client.d.ts +3 -0
  3. package/dist/browser/client.d.ts.map +1 -1
  4. package/dist/browser/http.d.ts +7 -4
  5. package/dist/browser/http.d.ts.map +1 -1
  6. package/dist/cli.js +3 -2
  7. package/dist/{index-nrytvb30.js → index-03j2t778.js} +5 -8
  8. package/dist/{index-kp8xamqp.js → index-0hj37z43.js} +4 -2
  9. package/dist/index-48ffdxgk.js +6 -0
  10. package/dist/{index-44xysy8r.js → index-fjfzsq6y.js} +383 -26
  11. package/dist/index-h05ygjqx.js +149 -0
  12. package/dist/{index-ee621cmy.js → index-jewp9r0a.js} +5 -139
  13. package/dist/index-p9d4cxt5.js +436 -0
  14. package/dist/{index-8ekq6res.js → index-v58mwa19.js} +4 -2
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +47 -3
  18. package/dist/node.d.ts +4 -3
  19. package/dist/node.d.ts.map +1 -1
  20. package/dist/node.js +109 -14
  21. package/dist/observability/audit.d.ts +4 -1
  22. package/dist/observability/audit.d.ts.map +1 -1
  23. package/dist/observability/index.d.ts +1 -0
  24. package/dist/observability/index.d.ts.map +1 -1
  25. package/dist/observability/index.js +150 -16
  26. package/dist/observability/status.d.ts +105 -0
  27. package/dist/observability/status.d.ts.map +1 -0
  28. package/dist/server/bun.d.ts +7 -8
  29. package/dist/server/bun.d.ts.map +1 -1
  30. package/dist/server/implement.d.ts +16 -6
  31. package/dist/server/implement.d.ts.map +1 -1
  32. package/dist/server/index.d.ts +4 -3
  33. package/dist/server/index.d.ts.map +1 -1
  34. package/dist/server/index.js +132 -11
  35. package/dist/server/node.d.ts +12 -13
  36. package/dist/server/node.d.ts.map +1 -1
  37. package/dist/server/router.d.ts +4 -1
  38. package/dist/server/router.d.ts.map +1 -1
  39. package/dist/server/shutdown.d.ts +76 -0
  40. package/dist/server/shutdown.d.ts.map +1 -0
  41. package/dist/server/socket-io-config.d.ts +5 -1
  42. package/dist/server/socket-io-config.d.ts.map +1 -1
  43. package/dist/server/socket-io-node.d.ts +4 -1
  44. package/dist/server/socket-io-node.d.ts.map +1 -1
  45. package/dist/server/socket-io.d.ts +12 -4
  46. package/dist/server/socket-io.d.ts.map +1 -1
  47. package/dist/testing.d.ts +34 -0
  48. package/dist/testing.d.ts.map +1 -0
  49. package/dist/testing.js +36 -0
  50. package/dist/tools/mcp.d.ts +1 -0
  51. package/dist/tools/mcp.d.ts.map +1 -1
  52. package/dist/tools.js +21 -428
  53. package/llms-full.txt +290 -43
  54. package/package.json +8 -3
package/llms-full.txt CHANGED
@@ -33,7 +33,7 @@ realtime, `@tanstack/react-query` + `react-query-kit` for React). See
33
33
 
34
34
  ## Entrypoints
35
35
 
36
- stitchkit ships eight entrypoints. Each is import-safe for one environment —
36
+ stitchkit ships nine entrypoints. Each is import-safe for one environment —
37
37
  keeping server-only code (`Bun.serve`, the MCP SDK) out of browser bundles.
38
38
 
39
39
  | Import | Use in | Holds |
@@ -45,6 +45,7 @@ keeping server-only code (`Bun.serve`, the MCP SDK) out of browser bundles.
45
45
  | `stitchkit/tools` | server | `createMcpHandler`, `mountMcp`, `mountAgent`, the OAuth provider, native tools |
46
46
  | `stitchkit/cli` | server | `createCli` — the CLI transport, light (no MCP SDK / `ai`) |
47
47
  | `stitchkit/observability` | server | request/tool event projections — `createObservability`, trace context, sanitisation |
48
+ | `stitchkit/testing` | tests on Bun or Node | in-process generated clients over a real Fetch handler, without a TCP port |
48
49
  | `stitchkit/react` | browser | `createCursorQuery`, `createCacheBridge` |
49
50
 
50
51
  Rule of thumb: browser code imports `stitchkit` and `stitchkit/react`; server
@@ -685,10 +686,35 @@ export const implement = createImplement<AppContext>()
685
686
  // every implement() call now has ctx.user typed
686
687
  ```
687
688
 
689
+ ### `implementRegistry` — one backend registry
690
+
691
+ When contracts already live in one literal registry, bind the backend from that
692
+ same source instead of maintaining a parallel `services` list:
693
+
694
+ ```ts
695
+ import { implementRegistry } from 'stitchkit/server'
696
+
697
+ export const apiServices = implementRegistry(apiContractRegistry, {
698
+ users: usersHandlers,
699
+ posts: postsHandlers,
700
+ })
701
+ ```
702
+
703
+ Every registry key is required, extra keys fail, and each handler map is checked
704
+ against its own contract. The returned service order follows the contract
705
+ registry order. `createImplementRegistry<AppContext>()` fixes the application
706
+ context once, like `createImplement` does for a single contract. Runtime callers
707
+ also fail first on missing/extra keys and duplicate contract prefixes.
708
+
709
+ The registry is intentionally flat: every key must point to one concrete
710
+ `defineContract()` result. Composed namespace arrays are mounted explicitly with
711
+ `implement()` because they do not have a one-key-to-one-handler-map boundary.
712
+
688
713
  ## `createServer`
689
714
 
690
- `createServer(config)` builds the router and starts `Bun.serve()`. It returns
691
- the Bun server instance.
715
+ `createServer(config)` builds the router and starts `Bun.serve()`. It returns a
716
+ managed handle with `url`, `port`, the concrete server under `runtime`, live
717
+ `status`, and one idempotent `shutdown()` lifecycle.
692
718
 
693
719
  ```ts
694
720
  import { createServer } from 'stitchkit/server'
@@ -721,8 +747,34 @@ server. See [Testing & deployment](./testing-and-deployment.md).
721
747
  | `logging` | `true` for built-in request logs, or a `LoggingConfig` (see below) |
722
748
  | `traceId` | override per-request trace-id resolution — may return `undefined` to fall back |
723
749
  | `wrapFetch` | compose wrappers around the finished handler (request context, audit) |
724
- | `websocket` | Bun WebSocket handlers e.g. from `createSocketIOServer` |
725
- | `routes` / `development` / `bun` | passthrough to `Bun.serve` |
750
+ | `socket` | full Stitchkit Socket.IO handle; route, default WebSocket handler and shutdown are mounted once |
751
+ | `websocket` | custom Bun WebSocket handler; with `socket`, this is the explicit composed handler |
752
+ | `development` / `bun` | passthrough to `Bun.serve` |
753
+
754
+ Native Bun `routes` are intentionally not accepted: Bun matches them before
755
+ `fetch`, so they could bypass shutdown admission. Use `rawRoutes`; they retain
756
+ the Fetch `Request → Response` model and participate in lifecycle tracking.
757
+
758
+ ### Managed shutdown
759
+
760
+ ```ts
761
+ const server = createServer({ services, socket })
762
+
763
+ const result = await server.shutdown({
764
+ gracePeriodMs: 30_000,
765
+ retryAfterSeconds: 5,
766
+ signal: shutdownController.signal,
767
+ })
768
+ ```
769
+
770
+ The first call closes HTTP and Socket.IO admission, drains accepted application
771
+ requests, closes realtime transports and stops the runtime within one total
772
+ budget. Repeated calls return the same Promise; the first options win. New
773
+ ordinary HTTP work receives `503`, `Retry-After` and `Connection: close` outside
774
+ `wrapFetch`. `result.outcome` is `clean` or `forced`; a forced result preserves
775
+ the pending snapshot and reason while final pending counters describe the
776
+ post-close transport state. `runtime` is a diagnostics escape hatch, not a
777
+ second canonical stop path.
726
778
 
727
779
  ### Trusted HTTPS in development
728
780
 
@@ -991,6 +1043,21 @@ not retain the text. `maxJsonBodyBytes` may also be set once on `createServer` /
991
1043
  `createHandler`; a route value wins. Both limits are opt-in and abort an
992
1044
  oversized stream before it is fully buffered. → ADR 0051
993
1045
 
1046
+ ### Choosing an HTTP boundary
1047
+
1048
+ | Need | Contract declaration | What remains framework-owned |
1049
+ |------|----------------------|------------------------------|
1050
+ | Typed JSON request/response | ordinary `input` / `output` | routing, auth, schemas, hooks, client, OpenAPI |
1051
+ | HMAC-signed JSON | `rawBody: true` + `input` / `output` | the same pipeline plus the exact decoded request text |
1052
+ | File upload | typed `multipart` descriptor | file cardinality/limits, text input validation and client form encoding |
1053
+ | File, stream or redirect response behind contract auth | `rawResponse: true` | request parsing, route identity, auth and typed URL/client surface |
1054
+ | Transport that cannot be expressed as the contract pipeline | `RawRoute` | only raw routing, CORS, request hook and error normalisation |
1055
+
1056
+ `rawBody` is for JSON signatures: verify `ctx.rawBody` against the signature
1057
+ header, then use the already validated `ctx.input`. A provider-specific binary
1058
+ signature protocol, OAuth callback or WebSocket upgrade can be a real raw route,
1059
+ but it must own its validation and authorization explicitly.
1060
+
994
1061
  ## Typed JSON response metadata
995
1062
 
996
1063
  A JSON endpoint that must attach dynamic HTTP headers while preserving typed
@@ -1158,6 +1225,13 @@ wildcard — and the two combine: `/app/:slug/*filePath` matches `/app/x/a/b` wi
1158
1225
  deep-link fallback). List more specific routes before the wildcard — the first
1159
1226
  match wins. `staticRoute()` builds a raw route that serves a directory.
1160
1227
 
1228
+ At startup Stitchkit rejects exact duplicates, equivalent parameter shapes
1229
+ (`/users/:id` vs `/users/:userId`) and any later raw route completely hidden by
1230
+ an earlier raw route. Partial overlap stays legal, including the recommended
1231
+ specific-before-wildcard order. `GET` and `HEAD` remain independent because that
1232
+ is how the actual raw matcher dispatches them. Raw-vs-contract overlap remains a
1233
+ startup diagnostic naming the bypassed contract identity and scope.
1234
+
1161
1235
  ### Raw-route helpers
1162
1236
 
1163
1237
  A raw route gives up the contract pipeline, so three things get re-implemented in
@@ -1483,7 +1557,7 @@ without repeating transport configuration.
1483
1557
  | `baseUrl` | — | URL prefix for every request |
1484
1558
  | `timeout` | `30000` | request timeout, ms |
1485
1559
  | `credentials` | `'include'` | fetch credentials mode |
1486
- | `retry` | 2× GET, network errors only | transport retry policy |
1560
+ | `retry` | 2 retries after the initial GET; network errors only | transport retry policy |
1487
1561
  | `headers` | — | extra headers — an object, or a function re-run per request |
1488
1562
  | `suppressUnauthorizedFor` | `[]` | exact contract-derived operation matchers whose expected 401 does not emit `unauthorized` |
1489
1563
  | `parseError` | built-in | map an error body to `{ code, message, details, hint }` |
@@ -1509,7 +1583,19 @@ end-to-end. A `traceparent` you set yourself (via `headers`) always wins.
1509
1583
  Retry is deliberately conservative: only a connection that never landed (a
1510
1584
  network error), only on idempotent `GET`. A server that *responded* with a 5xx
1511
1585
  is the data layer's call (TanStack Query), not the transport's — retrying in
1512
- both places multiplies attempts.
1586
+ both places multiplies attempts. `retry.limit` counts retries after the initial
1587
+ attempt, so the default `2` permits at most three total attempts. The default
1588
+ `statusCodes: []` does not replay HTTP responses; explicit `methods` or
1589
+ `statusCodes` expand that policy when a project has a proven idempotent case.
1590
+
1591
+ Inside Next.js 16 server rendering, the first attempt still uses Next's normal
1592
+ request memoization. If Ky authorizes a retry after a network rejection,
1593
+ Stitchkit passes that retry's current `Request.signal` in the second fetch
1594
+ argument and materializes the current Request as URL + init. Next 16.3 otherwise
1595
+ merges `init` into a Request before its dedupe layer and loses the explicit
1596
+ signal opt-out. The retry therefore performs a new network attempt instead of
1597
+ returning the cached rejection. This adapter does not broaden retry policy: POST, unconfigured HTTP
1598
+ statuses, cancellation and exhausted budgets retain the rules above.
1513
1599
 
1514
1600
  ## `createClient`
1515
1601
 
@@ -2251,15 +2337,18 @@ validateMcpSchemas({
2251
2337
  extend,
2252
2338
  flattenUnionInput: true,
2253
2339
  requireTypedProperties: true,
2254
- allowUntyped: ['docs_create.payload'], // deliberately free-form
2255
2340
  requirePortableFormats: true,
2256
2341
  allowFormats: [],
2257
2342
  })
2258
2343
  ```
2259
2344
 
2260
- Off by default, because a contract may legitimately declare `z.unknown()`.
2261
- `allowUntyped` takes dotted `tool.property` paths an entry there is a decision,
2262
- anything else is a finding. On `createMcpHandler`, put the policy under
2345
+ For an arbitrary **JSON value**, declare `z.json()`: its recursive JSON Schema
2346
+ describes strings, numbers, booleans, null, arrays and objects, so strict schema
2347
+ validation needs no exception. `z.unknown()` means an unknown runtime value
2348
+ including values JSON cannot represent — and therefore gives a model no usable
2349
+ shape. The guard is off by default for existing contracts. `allowUntyped` takes
2350
+ dotted `tool.property` paths and is reserved for a presentation value that is
2351
+ genuinely impossible to represent as JSON Schema. On `createMcpHandler`, put the policy under
2263
2352
  `schemaValidation`; the handler supplies its real `extend` and
2264
2353
  `flattenUnionInput`, so the check cannot vet a different document from the one
2265
2354
  advertised.
@@ -3050,13 +3139,12 @@ realtime.onConnection(({ raw, events, to }) => {
3050
3139
  })
3051
3140
  ```
3052
3141
 
3053
- It returns a handle with three pieces, all wired into `createServer`:
3142
+ Pass the full handle to `createServer`; it mounts and owns the transport once:
3054
3143
 
3055
3144
  ```ts
3056
3145
  createServer({
3057
3146
  services,
3058
- websocket: socket.websocket, // → Bun.serve websocket handlers
3059
- rawRoutes: [socket.route], // ready-made /socket.io/*socketPath route
3147
+ socket,
3060
3148
  })
3061
3149
 
3062
3150
  // elsewhere — validated broadcast:
@@ -3076,11 +3164,15 @@ export function publishExampleNote(realtime: ExampleRealtimePublisher): void {
3076
3164
  | Handle field | Purpose |
3077
3165
  |--------------|---------|
3078
3166
  | `io` | raw Socket.IO server for middleware, handshake auth and transport ownership |
3079
- | `websocket` | Bun WebSocket handlers pass to `createServer({ websocket })` |
3080
- | `route` | the `/socket.io/*socketPath` raw route pass to `createServer({ rawRoutes })` |
3081
-
3082
- `SocketIOServerConfig` also takes `path`, `transports`, `pingTimeout` and
3083
- `pingInterval`. For anything else socket.io's `ServerOptions` exposes, use the
3167
+ | `websocket` | Bun handlers used directly only in explicit raw-lane composition |
3168
+ | `route` | `/socket.io/*socketPath`, mounted automatically by `createServer({ socket })` |
3169
+ | `close()` | idempotent standalone close for CLI/tools with no HTTP server |
3170
+ | `beginShutdown()` / `connections()` | lifecycle surface consumed by the managed server |
3171
+
3172
+ `SocketIOServerConfig` also takes `path`, `transports`, `pingTimeout`,
3173
+ `pingInterval` and a runtime-neutral `allowRequest(Request)` handshake policy.
3174
+ The policy is composed with managed-shutdown admission on both Bun and Node.
3175
+ For anything else socket.io's `ServerOptions` exposes, use the
3084
3176
  typed **`serverOptions`** passthrough — most often `maxHttpBufferSize` to lift the
3085
3177
  1 MB default for large emits:
3086
3178
 
@@ -3091,7 +3183,8 @@ await createSocketIOServer({
3091
3183
  })
3092
3184
  ```
3093
3185
 
3094
- The wrapper-owned fields (`cors` / `path` / `transports` / `ping*`) take
3186
+ The wrapper-owned fields (`cors` / `path` / `transports` / `ping*` /
3187
+ `allowRequest`) take
3095
3188
  precedence over the same keys in `serverOptions`. On Bun the engine-level options
3096
3189
  (`maxHttpBufferSize`, the ping heartbeat, `upgradeTimeout`) are forwarded to
3097
3190
  `@socket.io/bun-engine` too — so a configured `maxHttpBufferSize` actually applies
@@ -3388,8 +3481,9 @@ const websocket = composeWebSocketHandlers(
3388
3481
 
3389
3482
  createServer({
3390
3483
  services,
3484
+ socket,
3391
3485
  websocket,
3392
- rawRoutes: [socket.route, pcmRoute],
3486
+ rawRoutes: [pcmRoute],
3393
3487
  })
3394
3488
  ```
3395
3489
 
@@ -3981,8 +4075,8 @@ export const observability = createObservability({
3981
4075
  },
3982
4076
  })
3983
4077
 
3984
- await observability.flush()
3985
- await observability.close()
4078
+ const status = observability.getStatus()
4079
+ const drained = await observability.close()
3986
4080
  ```
3987
4081
 
3988
4082
  `maxPending` defaults to `1000` per sink and must be a positive safe integer.
@@ -3993,9 +4087,15 @@ cancelled. Sink, filter and diagnostic-callback failures remain isolated from
3993
4087
  the observed request or tool call and are reported through `onSinkError` when
3994
4088
  configured; `onSinkError`/`onDrop` cannot create unhandled rejections.
3995
4089
 
4090
+ `getStatus()` returns immutable per-surface and aggregate counters: capacity,
4091
+ received, accepted, filtered, completed, dropped, failed, preparation failures,
4092
+ preparing/pending work and the closed flag. Read it from readiness or metrics
4093
+ without parsing callback logs.
4094
+
3996
4095
  `flush()` snapshots the current generation and waits only for events admitted
3997
4096
  up to that call. `close()` atomically stops admission, drains every accepted
3998
- generation and is idempotent. Graceful shutdown order is therefore:
4097
+ generation and returns the final counters plus `durationMs`; repeated calls
4098
+ return the same report. Graceful shutdown order is therefore:
3999
4099
 
4000
4100
  1. stop HTTP/MCP admission;
4001
4101
  2. wait for active requests and tool calls;
@@ -4431,7 +4531,33 @@ never block or break the request. Swallow the sink's own errors.
4431
4531
  stitchkit's own test suite runs on `bun:test`. The contract makes most of an
4432
4532
  API testable without a live socket.
4433
4533
 
4434
- ### Test handlers in process
4534
+ ### Test generated clients in process
4535
+
4536
+ `createHandlerTestClient` runs the real generated client against the real Fetch
4537
+ handler without opening a TCP port. It keeps URL construction, headers/cookies,
4538
+ multipart encoding, cancellation, output validation, `ApiError` and
4539
+ `x-request-id` correlation in the test path:
4540
+
4541
+ ```ts
4542
+ import { createHandlerTestClient } from 'stitchkit/testing'
4543
+
4544
+ const api = createHandlerTestClient({
4545
+ contract: notes,
4546
+ handler,
4547
+ pathPrefix: 'api',
4548
+ client: { headers: { cookie: 'session=test' } },
4549
+ })
4550
+
4551
+ expect(await api.create({ text: 'hi' })).toEqual({ id: '1', text: 'hi' })
4552
+ ```
4553
+
4554
+ `contractConfig` accepts the same scoped `pathPrefix` / `stripPrefixKeys` as
4555
+ `createClient`. `createHandlerTestClients` is the batch form for a literal
4556
+ contract registry. Both helpers are Fetch-only: they construct ordinary
4557
+ absolute `Request` objects and call `createHandler` directly, so Bun and Node
4558
+ exercise the same framework pipeline.
4559
+
4560
+ ### Test handlers with raw Requests
4435
4561
 
4436
4562
  `createHandler` is the router as a plain `(req) => Promise<Response>` function —
4437
4563
  no `Bun.serve`, no port. Drive it with a `Request`:
@@ -4461,8 +4587,8 @@ test('create returns the note', async () => {
4461
4587
  })
4462
4588
  ```
4463
4589
 
4464
- This exercises the full pipeline routing, schema parsing, hooks, the error
4465
- envelope with no network.
4590
+ This lower-level form exercises the same pipeline while letting a test inspect
4591
+ the raw `Response` itself.
4466
4592
 
4467
4593
  ### Test handlers directly
4468
4594
 
@@ -4505,8 +4631,34 @@ createServer({
4505
4631
  })
4506
4632
  ```
4507
4633
 
4508
- `createServer` returns the `Bun.serve` instance keep the reference if you need
4509
- `.stop()` for a graceful shutdown.
4634
+ Keep the managed handle and wire process policy explicitly:
4635
+
4636
+ ```ts
4637
+ const server = createServer({ services, socket })
4638
+ const force = new AbortController()
4639
+ let closing: Promise<void> | undefined
4640
+
4641
+ function shutdown() {
4642
+ if (closing) {
4643
+ force.abort() // a later signal shortens the same shutdown, not a second chain
4644
+ return closing
4645
+ }
4646
+ closing = server.shutdown({ gracePeriodMs: 30_000, signal: force.signal }).then(async result => {
4647
+ await mcp.close()
4648
+ await prisma.$disconnect()
4649
+ console.log(result)
4650
+ })
4651
+ return closing
4652
+ }
4653
+
4654
+ process.on('SIGTERM', () => void shutdown())
4655
+ process.on('SIGINT', () => void shutdown())
4656
+ ```
4657
+
4658
+ The server owns HTTP/Socket.IO transport resources. MCP, databases, queues and
4659
+ domain run-state remain application resources and close explicitly after server
4660
+ drain. Do not call `runtime.stop()` or `socket.io.close()` in parallel with
4661
+ `shutdown()`.
4510
4662
 
4511
4663
  ### Deploy on Node
4512
4664
 
@@ -4517,10 +4669,13 @@ the listener differs: replace `createServer` with **`serveNode`** (from
4517
4669
  ```ts
4518
4670
  import { serveNode } from 'stitchkit/node'
4519
4671
 
4520
- serveNode({
4672
+ const server = await serveNode({
4521
4673
  services,
4674
+ socket,
4522
4675
  port: Number(process.env.PORT ?? 3000),
4523
4676
  })
4677
+
4678
+ await server.shutdown({ gracePeriodMs: 30_000 })
4524
4679
  ```
4525
4680
 
4526
4681
  Notes for a Node host:
@@ -4529,9 +4684,6 @@ Notes for a Node host:
4529
4684
  raw routes use `RawRoute<TServer = unknown>`; supply a host server generic
4530
4685
  only when an embedding adapter passes one to `createHandler`.
4531
4686
 
4532
- - Add **`@types/bun`** as a dev dependency — it is an optional peer that types the
4533
- shared `stitchkit/server` surface (without it `tsc` reports a missing `Bun`
4534
- namespace).
4535
4687
  - **Socket.IO** attaches to the Node HTTP server via `serveNode({ socket })`, and
4536
4688
  on Node the default transport is `['websocket']` — set the client to match
4537
4689
  (`transports: ['websocket']`). See [realtime](./realtime.md).
@@ -5541,9 +5693,68 @@ socket.io
5541
5693
  socket.attach(nodeHttpServer)
5542
5694
  ```
5543
5695
 
5544
- Inline Bun routes passed to `createServer` continue to infer `BunServer`; no
5545
- annotation is needed. Node consumers can remove `@types/bun` unless another
5546
- dependency independently requires it.
5696
+ Node consumers can remove `@types/bun` unless another dependency independently
5697
+ requires it.
5698
+
5699
+ ### Managed server shutdown
5700
+
5701
+ `createServer()` and `serveNode()` now return the same structural managed
5702
+ lifecycle. Replace every direct runtime stop and parallel Socket.IO close:
5703
+
5704
+ ```ts
5705
+ // before — split ownership
5706
+ const socket = await createSocketIOServer(config)
5707
+ const server = createServer({
5708
+ services,
5709
+ websocket: socket.websocket,
5710
+ rawRoutes: [socket.route],
5711
+ })
5712
+ server.stop()
5713
+ await socket.io.close()
5714
+
5715
+ // after — one owner and one total deadline
5716
+ const socket = await createSocketIOServer(config)
5717
+ const server = createServer({ services, socket })
5718
+ const result = await server.shutdown({ gracePeriodMs: 30_000 })
5719
+ ```
5720
+
5721
+ On Node, keep the same `socket` field and replace `handle.close()` with
5722
+ `handle.shutdown()`. Runtime-specific diagnostics move under `handle.runtime`;
5723
+ do not use it as a second shutdown path. Standalone CLI/tools that create a
5724
+ Socket.IO handle without an HTTP server call `await socket.close()`.
5725
+
5726
+ If Bun Socket.IO shares the port with a raw lane, keep the explicit composition
5727
+ but let the server mount the Socket.IO route:
5728
+
5729
+ ```ts
5730
+ createServer({
5731
+ services,
5732
+ socket,
5733
+ websocket: composeWebSocketHandlers([
5734
+ webSocketLane({ match: isRaw, handlers: rawHandlers }),
5735
+ socketIoLane(socket.websocket),
5736
+ ]),
5737
+ rawRoutes: [rawUpgradeRoute],
5738
+ })
5739
+ ```
5740
+
5741
+ Move native Bun `routes` entries to `rawRoutes`. Native routes run before the
5742
+ Fetch handler and therefore cannot participate in admission or drain. Wire
5743
+ `SIGTERM`/`SIGINT` in the application; the first signal starts `shutdown()`, and
5744
+ a later signal may abort the same controller. Close MCP, databases and queues
5745
+ after the server result—those resources remain application-owned.
5746
+
5747
+ Move a handshake policy from the Node-only callback shape inside
5748
+ `serverOptions` to the runtime-neutral top-level policy. It receives a Web
5749
+ `Request`, may be async, and returns whether to admit the handshake:
5750
+
5751
+ ```ts
5752
+ // before
5753
+ serverOptions: { allowRequest: (request, done) => done(null, allowed(request)) }
5754
+
5755
+ // after
5756
+ allowRequest: (request) => allowed(request)
5757
+ ```
5547
5758
 
5548
5759
  ## Your handlers may be returning more than the contract declares
5549
5760
 
@@ -5628,15 +5839,16 @@ The browser-and-server entrypoint. Re-exports everything from
5628
5839
  | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
5629
5840
  | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
5630
5841
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
5842
+ | `ClientFetch` | _type_ | injectable Fetch-compatible transport used by framework testing adapters |
5631
5843
  | `ClientRequestOptions` | _type_ | per-call `{ signal?: AbortSignal }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
5632
5844
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
5633
5845
  | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
5634
5846
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
5635
- | `createHttpClient` | function | the Ky-based HTTP transport — [guide](../guide/client.md#createhttpclient) |
5847
+ | `createHttpClient` | function | the Ky-based HTTP transport; on Next.js SSR its first attempt stays request-memoizable while every retry is a distinct transport attempt — [guide](../guide/client.md#createhttpclient) |
5636
5848
  | `ApiError` | class | a non-2xx response, with `code` / `status` / `details` / `hint` and optional readonly `traceId` from `x-request-id` |
5637
5849
  | `HttpClient` | _type_ | the transport interface `createClient` builds on |
5638
5850
  | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
5639
- | `HttpClientConfig` | _type_ | config for `createHttpClient` |
5851
+ | `HttpClientConfig` | _type_ | config for `createHttpClient`; retry `limit` counts retries after the initial attempt (default 2 = at most 3 GET attempts), with `statusCodes: []` by default — [details](../guide/client.md#createhttpclient) |
5640
5852
  | `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
5641
5853
  | `RequestOptions` | _type_ | per-call options — params, timeout, response type |
5642
5854
  | `HeaderProvider` | _type_ | static or per-request headers |
@@ -5789,6 +6001,11 @@ Also re-exports the error helpers from `stitchkit/contract`.
5789
6001
  | `createHandler` | function | the router as a bare `(req) => Response` — [guide](../guide/server.md#createserver) |
5790
6002
  | `implement` | function | bind a contract to typed handlers — [guide](../guide/server.md#implement) |
5791
6003
  | `createImplement` | function | fix the handler context type once |
6004
+ | `implementRegistry` | function | bind one exact contract registry to one exact backend handler registry |
6005
+ | `createImplementRegistry` | function | context-typed factory for `implementRegistry` |
6006
+ | `ImplementationRegistry` | _type_ | flat literal registry of concrete contracts accepted by `implementRegistry` |
6007
+ | `RegistryHandlers` | _type_ | exact backend handler registry inferred from a contract registry |
6008
+ | `ExactRegistryHandlers` | _type_ | fail-first handler shape that rejects extra registry and endpoint keys |
5792
6009
  | `staticRoute` | function | a raw route that serves a directory |
5793
6010
  | `serveFile` | function | serve a file with `Range` / `304` / `HEAD` — [guide](../guide/server.md#serving-files--range-requests) |
5794
6011
  | `parseByteRange` | function | parse a single `Range` header → range / `unsatisfiable` / `null` |
@@ -5806,6 +6023,12 @@ Also re-exports the error helpers from `stitchkit/contract`.
5806
6023
  | `parseBody` | function | parse + Zod-validate a JSON body → `data` or `null` (no throw) |
5807
6024
  | `HandlerConfig` | _type_ | config for `createHandler`, including optional `maxJsonBodyBytes`; bound to `BunServer` on this entrypoint |
5808
6025
  | `BunServerConfig` | _type_ | config for `createServer` (Bun) |
6026
+ | `BunServerHandle` | _type_ | managed Bun handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
6027
+ | `ManagedServerHandle` | _type_ | shared lifecycle shape generic over the runtime escape hatch |
6028
+ | `ShutdownOptionsSchema` / `ShutdownOptions` | schema / _type_ | one grace budget, retry hint and optional external abort signal |
6029
+ | `ShutdownStatusSchema` / `ShutdownStatus` | schema / _type_ | live state and request/WebSocket counters |
6030
+ | `ShutdownResultSchema` / `ShutdownResult` | schema / _type_ | clean/forced result with final counters and at-force snapshots |
6031
+ | `ShutdownStateSchema` / `ShutdownState` | schema / _type_ | managed lifecycle state machine |
5809
6032
  | `ServiceDef` | _type_ | the result of `implement` |
5810
6033
  | `MethodDef` | _type_ | one resolved endpoint inside a service |
5811
6034
  | `OperationIdentity` | _type_ | path-free service/action/scope/method identity shared by contract and native tool operations |
@@ -5871,8 +6094,10 @@ Also re-exports the error helpers from `stitchkit/contract`.
5871
6094
  | `RealtimeServer` | _type_ | validated broadcast and connection API inferred from a realtime contract |
5872
6095
  | `RealtimeServerConnection` | _type_ | one validated connection with raw socket access for auth and rooms |
5873
6096
  | `RealtimeServerHandle` | _type_ | minimal Socket.IO server handle accepted by `bindRealtimeServer` |
6097
+ | `SocketIORequestPolicy` | _type_ | runtime-neutral async-capable Web `Request` handshake admission policy |
5874
6098
  | `SocketIOServerConfig` | _type_ | config for `createSocketIOServer` |
5875
- | `SocketIOServerHandle` | _type_ | the `{ io, websocket, route }` handle |
6099
+ | `SocketIOServerHandle` | _type_ | typed Socket.IO server plus Bun mount fields and idempotent lifecycle |
6100
+ | `SocketIOServerLifecycle` | _type_ | non-generic Bun mount/shutdown portion accepted by `createServer` |
5876
6101
  | `composeWebSocketHandlers` | function | compose one Bun `websocket` from N lanes — a raw binary lane beside Socket.IO ([guide](../guide/realtime.md#raw-binary-lane-bun)) |
5877
6102
  | `webSocketLane` | function | a typed, cast-free lane for `composeWebSocketHandlers` |
5878
6103
  | `socketIoLane` | function | the Socket.IO catch-all lane for `composeWebSocketHandlers` |
@@ -5938,7 +6163,11 @@ audit event. See the [Observability guide](../guide/observability.md).
5938
6163
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
5939
6164
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink |
5940
6165
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
5941
- | `Observability` | _type_ | `{ request?, toolCall, flush(), close() }` with bounded sink lifecycle |
6166
+ | `Observability` | _type_ | `{ request?, toolCall, getStatus(), flush(), close() }` with bounded sink lifecycle |
6167
+ | `ObservabilitySinkStatus` | _type_ | immutable counters for one bounded request/tool sink |
6168
+ | `ObservabilityStatus` | _type_ | per-surface plus aggregate operational snapshot |
6169
+ | `ObservabilityDrainReport` | _type_ | final closed/drained snapshot plus duration |
6170
+ | `ObservabilitySinkStatusSchema` / `ObservabilityStatusSchema` / `ObservabilityDrainReportSchema` | schema | runtime schemas for status/report integration boundaries |
5942
6171
  | `RequestEventSinkConfig` | _type_ | `write`, filter/sanitisation, `maxPending`, `onSinkError` and `onDrop` |
5943
6172
  | `RequestObservabilityConfig` | _type_ | request sink plus opt-in payload capture |
5944
6173
  | `SinkDropReason` | _type_ | `'capacity' \| 'closed'` |
@@ -6005,7 +6234,7 @@ payload.
6005
6234
  | `createMcpHandler` | function | a stateless dual-era Streamable-HTTP MCP handler — [guide](../guide/mcp-and-agents.md#mcp--createmcphandler) |
6006
6235
  | `createMcpHttpRoute` | function | framework-owned `RawRoute` adapter for an MCP HTTP handler |
6007
6236
  | `createStdioMcpServer` | function | a complete stdio MCP server — [guide](../guide/mcp-and-agents.md#mcp-over-stdio--createstdiomcpserver) |
6008
- | `buildMcpServer` | function | build an `McpServer` from contract/runtime surfaces the transport-neutral core |
6237
+ | `buildMcpServer` | function | build an `McpServer` from contract/runtime surfaces; no-auth configs omit the second argument |
6009
6238
  | `mountMcp` | function | add contract tools to an existing `McpServer` — [guide](../guide/mcp-and-agents.md#mountmcp) |
6010
6239
  | `implementRemote` | function | bind a contract to a remote HTTP API — [guide](../guide/mcp-and-agents.md#proxying-a-remote-api--implementremote) |
6011
6240
  | `mountAgent` | function | a Vercel AI SDK `ToolSet` from a service — [guide](../guide/mcp-and-agents.md#ai-agents--mountagent) |
@@ -6167,6 +6396,22 @@ Advanced building blocks — the shared machinery the mounts are built on.
6167
6396
 
6168
6397
  ---
6169
6398
 
6399
+ ## `stitchkit/testing`
6400
+
6401
+ Fetch-only integration helpers that preserve the real generated-client and
6402
+ handler pipeline without opening a TCP port.
6403
+
6404
+ | Export | Kind | Summary |
6405
+ |--------|------|---------|
6406
+ | `createHandlerTestClient` | function | one contract client backed by an in-process `FetchHandler` |
6407
+ | `createHandlerTestClients` | function | exact contract-registry batch form |
6408
+ | `HandlerTestClientDefaults` | _type_ | ordinary bare-client defaults with handler-owned `baseUrl` and `fetch` removed |
6409
+ | `HandlerTestClientConfig` | _type_ | handler, contract, path prefix, scoped config and client request defaults |
6410
+ | `HandlerTestClientsConfig` | _type_ | batch helper configuration |
6411
+ | `HandlerTestTransportConfig` | _type_ | shared in-process handler, origin, prefix, client defaults and optional server handle |
6412
+
6413
+ ---
6414
+
6170
6415
  ## `stitchkit/node`
6171
6416
 
6172
6417
  Server-only, for Node ≥ 22 (Bun uses `stitchkit/server`). The runtime-agnostic
@@ -6181,9 +6426,11 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
6181
6426
  | `createSocketIOServer` | function | the typed Node Socket.IO server (`io` + `attach`; no Bun engine declarations) |
6182
6427
  | `implement` / `createImplement` | function | bind a contract to typed handlers (same as `/server`) |
6183
6428
  | `NodeServerConfig` | _type_ | config for `serveNode` |
6184
- | `NodeServerHandle` | _type_ | the `serveNode` handle (`{ port, stop }`) |
6429
+ | `NodeServerHandle` | _type_ | managed Node handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
6430
+ | `NodeRuntimeServer` | _type_ | concrete `srvx/node` runtime escape hatch |
6431
+ | `NodeSocketLifecycle` | _type_ | Bun-free Socket.IO lifecycle accepted by `serveNode` |
6185
6432
  | `HandlerConfig` / `ServiceDef` / `RawRoute` / `RawRouteContext` | _type_ | runtime-neutral handler types; raw routes default their host server to `unknown` |
6186
- | `SocketIOServerConfig` / `SocketIOServerHandle` | _type_ | shared config and the Node-only `{ io, attach }` handle |
6433
+ | `SocketIORequestPolicy` / `SocketIOServerConfig` / `SocketIOServerHandle` | _type_ | runtime-neutral handshake policy, config and Bun-free Node handle with `io`, `attach` and lifecycle |
6187
6434
  | `AppError` + `appError` / `badRequest` / `unauthorized` / `forbidden` / `notFound` / `conflict` / `rateLimited` | — | error helpers (same as `/contract`) |
6188
6435
 
6189
6436
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",
@@ -72,6 +72,10 @@
72
72
  "types": "./dist/observability/index.d.ts",
73
73
  "import": "./dist/observability/index.js"
74
74
  },
75
+ "./testing": {
76
+ "types": "./dist/testing.d.ts",
77
+ "import": "./dist/testing.js"
78
+ },
75
79
  "./node": {
76
80
  "types": "./dist/node.d.ts",
77
81
  "import": "./dist/node.js"
@@ -83,7 +87,7 @@
83
87
  "scripts": {
84
88
  "check": "bun x tsc --noEmit",
85
89
  "build:browser": "bun build src/index.ts src/react.ts src/contract/index.ts --outdir dist --target node --packages external --splitting --root src",
86
- "build:server": "bun build src/server/index.ts src/node.ts src/tools.ts src/cli.ts src/observability/index.ts --outdir dist --target node --packages external --splitting --root src",
90
+ "build:server": "bun build src/server/index.ts src/node.ts src/tools.ts src/cli.ts src/testing.ts src/observability/index.ts --outdir dist --target node --packages external --splitting --root src",
87
91
  "build:js": "bun run build:browser && bun run build:server",
88
92
  "build:types": "bun x tsc -p tsconfig.build.json --emitDeclarationOnly",
89
93
  "build": "rm -rf dist && bun run build:js && bun run build:types && bun scripts/check-browser-clean.mjs && bun scripts/check-env-live.mjs && bun scripts/check-public-types.mjs",
@@ -91,6 +95,7 @@
91
95
  "prepack": "cp ../../README.md ./README.md && bun ../../scripts/gen-llms.ts && bun run build",
92
96
  "test": "bun test",
93
97
  "smoke:node": "node scripts/node-smoke.mjs",
98
+ "smoke:next-ssr": "node scripts/next-ssr-retry-smoke.mjs",
94
99
  "consumer-lane": "bun scripts/consumer-lane/run.mjs",
95
100
  "bench:mcp-preparation": "bun scripts/benchmark-mcp-preparation.ts"
96
101
  },
@@ -161,7 +166,7 @@
161
166
  "@types/json-schema": "^7.0.15",
162
167
  "@types/react": "^19.2.18",
163
168
  "@typescript/typescript6": "^6.0.2",
164
- "ai": "^7.0.58",
169
+ "ai": "^7.0.65",
165
170
  "react": "^19.2.8",
166
171
  "react-query-kit": "^3.3.4",
167
172
  "socket.io": "^4.8.3",