1import * as ConsoleService from "effect/Console";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { stripVTControlCharacters } from "node:util";
6import { makeResourceOutput } from "../Util/ResourceOutput.ts";
7import { makeDevLogOpener } from "../Local/DevLog.ts";
8import { FQN_SEPARATOR } from "../FQN.ts";
9import * as LocalProvider from "../Local/LocalProvider.ts";
10import * as ProviderLayer from "../Local/ProviderLayer.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import { Stage } from "../Stage.ts";
14import {
15 CommandExecutor,
16 UnexpectedExit,
17 makeCommandError,
18 type CommandProps,
19} from "./Command.ts";
20import { makeCommandRedactor } from "./Redaction.ts";
21import { moduleExtension } from "../Util/Node.ts";
22
23export interface DevProps extends CommandProps {}
24
25export 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 */
96export const Dev = Resource<Dev>("Command.Dev");
97
98export const DevProvider = () =>
99 ProviderLayer.dual(Dev, {
100 live: DevProviderLive,
101 local: DevProviderLocal,
102 });
103
104export 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
112export 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.
230const 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.
235const 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 */
245export 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
258const isLocalUrl = (url: string) => LOCAL_URL_REGEX.test(url);