stitchkit 0.60.1 → 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.
- package/dist/application/kernel.d.ts +23 -0
- package/dist/application/kernel.d.ts.map +1 -1
- package/dist/application/server-resource.d.ts.map +1 -1
- package/dist/application.d.ts +1 -1
- package/dist/application.d.ts.map +1 -1
- package/dist/application.js +11 -6
- package/dist/browser/socket-io.d.ts +47 -0
- package/dist/browser/socket-io.d.ts.map +1 -1
- package/dist/browser/stream.d.ts +23 -0
- package/dist/browser/stream.d.ts.map +1 -1
- package/dist/contract/index.js +1 -1
- package/dist/{index-t8xrqc9g.js → index-413xk7ga.js} +1 -1
- package/dist/{index-82e74yfx.js → index-eabpd4tb.js} +56 -7
- package/dist/{index-2cgbdckv.js → index-s1tywej8.js} +88 -19
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +213 -17
- package/dist/internal/optional-peer.d.ts +14 -0
- package/dist/internal/optional-peer.d.ts.map +1 -0
- package/dist/node.js +1 -1
- package/dist/realtime/contract.d.ts +7 -1
- package/dist/realtime/contract.d.ts.map +1 -1
- package/dist/realtime/index.d.ts +2 -1
- package/dist/realtime/index.d.ts.map +1 -1
- package/dist/realtime/rejected-frame.d.ts +86 -0
- package/dist/realtime/rejected-frame.d.ts.map +1 -0
- package/dist/realtime/request.d.ts +24 -0
- package/dist/realtime/request.d.ts.map +1 -1
- package/dist/realtime/socket.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +196 -3
- package/dist/server/socket-io.d.ts.map +1 -1
- package/dist/server/stream.d.ts.map +1 -1
- package/dist/server/streaming-route.d.ts +130 -0
- package/dist/server/streaming-route.d.ts.map +1 -0
- package/dist/testing.js +1 -1
- package/llms-full.txt +364 -23
- package/package.json +2 -2
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
|
-
|
|
4761
|
-
|
|
4762
|
-
`
|
|
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
|
|
4766
|
-
import { z } from 'zod'
|
|
4924
|
+
import { RealtimeRequestRejectedError } from 'stitchkit'
|
|
4767
4925
|
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
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
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
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
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
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
|
|
@@ -9131,7 +9455,9 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
9131
9455
|
| `bindRealtimeClient` | function | bind contract validation and typed acknowledgements to an existing Stitchkit client transport without owning its lifecycle |
|
|
9132
9456
|
| `createRetainedTopics` | function | retained last-value store for sticky events — [guide](../guide/realtime.md#sticky-events) |
|
|
9133
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) |
|
|
9134
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 |
|
|
9135
9461
|
| `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` (incl. `retain`, `onConnectError`, `onDroppedEmit`) |
|
|
9136
9462
|
| `SocketEventMap` | _type_ | the shape of an event map |
|
|
9137
9463
|
| `RealtimeClient` | _type_ | validated client inferred from a realtime contract |
|
|
@@ -9146,6 +9472,12 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
9146
9472
|
| `RealtimeRequestTimeoutError` | class | stable `REALTIME_REQUEST_TIMEOUT` rejection |
|
|
9147
9473
|
| `RealtimeRequestDisconnectedError` | class | stable `REALTIME_REQUEST_DISCONNECTED` rejection, including an immediate disconnected call |
|
|
9148
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 |
|
|
9149
9481
|
| `RealtimeContract` | _type_ | shared server-to-client and client-to-server event registries |
|
|
9150
9482
|
| `RealtimeEventRegistry` | _type_ | string-keyed registry of event definitions |
|
|
9151
9483
|
| `RealtimeEventDefinition` | _type_ | one tuple-shaped event and optional acknowledgement schema |
|
|
@@ -9159,6 +9491,7 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
9159
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) |
|
|
9160
9492
|
| `RetainedTopics` | _type_ | the `createRetainedTopics` handle |
|
|
9161
9493
|
| `ParseSSEOptions` | _type_ | options for `parseSSE` |
|
|
9494
|
+
| `ParseNDJSONOptions` | _type_ | options for `parseNDJSON` |
|
|
9162
9495
|
|
|
9163
9496
|
### Trace (client)
|
|
9164
9497
|
|
|
@@ -9414,6 +9747,10 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
9414
9747
|
| Export | Kind | Summary |
|
|
9415
9748
|
|--------|------|---------|
|
|
9416
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 |
|
|
9417
9754
|
| `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
|
|
9418
9755
|
| `MultipartLifecycle` | _type_ | request-scoped rollback ownership for accepted streamed handles |
|
|
9419
9756
|
| `MultipartResult` | _type_ | what `parseMultipart` returns |
|
|
@@ -9449,6 +9786,9 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
9449
9786
|
| `RateLimitConfig` | _type_ | config for `createRateLimiter` |
|
|
9450
9787
|
| `ClientIpOptions` | _type_ | trusted-proxy config for `extractIp` / `resolveSocketIp` |
|
|
9451
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'` |
|
|
9452
9792
|
|
|
9453
9793
|
### OpenAPI
|
|
9454
9794
|
|
|
@@ -9479,6 +9819,7 @@ cutovers are covered by the executable
|
|
|
9479
9819
|
| `ApplicationResourceFailure` | _type_ | one resource failure with the cause its phase label cannot carry — delivered to `onResourceFailure` |
|
|
9480
9820
|
| `ApplicationResourcePhase` | _type_ | the phase a managed resource failed in — the vocabulary of `ApplicationResourceShutdown.failures` |
|
|
9481
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 |
|
|
9482
9823
|
| `ActivityTokenBrand` | const | the brand symbol `ActivityToken` carries, exported so `ActivityProjection` is implementable |
|
|
9483
9824
|
| `defineManagedResource` | function | retain the exact typed resource declaration; every invoked start is rollback-eligible |
|
|
9484
9825
|
| `managedServerResource` | function | adapt an existing managed server without copying its HTTP/WebSocket shutdown machine |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stitchkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.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",
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"@types/json-schema": "^7.0.15",
|
|
213
213
|
"@types/react": "^19.2.18",
|
|
214
214
|
"@typescript/typescript6": "^6.0.2",
|
|
215
|
-
"ai": "^7.0.
|
|
215
|
+
"ai": "^7.0.79",
|
|
216
216
|
"grammy": "^1.45.1",
|
|
217
217
|
"react": "^19.2.8",
|
|
218
218
|
"react-query-kit": "^3.3.4",
|