33c161d9dd
- 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.
1434 lines
55 KiB
TypeScript
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();
|
|
}
|