1import { ExpoConfig, getConfig } from '@expo/config';
2
3import {
4  closeDevelopmentSessionAsync,
5  updateDevelopmentSessionAsync,
6} from '../../api/updateDevelopmentSession';
7import { getUserAsync } from '../../api/user/user';
8import { env } from '../../utils/env';
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 (env.EXPO_OFFLINE) {
50        debug(
51          'This project will not be suggested in Expo Go or Dev Clients because Expo CLI is running in offline-mode.'
52        );
53        this.stopNotifying();
54        return;
55      }
56
57      const deviceIds = await this.getDeviceInstallationIdsAsync();
58
59      if (!(await isAuthenticatedAsync()) && !deviceIds?.length) {
60        debug(
61          'Development session will not ping because the user is not authenticated and there are no devices.'
62        );
63        this.stopNotifying();
64        return;
65      }
66
67      if (this.url) {
68        debug(`Development session ping (runtime: ${runtime}, url: ${this.url})`);
69
70        await updateDevelopmentSessionAsync({
71          url: this.url,
72          runtime,
73          exp,
74          deviceIds,
75        });
76      }
77
78      this.stopNotifying();
79
80      this.timeout = setTimeout(() => this.startAsync({ exp, runtime }), UPDATE_FREQUENCY);
81    } catch (error: any) {
82      debug(`Error updating development session API: ${error}`);
83      this.stopNotifying();
84      this.onError(error);
85    }
86  }
87
88  /** Get all recent devices for the project. */
89  private async getDeviceInstallationIdsAsync(): Promise<string[]> {
90    const { devices } = await ProjectDevices.getDevicesInfoAsync(this.projectRoot);
91    return devices.map(({ installationId }) => installationId);
92  }
93
94  /** Stop notifying the Expo servers that the development session is running. */
95  public stopNotifying() {
96    if (this.timeout) {
97      clearTimeout(this.timeout);
98    }
99    this.timeout = null;
100  }
101
102  public async closeAsync(): Promise<void> {
103    this.stopNotifying();
104
105    const deviceIds = await this.getDeviceInstallationIdsAsync();
106
107    if (!(await isAuthenticatedAsync()) && !deviceIds?.length) {
108      return;
109    }
110
111    if (this.url) {
112      await closeDevelopmentSessionAsync({
113        url: this.url,
114        deviceIds,
115      });
116    }
117  }
118}
119