diff --git a/bun.lock b/bun.lock
index a8f7b37ba..cbde5635d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -76,6 +76,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "catalog:",
"@babel/parser": "catalog:",
+ "@babel/traverse": "catalog:",
"@mozilla/readability": "catalog:",
"@oh-my-pi/hashline": "catalog:",
"@oh-my-pi/omp-stats": "catalog:",
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 1c6b04a12..2d826750b 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -55,6 +55,9 @@
- Fixed in-progress aborts awaiting `session_stop` extension handlers whose results would be discarded.
- Fixed `/retry` reporting "Nothing to retry" after a stream stalled or aborted mid-tool-call.
- Fixed locally consumed extension commands triggering automatic title generation and exposing their command text to the title model.
+### Fixed
+
+- Fixed compiled binaries failing to load legacy Pi extensions with minified imports, `pi-ai/compat`, or transitive runtime dependencies. The compatibility loader now follows compact static imports, resolves transitive on-disk ESM imports and CommonJS requires with package conditions, and restores the legacy `copyToClipboard` and `decodeKittyPrintable` root exports used by `pi-vimmode` and `pi-web-access`.
## [17.0.7] - 2026-07-21
diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json
index 5c637d6c4..0e131d816 100644
--- a/packages/coding-agent/package.json
+++ b/packages/coding-agent/package.json
@@ -51,6 +51,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "catalog:",
"@babel/parser": "catalog:",
+ "@babel/traverse": "catalog:",
"@mozilla/readability": "catalog:",
"@oh-my-pi/hashline": "catalog:",
"@oh-my-pi/omp-stats": "catalog:",
diff --git a/packages/coding-agent/scripts/legacy-pi-virtual-module.ts b/packages/coding-agent/scripts/legacy-pi-virtual-module.ts
index b759a6044..a83ef4f7f 100644
--- a/packages/coding-agent/scripts/legacy-pi-virtual-module.ts
+++ b/packages/coding-agent/scripts/legacy-pi-virtual-module.ts
@@ -19,7 +19,7 @@ const BUNDLED_PACKAGES: readonly BundledPackage[] = [
{ dir: "ai", identifier: "PiAi", rootShim: "legacy-pi-ai-shim.ts" },
{ dir: "coding-agent", identifier: "PiCodingAgent", rootShim: "legacy-pi-coding-agent-shim.ts" },
{ dir: "natives", identifier: "PiNatives", rootShim: null },
- { dir: "tui", identifier: "PiTui", rootShim: null },
+ { dir: "tui", identifier: "PiTui", rootShim: "legacy-pi-tui-shim.ts" },
{ dir: "utils", identifier: "PiUtils", rootShim: null },
];
diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts
index 6542a6481..ef2c08a6e 100644
--- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts
+++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts
@@ -1362,4 +1362,5 @@ export function getPackageDir(): string {
export * from "../index";
export { formatBytes as formatSize } from "../tools/render-utils";
+export { copyToClipboard } from "../utils/clipboard";
export { Type } from "./typebox";
diff --git a/packages/coding-agent/src/extensibility/legacy-pi-tui-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-tui-shim.ts
new file mode 100644
index 000000000..4ac5f57fd
--- /dev/null
+++ b/packages/coding-agent/src/extensibility/legacy-pi-tui-shim.ts
@@ -0,0 +1,10 @@
+/**
+ * Compatibility shim for legacy extensions importing the package root of
+ * `@earendil-works/pi-tui` or `@mariozechner/pi-tui`.
+ *
+ * The historical root exported `decodeKittyPrintable`; the canonical TUI now
+ * exposes the equivalent, broader `decodePrintableKey` helper. Keep the legacy
+ * name available without reintroducing it into the canonical package surface.
+ */
+export * from "@oh-my-pi/pi-tui";
+export { decodePrintableKey as decodeKittyPrintable } from "@oh-my-pi/pi-tui";
diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts
index 40d1a7ca8..dbde63e33 100644
--- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts
+++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts
@@ -1,13 +1,35 @@
///
+
import * as fs from "node:fs";
import { createRequire, isBuiltin } from "node:module";
import * as path from "node:path";
import * as url from "node:url";
+import type { ParserPlugin } from "@babel/parser";
+import { parse as parseBabel } from "@babel/parser";
+import * as traverseModule from "@babel/traverse";
import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
import { registerPluginCacheInvalidator } from "../../discovery/helpers";
const IS_COMPILED_BINARY = isCompiledBinary();
+function isBabelTraverse(value: unknown): value is typeof traverseModule.default {
+ return typeof value === "function";
+}
+
+// Bun's compiled CJS interop wraps Babel traverse's default one level deeper.
+const traverseDefault: unknown = traverseModule.default;
+const nestedTraverse =
+ traverseDefault !== null && typeof traverseDefault === "object" && "default" in traverseDefault
+ ? traverseDefault.default
+ : undefined;
+const traverseCandidate = isBabelTraverse(traverseDefault) ? traverseDefault : nestedTraverse;
+if (!isBabelTraverse(traverseCandidate)) {
+ throw new TypeError(
+ `Invalid @babel/traverse export: expected function, got default=${typeof traverseDefault}, nested=${typeof nestedTraverse}`,
+ );
+}
+const traverseAst = traverseCandidate;
+
// === Bundled host modules (issue #3423) ===
//
// Bun 1.3.14 stopped exposing `--compile` extras through any filesystem-style
@@ -42,6 +64,95 @@ interface BundledVirtualResolveResult {
namespace: typeof BUNDLED_VIRTUAL_NAMESPACE;
}
+interface ExtensionSpecifierReference {
+ readonly kind: "import" | "require";
+ readonly specifier: string;
+ readonly start: number;
+ readonly end: number;
+}
+
+function collectExtensionSpecifierReferences(source: string, importerPath: string): ExtensionSpecifierReference[] {
+ const extension = path.extname(importerPath).toLowerCase();
+ const plugins: ParserPlugin[] = ["decorators-legacy", "explicitResourceManagement"];
+ if (extension === ".ts" || extension === ".mts" || extension === ".cts" || extension === ".tsx") {
+ plugins.push("typescript");
+ }
+ if (extension === ".jsx" || extension === ".tsx") {
+ plugins.push("jsx");
+ }
+
+ const ast = (() => {
+ try {
+ return parseBabel(source, {
+ sourceType: "unambiguous",
+ allowAwaitOutsideFunction: true,
+ allowReturnOutsideFunction: true,
+ allowImportExportEverywhere: true,
+ allowNewTargetOutsideFunction: true,
+ allowSuperOutsideMethod: true,
+ allowUndeclaredExports: true,
+ errorRecovery: true,
+ plugins,
+ });
+ } catch (error) {
+ throw new Error(
+ `Failed to parse extension source for dependency rewriting: ${importerPath}: ${error instanceof Error ? error.message : String(error)}`,
+ { cause: error },
+ );
+ }
+ })();
+
+ const references: ExtensionSpecifierReference[] = [];
+ const record = (kind: ExtensionSpecifierReference["kind"], literal: unknown): void => {
+ if (!literal || typeof literal !== "object") return;
+ const node = literal as { type?: string; value?: unknown; start?: number | null; end?: number | null };
+ if (
+ node.type === "StringLiteral" &&
+ typeof node.value === "string" &&
+ typeof node.start === "number" &&
+ typeof node.end === "number"
+ ) {
+ references.push({ kind, specifier: node.value, start: node.start, end: node.end });
+ }
+ };
+ traverseAst(ast, {
+ enter(nodePath) {
+ const node = nodePath.node;
+ if (
+ node.type === "ImportDeclaration" ||
+ node.type === "ExportNamedDeclaration" ||
+ node.type === "ExportAllDeclaration"
+ ) {
+ record("import", node.source);
+ } else if (node.type === "ImportExpression") {
+ record("import", node.source);
+ } else if (node.type === "CallExpression") {
+ if (node.callee.type === "Import") {
+ record("import", node.arguments[0]);
+ } else if (
+ node.callee.type === "Identifier" &&
+ node.callee.name === "require" &&
+ !nodePath.scope.hasBinding("require", true)
+ ) {
+ record("require", node.arguments[0]);
+ }
+ }
+ },
+ });
+ return references;
+}
+
+function applySpecifierReplacements(
+ source: string,
+ replacements: ReadonlyArray,
+): string {
+ let rewritten = source;
+ for (const reference of [...replacements].sort((left, right) => right.start - left.start)) {
+ rewritten = `${rewritten.slice(0, reference.start)}${JSON.stringify(reference.replacement)}${rewritten.slice(reference.end)}`;
+ }
+ return rewritten;
+}
+
const loadedBundledModules: Record = {};
let bundledModuleLoadersPromise: Promise | null = null;
@@ -180,6 +291,7 @@ const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|");
const PI_SUBPATH_REMAPS: ReadonlyMap = new Map([
["pi-ai/utils/oauth", "pi-ai/oauth"],
["pi-ai/utils/oauth/", "pi-ai/oauth/"],
+ ["pi-ai/compat", "pi-ai"],
]);
function remapLegacyPiSubpath(rest: string): string {
@@ -198,18 +310,16 @@ function remapLegacyPiSubpath(rest: string): string {
}
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();
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 SUPPORTED_PACKAGE_REQUIRE_CONDITIONS = new Set(["bun", "node", "require", "default"]);
const packageRootCache = new Map();
const packageImportsCache = new Map | null>();
const nodePackageRootCache = new Map>();
const packageManifestCache = new Map | null>>();
const bareDependencyResolutionCache = new Map>();
+const bareRequireResolutionCache = new Map>();
const realpathCache = new Map>();
const nativeAddonResolutionCache = new Map>();
const nativeAddonRequireScanCache = new Map>();
@@ -222,6 +332,7 @@ function clearLegacyPiResolutionCaches(): void {
nodePackageRootCache.clear();
packageManifestCache.clear();
bareDependencyResolutionCache.clear();
+ bareRequireResolutionCache.clear();
nativeAddonResolutionCache.clear();
nativeAddonRequireScanCache.clear();
nativeAddonLoaderModulePaths.clear();
@@ -338,14 +449,21 @@ 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-*`.
+// Legacy pi-tui exported `decodeKittyPrintable` from its package root. The
+// canonical TUI replaced it with the broader `decodePrintableKey`; route only
+// legacy root imports through a sibling shim that preserves the old name.
+const LEGACY_PI_TUI_SHIM_PATH = IS_COMPILED_BINARY
+ ? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-tui`)
+ : sourceShimPath("legacy-pi-tui-shim.ts");
+
+// Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`, `pi-tui`)
+// always replace the canonical surface so legacy helpers stay reachable. The
+// other bundled host packages (`pi-agent-core`, `pi-natives`, `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 bundled packages live at
+// `node_modules/@oh-my-pi/pi-*`.
//
// Compiled-binary entries are `omp-legacy-pi-bundled:` specifiers handed
// to the synthetic onLoad in `installLegacyPiSpecifierShim()` — bunfs paths
@@ -390,6 +508,7 @@ export function __buildLegacyPiPackageRootOverrides(
const candidates: Record = {
[`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
[`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
+ [`${CANONICAL_PI_SCOPE}/pi-tui`]: LEGACY_PI_TUI_SHIM_PATH,
};
if (isCompiled) {
for (const key of bundledModuleKeys) {
@@ -473,33 +592,6 @@ function toImportSpecifier(resolvedPath: string): string {
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
@@ -508,43 +600,61 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
* 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=` 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.
+ * When `mtimeTag` is provided, extension-owned relative graph specifiers
+ * (`./`/`../`) and, by default, resolved package `#alias/*` and extension-local
+ * bare deps also carry a `?mtime=` cache-bust so Bun rekeys them on
+ * same-process reloads. `resolvedImportMtimeTag` can disable the tag for
+ * resolved package and bare imports inside third-party dependencies, whose
+ * transitive bare imports must retain a query-free importer path for Bun's
+ * runtime `node_modules` resolution. 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,
+ resolvedImportMtimeTag: string | null = mtimeTag,
): Promise {
// 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;
+ const references = await collectExtensionSpecifierReferences(source, importerPath);
+ const replacements: Array = [];
+ for (const reference of references) {
+ if (reference.kind !== "import") continue;
+
+ const specifier = reference.specifier;
+ let replacement: string | null = null;
+ const remappedSpecifier = remapLegacyPiSpecifier(specifier);
+ if (remappedSpecifier) {
+ try {
+ replacement = toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier));
+ } catch {
+ // Compiled fallback may be absent from a malformed build. Continue to
+ // the extension's on-disk peer dependency resolution below.
+ }
+ }
+ if (!replacement && TYPEBOX_SHIM_PATH && (specifier === "typebox" || specifier === "@sinclair/typebox")) {
+ replacement = toImportSpecifier(TYPEBOX_SHIM_PATH);
+ }
+ if (!replacement && specifier.startsWith("#")) {
+ const resolved = await resolvePackageImportSpecifier(specifier, importerPath);
+ if (resolved) replacement = toGraphImportSpecifier(resolved, resolvedImportMtimeTag);
+ }
+ if (!replacement && isBareExtensionDependencySpecifier(specifier)) {
+ const resolved = await resolveExtensionBareDependency(specifier, importerPath);
+ if (resolved) replacement = toGraphImportSpecifier(resolved, resolvedImportMtimeTag);
+ }
+ if (!replacement && mtimeTag && /^\.\.?\//.test(specifier) && !specifier.includes("?")) {
+ replacement = `${specifier}?mtime=${mtimeTag}`;
+ }
+ if (replacement && replacement !== specifier) {
+ replacements.push({ ...reference, replacement });
+ }
}
- return withNativeAddons.replace(
- RELATIVE_GRAPH_IMPORT_SPECIFIER_REGEX,
- (_match, prefix: string, specifier: string, suffix: string) => `${prefix}${specifier}?mtime=${mtimeTag}${suffix}`,
- );
+ const withImports = applySpecifierReplacements(source, replacements);
+ return rewriteExtensionBareRequires(withImports, importerPath);
}
/** Test seam for compiled-binary legacy extension source rewriting. */
@@ -552,15 +662,11 @@ export async function __rewriteLegacyExtensionSourceForTests(
source: string,
importerPath: string,
mtimeTag: string | null = null,
+ resolvedImportMtimeTag: string | null = mtimeTag,
): Promise {
- return rewriteLegacyExtensionSource(source, importerPath, mtimeTag);
+ return rewriteLegacyExtensionSource(source, importerPath, mtimeTag, resolvedImportMtimeTag);
}
-// Match relative graph specifiers so their `./foo.ts` /`../foo` targets get a
-// `?mtime=` 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=` (Bun keys
@@ -672,7 +778,10 @@ async function readPackageImports(packageRoot: string): Promise = SUPPORTED_PACKAGE_IMPORT_CONDITIONS,
+): PackageImportTargetSelection {
if (entry === null) {
return PACKAGE_IMPORT_EXCLUDED;
}
@@ -681,7 +790,7 @@ function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection
}
if (Array.isArray(entry)) {
for (const item of entry) {
- const target = selectPackageImportTarget(item);
+ const target = selectPackageImportTarget(item, conditions);
if (target !== null) return target;
}
return null;
@@ -690,10 +799,10 @@ function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection
return null;
}
for (const [condition, value] of Object.entries(entry)) {
- if (!SUPPORTED_PACKAGE_IMPORT_CONDITIONS.has(condition)) {
+ if (!conditions.has(condition)) {
continue;
}
- const target = selectPackageImportTarget(value);
+ const target = selectPackageImportTarget(value, conditions);
if (target !== null) return target;
}
return null;
@@ -765,38 +874,6 @@ async function resolvePackageImportSpecifier(specifier: string, importerPath: st
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 {
- 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(".") ||
@@ -888,9 +965,10 @@ async function resolveNodePackageExport(
packageRoot: string,
subpath: string | null,
manifest: Record,
+ conditions: ReadonlySet = SUPPORTED_PACKAGE_IMPORT_CONDITIONS,
): Promise {
const exportsField = manifest.exports;
- const rootTarget = subpath === null ? selectPackageImportTarget(exportsField) : null;
+ const rootTarget = subpath === null ? selectPackageImportTarget(exportsField, conditions) : null;
if (rootTarget !== null && rootTarget !== PACKAGE_IMPORT_EXCLUDED) {
return resolvePackageExportTarget(packageRoot, rootTarget, null);
}
@@ -899,30 +977,41 @@ async function resolveNodePackageExport(
}
const exactKey = subpath === null ? "." : `./${subpath}`;
- const exactTarget = selectPackageImportTarget(exportsField[exactKey]);
- if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) {
- return resolvePackageExportTarget(packageRoot, exactTarget, null);
+ if (Object.hasOwn(exportsField, exactKey)) {
+ const exactTarget = selectPackageImportTarget(exportsField[exactKey], conditions);
+ return exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED
+ ? resolvePackageExportTarget(packageRoot, exactTarget, null)
+ : null;
}
+ let bestMatch: {
+ keyLength: number;
+ prefixLength: number;
+ target: PackageImportTargetSelection;
+ wildcard: string;
+ } | null = null;
for (const [key, entry] of Object.entries(exportsField)) {
const starIndex = key.indexOf("*");
- if (starIndex === -1 || subpath === null) continue;
+ if (starIndex === -1 || subpath === null || !key.startsWith("./")) continue;
const prefix = key.slice(2, starIndex);
const suffix = key.slice(starIndex + 1);
- if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
- continue;
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
+ if (
+ !bestMatch ||
+ prefix.length > bestMatch.prefixLength ||
+ (prefix.length === bestMatch.prefixLength && key.length > bestMatch.keyLength)
+ ) {
+ bestMatch = {
+ keyLength: key.length,
+ prefixLength: prefix.length,
+ target: selectPackageImportTarget(entry, conditions),
+ wildcard: subpath.slice(prefix.length, subpath.length - suffix.length),
+ };
}
- 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;
+ return bestMatch?.target && bestMatch.target !== PACKAGE_IMPORT_EXCLUDED
+ ? resolvePackageExportTarget(packageRoot, bestMatch.target, bestMatch.wildcard)
+ : null;
}
async function resolveNodePackageFallback(
@@ -950,10 +1039,29 @@ async function resolveNodePackageDependency(specifier: string, importerPath: str
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))
- );
+ return Object.hasOwn(manifest, "exports")
+ ? resolveNodePackageExport(packageRoot, parsed.subpath, manifest)
+ : resolveNodePackageFallback(packageRoot, parsed.subpath, manifest);
+}
+
+async function resolveNodePackageRequire(specifier: string, importerPath: string): Promise {
+ 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;
+
+ if (Object.hasOwn(manifest, "exports")) {
+ return resolveNodePackageExport(packageRoot, parsed.subpath, manifest, SUPPORTED_PACKAGE_REQUIRE_CONDITIONS);
+ }
+ if (parsed.subpath !== null) {
+ return resolveSourceModuleFile(path.join(packageRoot, parsed.subpath));
+ }
+ const main = manifest.main;
+ return typeof main === "string"
+ ? await resolveSourceModuleFile(path.resolve(packageRoot, main))
+ : await resolveSourceModuleFile(path.join(packageRoot, "index"));
}
async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise {
@@ -971,6 +1079,13 @@ async function resolveExtensionBareDependency(specifier: string, importerPath: s
}
async function resolveExtensionBareDependencyUncached(specifier: string, importerPath: string): Promise {
+ // Resolve against the runtime package manifest first. Besides working in a
+ // compiled binary, this preserves the package's ESM `import` condition when
+ // the absolute target is later loaded outside normal package resolution.
+ const packageResolved = await resolveNodePackageDependency(specifier, importerPath);
+ if (packageResolved) {
+ return packageResolved;
+ }
try {
const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
@@ -979,16 +1094,11 @@ async function resolveExtensionBareDependencyUncached(specifier: string, importe
} catch {
// Compiled binaries do not reliably resolve runtime extension node_modules.
}
- return resolveNodePackageDependency(specifier, importerPath);
+ return null;
}
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
@@ -1031,42 +1141,63 @@ async function resolveExtensionNativeAddonUncached(specifier: string, importerPa
return realpathOrSelf(target);
}
+async function resolveExtensionBareRequire(specifier: string, importerPath: string): Promise {
+ if (!isBareExtensionDependencySpecifier(specifier)) {
+ return null;
+ }
+
+ const cacheKey = `${specifier}\0${path.resolve(path.dirname(importerPath))}`;
+ const cached = bareRequireResolutionCache.get(cacheKey);
+ if (cached) return cached;
+
+ const resolution = (async () => {
+ const nativeAddon = await resolveExtensionNativeAddon(specifier, importerPath);
+ if (nativeAddon) {
+ return nativeAddon;
+ }
+ const packageResolved = await resolveNodePackageRequire(specifier, importerPath);
+ if (packageResolved) {
+ return realpathOrSelf(packageResolved);
+ }
+ try {
+ const resolved = createRequire(importerPath).resolve(specifier);
+ return resolved === specifier || resolved.startsWith("node:") || resolved.startsWith("bun:")
+ ? null
+ : await realpathOrSelf(resolved);
+ } catch {
+ return null;
+ }
+ })();
+ bareRequireResolutionCache.set(cacheKey, resolution);
+ return resolution;
+}
+
/**
- * 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-'") even when the package sits in the
- * extension's own node_modules; requiring the addon by absolute path works.
+ * Rewrite bare `require()` specifiers into absolute-path requires. In
+ * `bun build --compile` binaries, runtime resolution fails even when the
+ * package sits in the extension's own node_modules. Manifest resolution
+ * preserves CommonJS `require` export conditions and native-addon entrypoints;
+ * relative and builtin requires remain untouched.
*/
-async function rewriteExtensionNativeAddonRequires(source: string, importerPath: string): Promise {
- 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);
+async function rewriteExtensionBareRequires(source: string, importerPath: string): Promise {
+ const references = await collectExtensionSpecifierReferences(source, importerPath);
+ const replacements: Array = [];
+ for (const reference of references) {
+ if (reference.kind !== "require") continue;
+ const resolved = await resolveExtensionBareRequire(reference.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;
+ replacements.push({
+ ...reference,
+ replacement: stripWindowsExtendedLengthPathPrefix(resolved).replaceAll("\\", "/"),
+ });
}
-
- if (lastIndex === 0) {
- return source;
- }
- return `${rewritten}${source.slice(lastIndex)}`;
+ return applySpecifierReplacements(source, replacements);
}
/**
* 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
+ * extension graph so {@link rewriteExtensionBareRequires} can pin its
* platform-package requires to absolute paths.
*/
async function moduleRequiresNativeAddon(modulePath: string): Promise {
@@ -1085,61 +1216,29 @@ async function moduleRequiresNativeAddonUncached(modulePath: string): Promise {
- 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;
+// Source modules in an extension graph are discovered from parsed static,
+// dynamic, re-export, and direct CommonJS require specifiers. Parsing keeps
+// import-looking text in strings, templates, regex literals, and comments out
+// of dependency resolution.
// 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>();
+const extensionGraphCacheBustResolvedImportModules = new Map>();
const commonJsModuleSources = new Map();
const commonJsFallbackModulePaths = new Map();
const COMMONJS_REQUIRE_GLOBAL = "__ompLegacyPiRequireGraphModule";
@@ -1234,22 +1333,27 @@ async function realpathOrSelfUncached(p: string): Promise {
}
}
+interface ExtensionModuleGraph {
+ readonly modules: Map;
+ readonly cacheBustResolvedImportModules: Set;
+}
+
/**
* 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.
+ * Relative imports, package `imports` aliases, and ESM bare dependencies are
+ * graph-owned recursively because compiled Bun cannot resolve runtime
+ * `node_modules` from those modules. Resolved imports inside third-party
+ * dependencies omit the reload tag so their importer paths stay query-free.
* CommonJS modules reached through `require()` stay on Bun's native loader
* unless they resolve native addons. CommonJS reached through ESM imports stays
* graph-owned so the load hook can expose its exports through an ESM default.
*/
-async function collectExtensionModules(entryRealPath: string): Promise