stitchkit 0.60.0 → 0.61.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 (63) hide show
  1. package/dist/agent-runtime/coordinator.d.ts +10 -0
  2. package/dist/agent-runtime/coordinator.d.ts.map +1 -1
  3. package/dist/agent-runtime/run-execution.d.ts.map +1 -1
  4. package/dist/agent-runtime/runtime.d.ts.map +1 -1
  5. package/dist/agent-runtime/schemas.d.ts.map +1 -1
  6. package/dist/agent-runtime/store-driver.d.ts.map +1 -1
  7. package/dist/agent-runtime.js +97 -12
  8. package/dist/application/grammy.d.ts +12 -6
  9. package/dist/application/grammy.d.ts.map +1 -1
  10. package/dist/application/kernel.d.ts +35 -2
  11. package/dist/application/kernel.d.ts.map +1 -1
  12. package/dist/application/server-resource.d.ts.map +1 -1
  13. package/dist/application-grammy.js +5 -3
  14. package/dist/application.d.ts +1 -1
  15. package/dist/application.d.ts.map +1 -1
  16. package/dist/application.js +12 -6
  17. package/dist/browser/socket-io.d.ts +47 -0
  18. package/dist/browser/socket-io.d.ts.map +1 -1
  19. package/dist/browser/stream.d.ts +23 -0
  20. package/dist/browser/stream.d.ts.map +1 -1
  21. package/dist/cli.js +5 -4
  22. package/dist/contract/errors.d.ts +1 -0
  23. package/dist/contract/errors.d.ts.map +1 -1
  24. package/dist/contract/index.js +1 -1
  25. package/dist/{index-8pc5s3pz.js → index-1pgeyyee.js} +3 -3
  26. package/dist/{index-9h1vba8n.js → index-413xk7ga.js} +2 -1
  27. package/dist/{index-8es1hrv4.js → index-6taryy00.js} +1 -1
  28. package/dist/{index-1zencgcb.js → index-eabpd4tb.js} +61 -10
  29. package/dist/{index-3w74r08v.js → index-mfw1pec7.js} +7 -5
  30. package/dist/{index-f7521k0x.js → index-s1tywej8.js} +95 -24
  31. package/dist/{index-6fbp58g8.js → index-tvwcrx2d.js} +4 -2
  32. package/dist/{index-v6e8hnkx.js → index-wd8g8z6e.js} +1 -84
  33. package/dist/{index-2akar0rq.js → index-wrhf06ak.js} +7 -5
  34. package/dist/index-xxye8j3k.js +85 -0
  35. package/dist/{index-3hn10n4c.js → index-y91zd0ch.js} +1 -1
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +213 -17
  39. package/dist/internal/optional-peer.d.ts +14 -0
  40. package/dist/internal/optional-peer.d.ts.map +1 -0
  41. package/dist/node.js +8 -7
  42. package/dist/observability/index.js +4 -3
  43. package/dist/realtime/contract.d.ts +7 -1
  44. package/dist/realtime/contract.d.ts.map +1 -1
  45. package/dist/realtime/index.d.ts +2 -1
  46. package/dist/realtime/index.d.ts.map +1 -1
  47. package/dist/realtime/rejected-frame.d.ts +86 -0
  48. package/dist/realtime/rejected-frame.d.ts.map +1 -0
  49. package/dist/realtime/request.d.ts +24 -0
  50. package/dist/realtime/request.d.ts.map +1 -1
  51. package/dist/realtime/socket.d.ts.map +1 -1
  52. package/dist/remote.js +4 -2
  53. package/dist/server/index.d.ts +1 -0
  54. package/dist/server/index.d.ts.map +1 -1
  55. package/dist/server/index.js +209 -15
  56. package/dist/server/socket-io.d.ts.map +1 -1
  57. package/dist/server/stream.d.ts.map +1 -1
  58. package/dist/server/streaming-route.d.ts +130 -0
  59. package/dist/server/streaming-route.d.ts.map +1 -0
  60. package/dist/testing.js +2 -1
  61. package/dist/tools.js +11 -9
  62. package/llms-full.txt +422 -79
  63. package/package.json +5 -5
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  signJwt,
3
3
  verifyPkce
4
- } from "./index-3w74r08v.js";
4
+ } from "./index-mfw1pec7.js";
5
5
  import {
6
6
  DEFAULT_CORS_ALLOW_HEADERS,
7
7
  DEFAULT_PROCESS_SIGNALS,
@@ -9,7 +9,7 @@ import {
9
9
  defaultSignalSource,
10
10
  guardSignalCallback,
11
11
  reportSignalError
12
- } from "./index-3hn10n4c.js";
12
+ } from "./index-y91zd0ch.js";
13
13
  import {
14
14
  WaitTimeoutError,
15
15
  collectToolSurface,
@@ -24,7 +24,7 @@ import {
24
24
  formatToolError,
25
25
  readCapped,
26
26
  runWaitOperation
27
- } from "./index-8pc5s3pz.js";
27
+ } from "./index-1pgeyyee.js";
28
28
  import {
29
29
  ToolExecutionControlError,
30
30
  coerceJsonArgs,
@@ -32,7 +32,7 @@ import {
32
32
  isToolExecutionControlError,
33
33
  toolErrorFromResult,
34
34
  toolResultFromError
35
- } from "./index-2akar0rq.js";
35
+ } from "./index-wrhf06ak.js";
36
36
  import {
37
37
  ManagedFileError
38
38
  } from "./index-bfcpjw20.js";
