@gscdump/sdk 1.4.0 → 1.4.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,1012 @@
1
+ import { utf8Size } from "../utf8.mjs";
2
+ import { GSCDUMP_REALTIME_ACK_POLICY, GSCDUMP_REALTIME_CLOSE_CODES, GSCDUMP_REALTIME_CONNECTION_POLICY, GSCDUMP_REALTIME_LIMITS, GSCDUMP_REALTIME_PING, GSCDUMP_REALTIME_PONG, GSCDUMP_REALTIME_PROTOCOL_VERSION, GSCDUMP_REALTIME_SUBPROTOCOL, REALTIME_V1_EVENT_NAMES, REALTIME_V1_RESOURCE_TYPES, createRealtimeV1Schemas } from "@gscdump/contracts/v1/realtime";
3
+ const GSCDUMP_REALTIME_V1_SDK_VERSION = "1.4.2";
4
+ var GscdumpRealtimeV1Error = class extends Error {
5
+ tag = "GscdumpRealtimeV1Error";
6
+ code;
7
+ retryable;
8
+ terminal;
9
+ details;
10
+ cause;
11
+ constructor(options) {
12
+ super(options.message);
13
+ this.name = "GscdumpRealtimeV1Error";
14
+ this.code = options.code;
15
+ this.retryable = options.retryable;
16
+ this.terminal = options.terminal;
17
+ this.details = options.details ?? {};
18
+ this.cause = options.cause;
19
+ }
20
+ };
21
+ var StaleEpochError = class extends Error {
22
+ constructor() {
23
+ super("The realtime connection epoch is no longer active.");
24
+ this.name = "StaleEpochError";
25
+ }
26
+ };
27
+ function createMemoryCursorStore() {
28
+ let value = null;
29
+ return {
30
+ load: () => value,
31
+ save: (cursor) => {
32
+ value = { ...cursor };
33
+ }
34
+ };
35
+ }
36
+ function defaultRuntime() {
37
+ return {
38
+ createSocket(url, protocols) {
39
+ if (typeof globalThis.WebSocket !== "function") throw new GscdumpRealtimeV1Error({
40
+ code: "runtime_unavailable",
41
+ message: "This runtime does not provide WebSocket; inject a realtime runtime port.",
42
+ retryable: false,
43
+ terminal: true
44
+ });
45
+ return new globalThis.WebSocket(url, [...protocols]);
46
+ },
47
+ setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms),
48
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
49
+ random: () => Math.random(),
50
+ now: () => Date.now()
51
+ };
52
+ }
53
+ const UTF8_DECODER = new TextDecoder();
54
+ const REALTIME_EVENT_NAMES = new Set(REALTIME_V1_EVENT_NAMES);
55
+ const REALTIME_RESOURCE_TYPES = new Set(REALTIME_V1_RESOURCE_TYPES);
56
+ const APPLIED_EVENT_CACHE_MAX = 1e4;
57
+ async function messageText(raw) {
58
+ if (typeof raw === "string") return raw;
59
+ if (raw instanceof ArrayBuffer) return UTF8_DECODER.decode(raw);
60
+ if (ArrayBuffer.isView(raw)) return UTF8_DECODER.decode(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength));
61
+ if (typeof Blob !== "undefined" && raw instanceof Blob) return raw.text();
62
+ throw new TypeError("Realtime frames must be text or UTF-8 binary data.");
63
+ }
64
+ function compareSequence(left, right) {
65
+ const a = BigInt(left);
66
+ const b = BigInt(right);
67
+ return a < b ? -1 : a > b ? 1 : 0;
68
+ }
69
+ function nextSequence(sequence) {
70
+ return (BigInt(sequence) + 1n).toString();
71
+ }
72
+ function laterCursor(left, right) {
73
+ return compareSequence(left.sequence, right.sequence) >= 0 ? left : right;
74
+ }
75
+ function sameCursor(left, right) {
76
+ return left?.streamId === right?.streamId && left?.sequence === right?.sequence;
77
+ }
78
+ function parseErrorDetails(cause) {
79
+ if (typeof cause === "object" && cause !== null && "issues" in cause) return { issues: cause.issues };
80
+ return {};
81
+ }
82
+ function safeRandom(random) {
83
+ const value = random();
84
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : .5;
85
+ }
86
+ function createGscdumpRealtimeV1Client(options) {
87
+ const schemas = createRealtimeV1Schemas();
88
+ const runtime = options.runtime ?? defaultRuntime();
89
+ const cursorStore = options.cursorStore ?? createMemoryCursorStore();
90
+ const sdkVersion = options.sdkVersion ?? "1.4.2";
91
+ if (!sdkVersion) throw new TypeError("sdkVersion cannot be empty.");
92
+ let running = false;
93
+ let epoch = 0;
94
+ let reconnectTimer;
95
+ let heartbeatTimer;
96
+ let rotationTimer;
97
+ let current = null;
98
+ let immediateExpiredTicketRetry = true;
99
+ let cursor = null;
100
+ let streamId = null;
101
+ let head = null;
102
+ let attempt = 0;
103
+ let transport = "idle";
104
+ let freshness = "unknown";
105
+ let lastError = null;
106
+ const eventFailures = /* @__PURE__ */ new Map();
107
+ const appliedEventIds = /* @__PURE__ */ new Set();
108
+ const appliedEventOrder = [];
109
+ let appliedEventCursor = 0;
110
+ function getSnapshot() {
111
+ return {
112
+ transport,
113
+ freshness,
114
+ attempt,
115
+ streamId,
116
+ cursor: cursor ? { ...cursor } : null,
117
+ head: head ? { ...head } : null,
118
+ error: lastError
119
+ };
120
+ }
121
+ function observe(observation) {
122
+ if (!options.onObservation) return;
123
+ try {
124
+ Promise.resolve(options.onObservation(observation)).catch(() => {});
125
+ } catch {}
126
+ }
127
+ function emitState() {
128
+ observe({
129
+ type: "state",
130
+ state: getSnapshot()
131
+ });
132
+ }
133
+ function setState(nextTransport, nextFreshness = freshness) {
134
+ transport = nextTransport;
135
+ freshness = nextFreshness;
136
+ emitState();
137
+ }
138
+ function reportError(error) {
139
+ lastError = error;
140
+ observe({
141
+ type: "error",
142
+ error,
143
+ state: getSnapshot()
144
+ });
145
+ emitState();
146
+ }
147
+ function clearTimer(handle) {
148
+ if (handle !== void 0) runtime.clearTimeout(handle);
149
+ }
150
+ function clearConnectionTimers() {
151
+ clearTimer(heartbeatTimer);
152
+ clearTimer(rotationTimer);
153
+ heartbeatTimer = void 0;
154
+ rotationTimer = void 0;
155
+ }
156
+ function ensureEpoch(context) {
157
+ if (!running || current !== context || context.closed || context.epoch !== epoch) throw new StaleEpochError();
158
+ }
159
+ function closeSocket(context, code, reason) {
160
+ if (context.closed) return;
161
+ context.closed = true;
162
+ if (current === context) {
163
+ current = null;
164
+ epoch++;
165
+ }
166
+ clearConnectionTimers();
167
+ try {
168
+ context.socket.close(code, reason);
169
+ } catch {}
170
+ }
171
+ function reconnectBackoff() {
172
+ const exponent = Math.max(0, attempt - 1);
173
+ const cap = Math.min(GSCDUMP_REALTIME_CONNECTION_POLICY.reconnectMaxDelayMs, GSCDUMP_REALTIME_CONNECTION_POLICY.reconnectBaseDelayMs * 2 ** exponent);
174
+ return cap / 2 + safeRandom(runtime.random) * cap / 2;
175
+ }
176
+ function scheduleReconnect(context, reason, minimumDelayMs = 0, immediate = false) {
177
+ if (!running) return;
178
+ if (context) closeSocket(context, GSCDUMP_REALTIME_CLOSE_CODES.internalError, reason);
179
+ clearTimer(reconnectTimer);
180
+ attempt++;
181
+ const delay = immediate ? minimumDelayMs : Math.max(minimumDelayMs, reconnectBackoff());
182
+ setState("waiting", freshness === "unknown" || freshness === "degraded" ? freshness : "stale");
183
+ reconnectTimer = runtime.setTimeout(() => {
184
+ reconnectTimer = void 0;
185
+ beginAttempt();
186
+ }, delay);
187
+ }
188
+ function terminal(error, context = current) {
189
+ running = false;
190
+ clearTimer(reconnectTimer);
191
+ reconnectTimer = void 0;
192
+ if (context) closeSocket(context, GSCDUMP_REALTIME_CLOSE_CODES.normal, "terminal");
193
+ freshness = "degraded";
194
+ transport = "terminal";
195
+ reportError(error);
196
+ }
197
+ function assertStream(value, context, label) {
198
+ if (value.streamId !== context.ticket.head.streamId) throw new GscdumpRealtimeV1Error({
199
+ code: "protocol_error",
200
+ message: `${label} did not match the ticket's exact stream.`,
201
+ retryable: false,
202
+ terminal: true,
203
+ details: {
204
+ expected: context.ticket.head.streamId,
205
+ received: value.streamId
206
+ }
207
+ });
208
+ }
209
+ function sendJson(context, value) {
210
+ ensureEpoch(context);
211
+ const serialized = JSON.stringify(value);
212
+ if (utf8Size(serialized) > GSCDUMP_REALTIME_LIMITS.clientFrameMaxBytes) throw new GscdumpRealtimeV1Error({
213
+ code: "protocol_error",
214
+ message: "The generated client frame exceeded the v1 size limit.",
215
+ retryable: false,
216
+ terminal: true
217
+ });
218
+ try {
219
+ context.socket.send(serialized);
220
+ } catch (cause) {
221
+ throw new GscdumpRealtimeV1Error({
222
+ code: "socket_error",
223
+ message: "Could not send a realtime client frame.",
224
+ retryable: true,
225
+ terminal: false,
226
+ cause
227
+ });
228
+ }
229
+ }
230
+ function sendAck(context, appliedCursor) {
231
+ sendJson(context, schemas.ackFrame.parse({
232
+ type: "ack",
233
+ cursor: appliedCursor
234
+ }));
235
+ }
236
+ function eventDedupeKey(stream, id) {
237
+ return `${stream}:${id}`;
238
+ }
239
+ function rememberEvent(stream, id) {
240
+ const key = eventDedupeKey(stream, id);
241
+ if (appliedEventIds.has(key)) return;
242
+ appliedEventIds.add(key);
243
+ if (appliedEventOrder.length < APPLIED_EVENT_CACHE_MAX) appliedEventOrder.push(key);
244
+ else {
245
+ const oldest = appliedEventOrder[appliedEventCursor];
246
+ if (oldest) appliedEventIds.delete(oldest);
247
+ appliedEventOrder[appliedEventCursor] = key;
248
+ appliedEventCursor = (appliedEventCursor + 1) % APPLIED_EVENT_CACHE_MAX;
249
+ }
250
+ }
251
+ function eventFailureKey(event) {
252
+ return `${event.cursor?.streamId ?? "ephemeral"}:${event.id}:${event.cursor?.sequence ?? "ephemeral"}`;
253
+ }
254
+ function eventForRequiredEffect(event) {
255
+ return REALTIME_EVENT_NAMES.has(event.name) && event.eventVersion === 1 ? event : {
256
+ ...event,
257
+ data: null
258
+ };
259
+ }
260
+ function eventAdvisories(event) {
261
+ if (!REALTIME_EVENT_NAMES.has(event.name)) observe({
262
+ type: "advisory",
263
+ code: "unknown_event",
264
+ event
265
+ });
266
+ else if (event.eventVersion !== 1) observe({
267
+ type: "advisory",
268
+ code: "unsupported_event_version",
269
+ event
270
+ });
271
+ if (event.changes.some((change) => !REALTIME_RESOURCE_TYPES.has(change.type))) observe({
272
+ type: "advisory",
273
+ code: "unknown_resource",
274
+ event
275
+ });
276
+ }
277
+ async function saveCursor(context, nextCursor) {
278
+ ensureEpoch(context);
279
+ await cursorStore.save(nextCursor);
280
+ ensureEpoch(context);
281
+ }
282
+ async function performResync(context, request) {
283
+ ensureEpoch(context);
284
+ setState("handshaking", "resyncing");
285
+ try {
286
+ await options.resync(request);
287
+ ensureEpoch(context);
288
+ } catch (cause) {
289
+ if (cause instanceof StaleEpochError || !running || current !== context || context.closed) return;
290
+ terminal(new GscdumpRealtimeV1Error({
291
+ code: "resync_failed",
292
+ message: `Realtime resync failed (${request.reason}).`,
293
+ retryable: false,
294
+ terminal: true,
295
+ details: {
296
+ reason: request.reason,
297
+ resumeAfter: request.resumeAfter
298
+ },
299
+ cause
300
+ }), context);
301
+ return;
302
+ }
303
+ try {
304
+ await saveCursor(context, request.resumeAfter);
305
+ } catch (cause) {
306
+ if (cause instanceof StaleEpochError || !running || current !== context || context.closed) return;
307
+ terminal(new GscdumpRealtimeV1Error({
308
+ code: "cursor_store_failed",
309
+ message: "Realtime resync completed, but its resume cursor could not be persisted.",
310
+ retryable: false,
311
+ terminal: true,
312
+ details: { resumeAfter: request.resumeAfter },
313
+ cause
314
+ }), context);
315
+ return;
316
+ }
317
+ cursor = { ...request.resumeAfter };
318
+ eventFailures.clear();
319
+ emitState();
320
+ scheduleReconnect(context, "resync-complete", 0, true);
321
+ }
322
+ async function handleEventFailure(context, event, code, cause) {
323
+ if (cause instanceof StaleEpochError) return;
324
+ ensureEpoch(context);
325
+ const key = eventFailureKey(event);
326
+ const failures = (eventFailures.get(key) ?? 0) + 1;
327
+ eventFailures.set(key, failures);
328
+ const error = new GscdumpRealtimeV1Error({
329
+ code,
330
+ message: code === "effect_failed" ? `The required effect rejected for durable event ${event.id}.` : `The cursor store rejected durable event ${event.id}.`,
331
+ retryable: failures < GSCDUMP_REALTIME_ACK_POLICY.effectRetryBeforeUnsafeResync,
332
+ terminal: false,
333
+ details: {
334
+ eventId: event.id,
335
+ sequence: event.cursor?.sequence,
336
+ failures
337
+ },
338
+ cause
339
+ });
340
+ freshness = "degraded";
341
+ reportError(error);
342
+ if (failures < GSCDUMP_REALTIME_ACK_POLICY.effectRetryBeforeUnsafeResync) {
343
+ scheduleReconnect(context, code);
344
+ return;
345
+ }
346
+ const eventCursor = event.cursor;
347
+ if (!eventCursor) return;
348
+ const resumeAfter = context.head ? laterCursor(context.head, eventCursor) : eventCursor;
349
+ await performResync(context, {
350
+ reason: "effect_application_failed",
351
+ source: "client",
352
+ streamId: eventCursor.streamId,
353
+ previousCursor: cursor,
354
+ resumeAfter,
355
+ failedEvent: event,
356
+ cause
357
+ });
358
+ }
359
+ async function applyDurableEvent(context, event) {
360
+ ensureEpoch(context);
361
+ assertStream(event.cursor, context, "event cursor");
362
+ if (context.replayThrough && compareSequence(event.cursor.sequence, context.replayThrough.sequence) > 0) throw new GscdumpRealtimeV1Error({
363
+ code: "protocol_error",
364
+ message: "A live event arrived before the declared replay completed.",
365
+ retryable: false,
366
+ terminal: true
367
+ });
368
+ if (!context.replaying && (!context.head || compareSequence(event.cursor.sequence, context.head.sequence) > 0)) {
369
+ context.head = { ...event.cursor };
370
+ head = { ...event.cursor };
371
+ }
372
+ if (!cursor) {
373
+ await performResync(context, {
374
+ reason: "initial_cursor_missing",
375
+ source: "client",
376
+ streamId: event.cursor.streamId,
377
+ previousCursor: null,
378
+ resumeAfter: context.head ? laterCursor(context.head, event.cursor) : event.cursor
379
+ });
380
+ return;
381
+ }
382
+ assertStream(cursor, context, "stored cursor");
383
+ if (compareSequence(event.cursor.sequence, cursor.sequence) <= 0) {
384
+ sendAck(context, cursor);
385
+ return;
386
+ }
387
+ if (event.cursor.sequence !== nextSequence(cursor.sequence)) {
388
+ const resumeAfter = context.head ? laterCursor(context.head, event.cursor) : event.cursor;
389
+ await performResync(context, {
390
+ reason: "sequence_gap",
391
+ source: "client",
392
+ streamId: event.cursor.streamId,
393
+ previousCursor: cursor,
394
+ resumeAfter
395
+ });
396
+ return;
397
+ }
398
+ setState(context.replaying ? "replaying" : "live", "applying");
399
+ const effectEvent = eventForRequiredEffect(event);
400
+ if (!appliedEventIds.has(eventDedupeKey(event.cursor.streamId, event.id))) try {
401
+ await options.applyEvent(effectEvent);
402
+ ensureEpoch(context);
403
+ } catch (cause) {
404
+ await handleEventFailure(context, event, "effect_failed", cause);
405
+ return;
406
+ }
407
+ try {
408
+ await saveCursor(context, event.cursor);
409
+ } catch (cause) {
410
+ await handleEventFailure(context, event, "cursor_store_failed", cause);
411
+ return;
412
+ }
413
+ cursor = { ...event.cursor };
414
+ rememberEvent(event.cursor.streamId, event.id);
415
+ eventFailures.delete(eventFailureKey(event));
416
+ sendAck(context, cursor);
417
+ freshness = context.replaying ? "stale" : "fresh";
418
+ transport = context.replaying ? "replaying" : "live";
419
+ emitState();
420
+ eventAdvisories(effectEvent);
421
+ observe({
422
+ type: "event",
423
+ event: effectEvent,
424
+ cursor: { ...cursor }
425
+ });
426
+ }
427
+ function scheduleHeartbeat(context) {
428
+ clearTimer(heartbeatTimer);
429
+ heartbeatTimer = runtime.setTimeout(() => {
430
+ heartbeatTimer = void 0;
431
+ if (!running || current !== context || context.closed) return;
432
+ const staleFor = runtime.now() - context.lastPongAt;
433
+ if (staleFor >= GSCDUMP_REALTIME_CONNECTION_POLICY.staleAfterMs) {
434
+ const error = new GscdumpRealtimeV1Error({
435
+ code: "heartbeat_stale",
436
+ message: "Realtime heartbeat became stale.",
437
+ retryable: true,
438
+ terminal: false,
439
+ details: { staleFor }
440
+ });
441
+ freshness = "stale";
442
+ reportError(error);
443
+ scheduleReconnect(context, "heartbeat-stale");
444
+ return;
445
+ }
446
+ try {
447
+ context.socket.send(GSCDUMP_REALTIME_PING);
448
+ } catch (cause) {
449
+ reportError(new GscdumpRealtimeV1Error({
450
+ code: "socket_error",
451
+ message: "Could not send the realtime heartbeat.",
452
+ retryable: true,
453
+ terminal: false,
454
+ cause
455
+ }));
456
+ scheduleReconnect(context, "heartbeat-send");
457
+ return;
458
+ }
459
+ scheduleHeartbeat(context);
460
+ }, GSCDUMP_REALTIME_CONNECTION_POLICY.heartbeatIntervalMs);
461
+ }
462
+ function scheduleRotation(context, expiresAt) {
463
+ clearTimer(rotationTimer);
464
+ const delay = Math.max(0, Date.parse(expiresAt) - runtime.now() - GSCDUMP_REALTIME_CONNECTION_POLICY.connectionRefreshBeforeExpiryMs);
465
+ rotationTimer = runtime.setTimeout(() => {
466
+ rotationTimer = void 0;
467
+ if (running && current === context && !context.closed) scheduleReconnect(context, "connection-expiry-rotation", 0, true);
468
+ }, delay);
469
+ }
470
+ async function handleReady(context, frame) {
471
+ ensureEpoch(context);
472
+ if (context.ready) throw new GscdumpRealtimeV1Error({
473
+ code: "protocol_error",
474
+ message: "The server sent more than one ready frame.",
475
+ retryable: false,
476
+ terminal: true
477
+ });
478
+ assertStream(frame, context, "ready frame");
479
+ assertStream(frame.head, context, "ready head");
480
+ assertStream(frame.replayFloor, context, "ready replay floor");
481
+ if (compareSequence(frame.head.sequence, context.ticket.head.sequence) < 0) throw new GscdumpRealtimeV1Error({
482
+ code: "protocol_error",
483
+ message: "The ready head regressed behind the ticket head.",
484
+ retryable: false,
485
+ terminal: true,
486
+ details: {
487
+ ticketHead: context.ticket.head,
488
+ readyHead: frame.head
489
+ }
490
+ });
491
+ const expiresAt = Date.parse(frame.expiresAt);
492
+ if (!Number.isFinite(expiresAt) || expiresAt <= runtime.now() || expiresAt - runtime.now() > context.ticket.maxConnectionSeconds * 1e3) throw new GscdumpRealtimeV1Error({
493
+ code: "protocol_error",
494
+ message: "The ready frame carried an invalid signed connection expiry.",
495
+ retryable: false,
496
+ terminal: true,
497
+ details: { expiresAt: frame.expiresAt }
498
+ });
499
+ context.ready = true;
500
+ context.head = { ...frame.head };
501
+ context.lastPongAt = runtime.now();
502
+ head = { ...frame.head };
503
+ streamId = frame.streamId;
504
+ attempt = 0;
505
+ lastError = null;
506
+ scheduleHeartbeat(context);
507
+ scheduleRotation(context, frame.expiresAt);
508
+ if (!cursor) {
509
+ await performResync(context, {
510
+ reason: "initial_cursor_missing",
511
+ source: "client",
512
+ streamId: frame.streamId,
513
+ previousCursor: null,
514
+ resumeAfter: frame.head
515
+ });
516
+ return;
517
+ }
518
+ if (cursor.streamId !== frame.streamId) {
519
+ await performResync(context, {
520
+ reason: "stream_mismatch",
521
+ source: "client",
522
+ streamId: frame.streamId,
523
+ previousCursor: cursor,
524
+ resumeAfter: frame.head
525
+ });
526
+ return;
527
+ }
528
+ if (compareSequence(cursor.sequence, frame.head.sequence) > 0) {
529
+ await performResync(context, {
530
+ reason: "sequence_gap",
531
+ source: "client",
532
+ streamId: frame.streamId,
533
+ previousCursor: cursor,
534
+ resumeAfter: frame.head
535
+ });
536
+ return;
537
+ }
538
+ if (compareSequence(cursor.sequence, frame.replayFloor.sequence) < 0) {
539
+ await performResync(context, {
540
+ reason: "cursor_expired",
541
+ source: "client",
542
+ streamId: frame.streamId,
543
+ previousCursor: cursor,
544
+ resumeAfter: frame.head
545
+ });
546
+ return;
547
+ }
548
+ if (sameCursor(cursor, frame.head)) {
549
+ context.replaying = false;
550
+ setState("live", "fresh");
551
+ } else {
552
+ context.replaying = true;
553
+ setState("replaying", "stale");
554
+ }
555
+ }
556
+ async function processFrame(context, frame) {
557
+ ensureEpoch(context);
558
+ if (frame.type === "ready") {
559
+ await handleReady(context, frame);
560
+ return;
561
+ }
562
+ if (frame.type === "error") {
563
+ const retryable = !(frame.error.code === "invalid_frame" || frame.error.code === "protocol_mismatch" || frame.error.code === "policy_violation") && frame.error.retryable;
564
+ const error = new GscdumpRealtimeV1Error({
565
+ code: frame.error.code === "internal_error" || frame.error.code === "overloaded" ? "socket_error" : "protocol_error",
566
+ message: frame.error.message,
567
+ retryable,
568
+ terminal: !retryable,
569
+ details: {
570
+ serverCode: frame.error.code,
571
+ retryAfter: frame.error.retryAfter
572
+ }
573
+ });
574
+ reportError(error);
575
+ if (retryable) {
576
+ context.retryAfterMs = frame.error.retryAfter === void 0 ? void 0 : frame.error.retryAfter * 1e3;
577
+ scheduleReconnect(context, "server-error", context.retryAfterMs);
578
+ } else terminal(error, context);
579
+ return;
580
+ }
581
+ if (!context.ready) throw new GscdumpRealtimeV1Error({
582
+ code: "protocol_error",
583
+ message: `The server sent ${frame.type} before ready.`,
584
+ retryable: false,
585
+ terminal: true
586
+ });
587
+ if (frame.type === "replay.begin") {
588
+ assertStream(frame.through, context, "replay through cursor");
589
+ if (frame.after) assertStream(frame.after, context, "replay after cursor");
590
+ if (context.replayThrough) throw new GscdumpRealtimeV1Error({
591
+ code: "protocol_error",
592
+ message: "The server sent a nested replay.begin frame.",
593
+ retryable: false,
594
+ terminal: true
595
+ });
596
+ if (!sameCursor(frame.after, cursor)) {
597
+ await performResync(context, {
598
+ reason: "sequence_gap",
599
+ source: "client",
600
+ streamId: context.ticket.head.streamId,
601
+ previousCursor: cursor,
602
+ resumeAfter: frame.through
603
+ });
604
+ return;
605
+ }
606
+ if (!context.head || !sameCursor(frame.through, context.head)) throw new GscdumpRealtimeV1Error({
607
+ code: "protocol_error",
608
+ message: "The replay boundary did not match the ready head.",
609
+ retryable: false,
610
+ terminal: true
611
+ });
612
+ context.replaying = true;
613
+ context.replayThrough = { ...frame.through };
614
+ setState("replaying", "stale");
615
+ return;
616
+ }
617
+ if (frame.type === "replay.end") {
618
+ assertStream(frame.through, context, "replay end cursor");
619
+ if (!context.replaying || !context.replayThrough || !sameCursor(context.replayThrough, frame.through) || !sameCursor(cursor, frame.through)) {
620
+ await performResync(context, {
621
+ reason: "sequence_gap",
622
+ source: "client",
623
+ streamId: frame.through.streamId,
624
+ previousCursor: cursor,
625
+ resumeAfter: context.head ? laterCursor(context.head, frame.through) : frame.through
626
+ });
627
+ return;
628
+ }
629
+ context.replaying = false;
630
+ context.replayThrough = null;
631
+ setState("live", "fresh");
632
+ return;
633
+ }
634
+ if (frame.type === "resync.required") {
635
+ assertStream(frame.scope, context, "resync scope");
636
+ assertStream(frame.resumeAfter, context, "resync cursor");
637
+ const minimumSequence = context.head && cursor ? laterCursor(context.head, cursor).sequence : context.head?.sequence ?? cursor?.sequence;
638
+ if (minimumSequence !== void 0 && compareSequence(frame.resumeAfter.sequence, minimumSequence) < 0) throw new GscdumpRealtimeV1Error({
639
+ code: "protocol_error",
640
+ message: "The resync cursor regressed behind observed stream state.",
641
+ retryable: false,
642
+ terminal: true,
643
+ details: {
644
+ minimumSequence,
645
+ resumeAfter: frame.resumeAfter
646
+ }
647
+ });
648
+ await performResync(context, {
649
+ reason: frame.reason,
650
+ source: "server",
651
+ streamId: frame.scope.streamId,
652
+ previousCursor: cursor,
653
+ resumeAfter: frame.resumeAfter
654
+ });
655
+ return;
656
+ }
657
+ if (frame.type === "batch") {
658
+ if (context.replaying && !context.replayThrough) throw new GscdumpRealtimeV1Error({
659
+ code: "protocol_error",
660
+ message: "A replay batch arrived before replay.begin.",
661
+ retryable: false,
662
+ terminal: true
663
+ });
664
+ for (const event of frame.events) {
665
+ ensureEpoch(context);
666
+ await applyDurableEvent(context, event);
667
+ }
668
+ return;
669
+ }
670
+ if (frame.type === "event") {
671
+ if (frame.delivery === "ephemeral") {
672
+ const observedEvent = eventForRequiredEffect(frame);
673
+ eventAdvisories(observedEvent);
674
+ observe({
675
+ type: "event",
676
+ event: observedEvent,
677
+ cursor: cursor ? { ...cursor } : null
678
+ });
679
+ return;
680
+ }
681
+ if (context.replaying && !context.replayThrough) throw new GscdumpRealtimeV1Error({
682
+ code: "protocol_error",
683
+ message: "A durable replay event arrived before replay.begin.",
684
+ retryable: false,
685
+ terminal: true
686
+ });
687
+ await applyDurableEvent(context, frame);
688
+ }
689
+ }
690
+ async function processRawMessage(context, raw) {
691
+ ensureEpoch(context);
692
+ const text = await messageText(raw);
693
+ ensureEpoch(context);
694
+ if (text === GSCDUMP_REALTIME_PONG) {
695
+ context.lastPongAt = runtime.now();
696
+ return;
697
+ }
698
+ const textBytes = utf8Size(text);
699
+ if (textBytes > GSCDUMP_REALTIME_LIMITS.outboundFrameMaxBytes) throw new GscdumpRealtimeV1Error({
700
+ code: "protocol_error",
701
+ message: "A server frame exceeded the v1 outbound frame limit.",
702
+ retryable: false,
703
+ terminal: true
704
+ });
705
+ let rawFrame;
706
+ try {
707
+ rawFrame = JSON.parse(text);
708
+ } catch (cause) {
709
+ throw new GscdumpRealtimeV1Error({
710
+ code: "protocol_error",
711
+ message: "The server sent malformed realtime JSON.",
712
+ retryable: false,
713
+ terminal: true,
714
+ cause
715
+ });
716
+ }
717
+ const parsed = schemas.serverFrame.safeParse(rawFrame);
718
+ if (!parsed.success) throw new GscdumpRealtimeV1Error({
719
+ code: "protocol_error",
720
+ message: "The server frame did not match the v1 protocol.",
721
+ retryable: false,
722
+ terminal: true,
723
+ details: parseErrorDetails(parsed.error),
724
+ cause: parsed.error
725
+ });
726
+ if (parsed.data.type === "event") {
727
+ if (textBytes > (parsed.data.delivery === "durable" ? GSCDUMP_REALTIME_LIMITS.durableEventMaxBytes : GSCDUMP_REALTIME_LIMITS.ephemeralEventMaxBytes)) throw new GscdumpRealtimeV1Error({
728
+ code: "protocol_error",
729
+ message: `A ${parsed.data.delivery} event exceeded its v1 size limit.`,
730
+ retryable: false,
731
+ terminal: true
732
+ });
733
+ }
734
+ if (parsed.data.type === "batch") {
735
+ for (const event of parsed.data.events) if (utf8Size(JSON.stringify(event)) > GSCDUMP_REALTIME_LIMITS.durableEventMaxBytes) throw new GscdumpRealtimeV1Error({
736
+ code: "protocol_error",
737
+ message: "A durable event inside a batch exceeded its v1 size limit.",
738
+ retryable: false,
739
+ terminal: true
740
+ });
741
+ }
742
+ await processFrame(context, parsed.data);
743
+ }
744
+ function handleFrameQueueError(context, cause) {
745
+ if (cause instanceof StaleEpochError || !running || current !== context) return;
746
+ const error = cause instanceof GscdumpRealtimeV1Error ? cause : new GscdumpRealtimeV1Error({
747
+ code: "protocol_error",
748
+ message: "Realtime frame processing failed.",
749
+ retryable: false,
750
+ terminal: true,
751
+ cause
752
+ });
753
+ if (error.terminal) {
754
+ terminal(error, context);
755
+ return;
756
+ }
757
+ reportError(error);
758
+ scheduleReconnect(context, error.code);
759
+ }
760
+ async function loadCursor(ticket) {
761
+ let stored;
762
+ try {
763
+ stored = await cursorStore.load();
764
+ } catch (cause) {
765
+ throw new GscdumpRealtimeV1Error({
766
+ code: "cursor_store_failed",
767
+ message: "Could not load the realtime cursor.",
768
+ retryable: false,
769
+ terminal: true,
770
+ cause
771
+ });
772
+ }
773
+ if (stored === null) return null;
774
+ const parsed = schemas.cursor.safeParse(stored);
775
+ if (!parsed.success) {
776
+ reportError(new GscdumpRealtimeV1Error({
777
+ code: "cursor_store_failed",
778
+ message: "The stored realtime cursor was invalid; a full resync is required.",
779
+ retryable: true,
780
+ terminal: false,
781
+ details: parseErrorDetails(parsed.error),
782
+ cause: parsed.error
783
+ }));
784
+ return null;
785
+ }
786
+ if (parsed.data.streamId !== ticket.head.streamId) return null;
787
+ return parsed.data;
788
+ }
789
+ async function provideFreshTicket() {
790
+ let raw;
791
+ try {
792
+ raw = await options.ticketProvider();
793
+ } catch (cause) {
794
+ throw new GscdumpRealtimeV1Error({
795
+ code: "ticket_provider_failed",
796
+ message: "The realtime ticket provider rejected.",
797
+ retryable: true,
798
+ terminal: false,
799
+ cause
800
+ });
801
+ }
802
+ const parsed = schemas.ticketResponseClient.safeParse(raw);
803
+ if (!parsed.success) throw new GscdumpRealtimeV1Error({
804
+ code: "ticket_invalid",
805
+ message: "The realtime ticket response did not match the v1 contract.",
806
+ retryable: false,
807
+ terminal: true,
808
+ details: parseErrorDetails(parsed.error),
809
+ cause: parsed.error
810
+ });
811
+ if (Date.parse(parsed.data.data.expiresAt) <= runtime.now()) throw new GscdumpRealtimeV1Error({
812
+ code: "ticket_invalid",
813
+ message: "The realtime ticket had already expired locally.",
814
+ retryable: true,
815
+ terminal: false,
816
+ details: { localExpiry: true }
817
+ });
818
+ return parsed.data.data;
819
+ }
820
+ async function beginAttempt() {
821
+ if (!running) return;
822
+ const attemptEpoch = ++epoch;
823
+ setState("ticketing", freshness === "fresh" ? "stale" : freshness);
824
+ let ticket;
825
+ try {
826
+ ticket = await provideFreshTicket();
827
+ } catch (cause) {
828
+ if (!running || epoch !== attemptEpoch) return;
829
+ const error = cause instanceof GscdumpRealtimeV1Error ? cause : new GscdumpRealtimeV1Error({
830
+ code: "ticket_provider_failed",
831
+ message: "Could not obtain a realtime ticket.",
832
+ retryable: true,
833
+ terminal: false,
834
+ cause
835
+ });
836
+ if (error.code === "ticket_invalid" && error.details.localExpiry === true && immediateExpiredTicketRetry) {
837
+ immediateExpiredTicketRetry = false;
838
+ beginAttempt();
839
+ return;
840
+ }
841
+ reportError(error);
842
+ if (error.terminal) terminal(error, null);
843
+ else scheduleReconnect(null, error.code);
844
+ return;
845
+ }
846
+ immediateExpiredTicketRetry = true;
847
+ if (!running || epoch !== attemptEpoch) return;
848
+ try {
849
+ cursor = await loadCursor(ticket);
850
+ } catch (cause) {
851
+ if (!running || epoch !== attemptEpoch) return;
852
+ terminal(cause, null);
853
+ return;
854
+ }
855
+ if (!running || epoch !== attemptEpoch) return;
856
+ streamId = ticket.head.streamId;
857
+ head = { ...ticket.head };
858
+ emitState();
859
+ let socket;
860
+ try {
861
+ socket = runtime.createSocket(ticket.socketUrl, [ticket.protocol, ticket.ticket]);
862
+ } catch (cause) {
863
+ terminal(cause instanceof GscdumpRealtimeV1Error ? cause : new GscdumpRealtimeV1Error({
864
+ code: "runtime_unavailable",
865
+ message: "Could not create the realtime WebSocket.",
866
+ retryable: false,
867
+ terminal: true,
868
+ cause
869
+ }), null);
870
+ return;
871
+ }
872
+ const context = {
873
+ epoch: attemptEpoch,
874
+ socket,
875
+ ticket,
876
+ accepted: false,
877
+ ready: false,
878
+ closed: false,
879
+ replaying: false,
880
+ replayThrough: null,
881
+ lastPongAt: runtime.now(),
882
+ head: null,
883
+ retryAfterMs: void 0,
884
+ frameQueue: Promise.resolve()
885
+ };
886
+ current = context;
887
+ setState("connecting", freshness);
888
+ socket.onopen = () => {
889
+ if (!running || current !== context || context.closed) return;
890
+ context.accepted = true;
891
+ if (socket.protocol !== GSCDUMP_REALTIME_SUBPROTOCOL) {
892
+ terminal(new GscdumpRealtimeV1Error({
893
+ code: "protocol_error",
894
+ message: "The WebSocket did not select gscdump.v1.",
895
+ retryable: false,
896
+ terminal: true,
897
+ details: { selectedProtocol: socket.protocol }
898
+ }), context);
899
+ return;
900
+ }
901
+ setState("handshaking", cursor ? "stale" : "unknown");
902
+ try {
903
+ const hello = schemas.helloFrame.parse({
904
+ type: "hello",
905
+ protocolVersion: GSCDUMP_REALTIME_PROTOCOL_VERSION,
906
+ sdkVersion,
907
+ resume: cursor
908
+ });
909
+ sendJson(context, hello);
910
+ } catch (cause) {
911
+ handleFrameQueueError(context, cause);
912
+ }
913
+ };
914
+ socket.onmessage = (event) => {
915
+ if (!running || current !== context || context.closed) return;
916
+ const work = context.frameQueue.then(() => processRawMessage(context, event.data));
917
+ context.frameQueue = work.catch((cause) => {
918
+ handleFrameQueueError(context, cause);
919
+ });
920
+ };
921
+ socket.onerror = (cause) => {
922
+ if (!running || current !== context || context.closed) return;
923
+ const error = new GscdumpRealtimeV1Error({
924
+ code: context.accepted ? "socket_error" : "upgrade_rejected",
925
+ message: context.accepted ? "The accepted realtime socket emitted an opaque error." : "The realtime WebSocket upgrade was rejected or failed opaquely.",
926
+ retryable: true,
927
+ terminal: false,
928
+ cause
929
+ });
930
+ reportError(error);
931
+ scheduleReconnect(context, error.code);
932
+ };
933
+ socket.onclose = (event) => {
934
+ if (context.closed || current !== context) return;
935
+ current = null;
936
+ epoch++;
937
+ clearConnectionTimers();
938
+ const code = event.code ?? 1006;
939
+ if (!running) return;
940
+ if (!context.accepted) {
941
+ reportError(new GscdumpRealtimeV1Error({
942
+ code: "upgrade_rejected",
943
+ message: "The realtime WebSocket upgrade failed opaquely.",
944
+ retryable: true,
945
+ terminal: false,
946
+ details: { closeCode: code }
947
+ }));
948
+ scheduleReconnect(null, "upgrade-rejected");
949
+ return;
950
+ }
951
+ if (code === GSCDUMP_REALTIME_CLOSE_CODES.normal) {
952
+ running = false;
953
+ setState("stopped", "unknown");
954
+ return;
955
+ }
956
+ if (code === GSCDUMP_REALTIME_CLOSE_CODES.protocolError || code === GSCDUMP_REALTIME_CLOSE_CODES.policyViolation) {
957
+ terminal(new GscdumpRealtimeV1Error({
958
+ code: "protocol_error",
959
+ message: `The realtime server closed with terminal code ${code}.`,
960
+ retryable: false,
961
+ terminal: true,
962
+ details: {
963
+ closeCode: code,
964
+ reason: event.reason
965
+ }
966
+ }), null);
967
+ return;
968
+ }
969
+ if (code === GSCDUMP_REALTIME_CLOSE_CODES.maximumLifetime) {
970
+ scheduleReconnect(null, "maximum-lifetime", 0, true);
971
+ return;
972
+ }
973
+ if (code === 1006 || code === GSCDUMP_REALTIME_CLOSE_CODES.internalError || code === GSCDUMP_REALTIME_CLOSE_CODES.serviceRestart || code === GSCDUMP_REALTIME_CLOSE_CODES.overloadedOrAckLag) {
974
+ scheduleReconnect(null, `close-${code}`, context.retryAfterMs);
975
+ return;
976
+ }
977
+ terminal(new GscdumpRealtimeV1Error({
978
+ code: "protocol_error",
979
+ message: `The realtime server used an unsupported close code ${code}.`,
980
+ retryable: false,
981
+ terminal: true,
982
+ details: {
983
+ closeCode: code,
984
+ reason: event.reason
985
+ }
986
+ }), null);
987
+ };
988
+ }
989
+ async function start() {
990
+ if (running) return;
991
+ running = true;
992
+ attempt = 0;
993
+ lastError = null;
994
+ immediateExpiredTicketRetry = true;
995
+ freshness = cursor ? "stale" : "unknown";
996
+ await beginAttempt();
997
+ }
998
+ function stop() {
999
+ running = false;
1000
+ clearTimer(reconnectTimer);
1001
+ reconnectTimer = void 0;
1002
+ if (current) closeSocket(current, GSCDUMP_REALTIME_CLOSE_CODES.normal, "manual");
1003
+ else epoch++;
1004
+ setState("stopped", "unknown");
1005
+ }
1006
+ return {
1007
+ start,
1008
+ stop,
1009
+ getSnapshot
1010
+ };
1011
+ }
1012
+ export { GSCDUMP_REALTIME_V1_SDK_VERSION, GscdumpRealtimeV1Error, createGscdumpRealtimeV1Client };