1import * as Effect from "effect/Effect";
2import * as NodeNet from "node:net";
3
4/**
5 * The process's working directory, captured ONCE at module load.
6 *
7 * Relative user paths (Vite roots, asset directories) must resolve against
8 * this instead of a live `process.cwd()` read: third-party code sharing the
9 * process can change the working directory transiently — most notably
10 * cross-spawn, which `process.chdir`s into a spawn's `cwd` to resolve its
11 * binary on PATH and then chdirs back. A live read racing that window
12 * resolves against an unrelated directory. (`State/LocalState.ts` anchors
13 * the state tree the same way, for the same reason.)
14 */
15export const initialCwd: string = process.cwd();
16
17/**
18 * The extension of alchemy's own modules next to `importMetaUrl`: `.ts`
19 * when running from `src/` (Bun, or Node under the dev loader), `.js`
20 * from the compiled `lib/`. The dev loader namespaces file URLs with a
21 * query, so only the pathname is consulted.
22 */
23export const moduleExtension = (importMetaUrl: string) =>
24 new URL(importMetaUrl).pathname.endsWith(".ts") ? ".ts" : ".js";
25
26/**
27 * Opt out of cross-spawn's temporary `process.chdir` dance.
28 *
29 * cross-spawn (bundled inside vite, wrangler, next, tinyexec, playwright,
30 * tsx, prisma, …) resolves a spawn's binary by `process.chdir`ing the
31 * WHOLE parent process into the spawn's `cwd`, calling `which.sync`, and
32 * chdiring back. In an alchemy process — which runs many resource builds
33 * and hashers concurrently on one event loop — every fiber that touches a
34 * relative path can race that window and resolve against an unrelated
35 * directory.
36 *
37 * `process.chdir.disabled = true` is NODE'S OWN convention: Node sets it
38 * on `process.chdir` inside worker threads (where chdir is forbidden),
39 * and cross-spawn checks it to skip the dance. Setting it on the main
40 * thread opts us into the exact mode every cross-spawn copy already runs
41 * in inside jest/vitest/playwright workers daily — not an untested path.
42 * The flag is purely advisory: `process.chdir` itself keeps working, and
43 * an audit of the dependency tree shows the only readers are bundled
44 * cross-spawn copies; nothing else writes or consults it.
45 *
46 * Behavior delta: a spawn combining a custom `cwd` with a command that is
47 * neither absolute nor on PATH (e.g. `./scripts/build.sh`). POSIX still
48 * works (cross-spawn falls back to the raw command; the OS resolves it in
49 * the child's cwd). On Windows that shape can fail to resolve — the same
50 * limitation those tools already have inside worker threads. Everything
51 * alchemy and its toolchains spawn is absolute or on PATH, and `Command`
52 * resources use effect's ChildProcess (plain spawn, no cross-spawn).
53 */
54export const disableCrossSpawnChdir = (): void => {
55 (process.chdir as { disabled?: boolean }).disabled = true;
56};
57
58/**
59 * Node arguments that install alchemy's Oxc loader in a child process, the
60 * same way `bin/cli.js` installs it for the CLI itself. Every `.ts`/`.tsx`
61 * a Node process loads — alchemy's own source in a checkout, the user's
62 * stack everywhere — goes through it; nothing relies on Node's built-in
63 * TypeScript support (strip-only, and Node 26 removed the transform flag).
64 *
65 * A `.ts` entry means a checkout: `bin/register-dev-mode.js` adds the
66 * src-condition resolution for workspace packages on top of the loader.
67 * A `.js` entry means a published install: `bin/register-oxc.js` is the
68 * loader alone.
69 */
70export const nodeLoaderArgs = (entry: string): string[] => [
71 "--import",
72 import.meta.resolve(
73 /\.[cm]?tsx?$/.test(entry)
74 ? "../../bin/register-dev-mode.js"
75 : "../../bin/register-oxc.js",
76 ),
77];
78
79/**
80 * Whether this Node supports the synchronous in-thread loader hooks
81 * (`module.registerHooks`, v22.15 / v23.5) that `bin/register-dev-mode.js`
82 * needs. This is THE capability gate for running the CLI from source under
83 * node: with the hooks installed, the Oxc loader handles every `.ts`/`.tsx`
84 * file regardless of Node's own TypeScript support.
85 *
86 * `bin/cli.js` mirrors this predicate inline — it must run under plain node
87 * before any `.ts` can load. Keep the two in sync.
88 */
89export const isRegisterHooksSupported = (
90 version = process.versions.node,
91): boolean => {
92 const [major = 0, minor = 0] = version.split(".").map(Number);
93 return (
94 (major === 22 && minor >= 15) || (major === 23 && minor >= 5) || major >= 24
95 );
96};
97
98/**
99 * Ask the OS for an unused TCP port, release it, and return its number.
100 *
101 * The port is only available, not reserved: another process can claim it
102 * before the caller binds. Callers should keep the gap short and still handle
103 * `EADDRINUSE`.
104 */
105export const findAvailablePort = (host = "127.0.0.1") =>
106 Effect.callback<number, Error>((resume) => {
107 const server = NodeNet.createServer();
108 server.unref();
109 server.once("error", (error) => resume(Effect.fail(error)));
110 server.listen(0, host, () => {
111 const address = server.address();
112 const port =
113 typeof address === "object" && address !== null
114 ? address.port
115 : undefined;
116 server.close((error) => {
117 if (error) {
118 resume(Effect.fail(error));
119 } else if (port !== undefined) {
120 resume(Effect.succeed(port));
121 } else {
122 resume(
123 Effect.fail(new Error("Failed to allocate an available port")),
124 );
125 }
126 });
127 });
128 });