Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions server/api/registry/docs/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ export default defineCachedEventHandler(
throw createError({ statusCode: 404, message: 'No latest version found' })
}

const versionData = packument.versions?.[version]
const exports = versionData?.exports as Record<string, unknown> | undefined
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remember exports can also be a string (one entrypoint so exports: "./foo.js" is like exports: {".": "./foo.js"} iirc)


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 {
Expand Down Expand Up @@ -64,7 +67,7 @@ export default defineCachedEventHandler(
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
return `docs:v1:${pkg}`
return `docs:v2:${pkg}`
},
},
)
133 changes: 118 additions & 15 deletions server/utils/docs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,119 @@ 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.
*
* 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<DenoDocResult> {
// Get types URL from esm.sh header
const typesUrl = await getTypesUrl(packageName, version)
export async function getDocNodes(
packageName: string,
version: string,
exports?: Record<string, unknown>,
): Promise<DenoDocResult> {
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: [] }
}

// 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[]))
}

if (!typesUrl) {
return { version: 1, nodes: allNodes }
} catch {
// Silent failure - all docgen errors are graceful degradation
// In future should maybe consider debug mode / struct logging of some kind
return { version: 1, nodes: [] }
}
}

// Generate docs using @deno/doc WASM
const result = await doc([typesUrl], {
load: createLoader(),
resolve: createResolver(),
})
// =============================================================================
// Types URL Discovery
// =============================================================================

// Collect all nodes from all specifiers
const allNodes: DenoDocNode[] = []
for (const nodes of Object.values(result)) {
allNodes.push(...(nodes as DenoDocNode[]))
/**
* Get all TypeScript types URLs for a package, including subpath exports.
*/
async function getAllTypesUrls(
packageName: string,
version: string,
exports?: Record<string, unknown>,
): Promise<string[]> {
const mainTypesUrl = await getTypesUrl(packageName, version)
const subpathTypesUrls = await getSubpathTypesUrlsFromExports(packageName, version, exports)

// Combine and deduplicate
const allUrls = new Set<string>()
if (mainTypesUrl) allUrls.add(mainTypesUrl)
for (const url of subpathTypesUrls) {
allUrls.add(url)
}

return { version: 1, nodes: allNodes }
return [...allUrls]
}

/**
* Extract types URLs from pre-fetched package exports.
*/
async function getSubpathTypesUrlsFromExports(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bottleneck. Going to think if there's a better way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or rather, it's a bottleneck that we have to run deno doc potentially on a bunch of subpath exports.

packageName: string,
version: string,
exports?: Record<string, unknown>,
): Promise<string[]> {
// 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<string, unknown>
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)
}

// =============================================================================
Expand Down Expand Up @@ -154,8 +239,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<string | null> {
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<string | null> {
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<string | null> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)

Expand Down
3 changes: 2 additions & 1 deletion server/utils/docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ import { renderDocNodes, renderToc } from './render'
export async function generateDocsWithDeno(
packageName: string,
version: string,
exports?: Record<string, unknown>,
): Promise<DocsGenerationResult | null> {
// 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
Expand Down