@fytkit/sdk 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,29 @@
1
1
  import { createORPCClient, ORPCError } from '@orpc/client';
2
2
  import { RPCLink } from '@orpc/client/fetch';
3
3
 
4
+ // src/error.ts
5
+
6
+ // src/is-record.ts
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+
11
+ // src/refusal.ts
12
+ function refusalData(error, reason) {
13
+ if (!isRecord(error) || typeof error.kind !== "string") return null;
14
+ const refusal = error.refusal;
15
+ if (!isRecord(refusal) || refusal.reason !== reason || !isRecord(refusal.data)) return null;
16
+ return refusal.data;
17
+ }
18
+ var MEMBER_CAP_QUOTA = "max_clients";
19
+ function pickRefusal(data) {
20
+ if (!isRecord(data)) return {};
21
+ const { reason, ...rest } = data;
22
+ if (typeof reason === "string" && reason.length > 0) return { refusal: { reason, data: rest } };
23
+ if (data.quotaCode === MEMBER_CAP_QUOTA) return { refusal: { reason: MEMBER_CAP_QUOTA, data: {} } };
24
+ return {};
25
+ }
26
+
4
27
  // src/error.ts
5
28
  function pickIssues(data) {
6
29
  if (!data || typeof data !== "object") return null;
@@ -31,7 +54,8 @@ function normalizeError(err) {
31
54
  return {
32
55
  kind: "forbidden",
33
56
  reason: message || "Forbidden",
34
- ...pickStringField(data, "code")
57
+ ...pickStringField(data, "code"),
58
+ ...pickRefusal(data)
35
59
  };
36
60
  case "PAYMENT_REQUIRED":
37
61
  return { kind: "payment_required", message: message || "Payment Required" };
@@ -39,20 +63,33 @@ function normalizeError(err) {
39
63
  return {
40
64
  kind: "conflict",
41
65
  message: message || "Conflict",
42
- ...pickStringField(data, "enrollmentId")
66
+ ...pickStringField(data, "enrollmentId"),
67
+ ...pickRefusal(data)
43
68
  };
44
69
  case "NOT_FOUND":
45
- return { kind: "not_found" };
70
+ return { kind: "not_found", ...pickRefusal(data) };
46
71
  case "TOO_MANY_REQUESTS":
47
- return { kind: "rate_limited", retryAfter: pickRetryAfter(data) };
72
+ return { kind: "rate_limited", retryAfter: pickRetryAfter(data), ...pickRefusal(data) };
48
73
  case "BAD_REQUEST":
49
74
  case "UNPROCESSABLE_CONTENT": {
50
75
  const issues = pickIssues(data);
51
- if (issues) return { kind: "validation", issues };
52
- return { kind: "server", status, message, ...pickStringField(data, "code") };
76
+ if (issues) return { kind: "validation", issues, ...pickRefusal(data) };
77
+ return {
78
+ kind: "server",
79
+ status,
80
+ message,
81
+ ...pickStringField(data, "code"),
82
+ ...pickRefusal(data)
83
+ };
53
84
  }
54
85
  default:
55
- return { kind: "server", status, message, ...pickStringField(data, "code") };
86
+ return {
87
+ kind: "server",
88
+ status,
89
+ message,
90
+ ...pickStringField(data, "code"),
91
+ ...pickRefusal(data)
92
+ };
56
93
  }
57
94
  }
58
95
  return { kind: "server", status: 0 };
@@ -385,6 +422,8 @@ function createDataNamespaces(transport) {
385
422
  sessionLogs: wrapSlice(c.sessionLog),
386
423
  progression: wrapSlice(c.progression),
387
424
  bodyMetrics: wrapSlice(c.bodyMetric),
425
+ checkIns: wrapSlice(c.checkin),
426
+ messages: wrapSlice(c.message),
388
427
  progress: wrapSlice(c.progress),
389
428
  media: wrapSlice(c.media),
390
429
  appConfig: wrapSlice(c.appConfig),
@@ -445,14 +484,67 @@ function createMemoryStorageAdapter() {
445
484
  }
446
485
  };
447
486
  }
