1import type { FlyMachineService } from "@distilled.cloud/fly-io/machines";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import type * as rolldown from "rolldown";
8import * as Bundle from "../Bundle/Bundle.ts";
9import {
10 matchesPackageRoot,
11 normalizeInstallTargets,
12 resolvePackageInstallIdentity,
13 type PackageInstall,
14} from "../Bundle/InstalledPackages.ts";
15import {
16 findCwdForBundle,
17 getStableContextDir,
18 resolveMainPath,
19} from "../Bundle/TempRoot.ts";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
22import type { Docker } from "../Docker/Docker.ts";
23import type { ResourceBinding } from "../Resource.ts";
24import {
25 createContainerRuntimeContext,
26 type HostRuntimeContext,
27} from "../Server/Process.ts";
28import {
29 copyExtraFiles,
30 contextRootOf,
31 extraFileDestination,
32 hashExtraFiles,
33 isContextRootDest,
34 posixRelUnder,
35 resolveExtraSource,
36 type ExtraFile,
37} from "../Util/extraFiles.ts";
38import { sha256, sha256Object } from "../Util/sha256.ts";
39import type { BoundTarget, DiskSpec, ServiceBinding } from "./MountVolume.ts";
40import { safeHttpEffect } from "../Http.ts";
41import { rpcMethodsOf, serveFlyRpc } from "./rpc.ts";
42
43export type FlyHostRuntimeContext = HostRuntimeContext;
44
45/**
46 * Container runtime context for Fly hosts. It also serves the program's RPC
47 * methods to bound callers (see {@link serveFlyRpc}) and boots the HTTP
48 * server for a program that exposes methods but no `fetch`.
49 */
50export const createFlyHostRuntimeContext =
51 (type: string) =>
52 (id: string): HostRuntimeContext => {
53 const base = createContainerRuntimeContext(type)(id);
54 const serveBase = base.serve;
55 const serve: HostRuntimeContext["serve"] = (handler, options) => {
56 const shape = options?.shape;
57 const methods = rpcMethodsOf(shape);
58 // `safeHttpEffect` also resolves a `fetch` given as an Effect that
59 // builds the handler.
60 const wrapped = serveFlyRpc(methods, safeHttpEffect(handler));
61 const boots =
62 shape === undefined ||
63 shape.fetch !== undefined ||
64 Object.keys(methods).length > 0;
65 return serveBase(
66 wrapped,
67 boots
68 ? { shape: { ...shape, fetch: shape?.fetch ?? wrapped } }
69 : options,
70 ) as Effect.Effect<void, never, any>;
71 };
72 return Object.assign(base, { serve });
73 };
74
75export const FLY_REGISTRY = "registry.fly.io";
76export const DEFAULT_BASE_IMAGE = "node:26-slim";
77export const DEFAULT_PORT = 3000;
78export const MACHINE_PLATFORM = "linux/amd64";
79
80export interface FlyBuildOptions extends Bundle.BundleConfig {
81 /**
82 * Native or Node-only packages to install into the Machine image with
83 * `npm install` instead of bundling them. `pg` is CommonJS: Rolldown's
84 * interop turns `Client` into a namespace (`The superclass is not a
85 * constructor`). Same `build.install` shape as Lambda.
86 *
87 * @example
88 * ```typescript
89 * build: { install: ["pg"] }
90 * ```
91 */
92 readonly install?: PackageInstall;
93}
94
95export type { ExtraFile };
96
97export interface HostedProgramProps {
98 main: string;
99 handler?: string;
100 port?: number;
101 image?: string;
102 env?: Record<string, any>;
103 isExternal?: boolean;
104 build?: FlyBuildOptions;
105 /**
106 * Extra host directories baked into the image (framework client
107 * assets, Next.js `.next`, …). Hashed into `code.hash` so asset
108 * changes rebuild the image.
109 */
110 extraFiles?: ReadonlyArray<ExtraFile>;
111}
112
113export { extraFileDestination };
114
115const matchesConfiguredExternal = (
116 external: rolldown.InputOptions["external"],
117 moduleId: string,
118 parentId: string | undefined,
119 isResolved: boolean,
120): boolean => {
121 if (external === undefined) return false;
122 if (typeof external === "function") {
123 return external(moduleId, parentId, isResolved) === true;
124 }
125 const matchers = Array.isArray(external) ? external : [external];
126 return matchers.some((matcher) =>
127 typeof matcher === "string" ? matcher === moduleId : matcher.test(moduleId),
128 );
129};
130
131export class DeployTokenMissing extends Data.TaggedError(
132 "Fly.DeployTokenMissing",
133)<{
134 appName: string;
135}> {}
136
137/**
138 * The generated entry for `Fly.Service` / `Fly.Sprite` machines: a shim
139 * importing only `alchemy/Runtime/Bootstrap/Fly` plus the user's `main` —
140 * see that module for why the entry never imports alchemy's own
141 * dependencies. The runtime flag is raised BEFORE the user's module is
142 * evaluated (hence the dynamic import) so its module-scope code sees it.
143 */
144const makeBunBootstrap =
145 (handler: string) =>
146 (importPath: string): string =>
147 `
148import { bootstrap } from "alchemy/Runtime/Bootstrap/Fly";
149
150globalThis.__ALCHEMY_RUNTIME__ = true;
151const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)});
152
153await bootstrap(entrypoint);
154`;
155
156/** Unwrap env inputs while preserving serialized RuntimeContext markers. */
157export const plainEnvValue = (value: unknown): string | undefined => {
158 if (value === undefined || value === null) return undefined;
159 if (Redacted.isRedacted(value)) return plainEnvValue(Redacted.value(value));
160 if (typeof value === "string") {
161 return value.length > 0 ? value : undefined;
162 }
163 if (typeof value === "number" || typeof value === "boolean") {
164 return String(value);
165 }
166 return undefined;
167};
168
169export const toEnvRecord = (
170 env: Record<string, any> | undefined,
171): Record<string, string> =>
172 Object.fromEntries(
173 Object.entries(env ?? {}).flatMap(([key, value]) => {
174 const raw = plainEnvValue(value);
175 return raw === undefined ? [] : [[key, raw]];
176 }),
177 );
178
179const coerceBindingName = (value: unknown): string | undefined => {
180 const direct = plainEnvValue(value);
181 if (direct !== undefined) return direct;
182 if (value != null && typeof value === "object") {
183 const record = value as { name?: unknown; id?: unknown; addOnId?: unknown };
184 return (
185 coerceBindingName(record.name) ??
186 coerceBindingName(record.id) ??
187 coerceBindingName(record.addOnId)
188 );
189 }
190 return undefined;
191};
192
193export const collectBindingState = (
194 bindings: readonly ResourceBinding<ServiceBinding>[],
195) => {
196 const active = bindings.filter(
197 (binding: ResourceBinding<ServiceBinding> & { action?: string }) =>
198 binding.action !== "delete",
199 );
200 const env = toEnvRecord(
201 active
202 .map((binding) => binding?.data?.env)
203 .reduce<Record<string, any>>((acc, value) => ({ ...acc, ...value }), {}),
204 );
205 const mounts: DiskSpec[] = [];
206 const seen = new Set<string>();
207 const redis: { name: string; id?: string }[] = [];
208 const buckets: {
209 name: string;
210 id?: string;
211 values?: Record<string, string>;
212 }[] = [];
213 const postgres: { clusterId: string; variableName?: string }[] = [];
214 const targets: BoundTarget[] = [];
215 for (const binding of active) {
216 const target = binding?.data?.target;
217 if (target !== undefined) targets.push(target);
218 for (const mount of binding?.data?.mounts ?? []) {
219 if (seen.has(mount.path)) continue;
220 seen.add(mount.path);
221 mounts.push(mount);
222 }
223 const attached = binding?.data?.redis;
224 const redisName = coerceBindingName(attached?.name);
225 const redisId = coerceBindingName(attached?.id);
226 if (redisName !== undefined || redisId !== undefined) {
227 redis.push({
228 name: redisName ?? "",
229 id: redisId,
230 });
231 }
232 const bucket = binding?.data?.bucket as
233 | {
234 name?: unknown;
235 id?: unknown;
236 addOnId?: unknown;
237 bucketName?: unknown;
238 accessKeyId?: unknown;
239 secretAccessKey?: unknown;
240 endpoint?: unknown;
241 region?: unknown;
242 }
243 | undefined;
244 const bucketName = coerceBindingName(bucket?.name);
245 const bucketId =
246 coerceBindingName(bucket?.id) ?? coerceBindingName(bucket?.addOnId);
247 if (bucketName !== undefined || bucketId !== undefined) {
248 // The bound Bucket's attributes carry the create-time Tigris
249 // credentials, which Fly's add-on lookup often returns empty.
250 const values: Record<string, string> = {};
251 for (const [key, value] of [
252 ["BUCKET_NAME", bucket?.bucketName],
253 ["AWS_ACCESS_KEY_ID", bucket?.accessKeyId],
254 ["AWS_SECRET_ACCESS_KEY", bucket?.secretAccessKey],
255 ["AWS_ENDPOINT_URL_S3", bucket?.endpoint],
256 ["AWS_REGION", bucket?.region],
257 ] as const) {
258 const plain = plainEnvValue(value);
259 if (plain !== undefined) values[key] = plain;
260 }
261 buckets.push({
262 name: bucketName ?? "",
263 id: bucketId,
264 values,
265 });
266 }
267 const pg = binding?.data?.postgres;
268 const clusterId = coerceBindingName(pg?.clusterId);
269 if (clusterId !== undefined) {
270 postgres.push({
271 clusterId,
272 variableName:
273 typeof pg?.variableName === "string" && pg.variableName.length > 0
274 ? pg.variableName
275 : undefined,
276 });
277 }
278 }
279 return { env, mounts, redis, buckets, postgres, targets };
280};
281
282/**
283 * HTTP 80 → HTTPS redirect plus HTTPS 443 for public Services. Private
284 * (Flycast-only) Services publish plain HTTP on 80: Fly issues no
285 * certificate for `.flycast`, so a redirect to HTTPS would break callers.
286 */
287export const defaultHttpServices = (
288 port: number,
289 count = 1,
290 isPublic = true,
291): FlyMachineService[] => [
292 {
293 protocol: "tcp",
294 internal_port: port,
295 autostart: true,
296 autostop: "off",
297 min_machines_running: count,
298 ports: isPublic
299 ? [
300 { port: 80, handlers: ["http"], force_https: true },
301 { port: 443, handlers: ["tls", "http"] },
302 ]
303 : [{ port: 80, handlers: ["http"] }],
304 // Wait until the process is listening before the proxy sends traffic.
305 // Without this, fly.dev hangs (status 0) while Node is still booting.
306 checks: [
307 {
308 type: "tcp",
309 port,
310 interval: "10s",
311 timeout: "2s",
312 grace_period: "30s",
313 },
314 ],
315 },
316];
317
318const sanitizeImageRepo = (id: string): string => {
319 const lowered = id
320 .toLowerCase()
321 .replace(/[^a-z0-9_.-]+/g, "-")
322 .replace(/^-+|-+$/g, "");
323 return lowered.length === 0 ? "service" : lowered;
324};
325
326const generateDockerfile = (
327 props: HostedProgramProps,
328 hasChunks: boolean,
329 install?: Record<string, string>,
330 entryRel?: string,
331) => {
332 const port = props.port ?? DEFAULT_PORT;
333 const lines = [`FROM ${props.image ?? DEFAULT_BASE_IMAGE}`, `WORKDIR /app`];
334 if (install !== undefined && Object.keys(install).length > 0) {
335 lines.push(
336 `COPY package.json /app/package.json`,
337 `RUN npm install --omit=dev --no-fund --no-audit`,
338 );
339 }
340 if (props.isExternal === true) {
341 lines.push(`COPY . /app`);
342 const entry =
343 entryRel !== undefined && entryRel.length > 0
344 ? entryRel
345 : "serve-node.mjs";
346 lines.push(
347 `ENV PORT=${String(port)}`,
348 `ENV HOST=0.0.0.0`,
349 `EXPOSE ${String(port)}`,
350 `ENTRYPOINT ["node", ${JSON.stringify(`/app/${entry}`)}]`,
351 );
352 return `${lines.join("\n")}\n`;
353 }
354 lines.push(`COPY index.mjs /app/index.mjs`);
355 if (hasChunks) {
356 lines.push(`COPY *.js /app/`);
357 }
358 const seen = new Set<string>();
359 for (const extra of props.extraFiles ?? []) {
360 const dest = extraFileDestination(extra.dest);
361 if (isContextRootDest(dest) || seen.has(dest)) continue;
362 seen.add(dest);
363 lines.push(`COPY ${dest} /app/${dest}`);
364 }
365 lines.push(
366 `ENV PORT=${String(port)}`,
367 `ENV HOST=0.0.0.0`,
368 `EXPOSE ${String(port)}`,
369 `ENTRYPOINT ["node", "/app/index.mjs"]`,
370 );
371 return `${lines.join("\n")}\n`;
372};
373
374const installManifest = (dependencies: Record<string, string>) =>
375 `${JSON.stringify(
376 { private: true, type: "module", dependencies },
377 null,
378 2,
379 )}\n`;
380
381export const createFlyHostedSupport = ({
382 stackName,
383 stage,
384 virtualEntryPlugin,
385 docker,
386 dotAlchemy,
387}: {
388 stackName: string;
389 stage: string;
390 virtualEntryPlugin: (
391 content: (importPath: string) => string,
392 ) => rolldown.Plugin;
393 docker: Docker["Service"];
394 dotAlchemy: string;
395}) => {
396 const alchemyEnv = {
397 ALCHEMY_STACK_NAME: stackName,
398 ALCHEMY_STAGE: stage,
399 ALCHEMY_PHASE: "runtime",
400 HOST: "0.0.0.0",
401 };
402
403 const bundleProgram = Effect.fn(function* (props: HostedProgramProps) {
404 const handler = props.handler ?? "default";
405 const realMain = yield* resolveMainPath(props.main);
406 const cwd = yield* findCwdForBundle(realMain);
407 const bootstrap = makeBunBootstrap(handler);
408 const requested = yield* normalizeInstallTargets(props.build?.install);
409 const installRoots = new Set(Object.keys(requested));
410 const configuredExternal = props.build?.input?.external;
411
412 const buildBundle = Effect.fn(function* (
413 entry: string,
414 plugins?: rolldown.RolldownPluginOption,
415 ) {
416 return yield* Bundle.build(
417 {
418 ...props.build?.input,
419 input: entry,
420 cwd,
421 platform: "node",
422 external: (moduleId, parentId, isResolved) => {
423 if (moduleId === "bun" || moduleId.startsWith("bun:")) return true;
424 for (const root of installRoots) {
425 if (matchesPackageRoot(moduleId, root)) return true;
426 }
427 return matchesConfiguredExternal(
428 configuredExternal,
429 moduleId,
430 parentId,
431 isResolved,
432 );
433 },
434 resolve: {
435 conditionNames: [...Bundle.NODE_CONDITION_NAMES],
436 ...props.build?.input?.resolve,
437 },
438 plugins: [props.build?.input?.plugins, plugins],
439 },
440 {
441 ...props.build?.output,
442 format: "esm",
443 sourcemap: props.build?.output?.sourcemap ?? false,
444 entryFileNames: "index.mjs",
445 strictExecutionOrder: true,
446 keepNames: true,
447 },
448 props.build,
449 );
450 });
451
452 if (props.isExternal === true) {
453 const fs = yield* FileSystem.FileSystem;
454 const path = yield* Path.Path;
455 const bytes = yield* fs.readFile(realMain);
456 const hash = yield* sha256(bytes);
457 return {
458 files: [{ path: path.basename(realMain), content: bytes }],
459 hash,
460 };
461 }
462
463 const bundleOutput = yield* buildBundle(
464 realMain,
465 virtualEntryPlugin(bootstrap),
466 );
467
468 const files = bundleOutput.files.map((file) => ({
469 path: file.path,
470 content:
471 typeof file.content === "string"
472 ? new TextEncoder().encode(file.content)
473 : file.content,
474 }));
475
476 return { files, hash: bundleOutput.hash };
477 });
478
479 const computeCodeHash = Effect.fn(function* (props: HostedProgramProps) {
480 const bundled = yield* bundleProgram(props);
481 const realMain = yield* resolveMainPath(props.main);
482 const cwd = yield* findCwdForBundle(realMain);
483 const path = yield* Path.Path;
484 const requested = yield* normalizeInstallTargets(props.build?.install);
485 const identity =
486 Object.keys(requested).length > 0
487 ? yield* resolvePackageInstallIdentity({ cwd, requested })
488 : undefined;
489 const install =
490 identity !== undefined && Object.keys(identity.resolved).length > 0
491 ? identity.resolved
492 : undefined;
493 const packageJson =
494 install === undefined ? undefined : installManifest(install);
495 const extras = (props.extraFiles ?? []).map((file) => ({
496 source: file.source,
497 dest: file.dest,
498 }));
499 const root = contextRootOf(realMain, extras, path, (source) =>
500 resolveExtraSource(source, path),
501 );
502 const entryRel =
503 props.isExternal === true
504 ? (posixRelUnder(root, realMain, path) ?? path.basename(realMain))
505 : undefined;
506 const dockerfile = generateDockerfile(
507 props,
508 bundled.files.length > 1,
509 install,
510 entryRel,
511 );
512 const extraFiles = yield* hashExtraFiles(props.extraFiles);
513 const codeHash = (yield* sha256Object({
514 bundleHash: bundled.hash,
515 dockerfile,
516 packageJson,
517 extraFiles,
518 extraFilesIncludesNodeModules: true,
519 })).slice(0, 16);
520 return { bundled, dockerfile, codeHash, packageJson, entryRel };
521 });
522
523 const imageExists = (imageRef: string) =>
524 docker.image.inspect(imageRef).pipe(
525 Effect.map(() => true),
526 Effect.catchReason("PlatformError", "NotFound", () =>
527 Effect.succeed(false),
528 ),
529 );
530
531 const pushBackoff = Schedule.exponential("2 seconds");
532
533 /**
534 * Bundle `main`, content-hash the image, build it when missing, and
535 * push to `registry.fly.io/{appName}:{logicalId}-{hash}` using an
536 * app deploy token. When `previousHash` matches, skip build and push.
537 *
538 * Fly's registry is app-scoped (`/v2/{app}/...`). A nested
539 * `{app}/{image}` repository 404s on blob upload.
540 */
541 const resolveImage = Effect.fn(function* (input: {
542 id: string;
543 appName: string;
544 props: HostedProgramProps;
545 previousHash?: string;
546 session?: { note: (message: string) => Effect.Effect<void> };
547 }) {
548 const note = input.session?.note ?? ((_message: string) => Effect.void);
549 yield* note(`Bundling ${input.id} program...`);
550 const { bundled, dockerfile, codeHash, packageJson } =
551 yield* computeCodeHash(input.props);
552 yield* note(`Hashed ${input.id} (${codeHash})`);
553 const repo = sanitizeImageRepo(input.id);
554 const imageRef = `${FLY_REGISTRY}/${input.appName}:${repo}-${codeHash}`;
555
556 if (input.previousHash === codeHash) {
557 return { imageRef, codeHash };
558 }
559
560 if (!(yield* imageExists(imageRef))) {
561 const realMain = yield* resolveMainPath(input.props.main);
562 const contextDir = yield* getStableContextDir(
563 realMain,
564 dotAlchemy,
565 `${input.id}-image`,
566 );
567 const fs = yield* FileSystem.FileSystem;
568 const path = yield* Path.Path;
569 const files =
570 input.props.isExternal === true
571 ? packageJson !== undefined
572 ? [
573 {
574 path: "package.json",
575 content: new TextEncoder().encode(packageJson),
576 },
577 ]
578 : []
579 : [
580 ...bundled.files.map((file, index) => ({
581 path: index === 0 ? "index.mjs" : file.path,
582 content: file.content,
583 })),
584 ...(packageJson !== undefined
585 ? [
586 {
587 path: "package.json",
588 content: new TextEncoder().encode(packageJson),
589 },
590 ]
591 : []),
592 ];
593 yield* docker.materialize({
594 context: contextDir,
595 dockerfile,
596 files,
597 });
598 yield* copyExtraFiles(contextDir, input.props.extraFiles);
599 if (input.props.isExternal === true) {
600 const extras = (input.props.extraFiles ?? []).map((file) => ({
601 source: file.source,
602 dest: file.dest,
603 }));
604 const root = contextRootOf(realMain, extras, path, (source) =>
605 resolveExtraSource(source, path),
606 );
607 const entryRel =
608 posixRelUnder(root, realMain, path) ?? path.basename(realMain);
609 const dest = path.join(contextDir, entryRel);
610 if (!(yield* fs.exists(dest).pipe(Effect.orElseSucceed(() => false)))) {
611 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
612 yield* fs.copy(realMain, dest, { overwrite: true });
613 }
614 }
615 yield* note(`Building container image ${imageRef}...`);
616 yield* docker.image.build({
617 context: contextDir,
618 tag: imageRef,
619 platform: MACHINE_PLATFORM,
620 });
621 yield* note(`Built ${imageRef}`);
622 }
623
624 const minted = yield* machines.createAppDeployToken({
625 app_name: input.appName,
626 });
627 const token = minted.token;
628 if (token === undefined || token.length === 0) {
629 return yield* new DeployTokenMissing({ appName: input.appName });
630 }
631
632 yield* note(`Pushing ${imageRef}...`);
633 yield* docker.image
634 .push(imageRef, {
635 server: FLY_REGISTRY,
636 username: "x",
637 password: Redacted.make(token),
638 })
639 .pipe(
640 Effect.retry({
641 times: 3,
642 schedule: pushBackoff,
643 }),
644 );
645 yield* note(`Pushed ${imageRef}`);
646 return { imageRef, codeHash };
647 });
648
649 const hash = Effect.fn(function* (props: HostedProgramProps) {
650 const { codeHash } = yield* computeCodeHash(props);
651 return codeHash;
652 });
653
654 return {
655 alchemyEnv,
656 bundleProgram,
657 computeCodeHash,
658 resolveImage,
659 hash,
660 };
661};
662
663/**
664 * Bundle a Sprite program the same way {@link createFlyHostedSupport}
665 * bundles a Service — rolldown + Node bootstrap — without building a
666 * Docker image. The provider writes the files onto the Sprite.
667 */
668export const createSpriteHostedSupport = ({
669 stackName,
670 stage,
671 virtualEntryPlugin,
672}: {
673 stackName: string;
674 stage: string;
675 virtualEntryPlugin: (
676 content: (importPath: string) => string,
677 ) => rolldown.Plugin;
678}) => {
679 const alchemyEnv = {
680 ALCHEMY_STACK_NAME: stackName,
681 ALCHEMY_STAGE: stage,
682 ALCHEMY_PHASE: "runtime",
683 HOST: "0.0.0.0",
684 };
685
686 const bundleProgram = Effect.fn(function* (props: HostedProgramProps) {
687 const handler = props.handler ?? "default";
688 const realMain = yield* resolveMainPath(props.main);
689 const cwd = yield* findCwdForBundle(realMain);
690 const bootstrap = makeBunBootstrap(handler);
691
692 const buildBundle = Effect.fn(function* (
693 entry: string,
694 plugins?: rolldown.RolldownPluginOption,
695 ) {
696 return yield* Bundle.build(
697 {
698 ...props.build?.input,
699 input: entry,
700 cwd,
701 platform: "node",
702 external: [
703 "bun",
704 "bun:*",
705 ...((props.build?.input?.external as string[] | undefined) ?? []),
706 ],
707 resolve: {
708 conditionNames: [...Bundle.NODE_CONDITION_NAMES],
709 ...props.build?.input?.resolve,
710 },
711 plugins: [props.build?.input?.plugins, plugins],
712 },
713 {
714 ...props.build?.output,
715 format: "esm",
716 sourcemap: props.build?.output?.sourcemap ?? false,
717 minify: props.build?.output?.minify ?? false,
718 entryFileNames: "index.mjs",
719 },
720 props.build,
721 );
722 });
723
724 const bundleOutput = props.isExternal
725 ? yield* buildBundle(realMain)
726 : yield* buildBundle(realMain, virtualEntryPlugin(bootstrap));
727
728 const files = bundleOutput.files.map((file) => ({
729 path: file.path,
730 content:
731 typeof file.content === "string"
732 ? new TextEncoder().encode(file.content)
733 : file.content,
734 }));
735
736 return { files, hash: bundleOutput.hash };
737 });
738
739 const hash = Effect.fn(function* (props: HostedProgramProps) {
740 const bundled = yield* bundleProgram(props);
741 return (yield* sha256Object({ bundleHash: bundled.hash })).slice(0, 16);
742 });
743
744 return { alchemyEnv, bundleProgram, hash };
745};