@nebulr-group/bridge-auth-core 0.1.2 → 0.4.0-beta.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/dist/billing/bridge-subscription.d.ts +71 -0
- package/dist/billing/bridge-subscription.d.ts.map +1 -0
- package/dist/billing/bridge-subscription.js +209 -0
- package/dist/billing/bridge-subscription.js.map +1 -0
- package/dist/billing/entitlements-store.d.ts +61 -0
- package/dist/billing/entitlements-store.d.ts.map +1 -0
- package/dist/billing/entitlements-store.js +127 -0
- package/dist/billing/entitlements-store.js.map +1 -0
- package/dist/billing/fetch-billing-state.d.ts +9 -0
- package/dist/billing/fetch-billing-state.d.ts.map +1 -0
- package/dist/billing/fetch-billing-state.js +22 -0
- package/dist/billing/fetch-billing-state.js.map +1 -0
- package/dist/billing/lock-signal.d.ts +6 -0
- package/dist/billing/lock-signal.d.ts.map +1 -0
- package/dist/billing/lock-signal.js +8 -0
- package/dist/billing/lock-signal.js.map +1 -0
- package/dist/billing/quota-store.d.ts +86 -0
- package/dist/billing/quota-store.d.ts.map +1 -0
- package/dist/billing/quota-store.js +180 -0
- package/dist/billing/quota-store.js.map +1 -0
- package/dist/billing/types.d.ts +64 -0
- package/dist/billing/types.d.ts.map +1 -0
- package/dist/billing/types.js +49 -0
- package/dist/billing/types.js.map +1 -0
- package/dist/billing/use-bridge.d.ts +131 -0
- package/dist/billing/use-bridge.d.ts.map +1 -0
- package/dist/billing/use-bridge.js +181 -0
- package/dist/billing/use-bridge.js.map +1 -0
- package/dist/bridge-auth.d.ts +39 -1
- package/dist/bridge-auth.d.ts.map +1 -1
- package/dist/bridge-auth.js +89 -6
- package/dist/bridge-auth.js.map +1 -1
- package/dist/direct-auth.d.ts +1 -0
- package/dist/direct-auth.d.ts.map +1 -1
- package/dist/direct-auth.js +7 -0
- package/dist/direct-auth.js.map +1 -1
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -1
- package/dist/flags/attribute-providers.d.ts +229 -0
- package/dist/flags/attribute-providers.d.ts.map +1 -0
- package/dist/flags/attribute-providers.js +375 -0
- package/dist/flags/attribute-providers.js.map +1 -0
- package/dist/flags/dev-attribute-provider.d.ts +67 -0
- package/dist/flags/dev-attribute-provider.d.ts.map +1 -0
- package/dist/flags/dev-attribute-provider.js +200 -0
- package/dist/flags/dev-attribute-provider.js.map +1 -0
- package/dist/flags/evaluator.d.ts +58 -0
- package/dist/flags/evaluator.d.ts.map +1 -0
- package/dist/flags/evaluator.js +160 -0
- package/dist/flags/evaluator.js.map +1 -0
- package/dist/flags/flag.d.ts +199 -0
- package/dist/flags/flag.d.ts.map +1 -0
- package/dist/flags/flag.js +337 -0
- package/dist/flags/flag.js.map +1 -0
- package/dist/flags/identity.d.ts +77 -0
- package/dist/flags/identity.d.ts.map +1 -0
- package/dist/flags/identity.js +134 -0
- package/dist/flags/identity.js.map +1 -0
- package/dist/flags/index.d.ts +20 -0
- package/dist/flags/index.d.ts.map +1 -0
- package/dist/flags/index.js +21 -0
- package/dist/flags/index.js.map +1 -0
- package/dist/flags/operators.d.ts +63 -0
- package/dist/flags/operators.d.ts.map +1 -0
- package/dist/flags/operators.js +299 -0
- package/dist/flags/operators.js.map +1 -0
- package/dist/flags/propagation.d.ts +16 -0
- package/dist/flags/propagation.d.ts.map +1 -0
- package/dist/flags/propagation.js +107 -0
- package/dist/flags/propagation.js.map +1 -0
- package/dist/flags/realtime.d.ts +353 -0
- package/dist/flags/realtime.d.ts.map +1 -0
- package/dist/flags/realtime.js +779 -0
- package/dist/flags/realtime.js.map +1 -0
- package/dist/flags/runtime-mode.d.ts +32 -0
- package/dist/flags/runtime-mode.d.ts.map +1 -0
- package/dist/flags/runtime-mode.js +84 -0
- package/dist/flags/runtime-mode.js.map +1 -0
- package/dist/flags/telemetry.d.ts +79 -0
- package/dist/flags/telemetry.d.ts.map +1 -0
- package/dist/flags/telemetry.js +286 -0
- package/dist/flags/telemetry.js.map +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +44 -12
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/management-types.d.ts +38 -0
- package/dist/management-types.d.ts.map +1 -1
- package/dist/route-guard.d.ts.map +1 -1
- package/dist/route-guard.js +15 -5
- package/dist/route-guard.js.map +1 -1
- package/dist/token-manager.d.ts +3 -1
- package/dist/token-manager.d.ts.map +1 -1
- package/dist/token-manager.js +10 -6
- package/dist/token-manager.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/usage/index.d.ts +3 -0
- package/dist/usage/index.d.ts.map +1 -0
- package/dist/usage/index.js +4 -0
- package/dist/usage/index.js.map +1 -0
- package/dist/usage/storage/durable-storage.d.ts +47 -0
- package/dist/usage/storage/durable-storage.d.ts.map +1 -0
- package/dist/usage/storage/durable-storage.js +27 -0
- package/dist/usage/storage/durable-storage.js.map +1 -0
- package/dist/usage/storage/index.d.ts +7 -0
- package/dist/usage/storage/index.d.ts.map +1 -0
- package/dist/usage/storage/index.js +28 -0
- package/dist/usage/storage/index.js.map +1 -0
- package/dist/usage/storage/indexeddb-storage.d.ts +18 -0
- package/dist/usage/storage/indexeddb-storage.d.ts.map +1 -0
- package/dist/usage/storage/indexeddb-storage.js +159 -0
- package/dist/usage/storage/indexeddb-storage.js.map +1 -0
- package/dist/usage/storage/node-fs-storage.d.ts +29 -0
- package/dist/usage/storage/node-fs-storage.d.ts.map +1 -0
- package/dist/usage/storage/node-fs-storage.js +180 -0
- package/dist/usage/storage/node-fs-storage.js.map +1 -0
- package/dist/usage/storage/noop-storage.d.ts +17 -0
- package/dist/usage/storage/noop-storage.d.ts.map +1 -0
- package/dist/usage/storage/noop-storage.js +67 -0
- package/dist/usage/storage/noop-storage.js.map +1 -0
- package/dist/usage/usage-reporter.d.ts +64 -0
- package/dist/usage/usage-reporter.d.ts.map +1 -0
- package/dist/usage/usage-reporter.js +234 -0
- package/dist/usage/usage-reporter.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// SDK realtime client (TBP-150).
|
|
2
|
+
//
|
|
3
|
+
// Auto-discovers the workspace's pub/sub protocol from `GET /realtime/config`
|
|
4
|
+
// (TBP-147), authorizes via `POST /realtime/authorize` (TBP-151 — Centrifugo
|
|
5
|
+
// path only; AppSync uses a Lambda authorizer server-side), and connects to
|
|
6
|
+
// receive live flag updates + per-user identity changes.
|
|
7
|
+
//
|
|
8
|
+
// Two protocols, one shape:
|
|
9
|
+
// - `centrifugo`: WebSocket to a Centrifugo server using a signed connect token.
|
|
10
|
+
// - `appsync`: WebSocket to AWS AppSync Events (TBP-148). Client carries
|
|
11
|
+
// its Bridge JWT directly in the `header-…` subprotocol; the
|
|
12
|
+
// Lambda authorizer makes per-channel decisions server-side
|
|
13
|
+
// (no `/realtime/authorize` round-trip).
|
|
14
|
+
//
|
|
15
|
+
// `noop` server-side → realtime is disabled; the SDK falls back to periodic
|
|
16
|
+
// poll or simply doesn't get live updates.
|
|
17
|
+
//
|
|
18
|
+
// Messages received on the workspace channel update the BridgeFlags cache
|
|
19
|
+
// (`upsert` / `remove`). Per-user channel messages are handled via callbacks
|
|
20
|
+
// the framework SDK supplies (token refresh, attribute changes — see TBP-90).
|
|
21
|
+
export class RealtimeClient {
|
|
22
|
+
cfg;
|
|
23
|
+
ws;
|
|
24
|
+
state = 'idle';
|
|
25
|
+
reconnectDelayMs;
|
|
26
|
+
reconnectTimer;
|
|
27
|
+
bridge;
|
|
28
|
+
onUserStateHook;
|
|
29
|
+
onSubscriptionPlanChangedHook;
|
|
30
|
+
onBillingLifecycleHook;
|
|
31
|
+
onQuotaUpdatedHook;
|
|
32
|
+
onEntitlementsChangedHook;
|
|
33
|
+
// Phase 3 (TBP-287/314) — fans `session.snapshot` out to whichever slices
|
|
34
|
+
// the consumer has wired (app.branding, tenant.subscription, etc.).
|
|
35
|
+
onSnapshotHook;
|
|
36
|
+
onOpenHook;
|
|
37
|
+
onCloseHook;
|
|
38
|
+
stopped = false;
|
|
39
|
+
constructor(cfg) {
|
|
40
|
+
const defaultWs = ((url, protocols) =>
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
new globalThis.WebSocket(url, protocols));
|
|
43
|
+
this.cfg = {
|
|
44
|
+
apiBaseUrl: cfg.apiBaseUrl.replace(/\/+$/, ''),
|
|
45
|
+
apiKey: cfg.apiKey,
|
|
46
|
+
appId: cfg.appId,
|
|
47
|
+
workspaceId: cfg.workspaceId,
|
|
48
|
+
userId: cfg.userId,
|
|
49
|
+
enabled: cfg.enabled !== false,
|
|
50
|
+
reconnectBaseMs: cfg.reconnectBaseMs ?? 1000,
|
|
51
|
+
reconnectMaxMs: cfg.reconnectMaxMs ?? 30_000,
|
|
52
|
+
websocketFactory: cfg.websocketFactory ?? defaultWs,
|
|
53
|
+
fetchFn: cfg.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined),
|
|
54
|
+
getAuthToken: cfg.getAuthToken,
|
|
55
|
+
};
|
|
56
|
+
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
57
|
+
}
|
|
58
|
+
/** Attach to a BridgeFlags instance — flag updates auto-apply to its cache. */
|
|
59
|
+
attach(bridge) {
|
|
60
|
+
this.bridge = bridge;
|
|
61
|
+
}
|
|
62
|
+
/** Register a hook for per-user channel messages. */
|
|
63
|
+
setOnUserState(hook) {
|
|
64
|
+
this.onUserStateHook = hook;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Billing 2.0 US-3 — register a hook for canonical subscription plan-change
|
|
68
|
+
* events. The billing reactive surface (`BridgeSubscription.attach(rt)`)
|
|
69
|
+
* wires this up; framework SDKs typically don't call it directly.
|
|
70
|
+
*/
|
|
71
|
+
setOnSubscriptionPlanChanged(hook) {
|
|
72
|
+
this.onSubscriptionPlanChangedHook = hook;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Billing 2.0 US-5+ — register a hook for all canonical lifecycle events
|
|
76
|
+
* (payment.*, subscription.*, dunning.*, entitlements.*). `BridgeSubscription.attach(rt)`
|
|
77
|
+
* wires this; user-level event handlers can also register here via
|
|
78
|
+
* `useBridge().handle({ "payment.failed": ... })`.
|
|
79
|
+
*/
|
|
80
|
+
setOnBillingLifecycle(hook) {
|
|
81
|
+
this.onBillingLifecycleHook = hook;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Billing 2.0 US-11 — register a hook for `quota.updated` payloads on the
|
|
85
|
+
* workspace channel. `useBridge().quota(metric)` consumers wire this up
|
|
86
|
+
* so live counter UI reflects server-side ingest without polling.
|
|
87
|
+
*/
|
|
88
|
+
setOnQuotaUpdated(hook) {
|
|
89
|
+
this.onQuotaUpdatedHook = hook;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Billing 2.0 US-12 — register a hook for `entitlements.changed` payloads
|
|
93
|
+
* on the workspace channel that carry the full entitlements map.
|
|
94
|
+
* `useBridge().entitlements.can(...)` consumers wire this up so the cache
|
|
95
|
+
* replaces wholesale on every diff.
|
|
96
|
+
*
|
|
97
|
+
* Note: the legacy `BillingLifecycleMessage` `entitlements.changed` kind
|
|
98
|
+
* (signal-only, no payload) keeps firing through `setOnBillingLifecycle`
|
|
99
|
+
* — this hook ONLY fires when the wire payload includes the `entitlements`
|
|
100
|
+
* field. Allows both old and new consumers to coexist during rollout.
|
|
101
|
+
*/
|
|
102
|
+
setOnEntitlementsChanged(hook) {
|
|
103
|
+
this.onEntitlementsChangedHook = hook;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Phase 3 (TBP-287/314) — register a hook for `session.snapshot`. The server
|
|
107
|
+
* publishes one on every successful per-user channel subscribe (initial
|
|
108
|
+
* connect AND reconnect). Framework SDKs use this to pre-populate the
|
|
109
|
+
* `bridge.app.branding` / `bridge.tenant.{subscription,entitlements}` /
|
|
110
|
+
* `bridge.user` slices on first paint, eliminating the per-slice REST
|
|
111
|
+
* hydrate round-trips that the legacy bootstrap path required.
|
|
112
|
+
*
|
|
113
|
+
* Composition is fixed (no consumer config). If a later release promotes
|
|
114
|
+
* another slice into the snapshot, that slice's `.load()` becomes a no-op
|
|
115
|
+
* automatically — consumers don't need code changes.
|
|
116
|
+
*/
|
|
117
|
+
setOnSnapshot(hook) {
|
|
118
|
+
this.onSnapshotHook = hook;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Register a hook fired when the WebSocket transitions to `'open'` —
|
|
122
|
+
* fires on initial connect AND on every successful reconnect. Framework
|
|
123
|
+
* SDKs use this to re-fire startup tasks (e.g. cache hydration) that
|
|
124
|
+
* may have been missed during an outage.
|
|
125
|
+
*/
|
|
126
|
+
setOnOpen(hook) {
|
|
127
|
+
this.onOpenHook = hook;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Register a hook fired when the WebSocket transitions to `'closed'` —
|
|
131
|
+
* use for surfacing connection status to consumers (e.g. an "offline"
|
|
132
|
+
* indicator). Fires on intentional close as well; check `getState()`
|
|
133
|
+
* if you need to distinguish.
|
|
134
|
+
*/
|
|
135
|
+
setOnClose(hook) {
|
|
136
|
+
this.onCloseHook = hook;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Re-run the authorize step against the current `getAuthToken()` value and
|
|
140
|
+
* re-open the WebSocket. Used by the framework SDK on every token rotation
|
|
141
|
+
* where the userId is unchanged but the JWT value rotated (post-refresh).
|
|
142
|
+
*
|
|
143
|
+
* Without this, the existing connection keeps riding the OLD token until
|
|
144
|
+
* Centrifugo's own connection-token TTL drops it — a strictly-larger
|
|
145
|
+
* blast-radius window than necessary.
|
|
146
|
+
*
|
|
147
|
+
* Behavior:
|
|
148
|
+
* - Disabled / stopped → no-op.
|
|
149
|
+
* - Mid-`connecting` → no-op; the in-flight authorize() reads the current
|
|
150
|
+
* token by closure, so it will already pick up the new value.
|
|
151
|
+
* - Open → drop the ws ref (so the old socket's onclose treats itself as
|
|
152
|
+
* stale via the identity guard in `openWebSocket`), close it with
|
|
153
|
+
* `1000 / sdk.reauthorize`, reset backoff, then `start()` immediately.
|
|
154
|
+
*/
|
|
155
|
+
async reauthorize() {
|
|
156
|
+
if (!this.cfg.enabled || this.stopped)
|
|
157
|
+
return;
|
|
158
|
+
if (this.state === 'connecting')
|
|
159
|
+
return;
|
|
160
|
+
if (this.reconnectTimer) {
|
|
161
|
+
clearTimeout(this.reconnectTimer);
|
|
162
|
+
this.reconnectTimer = undefined;
|
|
163
|
+
}
|
|
164
|
+
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
165
|
+
if (this.state === 'open' && this.ws) {
|
|
166
|
+
const oldWs = this.ws;
|
|
167
|
+
this.ws = undefined;
|
|
168
|
+
this.state = 'closed';
|
|
169
|
+
try {
|
|
170
|
+
oldWs.close(1000, 'sdk.reauthorize');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// ignore
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await this.start();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Phase 2 (TBP-307) — Set/update the appId after initial start. Framework
|
|
180
|
+
* SDKs call this when the app context first lands (e.g. after the first
|
|
181
|
+
* authorize round-trip exposes the JWT `aid` claim). Triggers a reconnect
|
|
182
|
+
* so the new `app:<appId>` channel is included in the next authorize.
|
|
183
|
+
*/
|
|
184
|
+
setAppId(appId) {
|
|
185
|
+
if (this.cfg.appId === appId)
|
|
186
|
+
return;
|
|
187
|
+
this.cfg.appId = appId;
|
|
188
|
+
if (this.state === 'open' && this.ws) {
|
|
189
|
+
this.ws.close(1000, 'sdk.setAppId');
|
|
190
|
+
// onclose → scheduleReconnect → start() picks up updated channelsToSubscribe()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Phase 2 (TBP-307) — Set/update the workspaceId after initial start. Used
|
|
195
|
+
* by framework SDKs when the tenant context lands or changes (workspace
|
|
196
|
+
* switcher, tenant join/leave). Triggers a reconnect.
|
|
197
|
+
*/
|
|
198
|
+
setWorkspaceId(workspaceId) {
|
|
199
|
+
if (this.cfg.workspaceId === workspaceId)
|
|
200
|
+
return;
|
|
201
|
+
this.cfg.workspaceId = workspaceId;
|
|
202
|
+
if (this.state === 'open' && this.ws) {
|
|
203
|
+
this.ws.close(1000, 'sdk.setWorkspaceId');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Update the userId after initial start — used by the framework SDK to
|
|
208
|
+
* subscribe to the per-user channel when the user logs in post-bootstrap.
|
|
209
|
+
* Triggers a reconnect so the new channel is included in the next authorize.
|
|
210
|
+
*/
|
|
211
|
+
setUserId(userId) {
|
|
212
|
+
if (this.cfg.userId === userId)
|
|
213
|
+
return;
|
|
214
|
+
this.cfg.userId = userId;
|
|
215
|
+
if (this.state === 'open' && this.ws) {
|
|
216
|
+
this.ws.close(1000, 'sdk.setUserId');
|
|
217
|
+
// onclose fires → scheduleReconnect → start() picks up updated channelsToSubscribe()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Begin connecting. Idempotent. */
|
|
221
|
+
async start() {
|
|
222
|
+
if (!this.cfg.enabled || this.stopped)
|
|
223
|
+
return;
|
|
224
|
+
if (this.state !== 'idle' && this.state !== 'closed')
|
|
225
|
+
return;
|
|
226
|
+
this.state = 'connecting';
|
|
227
|
+
try {
|
|
228
|
+
const serverConfig = await this.fetchServerConfig();
|
|
229
|
+
if (serverConfig.kind === 'noop' || !serverConfig.endpoint) {
|
|
230
|
+
this.state = 'closed';
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const channels = this.channelsToSubscribe();
|
|
234
|
+
if (serverConfig.kind === 'appsync') {
|
|
235
|
+
// AppSync uses a Lambda authorizer for per-channel auth — no client-side
|
|
236
|
+
// /realtime/authorize round-trip. The Bridge JWT is carried in the
|
|
237
|
+
// subprotocol negotiation; the Lambda decides allow/deny per channel.
|
|
238
|
+
this.openAppSyncWebSocket(serverConfig.endpoint, channels);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (serverConfig.kind === 'centrifugo') {
|
|
242
|
+
const auth = await this.authorize(channels);
|
|
243
|
+
this.openWebSocket(serverConfig.endpoint, auth);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Unknown protocol — close cleanly so consumers don't get stuck in
|
|
247
|
+
// 'connecting'. New transports must be added explicitly here.
|
|
248
|
+
this.state = 'closed';
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
this.state = 'closed';
|
|
252
|
+
this.scheduleReconnect();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Close the connection. Idempotent. */
|
|
256
|
+
async stop() {
|
|
257
|
+
this.stopped = true;
|
|
258
|
+
if (this.reconnectTimer) {
|
|
259
|
+
clearTimeout(this.reconnectTimer);
|
|
260
|
+
this.reconnectTimer = undefined;
|
|
261
|
+
}
|
|
262
|
+
if (this.ws) {
|
|
263
|
+
try {
|
|
264
|
+
this.ws.close(1000, 'sdk.stop');
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// ignore
|
|
268
|
+
}
|
|
269
|
+
this.ws = undefined;
|
|
270
|
+
}
|
|
271
|
+
this.state = 'closed';
|
|
272
|
+
}
|
|
273
|
+
/** Read connection state. */
|
|
274
|
+
getState() {
|
|
275
|
+
return this.state;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Channels this client subscribes to — the three canonical channels:
|
|
279
|
+
* - `app:<appId>` — flag mutations, app config (app-scoped)
|
|
280
|
+
* - `workspace:<wsId>` — subscription, quota, entitlement (tenant-scoped)
|
|
281
|
+
* - `user:<userId>` — user-state, role/attr change (user-scoped)
|
|
282
|
+
*
|
|
283
|
+
* Each id is optional; the SDK skips the channel if its id isn't configured.
|
|
284
|
+
* The anonymous-only standalone case ends up with just `app:<appId>`.
|
|
285
|
+
*/
|
|
286
|
+
channelsToSubscribe() {
|
|
287
|
+
const out = [];
|
|
288
|
+
if (this.cfg.appId)
|
|
289
|
+
out.push(`app:${this.cfg.appId}`);
|
|
290
|
+
if (this.cfg.workspaceId)
|
|
291
|
+
out.push(`workspace:${this.cfg.workspaceId}`);
|
|
292
|
+
if (this.cfg.userId)
|
|
293
|
+
out.push(`user:${this.cfg.userId}`);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
// ── private ───────────────────────────────────────────────────────────────
|
|
297
|
+
async fetchServerConfig() {
|
|
298
|
+
const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/config`, {
|
|
299
|
+
method: 'GET',
|
|
300
|
+
headers: { 'x-api-key': this.cfg.apiKey },
|
|
301
|
+
});
|
|
302
|
+
if (!res.ok) {
|
|
303
|
+
throw new Error(`realtime config fetch failed: ${res.status}`);
|
|
304
|
+
}
|
|
305
|
+
return (await res.json());
|
|
306
|
+
}
|
|
307
|
+
async authorize(channels) {
|
|
308
|
+
const token = this.cfg.getAuthToken?.() ?? this.cfg.apiKey;
|
|
309
|
+
const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/authorize`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: {
|
|
312
|
+
Authorization: `Bearer ${token}`,
|
|
313
|
+
'Content-Type': 'application/json',
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify({ channels }),
|
|
316
|
+
});
|
|
317
|
+
if (!res.ok) {
|
|
318
|
+
throw new Error(`realtime authorize failed: ${res.status}`);
|
|
319
|
+
}
|
|
320
|
+
return (await res.json());
|
|
321
|
+
}
|
|
322
|
+
openWebSocket(endpoint, auth) {
|
|
323
|
+
const ws = this.cfg.websocketFactory(endpoint);
|
|
324
|
+
this.ws = ws;
|
|
325
|
+
ws.onopen = () => {
|
|
326
|
+
if (this.ws !== ws)
|
|
327
|
+
return;
|
|
328
|
+
this.state = 'open';
|
|
329
|
+
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
330
|
+
// Send connect with the signed token + channels. Centrifugo expects a
|
|
331
|
+
// command frame like `{ "connect": { "token": "..." }, "id": 1 }` and
|
|
332
|
+
// separate subscribe frames per channel. For v1 we send one connect
|
|
333
|
+
// and let the server-side token's `channels` claim handle subscription.
|
|
334
|
+
try {
|
|
335
|
+
ws.send(JSON.stringify({ id: 1, connect: { token: auth.signedToken } }));
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// ignore
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
this.onOpenHook?.();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// hook errors must not break the connection
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
ws.onmessage = (ev) => {
|
|
348
|
+
if (this.ws !== ws)
|
|
349
|
+
return;
|
|
350
|
+
// Centrifugo v5 JSON protocol keepalive: the server periodically sends
|
|
351
|
+
// an empty `{}` frame and expects an empty `{}` reply. Without this
|
|
352
|
+
// echo the server closes the connection on its pong-timeout and the
|
|
353
|
+
// SDK ends up in a perpetual reconnect loop.
|
|
354
|
+
if (typeof ev.data === 'string' && ev.data === '{}') {
|
|
355
|
+
try {
|
|
356
|
+
ws.send('{}');
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// ignore — onclose will pick up a broken socket
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const parsed = parseMessage(ev.data);
|
|
365
|
+
if (parsed)
|
|
366
|
+
this.handleMessage(parsed);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// ignore malformed
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
ws.onclose = () => {
|
|
373
|
+
// Identity guard — if we've already replaced this ws (e.g. via
|
|
374
|
+
// reauthorize() dropping the ref before close), the late-firing onclose
|
|
375
|
+
// is from a stale socket. Don't flap state or fire hooks.
|
|
376
|
+
if (this.ws !== ws)
|
|
377
|
+
return;
|
|
378
|
+
this.state = 'closed';
|
|
379
|
+
try {
|
|
380
|
+
this.onCloseHook?.();
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// hook errors must not block reconnect scheduling
|
|
384
|
+
}
|
|
385
|
+
this.scheduleReconnect();
|
|
386
|
+
};
|
|
387
|
+
ws.onerror = () => {
|
|
388
|
+
// Let onclose handle reconnect — errors are noisy but not actionable.
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* TBP-148 — AppSync Events transport.
|
|
393
|
+
*
|
|
394
|
+
* Wire protocol (AWS public spec for AppSync Events, distinct from the older
|
|
395
|
+
* AppSync GraphQL `graphql-ws` subscriptions):
|
|
396
|
+
* - WebSocket to `wss://<endpoint>/event/realtime` (path appended if the
|
|
397
|
+
* endpoint from /realtime/config doesn't already include it — stage
|
|
398
|
+
* CFN output is the bare host today; the spec test uses the full URL).
|
|
399
|
+
* - Subprotocols: `['aws-appsync-event-ws', 'header-<base64url-json>']`.
|
|
400
|
+
* The `header-…` token carries auth, since browsers can't set arbitrary
|
|
401
|
+
* HTTP headers on a WebSocket.
|
|
402
|
+
* - After open: `{type:'connection_init'}` → server replies
|
|
403
|
+
* `{type:'connection_ack', connectionTimeoutMs}` → then one
|
|
404
|
+
* `{type:'subscribe', id, channel, authorization}` per channel.
|
|
405
|
+
* - Data: `{type:'data', id, event:'<json-string>'}` — `event` is a string
|
|
406
|
+
* (matches `appsync-events.adapter.ts:90` JSON.stringify).
|
|
407
|
+
* - Keepalive: server sends `{type:'ka'}` (silently ignored).
|
|
408
|
+
* - Channel wire format: `<ns>/<id>` (colon-to-slash; mirrors
|
|
409
|
+
* `appsync-events.adapter.ts:87` and `appsync-authorizer.handler.ts:59`).
|
|
410
|
+
*
|
|
411
|
+
* Anonymous flow: `getAuthToken()` returns undefined → Authorization sent as
|
|
412
|
+
* empty string. The Lambda authorizer accepts that only for `app:<appId>`
|
|
413
|
+
* channels whose origin matches the app's allowedOrigins (demo path).
|
|
414
|
+
*/
|
|
415
|
+
openAppSyncWebSocket(endpoint, channels) {
|
|
416
|
+
const { url, httpHost } = normalizeAppSyncEndpoint(endpoint);
|
|
417
|
+
// AWS spec: the auth header's `host` field refers to the HTTP endpoint
|
|
418
|
+
// even when the wss:// call is made against the realtime endpoint. The
|
|
419
|
+
// server-side validation uses this to verify the connection — sending
|
|
420
|
+
// the realtime host instead produces a silent close 1000 right after
|
|
421
|
+
// the WS upgrade succeeds.
|
|
422
|
+
const authHeader = buildAppSyncAuthHeader(this.cfg.getAuthToken?.(), httpHost);
|
|
423
|
+
const headerProtocol = `header-${base64urlEncode(JSON.stringify(authHeader))}`;
|
|
424
|
+
const ws = this.cfg.websocketFactory(url, [APPSYNC_WS_PROTOCOL, headerProtocol]);
|
|
425
|
+
this.ws = ws;
|
|
426
|
+
ws.onopen = () => {
|
|
427
|
+
if (this.ws !== ws)
|
|
428
|
+
return;
|
|
429
|
+
// `state` stays 'connecting' until connection_ack lands — premature
|
|
430
|
+
// transition would let the client miss server-side rejects (auth
|
|
431
|
+
// failure surfaces as a quick close right after open).
|
|
432
|
+
try {
|
|
433
|
+
ws.send(JSON.stringify({ type: 'connection_init' }));
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// onclose will fire on a broken socket; no further work here.
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
ws.onmessage = (ev) => {
|
|
440
|
+
if (this.ws !== ws)
|
|
441
|
+
return;
|
|
442
|
+
let frame;
|
|
443
|
+
try {
|
|
444
|
+
frame = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return; // malformed — ignore
|
|
448
|
+
}
|
|
449
|
+
if (!frame || typeof frame !== 'object')
|
|
450
|
+
return;
|
|
451
|
+
const type = typeof frame.type === 'string' ? frame.type : '';
|
|
452
|
+
switch (type) {
|
|
453
|
+
case 'connection_ack': {
|
|
454
|
+
// Handshake complete — open the gate for app traffic.
|
|
455
|
+
this.state = 'open';
|
|
456
|
+
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
457
|
+
for (const channel of channels) {
|
|
458
|
+
try {
|
|
459
|
+
ws.send(JSON.stringify({
|
|
460
|
+
type: 'subscribe',
|
|
461
|
+
id: appSyncSubscriptionId(),
|
|
462
|
+
channel: appSyncChannelToWire(channel),
|
|
463
|
+
authorization: authHeader,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// ignore — onclose will pick up a broken socket.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
this.onOpenHook?.();
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// hook errors must not break the connection.
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case 'ka':
|
|
479
|
+
// Keepalive — server-initiated, no client response required.
|
|
480
|
+
break;
|
|
481
|
+
case 'data': {
|
|
482
|
+
// Per AWS spec, the `event` field is an **array of stringified JSON
|
|
483
|
+
// values** (publish accepts `events: [...]`; data delivers `event:
|
|
484
|
+
// [...]`). The backend currently publishes one entry per frame
|
|
485
|
+
// (`events: [JSON.stringify(payload)]`), but the wire shape is an
|
|
486
|
+
// array either way — iterate, decode each, dispatch independently.
|
|
487
|
+
const rawList = Array.isArray(frame.event)
|
|
488
|
+
? frame.event
|
|
489
|
+
: typeof frame.event === 'string'
|
|
490
|
+
? [frame.event]
|
|
491
|
+
: [];
|
|
492
|
+
for (const raw of rawList) {
|
|
493
|
+
if (typeof raw !== 'string')
|
|
494
|
+
continue;
|
|
495
|
+
let payload;
|
|
496
|
+
try {
|
|
497
|
+
payload = JSON.parse(raw);
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (payload &&
|
|
503
|
+
typeof payload === 'object' &&
|
|
504
|
+
typeof payload.kind === 'string') {
|
|
505
|
+
try {
|
|
506
|
+
this.handleMessage(payload);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// hook errors are isolated per-handler in handleMessage.
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case 'subscribe_success':
|
|
516
|
+
// Per-channel ack — no action required, server now streams data.
|
|
517
|
+
break;
|
|
518
|
+
case 'connection_error':
|
|
519
|
+
case 'subscribe_error':
|
|
520
|
+
case 'error':
|
|
521
|
+
// Server-side reject — close cleanly so onclose triggers reconnect.
|
|
522
|
+
// We intentionally don't surface the error message: the next
|
|
523
|
+
// connect's authorize will succeed or fail with the same signal.
|
|
524
|
+
try {
|
|
525
|
+
ws.close(1011, `appsync:${type}`);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// ignore
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
default:
|
|
532
|
+
// Unknown frame type — ignore. Forward-compatible with future
|
|
533
|
+
// protocol additions (e.g. `keepalive`, `pong`, …).
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
ws.onclose = () => {
|
|
538
|
+
if (this.ws !== ws)
|
|
539
|
+
return;
|
|
540
|
+
this.state = 'closed';
|
|
541
|
+
try {
|
|
542
|
+
this.onCloseHook?.();
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// hook errors must not block reconnect scheduling.
|
|
546
|
+
}
|
|
547
|
+
this.scheduleReconnect();
|
|
548
|
+
};
|
|
549
|
+
ws.onerror = () => {
|
|
550
|
+
// Let onclose handle reconnect — errors are noisy but not actionable.
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
handleMessage(msg) {
|
|
554
|
+
switch (msg.kind) {
|
|
555
|
+
case 'flag.updated':
|
|
556
|
+
if (this.bridge && msg.flag)
|
|
557
|
+
this.bridge.upsert(msg.flag);
|
|
558
|
+
break;
|
|
559
|
+
case 'flag.removed':
|
|
560
|
+
if (this.bridge && msg.key)
|
|
561
|
+
this.bridge.remove(msg.key);
|
|
562
|
+
break;
|
|
563
|
+
case 'user.state_changed':
|
|
564
|
+
if (this.onUserStateHook) {
|
|
565
|
+
try {
|
|
566
|
+
this.onUserStateHook(msg);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// ignore
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
case 'subscription.plan_changed':
|
|
574
|
+
if (this.onSubscriptionPlanChangedHook) {
|
|
575
|
+
try {
|
|
576
|
+
this.onSubscriptionPlanChangedHook(msg);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
// ignore
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
case 'payment.failed':
|
|
584
|
+
case 'payment.succeeded':
|
|
585
|
+
case 'subscription.created':
|
|
586
|
+
case 'subscription.updated':
|
|
587
|
+
case 'subscription.canceled':
|
|
588
|
+
case 'subscription.reactivated':
|
|
589
|
+
case 'subscription.trial_started':
|
|
590
|
+
case 'subscription.trial_ending_soon':
|
|
591
|
+
case 'subscription.trial_converted':
|
|
592
|
+
case 'subscription.trial_expired':
|
|
593
|
+
case 'dunning.entered':
|
|
594
|
+
case 'dunning.retry_scheduled':
|
|
595
|
+
case 'dunning.recovered':
|
|
596
|
+
case 'dunning.exhausted':
|
|
597
|
+
if (this.onBillingLifecycleHook) {
|
|
598
|
+
try {
|
|
599
|
+
this.onBillingLifecycleHook(msg);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// ignore
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
break;
|
|
606
|
+
case 'entitlements.changed':
|
|
607
|
+
// bridge-api always publishes the payload-carrying shape (US-12);
|
|
608
|
+
// the pre-prod signal-only fallback was removed at milestone close-out.
|
|
609
|
+
if (this.onEntitlementsChangedHook) {
|
|
610
|
+
try {
|
|
611
|
+
this.onEntitlementsChangedHook(msg);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// ignore
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
case 'quota.updated':
|
|
619
|
+
if (this.onQuotaUpdatedHook) {
|
|
620
|
+
try {
|
|
621
|
+
this.onQuotaUpdatedHook(msg);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// ignore
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
break;
|
|
628
|
+
case 'session.snapshot':
|
|
629
|
+
// Phase 3 (TBP-287/314) — first-paint snapshot. Defensive check on
|
|
630
|
+
// `data` because the wire shape is deeper than the other kinds and
|
|
631
|
+
// a partial server might omit it; we never want to call the hook
|
|
632
|
+
// with `undefined`.
|
|
633
|
+
if (this.onSnapshotHook && msg.data) {
|
|
634
|
+
try {
|
|
635
|
+
this.onSnapshotHook(msg);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// ignore
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
scheduleReconnect() {
|
|
645
|
+
if (this.stopped)
|
|
646
|
+
return;
|
|
647
|
+
if (this.reconnectTimer)
|
|
648
|
+
return;
|
|
649
|
+
this.reconnectTimer = setTimeout(() => {
|
|
650
|
+
this.reconnectTimer = undefined;
|
|
651
|
+
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.cfg.reconnectMaxMs);
|
|
652
|
+
void this.start();
|
|
653
|
+
}, this.reconnectDelayMs);
|
|
654
|
+
if (this.reconnectTimer?.unref)
|
|
655
|
+
this.reconnectTimer.unref();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function parseMessage(raw) {
|
|
659
|
+
if (typeof raw !== 'string')
|
|
660
|
+
return null;
|
|
661
|
+
let parsed;
|
|
662
|
+
try {
|
|
663
|
+
parsed = JSON.parse(raw);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
// Centrifugo wraps publish data in `{ push: { channel, pub: { data: {...} } } }`.
|
|
669
|
+
// We accept both that shape and a flat `{ kind: ... }` shape so the same
|
|
670
|
+
// client works with simpler transports too.
|
|
671
|
+
const data = parsed?.push?.pub?.data ??
|
|
672
|
+
parsed?.pub?.data ??
|
|
673
|
+
parsed;
|
|
674
|
+
if (!data || typeof data !== 'object' || typeof data.kind !== 'string')
|
|
675
|
+
return null;
|
|
676
|
+
return data;
|
|
677
|
+
}
|
|
678
|
+
// ── AppSync Events helpers (TBP-148) ─────────────────────────────────────────
|
|
679
|
+
/** Subprotocol identifier for AppSync Events realtime channels. */
|
|
680
|
+
const APPSYNC_WS_PROTOCOL = 'aws-appsync-event-ws';
|
|
681
|
+
/**
|
|
682
|
+
* Normalize the realtime endpoint to a full `wss://…/event/realtime` URL and
|
|
683
|
+
* compute the matching HTTP host (needed in the auth header — AppSync
|
|
684
|
+
* server-side validation uses the HTTP host, NOT the realtime host).
|
|
685
|
+
*
|
|
686
|
+
* Stage's CFN output currently surfaces a bare host (`<id>.appsync-realtime-api.<region>.amazonaws.com`),
|
|
687
|
+
* while the backend spec tests use the fully-qualified `wss://…/event/realtime`.
|
|
688
|
+
* Both must work — this normalizer accepts either.
|
|
689
|
+
*
|
|
690
|
+
* HTTP-host derivation: AWS uses two parallel domains for AppSync Events:
|
|
691
|
+
* wss://<id>.appsync-realtime-api.<region>.amazonaws.com/event/realtime
|
|
692
|
+
* https://<id>.appsync-api.<region>.amazonaws.com/event
|
|
693
|
+
* The HTTP host is the realtime host with `appsync-realtime-api` swapped for
|
|
694
|
+
* `appsync-api`. Custom domains skip the suffix entirely (host == http host).
|
|
695
|
+
*/
|
|
696
|
+
function normalizeAppSyncEndpoint(endpoint) {
|
|
697
|
+
let raw = endpoint.trim();
|
|
698
|
+
if (!raw.startsWith('ws://') && !raw.startsWith('wss://')) {
|
|
699
|
+
raw = `wss://${raw}`;
|
|
700
|
+
}
|
|
701
|
+
raw = raw.replace(/\/+$/, '');
|
|
702
|
+
if (!/\/event\/realtime$/.test(raw)) {
|
|
703
|
+
raw = `${raw}/event/realtime`;
|
|
704
|
+
}
|
|
705
|
+
let realtimeHost = '';
|
|
706
|
+
try {
|
|
707
|
+
realtimeHost = new URL(raw).host;
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
realtimeHost = endpoint.replace(/^wss?:\/\//, '').split('/')[0] ?? '';
|
|
711
|
+
}
|
|
712
|
+
// Swap the realtime suffix → http suffix. If the host doesn't match the
|
|
713
|
+
// standard AppSync naming (e.g. custom domain), pass it through unchanged
|
|
714
|
+
// — the same host serves both endpoints on custom domains.
|
|
715
|
+
const httpHost = realtimeHost.replace('.appsync-realtime-api.', '.appsync-api.');
|
|
716
|
+
return { url: raw, httpHost };
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Build the AppSync Events auth header carried in the `header-…` subprotocol
|
|
720
|
+
* token. Anonymous sessions send Authorization: '' — the Lambda authorizer
|
|
721
|
+
* accepts that only for `app:<appId>` channels with a passing origin check.
|
|
722
|
+
*/
|
|
723
|
+
function buildAppSyncAuthHeader(token, host) {
|
|
724
|
+
return {
|
|
725
|
+
Authorization: token ? `Bearer ${token}` : '',
|
|
726
|
+
host,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Base64url-encode a UTF-8 string. AppSync Events expects the `header-…`
|
|
731
|
+
* subprotocol token to be base64url (no padding). Uses `btoa` when available
|
|
732
|
+
* (every modern browser + Node ≥16 globalThis); falls back to a manual encode
|
|
733
|
+
* for the rare environment where it isn't.
|
|
734
|
+
*/
|
|
735
|
+
function base64urlEncode(input) {
|
|
736
|
+
let b64;
|
|
737
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
738
|
+
const g = globalThis;
|
|
739
|
+
if (typeof g.btoa === 'function') {
|
|
740
|
+
// btoa wants binary string; encode UTF-8 → bytes first so non-ASCII JWTs
|
|
741
|
+
// survive (rare but legal — JWT header/claims can be unicode).
|
|
742
|
+
const utf8 = unescape(encodeURIComponent(input));
|
|
743
|
+
b64 = g.btoa(utf8);
|
|
744
|
+
}
|
|
745
|
+
else if (typeof g.Buffer?.from === 'function') {
|
|
746
|
+
b64 = g.Buffer.from(input, 'utf-8').toString('base64');
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
// No encoder available — return the raw input. Will fail the handshake,
|
|
750
|
+
// but loudly (Lambda authorizer rejects), which is preferable to silent
|
|
751
|
+
// corruption.
|
|
752
|
+
return input;
|
|
753
|
+
}
|
|
754
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Translate internal channel name (`<ns>:<id>`) to the AppSync wire form
|
|
758
|
+
* (`<ns>/<id>`). Mirrors the publish side at
|
|
759
|
+
* `microservices/shared/realtime/adapters/appsync-events.adapter.ts:87`
|
|
760
|
+
* and the Lambda authorizer's normalizer at
|
|
761
|
+
* `microservices/shared/realtime/appsync-authorizer.handler.ts:59`.
|
|
762
|
+
*/
|
|
763
|
+
function appSyncChannelToWire(internal) {
|
|
764
|
+
return internal.replace(':', '/');
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Stable per-subscribe identifier. Prefer `crypto.randomUUID()` (ES2022,
|
|
768
|
+
* available in every supported runtime — browser globals + Node ≥19); fall
|
|
769
|
+
* back to a Math.random-based id for the rare environment where it isn't.
|
|
770
|
+
*/
|
|
771
|
+
function appSyncSubscriptionId() {
|
|
772
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
773
|
+
const g = globalThis;
|
|
774
|
+
if (g.crypto?.randomUUID) {
|
|
775
|
+
return g.crypto.randomUUID();
|
|
776
|
+
}
|
|
777
|
+
return `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
778
|
+
}
|
|
779
|
+
//# sourceMappingURL=realtime.js.map
|