浏览代码

feat: automatic reconnect

    * chore: use dev database port when connecting, if available
    * feat: disconnected dialog
    * feat: don't drop back to login component on connection closure
    * feat: return ready state from socket hook and attempt reconnection on close
    * feat: optional custom dev database port
    * feat: reconnect with exponential backoff
Fela Maslen 4 年之前
父节点
当前提交
b0eec02481

+ 1 - 1
gmus-backend/docker-compose.yml

@@ -50,7 +50,7 @@ services:
     networks:
       - net_gmus_dev
     ports:
-      - 5417:5432
+      - ${DB_PORT_GMUS_DEV:-5432}:5432
     environment:
       POSTGRES_USER: docker
       POSTGRES_PASSWORD: docker

+ 4 - 1
gmus-backend/pkg/config/config.go

@@ -53,8 +53,11 @@ func getDatabaseUrl() string {
 
 	user := os.Getenv("POSTGRES_USER")
 	password := os.Getenv("POSTGRES_PASSWORD")
+	portDev, hasPortDev := os.LookupEnv("DB_PORT_GMUS_DEV")
 	port, hasPort := os.LookupEnv("POSTGRES_PORT")
-	if !hasPort {
+	if hasPortDev {
+		port = portDev
+	} else if !hasPort {
 		port = "5432"
 	}
 	portNumeric, err := strconv.Atoi(port)

+ 1 - 1
gmus-backend/pkg/server/handler.go

@@ -16,7 +16,7 @@ func routeHandler(
 ) func(w http.ResponseWriter, r *http.Request) {
 
 	return func(w http.ResponseWriter, r *http.Request) {
-		l.Verbose("[%s] %s\n", r.Method, r.URL);
+		l.Verbose("[%s] %s\n", r.Method, r.URL)
 		err := handler(l, rdb, w, r)
 
 		if err != nil {

+ 1 - 0
gmus-mobile/Dockerfile

@@ -26,6 +26,7 @@ RUN mkdir app
 WORKDIR app
 
 COPY . .
+RUN touch .env
 USER root
 RUN chown -R developer:developer /home/developer/app
 USER developer

+ 0 - 2
gmus-mobile/lib/components/artists.dart

@@ -5,8 +5,6 @@ import 'package:http/http.dart' as http;
 
 import '../utils/url.dart';
 
-import './spinner.dart';
-
 Future<List<String>> fetchArtists(String apiUrl) async {
   final response = await http.get(formattedUrl(apiUrl, '/artists'));
 

+ 15 - 4
gmus-web/src/components/app.tsx

@@ -13,14 +13,22 @@ import { LoadingWrapper } from './identify';
 import { Interact, Props as InteractProps } from './interact';
 import { Player } from './player';
 import { uiProviders } from './ui';
-import { UIProvider } from './ui/types';
+import { UIProps, UIProvider } from './ui/types';
 
 export type Props = {
-  socket: WebSocket;
+  socket: WebSocket | null;
   interacted: boolean;
-} & InteractProps;
+} & InteractProps &
+  Pick<UIProps, 'connecting' | 'ready' | 'error'>;
 
-export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
+export const App: React.FC<Props> = ({
+  socket,
+  connecting,
+  ready,
+  error,
+  interacted,
+  setInteracted,
+}) => {
   useKeepalive(socket);
   useCurrentlyPlayingSongInfo();
 
@@ -59,6 +67,9 @@ export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
         {interacted && (
           <Suspense fallback={<LoadingWrapper />}>
             <UI
+              connecting={connecting}
+              ready={ready}
+              error={error}
               isMaster={isMaster(state)}
               currentSong={state.songInfo}
               nextSong={onNext}

+ 13 - 3
gmus-web/src/components/root.tsx

@@ -21,13 +21,16 @@ export const Root: React.FC = () => {
     [dispatch],
   );
 
-  const { name, onIdentify, socket, connecting, connected, error } = useSocket(onMessage, onLogin);
+  const { identified, onIdentify, ready, connecting, error, socket } = useSocket(
+    onMessage,
+    onLogin,
+  );
 
   const dispatchWithEffects = useDispatchWithEffects(state, dispatch, socket);
 
   const [interacted, setInteracted] = useState<boolean>(false);
 
-  if (!(socket && connected && name) || error) {
+  if (!identified) {
     return (
       <Identify connecting={connecting} onIdentify={onIdentify} setInteracted={setInteracted} />
     );
@@ -36,7 +39,14 @@ export const Root: React.FC = () => {
   return (
     <StateContext.Provider value={state}>
       <DispatchContext.Provider value={dispatchWithEffects}>
-        <App socket={socket} interacted={interacted} setInteracted={setInteracted} />
+        <App
+          socket={socket}
+          connecting={connecting}
+          ready={ready}
+          error={error}
+          interacted={interacted}
+          setInteracted={setInteracted}
+        />
       </DispatchContext.Provider>
     </StateContext.Provider>
   );

+ 4 - 0
gmus-web/src/components/ui/cmus/styled/spinner.tsx

@@ -1,4 +1,5 @@
 import { rem } from 'polished';
+import React from 'react';
 import styled, { keyframes } from 'styled-components';
 
 const spin = keyframes`
@@ -26,3 +27,6 @@ export const AsciiSpinner = styled.span`
     width: auto;
   }
 `;
+
+export const SpinnerOrEmpty: React.FC<{ loading: boolean }> = ({ loading }) =>
+  loading ? <AsciiSpinner /> : <span>&nbsp;&nbsp;</span>;

+ 2 - 2
gmus-web/src/components/ui/cmus/views/artists.tsx

@@ -5,7 +5,7 @@ import { FixedSizeList as List } from 'react-window';
 import { namedMemo } from '../../../../utils/component';
 import { CmusUIStateContext } from '../reducer';
 import { NoWrapFill } from '../styled/layout';
-import { AsciiSpinner } from '../styled/spinner';
+import { SpinnerOrEmpty } from '../styled/spinner';
 import { getArtistAlbumScrollIndex, lineHeight, useAutoJumpyScroll } from '../utils/scroll';
 
 import * as Styled from './artists.styles';
@@ -46,7 +46,7 @@ const Artist = namedMemo<{ row: ArtistData; style: CSSProperties }>(
       style={style}
       highlight={highlight}
     >
-      {loading ? <AsciiSpinner /> : <>&nbsp;&nbsp;</>}
+      <SpinnerOrEmpty loading={loading} />
       <NoWrapFill>{artist || 'Unknown Artist'}</NoWrapFill>
     </Styled.ArtistTitle>
   ),

+ 10 - 0
gmus-web/src/components/ui/cmus/views/disconnected.tsx

@@ -0,0 +1,10 @@
+import React from 'react';
+import { H3 } from '../styled/typography';
+import { HelpDialogContainer } from './help.styles';
+
+export const DisconnectedDialog: React.FC = () => (
+  <HelpDialogContainer>
+    <H3>Disconnected</H3>
+    <p>We will automatically try to reconnect.</p>
+  </HelpDialogContainer>
+);

+ 21 - 1
gmus-web/src/components/ui/cmus/views/status.tsx

@@ -4,11 +4,15 @@ import { isMaster } from '../../../../selectors';
 
 import { MusicPlayer, Song } from '../../../../types';
 import { formatTime } from '../../../../utils/time';
+import { AsciiSpinner } from '../styled/spinner';
 
 import * as Styled from './status.styles';
 
 export type Props = {
   song: Song | null;
+  connecting?: boolean;
+  ready?: boolean;
+  error?: boolean;
 };
 
 function getTrackMetadata(song: Song | null): string {
@@ -28,7 +32,21 @@ function getPlayPauseIcon(player: MusicPlayer): string {
   return '|';
 }
 
-export const PlayerStatus: React.FC<Props> = ({ song }) => {
+const StatusIcon: React.FC<Omit<Props, 'song'>> = ({
+  connecting = false,
+  ready = false,
+  error = false,
+}) => {
+  if (connecting) {
+    return <AsciiSpinner />;
+  }
+  if (error || !ready) {
+    return <span>!&nbsp;</span>;
+  }
+  return <span>✓&nbsp;</span>;
+};
+
+export const PlayerStatus: React.FC<Props> = ({ song, ...props }) => {
   const state = useContext(StateContext);
   return (
     <Styled.StatusContainer>
@@ -43,6 +61,8 @@ export const PlayerStatus: React.FC<Props> = ({ song }) => {
         <Styled.ClientName>
           {state.myClientName} [{isMaster(state) ? 'Master' : 'Slave'}]
         </Styled.ClientName>
+        &nbsp;
+        <StatusIcon {...props} />
       </Styled.PlayStatus>
     </Styled.StatusContainer>
   );

+ 16 - 3
gmus-web/src/components/ui/cmus/wrapper.tsx

@@ -19,6 +19,7 @@ import { Overlay, View } from './types';
 import { useLibrary } from './utils/library';
 import { ViewClientList } from './views/clients';
 import { CommandView } from './views/command';
+import { DisconnectedDialog } from './views/disconnected';
 import { HelpDialog } from './views/help';
 import { ViewLibrary } from './views/library';
 import { ViewQueue } from './views/queue';
@@ -28,7 +29,14 @@ import * as Styled from './wrapper.styles';
 
 const viewTitles = Object.values(View);
 
-export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, prevSong }) => {
+export const CmusUIProvider: UIProviderComponent = ({
+  connecting,
+  ready,
+  error,
+  currentSong,
+  nextSong,
+  prevSong,
+}) => {
   useMaster();
 
   const dispatch = useContext(DispatchContext);
@@ -53,7 +61,7 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
     }
   }, [stateUI.skipSong, nextSong, prevSong]);
 
-  useVimBindings(dispatchUI, stateUI.commandMode || stateUI.searchMode);
+  useVimBindings(dispatchUI, !ready || stateUI.commandMode || stateUI.searchMode);
 
   useLibrary(stateUI, dispatchUI);
 
@@ -83,8 +91,13 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
               {stateUI.overlay === Overlay.Help && <HelpDialog />}
             </Styled.Overlay>
           )}
+          {!ready && (
+            <Styled.Overlay>
+              <DisconnectedDialog />
+            </Styled.Overlay>
+          )}
           {stateUI.searchMode && <Search />}
-          <PlayerStatus song={currentSong} />
+          <PlayerStatus song={currentSong} connecting={connecting} error={error} ready={ready} />
           <CommandView />
         </Styled.Wrapper>
       </CmusUIDispatchContext.Provider>

+ 3 - 0
gmus-web/src/components/ui/types.ts

@@ -8,6 +8,9 @@ export enum UIProvider {
 }
 
 export type UIProps = {
+  connecting: boolean;
+  ready: boolean;
+  error: boolean;
   isMaster: boolean;
   currentSong: Song | null;
   nextSong: () => void;

+ 99 - 9
gmus-web/src/hooks/socket.spec.tsx

@@ -1,4 +1,4 @@
-import { act, fireEvent, render, RenderResult } from '@testing-library/react';
+import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react';
 import WS from 'jest-websocket-mock';
 import React, { Dispatch } from 'react';
 import * as storageHooks from 'react-storage-hooks';
@@ -167,9 +167,10 @@ describe(useSocket.name, () => {
   it.each`
     testCase                   | key             | expectedValue
     ${'the name'}              | ${'name'}       | ${''}
-    ${'the error status'}      | ${'error'}      | ${false}
     ${'the connecting status'} | ${'connecting'} | ${false}
-    ${'the connected status'}  | ${'connected'}  | ${false}
+    ${'the error status'}      | ${'error'}      | ${false}
+    ${'the ready status'}      | ${'ready'}      | ${false}
+    ${'the identified status'} | ${'identified'} | ${false}
   `('should return $testCase', ({ key, expectedValue }) => {
     expect.assertions(1);
     const { getByTestId } = render(<TestComponent />);
@@ -223,9 +224,19 @@ describe(useSocket.name, () => {
       expect(server).toHaveReceivedMessages(['Hello world!']);
     });
 
-    it('should set the connected state to true', async () => {
-      expect.assertions(1);
+    it('should set the connecting, ready and identified state', async () => {
+      expect.assertions(2);
       const { getByTestId } = setupIdentify();
+
+      expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+        expect.objectContaining({
+          connecting: true,
+          error: false,
+          ready: false,
+          identified: true,
+        }),
+      );
+
       await act(async () => {
         await server.connected;
       });
@@ -233,7 +244,9 @@ describe(useSocket.name, () => {
       expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
         expect.objectContaining({
           connecting: false,
-          connected: true,
+          error: false,
+          ready: true,
+          identified: true,
         }),
       );
     });
@@ -285,14 +298,16 @@ describe(useSocket.name, () => {
         .mockReturnValue(['my-stored-name' as unknown, saveName, undefined]);
     });
 
-    it('should set connecting to true', () => {
+    it('should set the status', () => {
       expect.assertions(1);
       const { getByTestId } = render(<TestComponent />);
 
       expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
         expect.objectContaining({
           connecting: true,
-          connected: false,
+          error: false,
+          identified: true,
+          ready: false,
         }),
       );
     });
@@ -322,7 +337,9 @@ describe(useSocket.name, () => {
       expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
         expect.objectContaining({
           connecting: false,
-          connected: true,
+          error: false,
+          identified: true,
+          ready: true,
         }),
       );
     });
@@ -349,4 +366,77 @@ describe(useSocket.name, () => {
       expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ data: 'foo' }));
     });
   });
+
+  describe('when an error occurs', () => {
+    let server: WS;
+    beforeEach(() => {
+      server = new WS('ws://my-api.url:1234/pubsub');
+    });
+
+    const setupError = async (): Promise<RenderResult> => {
+      const utils = render(<TestComponent />);
+      act(() => {
+        fireEvent.click(utils.getByText('Identify!'));
+      });
+
+      await server.connected;
+
+      act(() => {
+        server.error();
+      });
+
+      server = new WS('ws://my-api.url:1234/pubsub');
+
+      return utils;
+    };
+
+    it('should reconnect automatically', async () => {
+      expect.assertions(1);
+      await setupError();
+
+      await server.connected;
+
+      server.send('foo');
+
+      expect(onMessage).toHaveBeenCalledTimes(1);
+    });
+
+    it('should set error to true but keep the identified state', async () => {
+      expect.hasAssertions();
+      const { getByTestId } = await setupError();
+
+      expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+        expect.objectContaining({
+          ready: false,
+          error: true,
+          connecting: false,
+          identified: true,
+        }),
+      );
+
+      await waitFor(() => {
+        expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+          expect.objectContaining({
+            ready: false,
+            error: true,
+            connecting: true,
+            identified: true,
+          }),
+        );
+      });
+
+      await server.connected;
+
+      await waitFor(() => {
+        expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+          expect.objectContaining({
+            ready: true,
+            error: false,
+            connecting: false,
+            identified: true,
+          }),
+        );
+      });
+    });
+  });
 });

+ 82 - 32
gmus-web/src/hooks/socket.ts

@@ -7,6 +7,7 @@ import { errorOccurred } from '../actions/error';
 import { socketKeepaliveTimeout } from '../constants/system';
 import { globalEffects } from '../effects';
 import { GlobalState } from '../reducer';
+import { noop } from '../utils/noop';
 import { getPubsubUrl } from '../utils/url';
 
 const getUniqueName = (name: string): string => (name.length ? `${name}-${nanoid(5)}` : '');
@@ -61,45 +62,68 @@ export function useDispatchWithEffects(
   return dispatchWithEffects;
 }
 
-export function useSocket(
-  onMessage: OnMessage,
-  onLogin: (name: string) => void,
-): {
-  name: string | null;
-  onIdentify: (name: string) => void;
+const getConnectAttemptDelayMs = (connectAttemptNumber: number): number =>
+  Math.min(16000, 1000 * 2 ** connectAttemptNumber);
+
+type SocketState = {
+  uniqueName: string;
+  tempName: string;
   socket: WebSocket | null;
   error: boolean;
   connecting: boolean;
-  connected: boolean;
-} {
-  const [storedName, saveName] = useStorageState<string>(localStorage, clientNameKey, '');
-  const [uniqueName, setUniqueName] = useState<string>(getUniqueName(storedName));
-  const [tempName, onIdentify] = useState<string>(storedName);
+  connectAttemptNumber: number;
+};
+
+type SocketHookResult = {
+  name: string | null;
+  identified: boolean;
+  onIdentify: (name: string) => void;
+  connecting: boolean;
+  error: boolean;
+  ready: boolean;
+  socket: WebSocket | null;
+};
 
-  const [socket, setSocket] = useState<WebSocket | null>(null);
-  const [error, setError] = useState<boolean>(false);
+export function useSocket(onMessage: OnMessage, onLogin: (name: string) => void): SocketHookResult {
+  const [storedName, saveName] = useStorageState<string>(localStorage, clientNameKey, '');
+  const [{ uniqueName, tempName, socket, error, connecting }, setState] = useState<SocketState>({
+    uniqueName: getUniqueName(storedName),
+    tempName: storedName,
+    socket: null,
+    error: false,
+    connecting: false,
+    connectAttemptNumber: 0,
+  });
 
-  const [connecting, setConnecting] = useState<boolean>(false);
+  const connectAttemptTimer = useRef<number>();
 
   useEffect(() => {
     let cancelled = false;
     let ws: WebSocket | undefined;
-    if (tempName) {
-      setConnecting(true);
+
+    const connectIfPossible = (): void => {
+      if (!tempName) {
+        setState((last) => ({ ...last, connecting: false, error: false }));
+        return;
+      }
+
+      setState((last) => ({ ...last, connecting: true }));
 
       const uniqueTempName = getUniqueName(tempName);
       ws = new WebSocket(`${getPubsubUrl()}?client-name=${uniqueTempName}`);
 
       ws.onopen = (): void => {
         if (!cancelled && ws && ws.readyState === ws.OPEN) {
-          setError(false);
-          setConnecting(false);
+          setState((last) => ({
+            ...last,
+            error: false,
+            connecting: false,
+            connectAttemptNumber: 0,
+            uniqueName: uniqueTempName,
+            socket: ws ?? null,
+          }));
 
-          onIdentify('');
           saveName(tempName);
-          setUniqueName(uniqueTempName);
-
-          setSocket(ws);
           onLogin(uniqueTempName);
         }
       };
@@ -107,33 +131,59 @@ export function useSocket(
       ws.onmessage = onMessage;
 
       ws.onclose = (): void => {
-        setError(false);
-        setSocket(null);
+        setState((last) => {
+          clearTimeout(connectAttemptTimer.current);
+          connectAttemptTimer.current = setTimeout(
+            connectIfPossible,
+            getConnectAttemptDelayMs(last.connectAttemptNumber),
+          );
+
+          return {
+            ...last,
+            socket: null,
+            connecting: false,
+            connectAttemptNumber: last.connectAttemptNumber + 1,
+          };
+        });
       };
-    } else {
-      setConnecting(false);
-      setError(false);
-    }
+
+      ws.onerror = (): void => {
+        setState((last) => ({ ...last, error: true }));
+      };
+    };
+
+    connectIfPossible();
 
     return (): void => {
+      clearTimeout(connectAttemptTimer.current);
       cancelled = true;
     };
   }, [onMessage, onLogin, tempName, saveName]);
 
+  const onIdentify = useCallback(
+    (name: string) => setState((last) => ({ ...last, tempName: name })),
+    [],
+  );
+
   return {
     name: uniqueName,
+    identified: !!tempName,
     onIdentify,
-    socket,
-    error,
     connecting,
-    connected: !!socket && socket?.readyState === socket?.OPEN,
+    error,
+    ready: !!(socket && socket.readyState === socket.OPEN),
+    socket,
   };
 }
 
-export function useKeepalive(socket: WebSocket): void {
+export function useKeepalive(socket: WebSocket | null): void {
   const keepalive = useRef<number>();
 
   useEffect(() => {
+    if (!socket) {
+      return noop;
+    }
+
     keepalive.current = window.setInterval(() => {
       if (socket.readyState === socket.OPEN) {
         socket.send(JSON.stringify({ type: 'PING' }));

+ 3 - 3
gmus-web/src/hooks/vim.ts

@@ -1,6 +1,8 @@
 import { useThrottleCallback } from '@react-hook/throttle';
 import { Dispatch, useCallback, useEffect } from 'react';
 
+import { noop } from '../utils/noop';
+
 export const Keys = {
   tab: 'Tab',
   enter: 'Enter',
@@ -57,9 +59,7 @@ export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>, skip = fals
 
   useEffect(() => {
     if (skip) {
-      return (): void => {
-        // pass
-      };
+      return noop;
     }
 
     window.addEventListener('keydown', listenerThrottled);

+ 3 - 0
gmus-web/src/utils/noop.ts

@@ -0,0 +1,3 @@
+export const noop = (): void => {
+  /* pass */
+};