@@ -52,18 +52,16 @@ import"./index-6djpbnda.js";
52
52
  import"./index-cby4ar3v.js";
53
53
  import {
54
54
  redact
55
- } from "./index-8es1hrv4.js";
55
+ } from "./index-6taryy00.js";
56
56
  import {
57
57
  getRequestContext,
58
58
  getTraceId,
59
59
  normalizeError,
60
60
  runWithRequestContext
61
- } from "./index-6fbp58g8.js";
61
+ } from "./index-tvwcrx2d.js";
62
62
  import {
63
- AppError,
64
- STITCH_ERROR_STATUS,
65
63
  defineContract
66
- } from "./index-v6e8hnkx.js";
64
+ } from "./index-wd8g8z6e.js";
67
65
  import {
68
66
  ManagedFilePathSchema,
69
67
  ManagedFileRefSchema
@@ -74,6 +72,10 @@ import {
74
72
  import {
75
73
  isRecord
76
74
  } from "./index-smpbdg6k.js";
75
+ import {
76
+ AppError,
77
+ STITCH_ERROR_STATUS
78
+ } from "./index-xxye8j3k.js";
77
79
 
78
80
  // src/tools/agent.ts
79
81
  import { jsonSchema, tool } from "ai";
package/llms-full.txt CHANGED
@@ -1515,6 +1515,7 @@ focused helper — not a sub-framework.
1515
1515
  |--------|------|
1516
1516
  | `serveFile()` | serve a file with `Range` / `304` / `HEAD` (media seeking) |
1517
1517
  | `streamSSE()` | turn an `AsyncGenerator` into a Server-Sent-Events `Response` |
1518
+ | `ndjsonRoute()` / `sseRoute()` | a **long-lived** subscription route, with the whole checklist |
1518
1519
  | `parseMultipart()` | parse a typed buffered/streaming multipart descriptor |
1519
1520
  | `createRateLimiter()` | per-key token-bucket rate limiting |
1520
1521
  | `createCache()` + `cacheHeaders()` | in-memory TTL cache; `Cache-Control` builder |
@@ -1540,6 +1541,64 @@ stream: () => streamSSE(tokens()), // → a text/event-stream Response
1540
1541
 
1541
1542
  The client side is [`parseSSE`](./client.md#sse).
1542
1543
 
1544
+ `streamSSE` is for a stream that **finishes** — a completion, a job's output.
1545
+ For one that stays open, see below.
1546
+
1547
+ ### Long-lived subscriptions
1548
+
1549
+ `streamSSE` assumes the generator keeps producing. A **subscription** is the
1550
+ opposite: silence is its normal state, and three unrelated things have to be
1551
+ right or it breaks without saying anything. (Measured: with a heartbeat under
1552
+ the threshold, either of the first two alone keeps an in-process connection
1553
+ alive — the first earns its place against what a heartbeat cannot reach, an
1554
+ intermediary applying its own idle rule.)
1555
+
1556
+ 1. **The generic idle timeout has to go.** Without `server.timeout(req, 0)` Bun
1557
+ resets the connection after ten seconds — a healthy connection severed on a
1558
+ schedule, precisely because the subscriber had nothing to be told.
1559
+ 2. **Something has to be on the wire.** Even with the timeout gone,
1560
+ intermediate proxies are under no obligation to hold a connection carrying no
1561
+ bytes.
1562
+ 3. **The headers have to leave at open.** A runtime sends nothing until the body
1563
+ produces a byte, so the consumer's `fetch` does not return. "Subscribed and
1564
+ silent" then looks exactly like "not answering", and there is nothing to
1565
+ inspect because there is no response yet.
1566
+
1567
+ `ndjsonRoute` / `sseRoute` do all three, and close the source when the consumer
1568
+ goes away:
1569
+
1570
+ ```ts
1571
+ import { ndjsonRoute } from 'stitchkit/server'
1572
+
1573
+ const events = ndjsonRoute({
1574
+ path: '/events/subscribe',
1575
+ heartbeatMs: 5_000, // default; keep it well under 10s
1576
+ source: async function* (request, { signal }) {
1577
+ for await (const event of subscribe({ signal })) yield event
1578
+ },
1579
+ })
1580
+
1581
+ createServer({ port: 3000, rawRoutes: [events] })
1582
+ ```
1583
+
1584
+ The client half is [`parseNDJSON`](./client.md#ndjson) — and the keep-alive
1585
+ frame is an **empty line**, so "blank lines are skipped" is part of the
1586
+ documented contract rather than an agreement between two halves of one project.
1587
+ `sseRoute` frames the same source as SSE and is read by `parseSSE` unchanged.
1588
+
1589
+ **Honour `context.signal`.** It is the one part the route cannot do for you, and
1590
+ the reason is worth knowing: an async generator serialises its requests, so
1591
+ `iterator.return()` issued while a `next()` is in flight is *queued behind it*.
1592
+ A subscription is in `next()` almost always, so the close would wait for an
1593
+ event that may never come. The signal is aborted the moment the consumer
1594
+ disconnects — through either route, a request abort or a stream cancel — and a
1595
+ source that waits on it stops at once. (`iterator.return()` is still called; it
1596
+ closes a source suspended at a `yield`.)
1597
+
1598
+ A failure part-way through arrives as a final frame carrying the framework
1599
+ error envelope, normalised — once the headers are gone there is no status left
1600
+ to send, and an internal message must not reach the wire raw.
1601
+
1543
1602
  ### Multipart
1544
1603
 
1545
1604
  The contract owns one descriptor for buffered and streaming delivery:
@@ -2172,7 +2231,42 @@ for await (const event of parseSSE(res)) {
2172
2231
  }
2173
2232
  ```
2174
2233
 
2175
- The server side is [`streamSSE`](./server.md#sse-streaming).
2234
+ The server side is [`streamSSE`](./server.md#sse-streaming), or
2235
+ [`sseRoute`](./server.md#long-lived-subscriptions) for a subscription that stays
2236
+ open.
2237
+
2238
+ ## NDJSON
2239
+
2240
+ `parseNDJSON` reads a newline-delimited JSON body — the client half of
2241
+ [`ndjsonRoute`](./server.md#long-lived-subscriptions):
2242
+
2243
+ ```ts
2244
+ import { parseNDJSON } from 'stitchkit'
2245
+
2246
+ const subscription = new AbortController()
2247
+ const res = await fetch('/api/events/subscribe', { signal: subscription.signal })
2248
+ for await (const event of parseNDJSON(res)) {
2249
+ console.log(event)
2250
+ }
2251
+
2252
+ // ...to unsubscribe:
2253
+ subscription.abort()
2254
+ ```
2255
+
2256
+ **Use the `AbortController` for a subscription.** Leaving the loop with `break`
2257
+ cancels the body, and on a stream that ends that is enough — but it is not a
2258
+ reliable way to tell the *server* you are gone: measured against Bun today, the
2259
+ source stayed alive for seconds after a client-side cancel. Aborting the request
2260
+ reaches [`context.signal`](./server.md#long-lived-subscriptions) on the other
2261
+ end at once, which is what actually ends the work.
2262
+
2263
+ **Blank lines are skipped**, and that is the contract rather than a
2264
+ convenience: a long-lived stream must send something while it is idle or
2265
+ intermediaries drop it, and an empty line is the natural pulse for this framing.
2266
+ Writing the rule down on both sides is what stops it being a verbal agreement —
2267
+ the server's keep-alive and the reader's skip are one decision with two
2268
+ implementations. A frame that is not valid JSON goes to `onParseError` rather
2269
+ than throwing, so one bad line does not end the subscription.
2176
2270
 
2177
2271
 
2178
2272
  ==============================================================================
@@ -3939,6 +4033,71 @@ may retain lifecycle `ready`, but snapshot health is `degraded`, never
3939
4033
 
3940
4034
  Readiness is not hidden polling. A resource reports health changes through its
3941
4035
  lifecycle context; the application decides when a database/provider probe runs.
4036
+
4037
+ **A `reportHealth` call inside `start` is kept.** A resource that says nothing
4038
+ is assumed healthy once it is ready; one that reports its own health has already
4039
+ answered the question, and the answer stands. (It used to be overwritten — and
4040
+ the example above hides that, because `healthy` is the same value that
4041
+ overwrote it.)
4042
+
4043
+ **A resource is required unless you write `required: false`.** That default is
4044
+ what makes the next sentence bite.
4045
+
4046
+ **Readiness requires every required resource to be healthy**, so "ready but
4047
+ degraded" is unreachable for a required resource by construction: an
4048
+ application whose required resource reports anything but `healthy` refuses to
4049
+ start, and says which resource and in what state. The refusal distinguishes the
4050
+ two ways to get there — a resource that was never healthy is pointed at
4051
+ `required: false`; one that was healthy and stopped is pointed at
4052
+ `onResourceFailure`. A resource that is *expected* to start
4053
+ degraded — up, but still dialling something external — belongs behind
4054
+ `required: false`, where it keeps its own health and does not gate the
4055
+ application:
4056
+
4057
+ ```ts
4058
+ defineManagedResource({
4059
+ id: 'dialling',
4060
+ required: false,
4061
+ start: ({ reportHealth }) => { reportHealth('degraded') },
4062
+ })
4063
+ ```
4064
+
4065
+ An optional resource reporting non-healthy does not gate readiness, but it does
4066
+ move the application **aggregate** to `degraded` — which a readiness endpoint
4067
+ mapping `degraded` to non-200 will notice.
4068
+
4069
+ If startup fails, every resource that was already started is closed in reverse
4070
+ order. The rollback runs **one** phase — `close` — not the five a real shutdown
4071
+ runs, and `close` receives the same deadlines a shutdown would give it, so a
4072
+ server drains what is in flight instead of aborting it.
4073
+
4074
+ Those deadlines come from the application's declared budget, and it is worth
4075
+ knowing what they cost. With nothing in flight the rollback returns at once: a
4076
+ grace period is a ceiling, not a sleep. With something in flight it waits for
4077
+ it — that is the point — and with something that **never finishes**, a hung
4078
+ upstream or a client that ignores a close frame, it waits out the whole budget
4079
+ before forcing. Under the default 30s+5s that turns a failed `start()` that used
4080
+ to reject in milliseconds into one that can take 35 seconds to reject.
4081
+
4082
+ An application that would rather hear about a broken start immediately says so,
4083
+ in the one place both stopping paths read:
4084
+
4085
+ ```ts
4086
+ createApplication({
4087
+ id: 'app',
4088
+ resources,
4089
+ // Applies to `shutdown()` called with no options AND to the rollback of a
4090
+ // failed `start()`, which has no call site of its own to be told.
4091
+ shutdown: { gracePeriodMs: 5_000, forceTimeoutMs: 1_000 },
4092
+ })
4093
+ ```
4094
+
4095
+ The budget is a real bound, not just a number handed to each resource: a `close`
4096
+ that never returns is abandoned when the budget runs out, reported as a `close`
4097
+ failure, and the startup error stays the `cause` of the `AggregateError` that
4098
+ `start()` rejects with. Without that, one unresponsive resource could keep a
4099
+ failed startup from ever reporting why it failed.
4100
+
3942
4101
  A required long-lived completion that rejects after startup makes readiness
3943
4102
  false and health unhealthy. Stitchkit records the failure but does not restart
3944
4103
  the resource or process.
@@ -4757,36 +4916,64 @@ const realtimeContract = defineRealtimeContract({
4757
4916
  })
4758
4917
  ```
4759
4918
 
4760
- The original `z.ZodError` is retained as `RealtimeRejectedEvent.error.cause`.
4761
- Because event arguments are a tuple, the first payload's `v` path is
4762
- `[0, 'v']` (not `['v']`):
4919
+ A frame that fails this check is **refused, and the sender is told so** — when
4920
+ the event has an acknowledgement. `request()` rejects with
4921
+ `RealtimeRequestRejectedError`, immediately, carrying the peer's own issues:
4763
4922
 
4764
4923
  ```ts
4765
- import type { RealtimeRejectedEvent } from 'stitchkit'
4766
- import { z } from 'zod'
4924
+ import { RealtimeRequestRejectedError } from 'stitchkit'
4767
4925
 
4768
- function isProtocolGenerationMismatch(rejected: RealtimeRejectedEvent): boolean {
4769
- const cause = rejected.error.cause
4770
- if (!(cause instanceof z.ZodError)) return false
4771
- const first = cause.issues[0]
4772
- return first?.code === 'invalid_value'
4773
- && first.path.length === 2
4774
- && first.path[0] === 0
4775
- && first.path[1] === 'v'
4926
+ try {
4927
+ await socket.request('replicate', message, { timeoutMs: 5_000 })
4928
+ } catch (error) {
4929
+ if (error instanceof RealtimeRequestRejectedError) {
4930
+ // reason: 'invalid-arguments'; issues: [{ path: '0.v', code: 'invalid_value', … }]
4931
+ if (error.issues?.some((issue) => issue.path === '0.v')) schedulePeerUpgrade()
4932
+ else reportMalformedRealtimePayload(error)
4933
+ }
4776
4934
  }
4935
+ ```
4777
4936
 
4778
- const socket = createRealtimeClient(realtimeContract, {
4779
- url,
4780
- onRejected: (rejected) => {
4781
- if (isProtocolGenerationMismatch(rejected)) schedulePeerUpgrade()
4782
- else reportMalformedRealtimePayload(rejected)
4937
+ `path` is `'0.v'` and not `'v'` because event arguments are a tuple: index `0`
4938
+ is the first payload. The issues are already flattened by Stitchkit's own
4939
+ normaliser, so telling "wrong generation" from "malformed payload" is one
4940
+ comparison rather than an inspection of a `ZodError`'s internals.
4941
+
4942
+ The receiving side still reports it locally through `onRejected` — a refusal is
4943
+ now visible on **both** ends rather than only where it happened.
4944
+
4945
+ **Two limits, both real.** A **fire-and-forget** event has no acknowledgement
4946
+ channel, so its refusal stays local: the sender learns nothing, and no
4947
+ convention in the payload can change that. And an event the receiver's contract
4948
+ does not contain has no listener at all, so there is nothing on that side to
4949
+ answer with — adding an event is not a change a generation field can announce.
4950
+
4951
+ ### Where protocol identity belongs
4952
+
4953
+ For a distributed pair whose planes are mostly fire-and-forget, compare
4954
+ identity **at the handshake** instead, where a mismatch is refused before the
4955
+ first frame is interpreted and both ends see it at once. The typed handshake is
4956
+ already the place:
4957
+
4958
+ ```ts
4959
+ const handshake = {
4960
+ schema: z.object({ token: z.string(), protocol: z.string() }),
4961
+ verify: (auth) => {
4962
+ if (auth.protocol !== PROTOCOL_IDENTITY) return null // refused, with a reason
4963
+ return { subject: verifyToken(auth.token) }
4783
4964
  },
4784
- })
4965
+ }
4785
4966
  ```
4786
4967
 
4787
- A generation mismatch means “upgrade the peer”; another schema rejection means
4788
- “fix the producer or payload”. This stays an application convention, not a core
4789
- API: only the application knows which field denotes protocol compatibility.
4968
+ The client sends it as `auth`, and a rejection reaches `onConnectError` with
4969
+ `terminal: true` distinguishable in a log from a bad token, so a half-rolled
4970
+ deployment reads as a half-rolled deployment and not as an access problem.
4971
+
4972
+ What that identity *is* remains the application's decision — a build version, a
4973
+ contract hash, a protocol generation. Stitchkit does not compare it for you
4974
+ (→ ADR 0002); it gives the place where the comparison happens before any frame
4975
+ is interpreted, and it makes the per-frame alternative honest by letting its
4976
+ refusals be seen.
4790
4977
 
4791
4978
  ## Server — `createSocketIOServer`
4792
4979
 
@@ -7422,6 +7609,143 @@ current one *up to* your target, and apply each snippet.
7422
7609
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
7423
7610
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
7424
7611
 
7612
+ ## Released migration: 0.61.0
7613
+
7614
+ Three behaviour changes between versions. None moves an export — the surface is
7615
+ strictly additive — and all three change what a running system does, which is
7616
+ what this heading is for.
7617
+
7618
+ ### A failed `start()` now drains before it rejects
7619
+
7620
+ The rollback of a failed startup used to close every resource with a zero
7621
+ budget: it returned almost at once, by severing requests the server had already
7622
+ accepted. It now spends the application's shutdown budget, so a request already
7623
+ in flight is answered rather than killed.
7624
+
7625
+ **What to check.** Nothing, if a failed startup has nothing in flight — the
7626
+ rollback still returns immediately. The case to think about is a request that
7627
+ never finishes: a hung upstream, a client ignoring a close frame, a streaming
7628
+ subscription. Under the default 30s grace and 5s force, a `start()` that used to
7629
+ reject in milliseconds can now take 35 seconds to reject.
7630
+
7631
+ If a fast failure matters more than draining — a supervisor waiting to restart,
7632
+ a boot check in CI — declare a smaller budget. The same field is the default for
7633
+ `shutdown()` with no options, so this is one decision, not two:
7634
+
7635
+ ```ts
7636
+ // before
7637
+ createApplication({ id: 'app', resources })
7638
+
7639
+ // after
7640
+ createApplication({
7641
+ id: 'app',
7642
+ resources,
7643
+ shutdown: { gracePeriodMs: 5_000, forceTimeoutMs: 1_000 },
7644
+ })
7645
+ ```
7646
+
7647
+ The budget is a real bound: a `close` that never returns is abandoned when it
7648
+ runs out and reported as a `close` failure, and the startup error remains the
7649
+ `cause` of the `AggregateError` `start()` rejects with. → ADR 0107
7650
+
7651
+ ### A refused realtime frame now answers its sender
7652
+
7653
+ A frame that fails the receiver's `args` schema used to be dropped where it
7654
+ landed. If the event carries an acknowledgement, the receiver now answers it
7655
+ with a reserved envelope and the sender's `request()` rejects at once with
7656
+ `RealtimeRequestRejectedError`. → ADR 0106
7657
+
7658
+ **What to check before you upgrade one half of a distributed pair.** Look at the
7659
+ `ack` schemas on the OLDER peer:
7660
+
7661
+ ```ts
7662
+ // safe: a contract-first acknowledgement refuses the envelope, so the older
7663
+ // peer raises RealtimeRequestInvalidAcknowledgementError at once instead of
7664
+ // waiting out its deadline. Different error, still an error, and sooner.
7665
+ ack: z.object({ stored: z.boolean() })
7666
+
7667
+ // NOT safe: a schema that validates nothing accepts the refusal AS A VALUE.
7668
+ // The older peer reads a refusal as a successful acknowledgement — silently.
7669
+ ack: z.unknown()
7670
+ ack: z.looseObject({})
7671
+ ```
7672
+
7673
+ If any acknowledgement on the older side is permissive, tighten it before the
7674
+ rollout, or upgrade both halves together.
7675
+
7676
+ Also: the receiver now invokes the peer's raw acknowledgement callback for a
7677
+ refused frame, including when the peer is a plain Socket.IO client. That
7678
+ callback previously could not run on a refused frame and now can.
7679
+
7680
+ `RealtimeRejectedEvent['reason']` gained `'rejected-by-peer'`. If you `switch`
7681
+ over it exhaustively with an `assertNever` default, that stops compiling — add
7682
+ the case.
7683
+
7684
+ ### Two smaller behaviour changes, easy to miss
7685
+
7686
+ **`streamSSE`'s `cancel` no longer awaits the generator.** Teardown is now
7687
+ unordered relative to request completion. If your generator releases a resource
7688
+ in `return()`/`finally` — a temp file, a pooled connection — and anything
7689
+ downstream assumed that had finished by the time the response settled, it no
7690
+ longer has. Release in the generator's own `finally` and do not depend on the
7691
+ ordering.
7692
+
7693
+ **A `socket.io-client` peer that cannot load no longer kills the process.** With
7694
+ `onConnectError` configured, the failure is delivered there with
7695
+ `terminal: true` instead of crashing. If your handler logs and moves on, you now
7696
+ have a live process whose client will never connect, where a supervisor used to
7697
+ restart it. Treat `terminal: true` as fatal if that is what you want.
7698
+
7699
+ ### `reportHealth` inside `start` is no longer discarded
7700
+
7701
+ Becoming ready assigned `healthy` unconditionally, throwing away whatever a
7702
+ resource reported during `start`. It is now kept, and only a resource that
7703
+ reported nothing is assumed healthy.
7704
+
7705
+ **How to find what this touches:** grep your `start` bodies for **every**
7706
+ `reportHealth` call, not only the ones reporting `degraded`. The old
7707
+ unconditional assignment was also a repair — a resource that reported
7708
+ `'unhealthy'` early in `start` and never corrected itself was quietly fixed up
7709
+ on the way to ready.
7710
+
7711
+ Two cases, and they need opposite fixes.
7712
+
7713
+ **1. A resource that is genuinely expected to start degraded** — up, but still
7714
+ dialling something external. `required` defaults to **`true`**, and readiness
7715
+ requires every required resource to be healthy, so such a resource now refuses
7716
+ the whole startup where before its report vanished. That is the invariant
7717
+ working as intended; what changed is that it can be reached. Say what it is:
7718
+
7719
+ ```ts
7720
+ // before: started, and its report was discarded
7721
+ defineManagedResource({ id: 'dialling', start: ({ reportHealth }) => reportHealth('degraded') })
7722
+
7723
+ // after: says what it is, and does not gate the application
7724
+ defineManagedResource({ id: 'dialling', required: false, start: ({ reportHealth }) => reportHealth('degraded') })
7725
+ ```
7726
+
7727
+ **2. A resource that reported `'unhealthy'` early and became healthy later** —
7728
+ a pessimistic report before a connection settled. Here `required: false` is the
7729
+ **wrong** fix: it would hide a real failure. Report the recovery instead:
7730
+
7731
+ ```ts
7732
+ start: async ({ reportHealth }) => {
7733
+ reportHealth('unhealthy')
7734
+ await connect()
7735
+ reportHealth('healthy') // ← previously unnecessary; now it is the fix
7736
+ }
7737
+ ```
7738
+
7739
+ **Also check your health endpoint.** An **optional** resource reporting
7740
+ non-healthy during `start` now moves the application aggregate to `degraded`,
7741
+ where before it stayed `healthy`. A readiness probe that maps `degraded` to a
7742
+ non-200 will flip on upgrade — and a supervisor that restarts on that will loop.
7743
+
7744
+ The refusals now say which of the two happened: a resource that was never
7745
+ healthy is told it "is not healthy" and pointed at `required: false`; one that
7746
+ was healthy and stopped is told it "lost readiness" and pointed at
7747
+ `onResourceFailure`.
7748
+
7425
7749
  ## Released migration: 0.60.0
7426
7750
 
7427
7751
  ### close() says what it achieved
@@ -7496,10 +7820,12 @@ if (error instanceof AgentRuntimeConflictError) …
7496
7820
  `ActivityTokenBrand` is exported, so `ActivityProjection` can be implemented by
7497
7821
  a test double. `STITCH_ERROR_STATUS` gained `APPLICATION_NOT_ACCEPTING` (503) —
7498
7822
  only an exhaustive `satisfies Record<StitchErrorCode, …>` map stops compiling,
7499
- and the fix is one line. `GRAMMY_WEBHOOK_NOT_ACCEPTING` is deliberately *not*
7500
- registered: the registry belongs to the generic core, and a provider name has no
7501
- place in a union every consumer imports. It travels as itself through a partial
7502
- `codeMap`.
7823
+ and the fix is one line. (`GRAMMY_WEBHOOK_NOT_ACCEPTING` joined it in 0.60.1,
7824
+ under the same rule see ADR 0105. This section briefly said the opposite: that
7825
+ an adapter's code stays out of the registry. It does not, and leaving it out was
7826
+ the worse of the two, because `isStitchErrorCode` then answered `false` and the
7827
+ code reached the wire spelled stitchkit's way, past both `codeMap` and
7828
+ `unmappedCode`.)
7503
7829
 
7504
7830
  `application.shutdown()` no longer accepts `retryAfterSeconds`. Delete it from
7505
7831
  the call — the kernel never read it. If you meant the HTTP `Retry-After` a
@@ -7995,58 +8321,6 @@ The Node-shaped callback is gone, and the policy is now composed with shutdown
7995
8321
  admission on both runtimes: a handshake arriving during drain is refused for
7996
8322
  you.
7997
8323
 
7998
- ## Released migration: 0.46.0
7999
-
8000
- ### `REALTIME_CONTRACT_VIOLATION` joined the error registry
8001
-
8002
- Realtime contract failures use the framework error model instead of a bare
8003
- `ZodError`, so an exhaustive map stops compiling until the code is added:
8004
-
8005
- ```ts
8006
- // before
8007
- { …, INTERNAL_SERVER_ERROR: 'internal' } satisfies Record<StitchErrorCode, string>
8008
- // after
8009
- { …, INTERNAL_SERVER_ERROR: 'internal', REALTIME_CONTRACT_VIOLATION: 'internal' } satisfies Record<StitchErrorCode, string>
8010
- ```
8011
-
8012
- Only an exhaustive map breaks. Since 0.56.1 `codeMap` itself is partial, so a
8013
- map without the `satisfies` keeps compiling and lets the code travel as itself.
8014
-
8015
- ### `RealtimeRejectedEvent.error` is an `AppError`
8016
-
8017
- ```ts
8018
- // before
8019
- onRejected: ({ error }) => error.issues
8020
- // after
8021
- onRejected: ({ error }) => error.details?.issues // the ZodError moves to error.cause
8022
- ```
8023
-
8024
- The envelope gained `reason` and `fault`. The consequence worth checking: code
8025
- reading `.issues` directly does not fail to compile if the handler is loosely
8026
- typed — it silently reads `undefined`. Grep for `.issues` on rejection handlers.
8027
-
8028
- ### CLI construction refuses reserved names
8029
-
8030
- A contract field or tool named `json`, `wait`, `quiet`, `dry-run`, `help`,
8031
- `version`, `wait-timeout` or `output-dir` now **throws while the CLI is built**,
8032
- instead of being silently shadowed:
8033
-
8034
- ```ts
8035
- // before: app schedule_job --wait 2h → {"path":"2h"}, exit 0
8036
- // after: building a CLI over a contract with a "wait" field throws
8037
- ```
8038
-
8039
- This one fires at startup, not at call time, so an application shipping such a
8040
- field crashes on boot after the upgrade. That is deliberate — the old behaviour
8041
- corrupted arguments silently — but it means the upgrade is not safe to deploy
8042
- without building the CLI once locally.
8043
-
8044
- ### `createToolLogger` writes to stderr
8045
-
8046
- stdout is the JSON-RPC channel of a stdio MCP server, and the previous
8047
- `console.info` default corrupted it. Pass `log` to redirect if your process
8048
- collected tool logs from stdout.
8049
-
8050
8324
  ## Released migration: 0.48.0
8051
8325
 
8052
8326
  ### Typed-client request options move to `.withOptions`
@@ -8172,6 +8446,58 @@ contract shape.
8172
8446
  (`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
8173
8447
  `MethodDef`, `maxUploadBytes`) are available to adopt, not required.
8174
8448
 
8449
+ ## Released migration: 0.46.0
8450
+
8451
+ ### `REALTIME_CONTRACT_VIOLATION` joined the error registry
8452
+
8453
+ Realtime contract failures use the framework error model instead of a bare
8454
+ `ZodError`, so an exhaustive map stops compiling until the code is added:
8455
+
8456
+ ```ts
8457
+ // before
8458
+ { …, INTERNAL_SERVER_ERROR: 'internal' } satisfies Record<StitchErrorCode, string>
8459
+ // after
8460
+ { …, INTERNAL_SERVER_ERROR: 'internal', REALTIME_CONTRACT_VIOLATION: 'internal' } satisfies Record<StitchErrorCode, string>
8461
+ ```
8462
+
8463
+ Only an exhaustive map breaks. Since 0.56.1 `codeMap` itself is partial, so a
8464
+ map without the `satisfies` keeps compiling and lets the code travel as itself.
8465
+
8466
+ ### `RealtimeRejectedEvent.error` is an `AppError`
8467
+
8468
+ ```ts
8469
+ // before
8470
+ onRejected: ({ error }) => error.issues
8471
+ // after
8472
+ onRejected: ({ error }) => error.details?.issues // the ZodError moves to error.cause
8473
+ ```
8474
+
8475
+ The envelope gained `reason` and `fault`. The consequence worth checking: code
8476
+ reading `.issues` directly does not fail to compile if the handler is loosely
8477
+ typed — it silently reads `undefined`. Grep for `.issues` on rejection handlers.
8478
+
8479
+ ### CLI construction refuses reserved names
8480
+
8481
+ A contract field or tool named `json`, `wait`, `quiet`, `dry-run`, `help`,
8482
+ `version`, `wait-timeout` or `output-dir` now **throws while the CLI is built**,
8483
+ instead of being silently shadowed:
8484
+
8485
+ ```ts
8486
+ // before: app schedule_job --wait 2h → {"path":"2h"}, exit 0
8487
+ // after: building a CLI over a contract with a "wait" field throws
8488
+ ```
8489
+
8490
+ This one fires at startup, not at call time, so an application shipping such a
8491
+ field crashes on boot after the upgrade. That is deliberate — the old behaviour
8492
+ corrupted arguments silently — but it means the upgrade is not safe to deploy
8493
+ without building the CLI once locally.
8494
+
8495
+ ### `createToolLogger` writes to stderr
8496
+
8497
+ stdout is the JSON-RPC channel of a stdio MCP server, and the previous
8498
+ `console.info` default corrupted it. Pass `log` to redirect if your process
8499
+ collected tool logs from stdout.
8500
+
8175
8501
  ## Released migration: 0.44.0
8176
8502
 
8177
8503
  ### MCP TypeScript SDK v2 and protocol `2026-07-28`
@@ -9129,7 +9455,9 @@ The browser-and-server entrypoint. Re-exports everything from
9129
9455
  | `bindRealtimeClient` | function | bind contract validation and typed acknowledgements to an existing Stitchkit client transport without owning its lifecycle |
9130
9456
  | `createRetainedTopics` | function | retained last-value store for sticky events — [guide](../guide/realtime.md#sticky-events) |
9131
9457
  | `parseSSE` | function | parse an SSE `Response` into an async generator — [guide](../guide/client.md#sse) |
9458
+ | `parseNDJSON` | function | parse an NDJSON `Response`; blank keep-alive lines are skipped — [guide](../guide/client.md#ndjson) |
9132
9459
  | `SocketIOClient` | _type_ | low-level client handle; `emit` reports disconnected drops and `emitWithAck` exposes the native Promise primitive used by validated `request()` |
9460
+ | `SocketIOClientPeerLoaders` | _type_ | inject `socket.io-client` so a bundler can put it in a self-contained artifact |
9133
9461
  | `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` (incl. `retain`, `onConnectError`, `onDroppedEmit`) |
9134
9462
  | `SocketEventMap` | _type_ | the shape of an event map |
9135
9463
  | `RealtimeClient` | _type_ | validated client inferred from a realtime contract |
@@ -9144,6 +9472,12 @@ The browser-and-server entrypoint. Re-exports everything from
9144
9472
  | `RealtimeRequestTimeoutError` | class | stable `REALTIME_REQUEST_TIMEOUT` rejection |
9145
9473
  | `RealtimeRequestDisconnectedError` | class | stable `REALTIME_REQUEST_DISCONNECTED` rejection, including an immediate disconnected call |
9146
9474
  | `RealtimeRequestInvalidAcknowledgementError` | class | invalid ack was reported through `onRejected` and the request rejected |
9475
+ | `RealtimeRequestRejectedError` | class | the peer refused the frame against its own contract and said so — `reason`, `issues` — instead of leaving the sender to time out ([ADR 0106](../decisions/0106-a-refused-frame-answers-its-sender.md)) |
9476
+ | `REALTIME_REJECTION_KEY` | const | the reserved acknowledgement key a refusal travels under |
9477
+ | `RealtimeRejectionEnvelope` | _type_ | the wire shape of a refusal |
9478
+ | `RealtimeRejectionReport` | _type_ | what the sender is told: event, reason, message, issues |
9479
+ | `RealtimeRejectionIssue` | _type_ | one refused field, already flattened (`path: '0.v'`) |
9480
+ | `asRealtimeRejection` | function | recognise a refusal in an acknowledgement value, validating it |
9147
9481
  | `RealtimeContract` | _type_ | shared server-to-client and client-to-server event registries |
9148
9482
  | `RealtimeEventRegistry` | _type_ | string-keyed registry of event definitions |
9149
9483
  | `RealtimeEventDefinition` | _type_ | one tuple-shaped event and optional acknowledgement schema |
@@ -9157,6 +9491,7 @@ The browser-and-server entrypoint. Re-exports everything from
9157
9491
  | `ValidatedRealtimeSocket` | _type_ | runtime-validating `on`/`emit` surface inferred from registries; `emit` returns "accepted by the transport" (`false` only for a client-side disconnected drop) |
9158
9492
  | `RetainedTopics` | _type_ | the `createRetainedTopics` handle |
9159
9493
  | `ParseSSEOptions` | _type_ | options for `parseSSE` |
9494
+ | `ParseNDJSONOptions` | _type_ | options for `parseNDJSON` |
9160
9495
 
9161
9496
  ### Trace (client)
9162
9497
 
@@ -9412,6 +9747,10 @@ Also re-exports the error helpers from `stitchkit/contract`.
9412
9747
  | Export | Kind | Summary |
9413
9748
  |--------|------|---------|
9414
9749
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
9750
+ | `streamingRoute` | function | a long-lived subscription route: idle timeout, heartbeat, opening flush, cancellation — [guide](../guide/server.md#long-lived-subscriptions) |
9751
+ | `ndjsonRoute` | function | `streamingRoute` framed as NDJSON |
9752
+ | `sseRoute` | function | `streamingRoute` framed as SSE |
9753
+ | `DEFAULT_STREAM_HEARTBEAT_MS` | const | 5000 — deliberately well under Bun's ten-second idle threshold |
9415
9754
  | `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
9416
9755
  | `MultipartLifecycle` | _type_ | request-scoped rollback ownership for accepted streamed handles |
9417
9756
  | `MultipartResult` | _type_ | what `parseMultipart` returns |
@@ -9447,6 +9786,9 @@ Also re-exports the error helpers from `stitchkit/contract`.
9447
9786
  | `RateLimitConfig` | _type_ | config for `createRateLimiter` |
9448
9787
  | `ClientIpOptions` | _type_ | trusted-proxy config for `extractIp` / `resolveSocketIp` |
9449
9788
  | `ParseSSEOptions` | _type_ | options for `parseSSE` |
9789
+ | `StreamingRouteOptions` | _type_ | options for `streamingRoute` / `ndjsonRoute` / `sseRoute` |
9790
+ | `StreamingSourceContext` | _type_ | what a streaming source is given, including the cancellation `signal` |
9791
+ | `StreamingFormat` | _type_ | `'ndjson' \| 'sse'` |
9450
9792
 
9451
9793
  ### OpenAPI
9452
9794
 
@@ -9477,6 +9819,7 @@ cutovers are covered by the executable
9477
9819
  | `ApplicationResourceFailure` | _type_ | one resource failure with the cause its phase label cannot carry — delivered to `onResourceFailure` |
9478
9820
  | `ApplicationResourcePhase` | _type_ | the phase a managed resource failed in — the vocabulary of `ApplicationResourceShutdown.failures` |
9479
9821
  | `ApplicationShutdownOptionsSchema` / `ApplicationShutdownOptions` | schema / _type_ | the two shutdown budgets and an abort signal — without the HTTP-only `retryAfterSeconds` |
9822
+ | `ApplicationShutdownBudgetSchema` / `ApplicationShutdownBudget` | schema / _type_ | the same two budgets without a signal — `ApplicationConfig.shutdown`, the default for `shutdown()` and the only budget a failed startup's rollback can read |
9480
9823
  | `ActivityTokenBrand` | const | the brand symbol `ActivityToken` carries, exported so `ActivityProjection` is implementable |
9481
9824
  | `defineManagedResource` | function | retain the exact typed resource declaration; every invoked start is rollback-eligible |
9482
9825
  | `managedServerResource` | function | adapt an existing managed server without copying its HTTP/WebSocket shutdown machine |