| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Equal from "effect/Equal"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as NodeNet from "node:net"; |
| 7 | import { hashDirectory, type MemoOptions } from "../Command/Memo.ts"; |
| 8 | import { havePropsChanged, isResolved } from "../Diff.ts"; |
| 9 | import * as LocalProvider from "../Local/LocalProvider.ts"; |
| 10 | import * as ProviderLayer from "../Local/ProviderLayer.ts"; |
| 11 | import * as Provider from "../Provider.ts"; |
| 12 | import { Resource } from "../Resource.ts"; |
| 13 | import { initialCwd, moduleExtension } from "../Util/Node.ts"; |
| 14 | import { sha256Object } from "../Util/sha256.ts"; |
| 15 | |
| 16 | /** |
| 17 | * The structural slice of a framework-integration module |
| 18 | * (`@alchemy.run/frontend-frameworks/nuxt`, `@alchemy.run/frontend-frameworks/astro`, ...) this resource |
| 19 | * drives. Typed structurally so alchemy carries no dependency on |
| 20 | * `@distilled.cloud/framework-core` — the *project's* install is always the |
| 21 | * one loaded. Requirement channels are erased (the module is loaded |
| 22 | * dynamically, so its effects are typed post-hoc): `build` is |
| 23 | * requirement-free and `dev` runs in the caller's ambient Scope. |
| 24 | */ |
| 25 | interface FrameworkModule { |
| 26 | readonly make: (options: Record<string, unknown>) => Effect.Effect< |
| 27 | { |
| 28 | readonly build: (options?: { |
| 29 | readonly root?: string; |
| 30 | readonly env?: Record<string, string>; |
| 31 | }) => Effect.Effect<FrameworkBuildOutputSlice, unknown>; |
| 32 | readonly dev: (options?: { |
| 33 | readonly root?: string; |
| 34 | readonly port?: number; |
| 35 | readonly host?: string; |
| 36 | }) => Effect.Effect<{ readonly url: string }, unknown>; |
| 37 | }, |
| 38 | unknown, |
| 39 | FileSystem.FileSystem | Path.Path |
| 40 | >; |
| 41 | } |
| 42 | |
| 43 | /** The structural slice of framework-core's `BuildOutput` this resource reads. */ |
| 44 | interface FrameworkBuildOutputSlice { |
| 45 | readonly distDirectory?: string | undefined; |
| 46 | readonly clientDirectory: string | undefined; |
| 47 | readonly serverModules: Array<{ readonly name: string }> | undefined; |
| 48 | } |
| 49 | |
| 50 | export class FrameworkServerError extends Data.TaggedError( |
| 51 | "FrameworkServerError", |
| 52 | )<{ |
| 53 | readonly framework: string; |
| 54 | readonly message: string; |
| 55 | readonly cause?: unknown; |
| 56 | }> {} |
| 57 | |
| 58 | /** Drops `undefined`-valued entries from {@link ServerProps.env}. */ |
| 59 | const definedEnv = ( |
| 60 | env: Record<string, string | undefined> | undefined, |
| 61 | ): Record<string, string> | undefined => |
| 62 | env === undefined |
| 63 | ? undefined |
| 64 | : Object.fromEntries( |
| 65 | Object.entries(env).filter( |
| 66 | (entry): entry is [string, string] => entry[1] !== undefined, |
| 67 | ), |
| 68 | ); |
| 69 | |
| 70 | /** |
| 71 | * Options for the local dev server that runs a framework site under |
| 72 | * `alchemy dev`. |
| 73 | * |
| 74 | * Use `{ mode: "external" }` to skip starting a dev server entirely — |
| 75 | * useful when an external dev server (e.g. one you run yourself in |
| 76 | * another terminal) is serving the site instead. |
| 77 | */ |
| 78 | export type ServerDevProps = |
| 79 | | { |
| 80 | /** |
| 81 | * Run the framework's own dev server locally (the default). |
| 82 | * @default "server" |
| 83 | */ |
| 84 | mode?: "server"; |
| 85 | /** |
| 86 | * Host the dev server binds to. Defaults to the framework's own |
| 87 | * choice (localhost). |
| 88 | */ |
| 89 | host?: string; |
| 90 | /** |
| 91 | * Preferred port for the dev server. Defaults to an ephemeral port. |
| 92 | * If the port is unavailable, the next free port is used unless |
| 93 | * {@link strictPort} is `true`. |
| 94 | */ |
| 95 | port?: number; |
| 96 | /** |
| 97 | * When `true`, fail instead of falling back to another port if |
| 98 | * {@link port} is already in use. |
| 99 | * @default false |
| 100 | */ |
| 101 | strictPort?: boolean; |
| 102 | } |
| 103 | | { |
| 104 | /** |
| 105 | * Don't start a dev server; an external dev server is running instead. |
| 106 | */ |
| 107 | mode: "external"; |
| 108 | /** |
| 109 | * URL the external dev server is reachable at, if applicable. |
| 110 | * This will be returned as the `url` attribute of the Server resource. |
| 111 | */ |
| 112 | url?: string; |
| 113 | }; |
| 114 | |
| 115 | export interface ServerProps { |
| 116 | /** |
| 117 | * Module specifier of the framework-integration package that implements |
| 118 | * the build and dev server (e.g. `"@alchemy.run/frontend-frameworks/nuxt"`). Must be |
| 119 | * installed in your project — it is loaded dynamically at deploy time and |
| 120 | * drives your project's own framework toolchain. |
| 121 | */ |
| 122 | framework: string; |
| 123 | /** |
| 124 | * Project root directory (the directory containing the framework config), |
| 125 | * relative to the process working directory. |
| 126 | * @default "." |
| 127 | */ |
| 128 | root?: string; |
| 129 | /** |
| 130 | * The deploy target the build is produced for: a module specifier |
| 131 | * resolved from your project's `node_modules` |
| 132 | * (e.g. `"@alchemy.run/frontend-frameworks/nuxt/aws"`). |
| 133 | */ |
| 134 | target: string; |
| 135 | /** |
| 136 | * Framework-integration options forwarded to the module's `make()` |
| 137 | * (e.g. `{ nuxt: { ... } }` config overrides). Must be JSON-serializable — |
| 138 | * the value participates in the memo hash and is persisted in state. |
| 139 | */ |
| 140 | options?: Record<string, unknown>; |
| 141 | /** |
| 142 | * Options for the local dev server that runs this site under |
| 143 | * `alchemy dev`. See {@link ServerDevProps}. |
| 144 | */ |
| 145 | dev?: ServerDevProps; |
| 146 | /** |
| 147 | * Environment variables for the framework server. On deploy the |
| 148 | * composite sets these on the Lambda; during `alchemy dev` they are |
| 149 | * applied to the dev server's process environment (the dev sidecar and |
| 150 | * any child it spawns) so server code reads the same values in both |
| 151 | * modes. Changing a value restarts the dev server. |
| 152 | * |
| 153 | * Entries whose value is `undefined` are dropped — this lets website |
| 154 | * composites forward optional values (e.g. an `Output` service URL that |
| 155 | * resolves to `undefined`) without filtering first. |
| 156 | */ |
| 157 | env?: Record<string, string | undefined>; |
| 158 | /** |
| 159 | * Controls which files are hashed to decide whether the build should |
| 160 | * re-run. By default every non-gitignored file in the root is hashed, |
| 161 | * plus the nearest lockfile. Set `false` to rebuild on every deploy. |
| 162 | * @default true |
| 163 | */ |
| 164 | memo?: MemoOptions | boolean; |
| 165 | } |
| 166 | |
| 167 | export interface Server extends Resource< |
| 168 | "Website.Server", |
| 169 | ServerProps, |
| 170 | { |
| 171 | /** |
| 172 | * Root output directory of the build (e.g. `.output`), relative to the |
| 173 | * process's initial working directory. `undefined` in dev mode (no |
| 174 | * production build runs). |
| 175 | */ |
| 176 | distDir: string | undefined; |
| 177 | /** |
| 178 | * Static-assets directory of the build (prerendered pages included), |
| 179 | * relative to the initial working directory. `undefined` when the build |
| 180 | * produced no client assets, and in dev mode. |
| 181 | */ |
| 182 | clientDir: string | undefined; |
| 183 | /** |
| 184 | * The server entry module on disk (e.g. `.output/server/index.mjs`), |
| 185 | * relative to the initial working directory. `undefined` for |
| 186 | * assets-only builds, and in dev mode. |
| 187 | */ |
| 188 | serverEntry: string | undefined; |
| 189 | /** |
| 190 | * The framework dev server's local URL (e.g. `http://localhost:3000`) |
| 191 | * during `alchemy dev`. `undefined` on deploys. |
| 192 | */ |
| 193 | url: string | undefined; |
| 194 | hash: { |
| 195 | /** |
| 196 | * Hash of the input files (plus framework/target/options) that |
| 197 | * produced this build. `undefined` in dev mode. |
| 198 | */ |
| 199 | input: string | undefined; |
| 200 | /** |
| 201 | * Hash of the build output files. `undefined` in dev mode. |
| 202 | */ |
| 203 | output: string | undefined; |
| 204 | }; |
| 205 | } |
| 206 | > {} |
| 207 | |
| 208 | /** |
| 209 | * The framework toolchain half of a website: on `alchemy deploy` it |
| 210 | * runs the framework's production build with a platform deploy target and |
| 211 | * tracks the on-disk output in state; on `alchemy dev` it runs the |
| 212 | * framework's OWN dev server (native HMR via the framework's kit) in the |
| 213 | * dev sidecar and exposes its local URL. Shared by AWS, Fly, Hetzner, and |
| 214 | * Railway website composites. |
| 215 | * |
| 216 | * The framework package and the target module are loaded from *your* |
| 217 | * project's `node_modules`, so your project's framework version drives the |
| 218 | * build. Build inputs are content-hashed so an unchanged project skips the |
| 219 | * rebuild entirely. |
| 220 | * |
| 221 | * ### Building Frameworks |
| 222 | * **Example:** Nuxt For AWS Lambda |
| 223 | * ```typescript |
| 224 | * const server = yield* Server("Server", { |
| 225 | * framework: "@alchemy.run/frontend-frameworks/nuxt", |
| 226 | * target: "@alchemy.run/frontend-frameworks/nuxt/aws", |
| 227 | * root: "./app", |
| 228 | * }); |
| 229 | * // deploy: server.serverEntry -> .output/server/index.mjs (Lambda handler) |
| 230 | * // server.clientDir -> .output/public (static assets for the CDN) |
| 231 | * // dev: server.url -> http://localhost:<port> (framework HMR server) |
| 232 | * ``` |
| 233 | * |
| 234 | * @resource |
| 235 | */ |
| 236 | export const Server = Resource<Server>("Website.Server", { |
| 237 | aliases: ["AWS.Website.Server", "AWS.Website.FrameworkBuild"], |
| 238 | }); |
| 239 | |
| 240 | const importFrameworkModule = (specifier: string) => |
| 241 | Effect.tryPromise({ |
| 242 | try: () => import(specifier) as Promise<Partial<FrameworkModule>>, |
| 243 | catch: (cause) => |
| 244 | new FrameworkServerError({ |
| 245 | framework: specifier, |
| 246 | message: |
| 247 | `Failed to import the framework integration "${specifier}". ` + |
| 248 | "It must be installed in your project (it is loaded dynamically at deploy time).", |
| 249 | cause, |
| 250 | }), |
| 251 | }).pipe( |
| 252 | Effect.flatMap((module_) => |
| 253 | typeof module_.make === "function" |
| 254 | ? Effect.succeed(module_ as FrameworkModule) |
| 255 | : Effect.fail( |
| 256 | new FrameworkServerError({ |
| 257 | framework: specifier, |
| 258 | message: `"${specifier}" does not export the framework-integration contract (a "make" function)`, |
| 259 | }), |
| 260 | ), |
| 261 | ), |
| 262 | ); |
| 263 | |
| 264 | const makeFramework = (props: ServerProps, root: string) => |
| 265 | importFrameworkModule(props.framework).pipe( |
| 266 | Effect.flatMap((module_) => |
| 267 | Effect.mapError( |
| 268 | module_.make({ |
| 269 | ...props.options, |
| 270 | root, |
| 271 | target: props.target, |
| 272 | }), |
| 273 | (cause) => |
| 274 | new FrameworkServerError({ |
| 275 | framework: props.framework, |
| 276 | message: "Failed to initialize the framework integration", |
| 277 | cause, |
| 278 | }), |
| 279 | ), |
| 280 | ), |
| 281 | ); |
| 282 | |
| 283 | export const ServerProvider = () => |
| 284 | ProviderLayer.dual(Server, { |
| 285 | live: ServerProviderLive, |
| 286 | local: ServerProviderLocal, |
| 287 | }); |
| 288 | |
| 289 | export const ServerProviderLive = () => |
| 290 | Provider.effect( |
| 291 | Server, |
| 292 | Effect.gen(function* () { |
| 293 | const path = yield* Path.Path; |
| 294 | const fs = yield* FileSystem.FileSystem; |
| 295 | |
| 296 | const runBuild = Effect.fn(function* (props: ServerProps) { |
| 297 | const root = path.resolve(initialCwd, props.root ?? "."); |
| 298 | const service = yield* makeFramework(props, root); |
| 299 | return yield* Effect.mapError( |
| 300 | service.build({ root, env: definedEnv(props.env) }), |
| 301 | (cause) => |
| 302 | new FrameworkServerError({ |
| 303 | framework: props.framework, |
| 304 | message: `The ${props.framework} build failed`, |
| 305 | cause, |
| 306 | }), |
| 307 | ); |
| 308 | }); |
| 309 | |
| 310 | const hashInput = (props: ServerProps, root: string) => |
| 311 | hashDirectory({ |
| 312 | cwd: root, |
| 313 | memo: |
| 314 | props.memo === true || |
| 315 | props.memo === undefined || |
| 316 | props.memo === false |
| 317 | ? {} |
| 318 | : props.memo, |
| 319 | }).pipe( |
| 320 | Effect.flatMap((files) => |
| 321 | sha256Object({ |
| 322 | files, |
| 323 | framework: props.framework, |
| 324 | target: props.target, |
| 325 | options: props.options, |
| 326 | }), |
| 327 | ), |
| 328 | ); |
| 329 | |
| 330 | const hashOutput = (props: ServerProps, distDir: string) => |
| 331 | hashDirectory({ |
| 332 | cwd: distDir, |
| 333 | memo: { |
| 334 | // Next.js serves from its project root, not a dedicated output directory. |
| 335 | exclude: |
| 336 | path.resolve(distDir) === |
| 337 | path.resolve(initialCwd, props.root ?? ".") |
| 338 | ? [ |
| 339 | "**/node_modules/**", |
| 340 | "**/.git/**", |
| 341 | "**/.alchemy/**", |
| 342 | ".next/cache/**", |
| 343 | ] |
| 344 | : [], |
| 345 | lockfile: false, |
| 346 | }, |
| 347 | }); |
| 348 | |
| 349 | const makeOutput = Effect.fn(function* ( |
| 350 | props: ServerProps, |
| 351 | built: FrameworkBuildOutputSlice, |
| 352 | ) { |
| 353 | const root = path.resolve(initialCwd, props.root ?? "."); |
| 354 | const distDir = built.distDirectory ?? path.join(root, "dist"); |
| 355 | if (!(yield* fs.exists(distDir))) { |
| 356 | return yield* Effect.fail( |
| 357 | new FrameworkServerError({ |
| 358 | framework: props.framework, |
| 359 | message: `The build produced no output directory at ${distDir}`, |
| 360 | }), |
| 361 | ); |
| 362 | } |
| 363 | const entryName = built.serverModules?.[0]?.name; |
| 364 | return { |
| 365 | distDir: path.relative(initialCwd, distDir), |
| 366 | clientDir: |
| 367 | built.clientDirectory !== undefined |
| 368 | ? path.relative(initialCwd, built.clientDirectory) |
| 369 | : undefined, |
| 370 | serverEntry: |
| 371 | entryName !== undefined |
| 372 | ? path.relative(initialCwd, path.join(distDir, entryName)) |
| 373 | : undefined, |
| 374 | url: undefined, |
| 375 | hash: |
| 376 | props.memo === false |
| 377 | ? { input: undefined, output: undefined } |
| 378 | : yield* Effect.all( |
| 379 | { |
| 380 | input: hashInput(props, root), |
| 381 | output: hashOutput(props, distDir), |
| 382 | }, |
| 383 | { concurrency: "unbounded" }, |
| 384 | ), |
| 385 | }; |
| 386 | }); |
| 387 | |
| 388 | return { |
| 389 | list: () => Effect.succeed([]), |
| 390 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 391 | if (!output || !isResolved(news)) return undefined; |
| 392 | if (news.memo === false || !output.hash.input || !output.hash.output) |
| 393 | return { action: "update" }; |
| 394 | if (havePropsChanged(olds, news)) return { action: "update" }; |
| 395 | const root = path.resolve(initialCwd, news.root ?? "."); |
| 396 | // Cheap check: same inputs + output still on disk with the same |
| 397 | // content hash -> noop without re-running the build. |
| 398 | const input = yield* hashInput(news, root); |
| 399 | if (input !== output.hash.input) return { action: "update" }; |
| 400 | if (output.distDir === undefined) return { action: "update" }; |
| 401 | const distDir = path.resolve(initialCwd, output.distDir); |
| 402 | if (!(yield* fs.exists(distDir))) return { action: "update" }; |
| 403 | const outHash = yield* hashOutput(news, distDir); |
| 404 | return { |
| 405 | action: Equal.equals(outHash, output.hash.output) |
| 406 | ? "noop" |
| 407 | : "update", |
| 408 | }; |
| 409 | }), |
| 410 | reconcile: Effect.fn(function* ({ news }) { |
| 411 | const built = yield* runBuild(news); |
| 412 | return yield* makeOutput(news, built); |
| 413 | }), |
| 414 | delete: Effect.fn(function* ({ olds, output }) { |
| 415 | if (output.distDir === undefined) return; |
| 416 | const root = path.resolve(initialCwd, olds.root ?? "."); |
| 417 | const distDir = path.resolve(initialCwd, output.distDir); |
| 418 | if (!(yield* fs.exists(root)) || !(yield* fs.exists(distDir))) return; |
| 419 | // Some frameworks (Next.js) serve from the project root itself. |
| 420 | // Only dedicated output directories inside that root are disposable. |
| 421 | // Canonical paths also protect roots reached through a symlink. |
| 422 | const relative = path.relative( |
| 423 | yield* fs.realPath(root), |
| 424 | yield* fs.realPath(distDir), |
| 425 | ); |
| 426 | if ( |
| 427 | relative === "" || |
| 428 | relative === ".." || |
| 429 | relative.startsWith(`..${path.sep}`) || |
| 430 | path.isAbsolute(relative) |
| 431 | ) |
| 432 | return; |
| 433 | yield* fs.remove(distDir, { recursive: true }); |
| 434 | }), |
| 435 | }; |
| 436 | }), |
| 437 | ); |
| 438 | |
| 439 | /** |
| 440 | * Try to bind `port` on `host`; resolves `true` when the port is free. |
| 441 | * The listener is closed immediately — the port is only observed |
| 442 | * available, not reserved, so the caller should bind promptly and the |
| 443 | * framework still handles the (tiny) race window itself. |
| 444 | */ |
| 445 | const isPortFree = (port: number, host: string) => |
| 446 | Effect.callback<boolean>((resume) => { |
| 447 | const server = NodeNet.createServer(); |
| 448 | server.unref(); |
| 449 | server.once("error", () => resume(Effect.succeed(false))); |
| 450 | server.listen(port, host, () => { |
| 451 | server.close(() => resume(Effect.succeed(true))); |
| 452 | }); |
| 453 | }); |
| 454 | |
| 455 | /** |
| 456 | * Resolve the dev server's port from the `dev` props: probe the preferred |
| 457 | * port and either fail (`strictPort`) or walk forward to the next free |
| 458 | * port when it is taken. |
| 459 | */ |
| 460 | const resolveDevPort = Effect.fn(function* (options: { |
| 461 | readonly framework: string; |
| 462 | readonly port: number; |
| 463 | readonly host: string; |
| 464 | readonly strictPort: boolean; |
| 465 | }) { |
| 466 | const { framework, port, host, strictPort } = options; |
| 467 | if (yield* isPortFree(port, host)) return port; |
| 468 | if (strictPort) { |
| 469 | return yield* Effect.fail( |
| 470 | new FrameworkServerError({ |
| 471 | framework, |
| 472 | message: `Port ${port} is already in use and \`dev.strictPort\` is set`, |
| 473 | }), |
| 474 | ); |
| 475 | } |
| 476 | for (let candidate = port + 1; candidate <= port + 100; candidate++) { |
| 477 | if (yield* isPortFree(candidate, host)) return candidate; |
| 478 | } |
| 479 | return yield* Effect.fail( |
| 480 | new FrameworkServerError({ |
| 481 | framework, |
| 482 | message: `No free port found between ${port} and ${port + 100}`, |
| 483 | }), |
| 484 | ); |
| 485 | }); |
| 486 | |
| 487 | /** |
| 488 | * A framework dev server may advertise its *bind* address — nuxt echoes the |
| 489 | * host it was told to listen on, so a Router-attached site (which binds |
| 490 | * `0.0.0.0` to be reachable from the emulator container) reports |
| 491 | * `http://0.0.0.0:{port}/`. The unspecified address is not a connectable |
| 492 | * host: normalize it to `localhost` for the `url` attribute (browser links, |
| 493 | * Router dev routing, the emulated CloudFront edge dialing the origin). The |
| 494 | * server itself still listens on every interface. |
| 495 | */ |
| 496 | const normalizeAdvertisedUrl = (url: string) => |
| 497 | url.replace( |
| 498 | /^(https?:\/\/)(?:0\.0\.0\.0|\[::\]|\[0+(?::0+){7}\])(?=[:/]|$)/, |
| 499 | "$1localhost", |
| 500 | ); |
| 501 | |
| 502 | /** |
| 503 | * The `alchemy dev` variant: runs the framework's own dev server (native |
| 504 | * HMR through the framework's kit — nuxt, astro, ...) inside the dev |
| 505 | * sidecar, so it survives user-code hot reloads. Restarts when the |
| 506 | * framework/target/root/options config changes. |
| 507 | */ |
| 508 | export const ServerProviderLocal = () => |
| 509 | LocalProvider.make( |
| 510 | Server, |
| 511 | import.meta.resolve( |
| 512 | `./ServerLocal${moduleExtension(import.meta.url)}`, |
| 513 | import.meta.url, |
| 514 | ), |
| 515 | Effect.gen(function* () { |
| 516 | const path = yield* Path.Path; |
| 517 | const fs = yield* FileSystem.FileSystem; |
| 518 | return { |
| 519 | start: Effect.fn(function* ({ news: props }) { |
| 520 | // External mode: an external dev server (run out-of-band by the |
| 521 | // user) serves the site — don't import the framework or spawn |
| 522 | // anything, just surface the external URL as the attribute. |
| 523 | if (props.dev?.mode === "external") { |
| 524 | return { |
| 525 | distDir: undefined, |
| 526 | clientDir: undefined, |
| 527 | serverEntry: undefined, |
| 528 | url: props.dev.url, |
| 529 | hash: { input: undefined, output: undefined }, |
| 530 | }; |
| 531 | } |
| 532 | const dev = props.dev; |
| 533 | const root = path.resolve(initialCwd, props.root ?? "."); |
| 534 | // Dev/live env parity: the composite sets these on the Lambda on |
| 535 | // deploy; in dev the framework server runs inside the sidecar |
| 536 | // (or as its child — `next dev`), so the sidecar's process env |
| 537 | // is what SSR code reads. `env` is part of the restart surface, |
| 538 | // so a changed value restarts the dev server with the new |
| 539 | // environment. Values are not unset on stop: sibling dev servers |
| 540 | // share the process, so clearing could clobber their keys. |
| 541 | if (props.env !== undefined) { |
| 542 | yield* Effect.sync(() => { |
| 543 | for (const [key, value] of Object.entries(props.env!)) { |
| 544 | if (value === undefined) continue; |
| 545 | process.env[key] = String(value); |
| 546 | } |
| 547 | }); |
| 548 | } |
| 549 | const service = yield* makeFramework(props, root).pipe( |
| 550 | Effect.provideService(FileSystem.FileSystem, fs), |
| 551 | Effect.provideService(Path.Path, path), |
| 552 | ); |
| 553 | // Resolve the port BEFORE handing off to the framework: probe |
| 554 | // the preferred port, fail on `strictPort`, otherwise walk |
| 555 | // forward to the next free port. Without a preferred port the |
| 556 | // framework picks its own (ephemeral) port. |
| 557 | const port = |
| 558 | dev?.port !== undefined |
| 559 | ? yield* resolveDevPort({ |
| 560 | framework: props.framework, |
| 561 | port: dev.port, |
| 562 | host: dev.host ?? "127.0.0.1", |
| 563 | strictPort: dev.strictPort ?? false, |
| 564 | }) |
| 565 | : undefined; |
| 566 | // `dev` is scoped: the server lives in the instance scope the |
| 567 | // LocalProvider helper provides and is torn down on |
| 568 | // restart/delete. It resolves at readiness with the local URL. |
| 569 | const { url } = yield* Effect.mapError( |
| 570 | service.dev({ root, port, host: dev?.host }), |
| 571 | (cause) => |
| 572 | new FrameworkServerError({ |
| 573 | framework: props.framework, |
| 574 | message: `The ${props.framework} dev server failed to start`, |
| 575 | cause, |
| 576 | }), |
| 577 | ); |
| 578 | return { |
| 579 | distDir: undefined, |
| 580 | clientDir: undefined, |
| 581 | serverEntry: undefined, |
| 582 | url: normalizeAdvertisedUrl(url), |
| 583 | hash: { input: undefined, output: undefined }, |
| 584 | }; |
| 585 | }), |
| 586 | } satisfies LocalProvider.LocalProviderSpec<Server>; |
| 587 | }), |
| 588 | ); |