1import { ExpoConfig, getConfig } from '@expo/config';
2
3import { APISettings } from '../../api/settings';
4import {
5  closeDevelopmentSessionAsync,
6  updateDevelopmentSessionAsync,
7} from '../../api/updateDevelopmentSession';
8import { getUserAsync } from '../../api/user/user';
9import * as ProjectDevices from '../project/devices';
10
11const debug = require('debug')('expo:start:server:developmentSession') as typeof console.log;
12
13const UPDATE_FREQUENCY = 20 * 1000; // 20 seconds
14
15async function isAuthenticatedAsync(): Promise<boolean> {
16  return !!(await getUserAsync().catch(() => null));
17}
18
19export class DevelopmentSession {
20  private timeout: NodeJS.Timeout | null = null;
21
22  constructor(
23    /** Project root directory. */
24    private projectRoot: string,
25    /** Development Server URL. */
26    public url: string | null
27  ) {}
28
29  /**
30   * Notify the Expo servers that a project is running, this enables the Expo Go app
31   * and Dev Clients to offer a "recently in development" section for quick access.
32   *
33   * This method starts an interval that will continue to ping the servers until we stop it.
34   *
35   * @param projectRoot Project root folder, used for retrieving device installation IDs.
36   * @param props.exp Partial Expo config with values that will be used in the Expo Go app.
37   * @param props.runtime which runtime the app should be opened in. `native` for dev clients, `web` for web browsers.
38   * @returns
39   */
40  public async startAsync({
41    exp = getConfig(this.projectRoot).exp,
42    runtime,
43  }: {
44    exp?: Pick<ExpoConfig, 'name' | 'description' | 'slug' | 'primaryColor'>;
45    runtime: 'native' | 'web';
46  }): Promise<void> {
47    if (APISettings.isOffline) {
48      debug('Development session will not ping because the server is offline.');
49      this.stopNotifying();
50      return;
51    }
52
53    const deviceIds = await this.getDeviceInstallationIdsAsync();
54
55    if (!(await isAuthenticatedAsync()) && !deviceIds?.length) {
56      debug(
57        'Development session will not ping because the user is not authenticated and there are no devices.'
58      );
59      this.stopNotifying();
60      return;
61    }
62
63    if (this.url) {
64      debug(`Development session ping (runtime: ${runtime}, url: ${this.url})`);
65      await updateDevelopmentSessionAsync({
66        url: this.url,
67        runtime,
68        exp,
69        deviceIds,
70      });
71    }
72
73    this.stopNotifying();
74
75    this.timeout = setTimeout(() => this.startAsync({ exp, runtime }), UPDATE_FREQUENCY);
76  }
77
78  /** Get all recent devices for the project. */
79  private async getDeviceInstallationIdsAsync(): Promise<string[]> {
80    const { devices } = await ProjectDevices.getDevicesInfoAsync(this.projectRoot);
81    return devices.map(({ installationId }) => installationId);
82  }
83
84  /** Stop notifying the Expo servers that the development session is running. */
85  public stopNotifying() {
86    if (this.timeout) {
87      clearTimeout(this.timeout);
88    }
89    this.timeout = null;
90  }
91
92  public async closeAsync(): Promise<void> {
93    this.stopNotifying();
94
95    const deviceIds = await this.getDeviceInstallationIdsAsync();
96
97    if (!(await isAuthenticatedAsync()) && !deviceIds?.length) {
98      return;
99    }
100
101    if (this.url) {
102      await closeDevelopmentSessionAsync({
103        url: this.url,
104        deviceIds,
105      });
106    }
107  }
108}
109