Skip to content
Source
Sign inSign up
Open History to see the last commit

ExpoApi.ts

347 lines12.6 KB

1/**
2 * Expo's GraphQL API for Expo Simulators sessions. The operations in
3 * `ExpoApi.graphql` are the ones eas-cli's `simulator:*` commands send; their
4 * client is generated from `schema/expo.graphql` (`vp run generate`).
5 */
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13import {
14 GraphQLClient,
15 GraphQLClientError,
16 GraphQLMiddleware,
17 GraphQLProtocol,
18} from "effect/graphql";
19import { HttpClient } from "effect/http";
20import * as Headers from "effect/http/Headers";
21import * as HostProcess from "@t3tools/shared/HostProcess";
22​
23import { ExpoApiGraphQLGroup, type T3EasSimulatorSession } from "./ExpoApi.graphql.ts";
24import type { DeviceRunSessionStatus } from "../../schema/expo.graphql.ts";
25​
26const EXPO_GRAPHQL_URL = "https://api.expo.dev/graphql";
27const REQUEST_TIMEOUT = "30 seconds";
28​
29export type ExpoCredentialSource = "settings" | "env" | "eas-login";
30​
31export interface ExpoCredential {
32 readonly source: ExpoCredentialSource;
33 readonly header: {
34 readonly name: "authorization" | "expo-session";
35 readonly value: Redacted.Redacted<string>;
36 };
38​
39export class ExpoNotSignedInError extends Schema.TaggedError<ExpoNotSignedInError>()(
40 "ExpoNotSignedInError",
41 {},
42) {
43 override get message(): string {
44 return "No Expo credential on the server: save an access token for this host, set EXPO_TOKEN, or run `eas login`.";
45 }
47​
48const CREDENTIAL_SOURCE_NAMES: Record<ExpoCredentialSource, string> = {
49 settings: "The access token saved for this host",
50 env: "EXPO_TOKEN",
51 "eas-login": "The eas login session",
52};
53​
54export class ExpoInvalidCredentialError extends Schema.TaggedError<ExpoInvalidCredentialError>()(
55 "ExpoInvalidCredentialError",
56 { source: Schema.Literals(["settings", "env", "eas-login"]) },
57) {
58 override get message(): string {
59 return `${CREDENTIAL_SOURCE_NAMES[this.source]} is not a usable Expo credential. Replace it.`;
60 }
62​
63/**
64 * Expo credentials are printable ASCII. Anything else (the redacted
65 * placeholder, a pasted newline) would fail inside fetch with the header
66 * value in its message, so it is refused here.
67 */
68const credential = (
69 source: ExpoCredentialSource,
70 header: ExpoCredential["header"]["name"],
71 value: string,
72) =>
73 /^[\x21-\x7e]+$/.test(value)
74 ? Effect.succeed({
75 source,
76 header: {
77 name: header,
78 value: Redacted.make(header === "authorization" ? `Bearer ${value}` : value),
79 },
80 } satisfies ExpoCredential)
81 : Effect.fail(new ExpoInvalidCredentialError({ source }));
82​
83export class ExpoApiError extends Schema.TaggedError<ExpoApiError>()("ExpoApiError", {
84 operation: Schema.String,
85 detail: Schema.optional(Schema.String),
86 /**
87 * Expo answered and refused, so a mutation did nothing. Otherwise (no
88 * answer, a server error, an answer that did not decode) it may have run.
89 */
90 rejected: Schema.optional(Schema.Boolean),
91 /** The HTTP status Expo answered with, when it answered. */
92 status: Schema.optional(Schema.Int),
93}) {
94 override get message(): string {
95 return `Expo could not ${this.operation}${this.detail ? `: ${this.detail}` : "."}`;
96 }
98​
99/** `eas login` and `expo login` keep the session secret here. */
100const ExpoState = Schema.fromJsonString(
101 Schema.Struct({
102 auth: Schema.optional(
103 Schema.NullOr(
104 Schema.Struct({ sessionSecret: Schema.optional(Schema.NullOr(Schema.String)) }),
105 ),
106 ),
107 }),
108);
109const decodeExpoState = Schema.decodeUnknownOption(ExpoState);
110​
111/** A token saved for the host, then EXPO_TOKEN, then the `eas login` session. */
112export const resolveExpoCredential = Effect.fn("ExpoApi.resolveExpoCredential")(function* (
113 savedToken: string,
114) {
115 const saved = savedToken.trim();
116 if (saved !== "") return yield* credential("settings", "authorization", saved);
117 const fromEnv = (yield* HostProcess.Environment).EXPO_TOKEN?.trim();
118 if (fromEnv) return yield* credential("env", "authorization", fromEnv);
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const statePath = path.join(yield* HostProcess.HomeDirectory, ".expo", "state.json");
122 const state = yield* fs.readFileString(statePath).pipe(Effect.option);
123 const secret = Option.flatMap(state, decodeExpoState).pipe(
124 Option.flatMap((value) => Option.fromNullishOr(value.auth?.sessionSecret?.trim() || null)),
125 );
126 if (Option.isNone(secret)) return yield* new ExpoNotSignedInError();
127 return yield* credential("eas-login", "expo-session", secret.value);
128});
129​
130export type EasSessionStatus = DeviceRunSessionStatus;
131​
132export interface EasSession {
133 readonly id: string;
134 readonly status: EasSessionStatus;
135 /** Present once the simulator is ready; tokens grant full device control. */
136 readonly endpoints: {
137 readonly agentDevice: { readonly url: string; readonly token: Redacted.Redacted<string> };
138 readonly preview: { readonly url: string; readonly token: Redacted.Redacted<string> } | null;
139 } | null;
141​
142export interface EasApp {
143 readonly id: string;
144 readonly fullName: string;
146​
147export interface CreateEasSessionInput {
148 readonly appId: string;
149 readonly platform: "ios" | "android";
150 readonly name: string;
151 readonly maxRunTimeMinutes: number;
152 readonly maxIdleTimeMinutes: number;
154​
155/** Sends every operation with the resolved credential. */
156class ExpoAuth extends GraphQLMiddleware.Service<ExpoAuth>()("t3/server/device/ExpoApi/ExpoAuth") {}
157​
158const ExpoGroup = ExpoApiGraphQLGroup.middleware(ExpoAuth);
159​
160/**
161 * Expo's answer as an `ExpoApiError`, which has no cause: a transport error's
162 * holds the request with its credential header, and a decode error's can
163 * quote session tokens. These errors also travel to clients as RPC defects.
164 */
165const toExpoApiError = (
166 operation: string,
167 credential: ExpoCredential,
168 error: GraphQLClientError.GraphQLClientError,
169): ExpoApiError => {
170 const reason = error.reason;
171 const status =
172 reason._tag === "TransportError" || reason._tag === "ResponseError" ? reason.status : undefined;
173 // Expo user errors have `errorType: "USER"`, no `isTransient`, and null data (eas-cli's rule).
174 // Apollo's `code` is unreliable: Expo also uses INTERNAL_SERVER_ERROR for user errors.
175 // GraphQL errors at 5xx, beside data, or caused by Expo may follow a request that ran.
176 const errors = reason._tag === "ResponseError" ? reason.errors : [];
177 const fault = errors.some(
178 ({ extensions }) =>
179 extensions?.isTransient === true ||
180 (extensions?.errorType !== undefined && extensions.errorType !== "USER") ||
181 extensions?.errorCode === "INTERNAL_SERVER_ERROR",
182 );
183 const usersFault =
184 errors.length > 0 && errors.every(({ extensions }) => extensions?.errorType === "USER");
185 const uncertain =
186 reason._tag === "ResponseError" &&
187 (fault || (reason.data !== undefined && !(reason.data === null && usersFault)));
188 const rejected =
189 reason._tag === "EncodeError" ||
190 (status !== undefined &&
191 status < 500 &&
192 !uncertain &&
193 (status >= 400 || reason._tag === "ResponseError"));
194 // Expo's own messages are the reason the user acts on, but nothing promises
195 // they never echo a request: the credential is cut out, and they are bounded.
196 const secret = Redacted.value(credential.header.value);
197 const token = secret.replace(/^Bearer /, "");
198 const scrub = (text: string) =>
199 text.replaceAll(secret, "[redacted]").replaceAll(token, "[redacted]").slice(0, 300);
200 const failure = (detail: string) =>
201 new ExpoApiError({
202 operation,
203 detail: scrub(detail),
204 rejected,
205 ...(status !== undefined ? { status } : {}),
206 });
207 if (status === 401 || status === 403) {
208 const what = credential.source === "eas-login" ? "eas login session" : "access token";
209 return failure(`the ${what} was rejected (HTTP ${status})`);
210 }
211 switch (reason._tag) {
212 case "ResponseError":
213 return failure(reason.errors.map((graphQlError) => graphQlError.message).join("; "));
214 case "TransportError":
215 return failure(
216 status === undefined ? "Expo's API is unreachable" : `unexpected response (HTTP ${status})`,
217 );
218 case "EncodeError":
219 return failure("the request could not be encoded");
220 default:
221 return failure("unexpected response");
222 }
223};
224​
225const toSession = (
226 session: T3EasSimulatorSession.Result["deviceRunSessions"]["byId"],
227): EasSession => {
228 const remote = session.remoteConfig;
229 return {
230 id: session.id,
231 status: session.status,
232 endpoints:
233 remote?.__typename === "AgentDeviceRunSessionRemoteConfig"
234 ? {
235 agentDevice: {
236 url: remote.agentDeviceRemoteSessionUrl,
237 token: Redacted.make(remote.agentDeviceRemoteSessionToken),
238 },
239 preview:
240 remote.previewApiUrl && remote.webPreviewToken
241 ? { url: remote.previewApiUrl, token: Redacted.make(remote.webPreviewToken) }
242 : null,
243 }
244 : null,
245 };
246};
247​
248/** Typed calls against Expo's API with one resolved credential. */
249export const makeExpoApi = (http: HttpClient.HttpClient, credential: ExpoCredential) => {
250 const client = GraphQLClient.make(ExpoGroup).pipe(
251 Effect.provide(
252 Layer.mergeAll(
253 Layer.succeed(
254 ExpoAuth,
255 GraphQLMiddleware.mapRequest((request) =>
256 Effect.succeed({
257 ...request,
258 headers: Headers.set(
259 request.headers,
260 credential.header.name,
261 Redacted.value(credential.header.value),
262 ),
263 }),
264 ),
265 ),
266 GraphQLProtocol.layerHttp({ url: EXPO_GRAPHQL_URL }).pipe(
267 Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
268 ),
269 ),
270 ),
271 );
272 const call = <A>(
273 operation: string,
274 run: (
275 client: GraphQLClient.FromGroup<typeof ExpoGroup>,
276 ) => Effect.Effect<A, GraphQLClientError.GraphQLClientError>,
277 ) =>
278 Effect.flatMap(client, run).pipe(
279 Effect.mapError((error) => toExpoApiError(operation, credential, error)),
280 Effect.timeoutOrElse({
281 duration: REQUEST_TIMEOUT,
282 orElse: () =>
283 Effect.fail(new ExpoApiError({ operation, detail: "Expo's API did not answer in time" })),
284 }),
285 Effect.withSpan(`ExpoApi.${operation}`),
286 );
287​
288 return {
289 credential,
290 viewer: call("read the signed-in account", (expo) => expo.T3ExpoViewer()).pipe(
291 Effect.flatMap(({ meActor }) =>
292 meActor === null
293 ? Effect.fail(
294 new ExpoApiError({
295 operation: "read the signed-in account",
296 detail: "no account is signed in",
297 }),
298 )
299 : Effect.succeed({
300 name:
301 "username" in meActor
302 ? meActor.username
303 : "firstName" in meActor
304 ? (meActor.firstName ?? meActor.__typename)
305 : meActor.__typename,
306 apps: meActor.accounts.flatMap((account) =>
307 account.appsPaginated.edges.map(({ node }): EasApp => node),
308 ),
309 }),
310 ),
311 ),
312 create: (input: CreateEasSessionInput) =>
313 call("start a simulator session", (expo) =>
314 expo.T3CreateEasSimulatorSession({
315 input: {
316 appId: input.appId,
317 platform: input.platform === "ios" ? "IOS" : "ANDROID",
318 type: "AGENT_DEVICE",
319 name: input.name,
320 maxRunTimeMinutes: input.maxRunTimeMinutes,
321 maxIdleTimeMinutes: input.maxIdleTimeMinutes,
322 // Expo only issues the preview host's API URL when preview access is configured.
323 corsOrigins: ["http://localhost"],
324 },
325 }),
326 ).pipe(Effect.map(({ deviceRunSession }) => deviceRunSession.createDeviceRunSession.id)),
327 session: (id: string) =>
328 call("read a simulator session", (expo) => expo.T3EasSimulatorSession({ id })).pipe(
329 Effect.map(({ deviceRunSessions }) => toSession(deviceRunSessions.byId)),
330 ),
331 activeSessions: (appId: string) =>
332 call("list simulator sessions", (expo) =>
333 expo.T3ActiveEasSimulatorSessions({
334 appId,
335 filter: { statuses: ["NEW", "QUEUED", "STARTING", "IN_PROGRESS"] },
336 }),
337 ).pipe(
338 Effect.map(({ app }) => app.byId.deviceRunSessionsPaginated.edges.map(({ node }) => node)),
339 ),
340 stop: (id: string) =>
341 call("stop a simulator session", (expo) => expo.T3StopEasSimulatorSession({ id })).pipe(
342 Effect.map(({ deviceRunSession }) => deviceRunSession.ensureDeviceRunSessionStopped.status),
343 ),
344 };
345};
346​
347export type ExpoApi = ReturnType<typeof makeExpoApi>;