@spine-event-engine/client-web 2.0.0-snapshot.2

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.
@@ -0,0 +1,1049 @@
1
+ /*
2
+ * Copyright 2026, CodeMatters. All rights reserved.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5
+ * in compliance with the License. You may obtain a copy of the License at
6
+ *
7
+ * https://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
10
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11
+ * or implied. See the License for the specific language governing permissions and limitations under
12
+ * the License.
13
+ */
14
+ import { clone, create, toBinary } from "@bufbuild/protobuf";
15
+ import { TimestampSchema } from "@bufbuild/protobuf/wkt";
16
+ import { createClient } from "@connectrpc/connect";
17
+ import { createConnectTransport, createGrpcWebTransport } from "@connectrpc/connect-web";
18
+ import { SignalEnvelopes, AnyMessages } from "@spine-event-engine/core";
19
+ import { ActorContextSchema, CommandContextSchema, CommandIdSchema, TenantIdSchema, UserIdSchema, ZoneIdSchema, } from "@spine-event-engine/proto";
20
+ import { CommandService, QuerySchema, QueryResponseSchema, QueryService, SubscriptionService, SubscriptionUpdateSchema, TargetSchema, TopicSchema, } from "@spine-event-engine/proto/client";
21
+ /**
22
+ * Thrown for a service response that violates the frozen wire contract.
23
+ */
24
+ export class ClientProtocolError extends Error {
25
+ // prettier-ignore
26
+ /**
27
+ * Creates an error for an invalid wire response.
28
+ * @param message Explains the protocol violation.
29
+ */
30
+ constructor(message) {
31
+ super(`Client protocol error: ${message}`);
32
+ this.name = "ClientProtocolError";
33
+ }
34
+ }
35
+ /**
36
+ * Internal marker selecting the terminal overflow path without inspecting error text.
37
+ */
38
+ class SubscriptionBufferOverflowError extends ClientProtocolError {
39
+ }
40
+ /**
41
+ * Internal marker for a transport stream that ended without terminal cancellation.
42
+ */
43
+ class SubscriptionStreamEndedError extends ClientProtocolError {
44
+ }
45
+ /**
46
+ * Browser-safe Spine client whose transport and ID source are supplied by the caller.
47
+ */
48
+ export class Client {
49
+ #owner;
50
+ #tenant;
51
+ #zoneId;
52
+ #subscriptions;
53
+ /**
54
+ * Creates a browser client from a supplied transport and immutable options.
55
+ *
56
+ * @param source Supplies the browser-safe transport and request-ID source.
57
+ * @param options Supplies optional tenant, zone, reconnect, and subscription settings.
58
+ */
59
+ constructor(source, options) {
60
+ this.#owner = new ClientOwner(source, options.onReauthenticateBeforeReconnect);
61
+ this.#tenant = BrowserClientValues.tenant(options.tenant);
62
+ this.#zoneId = BrowserClientValues.zoneId(options.zoneId);
63
+ this.#subscriptions = BrowserClientValues.subscriptionRuntimeOptions(options.subscriptions);
64
+ }
65
+ /**
66
+ * Creates a client from an injected transport and request-ID source.
67
+ * @param source Supplies the transport and request-ID source.
68
+ * @param options Supplies immutable client options.
69
+ * @returns Returns the created client.
70
+ */
71
+ static usingTransport(source, options = {}) {
72
+ return new Client(source, options);
73
+ }
74
+ /**
75
+ * Creates a browser client that always uses the gRPC-Web protocol.
76
+ * @param baseUrl Supplies the gateway base URL.
77
+ * @param options Supplies browser client options.
78
+ * @returns Returns the created client.
79
+ */
80
+ static forGrpcWeb(baseUrl, options = {}) {
81
+ return new Client(BrowserClientValues.browserSource(createGrpcWebTransport(BrowserClientValues.browserTransportOptions(baseUrl, options))), options);
82
+ }
83
+ /**
84
+ * Creates a browser client that always uses binary Connect (`application/proto`).
85
+ *
86
+ * The selected gateway must permit binary Connect, including packed `Any` command
87
+ * and query values. Selection is explicit: this method never probes or falls back.
88
+ * @param baseUrl Supplies the gateway base URL.
89
+ * @param options Supplies browser client options.
90
+ * @returns Returns the created client.
91
+ */
92
+ static forConnect(baseUrl, options = {}) {
93
+ return new Client(BrowserClientValues.browserSource(createConnectTransport({
94
+ ...BrowserClientValues.browserTransportOptions(baseUrl, options),
95
+ useBinaryFormat: true,
96
+ })), options);
97
+ }
98
+ /**
99
+ * Creates an immutable request scope for the guest actor.
100
+ * @returns Returns the guest request scope.
101
+ */
102
+ asGuest() {
103
+ return new Request(this.#owner, this.#tenant, this.#zoneId, this.#subscriptions, "guest");
104
+ }
105
+ /**
106
+ * Creates an immutable request scope for one actor.
107
+ * @param user Identifies the actor for requests in the scope.
108
+ * @returns Returns the actor request scope.
109
+ */
110
+ onBehalfOf(user) {
111
+ if (user.length === 0)
112
+ throw new TypeError("Client actor must not be empty.");
113
+ return new Request(this.#owner, this.#tenant, this.#zoneId, this.#subscriptions, user);
114
+ }
115
+ /**
116
+ * Closes the client by requesting open-work cancellation, awaiting subscription cleanup, and closing its transport.
117
+ * @returns Completes after subscription cleanup and transport closure.
118
+ */
119
+ close() {
120
+ return this.#owner.close();
121
+ }
122
+ }
123
+ class Request {
124
+ #owner;
125
+ #tenant;
126
+ #zoneId;
127
+ #subscriptions;
128
+ #actor;
129
+ constructor(owner, selectedTenant, selectedZoneId, subscriptions, actor) {
130
+ this.#owner = owner;
131
+ this.#tenant = selectedTenant;
132
+ this.#zoneId = selectedZoneId;
133
+ this.#subscriptions = subscriptions;
134
+ this.#actor = actor;
135
+ }
136
+ async post(schema, message, options = {}) {
137
+ return this.#owner.run(options.signal, async (signal) => {
138
+ const id = this.#owner.createRequestId();
139
+ if (id.length === 0)
140
+ throw new ClientProtocolError("request ID is missing or invalid.");
141
+ const command = SignalEnvelopes.command({
142
+ id: create(CommandIdSchema, { uuid: id }),
143
+ context: create(CommandContextSchema, { actorContext: this.#context() }),
144
+ schema,
145
+ message,
146
+ validate: false,
147
+ });
148
+ const ack = await createClient(CommandService, this.#owner.transport).post(command, {
149
+ signal,
150
+ });
151
+ BrowserClientValues.validateAckId(ack.messageId, id);
152
+ return BrowserClientValues.outcome(ack.status?.status);
153
+ });
154
+ }
155
+ async send(queryOrBuilder, options = {}) {
156
+ return this.#owner.run(options.signal, async (signal) => {
157
+ const source = "build" in queryOrBuilder ? queryOrBuilder.build() : queryOrBuilder;
158
+ const query = clone(QuerySchema, source);
159
+ query.context = this.#context();
160
+ return createClient(QueryService, this.#owner.transport).read(query, { signal });
161
+ });
162
+ }
163
+ async createSubscription(topic, options) {
164
+ const validatedOptions = BrowserClientValues.validateSubscriptionOptions(options);
165
+ return this.#owner.run(validatedOptions.signal, (signal) => Promise.resolve(new TopicSubscription(this.#owner, BrowserClientValues.cloneTopic(topic, this.#context()), signal, validatedOptions, this.#subscriptions)));
166
+ }
167
+ #context() {
168
+ return create(ActorContextSchema, {
169
+ ...(this.#tenant === undefined ? {} : { tenantId: clone(TenantIdSchema, this.#tenant) }),
170
+ zoneId: clone(ZoneIdSchema, this.#zoneId),
171
+ actor: create(UserIdSchema, { value: this.#actor }),
172
+ timestamp: create(TimestampSchema, { seconds: BigInt(Math.floor(Date.now() / 1_000)) }),
173
+ });
174
+ }
175
+ }
176
+ class ClientOwner {
177
+ transport;
178
+ #source;
179
+ #onReauthenticateBeforeReconnect;
180
+ #controllers = new Set();
181
+ #subscriptions = new Set();
182
+ #closed = false;
183
+ #close;
184
+ constructor(source, onReauthenticateBeforeReconnect) {
185
+ this.#source = source;
186
+ this.#onReauthenticateBeforeReconnect = onReauthenticateBeforeReconnect;
187
+ this.transport = source.transport;
188
+ }
189
+ createRequestId() {
190
+ return this.#source.createRequestId();
191
+ }
192
+ async onReauthenticateBeforeReconnect(signal, remainingMs) {
193
+ this.assertOpen();
194
+ const callback = this.#onReauthenticateBeforeReconnect;
195
+ if (callback === undefined)
196
+ return;
197
+ if (!Number.isSafeInteger(remainingMs) || remainingMs <= 0)
198
+ throw new ClientProtocolError("subscription reauthentication retry deadline is exhausted.");
199
+ const controller = new AbortController();
200
+ const abort = () => {
201
+ controller.abort(signal.reason);
202
+ };
203
+ const timeout = setTimeout(() => {
204
+ controller.abort(new ClientProtocolError("subscription reauthentication timed out."));
205
+ }, remainingMs);
206
+ signal.addEventListener("abort", abort, { once: true });
207
+ const pending = Promise.resolve().then(() => callback(controller.signal));
208
+ const terminal = Promise.withResolvers();
209
+ const rejectTerminal = () => {
210
+ terminal.reject(controller.signal.reason ??
211
+ new ClientProtocolError("subscription reauthentication aborted."));
212
+ };
213
+ controller.signal.addEventListener("abort", rejectTerminal, { once: true });
214
+ try {
215
+ await Promise.race([pending, terminal.promise]);
216
+ this.assertOpen();
217
+ }
218
+ finally {
219
+ clearTimeout(timeout);
220
+ signal.removeEventListener("abort", abort);
221
+ controller.signal.removeEventListener("abort", rejectTerminal);
222
+ void pending.catch(() => undefined);
223
+ }
224
+ }
225
+ async run(signal, work) {
226
+ this.assertOpen();
227
+ if (signal?.aborted)
228
+ throw signal.reason;
229
+ const controller = new AbortController();
230
+ const abort = () => {
231
+ controller.abort(signal?.reason);
232
+ };
233
+ signal?.addEventListener("abort", abort, { once: true });
234
+ this.#controllers.add(controller);
235
+ try {
236
+ const result = await work(controller.signal);
237
+ this.assertOpen();
238
+ return result;
239
+ }
240
+ finally {
241
+ this.#controllers.delete(controller);
242
+ signal?.removeEventListener("abort", abort);
243
+ }
244
+ }
245
+ async close() {
246
+ return (this.#close ??= this.closeOwned());
247
+ }
248
+ add(subscription) {
249
+ this.assertOpen();
250
+ this.#subscriptions.add(subscription);
251
+ }
252
+ remove(subscription) {
253
+ this.#subscriptions.delete(subscription);
254
+ }
255
+ assertOpen() {
256
+ if (this.#closed)
257
+ throw new ClientProtocolError("client is closing.");
258
+ }
259
+ async closeOwned() {
260
+ this.#closed = true;
261
+ for (const controller of this.#controllers)
262
+ controller.abort();
263
+ const failures = [];
264
+ try {
265
+ const settled = await Promise.allSettled([...this.#subscriptions].map((subscription) => subscription.cancel()));
266
+ for (const result of settled)
267
+ if (result.status === "rejected")
268
+ failures.push(result.reason);
269
+ }
270
+ finally {
271
+ try {
272
+ this.#source.close?.();
273
+ }
274
+ catch (error) {
275
+ failures.push(error);
276
+ }
277
+ }
278
+ if (failures.length === 1)
279
+ throw failures[0];
280
+ if (failures.length > 1)
281
+ throw new AggregateError(failures, `Client close cleanup failed: ${failures.map((failure) => String(failure)).join("; ")}`);
282
+ }
283
+ }
284
+ class TopicSubscription {
285
+ #owner;
286
+ #topic;
287
+ #signal;
288
+ /**
289
+ * Subscription kind and optional authoritative Entity recovery query.
290
+ */
291
+ #options;
292
+ /**
293
+ * Validated bounded-queue, retry, and scheduler settings.
294
+ */
295
+ #runtime;
296
+ #updates;
297
+ #lifecycle;
298
+ #controller = new AbortController();
299
+ #wire;
300
+ #cancelled = false;
301
+ #generation = 0;
302
+ #terminal = false;
303
+ #wireCleanup;
304
+ #activation;
305
+ #cancellation;
306
+ #streamIterator;
307
+ #retryAttempt = 0;
308
+ #retryStartedAt;
309
+ #connectedAt;
310
+ constructor(owner, topic, signal, options, runtime) {
311
+ this.#owner = owner;
312
+ this.#topic = topic;
313
+ this.#signal = signal;
314
+ this.#options = options;
315
+ this.#runtime = runtime;
316
+ this.#updates = new BrowserClientValues.BoundedChannel("subscription update", runtime.updateCapacity, runtime.updateByteCapacity);
317
+ this.#lifecycle = new BrowserClientValues.BoundedChannel("subscription lifecycle", runtime.lifecycleCapacity);
318
+ owner.add(this);
319
+ }
320
+ get updates() {
321
+ return this.#updates;
322
+ }
323
+ get lifecycle() {
324
+ return this.#lifecycle;
325
+ }
326
+ async activate(options = {}) {
327
+ this.#owner.assertOpen();
328
+ if (this.#cancelled)
329
+ throw new ClientProtocolError("subscription is cancelled.");
330
+ await (this.#activation ??= this.#activateOwned(options.signal));
331
+ }
332
+ cancel() {
333
+ return (this.#cancellation ??= this.#cancelOwned());
334
+ }
335
+ async #activateOwned(signal) {
336
+ if (signal?.aborted)
337
+ throw signal.reason;
338
+ if (this.#signal.aborted)
339
+ throw this.#signal.reason;
340
+ const generation = ++this.#generation;
341
+ const abort = () => {
342
+ this.#controller.abort(signal?.reason ?? this.#signal.reason);
343
+ };
344
+ signal?.addEventListener("abort", abort, { once: true });
345
+ this.#signal.addEventListener("abort", abort, { once: true });
346
+ try {
347
+ this.#pushLifecycle({ state: "connecting", generation, attempt: 0 });
348
+ const pendingSubscription = createClient(SubscriptionService, this.#owner.transport).subscribe(this.#topic, { signal: this.#controller.signal });
349
+ const subscription = await ClientTerminalValues.raceTerminal(pendingSubscription, this.#controller.signal, () => {
350
+ ClientTerminalValues.cancelLateSubscription(pendingSubscription, this.#owner.transport);
351
+ });
352
+ this.#wire = subscription;
353
+ BrowserClientValues.validateSubscription(subscription, this.#topic, true);
354
+ if (generation !== this.#generation) {
355
+ await this.#cancelWireOnce(subscription);
356
+ throw new ClientProtocolError("subscription is cancelled.");
357
+ }
358
+ const updates = createClient(SubscriptionService, this.#owner.transport).activate(subscription, {
359
+ signal: this.#controller.signal,
360
+ });
361
+ const iterator = updates[Symbol.asyncIterator]();
362
+ this.#streamIterator = iterator;
363
+ void this.consumeUpdates(iterator, subscription, generation).catch(() => undefined);
364
+ if (this.#cancelled || generation !== this.#generation) {
365
+ await this.#cancelWireOnce(subscription);
366
+ throw new ClientProtocolError("subscription is cancelled.");
367
+ }
368
+ this.#pushLifecycle({ state: "connected", generation });
369
+ this.#connectedAt = this.#runtime.scheduler.now();
370
+ }
371
+ catch (error) {
372
+ if (this.#cancelled)
373
+ throw error;
374
+ if (this.#retryable(error) && this.#canRetry()) {
375
+ await this.#recover(error, this.#wire);
376
+ return;
377
+ }
378
+ this.#failStreams(error, generation);
379
+ let cleanupFailure;
380
+ try {
381
+ if (this.#wire !== undefined)
382
+ await this.#cancelWireOnce(this.#wire);
383
+ }
384
+ catch (cleanupError) {
385
+ cleanupFailure = cleanupError;
386
+ }
387
+ finally {
388
+ this.#cancelled = true;
389
+ this.#owner.remove(this);
390
+ }
391
+ if (cleanupFailure !== undefined)
392
+ throw new AggregateError([error, cleanupFailure], "Subscription activation cleanup failed.");
393
+ throw error;
394
+ }
395
+ finally {
396
+ signal?.removeEventListener("abort", abort);
397
+ this.#signal.removeEventListener("abort", abort);
398
+ }
399
+ }
400
+ async #cancelOwned() {
401
+ this.#cancelled = true;
402
+ this.#generation++;
403
+ this.#updates.discard();
404
+ this.#finishLifecycle({ state: "closed", generation: this.#generation - 1 });
405
+ this.#controller.abort();
406
+ await this.#disposeLateIterator();
407
+ try {
408
+ if (this.#wire !== undefined)
409
+ await this.#cancelWireOnce(this.#wire);
410
+ }
411
+ finally {
412
+ this.#owner.remove(this);
413
+ }
414
+ }
415
+ async consumeUpdates(updates, subscription, generation) {
416
+ let recovering = false;
417
+ try {
418
+ for (;;) {
419
+ const next = await ClientTerminalValues.raceTerminal(updates.next(), this.#controller.signal);
420
+ if (next.done)
421
+ throw new SubscriptionStreamEndedError("subscription stream ended unexpectedly.");
422
+ if (this.#cancelled || generation !== this.#generation)
423
+ return;
424
+ const update = next.value;
425
+ const topic = subscription.topic;
426
+ if (topic === undefined)
427
+ throw new ClientProtocolError("accepted subscription topic is missing.");
428
+ BrowserClientValues.validateSubscription(update.subscription, topic, true);
429
+ if (update.subscription?.id?.value !== subscription.id?.value)
430
+ throw new ClientProtocolError("subscription update ID does not match the accepted subscription.");
431
+ if (update.response?.status?.status.case === "error" &&
432
+ update.response.status.status.value.type === "backend-unavailable" &&
433
+ update.update.case === undefined) {
434
+ this.#pushLifecycle({ state: "gapPossible", generation });
435
+ continue;
436
+ }
437
+ const delivery = BrowserClientValues.freezeDelivery({
438
+ kind: "update",
439
+ update: clone(SubscriptionUpdateSchema, update),
440
+ });
441
+ const bytes = toBinary(SubscriptionUpdateSchema, update).byteLength;
442
+ this.#pushUpdate(delivery, bytes);
443
+ }
444
+ }
445
+ catch (error) {
446
+ if (this.#cancelled)
447
+ return;
448
+ if (this.#retryable(error) && this.#canRetry()) {
449
+ recovering = true;
450
+ void this.#recover(error, subscription).catch(() => undefined);
451
+ return;
452
+ }
453
+ this.#failStreams(error, generation);
454
+ try {
455
+ await this.#cancelAfterFailure();
456
+ }
457
+ catch {
458
+ // The stream's original terminal error remains observable; cleanup is best effort.
459
+ }
460
+ }
461
+ finally {
462
+ if (!recovering) {
463
+ this.#updates.close();
464
+ this.#lifecycle.close();
465
+ this.#owner.remove(this);
466
+ if (this.#streamIterator === updates)
467
+ this.#streamIterator = undefined;
468
+ }
469
+ }
470
+ }
471
+ #retryable(error) {
472
+ return (error instanceof SubscriptionStreamEndedError ||
473
+ (error instanceof Error && !(error instanceof ClientProtocolError)));
474
+ }
475
+ #canRetry() {
476
+ const now = this.#runtime.scheduler.now();
477
+ const connectedAt = this.#connectedAt;
478
+ this.#connectedAt = undefined;
479
+ if (connectedAt !== undefined && now - connectedAt >= this.#runtime.retryPolicy.maxElapsedMs) {
480
+ this.#retryAttempt = 0;
481
+ this.#retryStartedAt = undefined;
482
+ }
483
+ this.#retryStartedAt ??= now;
484
+ return this.#retryAttempt < this.#runtime.retryPolicy.maxAttempts && !this.#elapsed();
485
+ }
486
+ #elapsed() {
487
+ return (this.#runtime.scheduler.now() - (this.#retryStartedAt ?? 0) >=
488
+ this.#runtime.retryPolicy.maxElapsedMs);
489
+ }
490
+ async #recover(error, previousWire) {
491
+ let failure = error;
492
+ let wire = previousWire;
493
+ let generation = this.#generation;
494
+ try {
495
+ while (this.#retryable(failure) && this.#canRetry()) {
496
+ const attempt = ++this.#retryAttempt;
497
+ generation = ++this.#generation;
498
+ await this.#disposeLateIterator();
499
+ if (wire !== undefined)
500
+ await this.#cancelWireOnce(wire);
501
+ const delay = this.#runtime.retryPolicy.delayMs(attempt);
502
+ if (!Number.isSafeInteger(delay) || delay <= 0)
503
+ throw new ClientProtocolError("subscription retry delay must be a positive safe integer.");
504
+ await ClientTerminalValues.raceTerminal(this.#runtime.scheduler.wait(delay, this.#controller.signal), this.#controller.signal);
505
+ if (this.#controller.signal.aborted)
506
+ throw this.#controller.signal.reason;
507
+ if (this.#elapsed())
508
+ throw failure;
509
+ await ClientTerminalValues.raceTerminal(this.#owner.onReauthenticateBeforeReconnect(this.#controller.signal, this.#remainingRetryMs()), this.#controller.signal);
510
+ if (this.#elapsed())
511
+ throw failure;
512
+ this.#pushLifecycle({ state: "connecting", generation, attempt });
513
+ try {
514
+ const pending = createClient(SubscriptionService, this.#owner.transport).subscribe(this.#topic, { signal: this.#controller.signal });
515
+ const subscription = await ClientTerminalValues.raceTerminal(pending, this.#controller.signal, () => {
516
+ ClientTerminalValues.cancelLateSubscription(pending, this.#owner.transport);
517
+ });
518
+ this.#wire = subscription;
519
+ BrowserClientValues.validateSubscription(subscription, this.#topic, true);
520
+ const updates = createClient(SubscriptionService, this.#owner.transport).activate(subscription, { signal: this.#controller.signal });
521
+ const iterator = updates[Symbol.asyncIterator]();
522
+ this.#streamIterator = iterator;
523
+ if (this.#options.kind === "entity")
524
+ await this.#resynchronize(generation);
525
+ void this.consumeUpdates(iterator, subscription, generation).catch(() => undefined);
526
+ if (this.#options.kind === "event")
527
+ this.#pushLifecycle({ state: "gapPossible", generation });
528
+ this.#pushLifecycle({ state: "connected", generation });
529
+ this.#connectedAt = this.#runtime.scheduler.now();
530
+ return;
531
+ }
532
+ catch (retryFailure) {
533
+ failure = retryFailure;
534
+ wire = this.#wire;
535
+ }
536
+ }
537
+ throw failure;
538
+ }
539
+ catch (recoveryError) {
540
+ if (this.#cancelled)
541
+ throw recoveryError;
542
+ const terminalError = recoveryError instanceof Error ? recoveryError : error;
543
+ await this.#disposeLateIterator();
544
+ this.#failStreams(terminalError, generation);
545
+ try {
546
+ await this.#cancelAfterFailure();
547
+ }
548
+ catch {
549
+ // The terminal failure remains observable when cleanup fails.
550
+ }
551
+ finally {
552
+ this.#updates.close();
553
+ this.#lifecycle.close();
554
+ this.#owner.remove(this);
555
+ }
556
+ throw terminalError;
557
+ }
558
+ }
559
+ #remainingRetryMs() {
560
+ const elapsed = this.#runtime.scheduler.now() - (this.#retryStartedAt ?? 0);
561
+ const remaining = this.#runtime.retryPolicy.maxElapsedMs - elapsed;
562
+ if (!Number.isSafeInteger(remaining) || remaining <= 0)
563
+ throw new ClientProtocolError("subscription reauthentication retry deadline is exhausted.");
564
+ return remaining;
565
+ }
566
+ async #resynchronize(generation) {
567
+ let query;
568
+ try {
569
+ const source = this.#options.kind === "entity" ? this.#options.authoritativeQuery() : undefined;
570
+ if (source === undefined)
571
+ throw new Error("entity recovery query is missing.");
572
+ query = clone(QuerySchema, "build" in source ? source.build() : source);
573
+ }
574
+ catch (error) {
575
+ throw new ClientProtocolError(`authoritative query could not be prepared: ${String(error)}`);
576
+ }
577
+ if (!BrowserClientValues.sameTarget(query.target, this.#topic.target))
578
+ throw new ClientProtocolError("authoritative query target does not match the subscription topic.");
579
+ const topicContext = this.#topic.context;
580
+ if (topicContext === undefined)
581
+ throw new ClientProtocolError("subscription topic context is missing.");
582
+ query.context = clone(ActorContextSchema, topicContext);
583
+ this.#pushLifecycle({ state: "resynchronizing", generation });
584
+ const pending = createClient(QueryService, this.#owner.transport).read(query, {
585
+ signal: this.#controller.signal,
586
+ });
587
+ const response = await ClientTerminalValues.raceTerminal(pending, this.#controller.signal);
588
+ if (response.response?.status?.status.case !== "ok")
589
+ throw new ClientProtocolError("authoritative query response is not OK.");
590
+ if (this.#cancelled || generation !== this.#generation)
591
+ return;
592
+ this.#pushUpdate(BrowserClientValues.freezeResynchronization({
593
+ kind: "resynchronization",
594
+ response: clone(QueryResponseSchema, response),
595
+ }), toBinary(QueryResponseSchema, response).byteLength);
596
+ }
597
+ async #cancelAfterFailure() {
598
+ this.#cancelled = true;
599
+ try {
600
+ if (this.#wire !== undefined)
601
+ await this.#cancelWireOnce(this.#wire);
602
+ }
603
+ finally {
604
+ this.#owner.remove(this);
605
+ }
606
+ }
607
+ #pushUpdate(value, bytes) {
608
+ const error = this.#updates.push(value, bytes);
609
+ if (error !== undefined)
610
+ throw error;
611
+ }
612
+ #pushLifecycle(value) {
613
+ const error = this.#lifecycle.push(value);
614
+ if (error !== undefined)
615
+ throw error;
616
+ }
617
+ #failStreams(error, generation) {
618
+ const terminalError = error instanceof Error ? error : new Error(String(error));
619
+ this.#updates.fail(terminalError);
620
+ if (terminalError instanceof SubscriptionBufferOverflowError) {
621
+ this.#terminal = true;
622
+ this.#lifecycle.fail(terminalError);
623
+ return;
624
+ }
625
+ if (this.#terminal)
626
+ return;
627
+ this.#terminal = true;
628
+ this.#finishLifecycle({
629
+ state: "failed",
630
+ generation,
631
+ error: terminalError,
632
+ });
633
+ }
634
+ #finishLifecycle(value) {
635
+ if (this.#terminal && value.state === "closed")
636
+ return;
637
+ this.#terminal = true;
638
+ this.#lifecycle.finish(value);
639
+ }
640
+ #cancelWireOnce(subscription) {
641
+ if (this.#wireCleanup?.wire === subscription)
642
+ return this.#wireCleanup.promise;
643
+ const promise = ClientTerminalValues.cancelWire(this.#owner.transport, subscription);
644
+ this.#wireCleanup = { wire: subscription, promise };
645
+ return promise;
646
+ }
647
+ async #disposeLateIterator() {
648
+ const iterator = this.#streamIterator;
649
+ if (iterator === undefined)
650
+ return;
651
+ this.#streamIterator = undefined;
652
+ try {
653
+ let timeout;
654
+ try {
655
+ await Promise.race([
656
+ Promise.resolve(iterator.return?.()).catch(() => undefined),
657
+ new Promise((resolve) => {
658
+ timeout = setTimeout(resolve, CLEANUP_TIMEOUT_MS);
659
+ }),
660
+ ]);
661
+ }
662
+ finally {
663
+ if (timeout !== undefined)
664
+ clearTimeout(timeout);
665
+ }
666
+ }
667
+ catch {
668
+ // Local terminal state must not depend on a non-cooperative iterator.
669
+ }
670
+ }
671
+ }
672
+ const CLEANUP_TIMEOUT_MS = 1_000;
673
+ const ClientTerminalValues = Object.freeze({
674
+ raceTerminal(pending, signal, onTerminal) {
675
+ if (signal.aborted) {
676
+ void pending.catch(() => undefined);
677
+ onTerminal?.();
678
+ return Promise.reject(ClientTerminalValues.abortError(signal));
679
+ }
680
+ const terminal = Promise.withResolvers();
681
+ const abort = () => {
682
+ terminal.reject(ClientTerminalValues.abortError(signal));
683
+ };
684
+ signal.addEventListener("abort", abort, { once: true });
685
+ return Promise.race([pending, terminal.promise])
686
+ .catch((error) => {
687
+ if (signal.aborted)
688
+ onTerminal?.();
689
+ throw error;
690
+ })
691
+ .finally(() => {
692
+ signal.removeEventListener("abort", abort);
693
+ });
694
+ },
695
+ abortError(signal) {
696
+ const reason = signal.reason;
697
+ if (reason instanceof Error)
698
+ return reason;
699
+ if (typeof reason === "string")
700
+ return new Error(reason);
701
+ if (typeof reason === "number" || typeof reason === "boolean" || typeof reason === "bigint")
702
+ return new Error(String(reason));
703
+ return new Error("operation aborted.");
704
+ },
705
+ /**
706
+ * Cancels a wire accepted after its local subscription has already terminated.
707
+ */
708
+ cancelLateSubscription(pending, transport) {
709
+ void pending
710
+ .then((subscription) => ClientTerminalValues.cancelWire(transport, subscription))
711
+ .catch(() => undefined);
712
+ },
713
+ async cancelWire(transport, subscription) {
714
+ const controller = new AbortController();
715
+ let timeout;
716
+ const timedOut = new Promise((_, reject) => {
717
+ timeout = setTimeout(() => {
718
+ controller.abort();
719
+ reject(new ClientProtocolError("subscription cleanup timed out."));
720
+ }, CLEANUP_TIMEOUT_MS);
721
+ });
722
+ const remote = createClient(SubscriptionService, transport).cancel(subscription, {
723
+ signal: controller.signal,
724
+ });
725
+ void remote.catch(() => undefined);
726
+ try {
727
+ await Promise.race([remote, timedOut]);
728
+ }
729
+ finally {
730
+ if (timeout !== undefined)
731
+ clearTimeout(timeout);
732
+ }
733
+ },
734
+ });
735
+ /**
736
+ * Builds browser-client request, subscription, and immutable wire values.
737
+ */
738
+ const BrowserClientValues = Object.freeze({
739
+ subscriptionRuntimeOptions(options) {
740
+ return {
741
+ updateCapacity: BrowserClientValues.positiveSubscriptionOption(options?.updateBufferCapacity, 64, "update buffer capacity"),
742
+ updateByteCapacity: BrowserClientValues.positiveSubscriptionOption(options?.updateBufferByteCapacity, 1_048_576, "update buffer byte capacity"),
743
+ lifecycleCapacity: BrowserClientValues.positiveSubscriptionOption(options?.lifecycleBufferCapacity, 32, "lifecycle buffer capacity"),
744
+ retryPolicy: BrowserClientValues.retryPolicy(options?.retryPolicy),
745
+ scheduler: BrowserClientValues.scheduler(options?.scheduler),
746
+ };
747
+ },
748
+ DEFAULT_RETRY_POLICY: {
749
+ maxAttempts: 5,
750
+ maxElapsedMs: 30_000,
751
+ delayMs(attempt) {
752
+ const bounded = Math.min(5_000, 250 * 2 ** Math.max(0, attempt - 1));
753
+ return Math.min(5_000, Math.max(1, Math.round(bounded * (0.8 + Math.random() * 0.4))));
754
+ },
755
+ },
756
+ DEFAULT_SUBSCRIPTION_SCHEDULER: {
757
+ now: () => Date.now(),
758
+ wait: (delayMs, signal) => new Promise((resolve, reject) => {
759
+ if (signal.aborted) {
760
+ reject(ClientTerminalValues.abortError(signal));
761
+ return;
762
+ }
763
+ const timeout = setTimeout(resolve, delayMs);
764
+ signal.addEventListener("abort", () => {
765
+ clearTimeout(timeout);
766
+ reject(ClientTerminalValues.abortError(signal));
767
+ }, { once: true });
768
+ }),
769
+ },
770
+ retryPolicy(policy) {
771
+ const resolved = policy ?? BrowserClientValues.DEFAULT_RETRY_POLICY;
772
+ if (!Number.isSafeInteger(resolved.maxAttempts) || resolved.maxAttempts <= 0)
773
+ throw new TypeError("Client subscription retry max attempts must be a positive safe integer.");
774
+ if (!Number.isSafeInteger(resolved.maxElapsedMs) || resolved.maxElapsedMs <= 0)
775
+ throw new TypeError("Client subscription retry max elapsed time must be a positive safe integer.");
776
+ if (typeof resolved.delayMs !== "function")
777
+ throw new TypeError("Client subscription retry delay must be a function.");
778
+ for (let attempt = 1; attempt <= resolved.maxAttempts; attempt++) {
779
+ const delay = resolved.delayMs(attempt);
780
+ if (!Number.isSafeInteger(delay) || delay <= 0)
781
+ throw new TypeError("Client subscription retry delay must be a positive safe integer.");
782
+ }
783
+ return resolved;
784
+ },
785
+ scheduler(scheduler) {
786
+ const resolved = scheduler ?? BrowserClientValues.DEFAULT_SUBSCRIPTION_SCHEDULER;
787
+ if (typeof resolved.now !== "function" || typeof resolved.wait !== "function")
788
+ throw new TypeError("Client subscription scheduler must provide now() and wait().");
789
+ const now = resolved.now();
790
+ if (!Number.isSafeInteger(now) || now < 0)
791
+ throw new TypeError("Client subscription scheduler time must be a non-negative safe integer.");
792
+ return resolved;
793
+ },
794
+ positiveSubscriptionOption(value, fallback, name) {
795
+ const resolved = value ?? fallback;
796
+ if (!Number.isSafeInteger(resolved) || resolved <= 0)
797
+ throw new TypeError(`Client subscription ${name} must be a positive safe integer.`);
798
+ return resolved;
799
+ },
800
+ validateSubscriptionOptions(options) {
801
+ if (options === null ||
802
+ typeof options !== "object" ||
803
+ !("kind" in options) ||
804
+ (options.kind !== "event" && options.kind !== "entity"))
805
+ throw new TypeError("Subscription kind must be 'event' or 'entity'.");
806
+ if (options.kind === "event") {
807
+ if ("authoritativeQuery" in options)
808
+ throw new TypeError("Event subscriptions must not provide an authoritative query.");
809
+ return options;
810
+ }
811
+ if (!("authoritativeQuery" in options) || typeof options.authoritativeQuery !== "function")
812
+ throw new TypeError("Entity subscriptions require an authoritative query.");
813
+ return options;
814
+ },
815
+ BoundedChannel: class BoundedChannel {
816
+ #name;
817
+ #capacity;
818
+ #byteCapacity;
819
+ #values = [];
820
+ #bytes = 0;
821
+ #consumer = false;
822
+ #closed = false;
823
+ #error;
824
+ #pending;
825
+ constructor(name, capacity, byteCapacity) {
826
+ this.#name = name;
827
+ this.#capacity = capacity;
828
+ this.#byteCapacity = byteCapacity;
829
+ }
830
+ push(value, bytes = 0) {
831
+ if (this.#closed || this.#error !== undefined)
832
+ return new ClientProtocolError(`${this.#name} stream is closed.`);
833
+ if (this.#values.length >= this.#capacity ||
834
+ this.#bytes + bytes > (this.#byteCapacity ?? Infinity)) {
835
+ return new SubscriptionBufferOverflowError(`${this.#name} buffer overflow.`);
836
+ }
837
+ if (this.#pending !== undefined) {
838
+ this.#pending.resolve({ done: false, value });
839
+ this.#pending = undefined;
840
+ return undefined;
841
+ }
842
+ this.#values.push({ value, bytes });
843
+ this.#bytes += bytes;
844
+ return undefined;
845
+ }
846
+ /**
847
+ * Ends after already accepted values have been consumed.
848
+ */
849
+ close() {
850
+ this.#closed = true;
851
+ if (this.#values.length === 0 && this.#error === undefined) {
852
+ this.#pending?.resolve({ done: true, value: undefined });
853
+ this.#pending = undefined;
854
+ }
855
+ }
856
+ /**
857
+ * Discards buffered values for explicit local cancellation.
858
+ */
859
+ discard() {
860
+ this.#closed = true;
861
+ this.#values.length = 0;
862
+ this.#bytes = 0;
863
+ if (this.#error === undefined) {
864
+ this.#pending?.resolve({ done: true, value: undefined });
865
+ this.#pending = undefined;
866
+ }
867
+ }
868
+ /**
869
+ * Appends one terminal notice after the configured non-terminal capacity, then ends.
870
+ * The terminal slot is bounded and never displaces an admitted lifecycle notice.
871
+ */
872
+ finish(value) {
873
+ if (this.#error !== undefined)
874
+ return;
875
+ this.#closed = true;
876
+ if (this.#pending !== undefined) {
877
+ this.#pending.resolve({ done: false, value });
878
+ this.#pending = undefined;
879
+ return;
880
+ }
881
+ this.#values.push({ value, bytes: 0 });
882
+ }
883
+ fail(error) {
884
+ if (this.#error !== undefined)
885
+ return;
886
+ this.#error = error;
887
+ this.#values.length = 0;
888
+ this.#pending?.reject(error);
889
+ this.#pending = undefined;
890
+ }
891
+ [Symbol.asyncIterator]() {
892
+ if (this.#consumer)
893
+ throw new ClientProtocolError(`${this.#name} stream has a single consumer.`);
894
+ this.#consumer = true;
895
+ return { next: () => this.next() };
896
+ }
897
+ next() {
898
+ if (this.#error !== undefined)
899
+ return Promise.reject(this.#error);
900
+ const entry = this.#values.shift();
901
+ if (entry !== undefined) {
902
+ this.#bytes -= entry.bytes;
903
+ return Promise.resolve({ done: false, value: entry.value });
904
+ }
905
+ if (this.#closed)
906
+ return Promise.resolve({ done: true, value: undefined });
907
+ if (this.#pending !== undefined)
908
+ return Promise.reject(new ClientProtocolError(`${this.#name} stream allows only one pending next() call.`));
909
+ this.#pending = Promise.withResolvers();
910
+ return this.#pending.promise;
911
+ }
912
+ },
913
+ freezeDelivery(delivery) {
914
+ return Object.freeze({ ...delivery, update: BrowserClientValues.deepFreeze(delivery.update) });
915
+ },
916
+ freezeResynchronization(delivery) {
917
+ return Object.freeze({
918
+ ...delivery,
919
+ response: BrowserClientValues.deepFreeze(delivery.response),
920
+ });
921
+ },
922
+ deepFreeze(value) {
923
+ if (value === null || typeof value !== "object" || ArrayBuffer.isView(value))
924
+ return value;
925
+ for (const child of Object.values(value))
926
+ BrowserClientValues.deepFreeze(child);
927
+ return Object.freeze(value);
928
+ },
929
+ browserSource(transport) {
930
+ return { transport, createRequestId: BrowserClientValues.browserRequestId };
931
+ },
932
+ browserTransportOptions(baseUrl, options) {
933
+ return {
934
+ baseUrl,
935
+ interceptors: options.onRequestMetadata === undefined
936
+ ? []
937
+ : [BrowserClientValues.requestMetadata(options.onRequestMetadata)],
938
+ ...(options.credentials === undefined
939
+ ? {}
940
+ : { fetch: BrowserClientValues.credentialedFetch(options.credentials) }),
941
+ };
942
+ },
943
+ credentialedFetch(credentials) {
944
+ if (credentials !== "omit" && credentials !== "same-origin" && credentials !== "include")
945
+ throw new TypeError("Browser Fetch credentials must be omit, same-origin, or include.");
946
+ return (input, init) => globalThis.fetch(input, { ...init, credentials });
947
+ },
948
+ requestMetadata(onRequestMetadata) {
949
+ return (next) => async (request) => {
950
+ const metadata = new Headers(onRequestMetadata());
951
+ for (const [name, value] of metadata)
952
+ request.header.set(name, value);
953
+ return next(request);
954
+ };
955
+ },
956
+ browserRequestId() {
957
+ const crypto = globalThis.crypto;
958
+ if (!BrowserClientValues.isBrowserCrypto(crypto))
959
+ throw new ClientProtocolError("secure random browser API is unavailable for request IDs.");
960
+ if (typeof crypto.randomUUID === "function")
961
+ return crypto.randomUUID();
962
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
963
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
964
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
965
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
966
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
967
+ },
968
+ isBrowserCrypto(value) {
969
+ return (value !== null &&
970
+ typeof value === "object" &&
971
+ "getRandomValues" in value &&
972
+ typeof value.getRandomValues === "function");
973
+ },
974
+ cloneTopic(topic, context) {
975
+ const prepared = structuredClone(topic);
976
+ prepared.context = context;
977
+ return prepared;
978
+ },
979
+ validateSubscription(subscription, expectedTopic, allowRewrittenContext = false) {
980
+ if (subscription === undefined || subscription.id?.value.length === 0)
981
+ throw new ClientProtocolError("subscription ID is missing or invalid.");
982
+ if (subscription.topic === undefined ||
983
+ !BrowserClientValues.sameTopic(subscription.topic, expectedTopic, allowRewrittenContext))
984
+ throw new ClientProtocolError("subscription topic does not match the requested topic.");
985
+ },
986
+ sameTopic(left, right, ignoreContext = false) {
987
+ if (ignoreContext) {
988
+ left = { ...left, context: undefined };
989
+ right = { ...right, context: undefined };
990
+ }
991
+ const a = toBinary(TopicSchema, left);
992
+ const b = toBinary(TopicSchema, right);
993
+ return a.length === b.length && a.every((value, index) => value === b[index]);
994
+ },
995
+ sameTarget(left, right) {
996
+ if (left === undefined || right === undefined)
997
+ return left === right;
998
+ const a = toBinary(TargetSchema, left);
999
+ const b = toBinary(TargetSchema, right);
1000
+ return a.length === b.length && a.every((value, index) => value === b[index]);
1001
+ },
1002
+ tenant(value) {
1003
+ if (value === undefined)
1004
+ return undefined;
1005
+ if (typeof value !== "string") {
1006
+ if (value.kind.case !== "value" || value.kind.value.length === 0)
1007
+ throw new TypeError("Client tenant must not be empty.");
1008
+ return clone(TenantIdSchema, value);
1009
+ }
1010
+ if (value.length === 0)
1011
+ throw new TypeError("Client tenant must not be empty.");
1012
+ return create(TenantIdSchema, { kind: { case: "value", value } });
1013
+ },
1014
+ zoneId(value) {
1015
+ if (typeof value !== "string" && value !== undefined) {
1016
+ if (value.value.length === 0)
1017
+ throw new TypeError("Client zoneId must not be empty.");
1018
+ return clone(ZoneIdSchema, value);
1019
+ }
1020
+ const zone = value ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
1021
+ if (zone.length === 0)
1022
+ throw new TypeError("Client zoneId must not be empty.");
1023
+ return create(ZoneIdSchema, { value: zone });
1024
+ },
1025
+ outcome(status) {
1026
+ if (status?.case === "ok")
1027
+ return Object.freeze({ kind: "ok" });
1028
+ if (status?.case === "error")
1029
+ return Object.freeze({
1030
+ kind: "error",
1031
+ error: BrowserClientValues.cloneMessage(status.value),
1032
+ });
1033
+ if (status?.case === "rejection")
1034
+ return Object.freeze({
1035
+ kind: "rejection",
1036
+ rejection: BrowserClientValues.cloneMessage(status.value),
1037
+ });
1038
+ throw new ClientProtocolError("response status is missing or invalid.");
1039
+ },
1040
+ validateAckId(packed, id) {
1041
+ const commandId = packed === undefined ? undefined : AnyMessages.unpack(packed, CommandIdSchema);
1042
+ if (commandId?.uuid !== id)
1043
+ throw new ClientProtocolError("acknowledgement command ID does not match the posted command.");
1044
+ },
1045
+ cloneMessage(message) {
1046
+ return structuredClone(message);
1047
+ },
1048
+ });
1049
+ //# sourceMappingURL=client.js.map