1import type {
2 Sprite as FlySprite,
3 UrlAuth as FlyUrlAuth,
4} from "@distilled.cloud/fly-io/sprites";
5import * as sprites from "@distilled.cloud/fly-io/sprites";
6import * as Data from "effect/Data";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import { Unowned } from "../AdoptPolicy.ts";
13import * as Bundle from "../Bundle/Bundle.ts";
14import { isResolved } from "../Diff.ts";
15import { Platform, type Main, type PlatformProps } from "../Platform.ts";
16import * as Provider from "../Provider.ts";
17import type { Resource, ResourceBinding } from "../Resource.ts";
18import type { ServerHost } from "../Server/Process.ts";
19import { Stack } from "../Stack.ts";
20import { createInternalTags } from "../Tags.ts";
21import {
22 createFlyResourceName,
23 matchesAlchemyPhysicalName,
24 sanitizeFlyAppName,
25} from "./Metadata.ts";
26import type { Providers } from "./Providers.ts";
27import {
28 createFlyHostRuntimeContext,
29 createSpriteHostedSupport,
30 DEFAULT_PORT,
31 type FlyHostRuntimeContext,
32 type HostedProgramProps,
33} from "./hosted.ts";
34
35const DEFAULT_URL_AUTH: UrlAuth = "public";
36const APP_DIR = "/home/sprite/alchemy";
37const BUN_BIN = "/home/sprite/.bun/bin/bun";
38const SERVICE_NAME = "alchemy";
39const OWNER_LABEL = "alchemy.type=Fly.Sprite";
40
41const backoff = Schedule.min([
42 Schedule.exponential(Duration.millis(500), 1.5),
43 Schedule.spaced(Duration.seconds(5)),
44]);
45
46export type UrlAuth = "public" | "sprite";
47
48export type SpriteStatus = "cold" | "warm" | "running";
49
50export interface SpriteProps extends PlatformProps {
51 /**
52 * Module entrypoint bundled with rolldown and written onto the Sprite.
53 * Typically `import.meta.url`. A content-hash change updates in place.
54 */
55 main: string;
56 /**
57 * Sprite name. Unique per organization. If omitted, a unique name is
58 * generated from the stack, stage and logical ID. Changing it replaces
59 * the Sprite.
60 */
61 name?: string;
62 /**
63 * Who can hit {@link Sprite} `url`. `public` is open. `sprite` requires
64 * Sprites auth.
65 *
66 * @default "public"
67 */
68 urlAuth?: UrlAuth;
69 /**
70 * Port the hosted HTTP server listens on. Written to `PORT` and used
71 * as the Sprite service `http_port`.
72 *
73 * @default 3000
74 */
75 port?: number;
76 /**
77 * Named export to load from `main`.
78 *
79 * @default "default"
80 */
81 handler?: string;
82 /**
83 * Additional environment variables for the hosted process. Merged
84 * after binding-injected `env`.
85 */
86 env?: Record<string, any>;
87 /**
88 * Bundler configuration for `main`: rolldown `input`/`output`
89 * overrides plus pure-annotation options (`pure`).
90 */
91 build?: Bundle.BundleConfig;
92}
93
94/**
95 * Binding contract accepted by {@link Sprite} for injected env.
96 */
97export interface SpriteBinding {
98 env?: Record<string, any>;
99}
100
101export type Sprite = Resource<
102 "Fly.Sprite",
103 SpriteProps,
104 {
105 /** Fly Sprite id. */
106 spriteId: string;
107 /** Physical Sprite name (unique per org). */
108 name: string;
109 /**
110 * Public Sprite URL (`https://{name}-….sprites.app`).
111 */
112 url: string;
113 /** Observed runtime status. Hibernates to `cold` when idle. */
114 status: SpriteStatus;
115 /** Observed URL auth setting. */
116 urlAuth: UrlAuth;
117 /** Organization slug, if the API returned one. */
118 orgSlug: string | undefined;
119 /** Content hash of the bundled program. */
120 code: {
121 hash: string;
122 };
123 },
124 SpriteBinding,
125 Providers
126>;
127
128export const isSprite = (value: unknown): value is Sprite =>
129 typeof value === "object" &&
130 value !== null &&
131 (value as { Type?: string }).Type === "Fly.Sprite";
132
133export type SpriteServices = ServerHost;
134
135export type SpriteShape = Main<SpriteServices>;
136
137export type SpriteRuntimeContext = FlyHostRuntimeContext;
138
139/**
140 * A Sprite is an Effect program running in a Fly.io Sprite. Sprites
141 * are org-scoped Linux sandboxes. They hibernate when idle and wake
142 * on demand. There is no parent {@link App}. Unlike a {@link Service},
143 * Alchemy does not build a Docker image.
144 *
145 * @see https://sprites.dev/api/sprites
146 *
147 * ### Declare a Sprite
148 * A Sprite is a class. Props describe the sandbox. The Effect is the
149 * program that runs on it. There is no parent App.
150 *
151 * `main: import.meta.url` is the bundle entrypoint. Alchemy bundles
152 * this file with Rolldown, writes it onto the Sprite, and runs it as
153 * a Sprite service on {@link port}. Auth is `FLY_API_TOKEN`.
154 *
155 * **Example:** Class + main
156 * ```typescript
157 * export default class Box extends Fly.Sprite<Box>()(
158 * "Box",
159 * { main: import.meta.url },
160 * Effect.gen(function* () {
161 * return {};
162 * }),
163 * ) {}
164 * ```
165 *
166 * ### Serve HTTP with fetch
167 * Return `fetch` from the init Effect to boot an HTTP server. The
168 * Sprite URL proxies to {@link port}.
169 *
170 * **Example:** Hello
171 * ```typescript
172 * export default class Box extends Fly.Sprite<Box>()(
173 * "Box",
174 * { main: import.meta.url },
175 * Effect.gen(function* () {
176 * return {
177 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
178 * };
179 * }),
180 * ) {}
181 * ```
182 *
183 * ### The public URL
184 * Yield the Sprite in the Stack. `box.url` is
185 * `https://{name}-….sprites.app`.
186 *
187 * **Example:** Stack output
188 * ```typescript
189 * export default Alchemy.Stack(
190 * "MyApp",
191 * { providers: Fly.providers(), state: Alchemy.localState() },
192 * Effect.gen(function* () {
193 * const box = yield* Box;
194 * return { url: box.url };
195 * }),
196 * );
197 * ```
198 *
199 * ### URL auth
200 * `urlAuth` is `public` or `sprite`. Default is `public` so `url`
201 * answers without a Sprites token.
202 *
203 * **Example:** Sprite-auth URL
204 * ```typescript
205 * export default class Box extends Fly.Sprite<Box>()(
206 * "Box",
207 * { main: import.meta.url, urlAuth: "sprite" },
208 * Effect.gen(function* () {
209 * return {
210 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
211 * };
212 * }),
213 * ) {}
214 * ```
215 *
216 * :::note[Default is public]
217 * Fly's API default is `sprite`. Alchemy defaults to `public` so a
218 * `fetch` handler is reachable.
219 * :::
220 *
221 * ### Config
222 * Yield `Config` in init. Alchemy reads the value from the env of
223 * whoever deploys and writes it onto the Sprite. Do not pass
224 * `env: { ... }` on a Sprite.
225 *
226 * Yield `FileSystem.FileSystem` in init, never inside `fetch`.
227 *
228 * **Example:** Config.Redacted
229 * ```typescript
230 * import * as Config from "effect/Config";
231 * import * as FileSystem from "effect/FileSystem";
232 * import * as Redacted from "effect/Redacted";
233 *
234 * export default class Box extends Fly.Sprite<Box>()(
235 * "Box",
236 * { main: import.meta.url },
237 * Effect.gen(function* () {
238 * const apiKey = yield* Config.Redacted("API_KEY");
239 * const fs = yield* FileSystem.FileSystem;
240 * yield* fs.makeDirectory("/tmp", { recursive: true });
241 *
242 * return {
243 * fetch: Effect.gen(function* () {
244 * const token = Redacted.value(apiKey);
245 * return HttpServerResponse.text("ok");
246 * }),
247 * };
248 * }),
249 * ) {}
250 * ```
251 *
252 * ### Exec
253 * {@link Exec} runs a command on the Sprite. Provide {@link ExecHttp}.
254 *
255 * **Example:** ls
256 * ```typescript
257 * const exec = yield* Fly.Exec(Box);
258 * const result = yield* exec({ cmd: ["ls", "-la"] });
259 * ```
260 *
261 * ### Checkpoint
262 * {@link Checkpoint} snapshots and restores the Sprite disk. Provide
263 * {@link CheckpointHttp}.
264 *
265 * **Example:** Create and restore
266 * ```typescript
267 * const checkpoint = yield* Fly.Checkpoint(Box);
268 * yield* checkpoint.create({ comment: "before" });
269 * yield* checkpoint.restore("v1");
270 * ```
271 *
272 * :::caution[Restore is destructive]
273 * The disk rewinds. Later writes are gone.
274 * :::
275 *
276 * ### A stable name
277 * Omit `name` and Alchemy generates one from the stack, stage, and
278 * logical ID.
279 *
280 * **Example:** Explicit name
281 * ```typescript
282 * export default class Box extends Fly.Sprite<Box>()(
283 * "Box",
284 * { main: import.meta.url, name: "box" },
285 * Effect.gen(function* () {
286 * return {
287 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
288 * };
289 * }),
290 * ) {}
291 * ```
292 *
293 * :::caution[Changing `name` replaces the Sprite]
294 * Fly cannot rename a Sprite. Alchemy creates the new name, then
295 * deletes the old one.
296 * :::
297 *
298 * ### Named export
299 * `handler` is the named export to load from `main`. Default is
300 * `"default"`.
301 *
302 * **Example:** Custom handler
303 * ```typescript
304 * export default class Box extends Fly.Sprite<Box>()(
305 * "Box",
306 * { main: import.meta.url, handler: "box" },
307 * Effect.gen(function* () {
308 * return {
309 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
310 * };
311 * }),
312 * ) {}
313 * ```
314 *
315 * @resource
316 * @product Sprite
317 */
318export const Sprite: Platform<
319 Sprite,
320 SpriteServices,
321 SpriteShape,
322 SpriteRuntimeContext
323> = Platform("Fly.Sprite", {
324 createRuntimeContext: createFlyHostRuntimeContext("Fly.Sprite"),
325});
326
327export class SpriteNotCreated extends Data.TaggedError("Fly.SpriteNotCreated")<{
328 name: string;
329}> {}
330
331export class SpriteExecFailed extends Data.TaggedError("Fly.SpriteExecFailed")<{
332 name: string;
333 cmd: string;
334 exitCode: number | undefined;
335 stderr: string | undefined;
336}> {}
337
338class SpritePending extends Data.TaggedError("Fly.SpritePending")<{
339 name: string;
340}> {}
341
342const toStatus = (status: string | undefined): SpriteStatus =>
343 status === "warm" || status === "running" || status === "cold"
344 ? status
345 : "cold";
346
347const toUrlAuth = (auth: string | undefined): UrlAuth =>
348 auth === "sprite" ? "sprite" : "public";
349
350const toAttrs = (
351 sprite: FlySprite,
352 name: string,
353 codeHash: string,
354): Sprite["Attributes"] => ({
355 spriteId: sprite.id ?? name,
356 name: sprite.name ?? name,
357 url: sprite.url ?? `https://${name}.sprites.app`,
358 status: toStatus(sprite.status),
359 urlAuth: toUrlAuth(sprite.url_settings?.auth),
360 orgSlug: sprite.org_slug ?? sprite.organization,
361 code: { hash: codeHash },
362});
363
364const isOwnedSprite = (sprite: FlySprite): boolean => {
365 const labels = sprite.labels ?? [];
366 if (labels.includes(OWNER_LABEL)) return true;
367 if (labels.some((label) => label.startsWith("alchemy.type="))) return true;
368 return matchesAlchemyPhysicalName(sprite.name);
369};
370
371const resolveSpriteName = (
372 id: string,
373 name: string | undefined,
374 existing?: string,
375) =>
376 Effect.gen(function* () {
377 if (name !== undefined) return sanitizeFlyAppName(name);
378 if (existing !== undefined) return existing;
379 return yield* createFlyResourceName(id);
380 });
381
382const ownershipLabels = (id: string) =>
383 Effect.gen(function* () {
384 const tags = yield* createInternalTags(id);
385 return [
386 ...Object.entries(tags).map(([key, value]) => `${key}=${value}`),
387 OWNER_LABEL,
388 ];
389 });
390
391const getByName = (name: string) =>
392 sprites
393 .getSprite({ name })
394 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
395
396const waitForSprite = (name: string) =>
397 getByName(name).pipe(
398 Effect.flatMap((found) =>
399 found !== undefined
400 ? Effect.succeed(found)
401 : Effect.fail(new SpritePending({ name })),
402 ),
403 Effect.retry({
404 while: (e) => e._tag === "Fly.SpritePending",
405 times: 8,
406 schedule: backoff,
407 }),
408 );
409
410const isTransient = (e: { readonly _tag: string }) =>
411 e._tag === "NotFound" ||
412 e._tag === "TooManyRequests" ||
413 e._tag === "ServiceUnavailable" ||
414 e._tag === "BadGateway" ||
415 e._tag === "GatewayTimeout";
416
417const retryTransient = <A, E extends { readonly _tag: string }, R>(
418 effect: Effect.Effect<A, E, R>,
419) =>
420 effect.pipe(
421 Effect.retry({
422 while: isTransient,
423 times: 8,
424 schedule: backoff,
425 }),
426 );
427
428const toEnv = (env: Record<string, any> | undefined): Record<string, string> =>
429 Object.fromEntries(
430 Object.entries(env ?? {}).flatMap(([key, value]) => {
431 if (value === undefined || value === null) return [];
432 const raw = Redacted.isRedacted(value) ? Redacted.value(value) : value;
433 return [[key, String(raw)]];
434 }),
435 );
436
437const collectBindingEnv = (
438 bindings: readonly ResourceBinding<SpriteBinding>[],
439) => {
440 const active = bindings.filter(
441 (binding: ResourceBinding<SpriteBinding> & { action?: string }) =>
442 binding.action !== "delete",
443 );
444 return active
445 .map((binding) => binding?.data?.env)
446 .reduce<Record<string, any>>((acc, value) => ({ ...acc, ...value }), {});
447};
448
449const desiredEnv = (
450 props: SpriteProps,
451 bindingEnv: Record<string, any>,
452 alchemyEnv: Record<string, string>,
453 port: number,
454): Record<string, string> => ({
455 ...toEnv(bindingEnv),
456 ...alchemyEnv,
457 PORT: String(port),
458 ...toEnv(props.env),
459});
460
461const renderEnvFile = (env: Record<string, string>) =>
462 `${Object.entries(env)
463 .sort(([left], [right]) => left.localeCompare(right))
464 .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
465 .join("\n")}\n`;
466
467const writeRemoteFile = (input: {
468 name: string;
469 path: string;
470 body: Uint8Array;
471}) =>
472 retryTransient(
473 sprites.writeFile({
474 name: input.name,
475 path: input.path,
476 workingDir: "/",
477 mkdir: true,
478 body: input.body,
479 }),
480 );
481
482const runExec = (input: { name: string; cmd: string[]; env?: string[] }) =>
483 Effect.gen(function* () {
484 const result = yield* retryTransient(
485 sprites.execCommand({
486 name: input.name,
487 cmd: input.cmd,
488 env: input.env,
489 }),
490 );
491 if ((result.exit_code ?? 0) !== 0) {
492 return yield* new SpriteExecFailed({
493 name: input.name,
494 cmd: input.cmd.join(" "),
495 exitCode: result.exit_code,
496 stderr: result.stderr,
497 });
498 }
499 return result;
500 });
501
502const ensureBun = (name: string) =>
503 runExec({
504 name,
505 cmd: [
506 "bash",
507 "-lc",
508 [
509 "set -euo pipefail",
510 "export HOME=/home/sprite",
511 "export BUN_INSTALL=/home/sprite/.bun",
512 'export PATH="$BUN_INSTALL/bin:$PATH"',
513 'if [ ! -x "$BUN_INSTALL/bin/bun" ]; then',
514 " curl -fsSL https://bun.sh/install | bash",
515 "fi",
516 `test -x ${BUN_BIN}`,
517 ].join("\n"),
518 ],
519 });
520
521const putAlchemyService = (input: { name: string; port: number }) =>
522 retryTransient(
523 sprites.putService({
524 name: input.name,
525 service_name: SERVICE_NAME,
526 cmd: "bash",
527 args: [
528 "-lc",
529 `set -a; [ -f ${APP_DIR}/env ] && . ${APP_DIR}/env; set +a; exec ${BUN_BIN} --no-install ${APP_DIR}/index.mjs`,
530 ],
531 dir: APP_DIR,
532 http_port: input.port,
533 }),
534 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
535
536const deployProgram = Effect.fn(function* (input: {
537 name: string;
538 files: ReadonlyArray<{ path: string; content: Uint8Array }>;
539 env: Record<string, string>;
540 port: number;
541 session?: { note: (message: string) => Effect.Effect<void> };
542}) {
543 const note = input.session?.note ?? ((_message: string) => Effect.void);
544 yield* note(`Writing ${input.name} program onto the Sprite...`);
545 for (const file of input.files) {
546 const rel = file.path.replace(/^\/+/, "");
547 yield* writeRemoteFile({
548 name: input.name,
549 path: `${APP_DIR}/${rel}`,
550 body: file.content,
551 });
552 }
553 yield* writeRemoteFile({
554 name: input.name,
555 path: `${APP_DIR}/env`,
556 body: new TextEncoder().encode(renderEnvFile(input.env)),
557 });
558 yield* note(`Installing bun on ${input.name}...`);
559 yield* ensureBun(input.name);
560 yield* note(`Starting ${input.name} service...`);
561 yield* putAlchemyService({ name: input.name, port: input.port });
562});
563
564export const SpriteProvider = () =>
565 Provider.effect(
566 Sprite,
567 Effect.gen(function* () {
568 const stack = yield* Stack;
569 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
570 const hosted = createSpriteHostedSupport({
571 stackName: stack.name,
572 stage: stack.stage,
573 virtualEntryPlugin,
574 });
575
576 return Sprite.Provider.of({
577 stables: ["spriteId", "name"],
578
579 diff: Effect.fn(function* ({ news, output }) {
580 if (news === undefined || output === undefined) return undefined;
581 if (isResolved(news)) {
582 const desiredName =
583 news.name !== undefined
584 ? sanitizeFlyAppName(news.name)
585 : output.name;
586 if (desiredName !== output.name) {
587 return { action: "replace" as const };
588 }
589 const desiredAuth = news.urlAuth ?? DEFAULT_URL_AUTH;
590 if (desiredAuth !== output.urlAuth) {
591 return { action: "update" as const };
592 }
593 }
594 // The code hash depends only on statically-known props — never
595 // gate it on the WHOLE props being resolved: an unresolved
596 // reference elsewhere in props would make code-only changes
597 // silently noop (see the same pattern in Service.ts). By diff
598 // time the effect-config form has been evaluated, so the object
599 // view is safe to read.
600 const statics = news as Partial<Pick<SpriteProps, "main" | "port">>;
601 if (isResolved({ main: statics.main, port: statics.port })) {
602 const hash = yield* hosted.hash(news as HostedProgramProps);
603 if (hash !== output.code.hash) {
604 return { action: "update" as const };
605 }
606 }
607 return undefined;
608 }),
609
610 read: Effect.fn(function* ({ id, olds, output }) {
611 const name = yield* resolveSpriteName(id, olds?.name, output?.name);
612 const found = yield* (
613 output?.name !== undefined
614 ? getByName(output.name)
615 : Effect.succeed(undefined)
616 ).pipe(
617 Effect.flatMap((existing) =>
618 existing !== undefined
619 ? Effect.succeed(existing)
620 : getByName(name),
621 ),
622 Effect.catchTag("SpritesNotEnabled", () =>
623 Effect.succeed(undefined),
624 ),
625 );
626 if (found === undefined) return undefined;
627 const attrs = toAttrs(found, name, output?.code.hash ?? "");
628 if (output !== undefined) return attrs;
629 return isOwnedSprite(found) ? attrs : Unowned(attrs);
630 }),
631
632 list: Effect.fn(function* () {
633 const items = yield* sprites.listSprites
634 .items({ max_results: 50 })
635 .pipe(
636 Stream.runCollect,
637 Effect.map((chunk) => Array.from(chunk)),
638 Effect.catchTag("SpritesNotEnabled", () => Effect.succeed([])),
639 );
640 return items
641 .filter(isOwnedSprite)
642 .map((sprite) => toAttrs(sprite, sprite.name ?? "", ""));
643 }),
644
645 reconcile: Effect.fn(function* ({
646 id,
647 news,
648 output,
649 bindings,
650 session,
651 }) {
652 const props = news;
653 const name = yield* resolveSpriteName(id, props.name, output?.name);
654 const urlAuth = props.urlAuth ?? DEFAULT_URL_AUTH;
655 const port = props.port ?? DEFAULT_PORT;
656 const labels = yield* ownershipLabels(id);
657 const boundEnv = collectBindingEnv(bindings ?? []);
658 const env = desiredEnv(props, boundEnv, hosted.alchemyEnv, port);
659
660 let current =
661 output?.name !== undefined
662 ? yield* getByName(output.name)
663 : undefined;
664 if (current === undefined && output?.name !== name) {
665 current = yield* getByName(name);
666 }
667
668 if (current === undefined) {
669 yield* sprites
670 .createSprite({
671 name,
672 url_settings: { auth: urlAuth },
673 labels,
674 })
675 .pipe(Effect.catchTag("Conflict", () => Effect.void));
676 current = yield* waitForSprite(name).pipe(
677 Effect.catchTag("Fly.SpritePending", () => getByName(name)),
678 );
679 }
680
681 if (current === undefined) {
682 return yield* new SpriteNotCreated({ name });
683 }
684 // Narrowed once so the `NotFound` fallbacks below keep the
685 // non-optional type instead of widening back to `| undefined`.
686 let sprite = current;
687
688 const observedAuth = toUrlAuth(sprite.url_settings?.auth);
689 if (observedAuth !== urlAuth) {
690 sprite = yield* sprites
691 .updateSprite({
692 name,
693 url_settings: { auth: urlAuth },
694 })
695 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(sprite)));
696 }
697
698 const codeHash = yield* hosted.hash(props);
699 if (codeHash !== output?.code.hash) {
700 const bundled = yield* hosted.bundleProgram(props);
701 yield* deployProgram({
702 name,
703 files: bundled.files,
704 env,
705 port,
706 session,
707 });
708 sprite = (yield* getByName(name)) ?? sprite;
709 }
710
711 return toAttrs(sprite, name, codeHash);
712 }),
713
714 delete: Effect.fn(function* ({ output }) {
715 if (output.name.length === 0) return;
716 yield* sprites
717 .deleteSprite({ name: output.name })
718 .pipe(
719 Effect.catchTag(
720 ["NotFound", "SpritesNotEnabled"],
721 () => Effect.void,
722 ),
723 );
724 }),
725 });
726 }),
727 );