From e19834de7b7602f7754b6a7cdfa1e957be7eb8e4 Mon Sep 17 00:00:00 2001 From: dev wells Date: Wed, 28 Jan 2026 09:42:55 -0500 Subject: [PATCH 1/4] fix(docgen): look for type entrypoints beyond just main entrypoint --- server/utils/docs/client.ts | 120 ++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index 6cbcec44..01041247 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -17,23 +17,29 @@ import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc' /** Timeout for fetching modules in milliseconds */ const FETCH_TIMEOUT_MS = 30 * 1000 +/** Maximum number of subpath exports to process (prevents runaway on huge packages) */ +const MAX_SUBPATH_EXPORTS = 10 + // ============================================================================= // Main Export // ============================================================================= /** * Get documentation nodes for a package using @deno/doc WASM. + * + * This function fetches types for all subpath exports (e.g., `nuxt`, `nuxt/app`, `nuxt/kit`) + * to provide comprehensive documentation for packages with multiple entry points. */ export async function getDocNodes(packageName: string, version: string): Promise { - // Get types URL from esm.sh header - const typesUrl = await getTypesUrl(packageName, version) + // Get all types URLs from package exports + const typesUrls = await getAllTypesUrls(packageName, version) - if (!typesUrl) { + if (typesUrls.length === 0) { return { version: 1, nodes: [] } } - // Generate docs using @deno/doc WASM - const result = await doc([typesUrl], { + // Generate docs using @deno/doc WASM for all entry points + const result = await doc(typesUrls, { load: createLoader(), resolve: createResolver(), }) @@ -47,6 +53,90 @@ export async function getDocNodes(packageName: string, version: string): Promise return { version: 1, nodes: allNodes } } +// ============================================================================= +// Types URL Discovery +// ============================================================================= + +/** + * Get all TypeScript types URLs for a package, including subpath exports. + * + * 1. Fetches package.json from npm registry to discover exports + * 2. For each subpath with types, queries esm.sh for the types URL + * 3. Returns all discovered types URLs + */ +async function getAllTypesUrls(packageName: string, version: string): Promise { + // First, try the main entry point + const mainTypesUrl = await getTypesUrl(packageName, version) + + // Fetch package exports to discover subpaths + const subpathTypesUrls = await getSubpathTypesUrls(packageName, version) + + // Combine and deduplicate + const allUrls = new Set() + if (mainTypesUrl) allUrls.add(mainTypesUrl) + for (const url of subpathTypesUrls) { + allUrls.add(url) + } + + return [...allUrls] +} + +/** + * Fetch package.json exports and get types URLs for each subpath. + */ +async function getSubpathTypesUrls(packageName: string, version: string): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + try { + // Fetch package.json from npm registry + const response = await fetch(`https://registry.npmjs.org/${packageName}/${version}`, { + signal: controller.signal, + }) + clearTimeout(timeoutId) + + if (!response.ok) return [] + + const pkgJson = await response.json() + const exports = pkgJson.exports + + // No exports field or simple string export + if (!exports || typeof exports !== 'object') return [] + + // Find subpaths with types + const subpathsWithTypes: string[] = [] + for (const [subpath, config] of Object.entries(exports)) { + // Skip the main entry (already handled) and non-object configs + if (subpath === '.' || typeof config !== 'object' || config === null) continue + // Skip package.json export + if (subpath === './package.json') continue + + const exportConfig = config as Record + if (exportConfig.types && typeof exportConfig.types === 'string') { + subpathsWithTypes.push(subpath) + } + } + + // Limit to prevent runaway on huge packages + const limitedSubpaths = subpathsWithTypes.slice(0, MAX_SUBPATH_EXPORTS) + + // Fetch types URLs for each subpath in parallel + const typesUrls = await Promise.all( + limitedSubpaths.map(async subpath => { + // Convert ./app to /app for esm.sh URL + // esm.sh format: https://esm.sh/nuxt@3.15.4/app (not nuxt/app@3.15.4) + const esmSubpath = subpath.startsWith('./') ? subpath.slice(1) : subpath + return getTypesUrlForSubpath(packageName, version, esmSubpath) + }), + ) + + return typesUrls.filter((url): url is string => url !== null) + } catch { + clearTimeout(timeoutId) + return [] + } +} + // ============================================================================= // Module Loading // ============================================================================= @@ -154,8 +244,26 @@ function createResolver(): (specifier: string, referrer: string) => string { * x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts */ async function getTypesUrl(packageName: string, version: string): Promise { - const url = `https://esm.sh/${packageName}@${version}` + return fetchTypesHeader(`https://esm.sh/${packageName}@${version}`) +} +/** + * Get types URL for a package subpath. + * Example: getTypesUrlForSubpath('nuxt', '3.15.4', '/app') + * → fetches https://esm.sh/nuxt@3.15.4/app + */ +async function getTypesUrlForSubpath( + packageName: string, + version: string, + subpath: string, +): Promise { + return fetchTypesHeader(`https://esm.sh/${packageName}@${version}${subpath}`) +} + +/** + * Fetch the x-typescript-types header from an esm.sh URL. + */ +async function fetchTypesHeader(url: string): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) From 51f9cfea2669656edb36b37c8af61b3f9a9f8f13 Mon Sep 17 00:00:00 2001 From: dev wells Date: Wed, 28 Jan 2026 10:35:56 -0500 Subject: [PATCH 2/4] fix: remove redundant req in docgen, refactor to use packument data --- server/api/registry/docs/[...pkg].get.ts | 6 +- server/utils/docs/client.ts | 143 +++++++++++------------ server/utils/docs/index.ts | 3 +- 3 files changed, 76 insertions(+), 76 deletions(-) diff --git a/server/api/registry/docs/[...pkg].get.ts b/server/api/registry/docs/[...pkg].get.ts index a728aea5..660eb6ef 100644 --- a/server/api/registry/docs/[...pkg].get.ts +++ b/server/api/registry/docs/[...pkg].get.ts @@ -25,9 +25,13 @@ export default defineCachedEventHandler( throw createError({ statusCode: 404, message: 'No latest version found' }) } + // Extract exports from the already-fetched packument to avoid redundant fetch + const versionData = packument.versions?.[version] + const exports = versionData?.exports as Record | undefined + let generated try { - generated = await generateDocsWithDeno(packageName, version) + generated = await generateDocsWithDeno(packageName, version, exports) } catch (error) { console.error(`Doc generation failed for ${packageName}@${version}:`, error) return { diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index 01041247..af0685ae 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -29,28 +29,41 @@ const MAX_SUBPATH_EXPORTS = 10 * * This function fetches types for all subpath exports (e.g., `nuxt`, `nuxt/app`, `nuxt/kit`) * to provide comprehensive documentation for packages with multiple entry points. + * + * All errors are caught and result in empty nodes - docgen failures are graceful degradation + * and should never cause error logging or wake up a maintainer. */ -export async function getDocNodes(packageName: string, version: string): Promise { - // Get all types URLs from package exports - const typesUrls = await getAllTypesUrls(packageName, version) +export async function getDocNodes( + packageName: string, + version: string, + exports?: Record, +): Promise { + try { + // Get all types URLs from package exports (uses pre-fetched exports data) + const typesUrls = await getAllTypesUrls(packageName, version, exports) - if (typesUrls.length === 0) { - return { version: 1, nodes: [] } - } + if (typesUrls.length === 0) { + return { version: 1, nodes: [] } + } - // Generate docs using @deno/doc WASM for all entry points - const result = await doc(typesUrls, { - load: createLoader(), - resolve: createResolver(), - }) + // Generate docs using @deno/doc WASM for all entry points + const result = await doc(typesUrls, { + load: createLoader(), + resolve: createResolver(), + }) - // Collect all nodes from all specifiers - const allNodes: DenoDocNode[] = [] - for (const nodes of Object.values(result)) { - allNodes.push(...(nodes as DenoDocNode[])) - } + // Collect all nodes from all specifiers + const allNodes: DenoDocNode[] = [] + for (const nodes of Object.values(result)) { + allNodes.push(...(nodes as DenoDocNode[])) + } - return { version: 1, nodes: allNodes } + return { version: 1, nodes: allNodes } + } catch { + // Silent failure - all docgen errors are graceful degradation + // This feature should never wake up a maintainer + return { version: 1, nodes: [] } + } } // ============================================================================= @@ -59,17 +72,14 @@ export async function getDocNodes(packageName: string, version: string): Promise /** * Get all TypeScript types URLs for a package, including subpath exports. - * - * 1. Fetches package.json from npm registry to discover exports - * 2. For each subpath with types, queries esm.sh for the types URL - * 3. Returns all discovered types URLs */ -async function getAllTypesUrls(packageName: string, version: string): Promise { - // First, try the main entry point +async function getAllTypesUrls( + packageName: string, + version: string, + exports?: Record, +): Promise { const mainTypesUrl = await getTypesUrl(packageName, version) - - // Fetch package exports to discover subpaths - const subpathTypesUrls = await getSubpathTypesUrls(packageName, version) + const subpathTypesUrls = await getSubpathTypesUrlsFromExports(packageName, version, exports) // Combine and deduplicate const allUrls = new Set() @@ -82,59 +92,44 @@ async function getAllTypesUrls(packageName: string, version: string): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) - - try { - // Fetch package.json from npm registry - const response = await fetch(`https://registry.npmjs.org/${packageName}/${version}`, { - signal: controller.signal, - }) - clearTimeout(timeoutId) - - if (!response.ok) return [] - - const pkgJson = await response.json() - const exports = pkgJson.exports - - // No exports field or simple string export - if (!exports || typeof exports !== 'object') return [] - - // Find subpaths with types - const subpathsWithTypes: string[] = [] - for (const [subpath, config] of Object.entries(exports)) { - // Skip the main entry (already handled) and non-object configs - if (subpath === '.' || typeof config !== 'object' || config === null) continue - // Skip package.json export - if (subpath === './package.json') continue - - const exportConfig = config as Record - if (exportConfig.types && typeof exportConfig.types === 'string') { - subpathsWithTypes.push(subpath) - } +async function getSubpathTypesUrlsFromExports( + packageName: string, + version: string, + exports?: Record, +): Promise { + // No exports field or simple string export + if (!exports || typeof exports !== 'object') return [] + + // Find subpaths with types + const subpathsWithTypes: string[] = [] + for (const [subpath, config] of Object.entries(exports)) { + // Skip the main entry (already handled) and non-object configs + if (subpath === '.' || typeof config !== 'object' || config === null) continue + // Skip package.json export + if (subpath === './package.json') continue + + const exportConfig = config as Record + if (exportConfig.types && typeof exportConfig.types === 'string') { + subpathsWithTypes.push(subpath) } + } - // Limit to prevent runaway on huge packages - const limitedSubpaths = subpathsWithTypes.slice(0, MAX_SUBPATH_EXPORTS) + // Limit to prevent runaway on huge packages + const limitedSubpaths = subpathsWithTypes.slice(0, MAX_SUBPATH_EXPORTS) - // Fetch types URLs for each subpath in parallel - const typesUrls = await Promise.all( - limitedSubpaths.map(async subpath => { - // Convert ./app to /app for esm.sh URL - // esm.sh format: https://esm.sh/nuxt@3.15.4/app (not nuxt/app@3.15.4) - const esmSubpath = subpath.startsWith('./') ? subpath.slice(1) : subpath - return getTypesUrlForSubpath(packageName, version, esmSubpath) - }), - ) + // Fetch types URLs for each subpath in parallel + const typesUrls = await Promise.all( + limitedSubpaths.map(async subpath => { + // Convert ./app to /app for esm.sh URL + // esm.sh format: https://esm.sh/nuxt@3.15.4/app (not nuxt/app@3.15.4) + const esmSubpath = subpath.startsWith('./') ? subpath.slice(1) : subpath + return getTypesUrlForSubpath(packageName, version, esmSubpath) + }), + ) - return typesUrls.filter((url): url is string => url !== null) - } catch { - clearTimeout(timeoutId) - return [] - } + return typesUrls.filter((url): url is string => url !== null) } // ============================================================================= diff --git a/server/utils/docs/index.ts b/server/utils/docs/index.ts index 66a73944..78c2bef5 100644 --- a/server/utils/docs/index.ts +++ b/server/utils/docs/index.ts @@ -34,9 +34,10 @@ import { renderDocNodes, renderToc } from './render' export async function generateDocsWithDeno( packageName: string, version: string, + exports?: Record, ): Promise { // Get doc nodes using @deno/doc WASM - const result = await getDocNodes(packageName, version) + const result = await getDocNodes(packageName, version, exports) if (!result.nodes || result.nodes.length === 0) { return null From b59e173ee0a7e95f32fe231cee1d2ff09fb45411 Mon Sep 17 00:00:00 2001 From: dev wells Date: Wed, 28 Jan 2026 10:40:22 -0500 Subject: [PATCH 3/4] chore: clean up comments --- server/api/registry/docs/[...pkg].get.ts | 1 - server/utils/docs/client.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/server/api/registry/docs/[...pkg].get.ts b/server/api/registry/docs/[...pkg].get.ts index 660eb6ef..ecc0229f 100644 --- a/server/api/registry/docs/[...pkg].get.ts +++ b/server/api/registry/docs/[...pkg].get.ts @@ -25,7 +25,6 @@ export default defineCachedEventHandler( throw createError({ statusCode: 404, message: 'No latest version found' }) } - // Extract exports from the already-fetched packument to avoid redundant fetch const versionData = packument.versions?.[version] const exports = versionData?.exports as Record | undefined diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index af0685ae..271a5531 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -61,7 +61,7 @@ export async function getDocNodes( return { version: 1, nodes: allNodes } } catch { // Silent failure - all docgen errors are graceful degradation - // This feature should never wake up a maintainer + // In future should maybe consider debug mode / struct logging of some kind return { version: 1, nodes: [] } } } From f8439cf345945e979c823ffaa6471fce4ccf3e42 Mon Sep 17 00:00:00 2001 From: dev wells Date: Wed, 28 Jan 2026 10:43:10 -0500 Subject: [PATCH 4/4] fix: bump cache key for docgen api --- server/api/registry/docs/[...pkg].get.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/registry/docs/[...pkg].get.ts b/server/api/registry/docs/[...pkg].get.ts index ecc0229f..80c97ec0 100644 --- a/server/api/registry/docs/[...pkg].get.ts +++ b/server/api/registry/docs/[...pkg].get.ts @@ -67,7 +67,7 @@ export default defineCachedEventHandler( swr: true, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `docs:v1:${pkg}` + return `docs:v2:${pkg}` }, }, )