@fluxy-chat/sdk 0.1.0 → 0.2.1

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,21 +1,47 @@
1
1
  export { FluxyAuthError, FluxyConnectionError, FluxySendError, FluxyTimeoutError, FLUXY_WS_CLOSE_NORMAL, FLUXY_WS_CLOSE_POLICY, computeReconnectBackoffMs, mapWebSocketCloseToError, } from "./errors";
2
2
  export { FluxyChatRoomConnection, } from "./room-connection";
3
3
  export { FluxyMessageStream } from "./message-stream";
4
+ export { clampHistoryLimit, mergeMessagesChronological, sortMessagesChronological, MAX_HISTORY_LIMIT, } from "./message-history";
5
+ export { decodeFluxyJwtPayload, jwtRefreshDelayMs } from "./jwt-utils";
6
+ export { FLUXY_MAX_MESSAGE_LENGTH, normalizeRoomMember, normalizeRoomMembers, } from "./room-rest";
7
+ export { validateAgentOutboundMessage, buildAgentOutboundWsPayload, } from "./agent-outbound";
8
+ export { FluxyRealtimeProvider, } from "./realtime-provider";
9
+ export { useFluxyChat, useFluxyChatOptional } from "./use-fluxy-chat";
10
+ export { useChat } from "./use-chat";
11
+ export { useRooms } from "./use-rooms";
4
12
  import { FluxyChatRoomConnection } from "./room-connection";
5
- import { FluxyAuthError, FluxySendError } from "./errors";
13
+ import { clampHistoryLimit, sortMessagesChronological } from "./message-history";
14
+ import { normalizeRoomMembers } from "./room-rest";
15
+ import { trimTrailingSlashes } from "./url-utils";
16
+ const AUDIO_FILE_SUFFIXES = [".webm", ".m4a", ".mp3", ".wav", ".ogg"];
17
+ function fileNameLooksLikeAudio(fileName) {
18
+ const lower = fileName.toLowerCase();
19
+ for (const ext of AUDIO_FILE_SUFFIXES) {
20
+ if (lower.endsWith(ext))
21
+ return true;
22
+ }
23
+ return false;
24
+ }
6
25
  function inferAttachmentKind(contentType, fileName) {
7
26
  const ct = (contentType || "").toLowerCase();
8
27
  if (ct.startsWith("image/"))
9
28
  return "image";
10
29
  if (ct.startsWith("audio/"))
11
30
  return "audio";
12
- if (/\.(webm|m4a|mp3|wav|ogg)$/i.test(fileName))
31
+ if (fileNameLooksLikeAudio(fileName))
13
32
  return "audio";
14
33
  return "file";
15
34
  }
