forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}