@durable-streams/client 0.1.2 → 0.1.4

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/src/stream.ts CHANGED
@@ -24,7 +24,12 @@ import {
24
24
  createFetchWithConsumedBody,
25
25
  } from "./fetch"
26
26
  import { stream as streamFn } from "./stream-api"
27
- import { handleErrorResponse, resolveHeaders, resolveParams } from "./utils"
27
+ import {
28
+ handleErrorResponse,
29
+ resolveHeaders,
30
+ resolveParams,
31
+ warnIfUsingHttpInBrowser,
32
+ } from "./utils"
28
33
  import type { BackoffOptions } from "./fetch"
29
34
  import type { queueAsPromised } from "fastq"
30
35
  import type {
@@ -158,6 +163,11 @@ export class DurableStream {
158
163
  this.#options = { ...opts, url: urlStr }
159
164
  this.#onError = opts.onError
160
165
 
166
+ // Set contentType from options if provided (for IdempotentProducer and other use cases)
167
+ if (opts.contentType) {
168
+ this.contentType = opts.contentType
169
+ }
170
+
161
171
  // Batching is enabled by default
162
172
  this.#batchingEnabled = opts.batching !== false
163
173
 
@@ -742,6 +752,7 @@ export class DurableStream {
742
752
  live: options?.live,
743
753
  json: options?.json,
744
754
  onError: options?.onError ?? this.#onError,
755
+ warnOnHttp: options?.warnOnHttp ?? this.#options.warnOnHttp,
745
756
  })
746
757
  }
747
758
 
@@ -864,4 +875,5 @@ function validateOptions(options: Partial<DurableStreamOptions>): void {
864
875
  if (options.signal && !(options.signal instanceof AbortSignal)) {
865
876
  throw new InvalidSignalError()
866
877
  }
878
+ warnIfUsingHttpInBrowser(options.url, options.warnOnHttp)
867
879
  }
package/src/types.ts CHANGED
@@ -158,6 +158,15 @@ export interface StreamOptions {
158
158
  * fall back to long-polling mode.
159
159
  */
160
160
  sseResilience?: SSEResilienceOptions
161
+
162
+ /**
163
+ * Whether to warn when using HTTP (not HTTPS) URLs in browser environments.
164
+ * HTTP limits browsers to 6 concurrent connections (HTTP/1.1), which can
165
+ * cause slow streams and app freezes with multiple active streams.
166
+ *
167
+ * @default true
168
+ */
169
+ warnOnHttp?: boolean
161
170
  }
162
171
 
163
172
  /**
@@ -310,6 +319,15 @@ export interface StreamHandleOptions {
310
319
  * @default true
311
320
  */
312
321
  batching?: boolean
322
+
323
+ /**
324
+ * Whether to warn when using HTTP (not HTTPS) URLs in browser environments.
325
+ * HTTP limits browsers to 6 concurrent connections (HTTP/1.1), which can
326
+ * cause slow streams and app freezes with multiple active streams.
327
+ *
328
+ * @default true
329
+ */
330
+ warnOnHttp?: boolean
313
331
  }
314
332
 
315
333
  /**
@@ -363,6 +381,26 @@ export interface AppendOptions {
363
381
  * AbortSignal for this operation.
364
382
  */
365
383
  signal?: AbortSignal
384
+
385
+ /**
386
+ * Producer ID for idempotent writes.
387
+ * Client-supplied stable identifier (e.g., "order-service-1").
388
+ * Must be provided together with producerEpoch and producerSeq.
389
+ */
390
+ producerId?: string
391
+
392
+ /**
393
+ * Producer epoch for idempotent writes.
394
+ * Client-declared, server-validated monotonically increasing.
395
+ * Increment on producer restart.
396
+ */
397
+ producerEpoch?: number
398
+
399
+ /**
400
+ * Producer sequence for idempotent writes.
401
+ * Monotonically increasing per epoch, per-batch.
402
+ */
403
+ producerSeq?: number
366
404
  }
367
405
 
368
406
  /**
@@ -735,3 +773,76 @@ export interface StreamResponse<TJson = unknown> {
735
773
  */
736
774
  readonly closed: Promise<void>
737
775
  }
776
+
777
+ // ============================================================================
778
+ // Idempotent Producer Types
779
+ // ============================================================================
780
+
781
+ /**
782
+ * Options for creating an IdempotentProducer.
783
+ */
784
+ export interface IdempotentProducerOptions {
785
+ /**
786
+ * Starting epoch (default: 0).
787
+ * Increment this on producer restart.
788
+ */
789
+ epoch?: number
790
+
791
+ /**
792
+ * On 403 Forbidden (stale epoch), automatically retry with epoch+1.
793
+ * Useful for serverless/ephemeral producers.
794
+ * @default false
795
+ */
796
+ autoClaim?: boolean
797
+
798
+ /**
799
+ * Maximum bytes before sending a batch.
800
+ * @default 1048576 (1MB)
801
+ */
802
+ maxBatchBytes?: number
803
+
804
+ /**
805
+ * Maximum time to wait for more messages before sending batch (ms).
806
+ * @default 5
807
+ */
808
+ lingerMs?: number
809
+
810
+ /**
811
+ * Maximum number of concurrent batches in flight.
812
+ * Higher values improve throughput at the cost of more memory.
813
+ * @default 5
814
+ */
815
+ maxInFlight?: number
816
+
817
+ /**
818
+ * Custom fetch implementation.
819
+ */
820
+ fetch?: typeof globalThis.fetch
821
+
822
+ /**
823
+ * AbortSignal for the producer lifecycle.
824
+ */
825
+ signal?: AbortSignal
826
+
827
+ /**
828
+ * Callback for batch errors in fire-and-forget mode.
829
+ * Since append() returns immediately, errors are reported via this callback.
830
+ * @param error - The error that occurred
831
+ */
832
+ onError?: (error: Error) => void
833
+ }
834
+
835
+ /**
836
+ * Result of an append operation from IdempotentProducer.
837
+ */
838
+ export interface IdempotentAppendResult {
839
+ /**
840
+ * The offset after this message was appended.
841
+ */
842
+ offset: Offset
843
+
844
+ /**
845
+ * Whether this was a duplicate (idempotent success).
846
+ */
847
+ duplicate: boolean
848
+ }
package/src/utils.ts CHANGED
@@ -102,3 +102,123 @@ export async function resolveValue<T>(
102
102
  }