487
+
488
+ // src/retry-after.ts
489
+ var DELAY_SECONDS = /^\d+$/;
490
+ function retryAfterHeaderMs(value, now) {
491
+ if (value === null) return null;
492
+ const trimmed = value.trim();
493
+ if (trimmed.length === 0) return null;
494
+ if (DELAY_SECONDS.test(trimmed)) return Number(trimmed) * 1e3;
495
+ if (!/[a-z]/i.test(trimmed)) return null;
496
+ const at = Date.parse(trimmed);
497
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
498
+ }
499
+ function parseJson(text) {
500
+ try {
501
+ return JSON.parse(text);
502
+ } catch {
503
+ return void 0;
504
+ }
505
+ }
506
+ function hintedBody(body, waitMs) {
507
+ if (isRecord(body) && isRecord(body.json) && typeof body.json.code === "string") {
508
+ const error = body.json;
509
+ const data = isRecord(error.data) ? error.data : {};
510
+ if (typeof data.retryAfterMs === "number") return null;
511
+ return { ...body, json: { ...error, data: { ...data, retryAfterMs: waitMs } } };
512
+ }
513
+ return {
514
+ json: {
515
+ defined: false,
516
+ code: "TOO_MANY_REQUESTS",
517
+ status: 429,
518
+ message: "Too Many Requests",
519
+ data: { retryAfterMs: waitMs }
520
+ }
521
+ };
522
+ }
523
+ async function withRetryAfterHint(response, now) {
524
+ if (response.status !== 429) return response;
525
+ const waitMs = retryAfterHeaderMs(response.headers.get("retry-after"), now);
526
+ if (waitMs === null) return response;
527
+ const hinted = hintedBody(parseJson(await response.clone().text()), waitMs);
528
+ if (hinted === null) return response;
529
+ const headers = new Headers(response.headers);
530
+ headers.set("content-type", "application/json");
531
+ headers.delete("content-length");
532
+ return new Response(JSON.stringify(hinted), {
533
+ status: response.status,
534
+ statusText: response.statusText,
535
+ headers
536
+ });
537
+ }
538
+
539
+ // src/transport.ts
448
540
  function createTransport(options) {
449
541
  let interceptor = null;
450
542
  const baseFetch = options.fetch ?? fetch.bind(globalThis);
451
- const wrappedFetch = (input, init) => {
543
+ const wrappedFetch = async (input, init) => {
452
544
  const request = input instanceof Request ? input : new Request(input, init);
453
545
  const next = (req) => baseFetch(req);
454
- if (!interceptor) return next(request);
455
- return interceptor(request, next);
546
+ const response = interceptor ? await interceptor(request, next) : await next(request);
547
+ return withRetryAfterHint(response, Date.now());
456
548
  };
457
549
  const link = new RPCLink({
458
550
  url: () => `${options.baseUrl}/rpc`,
@@ -551,6 +643,261 @@ function attachRefreshInterceptor(opts) {
551
643
  });
552
644
  }
553
645
 
646
+ // src/realtime.ts
647
+ var MESSAGES_FALLBACK_POLL_MS = {
648
+ conversation: 1e4,
649
+ unreadCount: 6e4
650
+ };
651
+ var THREAD_CHANGED_EVENT = "thread_changed";
652
+ var MEMBER_TOPIC_PREFIX = "member:";
653
+ var TOKEN_RENEW_BEFORE_MS = 6e4;
654
+ var TOKEN_MIN_DELAY_MS = 5e3;
655
+ var RETRY_MS = 6e4;
656
+ var REJOIN_DELAYS_MS = [5e3, 15e3, 6e4];
657
+ var SIGNED_OUT_EVENTS = /* @__PURE__ */ new Set([
658
+ "signed_out",
659
+ "signed_out_due_to_token_reuse"
660
+ ]);
661
+ var FINAL_ERROR_KINDS = /* @__PURE__ */ new Set([
662
+ "auth_required",
663
+ "forbidden",
664
+ "not_found",
665
+ "validation"
666
+ ]);
667
+ function isFinalError(error) {
668
+ if (typeof error !== "object" || error === null || !("kind" in error)) return false;
669
+ const kind = error.kind;
670
+ return typeof kind === "string" && FINAL_ERROR_KINDS.has(kind);
671
+ }
672
+ function isMemberTopic(topic) {
673
+ return typeof topic === "string" && topic.startsWith(MEMBER_TOPIC_PREFIX) && topic.length > MEMBER_TOPIC_PREFIX.length;
674
+ }
675
+ function isConnection(value) {
676
+ if (typeof value !== "object" || value === null) return false;
677
+ if (!("url" in value) || !("anonKey" in value)) return false;
678
+ return typeof value.url === "string" && value.url.length > 0 && typeof value.anonKey === "string" && value.anonKey.length > 0;
679
+ }
680
+ function renewalDelay(expiresAt, now) {
681
+ const expiry = Date.parse(expiresAt);
682
+ if (Number.isNaN(expiry)) return RETRY_MS;
683
+ return Math.max(TOKEN_MIN_DELAY_MS, expiry - TOKEN_RENEW_BEFORE_MS - now);
684
+ }
685
+ function isUsable(expiresAt, now) {
686
+ return Date.parse(expiresAt) - now > TOKEN_MIN_DELAY_MS;
687
+ }
688
+ function rejoinDelay(closures) {
689
+ const last = REJOIN_DELAYS_MS.length - 1;
690
+ return REJOIN_DELAYS_MS[Math.min(Math.max(closures - 1, 0), last)];
691
+ }
692
+ function ignore() {
693
+ }
694
+ function authorise(client, token) {
695
+ return new Promise((resolve) => {
696
+ resolve(client.realtime.setAuth(token));
697
+ }).then(ignore, ignore);
698
+ }
699
+ function subscribeToMessages(client, options) {
700
+ let closed = false;
701
+ let lastStatus = null;
702
+ let timer = null;
703
+ let renewing = false;
704
+ let connection = null;
705
+ let socket = null;
706
+ let held = null;
707
+ let channel = null;
708
+ let rejoinTimer = null;
709
+ let closures = 0;
710
+ let stopAuthListener = null;
711
+ const report = (status) => {
712
+ if (closed || status === lastStatus) return;
713
+ lastStatus = status;
714
+ options.onStatus?.(status);
715
+ };
716
+ const clearTimer = () => {
717
+ if (timer === null) return;
718
+ clearTimeout(timer);
719
+ timer = null;
720
+ };
721
+ const after = (delayMs, run) => {
722
+ clearTimer();
723
+ if (closed) return;
724
+ timer = setTimeout(() => {
725
+ timer = null;
726
+ void run();
727
+ }, delayMs);
728
+ };
729
+ const cancelRejoin = () => {
730
+ if (rejoinTimer !== null) clearTimeout(rejoinTimer);
731
+ rejoinTimer = null;
732
+ };
733
+ const drop = () => {
734
+ const open = socket;
735
+ const joined = channel;
736
+ socket = null;
737
+ held = null;
738
+ channel = null;
739
+ cancelRejoin();
740
+ closures = 0;
741
+ if (open === null) return;
742
+ if (joined !== null) Promise.resolve(open.removeChannel(joined)).then(ignore, ignore);
743
+ open.realtime.disconnect();
744
+ };
745
+ const close = () => {
746
+ if (closed) return;
747
+ closed = true;
748
+ clearTimer();
749
+ drop();
750
+ const stop = stopAuthListener;
751
+ stopAuthListener = null;
752
+ stop?.();
753
+ };
754
+ const finish = (status) => {
755
+ if (closed) return;
756
+ const changed = status !== lastStatus;
757
+ lastStatus = status;
758
+ close();
759
+ if (changed) options.onStatus?.(status);
760
+ };
761
+ const join = (joinTopic, open) => {
762
+ if (closed || socket !== open || channel !== null) return;
763
+ const joined = open.channel(joinTopic, { config: { private: true } });
764
+ channel = joined;
765
+ const current = () => !closed && channel === joined;
766
+ joined.on("broadcast", { event: THREAD_CHANGED_EVENT }, () => {
767
+ if (current()) options.onChange();
768
+ }).subscribe((status) => {
769
+ if (!current()) return;
770
+ if (status === "SUBSCRIBED") {
771
+ closures = 0;
772
+ report("live");
773
+ options.onChange();
774
+ return;
775
+ }
776
+ report("fallback");
777
+ if (status === "CLOSED") {
778
+ channel = null;
779
+ scheduleRejoin();
780
+ }
781
+ });
782
+ };
783
+ const authoriseAndJoin = (grant, open) => {
784
+ void authorise(open, grant.token).then(() => join(grant.topic, open));
785
+ };
786
+ const rejoin = () => {
787
+ const open = socket;
788
+ const grant = held;
789
+ if (closed || open === null || grant === null || channel !== null) return;
790
+ if (!isUsable(grant.expiresAt, Date.now())) {
791
+ void renew();
792
+ return;
793
+ }
794
+ authoriseAndJoin(grant, open);
795
+ };
796
+ const scheduleRejoin = () => {
797
+ if (closed || rejoinTimer !== null) return;
798
+ closures += 1;
799
+ rejoinTimer = setTimeout(() => {
800
+ rejoinTimer = null;
801
+ rejoin();
802
+ }, rejoinDelay(closures));
803
+ };
804
+ const connect = (grant, settings) => {
805
+ let opened;
806
+ try {
807
+ opened = options.realtime(settings);
808
+ } catch {
809
+ finish("fallback");
810
+ return;
811
+ }
812
+ socket = opened;
813
+ held = grant;
814
+ after(renewalDelay(grant.expiresAt, Date.now()), renew);
815
+ authoriseAndJoin(grant, opened);
816
+ };
817
+ const openGrant = async (grant) => {
818
+ if (!isMemberTopic(grant.topic)) {
819
+ finish("fallback");
820
+ return;
821
+ }
822
+ if (connection === null) {
823
+ let realtime;
824
+ try {
825
+ realtime = (await client.appConfig.get()).data.realtime;
826
+ } catch (error) {
827
+ if (closed) return;
828
+ if (isFinalError(error)) {
829
+ finish("fallback");
830
+ return;
831
+ }
832
+ report("fallback");
833
+ after(RETRY_MS, start);
834
+ return;
835
+ }
836
+ if (closed) return;
837
+ if (!isConnection(realtime)) {
838
+ finish("fallback");
839
+ return;
840
+ }
841
+ connection = realtime;
842
+ }
843
+ connect(grant, connection);
844
+ };
845
+ const renew = async () => {
846
+ if (renewing) return;
847
+ renewing = true;
848
+ let grant;
849
+ try {
850
+ grant = (await client.messages.realtimeToken({})).data;
851
+ } catch (error) {
852
+ if (closed) return;
853
+ if (isFinalError(error)) finish("refused");
854
+ else after(RETRY_MS, renew);
855
+ return;
856
+ } finally {
857
+ renewing = false;
858
+ }
859
+ const current = socket;
860
+ if (closed || current === null || held === null) return;
861
+ if (grant.topic !== held.topic) {
862
+ drop();
863
+ report("connecting");
864
+ await openGrant(grant);
865
+ return;
866
+ }
867
+ held = grant;
868
+ after(renewalDelay(grant.expiresAt, Date.now()), renew);
869
+ if (channel !== null) {
870
+ void authorise(current, grant.token);
871
+ return;
872
+ }
873
+ cancelRejoin();
874
+ authoriseAndJoin(grant, current);
875
+ };
876
+ const start = async () => {
877
+ let grant;
878
+ try {
879
+ grant = (await client.messages.realtimeToken({})).data;
880
+ } catch (error) {
881
+ if (closed) return;
882
+ if (isFinalError(error)) {
883
+ finish("refused");
884
+ return;
885
+ }
886
+ report("fallback");
887
+ after(RETRY_MS, start);
888
+ return;
889
+ }
890
+ if (closed) return;
891
+ await openGrant(grant);
892
+ };
893
+ report("connecting");
894
+ stopAuthListener = client.auth?.onAuthStateChange((event) => {
895
+ if (SIGNED_OUT_EVENTS.has(event.type)) finish("refused");
896
+ }) ?? null;
897
+ void start();
898
+ return close;
899
+ }
900
+
554
901
  // src/index.ts
555
902
  var DEFAULT_BASE_URL = "https://api.fytkit.com";
556
903
  function createFytkitClient(options) {
@@ -600,6 +947,6 @@ function createFytkitClient(options) {
600
947
  };
601
948
  }
602
949
 
603
- export { createFytkitClient, createLocalStorageAdapter, createMemoryStorageAdapter };
950
+ export { MESSAGES_FALLBACK_POLL_MS, createFytkitClient, createLocalStorageAdapter, createMemoryStorageAdapter, refusalData, subscribeToMessages };
604
951
  //# sourceMappingURL=index.js.map
605
952
  //# sourceMappingURL=index.js.map