[READ-ONLY] a fast, modern browser for the npm registry
at main 177 lines 4.8 kB view raw
1/** 2 * Deno Integration (WASM) 3 * 4 * Uses @deno/doc (WASM build of deno_doc) for documentation generation. 5 * This runs entirely in Node.js without requiring a Deno subprocess. 6 * 7 * @module server/utils/docs/client 8 */ 9 10import { doc, type DocNode } from '@deno/doc' 11import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc' 12import { isBuiltin } from 'node:module' 13 14// ============================================================================= 15// Configuration 16// ============================================================================= 17 18/** Timeout for fetching modules in milliseconds */ 19const FETCH_TIMEOUT_MS = 30 * 1000 20 21// ============================================================================= 22// Main Export 23// ============================================================================= 24 25/** 26 * Get documentation nodes for a package using @deno/doc WASM. 27 */ 28export async function getDocNodes(packageName: string, version: string): Promise<DenoDocResult> { 29 // Get types URL from esm.sh header 30 const typesUrl = await getTypesUrl(packageName, version) 31 32 if (!typesUrl) { 33 return { version: 1, nodes: [] } 34 } 35 36 // Generate docs using @deno/doc WASM 37 let result: Record<string, DocNode[]> 38 try { 39 result = await doc([typesUrl], { 40 load: createLoader(), 41 resolve: createResolver(), 42 }) 43 } catch { 44 return { version: 1, nodes: [] } 45 } 46 47 // Collect all nodes from all specifiers 48 const allNodes: DenoDocNode[] = [] 49 for (const nodes of Object.values(result)) { 50 allNodes.push(...(nodes as DenoDocNode[])) 51 } 52 53 return { version: 1, nodes: allNodes } 54} 55 56// ============================================================================= 57// Module Loading 58// ============================================================================= 59 60/** Load response for the doc() function */ 61interface LoadResponse { 62 kind: 'module' 63 specifier: string 64 headers?: Record<string, string> 65 content: string 66} 67 68/** 69 * Create a custom module loader for @deno/doc. 70 * 71 * Fetches modules from URLs using fetch(), with proper timeout handling. 72 */ 73function createLoader(): ( 74 specifier: string, 75 isDynamic?: boolean, 76 cacheSetting?: string, 77 checksum?: string, 78) => Promise<LoadResponse | undefined> { 79 return async ( 80 specifier: string, 81 _isDynamic?: boolean, 82 _cacheSetting?: string, 83 _checksum?: string, 84 ) => { 85 const url = URL.parse(specifier) 86 87 if (url === null) { 88 return undefined 89 } 90 91 // Only handle http/https URLs 92 if (url.protocol !== 'http:' && url.protocol !== 'https:') { 93 return undefined 94 } 95 96 try { 97 const response = await $fetch.raw<Blob>(url.toString(), { 98 method: 'GET', 99 timeout: FETCH_TIMEOUT_MS, 100 redirect: 'follow', 101 }) 102 103 if (response.status !== 200) { 104 return undefined 105 } 106 107 const content = (await response._data?.text()) ?? '' 108 const headers: Record<string, string> = {} 109 for (const [key, value] of response.headers) { 110 headers[key.toLowerCase()] = value 111 } 112 113 return { 114 kind: 'module', 115 specifier: response.url || specifier, 116 headers, 117 content, 118 } 119 } catch (e) { 120 // eslint-disable-next-line no-console 121 console.error(e) 122 return undefined 123 } 124 } 125} 126 127/** 128 * Create a module resolver for @deno/doc. 129 * 130 * Handles resolving relative imports and esm.sh redirects. 131 */ 132function createResolver(): (specifier: string, referrer: string) => string { 133 return (specifier: string, referrer: string) => { 134 // Handle relative imports 135 if (specifier.startsWith('.') || specifier.startsWith('/')) { 136 return new URL(specifier, referrer).toString() 137 } 138 139 // Handle bare specifiers - resolve through esm.sh 140 if ( 141 !specifier.startsWith('http://') && 142 !specifier.startsWith('https://') && 143 !isBuiltin(specifier) 144 ) { 145 // Try to resolve bare specifier relative to esm.sh base 146 const baseUrl = new URL(referrer) 147 if (baseUrl.hostname === 'esm.sh') { 148 return `https://esm.sh/${specifier}` 149 } 150 } 151 152 return specifier 153 } 154} 155 156/** 157 * Get the TypeScript types URL from esm.sh's x-typescript-types header. 158 * 159 * esm.sh serves types URL in the `x-typescript-types` header, not at the main URL. 160 * Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header: 161 * x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts 162 */ 163async function getTypesUrl(packageName: string, version: string): Promise<string | null> { 164 const url = `https://esm.sh/${packageName}@${version}` 165 166 try { 167 const response = await $fetch.raw(url, { 168 method: 'HEAD', 169 timeout: FETCH_TIMEOUT_MS, 170 }) 171 return response.headers.get('x-typescript-types') 172 } catch (e) { 173 // eslint-disable-next-line no-console 174 console.error(e) 175 return null 176 } 177}