@opinionated-machine/sse-fallback 0.1.1
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/LICENSE +21 -0
- package/README.md +365 -0
- package/dist/binding.d.ts +82 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +181 -0
- package/dist/binding.js.map +1 -0
- package/dist/bindingTypes.d.ts +327 -0
- package/dist/bindingTypes.d.ts.map +1 -0
- package/dist/bindingTypes.js +43 -0
- package/dist/bindingTypes.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/pollGate.d.ts +58 -0
- package/dist/pollGate.d.ts.map +1 -0
- package/dist/pollGate.js +64 -0
- package/dist/pollGate.js.map +1 -0
- package/dist/reconciler.d.ts +245 -0
- package/dist/reconciler.d.ts.map +1 -0
- package/dist/reconciler.js +568 -0
- package/dist/reconciler.js.map +1 -0
- package/dist/scheduler.d.ts +19 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +51 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/subscription.d.ts +164 -0
- package/dist/subscription.d.ts.map +1 -0
- package/dist/subscription.js +878 -0
- package/dist/subscription.js.map +1 -0
- package/dist/transport.d.ts +177 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +168 -0
- package/dist/transport.js.map +1 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Igor Savin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# @opinionated-machine/sse-fallback
|
|
2
|
+
|
|
3
|
+
Browser-safe client core for **SSE with a transparent polling fallback**, built
|
|
4
|
+
around `opinionated-machine` dual-mode contracts (one path serving JSON via
|
|
5
|
+
`Accept: application/json` and SSE via `Accept: text/event-stream`).
|
|
6
|
+
|
|
7
|
+
The client subscribes to the SSE branch for low-latency pushes and keeps a
|
|
8
|
+
**deadman timer**: when no data event arrives within the window, it polls the
|
|
9
|
+
JSON branch of the same route. A single **version gate** reconciles the two
|
|
10
|
+
channels, so app code sees exactly one uniform event stream — whether an event
|
|
11
|
+
was pushed, replayed after a reconnect, or synthesized from a poll snapshot is
|
|
12
|
+
invisible.
|
|
13
|
+
|
|
14
|
+
**One runtime dependency**, `@opinionated-machine/sse-parser`: the SSE
|
|
15
|
+
wire-format parser, shared with the server framework's test helpers so both
|
|
16
|
+
ends of a stream frame it identically. It is itself dependency-free and
|
|
17
|
+
browser-safe. `zod` and `@lokalise/api-contracts` are type-only optional peers,
|
|
18
|
+
and nothing from Node.js or Fastify is imported, so the package is safe to ship
|
|
19
|
+
to browsers (enforced by a source-tree check in CI).
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
Push channels fail silently: connections die without an error event, proxies
|
|
24
|
+
kill idle streams, a room rebalance drops a message. When the missed
|
|
25
|
+
notification gates workflow progress ("upload finished"), the user is stuck.
|
|
26
|
+
This package makes **polling the correctness backbone** (bounded staleness,
|
|
27
|
+
guaranteed) and SSE the latency optimization — instead of the other way
|
|
28
|
+
around.
|
|
29
|
+
|
|
30
|
+
Two failure detectors run independently:
|
|
31
|
+
|
|
32
|
+
| Timer | Reset by | Catches |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `staleConnection` | **any bytes** (incl. `: heartbeat` comments) | silently dead connections — force-close + reconnect + poll |
|
|
35
|
+
| `deadman` | **delivered events only** | healthy-but-wrong streams: a dropped message on a live connection, repaired by a reconciliation poll |
|
|
36
|
+
|
|
37
|
+
Heartbeats deliberately do *not* reset the deadman, and neither does a
|
|
38
|
+
duplicate the version gate drops: transport liveness is not delivery
|
|
39
|
+
correctness. A delivered event pushes the next poll out but does not shorten
|
|
40
|
+
the interval back to `deadmanDelayMs`; a stream that keeps delivering needs
|
|
41
|
+
less reconciliation, so only a poll that finds news the stream missed resets
|
|
42
|
+
the backoff.
|
|
43
|
+
|
|
44
|
+
## Declaring a binding
|
|
45
|
+
|
|
46
|
+
The binding is the one thing that cannot be inferred: how a poll snapshot
|
|
47
|
+
relates to the SSE events. Declare it once, colocated with the contract:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { defineFallbackBinding } from '@opinionated-machine/sse-fallback'
|
|
51
|
+
|
|
52
|
+
// Use case A — await async completion
|
|
53
|
+
export const uploadStatusBinding = defineFallbackBinding(uploadStatusContract, {
|
|
54
|
+
// Translate a snapshot into events; [] = "no news" (still advances the watermark)
|
|
55
|
+
snapshotToEvents: (s) =>
|
|
56
|
+
s.status === 'completed'
|
|
57
|
+
? [{ event: 'uploadFinished', data: { result: s.result } }]
|
|
58
|
+
: s.status === 'failed'
|
|
59
|
+
? [{ event: 'uploadFailed', data: { error: s.error } }]
|
|
60
|
+
: [],
|
|
61
|
+
version: { ofSnapshot: (s) => s.version },
|
|
62
|
+
terminalEvents: ['uploadFinished', 'uploadFailed'],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Use case B — initial state load + live hydration
|
|
66
|
+
export const projectStateBinding = defineFallbackBinding(projectStateContract, {
|
|
67
|
+
snapshotEvent: 'stateChanged', // shorthand: snapshot body ≡ this event's payload
|
|
68
|
+
version: { ofSnapshot: (s) => s.revision, ofEvent: (e) => e.data.revision, dense: true },
|
|
69
|
+
state: {
|
|
70
|
+
init: (s) => s,
|
|
71
|
+
apply: (state, e) => applyDelta(state, e),
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Escape hatches: `bindFallbackContracts(pollContract, streamContract, config)`
|
|
77
|
+
binds two pre-existing contracts on different paths;
|
|
78
|
+
`fromLegacyDualModeContract(contract, config)` accepts legacy
|
|
79
|
+
`buildSseContract` dual-mode contracts.
|
|
80
|
+
|
|
81
|
+
## Subscribing
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createResilientSubscription } from '@opinionated-machine/sse-fallback'
|
|
85
|
+
|
|
86
|
+
const sub = createResilientSubscription(uploadStatusBinding, {
|
|
87
|
+
transport, // FallbackTransport (see below)
|
|
88
|
+
params: { pathParams: { uploadId } },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Use case A: identical result whether it traveled over SSE or a poll
|
|
92
|
+
const { result } = await sub.waitFor('uploadFinished')
|
|
93
|
+
|
|
94
|
+
// Or consume the uniform stream
|
|
95
|
+
for await (const event of sub.events()) { ... }
|
|
96
|
+
|
|
97
|
+
// Use case B: reduced state
|
|
98
|
+
sub.onStateChange((state) => render(state))
|
|
99
|
+
|
|
100
|
+
sub.status // 'connecting' | 'live' | 'reconnecting' | 'polling' | 'stopped'
|
|
101
|
+
sub.nudge() // force an immediate reconciliation poll
|
|
102
|
+
sub.stop()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Why it stopped
|
|
106
|
+
|
|
107
|
+
`'stopped'` alone cannot be acted on: a completed job, an expired session and
|
|
108
|
+
a caller's own `stop()` all land there. Every stop carries a reason:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
sub.onStop(({ reason, status, limit }) => { ... })
|
|
112
|
+
sub.onStatusChange((status, detail) => { ... }) // detail is set for 'stopped'
|
|
113
|
+
sub.result // undefined while running
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await sub.waitFor('uploadFinished')
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof SubscriptionStoppedError && error.reason === 'budget-exhausted') {
|
|
119
|
+
showRetryPrompt()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| `reason` | Meaning |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `'terminal-event'` | a terminal event was delivered — success |
|
|
127
|
+
| `'unretryable-status'` | refused with a status in `unretryableStatuses` (`status`, `channel`) |
|
|
128
|
+
| `'budget-exhausted'` | `subscriptionBudget` ran out (`limit`) — show an error and offer a retry |
|
|
129
|
+
| `'manual'` | the caller called `stop()`, or the creation `signal` aborted |
|
|
130
|
+
|
|
131
|
+
### Bounding a pending operation
|
|
132
|
+
|
|
133
|
+
Every individual wait is bounded, but the subscription as a whole is not: a
|
|
134
|
+
backend stuck in a pending state deadman-polls until the tab closes. For
|
|
135
|
+
pending-completion subscriptions, declare a ceiling:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
createResilientSubscription(binding, {
|
|
139
|
+
transport,
|
|
140
|
+
policy: { subscriptionBudget: { maxDurationMs: 10 * 60_000, maxPolls: 200 } },
|
|
141
|
+
})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Unset by default, so a live-state surface keeps running for as long as it is
|
|
145
|
+
open.
|
|
146
|
+
|
|
147
|
+
### Recovering from an expired token
|
|
148
|
+
|
|
149
|
+
A 401 in a SPA is usually an expired token rather than a genuinely
|
|
150
|
+
unauthorized caller, and recovering without a page reload is the point of this
|
|
151
|
+
package. Give it a way to refresh:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
createResilientSubscription(binding, {
|
|
155
|
+
transport,
|
|
156
|
+
onAuthChallenge: async () => {
|
|
157
|
+
await auth.refresh() // the transport builds each request fresh
|
|
158
|
+
return true // retry the refused poll/connect once
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The retry is granted once per failure streak: a second refusal with no
|
|
164
|
+
successful request in between stops the subscription with
|
|
165
|
+
`'unretryable-status'`.
|
|
166
|
+
|
|
167
|
+
### Adopting before the SSE endpoint exists
|
|
168
|
+
|
|
169
|
+
`policy.mode: 'poll-only'` (or the `POLL_ONLY_POLICY` preset) never opens a
|
|
170
|
+
stream. The binding, version gate, reconciler and state machine are the same
|
|
171
|
+
ones the streaming rollout will use, so enabling SSE later is a config change
|
|
172
|
+
on an already-integrated subscription rather than a second migration.
|
|
173
|
+
|
|
174
|
+
The state machine: `CONNECTING → HYDRATING → LIVE ⇄ RECONNECTING →
|
|
175
|
+
POLLING_ONLY → STOPPED`. Hydration is **subscribe-first**: the stream opens,
|
|
176
|
+
live events are buffered, the snapshot is fetched, then buffered events newer
|
|
177
|
+
than the snapshot are flushed — a zero missed-event window. After N
|
|
178
|
+
consecutive connect failures the subscription degrades to pure polling and
|
|
179
|
+
keeps probing SSE in the background.
|
|
180
|
+
|
|
181
|
+
## The version gate
|
|
182
|
+
|
|
183
|
+
Every event and snapshot carries a version; an item is delivered iff its
|
|
184
|
+
version exceeds the high-watermark. This one rule handles:
|
|
185
|
+
|
|
186
|
+
- **duplicates** — an SSE event followed by a poll snapshot of the same update,
|
|
187
|
+
- **the stale-poll race** — a slow poll response arriving *after* a newer
|
|
188
|
+
pushed event is dropped at arrival time,
|
|
189
|
+
- **replay overlap** — server-side `Last-Event-ID` replay after reconnects.
|
|
190
|
+
|
|
191
|
+
`version: 'none'` opts into at-least-once/last-writer-wins semantics as an
|
|
192
|
+
adoption bridge — strongly prefer real versions.
|
|
193
|
+
|
|
194
|
+
## Server-side guarantees (the adopting team's checklist)
|
|
195
|
+
|
|
196
|
+
1. **Required**: a monotonic version per subscription scope, present in both
|
|
197
|
+
the snapshot body and each event; truthful (a snapshot at version *v*
|
|
198
|
+
reflects every event ≤ *v*). Snapshots must **subsume** prior events.
|
|
199
|
+
2. **Recommended**: stamp the SSE `id:` with that version — the client's
|
|
200
|
+
default extraction (bare integers and `createEventIdSequence()` ids alike)
|
|
201
|
+
and `Last-Event-ID` replay then compose for free. Prefer a domain version
|
|
202
|
+
(`job.version`, a revision column) as the id source: it is per-scope and
|
|
203
|
+
writer-independent. A per-process `createEventIdSequence()` is safe only for
|
|
204
|
+
a single writer — two pods sequencing into the same room use different
|
|
205
|
+
epochs, so every alternation between them reads as an epoch change and costs
|
|
206
|
+
a resync poll. For multi-writer scopes use a domain version or the
|
|
207
|
+
Redis-backed
|
|
208
|
+
`createRedisEventIdSequence()` from
|
|
209
|
+
`@opinionated-machine/sse-rooms-redis`.
|
|
210
|
+
3. Optional: dense versions (enables gap detection → instant repair polls),
|
|
211
|
+
`onReconnect` replay (declare `replay: 'trusted'` to skip post-reconnect
|
|
212
|
+
polls), heartbeats every ~15s (fast stale detection; correctness holds
|
|
213
|
+
without them).
|
|
214
|
+
|
|
215
|
+
## Transport
|
|
216
|
+
|
|
217
|
+
The core owns no HTTP. Implement two functions:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const transport: FallbackTransport = {
|
|
221
|
+
fetchSnapshot(request, { signal }) { ... }, // Accept: application/json
|
|
222
|
+
openStream(request, { signal, lastEventId }) { ... }, // Accept: text/event-stream,
|
|
223
|
+
// yields decoded text chunks
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`openStream` should yield **raw text chunks** — the core parses SSE framing
|
|
228
|
+
itself and uses chunk arrival as byte-level liveness, so heartbeat comments
|
|
229
|
+
count without any transport logic. A scripted `TestTransport` ships in the
|
|
230
|
+
package for deterministic fake-timer tests.
|
|
231
|
+
|
|
232
|
+
### Wrapping a client that only exposes parsed events
|
|
233
|
+
|
|
234
|
+
`EventSource` cannot expose comment frames at all, and an HTTP client whose
|
|
235
|
+
SSE mode yields events rather than text has already dropped them. `openStream`
|
|
236
|
+
may resolve with an `events: AsyncIterable<ParsedSseFrame>` instead of
|
|
237
|
+
`chunks`:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
openStream(request, { signal, lastEventId }) {
|
|
241
|
+
return { status: 200, headers, events: client.stream(request) }
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The cost is liveness, not correctness: `staleConnectionTimeoutMs` degrades
|
|
246
|
+
from byte-level to EVENT-level, so a stream carrying only heartbeat *comments*
|
|
247
|
+
looks idle and is force-closed at the timeout, and a silently dead connection
|
|
248
|
+
is only noticed once it elapses. Heartbeat *events* (a named event rather than
|
|
249
|
+
a comment) still reset it, and the deadman poll is unaffected. Prefer raw
|
|
250
|
+
chunks where the client allows it.
|
|
251
|
+
|
|
252
|
+
### Capping polls across subscriptions
|
|
253
|
+
|
|
254
|
+
Each subscription jitters its own backoff, which says nothing about the others
|
|
255
|
+
in the same tab: after a server blip every live subscription reconnects and
|
|
256
|
+
fires its own reconciliation poll at once. An app running dozens of
|
|
257
|
+
subscriptions turns one outage into a burst of dozens of requests against one
|
|
258
|
+
origin.
|
|
259
|
+
|
|
260
|
+
Share a gate between the subscriptions that should be capped together —
|
|
261
|
+
normally one per origin:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { createPollGate } from '@opinionated-machine/sse-fallback'
|
|
265
|
+
|
|
266
|
+
const pollGate = createPollGate({ maxConcurrent: 4, staggerMs: 2_000 })
|
|
267
|
+
createResilientSubscription(binding, { transport, pollGate })
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A gate delays polls, never cancels them: a subscription waiting for a slot
|
|
271
|
+
keeps its in-flight latch, so its deadman does not stack a second poll behind
|
|
272
|
+
the first. Without a gate, capping and staggering are the transport's
|
|
273
|
+
responsibility.
|
|
274
|
+
|
|
275
|
+
## Policy defaults
|
|
276
|
+
|
|
277
|
+
| Setting | Default | Notes |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| `initialPoll` | `'eager'` | closes the startup race for one GET |
|
|
280
|
+
| `deadmanDelayMs` | 10 000 | `LIVE_STATE_POLICY` preset: 120 000 |
|
|
281
|
+
| `deadmanIdleBackoff` | ×1.5 up to 60 s | quiet subscriptions poll less; only a poll that finds news resets it |
|
|
282
|
+
| `staleConnectionTimeoutMs` | 60 000 | `'off'` to disable byte-level liveness |
|
|
283
|
+
| `connectTimeoutMs` | 15 000 | a connect that never sends headers is a failure, not a stall |
|
|
284
|
+
| `pollTimeoutMs` | 10 000 | a poll that never settles would disable the backbone |
|
|
285
|
+
| `pollFailureBackoff` / `sseRetryBackoff` | 1 s ×2 up to 30 s, full jitter | |
|
|
286
|
+
| `serverRetryHintBounds` | 250 ms – 60 s | clamps the server's `retry:` hint |
|
|
287
|
+
| `degradedAfterFailures` | 3 | then `POLLING_ONLY` |
|
|
288
|
+
| `degradedPollIntervalMs` | 15 000 | the "old polling world", kept humane |
|
|
289
|
+
| `hydrationBufferLimit` | 1 000 | overflow → drop buffer + refetch |
|
|
290
|
+
| `hydrationAbandonAfterFailures` | 3 | flush the buffer rather than silence a healthy stream |
|
|
291
|
+
| `unretryableStatuses` | 401, 403, 404 | stop instead of retrying |
|
|
292
|
+
| `authChallengeStatuses` | 401 | offered to `onAuthChallenge` before giving up |
|
|
293
|
+
| `mode` | `'dual'` | `'poll-only'` never opens a stream |
|
|
294
|
+
| `subscriptionBudget` | unset | `{ maxDurationMs, maxPolls }` — a hard give-up bound |
|
|
295
|
+
|
|
296
|
+
Every wait in the machine is bounded, because an unbounded one turns the
|
|
297
|
+
fallback into no fallback at all: a hung connect or a poll that never settles
|
|
298
|
+
would leave nothing armed, which is precisely the silent-failure class this
|
|
299
|
+
package exists to catch.
|
|
300
|
+
|
|
301
|
+
## Event ids and the version gate
|
|
302
|
+
|
|
303
|
+
The default version extractor reads the SSE `id:` and accepts two shapes: a
|
|
304
|
+
bare integer (`"42"`), and the `"<epoch>-<counter>"` ids produced by the
|
|
305
|
+
server-side `createEventIdSequence()`. Sequence ids order by epoch first and
|
|
306
|
+
then counter, so a process restart — a new, larger epoch with the counter back
|
|
307
|
+
at 1 — reads as *newer*, not as a flood of duplicates.
|
|
308
|
+
|
|
309
|
+
The epoch is a string of digits, which is what makes `<digits>-<digits>` an
|
|
310
|
+
unambiguous marker for a generated id: a UUID matches `<anything>-<digits>` too,
|
|
311
|
+
and reading a chunk of one as a counter would order events at random. The
|
|
312
|
+
server-side generators refuse a non-numeric epoch for that reason, so every id
|
|
313
|
+
they produce is one this extractor can order.
|
|
314
|
+
|
|
315
|
+
An epoch change is a resynchronization point, not a measurable gap: the counters
|
|
316
|
+
on either side are unrelated, so the reconciler reports it as a gap with
|
|
317
|
+
`reason: 'epoch-change'`, polls for a snapshot, and rebuilds delta state from it
|
|
318
|
+
rather than applying more deltas across the restart.
|
|
319
|
+
|
|
320
|
+
That holds in **either direction**. A new epoch is not necessarily a larger one:
|
|
321
|
+
moving a writer from `createEventIdSequence()` (epoch seeded from `Date.now()`)
|
|
322
|
+
to `createRedisEventIdSequence()` (epoch `'0'` by default) lowers it. The epoch
|
|
323
|
+
is compared before the duplicate gate for exactly that reason — ranking the new
|
|
324
|
+
scope as "older" would drop every event and snapshot that followed it, forever.
|
|
325
|
+
The new epoch simply becomes the ordering scope, and the resync poll repairs
|
|
326
|
+
whatever the switch skipped. This applies to the default comparator only: a
|
|
327
|
+
binding that declares `version.compare` owns ordering end to end, epochs
|
|
328
|
+
included, and its verdict is never overridden.
|
|
329
|
+
|
|
330
|
+
Ids in any other shape (a UUID, say) carry **no** version: they are unique but
|
|
331
|
+
not orderable, so events are delivered at-least-once and the watermark does not
|
|
332
|
+
move. Declare `version.ofEvent` explicitly for any other id scheme rather than
|
|
333
|
+
letting an unorderable id masquerade as a version.
|
|
334
|
+
|
|
335
|
+
The same rule protects the gate from a version it cannot order at all —
|
|
336
|
+
`version.ofSnapshot` returning `undefined` because the body has no version
|
|
337
|
+
field, or `NaN`, or an empty string. Such a value is never stored as the
|
|
338
|
+
watermark (one that compares as "not less than" everything would drop the whole
|
|
339
|
+
stream as duplicates); the item is delivered, the watermark stays put, and
|
|
340
|
+
`diagnostics.onInvalidVersion` reports the degradation to at-least-once, which
|
|
341
|
+
is otherwise invisible.
|
|
342
|
+
|
|
343
|
+
## Known limitations (v1)
|
|
344
|
+
|
|
345
|
+
- **Snapshots must subsume events.** Append-only feeds where every event
|
|
346
|
+
matters individually and the snapshot only shows the latest item don't fit —
|
|
347
|
+
expose a windowed snapshot (`{ items: [...], version }`) instead.
|
|
348
|
+
- **One subscription is one physical SSE connection.** The binding model is
|
|
349
|
+
per-resource, so a tab with several pending jobs plus a live-state surface
|
|
350
|
+
opens one stream each. Under HTTP/1.1 that runs into the ~6-connections-per-
|
|
351
|
+
origin browser cap.
|
|
352
|
+
|
|
353
|
+
The position this package takes: per-resource streams are the recommended
|
|
354
|
+
model **behind an HTTP/2 gateway**, which removes the cap — the Envoy config
|
|
355
|
+
generated by `@opinionated-machine/gateway-envoy` in this repo is where that
|
|
356
|
+
is configured — and the per-scope snapshot/version model is what makes the
|
|
357
|
+
fallback correct in the first place. Where h2 cannot be relied on, stream
|
|
358
|
+
sharing is the roadmap item: either a SharedWorker `FallbackTransport`, or a
|
|
359
|
+
transport-level multiplexer where N logical subscriptions share one physical
|
|
360
|
+
stream keyed by contract + params, each keeping its own version gate.
|
|
361
|
+
`bindFallbackContracts` binds one poll to one stream today, so the
|
|
362
|
+
multiplexer is the missing piece for a user-wide stream.
|
|
363
|
+
`nudge()` / `stop()` give visibility-aware wrappers the hooks they need.
|
|
364
|
+
- No reorder buffer: on a single TCP stream, gaps are losses, not reorders —
|
|
365
|
+
polling is the repair path.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { EventPayloadMap, FallbackBindingConfig, InferContractEvents, InferContractSnapshot, InferLegacyEvents, InferLegacySnapshot } from './bindingTypes.ts';
|
|
2
|
+
import type { TransportRequest } from './transport.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Symbol under which a binding is stamped on its contract(s), so server-side
|
|
5
|
+
* tooling can later introspect bindings without a package dependency in
|
|
6
|
+
* either direction (`Symbol.for` — shared across duplicate package copies).
|
|
7
|
+
*/
|
|
8
|
+
export declare const FALLBACK_BINDING_SYMBOL: unique symbol;
|
|
9
|
+
/** Request parameters supplied when subscribing. */
|
|
10
|
+
export type FallbackRequestParams = {
|
|
11
|
+
pathParams?: Record<string, string | number>;
|
|
12
|
+
queryParams?: Record<string, string | number | boolean | undefined>;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
/** Request body for payload (POST/PUT/PATCH) contracts. */
|
|
15
|
+
body?: unknown;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A normalized fallback binding: the client core's single input shape.
|
|
19
|
+
* Produced by {@link defineFallbackBinding} (dual-mode contract — the
|
|
20
|
+
* primary form) or {@link bindFallbackContracts} (two separate contracts —
|
|
21
|
+
* the escape hatch); both normalize to a snapshot-request builder + a
|
|
22
|
+
* stream-request builder + the reconciliation config.
|
|
23
|
+
*/
|
|
24
|
+
export type FallbackBinding<Snapshot = unknown, Events extends EventPayloadMap = EventPayloadMap, State = undefined> = {
|
|
25
|
+
readonly config: FallbackBindingConfig<Snapshot, Events, State>;
|
|
26
|
+
buildSnapshotRequest(params: FallbackRequestParams): TransportRequest;
|
|
27
|
+
buildStreamRequest(params: FallbackRequestParams): TransportRequest;
|
|
28
|
+
};
|
|
29
|
+
/** Read a binding previously stamped on a contract, or `undefined`. */
|
|
30
|
+
export declare function readFallbackBinding(contract: object): FallbackBinding | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Declare a fallback binding on a dual-mode `defineApiContract` contract —
|
|
33
|
+
* one path serving JSON (the poll) and SSE (the push) via Accept
|
|
34
|
+
* negotiation. Snapshot and event types are inferred from the contract.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* export const uploadStatusBinding = defineFallbackBinding(uploadStatusContract, {
|
|
39
|
+
* snapshotToEvents: (s) =>
|
|
40
|
+
* s.status === 'completed' ? [{ event: 'uploadFinished', data: { result: s.result } }] : [],
|
|
41
|
+
* version: { ofSnapshot: (s) => s.version },
|
|
42
|
+
* terminalEvents: ['uploadFinished', 'uploadFailed'],
|
|
43
|
+
* })
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function defineFallbackBinding<TContract extends {
|
|
47
|
+
method: string;
|
|
48
|
+
pathResolver: (p: never) => string;
|
|
49
|
+
}, Snapshot = InferContractSnapshot<TContract>, Events extends EventPayloadMap = InferContractEvents<TContract>, State = undefined>(contract: TContract, config: FallbackBindingConfig<NoInfer<Snapshot>, NoInfer<Events>, State>): FallbackBinding<Snapshot, Events, State>;
|
|
50
|
+
export type BindFallbackContractsOptions<Snapshot, Events extends EventPayloadMap, State> = {
|
|
51
|
+
/**
|
|
52
|
+
* Map the subscription params onto each contract's params when their
|
|
53
|
+
* request shapes differ. Defaults to passing params through unchanged.
|
|
54
|
+
*/
|
|
55
|
+
mapParams?: {
|
|
56
|
+
toSnapshot?: (params: FallbackRequestParams) => FallbackRequestParams;
|
|
57
|
+
toStream?: (params: FallbackRequestParams) => FallbackRequestParams;
|
|
58
|
+
};
|
|
59
|
+
} & FallbackBindingConfig<Snapshot, Events, State>;
|
|
60
|
+
/**
|
|
61
|
+
* Escape hatch: bind two PRE-EXISTING contracts — a plain REST contract (the
|
|
62
|
+
* poll) and an SSE contract (the push) on different paths. Prefer the
|
|
63
|
+
* single dual-mode contract form (`defineFallbackBinding`) for new
|
|
64
|
+
* endpoints: one contract cannot drift against itself.
|
|
65
|
+
*/
|
|
66
|
+
export declare function bindFallbackContracts<TPoll extends {
|
|
67
|
+
method: string;
|
|
68
|
+
pathResolver: (p: never) => string;
|
|
69
|
+
}, TStream extends {
|
|
70
|
+
method: string;
|
|
71
|
+
pathResolver: (p: never) => string;
|
|
72
|
+
}, Snapshot = InferContractSnapshot<TPoll>, Events extends EventPayloadMap = [InferContractEvents<TStream>] extends [never] ? InferLegacyEvents<TStream> : InferContractEvents<TStream>, State = undefined>(poll: TPoll, stream: TStream, options: BindFallbackContractsOptions<NoInfer<Snapshot>, NoInfer<Events>, State>): FallbackBinding<Snapshot, Events, State>;
|
|
73
|
+
/**
|
|
74
|
+
* Declare a fallback binding on a legacy `buildSseContract` dual-mode
|
|
75
|
+
* contract (`successResponseBodySchema` + `serverSentEventSchemas`).
|
|
76
|
+
*/
|
|
77
|
+
export declare function fromLegacyDualModeContract<TContract extends {
|
|
78
|
+
method: string;
|
|
79
|
+
pathResolver: (p: never) => string;
|
|
80
|
+
isDualMode: boolean;
|
|
81
|
+
}, Snapshot = InferLegacySnapshot<TContract>, Events extends EventPayloadMap = InferLegacyEvents<TContract>, State = undefined>(contract: TContract, config: FallbackBindingConfig<NoInfer<Snapshot>, NoInfer<Events>, State>): FallbackBinding<Snapshot, Events, State>;
|
|
82
|
+
//# sourceMappingURL=binding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"binding.d.ts","sourceRoot":"","sources":["../src/binding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACpB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEtD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,eAAyD,CAAA;AAE7F,oDAAoD;AACpD,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAA;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,2DAA2D;IAC3D,IAAI,CAAC,EAAE,OAAO,CAAA;CACf,CAAA;AAED;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,CACzB,QAAQ,GAAG,OAAO,EAClB,MAAM,SAAS,eAAe,GAAG,eAAe,EAChD,KAAK,GAAG,SAAS,IACf;IACF,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,CAAA;IAC/D,oBAAoB,CAAC,MAAM,EAAE,qBAAqB,GAAG,gBAAgB,CAAA;IACrE,kBAAkB,CAAC,MAAM,EAAE,qBAAqB,GAAG,gBAAgB,CAAA;CACpE,CAAA;AA4GD,uEAAuE;AACvE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEjF;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,SAAS;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,MAAM,CAAA;CAAE,EACxE,QAAQ,GAAG,qBAAqB,CAAC,SAAS,CAAC,EAC3C,MAAM,SAAS,eAAe,GAAG,mBAAmB,CAAC,SAAS,CAAC,EAC/D,KAAK,GAAG,SAAS,EAEjB,QAAQ,EAAE,SAAS,EAGnB,MAAM,EAAE,qBAAqB,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,GACvE,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,CAmB1C;AAMD,MAAM,MAAM,4BAA4B,CAAC,QAAQ,EAAE,MAAM,SAAS,eAAe,EAAE,KAAK,IAAI;IAC1F;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,qBAAqB,CAAA;QACrE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,qBAAqB,CAAA;KACpE,CAAA;CACF,GAAG,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,CAAA;AAElD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,SAAS;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,MAAM,CAAA;CAAE,EACpE,OAAO,SAAS;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,MAAM,CAAA;CAAE,EACtE,QAAQ,GAAG,qBAAqB,CAAC,KAAK,CAAC,EACvC,MAAM,SAAS,eAAe,GAAG,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAC3E,iBAAiB,CAAC,OAAO,CAAC,GAC1B,mBAAmB,CAAC,OAAO,CAAC,EAChC,KAAK,GAAG,SAAS,EAEjB,IAAI,EAAE,KAAK,EACX,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,4BAA4B,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,GAC/E,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,CAkC1C;AAMD;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,SAAS;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,YAAY,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,MAAM,CAAA;IAClC,UAAU,EAAE,OAAO,CAAA;CACpB,EACD,QAAQ,GAAG,mBAAmB,CAAC,SAAS,CAAC,EACzC,MAAM,SAAS,eAAe,GAAG,iBAAiB,CAAC,SAAS,CAAC,EAC7D,KAAK,GAAG,SAAS,EAEjB,QAAQ,EAAE,SAAS,EACnB,MAAM,EAAE,qBAAqB,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,GACvE,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,CAe1C"}
|
package/dist/binding.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol under which a binding is stamped on its contract(s), so server-side
|
|
3
|
+
* tooling can later introspect bindings without a package dependency in
|
|
4
|
+
* either direction (`Symbol.for` — shared across duplicate package copies).
|
|
5
|
+
*/
|
|
6
|
+
export const FALLBACK_BINDING_SYMBOL = Symbol.for('opinionated-machine.sse-fallback.binding');
|
|
7
|
+
const SUCCESS_STATUS_CODES = [200, 201, 202, 203, 206, 207, 208, 226];
|
|
8
|
+
function isSseBodyDescriptor(value) {
|
|
9
|
+
return (typeof value === 'object' && value !== null && value._tag === 'SseBody');
|
|
10
|
+
}
|
|
11
|
+
function inspectResponseEntry(entry, shape) {
|
|
12
|
+
const content = entry.content;
|
|
13
|
+
if (!content || typeof content !== 'object') {
|
|
14
|
+
// A bare schema entry is a JSON response.
|
|
15
|
+
shape.hasNonSse = true;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const descriptor of Object.values(content)) {
|
|
19
|
+
if (isSseBodyDescriptor(descriptor))
|
|
20
|
+
shape.hasSse = true;
|
|
21
|
+
else
|
|
22
|
+
shape.hasNonSse = true;
|
|
23
|
+
}
|
|
24
|
+
if (entry.allowNoBody)
|
|
25
|
+
shape.hasNonSse = true;
|
|
26
|
+
}
|
|
27
|
+
function inspectApiContractResponses(contract) {
|
|
28
|
+
const shape = { hasSse: false, hasNonSse: false };
|
|
29
|
+
for (const code of SUCCESS_STATUS_CODES) {
|
|
30
|
+
const entry = contract.responsesByStatusCode?.[String(code)];
|
|
31
|
+
if (entry !== undefined) {
|
|
32
|
+
inspectResponseEntry(entry, shape);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return shape;
|
|
36
|
+
}
|
|
37
|
+
function validateConfig(config) {
|
|
38
|
+
const hasMapper = config.snapshotToEvents !== undefined;
|
|
39
|
+
const hasShorthand = config.snapshotEvent !== undefined;
|
|
40
|
+
if (hasMapper === hasShorthand) {
|
|
41
|
+
throw new Error('FallbackBindingConfig requires exactly one of snapshotToEvents / snapshotEvent.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Expand the `snapshotEvent` shorthand into `snapshotToEvents`. */
|
|
45
|
+
function normalizeConfig(config) {
|
|
46
|
+
validateConfig(config);
|
|
47
|
+
if (config.snapshotEvent === undefined)
|
|
48
|
+
return config;
|
|
49
|
+
const eventName = config.snapshotEvent;
|
|
50
|
+
return {
|
|
51
|
+
...config,
|
|
52
|
+
snapshotToEvents: (snapshot) => [
|
|
53
|
+
{ event: eventName, data: snapshot },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function stringifyQuery(queryParams) {
|
|
58
|
+
if (!queryParams)
|
|
59
|
+
return undefined;
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
continue;
|
|
64
|
+
out[key] = String(value);
|
|
65
|
+
}
|
|
66
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
67
|
+
}
|
|
68
|
+
function buildRequest(contract, params) {
|
|
69
|
+
return {
|
|
70
|
+
path: contract.pathResolver(params.pathParams ?? {}),
|
|
71
|
+
method: contract.method,
|
|
72
|
+
...(stringifyQuery(params.queryParams) ? { query: stringifyQuery(params.queryParams) } : {}),
|
|
73
|
+
...(params.headers ? { headers: params.headers } : {}),
|
|
74
|
+
...(params.body !== undefined ? { body: params.body } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function stampBinding(contract, binding) {
|
|
78
|
+
Object.defineProperty(contract, FALLBACK_BINDING_SYMBOL, {
|
|
79
|
+
value: binding,
|
|
80
|
+
enumerable: false,
|
|
81
|
+
configurable: true,
|
|
82
|
+
writable: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Read a binding previously stamped on a contract, or `undefined`. */
|
|
86
|
+
export function readFallbackBinding(contract) {
|
|
87
|
+
return contract[FALLBACK_BINDING_SYMBOL];
|
|
88
|
+
}
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Primary form: one dual-mode contract IS the binding
|
|
91
|
+
// ============================================================================
|
|
92
|
+
/**
|
|
93
|
+
* Declare a fallback binding on a dual-mode `defineApiContract` contract —
|
|
94
|
+
* one path serving JSON (the poll) and SSE (the push) via Accept
|
|
95
|
+
* negotiation. Snapshot and event types are inferred from the contract.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* export const uploadStatusBinding = defineFallbackBinding(uploadStatusContract, {
|
|
100
|
+
* snapshotToEvents: (s) =>
|
|
101
|
+
* s.status === 'completed' ? [{ event: 'uploadFinished', data: { result: s.result } }] : [],
|
|
102
|
+
* version: { ofSnapshot: (s) => s.version },
|
|
103
|
+
* terminalEvents: ['uploadFinished', 'uploadFailed'],
|
|
104
|
+
* })
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function defineFallbackBinding(contract,
|
|
108
|
+
// NoInfer: Snapshot/Events come from the CONTRACT (via the defaults), never
|
|
109
|
+
// widened from whatever the config functions happen to mention.
|
|
110
|
+
config) {
|
|
111
|
+
const contractLike = contract;
|
|
112
|
+
const shape = inspectApiContractResponses(contractLike);
|
|
113
|
+
const isLegacyDual = contractLike.isDualMode === true;
|
|
114
|
+
if (!isLegacyDual && !(shape.hasSse && shape.hasNonSse)) {
|
|
115
|
+
throw new Error('defineFallbackBinding requires a dual-mode contract (a success response with both an SSE and a non-SSE representation). ' +
|
|
116
|
+
'For separate poll/stream contracts use bindFallbackContracts; for legacy dual-mode contracts use fromLegacyDualModeContract.');
|
|
117
|
+
}
|
|
118
|
+
const normalized = normalizeConfig(config);
|
|
119
|
+
const binding = {
|
|
120
|
+
config: normalized,
|
|
121
|
+
buildSnapshotRequest: (params) => buildRequest(contractLike, params),
|
|
122
|
+
buildStreamRequest: (params) => buildRequest(contractLike, params),
|
|
123
|
+
};
|
|
124
|
+
stampBinding(contract, binding);
|
|
125
|
+
return binding;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Escape hatch: bind two PRE-EXISTING contracts — a plain REST contract (the
|
|
129
|
+
* poll) and an SSE contract (the push) on different paths. Prefer the
|
|
130
|
+
* single dual-mode contract form (`defineFallbackBinding`) for new
|
|
131
|
+
* endpoints: one contract cannot drift against itself.
|
|
132
|
+
*/
|
|
133
|
+
export function bindFallbackContracts(poll, stream, options) {
|
|
134
|
+
const pollLike = poll;
|
|
135
|
+
const streamLike = stream;
|
|
136
|
+
const pollShape = inspectApiContractResponses(pollLike);
|
|
137
|
+
if (pollShape.hasSse) {
|
|
138
|
+
throw new Error('bindFallbackContracts: the poll contract must be a plain (non-SSE) contract — its SSE responses would never be used.');
|
|
139
|
+
}
|
|
140
|
+
const streamShape = inspectApiContractResponses(streamLike);
|
|
141
|
+
const streamIsLegacySse = streamLike.isSSE === true ||
|
|
142
|
+
streamLike.isDualMode === true ||
|
|
143
|
+
streamLike.serverSentEventSchemas !== undefined;
|
|
144
|
+
if (!streamShape.hasSse && !streamIsLegacySse) {
|
|
145
|
+
throw new Error('bindFallbackContracts: the stream contract must declare an SSE success response.');
|
|
146
|
+
}
|
|
147
|
+
const { mapParams, ...config } = options;
|
|
148
|
+
const normalized = normalizeConfig(config);
|
|
149
|
+
const toSnapshot = mapParams?.toSnapshot ?? ((params) => params);
|
|
150
|
+
const toStream = mapParams?.toStream ?? ((params) => params);
|
|
151
|
+
const binding = {
|
|
152
|
+
config: normalized,
|
|
153
|
+
buildSnapshotRequest: (params) => buildRequest(pollLike, toSnapshot(params)),
|
|
154
|
+
buildStreamRequest: (params) => buildRequest(streamLike, toStream(params)),
|
|
155
|
+
};
|
|
156
|
+
stampBinding(poll, binding);
|
|
157
|
+
stampBinding(stream, binding);
|
|
158
|
+
return binding;
|
|
159
|
+
}
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Legacy adapter
|
|
162
|
+
// ============================================================================
|
|
163
|
+
/**
|
|
164
|
+
* Declare a fallback binding on a legacy `buildSseContract` dual-mode
|
|
165
|
+
* contract (`successResponseBodySchema` + `serverSentEventSchemas`).
|
|
166
|
+
*/
|
|
167
|
+
export function fromLegacyDualModeContract(contract, config) {
|
|
168
|
+
if (contract.isDualMode !== true) {
|
|
169
|
+
throw new Error('fromLegacyDualModeContract requires a legacy dual-mode contract (buildSseContract with successResponseBodySchema).');
|
|
170
|
+
}
|
|
171
|
+
const contractLike = contract;
|
|
172
|
+
const normalized = normalizeConfig(config);
|
|
173
|
+
const binding = {
|
|
174
|
+
config: normalized,
|
|
175
|
+
buildSnapshotRequest: (params) => buildRequest(contractLike, params),
|
|
176
|
+
buildStreamRequest: (params) => buildRequest(contractLike, params),
|
|
177
|
+
};
|
|
178
|
+
stampBinding(contract, binding);
|
|
179
|
+
return binding;
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=binding.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"binding.js","sourceRoot":"","sources":["../src/binding.ts"],"names":[],"mappings":"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"}
|