1import type {
2 FlyMachineConfig,
3 FlyMachineGuest,
4 FlyMachineMount,
5 FlyMachineService,
6 FlyStatic,
7 Machine as FlyMachine,
8} from "@distilled.cloud/fly-io/machines";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import { AlchemyContext } from "../AlchemyContext.ts";
13import * as Bundle from "../Bundle/Bundle.ts";
14import { deepEqual, isResolved } from "../Diff.ts";
15import { DockerLive, Docker } from "../Docker/Docker.ts";
16import {
17 Platform,
18 type Main,
19 type MainRpc,
20 type PlatformProps,
21} from "../Platform.ts";
22import * as Provider from "../Provider.ts";
23import type { Input } from "../Input.ts";
24import type { Resource } from "../Resource.ts";
25import type { ServerHost } from "../Server/Process.ts";
26import { Stack } from "../Stack.ts";
27import { App, deleteApp, ensureApp } from "./App.ts";
28import {
29 deploymentPolicy,
30 validateDeployment,
31 type MachineDeploy,
32 type MachineShutdown,
33 type MachineCheck,
34} from "./Deployment.ts";
35import type {
36 MachineGuest,
37 MachineImageRef,
38 MachineService,
39} from "./Machine.ts";
40import {
41 ensureFlycastAddress,
42 hasPublicAddress,
43 syncOwnedAppAddresses,
44} from "./IpAssignment.ts";
45import * as machines from "@distilled.cloud/fly-io/machines";
46import * as Redacted from "effect/Redacted";
47import { makeRandom } from "../Random.ts";
48import { resolveOrgSlug } from "./Environment.ts";
49import { bindingPortOf, findPortConflict, portsOfFly } from "./ports.ts";
50import { packEnvValue } from "../RuntimeContext.ts";
51import { boundTargetEnvKeys } from "./BindService.ts";
52import {
53 BINDING_PORT_ENV,
54 DEFAULT_BINDING_PORT,
55 RPC_ORG_ENV,
56 RPC_TOKEN_ENV,
57} from "./rpc.ts";
58import { type Region, regionList } from "./Region.ts";
59import {
60 alchemyMetadataKeys,
61 createFlyAppName,
62 createFlyResourceName,
63 diffMachineMetadata,
64 sanitizeFlyAppName,
65} from "./Metadata.ts";
66import type {
67 BoundTarget,
68 MountedDisk,
69 ServiceBinding,
70} from "./MountVolume.ts";
71import type { Providers } from "./Providers.ts";
72import {
73 collectBindingState,
74 createFlyHostedSupport,
75 createFlyHostRuntimeContext,
76 defaultHttpServices,
77 DEFAULT_PORT,
78 toEnvRecord,
79 type FlyBuildOptions,
80 type FlyHostRuntimeContext,
81 type HostedProgramProps,
82} from "./hosted.ts";
83import { attachBucketSecrets } from "./Bucket.ts";
84import { attachPostgresSecrets } from "./Postgres.ts";
85import { attachRedisSecrets } from "./Redis.ts";
86import {
87 deleteReplicaSet,
88 hasPublishedService,
89 listReplicaSets,
90 observeReplicaSet,
91 reconcileReplicas,
92 resolveCount,
93 sameServices,
94 toFlyService,
95 volumeIdsOf,
96 type Replica,
97 type ReplicaSet,
98} from "./replicas.ts";
99
100/**
101 * A resource-valued prop: the resource itself, or an Effect that produces
102 * it (so `yield* App(...)` and `App(...)` both type-check).
103 */
104type Ref<T> = T | Effect.Effect<T, never, Providers>;
105
106const DEFAULT_REGION = "iad";
107const DEFAULT_CPU_KIND = "shared";
108const DEFAULT_CPUS = 1;
109const DEFAULT_MEMORY_MB = 256;
110
111export interface ServiceProps extends PlatformProps {
112 /** Deployment strategy and readiness deadline. Defaults to in-place rolling updates. */
113 deploy?: MachineDeploy;
114 /** Graceful process shutdown. Defaults to SIGTERM / 30 seconds when blue/green is enabled. */
115 shutdown?: MachineShutdown;
116 /** Named readiness checks for workers without public services. */
117 checks?: Record<string, MachineCheck>;
118 /**
119 * Run inside an existing {@link App} instead of the Service's own App.
120 * The Service then shares that App's hostname, addresses, and secrets,
121 * and Alchemy does not manage addresses for it. Accepts a `Fly.App` or
122 * an Effect that produces one. Changing it, or adding or removing it,
123 * replaces the Service.
124 *
125 * By default each Service owns its App, so it gets its own
126 * `{name}.fly.dev` hostname, addresses, logs, and metrics.
127 */
128 app?: Ref<App>;
129 /**
130 * Whether the Service is reachable from the internet. A public Service
131 * gets a shared IPv4 and an IPv6 and serves `https://{name}.fly.dev`. A
132 * private Service is reachable only from other Apps in the organization,
133 * at `http://{name}.flycast`. Every address is free. Only applies when
134 * the Service owns its App (no `app`).
135 *
136 * @default true
137 */
138 public?: boolean;
139 /**
140 * Private network the Service's App joins. Only Apps on the same
141 * network can reach it, over `privateUrl` or `.internal`; every other
142 * App in the organization, including those on the default network,
143 * cannot resolve it. Services on one network reach each other freely.
144 * Use {@link stackNetwork} for a network unique to the stack and stage.
145 * Fly creates the network with the first App that names it. Changing
146 * it replaces the Service. Only applies when the Service owns its App
147 * (no `app`).
148 *
149 * @default the organization's default network, shared by every App
150 */
151 network?: string;
152 /**
153 * Plain-HTTP port Alchemy publishes for callers that bind this Service
154 * with {@link bindService}, when the Service publishes no plain-HTTP port
155 * of its own (a public Service's defaults are an HTTPS redirect on 80 and
156 * HTTPS on 443). Fly issues no certificate for `.flycast`, so bound
157 * callers use plain HTTP inside Fly's WireGuard-encrypted private network.
158 * Requests on this port must come from Fly's private network. Change it
159 * when the port collides with another Service in the same App.
160 *
161 * @default 7780
162 */
163 bindingPort?: number;
164 /**
165 * Caller token for bound callers. Set automatically from an
166 * `Alchemy.Random` child resource; do not pass it.
167 * @internal
168 */
169 rpcToken?: Redacted.Redacted<string>;
170 /**
171 * Module entrypoint bundled with rolldown and baked into a Docker
172 * image pushed to `registry.fly.io`. Typically `import.meta.url`.
173 * A content-hash change updates the workload using the selected deployment strategy.
174 */
175 main: string;
176 /**
177 * Region or regions to run in (`"iad"`, `["iad", "lhr"]`). The
178 * Service runs `count` Machines in each region behind one hostname;
179 * Fly's proxy sends each request to the nearest healthy Machine.
180 * Adding or removing a region updates the Service in place: Machines
181 * in a removed region are deleted, new regions get new Machines.
182 *
183 * @default "iad"
184 */
185 region?: Region | Region[];
186 /**
187 * Number of Machines to provision in each region, including
188 * stopped/suspended idle capacity. Fly's proxy load-balances
189 * `{app}.fly.dev` across available replicas.
190 * Blue/green checks a representative and the required running floor while
191 * preserving idle nonrepresentatives. Each replica gets its own Volume
192 * from every `MountVolume` binding; attached volumes require rolling updates.
193 *
194 * @default 1
195 */
196 count?: number;
197 /**
198 * Guest size. Defaults to shared-cpu-1x 256 MB.
199 */
200 guest?: MachineGuest;
201 /**
202 * Port the hosted HTTP server listens on. Written to `PORT` and used
203 * as the Fly proxy `internal_port`.
204 *
205 * @default 3000
206 */
207 port?: number;
208 /**
209 * Named export to load from `main`.
210 *
211 * @default "default"
212 */
213 handler?: string;
214 /**
215 * Additional environment variables for the hosted process. Merged
216 * after binding-injected `env`.
217 */
218 env?: Record<string, any>;
219 /**
220 * Bundler configuration for `main`: rolldown `input`/`output`
221 * overrides, pure-annotation options (`pure`), and `install` for
222 * packages that must ship as real `node_modules` (see {@link FlyBuildOptions}).
223 */
224 build?: FlyBuildOptions;
225 /**
226 * Environment image used as the generated Dockerfile's `FROM`. Must
227 * be able to run Node (websites and Effect-native Services use Node
228 * in production).
229 *
230 * @default "node:26-slim"
231 */
232 image?: string;
233 /**
234 * Fly proxy services. Defaults to HTTP 80 + HTTPS 443 → {@link port}.
235 */
236 services?: MachineService[];
237 /**
238 * Name of the Service's App, which is its `{name}.fly.dev` and
239 * `{name}.flycast` hostname. Globally unique across Fly. If omitted, a
240 * unique name is generated from the stack, stage and logical ID.
241 * Changing it replaces the Service.
242 *
243 * When `app` is set, this is instead the Machine name (unique per App;
244 * in blue/green mode a base for generation-qualified physical names).
245 */
246 name?: string;
247 /**
248 * Extra host directories copied into the Machine image next to the
249 * bundled entry (e.g. a website `clientDirectory` at `/app/dist`).
250 * Hashed into `code.hash` so asset changes update the image.
251 * Destination is relative to `/app`.
252 */
253 extraFiles?: ReadonlyArray<{
254 source: string;
255 dest: string;
256 }>;
257 /**
258 * Fly proxy static-file maps. Matching GET paths skip the process and
259 * are served from the image (`guestPath`) or a Tigris bucket
260 * (`tigrisBucket`). Website composites publish hashed client assets
261 * this way.
262 */
263 statics?: ReadonlyArray<{
264 /** Path inside the image, or key prefix in {@link tigrisBucket}. */
265 guestPath: string;
266 /** URL prefix, e.g. `"/"`. */
267 urlPrefix: string;
268 /** Tigris bucket name. When set, files come from the bucket. */
269 tigrisBucket?: string;
270 /** Directory index file (`index.html`) for Tigris statics. */
271 indexDocument?: string;
272 }>;
273}
274
275/** A port the Service publishes on Fly's proxy. */
276export interface ServiceEndpoint {
277 /**
278 * Hostname the port is published on: `{appName}.fly.dev`, or
279 * `{appName}.flycast` for a private Service.
280 */
281 host: string;
282 /** Port on Fly's proxy. */
283 port: number;
284 /** Port the process listens on inside the Machine. */
285 internalPort: number | undefined;
286 /** `tcp` or `udp`. */
287 protocol: string;
288 /** Fly proxy handlers (`tls`, `http`, `pg_tls`, …). Empty for raw TCP or UDP. */
289 handlers: string[];
290 /**
291 * URL when the port speaks HTTP: `https://` for `tls` + `http`, `http://`
292 * for plain `http`, with `:{port}` unless it is 443 or 80. `undefined`
293 * for other protocols, and for TLS ports on `.flycast` (Fly issues no
294 * certificate there).
295 */
296 url: string | undefined;
297}
298
299export type Service = Resource<
300 "Fly.Service",
301 ServiceProps,
302 {
303 /** Whether recovery must finish an interrupted deployment. */
304 rolloutPending?: boolean;
305 /** Fly App name the Machines run in. */
306 appName: string;
307 /** Whether the Service created and manages {@link appName}. */
308 ownsApp?: boolean;
309 /**
310 * Private network the Service's Machines are on: `network`, or the
311 * shared App's network. `undefined` for the organization's default.
312 */
313 network?: string;
314 /** Token bound callers send with RPC calls. */
315 rpcToken?: Redacted.Redacted<string>;
316 /** Fly Machine id of replica 0. */
317 machineId: string;
318 /** Fly Machine ids of every replica. */
319 machineIds: string[];
320 /** Logical base for generation-qualified Machine names. */
321 baseName?: string;
322 /** Machine name of replica 0 (unique per App). Changes during blue/green deployment. */
323 name: string;
324 /** Region of the first Machine. */
325 region: string;
326 /** Every region the Service's Machines run in. */
327 regions: string[];
328 /** Observed state of replica 0 (`created`, `started`, `stopped`, …). */
329 state: string;
330 /**
331 * Main public HTTP endpoint: the first published `tls` + `http` port
332 * as `https://{appName}.fly.dev[:port]`, else the first plain `http`
333 * port as `http://{appName}.fly.dev[:port]`. `https://{appName}.fly.dev`
334 * for the default services. `undefined` for a private Service or when
335 * no port speaks HTTP. When `app` is set, the App's addresses are
336 * managed elsewhere, so this is where the Service answers once the App
337 * is reachable. See {@link endpoints} for every published port.
338 */
339 url: string | undefined;
340 /**
341 * Every port the Service publishes, in declaration order. Ports that
342 * only redirect HTTP to HTTPS (`forceHttps`) and port ranges are
343 * omitted. Empty when nothing is published.
344 */
345 endpoints: ServiceEndpoint[];
346 /**
347 * Address bound callers use over Fly's private network:
348 * `http://{appName}.flycast`, with the port when it is not 80. It is
349 * the Service's first plain-HTTP port, or {@link ServiceProps.bindingPort}
350 * when it has none. Reachable only from Apps on the Service's network.
351 * `undefined` when nothing is published.
352 */
353 privateUrl: string | undefined;
354 /** Parsed image reference from Fly. */
355 imageRef: MachineImageRef | undefined;
356 /** Number of Machines in each region. */
357 count: number;
358 /** Disks mounted on replica 0. */
359 mounts: MountedDisk[];
360 /** Every replica in the set. */
361 replicas: Replica[];
362 /** Content hash of the bundled program's image. */
363 code: {
364 hash: string;
365 };
366 },
367 ServiceBinding,
368 Providers
369>;
370
371export const isService = (value: unknown): value is Service =>
372 typeof value === "object" &&
373 value !== null &&
374 (value as { Type?: string }).Type === "Fly.Service";
375
376export type ServiceServices = ServerHost;
377
378/**
379 * What a Service's program returns: `fetch` for HTTP, `run` for a
380 * background loop, and any other methods, which bound callers invoke
381 * with {@link bindService}.
382 */
383export type ServiceShape =
384 | Main<ServiceServices>
385 | (Exclude<Main<ServiceServices>, void> & MainRpc<ServiceServices>);
386
387export type ServiceRuntimeContext = FlyHostRuntimeContext;
388
389/**
390 * A Service is an Effect program running on Fly.io Machines in its own
391 * Fly App. It gets its own `{name}.fly.dev` hostname, addresses, logs,
392 * and metrics, the way each Cloudflare Worker is its own endpoint. Set
393 * `count` to scale it up or down.
394 *
395 * @see https://fly.io/docs/machines/api/machines-resource/
396 *
397 * ### Declare a Service
398 * A Service is a class. Props describe the Machine. The Effect is the
399 * program that runs on it.
400 *
401 * The Service creates its Fly App, and deleting the Service deletes it.
402 * `main: import.meta.url` is the bundle entrypoint. Alchemy bundles this
403 * file with Rolldown, builds a Docker image (default `node:26-slim`), and
404 * pushes it to `registry.fly.io/{app}:{id}-{hash}`.
405 *
406 * **Example:** Class + main
407 * ```typescript
408 * // src/api.ts
409 * import * as Fly from "alchemy/Fly";
410 * import * as Effect from "effect/Effect";
411 *
412 * export default class Api extends Fly.Service<Api>()(
413 * "Api",
414 * { main: import.meta.url },
415 * Effect.gen(function* () {
416 * return {};
417 * }),
418 * ) {}
419 * ```
420 *
421 * ### Serve HTTP with fetch
422 * Return `fetch` from the init Effect to boot an HTTP server. Omit
423 * `fetch` for a background service.
424 *
425 * **Example:** Hello
426 * ```typescript
427 * export default class Api extends Fly.Service<Api>()(
428 * "Api",
429 * { main: import.meta.url },
430 * Effect.gen(function* () {
431 * return {
432 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
433 * };
434 * }),
435 * ) {}
436 * ```
437 *
438 * ### Pin a region
439 * Fly Machines live in a region. Default is `iad`. See
440 * [Regions](/fly/compute/regions) for the list of codes.
441 *
442 * **Example:** Region
443 * ```typescript
444 * export default class Api extends Fly.Service<Api>()(
445 * "Api",
446 * { main: import.meta.url, region: "iad" },
447 * Effect.gen(function* () {
448 * return {
449 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
450 * };
451 * }),
452 * ) {}
453 * ```
454 *
455 * ### Run in several regions
456 * Pass a list to run the same program in several regions behind one
457 * hostname. `count` Machines run in each region, and Fly's proxy sends
458 * each request to the nearest healthy one. `regions` on the Service
459 * lists where it runs.
460 *
461 * **Example:** Two regions, two Machines each
462 * ```typescript
463 * export default class Api extends Fly.Service<Api>()(
464 * "Api",
465 * { main: import.meta.url, region: ["iad", "lhr"], count: 2 },
466 * Effect.gen(function* () {
467 * return {
468 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
469 * };
470 * }),
471 * ) {}
472 * ```
473 *
474 * Adding or removing a region updates the Service in place and keeps
475 * its App and hostname. Machines already in a kept region stay; a
476 * removed region's Machines are deleted. Each Machine's Volume from
477 * `MountVolume` is created in that Machine's region.
478 *
479 * ### Set the port
480 * `port` is the port the process listens on inside the Machine.
481 * Alchemy writes it to `PORT`. Default is `3000`.
482 *
483 * **Example:** Port 3000
484 * ```typescript
485 * export default class Api extends Fly.Service<Api>()(
486 * "Api",
487 * { main: import.meta.url, region: "iad", port: 3000 },
488 * Effect.gen(function* () {
489 * return {
490 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
491 * };
492 * }),
493 * ) {}
494 * ```
495 *
496 * ### The public URL
497 * A Service is public by default. Alchemy allocates a shared IPv4 and
498 * an IPv6 on its App (both free) and `api.url` is
499 * `https://{appName}.fly.dev`.
500 *
501 * **Example:** Stack output
502 * ```typescript
503 * export default Alchemy.Stack(
504 * "MyApp",
505 * { providers: Fly.providers(), state: Alchemy.localState() },
506 * Effect.gen(function* () {
507 * const api = yield* Api;
508 * return { url: api.url };
509 * }),
510 * );
511 * ```
512 *
513 * `url` is `undefined` when you pass `services: []` (nothing is
514 * published).
515 *
516 * ### Every published port
517 * `url` is one endpoint. A Service that publishes several ports has one
518 * endpoint per port at the same hostname, listed in `endpoints` with
519 * its port, handlers, and a URL when the port speaks HTTP. Ports that
520 * only redirect to HTTPS are left out.
521 *
522 * **Example:** An admin UI on 8443 and raw TCP on 7000
523 * ```typescript
524 * export default class Api extends Fly.Service<Api>()(
525 * "Api",
526 * {
527 * main: import.meta.url,
528 * services: [
529 * {
530 * internalPort: 3000,
531 * ports: [
532 * { port: 80, handlers: ["http"], forceHttps: true },
533 * { port: 443, handlers: ["tls", "http"] },
534 * ],
535 * },
536 * { internalPort: 9000, ports: [{ port: 8443, handlers: ["tls", "http"] }] },
537 * { internalPort: 7000, ports: [{ port: 7000 }] },
538 * ],
539 * },
540 * Effect.gen(function* () {
541 * return {};
542 * }),
543 * ) {}
544 *
545 * api.url; // "https://{appName}.fly.dev"
546 * api.endpoints.map((endpoint) => endpoint.url);
547 * // ["https://{appName}.fly.dev", "https://{appName}.fly.dev:8443", undefined]
548 * ```
549 *
550 * :::note[Plain HTTP on other ports needs IPv6]
551 * The free shared IPv4 serves TLS on any port but plain HTTP only on
552 * port 80. A plain-HTTP port such as 8080 answers over IPv6 only.
553 * :::
554 *
555 * ### Private Services
556 * `public: false` keeps a Service off the internet. It gets only a
557 * Flycast address and publishes plain HTTP on port 80 (Fly issues no
558 * certificate for `.flycast`). `url` is `undefined`, and `privateUrl` is
559 * `http://{appName}.flycast`. Calls go through Fly's proxy, so service
560 * checks, `autostart`, and blue/green cutover still apply.
561 *
562 * Turning `public` on or off updates the Service in place. Its App and
563 * hostname stay the same.
564 *
565 * ### Call another Service
566 * A Service can return methods next to `fetch`. Another Service calls
567 * them by binding it with {@link bindService}, which returns a typed
568 * client. Binding makes the caller deploy after the Service, and only
569 * callers that bind a Service receive its caller token, so only they
570 * can call its methods.
571 *
572 * **Example:** A private Service with methods
573 * ```typescript
574 * export default class Users extends Fly.Service<Users>()(
575 * "Users",
576 * { main: import.meta.url, public: false },
577 * Effect.gen(function* () {
578 * return {
579 * list: () => Effect.succeed(USERS),
580 * get: (id: string) =>
581 * Effect.succeed(USERS.find((user) => user.id === id)),
582 * };
583 * }),
584 * ) {}
585 * ```
586 *
587 * **Example:** Bind it from another Service
588 * ```typescript
589 * export default class Gateway extends Fly.Service<Gateway>()(
590 * "Gateway",
591 * { main: import.meta.url },
592 * Effect.gen(function* () {
593 * const users = yield* Fly.bindService(Users);
594 * return {
595 * fetch: Effect.gen(function* () {
596 * return yield* HttpServerResponse.json(yield* users.list());
597 * }).pipe(Effect.orDie),
598 * };
599 * }),
600 * ) {}
601 * ```
602 *
603 * Calls use plain HTTP to `privateUrl` inside Fly's WireGuard-encrypted
604 * private network. The Service accepts a method call only with its
605 * caller token and a `Fly-Src` signature from Fly's proxy for the same
606 * organization, so the public internet can never call it. A public
607 * Service publishes an extra plain-HTTP port for bound callers,
608 * `bindingPort` (default 7780), since its own ports are an HTTPS
609 * redirect and HTTPS. `client.fetch` sends a request to the Service's
610 * `fetch` routes, and {@link bindEndpoint} targets one published port.
611 * Two Services can bind each other; declare them as tag classes and
612 * provide their `.make` layers (see the
613 * [Connect Services guide](/fly/compute/connecting-services)).
614 *
615 * ### Isolate Services on a private network
616 * Fly's default private network spans the whole organization, so any
617 * App in it can reach a private Service's address. Put a stack's
618 * Services on their own network with `network`. {@link stackNetwork}
619 * names one per stack and stage. Apps on other networks, including the
620 * default one, cannot resolve them, and binding a Service on another
621 * network fails the caller's deploy with `Fly.ServiceUnreachable`
622 * before anything is created. A public Service on the network still
623 * serves `url`, which makes it the stack's single entry point.
624 *
625 * **Example:** Private backend behind a public gateway
626 * ```typescript
627 * export class Users extends Fly.Service<Users>()(
628 * "Users",
629 * Effect.gen(function* () {
630 * return {
631 * main: import.meta.url,
632 * public: false,
633 * network: yield* Fly.stackNetwork,
634 * };
635 * }),
636 * Effect.gen(function* () {
637 * return { list: () => Effect.succeed(USERS) };
638 * }),
639 * ) {}
640 *
641 * export class Gateway extends Fly.Service<Gateway>()(
642 * "Gateway",
643 * Effect.gen(function* () {
644 * return { main: import.meta.url, network: yield* Fly.stackNetwork };
645 * }),
646 * Effect.gen(function* () {
647 * const users = yield* Fly.bindService(Users);
648 * return {
649 * fetch: Effect.gen(function* () {
650 * return yield* HttpServerResponse.json(yield* users.list());
651 * }).pipe(Effect.orDie),
652 * };
653 * }),
654 * ) {}
655 * ```
656 *
657 * :::caution[Changing `network` replaces the Service]
658 * Fly cannot move an App between networks. Alchemy creates the Service
659 * on the new network, then deletes the old App.
660 * :::
661 *
662 * ### Fly's proxy is the load balancer
663 * There is no LoadBalancer resource. Fly runs an Anycast proxy at
664 * the edge.
665 *
666 * **Example:** Published ports
667 * ```typescript
668 * export default class Api extends Fly.Service<Api>()(
669 * "Api",
670 * { main: import.meta.url, region: "iad", port: 3000 },
671 * Effect.gen(function* () {
672 * return {
673 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
674 * };
675 * }),
676 * ) {}
677 * ```
678 *
679 * Unless you override `services`, Alchemy publishes HTTP 80 and
680 * HTTPS 443 on that proxy and points them at `port` inside each
681 * Machine (`internal_port`). A request to
682 * `https://{appName}.fly.dev` lands on Fly's edge. Fly terminates
683 * TLS on 443, picks one started Machine that published this service,
684 * and forwards to `port` where `fetch` runs.
685 *
686 * ### Configure routing health checks
687 * The generated service includes a TCP check on `port`. To customize
688 * it, provide `services` and configure each service's `checks` property.
689 * With rolling updates, reconcile waits for each started replica's checks
690 * before updating the next replica. Missing or non-passing results are
691 * polled within `deploy.healthTimeout` (60 seconds by default), then fail
692 * deployment with `Fly.ReplicaChecksNotPassing`. Later replicas remain
693 * unchanged; earlier updates are not rolled back. A single rolling replica
694 * can be unavailable. Blue/green checks replacements before retiring the
695 * old set, with representative/floor readiness for idle capacity.
696 *
697 * **Example:** HTTP readiness check
698 * ```typescript
699 * export default class Api extends Fly.Service<Api>()(
700 * "Api",
701 * {
702 * main: import.meta.url,
703 * port: 3000,
704 * services: [
705 * {
706 * protocol: "tcp",
707 * internalPort: 3000,
708 * ports: [
709 * { port: 80, handlers: ["http"], forceHttps: true },
710 * { port: 443, handlers: ["tls", "http"] },
711 * ],
712 * checks: [
713 * {
714 * type: "http",
715 * port: 3000,
716 * method: "GET",
717 * path: "/health",
718 * protocol: "http",
719 * interval: "15s",
720 * timeout: "2s",
721 * gracePeriod: "30s",
722 * },
723 * ],
724 * },
725 * ],
726 * },
727 * Effect.gen(function* () {
728 * return {
729 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
730 * };
731 * }),
732 * ) {}
733 * ```
734 *
735 * ### Scale with count
736 * `count` is how many Machines to provision, including idle capacity.
737 * Default is `1`. Replicas publish the same proxy service behind
738 * `{appName}.fly.dev`; Fly's proxy picks an available Machine per request.
739 * Each replica gets its own Volume from every {@link MountVolume} binding;
740 * attached volumes require rolling updates.
741 *
742 * **Example:** Three replicas
743 * ```typescript
744 * export default class Api extends Fly.Service<Api>()(
745 * "Api",
746 * { main: import.meta.url, region: "iad", count: 3, port: 3000 },
747 * Effect.gen(function* () {
748 * return {
749 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
750 * };
751 * }),
752 * ) {}
753 * ```
754 *
755 * ### Config
756 * Yield `Config` in init. Alchemy reads the value from the env of
757 * whoever deploys and writes it onto the Machine, so there is no need
758 * to copy it into `env`.
759 *
760 * `Config.Redacted("API_KEY")` is `Redacted<string>`. Unwrap with
761 * `Redacted.value` only where you need the raw string.
762 *
763 * Alchemy also injects `PORT` (when `port` is set) and stack metadata.
764 * For a secret Fly should own and inject into every Machine on an App,
765 * run the Service in an {@link App} (`app`) and use {@link Secret}.
766 *
767 * **Example:** Config.Redacted
768 * ```typescript
769 * import * as Config from "effect/Config";
770 * import * as Redacted from "effect/Redacted";
771 *
772 * export default class Api extends Fly.Service<Api>()(
773 * "Api",
774 * { main: import.meta.url, port: 3000 },
775 * Effect.gen(function* () {
776 * const apiKey = yield* Config.Redacted("API_KEY");
777 *
778 * return {
779 * fetch: Effect.gen(function* () {
780 * const token = Redacted.value(apiKey);
781 * return HttpServerResponse.text("ok");
782 * }),
783 * };
784 * }),
785 * ) {}
786 * ```
787 *
788 * ### Mount a disk
789 * Bind {@link MountVolume} inside init. App and region come from the
790 * Service. `count: 3` creates three Volumes, one per replica. Provide
791 * {@link MountVolumeLive}.
792 *
793 * **Example:** Per-replica disk
794 * ```typescript
795 * export default class Api extends Fly.Service<Api>()(
796 * "Api",
797 * { main: import.meta.url, region: "iad", count: 3, port: 3000 },
798 * Effect.gen(function* () {
799 * const disk = yield* Fly.MountVolume({ path: "/data", sizeGb: 1 });
800 * const fs = yield* FileSystem.FileSystem;
801 * return {
802 * fetch: Effect.gen(function* () {
803 * const text = yield* fs.readFileString(`${disk.path}/hello.txt`);
804 * return HttpServerResponse.text(text);
805 * }),
806 * };
807 * }).pipe(Effect.provide(Fly.MountVolumeLive)),
808 * ) {}
809 * ```
810 *
811 * ### Guest size
812 * `guest` is CPU kind, CPU count, and memory. Default is shared-cpu,
813 * 1 CPU, 256 MB. Set `gpuKind` and `gpus` for a GPU. Guest updates in
814 * place.
815 *
816 * **Example:** Bigger guest
817 * ```typescript
818 * export default class Api extends Fly.Service<Api>()(
819 * "Api",
820 * {
821 * main: import.meta.url,
822 * region: "iad",
823 * port: 3000,
824 * guest: { cpuKind: "shared", cpus: 2, memoryMb: 512 },
825 * },
826 * Effect.gen(function* () {
827 * return {
828 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
829 * };
830 * }),
831 * ) {}
832 * ```
833 *
834 * ### A stable hostname
835 * `name` names the Service's App, so it is the `{name}.fly.dev`
836 * hostname. App names are globally unique across Fly. Omit `name` and
837 * Alchemy generates one from the stack, stage, and logical ID.
838 *
839 * **Example:** Explicit name
840 * ```typescript
841 * export default class Api extends Fly.Service<Api>()(
842 * "Api",
843 * { main: import.meta.url, name: "api", port: 3000 },
844 * Effect.gen(function* () {
845 * return {
846 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
847 * };
848 * }),
849 * ) {}
850 * ```
851 *
852 * :::caution[Changing `name` replaces the Service]
853 * Fly cannot rename an App. Alchemy creates the Service under the new
854 * name, then deletes the old App.
855 * :::
856 *
857 * ### Named export
858 * `handler` is the named export to load from `main`. Default is
859 * `"default"`.
860 *
861 * **Example:** Custom handler
862 * ```typescript
863 * export default class Api extends Fly.Service<Api>()(
864 * "Api",
865 * { main: import.meta.url, handler: "api", port: 3000 },
866 * Effect.gen(function* () {
867 * return {
868 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
869 * };
870 * }),
871 * ) {}
872 * ```
873 *
874 * ### Base image
875 * `image` is the generated Dockerfile's `FROM`. Default is
876 * `node:26-slim`. A content-hash change of `main` updates the
877 * Machine in place.
878 *
879 * **Example:** Override FROM
880 * ```typescript
881 * export default class Api extends Fly.Service<Api>()(
882 * "Api",
883 * {
884 * main: import.meta.url,
885 * image: "node:26",
886 * port: 3000,
887 * },
888 * Effect.gen(function* () {
889 * return {
890 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
891 * };
892 * }),
893 * ) {}
894 * ```
895 *
896 * ### Custom proxy services
897 * `services` defaults to HTTP 80 (redirecting to HTTPS) + HTTPS 443
898 * toward `port`, or plain HTTP 80 for a private Service. Pass a custom
899 * list to change handlers or autostop. Pass `[]` so Fly does not
900 * publish a proxy.
901 *
902 * **Example:** Unpublished process
903 * ```typescript
904 * export default class Worker extends Fly.Service<Worker>()(
905 * "Worker",
906 * { main: import.meta.url, region: "iad", services: [] },
907 * Effect.gen(function* () {
908 * return {};
909 * }),
910 * ) {}
911 * ```
912 *
913 * ### Background services
914 * Omit `port` and `fetch`. Pass `services: []`. Use `ServerHost.run`
915 * for a long-running loop. If the process exits, Fly restarts it.
916 *
917 * **Example:** ServerHost.run
918 * ```typescript
919 * import { ServerHost } from "alchemy/Server";
920 *
921 * export default class Worker extends Fly.Service<Worker>()(
922 * "Worker",
923 * { main: import.meta.url, region: "iad", services: [] },
924 * Effect.gen(function* () {
925 * const host = yield* ServerHost;
926 *
927 * yield* host.run(
928 * Effect.gen(function* () {
929 * return yield* Effect.never;
930 * }).pipe(Effect.orDie),
931 * );
932 * }),
933 * ) {}
934 * ```
935 *
936 * ### Bundle config
937 * `build` is Rolldown `input` / `output` overrides plus
938 * pure-annotation options. Use it when `main` needs extra entry
939 * points or externals.
940 *
941 * **Example:** Externals
942 * ```typescript
943 * export default class Api extends Fly.Service<Api>()(
944 * "Api",
945 * {
946 * main: import.meta.url,
947 * port: 3000,
948 * build: { input: { external: ["sharp"] } },
949 * },
950 * Effect.gen(function* () {
951 * return {
952 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
953 * };
954 * }),
955 * ) {}
956 * ```
957 *
958 * **Example:** Install `pg` unbundled
959 * `pg` is CommonJS. Rolldown's interop turns `Client` into a namespace.
960 * Install it into the image so `@effect/sql-pg` / `Drizzle.Postgres` load
961 * it with Node's CJS semantics — same `build.install` as Lambda.
962 * ```typescript
963 * export default class Api extends Fly.Service<Api>()(
964 * "Api",
965 * {
966 * main: import.meta.url,
967 * port: 3000,
968 * build: { install: ["pg"] },
969 * },
970 * Effect.gen(function* () {
971 * const conn = yield* Fly.ConnectPostgres(Db);
972 * const db = yield* Drizzle.Postgres(conn.connectionString);
973 * return {
974 * fetch: Effect.gen(function* () {
975 * const rows = yield* db.execute("select 1 as ok");
976 * return HttpServerResponse.json({ rows });
977 * }),
978 * };
979 * }).pipe(Effect.provide(Fly.ConnectPostgresHttp)),
980 * ) {}
981 * ```
982 *
983 * ### Group Services in one App
984 * Pass `app` to run a Service inside an existing {@link App} instead of
985 * its own. The Services share the App's hostname, addresses, and
986 * {@link Secret}s, and a {@link Certificate} on the App covers them.
987 * Alchemy manages no addresses for a Service in a shared App: allocate
988 * an {@link IpAssignment} on the App. `url` and `endpoints` follow the
989 * Service's own ports, so Services in one App tell themselves apart by
990 * port (`https://{appName}.fly.dev` and `https://{appName}.fly.dev:8443`).
991 * `public` does not apply.
992 *
993 * **Example:** API and worker sharing a secret
994 * ```typescript
995 * export const Site = Fly.App("Site");
996 *
997 * class Api extends Fly.Service<Api>()(
998 * "Api",
999 * { app: Site, main: import.meta.url, port: 3000 },
1000 * Effect.gen(function* () {
1001 * return {
1002 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
1003 * };
1004 * }),
1005 * ) {}
1006 *
1007 * class Worker extends Fly.Service<Worker>()(
1008 * "Worker",
1009 * { app: Site, main: import.meta.url, services: [] },
1010 * Effect.gen(function* () {
1011 * return {};
1012 * }),
1013 * ) {}
1014 * ```
1015 *
1016 * Fly's proxy routes an App's traffic by port only, so each Service in
1017 * the App needs its own ports, including its own `bindingPort` when
1018 * Alchemy adds one. Two Services on the same port and protocol fail with
1019 * `Fly.ServicePortConflict` before any Machine is created: at plan time
1020 * when the App already exists, otherwise when the App deploys. Grouped
1021 * Services can be bound like any other; they cannot bind each other in
1022 * a cycle.
1023 *
1024 * ### Blue/green deployments
1025 * Opt into healthy replacement Machines instead of in-place updates.
1026 * The default TCP service check proves the server is listening; supply
1027 * service HTTP checks when readiness also depends on application state.
1028 *
1029 * **Example:** Single-replica HTTP replacement
1030 * ```typescript
1031 * export default class Api extends Fly.Service<Api>()(
1032 * "Api",
1033 * {
1034 * main: import.meta.url,
1035 * deploy: { strategy: "bluegreen" },
1036 * shutdown: { timeout: "30 seconds" },
1037 * },
1038 * Effect.succeed({ fetch: Effect.succeed(HttpServerResponse.text("ready")) }),
1039 * ) {}
1040 * ```
1041 *
1042 * Keep one Service declaration. The old process retains its own shutdown
1043 * signal and deadline when the replacement's policy changes. Managed
1044 * SIGTERM/SIGINT shutdown drains HTTP while runtime resource finalizers run;
1045 * shared dependencies remain alive until both settle or the deadline expires.
1046 * Applications own stop-acquisition barriers, separately scoped jobs, and
1047 * bounded drain or checkpoint logic using ordinary finalizers, not a new
1048 * shutdown hook. External servers own their signal handling. An old bootstrap
1049 * cannot gain handlers retroactively. Volumes are incompatible with blue/green;
1050 * physical IDs and names change. Service-bound secret versions are floors,
1051 * not vault snapshots, and native Machine leases are not deployment-wide locks.
1052 * Leases do not serialize vault writers or every simultaneous first deployment;
1053 * serialize CI invocations for the same resource.
1054 *
1055 * Stop and suspend autostop policies preserve idle nonrepresentatives while
1056 * a representative and the required running floor pass readiness. Requested
1057 * idle policy is restored before old retirement; a new instance needs fresh
1058 * checks. Suspension is not SIGTERM shutdown and does not run ordinary
1059 * shutdown finalizers. Replacements do not inherit suspended process memory.
1060 * See the [deployment guide](/fly/compute/deployments) for recovery,
1061 * idle capacity, application responsibilities, and verification limits.
1062 *
1063 * @resource
1064 * @product Service
1065 */
1066export const Service: Platform<
1067 Service,
1068 ServiceServices,
1069 ServiceShape,
1070 ServiceRuntimeContext
1071> = Platform("Fly.Service", {
1072 createRuntimeContext: createFlyHostRuntimeContext("Fly.Service"),
1073 // `{ app: Site }` at module scope is an Effect. Yield it here so the
1074 // App is registered and `news.app` is resolved attributes at
1075 // reconcile (same DX as `yield* App(...)` inside Effect.gen).
1076 transformProps: (id, props) =>
1077 Effect.gen(function* () {
1078 if (globalThis.__ALCHEMY_RUNTIME__) return props;
1079 const app = Effect.isEffect(props.app)
1080 ? yield* props.app as Effect.Effect<App, never, Providers>
1081 : props.app;
1082 // Only callers that bind this Service receive the token.
1083 const rpcToken = yield* makeRandom(`${id}RpcToken`);
1084 if (app !== undefined) {
1085 // Report the ports this Service publishes so the App can reject
1086 // two Services on the same port before either one is deployed.
1087 yield* app.bind(id, {
1088 service: id,
1089 services: props.services,
1090 bindingPort: props.bindingPort,
1091 });
1092 }
1093 return { ...props, app, rpcToken } as typeof props;
1094 }),
1095});
1096
1097export class ServiceNotCreated extends Data.TaggedError(
1098 "Fly.ServiceNotCreated",
1099)<{
1100 name: string;
1101 appName: string;
1102}> {}
1103
1104export class InvalidServiceProps extends Data.TaggedError(
1105 "Fly.InvalidServiceProps",
1106)<{
1107 message: string;
1108}> {}
1109
1110export class ServiceAppNotResolved extends Data.TaggedError(
1111 "Fly.ServiceAppNotResolved",
1112)<{
1113 message: string;
1114}> {}
1115
1116const appNameOf = (value: unknown): string | undefined => {
1117 if (value == null || typeof value !== "object") return undefined;
1118 const name = (value as { appName?: unknown }).appName;
1119 return typeof name === "string" && name.length > 0 ? name : undefined;
1120};
1121
1122const compactRecord = (
1123 record: Record<string, string | undefined> | null | undefined,
1124): Record<string, string> =>
1125 Object.fromEntries(
1126 Object.entries(record ?? {}).flatMap(([key, value]) =>
1127 value === undefined ? [] : [[key, value]],
1128 ),
1129 );
1130
1131const toEnv = toEnvRecord;
1132
1133const resolveMachineName = (
1134 id: string,
1135 name: string | undefined,
1136 existing?: string,
1137) =>
1138 Effect.gen(function* () {
1139 if (name !== undefined) return sanitizeFlyAppName(name);
1140 if (existing !== undefined) return existing;
1141 return yield* createFlyResourceName(id);
1142 });
1143
1144const toFlyGuest = (guest: MachineGuest | undefined): FlyMachineGuest => {
1145 const fly: FlyMachineGuest = {
1146 cpu_kind: guest?.cpuKind ?? DEFAULT_CPU_KIND,
1147 cpus: guest?.cpus ?? DEFAULT_CPUS,
1148 memory_mb: guest?.memoryMb ?? DEFAULT_MEMORY_MB,
1149 };
1150 if (guest?.gpuKind !== undefined) fly.gpu_kind = guest.gpuKind;
1151 if (guest?.gpus !== undefined) fly.gpus = guest.gpus;
1152 return fly;
1153};
1154
1155const desiredEnv = (
1156 props: ServiceProps,
1157 bindingEnv: Record<string, any>,
1158 alchemyEnv: Record<string, string>,
1159 port: number,
1160): Record<string, string> => ({
1161 ...toEnv(bindingEnv),
1162 ...alchemyEnv,
1163 PORT: String(port),
1164 ...toEnv(props.env),
1165});
1166
1167const toFlyStatics = (
1168 statics:
1169 | ReadonlyArray<{
1170 guestPath: string;
1171 urlPrefix: string;
1172 tigrisBucket?: string;
1173 indexDocument?: string;
1174 }>
1175 | undefined,
1176): FlyStatic[] | undefined => {
1177 if (statics === undefined || statics.length === 0) return undefined;
1178 return statics.map((entry) => ({
1179 guest_path: entry.guestPath,
1180 url_prefix: entry.urlPrefix,
1181 ...(entry.tigrisBucket !== undefined
1182 ? { tigris_bucket: entry.tigrisBucket }
1183 : {}),
1184 ...(entry.indexDocument !== undefined
1185 ? { index_document: entry.indexDocument }
1186 : {}),
1187 }));
1188};
1189
1190const buildConfig = (input: {
1191 image: string;
1192 guest: FlyMachineGuest;
1193 env: Record<string, string>;
1194 services: FlyMachineService[];
1195 mounts: FlyMachineMount[];
1196 metadata: Record<string, string>;
1197 statics?: FlyStatic[];
1198}): FlyMachineConfig => ({
1199 image: input.image,
1200 guest: input.guest,
1201 env: Object.keys(input.env).length > 0 ? input.env : undefined,
1202 services: input.services.length > 0 ? input.services : undefined,
1203 mounts: input.mounts.length > 0 ? input.mounts : undefined,
1204 metadata: input.metadata,
1205 statics:
1206 input.statics !== undefined && input.statics.length > 0
1207 ? input.statics
1208 : undefined,
1209});
1210
1211const sameImage = (machine: FlyMachine, image: string) => {
1212 const configImage = machine.config?.image;
1213 if (configImage === image) return true;
1214 const ref = machine.image_ref;
1215 const colon = image.lastIndexOf(":");
1216 const slash = image.lastIndexOf("/");
1217 const split = colon > slash ? colon : -1;
1218 const repo = split === -1 ? image : image.slice(0, split);
1219 const tag = split === -1 ? "latest" : image.slice(split + 1);
1220 const observedRepo = ref?.repository;
1221 if (observedRepo === undefined || ref?.tag !== tag) return false;
1222 return observedRepo === repo || observedRepo.endsWith(`/${repo}`);
1223};
1224
1225const sameGuest = (
1226 observed: FlyMachineGuest | undefined,
1227 desired: FlyMachineGuest,
1228) =>
1229 (observed?.cpu_kind ?? DEFAULT_CPU_KIND) === desired.cpu_kind &&
1230 (observed?.cpus ?? DEFAULT_CPUS) === desired.cpus &&
1231 (observed?.memory_mb ?? DEFAULT_MEMORY_MB) === desired.memory_mb &&
1232 observed?.gpu_kind === desired.gpu_kind &&
1233 observed?.gpus === desired.gpus;
1234
1235const sameEnv = (
1236 observed: Record<string, string | undefined> | undefined,
1237 desired: Record<string, string>,
1238) => deepEqual(compactRecord(observed), desired);
1239
1240const sameMounts = (
1241 observed: FlyMachineMount[] | undefined,
1242 desired: FlyMachineMount[],
1243) => {
1244 const key = (mount: FlyMachineMount) =>
1245 `${mount.volume ?? ""}:${mount.path ?? ""}`;
1246 const left = [...(observed ?? [])].map(key).sort();
1247 const right = desired.map(key).sort();
1248 return deepEqual(left, right);
1249};
1250
1251const metadataChanged = (
1252 observed: Record<string, string | undefined> | undefined,
1253 desired: Record<string, string>,
1254) => {
1255 const { removed, added, updated } = diffMachineMetadata(
1256 compactRecord(observed),
1257 desired,
1258 );
1259 return (
1260 removed.length > 0 ||
1261 Object.keys(added).length > 0 ||
1262 Object.keys(updated).length > 0
1263 );
1264};
1265
1266const sameStatics = (
1267 observed: FlyStatic[] | undefined,
1268 desired: FlyStatic[] | undefined,
1269) => {
1270 const normalize = (statics: FlyStatic[] | undefined) =>
1271 (statics ?? []).map((entry) => ({
1272 ...entry,
1273 // Empty and omitted index documents both disable directory indexes.
1274 index_document:
1275 entry.index_document === "" ? undefined : entry.index_document,
1276 }));
1277 return deepEqual(normalize(observed), normalize(desired), {
1278 stripNullish: true,
1279 });
1280};
1281
1282const configDrifted = (
1283 machine: FlyMachine,
1284 desired: {
1285 image: string;
1286 guest: FlyMachineGuest;
1287 env: Record<string, string>;
1288 services: FlyMachineService[];
1289 mounts: FlyMachineMount[];
1290 metadata: Record<string, string>;
1291 statics?: FlyStatic[];
1292 },
1293) => {
1294 const config = machine.config;
1295 return (
1296 !sameImage(machine, desired.image) ||
1297 !sameGuest(config?.guest, desired.guest) ||
1298 !sameEnv(config?.env, desired.env) ||
1299 !sameServices(config?.services, desired.services) ||
1300 !sameMounts(config?.mounts, desired.mounts) ||
1301 metadataChanged(config?.metadata, desired.metadata) ||
1302 !sameStatics(config?.statics, desired.statics)
1303 );
1304};
1305
1306const portsOf = (services: FlyMachineService[] | undefined) =>
1307 (services ?? []).flatMap((service) => service.ports ?? []);
1308
1309const withPort = (base: string, port: number, standard: number) =>
1310 port === standard ? base : `${base}:${port}`;
1311
1312const isRedirect = (handlers: string[], forceHttps: boolean | undefined) =>
1313 forceHttps === true && handlers.includes("http") && !handlers.includes("tls");
1314
1315/** Every published port on `host`, excluding HTTPS redirects and ranges. */
1316const endpointListOf = (
1317 host: string,
1318 services: FlyMachineService[] | undefined,
1319): ServiceEndpoint[] => {
1320 const flycast = host.endsWith(".flycast");
1321 return (services ?? []).flatMap((service) =>
1322 (service.ports ?? []).flatMap((entry) => {
1323 const handlers = entry.handlers ?? [];
1324 if (entry.port === undefined || isRedirect(handlers, entry.force_https))
1325 return [];
1326 const http = handlers.includes("http");
1327 const tls = handlers.includes("tls");
1328 const url = !http
1329 ? undefined
1330 : tls
1331 ? flycast
1332 ? undefined
1333 : withPort(`https://${host}`, entry.port, 443)
1334 : withPort(`http://${host}`, entry.port, 80);
1335 return [
1336 {
1337 host,
1338 port: entry.port,
1339 internalPort: service.internal_port,
1340 protocol: service.protocol ?? "tcp",
1341 handlers,
1342 url,
1343 },
1344 ];
1345 }),
1346 );
1347};
1348
1349/** HTTPS on the first TLS port, else HTTP on the first plain HTTP port. */
1350const mainUrlOf = (endpoints: ServiceEndpoint[]) =>
1351 endpoints.find((endpoint) => endpoint.url?.startsWith("https://"))?.url ??
1352 endpoints.find((endpoint) => endpoint.url !== undefined)?.url;
1353
1354/** `public` and `network` configure the Service's own App. */
1355const validateOwnership = (
1356 props: Partial<Pick<ServiceProps, "app" | "public" | "network">>,
1357) => {
1358 if (props.app === undefined) return Effect.void;
1359 const conflicting = (["public", "network"] as const).filter(
1360 (key) => props[key] !== undefined,
1361 );
1362 return conflicting.length === 0
1363 ? Effect.void
1364 : Effect.fail(
1365 new InvalidServiceProps({
1366 message: `Fly.Service ${conflicting.map((key) => `\`${key}\``).join(" and ")} only apply when the Service owns its App; remove \`app\` or ${conflicting.map((key) => `\`${key}\``).join(" and ")}.`,
1367 }),
1368 );
1369};
1370
1371/**
1372 * A private network name unique to the current stack and stage, for the
1373 * Service `network` prop. Services in one stage reach each other; other
1374 * stages and every other App in the organization cannot.
1375 */
1376export const stackNetwork = Effect.gen(function* () {
1377 const stack = yield* Stack;
1378 const name = `${stack.name}-${stack.stage}`
1379 .toLowerCase()
1380 .replace(/[^a-z0-9-]+/g, "-")
1381 .replace(/-+/g, "-")
1382 .replace(/^-|-$/g, "")
1383 .slice(0, 63)
1384 .replace(/-$/, "");
1385 return /^[a-z]/.test(name) ? name : `n-${name}`.slice(0, 63);
1386});
1387
1388interface Access {
1389 ownsApp: boolean;
1390 isPublic: boolean;
1391 network: string | undefined;
1392 bindingPort: number;
1393 rpcToken: Redacted.Redacted<string> | undefined;
1394}
1395
1396/** Drop the port Alchemy added for bindings from a services list. */
1397const withoutBindingPort = (
1398 services: FlyMachineService[] | undefined,
1399 bindingPort: number,
1400) =>
1401 (services ?? []).flatMap((service) => {
1402 const ports = (service.ports ?? []).filter(
1403 (entry) =>
1404 !(
1405 entry.port === bindingPort &&
1406 (entry.handlers ?? []).includes("http") &&
1407 !(entry.handlers ?? []).includes("tls")
1408 ),
1409 );
1410 return ports.length === 0 ? [] : [{ ...service, ports }];
1411 });
1412
1413const endpointsOf = (set: ReplicaSet, access: Access) => {
1414 if (!hasPublishedService(set.services))
1415 return { url: undefined, privateUrl: undefined, endpoints: [] };
1416 const isPublic = !access.ownsApp || access.isPublic;
1417 const binding = bindingPortOf(portsOfFly(set.services), access.bindingPort);
1418 const visible = binding?.added
1419 ? withoutBindingPort(set.services, binding.port)
1420 : set.services;
1421 const endpoints = endpointListOf(
1422 `${set.appName}.${isPublic ? "fly.dev" : "flycast"}`,
1423 visible,
1424 );
1425 return {
1426 url: isPublic ? mainUrlOf(endpoints) : undefined,
1427 privateUrl:
1428 binding === undefined
1429 ? undefined
1430 : withPort(`http://${set.appName}.flycast`, binding.port, 80),
1431 endpoints,
1432 };
1433};
1434
1435/**
1436 * The Service's Fly services plus the plain-HTTP port for bound callers,
1437 * when none of its published ports serves plain HTTP.
1438 */
1439const withBindingService = (
1440 services: FlyMachineService[],
1441 internalPort: number,
1442 bindingPort: number,
1443): { services: FlyMachineService[]; added: boolean } => {
1444 const binding = bindingPortOf(portsOfFly(services), bindingPort);
1445 if (binding?.added !== true) return { services, added: false };
1446 if (portsOfFly(services).some((entry) => entry.start === bindingPort))
1447 return { services, added: true };
1448 // Add the port to the first TCP service so it shares that service's
1449 // internal port and health checks (blue/green requires checks on every
1450 // published service).
1451 const index = services.findIndex(
1452 (service) => (service.protocol ?? "tcp") === "tcp",
1453 );
1454 if (index === -1) {
1455 return {
1456 added: true,
1457 services: [
1458 ...services,
1459 {
1460 protocol: "tcp",
1461 internal_port: internalPort,
1462 ports: [{ port: bindingPort, handlers: ["http"] }],
1463 },
1464 ],
1465 };
1466 }
1467 return {
1468 added: true,
1469 services: services.map((service, i) =>
1470 i === index
1471 ? {
1472 ...service,
1473 ports: [
1474 ...(service.ports ?? []),
1475 { port: bindingPort, handlers: ["http"] },
1476 ],
1477 }
1478 : service,
1479 ),
1480 };
1481};
1482
1483/** Fly reports the organization's default network as `default`. */
1484const normalizeNetwork = (network: string | undefined | null) =>
1485 network === undefined ||
1486 network === null ||
1487 network === "" ||
1488 network === "default"
1489 ? undefined
1490 : network;
1491
1492export class ServiceUnreachable extends Data.TaggedError(
1493 "Fly.ServiceUnreachable",
1494)<{
1495 service: string;
1496 target: string;
1497 network: string | undefined;
1498 targetNetwork: string | undefined;
1499}> {
1500 get message() {
1501 return `Fly Service ${this.service} binds ${this.target}, but ${this.service} is on the ${this.network ?? "default"} network and ${this.target} is on the ${this.targetNetwork ?? "default"} network. Put both on the same \`network\`.`;
1502 }
1503}
1504
1505export class ServiceNotBindable extends Data.TaggedError(
1506 "Fly.ServiceNotBindable",
1507)<{
1508 service: string;
1509 target: string;
1510}> {
1511 get message() {
1512 return `Fly Service ${this.service} binds ${this.target}, which publishes no ports (\`services: []\`), so it has no private address.`;
1513 }
1514}
1515
1516export class EndpointNotPublished extends Data.TaggedError(
1517 "Fly.EndpointNotPublished",
1518)<{
1519 service: string;
1520 target: string;
1521 port: number;
1522}> {
1523 get message() {
1524 return `Fly Service ${this.service} binds port ${this.port} of ${this.target}, which does not publish it.`;
1525 }
1526}
1527
1528/** Check every Service this one binds is reachable from its network. */
1529const validateTargets = (
1530 service: string,
1531 network: string | undefined,
1532 targets: readonly BoundTarget[],
1533) =>
1534 Effect.gen(function* () {
1535 for (const target of targets) {
1536 const targetNetwork = normalizeNetwork(target.network);
1537 if (targetNetwork !== normalizeNetwork(network)) {
1538 return yield* new ServiceUnreachable({
1539 service,
1540 target: target.service,
1541 network: normalizeNetwork(network),
1542 targetNetwork,
1543 });
1544 }
1545 if (target.privateUrl === undefined) {
1546 return yield* new ServiceNotBindable({
1547 service,
1548 target: target.service,
1549 });
1550 }
1551 if (
1552 target.port !== undefined &&
1553 !(target.endpoints ?? []).some((entry) => entry.port === target.port)
1554 ) {
1555 return yield* new EndpointNotPublished({
1556 service,
1557 target: target.service,
1558 port: target.port,
1559 });
1560 }
1561 }
1562 });
1563
1564/**
1565 * Fail when a Machine outside this Service already publishes one of its
1566 * ports in a shared App. Catches Machines the App's bindings do not list:
1567 * other stacks, the `fly` CLI, or plain `Fly.Machine`s.
1568 */
1569const validateObservedPorts = (
1570 appName: string,
1571 fqn: string,
1572 id: string,
1573 services: FlyMachineService[],
1574) =>
1575 Effect.gen(function* () {
1576 const listed = yield* machines
1577 .listMachines({ app_name: appName })
1578 .pipe(Effect.catchTag(["NotFound"], () => Effect.succeed([])));
1579 const others = new Map<string, FlyMachineService[] | undefined>();
1580 for (const machine of listed) {
1581 if (machine.state === "destroyed") continue;
1582 const owner =
1583 machine.config?.metadata?.[alchemyMetadataKeys.fqn] ?? machine.id ?? "";
1584 if (owner === fqn || others.has(owner)) continue;
1585 others.set(owner, machine.config?.services);
1586 }
1587 const mine = portsOfFly(services);
1588 for (const [owner, theirs] of others) {
1589 const conflict = findPortConflict(appName, [
1590 { id, ports: mine },
1591 { id: owner, ports: portsOfFly(theirs) },
1592 ]);
1593 if (
1594 conflict !== undefined &&
1595 conflict.publishers[0] !== conflict.publishers[1]
1596 )
1597 return yield* conflict;
1598 }
1599 });
1600
1601const toAttrs = (
1602 set: ReplicaSet,
1603 codeHash: string,
1604 access: Access,
1605): Service["Attributes"] => ({
1606 appName: set.appName,
1607 ownsApp: access.ownsApp,
1608 network: normalizeNetwork(access.network),
1609 rpcToken: access.rpcToken,
1610 ...endpointsOf(set, access),
1611 rolloutPending: set.rolloutPending,
1612 machineId: set.machineId,
1613 machineIds: set.machineIds,
1614 name: set.name,
1615 baseName: set.baseName,
1616 region: set.region,
1617 regions: set.regions,
1618 state: set.state,
1619 imageRef: set.imageRef,
1620 count: Math.round(set.count / Math.max(1, set.regions.length)),
1621 mounts: set.mounts,
1622 replicas: set.replicas,
1623 code: { hash: codeHash },
1624});
1625
1626const machineIdsOf = (output: Service["Attributes"] | undefined) =>
1627 output?.machineIds ??
1628 (output?.machineId !== undefined && output.machineId.length > 0
1629 ? [output.machineId]
1630 : []);
1631
1632/** The private network the Service's Machines join. */
1633const networkOf = (props: ServiceProps) =>
1634 normalizeNetwork(
1635 props.app === undefined
1636 ? props.network
1637 : (props.app as { network?: string }).network,
1638 );
1639
1640/**
1641 * Resolve the App the Service runs in. A Service that owns its App creates
1642 * it with its addresses; one placed in an App gets a Flycast address on it
1643 * so bound callers can reach it.
1644 */
1645const ensureServiceApp = Effect.fn(function* (
1646 id: string,
1647 props: ServiceProps,
1648 output: Service["Attributes"] | undefined,
1649) {
1650 yield* validateOwnership(props);
1651 const ownsApp = props.app === undefined;
1652 const isPublic = !ownsApp || props.public !== false;
1653 if (ownsApp) {
1654 const desired =
1655 props.name !== undefined
1656 ? sanitizeFlyAppName(props.name)
1657 : output?.ownsApp === true
1658 ? output.appName
1659 : yield* createFlyAppName(id);
1660 const app = yield* ensureApp({
1661 name: desired,
1662 network: props.network,
1663 previousName: output?.ownsApp === true ? output.appName : undefined,
1664 });
1665 const appName = app.name ?? desired;
1666 yield* syncOwnedAppAddresses(appName, isPublic, props.network);
1667 return { appName, ownsApp, isPublic, network: props.network };
1668 }
1669 const appName = appNameOf(props.app) ?? output?.appName;
1670 if (appName === undefined) {
1671 return yield* new ServiceAppNotResolved({
1672 message: "Fly.Service requires a resolved App with appName.",
1673 });
1674 }
1675 const network = networkOf(props);
1676 yield* ensureFlycastAddress(appName, network);
1677 return { appName, ownsApp, isPublic, network };
1678});
1679
1680export const ServiceProvider = () =>
1681 Provider.effect(
1682 Service,
1683 Effect.gen(function* () {
1684 const stack = yield* Stack;
1685 const docker = yield* Docker;
1686 const { dotAlchemy } = yield* AlchemyContext;
1687 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
1688 const hosted = createFlyHostedSupport({
1689 stackName: stack.name,
1690 stage: stack.stage,
1691 virtualEntryPlugin,
1692 docker,
1693 dotAlchemy,
1694 });
1695
1696 return Service.Provider.of({
1697 stables: ["appName"],
1698 nuke: { dependsOn: ["Fly.App"] },
1699
1700 diff: Effect.fn(function* ({ id, news, output }) {
1701 if (news === undefined) return;
1702 const shape = news as Partial<
1703 Pick<ServiceProps, "app" | "public" | "network">
1704 >;
1705 const ownsApp = shape.app === undefined;
1706 yield* validateOwnership(shape);
1707 if ("main" in news) {
1708 const settings: Input<
1709 Pick<
1710 ServiceProps,
1711 | "deploy"
1712 | "shutdown"
1713 | "checks"
1714 | "services"
1715 | "port"
1716 | "isExternal"
1717 | "public"
1718 >
1719 > = {
1720 deploy: news.deploy,
1721 shutdown: news.shutdown,
1722 services: news.services,
1723 checks: news.checks,
1724 port: news.port,
1725 isExternal: news.isExternal,
1726 public: news.public,
1727 };
1728 if (
1729 isResolved<
1730 Pick<
1731 ServiceProps,
1732 | "deploy"
1733 | "shutdown"
1734 | "checks"
1735 | "services"
1736 | "port"
1737 | "isExternal"
1738 | "public"
1739 >
1740 >(settings)
1741 ) {
1742 yield* validateDeployment(
1743 yield* deploymentPolicy(
1744 settings.deploy,
1745 settings.shutdown,
1746 !settings.isExternal,
1747 ),
1748 {
1749 services:
1750 settings.services?.map(toFlyService) ??
1751 defaultHttpServices(
1752 settings.port ?? DEFAULT_PORT,
1753 1,
1754 !ownsApp || settings.public !== false,
1755 ),
1756 checks: settings.checks,
1757 },
1758 false,
1759 );
1760 }
1761 }
1762 if (output === undefined) return;
1763 // Moving into or out of a shared App changes the hostname.
1764 if (ownsApp !== (output.ownsApp === true)) {
1765 return { action: "replace" as const, deleteFirst: false };
1766 }
1767 if (ownsApp && isResolved(news)) {
1768 const desiredAppName =
1769 news.name !== undefined
1770 ? sanitizeFlyAppName(news.name)
1771 : output.appName;
1772 const nameChanged = desiredAppName !== output.appName;
1773 // Fly cannot move an App to another network.
1774 const networkChanged =
1775 normalizeNetwork(news.network) !==
1776 normalizeNetwork(output.network);
1777 if (nameChanged || networkChanged) {
1778 return {
1779 action: "replace" as const,
1780 // A pinned App name cannot exist twice.
1781 deleteFirst: !nameChanged && news.name !== undefined,
1782 };
1783 }
1784 }
1785 if (!ownsApp && isResolved(news)) {
1786 const desiredAppName = appNameOf(news.app);
1787 const appChanged =
1788 desiredAppName !== undefined && desiredAppName !== output.appName;
1789 const desiredName =
1790 news.name !== undefined
1791 ? sanitizeFlyAppName(news.name)
1792 : (output.baseName ?? output.name);
1793 const nameChanged =
1794 desiredName !== (output.baseName ?? output.name);
1795 if (appChanged || nameChanged) {
1796 return {
1797 action: "replace" as const,
1798 deleteFirst: nameChanged === false && appChanged === false,
1799 };
1800 }
1801 }
1802 // The code hash depends only on statically-known props (`main`,
1803 // `build`, `image`, `port`, `extraFiles`, `isExternal`). Never
1804 // gate it on the WHOLE props being resolved: `app` is a resource
1805 // reference that stays unresolved at diff time, so a whole-props
1806 // guard makes code-only changes silently noop. By diff time the
1807 // effect-config form has been evaluated, so the object view is
1808 // safe to read.
1809 const statics = news as Partial<
1810 Pick<
1811 ServiceProps,
1812 "main" | "build" | "image" | "port" | "extraFiles" | "isExternal"
1813 >
1814 >;
1815 if (
1816 isResolved({
1817 main: statics.main,
1818 build: statics.build,
1819 image: statics.image,
1820 port: statics.port,
1821 extraFiles: statics.extraFiles,
1822 isExternal: statics.isExternal,
1823 }) &&
1824 statics.main !== undefined
1825 ) {
1826 const hash = yield* hosted.hash(statics as HostedProgramProps);
1827 if (hash !== output.code.hash) {
1828 return { action: "update" as const };
1829 }
1830 }
1831 return output.rolloutPending
1832 ? { action: "update" as const }
1833 : undefined;
1834 }),
1835
1836 read: Effect.fn(function* ({ id, fqn, instanceId, olds, output }) {
1837 const ownsApp =
1838 output?.ownsApp ?? (olds !== undefined && olds.app === undefined);
1839 const appName = ownsApp
1840 ? (output?.appName ??
1841 (olds?.name !== undefined
1842 ? sanitizeFlyAppName(olds.name)
1843 : yield* createFlyAppName(id)))
1844 : (appNameOf(olds?.app) ?? output?.appName);
1845 const name = yield* resolveMachineName(
1846 id,
1847 ownsApp ? undefined : olds?.name,
1848 output?.baseName ?? output?.name,
1849 );
1850 const found = yield* observeReplicaSet({
1851 appName,
1852 id,
1853 type: "Fly.Service",
1854 fqn,
1855 resourceInstanceId: instanceId,
1856 machineIds: machineIdsOf(output),
1857 baseName: name,
1858 });
1859 if (found === undefined) return undefined;
1860 return toAttrs(found, output?.code.hash ?? "", {
1861 ownsApp,
1862 isPublic: ownsApp && (yield* hasPublicAddress(found.appName)),
1863 network: output?.network ?? (ownsApp ? olds?.network : undefined),
1864 bindingPort: olds?.bindingPort ?? DEFAULT_BINDING_PORT,
1865 rpcToken: output?.rpcToken,
1866 });
1867 }),
1868
1869 list: Effect.fn(function* () {
1870 const sets = yield* listReplicaSets("Fly.Service");
1871 // Owned Apps are also listed (and nuked) as Fly.App rows.
1872 return sets.map((set) =>
1873 toAttrs(set, "", {
1874 ownsApp: false,
1875 isPublic: true,
1876 network: undefined,
1877 bindingPort: DEFAULT_BINDING_PORT,
1878 rpcToken: undefined,
1879 }),
1880 );
1881 }),
1882
1883 reconcile: Effect.fn(function* ({
1884 id,
1885 fqn,
1886 instanceId,
1887 news,
1888 output,
1889 bindings,
1890 session,
1891 }) {
1892 const props = news;
1893 const policy = yield* deploymentPolicy(
1894 props.deploy,
1895 props.shutdown,
1896 !props.isExternal,
1897 );
1898 yield* validateOwnership(props);
1899 const bound = collectBindingState(bindings ?? []);
1900 // Before creating anything: every bound Service must be reachable.
1901 yield* validateTargets(id, networkOf(props), bound.targets);
1902 const { appName, ownsApp, isPublic, network } =
1903 yield* ensureServiceApp(id, props, output);
1904 const name = yield* resolveMachineName(
1905 id,
1906 ownsApp ? undefined : props.name,
1907 output?.baseName ?? output?.name,
1908 );
1909 const regions = regionList(props.region, DEFAULT_REGION);
1910 const count = resolveCount(props.count);
1911 const port = props.port ?? DEFAULT_PORT;
1912 const bindingPort = props.bindingPort ?? DEFAULT_BINDING_PORT;
1913 const secretVersions: number[] = [];
1914 const redisVersion = yield* attachRedisSecrets(appName, bound.redis);
1915 if (redisVersion !== undefined) secretVersions.push(redisVersion);
1916 const bucketVersion = yield* attachBucketSecrets(
1917 appName,
1918 bound.buckets,
1919 {
1920 ...bound.env,
1921 ...toEnv(props.env),
1922 },
1923 );
1924 if (bucketVersion !== undefined) secretVersions.push(bucketVersion);
1925 for (const pg of bound.postgres) {
1926 const version = yield* attachPostgresSecrets(
1927 appName,
1928 pg.clusterId,
1929 pg.variableName,
1930 );
1931 if (version !== undefined) secretVersions.push(version);
1932 }
1933 const minSecretsVersion =
1934 secretVersions.length > 0 ? Math.max(...secretVersions) : undefined;
1935 const env = desiredEnv(props, bound.env, hosted.alchemyEnv, port);
1936 if (policy.shutdown && !props.isExternal) {
1937 env.ALCHEMY_FLY_SHUTDOWN_TIMEOUT_MS = String(
1938 policy.shutdown.timeoutMs,
1939 );
1940 }
1941 const guest = toFlyGuest(props.guest);
1942 const published = withBindingService(
1943 props.services !== undefined
1944 ? props.services.map(toFlyService)
1945 : defaultHttpServices(port, count, isPublic),
1946 port,
1947 bindingPort,
1948 );
1949 const services = published.services;
1950 if (!ownsApp)
1951 yield* validateObservedPorts(appName, fqn, id, services);
1952 if (props.rpcToken !== undefined)
1953 env[RPC_TOKEN_ENV] = Redacted.value(props.rpcToken);
1954 for (const target of bound.targets) {
1955 const keys = boundTargetEnvKeys(target.service);
1956 env[keys.appName] = packEnvValue(target.appName);
1957 if (target.privateUrl !== undefined)
1958 env[keys.url] = packEnvValue(target.privateUrl);
1959 if (target.rpcToken !== undefined)
1960 env[keys.token] = packEnvValue(target.rpcToken);
1961 }
1962 env[RPC_ORG_ENV] = yield* resolveOrgSlug();
1963 if (published.added) env[BINDING_PORT_ENV] = String(bindingPort);
1964 const statics = toFlyStatics(props.statics);
1965
1966 const { imageRef, codeHash } = yield* hosted.resolveImage({
1967 id,
1968 appName,
1969 props,
1970 previousHash: output?.code.hash,
1971 session,
1972 });
1973
1974 const set = yield* reconcileReplicas({
1975 id,
1976 type: "Fly.Service",
1977 fqn,
1978 resourceInstanceId: instanceId,
1979 policy,
1980 checks: props.checks,
1981 appName,
1982 baseName: name,
1983 regions,
1984 // `count` is per region.
1985 count: count * regions.length,
1986 disks: bound.mounts,
1987 minSecretsVersion,
1988 outputMachineIds: machineIdsOf(output),
1989 preferVolumeIds: (output?.replicas ?? []).map((replica) =>
1990 replica.mounts.map((mount) => mount.volumeId),
1991 ),
1992 configDrifted: (machine, desired) =>
1993 configDrifted(machine, {
1994 image: imageRef,
1995 guest,
1996 env,
1997 services,
1998 mounts: desired.mounts,
1999 metadata: desired.metadata,
2000 statics,
2001 }),
2002 buildConfig: ({ mounts, metadata }) =>
2003 buildConfig({
2004 image: imageRef,
2005 guest,
2006 env,
2007 services,
2008 mounts,
2009 metadata,
2010 statics,
2011 }),
2012 }).pipe(
2013 Effect.catchTag("Fly.ReplicaNotCreated", (error) =>
2014 Effect.fail(
2015 new ServiceNotCreated({
2016 name: error.name,
2017 appName: error.appName,
2018 }),
2019 ),
2020 ),
2021 );
2022 return toAttrs(set, codeHash, {
2023 ownsApp,
2024 isPublic,
2025 network,
2026 bindingPort,
2027 rpcToken: props.rpcToken,
2028 });
2029 }),
2030
2031 // Create the App, its addresses, and the caller token before any
2032 // Machine, so two Services that bind each other can both resolve
2033 // the other's private address and token.
2034 precreate: Effect.fn(function* ({ id, news }) {
2035 const props = news;
2036 // Precreate runs before dependencies resolve. A Service placed in
2037 // an App cannot see the App yet, so it has no stub to offer;
2038 // cycles are supported between Services that own their App.
2039 const inert =
2040 props.app !== undefined ||
2041 !isResolved(props.name) ||
2042 !isResolved(props.network) ||
2043 !isResolved(props.services) ||
2044 !isResolved(props.bindingPort);
2045 // No stub: a failed create then recovers through `read`, which
2046 // finds the App from the persisted props.
2047 if (inert) return undefined as unknown as Service["Attributes"];
2048 const { appName, ownsApp, isPublic, network } =
2049 yield* ensureServiceApp(id, props, undefined);
2050 const port = props.port ?? DEFAULT_PORT;
2051 const count = resolveCount(props.count);
2052 const bindingPort = props.bindingPort ?? DEFAULT_BINDING_PORT;
2053 const { services } = withBindingService(
2054 props.services !== undefined
2055 ? props.services.map(toFlyService)
2056 : defaultHttpServices(port, count, isPublic),
2057 port,
2058 bindingPort,
2059 );
2060 const name = yield* resolveMachineName(
2061 id,
2062 ownsApp ? undefined : props.name,
2063 );
2064 const stub: ReplicaSet = {
2065 appName,
2066 machineId: "",
2067 machineIds: [],
2068 name,
2069 baseName: name,
2070 region: regionList(props.region, DEFAULT_REGION)[0]!,
2071 regions: regionList(props.region, DEFAULT_REGION),
2072 state: "created",
2073 instanceId: undefined,
2074 privateIp: undefined,
2075 imageRef: undefined,
2076 guest: undefined,
2077 services,
2078 count: 0,
2079 mounts: [],
2080 replicas: [],
2081 };
2082 return toAttrs(stub, "", {
2083 ownsApp,
2084 isPublic,
2085 network,
2086 bindingPort,
2087 rpcToken: isResolved(props.rpcToken) ? props.rpcToken : undefined,
2088 });
2089 }),
2090
2091 delete: Effect.fn(function* ({
2092 id,
2093 fqn,
2094 instanceId,
2095 olds,
2096 output,
2097 force,
2098 }) {
2099 const appName = output.appName || appNameOf(olds.app);
2100 // A precreate stub that never reached its App has nothing to delete.
2101 if (appName === undefined || appName.length === 0) return;
2102 yield* deleteReplicaSet({
2103 appName,
2104 id,
2105 type: "Fly.Service",
2106 fqn,
2107 resourceInstanceId: instanceId,
2108 machineIds: machineIdsOf(output),
2109 volumeIds: volumeIdsOf(output),
2110 force,
2111 });
2112 if (output.ownsApp === true) yield* deleteApp(appName);
2113 }),
2114 });
2115 }),
2116 ).pipe(Layer.provide(DockerLive));