durablews 1.0.0 → 2.0.0-alpha.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.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * The Standard Schema v1 interface (https://standardschema.dev), vendored as
3
+ * types-only per the spec's guidance — implementing libraries (zod, valibot,
4
+ * arktype, …) conform to this shape, so DurableWS can accept any of them
5
+ * without depending on any of them. Keeps core zero-dependency.
6
+ */
7
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
8
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>;
9
+ }
10
+ declare namespace StandardSchemaV1 {
11
+ interface Props<Input = unknown, Output = Input> {
12
+ readonly version: 1;
13
+ readonly vendor: string;
14
+ readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
15
+ readonly types?: Types<Input, Output> | undefined;
16
+ }
17
+ type Result<Output> = SuccessResult<Output> | FailureResult;
18
+ interface SuccessResult<Output> {
19
+ readonly value: Output;
20
+ readonly issues?: undefined;
21
+ }
22
+ interface FailureResult {
23
+ readonly issues: ReadonlyArray<Issue>;
24
+ }
25
+ interface Issue {
26
+ readonly message: string;
27
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
28
+ }
29
+ interface PathSegment {
30
+ readonly key: PropertyKey;
31
+ }
32
+ interface Types<Input = unknown, Output = Input> {
33
+ readonly input: Input;
34
+ readonly output: Output;
35
+ }
36
+ type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
37
+ type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
38
+ }
39
+ /**
40
+ * Thrown (as an `error` event payload) when an inbound message fails the
41
+ * configured schema. The message is **not** emitted — handlers only ever see
42
+ * data that passed validation.
43
+ */
44
+ declare class SchemaValidationError extends Error {
45
+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
46
+ constructor(issues: ReadonlyArray<StandardSchemaV1.Issue>);
47
+ }
48
+
49
+ /**
50
+ * Translates between application values and WebSocket wire frames.
51
+ *
52
+ * The codec is the single seam where serialization lives: `send()` runs values
53
+ * through `encode`, and incoming frames are run through `decode` before being
54
+ * emitted as `message`. The default is JSON (see `codec.ts`); supply your own
55
+ * for binary protocols, schema-based formats, etc.
56
+ */
57
+ interface Codec {
58
+ /**
59
+ * Encode an outgoing value into a WebSocket-sendable frame.
60
+ *
61
+ * The return type deliberately mirrors `WebSocket.send`'s parameter
62
+ * (`BufferSource` is its exact alias), so the codec contract can never
63
+ * drift looser than what the socket accepts — e.g. TS 6's lib excludes
64
+ * `SharedArrayBuffer`-backed views from `send`, and this excludes them too.
65
+ */
66
+ encode(data: unknown): string | BufferSource | Blob;
67
+ /** Decode an incoming frame (`string` for text, `ArrayBuffer`/`Blob` for binary). */
68
+ decode(data: unknown): unknown;
69
+ }
70
+ /**
71
+ * Tuning for automatic reconnection. All fields optional — the defaults give
72
+ * full-jitter exponential backoff with unlimited retries.
73
+ */
74
+ interface ReconnectOptions {
75
+ /** First-retry delay ceiling in ms. Default `500`. */
76
+ readonly baseDelay?: number;
77
+ /** Exponential growth factor. Default `2`. */
78
+ readonly factor?: number;
79
+ /** Delay ceiling in ms. Default `30_000`. */
80
+ readonly maxDelay?: number;
81
+ /**
82
+ * Full jitter: each delay is drawn uniformly from `[0, computed]`, so a
83
+ * fleet of clients dropped at once doesn't retry in synchronized waves.
84
+ * Default `true`; set `false` for exact exponential delays.
85
+ */
86
+ readonly jitter?: boolean;
87
+ /** Retries per disconnection before giving up. Default `Infinity`. */
88
+ readonly maxRetries?: number;
89
+ /**
90
+ * Decide per-close whether to reconnect (e.g. skip auth rejections by
91
+ * close code). Default: always reconnect. User-initiated `close()` never
92
+ * reconnects, regardless of this.
93
+ */
94
+ readonly shouldReconnect?: (event: CloseEvent) => boolean;
95
+ }
96
+ /**
97
+ * Opt-in liveness checking. When configured, the client sends `message` every
98
+ * `interval` ms while open; if **no inbound frame of any kind** arrives within
99
+ * `timeout` ms of a ping, the link is declared dead and force-closed (code
100
+ * `4408`), which flows into the normal reconnect machinery.
101
+ *
102
+ * Requires a server that responds to the heartbeat message (or talks
103
+ * regularly for other reasons) — that app-level contract is why heartbeat is
104
+ * opt-in rather than on by default.
105
+ */
106
+ interface HeartbeatOptions {
107
+ /** Milliseconds between pings. */
108
+ readonly interval: number;
109
+ /** The ping payload, run through the codec. Default `"ping"`. */
110
+ readonly message?: unknown;
111
+ /**
112
+ * Milliseconds after a ping to wait for inbound traffic before declaring
113
+ * the link dead. Default: `interval`.
114
+ */
115
+ readonly timeout?: number;
116
+ }
117
+ /**
118
+ * Tuning for the outbound message queue.
119
+ */
120
+ interface QueueOptions {
121
+ /**
122
+ * Maximum queued messages. When full, the **oldest** is dropped and a
123
+ * `drop` event fires — never silently unbounded, never silently lossy.
124
+ * Default `256`.
125
+ */
126
+ readonly maxSize?: number;
127
+ }
128
+ /**
129
+ * Configuration options for the WebSocket client.
130
+ */
131
+ interface WebSocketClientConfig {
132
+ /** The URL to connect to. */
133
+ readonly url: string | URL;
134
+ /** Optional subprotocol(s) passed to the underlying `WebSocket`. */
135
+ readonly protocols?: string | string[];
136
+ /**
137
+ * `binaryType` applied to the underlying socket on every (re)connect:
138
+ * `"arraybuffer"` delivers binary frames as `ArrayBuffer` instead of the
139
+ * browser default `Blob`.
140
+ */
141
+ readonly binaryType?: BinaryType;
142
+ /** Wire-format codec. Defaults to JSON (`jsonCodec`). */
143
+ readonly codec?: Codec;
144
+ /**
145
+ * Automatic reconnection — **on by default** (durable by default). Pass
146
+ * `false` to disable, or options to tune the backoff.
147
+ */
148
+ readonly reconnect?: false | ReconnectOptions;
149
+ /**
150
+ * Outbound queueing while disconnected — **on by default**. `send()`
151
+ * during `connecting`/`reconnecting` queues and flushes in order on open.
152
+ * Pass `false` to make `send()` throw whenever the socket isn't open.
153
+ */
154
+ readonly queue?: false | QueueOptions;
155
+ /**
156
+ * Liveness checking — **opt-in** (off unless configured). See
157
+ * {@link HeartbeatOptions}.
158
+ */
159
+ readonly heartbeat?: HeartbeatOptions;
160
+ /**
161
+ * A [Standard Schema](https://standardschema.dev) (zod, valibot, arktype,
162
+ * …) validating every **inbound** message after `codec.decode` and before
163
+ * middleware. Valid messages flow on with the schema's output value;
164
+ * invalid ones surface as an `error` event (`SchemaValidationError`) and
165
+ * are never emitted as `message`. When passed to `defineClient`, the
166
+ * message type is **inferred from the schema** — no generics needed.
167
+ */
168
+ readonly schema?: StandardSchemaV1;
169
+ }
170
+ /**
171
+ * The states a connection can occupy. This is the public, observable lifecycle.
172
+ *
173
+ * - `idle` — created but never connected.
174
+ * - `connecting` — a socket is open-in-progress, awaiting the first `open`.
175
+ * - `open` — connected and ready to send/receive.
176
+ * - `closing` — a close has been requested, awaiting the socket to finish.
177
+ * - `reconnecting` — the connection dropped; waiting out the backoff delay
178
+ * before the next attempt.
179
+ * - `closed` — the socket is closed; the client may `connect()` again.
180
+ */
181
+ type ConnectionState = "idle" | "connecting" | "open" | "closing" | "reconnecting" | "closed";
182
+ /**
183
+ * A read-only snapshot of the client's observable state. Bounded by design —
184
+ * it holds lifecycle, never message history.
185
+ */
186
+ interface ClientState {
187
+ /** The current connection state. */
188
+ readonly state: ConnectionState;
189
+ /**
190
+ * The most recent failure, if any: a transport error (`Event`) or a
191
+ * client-detected one like a heartbeat timeout (`Error`).
192
+ */
193
+ readonly lastError: Event | Error | null;
194
+ /**
195
+ * Retries used in the current disconnection episode. `0` while healthy;
196
+ * resets on a successful open or a user `close()`.
197
+ */
198
+ readonly retryAttempt: number;
199
+ /** Messages currently waiting in the outbound queue. */
200
+ readonly queueLength: number;
201
+ }
202
+ /**
203
+ * Payload emitted on every `drop` event — a queued outbound message that will
204
+ * never be sent.
205
+ */
206
+ interface DropEvent<TOut = unknown> {
207
+ /** The original (un-encoded) value passed to `send()`. */
208
+ readonly data: TOut;
209
+ /**
210
+ * Why it was dropped: `overflow` — the queue was full and this was the
211
+ * oldest entry; `close` — the connection ended (user `close()` or terminal
212
+ * failure) with messages still queued.
213
+ */
214
+ readonly reason: "overflow" | "close";
215
+ }
216
+ /**
217
+ * Payload emitted on every `reconnecting` event (one per scheduled retry).
218
+ */
219
+ interface ReconnectingEvent {
220
+ /** 1-based retry number within this disconnection episode. */
221
+ readonly attempt: number;
222
+ /** The backoff delay (ms) before this attempt fires. */
223
+ readonly delay: number;
224
+ }
225
+ /**
226
+ * The context handed to each message middleware.
227
+ */
228
+ interface MessageContext<TIn = unknown> {
229
+ /**
230
+ * The decoded (and, if a schema is configured, validated) inbound message.
231
+ * Middleware may reassign this to transform what later middleware — and
232
+ * the `message` event — receive.
233
+ */
234
+ data: TIn;
235
+ /** The client, e.g. for sending a reply from within the pipeline. */
236
+ readonly client: WebSocketClient<TIn>;
237
+ }
238
+ /**
239
+ * Message middleware, run in registration order for each inbound message.
240
+ *
241
+ * Call `next()` to pass control to the next middleware; the message is emitted
242
+ * as a `message` event only if the whole chain calls through. Return without
243
+ * calling `next()` to short-circuit (e.g. an auto-reply that shouldn't bubble).
244
+ * May be async.
245
+ */
246
+ type Middleware<TIn = unknown> = (ctx: MessageContext<TIn>, next: () => void | Promise<void>) => void | Promise<void>;
247
+ /**
248
+ * The context handed to each outbound middleware.
249
+ */
250
+ interface OutboundContext<TOut = unknown> {
251
+ /**
252
+ * The outbound message, exactly as passed to `send()`. Middleware may
253
+ * reassign this to transform what later middleware — and ultimately
254
+ * `codec.encode` — receive.
255
+ */
256
+ data: TOut;
257
+ /** The client, e.g. to read `getState()` from within the pipeline. */
258
+ readonly client: WebSocketClient<unknown, TOut>;
259
+ }
260
+ /**
261
+ * Outbound middleware, run in registration order for each outgoing message at
262
+ * **transmission time** — after dequeue, before `codec.encode` — so a message
263
+ * queued across a reconnect sees middleware (e.g. a token refresh) that is
264
+ * fresh when it actually goes out.
265
+ *
266
+ * May be async: the outbound path is serialized, so messages reach the socket
267
+ * in `send()` order even while an earlier message's middleware awaits (a
268
+ * delaying middleware therefore delays everything behind it — head-of-line,
269
+ * by design). Return without calling `next()` to deliberately not send the
270
+ * message (no `drop` event — `drop` means durability loss, not policy).
271
+ * A throw/rejection surfaces as an `error` event and skips only that message.
272
+ *
273
+ * Heartbeat pings bypass outbound middleware entirely.
274
+ */
275
+ type OutboundMiddleware<TOut = unknown> = (ctx: OutboundContext<TOut>, next: () => void | Promise<void>) => void | Promise<void>;
276
+ /**
277
+ * The object form accepted by `use()`: register middleware per direction, so
278
+ * one logical middleware (auth, logging, metrics) has a single registration
279
+ * site. Either side may be omitted.
280
+ */
281
+ interface DirectionalMiddleware<TIn = unknown, TOut = unknown> {
282
+ readonly inbound?: Middleware<TIn>;
283
+ readonly outbound?: OutboundMiddleware<TOut>;
284
+ }
285
+ /**
286
+ * Payload emitted on every `statechange`.
287
+ */
288
+ interface StateChange {
289
+ /** The state being left. */
290
+ readonly previous: ConnectionState;
291
+ /** The state being entered. */
292
+ readonly current: ConnectionState;
293
+ }
294
+ /**
295
+ * The events a client emits, mapped to their payload types. Used to give
296
+ * `on()` precise, per-event payload typing.
297
+ */
298
+ interface ClientEventMap<TIn = unknown, TOut = unknown> {
299
+ /** The socket opened. */
300
+ open: undefined;
301
+ /** A message was received, decoded, and (if configured) validated. */
302
+ message: TIn;
303
+ /** The socket closed (clean or otherwise). */
304
+ close: CloseEvent;
305
+ /**
306
+ * Something failed: a transport error (an `Event` from the socket) or a
307
+ * middleware that threw/rejected (an `Error`).
308
+ */
309
+ error: Event | Error;
310
+ /** The connection state changed. */
311
+ statechange: StateChange;
312
+ /** A reconnect attempt was scheduled (fires once per retry). */
313
+ reconnecting: ReconnectingEvent;
314
+ /** A queued outbound message was dropped and will never be sent. */
315
+ drop: DropEvent<TOut>;
316
+ }
317
+ /**
318
+ * A resilient, zero-dependency WebSocket client.
319
+ *
320
+ * @typeParam TIn - The type of inbound messages (what `on("message")`
321
+ * handlers receive). Inferred from `config.schema` when one is given.
322
+ * @typeParam TOut - The type accepted by `send()`.
323
+ */
324
+ interface WebSocketClient<TIn = unknown, TOut = unknown> {
325
+ /** The current connection state. */
326
+ readonly state: ConnectionState;
327
+ /**
328
+ * Opens the connection.
329
+ *
330
+ * Resolves the first time the socket opens — including when that open is
331
+ * a successful *retry* (the promise survives failed attempts while
332
+ * reconnection is active). Calling it while already `open` resolves
333
+ * immediately; calling it while `connecting` returns the same in-flight
334
+ * promise (idempotent); calling it while `reconnecting` skips the backoff
335
+ * wait and attempts immediately.
336
+ *
337
+ * Rejects only on **terminal** failure: retries exhausted
338
+ * (`reconnect.maxRetries`), a `shouldReconnect` veto, `close()` before the
339
+ * first open, or — with `reconnect: false` — any close before opening.
340
+ *
341
+ * **Under the default `maxRetries: Infinity`, this promise never rejects**:
342
+ * against a down host it stays pending while the client keeps retrying.
343
+ * That is the durable-by-default contract, by design. If you need a
344
+ * deadline, set a finite `maxRetries` or race it:
345
+ * `Promise.race([client.connect(), timeout(10_000)])`.
346
+ *
347
+ * Failures *after* the first open surface via the `error` / `close` /
348
+ * `reconnecting` events, not this promise. For fire-and-forget use, attach
349
+ * a `.catch` or listen for `error` to avoid an unhandled rejection.
350
+ */
351
+ connect(): Promise<void>;
352
+ /**
353
+ * Sends data over the connection. Non-string data is encoded (JSON by
354
+ * default).
355
+ *
356
+ * While `connecting` or `reconnecting`, the message is **queued** (bounded,
357
+ * drop-oldest — see `queue` config) and flushed in order when the socket
358
+ * opens; queued messages that will never send surface as `drop` events.
359
+ * Throws when the client is `idle`, `closing`, or `closed` — states where
360
+ * no open is coming — or whenever the socket isn't open if `queue: false`.
361
+ */
362
+ send(data: TOut): void;
363
+ /**
364
+ * Closes the connection.
365
+ * @param code - Optional close code (see `error.ts`).
366
+ * @param reason - Optional human-readable close reason.
367
+ */
368
+ close(code?: number, reason?: string): void;
369
+ /**
370
+ * Subscribes to a client event.
371
+ * @returns an unsubscribe function.
372
+ */
373
+ on<K extends keyof ClientEventMap<TIn, TOut>>(event: K, handler: (payload: ClientEventMap<TIn, TOut>[K]) => void): () => void;
374
+ /**
375
+ * Registers message middleware.
376
+ *
377
+ * A bare function registers **inbound** middleware (run in order for each
378
+ * inbound message — unchanged). The object form registers per direction:
379
+ * `use({ outbound })`, `use({ inbound, outbound })`. See
380
+ * {@link OutboundMiddleware} for the outbound contract (transmission-time
381
+ * execution, ordered async, silent short-circuit, per-message errors).
382
+ *
383
+ * @returns the client, for chaining.
384
+ */
385
+ use(middleware: Middleware<TIn> | DirectionalMiddleware<TIn, TOut>): WebSocketClient<TIn, TOut>;
386
+ /**
387
+ * Returns a read-only snapshot of the client's observable state.
388
+ *
389
+ * The snapshot is **referentially stable**: repeated calls return the same
390
+ * frozen object until something actually changes. Together with
391
+ * {@link subscribe} this is exactly the `subscribe`/`getSnapshot` pair
392
+ * React's `useSyncExternalStore` requires, and it drives Vue/Svelte
393
+ * reactivity equally well.
394
+ */
395
+ getState(): ClientState;
396
+ /**
397
+ * Subscribes to **any** change of the observable snapshot — connection
398
+ * state, `lastError`, `retryAttempt`, or `queueLength`. Unlike
399
+ * `on("statechange")` (which fires only on FSM transitions), this also
400
+ * fires when, e.g., the queue grows on a `send()` while disconnected.
401
+ *
402
+ * The listener receives no arguments; read `getState()` for the new
403
+ * snapshot.
404
+ *
405
+ * @returns an unsubscribe function.
406
+ */
407
+ subscribe(listener: () => void): () => void;
408
+ }
409
+
410
+ export { type Codec as C, type DirectionalMiddleware as D, type HeartbeatOptions as H, type Middleware as M, type OutboundContext as O, type QueueOptions as Q, type ReconnectOptions as R, StandardSchemaV1 as S, type WebSocketClient as W, type WebSocketClientConfig as a, type ClientEventMap as b, type ClientState as c, type ConnectionState as d, type DropEvent as e, type MessageContext as f, type OutboundMiddleware as g, type ReconnectingEvent as h, SchemaValidationError as i, type StateChange as j };