@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 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":"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"}
@@ -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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"}