gloomberb 0.5.0 → 0.5.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/package.json +1 -1
- package/src/components/layout/floating-pane.tsx +3 -2
- package/src/components/layout/pane-sizing.ts +10 -0
- package/src/components/layout/pane.tsx +9 -2
- package/src/components/layout/shell.test.tsx +99 -4
- package/src/components/layout/shell.tsx +5 -3
- package/src/plugins/builtin/chat-controller.test.ts +21 -0
- package/src/plugins/builtin/chat-controller.ts +3 -2
- package/src/plugins/builtin/chat.tsx +10 -2
- package/src/utils/api-client.test.ts +90 -0
- package/src/utils/api-client.ts +56 -16
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { colors, floatingPaneBg } from "../../theme/colors";
|
|
3
3
|
import { PaneHeader } from "./pane-header";
|
|
4
|
-
import { getPaneBodyHeight } from "./pane-sizing";
|
|
4
|
+
import { getPaneBodyHeight, getPaneBodyHorizontalInset } from "./pane-sizing";
|
|
5
5
|
|
|
6
6
|
interface FloatingPaneWrapperProps {
|
|
7
7
|
title: string;
|
|
@@ -22,6 +22,7 @@ export function FloatingPaneWrapper({
|
|
|
22
22
|
}: FloatingPaneWrapperProps) {
|
|
23
23
|
const bg = floatingPaneBg(focused);
|
|
24
24
|
const bodyHeight = getPaneBodyHeight(height);
|
|
25
|
+
const bodyInset = getPaneBodyHorizontalInset(focused);
|
|
25
26
|
|
|
26
27
|
return (
|
|
27
28
|
<box
|
|
@@ -38,7 +39,7 @@ export function FloatingPaneWrapper({
|
|
|
38
39
|
<PaneHeader title={title} width={width} focused={focused} floating showActions={showActions} />
|
|
39
40
|
|
|
40
41
|
{/* Content */}
|
|
41
|
-
<box height={bodyHeight} overflow="hidden">
|
|
42
|
+
<box height={bodyHeight} overflow="hidden" paddingLeft={bodyInset} paddingRight={bodyInset}>
|
|
42
43
|
{children}
|
|
43
44
|
</box>
|
|
44
45
|
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
const RESERVED_PANE_CHROME_ROWS = 2;
|
|
2
|
+
const FOCUSED_PANE_BODY_INSET = 1;
|
|
3
|
+
const RESERVED_FOCUSED_PANE_CHROME_COLUMNS = FOCUSED_PANE_BODY_INSET * 2;
|
|
2
4
|
|
|
3
5
|
export function getPaneBodyHeight(height: number): number {
|
|
4
6
|
// Reserve the header row plus the bottom border/resize row.
|
|
5
7
|
return Math.max(1, height - RESERVED_PANE_CHROME_ROWS);
|
|
6
8
|
}
|
|
9
|
+
|
|
10
|
+
export function getPaneBodyHorizontalInset(focused: boolean): number {
|
|
11
|
+
return focused ? FOCUSED_PANE_BODY_INSET : 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getPaneBodyWidth(width: number, focused: boolean): number {
|
|
15
|
+
return Math.max(1, width - (focused ? RESERVED_FOCUSED_PANE_CHROME_COLUMNS : 0));
|
|
16
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { paneBg } from "../../theme/colors";
|
|
3
3
|
import { PaneHeader } from "./pane-header";
|
|
4
|
-
import { getPaneBodyHeight } from "./pane-sizing";
|
|
4
|
+
import { getPaneBodyHeight, getPaneBodyHorizontalInset } from "./pane-sizing";
|
|
5
5
|
|
|
6
6
|
interface PaneWrapperProps {
|
|
7
7
|
title?: string;
|
|
@@ -30,6 +30,7 @@ export function PaneWrapper({
|
|
|
30
30
|
const bodyHeight = typeof height === "number"
|
|
31
31
|
? title ? getPaneBodyHeight(height) : height
|
|
32
32
|
: undefined;
|
|
33
|
+
const bodyInset = getPaneBodyHorizontalInset(focused);
|
|
33
34
|
|
|
34
35
|
return (
|
|
35
36
|
<box
|
|
@@ -45,7 +46,13 @@ export function PaneWrapper({
|
|
|
45
46
|
{title && (
|
|
46
47
|
<PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
|
|
47
48
|
)}
|
|
48
|
-
<box
|
|
49
|
+
<box
|
|
50
|
+
height={bodyHeight}
|
|
51
|
+
flexGrow={bodyHeight == null ? 1 : 0}
|
|
52
|
+
overflow="hidden"
|
|
53
|
+
paddingLeft={bodyInset}
|
|
54
|
+
paddingRight={bodyInset}
|
|
55
|
+
>
|
|
49
56
|
{children}
|
|
50
57
|
</box>
|
|
51
58
|
</box>
|
|
@@ -35,6 +35,7 @@ afterEach(() => {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
function createShellPluginRegistry(options?: {
|
|
38
|
+
portfolioListComponent?: (props: PaneProps) => ReactNode;
|
|
38
39
|
tickerDetailComponent?: (props: PaneProps) => ReactNode;
|
|
39
40
|
}): PluginRegistry {
|
|
40
41
|
return {
|
|
@@ -42,7 +43,7 @@ function createShellPluginRegistry(options?: {
|
|
|
42
43
|
["portfolio-list", {
|
|
43
44
|
id: "portfolio-list",
|
|
44
45
|
name: "Portfolio List",
|
|
45
|
-
component: () => <text>Portfolio Body</text
|
|
46
|
+
component: options?.portfolioListComponent ?? (() => <text>Portfolio Body</text>),
|
|
46
47
|
defaultPosition: "left",
|
|
47
48
|
}],
|
|
48
49
|
["ticker-detail", {
|
|
@@ -281,6 +282,20 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
|
|
|
281
282
|
);
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
function FocusBorderProbe({ width, height }: Pick<PaneProps, "width" | "height">) {
|
|
286
|
+
return (
|
|
287
|
+
<box flexDirection="column" width={width} height={height}>
|
|
288
|
+
<box height={1} width={width}>
|
|
289
|
+
<text>Read Probe</text>
|
|
290
|
+
</box>
|
|
291
|
+
<box height={1} width={width} flexDirection="row">
|
|
292
|
+
<box flexGrow={1} />
|
|
293
|
+
<text>Right</text>
|
|
294
|
+
</box>
|
|
295
|
+
</box>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
284
299
|
function ChartShellHarness({
|
|
285
300
|
pluginRegistry,
|
|
286
301
|
config,
|
|
@@ -651,9 +666,11 @@ describe("Shell", () => {
|
|
|
651
666
|
harnessState?.config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main")?.settings?.chartResolution,
|
|
652
667
|
).toBe("1wk");
|
|
653
668
|
const resolutionSpans = testSetup.captureSpans().lines[resolutionRow]?.spans ?? [];
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
669
|
+
const boldResolutionText = resolutionSpans
|
|
670
|
+
.filter((span) => (span.attributes & TextAttributes.BOLD) !== 0)
|
|
671
|
+
.map((span) => span.text)
|
|
672
|
+
.join("");
|
|
673
|
+
expect(boldResolutionText).toContain(targetResolution);
|
|
657
674
|
});
|
|
658
675
|
|
|
659
676
|
test("keeps the cash drawer and gridlock tip on distinct click rows in the full app layout", async () => {
|
|
@@ -700,6 +717,84 @@ describe("Shell", () => {
|
|
|
700
717
|
expect(toasts).toEqual(["Retiled all panes"]);
|
|
701
718
|
});
|
|
702
719
|
|
|
720
|
+
test("keeps focused docked pane body text inside the focus border", async () => {
|
|
721
|
+
const config = createDefaultConfig("/tmp/gloomberb-shell-focus-border-test");
|
|
722
|
+
const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
|
|
723
|
+
if (!mainPane) throw new Error("missing default portfolio pane");
|
|
724
|
+
|
|
725
|
+
const singlePaneLayout = {
|
|
726
|
+
dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
|
|
727
|
+
instances: [{ ...mainPane }],
|
|
728
|
+
floating: [],
|
|
729
|
+
};
|
|
730
|
+
const state = {
|
|
731
|
+
...createInitialState({
|
|
732
|
+
...config,
|
|
733
|
+
layout: cloneLayout(singlePaneLayout),
|
|
734
|
+
layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
|
|
735
|
+
}),
|
|
736
|
+
focusedPaneId: "portfolio-list:main",
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
testSetup = await testRender(
|
|
740
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
741
|
+
<DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
|
|
742
|
+
<Shell pluginRegistry={createShellPluginRegistry({
|
|
743
|
+
portfolioListComponent: FocusBorderProbe,
|
|
744
|
+
})} />
|
|
745
|
+
</DialogProvider>
|
|
746
|
+
</AppContext>,
|
|
747
|
+
{ width: 40, height: 10 },
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
await testSetup.renderOnce();
|
|
751
|
+
|
|
752
|
+
const frame = testSetup.captureCharFrame();
|
|
753
|
+
expect(frame).toContain("│Read Probe");
|
|
754
|
+
expect(frame).toContain("Right│");
|
|
755
|
+
expect(frame).not.toContain("│ead Probe");
|
|
756
|
+
expect(frame).not.toContain("Righ│");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("keeps focused floating pane body text inside the focus border", async () => {
|
|
760
|
+
const config = createDefaultConfig("/tmp/gloomberb-shell-floating-focus-border-test");
|
|
761
|
+
const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
|
|
762
|
+
if (!detailPane) throw new Error("missing detail pane");
|
|
763
|
+
|
|
764
|
+
const floatingOnlyLayout = {
|
|
765
|
+
dockRoot: null,
|
|
766
|
+
instances: [{ ...detailPane }],
|
|
767
|
+
floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
|
|
768
|
+
};
|
|
769
|
+
const state = {
|
|
770
|
+
...createInitialState({
|
|
771
|
+
...config,
|
|
772
|
+
layout: cloneLayout(floatingOnlyLayout),
|
|
773
|
+
layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
|
|
774
|
+
}),
|
|
775
|
+
focusedPaneId: "ticker-detail:main",
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
testSetup = await testRender(
|
|
779
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
780
|
+
<DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
|
|
781
|
+
<Shell pluginRegistry={createShellPluginRegistry({
|
|
782
|
+
tickerDetailComponent: FocusBorderProbe,
|
|
783
|
+
})} />
|
|
784
|
+
</DialogProvider>
|
|
785
|
+
</AppContext>,
|
|
786
|
+
{ width: 40, height: 12 },
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
await testSetup.renderOnce();
|
|
790
|
+
|
|
791
|
+
const frame = testSetup.captureCharFrame();
|
|
792
|
+
expect(frame).toContain("│Read Probe");
|
|
793
|
+
expect(frame).toContain("Right│");
|
|
794
|
+
expect(frame).not.toContain("│ead Probe");
|
|
795
|
+
expect(frame).not.toContain("Righ│");
|
|
796
|
+
});
|
|
797
|
+
|
|
703
798
|
test("shows the gridlock tip after snapping a pane to a half screen", () => {
|
|
704
799
|
const config = createDefaultConfig("/tmp/gloomberb-shell-snap-test");
|
|
705
800
|
const snapLayout = cloneLayout(config.layout);
|
|
@@ -39,7 +39,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
|
|
|
39
39
|
import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
|
|
40
40
|
import { FloatingPaneWrapper } from "./floating-pane";
|
|
41
41
|
import { PaneWrapper } from "./pane";
|
|
42
|
-
import { getPaneBodyHeight } from "./pane-sizing";
|
|
42
|
+
import { getPaneBodyHeight, getPaneBodyWidth } from "./pane-sizing";
|
|
43
43
|
|
|
44
44
|
interface ShellProps {
|
|
45
45
|
pluginRegistry: PluginRegistry;
|
|
@@ -987,6 +987,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
987
987
|
const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
|
|
988
988
|
const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
|
|
989
989
|
const bodyHeight = getPaneBodyHeight(leaf.rect.height);
|
|
990
|
+
const bodyWidth = getPaneBodyWidth(leaf.rect.width, focused);
|
|
990
991
|
return (
|
|
991
992
|
<box
|
|
992
993
|
key={`dock:${leaf.instanceId}`}
|
|
@@ -1009,7 +1010,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1009
1010
|
paneId={pane.instance.instanceId}
|
|
1010
1011
|
paneType={pane.instance.paneId}
|
|
1011
1012
|
focused={focused}
|
|
1012
|
-
width={
|
|
1013
|
+
width={bodyWidth}
|
|
1013
1014
|
height={bodyHeight}
|
|
1014
1015
|
/>
|
|
1015
1016
|
</PaneInstanceProvider>
|
|
@@ -1023,6 +1024,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1023
1024
|
const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
|
|
1024
1025
|
const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
|
|
1025
1026
|
const bodyHeight = getPaneBodyHeight(preview.height);
|
|
1027
|
+
const bodyWidth = getPaneBodyWidth(preview.width, focused);
|
|
1026
1028
|
return (
|
|
1027
1029
|
<FloatingPaneWrapper
|
|
1028
1030
|
key={`float:${pane.instance.instanceId}`}
|
|
@@ -1041,7 +1043,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1041
1043
|
paneId={pane.instance.instanceId}
|
|
1042
1044
|
paneType={pane.instance.paneId}
|
|
1043
1045
|
focused={focused}
|
|
1044
|
-
width={
|
|
1046
|
+
width={bodyWidth}
|
|
1045
1047
|
height={bodyHeight}
|
|
1046
1048
|
close={() => handleFloatingClose(pane.instance.instanceId)}
|
|
1047
1049
|
/>
|
|
@@ -155,6 +155,27 @@ describe("ChatController", () => {
|
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
test("does not restore persisted websocket tokens", () => {
|
|
159
|
+
const persistence = new MemoryPersistence();
|
|
160
|
+
const controller = new ChatController();
|
|
161
|
+
|
|
162
|
+
persistence.setState("session", {
|
|
163
|
+
sessionToken: "token-123",
|
|
164
|
+
websocketToken: "stale-ws-token",
|
|
165
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
166
|
+
}, { schemaVersion: 1 });
|
|
167
|
+
|
|
168
|
+
controller.attachPersistence(persistence);
|
|
169
|
+
|
|
170
|
+
expect(apiClient.getSessionToken()).toBe("token-123");
|
|
171
|
+
expect(apiClient.getWebSocketToken()).toBeNull();
|
|
172
|
+
expect(apiClient.getCurrentUser()).toMatchObject({
|
|
173
|
+
id: "u1",
|
|
174
|
+
username: "vince",
|
|
175
|
+
emailVerified: true,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
158
179
|
test("reset clears persisted chat state and session token", () => {
|
|
159
180
|
const persistence = new MemoryPersistence();
|
|
160
181
|
const controller = new ChatController();
|
|
@@ -129,7 +129,9 @@ export class ChatController {
|
|
|
129
129
|
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
130
130
|
});
|
|
131
131
|
apiClient.setSessionToken(session?.sessionToken ?? null);
|
|
132
|
-
|
|
132
|
+
// WebSocket tokens are short-lived connection credentials. Reusing a persisted
|
|
133
|
+
// one can trap reconnects on an expired token even while the session cookie is valid.
|
|
134
|
+
apiClient.setWebSocketToken(null);
|
|
133
135
|
apiClient.restoreCachedUser(session?.user ?? null);
|
|
134
136
|
this.user = session?.user
|
|
135
137
|
? {
|
|
@@ -483,7 +485,6 @@ export class ChatController {
|
|
|
483
485
|
private persistSession(): void {
|
|
484
486
|
const value = {
|
|
485
487
|
sessionToken: apiClient.getSessionToken(),
|
|
486
|
-
websocketToken: apiClient.getWebSocketToken(),
|
|
487
488
|
user: this.user,
|
|
488
489
|
} satisfies PersistedSessionState;
|
|
489
490
|
this.resume?.setState(SESSION_STATE_KEY, value, {
|
|
@@ -962,10 +962,18 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
962
962
|
ctx.notify({ body: "Not logged in.", type: "error" });
|
|
963
963
|
return;
|
|
964
964
|
}
|
|
965
|
-
|
|
965
|
+
let signOutError: unknown = null;
|
|
966
|
+
try {
|
|
967
|
+
await apiClient.signOut();
|
|
968
|
+
} catch (error) {
|
|
969
|
+
signOutError = error;
|
|
970
|
+
}
|
|
966
971
|
await chatController.refreshSession();
|
|
967
972
|
await chatController.refreshMessages();
|
|
968
|
-
ctx.notify({
|
|
973
|
+
ctx.notify({
|
|
974
|
+
body: signOutError ? "Logged out locally. Cloud sign-out did not complete." : "Logged out.",
|
|
975
|
+
type: "info",
|
|
976
|
+
});
|
|
969
977
|
},
|
|
970
978
|
hidden: () => !apiClient.getSessionToken(),
|
|
971
979
|
});
|
|
@@ -3,6 +3,7 @@ import type { AuthUser } from "./api-client";
|
|
|
3
3
|
import { apiClient } from "./api-client";
|
|
4
4
|
|
|
5
5
|
const originalFetch = globalThis.fetch;
|
|
6
|
+
const originalWebSocket = globalThis.WebSocket;
|
|
6
7
|
|
|
7
8
|
const verifiedUser: AuthUser = {
|
|
8
9
|
id: "user-1",
|
|
@@ -33,7 +34,9 @@ function createResponse(body: unknown, options: { status?: number; cookies?: str
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
afterEach(() => {
|
|
37
|
+
apiClient.dispose();
|
|
36
38
|
globalThis.fetch = originalFetch;
|
|
39
|
+
globalThis.WebSocket = originalWebSocket;
|
|
37
40
|
apiClient.setSessionToken(null);
|
|
38
41
|
apiClient.setWebSocketToken(null);
|
|
39
42
|
});
|
|
@@ -83,6 +86,93 @@ describe("apiClient auth cookies", () => {
|
|
|
83
86
|
"__Secure-gloomberb.session_token=persisted-token.value; gloomberb.session_token=persisted-token.value",
|
|
84
87
|
]);
|
|
85
88
|
});
|
|
89
|
+
|
|
90
|
+
test("keeps cached identity when session refresh is rejected without a hard account-missing response", async () => {
|
|
91
|
+
apiClient.setSessionToken("persisted-token.value");
|
|
92
|
+
apiClient.restoreCachedUser(verifiedUser);
|
|
93
|
+
|
|
94
|
+
globalThis.fetch = (async () => createResponse({ message: "Unauthorized" }, { status: 401 })) as typeof fetch;
|
|
95
|
+
|
|
96
|
+
await expect(apiClient.getSession()).rejects.toThrow("Unauthorized");
|
|
97
|
+
expect(apiClient.getSessionToken()).toBe("persisted-token.value");
|
|
98
|
+
expect(apiClient.getCurrentUser()).toMatchObject({
|
|
99
|
+
id: verifiedUser.id,
|
|
100
|
+
username: verifiedUser.username,
|
|
101
|
+
emailVerified: true,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("clears cached identity when session refresh says the account no longer exists", async () => {
|
|
106
|
+
apiClient.setSessionToken("persisted-token.value");
|
|
107
|
+
apiClient.setWebSocketToken("ws-token");
|
|
108
|
+
apiClient.restoreCachedUser(verifiedUser);
|
|
109
|
+
|
|
110
|
+
globalThis.fetch = (async () => createResponse({ code: "USER_NOT_FOUND" }, { status: 403 })) as typeof fetch;
|
|
111
|
+
|
|
112
|
+
await expect(apiClient.getSession()).resolves.toBeNull();
|
|
113
|
+
expect(apiClient.getSessionToken()).toBeNull();
|
|
114
|
+
expect(apiClient.getWebSocketToken()).toBeNull();
|
|
115
|
+
expect(apiClient.getCurrentUser()).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("clears local session on explicit sign out even if the server request fails", async () => {
|
|
119
|
+
apiClient.setSessionToken("persisted-token.value");
|
|
120
|
+
apiClient.setWebSocketToken("ws-token");
|
|
121
|
+
apiClient.restoreCachedUser(verifiedUser);
|
|
122
|
+
|
|
123
|
+
globalThis.fetch = (async () => createResponse({ message: "server unavailable" }, { status: 503 })) as typeof fetch;
|
|
124
|
+
|
|
125
|
+
await expect(apiClient.signOut()).rejects.toThrow("server unavailable");
|
|
126
|
+
expect(apiClient.getSessionToken()).toBeNull();
|
|
127
|
+
expect(apiClient.getWebSocketToken()).toBeNull();
|
|
128
|
+
expect(apiClient.getCurrentUser()).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("drops a stale websocket token after socket close so reconnect can use the session token", () => {
|
|
132
|
+
const sockets: Array<{
|
|
133
|
+
url: string;
|
|
134
|
+
readyState: number;
|
|
135
|
+
onclose: ((event: unknown) => void) | null;
|
|
136
|
+
close: () => void;
|
|
137
|
+
}> = [];
|
|
138
|
+
|
|
139
|
+
class FakeWebSocket {
|
|
140
|
+
static OPEN = 1;
|
|
141
|
+
readyState = 0;
|
|
142
|
+
onopen: ((event: unknown) => void) | null = null;
|
|
143
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
144
|
+
onclose: ((event: unknown) => void) | null = null;
|
|
145
|
+
onerror: ((event: unknown) => void) | null = null;
|
|
146
|
+
|
|
147
|
+
constructor(readonly url: string) {
|
|
148
|
+
sockets.push(this);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
send(): void {}
|
|
152
|
+
|
|
153
|
+
close(): void {
|
|
154
|
+
this.readyState = 3;
|
|
155
|
+
this.onclose?.({ code: 1000, reason: "closed" });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
globalThis.WebSocket = FakeWebSocket as any;
|
|
160
|
+
apiClient.setSessionToken("session-token");
|
|
161
|
+
apiClient.setWebSocketToken("stale-ws-token");
|
|
162
|
+
apiClient.restoreCachedUser(verifiedUser);
|
|
163
|
+
|
|
164
|
+
const unsubscribe = apiClient.subscribeQuotes([{ symbol: "AAPL" }], () => {});
|
|
165
|
+
|
|
166
|
+
expect(sockets).toHaveLength(1);
|
|
167
|
+
expect(sockets[0]!.url).toContain("token=stale-ws-token");
|
|
168
|
+
|
|
169
|
+
sockets[0]!.onclose?.({ code: 1008, reason: "Unauthorized" });
|
|
170
|
+
|
|
171
|
+
expect(apiClient.getWebSocketToken()).toBeNull();
|
|
172
|
+
expect(apiClient.getSessionToken()).toBe("session-token");
|
|
173
|
+
|
|
174
|
+
unsubscribe();
|
|
175
|
+
});
|
|
86
176
|
});
|
|
87
177
|
|
|
88
178
|
describe("apiClient chat timestamps", () => {
|
package/src/utils/api-client.ts
CHANGED
|
@@ -6,6 +6,11 @@ import { normalizeTimestamp } from "./timestamp";
|
|
|
6
6
|
const DEFAULT_API_URL = "https://api.gloom.sh";
|
|
7
7
|
const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
|
|
8
8
|
const cloudApiLog = debugLog.createLogger("cloud-api");
|
|
9
|
+
const HARD_SESSION_INVALID_PATTERNS = [
|
|
10
|
+
/\b(user|account)\b.*\b(not found|deleted|removed|disabled|deactivated|suspended)\b/i,
|
|
11
|
+
/\b(user|account)\b.*\bdoes(?:\s+not|n't)\s+exist\b/i,
|
|
12
|
+
/\b(no|unknown|missing)\s+(user|account)\b/i,
|
|
13
|
+
];
|
|
9
14
|
|
|
10
15
|
export interface ChatMessage {
|
|
11
16
|
id: string;
|
|
@@ -131,6 +136,22 @@ class ApiRequestError extends Error {
|
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
function parseApiErrorMessage(body: string): string {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
142
|
+
const parts = [parsed.message, parsed.error, parsed.code, parsed.reason]
|
|
143
|
+
.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
144
|
+
return parts.join(" ") || body;
|
|
145
|
+
} catch {
|
|
146
|
+
return body;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isHardSessionInvalidMessage(message: string): boolean {
|
|
151
|
+
const normalized = message.replace(/[_-]+/g, " ");
|
|
152
|
+
return HARD_SESSION_INVALID_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
class GloomApiClient {
|
|
135
156
|
private sessionToken: string | null = null;
|
|
136
157
|
private sessionCookieName: SessionCookieName | null = null;
|
|
@@ -262,12 +283,7 @@ class GloomApiClient {
|
|
|
262
283
|
|
|
263
284
|
if (!res.ok) {
|
|
264
285
|
const body = await res.text();
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
msg = JSON.parse(body).message ?? body;
|
|
268
|
-
} catch {
|
|
269
|
-
msg = body;
|
|
270
|
-
}
|
|
286
|
+
const msg = parseApiErrorMessage(body);
|
|
271
287
|
throw new ApiRequestError(msg, res.status);
|
|
272
288
|
}
|
|
273
289
|
|
|
@@ -300,10 +316,11 @@ class GloomApiClient {
|
|
|
300
316
|
|
|
301
317
|
const socketToken = this.getSocketAuthToken();
|
|
302
318
|
if (!socketToken) return;
|
|
319
|
+
const usingWebSocketToken = !!this.websocketToken;
|
|
303
320
|
const url = `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`;
|
|
304
321
|
cloudApiLog.info("open websocket", {
|
|
305
322
|
hasToken: !!socketToken,
|
|
306
|
-
tokenSource:
|
|
323
|
+
tokenSource: usingWebSocketToken ? "websocket" : "session",
|
|
307
324
|
quoteTargets: this.quoteTargets.size,
|
|
308
325
|
channelTargets: this.channelListeners.size,
|
|
309
326
|
});
|
|
@@ -321,11 +338,24 @@ class GloomApiClient {
|
|
|
321
338
|
void this.handleSocketMessage(String(event.data));
|
|
322
339
|
};
|
|
323
340
|
|
|
324
|
-
ws.onclose = () => {
|
|
341
|
+
ws.onclose = (event) => {
|
|
342
|
+
const activeSocket = this.ws === ws;
|
|
325
343
|
if (this.ws === ws) {
|
|
326
344
|
this.ws = null;
|
|
327
345
|
}
|
|
328
|
-
|
|
346
|
+
const closeEvent = event as CloseEvent | undefined;
|
|
347
|
+
cloudApiLog.warn("websocket closed", {
|
|
348
|
+
quoteTargets: this.quoteTargets.size,
|
|
349
|
+
channelTargets: this.channelListeners.size,
|
|
350
|
+
code: closeEvent?.code,
|
|
351
|
+
reason: closeEvent?.reason,
|
|
352
|
+
tokenSource: usingWebSocketToken ? "websocket" : "session",
|
|
353
|
+
});
|
|
354
|
+
if (activeSocket && usingWebSocketToken && this.sessionToken) {
|
|
355
|
+
this.websocketToken = null;
|
|
356
|
+
this.reconnectDelayMs = 1000;
|
|
357
|
+
cloudApiLog.warn("cleared websocket token after socket close; falling back to session token");
|
|
358
|
+
}
|
|
329
359
|
if (!this.shouldKeepSocketOpen()) return;
|
|
330
360
|
this.scheduleReconnect();
|
|
331
361
|
};
|
|
@@ -410,6 +440,14 @@ class GloomApiClient {
|
|
|
410
440
|
|
|
411
441
|
if (parsed?.type === "auth.unverified") {
|
|
412
442
|
cloudApiLog.warn("websocket marked unverified");
|
|
443
|
+
if (this.websocketToken && this.sessionToken) {
|
|
444
|
+
this.websocketToken = null;
|
|
445
|
+
this.reconnectDelayMs = 1000;
|
|
446
|
+
cloudApiLog.warn("cleared websocket token after auth rejection; falling back to session token");
|
|
447
|
+
this.teardownSocket();
|
|
448
|
+
this.scheduleReconnect();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
413
451
|
if (this.currentUser) {
|
|
414
452
|
this.currentUser = { ...this.currentUser, emailVerified: false };
|
|
415
453
|
}
|
|
@@ -464,9 +502,11 @@ class GloomApiClient {
|
|
|
464
502
|
}
|
|
465
503
|
|
|
466
504
|
async signOut(): Promise<void> {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
505
|
+
try {
|
|
506
|
+
await this.request("/api/auth/api/auth/sign-out", { method: "POST" });
|
|
507
|
+
} finally {
|
|
508
|
+
this.setSessionToken(null);
|
|
509
|
+
}
|
|
470
510
|
}
|
|
471
511
|
|
|
472
512
|
async getSession(): Promise<AuthUser | null> {
|
|
@@ -478,11 +518,11 @@ class GloomApiClient {
|
|
|
478
518
|
this.setCurrentUser(user);
|
|
479
519
|
return user;
|
|
480
520
|
} catch (error) {
|
|
481
|
-
if (
|
|
482
|
-
|
|
521
|
+
if (error instanceof ApiRequestError && isHardSessionInvalidMessage(error.message)) {
|
|
522
|
+
this.setSessionToken(null);
|
|
523
|
+
return null;
|
|
483
524
|
}
|
|
484
|
-
|
|
485
|
-
return null;
|
|
525
|
+
throw error;
|
|
486
526
|
}
|
|
487
527
|
}
|
|
488
528
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.5.
|
|
1
|
+
export const VERSION = "0.5.1";
|