| 1 | import * as ConsoleService from "effect/Console"; |
| 2 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { stripVTControlCharacters } from "node:util"; |
| 6 | import { makeResourceOutput } from "../Util/ResourceOutput.ts"; |
| 7 | import { makeDevLogOpener } from "../Local/DevLog.ts"; |
| 8 | import { FQN_SEPARATOR } from "../FQN.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 { Stage } from "../Stage.ts"; |
| 14 | import { |
| 15 | CommandExecutor, |
| 16 | UnexpectedExit, |
| 17 | makeCommandError, |
| 18 | type CommandProps, |
| 19 | } from "./Command.ts"; |
| 20 | import { makeCommandRedactor } from "./Redaction.ts"; |
| 21 | import { moduleExtension } from "../Util/Node.ts"; |
| 22 | |
| 23 | export interface DevProps extends CommandProps {} |
| 24 | |
| 25 | export interface Dev extends Resource< |
| 26 | "Command.Dev", |
| 27 | DevProps, |
| 28 | { |
| 29 | /** |
| 30 | * URL extracted from stdout/stderr. A `localhost`/IP URL (the dev |
| 31 | * server's own address) is preferred over any other URL the command |
| 32 | * prints; a non-local URL is only used as a fallback if no local one |
| 33 | * appears. Best-effort: `undefined` if no URL appears within 5 seconds. |
| 34 | */ |
| 35 | url: string | undefined; |
| 36 | } |
| 37 | > {} |
| 38 | |
| 39 | /** |
| 40 | * A long-lived shell process scoped to a stack instance, started during |
| 41 | * `alchemy dev` and restarted when its inputs change. During `alchemy deploy` |
| 42 | * this is a no-op — `Dev` resources only run in dev mode. |
| 43 | * |
| 44 | * The child process runs inside the dev sidecar (see `Command/Local.ts`) so it |
| 45 | * survives user-code HMR — Alchemy's user process can restart without killing |
| 46 | * your `npm run dev` server. Its stdout/stderr are mirrored to the terminal |
| 47 | * (preserving colored output) and scanned for an `http(s)://…` URL, favoring |
| 48 | * a `localhost`/IP URL (the dev server's own address) over any unrelated URL |
| 49 | * the command prints first. The result is exposed as the `url` output |
| 50 | * attribute — useful for surfacing a dev server's local URL back out to |
| 51 | * whatever resource declared this `Dev`. |
| 52 | * |
| 53 | * |
| 54 | * ### Basic Usage |
| 55 | * Pass a shell command that starts a long-lived dev server. Alchemy |
| 56 | * runs it in the background and extracts the first URL it prints. |
| 57 | * |
| 58 | * **Example:** Start a Vite dev server |
| 59 | * ```typescript |
| 60 | * const dev = yield* Dev("Frontend", { |
| 61 | * command: "npm run dev", |
| 62 | * }); |
| 63 | * yield* Console.log(dev.url); // e.g. "http://localhost:5173" |
| 64 | * ``` |
| 65 | * |
| 66 | * ### Working Directory |
| 67 | * Use `cwd` to run the command in a subdirectory — useful in |
| 68 | * monorepos where each package has its own dev server. |
| 69 | * |
| 70 | * **Example:** Monorepo package |
| 71 | * ```typescript |
| 72 | * const dev = yield* Dev("Web", { |
| 73 | * command: "npm run dev", |
| 74 | * cwd: "apps/web", |
| 75 | * }); |
| 76 | * ``` |
| 77 | * |
| 78 | * ### Environment Variables |
| 79 | * Extra environment variables are merged on top of `process.env`. |
| 80 | * Sensitive values can be wrapped in `Redacted` to keep them out |
| 81 | * of logs and state files. |
| 82 | * |
| 83 | * **Example:** Custom port and env |
| 84 | * ```typescript |
| 85 | * const dev = yield* Dev("Api", { |
| 86 | * command: "npm run dev", |
| 87 | * env: { |
| 88 | * PORT: "4000", |
| 89 | * DATABASE_URL: Redacted.make("postgres://..."), |
| 90 | * }, |
| 91 | * }); |
| 92 | * ``` |
| 93 | * |
| 94 | * @resource |
| 95 | */ |
| 96 | export const Dev = Resource<Dev>("Command.Dev"); |
| 97 | |
| 98 | export const DevProvider = () => |
| 99 | ProviderLayer.dual(Dev, { |
| 100 | live: DevProviderLive, |
| 101 | local: DevProviderLocal, |
| 102 | }); |
| 103 | |
| 104 | export const DevProviderLive = () => |
| 105 | Provider.succeed(Dev, { |
| 106 | list: () => Effect.succeed([]), |
| 107 | diff: () => Effect.succeed({ action: "noop" }), |
| 108 | reconcile: () => Effect.succeed({ url: undefined }), |
| 109 | delete: () => Effect.void, |
| 110 | }); |
| 111 | |
| 112 | export const DevProviderLocal = () => |
| 113 | LocalProvider.make( |
| 114 | Dev, |
| 115 | import.meta.resolve( |
| 116 | `./Local${moduleExtension(import.meta.url)}`, |
| 117 | import.meta.url, |
| 118 | ), |
| 119 | Effect.gen(function* () { |
| 120 | const { spawn } = yield* CommandExecutor; |
| 121 | const stage = yield* Stage; |
| 122 | const openDevLog = yield* makeDevLogOpener; |
| 123 | const baseConsole = yield* ConsoleService.Console; |
| 124 | |
| 125 | return { |
| 126 | // The dev process is spawned into the instance scope the helper |
| 127 | // provides: it keeps running after `start` returns (readiness) and |
| 128 | // is killed when the helper closes the scope on restart/delete. |
| 129 | start: Effect.fn(function* ({ id, fqn, news: props, invalidate }) { |
| 130 | const child = yield* spawn(props); |
| 131 | const redactor = makeCommandRedactor(props.env); |
| 132 | // One log file per process generation, closed with the instance |
| 133 | // scope: log/{stage}/{fqn…}/{timestamp}.log (namespaces nest as dirs). Terminal lines |
| 134 | // carry the resource's pnpm-style prefix; the file gets raw text. |
| 135 | const devLog = yield* openDevLog(stage, ...fqn.split(FQN_SEPARATOR)); |
| 136 | yield* Effect.log(`[${fqn}] Logs → ${devLog.path}`); |
| 137 | // Through the Console SERVICE, never a raw fd write: the process |
| 138 | // that owns the terminal runs an ink renderer, and a bare write |
| 139 | // to its stdout tears the animated region. The CLI's console |
| 140 | // capture inserts these lines above it instead. |
| 141 | const prefixed = makeResourceOutput(fqn, baseConsole); |
| 142 | yield* Effect.addFinalizer(() => |
| 143 | Effect.sync(() => { |
| 144 | prefixed.stdout.flush(); |
| 145 | prefixed.stderr.flush(); |
| 146 | }), |
| 147 | ); |
| 148 | |
| 149 | let buffer = ""; |
| 150 | // A non-local URL seen so far (docs link, error page, update notice, |
| 151 | // …). Held as a fallback so that if the dev server never prints a |
| 152 | // localhost/IP URL we still surface something, but a localhost URL |
| 153 | // always wins if one shows up. See issue #695. |
| 154 | let fallbackUrl: string | undefined; |
| 155 | const deferred = yield* Deferred.make<string>(); |
| 156 | |
| 157 | const mirror = (sink: "stdout" | "stderr") => |
| 158 | child[sink].pipe( |
| 159 | Stream.decodeText, |
| 160 | redactor.stream, |
| 161 | Stream.tap((text) => |
| 162 | Effect.sync(() => { |
| 163 | prefixed[sink].push(text); |
| 164 | devLog.write(text); |
| 165 | }), |
| 166 | ), |
| 167 | Stream.tap((text) => |
| 168 | Effect.sync(() => { |
| 169 | if (Deferred.isDoneUnsafe(deferred)) return; |
| 170 | buffer += text; |
| 171 | const url = extractUrl(buffer); |
| 172 | if (!url) return; |
| 173 | if (isLocalUrl(url)) { |
| 174 | // The dev server's own address — resolve immediately. |
| 175 | Deferred.doneUnsafe(deferred, Effect.succeed(url)); |
| 176 | } else { |
| 177 | // Keep scanning: a localhost/IP URL may still appear. |
| 178 | fallbackUrl = url; |
| 179 | } |
| 180 | }), |
| 181 | ), |
| 182 | Stream.runDrain, |
| 183 | Effect.forkScoped, |
| 184 | ); |
| 185 | |
| 186 | yield* mirror("stdout"); |
| 187 | yield* mirror("stderr"); |
| 188 | |
| 189 | // Readiness: a URL appears (or the 5s budget elapses), unless the |
| 190 | // process exits first — an exit before readiness is a failure. |
| 191 | const url = yield* Effect.raceAllFirst([ |
| 192 | Deferred.await(deferred).pipe( |
| 193 | Effect.timeoutOrElse({ |
| 194 | duration: "5 seconds", |
| 195 | // No localhost/IP URL appeared in time — fall back to any |
| 196 | // other URL we saw (or `undefined` if it stayed silent). |
| 197 | orElse: () => Effect.succeed(fallbackUrl), |
| 198 | }), |
| 199 | ), |
| 200 | child.exitCode.pipe( |
| 201 | Effect.mapError((error) => makeCommandError(props, error.reason)), |
| 202 | Effect.flatMap((exitCode) => |
| 203 | makeCommandError( |
| 204 | props, |
| 205 | new UnexpectedExit({ exitCode, stderr: buffer }), |
| 206 | ), |
| 207 | ), |
| 208 | ), |
| 209 | ]); |
| 210 | |
| 211 | // The process may die on its own after readiness (crash, manual |
| 212 | // kill). Drop it from the running registry so the next plan |
| 213 | // reports `update` and restarts it. |
| 214 | yield* child.exitCode.pipe( |
| 215 | Effect.exit, |
| 216 | Effect.flatMap(() => invalidate), |
| 217 | Effect.forkScoped, |
| 218 | ); |
| 219 | |
| 220 | return { url }; |
| 221 | }), |
| 222 | } satisfies LocalProvider.LocalProviderSpec<Dev>; |
| 223 | }), |
| 224 | ); |
| 225 | |
| 226 | // Matches an http(s) URL whose host is `localhost`, an IPv4 address, or a |
| 227 | // bracketed IPv6 address — i.e. the shape a dev server prints for its own |
| 228 | // local address. Preferred over any other URL a command might print first |
| 229 | // (docs links, error pages, update notices). See issue #695. |
| 230 | const LOCAL_URL_REGEX = |
| 231 | /https?:\/\/(?:localhost|\[[0-9a-fA-F:]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?[^\s)\],"'`]*/; |
| 232 | |
| 233 | // Matches the first plain http(s) URL. Stops at whitespace and at a small |
| 234 | // set of punctuation typically used to wrap URLs in log output. |
| 235 | const URL_REGEX = /https?:\/\/[^\s)\],"'`]+/; |
| 236 | |
| 237 | /** |
| 238 | * Extract a URL from `text`, favoring a localhost/IP URL (the dev server's |
| 239 | * own address) over any other URL. Returns the first localhost/IP URL if one |
| 240 | * is present, otherwise the first plain http(s) URL, otherwise `undefined`. |
| 241 | * `Vite`, `Next`, etc. surround the URL with ANSI color codes that would |
| 242 | * otherwise be eaten by the URL regex, so strip them first. |
| 243 | * @internal |
| 244 | */ |
| 245 | export const extractUrl = (text: string) => { |
| 246 | const clean = stripVTControlCharacters(text); |
| 247 | const url = clean.match(LOCAL_URL_REGEX)?.[0] ?? clean.match(URL_REGEX)?.[0]; |
| 248 | // Some dev servers print their *bind* address (Nuxt: `http://0.0.0.0:3000`), |
| 249 | // which is not a connectable host. Normalize the unspecified address to |
| 250 | // `localhost` so consumers of `url` — browser links, Router dev routing, |
| 251 | // the emulated CloudFront edge dialing the origin — can actually reach it. |
| 252 | return url?.replace( |
| 253 | /^(https?:\/\/)(?:0\.0\.0\.0|\[::\]|\[0+:0+:0+:0+:0+:0+:0+:0+\])(?=[:/]|$)/, |
| 254 | "$1localhost", |
| 255 | ); |
| 256 | }; |
| 257 | |
| 258 | const isLocalUrl = (url: string) => LOCAL_URL_REGEX.test(url); |