| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * 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 | */ |
| 15 | export 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 | */ |
| 23 | export 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 | */ |
| 54 | export 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 | */ |
| 70 | export 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 | */ |
| 89 | export 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 | */ |
| 105 | export 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 | }); |