@brainfish-ai/devdoc 0.1.48 → 0.1.49
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/dist/cli/commands/deploy.js +16 -11
- package/package.json +1 -1
- package/renderer/app/[...slug]/client.js +17 -0
- package/renderer/app/[...slug]/page.js +125 -0
- package/renderer/app/api/assets/[...path]/route.js +23 -4
- package/renderer/app/api/chat/route.js +188 -25
- package/renderer/app/api/collections/route.js +95 -2
- package/renderer/app/api/deploy/route.js +4 -0
- package/renderer/app/api/suggestions/route.js +98 -10
- package/renderer/app/globals.css +33 -0
- package/renderer/app/layout.js +83 -8
- package/renderer/components/docs/mdx/cards.js +16 -45
- package/renderer/components/docs/mdx/file-tree.js +102 -0
- package/renderer/components/docs/mdx/index.js +7 -0
- package/renderer/components/docs-viewer/agent/agent-chat.js +75 -11
- package/renderer/components/docs-viewer/agent/messages/assistant-message.js +67 -3
- package/renderer/components/docs-viewer/agent/messages/tool-call-display.js +49 -4
- package/renderer/components/docs-viewer/content/content-router.js +1 -1
- package/renderer/components/docs-viewer/content/doc-page.js +36 -28
- package/renderer/components/docs-viewer/index.js +223 -58
- package/renderer/components/docs-viewer/playground/graphql-playground.js +131 -33
- package/renderer/components/docs-viewer/shared/method-badge.js +11 -2
- package/renderer/components/docs-viewer/sidebar/collection-tree.js +44 -6
- package/renderer/components/docs-viewer/sidebar/index.js +2 -1
- package/renderer/components/docs-viewer/sidebar/right-sidebar.js +3 -1
- package/renderer/components/docs-viewer/sidebar/sidebar-item.js +5 -7
- package/renderer/hooks/use-route-state.js +44 -56
- package/renderer/lib/api-docs/agent/indexer.js +73 -12
- package/renderer/lib/api-docs/agent/use-suggestions.js +26 -16
- package/renderer/lib/api-docs/code-editor/mode-context.js +16 -18
- package/renderer/lib/api-docs/parsers/openapi/transformer.js +8 -1
- package/renderer/lib/cache/purge.js +98 -0
- package/renderer/lib/docs-link-utils.js +146 -0
- package/renderer/lib/docs-navigation-context.js +3 -2
- package/renderer/lib/docs-navigation.js +50 -41
- package/renderer/lib/rate-limit.js +203 -0
|
@@ -4,20 +4,26 @@
|
|
|
4
4
|
* These functions provide simple URL updates without state management.
|
|
5
5
|
* The URL is the single source of truth - components derive their state from the URL.
|
|
6
6
|
*
|
|
7
|
-
* URL Schema:
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
7
|
+
* URL Schema (path-based for SEO):
|
|
8
|
+
* - /[tab]/page/[slug] → Doc page (docs view)
|
|
9
|
+
* - /[tab]/endpoint/[id] → API endpoint (docs view)
|
|
10
|
+
* - /[tab]/endpoint/[id]/playground → API endpoint (playground view)
|
|
11
|
+
* - /[tab]/endpoint/[id]/notes → API endpoint (notes view)
|
|
12
|
+
* - /[tab]/page/[slug]/notes → Doc page (notes view)
|
|
13
13
|
*/ /**
|
|
14
|
+
* Helper to update URL and trigger popstate for React to pick up the change
|
|
15
|
+
*/ function pushPath(path) {
|
|
16
|
+
window.history.pushState(null, '', path);
|
|
17
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
14
20
|
* Navigate to a documentation page
|
|
15
21
|
* @param tab - Tab ID (e.g., 'guides', 'graphql-api')
|
|
16
22
|
* @param slug - Page slug (e.g., 'graphql/introduction')
|
|
17
23
|
* @param view - View mode (docs, playground, notes). Defaults to 'docs'
|
|
18
24
|
*/ export function navigateToPage(tab, slug, view) {
|
|
19
25
|
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
20
|
-
|
|
26
|
+
pushPath(`/${tab}/page/${slug}${viewSuffix}`);
|
|
21
27
|
}
|
|
22
28
|
/**
|
|
23
29
|
* Navigate to an API endpoint or GraphQL operation
|
|
@@ -26,77 +32,80 @@
|
|
|
26
32
|
* @param view - View mode (docs, playground, notes). Defaults to 'docs'
|
|
27
33
|
*/ export function navigateToEndpoint(tab, id, view) {
|
|
28
34
|
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
29
|
-
|
|
35
|
+
pushPath(`/${tab}/endpoint/${id}${viewSuffix}`);
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
32
38
|
* Navigate to a section within a page (scroll to element)
|
|
33
39
|
* @param tab - Tab ID
|
|
34
40
|
* @param sectionId - Section/heading ID to scroll to
|
|
35
41
|
*/ export function navigateToSection(tab, sectionId) {
|
|
36
|
-
|
|
42
|
+
pushPath(`/${tab}/section/${sectionId}`);
|
|
37
43
|
}
|
|
38
44
|
/**
|
|
39
45
|
* Navigate to a tab (show default content for that tab)
|
|
40
46
|
* @param tab - Tab ID
|
|
41
47
|
*/ export function navigateToTab(tab) {
|
|
42
|
-
|
|
48
|
+
pushPath(`/${tab}`);
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Switch view mode while keeping the current content
|
|
46
52
|
* @param view - View mode to switch to
|
|
47
53
|
*/ export function switchView(view) {
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
54
|
+
const pathname = window.location.pathname;
|
|
55
|
+
if (!pathname || pathname === '/') return;
|
|
50
56
|
// Remove existing view suffix if present
|
|
51
|
-
const
|
|
57
|
+
const cleanPath = pathname.replace(/\/(playground|notes)$/, '');
|
|
52
58
|
// Add new view suffix (unless switching to docs, which is the default)
|
|
53
59
|
const viewSuffix = view !== 'docs' ? `/${view}` : '';
|
|
54
|
-
|
|
60
|
+
pushPath(`${cleanPath}${viewSuffix}`);
|
|
55
61
|
}
|
|
56
62
|
/**
|
|
57
63
|
* Clear current selection, optionally staying on a tab
|
|
58
64
|
* @param tab - Optional tab to stay on
|
|
59
65
|
*/ export function clearSelection(tab) {
|
|
60
66
|
if (tab) {
|
|
61
|
-
|
|
67
|
+
pushPath(`/${tab}`);
|
|
62
68
|
} else {
|
|
63
|
-
|
|
64
|
-
window.history.pushState(null, '', window.location.pathname + window.location.search);
|
|
69
|
+
pushPath('/');
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
68
73
|
* Update URL without triggering navigation (for history management)
|
|
69
|
-
* @param
|
|
70
|
-
*/ export function updateUrlSilently(
|
|
71
|
-
|
|
74
|
+
* @param path - Path value (without leading /)
|
|
75
|
+
*/ export function updateUrlSilently(path) {
|
|
76
|
+
const fullPath = path.startsWith('/') ? path : `/${path}`;
|
|
77
|
+
window.history.replaceState(null, '', fullPath);
|
|
72
78
|
}
|
|
73
79
|
/**
|
|
74
80
|
* Push a new URL to history (for back/forward navigation)
|
|
75
|
-
* @param
|
|
76
|
-
*/ export function pushUrl(
|
|
77
|
-
|
|
81
|
+
* @param path - Path value (without leading /)
|
|
82
|
+
*/ export function pushUrl(path) {
|
|
83
|
+
const fullPath = path.startsWith('/') ? path : `/${path}`;
|
|
84
|
+
pushPath(fullPath);
|
|
78
85
|
}
|
|
79
86
|
/**
|
|
80
|
-
* Build
|
|
81
|
-
*/ export const
|
|
87
|
+
* Build URL strings (for href attributes)
|
|
88
|
+
*/ export const urls = {
|
|
82
89
|
page: (tab, slug, view)=>{
|
|
83
90
|
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
84
|
-
return
|
|
91
|
+
return `/${tab}/page/${slug}${viewSuffix}`;
|
|
85
92
|
},
|
|
86
93
|
endpoint: (tab, id, view)=>{
|
|
87
94
|
const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
|
|
88
|
-
return
|
|
95
|
+
return `/${tab}/endpoint/${id}${viewSuffix}`;
|
|
89
96
|
},
|
|
90
|
-
section: (tab, sectionId)
|
|
91
|
-
tab: (tab)
|
|
97
|
+
section: (tab, sectionId)=>`/${tab}/section/${sectionId}`,
|
|
98
|
+
tab: (tab)=>`/${tab}`
|
|
92
99
|
};
|
|
93
100
|
/**
|
|
94
|
-
* Parse a
|
|
95
|
-
* @param
|
|
101
|
+
* Parse a URL path into its components
|
|
102
|
+
* @param path - Path value (with or without leading /)
|
|
96
103
|
* @returns Parsed components
|
|
97
|
-
*/ export function
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
*/ export function parseUrl(path) {
|
|
105
|
+
// Remove leading slash and hash if present
|
|
106
|
+
let cleanPath = path.startsWith('#') ? path.slice(1) : path;
|
|
107
|
+
cleanPath = cleanPath.startsWith('/') ? cleanPath.slice(1) : cleanPath;
|
|
108
|
+
if (!cleanPath) {
|
|
100
109
|
return {
|
|
101
110
|
tab: null,
|
|
102
111
|
type: null,
|
|
@@ -104,7 +113,7 @@
|
|
|
104
113
|
view: 'docs'
|
|
105
114
|
};
|
|
106
115
|
}
|
|
107
|
-
const parts =
|
|
116
|
+
const parts = cleanPath.split('/');
|
|
108
117
|
const tab = parts[0] || null;
|
|
109
118
|
const type = parts[1];
|
|
110
119
|
// Check if last part is a view mode
|
|
@@ -129,12 +138,12 @@
|
|
|
129
138
|
};
|
|
130
139
|
}
|
|
131
140
|
/**
|
|
132
|
-
* Check if two
|
|
133
|
-
* @param
|
|
134
|
-
* @param
|
|
141
|
+
* Check if two URLs point to the same content (ignoring view mode)
|
|
142
|
+
* @param path1 - First path
|
|
143
|
+
* @param path2 - Second path
|
|
135
144
|
* @returns True if they point to the same content
|
|
136
|
-
*/ export function isSameContent(
|
|
137
|
-
const parsed1 =
|
|
138
|
-
const parsed2 =
|
|
145
|
+
*/ export function isSameContent(path1, path2) {
|
|
146
|
+
const parsed1 = parseUrl(path1);
|
|
147
|
+
const parsed2 = parseUrl(path2);
|
|
139
148
|
return parsed1.tab === parsed2.tab && parsed1.type === parsed2.type && parsed1.id === parsed2.id;
|
|
140
149
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* IP-based Rate Limiting Utility
|
|
16
|
+
*
|
|
17
|
+
* Uses CacheUtils (Vercel KV in production, node-cache locally) for storage.
|
|
18
|
+
* Implements a sliding window rate limiter with configurable limits.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { RateLimiter } from '@/lib/rate-limit';
|
|
23
|
+
*
|
|
24
|
+
* const limiter = new RateLimiter({
|
|
25
|
+
* prefix: 'suggestions',
|
|
26
|
+
* limit: 10, // 10 requests
|
|
27
|
+
* windowSeconds: 60, // per 60 seconds
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // In your API route:
|
|
31
|
+
* const { success, remaining, reset } = await limiter.check(request);
|
|
32
|
+
* if (!success) {
|
|
33
|
+
* return new Response('Too Many Requests', {
|
|
34
|
+
* status: 429,
|
|
35
|
+
* headers: {
|
|
36
|
+
* 'X-RateLimit-Limit': limit.toString(),
|
|
37
|
+
* 'X-RateLimit-Remaining': '0',
|
|
38
|
+
* 'X-RateLimit-Reset': reset.toString(),
|
|
39
|
+
* 'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
|
|
40
|
+
* }
|
|
41
|
+
* });
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/ import { CacheUtils } from '@/lib/cache';
|
|
45
|
+
/**
|
|
46
|
+
* Get client IP from request headers
|
|
47
|
+
* Handles various proxy scenarios (Vercel, Cloudflare, nginx, etc.)
|
|
48
|
+
*/ export function getClientIp(request) {
|
|
49
|
+
const headers = request.headers;
|
|
50
|
+
// Vercel / general proxy
|
|
51
|
+
const xForwardedFor = headers.get('x-forwarded-for');
|
|
52
|
+
if (xForwardedFor) {
|
|
53
|
+
// Take the first IP (original client) from the chain
|
|
54
|
+
const firstIp = xForwardedFor.split(',')[0].trim();
|
|
55
|
+
if (firstIp) return firstIp;
|
|
56
|
+
}
|
|
57
|
+
// Vercel-specific
|
|
58
|
+
const xRealIp = headers.get('x-real-ip');
|
|
59
|
+
if (xRealIp) return xRealIp;
|
|
60
|
+
// Cloudflare
|
|
61
|
+
const cfConnectingIp = headers.get('cf-connecting-ip');
|
|
62
|
+
if (cfConnectingIp) return cfConnectingIp;
|
|
63
|
+
// True-Client-IP (Akamai, Cloudflare Enterprise)
|
|
64
|
+
const trueClientIp = headers.get('true-client-ip');
|
|
65
|
+
if (trueClientIp) return trueClientIp;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Rate limiter class using sliding window algorithm
|
|
70
|
+
*/ export class RateLimiter {
|
|
71
|
+
/**
|
|
72
|
+
* Generate cache key for rate limiting
|
|
73
|
+
*/ getCacheKey(identifier) {
|
|
74
|
+
return `ratelimit:${this.config.prefix}:${identifier}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Check if request is allowed and update counter
|
|
78
|
+
*/ async check(request) {
|
|
79
|
+
const { limit, windowSeconds, skipIps, getIdentifier } = this.config;
|
|
80
|
+
// Get identifier (IP or custom)
|
|
81
|
+
const identifier = getIdentifier ? getIdentifier(request) : getClientIp(request);
|
|
82
|
+
// If we can't identify the client, allow the request but log it
|
|
83
|
+
if (!identifier) {
|
|
84
|
+
console.warn('[RateLimiter] Could not identify client, allowing request');
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
remaining: limit,
|
|
88
|
+
reset: Date.now() + windowSeconds * 1000,
|
|
89
|
+
count: 0,
|
|
90
|
+
limit
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Skip rate limiting for certain IPs
|
|
94
|
+
if (skipIps?.includes(identifier)) {
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
remaining: limit,
|
|
98
|
+
reset: Date.now() + windowSeconds * 1000,
|
|
99
|
+
count: 0,
|
|
100
|
+
limit
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const cacheKey = this.getCacheKey(identifier);
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
// Get current rate limit entry
|
|
106
|
+
let entry = await CacheUtils.get(cacheKey);
|
|
107
|
+
// If no entry or window has expired, create new window
|
|
108
|
+
if (!entry || entry.resetAt <= now) {
|
|
109
|
+
entry = {
|
|
110
|
+
count: 1,
|
|
111
|
+
resetAt: now + windowSeconds * 1000
|
|
112
|
+
};
|
|
113
|
+
await CacheUtils.set(cacheKey, entry, windowSeconds);
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
remaining: limit - 1,
|
|
117
|
+
reset: entry.resetAt,
|
|
118
|
+
count: 1,
|
|
119
|
+
limit
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Window still active, check if limit exceeded
|
|
123
|
+
if (entry.count >= limit) {
|
|
124
|
+
console.log(`[RateLimiter] Rate limit exceeded for ${identifier} on ${this.config.prefix}`);
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
remaining: 0,
|
|
128
|
+
reset: entry.resetAt,
|
|
129
|
+
count: entry.count,
|
|
130
|
+
limit
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// Increment counter
|
|
134
|
+
entry.count += 1;
|
|
135
|
+
const remainingTtl = Math.ceil((entry.resetAt - now) / 1000);
|
|
136
|
+
await CacheUtils.set(cacheKey, entry, remainingTtl);
|
|
137
|
+
return {
|
|
138
|
+
success: true,
|
|
139
|
+
remaining: limit - entry.count,
|
|
140
|
+
reset: entry.resetAt,
|
|
141
|
+
count: entry.count,
|
|
142
|
+
limit
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Create rate limit headers for response
|
|
147
|
+
*/ static createHeaders(result) {
|
|
148
|
+
const headers = {
|
|
149
|
+
'X-RateLimit-Limit': result.limit.toString(),
|
|
150
|
+
'X-RateLimit-Remaining': result.remaining.toString(),
|
|
151
|
+
'X-RateLimit-Reset': result.reset.toString()
|
|
152
|
+
};
|
|
153
|
+
if (!result.success) {
|
|
154
|
+
const retryAfter = Math.ceil((result.reset - Date.now()) / 1000);
|
|
155
|
+
headers['Retry-After'] = Math.max(1, retryAfter).toString();
|
|
156
|
+
}
|
|
157
|
+
return headers;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Create a 429 Too Many Requests response
|
|
161
|
+
*/ static tooManyRequestsResponse(result, message) {
|
|
162
|
+
return new Response(JSON.stringify({
|
|
163
|
+
error: 'Too Many Requests',
|
|
164
|
+
message: message || 'Rate limit exceeded. Please try again later.',
|
|
165
|
+
retryAfter: Math.ceil((result.reset - Date.now()) / 1000)
|
|
166
|
+
}), {
|
|
167
|
+
status: 429,
|
|
168
|
+
headers: {
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
...RateLimiter.createHeaders(result)
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
constructor(config){
|
|
175
|
+
_define_property(this, "config", void 0);
|
|
176
|
+
this.config = {
|
|
177
|
+
skipIps: [
|
|
178
|
+
'127.0.0.1',
|
|
179
|
+
'::1'
|
|
180
|
+
],
|
|
181
|
+
...config
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Pre-configured rate limiters for common use cases
|
|
186
|
+
export const rateLimiters = {
|
|
187
|
+
/** Suggestions API: 30 requests per minute */ suggestions: new RateLimiter({
|
|
188
|
+
prefix: 'suggestions',
|
|
189
|
+
limit: 30,
|
|
190
|
+
windowSeconds: 60
|
|
191
|
+
}),
|
|
192
|
+
/** Chat API: 20 requests per minute */ chat: new RateLimiter({
|
|
193
|
+
prefix: 'chat',
|
|
194
|
+
limit: 20,
|
|
195
|
+
windowSeconds: 60
|
|
196
|
+
}),
|
|
197
|
+
/** General API: 100 requests per minute */ general: new RateLimiter({
|
|
198
|
+
prefix: 'general',
|
|
199
|
+
limit: 100,
|
|
200
|
+
windowSeconds: 60
|
|
201
|
+
})
|
|
202
|
+
};
|
|
203
|
+
export default RateLimiter;
|