@beignet/core 0.0.53 → 0.0.54

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @beignet/core
2
2
 
3
+ ## 0.0.54
4
+
5
+ ### Patch Changes
6
+
7
+ - b71b34f: Add typed, authorized browser broadcasting with bounded SSE connections, memory
8
+ and Redis providers, reconnect reconciliation, React Query invalidation,
9
+ initiating-client exclusion, notification delivery, and transactional inbox
10
+ scaffolding. Raw routes now preserve context added by route hooks.
11
+
3
12
  ## 0.0.53
4
13
 
5
14
  ### Patch Changes
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  This package provides Beignet's framework primitives: contracts, server runtime,
12
12
  typed client, use cases, agent capabilities, ports, domain helpers, app errors, config, events,
13
13
  idempotency, locks, outbox, mail, notifications, payments, search, webhooks,
14
- feature flags, error reporting, encryption, schedules, uploads, entitlements, pagination
14
+ feature flags, error reporting, encryption, broadcasting, schedules, uploads, entitlements, pagination
15
15
  helpers, testing helpers, and OpenAPI generation.
16
16
 
17
17
  ## Installation
@@ -50,6 +50,9 @@ name the framework area they depend on.
50
50
  | --- | --- |
51
51
  | `@beignet/core/agent-capabilities` | Typed agent capability definitions, registries, validation, and execution |
52
52
  | `@beignet/core/application` | Use case builder and test helpers |
53
+ | `@beignet/core/broadcasting` | Browser-safe typed channel definitions and transport validation |
54
+ | `@beignet/core/broadcasting/server` | BroadcastPort, authorization bindings, registry, and authenticated origins |
55
+ | `@beignet/core/broadcasting/client` | Multiplexed streaming browser client with reconnect reconciliation |
53
56
  | `@beignet/core/client` | Typed HTTP client |
54
57
  | `@beignet/core/client-only` | Static lint marker for modules intended for client-side imports |
55
58
  | `@beignet/core/config` | Environment config validation |
@@ -1176,7 +1179,7 @@ contracts should stay on explicit paths; catch-all contract patterns such as
1176
1179
  For routes that cannot be contracts at all — third-party callback endpoints
1177
1180
  with externally defined request shapes, signature-verified webhooks,
1178
1181
  streaming endpoints that own body consumption —
1179
- `server.rawRoute({ name, method, path, metadata }).handle(fn)` builds a
1182
+ `server.rawRoute({ name, method, path, metadata, hooks }).handle(fn)` builds a
1180
1183
  handler that still runs the whole pipeline (hooks, context creation,
1181
1184
  instrumentation, framework error mapping) without contract parsing or
1182
1185
  validation. The request body stays unconsumed for the handler, `metadata`
