@starcite/sdk 0.0.1 → 0.0.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.
package/README.md CHANGED
@@ -151,13 +151,51 @@ setTimeout(() => controller.abort(), 5000);
151
151
  for await (const event of session.tail({
152
152
  cursor: 0,
153
153
  agent: "drafter",
154
+ reconnect: true,
155
+ reconnectDelayMs: 3000,
154
156
  signal: controller.signal,
155
157
  })) {
156
158
  console.log(event);
157
159
  }
158
160
  ```
159
161
 
160
- `tail()` replays `seq > cursor` and then streams live events on the same connection.
162
+ `tail()` replays `seq > cursor`, streams live events, and automatically reconnects
163
+ on transport failures while resuming from the last observed `seq`.
164
+
165
+ - Set `reconnect: false` to disable automatic reconnect behavior.
166
+ - By default, reconnect retries continue until the stream is aborted or closes gracefully.
167
+ - Use `reconnectDelayMs` to control retry cadence for spotty networks.
168
+
169
+ ## Browser Restart Resilience
170
+
171
+ `tail()` reconnects robustly for transport failures, but browser refresh/crash
172
+ resets in-memory state. Persist your last processed `seq` and restart from it.
173
+
174
+ ```ts
175
+ const sessionId = "ses_demo";
176
+ const cursorKey = `starcite:${sessionId}:lastSeq`;
177
+
178
+ const rawCursor = localStorage.getItem(cursorKey) ?? "0";
179
+ let lastSeq = Number.parseInt(rawCursor, 10);
180
+
181
+ if (!Number.isInteger(lastSeq) || lastSeq < 0) {
182
+ lastSeq = 0;
183
+ }
184
+
185
+ for await (const event of client.session(sessionId).tail({
186
+ cursor: lastSeq,
187
+ reconnect: true,
188
+ reconnectDelayMs: 3000,
189
+ })) {
190
+ // Process event first, then persist cursor when your side effects succeed.
191
+ await renderOrStore(event);
192
+ lastSeq = event.seq;
193
+ localStorage.setItem(cursorKey, `${lastSeq}`);
194
+ }
195
+ ```
196
+
197
+ This pattern protects against missed events across browser restarts. Design your
198
+ event handler to be idempotent by `seq` to safely tolerate replays.
161
199
 
162
200
  ## Error Handling
163
201
 
@@ -212,6 +250,12 @@ bun run --cwd packages/typescript-sdk build
212
250
  bun run --cwd packages/typescript-sdk test
213
251
  ```
214
252
 
253
+ Optional reconnect soak test (runs ~40s, disabled by default):
254
+
255
+ ```bash
256
+ STARCITE_SDK_RUN_SOAK=1 bun run --cwd packages/typescript-sdk test -- test/client.reconnect.integration.test.ts
257
+ ```
258
+
215
259
  ## Links
216
260
 
217
261
  - Product docs and examples: https://starcite.ai
