@doccov/api 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @doccov/api
2
+
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @openpkg-ts/spec@0.3.0
package/README.md CHANGED
@@ -1,153 +1,42 @@
1
1
  # @doccov/api
2
2
 
3
- DocCov API server for badge generation and coverage services.
3
+ DocCov API server for badges, widgets, and scanning.
4
4
 
5
5
  ## Endpoints
6
6
 
7
- ### Badge Endpoint
7
+ | Endpoint | Description |
8
+ |----------|-------------|
9
+ | `GET /badge/:owner/:repo` | Coverage badge SVG |
10
+ | `GET /widget/:owner/:repo` | Coverage widget SVG |
11
+ | `GET /leaderboard` | Public rankings |
12
+ | `GET /scan-stream` | SSE repo scanning |
13
+ | `POST /api/examples/run` | Execute code |
8
14
 
9
- ```
10
- GET /badge/:owner/:repo
11
- ```
12
-
13
- Returns an SVG badge showing the documentation coverage percentage.
14
-
15
- **Query Parameters:**
16
- - `branch` - Git branch to fetch from (default: `main`)
17
-
18
- **Example:**
19
- ```
20
- https://api.doccov.com/badge/tanstack/query
21
- ```
15
+ ## Badge
22
16
 
23
- **Embed in README:**
24
17
  ```markdown
25
- ![DocCov](https://api.doccov.com/badge/your-org/your-repo)
26
- ```
27
-
28
- ### Scan Endpoint
29
-
30
- ```
31
- POST /scan
18
+ ![DocCov](https://api.doccov.com/badge/YOUR_ORG/YOUR_REPO)
32
19
  ```
33
20
 
34
- Scans a public GitHub repository for documentation coverage.
35
-
36
- **Request Body:**
37
- ```json
38
- {
39
- "url": "https://github.com/owner/repo",
40
- "ref": "main",
41
- "package": "@scope/package-name"
42
- }
43
- ```
44
-
45
- **Response:**
46
- ```json
47
- {
48
- "jobId": "scan-123456-abc",
49
- "status": "pending",
50
- "pollUrl": "/scan/scan-123456-abc"
51
- }
52
- ```
53
-
54
- Poll the `pollUrl` to get results when the scan completes.
55
-
56
- ### Health Check
57
-
58
- ```
59
- GET /health
60
- ```
61
-
62
- Returns API health status.
63
-
64
21
  ## Development
65
22
 
66
23
  ```bash
67
- # Install dependencies
68
- bun install
69
-
70
- # Run in development mode (uses local CLI spawn)
24
+ cd packages/api
71
25
  bun run dev
72
-
73
- # Start production server
74
- bun run start
75
26
  ```
76
27
 
77
- ### Local Development
78
-
79
- In local development, the scan endpoint spawns the `doccov` CLI directly. No additional setup required.
80
-
81
- ### Production (Vercel Sandbox)
82
-
83
- In production on Vercel, scans run in isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) VMs for security and consistency.
84
-
85
- **Setup:**
86
-
87
- 1. Link to your Vercel project:
88
- ```bash
89
- cd packages/api
90
- vercel link
91
- ```
92
-
93
- 2. Pull environment variables (includes OIDC token):
94
- ```bash
95
- vercel env pull
96
- ```
97
-
98
- The SDK automatically uses the `VERCEL_OIDC_TOKEN` when available. When deployed to Vercel, tokens are provided automatically.
99
-
100
- ## Environment Variables
101
-
102
- - `PORT` - Server port (default: 3000)
103
- - `VERCEL_OIDC_TOKEN` - Vercel OIDC token for sandbox authentication (auto-provided on Vercel)
104
-
105
28
  ## Deployment
106
29
 
