Skip to content
Source
Sign inSign up

Dev.ts

340 lines13.2 KB

1import * as NodeNet from "node:net";
2import { stripVTControlCharacters } from "node:util";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import { SystemError } from "effect/PlatformError";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import * as LocalProvider from "../Local/LocalProvider.ts";
9import * as ProviderLayer from "../Local/ProviderLayer.ts";
10import * as Provider from "../Provider.ts";
11import { Resource } from "../Resource.ts";
12import { moduleExtension } from "../Util/Node.ts";
13import { makeResourceOutput } from "../Util/ResourceOutput.ts";
14import { CommandExecutor, UnexpectedExit, makeCommandError, type CommandProps } from "./Command.ts";
15import { makeCommandRedactor } from "./Redaction.ts";
16​
17export interface DevProps extends CommandProps {
18 /**
19 * Make the URL required: the resource is ready only once the command
20 * prints a URL, however long that takes, and fails if the command exits
21 * first. Without it, the resource waits 5 seconds and settles with
22 * `url: undefined` when no URL appears.
23 *
24 * A localhost/IP URL is still preferred: when the first URL printed is
25 * another one, the resource waits up to 5 seconds more for a local one
26 * before settling with it.
27 *
28 * @default false
29 */
30 waitForUrl?: boolean;
31 /**
32 * The port the command listens on. Alchemy sets `PORT` to it, over any
33 * `env.PORT`, and the resource is ready once the port accepts a
34 * connection, however long that takes. Output isn't scanned for a URL,
35 * so `waitForUrl` has no effect: `url` is `http://localhost:<port>`.
36 *
37 * The resource fails if the command exits before the port opens, or if
38 * another process already listens on the port when the command starts.
39 */
40 port?: number;
42​
43export interface Dev extends Resource<
44 "Command.Dev",
45 DevProps,
46 {
47 /**
48 * URL extracted from stdout/stderr. A `localhost`/IP URL (the dev
49 * server's own address) is preferred over any other URL the command
50 * prints; a non-local URL is only used as a fallback if no local one
51 * appears. Best-effort: `undefined` if no URL appears within 5 seconds,
52 * unless `waitForUrl` is set, which waits until one does. With `port`,
53 * it is `http://localhost:<port>`.
54 */
55 url: string | undefined;
56 }
57> {}
58​
59/**
60 * A long-lived shell process scoped to a stack instance, started during
61 * `alchemy dev` and restarted when its inputs change. During `alchemy deploy`
62 * this is a no-op — `Dev` resources only run in dev mode.
63 *
64 * The child process runs inside the dev sidecar (see `Command/Local.ts`) so it
65 * survives user-code HMR — Alchemy's user process can restart without killing
66 * your `npm run dev` server. Its stdout/stderr are mirrored to the terminal
67 * (preserving colored output) and scanned for an `http(s)://…` URL, favoring
68 * a `localhost`/IP URL (the dev server's own address) over any unrelated URL
69 * the command prints first. The result is exposed as the `url` output
70 * attribute — useful for surfacing a dev server's local URL back out to
71 * whatever resource declared this `Dev`.
72 *
73 *
74 * ### Basic Usage
75 * Pass a shell command that starts a long-lived dev server. Alchemy
76 * runs it in the background and extracts the first URL it prints.
77 *
78 * **Example:** Start a Vite dev server
79 * ```typescript
80 * const dev = yield* Dev("Frontend", {
81 * command: "npm run dev",
82 * });
83 * yield* Console.log(dev.url); // e.g. "http://localhost:5173"
84 * ```
85 *
86 * ### Requiring a URL
87 * By default the resource waits 5 seconds for a URL and settles with
88 * `url: undefined` if none appears. Set `waitForUrl` when downstream
89 * resources depend on the URL: the resource is ready only once the
90 * command prints one, however long the server takes to start, and fails
91 * if the command exits first.
92 *
93 * **Example:** Wait for a slow server's URL
94 * ```typescript
95 * const engine = yield* Dev("Engine", {
96 * command: "rivet-engine start",
97 * waitForUrl: true,
98 * });
99 * ```
100 *
101 * ### Known Port
102 * When you choose the port, pass it as `port`: Alchemy sets `PORT` to it
103 * and the resource is ready once the port accepts a connection, with no
104 * output scanning. This suits servers that print no URL, and wrappers
105 * that route the port elsewhere.
106 *
107 * **Example:** Serve on port 4000
108 * ```typescript
109 * const api = yield* Dev("Api", {
110 * command: "node server.ts",
111 * port: 4000,
112 * });
113 * yield* Console.log(api.url); // "http://localhost:4000"
114 * ```
115 *
116 * ### Working Directory
117 * Use `cwd` to run the command in a subdirectory — useful in
118 * monorepos where each package has its own dev server.
119 *
120 * **Example:** Monorepo package
121 * ```typescript
122 * const dev = yield* Dev("Web", {
123 * command: "npm run dev",
124 * cwd: "apps/web",
125 * });
126 * ```
127 *
128 * ### Environment Variables
129 * Extra environment variables are merged on top of `process.env`.
130 * Sensitive values can be wrapped in `Redacted` to keep them out
131 * of logs and state files.
132 *
133 * **Example:** Custom port and env
134 * ```typescript
135 * const dev = yield* Dev("Api", {
136 * command: "npm run dev",
137 * env: {
138 * PORT: "4000",
139 * DATABASE_URL: Redacted.make("postgres://..."),
140 * },
141 * });
142 * ```
143 *
144 * @resource
145 */
146export const Dev = Resource<Dev>("Command.Dev");
147​
148export const DevProvider = () =>
149 ProviderLayer.dual(Dev, {
150 live: DevProviderLive,
151 local: DevProviderLocal,
152 });
153​
154export const DevProviderLive = () =>
155 Provider.succeed(Dev, {
156 list: () => Effect.succeed([]),
157 diff: () => Effect.succeed({ action: "noop" }),
158 reconcile: () => Effect.succeed({ url: undefined }),
159 delete: () => Effect.void,
160 });
161​
162export const DevProviderLocal = () =>
163 LocalProvider.make(
164 Dev,
165 import.meta.resolve(`./Local${moduleExtension(import.meta.url)}`, import.meta.url),
166 Effect.gen(function* () {
167 const { spawn } = yield* CommandExecutor;
168​
169 return {
170 // The dev process is spawned into the instance scope the helper
171 // provides: it keeps running after `start` returns (readiness) and
172 // is killed when the helper closes the scope on restart/delete.
173 start: Effect.fn(function* ({ fqn, news: props, invalidate }) {
174 const { port } = props;
175 // A restart has already killed the previous process group, so a
176 // listener now is someone else's (an orphaned server, say), and
177 // the readiness probe below would mistake it for this command.
178 if (port !== undefined && (yield* isListening(port))) {
179 return yield* makeCommandError(
180 props,
181 new SystemError({
182 _tag: "Busy",
183 module: "Command",
184 method: "Dev",
185 pathOrDescriptor: port,
186 description: "another process already listens on this port",
187 }),
188 );
189 }
190 const child = yield* spawn(
191 port === undefined ? props : { ...props, env: { ...props.env, PORT: `${port}` } },
192 );
193 const redactor = makeCommandRedactor(props.env);
194 // Through the logger, never a raw fd write: the process that owns
195 // the terminal renders the records and writes them to the run log
196 // (and a bare write to its stdout would tear the animated region).
197 const prefixed = yield* makeResourceOutput(fqn);
198 yield* Effect.addFinalizer(() =>
199 Effect.sync(() => {
200 prefixed.stdout.flush();
201 prefixed.stderr.flush();
202 }),
203 );
204​
205 let buffer = "";
206 // A non-local URL seen so far (docs link, error page, update notice,
207 // …). Held as a fallback so that if the dev server never prints a
208 // localhost/IP URL we still surface something, but a localhost URL
209 // always wins if one shows up. See issue #695.
210 let fallbackUrl: string | undefined;
211 const deferred = yield* Deferred.make<string>();
212 // Done when the first URL of any kind appears.
213 const anyUrl = yield* Deferred.make<void>();
214​
215 const mirror = (sink: "stdout" | "stderr") =>
216 child[sink].pipe(
217 Stream.decodeText,
218 redactor.stream,
219 Stream.tap((text) => Effect.sync(() => prefixed[sink].push(text))),
220 Stream.tap((text) =>
221 Effect.sync(() => {
222 if (Deferred.isDoneUnsafe(deferred)) return;
223 buffer += text;
224 const url = extractUrl(buffer);
225 if (!url) return;
226 Deferred.doneUnsafe(anyUrl, Effect.void);
227 if (isLocalUrl(url)) {
228 // The dev server's own address — resolve immediately.
229 Deferred.doneUnsafe(deferred, Effect.succeed(url));
230 } else {
231 // Keep scanning: a localhost/IP URL may still appear.
232 fallbackUrl = url;
233 }
234 }),
235 ),
236 Stream.runDrain,
237 Effect.forkScoped,
238 );
239​
240 yield* mirror("stdout");
241 yield* mirror("stderr");
242​
243 // Readiness: a localhost/IP URL appears, or 5 seconds pass and we
244 // settle with any other URL we saw (`undefined` with none). With
245 // `waitForUrl`, those 5 seconds start only once some URL appears,
246 // so the URL is required and there is no deadline: the only other
247 // way out is the process exiting, which is a failure. With `port`,
248 // readiness is the port opening instead, again with no deadline.
249 const awaitLocalUrl = Deferred.await(deferred).pipe(
250 Effect.timeoutOrElse({
251 duration: "5 seconds",
252 orElse: () => Effect.succeed(fallbackUrl),
253 }),
254 );
255 const url = yield* Effect.raceAllFirst([
256 port !== undefined
257 ? isListening(port).pipe(
258 Effect.repeat({
259 schedule: Schedule.spaced("100 millis"),
260 until: (listening) => listening,
261 }),
262 Effect.as(`http://localhost:${port}`),
263 )
264 : props.waitForUrl
265 ? Deferred.await(anyUrl).pipe(Effect.andThen(awaitLocalUrl))
266 : awaitLocalUrl,
267 child.exitCode.pipe(
268 Effect.mapError((error) => makeCommandError(props, error.reason)),
269 Effect.flatMap((exitCode) =>
270 makeCommandError(props, new UnexpectedExit({ exitCode, stderr: buffer })),
271 ),
272 ),
273 ]);
274​
275 // The process may die on its own after readiness (crash, manual
276 // kill). Drop it from the running registry so the next plan
277 // reports `update` and restarts it.
278 yield* child.exitCode.pipe(
279 Effect.exit,
280 Effect.flatMap(() => invalidate),
281 Effect.forkScoped,
282 );
283​
284 return { url };
285 }),
286 } satisfies LocalProvider.LocalProviderSpec<Dev>;
287 }),
288 );
289​
290// Matches an http(s) URL whose host is `localhost`, an IPv4 address, or a
291// bracketed IPv6 address — i.e. the shape a dev server prints for its own
292// local address. Preferred over any other URL a command might print first
293// (docs links, error pages, update notices). See issue #695.
294const LOCAL_URL_REGEX =
295 /https?:\/\/(?:localhost|\[[0-9a-fA-F:]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?[^\s)\],"'`]*/;
296​
297// Matches the first plain http(s) URL. Stops at whitespace and at a small
298// set of punctuation typically used to wrap URLs in log output.
299const URL_REGEX = /https?:\/\/[^\s)\],"'`]+/;
300​
301/**
302 * Extract a URL from `text`, favoring a localhost/IP URL (the dev server's
303 * own address) over any other URL. Returns the first localhost/IP URL if one
304 * is present, otherwise the first plain http(s) URL, otherwise `undefined`.
305 * `Vite`, `Next`, etc. surround the URL with ANSI color codes that would
306 * otherwise be eaten by the URL regex, so strip them first.
307 * @internal
308 */
309export const extractUrl = (text: string) => {
310 const clean = stripVTControlCharacters(text);
311 const url = clean.match(LOCAL_URL_REGEX)?.[0] ?? clean.match(URL_REGEX)?.[0];
312 // Some dev servers print their *bind* address (Nuxt: `http://0.0.0.0:3000`),
313 // which is not a connectable host. Normalize the unspecified address to
314 // `localhost` so consumers of `url` — browser links, Router dev routing,
315 // the emulated CloudFront edge dialing the origin — can actually reach it.
316 return url?.replace(
317 /^(https?:\/\/)(?:0\.0\.0\.0|\[::\]|\[0+:0+:0+:0+:0+:0+:0+:0+\])(?=[:/]|$)/,
318 "$1localhost",
319 );
320};
321​
322const isLocalUrl = (url: string) => LOCAL_URL_REGEX.test(url);
323​
324// Whether a TCP connection to `port` succeeds on either loopback address:
325// a server bound to `localhost` may listen on only one of them.
326const isListening = (port: number) =>
327 Effect.all([connects("127.0.0.1", port), connects("::1", port)], {
328 concurrency: "unbounded",
329 }).pipe(Effect.map(([ipv4, ipv6]) => ipv4 || ipv6));
330​
331const connects = (host: string, port: number) =>
332 Effect.callback<boolean>((resume) => {
333 const socket = NodeNet.connect({ host, port });
334 socket.once("connect", () => {
335 socket.destroy();
336 resume(Effect.succeed(true));
337 });
338 socket.once("error", () => resume(Effect.succeed(false)));
339 return Effect.sync(() => socket.destroy());
340 });