| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { existsSync, readFileSync } from "node:fs"; |
| 4 | import { createRequire } from "node:module"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | import { pathToFileURL } from "node:url"; |
| 7 | |
| 8 | export 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 | */ |
| 24 | const 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 | */ |
| 66 | export 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 | */ |
| 111 | export 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 | |
| 134 | const 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 | */ |
| 159 | export 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 | }); |