107
- ### Deploy to Vercel
108
-
109
- 1. **Link the project** (first time only):
110
- ```bash
111
- cd packages/api
112
- vercel link
113
- ```
114
-
115
- 2. **Deploy to preview**:
116
- ```bash
117
- vercel deploy
118
- ```
119
-
120
- 3. **Deploy to production**:
121
- ```bash
122
- vercel deploy --prod
123
- ```
124
-
125
- ### Vercel Project Settings
126
-
127
- When linking, use these settings:
128
- - **Framework Preset**: Other
129
- - **Build Command**: (leave empty, no build needed)
130
- - **Output Directory**: (leave empty)
131
- - **Install Command**: `bun install`
132
-
133
- ### Vercel Sandbox
134
-
135
- The `/scan` endpoint uses [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) for isolated execution:
136
- - Automatic OIDC authentication when deployed to Vercel
137
- - Each scan runs in a fresh `node22` VM
138
- - 4 vCPUs, 5-minute timeout per scan
139
- - ~$0.01-0.05 per scan (see [pricing](https://vercel.com/docs/vercel-sandbox/pricing))
140
-
141
- ### Requirements for Sandbox
142
-
143
- Before sandbox scans work, `@doccov/cli` must be published to npm:
144
30
  ```bash
145
- npm publish --access public
31
+ vercel --prod
146
32
  ```
147
33
 
148
- The sandbox installs the CLI via `npm install -g @doccov/cli`.
34
+ ## Documentation
35
+
36
+ - [API Overview](../../docs/api/overview.md)
37
+ - [Endpoints](../../docs/api/endpoints/)
38
+ - [Self-Hosting](../../docs/api/self-hosting.md)
149
39
 
150
40
  ## License
151
41
 
152
42
  MIT
153
-
@@ -0,0 +1,252 @@
1
+ import { spawn } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
6
+ import { Sandbox } from '@vercel/sandbox';
7
+
8
+ export const config = {
9
+ runtime: 'nodejs',
10
+ maxDuration: 30,
11
+ };
12
+
13
+ interface RunExampleRequest {
14
+ packageName: string;
15
+ packageVersion?: string;
16
+ code: string;
17
+ }
18
+
19
+ interface RunExampleResponse {
20
+ success: boolean;
21
+ stdout: string;
22
+ stderr: string;
23
+ exitCode: number;
24
+ duration: number;
25
+ }
26
+
27
+ /**
28
+ * Check if running on Vercel (use sandbox) vs local dev (use spawn)
29
+ */
30
+ function isVercelEnvironment(): boolean {
31
+ return process.env.VERCEL === '1';
32
+ }
33
+
34
+ /**
35
+ * Run example code locally via Node spawn (development fallback)
36
+ */
37
+ async function runExampleLocal(
38
+ code: string,
39
+ packageName: string,
40
+ packageVersion?: string,
41
+ ): Promise<RunExampleResponse> {
42
+ const tmpDir = os.tmpdir();
43
+ const workDir = path.join(
44
+ tmpDir,
45
+ `doccov-example-${Date.now()}-${Math.random().toString(36).slice(2)}`,
46
+ );
47
+ const codeFile = path.join(workDir, 'example.ts');
48
+
49
+ try {
50
+ fs.mkdirSync(workDir, { recursive: true });
51
+
52
+ // Create package.json
53
+ const pkgJson = {
54
+ name: 'example-runner',
55
+ type: 'module',
56
+ dependencies: {
57
+ [packageName]: packageVersion || 'latest',
58
+ },
59
+ };
60
+ fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
61
+
62
+ // Write example code
63
+ fs.writeFileSync(codeFile, code);
64
+
65
+ const startTime = Date.now();
66
+
67
+ // Install dependencies
68
+ await new Promise<void>((resolve, reject) => {
69
+ const proc = spawn('npm', ['install', '--silent'], { cwd: workDir, timeout: 15000 });
70
+ proc.on('close', (code) =>
71
+ code === 0 ? resolve() : reject(new Error('npm install failed')),
72
+ );
73
+ proc.on('error', reject);
74
+ });
75
+
76
+ // Run the example
77
+ return await new Promise<RunExampleResponse>((resolve) => {
78
+ let stdout = '';
79
+ let stderr = '';
80
+
81
+ const proc = spawn('node', ['--experimental-strip-types', codeFile], {
82
+ cwd: workDir,
83
+ timeout: 5000,
84
+ });
85
+
86
+ proc.stdout?.on('data', (data) => {
87
+ stdout += data.toString();
88
+ });
89
+ proc.stderr?.on('data', (data) => {
90
+ stderr += data.toString();
91
+ });
92
+
93
+ proc.on('close', (exitCode) => {
94
+ resolve({
95
+ success: exitCode === 0,
96
+ stdout,
97
+ stderr,
98
+ exitCode: exitCode ?? 1,
99
+ duration: Date.now() - startTime,
100
+ });
101
+ });
102
+
103
+ proc.on('error', (error) => {
104
+ resolve({
105
+ success: false,
106
+ stdout,
107
+ stderr: error.message,
108
+ exitCode: 1,
109
+ duration: Date.now() - startTime,
110
+ });
111
+ });
112
+ });
113
+ } finally {
114
+ // Cleanup
115
+ try {
116
+ fs.rmSync(workDir, { recursive: true, force: true });
117
+ } catch {
118
+ // Ignore cleanup errors
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Run example code in Vercel Sandbox (production)
125
+ */
126
+ async function runExampleInSandbox(
127
+ code: string,
128
+ packageName: string,
129
+ packageVersion?: string,
130
+ ): Promise<RunExampleResponse> {
131
+ const startTime = Date.now();
132
+ const versionSpec = packageVersion ? `${packageName}@${packageVersion}` : packageName;
133
+
134
+ const sandbox = await Sandbox.create({
135
+ resources: { vcpus: 2 },
136
+ timeout: 30 * 1000,
137
+ runtime: 'node22',
138
+ });
139
+
140
+ try {
141
+ // Create working directory and cd into it
142
+ const workDir = '/tmp/doccov-run';
143
+ await sandbox.runCommand({
144
+ cmd: 'mkdir',
145
+ args: ['-p', workDir],
146
+ });
147
+
148
+ // Initialize npm project with ESM support
149
+ await sandbox.runCommand({
150
+ cmd: 'sh',
151
+ args: ['-c', `echo '{"type":"module"}' > ${workDir}/package.json`],
152
+ });
153
+
154
+ // Install the target package
155
+ const installResult = await sandbox.runCommand({
156
+ cmd: 'npm',
157
+ args: ['install', versionSpec, '--ignore-scripts', '--legacy-peer-deps'],
158
+ cwd: workDir,
159
+ });
160
+
161
+ if (installResult.exitCode !== 0) {
162
+ const installErr = (await installResult.stderr?.()) ?? 'Unknown install error';
163
+ return {
164
+ success: false,
165
+ stdout: '',
166
+ stderr: `Failed to install ${versionSpec}: ${installErr.slice(-200)}`,
167
+ exitCode: installResult.exitCode ?? 1,
168
+ duration: Date.now() - startTime,
169
+ };
170
+ }
171
+
172
+ // Write example code to working directory
173
+ const exampleFile = `${workDir}/example.ts`;
174
+ await sandbox.runCommand({
175
+ cmd: 'sh',
176
+ args: ['-c', `cat > ${exampleFile} << 'DOCCOV_EOF'\n${code}\nDOCCOV_EOF`],
177
+ });
178
+
179
+ // Run the example from the working directory
180
+ const runResult = await sandbox.runCommand({
181
+ cmd: 'node',
182
+ args: ['--experimental-strip-types', exampleFile],
183
+ cwd: workDir,
184
+ });
185
+
186
+ // Note: Vercel Sandbox returns stdout/stderr as async functions
187
+ const stdout = (await runResult.stdout?.()) ?? '';
188
+ const stderr = (await runResult.stderr?.()) ?? '';
189
+
190
+ return {
191
+ success: runResult.exitCode === 0,
192
+ stdout,
193
+ stderr,
194
+ exitCode: runResult.exitCode ?? 1,
195
+ duration: Date.now() - startTime,
196
+ };
197
+ } finally {
198
+ await sandbox.stop();
199
+ }
200
+ }
201
+
202
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
203
+ // CORS
204
+ res.setHeader('Access-Control-Allow-Origin', '*');
205
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
206
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
207
+
208
+ if (req.method === 'OPTIONS') {
209
+ return res.status(200).end();
210
+ }
211
+
212
+ if (req.method !== 'POST') {
213
+ return res.status(405).json({ error: 'Method not allowed' });
214
+ }
215
+
216
+ const body = req.body as RunExampleRequest;
217
+
218
+ if (!body.packageName) {
219
+ return res.status(400).json({ error: 'packageName is required' });
220
+ }
221
+
222
+ if (!body.code) {
223
+ return res.status(400).json({ error: 'code is required' });
224
+ }
225
+
226
+ // Strip markdown code block markers if present
227
+ const cleanCode = body.code
228
+ .replace(/^```(?:ts|typescript|js|javascript)?\n?/i, '')
229
+ .replace(/\n?```$/i, '')
230
+ .trim();
231
+
232
+ try {
233
+ let result: RunExampleResponse;
234
+
235
+ if (isVercelEnvironment()) {
236
+ result = await runExampleInSandbox(cleanCode, body.packageName, body.packageVersion);
237
+ } else {
238
+ result = await runExampleLocal(cleanCode, body.packageName, body.packageVersion);
239
+ }
240
+
241
+ return res.status(200).json(result);
242
+ } catch (error) {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ return res.status(500).json({
245
+ success: false,
246
+ stdout: '',
247
+ stderr: message,
248
+ exitCode: 1,
249
+ duration: 0,
250
+ });
251
+ }
252
+ }
package/api/index.ts ADDED
@@ -0,0 +1,218 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { handle } from 'hono/vercel';
4
+
5
+ export const config = {
6
+ runtime: 'edge',
7
+ };
8
+
9
+ const app = new Hono().basePath('/');
10
+
11
+ // Middleware
12
+ app.use('*', cors());
13
+
14
+ // Types
15
+ type BadgeColor =
16
+ | 'brightgreen'
17
+ | 'green'
18
+ | 'yellowgreen'
19
+ | 'yellow'
20
+ | 'orange'
21
+ | 'red'
22
+ | 'lightgrey';
23
+
24
+ interface OpenPkgSpec {
25
+ docs?: {
26
+ coverageScore?: number;
27
+ };
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ // Badge helpers
32
+ function getColorForScore(score: number): BadgeColor {
33
+ if (score >= 90) return 'brightgreen';
34
+ if (score >= 80) return 'green';
35
+ if (score >= 70) return 'yellowgreen';
36
+ if (score >= 60) return 'yellow';
37
+ if (score >= 50) return 'orange';
38
+ return 'red';
39
+ }
40
+
41
+ function generateBadgeSvg(label: string, message: string, color: BadgeColor): string {
42
+ const colors: Record<BadgeColor, string> = {
43
+ brightgreen: '#4c1',
44
+ green: '#97ca00',
45
+ yellowgreen: '#a4a61d',
46
+ yellow: '#dfb317',
47
+ orange: '#fe7d37',
48
+ red: '#e05d44',
49
+ lightgrey: '#9f9f9f',
50
+ };
51
+
52
+ const bgColor = colors[color];
53
+ const labelWidth = label.length * 7 + 10;
54
+ const messageWidth = message.length * 7 + 10;
55
+ const totalWidth = labelWidth + messageWidth;
56
+
57
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${message}">
58
+ <title>${label}: ${message}</title>
59
+ <linearGradient id="s" x2="0" y2="100%">
60
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
61
+ <stop offset="1" stop-opacity=".1"/>
62
+ </linearGradient>
63
+ <clipPath id="r">
64
+ <rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
65
+ </clipPath>
66
+ <g clip-path="url(#r)">
67
+ <rect width="${labelWidth}" height="20" fill="#555"/>
68
+ <rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${bgColor}"/>
69
+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
70
+ </g>
71
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
72
+ <text aria-hidden="true" x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
73
+ <text x="${labelWidth / 2}" y="14">${label}</text>
74
+ <text aria-hidden="true" x="${labelWidth + messageWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${message}</text>
75
+ <text x="${labelWidth + messageWidth / 2}" y="14">${message}</text>
76
+ </g>
77
+ </svg>`;
78
+ }
79
+
80
+ async function fetchSpecFromGitHub(
81
+ owner: string,
82
+ repo: string,
83
+ ref = 'main',
84
+ ): Promise<OpenPkgSpec | null> {
85
+ const urls = [
86
+ `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/openpkg.json`,
87
+ ...(ref === 'main'
88
+ ? [`https://raw.githubusercontent.com/${owner}/${repo}/master/openpkg.json`]
89
+ : []),
90
+ ];
91
+
92
+ for (const url of urls) {
93
+ try {
94
+ const response = await fetch(url);
95
+ if (response.ok) {
96
+ return (await response.json()) as OpenPkgSpec;
97
+ }
98
+ } catch {
99
+ // Try next URL
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ // Root
106
+ app.get('/', (c) => {
107
+ return c.json({
108
+ name: 'DocCov API',
109
+ version: '0.2.0',
110
+ endpoints: {
111
+ health: '/health',
112
+ badge: '/badge/:owner/:repo',
113
+ spec: '/spec/:owner/:repo/:ref?',
114
+ specPr: '/spec/:owner/:repo/pr/:pr',
115
+ scan: '/scan (POST)',
116
+ scanStream: '/scan-stream (GET)',
117
+ },
118
+ });
119
+ });
120
+
121
+ // Health check
122
+ app.get('/health', (c) => {
123
+ return c.json({ status: 'ok', timestamp: new Date().toISOString() });
124
+ });
125
+
126
+ // GET /badge/:owner/:repo
127
+ app.get('/badge/:owner/:repo', async (c) => {
128
+ const { owner, repo } = c.req.param();
129
+ const branch = c.req.query('branch') ?? 'main';
130
+
131
+ try {
132
+ const spec = await fetchSpecFromGitHub(owner, repo, branch);
133
+
134
+ if (!spec) {
135
+ const svg = generateBadgeSvg('docs', 'not found', 'lightgrey');
136
+ return c.body(svg, 404, {
137
+ 'Content-Type': 'image/svg+xml',
138
+ 'Cache-Control': 'no-cache',
139
+ });
140
+ }
141
+
142
+ const coverageScore = spec.docs?.coverageScore ?? 0;
143
+ const svg = generateBadgeSvg('docs', `${coverageScore}%`, getColorForScore(coverageScore));
144
+
145
+ return c.body(svg, 200, {
146
+ 'Content-Type': 'image/svg+xml',
147
+ 'Cache-Control': 'public, max-age=300',
148
+ });
149
+ } catch {
150
+ const svg = generateBadgeSvg('docs', 'error', 'red');
151
+ return c.body(svg, 500, {
152
+ 'Content-Type': 'image/svg+xml',
153
+ 'Cache-Control': 'no-cache',
154
+ });
155
+ }
156
+ });
157
+
158
+ // GET /badge/:owner/:repo.svg (alias)
159
+ app.get('/badge/:owner/:repo.svg', async (c) => {
160
+ const owner = c.req.param('owner');
161
+ const repoWithSvg = c.req.param('repo.svg') ?? '';
162
+ const repoName = repoWithSvg.replace(/\.svg$/, '');
163
+ return c.redirect(`/badge/${owner}/${repoName}`);
164
+ });
165
+
166
+ // GET /spec/:owner/:repo/pr/:pr - Must be before the :ref route
167
+ app.get('/spec/:owner/:repo/pr/:pr', async (c) => {
168
+ const { owner, repo, pr } = c.req.param();
169
+
170
+ try {
171
+ // Get PR head SHA from GitHub API
172
+ const prResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}`, {
173
+ headers: { 'User-Agent': 'DocCov' },
174
+ });
175
+
176
+ if (!prResponse.ok) {
177
+ return c.json({ error: 'PR not found' }, 404);
178
+ }
179
+
180
+ const prData = (await prResponse.json()) as { head: { sha: string } };
181
+ const headSha = prData.head.sha;
182
+
183
+ // Fetch spec from PR head
184
+ const specUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${headSha}/openpkg.json`;
185
+ const specResponse = await fetch(specUrl);
186
+
187
+ if (!specResponse.ok) {
188
+ return c.json({ error: 'Spec not found in PR' }, 404);
189
+ }
190
+
191
+ const spec = await specResponse.json();
192
+ return c.json(spec, 200, {
193
+ 'Cache-Control': 'no-cache',
194
+ });
195
+ } catch {
196
+ return c.json({ error: 'Failed to fetch PR spec' }, 500);
197
+ }
198
+ });
199
+
200
+ // GET /spec/:owner/:repo/:ref? (default ref = main)
201
+ app.get('/spec/:owner/:repo/:ref?', async (c) => {
202
+ const { owner, repo } = c.req.param();
203
+ const ref = c.req.param('ref') ?? 'main';
204
+
205
+ const spec = await fetchSpecFromGitHub(owner, repo, ref);
206
+
207
+ if (!spec) {
208
+ return c.json({ error: 'Spec not found' }, 404);
209
+ }
210
+
211
+ return c.json(spec, 200, {
212
+ 'Cache-Control': 'public, max-age=300',
213
+ });
214
+ });
215
+
216
+ // Note: /scan and /scan-stream are handled by separate Node.js functions
217
+
218
+ export default handle(app);