35
+ function httpUrlToWebSocketBase(url) {
36
+ if (url.startsWith("https://"))
37
+ return `wss://${url.slice("https://".length)}`;
38
+ if (url.startsWith("http://"))
39
+ return `ws://${url.slice("http://".length)}`;
40
+ return url;
41
+ }
16
42
  export class FluxyChatClient {
17
43
  constructor(options) {
18
- this.baseUrl = options.baseUrl.replace(/\/+$/, "");
44
+ this.baseUrl = trimTrailingSlashes(options.baseUrl);
19
45
  this.userId = options.userId;
20
46
  this.apiKey = options.apiKey;
21
47
  this.token = options.token;
@@ -37,7 +63,7 @@ export class FluxyChatClient {
37
63
  return undefined;
38
64
  }
39
65
  connect(roomId) {
40
- const wsBase = this.baseUrl.replace(/^http/, "ws");
66
+ const wsBase = httpUrlToWebSocketBase(this.baseUrl);
41
67
  const url = new URL(`/ws/room/${encodeURIComponent(roomId)}`, wsBase.endsWith("/") ? wsBase : `${wsBase}/`);
42
68
  if (this.apiKey) {
43
69
  url.searchParams.set("apiKey", this.apiKey);
@@ -64,20 +90,42 @@ export class FluxyChatClient {
64
90
  url.searchParams.set("userId", this.userId);
65
91
  return new EventSource(url.toString());
66
92
  }
67
- async fetchMessages(roomId, limit = 50) {
93
+ async fetchMessages(roomId, limitOrOptions = 50) {
68
94
  const trimmedRoomId = roomId.trim();
69
95
  if (!trimmedRoomId)
70
96
  return [];
97
+ const options = typeof limitOrOptions === "number" ? { limit: limitOrOptions } : limitOrOptions;
98
+ const limit = clampHistoryLimit(options.limit);
71
99
  const url = new URL("/api/messages", this.baseUrl);
72
100
  url.searchParams.set("roomId", trimmedRoomId);
73
101
  url.searchParams.set("limit", String(limit));
102
+ if (options.before?.trim()) {
103
+ url.searchParams.set("before", options.before.trim());
104
+ }
74
105
  const res = await fetch(url.toString(), {
75
106
  headers: this.authHeaders(),
76
107
  });
77
108
  if (!res.ok)
78
109
  throw new Error(`Failed to fetch messages: ${res.status}`);
79
110
  const body = await res.json();
80
- return body.messages ?? [];
111
+ return sortMessagesChronological((body.messages ?? []));
112
+ }
113
+ async fetchRoomMembers(roomId) {
114
+ const trimmedRoomId = roomId.trim();
115
+ if (!trimmedRoomId)
116
+ return [];
117
+ const url = new URL(`/rooms/${encodeURIComponent(trimmedRoomId)}/members`, this.baseUrl);
118
+ const res = await fetch(url.toString(), {
119
+ headers: this.authHeaders(),
120
+ });
121
+ if (!res.ok)
122
+ throw new Error(`Failed to fetch room members: ${res.status}`);
123
+ const body = await res.json();
124
+ return normalizeRoomMembers(body.members ?? []);
125
+ }
126
+ /** Alias for {@link fetchMessages} — chronological room history via REST. */
127
+ fetchRoomHistory(roomId, options) {
128
+ return this.fetchMessages(roomId, options ?? {});
81
129
  }
82
130
  async listRooms(type) {
83
131
  const url = new URL("/rooms", this.baseUrl);
@@ -399,445 +447,3 @@ export class FluxyChatClient {
399
447
  throw new Error(`Failed to delete webhook: ${res.status}`);
400
448
  }
401
449
  }
402
- // React hook convenience API
403
- import { useEffect, useRef, useState } from "react";
404
- export function useChat({ roomId, client, agentId }) {
405
- const [messages, setMessages] = useState([]);
406
- const [online, setOnline] = useState(0);
407
- const [typingUsers, setTypingUsers] = useState({});
408
- const [seenBy, setSeenBy] = useState({});
409
- const [onlineUsers, setOnlineUsers] = useState([]);
410
- const [connected, setConnected] = useState(false);
411
- const [connectionStatus, setConnectionStatus] = useState("connecting");
412
- const [reconnectAttempt, setReconnectAttempt] = useState(0);
413
- const [connectionError, setConnectionError] = useState(null);
414
- const [agentTyping, setAgentTyping] = useState(false);
415
- const [wsTypingAgentId, setWsTypingAgentId] = useState(null);
416
- const [invokeTypingAgentId, setInvokeTypingAgentId] = useState(null);
417
- const [reactions, setReactions] = useState({});
418
- const connectionRef = useRef(null);
419
- const sseRef = useRef(null);
420
- const pollTimerRef = useRef(null);
421
- useEffect(() => {
422
- let active = true;
423
- const trimmedRoomId = roomId.trim();
424
- const MAX_WS_RECONNECT_ATTEMPTS = 6;
425
- const POLL_INTERVAL_MS = 4000;
426
- const stopPollingFallback = () => {
427
- if (pollTimerRef.current) {
428
- clearInterval(pollTimerRef.current);
429
- pollTimerRef.current = null;
430
- }
431
- };
432
- const stopSSEFallback = () => {
433
- if (sseRef.current) {
434
- sseRef.current.close();
435
- sseRef.current = null;
436
- }
437
- };
438
- const startPollingFallback = () => {
439
- stopPollingFallback();
440
- stopSSEFallback();
441
- const tick = async () => {
442
- if (!active)
443
- return;
444
- try {
445
- const next = await client.fetchMessages(trimmedRoomId);
446
- if (active)
447
- setMessages(next);
448
- }
449
- catch {
450
- /* ignore transient poll errors */
451
- }
452
- };
453
- void tick();
454
- pollTimerRef.current = setInterval(tick, POLL_INTERVAL_MS);
455
- };
456
- const startSSEFallback = () => {
457
- stopPollingFallback();
458
- stopSSEFallback();
459
- const es = client.connectSSE(trimmedRoomId);
460
- if (!es) {
461
- startPollingFallback();
462
- return;
463
- }
464
- sseRef.current = es;
465
- setConnectionStatus("sse");
466
- es.addEventListener("message", (event) => {
467
- if (!active)
468
- return;
469
- try {
470
- const data = JSON.parse(event.data);
471
- handleEvent(data);
472
- }
473
- catch {
474
- /* ignore malformed SSE events */
475
- }
476
- });
477
- es.addEventListener("error", () => {
478
- if (!active)
479
- return;
480
- stopSSEFallback();
481
- startPollingFallback();
482
- setConnectionStatus("polling");
483
- });
484
- };
485
- const handleEvent = (data) => {
486
- if (data.type === "history") {
487
- setMessages(data.messages);
488
- }
489
- else if (data.type === "message") {
490
- setMessages((prev) => {
491
- const idx = prev.findIndex((m) => m.id === data.id);
492
- if (idx >= 0) {
493
- const next = [...prev];
494
- next[idx] = { ...next[idx], ...data };
495
- return next;
496
- }
497
- return [...prev, data];
498
- });
499
- }
500
- else if (data.type === "presence") {
501
- setOnline(data.online);
502
- if (data.users)
503
- setOnlineUsers(data.users);
504
- }
505
- else if (data.type === "typing") {
506
- setTypingUsers((prev) => ({
507
- ...prev,
508
- [data.userId]: data.isTyping,
509
- }));
510
- }
511
- else if (data.type === "agentTyping") {
512
- setAgentTyping(data.isTyping);
513
- setWsTypingAgentId(data.isTyping ? data.agentId : null);
514
- }
515
- else if (data.type === "edit") {
516
- setMessages((prev) => prev.map((m) => m.id === data.id
517
- ? {
518
- ...m,
519
- content: data.content,
520
- editedAt: data.editedAt,
521
- streaming: data.streaming ?? false,
522
- }
523
- : m));
524
- }
525
- else if (data.type === "reaction") {
526
- setReactions((prev) => {
527
- const byMessage = { ...prev };
528
- const current = { ...(byMessage[data.messageId] || {}) };
529
- const existingCount = current[data.emoji] || 0;
530
- if (data.op === "remove") {
531
- const nextCount = Math.max(existingCount - 1, 0);
532
- if (nextCount === 0) {
533
- delete current[data.emoji];
534
- }
535
- else {
536
- current[data.emoji] = nextCount;
537
- }
538
- }
539
- else {
540
- current[data.emoji] = existingCount + 1;
541
- }
542
- if (Object.keys(current).length === 0) {
543
- delete byMessage[data.messageId];
544
- }
545
- else {
546
- byMessage[data.messageId] = current;
547
- }
548
- return byMessage;
549
- });
550
- }
551
- else if (data.type === "read") {
552
- setSeenBy((prev) => {
553
- const existing = prev[data.messageId] || [];
554
- if (existing.includes(data.userId))
555
- return prev;
556
- return {
557
- ...prev,
558
- [data.messageId]: [...existing, data.userId],
559
- };
560
- });
561
- }
562
- else if (data.type === "delete") {
563
- if (data.hard) {
564
- setMessages((prev) => prev.filter((m) => m.id !== data.id));
565
- }
566
- else {
567
- setMessages((prev) => prev.map((m) => m.id === data.id
568
- ? { ...m, content: "[deleted]", deletedAt: data.deletedAt }
569
- : m));
570
- }
571
- }
572
- };
573
- if (!trimmedRoomId || !client.isAuthenticated()) {
574
- setMessages([]);
575
- setConnected(false);
576
- setConnectionStatus("disconnected");
577
- return () => {
578
- active = false;
579
- stopPollingFallback();
580
- stopSSEFallback();
581
- connectionRef.current?.close();
582
- connectionRef.current = null;
583
- };
584
- }
585
- client.fetchMessages(trimmedRoomId).then((initial) => {
586
- if (!active)
587
- return;
588
- setMessages(initial);
589
- }).catch(() => {
590
- /* history load is best-effort until member JWT + room are ready */
591
- });
592
- const connection = client.connectRoom(trimmedRoomId, {
593
- maxReconnectAttempts: MAX_WS_RECONNECT_ATTEMPTS,
594
- onStatusChange: (status) => {
595
- if (!active)
596
- return;
597
- if (status === "connected") {
598
- setConnected(true);
599
- setConnectionStatus("connected");
600
- setReconnectAttempt(0);
601
- setConnectionError(null);
602
- stopPollingFallback();
603
- stopSSEFallback();
604
- }
605
- else if (status === "connecting") {
606
- setConnectionStatus("connecting");
607
- setConnected(false);
608
- }
609
- else if (status === "reconnecting") {
610
- setConnectionStatus("reconnecting");
611
- setConnected(false);
612
- setReconnectAttempt(connection.reconnectAttempts);
613
- }
614
- else if (status === "disconnected") {
615
- setConnected(false);
616
- setConnectionStatus("disconnected");
617
- }
618
- },
619
- onAuthError: (err) => {
620
- if (!active)
621
- return;
622
- setConnectionError(err);
623
- setConnected(false);
624
- setConnectionStatus("disconnected");
625
- },
626
- onConnectionError: (err) => {
627
- if (!active)
628
- return;
629
- if (!(err instanceof FluxyAuthError)) {
630
- setConnectionError(err);
631
- }
632
- },
633
- onReconnectFailed: () => {
634
- if (!active)
635
- return;
636
- setReconnectAttempt(connection.reconnectAttempts);
637
- if (client.isAuthenticated()) {
638
- startSSEFallback();
639
- }
640
- else {
641
- startPollingFallback();
642
- }
643
- },
644
- });
645
- connection.addEventListener("message", (data) => {
646
- if (!active)
647
- return;
648
- handleEvent(data);
649
- });
650
- connectionRef.current = connection;
651
- connection.connect();
652
- return () => {
653
- active = false;
654
- stopPollingFallback();
655
- stopSSEFallback();
656
- connection.close();
657
- connectionRef.current = null;
658
- setConnected(false);
659
- setConnectionStatus("disconnected");
660
- };
661
- }, [roomId, client]);
662
- const sendMessage = (content, replyTo, attachments) => {
663
- if (client.isAuthenticated()) {
664
- void client
665
- .createMessage(roomId, content, replyTo, attachments)
666
- .catch((err) =>
667
- // eslint-disable-next-line no-console
668
- console.error("[fluxychat] REST sendMessage failed, falling back to WS:", err));
669
- return;
670
- }
671
- try {
672
- connectionRef.current?.sendJson({
673
- type: "message",
674
- userId: client.userId,
675
- content,
676
- parentId: replyTo ?? null,
677
- attachments: attachments ?? [],
678
- });
679
- }
680
- catch (err) {
681
- if (err instanceof FluxySendError)
682
- return;
683
- throw err;
684
- }
685
- };
686
- const setTyping = (isTyping) => {
687
- try {
688
- connectionRef.current?.sendJson({
689
- type: "typing",
690
- userId: client.userId,
691
- isTyping,
692
- });
693
- }
694
- catch {
695
- /* socket not open */
696
- }
697
- };
698
- const editMessage = (messageId, content) => {
699
- const tryWsEdit = () => {
700
- try {
701
- connectionRef.current?.sendJson({
702
- type: "edit",
703
- userId: client.userId,
704
- messageId,
705
- content,
706
- });
707
- }
708
- catch {
709
- /* socket not open */
710
- }
711
- };
712
- if (client.isAuthenticated()) {
713
- void client.editMessageRest(messageId, content).catch((err) => {
714
- // eslint-disable-next-line no-console
715
- console.error("[fluxychat] REST editMessage failed, falling back to WS:", err);
716
- tryWsEdit();
717
- });
718
- return;
719
- }
720
- tryWsEdit();
721
- };
722
- const sendReaction = (messageId, emoji, op = "add") => {
723
- if (client.isAuthenticated()) {
724
- void client
725
- .sendReactionRest(messageId, emoji, op)
726
- .catch((err) =>
727
- // eslint-disable-next-line no-console
728
- console.error("[fluxychat] REST sendReaction failed, falling back to WS:", err));
729
- return;
730
- }
731
- try {
732
- connectionRef.current?.sendJson({
733
- type: "reaction",
734
- userId: client.userId,
735
- messageId,
736
- emoji,
737
- op,
738
- });
739
- }
740
- catch {
741
- /* socket not open */
742
- }
743
- };
744
- const sendReadReceipt = (messageId) => {
745
- if (client.isAuthenticated()) {
746
- void client
747
- .markReadRest(roomId, messageId)
748
- .catch((err) =>
749
- // eslint-disable-next-line no-console
750
- console.error("[fluxychat] REST sendReadReceipt failed, falling back to WS:", err));
751
- return;
752
- }
753
- try {
754
- connectionRef.current?.sendJson({
755
- type: "read",
756
- userId: client.userId,
757
- messageId,
758
- });
759
- }
760
- catch {
761
- /* socket not open */
762
- }
763
- };
764
- const deleteMessage = (messageId) => {
765
- const tryWsDelete = () => {
766
- try {
767
- connectionRef.current?.sendJson({ type: "delete", messageId });
768
- }
769
- catch {
770
- /* socket not open */
771
- }
772
- };
773
- if (client.isAuthenticated()) {
774
- void client.deleteMessageRest(messageId).catch((err) => {
775
- // eslint-disable-next-line no-console
776
- console.error("[fluxychat] REST deleteMessage failed, falling back to WS:", err);
777
- tryWsDelete();
778
- });
779
- return;
780
- }
781
- tryWsDelete();
782
- };
783
- const invokeAgent = async (content, options) => {
784
- const targetAgentId = options?.agentId || agentId;
785
- if (!targetAgentId) {
786
- throw new Error("invokeAgent requires an agentId in hook options or call options");
787
- }
788
- setAgentTyping(true);
789
- try {
790
- const result = await client.invokeAgentRest(targetAgentId, roomId, content, {
791
- replyTo: options?.replyTo,
792
- });
793
- return result;
794
- }
795
- finally {
796
- setAgentTyping(false);
797
- }
798
- };
799
- return {
800
- messages,
801
- online,
802
- typingUsers,
803
- seenBy,
804
- onlineUsers,
805
- connected,
806
- connectionStatus,
807
- reconnectAttempt,
808
- connectionError,
809
- agentTyping,
810
- typingAgentId: wsTypingAgentId ?? invokeTypingAgentId,
811
- reactions,
812
- sendMessage,
813
- setTyping,
814
- editMessage,
815
- sendReaction,
816
- sendReadReceipt,
817
- deleteMessage,
818
- invokeAgent,
819
- };
820
- }
821
- export function useRooms(client) {
822
- const [rooms, setRooms] = useState([]);
823
- const [loading, setLoading] = useState(false);
824
- const [error, setError] = useState(null);
825
- const load = async () => {
826
- setLoading(true);
827
- setError(null);
828
- try {
829
- const next = await client.listRooms();
830
- setRooms(next);
831
- }
832
- catch (e) {
833
- setError(e instanceof Error ? e.message : String(e));
834
- }
835
- finally {
836
- setLoading(false);
837
- }
838
- };
839
- useEffect(() => {
840
- void load();
841
- }, [client]);
842
- return { rooms, loading, error, reload: load };
843
- }
@@ -0,0 +1,8 @@
1
+ export interface DecodedFluxyJwt {
2
+ exp?: number;
3
+ sub?: string;
4
+ tid?: string;
5
+ }
6
+ /** Decode payload from a JWT (no signature verification). */
7
+ export declare function decodeFluxyJwtPayload(token: string): DecodedFluxyJwt;
8
+ export declare function jwtRefreshDelayMs(expSeconds: number, bufferMs?: number): number;
@@ -0,0 +1,19 @@
1
+ /** Decode payload from a JWT (no signature verification). */
2
+ export function decodeFluxyJwtPayload(token) {
3
+ try {
4
+ const part = token.split(".")[1];
5
+ if (!part)
6
+ return {};
7
+ const normalized = part.replace(/-/g, "+").replace(/_/g, "/");
8
+ const json = typeof atob === "function"
9
+ ? atob(normalized)
10
+ : Buffer.from(normalized, "base64").toString("utf8");
11
+ return JSON.parse(json);
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ export function jwtRefreshDelayMs(expSeconds, bufferMs = 5 * 60 * 1000) {
18
+ return Math.max(expSeconds * 1000 - Date.now() - bufferMs, 0);
19
+ }
@@ -0,0 +1,23 @@
1
+ /** Minimal fields required for history merge/sort. */
2
+ export interface HistoryMessage {
3
+ id: number;
4
+ createdAt: string;
5
+ roomId?: string;
6
+ userId?: string;
7
+ content?: string;
8
+ senderId?: string;
9
+ parentId?: number | null;
10
+ editedAt?: string | null;
11
+ deletedAt?: string | null;
12
+ mentions?: string[];
13
+ streaming?: boolean;
14
+ attachments?: {
15
+ kind: string;
16
+ url: string;
17
+ name: string;
18
+ }[];
19
+ }
20
+ export declare const MAX_HISTORY_LIMIT = 500;
21
+ export declare function sortMessagesChronological<T extends HistoryMessage>(messages: T[]): T[];
22
+ export declare function mergeMessagesChronological<T extends HistoryMessage>(existing: T[], incoming: T[]): T[];
23
+ export declare function clampHistoryLimit(limit?: number): number;
@@ -0,0 +1,21 @@
1
+ const DEFAULT_HISTORY_LIMIT = 50;
2
+ export const MAX_HISTORY_LIMIT = 500;
3
+ export function sortMessagesChronological(messages) {
4
+ return [...messages].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
5
+ }
6
+ export function mergeMessagesChronological(existing, incoming) {
7
+ const byId = new Map();
8
+ for (const msg of [...incoming, ...existing]) {
9
+ if (!Number.isFinite(msg.id))
10
+ continue;
11
+ const prev = byId.get(msg.id);
12
+ byId.set(msg.id, prev ? { ...prev, ...msg } : msg);
13
+ }
14
+ return sortMessagesChronological([...byId.values()]);
15
+ }
16
+ export function clampHistoryLimit(limit) {
17
+ const n = limit ?? DEFAULT_HISTORY_LIMIT;
18
+ if (!Number.isFinite(n) || n < 1)
19
+ return DEFAULT_HISTORY_LIMIT;
20
+ return Math.min(Math.floor(n), MAX_HISTORY_LIMIT);
21
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ export interface FluxyAuthTokenResult {
3
+ token: string;
4
+ userId?: string;
5
+ }
6
+ export interface FluxyRealtimeProviderProps {
7
+ children: React.ReactNode;
8
+ /** Worker HTTP base URL (e.g. https://api.example.com). */
9
+ workerUrl: string;
10
+ /**
11
+ * Pre-minted member JWT, or a callback that returns one.
12
+ * Use with your backend `POST /auth/token` flow.
13
+ */
14
+ authTokenProvider?: string | (() => Promise<string | FluxyAuthTokenResult>);
15
+ /**
16
+ * Hosted dashboard style: POST to obtain `memberJwt` (default body `{}`).
17
+ * Example: `/api/fluxy/connect` on the Next.js app.
18
+ */
19
+ connectUrl?: string;
20
+ connectRequestInit?: Omit<RequestInit, "method" | "body">;
21
+ /** Fallback user id when the JWT has no `sub` claim. */
22
+ userId?: string;
23
+ /** Refresh this many ms before JWT expiry (default 5 minutes). */
24
+ refreshBufferMs?: number;
25
+ onSessionError?: (error: Error) => void;
26
+ }
27
+ export declare function FluxyRealtimeProvider({ children, workerUrl, authTokenProvider, connectUrl, connectRequestInit, userId: userIdProp, refreshBufferMs, onSessionError, }: FluxyRealtimeProviderProps): import("react/jsx-runtime").JSX.Element;