@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 +9 -0
- package/README.md +64 -2
- package/dist/broadcasting/client.d.ts +38 -0
- package/dist/broadcasting/client.d.ts.map +1 -0
- package/dist/broadcasting/client.js +519 -0
- package/dist/broadcasting/client.js.map +1 -0
- package/dist/broadcasting/index.d.ts +43 -0
- package/dist/broadcasting/index.d.ts.map +1 -0
- package/dist/broadcasting/index.js +87 -0
- package/dist/broadcasting/index.js.map +1 -0
- package/dist/broadcasting/server.d.ts +83 -0
- package/dist/broadcasting/server.d.ts.map +1 -0
- package/dist/broadcasting/server.js +213 -0
- package/dist/broadcasting/server.js.map +1 -0
- package/dist/notifications/index.d.ts +14 -0
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +17 -0
- package/dist/notifications/index.js.map +1 -1
- package/dist/ports/redaction.d.ts +1 -1
- package/dist/ports/redaction.d.ts.map +1 -1
- package/dist/ports/redaction.js +3 -0
- package/dist/ports/redaction.js.map +1 -1
- package/dist/server/server.d.ts +4 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1 -1
- package/dist/server/server.js.map +1 -1
- package/package.json +13 -1
- package/skills/app-architecture/SKILL.md +26 -0
- package/src/broadcasting/client.ts +714 -0
- package/src/broadcasting/index.ts +173 -0
- package/src/broadcasting/server.ts +352 -0
- package/src/notifications/index.ts +32 -0
- package/src/ports/redaction.ts +3 -0
- package/src/server/server.ts +8 -3
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
|