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