Skip to content
Source
Sign inSign up

EasDeviceHost.ts

948 lines36.6 KB

1/**
2 * Cloud simulators from EAS Simulator. A device exists only while a session
3 * runs, and every session bills the Expo account until it stops, so this host
4 * bounds each one, stops all of them when it stops, and stops sessions a
5 * previous server left behind. It never touches sessions it did not start.
6 */
7import {
8 DeviceBootError,
9 type DeviceHostSummary,
10 DeviceHostUnavailableError,
11 DeviceOperationError,
12 type DevicePlatform,
13 type EasDeviceHostConfig,
14 type SettingsSecretValue,
15} from "@t3tools/contracts";
16import * as Cause from "effect/Cause";
17import * as Clock from "effect/Clock";
18import * as Duration from "effect/Duration";
19import * as Effect from "effect/Effect";
20import * as Exit from "effect/Exit";
21import * as Fiber from "effect/Fiber";
22import * as FiberSet from "effect/FiberSet";
23import * as FileSystem from "effect/FileSystem";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as Random from "effect/Random";
27import * as Redacted from "effect/Redacted";
28import * as Schedule from "effect/Schedule";
29import * as Schema from "effect/Schema";
30import * as Semaphore from "effect/Semaphore";
31import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
32import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
33import * as HostProcess from "@t3tools/shared/HostProcess";
34​
35import * as ServerConfig from "../config.ts";
36import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
37import { requireSettingsSecret } from "../secrets/settingsSecretValue.ts";
38import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts";
39import * as DeviceHost from "./DeviceHost.ts";
40import { findEasProjectId } from "./easProjectId.ts";
41import { type ExpoApi, makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts";
42​
43const POLL_INTERVAL = Duration.seconds(5);
44// How stale a running session's status may get before listing rechecks it.
45const STATUS_TTL_MS = 15_000;
46// Expo's own CLI waits this long once a worker picks the session up.
47const START_TIMEOUT = Duration.minutes(15);
48const PREVIEW_TIMEOUT = Duration.seconds(20);
49const decodeServeSimApi = Schema.decodeUnknownEffect(
50 Schema.Struct({ device: Schema.NonEmptyString }),
51);
52const decodeServeEmuApi = Schema.decodeUnknownEffect(
53 Schema.Struct({ serial: Schema.NonEmptyString }),
54);
55/**
56 * Each session's preview host serves serve-sim at its root and serve-emu under
57 * its vendor prefix, and names its device by an id of its own that agent-device
58 * also selects it by.
59 */
60const PLATFORMS = {
61 ios: {
62 name: "iPhone (EAS Simulator)",
63 hubPrefix: "/vendor/serve-sim",
64 previewPrefix: "",
65 protocol: "serve-sim",
66 remoteDeviceId: (api: unknown) => decodeServeSimApi(api).pipe(Effect.map((api) => api.device)),
67 },
68 android: {
69 name: "Android (EAS Simulator)",
70 hubPrefix: "/vendor/serve-emu",
71 previewPrefix: "/vendor/serve-emu",
72 protocol: "serve-emu",
73 remoteDeviceId: (api: unknown) => decodeServeEmuApi(api).pipe(Effect.map((api) => api.serial)),
74 },
75} as const;
76​
77/** EAS picks the simulator model; one entry starts a session. */
78const CATALOG: ReadonlyArray<DeviceHost.HostDevice> = [
79 {
80 id: "eas-ios",
81 platform: "ios",
82 name: PLATFORMS.ios.name,
83 version: "Cloud",
84 booted: false,
85 physical: false,
86 },
87 {
88 id: "eas-android",
89 platform: "android",
90 name: PLATFORMS.android.name,
91 version: "Cloud",
92 booted: false,
93 physical: false,
94 },
95];
96​
97interface RunningSession {
98 readonly sessionId: string;
99 readonly name: string;
100 readonly platform: DevicePlatform;
101 readonly remoteDeviceId: string;
102 readonly preview: { readonly origin: string; readonly token: Redacted.Redacted<string> };
103 readonly agent: DeviceHost.AgentDaemonEndpoint;
104 /** Set once a stop has been asked for but Expo has not confirmed it. */
105 stopping: boolean;
107​
108/**
109 * Every session this host may have started, recorded by its unique name before
110 * Expo is asked to create it, so a server that crashes at any point can still
111 * find and stop the session on its next start.
112 */
113const LedgerEntry = Schema.Struct({
114 name: Schema.String,
115 appId: Schema.String,
116 recordedAt: Schema.Number,
117 /** Expo refused the create: nothing should run, but the reaper still looks. */
118 refused: Schema.optional(Schema.Boolean),
119});
120type LedgerEntry = typeof LedgerEntry.Type;
121const Ledger = Schema.fromJsonString(Schema.Array(LedgerEntry));
122const decodeLedger = Schema.decodeUnknownEffect(Ledger);
123const encodeLedger = Schema.encodeSync(Ledger);
124// How often listing looks for recorded sessions this host no longer tracks.
125const REAP_INTERVAL_MS = 60_000;
126// Expo's ready queue is unbounded despite its 115-minute session limit and five-minute cleanup.
127// Unlisted sessions stop counting after this lifetime; forget their records only once
128// confirmed not running or after a day unconfirmed.
129const RECORD_LIFETIME_MS = 180 * 60_000;
130const RECORD_MAX_AGE_MS = 24 * 60 * 60_000;
131// Expo lists a session it made within seconds; a create with an unknown
132// outcome stops counting against the limit once it has had this long.
133const UNCERTAIN_CREATE_MS = 5 * 60_000;
134// One failed request to Expo during a minutes-long start does not end the start.
135const TRANSIENT_RETRY = { times: 3, schedule: Schedule.spaced("2 seconds") } as const;
136​
137/** A malformed escape names no device, so the proxy answers 404 instead of failing. */
138const decodePathSegment = (segment: string): string | null => {
139 try {
140 return decodeURIComponent(segment);
141 } catch {
142 return null;
143 }
144};
145​
146/**
147 * The Expo credential a host's setting holds or names. Every session bills the account behind
148 * it, so a named secret that cannot be read fails rather than falling back to another login.
149 */
150export const resolveSavedExpoCredential = (
151 secrets: SecretResolver.SecretResolver["Service"],
152 saved: SettingsSecretValue,
153) => requireSettingsSecret(secrets, saved).pipe(Effect.flatMap(resolveExpoCredential));
154​
155export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDeviceHostConfig) {
156 const http = yield* HttpClient.HttpClient;
157 const fs = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 const server = yield* ServerConfig.ServerConfig;
160 const secrets = yield* SecretResolver.SecretResolver;
161 const services = yield* Effect.context<
162 FileSystem.FileSystem | Path.Path | Effect.Services<ReturnType<typeof findEasProjectId>>
163 >();
164 const hostId = config.id;
165 const ledgerPath = path.join(server.stateDir, "device", "eas", `${hostId}.json`);
166 // Keyed by Expo's session id, which is also the device id: every EAS
167 // simulator reports the same remote device id, so that id cannot tell sessions apart.
168 const sessions = new Map<string, RunningSession>();
169 const checkedAt = new Map<string, number>();
170 const startLock = yield* Semaphore.make(1);
171 const ledgerLock = yield* Semaphore.make(1);
172 // One sweep at a time: two would stop the same session twice.
173 const reapLock = yield* Semaphore.make(1);
174 // Starts run in the host rather than the caller: a client that disconnects
175 // while a session boots leaves it running and tracked, and stopping or
176 // closing the host interrupts the start, which then stops its session.
177 const starts = yield* FiberSet.make<string, unknown>();
178 // The startup sweep for recorded leftovers.
179 const background = yield* FiberSet.make<void, never>();
180 // Sessions this host owns that `sessions` does not hold: starts in progress,
181 // and sessions whose stop after a failed start failed too. The reaper leaves
182 // them alone and they count against the limit.
183 const starting = new Set<string>();
184 const unresolved = new Map<string, { readonly sessionId: string }>();
185 // Creates whose outcome is unknown (no answer, or a server error), and
186 // fresh records a previous server left: Expo may have made the session.
187 // Each listing looks for it, and it counts against the limit until found
188 // or until Expo would have listed it by now. The reaper handles these.
189 const uncertain = new Map<string, { readonly entry: LedgerEntry; readonly until: number }>();
190 const ownedNames = () =>
191 new Set([
192 ...[...sessions.values()].map((session) => session.name),
193 ...starting,
194 ...unresolved.keys(),
195 ]);
196 // Bumped whenever the host stops its sessions, so a start that finishes
197 // afterwards stops its own session instead of leaving it untracked.
198 let epoch = 0;
199 // Set once the host's scope closes. A listing still running on this
200 // instance then neither sweeps nor writes the ledger: a replacement with
201 // the same id owns the sessions and the record now.
202 let closed = false;
203 let reapedAt = 0;
204 let ready: DeviceHost.DeviceHostReady | null = null;
205​
206 const unavailable = (reason: string, cause?: unknown) =>
207 new DeviceHostUnavailableError({
208 hostId,
209 hostLabel: config.label,
210 reason,
211 ...(cause === undefined ? {} : { cause }),
212 });
213​
214 let lastApi: ExpoApi | undefined;
215 const api: Effect.Effect<ExpoApi, DeviceHostUnavailableError> = resolveSavedExpoCredential(
216 secrets,
217 config.accessToken,
218 ).pipe(
219 Effect.map((credential) => makeExpoApi(http, credential)),
220 Effect.tap((expo) =>
221 Effect.sync(() => {
222 lastApi = expo;
223 }),
224 ),
225 Effect.provide(services),
226 Effect.mapError((error) => unavailable(error.message, error)),
227 );
228 /** Stopping a billed session must not depend on the secret still being readable; starting one must. */
229 const apiForStop = api.pipe(
230 Effect.catch((error) => (lastApi === undefined ? Effect.fail(error) : Effect.succeed(lastApi))),
231 );
232​
233 /** A missing ledger is empty; an unreadable one is an error, never an empty list. */
234 const readLedger = fs.readFileString(ledgerPath).pipe(
235 Effect.catchIf(
236 (error) => error.reason._tag === "NotFound",
237 () => Effect.succeed("[]"),
238 ),
239 Effect.flatMap(decodeLedger),
240 Effect.mapError((cause) =>
241 unavailable(
242 `The record of running EAS sessions at ${ledgerPath} could not be read. Fix or remove it.`,
243 cause,
244 ),
245 ),
246 );
247 const writeLedger = Effect.fn("EasDeviceHost.writeLedger")(function* (
248 update: (entries: ReadonlyArray<LedgerEntry>) => ReadonlyArray<LedgerEntry>,
249 ) {
250 if (closed) return yield* unavailable(`${config.label} was replaced or removed.`);
251 const entries = update(yield* readLedger);
252 yield* writeFileStringAtomically({
253 filePath: ledgerPath,
254 contents: encodeLedger(entries),
255 }).pipe(
256 Effect.provide(services),
257 Effect.mapError((cause) =>
258 unavailable(
259 `The record of running EAS sessions at ${ledgerPath} could not be written.`,
260 cause,
261 ),
262 ),
263 );
264 }, ledgerLock.withPermit);
265 const markRefused = (name: string) =>
266 writeLedger((entries) =>
267 entries.map((entry) => (entry.name === name ? { ...entry, refused: true } : entry)),
268 ).pipe(
269 Effect.catch((cause) =>
270 Effect.logWarning("Could not update the EAS session record", { name, cause }),
271 ),
272 );
273 const forgetEntry = (name: string) =>
274 writeLedger((entries) => entries.filter((entry) => entry.name !== name)).pipe(
275 Effect.catch((cause) =>
276 Effect.logWarning("Could not update the EAS session record", { name, cause }),
277 ),
278 );
279​
280 /** Stops a session and, once Expo confirms it, drops it from the ledger. */
281 const stopSession = (expo: ExpoApi, sessionId: string, name: string) =>
282 expo.stop(sessionId).pipe(
283 Effect.andThen(forgetEntry(name)),
284 Effect.tapError((cause) =>
285 Effect.logWarning("Could not stop EAS Simulator session", { sessionId, cause }),
286 ),
287 );
288​
289 /** Finds recorded sessions by name, so a session whose id never arrived is found too. */
290 const stopRecorded = (expo: ExpoApi, entries: ReadonlyArray<LedgerEntry>) => {
291 const found = new Set<string>();
292 const listed = new Set<string>();
293 return Effect.forEach(
294 [...new Set(entries.map((entry) => entry.appId))],
295 (appId) =>
296 expo.activeSessions(appId).pipe(
297 Effect.flatMap((active) =>
298 Effect.forEach(
299 entries.filter((entry) => entry.appId === appId),
300 (entry) =>
301 Effect.gen(function* () {
302 const running = active.filter((session) => session.name === entry.name);
303 if (running.length > 0) {
304 found.add(entry.name);
305 return yield* Effect.forEach(
306 running,
307 (session) =>
308 stopSession(expo, session.id, entry.name).pipe(
309 // Still running and billed: owned, counted, and retried by listing.
310 Effect.catch(() =>
311 Effect.sync(
312 () => void unresolved.set(entry.name, { sessionId: session.id }),
313 ),
314 ),
315 ),
316 { discard: true },
317 );
318 }
319 }),
320 { discard: true },
321 ),
322 ),
323 Effect.tap(() => Effect.sync(() => void listed.add(appId))),
324 Effect.ignore,
325 ),
326 { discard: true, concurrency: 4 },
327 ).pipe(
328 Effect.as({ found, listed } as {
329 readonly found: ReadonlySet<string>;
330 /** The apps whose listing succeeded; a failed one rules nothing out. */
331 readonly listed: ReadonlySet<string>;
332 }),
333 );
334 };
335​
336 /**
337 * Retries the stop of a session whose stop failed. A session Expo already
338 * ended is let go even if stopping it keeps failing.
339 */
340 const retryUnresolved = (expo: ExpoApi, name: string, sessionId: string) =>
341 stopSession(expo, sessionId, name).pipe(
342 Effect.catch(() =>
343 expo
344 .session(sessionId)
345 .pipe(
346 Effect.flatMap((current) =>
347 current.status === "STOPPED" || current.status === "ERRORED"
348 ? forgetEntry(name)
349 : Effect.fail(current.status),
350 ),
351 ),
352 ),
353 Effect.andThen(Effect.sync(() => void unresolved.delete(name))),
354 Effect.ignore,
355 );
356​
357 /**
358 * Records a previous server left count against the limit until a sweep
359 * finds them (a fresh one until Expo would have listed it by now), so a
360 * restart cannot start past it.
361 */
362 const adoptRecorded = Effect.gen(function* () {
363 const now = yield* Clock.currentTimeMillis;
364 const owned = ownedNames();
365 for (const entry of yield* readLedger) {
366 const until = Math.max(entry.recordedAt + UNCERTAIN_CREATE_MS, now);
367 // A refused create made nothing: its record is for the reaper only.
368 if (!owned.has(entry.name) && !entry.refused) uncertain.set(entry.name, { entry, until });
369 }
370 }).pipe(Effect.ignore);
371​
372 /**
373 * Stops recorded sessions this host does not own: a previous server's, or
374 * a failed or uncertain start's. An uncertain one found (stopped, or
375 * unresolved if that failed) or long enough unlisted frees its slot; its
376 * record stays until it expires.
377 */
378 const reapLeftovers = Effect.fn("EasDeviceHost.reapLeftovers")(
379 function* (expo: ExpoApi) {
380 if (closed) return;
381 reapedAt = yield* Clock.currentTimeMillis;
382 const entries = yield* readLedger;
383 const owned = ownedNames();
384 const leftovers = entries.filter((entry) => !owned.has(entry.name));
385 const { found, listed } = yield* stopRecorded(expo, leftovers);
386 for (const [name, { entry, until }] of uncertain) {
387 if (found.has(name) || (reapedAt >= until && listed.has(entry.appId))) {
388 uncertain.delete(name);
389 }
390 }
391 // Expo has ended these by now unless they waited in its queue: the slot
392 // goes whatever the listing says (the app may be gone, or belong to
393 // another account); the record goes once a listing confirms Expo does
394 // not run it, or once no queue could have held it.
395 for (const entry of leftovers) {
396 const age = reapedAt - entry.recordedAt;
397 if (age > RECORD_LIFETIME_MS) uncertain.delete(entry.name);
398 if (found.has(entry.name)) continue;
399 if ((age > RECORD_LIFETIME_MS && listed.has(entry.appId)) || age > RECORD_MAX_AGE_MS) {
400 yield* forgetEntry(entry.name);
401 }
402 }
403 },
404 reapLock.withPermit,
405 Effect.catch((cause) =>
406 Effect.logWarning("Could not stop recorded EAS Simulator sessions", { cause }),
407 ),
408 );
409​
410 const previewDevice = (
411 origin: string,
412 token: Redacted.Redacted<string>,
413 platform: DevicePlatform,
414 ) =>
415 http
416 .execute(
417 HttpClientRequest.get(`${origin}${PLATFORMS[platform].previewPrefix}/api`).pipe(
418 HttpClientRequest.bearerToken(Redacted.value(token)),
419 ),
420 )
421 .pipe(
422 Effect.flatMap(HttpClientResponse.filterStatusOk),
423 Effect.flatMap((response) => response.json),
424 Effect.flatMap(PLATFORMS[platform].remoteDeviceId),
425 Effect.scoped,
426 Effect.timeout(PREVIEW_TIMEOUT),
427 Effect.retry(TRANSIENT_RETRY),
428 );
429​
430 const awaitRunning = Effect.fn("EasDeviceHost.awaitRunning")(
431 function* (expo: ExpoApi, sessionId: string) {
432 for (;;) {
433 const session = yield* expo.session(sessionId).pipe(
434 Effect.retry(TRANSIENT_RETRY),
435 Effect.mapError((error) => unavailable(error.message, error)),
436 );
437 if (session.status === "STOPPED" || session.status === "ERRORED") {
438 return yield* new DeviceBootError({
439 hostId,
440 deviceId: sessionId,
441 reason: "launch_failed",
442 cause: new Error(`EAS Simulator session ended (${session.status}) before it was ready`),
443 });
444 }
445 if (session.status === "IN_PROGRESS" && session.endpoints) return session.endpoints;
446 yield* Effect.sleep(POLL_INTERVAL);
447 }
448 },
449 (poll, _expo, sessionId) =>
450 poll.pipe(
451 Effect.timeoutOrElse({
452 duration: START_TIMEOUT,
453 orElse: () =>
454 Effect.fail(
455 new DeviceBootError({
456 hostId,
457 deviceId: sessionId,
458 reason: "timeout",
459 cause: new Error("EAS Simulator session did not start in time"),
460 }),
461 ),
462 }),
463 ),
464 );
465​
466 const reserveSlot = (name: string) =>
467 startLock.withPermit(
468 Effect.suspend(() => {
469 const pending = unresolved.size + uncertain.size;
470 if (sessions.size + starting.size + pending >= config.maxConcurrentSessions) {
471 const limit = `${config.label} is limited to ${config.maxConcurrentSessions} running ${config.maxConcurrentSessions === 1 ? "session" : "sessions"}.`;
472 return Effect.fail(
473 unavailable(
474 pending > 0
475 ? `${limit} ${pending === 1 ? "A session" : `${pending} sessions`} that failed to start or stop ${pending === 1 ? "is" : "are"} still being cleaned up; try again shortly.`
476 : `${limit} Close one in the Device panel first.`,
477 ),
478 );
479 }
480 starting.add(name);
481 return Effect.void;
482 }),
483 );
484​
485 const startSession = Effect.fn("EasDeviceHost.startSession")(function* (
486 device: DeviceHost.HostDevice,
487 context: DeviceHost.DeviceOpenContext | undefined,
488 startedIn: number,
489 ) {
490 const name = `T3 Code · ${config.label} · ${(yield* Random.nextIntBetween(0, 0xffffff)).toString(16).padStart(6, "0")}`;
491 // The credential comes first: without one, the answer is how to sign in,
492 // not that the limit is reached.
493 const expo = yield* api;
494 return yield* Effect.acquireUseRelease(
495 reserveSlot(name),
496 () =>
497 Effect.gen(function* () {
498 const appId =
499 config.appId ??
500 Option.getOrUndefined(
501 context?.workspaceRoot
502 ? yield* findEasProjectId(context.workspaceRoot).pipe(Effect.provide(services))
503 : Option.none<string>(),
504 );
505 if (!appId) {
506 return yield* unavailable(
507 "No EAS project to run the session under. Choose one for this host in Settings, or open the device from a thread whose project names extra.eas.projectId in app.json or its app config.",
508 );
509 }
510 const access = yield* expo
511 .availability(appId)
512 .pipe(Effect.mapError((error) => unavailable(error.message, error)));
513 if (!access.available) {
514 return yield* unavailable(
515 `EAS Simulator is not enabled for the ${access.account} Expo account.`,
516 );
517 }
518 const entry = { name, appId, recordedAt: yield* Clock.currentTimeMillis };
519 // Recorded first: a session the ledger cannot hold is never started.
520 yield* writeLedger((entries) => [...entries, entry]);
521 if (epoch !== startedIn) {
522 yield* forgetEntry(name);
523 return yield* unavailable(`${config.label} was stopped while the session started.`);
524 }
525 // Create runs uninterrupted (Expo's API answers within 30 seconds) and
526 // the stop below is in place before anything can interrupt the start,
527 // so a session Expo made is always either tracked or stopped.
528 return yield* Effect.uninterruptibleMask((restore) =>
529 Effect.gen(function* () {
530 const sessionId = yield* expo
531 .create({
532 appId,
533 platform: device.platform,
534 name,
535 maxRunTimeMinutes: config.maxSessionMinutes,
536 maxIdleTimeMinutes: config.idleMinutes,
537 })
538 .pipe(
539 // Unless Expo refused, it may have made the session before
540 // failing to answer. Unlisted, it stays counted for a while.
541 // Refused, it made nothing: the slot frees, but the record
542 // stays for the reaper in case the refusal was misread.
543 Effect.tapError((error) =>
544 error.rejected
545 ? stopRecorded(expo, [entry]).pipe(
546 Effect.flatMap(({ found }) =>
547 found.has(name) ? Effect.void : markRefused(name),
548 ),
549 )
550 : stopRecorded(expo, [entry]).pipe(
551 Effect.flatMap(({ found }) =>
552 found.has(name)
553 ? Effect.void
554 : Effect.map(Clock.currentTimeMillis, (now) => {
555 uncertain.set(name, {
556 entry,
557 until: now + UNCERTAIN_CREATE_MS,
558 });
559 }),
560 ),
561 ),
562 ),
563 Effect.mapError((error) => unavailable(error.message, error)),
564 );
565 return yield* restore(
566 trackSession(expo, sessionId, name, device.platform, startedIn),
567 ).pipe(
568 Effect.onError(() =>
569 stopSession(expo, sessionId, name).pipe(
570 // Still running and billed: owned, counted, and retried by listing.
571 Effect.catch(() => Effect.sync(() => void unresolved.set(name, { sessionId }))),
572 ),
573 ),
574 );
575 }),
576 );
577 }),
578 () => Effect.sync(() => void starting.delete(name)),
579 );
580 });
581​
582 /** Waits for a created session and starts tracking it. */
583 const trackSession = Effect.fn("EasDeviceHost.trackSession")(function* (
584 expo: ExpoApi,
585 sessionId: string,
586 name: string,
587 platform: DevicePlatform,
588 startedIn: number,
589 ) {
590 const endpoints = yield* awaitRunning(expo, sessionId);
591 if (!endpoints.preview) {
592 return yield* new DeviceBootError({
593 hostId,
594 deviceId: sessionId,
595 reason: "launch_failed",
596 cause: new Error("EAS Simulator returned no preview endpoint"),
597 });
598 }
599 const origin = new URL(endpoints.preview.url).origin;
600 const remoteDeviceId = yield* previewDevice(origin, endpoints.preview.token, platform).pipe(
601 Effect.mapError(
602 (cause) =>
603 new DeviceBootError({
604 hostId,
605 deviceId: sessionId,
606 reason: "launch_failed",
607 cause,
608 }),
609 ),
610 );
611 if (epoch !== startedIn) {
612 return yield* unavailable(`${config.label} was stopped while the session started.`);
613 }
614 sessions.set(sessionId, {
615 sessionId,
616 name,
617 platform,
618 remoteDeviceId,
619 preview: { origin, token: endpoints.preview.token },
620 agent: {
621 baseUrl: endpoints.agentDevice.url,
622 token: Redacted.value(endpoints.agentDevice.token),
623 },
624 stopping: false,
625 });
626 return sessionId;
627 });
628​
629 const sessionFor = (deviceId: string) => Option.fromNullishOr(sessions.get(deviceId));
630​
631 const forget = (session: RunningSession) =>
632 Effect.sync(() => {
633 sessions.delete(session.sessionId);
634 checkedAt.delete(session.sessionId);
635 });
636​
637 /**
638 * Expo ends sessions on its own at their run or idle limit; stop listing
639 * those. A stop that failed earlier is retried, and recorded sessions nothing
640 * tracks are reaped now and then.
641 */
642 const dropEnded = Effect.gen(function* () {
643 if (closed) return;
644 const now = yield* Clock.currentTimeMillis;
645 const stale = [...sessions.values()].filter(
646 (session) =>
647 session.stopping || now - (checkedAt.get(session.sessionId) ?? 0) > STATUS_TTL_MS,
648 );
649 const reapDue = now - reapedAt > REAP_INTERVAL_MS;
650 if (stale.length === 0 && unresolved.size === 0 && uncertain.size === 0 && !reapDue) return;
651 const reap = reapDue || uncertain.size > 0;
652 if (reap) reapedAt = now;
653 const expo = yield* apiForStop.pipe(
654 // Without a credential no sweep can look; a record Expo would have
655 // listed by now still stops counting, so the limit is not held forever.
656 Effect.tapError(() =>
657 Effect.sync(() => {
658 for (const [name, { until }] of uncertain) if (now >= until) uncertain.delete(name);
659 }),
660 ),
661 );
662 yield* Effect.forEach(
663 [...unresolved],
664 ([name, { sessionId }]) => retryUnresolved(expo, name, sessionId),
665 { discard: true, concurrency: 4 },
666 );
667 yield* Effect.forEach(
668 stale,
669 (session) =>
670 (session.stopping
671 ? stopSession(expo, session.sessionId, session.name).pipe(
672 Effect.andThen(forget(session)),
673 // A session Expo already ended is let go even if stopping it keeps failing.
674 Effect.catch(() =>
675 expo
676 .session(session.sessionId)
677 .pipe(
678 Effect.flatMap((current) =>
679 current.status === "STOPPED" || current.status === "ERRORED"
680 ? forget(session).pipe(Effect.andThen(forgetEntry(session.name)))
681 : Effect.fail(current.status),
682 ),
683 ),
684 ),
685 )
686 : expo
687 .session(session.sessionId)
688 .pipe(
689 Effect.flatMap((current) =>
690 current.status === "STOPPED" || current.status === "ERRORED"
691 ? forget(session).pipe(Effect.andThen(forgetEntry(session.name)))
692 : Effect.sync(() => void checkedAt.set(session.sessionId, now)),
693 ),
694 )
695 ).pipe(Effect.ignore),
696 { discard: true, concurrency: 4 },
697 );
698 if (reap) yield* reapLeftovers(expo);
699 }).pipe(Effect.ignore);
700​
701 /**
702 * Stops a tracked session. Until Expo confirms, it stays tracked: listed,
703 * counted against the limit, and retried by the next listing.
704 */
705 const stopTracked = (expo: ExpoApi, session: RunningSession) =>
706 Effect.suspend(() => {
707 session.stopping = true;
708 return stopSession(expo, session.sessionId, session.name);
709 }).pipe(Effect.andThen(forget(session)));
710​
711 const devices: DeviceHost.DeviceOperations = {
712 list: dropEnded.pipe(
713 Effect.andThen(
714 Effect.sync(() => ({
715 devices: [
716 ...CATALOG,
717 ...[...sessions.values()].map((session): DeviceHost.HostDevice => ({
718 id: session.sessionId,
719 platform: session.platform,
720 name: PLATFORMS[session.platform].name,
721 version: "Cloud",
722 booted: true,
723 physical: false,
724 })),
725 ],
726 detail: undefined,
727 })),
728 ),
729 ),
730 boot: (device, context) => {
731 if (sessions.has(device.id)) return Effect.succeed(device.id);
732 // Read before anything yields, so a stop that lands while the start
733 // reads settings or files is seen.
734 const startedIn = epoch;
735 return startSession(device, context, startedIn).pipe(
736 FiberSet.run(starts),
737 Effect.flatMap(Fiber.await),
738 Effect.flatMap((exit) =>
739 Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
740 ? Effect.fail(unavailable(`${config.label} was stopped while the session started.`))
741 : exit,
742 ),
743 );
744 },
745 attach: () => Effect.void,
746 shutdown: (device) =>
747 Option.match(sessionFor(device.id), {
748 onNone: () => Effect.void,
749 onSome: (session) =>
750 apiForStop.pipe(
751 Effect.flatMap((expo) => stopTracked(expo, session)),
752 Effect.asVoid,
753 Effect.mapError(
754 (cause) =>
755 new DeviceOperationError({
756 operation: "shutdown",
757 reason: "request_failed",
758 cause,
759 }),
760 ),
761 ),
762 }),
763 screenshot: (device) =>
764 Option.match(sessionFor(device.id), {
765 onNone: () =>
766 Effect.fail(
767 new DeviceOperationError({
768 operation: "screenshot",
769 reason: "request_failed",
770 cause: new Error("The EAS Simulator session is not running"),
771 }),
772 ),
773 onSome: (session) =>
774 http
775 .execute(
776 HttpClientRequest.post(
777 `${session.preview.origin}${PLATFORMS[session.platform].previewPrefix}/api/screenshot?device=${encodeURIComponent(session.remoteDeviceId)}`,
778 ).pipe(HttpClientRequest.bearerToken(Redacted.value(session.preview.token))),
779 )
780 .pipe(
781 Effect.flatMap(HttpClientResponse.filterStatusOk),
782 Effect.flatMap((response) => response.arrayBuffer),
783 Effect.map((buffer) => new Uint8Array(buffer)),
784 Effect.scoped,
785 Effect.timeout(PREVIEW_TIMEOUT),
786 Effect.mapError(
787 (cause) =>
788 new DeviceOperationError({
789 operation: "screenshot",
790 reason: "request_failed",
791 cause,
792 }),
793 ),
794 ),
795 }),
796 agentEndpoint: (deviceId) =>
797 Option.match(sessionFor(deviceId), {
798 onNone: () => Effect.fail(unavailable("This EAS Simulator device is no longer running.")),
799 onSome: (session) =>
800 Effect.succeed({ ...session.agent, agentDeviceId: session.remoteDeviceId }),
801 }),
802 endsDevices: true,
803 };
804​
805 /**
806 * Clients name the device by its session id; the preview host knows it by its
807 * own id, so the session id is swapped for that in the path and in the
808 * `device` parameter. A session answers only on its own platform's server.
809 */
810 const upstream: NonNullable<DeviceHost.DeviceHostReady["upstream"]> = (hubPath, search) =>
811 Effect.sync(() => {
812 const platform = (["ios", "android"] as const).find((candidate) =>
813 hubPath.startsWith(`${PLATFORMS[candidate].hubPrefix}/`),
814 );
815 if (!platform) return null;
816 const helper = /^\/vendor\/serve-sim\/helper\/([^/]+)\//.exec(hubPath);
817 const deviceId = helper ? decodePathSegment(helper[1]!) : search.get("device");
818 if (helper && deviceId === null) return null;
819 const session =
820 deviceId !== null
821 ? sessions.get(deviceId)
822 : sessions.size === 1
823 ? [...sessions.values()][0]
824 : undefined;
825 if (session?.platform !== platform) return null;
826 const { hubPrefix, previewPrefix, protocol } = PLATFORMS[platform];
827 const path = helper
828 ? `/helper/${encodeURIComponent(session.remoteDeviceId)}${hubPath.slice(helper[0].length - 1)}`
829 : `${previewPrefix}${hubPath.slice(hubPrefix.length)}`;
830 const params = new URLSearchParams(search);
831 if (params.has("device")) params.set("device", session.remoteDeviceId);
832 const query = params.size > 0 ? `?${params.toString()}` : "";
833 const token = Redacted.value(session.preview.token);
834 return {
835 url: `${session.preview.origin}${path}${query}`,
836 headers: { authorization: `Bearer ${token}` },
837 protocols: [`${protocol}.token.${token}`],
838 };
839 });
840​
841 const nodePath = yield* HostProcess.ExecutablePath;
842 const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = Effect.fn(
843 "EasDeviceHost.ensureReady",
844 )(function* () {
845 if (ready) return ready;
846 yield* adoptRecorded;
847 // Without a credential the host is still ready: opening a device then
848 // says how to sign in, and listing reaps once one is there.
849 yield* FiberSet.run(background)(api.pipe(Effect.flatMap(reapLeftovers), Effect.ignore));
850 ready = {
851 nodePath,
852 hub: { origin: "https://api.expo.dev" },
853 devices,
854 upstream,
855 // Each session runs its own daemon, so one that has ended holds no agent sessions.
856 releaseAgentSession: (session, deviceId) =>
857 Option.match(sessionFor(deviceId), {
858 onNone: () => Effect.void,
859 onSome: (running) => releaseAgentDeviceSession(http, running.agent)(session),
860 }),
861 // Device settings run simctl on the simulator's own machine, which EAS does not expose.
862 run: () =>
863 Effect.succeed({
864 stdout: "",
865 stderr: "EAS Simulator devices cannot run host commands.",
866 code: 127,
867 }),
868 helpers: { serveSimAxSettings: null, serveSimCli: null },
869 };
870 return ready;
871 });
872​
873 // A session whose stop fails stays tracked for the next listing to retry,
874 // and stays in the ledger for the next server if this one closes first.
875 const stopAll = Effect.gen(function* () {
876 epoch++;
877 // Interrupted starts stop the sessions they created before this returns.
878 yield* FiberSet.clear(starts);
879 yield* FiberSet.clear(background);
880 const expo = yield* apiForStop;
881 yield* Effect.forEach(
882 [...sessions.values()],
883 (session) => stopTracked(expo, session).pipe(Effect.ignore),
884 { discard: true, concurrency: 4 },
885 );
886 yield* Effect.forEach(
887 [...unresolved],
888 ([name, { sessionId }]) => retryUnresolved(expo, name, sessionId),
889 { discard: true, concurrency: 4 },
890 );
891 // Also stop a recorded session a create with an unknown outcome made after all.
892 yield* reapLeftovers(expo);
893 }).pipe(Effect.ignore);
894 yield* Effect.addFinalizer(() =>
895 stopAll.pipe(
896 // Under the ledger lock, so a write still in flight lands before a
897 // replacement writes its own, which that write would overwrite.
898 Effect.andThen(
899 ledgerLock.withPermit(
900 Effect.sync(() => {
901 closed = true;
902 }),
903 ),
904 ),
905 ),
906 );
907​
908 // Each server signs in on its own, so one with no Expo credential answers for itself. Reading
909 // the credential is local; whether Expo accepts it is only known once a session starts.
910 const platformAvailability = (platform: DevicePlatform) =>
911 resolveSavedExpoCredential(secrets, config.accessToken).pipe(
912 Effect.provide(services),
913 Effect.as({ platform, available: true }),
914 Effect.catch((error) =>
915 Effect.succeed({ platform, available: false, reason: error.message }),
916 ),
917 );
918​
919 const summary: Effect.Effect<DeviceHostSummary> = Effect.all([
920 platformAvailability("ios"),
921 platformAvailability("android"),
922 ]).pipe(
923 Effect.map((platforms) => ({
924 id: hostId,
925 label: config.label,
926 kind: "eas" as const,
927 platforms,
928 // Nothing to install: the hub and agent-device daemon run in each session.
929 hubInstalled: true,
930 agentDeviceInstalled: true,
931 })),
932 );
933​
934 return DeviceHost.DeviceHost.of({
935 id: hostId,
936 label: config.label,
937 summary,
938 platformAvailability,
939 ensureReady,
940 ensureAgentReady: Effect.fn("DeviceHost.ensureAgentReady")(function (onPhase) {
941 return ensureReady(onPhase).pipe(Effect.map((value) => ({ ...value, agentDevice: null })));
942 }),
943 current: Effect.sync(() => ready),
944 stopAgent: Effect.void,
945 // Unlike machines the user owns, every running session here is billed.
946 stop: stopAll,
947 });
948});