Skip to content

Commit 3cad3de

Browse files
committed
feat-docs-wasm-attempt
1 parent d382de7 commit 3cad3de

15 files changed

Lines changed: 432 additions & 207 deletions

File tree

PLAN_MICRO.md

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -48,74 +48,62 @@ docs-api/
4848
```typescript
4949
#!/usr/bin/env deno run --allow-net --allow-env
5050

51-
import { doc } from "jsr:@deno/doc";
51+
import { doc } from 'jsr:@deno/doc'
5252

5353
interface GenerateRequest {
54-
package: string;
55-
version: string;
54+
package: string
55+
version: string
5656
}
5757

5858
function validateAuth(req: Request): boolean {
59-
const authHeader = req.headers.get("Authorization");
60-
const expectedToken = Deno.env.get("API_SECRET");
61-
if (!expectedToken) return true;
62-
return authHeader === `Bearer ${expectedToken}`;
59+
const authHeader = req.headers.get('Authorization')
60+
const expectedToken = Deno.env.get('API_SECRET')
61+
if (!expectedToken) return true
62+
return authHeader === `Bearer ${expectedToken}`
6363
}
6464

6565
export default async function handler(req: Request): Promise<Response> {
6666
const headers = {
67-
"Access-Control-Allow-Origin": "https://npmx.dev",
68-
"Access-Control-Allow-Methods": "POST, OPTIONS",
69-
"Access-Control-Allow-Headers": "Content-Type, Authorization",
70-
"Content-Type": "application/json",
71-
};
72-
73-
if (req.method === "OPTIONS") {
74-
return new Response(null, { status: 204, headers });
67+
'Access-Control-Allow-Origin': 'https://npmx.dev',
68+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
69+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
70+
'Content-Type': 'application/json',
71+
}
72+
73+
if (req.method === 'OPTIONS') {
74+
return new Response(null, { status: 204, headers })
7575
}
7676

77-
if (req.method !== "POST") {
78-
return new Response(
79-
JSON.stringify({ error: "method_not_allowed" }),
80-
{ status: 405, headers }
81-
);
77+
if (req.method !== 'POST') {
78+
return new Response(JSON.stringify({ error: 'method_not_allowed' }), { status: 405, headers })
8279
}
8380

8481
if (!validateAuth(req)) {
85-
return new Response(
86-
JSON.stringify({ error: "unauthorized" }),
87-
{ status: 401, headers }
88-
);
82+
return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401, headers })
8983
}
9084

9185
try {
92-
const body: GenerateRequest = await req.json();
93-
86+
const body: GenerateRequest = await req.json()
87+
9488
if (!body.package || !body.version) {
95-
return new Response(
96-
JSON.stringify({ error: "bad_request" }),
97-
{ status: 400, headers }
98-
);
89+
return new Response(JSON.stringify({ error: 'bad_request' }), { status: 400, headers })
9990
}
10091

101-
const specifier = `https://esm.sh/${body.package}@${body.version}?target=deno`;
102-
const nodes = await doc(specifier);
103-
104-
return new Response(JSON.stringify({ nodes }), { status: 200, headers });
92+
const specifier = `https://esm.sh/${body.package}@${body.version}?target=deno`
93+
const nodes = await doc(specifier)
94+
95+
return new Response(JSON.stringify({ nodes }), { status: 200, headers })
10596
} catch (error) {
106-
const message = error instanceof Error ? error.message : "Unknown error";
107-
108-
if (message.includes("Could not find")) {
109-
return new Response(
110-
JSON.stringify({ error: "not_found" }),
111-
{ status: 404, headers }
112-
);
97+
const message = error instanceof Error ? error.message : 'Unknown error'
98+
99+
if (message.includes('Could not find')) {
100+
return new Response(JSON.stringify({ error: 'not_found' }), { status: 404, headers })
113101
}
114-
115-
return new Response(
116-
JSON.stringify({ error: "generation_failed", message }),
117-
{ status: 500, headers }
118-
);
102+
103+
return new Response(JSON.stringify({ error: 'generation_failed', message }), {
104+
status: 500,
105+
headers,
106+
})
119107
}
120108
}
121109
```
@@ -139,7 +127,7 @@ async function runDenoDoc(packageName: string, version: string): Promise<DenoDoc
139127
const headers: Record<string, string> = {
140128
'Content-Type': 'application/json',
141129
}
142-
130+
143131
if (DOCS_API_SECRET) {
144132
headers['Authorization'] = `Bearer ${DOCS_API_SECRET}`
145133
}
@@ -158,7 +146,7 @@ async function runDenoDoc(packageName: string, version: string): Promise<DenoDoc
158146
throw new Error(`Docs API error: ${error.message}`)
159147
}
160148

161-
return await response.json() as DenoDocResult
149+
return (await response.json()) as DenoDocResult
162150
}
163151

164152
export async function generateDocsWithDeno(
@@ -186,6 +174,7 @@ export async function generateDocsWithDeno(
186174
#### Remove Unused Code
187175

188176
Delete from `server/utils/docs.ts`:
177+
189178
- `execFileAsync` import
190179
- `DENO_DOC_TIMEOUT_MS`, `DENO_DOC_MAX_BUFFER` constants
191180
- `denoCheckPromise`, `isDenoInstalled()`, `verifyDenoInstalled()`
@@ -198,7 +187,7 @@ Keep subprocess as fallback for local dev:
198187

199188
```typescript
200189
async function runDenoDoc(packageName: string, version: string): Promise<DenoDocResult> {
201-
if (process.dev && await isDenoInstalled()) {
190+
if (process.dev && (await isDenoInstalled())) {
202191
return runLocalDenoDoc(packageName, version)
203192
}
204193
return runRemoteDenoDoc(packageName, version)

PLAN_WASM.md

Lines changed: 78 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,113 @@
11
# Plan: WASM-based Documentation Generation
22

3-
> **Status**: Alternative approach. See PLAN_MICRO.md for recommended approach.
3+
> **Status**: ✅ Working! Uses official `@deno/doc` v0.189.1 with patches for Node.js compatibility.
44
5-
Replace the `deno doc` subprocess with `tsdoc-extractor`, a WASM build of `deno_doc` for Node.js.
5+
## Summary
66

7-
## Package
7+
Successfully replaced the `deno doc` subprocess with WASM-based documentation generation that runs directly in Node.js/Vercel serverless, using the official `@deno/doc` package from JSR.
88

9-
- **npm**: `tsdoc-extractor`
10-
- **Size**: 2.8MB WASM + ~25KB JS
11-
- **Last updated**: July 2023
12-
- **Output**: Same JSON format as `deno doc --json`
9+
## Package Used
1310

14-
## Test Results
11+
- **Package**: `@deno/doc` (JSR - official Deno package)
12+
- **Version**: 0.189.1 (latest, Jan 2026)
13+
- **WASM size**: 4.7MB
14+
- **Install**: `pnpm install jsr:@deno/doc`
1515

16-
```
17-
ufo@1.5.0: 58 nodes (works)
18-
vue@3.5.0: 4 nodes (re-exports not followed - WASM limitation)
16+
## Compatibility Issues & Fixes
17+
18+
We encountered three issues when running `@deno/doc` in Node.js. All were resolved with a single patch file.
19+
20+
### Issue 1: Invalid JavaScript exports ✅ Fixed
21+
22+
```js
23+
// In mod.js - this is invalid JS, only works in Deno
24+
export * from './types.d.ts'
25+
export * from './html_types.d.ts'
1926
```
2027

21-
## Key Finding
28+
**Fix**: Patch removes these lines.
2229

23-
esm.sh serves types URL in the `x-typescript-types` header, not at the main URL:
30+
### Issue 2: WASM loader doesn't support Node.js ✅ Fixed
2431

25-
```bash
26-
$ curl -sI 'https://esm.sh/ufo@1.5.0' | grep x-typescript-types
27-
x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
32+
```js
33+
// Original code throws error for Node.js + file:// URLs
34+
if (isFile && typeof Deno !== 'object') {
35+
throw new Error('Loading local files are not supported in this environment')
36+
}
2837
```
2938

30-
## Architecture
39+
**Fix**: Patch adds Node.js fs support:
3140

41+
```js
42+
if (isNode && isFile) {
43+
const { readFileSync } = await import('node:fs')
44+
const { fileURLToPath } = await import('node:url')
45+
const wasmCode = readFileSync(fileURLToPath(url))
46+
return WebAssembly.instantiate(decompress ? decompress(wasmCode) : wasmCode, imports)
47+
}
3248
```
33-
Current: [Request] -> [subprocess: deno doc --json] -> [parse JSON]
34-
New: [Request] -> [fetch types] -> [tsdoc-extractor WASM] -> [parse JSON]
49+
50+
### Issue 3: Bundler rewrites WASM paths ✅ Fixed
51+
52+
When Nitro inlines the package, `import.meta.url` gets rewritten and WASM path becomes invalid.
53+
54+
**Fix**: Don't inline `@deno/doc` in Nitro config - let it load from `node_modules/`:
55+
56+
```ts
57+
// nuxt.config.ts - do NOT add @deno/doc to nitro.externals.inline
3558
```
3659

37-
## Implementation
60+
## Patch File
3861

39-
### 1. Install
62+
All fixes are in `patches/@jsr__deno__doc@0.189.1.patch` (managed by pnpm).
63+
64+
## Key Finding
65+
66+
esm.sh serves types URL in the `x-typescript-types` header:
4067

4168
```bash
42-
pnpm add tsdoc-extractor
69+
$ curl -sI 'https://esm.sh/ufo@1.5.0' | grep x-typescript-types
70+
x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
4371
```
4472

45-
### 2. Create `server/utils/docs-wasm.ts`
46-
47-
```typescript
48-
import { doc, defaultResolver } from 'tsdoc-extractor'
49-
import type { DenoDocNode, DocsGenerationResult } from '#shared/types/deno-doc'
73+
## Architecture
5074

51-
async function getTypesUrl(packageName: string, version: string): Promise<string | null> {
52-
const url = `https://esm.sh/${packageName}@${version}`
53-
const response = await fetch(url, { method: 'HEAD' })
54-
return response.headers.get('x-typescript-types')
55-
}
75+
```
76+
[Request] -> [fetch x-typescript-types header] -> [@deno/doc WASM] -> [HTML]
77+
```
5678

57-
function createResolver() {
58-
return (specifier: string, referrer: string): string => {
59-
if (specifier.startsWith('.')) {
60-
return new URL(specifier, referrer).toString()
61-
}
62-
if (specifier.startsWith('https://esm.sh/')) {
63-
return specifier
64-
}
65-
if (!specifier.startsWith('http')) {
66-
return `https://esm.sh/${specifier}`
67-
}
68-
return defaultResolver(specifier, referrer)
69-
}
70-
}
79+
## Files Changed
7180

72-
export async function generateDocsWithWasm(
73-
packageName: string,
74-
version: string,
75-
): Promise<DocsGenerationResult | null> {
76-
const typesUrl = await getTypesUrl(packageName, version)
77-
78-
if (!typesUrl) {
79-
return null
80-
}
81+
- `server/utils/docs.ts` - Uses `@deno/doc` with custom loader/resolver
82+
- `server/utils/docs-text.ts` - Extracted text utilities
83+
- `patches/@jsr__deno__doc@0.189.1.patch` - Node.js compatibility patch
8184

82-
const nodes = await doc(typesUrl, {
83-
resolve: createResolver(),
84-
}) as DenoDocNode[]
85+
## Verified Working
8586

86-
if (!nodes || nodes.length === 0) {
87-
return null
88-
}
87+
-`ufo@1.5.0` - Generates full docs
88+
-`react@19.0.0` - Large package, generates full docs
89+
- ✅ All 361 tests pass
90+
- ✅ Dev server runs without errors
8991

90-
const flattenedNodes = flattenNamespaces(nodes)
91-
const mergedSymbols = mergeOverloads(flattenedNodes)
92-
const symbolLookup = buildSymbolLookup(flattenedNodes)
92+
## Pros/Cons
9393

94-
const html = await renderDocNodes(mergedSymbols, symbolLookup)
95-
const toc = renderToc(mergedSymbols)
94+
**Pros**:
9695

97-
return { html, toc, nodes: flattenedNodes }
98-
}
99-
```
96+
- Single deployment (no microservice)
97+
- Uses official, maintained `@deno/doc` (latest version)
98+
- In-process, no network latency
99+
- 4.7MB WASM loaded once, cached
100100

101-
## Limitations
101+
**Cons**:
102102

103-
- **WASM from 2023**: Known issues with `export *` re-exports
104-
- **Rebuild requires Rust**: Need wasm-pack and deno_doc source to update
103+
- Requires patch for Node.js compatibility (may need updates per version)
104+
- 4.7MB added to server bundle
105+
- Patch maintenance burden
105106

106-
## Pros/Cons
107+
## Alternative: Microservice Approach
107108

108-
**Pros**: No external dependency, single deployment, faster cold starts
109+
See `PLAN_MICRO.md` for the microservice approach that runs actual Deno in a separate Vercel project. That approach:
109110

110-
**Cons**: Old WASM with known issues, complex packages may fail, 2.8MB bundle increase
111+
- Requires no patches
112+
- Has network latency
113+
- Requires managing two deployments

app/components/AppHeader.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ withDefaults(
1212
</script>
1313

1414
<template>
15-
<header aria-label="Site header" class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
15+
<header
16+
aria-label="Site header"
17+
class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"
18+
>
1619
<nav aria-label="Main navigation" class="container h-14 flex items-center justify-between">
1720
<NuxtLink
1821
v-if="showLogo"

app/components/DocsVersionSelector.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ function handleKeydown(event: KeyboardEvent) {
112112
v-for="tag in tags"
113113
:key="tag"
114114
class="text-[10px] px-1.5 py-0.5 rounded font-sans font-medium"
115-
:class="tag === 'latest' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-bg-muted text-fg-subtle'"
115+
:class="
116+
tag === 'latest'
117+
? 'bg-emerald-500/10 text-emerald-400'
118+
: 'bg-bg-muted text-fg-subtle'
119+
"
116120
>
117121
{{ tag }}
118122
</span>

0 commit comments

Comments
 (0)