@@ -2743,6 +2746,65 @@ field requiredness, descriptions, and constraints.
2743
2746
  - [`@beignet/nuqs`](https://beignetjs.com/nuqs) - URL query state integration with nuqs
2744
2747
  - [`@beignet/devtools`](https://beignetjs.com/devtools) - Local request, provider, and audit timeline
2745
2748
 
2749
+ ## Broadcasting
2750
+
2751
+ Define browser-safe channels with `defineChannel(name, { params, events })` from
2752
+ `@beignet/core/broadcasting`. Parameters parse to flat string records; event
2753
+ schemas produce stable canonical JSON. `BroadcastValidationError` rejects
2754
+ invalid data and non-idempotent transforms.
2755
+
2756
+ On the server, import `BroadcastPort` and `createBroadcasting<AppContext>()`
2757
+ from `@beignet/core/broadcasting/server`. Its `defineChannelBinding` requires an
2758
+ explicit authorization callback for every channel, and `defineChannelRegistry`
2759
+ rejects duplicate names. Use memory or Redis providers and expose the registry
2760
+ with `createBroadcastRoute` from `@beignet/web` or `@beignet/next`.
2761
+
2762
+ ```ts
2763
+ import { defineChannel } from "@beignet/core/broadcasting";
2764
+ import { z } from "zod";
2765
+
2766
+ export const changes = defineChannel("issues.changes", {
2767
+ params: z.object({ workspaceId: z.string() }),
2768
+ events: { changed: z.object({ issueId: z.string() }) },
2769
+ });
2770
+ ```
2771
+
2772
+ `broadcast.publish(changes, { params, event: "changed", data })` resolves on
2773
+ provider acceptance. `broadcast.subscribe(changes, { params, onEvent,
2774
+ onDisconnect })` returns `{ ready, unsubscribe }`; await readiness and cleanup.
2775
+ Publish after commit, or record a publication job in the transaction's outbox
2776
+ when the attempt must be retryable. Browser delivery is ephemeral.
2777
+
2778
+ `createBroadcastClient({ url, headers?, credentials?, fetch? })` from
2779
+ `@beignet/core/broadcasting/client` shares one streaming Fetch connection.
2780
+ Subscribe with `{ params, onEvent, onSync, onError?, onStatusChange? }` and use
2781
+ `onSync` to refetch after initial readiness and recovery. Each subscription
2782
+ exposes `unsubscribe()` and `getStatus()`. The client also exposes `getStatus()`,
2783
+ `resume()`, `close()`, and `getRequestHeaders()` for opt-in origin exclusion.
2784
+ Create a fresh client on user/workspace changes. Callbacks that already started
2785
+ remain application-owned work after unsubscribe.
2786
+
2787
+ `resolveBroadcastOrigin({ headers, principalId, tenantId, namespace })` binds
2788
+ the optional client header to authenticated server identity. Forward a captured
2789
+ origin through a job as `broadcastOrigin`, validate with `broadcastOriginSchema`
2790
+ when using Standard Schema directly, and pass it as `excludeOrigin` to publish.
2791
+ Use the same resolver on the stream endpoint. Missing, malformed, or anonymous
2792
+ origins disable exclusion. Default redaction hides origin headers and fields.
2793
+
2794
+ `defineBroadcastNotificationChannel({ channel, render })` from
2795
+ `@beignet/core/notifications` publishes rendered notifications through the port;
2796
+ return `undefined` to skip. Existing preferences and per-channel retries apply.
2797
+ A sent result means provider acceptance. Persistent inbox writes and their
2798
+ publication jobs must share an app-owned transaction; independent notification
2799
+ channels have no execution ordering guarantee.
2800
+
2801
+ The browser protocol has no replay IDs. Limits are 20 subscriptions, an 8 KiB
2802
+ encoded request, 64 KiB event data, 1 MiB buffers, 25-second heartbeats,
2803
+ 10-second readiness waits, and streams renewed within 60 seconds. Terminal 4xx
2804
+ responses block until explicit resume; 429/5xx retry with jitter and respect
2805
+ `Retry-After`. Retry deadlines survive subscription changes. See the
2806
+ [broadcasting guide](https://beignetjs.com/broadcasting).
2807
+
2746
2808
  ## License
2747
2809
 
2748
2810
  MIT
@@ -0,0 +1,38 @@
1
+ import { type ChannelDefinition, type InferChannelEvent, type InferChannelParams } from "./index.js";
2
+ export type BroadcastClientStatus = "connecting" | "connected" | "reconnecting" | "blocked" | "closed";
3
+ /** Safe connection/protocol failure; application callback errors are reported separately. */
4
+ export declare class BroadcastClientError extends Error {
5
+ readonly retryable: boolean;
6
+ readonly status?: number | undefined;
7
+ constructor(message: string, retryable: boolean, status?: number | undefined);
8
+ }
9
+ export interface BroadcastClientSubscription {
10
+ unsubscribe(): void;
11
+ getStatus(): BroadcastClientStatus;
12
+ }
13
+ export interface BroadcastClient {
14
+ subscribe<C extends ChannelDefinition>(channel: C, options: {
15
+ params: InferChannelParams<C>;
16
+ onEvent: (event: InferChannelEvent<C>) => void | Promise<void>;
17
+ /** Refetch authoritative state after initial readiness and every recovered connection. */
18
+ onSync: () => void | Promise<void>;
19
+ onError?: (error: unknown) => void;
20
+ onStatusChange?: (status: BroadcastClientStatus) => void;
21
+ }): BroadcastClientSubscription;
22
+ /** Merge these optional headers into the app's typed HTTP client to opt into exclusion. */
23
+ getRequestHeaders(): Record<string, string>;
24
+ getStatus(): BroadcastClientStatus;
25
+ /** Explicit app action after credentials/access change. Renewals never unblock denied subscriptions. */
26
+ resume(): void;
27
+ close(): void;
28
+ }
29
+ export interface BroadcastClientOptions {
30
+ url: string;
31
+ /** Resolved afresh for every request. Never place credentials in the URL. */
32
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
33
+ credentials?: RequestCredentials;
34
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
35
+ }
36
+ /** One multiplexed streaming Fetch connection per instance. Create an instance per app auth session. */
37
+ export declare function createBroadcastClient(options: BroadcastClientOptions): BroadcastClient;
38
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/broadcasting/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EAGxB,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,qBAAqB,GAC7B,YAAY,GACZ,WAAW,GACX,cAAc,GACd,SAAS,GACT,QAAQ,CAAC;AAEb,6FAA6F;AAC7F,qBAAa,oBAAqB,SAAQ,KAAK;IAG3C,QAAQ,CAAC,SAAS,EAAE,OAAO;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;gBAFxB,OAAO,EAAE,MAAM,EACN,SAAS,EAAE,OAAO,EAClB,MAAM,CAAC,EAAE,MAAM,YAAA;CAK3B;AAED,MAAM,WAAW,2BAA2B;IAC1C,WAAW,IAAI,IAAI,CAAC;IACpB,SAAS,IAAI,qBAAqB,CAAC;CACpC;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,CAAC,SAAS,iBAAiB,EACnC,OAAO,EAAE,CAAC,EACV,OAAO,EAAE;QACP,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;QAC9B,OAAO,EAAE,CAAC,KAAK,EAAE,iBAAiB,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC/D,0FAA0F;QAC1F,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;QACnC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;KAC1D,GACA,2BAA2B,CAAC;IAC/B,2FAA2F;IAC3F,iBAAiB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,SAAS,IAAI,qBAAqB,CAAC;IACnC,wGAAwG;IACxG,MAAM,IAAI,IAAI,CAAC;IACf,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,6EAA6E;IAC7E,OAAO,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC;IACnE,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC7E;AAyDD,wGAAwG;AACxG,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CAglBjB"}
@@ -0,0 +1,519 @@
1
+ import { BroadcastValidationError, broadcastLimits, channelKey, parseChannelEvent, parseChannelParams, } from "./index.js";
2
+ /** Safe connection/protocol failure; application callback errors are reported separately. */
3
+ export class BroadcastClientError extends Error {
4
+ retryable;
5
+ status;
6
+ constructor(message, retryable, status) {
7
+ super(message);
8
+ this.retryable = retryable;
9
+ this.status = status;
10
+ this.name = "BroadcastClientError";
11
+ }
12
+ }
13
+ function retryDelay(failures, minimum = 0) {
14
+ const ceiling = Math.min(30_000, 1_000 * 2 ** Math.min(failures, 5));
15
+ return Math.max(minimum, Math.round(ceiling * (0.5 + Math.random() * 0.5)));
16
+ }
17
+ function responseRetryDelay(response) {
18
+ const value = response.headers.get("retry-after");
19
+ if (!value)
20
+ return 0;
21
+ const seconds = Number(value);
22
+ const delay = Number.isFinite(seconds)
23
+ ? seconds * 1_000
24
+ : Date.parse(value) - Date.now();
25
+ return Number.isFinite(delay) ? Math.max(0, delay) : 0;
26
+ }
27
+ /** Stop waiting even if an application schema/header callback ignores cancellation. */
28
+ async function abortable(work, signal) {
29
+ let cancel = () => { };
30
+ try {
31
+ return await Promise.race([
32
+ work,
33
+ new Promise((_, reject) => {
34
+ cancel = () => reject(new BroadcastClientError("Broadcast operation interrupted", true));
35
+ if (signal.aborted)
36
+ cancel();
37
+ else
38
+ signal.addEventListener("abort", cancel, { once: true });
39
+ }),
40
+ ]);
41
+ }
42
+ finally {
43
+ signal.removeEventListener("abort", cancel);
44
+ }
45
+ }
46
+ /** One multiplexed streaming Fetch connection per instance. Create an instance per app auth session. */
47
+ export function createBroadcastClient(options) {
48
+ const fetcher = options.fetch ?? globalThis.fetch;
49
+ const clientId = crypto.randomUUID();
50
+ const entries = new Map();
51
+ const observers = new Set();
52
+ let nextId = 0;
53
+ let closed = false;
54
+ let generation = 0;
55
+ let controller;
56
+ let retryTimer;
57
+ let restartQueued = false;
58
+ let attempted = false;
59
+ function status(entry) {
60
+ if (closed)
61
+ return "closed";
62
+ if (entry?.blocked)
63
+ return "blocked";
64
+ if (entry?.ready)
65
+ return "connected";
66
+ if (!entry && [...entries.values()].some((value) => value.ready))
67
+ return "connected";
68
+ if (!entry &&
69
+ entries.size > 0 &&
70
+ [...entries.values()].every((value) => value.blocked))
71
+ return "blocked";
72
+ return attempted ? "reconnecting" : "connecting";
73
+ }
74
+ function report(observer, error) {
75
+ if (!observer.active)
76
+ return;
77
+ try {
78
+ observer.onError?.(error);
79
+ }
80
+ catch {
81
+ /* Error reporting cannot change transport state. */
82
+ }
83
+ }
84
+ function invoke(observer, callback) {
85
+ if (!observer.active)
86
+ return;
87
+ try {
88
+ void Promise.resolve(callback()).catch((error) => report(observer, error));
89
+ }
90
+ catch (error) {
91
+ report(observer, error);
92
+ }
93
+ }
94
+ function notify(entry) {
95
+ for (const observer of entry.observers)
96
+ invoke(observer, () => observer.onStatusChange?.(status(entry)));
97
+ }
98
+ function fail(entry, error, minimum = 0) {
99
+ entry.ready = false;
100
+ entry.blocked = !error.retryable;
101
+ if (error.retryable)
102
+ entry.nextAttemptAt = Date.now() + retryDelay(entry.failures++, minimum);
103
+ notify(entry);
104
+ for (const observer of entry.observers)
105
+ report(observer, error);
106
+ }
107
+ function clearRetry() {
108
+ if (retryTimer !== undefined)
109
+ clearTimeout(retryTimer);
110
+ retryTimer = undefined;
111
+ }
112
+ function scheduleRetry() {
113
+ clearRetry();
114
+ const waiting = [...entries.values()].filter((entry) => !entry.blocked && !entry.ready && entry.nextAttemptAt > 0);
115
+ if (!waiting.length || closed)
116
+ return;
117
+ const delay = Math.max(0, Math.min(...waiting.map((entry) => entry.nextAttemptAt)) - Date.now());
118
+ // Long Retry-After values retain their deadline across timer chunks and subscription changes.
119
+ retryTimer = setTimeout(() => {
120
+ if (waiting.some((entry) => !entry.blocked && entry.nextAttemptAt <= Date.now()))
121
+ restart();
122
+ else
123
+ scheduleRetry();
124
+ }, Math.min(delay, 2_147_483_647));
125
+ }
126
+ function restart() {
127
+ if (closed)
128
+ return;
129
+ generation++;
130
+ controller?.abort();
131
+ controller = undefined;
132
+ clearRetry();
133
+ for (const entry of entries.values()) {
134
+ entry.ready = false;
135
+ notify(entry);
136
+ }
137
+ if (restartQueued)
138
+ return;
139
+ restartQueued = true;
140
+ queueMicrotask(() => {
141
+ restartQueued = false;
142
+ if (!closed)
143
+ void connect(generation);
144
+ });
145
+ }
146
+ async function connect(current) {
147
+ const included = [...entries.values()].filter((entry) => !entry.blocked && entry.nextAttemptAt <= Date.now());
148
+ if (!included.length) {
149
+ scheduleRetry();
150
+ return;
151
+ }
152
+ for (const entry of included)
153
+ entry.nextAttemptAt = 0;
154
+ // Joining channels must not cancel deadlines for subscriptions still waiting to retry.
155
+ scheduleRetry();
156
+ attempted = true;
157
+ const abort = new AbortController();
158
+ controller = abort;
159
+ const live = () => !closed && current === generation && !abort.signal.aborted;
160
+ let reader;
161
+ let timer;
162
+ let lifetime;
163
+ let readiness;
164
+ const arm = (ms) => {
165
+ if (timer !== undefined)
166
+ clearTimeout(timer);
167
+ timer = setTimeout(() => abort.abort(), ms);
168
+ };
169
+ let terminal = false;
170
+ let reading = false;
171
+ try {
172
+ arm(broadcastLimits.readyTimeoutMs);
173
+ const url = new URL(options.url, typeof location === "undefined" ? "http://localhost" : location.href);
174
+ if (url.username ||
175
+ url.password ||
176
+ !["http:", "https:"].includes(url.protocol))
177
+ throw new BroadcastClientError("Invalid broadcast endpoint URL", false);
178
+ url.searchParams.set("subscriptions", JSON.stringify({
179
+ version: 1,
180
+ channels: included.map((entry) => ({
181
+ id: entry.id,
182
+ name: entry.channel.name,
183
+ params: entry.params,
184
+ })),
185
+ }));
186
+ if (new TextEncoder().encode(url.search).byteLength >
187
+ broadcastLimits.requestBytes)
188
+ throw new BroadcastClientError("Broadcast subscriptions exceed the request size limit", false);
189
+ const headers = new Headers(typeof options.headers === "function"
190
+ ? await abortable(Promise.resolve().then(options.headers), abort.signal)
191
+ : options.headers);
192
+ if (!live())
193
+ return;
194
+ headers.set("Accept", "text/event-stream");
195
+ headers.set("X-Beignet-Broadcast-Client", clientId);
196
+ headers.delete("Last-Event-ID");
197
+ const fetching = fetcher(url, {
198
+ method: "GET",
199
+ headers,
200
+ credentials: options.credentials ?? "same-origin",
201
+ cache: "no-store",
202
+ redirect: "error",
203
+ signal: abort.signal,
204
+ });
205
+ void fetching.then((response) => {
206
+ if (!live())
207
+ void response.body?.cancel().catch(() => undefined);
208
+ }, () => undefined);
209
+ const response = await abortable(fetching, abort.signal);
210
+ if (!live())
211
+ return;
212
+ if (!response.ok) {
213
+ terminal = true;
214
+ const retryable = response.status === 429 || response.status >= 500;
215
+ for (const entry of included)
216
+ fail(entry, new BroadcastClientError("Broadcast connection was rejected", retryable, response.status), responseRetryDelay(response));
217
+ void response.body?.cancel().catch(() => undefined);
218
+ return;
219
+ }
220
+ if (response.headers.get("content-type")?.split(";", 1)[0]?.trim() !==
221
+ "text/event-stream" ||
222
+ !response.body)
223
+ throw new BroadcastClientError("Invalid broadcast response", false);
224
+ reader = response.body.getReader();
225
+ reading = true;
226
+ arm(broadcastLimits.heartbeatMs * 2 + 5_000);
227
+ lifetime = setTimeout(() => abort.abort(), broadcastLimits.maxLifetimeMs);
228
+ readiness = setTimeout(() => {
229
+ for (const entry of included)
230
+ if (!entry.ready &&
231
+ !entry.blocked &&
232
+ entry.nextAttemptAt <= Date.now())
233
+ fail(entry, new BroadcastClientError("Broadcast readiness timed out", true));
234
+ restart();
235
+ }, broadcastLimits.readyTimeoutMs);
236
+ const byId = new Map(included.map((entry) => [entry.id, entry]));
237
+ const decoder = new TextDecoder("utf-8", { fatal: true });
238
+ let buffer = "";
239
+ let data = [];
240
+ let eventName = "";
241
+ let frameBytes = 0;
242
+ async function line(value) {
243
+ if (!value) {
244
+ if (data.length) {
245
+ if (eventName !== "broadcast")
246
+ throw new BroadcastClientError("Unknown broadcast frame", false);
247
+ const frame = JSON.parse(data.join("\n"));
248
+ if (!frame || typeof frame !== "object")
249
+ throw new BroadcastClientError("Invalid broadcast frame", false);
250
+ const message = frame;
251
+ const entry = typeof message.id === "string" ? byId.get(message.id) : undefined;
252
+ if (!entry || typeof message.type !== "string")
253
+ throw new BroadcastClientError("Invalid broadcast subscription frame", false);
254
+ if (entry.blocked || entry.nextAttemptAt > Date.now()) {
255
+ data = [];
256
+ eventName = "";
257
+ frameBytes = 0;
258
+ return;
259
+ }
260
+ if (message.type === "ready") {
261
+ if (entry.ready)
262
+ throw new BroadcastClientError("Duplicate broadcast readiness", false);
263
+ entry.ready = true;
264
+ entry.failures = 0;
265
+ entry.nextAttemptAt = 0;
266
+ notify(entry);
267
+ for (const observer of entry.observers)
268
+ invoke(observer, observer.onSync);
269
+ }
270
+ else if (message.type === "event") {
271
+ if (!entry.ready || typeof message.event !== "string")
272
+ throw new BroadcastClientError("Event before broadcast readiness", false);
273
+ const event = await abortable(parseChannelEvent(entry.channel, {
274
+ event: message.event,
275
+ data: message.data,
276
+ }), abort.signal);
277
+ if (live())
278
+ for (const observer of entry.observers)
279
+ invoke(observer, () => observer.onEvent(event));
280
+ }
281
+ else if (message.type === "rejected" ||
282
+ message.type === "unavailable") {
283
+ if (typeof message.status !== "number" ||
284
+ !Number.isInteger(message.status) ||
285
+ message.status < 400 ||
286
+ message.status > 599)
287
+ throw new BroadcastClientError("Invalid broadcast failure", false);
288
+ const retryable = message.type === "unavailable";
289
+ if (retryable !== (message.status === 429 || message.status >= 500))
290
+ throw new BroadcastClientError("Invalid broadcast failure classification", false);
291
+ const retryAfter = message.retryAfterMs;
292
+ if (retryAfter !== undefined &&
293
+ (typeof retryAfter !== "number" ||
294
+ !Number.isFinite(retryAfter) ||
295
+ retryAfter < 0))
296
+ throw new BroadcastClientError("Invalid broadcast retry delay", false);
297
+ fail(entry, new BroadcastClientError("Broadcast subscription is unavailable", retryable, message.status), typeof retryAfter === "number" ? retryAfter : 0);
298
+ scheduleRetry();
299
+ }
300
+ else
301
+ throw new BroadcastClientError("Unknown broadcast control frame", false);
302
+ if (included.every((entry) => entry.ready ||
303
+ entry.blocked ||
304
+ entry.nextAttemptAt > Date.now())) {
305
+ if (readiness !== undefined)
306
+ clearTimeout(readiness);
307
+ readiness = undefined;
308
+ }
309
+ if (included.every((entry) => entry.blocked || entry.nextAttemptAt > Date.now())) {
310
+ terminal = true;
311
+ abort.abort();
312
+ }
313
+ }
314
+ data = [];
315
+ eventName = "";
316
+ frameBytes = 0;
317
+ return;
318
+ }
319
+ frameBytes += new TextEncoder().encode(value).byteLength;
320
+ if (frameBytes >
321
+ broadcastLimits.payloadBytes + broadcastLimits.requestBytes)
322
+ throw new BroadcastClientError("Broadcast frame exceeds the size limit", false);
323
+ if (value.startsWith(":"))
324
+ return;
325
+ const colon = value.indexOf(":");
326
+ const field = colon < 0 ? value : value.slice(0, colon);
327
+ const content = colon < 0 ? "" : value.slice(colon + 1).replace(/^ /, "");
328
+ if (field === "data")
329
+ data.push(content);
330
+ else if (field === "event")
331
+ eventName = content;
332
+ else if (field === "id" || field === "retry")
333
+ throw new BroadcastClientError("Broadcast replay fields are not supported", false);
334
+ }
335
+ while (live()) {
336
+ const chunk = await abortable(reader.read(), abort.signal);
337
+ if (!live() || chunk.done)
338
+ break;
339
+ arm(broadcastLimits.heartbeatMs * 2 + 5_000);
340
+ if (chunk.value.byteLength > broadcastLimits.bufferedBytes)
341
+ throw new BroadcastClientError("Broadcast stream exceeds the buffer limit", false);
342
+ try {
343
+ buffer += decoder.decode(chunk.value, { stream: true });
344
+ }
345
+ catch {
346
+ throw new BroadcastClientError("Invalid broadcast text encoding", false);
347
+ }
348
+ if (new TextEncoder().encode(buffer).byteLength >
349
+ broadcastLimits.bufferedBytes)
350
+ throw new BroadcastClientError("Broadcast stream exceeds the buffer limit", false);
351
+ for (;;) {
352
+ const match = /\r\n|\r|\n/.exec(buffer);
353
+ if (!match)
354
+ break;
355
+ if (match[0] === "\r" && match.index === buffer.length - 1)
356
+ break;
357
+ const value = buffer.slice(0, match.index);
358
+ buffer = buffer.slice(match.index + match[0].length);
359
+ await line(value);
360
+ if (!live())
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ catch (error) {
366
+ if (current === generation &&
367
+ !closed &&
368
+ error instanceof BroadcastClientError &&
369
+ !error.retryable) {
370
+ terminal = true;
371
+ for (const entry of included)
372
+ fail(entry, error);
373
+ }
374
+ else if (current === generation &&
375
+ !closed &&
376
+ (error instanceof BroadcastValidationError ||
377
+ (reading && !abort.signal.aborted && error instanceof SyntaxError))) {
378
+ terminal = true;
379
+ for (const entry of included)
380
+ fail(entry, new BroadcastClientError("Invalid broadcast payload", false));
381
+ }
382
+ }
383
+ finally {
384
+ if (timer !== undefined)
385
+ clearTimeout(timer);
386
+ if (lifetime !== undefined)
387
+ clearTimeout(lifetime);
388
+ if (readiness !== undefined)
389
+ clearTimeout(readiness);
390
+ abort.abort();
391
+ void reader?.cancel().catch(() => undefined);
392
+ if (current === generation && !closed) {
393
+ controller = undefined;
394
+ if (!terminal)
395
+ for (const entry of included) {
396
+ if (!entry.blocked && entry.nextAttemptAt <= Date.now())
397
+ fail(entry, new BroadcastClientError("Broadcast connection interrupted", true));
398
+ }
399
+ scheduleRetry();
400
+ }
401
+ }
402
+ }
403
+ function wake() {
404
+ if (typeof document !== "undefined" &&
405
+ document.visibilityState === "hidden")
406
+ return;
407
+ restart();
408
+ }
409
+ if (typeof window !== "undefined")
410
+ window.addEventListener("online", wake);
411
+ if (typeof document !== "undefined")
412
+ document.addEventListener("visibilitychange", wake);
413
+ return {
414
+ subscribe(channel, subscriptionOptions) {
415
+ if (closed)
416
+ throw new BroadcastClientError("Broadcast client is closed", false);
417
+ let entry;
418
+ let invalid = false;
419
+ const observer = {
420
+ ...subscriptionOptions,
421
+ active: true,
422
+ validation: new AbortController(),
423
+ onEvent: (event) => subscriptionOptions.onEvent(event),
424
+ };
425
+ observers.add(observer);
426
+ const validationTimeout = setTimeout(() => observer.validation.abort(), broadcastLimits.readyTimeoutMs);
427
+ void abortable(parseChannelParams(channel, subscriptionOptions.params), observer.validation.signal)
428
+ .then((params) => {
429
+ if (!observer.active || closed)
430
+ return;
431
+ const key = channelKey(channel.name, params);
432
+ entry = entries.get(key);
433
+ if (!entry) {
434
+ if (entries.size >= broadcastLimits.subscriptions)
435
+ throw new BroadcastClientError("Broadcast client supports at most 20 distinct subscriptions", false);
436
+ entry = {
437
+ id: String(++nextId),
438
+ key,
439
+ channel,
440
+ params,
441
+ observers: new Set(),
442
+ blocked: false,
443
+ ready: false,
444
+ failures: 0,
445
+ nextAttemptAt: 0,
446
+ };
447
+ entries.set(key, entry);
448
+ entry.observers.add(observer);
449
+ restart();
450
+ }
451
+ else {
452
+ if (entry.channel !== channel)
453
+ throw new BroadcastClientError(`Conflicting definitions for channel ${channel.name}`, false);
454
+ entry.observers.add(observer);
455
+ invoke(observer, () => observer.onStatusChange?.(status(entry)));
456
+ if (entry.ready)
457
+ invoke(observer, observer.onSync);
458
+ }
459
+ })
460
+ .catch((error) => {
461
+ invalid = true;
462
+ report(observer, error);
463
+ invoke(observer, () => observer.onStatusChange?.("blocked"));
464
+ })
465
+ .finally(() => clearTimeout(validationTimeout));
466
+ return {
467
+ getStatus: () => !observer.active || closed
468
+ ? "closed"
469
+ : invalid
470
+ ? "blocked"
471
+ : status(entry),
472
+ unsubscribe() {
473
+ if (!observer.active)
474
+ return;
475
+ observer.active = false;
476
+ observer.validation.abort();
477
+ observers.delete(observer);
478
+ entry?.observers.delete(observer);
479
+ if (entry &&
480
+ !entry.observers.size &&
481
+ entries.get(entry.key) === entry) {
482
+ entries.delete(entry.key);
483
+ restart();
484
+ }
485
+ },
486
+ };
487
+ },
488
+ getRequestHeaders: () => ({ "X-Beignet-Broadcast-Client": clientId }),
489
+ getStatus: () => status(),
490
+ resume() {
491
+ for (const entry of entries.values()) {
492
+ entry.blocked = false;
493
+ entry.nextAttemptAt = 0;
494
+ entry.failures = 0;
495
+ }
496
+ restart();
497
+ },
498
+ close() {
499
+ if (closed)
500
+ return;
501
+ closed = true;
502
+ generation++;
503
+ controller?.abort();
504
+ clearRetry();
505
+ if (typeof window !== "undefined")
506
+ window.removeEventListener("online", wake);
507
+ if (typeof document !== "undefined")
508
+ document.removeEventListener("visibilitychange", wake);
509
+ for (const observer of observers) {
510
+ invoke(observer, () => observer.onStatusChange?.("closed"));
511
+ observer.active = false;
512
+ observer.validation.abort();
513
+ }
514
+ observers.clear();
515
+ entries.clear();
516
+ },
517
+ };
518
+ }
519
+ //# sourceMappingURL=client.js.map