1import { createRequire } from "node:module";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Redacted from "effect/Redacted";
7import type * as rolldown from "rolldown";
8import * as Bundle from "../Bundle/Bundle.ts";
9import {
10 matchesPackageRoot,
11 normalizeInstallTargets,
12 parsePackageRootFromSpecifier,
13 resolvePackageInstallIdentity,
14 type PackageInstall,
15} from "../Bundle/InstalledPackages.ts";
16import {
17 findCwdForBundle,
18 getStableContextDir,
19 resolveMainPath,
20} from "../Bundle/TempRoot.ts";
21import type { ResourceBinding } from "../Resource.ts";
22import { safeHttpEffect } from "../Http.ts";
23import { Self } from "../Self.ts";
24import {
25 createContainerRuntimeContext,
26 type HostRuntimeContext,
27} from "../Server/Process.ts";
28import * as HttpServerRequest from "effect/http/HttpServerRequest";
29import * as HttpServerResponse from "effect/http/HttpServerResponse";
30import {
31 copyExtraFiles,
32 contextRootOf,
33 extraFileDestination,
34 hashExtraFiles,
35 isContextRootDest,
36 posixRelUnder,
37 resolveExtraSource,
38 type ExtraFile,
39} from "../Util/extraFiles.ts";
40import { sha256, sha256Object } from "../Util/sha256.ts";
41import type { MountSpec, ServiceBinding } from "./MountVolume.ts";
42
43export type RailwayHostRuntimeContext = HostRuntimeContext;
44
45/**
46 * Container host. RPC wrapping lives in `Service.ts` so canvas Functions
47 * do not pay for `rpc-server.ts` in the 96KB start command. The generated
48 * Service entry is a shim that imports `alchemy/Runtime/Bootstrap/Railway`
49 * (Node HTTP) plus the user's `main` — see that module for why.
50 */
51export const createRailwayHostRuntimeContext = createContainerRuntimeContext;
52
53/**
54 * Function runtime: register the fetch handler on `globalThis` instead of
55 * booting an HTTP server. The canvas wrapper `Bun.serve`s first so a
56 * failed Effect import cannot 502 before the process is bound.
57 */
58export const createRailwayFunctionRuntimeContext =
59 (type: string) =>
60 (id: string): HostRuntimeContext => {
61 const base = createContainerRuntimeContext(type)(id);
62 return {
63 ...base,
64 serve: ((handler, options) =>
65 Effect.sync(() => {
66 if (!globalThis.__ALCHEMY_RUNTIME__) return;
67 const run = safeHttpEffect(
68 (globalThis as any).__R?.(options?.shape, handler) ?? handler,
69 );
70 (
71 globalThis as typeof globalThis & {
72 __aFF?: (request: Request) => Promise<Response>;
73 }
74 ).__aFF = async (request: Request) => {
75 try {
76 const response = await Effect.runPromise(
77 run.pipe(
78 Effect.provideService(
79 HttpServerRequest.HttpServerRequest,
80 HttpServerRequest.fromWeb(request),
81 ),
82 Effect.scoped,
83 ),
84 );
85 return HttpServerResponse.toWeb(response);
86 } catch (error) {
87 return new Response(String(error), { status: 500 });
88 }
89 };
90 })) as HostRuntimeContext["serve"],
91 exports: Effect.sync(() => ({
92 program: Effect.void,
93 })),
94 } as HostRuntimeContext;
95 };
96
97export const DEFAULT_BASE_IMAGE = "node:26-slim";
98export const DEFAULT_PORT = 3000;
99
100export interface RailwayBuildOptions extends Bundle.BundleConfig {
101 /**
102 * Native or Node-only packages to install into the image with
103 * `npm install` instead of bundling them. `pg` is CommonJS: Rolldown's
104 * interop turns `Client` into a namespace (`The superclass is not a
105 * constructor`). Same `build.install` shape as Lambda / Fly.
106 *
107 * @example
108 * ```typescript
109 * build: { install: ["pg"] }
110 * ```
111 */
112 readonly install?: PackageInstall;
113}
114
115export type { ExtraFile };
116
117export interface HostedProgramProps {
118 main: string;
119 handler?: string;
120 port?: number;
121 /**
122 * Dockerfile `FROM` for the Effect-native image. Ignored for the
123 * public-image path (`props.image` without `main`).
124 *
125 * @default "node:26-slim"
126 */
127 image?: string;
128 env?: Record<string, any>;
129 isExternal?: boolean;
130 build?: RailwayBuildOptions;
131 /**
132 * Extra files/directories copied into `/app` after the bundled entry.
133 * Hashed into `code.hash` so asset-only changes rebuild the image.
134 */
135 extraFiles?: ReadonlyArray<ExtraFile>;
136}
137
138const matchesConfiguredExternal = (
139 external: rolldown.InputOptions["external"],
140 moduleId: string,
141 parentId: string | undefined,
142 isResolved: boolean,
143): boolean => {
144 if (external === undefined) return false;
145 if (typeof external === "function") {
146 return external(moduleId, parentId, isResolved) === true;
147 }
148 const matchers = Array.isArray(external) ? external : [external];
149 return matchers.some((matcher) =>
150 typeof matcher === "string" ? matcher === moduleId : matcher.test(moduleId),
151 );
152};
153
154export class ExtraFileMissing extends Data.TaggedError(
155 "Railway.ExtraFileMissing",
156)<{
157 source: string;
158 dest: string;
159}> {}
160
161const dockerfileCopyLines = (files: ReadonlyArray<ExtraFile> | undefined) =>
162 (files ?? []).flatMap((file) => {
163 const dest = extraFileDestination(file.dest);
164 if (isContextRootDest(dest)) return [];
165 return [`COPY ${dest} /app/${dest}`];
166 });
167
168/**
169 * The generated entry for `Railway.Service` containers: a shim importing
170 * only `alchemy/Runtime/Bootstrap/Railway` plus the user's `main` — see
171 * that module for why the entry never imports alchemy's own dependencies.
172 */
173const makeNodeBootstrap =
174 (handler: string) =>
175 (importPath: string): string =>
176 `
177import { bootstrap } from "alchemy/Runtime/Bootstrap/Railway";
178
179globalThis.__ALCHEMY_RUNTIME__ = true;
180const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)});
181
182await bootstrap(entrypoint);
183`;
184
185/**
186 * Inner Function module: builds the class layer so `serve` registers
187 * `globalThis.__aFF`. The canvas wrapper listens first, then import()s
188 * this file.
189 */
190const makeFunctionBootstrap =
191 (handler: string) =>
192 (importPath: string): string =>
193 `
194import { makeEntrypointLayer, reifyBoundConfigProvider } from "alchemy/Runtime";
195import { Stack } from "alchemy/Stack";
196import * as Config from "effect/Config";
197import * as ConfigProvider from "effect/ConfigProvider";
198import * as Context from "effect/Context";
199import * as Effect from "effect/Effect";
200import * as Layer from "effect/Layer";
201import * as FetchHttpClient from "effect/http/FetchHttpClient";
202
203globalThis.__ALCHEMY_RUNTIME__ = true;
204const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)});
205const tag = Context.Service(${JSON.stringify(Self.key)});
206const layer = makeEntrypointLayer(tag, entrypoint);
207const platform = FetchHttpClient.layer;
208const stack = Layer.effect(
209 Stack,
210 Effect.all([
211 Config.String("ALCHEMY_STACK_NAME"),
212 Config.String("ALCHEMY_STAGE")
213 ]).pipe(
214 Effect.map(([name, stage]) => ({
215 name,
216 stage,
217 bindings: {},
218 resources: {}
219 }))
220 )
221);
222await Effect.runPromise(
223 tag.pipe(
224 Effect.provide(
225 layer.pipe(
226 Layer.provideMerge(stack),
227 Layer.provideMerge(platform),
228 Layer.provideMerge(
229 Layer.succeed(
230 ConfigProvider.ConfigProvider,
231 reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env)
232 )
233 ),
234 )
235 )
236 )
237);
238`;
239
240/** Flatten a binding/env leaf into an env string. Unwraps Redacted. */
241export const plainEnvValue = (value: unknown): string | undefined => {
242 if (value === undefined || value === null) return undefined;
243 if (Redacted.isRedacted(value)) return plainEnvValue(Redacted.value(value));
244 if (typeof value === "string") {
245 if (value.startsWith("{")) {
246 try {
247 const parsed: unknown = JSON.parse(value);
248 if (
249 typeof parsed === "object" &&
250 parsed !== null &&
251 (parsed as { _tag?: unknown })._tag === "Redacted" &&
252 typeof (parsed as { value?: unknown }).value === "string"
253 ) {
254 const inner = (parsed as { value: string }).value;
255 return inner.length > 0 ? inner : undefined;
256 }
257 } catch {
258 // plain string that happens to start with `{`
259 }
260 }
261 return value.length > 0 ? value : undefined;
262 }
263 if (typeof value === "number" || typeof value === "boolean") {
264 return String(value);
265 }
266 return undefined;
267};
268
269export const toEnvRecord = (
270 env: Record<string, any> | undefined,
271): Record<string, string> =>
272 Object.fromEntries(
273 Object.entries(env ?? {}).flatMap(([key, value]) => {
274 const raw = plainEnvValue(value);
275 return raw === undefined ? [] : [[key, raw]];
276 }),
277 );
278
279const coerceBindingId = (value: unknown): string | undefined => {
280 const direct = plainEnvValue(value);
281 if (direct !== undefined) return direct;
282 if (value != null && typeof value === "object") {
283 const record = value as { volumeId?: unknown; id?: unknown };
284 return coerceBindingId(record.volumeId) ?? coerceBindingId(record.id);
285 }
286 return undefined;
287};
288
289export const collectBindingState = (
290 bindings: readonly ResourceBinding<ServiceBinding>[],
291) => {
292 const active = bindings.filter(
293 (binding: ResourceBinding<ServiceBinding> & { action?: string }) =>
294 binding.action !== "delete",
295 );
296 const env = toEnvRecord(
297 active
298 .map((binding) => binding?.data?.env)
299 .reduce<Record<string, any>>((acc, value) => ({ ...acc, ...value }), {}),
300 );
301 const mounts: MountSpec[] = [];
302 const seen = new Set<string>();
303 for (const binding of active) {
304 for (const mount of binding?.data?.mounts ?? []) {
305 const volumeId = coerceBindingId(mount.volumeId);
306 if (volumeId === undefined) continue;
307 const key = `${volumeId}:${mount.path}`;
308 if (seen.has(key)) continue;
309 seen.add(key);
310 mounts.push({ volumeId, path: mount.path });
311 }
312 }
313 return { env, mounts };
314};
315
316export class FunctionBundleNotSingleFile extends Data.TaggedError(
317 "Railway.FunctionBundleNotSingleFile",
318)<{
319 files: readonly string[];
320}> {}
321
322const decodeBundleText = (content: string | Uint8Array): string =>
323 typeof content === "string" ? content : new TextDecoder().decode(content);
324
325const IMPORT_SPEC =
326 /(?:from|import)\s*\(\s*["']([^"']+)["']|(?:from|import)\s+["']([^"']+)["']/g;
327
328const CANVAS_PINNED = new Set(["effect", "@effect/platform-bun"]);
329
330/** Packages Railway must install; `@effect/platform-node` is stubbed. */
331const skipCanvasPin = (root: string): boolean =>
332 CANVAS_PINNED.has(root) ||
333 root === "alchemy" ||
334 root.startsWith("@effect/platform-node") ||
335 root.startsWith("@distilled.cloud/");
336
337const collectCanvasPackageRoots = (
338 source: string,
339 install: Readonly<Record<string, string>>,
340): string[] => {
341 const roots = new Set<string>(Object.keys(install));
342 for (const match of source.matchAll(IMPORT_SPEC)) {
343 const spec = match[1] ?? match[2];
344 if (spec === undefined) continue;
345 const root = parsePackageRootFromSpecifier(spec);
346 if (root !== undefined && !skipCanvasPin(root)) roots.add(root);
347 }
348 return [...roots].sort();
349};
350
351const readPackageVersion = (name: string): string | undefined => {
352 try {
353 const req = createRequire(import.meta.url);
354 const version = (req(`${name}/package.json`) as { version?: string })
355 .version;
356 return typeof version === "string" && version.length > 0
357 ? version
358 : undefined;
359 } catch {
360 return undefined;
361 }
362};
363
364const pinImport = (name: string, version?: string): string => {
365 const spec =
366 version !== undefined && version.length > 0 && version !== "*"
367 ? `${name}@${version}`
368 : name;
369 return `import ${JSON.stringify(spec)};`;
370};
371
372/**
373 * Canvas wrapper that listens first, then import()s the bundled module.
374 * Static `import "pkg@version"` pins are for Railway's AST installer —
375 * `effect` is 4.x here, 3.x on npm without a pin. Extra pins come from
376 * remaining external imports (`pg`, `drizzle-orm`, `@effect/sql-pg`).
377 */
378const wrapCanvasListener = (
379 inner: string,
380 install: Readonly<Record<string, string>> = {},
381) =>
382 Effect.sync(() => {
383 const effectVersion = readPackageVersion("effect") ?? "latest";
384 const bunVersion = readPackageVersion("@effect/platform-bun") ?? "latest";
385 const extraPins = collectCanvasPackageRoots(inner, install).map((name) => {
386 const requested = install[name];
387 const version =
388 requested !== undefined && requested !== "*"
389 ? requested
390 : readPackageVersion(name);
391 return pinImport(name, version);
392 });
393 const pins = [
394 pinImport("effect", effectVersion),
395 ...(inner.includes("@effect/platform-bun")
396 ? [pinImport("@effect/platform-bun", bunVersion)]
397 : []),
398 ...extraPins,
399 ].join("\n");
400 return `${pins}
401const g=globalThis,port=Number(process.env.PORT??3000);
402g.__aFF??=async()=>new Response("");
403Bun.serve({hostname:"0.0.0.0",port,fetch:r=>g.__aFF(r)});
404try{
405const u=new URL("./i.mjs",import.meta.url);
406await Bun.write(u,${JSON.stringify(inner)});
407await import(u.href);
408}catch(e){
409g.__aFF=async()=>new Response(String(e),{status:500});
410}
411`;
412 });
413
414/**
415 * Canvas wrapper for async (non-Effect) Functions. No Effect pin, no
416 * `__aFF` bridge — the user's `export default { fetch }` is the handler,
417 * with `process.env` as the second argument (Cloudflare `env`).
418 */
419const wrapAsyncCanvasListener = (
420 inner: string,
421 handler: string,
422 install: Readonly<Record<string, string>> = {},
423) =>
424 Effect.sync(() => {
425 const extraPins = collectCanvasPackageRoots(inner, install).map((name) => {
426 const requested = install[name];
427 const version =
428 requested !== undefined && requested !== "*"
429 ? requested
430 : readPackageVersion(name);
431 return pinImport(name, version);
432 });
433 const pins = extraPins.join("\n");
434 const handlerLit = JSON.stringify(handler);
435 return `${pins}
436const port=Number(process.env.PORT??3000);
437const fail=e=>new Response(String(e),{status:500});
438try{
439const u=new URL("./i.mjs",import.meta.url);
440await Bun.write(u,${JSON.stringify(inner)});
441const m=await import(u.href);
442const e=m[${handlerLit}];
443const f=typeof e==="function"?e:e?.fetch;
444if(typeof f!=="function")throw new Error("async Function missing fetch");
445Bun.serve({hostname:"0.0.0.0",port,fetch:r=>f(r,process.env)});
446}catch(e){
447Bun.serve({hostname:"0.0.0.0",port,fetch:()=>fail(e)});
448}
449`;
450 });
451
452const isCanvasFunctionExternal = (moduleId: string): boolean => {
453 if (moduleId.startsWith("@effect/platform-node")) return false;
454 if (moduleId === "node:os") return false;
455 return (
456 moduleId === "effect" ||
457 moduleId.startsWith("effect/") ||
458 moduleId.startsWith("@effect/") ||
459 moduleId.startsWith("node:") ||
460 moduleId === "bun" ||
461 moduleId.startsWith("bun:")
462 );
463};
464
465const STUB_PLATFORM_NODE = "virtual:alchemy-stub-platform-node";
466
467/** Functions run on Bun. Drop `@effect/platform-node` so Railway does not install it. */
468const stubPlatformNodePlugin = (): rolldown.Plugin => ({
469 name: "alchemy:stub-platform-node",
470 resolveId: {
471 filter: { id: /^@effect\/platform-node/ },
472 handler() {
473 return { id: STUB_PLATFORM_NODE, moduleSideEffects: false };
474 },
475 },
476 load: {
477 filter: { id: /^virtual:alchemy-stub-platform-node$/ },
478 handler() {
479 return { code: "export default {};\n", moduleType: "js" };
480 },
481 },
482});
483
484const STUB_NODE_OS = "virtual:alchemy-stub-node-os";
485
486const stubNodeOsPlugin = (): rolldown.Plugin => ({
487 name: "alchemy:stub-node-os",
488 resolveId: {
489 filter: { id: /^node:os$/ },
490 handler() {
491 return { id: STUB_NODE_OS, moduleSideEffects: false };
492 },
493 },
494 load: {
495 filter: { id: /^virtual:alchemy-stub-node-os$/ },
496 handler() {
497 return {
498 code: [
499 "export const homedir = () => '/';",
500 "export const cpus = () => [];",
501 "export const platform = () => 'linux';",
502 "export const arch = () => 'x64';",
503 "export const tmpdir = () => '/tmp';",
504 "export const hostname = () => 'railway';",
505 "export const type = () => 'Linux';",
506 "export const release = () => '';",
507 "export const endianness = () => 'LE';",
508 "export default { homedir, cpus, platform, arch, tmpdir, hostname, type, release, endianness };",
509 "",
510 ].join("\n"),
511 moduleType: "js",
512 };
513 },
514 },
515});
516
517const createBundleProgram = (
518 virtualEntryPlugin: (
519 content: (importPath: string) => string,
520 ) => rolldown.Plugin,
521 options?: {
522 output?: Partial<rolldown.OutputOptions>;
523 /**
524 * Extra unresolved specifiers to leave as imports. Canvas Functions
525 * auto-install npm packages; leaving `effect` external keeps the
526 * encoded start command under 96KB.
527 */
528 canvasExternals?: boolean;
529 bootstrap?: (handler: string) => (importPath: string) => string;
530 /**
531 * Bundle `main` as the entry with no virtual bootstrap. Used for
532 * async (non-Effect) canvas Functions (`isExternal`).
533 */
534 skipVirtualEntry?: boolean;
535 },
536) =>
537 Effect.fn(function* (props: HostedProgramProps) {
538 const handler = props.handler ?? "default";
539 const realMain = yield* resolveMainPath(props.main);
540 const cwd = yield* findCwdForBundle(realMain);
541 const bootstrap = (options?.bootstrap ?? makeNodeBootstrap)(handler);
542 const requested = yield* normalizeInstallTargets(props.build?.install);
543 const installRoots = new Set(Object.keys(requested));
544 const configuredExternal = props.build?.input?.external;
545 const output = options?.output;
546 const canvasExternals = options?.canvasExternals === true;
547
548 const buildBundle = Effect.fn(function* (
549 entry: string,
550 plugins?: rolldown.RolldownPluginOption,
551 ) {
552 return yield* Bundle.build(
553 {
554 ...props.build?.input,
555 input: entry,
556 cwd,
557 platform: "node",
558 external: (moduleId, parentId, isResolved) => {
559 if (moduleId === "bun" || moduleId.startsWith("bun:")) return true;
560 if (canvasExternals && isCanvasFunctionExternal(moduleId)) {
561 return true;
562 }
563 for (const root of installRoots) {
564 if (matchesPackageRoot(moduleId, root)) return true;
565 }
566 return matchesConfiguredExternal(
567 configuredExternal,
568 moduleId,
569 parentId,
570 isResolved,
571 );
572 },
573 resolve: {
574 // Hosted images run on Node but must still match the `bun`
575 // export of workspace packages (`@distilled.cloud/*`, alchemy).
576 // Those maps have no `node` condition — only `bun`/`worker`/
577 // `default` — and `default` is `lib/` which is absent in a
578 // source checkout. Leaving the specifier unresolved makes
579 // rolldown treat it as external, so the container dies with
580 // `ERR_MODULE_NOT_FOUND` (the Volume tutorial's FAILED deploy).
581 conditionNames: canvasExternals
582 ? ["bun", "import", "module", "default"]
583 : [...Bundle.BUN_CONDITION_NAMES],
584 ...props.build?.input?.resolve,
585 },
586 plugins: [
587 canvasExternals ? stubPlatformNodePlugin() : undefined,
588 canvasExternals ? stubNodeOsPlugin() : undefined,
589 props.build?.input?.plugins,
590 plugins,
591 ],
592 },
593 {
594 ...props.build?.output,
595 ...output,
596 format: "esm",
597 sourcemap:
598 output?.sourcemap ?? props.build?.output?.sourcemap ?? false,
599 entryFileNames: "index.mjs",
600 strictExecutionOrder: true,
601 keepNames: !canvasExternals,
602 },
603 props.build,
604 );
605 });
606
607 if (props.isExternal === true && options?.skipVirtualEntry !== true) {
608 const fs = yield* FileSystem.FileSystem;
609 const path = yield* Path.Path;
610 const bytes = yield* fs.readFile(realMain);
611 const hash = yield* sha256(bytes);
612 return {
613 files: [{ path: path.basename(realMain), content: bytes }],
614 hash,
615 };
616 }
617
618 const bundleOutput = yield* buildBundle(
619 realMain,
620 options?.skipVirtualEntry === true
621 ? undefined
622 : virtualEntryPlugin(bootstrap),
623 );
624
625 const files = bundleOutput.files.map((file) => ({
626 path: file.path,
627 content:
628 typeof file.content === "string"
629 ? new TextEncoder().encode(file.content)
630 : file.content,
631 }));
632
633 return { files, hash: bundleOutput.hash };
634 });
635
636/**
637 * Bundle an Effect-native Railway.Function into a single TypeScript/JS
638 * file for the canvas function runtime. No Docker. No registry.
639 */
640export const createRailwayFunctionSupport = ({
641 stackName,
642 stage,
643 virtualEntryPlugin,
644}: {
645 stackName: string;
646 stage: string;
647 virtualEntryPlugin: (
648 content: (importPath: string) => string,
649 ) => rolldown.Plugin;
650}) => {
651 const alchemyEnv = {
652 ALCHEMY_STACK_NAME: stackName,
653 ALCHEMY_STAGE: stage,
654 ALCHEMY_PHASE: "runtime",
655 HOST: "0.0.0.0",
656 };
657
658 const bundleProgram = createBundleProgram(virtualEntryPlugin, {
659 canvasExternals: true,
660 bootstrap: makeFunctionBootstrap,
661 output: {
662 // dce-only (Bundle.build default) keeps `import … from "effect/…"`
663 // readable so Railway's AST installer sees Effect 4 pins. Full
664 // minify still 502s: npm `effect` is 3.x without those subpaths.
665 codeSplitting: false,
666 },
667 });
668
669 const asyncBundleProgram = createBundleProgram(virtualEntryPlugin, {
670 canvasExternals: true,
671 skipVirtualEntry: true,
672 output: { codeSplitting: false },
673 });
674
675 const bundleToSource = Effect.fn(function* (props: HostedProgramProps) {
676 const bundled =
677 props.isExternal === true
678 ? yield* asyncBundleProgram(props)
679 : yield* bundleProgram(props);
680 if (bundled.files.length !== 1) {
681 return yield* new FunctionBundleNotSingleFile({
682 files: bundled.files.map((file) => file.path),
683 });
684 }
685 const inner = decodeBundleText(bundled.files[0]!.content);
686 const install = yield* normalizeInstallTargets(props.build?.install);
687 const source =
688 props.isExternal === true
689 ? yield* wrapAsyncCanvasListener(
690 inner,
691 props.handler ?? "default",
692 install,
693 )
694 : yield* wrapCanvasListener(inner, install);
695 const hash = yield* sha256(source);
696 return { source, hash };
697 });
698
699 const hash = Effect.fn(function* (props: HostedProgramProps) {
700 const bundled = yield* bundleToSource(props);
701 return bundled.hash;
702 });
703
704 return { alchemyEnv, bundleProgram, bundleToSource, hash };
705};
706
707const generateDockerfile = (
708 props: HostedProgramProps,
709 hasChunks: boolean,
710 install?: Record<string, string>,
711 entryRel?: string,
712) => {
713 const port = props.port ?? DEFAULT_PORT;
714 const lines = [`FROM ${props.image ?? DEFAULT_BASE_IMAGE}`, `WORKDIR /app`];
715 if (install !== undefined && Object.keys(install).length > 0) {
716 lines.push(
717 `COPY package.json /app/package.json`,
718 `RUN npm install --omit=dev --no-fund --no-audit`,
719 );
720 }
721 if (props.isExternal === true) {
722 lines.push(`COPY . /app`);
723 const entry =
724 entryRel !== undefined && entryRel.length > 0
725 ? entryRel
726 : "serve-node.mjs";
727 lines.push(
728 `ENV PORT=${String(port)}`,
729 `ENV HOST=0.0.0.0`,
730 `EXPOSE ${String(port)}`,
731 `ENTRYPOINT ["node", ${JSON.stringify(`/app/${entry}`)}]`,
732 );
733 return `${lines.join("\n")}\n`;
734 }
735 lines.push(`COPY index.mjs /app/index.mjs`);
736 if (hasChunks) {
737 lines.push(`COPY *.js /app/`);
738 }
739 lines.push(...dockerfileCopyLines(props.extraFiles));
740 lines.push(
741 `ENV PORT=${String(port)}`,
742 `ENV HOST=0.0.0.0`,
743 `EXPOSE ${String(port)}`,
744 `ENTRYPOINT ["node", "/app/index.mjs"]`,
745 );
746 return `${lines.join("\n")}\n`;
747};
748
749const installManifest = (dependencies: Record<string, string>) =>
750 `${JSON.stringify(
751 { private: true, type: "module", dependencies },
752 null,
753 2,
754 )}\n`;
755
756export const createRailwayHostedSupport = ({
757 stackName,
758 stage,
759 virtualEntryPlugin,
760 dotAlchemy,
761}: {
762 stackName: string;
763 stage: string;
764 virtualEntryPlugin: (
765 content: (importPath: string) => string,
766 ) => rolldown.Plugin;
767 dotAlchemy: string;
768}) => {
769 const alchemyEnv = {
770 ALCHEMY_STACK_NAME: stackName,
771 ALCHEMY_STAGE: stage,
772 ALCHEMY_PHASE: "runtime",
773 HOST: "0.0.0.0",
774 };
775
776 const bundleProgram = createBundleProgram(virtualEntryPlugin);
777
778 const computeCodeHash = Effect.fn(function* (props: HostedProgramProps) {
779 const bundled = yield* bundleProgram(props);
780 const realMain = yield* resolveMainPath(props.main);
781 const cwd = yield* findCwdForBundle(realMain);
782 const requested = yield* normalizeInstallTargets(props.build?.install);
783 const identity =
784 Object.keys(requested).length > 0
785 ? yield* resolvePackageInstallIdentity({ cwd, requested })
786 : undefined;
787 const install =
788 identity !== undefined && Object.keys(identity.resolved).length > 0
789 ? identity.resolved
790 : undefined;
791 const packageJson =
792 install === undefined ? undefined : installManifest(install);
793 const path = yield* Path.Path;
794 const extras = props.extraFiles ?? [];
795 const root = contextRootOf(realMain, extras, path, (source) =>
796 resolveExtraSource(source, path),
797 );
798 const entryRel =
799 props.isExternal === true
800 ? (posixRelUnder(root, realMain, path) ?? path.basename(realMain))
801 : undefined;
802 const dockerfile = generateDockerfile(
803 props,
804 bundled.files.length > 1,
805 install,
806 entryRel,
807 );
808 const extraFiles = yield* hashExtraFiles(props.extraFiles);
809 const codeHash = (yield* sha256Object({
810 bundleHash: bundled.hash,
811 dockerfile,
812 packageJson,
813 extraFiles,
814 extraFilesIncludesNodeModules: true,
815 })).slice(0, 16);
816 return { bundled, dockerfile, codeHash, packageJson };
817 });
818
819 /**
820 * Write the generated Dockerfile + bundled entry + extraFiles into a
821 * stable context directory. Railway builds this; Alchemy does not.
822 */
823 const writeContext = Effect.fn(function* (input: {
824 id: string;
825 props: HostedProgramProps;
826 hashed: {
827 bundled: {
828 files: ReadonlyArray<{ path: string; content: string | Uint8Array }>;
829 };
830 dockerfile: string;
831 packageJson: string | undefined;
832 };
833 }) {
834 const fs = yield* FileSystem.FileSystem;
835 const path = yield* Path.Path;
836 const realMain = yield* resolveMainPath(input.props.main);
837 const contextDir = yield* getStableContextDir(
838 realMain,
839 dotAlchemy,
840 `${input.id}-image`,
841 );
842 if (yield* fs.exists(contextDir)) {
843 yield* fs.remove(contextDir, { recursive: true });
844 }
845 yield* fs.makeDirectory(contextDir, { recursive: true });
846 const files: Array<{ path: string; content: string | Uint8Array }> = [
847 { path: "Dockerfile", content: input.hashed.dockerfile },
848 ...(input.props.isExternal === true
849 ? []
850 : input.hashed.bundled.files.map((file, index) => ({
851 path: index === 0 ? "index.mjs" : file.path,
852 content: file.content,
853 }))),
854 ];
855 if (input.hashed.packageJson !== undefined) {
856 files.push({
857 path: "package.json",
858 content: input.hashed.packageJson,
859 });
860 }
861 for (const file of files) {
862 const fullPath = path.join(contextDir, file.path);
863 yield* fs.makeDirectory(path.dirname(fullPath), { recursive: true });
864 if (typeof file.content === "string") {
865 yield* fs.writeFileString(fullPath, file.content);
866 } else {
867 yield* fs.writeFile(fullPath, file.content);
868 }
869 }
870 yield* copyExtraFiles(contextDir, input.props.extraFiles, {
871 onMissing: (file) => new ExtraFileMissing(file),
872 });
873 if (input.props.isExternal === true) {
874 const extras = input.props.extraFiles ?? [];
875 const root = contextRootOf(realMain, extras, path, (source) =>
876 resolveExtraSource(source, path),
877 );
878 const entryRel =
879 posixRelUnder(root, realMain, path) ?? path.basename(realMain);
880 const dest = path.join(contextDir, entryRel);
881 if (!(yield* fs.exists(dest))) {
882 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
883 const contents = yield* fs.readFile(realMain);
884 yield* fs.writeFile(dest, contents);
885 }
886 }
887 return contextDir;
888 });
889
890 const hash = Effect.fn(function* (props: HostedProgramProps) {
891 const { codeHash } = yield* computeCodeHash(props);
892 return codeHash;
893 });
894
895 return {
896 alchemyEnv,
897 bundleProgram,
898 computeCodeHash,
899 writeContext,
900 hash,
901 };
902};