103
103
  return value
104
104
  }
105
+
106
+ // Module-level Set to track origins we've already warned about (prevents log spam)
107
+ const warnedOrigins = new Set<string>()
108
+
109
+ /**
110
+ * Safely read NODE_ENV without triggering "process is not defined" errors.
111
+ * Works in both browser and Node.js environments.
112
+ */
113
+ function getNodeEnvSafely(): string | undefined {
114
+ if (typeof process === `undefined`) return undefined
115
+ // Use optional chaining for process.env in case it's undefined (e.g., in some bundler environments)
116
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
117
+ return process.env?.NODE_ENV
118
+ }
119
+
120
+ /**
121
+ * Check if we're in a browser environment.
122
+ */
123
+ function isBrowserEnvironment(): boolean {
124
+ return typeof globalThis.window !== `undefined`
125
+ }
126
+
127
+ /**
128
+ * Get window.location.href safely, returning undefined if not available.
129
+ */
130
+ function getWindowLocationHref(): string | undefined {
131
+ if (
132
+ typeof globalThis.window !== `undefined` &&
133
+ typeof globalThis.window.location !== `undefined`
134
+ ) {
135
+ return globalThis.window.location.href
136
+ }
137
+ return undefined
138
+ }
139
+
140
+ /**
141
+ * Resolve a URL string, handling relative URLs in browser environments.
142
+ * Returns undefined if the URL cannot be parsed.
143
+ */
144
+ function resolveUrlMaybe(urlString: string): URL | undefined {
145
+ try {
146
+ // First try parsing as an absolute URL
147
+ return new URL(urlString)
148
+ } catch {
149
+ // If that fails and we're in a browser, try resolving as relative URL
150
+ const base = getWindowLocationHref()
151
+ if (base) {
152
+ try {
153
+ return new URL(urlString, base)
154
+ } catch {
155
+ return undefined
156
+ }
157
+ }
158
+ return undefined
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Warn if using HTTP (not HTTPS) URL in a browser environment.
164
+ * HTTP typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1,
165
+ * which can cause slow streams and app freezes with multiple active streams.
166
+ *
167
+ * Features:
168
+ * - Warns only once per origin to prevent log spam
169
+ * - Handles relative URLs by resolving against window.location.href
170
+ * - Safe to call in Node.js environments (no-op)
171
+ * - Skips warning during tests (NODE_ENV=test)
172
+ */
173
+ export function warnIfUsingHttpInBrowser(
174
+ url: string | URL,
175
+ warnOnHttp?: boolean
176
+ ): void {
177
+ // Skip warning if explicitly disabled
178
+ if (warnOnHttp === false) return
179
+
180
+ // Skip warning during tests
181
+ const nodeEnv = getNodeEnvSafely()
182
+ if (nodeEnv === `test`) {
183
+ return
184
+ }
185
+
186
+ // Only warn in browser environments
187
+ if (
188
+ !isBrowserEnvironment() ||
189
+ typeof console === `undefined` ||
190
+ typeof console.warn !== `function`
191
+ ) {
192
+ return
193
+ }
194
+
195
+ // Parse the URL (handles both absolute and relative URLs)
196
+ const urlStr = url instanceof URL ? url.toString() : url
197
+ const parsedUrl = resolveUrlMaybe(urlStr)
198
+
199
+ if (!parsedUrl) {
200
+ // Could not parse URL - silently skip
201
+ return
202
+ }
203
+
204
+ // Check if URL uses HTTP protocol
205
+ if (parsedUrl.protocol === `http:`) {
206
+ // Only warn once per origin
207
+ if (!warnedOrigins.has(parsedUrl.origin)) {
208
+ warnedOrigins.add(parsedUrl.origin)
209
+ console.warn(
210
+ `[DurableStream] Using HTTP (not HTTPS) typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1. ` +
211
+ `This can cause slow streams and app freezes with multiple active streams. ` +
212
+ `Use HTTPS for HTTP/2 support. See https://electric-sql.com/r/electric-http2 for more information.`
213
+ )
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Reset the HTTP warning state. Only exported for testing purposes.
220
+ * @internal
221
+ */
222
+ export function _resetHttpWarningForTesting(): void {
223
+ warnedOrigins.clear()
224
+ }