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  protected 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    /** Catch any errors that may occur during the `startAsync` method. */
28    private onError: (error: Error) => void
29  ) {}
30
31  /**
32   * Notify the Expo servers that a project is running, this enables the Expo Go app
33   * and Dev Clients to offer a "recently in development" section for quick access.
34   *
35   * This method starts an interval that will continue to ping the servers until we stop it.
36   *
37   * @param projectRoot Project root folder, used for retrieving device installation IDs.
38   * @param props.exp Partial Expo config with values that will be used in the Expo Go app.
39   * @param props.runtime which runtime the app should be opened in. `native` for dev clients, `web` for web browsers.
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    try {
49      if (APISettings.isOffline) {
50        debug('Development session will not ping because the server is offline.');
51        this.stopNotifying();
52        return;
53      }
54
55      const deviceIds = await this.getDeviceInstallationIdsAsync();
56
57      if (!(await isAuthenticatedAsync()) && !deviceIds?.length) {
58        debug(
59          'Development session will not ping because the user is not authenticated and there are no devices.'
60        );
61        this.stopNotifying();
62        return;
63      }
64
65      if (this.url) {
66        debug(`Development session ping (runtime: ${runtime}, url: ${this.url})`);
67
68        await updateDevelopmentSessionAsync({
69          url: this.url,
70          runtime,
71          exp,
72          deviceIds,
73        });
74      }
75
76      this.stopNotifying();
77
78      this.timeout = setTimeout(() => this.startAsync({ exp, runtime }), UPDATE_FREQUENCY);
79    } catch (error: any) {
80      debug(`Error updating development session API: ${error}`);
81      this.stopNotifying();
82      this.onError(error);
83    }
84  }
85
86  /** Get all recent devices for the project. */
87  private async getDeviceInstallationIdsAsync(): Promise<string[]> {
88    const { devices } = await ProjectDevices.getDevicesInfoAsync(this.projectRoot);
89    return devices.map(({ installationId }) => installationId);
90  }
91
92  /** Stop notifying the Expo servers that the development session is running. */
93  public stopNotifying() {
94    if (this.timeout) {
95      clearTimeout(this.timeout);
96    }
97    this.timeout = null;
98  }
99
100  public async closeAsync(): Promise<void> {
101    this.stopNotifying();
102
103    const deviceIds = await this.getDeviceInstallationIdsAsync();
104
105    if (!(await isAuthenticatedAsync()) && !deviceIds?.length) {
106      return;
107    }
108
109    if (this.url) {
110      await closeDevelopmentSessionAsync({
111        url: this.url,
112        deviceIds,
113      });
114    }
115  }
116}
117