@doccov/api 0.2.2 → 0.3.0
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 +18 -0
- package/api/scan-stream.ts +14 -8
- package/package.json +4 -3
- package/src/index.ts +11 -0
- package/src/middleware/rate-limit.ts +112 -0
- package/src/routes/scan.ts +20 -9
- package/src/scan-worker.ts +6 -2
- package/src/schemas/scan.ts +16 -0
- package/src/stores/job-store.interface.ts +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @doccov/api
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- add rate limiting middleware and job store abstractions
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @openpkg-ts/spec@0.4.1
|
|
13
|
+
|
|
14
|
+
## 0.2.3
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @openpkg-ts/spec@0.4.0
|
|
20
|
+
|
|
3
21
|
## 0.2.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/api/scan-stream.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Writable } from 'node:stream';
|
|
2
|
-
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
3
|
-
import { Sandbox } from '@vercel/sandbox';
|
|
4
2
|
import {
|
|
5
|
-
SandboxFileSystem,
|
|
6
3
|
detectBuildInfo,
|
|
7
4
|
detectMonorepo,
|
|
8
5
|
detectPackageManager,
|
|
9
6
|
getInstallCommand,
|
|
10
7
|
getPrimaryBuildScript,
|
|
8
|
+
SandboxFileSystem,
|
|
11
9
|
} from '@doccov/sdk';
|
|
10
|
+
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
11
|
+
import { Sandbox } from '@vercel/sandbox';
|
|
12
12
|
|
|
13
13
|
export const config = {
|
|
14
14
|
runtime: 'nodejs',
|
|
@@ -153,7 +153,13 @@ async function runScanWithProgress(
|
|
|
153
153
|
// Try fetching the ref first (might be a tag not fetched by shallow clone)
|
|
154
154
|
await sandbox.runCommand({
|
|
155
155
|
cmd: 'git',
|
|
156
|
-
args: [
|
|
156
|
+
args: [
|
|
157
|
+
'fetch',
|
|
158
|
+
'--depth',
|
|
159
|
+
'1',
|
|
160
|
+
'origin',
|
|
161
|
+
`refs/tags/${options.ref}:refs/tags/${options.ref}`,
|
|
162
|
+
],
|
|
157
163
|
});
|
|
158
164
|
const retryResult = await sandbox.runCommand({
|
|
159
165
|
cmd: 'git',
|
|
@@ -174,7 +180,9 @@ async function runScanWithProgress(
|
|
|
174
180
|
|
|
175
181
|
// Detect package manager using SDK
|
|
176
182
|
const pmInfo = await detectPackageManager(fs);
|
|
177
|
-
const pmMessage = pmInfo.lockfile
|
|
183
|
+
const pmMessage = pmInfo.lockfile
|
|
184
|
+
? `Detected ${pmInfo.name} project`
|
|
185
|
+
: 'No lockfile detected';
|
|
178
186
|
sendEvent({ type: 'progress', stage: 'detecting', message: pmMessage, progress: 15 });
|
|
179
187
|
|
|
180
188
|
// Early monorepo detection - fail fast if monorepo without package param
|
|
@@ -189,9 +197,7 @@ async function runScanWithProgress(
|
|
|
189
197
|
progress: 17,
|
|
190
198
|
});
|
|
191
199
|
|
|
192
|
-
const availablePackages = mono.packages
|
|
193
|
-
.filter((p) => !p.private)
|
|
194
|
-
.map((p) => p.name);
|
|
200
|
+
const availablePackages = mono.packages.filter((p) => !p.private).map((p) => p.name);
|
|
195
201
|
|
|
196
202
|
await sandbox.stop();
|
|
197
203
|
sendEvent({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doccov/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "DocCov API - Badge endpoint and coverage services",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"doccov",
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
"format": "biome format --write src/"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@openpkg-ts/spec": "^0.
|
|
30
|
+
"@openpkg-ts/spec": "^0.4.1",
|
|
31
31
|
"@vercel/sandbox": "^1.0.3",
|
|
32
32
|
"hono": "^4.0.0",
|
|
33
|
-
"ms": "^2.1.3"
|
|
33
|
+
"ms": "^2.1.3",
|
|
34
|
+
"zod": "^3.25.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@types/bun": "latest",
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
|
+
import { rateLimit } from './middleware/rate-limit';
|
|
3
4
|
import { badgeRoute } from './routes/badge';
|
|
4
5
|
import { leaderboardRoute } from './routes/leaderboard';
|
|
5
6
|
import { scanRoute } from './routes/scan';
|
|
@@ -10,6 +11,16 @@ const app = new Hono();
|
|
|
10
11
|
// Middleware
|
|
11
12
|
app.use('*', cors());
|
|
12
13
|
|
|
14
|
+
// Rate limit /scan endpoint: 10 requests per minute per IP
|
|
15
|
+
app.use(
|
|
16
|
+
'/scan/*',
|
|
17
|
+
rateLimit({
|
|
18
|
+
windowMs: 60 * 1000,
|
|
19
|
+
max: 10,
|
|
20
|
+
message: 'Too many scan requests. Please try again in a minute.',
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
13
24
|
// Health check
|
|
14
25
|
app.get('/', (c) => {
|
|
15
26
|
return c.json({
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
2
|
+
|
|
3
|
+
interface RateLimitOptions {
|
|
4
|
+
/** Time window in milliseconds */
|
|
5
|
+
windowMs: number;
|
|
6
|
+
/** Max requests per window */
|
|
7
|
+
max: number;
|
|
8
|
+
/** Message to return when rate limited */
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RateLimitEntry {
|
|
13
|
+
count: number;
|
|
14
|
+
resetAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory rate limiter store
|
|
19
|
+
* Can be swapped for Redis in production
|
|
20
|
+
*/
|
|
21
|
+
class RateLimitStore {
|
|
22
|
+
private store = new Map<string, RateLimitEntry>();
|
|
23
|
+
|
|
24
|
+
get(key: string): RateLimitEntry | undefined {
|
|
25
|
+
const entry = this.store.get(key);
|
|
26
|
+
if (entry && Date.now() > entry.resetAt) {
|
|
27
|
+
this.store.delete(key);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
increment(key: string, windowMs: number): RateLimitEntry {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const existing = this.get(key);
|
|
36
|
+
|
|
37
|
+
if (existing) {
|
|
38
|
+
existing.count++;
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entry: RateLimitEntry = {
|
|
43
|
+
count: 1,
|
|
44
|
+
resetAt: now + windowMs,
|
|
45
|
+
};
|
|
46
|
+
this.store.set(key, entry);
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cleanup(): void {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
for (const [key, entry] of this.store.entries()) {
|
|
53
|
+
if (now > entry.resetAt) {
|
|
54
|
+
this.store.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const store = new RateLimitStore();
|
|
61
|
+
|
|
62
|
+
// Cleanup expired entries every minute
|
|
63
|
+
setInterval(() => store.cleanup(), 60 * 1000).unref();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get client IP from request
|
|
67
|
+
*/
|
|
68
|
+
function getClientIp(c: Context): string {
|
|
69
|
+
// Check common proxy headers
|
|
70
|
+
const forwarded = c.req.header('x-forwarded-for');
|
|
71
|
+
if (forwarded) {
|
|
72
|
+
return forwarded.split(',')[0].trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const realIp = c.req.header('x-real-ip');
|
|
76
|
+
if (realIp) {
|
|
77
|
+
return realIp;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Vercel-specific
|
|
81
|
+
const vercelIp = c.req.header('x-vercel-forwarded-for');
|
|
82
|
+
if (vercelIp) {
|
|
83
|
+
return vercelIp.split(',')[0].trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return 'unknown';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rate limiting middleware for Hono
|
|
91
|
+
*/
|
|
92
|
+
export function rateLimit(options: RateLimitOptions): MiddlewareHandler {
|
|
93
|
+
const { windowMs, max, message = 'Too many requests, please try again later' } = options;
|
|
94
|
+
|
|
95
|
+
return async (c: Context, next: Next) => {
|
|
96
|
+
const ip = getClientIp(c);
|
|
97
|
+
const key = `ratelimit:${ip}`;
|
|
98
|
+
|
|
99
|
+
const entry = store.increment(key, windowMs);
|
|
100
|
+
|
|
101
|
+
// Set rate limit headers
|
|
102
|
+
c.header('X-RateLimit-Limit', String(max));
|
|
103
|
+
c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
|
|
104
|
+
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
|
|
105
|
+
|
|
106
|
+
if (entry.count > max) {
|
|
107
|
+
return c.json({ error: message }, 429);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await next();
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/routes/scan.ts
CHANGED
|
@@ -3,26 +3,37 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import { isSandboxAvailable, runScanInSandbox } from '../sandbox-runner';
|
|
5
5
|
import { type ScanJob, type ScanResult, scanJobStore } from '../scan-worker';
|
|
6
|
+
import { scanRequestSchema } from '../schemas/scan';
|
|
6
7
|
|
|
7
8
|
export const scanRoute = new Hono();
|
|
8
9
|
|
|
9
|
-
interface ScanRequestBody {
|
|
10
|
-
url: string;
|
|
11
|
-
ref?: string;
|
|
12
|
-
package?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
10
|
/**
|
|
16
11
|
* POST /scan
|
|
17
12
|
* Start a new scan job
|
|
18
13
|
*/
|
|
19
14
|
scanRoute.post('/', async (c) => {
|
|
20
|
-
|
|
15
|
+
// Parse JSON with error handling
|
|
16
|
+
let rawBody: unknown;
|
|
17
|
+
try {
|
|
18
|
+
rawBody = await c.req.json();
|
|
19
|
+
} catch {
|
|
20
|
+
return c.json({ error: 'Invalid JSON' }, 400);
|
|
21
|
+
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Validate request body with Zod schema
|
|
24
|
+
const parsed = scanRequestSchema.safeParse(rawBody);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
return c.json(
|
|
27
|
+
{
|
|
28
|
+
error: 'Validation failed',
|
|
29
|
+
details: parsed.error.issues.map((i) => i.message),
|
|
30
|
+
},
|
|
31
|
+
400,
|
|
32
|
+
);
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
const body = parsed.data;
|
|
36
|
+
|
|
26
37
|
// Generate job ID
|
|
27
38
|
const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
28
39
|
|
package/src/scan-worker.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Scan job store and caching layer
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { JobStore } from './stores/job-store.interface';
|
|
6
|
+
|
|
5
7
|
export interface ScanResult {
|
|
6
8
|
owner: string;
|
|
7
9
|
repo: string;
|
|
@@ -42,8 +44,9 @@ const JOB_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* In-memory job store with caching
|
|
47
|
+
* Implements JobStore interface for easy swap to Redis/KV
|
|
45
48
|
*/
|
|
46
|
-
class ScanJobStore {
|
|
49
|
+
class ScanJobStore implements JobStore {
|
|
47
50
|
private jobs = new Map<string, ScanJob>();
|
|
48
51
|
private cache = new Map<string, CacheEntry>();
|
|
49
52
|
|
|
@@ -123,9 +126,10 @@ class ScanJobStore {
|
|
|
123
126
|
export const scanJobStore = new ScanJobStore();
|
|
124
127
|
|
|
125
128
|
// Periodic cleanup every 5 minutes
|
|
129
|
+
// Use .unref() to prevent keeping the process alive
|
|
126
130
|
setInterval(
|
|
127
131
|
() => {
|
|
128
132
|
scanJobStore.cleanup();
|
|
129
133
|
},
|
|
130
134
|
5 * 60 * 1000,
|
|
131
|
-
);
|
|
135
|
+
).unref();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for POST /scan request body
|
|
5
|
+
* Validates and restricts URLs to GitHub only
|
|
6
|
+
*/
|
|
7
|
+
export const scanRequestSchema = z.object({
|
|
8
|
+
url: z
|
|
9
|
+
.string()
|
|
10
|
+
.url('Invalid URL format')
|
|
11
|
+
.regex(/^https:\/\/github\.com\//, 'Only GitHub URLs are allowed'),
|
|
12
|
+
ref: z.string().optional(),
|
|
13
|
+
package: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type ScanRequestBody = z.infer<typeof scanRequestSchema>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ScanJob, ScanResult } from '../scan-worker';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interface for scan job storage
|
|
5
|
+
* Allows swapping in-memory store for Redis/KV in production
|
|
6
|
+
*/
|
|
7
|
+
export interface JobStore {
|
|
8
|
+
/** Get a job by ID */
|
|
9
|
+
get(jobId: string): ScanJob | undefined | Promise<ScanJob | undefined>;
|
|
10
|
+
|
|
11
|
+
/** Set/update a job */
|
|
12
|
+
set(jobId: string, job: ScanJob): void | Promise<void>;
|
|
13
|
+
|
|
14
|
+
/** Get cached scan result */
|
|
15
|
+
getFromCache(key: string): ScanResult | undefined | Promise<ScanResult | undefined>;
|
|
16
|
+
|
|
17
|
+
/** Cache a scan result */
|
|
18
|
+
setCache(key: string, result: ScanResult): void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
/** Cleanup expired entries */
|
|
21
|
+
cleanup(): void | Promise<void>;
|
|
22
|
+
|
|
23
|
+
/** Get store statistics */
|
|
24
|
+
stats(): { jobs: number; cache: number } | Promise<{ jobs: number; cache: number }>;
|
|
25
|
+
}
|