diff --git a/core/plugins/fs.ts b/core/plugins/fs.ts index 89dcdd7..3b4f1d1 100644 --- a/core/plugins/fs.ts +++ b/core/plugins/fs.ts @@ -3,7 +3,7 @@ import type { LocalState, ESBUILD } from "../types.ts"; import type { IFileSystem } from "../utils/filesystem.ts"; import type { Context } from "../context/context.ts"; -import { getFile } from "../utils/filesystem.ts"; +import { fileExists, getFile } from "../utils/filesystem.ts"; import { fromContext } from "../context/context.ts"; import { inferLoader, RESOLVE_EXTENSIONS } from "../utils/loader.ts"; import { dirname, extname, resolve } from "@bundle/utils/path"; @@ -35,16 +35,144 @@ export interface VirtualFileSystemPluginOptions { enableIndexFallback?: boolean; } +export interface VfsResolutionState extends LocalState { + resolveExtensions: string[], + enableIndexFallback: boolean, + prefixes: string[] +} + +export async function resolveVfsPath( + fs: IFileSystem, + candidate: string, + resolveExtensions: readonly string[], + enableIndexFallback = true, +): Promise { + // 1) Exact file hit. + if (await fileExists(fs, candidate)) + return candidate; + + // 2) Extension probing for extensionless imports. + if (extname(candidate).length === 0) { + for (const ext of resolveExtensions) { + const withExt = candidate + ext; + if (await fileExists(fs, withExt)) + return withExt; + } + } + + // 3) Optional directory -> index.* fallback. + // We can’t reliably distinguish file vs folder using only `has()`, + // so we just probe index.* in the directory path. + if (enableIndexFallback) { + for (const ext of resolveExtensions) { + const indexPath = resolve(candidate, "index" + ext); + if (await fileExists(fs, indexPath)) + return indexPath; + } + } + + return null; +} + +/** + * Remove the first matching prefix from `path` if present. + */ +export function stripAnyPrefix(path: string, prefixes: readonly string[]): string { + for (const prefix of prefixes) { + if (path.startsWith(prefix)) { + const rest = path.slice(prefix.length); + // Allow both `vfs:/x` and `vfs:x` + return rest.startsWith("/") ? rest : "/" + rest; + } + } + + return path; +} + +/** + * Core resolution logic shared by all VFS handlers. + * + * Returns undefined if the path doesn't resolve to a VFS file, + * allowing other plugins to handle it. + */ +export function VfsResolution(StateContext: Context>) { + const FileSystem = fromContext("filesystem", StateContext)!; + + const enableIndexFallback = fromContext("enableIndexFallback", StateContext) ?? true; + const resolveExtensions = fromContext("resolveExtensions", StateContext) ?? []; + const prefixes = fromContext("prefixes", StateContext) ?? []; + + return async function (args: ESBUILD.OnResolveArgs): Promise { + + + // 1) Never let VFS touch URLs. These belong to the HTTP/CDN pipeline. + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(args.path)) return; + + // 2) Strip optional VFS prefixes. + const stripped = stripAnyPrefix(args.path, prefixes); + + const isAbsolute = stripped.startsWith("/"); + const isRelative = stripped.startsWith("./") || stripped.startsWith("../"); + + // 3) Skip bare specifiers ("react", "@scope/pkg") entirely. + // Let Alias/Cdn/External resolve those. + if (!isAbsolute && !isRelative) return; + + // 4) Relative specifiers are only meaningful inside VFS. + // Otherwise we risk capturing unrelated plugin namespaces. + if (isRelative && args.namespace !== VIRTUAL_FILESYSTEM_NAMESPACE) return; + + // 5) Base directory: + // For VFS modules, esbuild will pass us `resolveDir` (because we return it in onLoad). + // For entry points, resolveDir may be empty; absolute paths still work. + const baseDir = args.resolveDir && args.resolveDir.length > 0 + ? args.resolveDir + : "/"; + + const candidate = isRelative ? resolve(baseDir, stripped) : resolve(stripped); + + // 6) Probe the VFS similarly to esbuild’s filesystem resolver: + // - exact path + // - extension probing if extensionless + // - optional directory -> index.* fallback + const resolved = await resolveVfsPath(FileSystem, candidate, resolveExtensions, enableIndexFallback); + if (!resolved) return; + + return { + // IMPORTANT: This is now the canonical identity for the module. + path: resolved, + namespace: VIRTUAL_FILESYSTEM_NAMESPACE, + pluginData: Object.assign({}, args.pluginData, { + vfsOriginalSpecifier: args.path, + }), + }; + }; +} + /** * Create an esbuild plugin that resolves and loads modules from the in-memory filesystem. * * Why this exists: - * - Your build uses entry points like `/index.tsx` (not present on disk) :contentReference[oaicite:6]{index=6} + * - Your build uses entry points like `/index.tsx` (not present on disk) * - You want Node-like resolution behavior while still letting esbuild do the heavy lifting * * Critical correctness rule: * - `onResolve` MUST return the final, canonical module path * (esbuild caches by `(namespace, path)`; returning raw specifiers causes collisions). + * + * Uses multiple targeted handlers instead of a single catch-all. + * This eliminates the need to explicitly skip URLs/bare imports and reduces + * unnecessary processing overhead. + * + * Handler strategy: + * 1. VFS-prefixed paths (`vfs:`, `virtual:`) - any namespace + * 2. Absolute paths (`/...`) - any namespace + * 3. Relative paths (`./`, `../`) - VFS namespace ONLY + * + * This ensures: + * - URLs are never matched (no need for explicit skip) + * - Bare imports are never matched (no need for explicit skip) + * - Relative imports from other namespaces don't trigger VFS */ export function VirtualFileSystemPlugin( StateContext: Context>, @@ -55,79 +183,64 @@ export function VirtualFileSystemPlugin( const prefixes = opts.prefixes ?? ["vfs:", "virtual:"]; const enableIndexFallback = opts.enableIndexFallback ?? true; + // Build regex for VFS prefixes: /^(vfs:|virtual:)/ + const prefixPattern = new RegExp(`^(${prefixes.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`); + return { name: VIRTUAL_FILESYSTEM_NAMESPACE, setup(build) { - const resolveExtensions = (build.initialOptions.resolveExtensions?.slice() ?? - RESOLVE_EXTENSIONS.slice()); - + const resolveExtensions = ( + build.initialOptions.resolveExtensions?.slice() + ?? RESOLVE_EXTENSIONS.slice() + ); + + const ctx = StateContext.with({ + resolveExtensions, + enableIndexFallback, + prefixes + }) as Context>; + /** - * Only treat these as VFS paths: - * - `/absolute` - * - `./relative` or `../relative` (but ONLY when importer is already in VFS namespace) - * - `vfs:` / `virtual:` prefixed + * Handler 1: VFS-prefixed paths (any namespace) + * + * Matches: `vfs:/index.tsx`, `virtual:./component.ts` + * These are explicit VFS references that should always be handled. */ - build.onResolve({ filter: /.*/ }, async (args) => { - // 1) Never let VFS touch URLs. These belong to the HTTP/CDN pipeline. - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(args.path)) { - return; - } - - // 2) Strip optional VFS prefixes. - const stripped = stripAnyPrefix(args.path, prefixes); - - const isAbsolute = stripped.startsWith("/"); - const isRelative = stripped.startsWith("./") || stripped.startsWith("../"); - - // 3) Skip bare specifiers ("react", "@scope/pkg") entirely. - // Let Alias/Cdn/External resolve those. - if (!isAbsolute && !isRelative) { - return; - } - - // 4) Relative specifiers are only meaningful inside VFS. - // Otherwise we risk capturing unrelated plugin namespaces. - if (isRelative && args.namespace !== VIRTUAL_FILESYSTEM_NAMESPACE) { - return; - } - - // 5) Base directory: - // For VFS modules, esbuild will pass us `resolveDir` (because we return it in onLoad). - // For entry points, resolveDir may be empty; absolute paths still work. - const baseDir = args.resolveDir && args.resolveDir.length > 0 - ? args.resolveDir - : "/"; - - const candidate = isRelative ? resolve(baseDir, stripped) : resolve(stripped); - - // 6) Probe the VFS similarly to esbuild’s filesystem resolver: - // - exact path - // - extension probing if extensionless - // - optional directory -> index.* fallback - const resolved = await resolveVfsPath(FileSystem, candidate, resolveExtensions, enableIndexFallback); - - if (!resolved) { - return; - } - - return { - // IMPORTANT: This is now the canonical identity for the module. - path: resolved, - namespace: VIRTUAL_FILESYSTEM_NAMESPACE, - pluginData: Object.assign({}, args.pluginData, { - vfsOriginalSpecifier: args.path, - }), - }; - }); + build.onResolve({ filter: prefixPattern }, VfsResolution(ctx)); + + /** + * Handler 2: Absolute paths (any namespace) + * + * Matches: `/index.tsx`, `/src/main.ts` + * Absolute paths can come from any namespace and should resolve against VFS root. + * + * Note: This does NOT match URLs like `https://...` because they start with + * a protocol, not `/`. The regex `^\/` only matches paths starting with `/`. + */ + build.onResolve({ filter: /^\// }, VfsResolution(ctx)); + + /** + * Handler 3: Relative paths from VFS namespace ONLY + * + * Matches: `./foo`, `../bar` (when importer is in VFS namespace) + * + * Critical: We only handle relative imports when the importer is already + * in the VFS namespace. This prevents VFS from intercepting relative + * imports from HTTP/CDN modules. + */ + build.onResolve({ filter: /^\.\.?\//, namespace: VIRTUAL_FILESYSTEM_NAMESPACE }, VfsResolution(ctx)); + /** + * onLoad handler for VFS namespace + * + * Loads file contents from the virtual filesystem. + */ build.onLoad({ filter: /.*/, namespace: VIRTUAL_FILESYSTEM_NAMESPACE }, async (args) => { // args.path is canonical (because onResolve returned it). const content = await getFile(FileSystem, args.path, "buffer"); // `getFile` returns null when missing/invalid; empty files are OK. - if (content === null) { - return; - } + if (content === null) return; return { contents: content, @@ -143,65 +256,6 @@ export function VirtualFileSystemPlugin( }; }; -/** - * Remove the first matching prefix from `path` if present. - */ -function stripAnyPrefix(path: string, prefixes: readonly string[]): string { - for (const prefix of prefixes) { - if (path.startsWith(prefix)) { - const rest = path.slice(prefix.length); - // Allow both `vfs:/x` and `vfs:x` - return rest.startsWith("/") ? rest : "/" + rest; - } - } - return path; -} - -async function resolveVfsPath( - fs: IFileSystem, - candidate: string, - resolveExtensions: readonly string[], - enableIndexFallback: boolean, -): Promise { - // 1) Exact file hit. - if (await fileExists(fs, candidate)) { - return candidate; - } - - // 2) Extension probing for extensionless imports. - if (extname(candidate).length === 0) { - for (const ext of resolveExtensions) { - const withExt = candidate + ext; - if (await fileExists(fs, withExt)) { - return withExt; - } - } - } - - // 3) Optional directory -> index.* fallback. - // We can’t reliably distinguish file vs folder using only `has()`, - // so we just probe index.* in the directory path. - if (enableIndexFallback) { - for (const ext of resolveExtensions) { - const indexPath = resolve(candidate, "index" + ext); - if (await fileExists(fs, indexPath)) { - return indexPath; - } - } - } - - return null; -} - -async function fileExists(fs: IFileSystem, path: string): Promise { - // This treats empty files as existing (Uint8Array length can be 0). - // It also avoids counting directories as “existing modules”. - const content = await getFile(fs, path, "buffer"); - return content !== null; -} - - - /** * Examples * @@ -219,4 +273,36 @@ async function fileExists(fs: IFileSystem, path: string): Promise * - /index.ts imports "https://pkg.pr.new/@tanstack/react-query@7988" * * VFS returns undefined for URLs, so your HTTP/CDN plugins handle it. + * + * Complex examples: + * + * Example 1: Direct VFS entry point + * - Entry: `/index.tsx` + * - Handler 2 (absolute) matches, resolves via VFS + * + * Example 2: Relative import within VFS + * - Importer: `/index.tsx` (VFS namespace) + * - Import: `./component.tsx` + * - Handler 3 (relative + VFS namespace) matches, resolves relative to importer + * + * Example 3: HTTP URL import + * - Import: `https://esm.sh/react` + * - No handler matches (not prefixed, not absolute `/`, not relative) + * - Falls through to HTTP/CDN plugins + * + * Example 4: Bare import + * - Import: `react` + * - No handler matches + * - Falls through to Alias/External/CDN plugins + * + * Example 5: Tarball URL + * - Import: `https://pkg.pr.new/@tanstack/react-query@7988` + * - No handler matches + * - TarballPlugin can handle it without VFS interference + * + * Example 6: Relative import from HTTP module + * - Importer: `https://esm.sh/react` (HTTP namespace) + * - Import: `./scheduler` + * - Handler 3 does NOT match (wrong namespace) + * - HttpPlugin handles the relative resolution */ diff --git a/core/plugins/http.ts b/core/plugins/http.ts index 9a94b4c..f3af374 100644 --- a/core/plugins/http.ts +++ b/core/plugins/http.ts @@ -1,8 +1,34 @@ -/** Based on https://github.com/hardfist/neo-tools/blob/main/packages/bundler/src/plugins/http.ts */ +/** Inspired by https://github.com/hardfist/neo-tools/blob/main/packages/bundler/src/plugins/http.ts */ +/** + * HTTP Plugin for esbuild + * + * Handles HTTP/HTTPS URL resolution and content loading: + * - Resolves relative imports within downloaded files + * - Resolves bare imports via CdnResolution + * - Fetches file content with extension probing + * - Extracts assets (WASM, Workers) from fetched files + * + * @module + * + * @example Direct HTTP import + * ```ts + * import { something } from "https://esm.sh/lodash@4.17.21"; + * // -> HTTP_NAMESPACE, loads content from URL + * ``` + * + * @example Relative import from HTTP source + * ```ts + * // Inside https://esm.sh/lodash@4.17.21/index.js + * import { debounce } from "./debounce.js"; + * // -> Resolved to https://esm.sh/lodash@4.17.21/debounce.js + * ``` + */ import type { ESBUILD, LocalState } from "../types.ts"; import type { Context } from "../context/context.ts"; +import type { CdnResolutionState } from "./cdn.ts"; import { fromContext, toContext } from "../context/context.ts"; +import { CdnResolution } from "./cdn.ts"; import { getRequest } from "@bundle/utils/fetch-and-cache"; import { decode } from "@bundle/utils/encode-decode"; @@ -16,16 +42,20 @@ import { setFile } from "../utils/filesystem.ts"; import { isBareImport, isAbsolute } from "@bundle/utils/path"; import { toURLPath, urlJoin } from "@bundle/utils/url"; -import { CdnResolution } from "./cdn.ts"; - /** HTTP Plugin Namespace */ export const HTTP_NAMESPACE = "http-url"; +export interface HttpResolutionState extends LocalState { + build: ESBUILD.PluginBuild +} + /** - * Fetches packages - * - * @param url package url to fetch - * @param logger Console log + * Fetches packages from HTTP/HTTPS URLs + * + * @param url Package URL to fetch + * @param fetchOpts Optional fetch configuration (e.g., { method: "HEAD" }) + * @returns Object with url, contentType, and content as Uint8Array + * @throws Error if fetch fails or returns HTML */ export async function fetchPkg(url: string, fetchOpts?: RequestInit) { try { @@ -54,19 +84,19 @@ export async function fetchPkg(url: string, fetchOpts?: RequestInit) { } /** - * Fetches assets from a js file, e.g. assets like WASM, Workers, etc... - * External assets are referenced using this syntax, e.g. new URL("...", import.meta.url) - * Any external assets found inside said original js file, are fetched and stored - * - * @param path Path for original js files - * @param content Content of original js files - * @param namespace esbuild plugin namespace - * @param logger Console log + * Fetches assets referenced in JS files via `new URL("...", import.meta.url)` + * + * External assets like WASM files and Workers are discovered and fetched. + * These are stored in the virtual file system for later bundling. + * + * @param path URL path for the original JS file + * @param content Content of the original JS file + * @param StateContext Context with filesystem access + * @returns Promise of settled results for each discovered asset */ export async function fetchAssets(path: string, content: Uint8Array, StateContext: Context>) { - // Regex for `new URL("./path.js", import.meta.url)`, - // I added support for comments so you can add comments and the regex - // will ignore the comments + // Regex for `new URL("./path.js", import.meta.url)`, + // Supports comments so you can add comments and the regex will ignore them const rgx = /new(?:\s|\n?)+URL\((?:\s*(?:\/\*(?:.*\n)*\*\/)?(?:\/\/.*\n)?)*(?:(?!\`.*\$\{)['"`](.*)['"`]),(?:\s*(?:\/\*(?:.*\n)*\*\/)?(?:\/\/.*\n)?)*import\.meta\.url(?:\s*(?:\/\*(?:.*\n)*\*\/)?(?:\/\/.*\n)?)*\)/g; const parentURL = new URL("./", path).toString(); @@ -92,7 +122,8 @@ export async function fetchAssets(path: string, content: Uint8Array(path: string, content: Uint8Array { - return exts.map(extension => ending + extension) -}).flat())); - -const endingVariantsLength = allEndingVariants.length; +export const AllEndingVariants = Array.from( + new Set( + FilePaths + .map(ending => { + return FileEndings.map(extension => ending + extension) + }) + .flat() + ) +); + +export const EndingVariantsLength = AllEndingVariants.length; /** - * Test the waters, what extension are we talking about here - * @param path - * @returns + * Probes for the correct file extension when not explicitly provided + * + * TypeScript files often don't have file extensions in imports, but servers + * require the full path. This function tries multiple extensions until one works. + * + * @param path Base path to probe + * @param headersOnly If true, only fetch headers (HEAD request) + * @param StateContext Optional context for caching failed probes + * @returns Object with resolved url, contentType, and optionally content */ -export async function determineExtension(path: string, headersOnly: boolean = true, StateContext: Context> | null = null) { +export async function determineExtension( + path: string, + headersOnly: boolean = true, + StateContext: Context> | null = null +) { // Some typescript files don't have file extensions but you can't fetch a file without their file extension // so bundlejs tries to solve for that const argPath = (suffix = "") => path + suffix; - const failedExtChecks = ( - StateContext ? fromContext("failedExtensionChecks", StateContext) : null - ) ?? new Set(); + const failedExtChecks = StateContext + ? fromContext("failedExtensionChecks", StateContext) + : null; + const failedSet = failedExtChecks ?? new Set(); let url = path; let content: Uint8Array | undefined; let contentType: string | null = null; let err: Error | undefined; - for (let i = 0; i < endingVariantsLength; i++) { - const endings = allEndingVariants[i]; + for (let i = 0; i < EndingVariantsLength; i++) { + const endings = AllEndingVariants[i]; const testingUrl = argPath(endings); try { - if (failedExtChecks?.has?.(testingUrl)) continue; + if (failedSet?.has?.(testingUrl)) continue; - ({ url, contentType, content } = await fetchPkg(testingUrl, headersOnly ? { method: "HEAD" } : undefined)); + ({ url, contentType, content } = await fetchPkg( + testingUrl, + headersOnly ? { method: "HEAD" } : undefined + )); break; } catch (e) { - failedExtChecks?.add?.(testingUrl); - - if (i === 0) { - err = e as Error; - } + failedSet?.add?.(testingUrl); + if (i === 0) err = e as Error; // If after checking all the different file extensions none of them are valid // Throw the first fetch error encountered, as that is generally the most accurate error - if (i >= endingVariantsLength - 1) { + if (i >= EndingVariantsLength - 1) { dispatchEvent(LOGGER_ERROR, err ?? e as Error); throw err ?? e; } @@ -161,12 +209,18 @@ export async function determineExtension(path: string, headersOnly: boolean = /** * Resolution algorithm for the esbuild HTTP plugin - * - * @param host The default host origin to use if an import doesn't already have one - * @param logger Console log + * + * Handles three cases: + * 1. HTTP/HTTPS URLs - direct load via HTTP_NAMESPACE + * 2. Bare imports - delegate to CdnResolution + * 3. Relative/absolute imports - resolve against parent URL + * + * @param StateContext Context with host and config + * @param build Optional esbuild PluginBuild for URL routing (passed to CdnResolution) */ -export function HttpResolution(StateContext: Context>) { +export function HttpResolution(StateContext: Context>) { const host = fromContext("host", StateContext)!; + const build = fromContext("build", StateContext)!; return async function (args: ESBUILD.OnResolveArgs): Promise { // Some packages use "../../" with the assumption that "/" is equal to "/index.js", this is supposed to fix that bug @@ -182,7 +236,7 @@ export function HttpResolution(StateContext: Context>) { sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? args.pluginData?.manifest?.sideEffects : undefined, - pluginData: { manifest: args.pluginData?.manifest }, + pluginData: args.pluginData, }; } @@ -197,7 +251,7 @@ export function HttpResolution(StateContext: Context>) { // If the import is a bare import, use the CDN plugins resolution algorithm if (isBareImport(argPath)) { - const ctx = StateContext.with({ origin }) as Context & { origin: string }>; + const ctx = StateContext.with({ build }) as Context>; return await CdnResolution(ctx)(args); } else { /** @@ -220,7 +274,7 @@ export function HttpResolution(StateContext: Context>) { sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? args.pluginData?.manifest?.sideEffects : undefined, - pluginData: { manifest: args.pluginData?.manifest }, + pluginData: args.pluginData, }; } } @@ -239,7 +293,7 @@ export function HttpResolution(StateContext: Context>) { sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? args.pluginData?.manifest?.sideEffects : undefined, - pluginData: { manifest: args.pluginData?.manifest }, + pluginData: args.pluginData, }; }; } @@ -266,6 +320,8 @@ export function HttpPlugin(StateContext: Context>): ESBUILD.Plu return { name: HTTP_NAMESPACE, setup(build) { + const ctx = StateContext.with({ build }) as Context>; + // Intercept import paths starting with "http:" and "https:" so // esbuild doesn't attempt to map them to a file system location. // Tag them with the "http-url" namespace to associate them with @@ -274,6 +330,10 @@ export function HttpPlugin(StateContext: Context>): ESBUILD.Plu return { path: args.path, namespace: HTTP_NAMESPACE, + sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? + args.pluginData?.manifest?.sideEffects : + undefined, + pluginData: args.pluginData, }; }); @@ -282,7 +342,7 @@ export function HttpPlugin(StateContext: Context>): ESBUILD.Plu // files will be in the "http-url" namespace. Make sure to keep // the newly resolved URL in the "http-url" namespace so imports // inside it will also be resolved as URLs recursively. - build.onResolve({ filter: /.*/, namespace: HTTP_NAMESPACE }, HttpResolution(StateContext)); + build.onResolve({ filter: /.*/, namespace: HTTP_NAMESPACE }, HttpResolution(ctx)); // When a URL is loaded, we want to actually download the content // from the internet. This has just enough logic to be able to diff --git a/core/plugins/tar.ts b/core/plugins/tar.ts index 5be8dd5..09c97f4 100644 --- a/core/plugins/tar.ts +++ b/core/plugins/tar.ts @@ -1,313 +1,612 @@ -import type { PackageJson, FullPackageVersion } from "@bundle/utils/types"; +/** + * Tarball Plugin for esbuild + * + * Handles tarball-based package sources like `pkg.pr.new` by: + * 1. Fetching and extracting tarballs into the virtual filesystem + * 2. Resolving package entry points via exports/main fields + * 3. Enabling imports from within extracted packages (including self-references) + * + * @example + * ```ts + * // Direct tarball URL import + * import { useQuery } from "https://pkg.pr.new/@tanstack/react-query@7988"; + * + * // Subpath import + * import { QueryClient } from "https://pkg.pr.new/@tanstack/react-query@7988/build/modern"; + * ``` + */ +import type { TarStreamEntry } from "@bundle/utils/tar"; import type { LocalState, ESBUILD } from "../types.ts"; -import type { Context, record } from "../context/context.ts"; +import type { PackageJson } from "@bundle/utils/types"; +import type { Context } from "../context/context.ts"; -import { fromContext } from "../context/context.ts"; +import { fromContext, toContext } from "../context/context.ts"; +import { UntarStream } from "@bundle/utils/tar"; import { resolve, legacy } from "@bundle/utils/resolve-exports-imports"; -import { parsePackageName } from "@bundle/utils/parse-package-name"; -import { getPackageOfVersion, getRegistryURL } from "@bundle/utils/npm-search"; -import { decompress } from "@bundle/compress/gzip" -import { extname, isBareImport, join } from "@bundle/utils/path"; +import { normalize, join } from "@bundle/utils/path"; import { getRequest } from "@bundle/utils/fetch-and-cache"; -import { deepMerge } from "@bundle/utils/deep-object"; -import { determineExtension, HTTP_NAMESPACE } from "./http.ts"; -import { dispatchEvent, LOGGER_WARN } from "../configs/events.ts"; +import { VIRTUAL_FILESYSTEM_NAMESPACE } from "./fs.ts"; +import { dispatchEvent, LOGGER_INFO, LOGGER_WARN, LOGGER_ERROR } from "../configs/events.ts"; -import { getCDNUrl, getCDNStyle, DEFAULT_CDN_HOST } from "../utils/cdn-format.ts"; import { getResolverConditions } from "../utils/resolve-conditions.ts"; +import { setFile, getFile } from "../utils/filesystem.ts"; +import { getCDNStyle } from "../utils/cdn-format.ts"; -/** CDN Plugin Namespace */ -export const TARBALL_NAMESPACE = "tar-url"; +import { + detectArchiveFromResponse, + type ArchiveDetection, + type ArchiveDiagnostic +} from "@bundle/utils/archive-detect"; -/** - * Resolution algorithm for the esbuild CDN plugin - * - * @param cdn The default CDN to use - * @param logger Console log - */ -export function TarResolution(StateContext: Context & { origin: string }>) { - const LocalConfig = fromContext("config", StateContext)!; - const manifest: Partial = LocalConfig["package.json"] ?? {}; - const esbuildOpts = LocalConfig.esbuild ?? {}; +/** Tarball Plugin Namespace */ +export const TARBALL_NAMESPACE = "tarball-url"; - const cdn = fromContext("origin", StateContext)! ?? DEFAULT_CDN_HOST; - const failedManifestUrls = fromContext("failedManifestUrls", StateContext) ?? new Set(); - const packageManifestsMap = fromContext("packageManifests", StateContext) ?? new Map(); +/** Root directory for extracted tarballs in VFS */ +const TARBALL_ROOT = "/__tarballs__"; - return async function (args: ESBUILD.OnResolveArgs): Promise { - const conditions = getResolverConditions(args, esbuildOpts); - let argPath = args.path; - - // Conceptually package.json = manifest, but for naming reasons we'll just call it manifest - const { sideEffects: _sideEffects, ..._inheritedManifest } = args.pluginData?.manifest ?? {}; - - // Object.assign & deepMerge essentially do the same thing for flat objects, - // except there are some instances where Object.assign is faster - const initialManifest: PackageJson | FullPackageVersion = deepMerge( - structuredClone(manifest), - - // If we've manually set the version of the dependency in the config, - // then force all occurances of that dependency to use the version specified in the config - Object.assign( - structuredClone(_inheritedManifest), - manifest.devDependencies ? { devDependencies: manifest.devDependencies } : null, - manifest.peerDependencies ? { peerDependencies: manifest.peerDependencies } : null, - manifest.dependencies ? { dependencies: manifest.dependencies } : null, - ) - ); +/** + * Mounted tarball metadata stored in StateContext + */ +export interface TarballMount { + /** When this mount was created (for cache eviction) */ + createdAt: number; + /** VFS path to the extracted package root */ + packageRoot: string; + /** The package.json manifest */ + manifest: PackageJson; + /** Original tarball URL */ + sourceUrl: string; +} - const initialDeps = Object.assign( - {}, - initialManifest.devDependencies, - initialManifest.peerDependencies, - initialManifest.dependencies, - ); +/** + * Extended LocalState with tarball-specific caches + */ +export interface TarballState { + /** Map of tarball URL -> mount info */ + tarballMounts: Map; + /** Inflight tarball fetches to prevent duplicate work */ + tarballInflight: Map>; +} - // Support a different default CDN + allow for custom CDN url schemes - const { path: _argPath, origin } = getCDNUrl(argPath, cdn); - const TARBALL_CDN = getCDNStyle(origin) === "tarball"; +/** + * Check if a URL points to a tarball-style CDN (like pkg.pr.new) + */ +function isTarballUrl(url: URL): boolean { + return getCDNStyle(url.origin) === "tarball"; +} - if (TARBALL_CDN) { - const { url } = getCDNUrl(argPath, origin); +/** + * Generate a stable, content-addressed key for a tarball URL + */ +async function getTarballKey(url: string): Promise { + // Normalize the URL for stable keys + const normalized = new URL(url); + normalized.hash = ""; + + // Sort search params for consistency + const params = Array.from(normalized.searchParams.entries()).sort(([a], [b]) => a.localeCompare(b)); + normalized.search = ""; + for (const [k, v] of params) { + normalized.searchParams.append(k, v); + } + + const bytes = new TextEncoder().encode(normalized.toString()); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hashArray = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")); + return hashArray.join("").slice(0, 16); +} - // Strongly cache package.json files - const res = await getRequest(url, true); - if (!res.ok) throw new Error(await res.text()); +/** + * Parse pkg.pr.new URL to extract package spec and subpath + * + * @example + * ``` + * https://pkg.pr.new/@tanstack/react-query@7988 + * -> { pkgSpec: "@tanstack/react-query@7988", subpath: "" } + * + * https://pkg.pr.new/@tanstack/react-query@7988/build/modern + * -> { pkgSpec: "@tanstack/react-query@7988", subpath: "/build/modern" } + * ``` + */ +export function parseTarballUrl(url: URL): { pkgSpec: string; subpath: string; packageUrl: URL } { + const parts = url.pathname.split("/").filter(Boolean); + + let pkgSpec = ""; + let rest: string[] = []; + + if (parts.length === 0) { + return { pkgSpec: "", subpath: "", packageUrl: url }; + } + + // Handle scoped packages (@scope/name@version) + if (parts[0].startsWith("@")) { + pkgSpec = parts.slice(0, 2).join("/"); + rest = parts.slice(2); + } else { + pkgSpec = parts[0]; + rest = parts.slice(1); + } + + // Build the package root URL (without subpath) + const packageUrl = new URL(url.toString()); + if (pkgSpec) { + const scopedParts = pkgSpec.split("/"); + packageUrl.pathname = `/${scopedParts.join("/")}`; + } + + return { + pkgSpec, + subpath: rest.length > 0 ? `/${rest.join("/")}` : "", + packageUrl + }; +} - const contentTypeHeader = res.headers.get("content-type"); - const contentType = contentTypeHeader?.trim()?.toLowerCase(); +/** + * Strip the "package/" prefix that npm tarballs typically have + */ +export function stripPackagePrefix(path: string): string { + if (path.startsWith("package/")) { + return path.slice("package/".length); + } + return path; +} - if (contentType === "application/tar+gzip") { - +/** + * Fetch and extract a tarball into the virtual filesystem + */ +export async function fetchAndExtractTarball( + url: string, + packageRoot: string, + StateContext: Context> +): Promise { + const FileSystem = fromContext("filesystem", StateContext)!; + + dispatchEvent(LOGGER_INFO, `Fetching tarball candidate: ${url}`); + + const response = await getRequest(url); + if (!response.ok) { + throw new Error(`Failed to fetch tarball: ${response.status} ${response.statusText}`); + } + + const { + detection, + diagnostic, + bodyForConsumption, + } = await detectArchiveFromResponse(url, response, { + peekWireBytes: 1024, + peekTarBytes: 512, + }); + + // If it isn't even tarball-like, bail early with a useful explanation. + if (!detection.isTarballLike) { + dispatchEvent(LOGGER_WARN, diagnostic.summary); + dispatchEvent(LOGGER_WARN, diagnostic.details); + throw new Error( + `TarballPlugin: URL did not resolve to a tarball-like payload.\n` + + `${diagnostic.summary}\n` + + `${diagnostic.details}`, + ); + } + + // If we cannot consume a body, we cannot extract. + if (!bodyForConsumption) { + dispatchEvent(LOGGER_WARN, diagnostic.summary); + dispatchEvent(LOGGER_WARN, diagnostic.details); + throw new Error(`TarballPlugin: response has no body; cannot extract.\n${diagnostic.summary}`); + } + + // Hard requirement: we only mount tar containers here. + // (If you later add ZipPlugin, this becomes a router decision instead of an error.) + if (detection.container !== "tar") { + dispatchEvent(LOGGER_WARN, diagnostic.summary); + dispatchEvent(LOGGER_WARN, diagnostic.details); + throw new Error( + `TarballPlugin: payload is not a tar container (detected=${detection.container}).\n` + + `${diagnostic.summary}\n` + + `${diagnostic.details}`, + ); + } + + const contentType = response.headers.get("content-type")?.trim().toLowerCase(); + + // pkg.pr.new returns application/tar+gzip + if (contentType !== "application/tar+gzip" && !url.endsWith(".tgz") && !url.endsWith(".tar.gz")) { + throw new Error(`Unexpected content type for tarball: ${contentType}`); + } + + // Build the tar entry stream based on detected wrapper. + // + // - gzip: supported via DecompressionStream in most modern runtimes + // - none/unknown: attempt direct untar (some servers lie; bytes decide) + // - others: fail with explicit “unsupported compression” + let tarEntryStream: ReadableStream; + + if (detection.compression === "gzip") { + tarEntryStream = bodyForConsumption + .pipeThrough(new DecompressionStream("gzip")) + .pipeThrough(new UntarStream()); + } else if (detection.compression === "none" || detection.compression === "unknown") { + tarEntryStream = bodyForConsumption.pipeThrough(new UntarStream()); + } else { + // zstd/xz/bzip2/etc: you detected it correctly, but runtime can't decode it here. + dispatchEvent(LOGGER_WARN, diagnostic.summary); + dispatchEvent(LOGGER_WARN, diagnostic.details); + throw new Error( + `TarballPlugin: unsupported tar wrapper "${detection.compression}".\n` + + `Add a decompressor (e.g. zstd) or route this to a dedicated plugin.\n` + + `${diagnostic.summary}\n` + + `${diagnostic.details}`, + ); + } + + // Extract into VFS. + const reader = tarEntryStream.getReader(); + let manifest: PackageJson | null = null; + + try { + while (true) { + const { done, value: entry } = await reader.read(); + if (done) break; + + // Normalize path and strip package/ prefix + const relativePath = normalize(stripPackagePrefix(entry.path)); + if (!relativePath || relativePath === ".") continue; + + // Skip directories (they're created implicitly by setFile) + if (entry.path.endsWith("/")) { + // Consume the readable stream even for directories + await entry.readable?.cancel(); + continue; } - } - - if (isBareImport(argPath)) { - - // npm standard CDNs, e.g. unpkg, skypack, esm.sh, etc... - const NPM_CDN = getCDNStyle(origin) === "npm"; - const TARBALL_CDN = getCDNStyle(origin) === "tarball"; - // Heavily based off of https://github.com/egoist/play-esbuild/blob/main/src/lib/esbuild.ts - const parsed = parsePackageName(_argPath, { defaultVersion: null }); - const parsedSubpath = parsed.path; + // Read file contents + const blob = await new Response(entry.readable).blob(); + const fileContent = new Uint8Array(await blob.arrayBuffer()); - // If the version of package isn't determinable from the path argument, - // check the inherited manifest for a potential version - let assumedVersion = parsed.version || "latest"; - if (!parsed.version) { - if (parsed.name in initialDeps) - assumedVersion = initialDeps[parsed.name]; - } - - let manifest = structuredClone(initialManifest); - let resultSubpath = parsedSubpath; + // Write to VFS + const vfsPath = join(packageRoot, relativePath); + await setFile(FileSystem, vfsPath, fileContent); - // If the CDN supports package.json and some other npm stuff, it counts as an npm CDN - if (NPM_CDN) { + // Capture package.json + if (relativePath === "package.json") { try { - const ext = extname(parsedSubpath); - const isDirectory = ext.length === 0; - const subpath = isDirectory ? parsedSubpath : ""; - let isSubpathDirectoryPackage = false; - - // If the subpath is a directory check to see if that subpath has a `package.json`, - // after which check if the parent directory has a `package.json` - const manifestVariants = [ - { path: getRegistryURL(`${parsed.name}@${assumedVersion}`).packageVersionURL }, - // { path: `${parsed.name}@${assumedVersion}/package.json` }, - isDirectory ? { - path: `${parsed.name}@${assumedVersion}${parsedSubpath}/package.json`, - isDir: true - } : null - ].filter(x => x !== null); - - const manifestVariantsLen = manifestVariants.length; - for (let i = 0; i < manifestVariantsLen; i++) { - const { path, isDir } = manifestVariants[i]!; - const { url } = getCDNUrl(path, origin); - - // If the url was fetched before and failed, skip it and try the next one - if (failedManifestUrls?.has?.(url.href) && i < manifestVariantsLen - 1) - continue; - - try { - // Strongly cache package.json files - const res = await getRequest(url, true); - if (!res.ok) throw new Error(await res.text()); - - manifest = await res.json(); - isSubpathDirectoryPackage = isDir ?? false; - - // If the package.json is not a sub-directory package, then we should cache it as such - if (!isDir) { - packageManifestsMap.set(`${parsed.name}${manifest?.version || assumedVersion}`, manifest); - } - break; - } catch (e) { - failedManifestUrls?.add?.(url.href); - - // If after checking all the different file extensions none of them are valid - // Throw the last fetch error encountered, as that is generally the most accurate error - if (i >= manifestVariantsLen - 1) - throw e; - } - } - - const relativePath = parsedSubpath.replace(/^\//, "./"); - - let modernResolve: ReturnType | null = null; - let legacyResolve: ReturnType | null = null; - let resolvedPath: string | null = parsedSubpath; - - try { - // Resolving imports & exports from the package.json - // If an import starts with "#" then it's a subpath-import, and should be treated as so - modernResolve = resolve(manifest, relativePath, { - browser: conditions.browser, - conditions: conditions.conditions, - require: conditions.require - }) || - // Same compat fallback as above (optional, but matches your current “try multiple ways” intent). - (!conditions.require - ? resolve(manifest, relativePath, { - browser: conditions.browser, - conditions: ["require", ...conditions.conditions], - require: true - }) - : undefined - ); - - if (modernResolve) { - resolvedPath = Array.isArray(modernResolve) ? modernResolve[0] : modernResolve; - } - // deno-lint-ignore no-empty - } catch (_) { } - - if (!modernResolve) { - // If the subpath has a package.json, and the modern resolve didn't work for it - // we can safely use legacy resolve, - // else, if the subpath doesn't have a package.json, then the subpath is literal, - // and we should just use the subpath as it is - // if there is no relative path, let's check the legacy resolve to fix it - const emptyRelativePath = relativePath.trim().length === 0 - if (isSubpathDirectoryPackage || emptyRelativePath) { - try { - // Resolving using main, module, etc... from package.json - legacyResolve = legacy(manifest, { browser: conditions.browser }) || - legacy(manifest, { fields: ["module", "main"] }) || - legacy(manifest, { fields: ["unpkg", "bin"] }); - - if (legacyResolve) { - // Some packages have `browser` fields in their package.json which have some values set to false - // e.g. typescript - > https://unpkg.com/browse/typescript@4.9.5/package.json - if (typeof legacyResolve === "object") { - const values = Object.values(legacyResolve); - const validValues = values.filter(x => x); - if (validValues.length <= 0) { - legacyResolve = legacy(manifest); - } - } - - if (Array.isArray(legacyResolve)) { - resolvedPath = legacyResolve[0]; - } else if (typeof legacyResolve === "object") { - const allKeys = Object.keys(legacyResolve); - const nonCJSKeys = allKeys.filter(key => { - return !/\.cjs$/.exec(key) && !/src\//.exec(key) && (legacyResolve as record)[key]!; - }); - const keysToUse = nonCJSKeys.length > 0 ? nonCJSKeys : allKeys; - resolvedPath = legacyResolve[keysToUse[0]] as string; - } else { - resolvedPath = legacyResolve; - } - } - // deno-lint-ignore no-empty - } catch (_) { } - } else resolvedPath = relativePath; - } - - if (resolvedPath && typeof resolvedPath === "string") { - resultSubpath = resolvedPath.replace(/^(\.\/)/, "/"); - } - - if (subpath && isSubpathDirectoryPackage) { - resultSubpath = `${subpath}${resultSubpath}`; - } + const text = new TextDecoder().decode(fileContent); + manifest = JSON.parse(text) as PackageJson; } catch (e) { - dispatchEvent(LOGGER_WARN, `You may want to change CDNs. The current CDN ${!/unpkg\.com/.test(origin) ? `"${origin}" doesn't` : `path "${origin}${_argPath}" may not`} support package.json files.\nThere is a chance the CDN you're using doesn't support looking through the package.json of packages. bundlejs will switch to inaccurate guesses for package versions. For package.json support you may wish to use https://unpkg.com or other CDN's that support package.json.`); - dispatchEvent(LOGGER_WARN, e); + dispatchEvent(LOGGER_WARN, `Failed to parse tarball package.json: ${String(e)}`); } } + } + } catch (e) { + // This is where archive-detect earns its keep: if untar fails, we print the full reasoning trail. + dispatchEvent(LOGGER_ERROR, new AggregateError([diagnostic.details], diagnostic.summary, { cause: e})); + throw new Error( + `TarballPlugin: extraction failed.\n` + + `${diagnostic.summary}\n` + + `${diagnostic.details}\n` + + `Cause: ${String(e)}`, + ); + } finally { + reader.releaseLock(); + } + + // Fallback: try to read package.json from VFS if we missed it + if (!manifest) { + try { + const pkgJsonPath = join(packageRoot, "package.json"); + const text = await getFile(FileSystem, pkgJsonPath, "string"); + if (text) manifest = JSON.parse(text) as PackageJson; + } catch (e) { + dispatchEvent(LOGGER_WARN, `Failed to read tarball package.json from VFS: ${String(e)}`); + } + } - // If the CDN is npm based then it should add the parsed version to the URL - // e.g. https://unpkg.com/spring-easing@v1.0.0/ - const knownVersion = manifest?.version || assumedVersion; - const cdnVersionFormat = NPM_CDN ? "@" + knownVersion : ""; - const { url } = getCDNUrl(`${parsed.name}${cdnVersionFormat}${resultSubpath}`, origin); + if (!manifest) { + manifest = { name: "unknown", version: "0.0.0" }; + } - // Store the package.json manifest of the dependencies fetched in the cache - if (!packageManifestsMap.get(`${parsed.name}${knownVersion}`)) { - try { - const _manifest = await getPackageOfVersion(`${parsed.name}${knownVersion}`); - if (_manifest) - packageManifestsMap.set(`${parsed.name}${knownVersion}`, _manifest); - } catch (e) { - console.warn(e); - } - } + dispatchEvent( + LOGGER_INFO, + `Extracted tarball: ${manifest.name}@${manifest.version} -> ${packageRoot} (compression=${detection.compression}, confidence=${detection.confidence})`, + ); - const peerDeps = Object.assign( - initialManifest?.peerDependencies ?? {}, - manifest?.peerDependencies ?? {}, - { - // Some packages rely on cyclic dependencies, e.g. https://x.com/jsbundle/status/1792325771354149261 - // so we create a new field in peerDependencies and place the current package and it's version, - // the algorithm should then be able to use the correct version if a dependency is cyclic - [parsed.name]: NPM_CDN ? knownVersion : (initialDeps[parsed.name] ?? "latest") - } - ); - const inheritPeerDependencies = structuredClone(peerDeps); + return manifest; +} - // Force inherit peerDependencies, makes it easier to keep versions stable - // and to avoid duplicates - for (const [name, version] of Object.entries(peerDeps)) { - inheritPeerDependencies[name] = initialDeps[name] ?? version; - } +/** + * Resolve the package entry point using exports or legacy fields + */ +export function resolvePackageEntry( + manifest: PackageJson, + subpath: string, + conditions: ReturnType +): string { + // Normalize subpath for exports resolution + const exportSubpath = subpath ? `.${subpath}` : "."; + + // Try modern exports resolution first + try { + const resolved = resolve(manifest, exportSubpath, { + browser: conditions.browser, + conditions: conditions.conditions, + require: conditions.require + }); + + if (resolved) { + const result = Array.isArray(resolved) ? resolved[0] : resolved; + if (typeof result === "string") { + return result.replace(/^\.\//, "/").replace(/^\./, "/"); + } + } + } catch { + // Fall through to legacy resolution + } + + // Try with require fallback if we're in import context + if (!conditions.require) { + try { + const resolved = resolve(manifest, exportSubpath, { + browser: conditions.browser, + conditions: ["require", ...conditions.conditions], + require: true + }); + + if (resolved) { + const result = Array.isArray(resolved) ? resolved[0] : resolved; + if (typeof result === "string") { + return result.replace(/^\.\//, "/").replace(/^\./, "/"); + } + } + } catch { + // Fall through to legacy resolution + } + } + + // For root import without subpath, try legacy resolution + if (!subpath || subpath === "/") { + try { + const legacyResult = legacy(manifest, { browser: conditions.browser }) || + legacy(manifest, { fields: ["module", "main"] }) || + legacy(manifest, { fields: ["unpkg", "bin"] }); + + if (legacyResult) { + if (typeof legacyResult === "string") { + return legacyResult.replace(/^\.\//, "/").replace(/^\./, "/"); + } + if (Array.isArray(legacyResult) && legacyResult[0]) { + return String(legacyResult[0]).replace(/^\.\//, "/").replace(/^\./, "/"); + } + if (typeof legacyResult === "object") { + const values = Object.values(legacyResult).filter(v => v && typeof v === "string"); + if (values.length > 0) { + return String(values[0]).replace(/^\.\//, "/").replace(/^\./, "/"); + } + } + } + } catch { + // Fall through to default + } + } + + // If we have a subpath, use it directly + if (subpath && subpath !== "/") return subpath; + + // Last resort: try common entry points + return "/index.js"; +} + +/** + * Get or create tarball mount, handling inflight deduplication + */ +export async function getOrCreateMount( + tarballUrl: string, + StateContext: Context & TarballState> +): Promise { + const mounts = fromContext("tarballMounts", StateContext) ?? new Map(); + const inflight = fromContext("tarballInflight", StateContext) ?? new Map>(); + + // Ensure maps are in context + if (!fromContext("tarballMounts", StateContext)) { + toContext("tarballMounts", mounts, StateContext); + } + if (!fromContext("tarballInflight", StateContext)) { + toContext("tarballInflight", inflight, StateContext); + } + + const key = await getTarballKey(tarballUrl); + + // Check existing mount + const existing = mounts.get(key); + if (existing) return existing; + + // Check inflight + const inflightPromise = inflight.get(key); + if (inflightPromise) return inflightPromise; + + // Create new mount + const mountPromise = (async () => { + const packageRoot = join(TARBALL_ROOT, key); + const manifest = await fetchAndExtractTarball(tarballUrl, packageRoot, StateContext); + + const mount: TarballMount = { + createdAt: Date.now(), + packageRoot, + manifest, + sourceUrl: tarballUrl + }; + + mounts.set(key, mount); + inflight.delete(key); + + return mount; + })(); + + inflight.set(key, mountPromise); + + try { + return await mountPromise; + } catch (e) { + inflight.delete(key); + throw e; + } +} + +/** + * Check if a path is inside a mounted tarball package + */ +export function findMountForPath( + vfsPath: string, + StateContext: Context & TarballState> +): TarballMount | null { + const mounts = fromContext("tarballMounts", StateContext); + if (!mounts) return null; + + for (const mount of mounts.values()) { + if (vfsPath.startsWith(mount.packageRoot + "/") || vfsPath === mount.packageRoot) { + return mount; + } + } + + return null; +} - return { - namespace: HTTP_NAMESPACE, - path: (await determineExtension(url.toString())).url, - sideEffects: typeof manifest?.sideEffects === "boolean" - ? manifest.sideEffects - : undefined, - pluginData: Object.assign({}, args.pluginData, { - manifest: deepMerge( - structuredClone(manifest), - { peerDependencies: inheritPeerDependencies } - ) +/** + * Resolution algorithm for tarball URLs + * + * Handles: + * - Direct tarball URL imports (https://pkg.pr.new/...) + * - Self-reference imports from within extracted packages + * - Subpath imports + */ +export function TarResolution(StateContext: Context>) { + const LocalConfig = fromContext("config", StateContext)!; + const esbuildOpts = LocalConfig.esbuild ?? {}; + + return async function (args: ESBUILD.OnResolveArgs): Promise { + // Handle direct tarball URL imports + if (/^https?:\/\//.test(args.path)) { + // Not a valid URL, let other plugins handle + const url = URL.parse(args.path); + if (!url) return; + + // Not a tarball CDN, let HTTP/CDN plugins handle + if (!isTarballUrl(url)) return; + + const { subpath, packageUrl } = parseTarballUrl(url); + const conditions = getResolverConditions(args, esbuildOpts); + + try { + console.log({ + packageUrl + }) + const mount = await getOrCreateMount(packageUrl.toString(), StateContext); + const entryPath = resolvePackageEntry(mount.manifest, subpath, conditions); + const resolvedPath = join(mount.packageRoot, entryPath); + + console.log({ + argPath: args.path, + entryPath, + resolvedPath, + name: mount?.manifest?.name, + deps: mount?.manifest?.dependencies, }) - }; - } - }; -}; + + return { + path: resolvedPath, + namespace: VIRTUAL_FILESYSTEM_NAMESPACE, + pluginData: Object.assign({}, args.pluginData, { + manifest: mount.manifest, + packageRoot: mount.packageRoot, + tarballUrl: packageUrl.toString(), + }) + }; + } catch (e) { + dispatchEvent(LOGGER_ERROR, e as Error); + throw e; + } + } + + // Handle self-reference imports from within a tarball package + // e.g., import { something } from "@tanstack/react-query" from within that package + const importer = args.pluginData?.importer ?? args.importer; + if (importer && typeof importer === "string") { + const mount = findMountForPath(importer, StateContext); + + if (mount && mount.manifest.name) { + const importPath = args.path.replace(/^node:/, ""); + + // Check if this is a self-reference (import matches package name) + if (importPath === mount.manifest.name || importPath.startsWith(mount.manifest.name + "/")) { + const subpath = importPath === mount.manifest.name + ? "" + : importPath.slice(mount.manifest.name.length); + + const conditions = getResolverConditions(args, esbuildOpts); + const entryPath = resolvePackageEntry(mount.manifest, subpath, conditions); + const resolvedPath = join(mount.packageRoot, entryPath); + + return { + path: resolvedPath, + namespace: VIRTUAL_FILESYSTEM_NAMESPACE, + pluginData: Object.assign({}, args.pluginData, { + manifest: mount.manifest, + packageRoot: mount.packageRoot, + tarballUrl: mount.sourceUrl, + }) + }; + } + } + } + + // Not a tarball-related import, let other plugins handle + return; + }; +} /** - * Esbuild CDN plugin + * Esbuild Tarball plugin + * + * Handles tarball-based package sources like pkg.pr.new by: + * 1. Intercepting tarball URLs before HTTP plugin + * 2. Extracting packages to VFS + * 3. Resolving entry points and self-references * - * @param cdn The default CDN to use - * @param logger Console log + * @example + * ```ts + * // Plugin order (tarball before HTTP): + * plugins: [ + * AliasPlugin(StateContext), + * ExternalPlugin(StateContext), + * TarballPlugin(StateContext), // <-- Must be before HTTP + * VirtualFileSystemPlugin(StateContext), + * HttpPlugin(StateContext), + * CdnPlugin(StateContext), + * ] + * ``` */ -export function TarballPlugin(StateContext: Context & { origin: string }>): ESBUILD.Plugin { - return { - name: TARBALL_NAMESPACE, - setup(build) { - // Resolve bare imports to the CDN required using different URL schemes - build.onResolve({ filter: /.*/ }, TarResolution(StateContext)); - build.onResolve({ filter: /.*/, namespace: TARBALL_NAMESPACE }, TarResolution(StateContext)); - }, - }; -}; \ No newline at end of file +export function TarballPlugin(StateContext: Context & TarballState>): ESBUILD.Plugin { + // Initialize tarball state if not present + if (!fromContext("tarballMounts", StateContext)) + toContext("tarballMounts", new Map(), StateContext); + + if (!fromContext("tarballInflight", StateContext)) + toContext("tarballInflight", new Map>(), StateContext); + + return { + name: TARBALL_NAMESPACE, + setup(build) { + // Intercept tarball URLs before HTTP plugin + // Filter matches any https:// URL, resolution function checks if it's actually a tarball CDN + build.onResolve({ filter: /^https?:\/\// }, TarResolution(StateContext)); + + // Also handle resolution within the tarball namespace (for chained resolution) + build.onResolve({ filter: /.*/, namespace: TARBALL_NAMESPACE }, TarResolution(StateContext)); + + // Handle self-references from VFS namespace (imports from within extracted packages) + build.onResolve({ filter: /.*/, namespace: VIRTUAL_FILESYSTEM_NAMESPACE }, TarResolution(StateContext)); + }, + }; +} \ No newline at end of file