package/dist/index.cjs CHANGED
@@ -66,10 +66,17 @@ var StarciteConnectionError = class extends StarciteError {
66
66
  // src/types.ts
67
67
  var import_zod = require("zod");
68
68
  var ArbitraryObjectSchema = import_zod.z.record(import_zod.z.unknown());
69
+ var CreatorTypeSchema = import_zod.z.union([import_zod.z.literal("user"), import_zod.z.literal("agent")]);
70
+ var SessionCreatorPrincipalSchema = import_zod.z.object({
71
+ tenant_id: import_zod.z.string().min(1),
72
+ id: import_zod.z.string().min(1),
73
+ type: CreatorTypeSchema
74
+ });
69
75
  var CreateSessionInputSchema = import_zod.z.object({
70
76
  id: import_zod.z.string().optional(),
71
77
  title: import_zod.z.string().optional(),
72
- metadata: ArbitraryObjectSchema.optional()
78
+ metadata: ArbitraryObjectSchema.optional(),
79
+ creator_principal: SessionCreatorPrincipalSchema.optional()
73
80
  });
74
81
  var SessionRecordSchema = import_zod.z.object({
75
82
  id: import_zod.z.string(),
@@ -147,6 +154,11 @@ var StarciteErrorPayloadSchema = import_zod.z.object({
147
154
  var DEFAULT_BASE_URL = typeof process !== "undefined" && process.env.STARCITE_BASE_URL ? process.env.STARCITE_BASE_URL : "http://localhost:4000";
148
155
  var TRAILING_SLASHES_REGEX = /\/+$/;
149
156
  var BEARER_PREFIX_REGEX = /^bearer\s+/i;
157
+ var DEFAULT_TAIL_RECONNECT_DELAY_MS = 3e3;
158
+ var NORMAL_WEBSOCKET_CLOSE_CODE = 1e3;
159
+ var SERVICE_TOKEN_SUB_ORG_PREFIX = "org:";
160
+ var SERVICE_TOKEN_SUB_AGENT_PREFIX = "agent:";
161
+ var SERVICE_TOKEN_SUB_USER_PREFIX = "user:";
150
162
  var TailFrameSchema = import_zod2.z.string().transform((frame, context) => {
151
163
  try {
152
164
  return JSON.parse(frame);
@@ -267,6 +279,105 @@ function formatAuthorizationHeader(apiKey) {
267
279
  }
268
280
  return `Bearer ${normalized}`;
269
281
  }
282
+ function firstNonEmptyString(value) {
283
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
284
+ }
285
+ function parseJwtSegment(segment) {
286
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(segment.length + (4 - segment.length % 4) % 4, "=");
287
+ try {
288
+ if (typeof atob === "function") {
289
+ return atob(base64);
290
+ }
291
+ if (typeof Buffer !== "undefined") {
292
+ return Buffer.from(base64, "base64").toString("utf8");
293
+ }
294
+ } catch {
295
+ return void 0;
296
+ }
297
+ return void 0;
298
+ }
299
+ function parseJwtClaims(apiKey) {
300
+ const token = apiKey.replace(BEARER_PREFIX_REGEX, "").trim();
301
+ const parts = token.split(".");
302
+ if (parts.length !== 3) {
303
+ return void 0;
304
+ }
305
+ const [, payloadSegment] = parts;
306
+ if (payloadSegment === void 0) {
307
+ return void 0;
308
+ }
309
+ const payload = parseJwtSegment(payloadSegment);
310
+ if (payload === void 0) {
311
+ return void 0;
312
+ }
313
+ try {
314
+ const decoded = JSON.parse(payload);
315
+ return decoded !== null && typeof decoded === "object" ? decoded : void 0;
316
+ } catch {
317
+ return void 0;
318
+ }
319
+ }
320
+ function parseClaimStrings(source, keys) {
321
+ for (const key of keys) {
322
+ const value = firstNonEmptyString(source[key]);
323
+ if (value) {
324
+ return value;
325
+ }
326
+ }
327
+ return void 0;
328
+ }
329
+ function parseActorIdentityFromSubject(subject) {
330
+ if (subject.startsWith(SERVICE_TOKEN_SUB_AGENT_PREFIX)) {
331
+ return { id: subject, type: "agent" };
332
+ }
333
+ if (subject.startsWith(SERVICE_TOKEN_SUB_USER_PREFIX)) {
334
+ return { id: subject, type: "user" };
335
+ }
336
+ return void 0;
337
+ }
338
+ function parseTenantIdFromSubject(subject) {
339
+ const actorIdentity = parseActorIdentityFromSubject(subject);
340
+ if (actorIdentity !== void 0) {
341
+ return "";
342
+ }
343
+ if (subject.startsWith(SERVICE_TOKEN_SUB_ORG_PREFIX)) {
344
+ return subject.slice(SERVICE_TOKEN_SUB_ORG_PREFIX.length).trim();
345
+ }
346
+ return subject;
347
+ }
348
+ function parseCreatorPrincipalFromClaims(claims) {
349
+ const subject = firstNonEmptyString(claims.sub);
350
+ const explicitPrincipal = claims.principal && typeof claims.principal === "object" ? claims.principal : void 0;
351
+ const mergedClaims = explicitPrincipal ? { ...claims, ...explicitPrincipal } : claims;
352
+ const actorFromSubject = subject ? parseActorIdentityFromSubject(subject) : void 0;
353
+ const principalTypeFromClaims = parseClaimStrings(mergedClaims, [
354
+ "principal_type",
355
+ "principalType",
356
+ "type"
357
+ ]);
358
+ const tenantId = parseClaimStrings(mergedClaims, ["tenant_id", "tenantId"]);
359
+ const rawPrincipalId = parseClaimStrings(mergedClaims, [
360
+ "principal_id",
361
+ "principalId",
362
+ "id",
363
+ "sub"
364
+ ]);
365
+ const actorFromRawId = rawPrincipalId ? parseActorIdentityFromSubject(rawPrincipalId) : void 0;
366
+ const principal = {
367
+ tenant_id: tenantId ?? (subject ? parseTenantIdFromSubject(subject) : ""),
368
+ id: rawPrincipalId ?? actorFromSubject?.id ?? "",
369
+ type: principalTypeFromClaims === "agent" || principalTypeFromClaims === "user" ? principalTypeFromClaims : actorFromSubject?.type ?? actorFromRawId?.type ?? "user"
370
+ };
371
+ if (principal.tenant_id.length === 0 || principal.id.length === 0 || principal.type.length === 0) {
372
+ return void 0;
373
+ }
374
+ const result = SessionCreatorPrincipalSchema.safeParse(principal);
375
+ return result.success ? result.data : void 0;
376
+ }
377
+ function parseCreatorPrincipalFromClaimsSafe(apiKey) {
378
+ const claims = parseJwtClaims(apiKey);
379
+ return claims ? parseCreatorPrincipalFromClaims(claims) : void 0;
380
+ }
270
381
  function parseEventFrame(data) {
271
382
  const result = TailFrameSchema.safeParse(data);
272
383
  if (!result.success) {
@@ -281,6 +392,58 @@ function getEventData(event) {
281
392
  }
282
393
  return void 0;
283
394
  }
395
+ function getCloseCode(event) {
396
+ if (event && typeof event === "object" && "code" in event) {
397
+ const code = event.code;
398
+ return typeof code === "number" ? code : void 0;
399
+ }
400
+ return void 0;
401
+ }
402
+ function getCloseReason(event) {
403
+ if (event && typeof event === "object" && "reason" in event) {
404
+ const reason = event.reason;
405
+ return typeof reason === "string" && reason.length > 0 ? reason : void 0;
406
+ }
407
+ return void 0;
408
+ }
409
+ function describeClose(code, reason) {
410
+ const codeText = `code ${typeof code === "number" ? code : "unknown"}`;
411
+ return reason ? `${codeText}, reason '${reason}'` : codeText;
412
+ }
413
+ async function waitForDelay(ms, signal) {
414
+ if (ms <= 0) {
415
+ return;
416
+ }
417
+ await new Promise((resolve) => {
418
+ let settled = false;
419
+ const timeout = setTimeout(() => {
420
+ if (settled) {
421
+ return;
422
+ }
423
+ settled = true;
424
+ if (signal) {
425
+ signal.removeEventListener("abort", onAbort);
426
+ }
427
+ resolve();
428
+ }, ms);
429
+ const onAbort = () => {
430
+ if (settled) {
431
+ return;
432
+ }
433
+ settled = true;
434
+ clearTimeout(timeout);
435
+ signal?.removeEventListener("abort", onAbort);
436
+ resolve();
437
+ };
438
+ if (signal) {
439
+ if (signal.aborted) {
440
+ onAbort();
441
+ return;
442
+ }
443
+ signal.addEventListener("abort", onAbort, { once: true });
444
+ }
445
+ });
446
+ }
284
447
  function agentFromActor(actor) {
285
448
  if (actor.startsWith("agent:")) {
286
449
  return actor.slice("agent:".length);
@@ -350,6 +513,7 @@ var StarciteSession = class {
350
513
  var StarciteClient = class {
351
514
  /** Normalized API base URL ending with `/v1`. */
352
515
  baseUrl;
516
+ inferredCreatorPrincipal;
353
517
  websocketBaseUrl;
354
518
  fetchFn;
355
519
  headers;
@@ -363,10 +527,9 @@ var StarciteClient = class {
363
527
  this.fetchFn = options.fetch ?? defaultFetch;
364
528
  this.headers = new Headers(options.headers);
365
529
  if (options.apiKey !== void 0) {
366
- this.headers.set(
367
- "authorization",
368
- formatAuthorizationHeader(options.apiKey)
369
- );
530
+ const authorization = formatAuthorizationHeader(options.apiKey);
531
+ this.headers.set("authorization", authorization);
532
+ this.inferredCreatorPrincipal = parseCreatorPrincipalFromClaimsSafe(authorization);
370
533
  }
371
534
  this.websocketFactory = options.websocketFactory ?? defaultWebSocketFactory;
372
535
  }
@@ -387,7 +550,10 @@ var StarciteClient = class {
387
550
  * Creates a new session and returns the raw session record.
388
551
  */
389
552
  createSession(input = {}) {
390
- const payload = CreateSessionInputSchema.parse(input);
553
+ const payload = CreateSessionInputSchema.parse({
554
+ ...input,
555
+ creator_principal: input.creator_principal ?? this.inferredCreatorPrincipal
556
+ });
391
557
  return this.request(
392
558
  "/sessions",
393
559
  {
@@ -452,77 +618,130 @@ var StarciteClient = class {
452
618
  /**
453
619
  * Opens a WebSocket tail stream and yields raw events.
454
620
  */
621
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: single-loop reconnect state machine is intentionally explicit for stream correctness.
455
622
  async *tailRawEvents(sessionId, options = {}) {
456
- const queue = new AsyncQueue();
457
- const cursor = options.cursor ?? 0;
458
- if (!Number.isInteger(cursor) || cursor < 0) {
623
+ const initialCursor = options.cursor ?? 0;
624
+ const reconnectEnabled = options.reconnect ?? true;
625
+ const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_TAIL_RECONNECT_DELAY_MS;
626
+ if (!Number.isInteger(initialCursor) || initialCursor < 0) {
459
627
  throw new StarciteError("tail() cursor must be a non-negative integer");
460
628
  }
461
- const wsUrl = `${this.websocketBaseUrl}/sessions/${encodeURIComponent(
462
- sessionId
463
- )}/tail?cursor=${cursor}`;
464
- const websocketHeaders = new Headers();
465
- const authorization = this.headers.get("authorization");
466
- if (authorization) {
467
- websocketHeaders.set("authorization", authorization);
629
+ if (!Number.isFinite(reconnectDelayMs) || reconnectDelayMs < 0) {
630
+ throw new StarciteError(
631
+ "tail() reconnectDelayMs must be a non-negative number"
632
+ );
468
633
  }
469
- const socket = this.websocketFactory(
470
- wsUrl,
471
- hasAnyHeaders(websocketHeaders) ? {
472
- headers: websocketHeaders
473
- } : void 0
474
- );
475
- const onMessage = (event) => {
634
+ let cursor = initialCursor;
635
+ while (true) {
636
+ if (options.signal?.aborted) {
637
+ return;
638
+ }
639
+ const wsUrl = `${this.websocketBaseUrl}/sessions/${encodeURIComponent(
640
+ sessionId
641
+ )}/tail?cursor=${cursor}`;
642
+ const websocketHeaders = new Headers();
643
+ const authorization = this.headers.get("authorization");
644
+ if (authorization) {
645
+ websocketHeaders.set("authorization", authorization);
646
+ }
647
+ let socket;
476
648
  try {
477
- const parsed = parseEventFrame(getEventData(event));
478
- if (options.agent && agentFromActor(parsed.actor) !== options.agent) {
479
- return;
480
- }
481
- queue.push(parsed);
649
+ socket = this.websocketFactory(
650
+ wsUrl,
651
+ hasAnyHeaders(websocketHeaders) ? {
652
+ headers: websocketHeaders
653
+ } : void 0
654
+ );
482
655
  } catch (error) {
483
- queue.fail(error);
656
+ const rootCause = toError(error).message;
657
+ if (!reconnectEnabled || options.signal?.aborted) {
658
+ throw new StarciteConnectionError(
659
+ `Tail connection failed for session '${sessionId}': ${rootCause}`
660
+ );
661
+ }
662
+ await waitForDelay(reconnectDelayMs, options.signal);
663
+ continue;
484
664
  }
485
- };
486
- const onError = () => {
487
- queue.fail(
488
- new StarciteConnectionError(
489
- `Tail connection failed for session '${sessionId}'`
490
- )
491
- );
492
- };
493
- const onClose = () => {
494
- queue.close();
495
- };
496
- socket.addEventListener("message", onMessage);
497
- socket.addEventListener("error", onError);
498
- socket.addEventListener("close", onClose);
499
- const onAbort = () => {
500
- queue.close();
501
- socket.close(1e3, "aborted");
502
- };
503
- if (options.signal) {
504
- if (options.signal.aborted) {
505
- onAbort();
506
- } else {
507
- options.signal.addEventListener("abort", onAbort, { once: true });
665
+ const queue = new AsyncQueue();
666
+ let sawTransportError = false;
667
+ let closeCode;
668
+ let closeReason;
669
+ let abortRequested = false;
670
+ const onMessage = (event) => {
671
+ try {
672
+ const parsed = parseEventFrame(getEventData(event));
673
+ cursor = Math.max(cursor, parsed.seq);
674
+ if (options.agent && agentFromActor(parsed.actor) !== options.agent) {
675
+ return;
676
+ }
677
+ queue.push(parsed);
678
+ } catch (error) {
679
+ queue.fail(error);
680
+ }
681
+ };
682
+ const onError = () => {
683
+ sawTransportError = true;
684
+ queue.close();
685
+ };
686
+ const onClose = (event) => {
687
+ closeCode = getCloseCode(event);
688
+ closeReason = getCloseReason(event);
689
+ queue.close();
690
+ };
691
+ const onAbort = () => {
692
+ abortRequested = true;
693
+ queue.close();
694
+ socket.close(NORMAL_WEBSOCKET_CLOSE_CODE, "aborted");
695
+ };
696
+ socket.addEventListener("message", onMessage);
697
+ socket.addEventListener("error", onError);
698
+ socket.addEventListener("close", onClose);
699
+ if (options.signal) {
700
+ if (options.signal.aborted) {
701
+ onAbort();
702
+ } else {
703
+ options.signal.addEventListener("abort", onAbort, { once: true });
704
+ }
508
705
  }
509
- }
510
- try {
511
- while (true) {
512
- const next = await queue.next();
513
- if (next.done) {
514
- break;
706
+ let iterationError = null;
707
+ try {
708
+ while (true) {
709
+ const next = await queue.next();
710
+ if (next.done) {
711
+ break;
712
+ }
713
+ yield next.value;
515
714
  }
516
- yield next.value;
715
+ } catch (error) {
716
+ iterationError = toError(error);
717
+ } finally {
718
+ socket.removeEventListener("message", onMessage);
719
+ socket.removeEventListener("error", onError);
720
+ socket.removeEventListener("close", onClose);
721
+ if (options.signal) {
722
+ options.signal.removeEventListener("abort", onAbort);
723
+ }
724
+ socket.close(NORMAL_WEBSOCKET_CLOSE_CODE, "finished");
517
725
  }
518
- } finally {
519
- socket.removeEventListener("message", onMessage);
520
- socket.removeEventListener("error", onError);
521
- socket.removeEventListener("close", onClose);
522
- if (options.signal) {
523
- options.signal.removeEventListener("abort", onAbort);
726
+ if (iterationError) {
727
+ throw iterationError;
728
+ }
729
+ if (abortRequested || options.signal?.aborted) {
730
+ return;
731
+ }
732
+ const gracefullyClosed = !sawTransportError && closeCode === NORMAL_WEBSOCKET_CLOSE_CODE;
733
+ if (gracefullyClosed) {
734
+ return;
735
+ }
736
+ if (!reconnectEnabled) {
737
+ throw new StarciteConnectionError(
738
+ `Tail connection dropped for session '${sessionId}' (${describeClose(
739
+ closeCode,
740
+ closeReason
741
+ )})`
742
+ );
524
743
  }
525
- socket.close(1e3, "finished");
744
+ await waitForDelay(reconnectDelayMs, options.signal);
526
745
  }
527
746
  }
528
747
  /**