| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 7 | import type { PackageInstall } from "../../Bundle/InstalledPackages.ts"; |
| 8 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 9 | import * as Output from "../../Output.ts"; |
| 10 | import { ProviderModePolicy } from "../../ProviderMode.ts"; |
| 11 | import { initialCwd } from "../../Util/Node.ts"; |
| 12 | import { |
| 13 | staticConfigFromAssets, |
| 14 | type WebsiteAssetsProps, |
| 15 | type WebsiteNotFoundHandling, |
| 16 | } from "../../Website/assets.ts"; |
| 17 | import { packSiteExtraFiles } from "../../Website/packExtraFiles.ts"; |
| 18 | import { |
| 19 | Server as FrameworkServer, |
| 20 | type ServerDevProps, |
| 21 | } from "../../Website/Server.ts"; |
| 22 | import { CustomDomain } from "../CustomDomain.ts"; |
| 23 | import type { ExtraFile } from "../hosted.ts"; |
| 24 | import { Project, type Project as ProjectResource } from "../Project.ts"; |
| 25 | import type { Environment as EnvironmentResource } from "../ProjectEnvironment.ts"; |
| 26 | import type { Providers } from "../Providers.ts"; |
| 27 | import { Service, type Service as ServiceResource } from "../Service.ts"; |
| 28 | import { Cdn } from "./Cdn.ts"; |
| 29 | |
| 30 | /** Port the generated Node serve entry and Railway Service listen on. */ |
| 31 | export const WEBSITE_PORT = 3000; |
| 32 | |
| 33 | /** |
| 34 | * A resource-valued prop: the resource itself, or an Effect that produces |
| 35 | * it (so `yield* Project(...)` and `Project(...)` both type-check). |
| 36 | */ |
| 37 | export type Ref<T> = T | Effect.Effect<T, never, Providers>; |
| 38 | |
| 39 | export type { ServerDevProps, WebsiteAssetsProps, WebsiteNotFoundHandling }; |
| 40 | export { staticConfigFromAssets }; |
| 41 | |
| 42 | /** |
| 43 | * Props shared by every Railway framework website composite. |
| 44 | */ |
| 45 | export interface FrameworkSiteProps { |
| 46 | /** |
| 47 | * Parent Railway Project. Accepts a `Railway.Project` or an Effect |
| 48 | * that produces one. When omitted, a `Railway.Project("Project")` is |
| 49 | * created under this site's namespace. |
| 50 | */ |
| 51 | project?: Ref<ProjectResource>; |
| 52 | /** |
| 53 | * Environment to deploy the Service into. Accepts a `Railway.Project` |
| 54 | * (primary environment), a `Railway.Environment`, or `{ environmentId }`. |
| 55 | * Defaults to the project's primary environment. |
| 56 | */ |
| 57 | environment?: Ref< |
| 58 | EnvironmentResource | ProjectResource | { readonly environmentId: string } |
| 59 | >; |
| 60 | /** |
| 61 | * Project root directory (the directory containing `package.json`). |
| 62 | * @default "." |
| 63 | */ |
| 64 | rootDir?: string; |
| 65 | /** |
| 66 | * Controls which files are hashed to decide whether the build re-runs. |
| 67 | * Forwarded to the framework integration when it honors memo options. |
| 68 | * @default true |
| 69 | */ |
| 70 | memo?: MemoOptions | boolean; |
| 71 | /** |
| 72 | * Process environment for the deployed Service (and, under |
| 73 | * `alchemy dev`, the framework dev server). Accepts `Output`s |
| 74 | * (e.g. `VITE_API_URL: api.url`). |
| 75 | */ |
| 76 | env?: Record< |
| 77 | string, |
| 78 | string | Redacted.Redacted<string> | Output.Output<string | undefined> |
| 79 | >; |
| 80 | /** |
| 81 | * Static-asset routing (`notFoundHandling`, `htmlHandling`). Railway |
| 82 | * CDN caches hashed files by Content-Type regardless of this bag. |
| 83 | */ |
| 84 | assets?: WebsiteAssetsProps; |
| 85 | /** |
| 86 | * Options for the local dev server that runs this site under |
| 87 | * `alchemy dev`. |
| 88 | */ |
| 89 | dev?: ServerDevProps; |
| 90 | /** |
| 91 | * Optional custom hostname attached via `Railway.CustomDomain`. A |
| 92 | * string is the hostname (`www.example.com`). When set, `url` is |
| 93 | * `https://{domain}` instead of the generated `*.up.railway.app`. |
| 94 | */ |
| 95 | domain?: string; |
| 96 | /** |
| 97 | * User-defined tags. Railway Services do not persist tags; accepted |
| 98 | * for API parity with AWS/Cloudflare Website composites. |
| 99 | */ |
| 100 | tags?: Record<string, string>; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Static-asset serving options mapped from Cloudflare `AssetsConfig` |
| 105 | * onto the generated Node serve entry. |
| 106 | */ |
| 107 | export interface WebsiteStaticConfig { |
| 108 | /** |
| 109 | * Answer misses with `index.html` (200) so client-side routes |
| 110 | * deep-link. Mutually exclusive with {@link errorPage}. |
| 111 | */ |
| 112 | spa?: boolean; |
| 113 | /** |
| 114 | * Serve this page (e.g. `404.html`) with status 404 when no file |
| 115 | * matches. Mutually exclusive with {@link spa}. |
| 116 | */ |
| 117 | errorPage?: string; |
| 118 | /** |
| 119 | * Vocs/Waku: serve `about/index.html` at `/about`. |
| 120 | * @default "none" |
| 121 | */ |
| 122 | htmlHandling?: "none" | "drop-trailing-slash"; |
| 123 | } |
| 124 | |
| 125 | /** Per-framework wiring for {@link makeFrameworkSite}. */ |
| 126 | export interface FrameworkSiteConfig { |
| 127 | /** Display name used in error messages (e.g. `"SvelteKit"`). */ |
| 128 | name: string; |
| 129 | /** Framework-integration module specifier. */ |
| 130 | framework: string; |
| 131 | /** Node deploy-target module specifier. */ |
| 132 | target: string; |
| 133 | /** |
| 134 | * Framework-specific build options forwarded to the integration (e.g. |
| 135 | * `{ kit }`, `{ nuxt }`, `{ astro }`). Must be JSON-serializable. |
| 136 | */ |
| 137 | options?: Record<string, unknown> | undefined; |
| 138 | /** |
| 139 | * Assets-only serving: used when the build produced no server modules |
| 140 | * (Vite, Foldkit, Vocs, Astro `output: "static"`). |
| 141 | */ |
| 142 | static?: WebsiteStaticConfig | undefined; |
| 143 | /** |
| 144 | * Native packages to `npm install` into the image instead of bundling |
| 145 | * (Next.js needs `next` / `react`). |
| 146 | */ |
| 147 | install?: PackageInstall | undefined; |
| 148 | /** |
| 149 | * How to bake the build into the image. |
| 150 | * - `"client"` (default): `COPY dist /app/dist` from `clientDirectory` |
| 151 | * - `"next"`: `COPY .next` + `public` (and `next.config.*`) |
| 152 | * @default "client" |
| 153 | */ |
| 154 | bake?: "client" | "next" | undefined; |
| 155 | } |
| 156 | |
| 157 | export interface Website { |
| 158 | /** |
| 159 | * Public site URL. Under `alchemy dev` this is the framework (or |
| 160 | * static) dev server (`http://localhost:<port>`). On deploy it is |
| 161 | * `https://{domain}` (`*.up.railway.app`, or the custom hostname). |
| 162 | */ |
| 163 | url: string | Output.Output<string | undefined> | undefined; |
| 164 | /** |
| 165 | * The Railway Service that serves the site. `undefined` under |
| 166 | * `alchemy dev` (no cloud resources are declared). |
| 167 | */ |
| 168 | service: ServiceResource | undefined; |
| 169 | /** |
| 170 | * The Railway Project the Service belongs to. `undefined` under |
| 171 | * `alchemy dev`. |
| 172 | */ |
| 173 | project: ProjectResource | undefined; |
| 174 | } |
| 175 | |
| 176 | export class FrameworkServerError extends Data.TaggedError( |
| 177 | "Railway.Website.FrameworkServerError", |
| 178 | )<{ |
| 179 | readonly framework: string; |
| 180 | readonly message: string; |
| 181 | readonly cause?: unknown; |
| 182 | }> {} |
| 183 | |
| 184 | const envRecord = ( |
| 185 | env: |
| 186 | | Record< |
| 187 | string, |
| 188 | string | Redacted.Redacted<string> | Output.Output<string | undefined> |
| 189 | > |
| 190 | | undefined, |
| 191 | ): Record<string, string | Output.Output<string | undefined>> | undefined => { |
| 192 | if (env === undefined) return undefined; |
| 193 | return Object.fromEntries( |
| 194 | Object.entries(env).map(([key, value]) => [ |
| 195 | key, |
| 196 | Redacted.isRedacted(value) ? Redacted.value(value) : value, |
| 197 | ]), |
| 198 | ); |
| 199 | }; |
| 200 | |
| 201 | /** |
| 202 | * Shared implementation behind the Railway framework website composites: |
| 203 | * build the framework through its Node deploy target, then deploy one |
| 204 | * `Railway.Service` whose image bakes the Node serve entry plus |
| 205 | * `clientDirectory`. |
| 206 | * |
| 207 | * During `alchemy dev` the site is the framework's own dev server (native |
| 208 | * HMR) and no cloud resources are declared; `Alchemy.remote()` opts back |
| 209 | * into the live Service path. |
| 210 | * |
| 211 | * Callers pipe `Namespace.push(id)` themselves (the composites do). |
| 212 | */ |
| 213 | const runFrameworkSite = Effect.fn("Railway.Website.FrameworkSite")(function* ( |
| 214 | _id: string, |
| 215 | props: FrameworkSiteProps, |
| 216 | config: FrameworkSiteConfig, |
| 217 | ) { |
| 218 | const ctx = yield* AlchemyContext; |
| 219 | const remoted = yield* ProviderModePolicy; |
| 220 | const isLocal = ctx.dev && remoted !== true; |
| 221 | const path = yield* Path.Path; |
| 222 | const fs = yield* FileSystem.FileSystem; |
| 223 | const root = path.resolve(initialCwd, props.rootDir ?? "."); |
| 224 | const bake = config.bake ?? "client"; |
| 225 | |
| 226 | const build = yield* FrameworkServer("Build", { |
| 227 | framework: config.framework, |
| 228 | target: config.target, |
| 229 | root: props.rootDir, |
| 230 | env: envRecord(props.env), |
| 231 | options: config.options, |
| 232 | memo: props.memo, |
| 233 | dev: props.dev, |
| 234 | }); |
| 235 | |
| 236 | if (isLocal) { |
| 237 | return { |
| 238 | url: build.url, |
| 239 | service: undefined, |
| 240 | project: undefined, |
| 241 | } satisfies Website; |
| 242 | } |
| 243 | |
| 244 | // The build runs at APPLY time (`Website.Server` is a resource), so its |
| 245 | // attributes are Outputs here — derive every deploy input lazily and let |
| 246 | // the engine resolve them once the build has produced real paths. Reading |
| 247 | // `build.distDir` eagerly in the composite body would observe an Output |
| 248 | // proxy, not a string. |
| 249 | const buildOut = Output.mapEffect( |
| 250 | ([serverEntry, distDir]: [string | undefined, string | undefined]) => |
| 251 | Effect.gen(function* () { |
| 252 | if (serverEntry === undefined || distDir === undefined) { |
| 253 | return yield* Effect.die( |
| 254 | new FrameworkServerError({ |
| 255 | framework: config.framework, |
| 256 | message: `The ${config.name} build produced no Node serve entry (serverModules[0]). The Node deploy target should write serve-node.mjs.`, |
| 257 | }), |
| 258 | ); |
| 259 | } |
| 260 | const main = path.resolve(initialCwd, serverEntry); |
| 261 | if (!(yield* fs.exists(main).pipe(Effect.orElseSucceed(() => false)))) { |
| 262 | return yield* Effect.die( |
| 263 | new FrameworkServerError({ |
| 264 | framework: config.framework, |
| 265 | message: `The ${config.name} build produced no server entry at ${main}`, |
| 266 | }), |
| 267 | ); |
| 268 | } |
| 269 | return { distDir: path.resolve(initialCwd, distDir), main }; |
| 270 | }), |
| 271 | )( |
| 272 | Output.all( |
| 273 | build.serverEntry as unknown as Output.Output<string | undefined>, |
| 274 | build.distDir as unknown as Output.Output<string | undefined>, |
| 275 | ) as unknown as Output.Output<[string | undefined, string | undefined]>, |
| 276 | ); |
| 277 | const main = Output.map(buildOut, (out) => out.main); |
| 278 | |
| 279 | // `bake === "next"` ships `.next`/`public`/`next.config.*` from the |
| 280 | // project root; every other bake ships the build's dist directory. BOTH |
| 281 | // must be derived from `buildOut`: the artifacts only exist once the |
| 282 | // build has run at apply — probing the root here (pre-build) would miss |
| 283 | // a fresh project's `.next` entirely. |
| 284 | const extraFiles = Output.mapEffect( |
| 285 | (out: { distDir: string; main: string }) => |
| 286 | packSiteExtraFiles(bake === "next" ? root : out.distDir, bake), |
| 287 | )(buildOut); |
| 288 | |
| 289 | const project = Effect.isEffect(props.project) |
| 290 | ? yield* props.project |
| 291 | : (props.project ?? (yield* Project("Project"))); |
| 292 | const environment = Effect.isEffect(props.environment) |
| 293 | ? yield* props.environment |
| 294 | : (props.environment ?? project); |
| 295 | const service = yield* Service("Service", { |
| 296 | project, |
| 297 | environment, |
| 298 | main: main as unknown as string, |
| 299 | port: WEBSITE_PORT, |
| 300 | healthcheck: "/health", |
| 301 | isExternal: true, |
| 302 | env: envRecord(props.env), |
| 303 | extraFiles: extraFiles as unknown as ExtraFile[] | undefined, |
| 304 | build: |
| 305 | config.install !== undefined ? { install: config.install } : undefined, |
| 306 | }); |
| 307 | yield* Cdn("Cdn", { |
| 308 | service, |
| 309 | environment, |
| 310 | htmlCaching: "AUTO", |
| 311 | purgeOnDeploy: "HTML", |
| 312 | }); |
| 313 | |
| 314 | if (props.domain !== undefined && props.domain.length > 0) { |
| 315 | yield* CustomDomain("Domain", { |
| 316 | service, |
| 317 | environment, |
| 318 | domain: props.domain, |
| 319 | targetPort: WEBSITE_PORT, |
| 320 | }); |
| 321 | return { |
| 322 | url: `https://${props.domain}`, |
| 323 | service, |
| 324 | project, |
| 325 | } satisfies Website; |
| 326 | } |
| 327 | |
| 328 | return { |
| 329 | url: service.url, |
| 330 | service, |
| 331 | project, |
| 332 | } satisfies Website; |
| 333 | }); |
| 334 | |
| 335 | /** |
| 336 | * Composite-level tagged errors (`FrameworkServerError`, filesystem) |
| 337 | * are defects — `Alchemy.Stack` only admits `ConfigError` on the user |
| 338 | * effect. |
| 339 | */ |
| 340 | export const makeFrameworkSite = ( |
| 341 | id: string, |
| 342 | props: FrameworkSiteProps, |
| 343 | config: FrameworkSiteConfig, |
| 344 | ) => runFrameworkSite(id, props, config).pipe(Effect.orDie); |