1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import { existsSync, readFileSync } from "node:fs";
4import { createRequire } from "node:module";
5import * as NodePath from "node:path";
6import { pathToFileURL } from "node:url";
7
8export class ModuleLoadError extends Data.TaggedError<"ModuleLoadError">(
9 "ModuleLoadError",
10)<{
11 readonly specifier: string;
12 readonly root: string;
13 readonly cause?: unknown;
14}> {
15 override get message(): string {
16 return `Failed to load "${this.specifier}" from "${this.root}"`;
17 }
18}
19
20/**
21 * Whether Node treats `filePath` as CommonJS: `.cjs`, or `.js` whose nearest
22 * `package.json` does not declare `"type": "module"`.
23 */
24const isCommonJsFile = (filePath: string): boolean => {
25 if (filePath.endsWith(".cjs")) return true;
26 if (!filePath.endsWith(".js")) return false;
27 let directory = NodePath.dirname(filePath);
28 for (;;) {
29 const manifest = NodePath.join(directory, "package.json");
30 if (existsSync(manifest)) {
31 try {
32 const { type } = JSON.parse(readFileSync(manifest, "utf8")) as {
33 type?: unknown;
34 };
35 return type !== "module";
36 } catch {
37 return true;
38 }
39 }
40 const parent = NodePath.dirname(directory);
41 if (parent === directory) return true;
42 directory = parent;
43 }
44};
45
46/**
47 * Import a module from a *project's* dependency tree rather than our own —
48 * the project's `vite`, `astro`, `waku`, `next`, etc. must be the instance
49 * the framework integration drives, not whatever happens to be hoisted next
50 * to this package.
51 *
52 * Resolution runs `createRequire(<root>/package.json).resolve(specifier)` and
53 * imports the resolved absolute path as a `file://` URL (required for ESM
54 * `import()` on Windows). If project-relative resolution fails (e.g. a
55 * non-linked global install), it falls back to a bare `import(specifier)`.
56 *
57 * A CommonJS entry is loaded with `require`, not `import()`. Alchemy starts
58 * every Node child with its Oxc loader (`module.registerHooks`), and once a
59 * synchronous `load` hook exists Node's ESM loader runs *imported* CommonJS
60 * through a re-invented `require` that lacks `require.extensions` and
61 * `require.cache` (nodejs/node#59666) — `next` patches `require.extensions`
62 * as it loads and dies with "Cannot read properties of undefined (reading
63 * '.js')". The real CommonJS loader still consults the hooks, so project
64 * TypeScript reached through `require()` keeps transpiling.
65 */
66export const loadProjectModule = <T = unknown>(
67 root: string,
68 specifier: string,
69): Effect.Effect<T, ModuleLoadError> =>
70 Effect.tryPromise({
71 try: async () => {
72 let primary: unknown;
73 try {
74 const require = createRequire(NodePath.resolve(root, "package.json"));
75 const resolved = require.resolve(specifier);
76 if (isCommonJsFile(resolved)) return require(resolved) as T;
77 return (await import(
78 /* @vite-ignore */ pathToFileURL(resolved).href
79 )) as T;
80 } catch (cause) {
81 primary = cause;
82 }
83 try {
84 // Fallback: a bare specifier resolves from our own module graph
85 // (works for non-linked installs).
86 return (await import(/* @vite-ignore */ specifier)) as T;
87 } catch (fallback) {
88 // The project-relative attempt is the meaningful failure — surface
89 // it as the message and cause; the bare fallback failing only says
90 // the specifier isn't in OUR graph, so it rides along in `errors`.
91 const error = new AggregateError([primary, fallback], String(primary));
92 error.cause = primary;
93 throw error;
94 }
95 },
96 catch: (cause) => new ModuleLoadError({ specifier, root, cause }),
97 });
98
99/**
100 * Resolve the directory of a project's installed package (the directory
101 * containing its `package.json`). Useful for deep paths that are not in the
102 * package's exports map (e.g. `waku/dist/lib/vite-entries/entry.server.js`).
103 *
104 * Prefer `createRequire(<root>/package.json).resolve(name/package.json)`.
105 * When that subpath is not exported (ESM-only packages such as `vinext`),
106 * search `require.resolve.paths(name)` — Node's CJS lookup, which Vite's
107 * ESM `import.meta.resolve` hooks do not intercept. Do not use
108 * `import.meta.resolve(name, projectPackageJson)`: after `vite.createBuilder`
109 * those hooks ignore parentURL and resolve from this package instead.
110 */
111export const resolveProjectPackageDirectory = (
112 root: string,
113 packageName: string,
114): Effect.Effect<string, ModuleLoadError> =>
115 Effect.try({
116 try: () => {
117 const require = createRequire(NodePath.resolve(root, "package.json"));
118 try {
119 return NodePath.dirname(require.resolve(`${packageName}/package.json`));
120 } catch (cause) {
121 const fallback = packageDirFromRequirePaths(require, packageName);
122 if (fallback !== undefined) return fallback;
123 throw cause;
124 }
125 },
126 catch: (cause) =>
127 new ModuleLoadError({
128 specifier: `${packageName}/package.json`,
129 root,
130 cause,
131 }),
132 });
133
134const packageDirFromRequirePaths = (
135 require: ReturnType<typeof createRequire>,
136 packageName: string,
137): string | undefined => {
138 for (const dir of require.resolve.paths(packageName) ?? []) {
139 const pkgJson = NodePath.join(dir, packageName, "package.json");
140 if (!existsSync(pkgJson)) continue;
141 try {
142 const name = (
143 JSON.parse(readFileSync(pkgJson, "utf8")) as { name?: unknown }
144 ).name;
145 if (name === packageName) return NodePath.dirname(pkgJson);
146 } catch {
147 continue;
148 }
149 }
150 return undefined;
151};
152
153/**
154 * Best-effort version of `packageName` as resolved from `fromDirectory`
155 * (`undefined` when unresolvable). Used to feature-detect a dependency of a
156 * project package — e.g. the Vite that Astro itself resolves — without
157 * loading the module.
158 */
159export const resolveInstalledPackageVersion = (
160 fromDirectory: string,
161 packageName: string,
162): Effect.Effect<string | undefined> =>
163 Effect.sync(() => {
164 try {
165 const require = createRequire(
166 NodePath.join(fromDirectory, "package.json"),
167 );
168 const pkg = require(`${packageName}/package.json`) as {
169 version?: unknown;
170 };
171 return typeof pkg.version === "string" ? pkg.version : undefined;
172 } catch {
173 return undefined;
174 }
175 });