@fytkit/sdk 3.4.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/CHANGELOG.md +7 -0
- package/README.md +81 -4
- package/dist/index.d.ts +1737 -39
- package/dist/index.js +358 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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 {
|
|
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
|
-
|
|
455
|
-
return
|
|
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
|