@crowdedkingdoms/crowdyjs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/MIGRATION.md +247 -0
- package/README.md +303 -0
- package/dist/auth-state.d.ts +11 -0
- package/dist/auth-state.d.ts.map +1 -0
- package/dist/auth-state.js +13 -0
- package/dist/client.d.ts +135 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +150 -0
- package/dist/crowdy-client.d.ts +182 -0
- package/dist/crowdy-client.d.ts.map +1 -0
- package/dist/crowdy-client.js +146 -0
- package/dist/domains/actors.d.ts +117 -0
- package/dist/domains/actors.d.ts.map +1 -0
- package/dist/domains/actors.js +140 -0
- package/dist/domains/admin.d.ts +61 -0
- package/dist/domains/admin.d.ts.map +1 -0
- package/dist/domains/admin.js +33 -0
- package/dist/domains/appAccess.d.ts +141 -0
- package/dist/domains/appAccess.d.ts.map +1 -0
- package/dist/domains/appAccess.js +198 -0
- package/dist/domains/apps.d.ts +192 -0
- package/dist/domains/apps.d.ts.map +1 -0
- package/dist/domains/apps.js +217 -0
- package/dist/domains/auth.d.ts +163 -0
- package/dist/domains/auth.d.ts.map +1 -0
- package/dist/domains/auth.js +208 -0
- package/dist/domains/avatars.d.ts +94 -0
- package/dist/domains/avatars.d.ts.map +1 -0
- package/dist/domains/avatars.js +137 -0
- package/dist/domains/billing.d.ts +97 -0
- package/dist/domains/billing.d.ts.map +1 -0
- package/dist/domains/billing.js +131 -0
- package/dist/domains/channels.d.ts +293 -0
- package/dist/domains/channels.d.ts.map +1 -0
- package/dist/domains/channels.js +353 -0
- package/dist/domains/chunks.d.ts +133 -0
- package/dist/domains/chunks.d.ts.map +1 -0
- package/dist/domains/chunks.js +153 -0
- package/dist/domains/controlPlane.d.ts +174 -0
- package/dist/domains/controlPlane.d.ts.map +1 -0
- package/dist/domains/controlPlane.js +252 -0
- package/dist/domains/environments.d.ts +155 -0
- package/dist/domains/environments.d.ts.map +1 -0
- package/dist/domains/environments.js +223 -0
- package/dist/domains/gameApps.d.ts +114 -0
- package/dist/domains/gameApps.d.ts.map +1 -0
- package/dist/domains/gameApps.js +169 -0
- package/dist/domains/gameModel.d.ts +668 -0
- package/dist/domains/gameModel.d.ts.map +1 -0
- package/dist/domains/gameModel.js +816 -0
- package/dist/domains/host.d.ts +35 -0
- package/dist/domains/host.d.ts.map +1 -0
- package/dist/domains/host.js +40 -0
- package/dist/domains/organizations.d.ts +179 -0
- package/dist/domains/organizations.d.ts.map +1 -0
- package/dist/domains/organizations.js +269 -0
- package/dist/domains/payments.d.ts +104 -0
- package/dist/domains/payments.d.ts.map +1 -0
- package/dist/domains/payments.js +129 -0
- package/dist/domains/platform.d.ts +49 -0
- package/dist/domains/platform.d.ts.map +1 -0
- package/dist/domains/platform.js +50 -0
- package/dist/domains/quotas.d.ts +62 -0
- package/dist/domains/quotas.d.ts.map +1 -0
- package/dist/domains/quotas.js +79 -0
- package/dist/domains/serverStatus.d.ts +90 -0
- package/dist/domains/serverStatus.d.ts.map +1 -0
- package/dist/domains/serverStatus.js +104 -0
- package/dist/domains/sharedEnvironment.d.ts +133 -0
- package/dist/domains/sharedEnvironment.d.ts.map +1 -0
- package/dist/domains/sharedEnvironment.js +179 -0
- package/dist/domains/state.d.ts +64 -0
- package/dist/domains/state.d.ts.map +1 -0
- package/dist/domains/state.js +75 -0
- package/dist/domains/teams.d.ts +292 -0
- package/dist/domains/teams.d.ts.map +1 -0
- package/dist/domains/teams.js +352 -0
- package/dist/domains/teleport.d.ts +41 -0
- package/dist/domains/teleport.d.ts.map +1 -0
- package/dist/domains/teleport.js +43 -0
- package/dist/domains/udp.d.ts +405 -0
- package/dist/domains/udp.d.ts.map +1 -0
- package/dist/domains/udp.js +457 -0
- package/dist/domains/usage.d.ts +76 -0
- package/dist/domains/usage.d.ts.map +1 -0
- package/dist/domains/usage.js +110 -0
- package/dist/domains/users.d.ts +147 -0
- package/dist/domains/users.d.ts.map +1 -0
- package/dist/domains/users.js +195 -0
- package/dist/domains/voxels.d.ts +136 -0
- package/dist/domains/voxels.d.ts.map +1 -0
- package/dist/domains/voxels.js +153 -0
- package/dist/errors.d.ts +158 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +142 -0
- package/dist/generated/graphql.d.ts +12206 -0
- package/dist/generated/graphql.d.ts.map +1 -0
- package/dist/generated/graphql.js +474 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +1 -0
- package/dist/realtime.d.ts +319 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +390 -0
- package/dist/session.d.ts +73 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +96 -0
- package/dist/subscriptions.d.ts +2 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +1 -0
- package/dist/types.d.ts +658 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +61 -0
- package/dist/utils.d.ts +98 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +136 -0
- package/dist/world.d.ts +236 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +275 -0
- package/package.json +73 -0
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { print } from 'graphql';
|
|
2
|
+
import { createClient } from 'graphql-ws';
|
|
3
|
+
import { silentLogger } from './logger.js';
|
|
4
|
+
import { CrowdyRealtimeError } from './errors.js';
|
|
5
|
+
import { UdpNotificationsDocument, } from './generated/graphql.js';
|
|
6
|
+
/**
|
|
7
|
+
* Manages the single WebSocket subscription to the game-api's
|
|
8
|
+
* `udpNotifications` stream — the realtime layer behind `client.udp` and
|
|
9
|
+
* `client.realtime`. It opens the socket lazily on the first {@link subscribe},
|
|
10
|
+
* authenticates with the shared session token, scopes the session to one
|
|
11
|
+
* `appId`, reconnects with jittered exponential backoff, re-reads the token and
|
|
12
|
+
* resubscribes on reconnect, fans each notification out to the registered
|
|
13
|
+
* {@link UdpNotificationHandlers}, and resolves `...AndWait` sends via
|
|
14
|
+
* {@link waitForSequence}.
|
|
15
|
+
*
|
|
16
|
+
* The connection lifecycle is observable through {@link status} /
|
|
17
|
+
* {@link onStatus} ({@link RealtimeStatus}). A realtime session is scoped to a
|
|
18
|
+
* single app, so run one client per app (sharing the same token store) for a
|
|
19
|
+
* player who is in multiple apps at once.
|
|
20
|
+
*
|
|
21
|
+
* You normally interact with this through `client.udp` / `client.realtime`
|
|
22
|
+
* rather than constructing it directly.
|
|
23
|
+
*/
|
|
24
|
+
export class RealtimeClient {
|
|
25
|
+
/**
|
|
26
|
+
* @param config - Reconnect/timeout/endpoint tuning; see
|
|
27
|
+
* {@link RealtimeConfig}.
|
|
28
|
+
* @param session - Shared session store. The client reads the Bearer token
|
|
29
|
+
* from it for the connection handshake and watches it for changes: clearing
|
|
30
|
+
* the token tears the connection down (emitting an `AUTH_CLEARED`
|
|
31
|
+
* {@link CrowdyRealtimeError}), while a token change made while connected
|
|
32
|
+
* forces a reconnect using the new token.
|
|
33
|
+
*/
|
|
34
|
+
constructor(config = {}, session) {
|
|
35
|
+
this.session = session;
|
|
36
|
+
this.client = null;
|
|
37
|
+
this.release = null;
|
|
38
|
+
this.desired = false;
|
|
39
|
+
this.statusValue = 'idle';
|
|
40
|
+
this.statusListeners = new Set();
|
|
41
|
+
this.subscribers = new Map();
|
|
42
|
+
this.pending = new Map();
|
|
43
|
+
this.nextSubscriberId = 1;
|
|
44
|
+
// App this realtime session is scoped to. Sent in connectionParams so the
|
|
45
|
+
// game-api only fans this app's spatial notifications to this subscription.
|
|
46
|
+
// The game-api rejects subscriptions that arrive without it.
|
|
47
|
+
this.subscribedAppId = null;
|
|
48
|
+
this.wsUrl = config.wsUrl || config.wsEndpoint || 'ws://localhost:3000/graphql';
|
|
49
|
+
this.logger = config.logger ?? silentLogger;
|
|
50
|
+
this.retryAttempts = config.retryAttempts ?? 8;
|
|
51
|
+
this.retryInitialDelayMs = config.retryInitialDelayMs ?? 250;
|
|
52
|
+
this.retryMaxDelayMs = config.retryMaxDelayMs ?? 5000;
|
|
53
|
+
this.waitTimeoutMs = config.waitTimeoutMs ?? 5000;
|
|
54
|
+
this.session.onChange((token) => {
|
|
55
|
+
if (!this.desired)
|
|
56
|
+
return;
|
|
57
|
+
if (!token) {
|
|
58
|
+
this.disconnect();
|
|
59
|
+
this.dispatchError(new CrowdyRealtimeError('Realtime disconnected because the session token was cleared', {
|
|
60
|
+
code: 'AUTH_CLEARED',
|
|
61
|
+
retryable: false,
|
|
62
|
+
}));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.restart();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The current connection state.
|
|
70
|
+
*
|
|
71
|
+
* @returns The latest {@link RealtimeStatus}.
|
|
72
|
+
*/
|
|
73
|
+
status() {
|
|
74
|
+
return this.statusValue;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Subscribe to connection-state changes. The listener is invoked
|
|
78
|
+
* **immediately** with the current status, then again on every transition.
|
|
79
|
+
*
|
|
80
|
+
* @param listener - Called with each new {@link RealtimeStatus}.
|
|
81
|
+
* @returns An unsubscribe function that removes the listener.
|
|
82
|
+
*/
|
|
83
|
+
onStatus(listener) {
|
|
84
|
+
this.statusListeners.add(listener);
|
|
85
|
+
listener(this.statusValue);
|
|
86
|
+
return () => {
|
|
87
|
+
this.statusListeners.delete(listener);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Mark the connection as desired and open the subscription if it isn't
|
|
92
|
+
* already open. You usually don't call this directly — {@link subscribe}
|
|
93
|
+
* calls it for you; use it (or `client.realtime.connect()`) only to pre-warm
|
|
94
|
+
* the socket.
|
|
95
|
+
*
|
|
96
|
+
* @throws {CrowdyRealtimeError} `AUTH_REQUIRED` if there is no session token.
|
|
97
|
+
*/
|
|
98
|
+
connect() {
|
|
99
|
+
this.desired = true;
|
|
100
|
+
this.ensureSubscription();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Close the socket and stop wanting a connection. Outstanding
|
|
104
|
+
* {@link waitForSequence} promises are left intact (they will time out on
|
|
105
|
+
* their own); use {@link close} to also reject those and drop all
|
|
106
|
+
* subscribers. Safe to call when already disconnected.
|
|
107
|
+
*/
|
|
108
|
+
disconnect() {
|
|
109
|
+
this.desired = false;
|
|
110
|
+
this.release?.();
|
|
111
|
+
this.release = null;
|
|
112
|
+
this.client?.dispose();
|
|
113
|
+
this.client = null;
|
|
114
|
+
this.setStatus('disconnected');
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Fully tear down the client: {@link disconnect}, drop all notification
|
|
118
|
+
* subscribers, and reject every outstanding {@link waitForSequence} promise
|
|
119
|
+
* with a non-retryable {@link CrowdyRealtimeError}. Call this when disposing
|
|
120
|
+
* the SDK instance.
|
|
121
|
+
*/
|
|
122
|
+
close() {
|
|
123
|
+
this.disconnect();
|
|
124
|
+
this.subscribers.clear();
|
|
125
|
+
this.rejectAllPending(new CrowdyRealtimeError('Realtime client closed', { retryable: false }));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Register a set of {@link UdpNotificationHandlers} and ensure the realtime
|
|
129
|
+
* connection is open, scoping the session to `appId`. The game-api requires
|
|
130
|
+
* an app id and rejects an app-agnostic subscription with a
|
|
131
|
+
* `RealtimeConnectionEvent` (`code: 'APP_ID_REQUIRED'`).
|
|
132
|
+
*
|
|
133
|
+
* Multiple handler sets can be registered at once; the returned function
|
|
134
|
+
* unregisters this one, and the socket closes automatically once the last
|
|
135
|
+
* subscriber unsubscribes.
|
|
136
|
+
*
|
|
137
|
+
* @param handlers - Callbacks for the notification types you care about.
|
|
138
|
+
* @param appId - The app to scope this realtime session to (decimal id;
|
|
139
|
+
* coerced to a string). Required.
|
|
140
|
+
* @returns An unsubscribe function that removes these handlers (and
|
|
141
|
+
* disconnects when none remain).
|
|
142
|
+
*/
|
|
143
|
+
subscribe(handlers, appId) {
|
|
144
|
+
// appId is required by the type; guard for JS callers so a missing value
|
|
145
|
+
// is sent as "no app" (cleanly rejected by the game-api) rather than the
|
|
146
|
+
// literal string "undefined".
|
|
147
|
+
this.subscribedAppId = appId != null ? String(appId) : null;
|
|
148
|
+
const id = `s${this.nextSubscriberId++}`;
|
|
149
|
+
this.subscribers.set(id, handlers);
|
|
150
|
+
this.connect();
|
|
151
|
+
return () => {
|
|
152
|
+
this.subscribers.delete(id);
|
|
153
|
+
if (this.subscribers.size === 0 && this.desired) {
|
|
154
|
+
this.disconnect();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Return a promise that resolves when a notification carrying the given
|
|
160
|
+
* `sequenceNumber` arrives — the mechanism behind the `...AndWait` spatial
|
|
161
|
+
* sends. Resolves with the matching {@link SpatialNotification}, or rejects
|
|
162
|
+
* if that match is a `GenericErrorResponse` or the wait times out.
|
|
163
|
+
*
|
|
164
|
+
* @param sequenceNumber - The sequence number to wait for (as allocated by
|
|
165
|
+
* {@link SequenceAllocator} and stamped on the send).
|
|
166
|
+
* @param timeoutMs - How long to wait before rejecting, in milliseconds.
|
|
167
|
+
* Defaults to the configured {@link RealtimeConfig.waitTimeoutMs}.
|
|
168
|
+
* @returns The matching spatial notification.
|
|
169
|
+
* @throws {CrowdyRealtimeError} `UDP_SEQUENCE_TIMEOUT` (retryable) on timeout,
|
|
170
|
+
* or carrying the server `errorCode` when the match is a
|
|
171
|
+
* `GenericErrorResponse`.
|
|
172
|
+
*/
|
|
173
|
+
waitForSequence(sequenceNumber, timeoutMs = this.waitTimeoutMs) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
this.removePending(sequenceNumber, wait);
|
|
177
|
+
reject(new CrowdyRealtimeError(`Timed out waiting for UDP response sequence ${sequenceNumber}`, { code: 'UDP_SEQUENCE_TIMEOUT', retryable: true }));
|
|
178
|
+
}, timeoutMs);
|
|
179
|
+
const wait = { resolve, reject, timer };
|
|
180
|
+
const waits = this.pending.get(sequenceNumber) ?? [];
|
|
181
|
+
waits.push(wait);
|
|
182
|
+
this.pending.set(sequenceNumber, waits);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
ensureSubscription() {
|
|
186
|
+
if (this.release)
|
|
187
|
+
return;
|
|
188
|
+
const token = this.session.getToken();
|
|
189
|
+
if (!token) {
|
|
190
|
+
const error = new CrowdyRealtimeError('Must be authenticated to subscribe', {
|
|
191
|
+
code: 'AUTH_REQUIRED',
|
|
192
|
+
retryable: false,
|
|
193
|
+
});
|
|
194
|
+
this.setStatus('failed');
|
|
195
|
+
this.dispatchError(error);
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
this.setStatus('connecting');
|
|
199
|
+
this.client = createClient({
|
|
200
|
+
url: this.wsUrl,
|
|
201
|
+
lazy: true,
|
|
202
|
+
retryAttempts: this.retryAttempts,
|
|
203
|
+
connectionParams: () => {
|
|
204
|
+
const currentToken = this.session.getToken();
|
|
205
|
+
if (!currentToken)
|
|
206
|
+
return {};
|
|
207
|
+
const params = {
|
|
208
|
+
Authorization: `Bearer ${currentToken}`,
|
|
209
|
+
};
|
|
210
|
+
if (this.subscribedAppId != null)
|
|
211
|
+
params.appId = this.subscribedAppId;
|
|
212
|
+
return params;
|
|
213
|
+
},
|
|
214
|
+
retryWait: async (retries) => {
|
|
215
|
+
this.setStatus('reconnecting');
|
|
216
|
+
const delay = Math.min(this.retryMaxDelayMs, this.retryInitialDelayMs * 2 ** retries);
|
|
217
|
+
const jitter = Math.floor(Math.random() * this.retryInitialDelayMs);
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
219
|
+
},
|
|
220
|
+
on: {
|
|
221
|
+
connected: () => this.setStatus('connected'),
|
|
222
|
+
closed: () => {
|
|
223
|
+
if (this.desired) {
|
|
224
|
+
this.setStatus('reconnecting');
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.setStatus('disconnected');
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
error: (error) => {
|
|
231
|
+
this.logger.error?.('Realtime WebSocket error', error);
|
|
232
|
+
this.dispatchError(new CrowdyRealtimeError('Realtime WebSocket error', {
|
|
233
|
+
code: 'WEBSOCKET_ERROR',
|
|
234
|
+
retryable: true,
|
|
235
|
+
cause: error,
|
|
236
|
+
}));
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
this.release = this.client.subscribe({ query: print(UdpNotificationsDocument) }, {
|
|
241
|
+
next: (message) => {
|
|
242
|
+
const data = message.data;
|
|
243
|
+
const notification = data?.udpNotifications;
|
|
244
|
+
if (notification)
|
|
245
|
+
this.dispatch(notification);
|
|
246
|
+
if (message.errors?.length) {
|
|
247
|
+
this.dispatchError(new CrowdyRealtimeError(message.errors[0]?.message ?? 'Subscription error', {
|
|
248
|
+
code: 'SUBSCRIPTION_ERROR',
|
|
249
|
+
retryable: true,
|
|
250
|
+
cause: message.errors,
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
error: (error) => {
|
|
255
|
+
this.setStatus('failed');
|
|
256
|
+
this.dispatchError(new CrowdyRealtimeError('Realtime subscription failed', {
|
|
257
|
+
code: 'SUBSCRIPTION_FAILED',
|
|
258
|
+
retryable: true,
|
|
259
|
+
cause: error,
|
|
260
|
+
}));
|
|
261
|
+
},
|
|
262
|
+
complete: () => {
|
|
263
|
+
this.release = null;
|
|
264
|
+
if (this.desired) {
|
|
265
|
+
this.setStatus('reconnecting');
|
|
266
|
+
this.ensureSubscription();
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
restart() {
|
|
272
|
+
this.release?.();
|
|
273
|
+
this.release = null;
|
|
274
|
+
this.client?.dispose();
|
|
275
|
+
this.client = null;
|
|
276
|
+
this.ensureSubscription();
|
|
277
|
+
}
|
|
278
|
+
dispatch(notification) {
|
|
279
|
+
this.resolvePending(notification);
|
|
280
|
+
// A non-retryable connection event (e.g. APP_ID_REQUIRED, AUTH_REQUIRED)
|
|
281
|
+
// means the server completed the subscription and resubscribing would just
|
|
282
|
+
// be rejected again. Stop wanting the connection so the `complete` handler
|
|
283
|
+
// doesn't immediately reopen it (lazy graphql-ws then closes the socket).
|
|
284
|
+
if (notification.__typename === 'RealtimeConnectionEvent' &&
|
|
285
|
+
notification.retryable === false) {
|
|
286
|
+
this.desired = false;
|
|
287
|
+
this.setStatus('failed');
|
|
288
|
+
}
|
|
289
|
+
for (const handlers of [...this.subscribers.values()]) {
|
|
290
|
+
try {
|
|
291
|
+
handlers.any?.(notification);
|
|
292
|
+
switch (notification.__typename) {
|
|
293
|
+
case 'ActorUpdateNotification':
|
|
294
|
+
handlers.actorUpdate?.(notification);
|
|
295
|
+
break;
|
|
296
|
+
case 'ActorUpdateResponse':
|
|
297
|
+
handlers.actorUpdateResponse?.(notification);
|
|
298
|
+
break;
|
|
299
|
+
case 'VoxelUpdateNotification':
|
|
300
|
+
handlers.voxelUpdate?.(notification);
|
|
301
|
+
break;
|
|
302
|
+
case 'VoxelUpdateResponse':
|
|
303
|
+
handlers.voxelUpdateResponse?.(notification);
|
|
304
|
+
break;
|
|
305
|
+
case 'ClientAudioNotification':
|
|
306
|
+
handlers.audio?.(notification);
|
|
307
|
+
break;
|
|
308
|
+
case 'ClientTextNotification':
|
|
309
|
+
handlers.text?.(notification);
|
|
310
|
+
break;
|
|
311
|
+
case 'ClientEventNotification':
|
|
312
|
+
handlers.clientEvent?.(notification);
|
|
313
|
+
break;
|
|
314
|
+
case 'ServerEventNotification':
|
|
315
|
+
handlers.serverEvent?.(notification);
|
|
316
|
+
break;
|
|
317
|
+
case 'SingleActorMessageNotification':
|
|
318
|
+
handlers.singleActorMessage?.(notification);
|
|
319
|
+
break;
|
|
320
|
+
case 'ChannelMessageNotification':
|
|
321
|
+
handlers.channelMessage?.(notification);
|
|
322
|
+
break;
|
|
323
|
+
case 'GenericErrorResponse':
|
|
324
|
+
handlers.genericError?.(notification);
|
|
325
|
+
break;
|
|
326
|
+
case 'RealtimeConnectionEvent':
|
|
327
|
+
handlers.connectionEvent?.(notification);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
this.logger.error?.('Realtime notification handler threw', error);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
resolvePending(notification) {
|
|
337
|
+
if (!('sequenceNumber' in notification))
|
|
338
|
+
return;
|
|
339
|
+
const waits = this.pending.get(notification.sequenceNumber);
|
|
340
|
+
if (!waits?.length)
|
|
341
|
+
return;
|
|
342
|
+
this.pending.delete(notification.sequenceNumber);
|
|
343
|
+
for (const wait of waits) {
|
|
344
|
+
clearTimeout(wait.timer);
|
|
345
|
+
if (notification.__typename === 'GenericErrorResponse') {
|
|
346
|
+
wait.reject(new CrowdyRealtimeError(`UDP request failed: ${notification.errorCode}`, {
|
|
347
|
+
code: notification.errorCode,
|
|
348
|
+
retryable: false,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
wait.resolve(notification);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
removePending(sequenceNumber, wait) {
|
|
357
|
+
const waits = this.pending.get(sequenceNumber);
|
|
358
|
+
if (!waits)
|
|
359
|
+
return;
|
|
360
|
+
const next = waits.filter((candidate) => candidate !== wait);
|
|
361
|
+
if (next.length) {
|
|
362
|
+
this.pending.set(sequenceNumber, next);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
this.pending.delete(sequenceNumber);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
rejectAllPending(error) {
|
|
369
|
+
for (const waits of this.pending.values()) {
|
|
370
|
+
for (const wait of waits) {
|
|
371
|
+
clearTimeout(wait.timer);
|
|
372
|
+
wait.reject(error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
this.pending.clear();
|
|
376
|
+
}
|
|
377
|
+
dispatchError(error) {
|
|
378
|
+
for (const handlers of [...this.subscribers.values()]) {
|
|
379
|
+
handlers.error?.(error);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
setStatus(status) {
|
|
383
|
+
if (status === this.statusValue)
|
|
384
|
+
return;
|
|
385
|
+
this.statusValue = status;
|
|
386
|
+
for (const listener of [...this.statusListeners]) {
|
|
387
|
+
listener(status);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Callback notified whenever the active token changes (`null` on sign-out). */
|
|
2
|
+
export type SessionListener = (token: string | null) => void;
|
|
3
|
+
/**
|
|
4
|
+
* Pluggable persistence for the Bearer token. Implement this to back the
|
|
5
|
+
* session with whatever storage your runtime offers (cookies, secure storage,
|
|
6
|
+
* a database for SSR, etc.). All three methods may be sync or async.
|
|
7
|
+
*
|
|
8
|
+
* `BrowserLocalStorageTokenStore` is provided for browser apps.
|
|
9
|
+
*/
|
|
10
|
+
export interface TokenStore {
|
|
11
|
+
/** Return the persisted token, or `null`/`undefined` if none. */
|
|
12
|
+
get(): string | null | Promise<string | null>;
|
|
13
|
+
/** Persist a token (called on login and token refresh). */
|
|
14
|
+
set(token: string): void | Promise<void>;
|
|
15
|
+
/** Remove the persisted token (called on logout). */
|
|
16
|
+
clear(): void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* {@link TokenStore} backed by the browser `localStorage`. No-ops gracefully
|
|
20
|
+
* when `localStorage` is unavailable (e.g. SSR), so it's safe to construct
|
|
21
|
+
* unconditionally.
|
|
22
|
+
*/
|
|
23
|
+
export declare class BrowserLocalStorageTokenStore implements TokenStore {
|
|
24
|
+
private readonly key;
|
|
25
|
+
/** @param key - localStorage key under which the token is stored. */
|
|
26
|
+
constructor(key?: string);
|
|
27
|
+
get(): string | null;
|
|
28
|
+
set(token: string): void;
|
|
29
|
+
clear(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* In-memory token holder with change notifications and optional persistence via
|
|
33
|
+
* a {@link TokenStore}. Setting the token fans out to every {@link onChange}
|
|
34
|
+
* listener, which is how the HTTP client and the WebSocket stay in lock-step
|
|
35
|
+
* (their auth can never drift).
|
|
36
|
+
*/
|
|
37
|
+
export declare class SessionStore {
|
|
38
|
+
private readonly tokenStore?;
|
|
39
|
+
private token;
|
|
40
|
+
private readonly listeners;
|
|
41
|
+
/** @param tokenStore - Optional persistence; when omitted the token is memory-only. */
|
|
42
|
+
constructor(tokenStore?: TokenStore | undefined);
|
|
43
|
+
/**
|
|
44
|
+
* Load the token from the {@link TokenStore} into memory (without re-persisting)
|
|
45
|
+
* and notify listeners. Call once on startup to resume a saved session.
|
|
46
|
+
*
|
|
47
|
+
* @returns The restored token, or `null` if none was stored.
|
|
48
|
+
*/
|
|
49
|
+
restore(): Promise<string | null>;
|
|
50
|
+
/** The current in-memory token, or `null` if there's no active session. */
|
|
51
|
+
getToken(): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Set (or clear, with `null`) the active token. Persists to the
|
|
54
|
+
* {@link TokenStore} unless `options.persist` is `false`, then notifies all
|
|
55
|
+
* listeners. A no-op if the token is unchanged.
|
|
56
|
+
*
|
|
57
|
+
* @param token - The new Bearer token, or `null` to sign out.
|
|
58
|
+
* @param options - `persist: false` updates memory + listeners only.
|
|
59
|
+
*/
|
|
60
|
+
setToken(token: string | null, options?: {
|
|
61
|
+
persist?: boolean;
|
|
62
|
+
}): void;
|
|
63
|
+
/** Clear the active token (equivalent to `setToken(null)`). */
|
|
64
|
+
clear(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Subscribe to token changes. The listener fires immediately with the current
|
|
67
|
+
* token, then on every change.
|
|
68
|
+
*
|
|
69
|
+
* @returns An unsubscribe function.
|
|
70
|
+
*/
|
|
71
|
+
onChange(listener: SessionListener): () => void;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,MAAM,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;AAE7D;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,iEAAiE;IACjE,GAAG,IAAI,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,2DAA2D;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,qDAAqD;IACrD,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/B;AAED;;;;GAIG;AACH,qBAAa,6BAA8B,YAAW,UAAU;IAElD,OAAO,CAAC,QAAQ,CAAC,GAAG;IADhC,qEAAqE;gBACxC,GAAG,SAAmB;IAEnD,GAAG,IAAI,MAAM,GAAG,IAAI;IAKpB,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKxB,KAAK,IAAI,IAAI;CAId;AAED;;;;;GAKG;AACH,qBAAa,YAAY;IAKX,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAJxC,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA8B;IAExD,uFAAuF;gBAC1D,UAAU,CAAC,EAAE,UAAU,YAAA;IAEpD;;;;;OAKG;IACG,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAMvC,2EAA2E;IAC3E,QAAQ,IAAI,MAAM,GAAG,IAAI;IAIzB;;;;;;;OAOG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,GAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,IAAI;IAiBzE,+DAA+D;IAC/D,KAAK,IAAI,IAAI;IAIb;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,IAAI;CAOhD"}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link TokenStore} backed by the browser `localStorage`. No-ops gracefully
|
|
3
|
+
* when `localStorage` is unavailable (e.g. SSR), so it's safe to construct
|
|
4
|
+
* unconditionally.
|
|
5
|
+
*/
|
|
6
|
+
export class BrowserLocalStorageTokenStore {
|
|
7
|
+
/** @param key - localStorage key under which the token is stored. */
|
|
8
|
+
constructor(key = 'crowdyjs:token') {
|
|
9
|
+
this.key = key;
|
|
10
|
+
}
|
|
11
|
+
get() {
|
|
12
|
+
if (typeof localStorage === 'undefined')
|
|
13
|
+
return null;
|
|
14
|
+
return localStorage.getItem(this.key);
|
|
15
|
+
}
|
|
16
|
+
set(token) {
|
|
17
|
+
if (typeof localStorage === 'undefined')
|
|
18
|
+
return;
|
|
19
|
+
localStorage.setItem(this.key, token);
|
|
20
|
+
}
|
|
21
|
+
clear() {
|
|
22
|
+
if (typeof localStorage === 'undefined')
|
|
23
|
+
return;
|
|
24
|
+
localStorage.removeItem(this.key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* In-memory token holder with change notifications and optional persistence via
|
|
29
|
+
* a {@link TokenStore}. Setting the token fans out to every {@link onChange}
|
|
30
|
+
* listener, which is how the HTTP client and the WebSocket stay in lock-step
|
|
31
|
+
* (their auth can never drift).
|
|
32
|
+
*/
|
|
33
|
+
export class SessionStore {
|
|
34
|
+
/** @param tokenStore - Optional persistence; when omitted the token is memory-only. */
|
|
35
|
+
constructor(tokenStore) {
|
|
36
|
+
this.tokenStore = tokenStore;
|
|
37
|
+
this.token = null;
|
|
38
|
+
this.listeners = new Set();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Load the token from the {@link TokenStore} into memory (without re-persisting)
|
|
42
|
+
* and notify listeners. Call once on startup to resume a saved session.
|
|
43
|
+
*
|
|
44
|
+
* @returns The restored token, or `null` if none was stored.
|
|
45
|
+
*/
|
|
46
|
+
async restore() {
|
|
47
|
+
const token = (await this.tokenStore?.get()) ?? null;
|
|
48
|
+
this.setToken(token, { persist: false });
|
|
49
|
+
return token;
|
|
50
|
+
}
|
|
51
|
+
/** The current in-memory token, or `null` if there's no active session. */
|
|
52
|
+
getToken() {
|
|
53
|
+
return this.token;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Set (or clear, with `null`) the active token. Persists to the
|
|
57
|
+
* {@link TokenStore} unless `options.persist` is `false`, then notifies all
|
|
58
|
+
* listeners. A no-op if the token is unchanged.
|
|
59
|
+
*
|
|
60
|
+
* @param token - The new Bearer token, or `null` to sign out.
|
|
61
|
+
* @param options - `persist: false` updates memory + listeners only.
|
|
62
|
+
*/
|
|
63
|
+
setToken(token, options = {}) {
|
|
64
|
+
if (token === this.token)
|
|
65
|
+
return;
|
|
66
|
+
this.token = token;
|
|
67
|
+
if (options.persist !== false) {
|
|
68
|
+
if (token) {
|
|
69
|
+
void this.tokenStore?.set(token);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
void this.tokenStore?.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const listener of [...this.listeners]) {
|
|
76
|
+
listener(token);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Clear the active token (equivalent to `setToken(null)`). */
|
|
80
|
+
clear() {
|
|
81
|
+
this.setToken(null);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to token changes. The listener fires immediately with the current
|
|
85
|
+
* token, then on every change.
|
|
86
|
+
*
|
|
87
|
+
* @returns An unsubscribe function.
|
|
88
|
+
*/
|
|
89
|
+
onChange(listener) {
|
|
90
|
+
this.listeners.add(listener);
|
|
91
|
+
listener(this.token);
|
|
92
|
+
return () => {
|
|
93
|
+
this.listeners.delete(listener);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscriptions.d.ts","sourceRoot":"","sources":["../src/subscriptions.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,IAAI,mBAAmB,EACrC,KAAK,cAAc,IAAI,yBAAyB,EAChD,KAAK,uBAAuB,GAC7B,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RealtimeClient as SubscriptionManager, } from './realtime.js';
|