Files
oh-my-pi/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts
T
can1357 33c161d9dd refactor(coding-agent): restructured plugin system and build logic
- Migrated legacy static bundled-module registry to a dynamic Bun-based in-memory plugin system.
- Removed over 4,000 lines of manually maintained registry files to reduce maintenance overhead.
- Implemented CJS absolute path rewriting and native-addon load hooks to improve FFI dependency resolution.
- Standardized binary build processes by centralizing compilation logic into shared utilities.
2026-07-11 07:33:22 +02:00

1434 lines
55 KiB
TypeScript

import * as fs from "node:fs";
import { isBuiltin } from "node:module";
import * as path from "node:path";
import * as url from "node:url";
import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
import { registerPluginCacheInvalidator } from "../../discovery/helpers";
const IS_COMPILED_BINARY = isCompiledBinary();
// === Bundled host modules (issue #3423) ===
//
// Bun 1.3.14 stopped exposing `--compile` extras through any filesystem-style
// API: `fs.existsSync`, `Bun.file().exists()`, `Bun.resolveSync`, and even
// `import("/$bunfs/...")` / `import("file:///$bunfs/...")` all fail for the
// embedded entries. Bun.plugin `onResolve` also no longer fires for transitive
// imports inside runtime-loaded extensions.
//
// Compiled builds therefore keep live JS-heap references to the host packages
// and serve requested surfaces through `omp-legacy-pi-bundled:<key>` synthetic
// modules. `scripts/legacy-pi-virtual-module.ts` derives the static import edges
// from current package exports inside a Bun build plugin: no generated source
// or duplicate key list exists on disk. Runtime extension loading stays lazy —
// the virtual module is evaluated only when an extension requests a host
// package — but the compiler still sees every possible edge at build time.
const BUNDLED_VIRTUAL_SCHEME = "omp-legacy-pi-bundled:";
const BUNDLED_VIRTUAL_NAMESPACE = "omp-legacy-pi-bundled";
const BUNDLED_MODULES_GLOBAL = "__ompLegacyPiBundledModules";
const TYPEBOX_BUNDLED_MODULE_KEY = "typebox";
type BundledModules = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
let bundledModulesPromise: Promise<BundledModules> | null = null;
/**
* Lazy-load the build-supplied host modules and stash them on `globalThis` for
* the synthetic module source emitted by `synthesizeBundledModuleSource`.
*
* `globalThis` is the bridge: each `omp-legacy-pi-bundled:<key>` source string
* becomes a separate ES module and cannot close over this file's lexical scope.
* The dynamic import is intentional conditional build code. Dev/test runs
* never execute it; binary builds resolve the literal through the in-memory
* plugin in `scripts/legacy-pi-virtual-module.ts`.
*/
function ensureBundledModulesLoaded(): Promise<BundledModules> {
if (!IS_COMPILED_BINARY) {
return Promise.reject(new Error("omp:legacy-pi-shim: bundled modules are only available in compiled mode"));
}
if (!bundledModulesPromise) {
bundledModulesPromise = import("omp-legacy-pi-modules").then(module => {
Reflect.set(globalThis, BUNDLED_MODULES_GLOBAL, module.BUNDLED_PI_MODULES);
return module.BUNDLED_PI_MODULES;
});
}
return bundledModulesPromise;
}
function bundledModuleVirtualSpecifier(moduleKey: string): string {
return `${BUNDLED_VIRTUAL_SCHEME}${moduleKey}`;
}
function isBundledVirtualSpecifier(value: string): boolean {
return value.startsWith(BUNDLED_VIRTUAL_SCHEME);
}
/**
* Build a synthetic ES module for one live bundled namespace. Every export
* reads through the global bridge; no bunfs path or copied package is involved.
*/
function synthesizeBundledModuleSourceFromModules(moduleKey: string, modules: BundledModules): string {
const mod = modules[moduleKey];
if (!mod) {
throw new Error(`omp:legacy-pi-shim: no bundled module registered for ${moduleKey}`);
}
const lines: string[] = [
`const __omp_bundled = globalThis[${JSON.stringify(BUNDLED_MODULES_GLOBAL)}][${JSON.stringify(moduleKey)}];`,
];
let hasDefault = false;
for (const exportName in mod) {
if (exportName === "default") {
hasDefault = true;
continue;
}
lines.push(`export const ${exportName} = __omp_bundled[${JSON.stringify(exportName)}];`);
}
if (hasDefault) {
lines.push("export default __omp_bundled.default;");
}
lines.push("");
return lines.join("\n");
}
/**
* Build the synthetic source served for one
* `omp-legacy-pi-bundled:<key>` import.
*/
async function synthesizeBundledModuleSource(moduleKey: string): Promise<string> {
const modules = await ensureBundledModulesLoaded();
return synthesizeBundledModuleSourceFromModules(moduleKey, modules);
}
/** Test seam for the virtual module's named/default export forwarding. */
export function __synthesizeLegacyPiBundledSourceWithModules(
moduleKey: string,
modules: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
): string {
return synthesizeBundledModuleSourceFromModules(moduleKey, modules);
}
/** Test seam for the global bridge key shared with synthetic module source. */
export function __getLegacyPiBundledModulesGlobal(): string {
return BUNDLED_MODULES_GLOBAL;
}
// Canonical scope for in-process pi packages. Plugins published against any of
// the aliased scopes below (mariozechner's original publish, earendil-works'
// fork, or the canonical @oh-my-pi scope itself) are remapped to this scope and
// resolved against the bundled copy that ships inside the omp binary. This
// keeps plugins running against the exact runtime state of the host (single
// module registry, single tool registry, etc.) regardless of which historical
// scope name they happened to declare in their peerDependencies.
const CANONICAL_PI_SCOPE = "@oh-my-pi";
// Scopes that have historically been used to publish (or alias) the same set
// of internal pi-* packages. `@oh-my-pi` is intentionally included so direct
// canonical imports still pass through the same host-bundled package resolution
// path instead of pulling a duplicate copy from plugin node_modules.
const PI_SCOPE_ALIASES = ["oh-my-pi", "mariozechner", "earendil-works"] as const;
// Internal pi-* package basenames bundled inside the omp binary.
const PI_PACKAGE_NAMES = ["pi-agent-core", "pi-ai", "pi-coding-agent", "pi-natives", "pi-tui", "pi-utils"] as const;
const PI_SCOPE_ALTERNATION = PI_SCOPE_ALIASES.join("|");
const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|");
// Upstream `@mariozechner/*` packages exposed a few subpaths at the package
// root that we relocated under a different folder. Each entry rewrites
// `<pkg>/<from>` → `<pkg>/<to>` after the scope has been canonicalised, so
// plugins importing the upstream layout still resolve to a real file in our
// bundled copy. Entries ending in `/` rewrite the whole subtree; add new
// `pkg/from -> pkg/to` pairs whenever an upstream-only subpath breaks resolution.
const PI_SUBPATH_REMAPS: ReadonlyMap<string, string> = new Map<string, string>([
["pi-ai/utils/oauth", "pi-ai/oauth"],
["pi-ai/utils/oauth/", "pi-ai/oauth/"],
]);
function remapLegacyPiSubpath(rest: string): string {
const exact = PI_SUBPATH_REMAPS.get(rest);
if (exact) {
return exact;
}
for (const [from, to] of PI_SUBPATH_REMAPS) {
if (from.endsWith("/") && rest.startsWith(from)) {
return `${to}${rest.slice(from.length)}`;
}
}
return rest;
}
const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`);
const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp(
`((?:from\\s+|import\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
"g",
);
const resolvedSpecifierFallbacks = new Map<string, string>();
const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"] as const;
const SUPPORTED_PACKAGE_IMPORT_CONDITIONS = new Set(["bun", "node", "import", "default"]);
const packageRootCache = new Map<string, string | null>();
const packageImportsCache = new Map<string, Record<string, unknown> | null>();
const nodePackageRootCache = new Map<string, Promise<string | null>>();
const packageManifestCache = new Map<string, Promise<Record<string, unknown> | null>>();
const bareDependencyResolutionCache = new Map<string, Promise<string | null>>();
const realpathCache = new Map<string, Promise<string>>();
const nativeAddonResolutionCache = new Map<string, Promise<string | null>>();
const nativeAddonRequireScanCache = new Map<string, Promise<boolean>>();
const nativeAddonLoaderModulePaths = new Set<string>();
function clearLegacyPiResolutionCaches(): void {
resolvedSpecifierFallbacks.clear();
packageRootCache.clear();
packageImportsCache.clear();
nodePackageRootCache.clear();
packageManifestCache.clear();
bareDependencyResolutionCache.clear();
nativeAddonResolutionCache.clear();
nativeAddonRequireScanCache.clear();
nativeAddonLoaderModulePaths.clear();
realpathCache.clear();
}
registerPluginCacheInvalidator(clearLegacyPiResolutionCaches);
const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
// Extensions that imported TypeBox directly used to resolve against a real
// `@sinclair/typebox` or `typebox` install. The runtime dep was replaced with
// the Zod-backed shim under `extensibility/typebox.ts`; plugins still importing
// either public name are redirected to that shim so existing extensions keep
// working without code changes. Submodules like `@sinclair/typebox/compiler`
// are intentionally not remapped — those expose TypeBox-only APIs the shim does
// not provide and plugins relying on them must vendor TypeBox directly.
const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
// Compat-shim path resolution. In compiled-binary mode every bundled surface
// is served through the `omp-legacy-pi-bundled:` virtual namespace (see the
// bundled-module block above) — bunfs paths are unreachable on Bun 1.3.14+, so the
// pre-#3423 helpers that derived `/$bunfs/root/...` paths from
// `import.meta.dir` are gone. Dev / source-link / installed-package modes
// still need a real filesystem path for the source shims, which
// `sourceShimPath` computes either from the npm prebuilt `dist/cli.js`
// bundle (`PI_BUNDLED=true`) or directly from the monorepo source tree.
/**
* Compute the package root for the npm prebuilt `dist/cli.js` bundle.
*
* `bundle-dist.ts` defines `process.env.PI_BUNDLED="true"`; after bundling,
* `import.meta.dir` points at `<package>/dist`. Do not resolve the package via
* bare `@oh-my-pi/pi-coding-agent` here: from a global install Bun can pick an
* older cache entry, recreating mixed-runtime plugin loading.
*/
export function __computeBundledSelfPackageRoot(metaDir: string, pathImpl: typeof path = path): string {
const normalizedMetaDir = pathImpl.normalize(metaDir);
if (pathImpl.basename(normalizedMetaDir) === "dist") {
return pathImpl.resolve(metaDir, "..");
}
const pluginsDirSuffix = pathImpl.join("src", "extensibility", "plugins");
if (normalizedMetaDir.endsWith(pluginsDirSuffix)) {
return pathImpl.resolve(metaDir, "..", "..", "..");
}
return pathImpl.resolve(metaDir);
}
function resolveBundledSelfPackageRoot(): string | undefined {
if (!process.env.PI_BUNDLED) return undefined;
return __computeBundledSelfPackageRoot(import.meta.dir);
}
const BUNDLED_SELF_PACKAGE_ROOT = resolveBundledSelfPackageRoot();
function sourceShimPath(file: string): string {
return BUNDLED_SELF_PACKAGE_ROOT
? path.join(BUNDLED_SELF_PACKAGE_ROOT, "src", "extensibility", file)
: path.resolve(import.meta.dir, "..", file);
}
/**
* Resolve the path the TypeBox compatibility shim ships at, then drop it when
* the source file is missing.
*
* In compiled-binary mode the shim is served through the
* `omp-legacy-pi-bundled:` virtual namespace (issue #3423) — bunfs paths are
* unreachable on Bun 1.3.14+, so the virtual specifier is always available and
* needs no filesystem probe. In dev / source-link / installed-package mode the
* shim is an on-disk source file; validation mirrors
* `__validateLegacyPiPackageRootOverrides` (#2168): if the computed candidate
* doesn't exist (e.g. an install that dropped the source — issue #3414),
* `resolveTypeBoxSpecifier` returns `undefined` and
* `rewriteLegacyExtensionSource` leaves bare `typebox` / `@sinclair/typebox`
* specifiers alone, so Bun falls through to native resolution against the
* extension's own `node_modules`.
*
* Exported for tests; production callers use `TYPEBOX_SHIM_PATH`.
*/
export function __resolveTypeBoxShimPath(
isCompiled: boolean,
sourcePath: string,
pathExistsSync: (p: string) => boolean = fs.existsSync,
): string | null {
if (isCompiled) {
return bundledModuleVirtualSpecifier(TYPEBOX_BUNDLED_MODULE_KEY);
}
return pathExistsSync(sourcePath) ? sourcePath : null;
}
const TYPEBOX_SHIM_PATH = __resolveTypeBoxShimPath(IS_COMPILED_BINARY, sourceShimPath("typebox.ts"));
// Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
// the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
// export (see `packages/ai/CHANGELOG.md`), so the bare canonical specifier no
// longer satisfies those imports. The override below redirects only the bare
// pi-ai package root onto a sibling shim that re-exports the canonical surface
// plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
// imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly
// against the bundled pi-ai package.
const LEGACY_PI_AI_SHIM_PATH = IS_COMPILED_BINARY
? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-ai`)
: sourceShimPath("legacy-pi-ai-shim.ts");
// The coding-agent's own `./src/index.ts` cannot be listed as an extra
// `bun --compile` entrypoint alongside the CLI entry without breaking binary
// startup (issue #1474 follow-up). In compiled-binary mode the legacy
// `@(scope)/pi-coding-agent` root therefore resolves through the bundled
// module shim; in dev / source-link / installed-package mode it points at the
// sibling source shim whose distinct file path avoids the #1474 collision
// while still re-exporting the canonical package surface.
const LEGACY_PI_CODING_AGENT_SHIM_PATH = IS_COMPILED_BINARY
? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-coding-agent`)
: sourceShimPath("legacy-pi-coding-agent-shim.ts");
// Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`) always
// replace the canonical surface so the legacy `Type` runtime and the legacy
// helpers stay reachable. The bundled host packages (`pi-agent-core`,
// `pi-natives`, `pi-tui`, `pi-utils`) are added only in compiled-binary mode
// to route extensions onto the in-process module instance — in dev /
// source-link / installed-package mode the canonical specifier resolves
// cleanly through `Bun.resolveSync` and hardcoding a source-tree path would
// miss installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`.
//
// Compiled-binary entries are `omp-legacy-pi-bundled:<key>` specifiers handed
// to the synthetic onLoad in `installLegacyPiSpecifierShim()` — bunfs paths
// are unusable on Bun 1.3.14+ (issue #3423). Filesystem-shaped overrides are
// still validated against on-disk presence so a missing dev-mode shim falls
// through to `getResolvedSpecifier`.
/**
* Drop overrides whose filesystem targets are missing so they can fall
* through to the canonical-resolution path. Virtual `omp-legacy-pi-bundled:`
* entries always pass — live bundled module references are the source of truth
* in compiled mode where bunfs paths are unreachable (issue #3423).
*
* `pathExistsSync` defaults to `fs.existsSync`; tests inject a stub to
* simulate the missing-entrypoint failure mode without touching the real FS.
*/
export function __validateLegacyPiPackageRootOverrides(
candidates: Record<string, string>,
pathExistsSync: (p: string) => boolean = fs.existsSync,
): Record<string, string> {
const valid: Record<string, string> = {};
for (const key in candidates) {
const candidate = candidates[key];
if (candidate && (isBundledVirtualSpecifier(candidate) || pathExistsSync(candidate))) {
valid[key] = candidate;
}
}
return valid;
}
/**
* Compute the override map keyed by every canonical specifier the host serves
* directly: the pi-ai / pi-coding-agent roots (compat shims that re-attach
* legacy helpers) plus, in compiled mode, every build-supplied module key.
* Subpath coverage stops `@(scope)/pi-ai/oauth` and friends from falling
* through to the extension's absent peer install when bunfs walks fail.
*/
export function __buildLegacyPiPackageRootOverrides(
isCompiled: boolean,
bundledModuleKeys: Iterable<string> = [],
): Record<string, string> {
const candidates: Record<string, string> = {
[`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
[`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
};
if (isCompiled) {
for (const key of bundledModuleKeys) {
// Shim-bearing roots already map to their compat surfaces; TypeBox
// has a dedicated TYPEBOX_SHIM_PATH route.
if (key in candidates || key === TYPEBOX_BUNDLED_MODULE_KEY) continue;
candidates[key] = bundledModuleVirtualSpecifier(key);
}
}
return __validateLegacyPiPackageRootOverrides(candidates);
}
// Seeded with compat roots at module init; first compiled extension load adds
// every key supplied by the in-memory build module.
let legacyPiPackageRootOverrides = __buildLegacyPiPackageRootOverrides(IS_COMPILED_BINARY);
let legacyPiOverridesReadyPromise: Promise<void> | null = null;
/** Complete compiled-mode overrides once from the lazily evaluated host modules. */
function ensureLegacyPiOverridesReady(): Promise<void> {
if (!IS_COMPILED_BINARY) {
return Promise.resolve();
}
if (!legacyPiOverridesReadyPromise) {
legacyPiOverridesReadyPromise = ensureBundledModulesLoaded().then(modules => {
legacyPiPackageRootOverrides = __buildLegacyPiPackageRootOverrides(true, Object.keys(modules));
});
}
return legacyPiOverridesReadyPromise;
}
let isLegacyPiSpecifierShimInstalled = false;
function remapLegacyPiSpecifier(specifier: string): string | null {
if (!LEGACY_PI_SPECIFIER_FILTER.test(specifier)) {
return null;
}
const slashIdx = specifier.indexOf("/", 1);
// Filter guarantees a slash exists, but guard anyway to keep the type narrow.
if (slashIdx === -1) {
return null;
}
const rest = specifier.slice(slashIdx + 1);
const remappedSubpath = remapLegacyPiSubpath(rest);
return `${CANONICAL_PI_SCOPE}/${remappedSubpath}`;
}
function getResolvedSpecifier(specifier: string): string {
const cached = resolvedSpecifierFallbacks.get(specifier);
if (cached) {
return cached;
}
const resolved = Bun.resolveSync(specifier, import.meta.dir);
resolvedSpecifierFallbacks.set(specifier, resolved);
return resolved;
}
/**
* Resolve a canonical `@oh-my-pi/*` specifier to a filesystem path, preferring
* a bundled compat shim when one is registered for the package root.
*
* Falls back to `getResolvedSpecifier` (which may throw under compiled binary
* mode); callers handle that the same way they would for non-overridden
* specifiers.
*/
function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
const override = legacyPiPackageRootOverrides[remappedSpecifier];
if (override) {
return override;
}
return getResolvedSpecifier(remappedSpecifier);
}
function toImportSpecifier(resolvedPath: string): string {
// Virtual `omp-legacy-pi-bundled:` specifiers are served by the synthetic
// onLoad in `installLegacyPiSpecifierShim()`; wrapping them as `file://`
// would corrupt the scheme.
if (isBundledVirtualSpecifier(resolvedPath)) {
return resolvedPath;
}
return url.pathToFileURL(stripWindowsExtendedLengthPathPrefix(resolvedPath)).href;
}
function rewriteLegacyPiImports(source: string): string {
return source.replace(
LEGACY_PI_IMPORT_SPECIFIER_REGEX,
(match, prefix: string, specifier: string, suffix: string) => {
const remappedSpecifier = remapLegacyPiSpecifier(specifier);
if (!remappedSpecifier) {
return match;
}
try {
return `${prefix}${toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier))}${suffix}`;
} catch {
// Resolution failed — typically in compiled binary mode where
// Bun.resolveSync cannot walk up from /$bunfs/root to find the
// bundled node_modules. Leave the specifier unchanged so Bun
// resolves it natively against the extension's own peer deps.
return match;
}
},
);
}
// Match the bare TypeBox import specifiers (static + dynamic). Subpath imports
// like `@sinclair/typebox/compiler` are intentionally excluded — they expose
// TypeBox-only APIs the Zod-backed shim does not provide.
const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox|typebox)(["'])/g;
/**
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
* `@(scope)/pi-*`, bare TypeBox packages, package `imports` aliases like
* `#src/*`, and extension-local bare dependencies — to absolute `file://` URLs
* or compiled-mode virtual specifiers. Relative siblings and built-in modules
* are left untouched so Bun resolves them from the extension's real on-disk
* location.
*
* When `mtimeTag` is provided, extension-owned graph specifiers (relative
* `./`/`../`, package `#alias/*`, and extension-local bare deps) also carry a
* `?mtime=<tag>` cache-bust so Bun rekeys them on same-process reloads. Host
* package rewrites (legacy `@(scope)/pi-*`, TypeBox shim) always emit
* `file://` URLs because they resolve to in-process host code that never
* changes between reloads.
*/
async function rewriteLegacyExtensionSource(
source: string,
importerPath: string,
mtimeTag: string | null = null,
): Promise<string> {
// Compiled mode completes the override map from the build-supplied module
// keys on first use; every rewrite path must see the full map.
await ensureLegacyPiOverridesReady();
const withPi = rewriteLegacyPiImports(source);
// When the TypeBox shim is missing (release build dropped the entrypoint —
// issue #3414), leave bare specifiers untouched so Bun resolves a real
// `typebox` / `@sinclair/typebox` install from the extension's own
// `node_modules`. `resolveTypeBoxSpecifier` mirrors the fall-through.
const withTypeBox = TYPEBOX_SHIM_PATH
? withPi.replace(
TYPEBOX_IMPORT_SPECIFIER_REGEX,
(_match, prefix: string, _specifier: string, suffix: string) =>
`${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
)
: withPi;
const withPkg = await rewriteExtensionPackageImports(withTypeBox, importerPath, mtimeTag);
const withBare = await rewriteExtensionBareImports(withPkg, importerPath, mtimeTag);
const withNativeAddons = await rewriteExtensionNativeAddonRequires(withBare, importerPath);
if (!mtimeTag) {
return withNativeAddons;
}
return withNativeAddons.replace(
RELATIVE_GRAPH_IMPORT_SPECIFIER_REGEX,
(_match, prefix: string, specifier: string, suffix: string) => `${prefix}${specifier}?mtime=${mtimeTag}${suffix}`,
);
}
/** Test seam for compiled-binary legacy extension source rewriting. */
export async function __rewriteLegacyExtensionSourceForTests(
source: string,
importerPath: string,
mtimeTag: string | null = null,
): Promise<string> {
return rewriteLegacyExtensionSource(source, importerPath, mtimeTag);
}
// Match relative graph specifiers so their `./foo.ts` /`../foo` targets get a
// `?mtime=<tag>` cache-bust suffix without disturbing already-rewritten
// `file://` URLs or bare/host specifiers.
const RELATIVE_GRAPH_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(\.\.?\/[^"'?\s]*)(["'])/g;
/**
* Build the import specifier for a graph-resolved absolute path. POSIX
* emits a bare filesystem path with an optional `?mtime=<tag>` (Bun keys
* query strings for bare-path specifiers), so same-process extension
* reloads pick up edits to package-alias (`#foo/*`) and extension-local
* bare deps. Windows and bundled virtual specifiers keep the current
* `file://` / virtual form — Bun ignores queries on `file://` URLs, so
* cache-bust does not reach Windows extensions until Bun changes that.
*/
function toGraphImportSpecifier(resolvedPath: string, mtimeTag: string | null): string {
if (isBundledVirtualSpecifier(resolvedPath)) {
return resolvedPath;
}
if (process.platform === "win32" || !mtimeTag) {
return url.pathToFileURL(stripWindowsExtendedLengthPathPrefix(resolvedPath)).href;
}
return `${stripWindowsExtendedLengthPathPrefix(resolvedPath)}?mtime=${mtimeTag}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.promises.stat(p);
return true;
} catch {
return false;
}
}
function hasSourceModuleExtension(p: string): boolean {
const ext = path.extname(p).toLowerCase();
return (SOURCE_MODULE_EXTENSIONS as readonly string[]).includes(ext);
}
async function resolveSourceModuleFile(basePath: string): Promise<string | null> {
try {
const stats = await fs.promises.stat(basePath);
if (stats.isFile()) {
// Non-source files (JSON, WASM, text assets, etc.) bypass the on-load
// rewrite hook so Bun's native loaders handle them; our hook would
// otherwise pass them through `getLoader()` which falls back to `js`.
return hasSourceModuleExtension(basePath) ? realpathOrSelf(basePath) : null;
}
if (stats.isDirectory()) {
for (const extension of SOURCE_MODULE_EXTENSIONS) {
const resolved = await resolveSourceModuleFile(path.join(basePath, `index${extension}`));
if (resolved) return resolved;
}
}
} catch {
// Fall through to extension candidates below.
}
if (path.extname(basePath)) {
return null;
}
for (const extension of SOURCE_MODULE_EXTENSIONS) {
const resolved = await resolveSourceModuleFile(`${basePath}${extension}`);
if (resolved) return resolved;
}
return null;
}
async function findPackageRoot(importerPath: string): Promise<string | null> {
let dir = path.dirname(importerPath);
while (true) {
const cached = packageRootCache.get(dir);
if (cached !== undefined) {
return cached;
}
if (await pathExists(path.join(dir, "package.json"))) {
packageRootCache.set(path.dirname(importerPath), dir);
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
packageRootCache.set(path.dirname(importerPath), null);
return null;
}
dir = parent;
}
}
async function readPackageImports(packageRoot: string): Promise<Record<string, unknown> | null> {
const cached = packageImportsCache.get(packageRoot);
if (cached !== undefined) {
return cached;
}
let imports: Record<string, unknown> | null = null;
try {
const pkg = await Bun.file(path.join(packageRoot, "package.json")).json();
if (isRecord(pkg) && isRecord(pkg.imports)) {
imports = pkg.imports;
}
} catch {
imports = null;
}
packageImportsCache.set(packageRoot, imports);
return imports;
}
type PackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED | null;
type ResolvedPackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED;
function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection {
if (entry === null) {
return PACKAGE_IMPORT_EXCLUDED;
}
if (typeof entry === "string") {
return entry;
}
if (Array.isArray(entry)) {
for (const item of entry) {
const target = selectPackageImportTarget(item);
if (target !== null) return target;
}
return null;
}
if (!isRecord(entry)) {
return null;
}
for (const [condition, value] of Object.entries(entry)) {
if (!SUPPORTED_PACKAGE_IMPORT_CONDITIONS.has(condition)) {
continue;
}
const target = selectPackageImportTarget(value);
if (target !== null) return target;
}
return null;
}
async function resolvePackageImportTarget(
packageRoot: string,
target: string,
wildcard: string | null,
): Promise<string | null> {
if (!target.startsWith("./")) {
return null;
}
const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
}
async function resolvePackageImportSpecifier(specifier: string, importerPath: string): Promise<string | null> {
if (!specifier.startsWith("#")) {
return null;
}
const packageRoot = await findPackageRoot(importerPath);
if (!packageRoot) {
return null;
}
const imports = await readPackageImports(packageRoot);
if (!imports) {
return null;
}
const exactTarget = selectPackageImportTarget(imports[specifier]);
if (exactTarget === PACKAGE_IMPORT_EXCLUDED) {
return null;
}
if (exactTarget !== null) {
return resolvePackageImportTarget(packageRoot, exactTarget, null);
}
let bestMatch: { keyLength: number; target: ResolvedPackageImportTargetSelection; wildcard: string } | null = null;
for (const [key, entry] of Object.entries(imports)) {
const starIndex = key.indexOf("*");
if (starIndex === -1) continue;
const prefix = key.slice(0, starIndex);
const suffix = key.slice(starIndex + 1);
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) {
continue;
}
const target = selectPackageImportTarget(entry);
if (target === null) {
continue;
}
if (!bestMatch || key.length > bestMatch.keyLength) {
bestMatch = {
keyLength: key.length,
target,
wildcard: specifier.slice(prefix.length, specifier.length - suffix.length),
};
}
}
if (!bestMatch || bestMatch.target === PACKAGE_IMPORT_EXCLUDED) {
return null;
}
return resolvePackageImportTarget(packageRoot, bestMatch.target, bestMatch.wildcard);
}
const PACKAGE_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(#[^"'()\s]+)(["'])/g;
async function rewriteExtensionPackageImports(
source: string,
importerPath: string,
mtimeTag: string | null = null,
): Promise<string> {
let rewritten = "";
let lastIndex = 0;
for (const match of source.matchAll(PACKAGE_IMPORT_SPECIFIER_REGEX)) {
const matchIndex = match.index;
if (matchIndex === undefined) continue;
const [fullMatch, prefix, specifier, suffix] = match;
if (!prefix || !specifier || !suffix) continue;
const resolved = await resolvePackageImportSpecifier(specifier, importerPath);
if (!resolved) continue;
rewritten += source.slice(lastIndex, matchIndex);
rewritten += `${prefix}${toGraphImportSpecifier(resolved, mtimeTag)}${suffix}`;
lastIndex = matchIndex + fullMatch.length;
}
if (lastIndex === 0) {
return source;
}
return `${rewritten}${source.slice(lastIndex)}`;
}
const BARE_EXTENSION_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
function isBareExtensionDependencySpecifier(specifier: string): boolean {
if (
specifier.startsWith(".") ||
specifier.startsWith("/") ||
specifier.startsWith("#") ||
specifier.startsWith("node:") ||
specifier.startsWith("bun:") ||
/^[a-z][a-z0-9+.-]*:/i.test(specifier)
) {
return false;
}
const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
return Boolean(packageName && !isBuiltin(packageName));
}
interface BarePackageSpecifier {
readonly name: string;
readonly subpath: string | null;
}
function splitBarePackageSpecifier(specifier: string): BarePackageSpecifier | null {
const parts = specifier.split("/");
if (specifier.startsWith("@")) {
const [scope, name, ...rest] = parts;
if (!scope || !name) return null;
return { name: `${scope}/${name}`, subpath: rest.length > 0 ? rest.join("/") : null };
}
const [name, ...rest] = parts;
if (!name) return null;
return { name, subpath: rest.length > 0 ? rest.join("/") : null };
}
async function findNodePackageRoot(packageName: string, importerPath: string): Promise<string | null> {
const cacheKey = `${packageName}\0${path.resolve(path.dirname(importerPath))}`;
const cached = nodePackageRootCache.get(cacheKey);
if (cached) return cached;
const promise = findNodePackageRootUncached(packageName, importerPath);
nodePackageRootCache.set(cacheKey, promise);
return promise;
}
async function findNodePackageRootUncached(packageName: string, importerPath: string): Promise<string | null> {
let dir = path.dirname(importerPath);
while (true) {
const candidate = path.join(dir, "node_modules", packageName);
if (await pathExists(path.join(candidate, "package.json"))) {
return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
const cached = packageManifestCache.get(packageRoot);
if (cached) return cached;
const promise = readPackageManifestUncached(packageRoot);
packageManifestCache.set(packageRoot, promise);
return promise;
}
async function readPackageManifestUncached(packageRoot: string): Promise<Record<string, unknown> | null> {
try {
const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
return isRecord(manifest) ? manifest : null;
} catch {
return null;
}
}
async function resolvePackageExportTarget(
packageRoot: string,
target: string,
wildcard: string | null,
): Promise<string | null> {
if (!target.startsWith("./")) {
return null;
}
const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
}
async function resolveNodePackageExport(
packageRoot: string,
subpath: string | null,
manifest: Record<string, unknown>,
): Promise<string | null> {
const exportsField = manifest.exports;
const rootTarget = subpath === null ? selectPackageImportTarget(exportsField) : null;
if (rootTarget !== null && rootTarget !== PACKAGE_IMPORT_EXCLUDED) {
return resolvePackageExportTarget(packageRoot, rootTarget, null);
}
if (!isRecord(exportsField)) {
return null;
}
const exactKey = subpath === null ? "." : `./${subpath}`;
const exactTarget = selectPackageImportTarget(exportsField[exactKey]);
if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) {
return resolvePackageExportTarget(packageRoot, exactTarget, null);
}
for (const [key, entry] of Object.entries(exportsField)) {
const starIndex = key.indexOf("*");
if (starIndex === -1 || subpath === null) continue;
const prefix = key.slice(2, starIndex);
const suffix = key.slice(starIndex + 1);
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
continue;
}
const target = selectPackageImportTarget(entry);
if (target === null || target === PACKAGE_IMPORT_EXCLUDED) {
continue;
}
return resolvePackageExportTarget(
packageRoot,
target,
subpath.slice(prefix.length, subpath.length - suffix.length),
);
}
return null;
}
async function resolveNodePackageFallback(
packageRoot: string,
subpath: string | null,
manifest: Record<string, unknown>,
): Promise<string | null> {
if (subpath !== null) {
return resolveSourceModuleFile(path.join(packageRoot, subpath));
}
for (const field of ["module", "main"]) {
const target = manifest[field];
if (typeof target === "string") {
const resolved = await resolveSourceModuleFile(path.resolve(packageRoot, target));
if (resolved) return resolved;
}
}
return resolveSourceModuleFile(path.join(packageRoot, "index"));
}
async function resolveNodePackageDependency(specifier: string, importerPath: string): Promise<string | null> {
const parsed = splitBarePackageSpecifier(specifier);
if (!parsed) return null;
const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
if (!packageRoot) return null;
const manifest = await readPackageManifest(packageRoot);
if (!manifest) return null;
return (
(await resolveNodePackageExport(packageRoot, parsed.subpath, manifest)) ??
(await resolveNodePackageFallback(packageRoot, parsed.subpath, manifest))
);
}
async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise<string | null> {
if (!isBareExtensionDependencySpecifier(specifier)) {
return null;
}
const cacheKey = `${specifier}\0${path.resolve(path.dirname(importerPath))}`;
const cached = bareDependencyResolutionCache.get(cacheKey);
if (cached) return cached;
const promise = resolveExtensionBareDependencyUncached(specifier, importerPath);
bareDependencyResolutionCache.set(cacheKey, promise);
return promise;
}
async function resolveExtensionBareDependencyUncached(specifier: string, importerPath: string): Promise<string | null> {
try {
const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
return resolved;
}
} catch {
// Compiled binaries do not reliably resolve runtime extension node_modules.
}
return resolveNodePackageDependency(specifier, importerPath);
}
const NATIVE_ADDON_EXTENSION = ".node";
// Match CommonJS require calls so bare native-addon specifiers can be pinned
// to absolute paths. Only requires whose resolution lands on a `.node` addon
// are rewritten; everything else stays on Bun's native resolver.
const NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX = /(\brequire\s*\(\s*["'])([^"'()\s]+)(["']\s*\))/g;
/**
* Resolve a bare specifier whose target is a native `.node` addon — either a
* package subpath ending in `.node`, or a package whose `main` points at an
* addon (the napi-rs per-platform package convention, e.g.
* `@yuuang/ffi-rs-darwin-arm64` → `ffi-rs.darwin-arm64.node`). Returns the
* addon's absolute realpath, or null when the specifier is not a native addon.
*/
async function resolveExtensionNativeAddon(specifier: string, importerPath: string): Promise<string | null> {
if (!isBareExtensionDependencySpecifier(specifier)) {
return null;
}
const cacheKey = `${specifier}\0${path.resolve(path.dirname(importerPath))}`;
const cached = nativeAddonResolutionCache.get(cacheKey);
if (cached) return cached;
const promise = resolveExtensionNativeAddonUncached(specifier, importerPath);
nativeAddonResolutionCache.set(cacheKey, promise);
return promise;
}
async function resolveExtensionNativeAddonUncached(specifier: string, importerPath: string): Promise<string | null> {
const parsed = splitBarePackageSpecifier(specifier);
if (!parsed) return null;
const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
if (!packageRoot) return null;
let target: string | null = null;
if (parsed.subpath !== null) {
target = parsed.subpath.endsWith(NATIVE_ADDON_EXTENSION) ? path.join(packageRoot, parsed.subpath) : null;
} else {
const manifest = await readPackageManifest(packageRoot);
const main = manifest?.main;
target =
typeof main === "string" && main.endsWith(NATIVE_ADDON_EXTENSION) ? path.resolve(packageRoot, main) : null;
}
if (!target || !(await pathExists(target))) {
return null;
}
return realpathOrSelf(target);
}
/**
* Rewrite bare `require()` specifiers that resolve to native `.node` addons
* into absolute-path requires. In `bun build --compile` binaries, Bun's bare
* resolution fails for packages whose `main` is a `.node` addon ("Cannot find
* module '@scope/pkg-<platform>'") even when the package sits in the
* extension's own node_modules; requiring the addon by absolute path works.
*/
async function rewriteExtensionNativeAddonRequires(source: string, importerPath: string): Promise<string> {
let rewritten = "";
let lastIndex = 0;
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
const matchIndex = match.index;
if (matchIndex === undefined) continue;
const [fullMatch, prefix, specifier, suffix] = match;
if (!prefix || !specifier || !suffix) continue;
const resolved = await resolveExtensionNativeAddon(specifier, importerPath);
if (!resolved) continue;
rewritten += source.slice(lastIndex, matchIndex);
// Forward slashes keep Windows paths valid inside single- or double-quoted literals.
rewritten += `${prefix}${stripWindowsExtendedLengthPathPrefix(resolved).replaceAll("\\", "/")}${suffix}`;
lastIndex = matchIndex + fullMatch.length;
}
if (lastIndex === 0) {
return source;
}
return `${rewritten}${source.slice(lastIndex)}`;
}
/**
* Whether a module's source contains a bare require that resolves to a native
* `.node` addon — i.e. a napi-rs style loader that must be hooked into the
* extension graph so {@link rewriteExtensionNativeAddonRequires} can pin its
* platform-package requires to absolute paths.
*/
async function moduleRequiresNativeAddon(modulePath: string): Promise<boolean> {
const cached = nativeAddonRequireScanCache.get(modulePath);
if (cached) return cached;
const promise = moduleRequiresNativeAddonUncached(modulePath);
nativeAddonRequireScanCache.set(modulePath, promise);
return promise;
}
async function moduleRequiresNativeAddonUncached(modulePath: string): Promise<boolean> {
let source: string;
try {
source = await Bun.file(modulePath).text();
} catch {
return false;
}
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
const specifier = match[2];
if (specifier && (await resolveExtensionNativeAddon(specifier, modulePath))) {
return true;
}
}
return false;
}
async function rewriteExtensionBareImports(
source: string,
importerPath: string,
mtimeTag: string | null = null,
): Promise<string> {
let rewritten = "";
let lastIndex = 0;
for (const match of source.matchAll(BARE_EXTENSION_IMPORT_SPECIFIER_REGEX)) {
const matchIndex = match.index;
if (matchIndex === undefined) continue;
const [fullMatch, prefix, specifier, suffix] = match;
if (!prefix || !specifier || !suffix) continue;
const resolved = await resolveExtensionBareDependency(specifier, importerPath);
if (!resolved) continue;
rewritten += source.slice(lastIndex, matchIndex);
rewritten += `${prefix}${toGraphImportSpecifier(resolved, mtimeTag)}${suffix}`;
lastIndex = matchIndex + fullMatch.length;
}
if (lastIndex === 0) {
return source;
}
return `${rewritten}${source.slice(lastIndex)}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Match source modules in an extension graph: relative imports, package
// `imports` aliases such as `#src/*`, and extension-local bare dependency
// entries. Bare imports inside node_modules dependencies remain native Bun
// resolutions; once the dependency entry is hooked, its relative children are
// still collected and rewritten with the reload mtime tag. `require()` calls
// are scanned too so CJS entries and napi-rs loaders reached without an
// import statement still join the graph.
const EXTENSION_GRAPH_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
// Extension source realpaths already covered by an installed load-time hook for
// each entry. `Bun.plugin()` registrations are process-global and permanent, so
// reloads install supplemental hooks only for modules added to the graph since
// the previous load.
const extensionGraphHookModules = new Map<string, Set<string>>();
let legacyPiLoadTag = 0;
function nextLegacyPiLoadTag(): string {
legacyPiLoadTag = Math.max(legacyPiLoadTag + 1, Date.now());
return String(legacyPiLoadTag);
}
/** Resolve symlinks in a path, falling back to the input if realpath fails. */
async function realpathOrSelf(p: string): Promise<string> {
const cached = realpathCache.get(p);
if (cached) return cached;
const promise = realpathOrSelfUncached(p);
realpathCache.set(p, promise);
return promise;
}
async function realpathOrSelfUncached(p: string): Promise<string> {
try {
return await fs.promises.realpath(p);
} catch {
return p;
}
}
/**
* Walk the extension's import graph starting at `entryRealPath`, returning the
* realpath of every reachable source module OMP must rewrite at load time.
* Relative imports and package `imports` aliases are always graph-owned.
* Extension-local bare dependency entries are also included so their relative
* children receive the reload mtime tag; bare imports inside those dependencies
* remain native Bun resolutions to avoid taking over full third-party graphs.
* CommonJS dependency entries stay native too, with one exception: napi-rs
* style loaders whose bare requires resolve to `.node` addons are hooked so
* their requires can be pinned to absolute paths (unresolvable by bare
* specifier inside `bun build --compile` binaries).
*/
async function collectExtensionModules(entryRealPath: string): Promise<Map<string, string>> {
const modules = new Map<string, string>();
const queuedFollowBareDependencies = new Map<string, boolean>([[entryRealPath, true]]);
const queue: Array<{ file: string; followBareDependencies: boolean }> = [
{ file: entryRealPath, followBareDependencies: true },
];
while (queue.length > 0) {
const item = queue.pop();
if (!item) {
continue;
}
const file = item.file;
const followBareDependencies = queuedFollowBareDependencies.get(file) ?? item.followBareDependencies;
if (modules.has(file)) {
continue;
}
let source: string;
try {
source = await Bun.file(file).text();
if (nativeAddonLoaderModulePaths.has(file)) {
// CJS requires cannot await an async onLoad hook. Resolve and
// rewrite native-addon paths before installing its sync hook.
source = await rewriteExtensionNativeAddonRequires(source, file);
}
} catch {
continue;
}
modules.set(file, source);
const dir = path.dirname(file);
const specifiers = new Set<string>();
for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
if (match[2]) specifiers.add(match[2]);
}
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
if (match[2]) specifiers.add(match[2]);
}
for (const specifier of specifiers) {
try {
let resolved: string | null = null;
let nextFollowsBareDependencies = followBareDependencies;
if (specifier.startsWith(".")) {
const candidate = Bun.resolveSync(specifier, dir);
resolved = hasSourceModuleExtension(candidate) ? await realpathOrSelf(candidate) : null;
} else if (specifier.startsWith("#")) {
resolved = await resolvePackageImportSpecifier(specifier, file);
} else if (
followBareDependencies &&
isBareExtensionDependencySpecifier(specifier) &&
!remapLegacyPiSpecifier(specifier) &&
specifier !== "typebox" &&
specifier !== "@sinclair/typebox"
) {
const parsed = splitBarePackageSpecifier(specifier);
const packageRoot = parsed ? await findNodePackageRoot(parsed.name, file) : null;
const manifest = packageRoot ? await readPackageManifest(packageRoot) : null;
const dependencyEntry = manifest ? await resolveExtensionBareDependency(specifier, file) : null;
const dependencyExtension = dependencyEntry ? path.extname(dependencyEntry) : null;
const isCommonJsEntry =
dependencyExtension === ".cjs" ||
dependencyExtension === ".cts" ||
((dependencyExtension === ".js" || dependencyExtension === ".jsx") && manifest?.type !== "module");
const isHookableEntry = Boolean(dependencyEntry && hasSourceModuleExtension(dependencyEntry));
const hookCommonJsEntry =
isHookableEntry && isCommonJsEntry && dependencyEntry
? await moduleRequiresNativeAddon(dependencyEntry)
: false;
if (isHookableEntry && dependencyEntry && (!isCommonJsEntry || hookCommonJsEntry)) {
resolved = await realpathOrSelf(dependencyEntry);
if (hookCommonJsEntry) {
nativeAddonLoaderModulePaths.add(resolved);
}
}
nextFollowsBareDependencies = false;
}
if (resolved && !modules.has(resolved)) {
const queuedFollowsBareDependencies = queuedFollowBareDependencies.get(resolved) ?? false;
const mergedFollowsBareDependencies = queuedFollowsBareDependencies || nextFollowsBareDependencies;
queuedFollowBareDependencies.set(resolved, mergedFollowsBareDependencies);
queue.push({ file: resolved, followBareDependencies: mergedFollowsBareDependencies });
}
} catch {
// Unresolvable import (e.g. a type-only path); skip it.
}
}
}
return modules;
}
/**
* Install exact-path load hooks for the current extension graph. ESM/TS source
* retains the async rewrite path. Native-addon CJS loaders use a synchronous
* hook with source pre-rewritten during graph collection; Bun rejects a CJS
* `require()` whose onLoad callback returns a promise.
*/
function installExtensionGraphHook(entryRealPath: string, modules: Map<string, string>): void {
const asyncModules = new Map<string, string>();
const syncCommonJsModules = new Map<string, string>();
for (const [modulePath, source] of modules) {
const destination = nativeAddonLoaderModulePaths.has(modulePath) ? syncCommonJsModules : asyncModules;
destination.set(modulePath, source);
}
if (asyncModules.size > 0) {
const alternation = [...asyncModules.keys()].map(escapeRegExp).join("|");
const filter = new RegExp(`^(?:${alternation})(?:\\?mtime=\\d+)?$`);
const hookId = Bun.hash(`${entryRealPath}\0async\0${[...asyncModules.keys()].join("\0")}`).toString(36);
Bun.plugin({
name: `omp:legacy-pi-ext:${hookId}`,
setup(build) {
build.onLoad({ filter, namespace: "file" }, async args => {
const queryIndex = args.path.indexOf("?mtime=");
const sourcePath = queryIndex >= 0 ? args.path.slice(0, queryIndex) : args.path;
const mtimeTag = queryIndex >= 0 ? args.path.slice(queryIndex + "?mtime=".length) : null;
const cached = asyncModules.get(sourcePath);
let raw: string;
if (cached !== undefined) {
// consume-once: preserves ?mtime edit-pickup for re-imports
asyncModules.delete(sourcePath);
raw = cached;
} else {
raw = await Bun.file(sourcePath).text();
}
return {
contents: await rewriteLegacyExtensionSource(raw, sourcePath, mtimeTag),
loader: getLoader(sourcePath),
};
});
},
});
}
if (syncCommonJsModules.size > 0) {
const alternation = [...syncCommonJsModules.keys()].map(escapeRegExp).join("|");
const filter = new RegExp(`^(?:${alternation})(?:\\?mtime=\\d+)?$`);
const hookId = Bun.hash(`${entryRealPath}\0sync-cjs\0${[...syncCommonJsModules.keys()].join("\0")}`).toString(36);
Bun.plugin({
name: `omp:legacy-pi-ext:${hookId}`,
setup(build) {
build.onLoad({ filter, namespace: "file" }, args => {
const queryIndex = args.path.indexOf("?mtime=");
const sourcePath = queryIndex >= 0 ? args.path.slice(0, queryIndex) : args.path;
const source = syncCommonJsModules.get(sourcePath);
if (source === undefined) {
throw new Error(`Missing pre-rewritten CommonJS extension source: ${sourcePath}`);
}
return { contents: source, loader: getLoader(sourcePath) };
});
},
});
}
}
/**
* Ensure every currently reachable extension source module has a load-time
* rewrite hook. The entry graph can grow across reloads, so each call collects
* the current graph and registers hooks for paths not covered by earlier loads.
*
* Returns the newly collected path→source map so the caller can drop entries
* the import never consumed; `undefined` when no new modules were discovered.
*/
async function ensureExtensionGraphHook(entryRealPath: string): Promise<Map<string, string> | undefined> {
const currentModules = await collectExtensionModules(entryRealPath);
let hookedModules = extensionGraphHookModules.get(entryRealPath);
if (!hookedModules) {
hookedModules = new Set<string>();
extensionGraphHookModules.set(entryRealPath, hookedModules);
}
const pendingModules = new Map<string, string>();
for (const [modulePath, source] of currentModules) {
if (!hookedModules.has(modulePath)) {
pendingModules.set(modulePath, source);
}
}
if (pendingModules.size === 0) {
return undefined;
}
installExtensionGraphHook(entryRealPath, pendingModules);
for (const modulePath of pendingModules.keys()) {
hookedModules.add(modulePath);
}
return pendingModules;
}
/**
* Load a legacy Pi extension module from its real on-disk location.
*
* The extension runs in place, so its `import.meta.url` is the real source file
* and `__dirname`-relative `readFileSync` asset loads (HTML/CSS bundled next to
* the entry) resolve exactly as they do under the original Pi runtime — no
* temp-directory mirroring and no asset copying. An `onLoad` hook scoped to the
* entry's source graph rewrites only host-resolved compatibility imports in the
* extension's own source; everything else resolves natively.
*/
export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
// Bun reports the realpath of a loaded module to `onLoad` and exposes it as
// `import.meta.url`. Resolve symlinks here too (macOS `/var`→`/private/var`,
// `bun link`/pnpm installs) so the rewrite filter matches the path Bun
// actually hands the hook.
const entryRealPath = await realpathOrSelf(path.resolve(resolvedPath));
await ensureLegacyPiOverridesReady();
const pendingSources = await ensureExtensionGraphHook(entryRealPath);
try {
// Dynamic import is required: legacy extension entry paths are user/plugin supplied at runtime.
// On POSIX, use the raw filesystem path so Bun keys the `?mtime`
// suffix as part of the module identity; Bun ignores query strings on
// `file://` specifiers, which would serve stale edited source.
const entrySpecifier =
process.platform === "win32" || isBundledVirtualSpecifier(entryRealPath)
? toImportSpecifier(entryRealPath)
: entryRealPath;
return await import(`${entrySpecifier}?mtime=${nextLegacyPiLoadTag()}`);
} finally {
// Drop whatever the initial import didn't consume: graph modules only
// reached by lazy dynamic imports must be read from disk at their actual
// import time, not served from this load-time snapshot.
pendingSources?.clear();
}
}
function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
if (path.endsWith(".tsx")) {
return "tsx";
}
if (path.endsWith(".jsx")) {
return "jsx";
}
if (path.endsWith(".ts") || path.endsWith(".mts") || path.endsWith(".cts")) {
return "ts";
}
return "js";
}
function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { path: string } | undefined {
const remappedSpecifier = remapLegacyPiSpecifier(args.path);
if (!remappedSpecifier) {
return undefined;
}
// Primary: resolve the canonical @oh-my-pi/* specifier from the host binary
// location. Works in dev mode and in source-link installs.
try {
return { path: resolveCanonicalPiSpecifier(remappedSpecifier) };
} catch {
// Fallback for compiled binary mode: the bundled packages live inside
// /$bunfs/root and aren't reachable by filesystem resolution. Prefer the
// canonical specifier against the importing file's directory when the
// plugin installed @oh-my-pi peer deps, then try the original legacy
// specifier for plugins that still vendor only @mariozechner or
// @earendil-works peer deps.
const importerDir = path.dirname(args.importer);
try {
return { path: Bun.resolveSync(remappedSpecifier, importerDir) };
} catch {
try {
return { path: Bun.resolveSync(args.path, importerDir) };
} catch {
return undefined;
}
}
}
}
function resolveTypeBoxSpecifier(): { path: string } | undefined {
return TYPEBOX_SHIM_PATH ? { path: TYPEBOX_SHIM_PATH } : undefined;
}
export function installLegacyPiSpecifierShim(): void {
if (isLegacyPiSpecifierShimInstalled) {
return;
}
isLegacyPiSpecifierShimInstalled = true;
Bun.plugin({
name: "omp:legacy-pi-shim",
setup(build) {
build.onResolve({ filter: LEGACY_PI_SPECIFIER_FILTER, namespace: "file" }, resolveLegacyPiSpecifier);
build.onResolve({ filter: TYPEBOX_SPECIFIER_FILTER, namespace: "file" }, resolveTypeBoxSpecifier);
// Compiled mode serves `omp-legacy-pi-bundled:<key>` imports from
// live host module references. No bunfs path leaves this loader.
build.onLoad({ filter: /.*/, namespace: BUNDLED_VIRTUAL_NAMESPACE }, async args => {
return { contents: await synthesizeBundledModuleSource(args.path), loader: "js" };
});
},
});
}
/** Test seam: clears the memoized canonical specifier resolutions. */
export function __resetLegacyPiResolutionCache(): void {
clearLegacyPiResolutionCaches();
}