@stainlessdev/docs-xray 0.1.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/LICENSE +201 -0
- package/README.md +60 -0
- package/package.json +32 -0
- package/public/xray-badge.js +179 -0
- package/src/astro/XRayPageSetup.astro +57 -0
- package/src/astro/index.ts +1 -0
- package/src/components/XRayPage.tsx +1243 -0
- package/src/components/XRayTabBadge.tsx +219 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +49 -0
- package/src/integration.ts +53 -0
- package/src/styles/xray.css +1032 -0
- package/src/types.ts +56 -0
- package/src/utils/index.ts +218 -0
- package/src/utils/spec.ts +120 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { loadSeenIdsFromStorage, loadPendingCountFromStorage } from '../utils';
|
|
3
|
+
|
|
4
|
+
type XrayRequestLog = {
|
|
5
|
+
request_id: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const POLL_INTERVAL = 2000;
|
|
9
|
+
|
|
10
|
+
type FetchResult = {
|
|
11
|
+
data: XrayRequestLog[];
|
|
12
|
+
status: 'ok' | 'unauthorized' | 'error';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function fetchRecentRequests(xrayApi: string): Promise<FetchResult> {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`${xrayApi}/v1/request_logs`, {
|
|
18
|
+
credentials: 'include',
|
|
19
|
+
});
|
|
20
|
+
if (response.status === 401) {
|
|
21
|
+
return { data: [], status: 'unauthorized' };
|
|
22
|
+
}
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
return { data: [], status: 'error' };
|
|
25
|
+
}
|
|
26
|
+
const json = await response.json();
|
|
27
|
+
return { data: json.data ?? [], status: 'ok' };
|
|
28
|
+
} catch {
|
|
29
|
+
return { data: [], status: 'error' };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type XRayTabBadgeProps = {
|
|
34
|
+
xrayTabLink: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default function XRayTabBadge({ xrayTabLink }: XRayTabBadgeProps) {
|
|
38
|
+
const [newCount, setNewCount] = useState(0);
|
|
39
|
+
const [pathname, setPathname] = useState(() =>
|
|
40
|
+
typeof window !== 'undefined' ? window.location.pathname : ''
|
|
41
|
+
);
|
|
42
|
+
const seenIdsRef = useRef<Set<string>>(loadSeenIdsFromStorage());
|
|
43
|
+
const badgeRef = useRef<HTMLSpanElement | null>(null);
|
|
44
|
+
|
|
45
|
+
// Listen for route changes (supports both popstate and Astro's client-side navigation)
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const updatePathname = () => setPathname(window.location.pathname);
|
|
48
|
+
|
|
49
|
+
// Standard browser navigation
|
|
50
|
+
window.addEventListener('popstate', updatePathname);
|
|
51
|
+
// Astro View Transitions
|
|
52
|
+
document.addEventListener('astro:page-load', updatePathname);
|
|
53
|
+
// Also check periodically in case other navigation methods are used
|
|
54
|
+
const intervalId = setInterval(updatePathname, 500);
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
window.removeEventListener('popstate', updatePathname);
|
|
58
|
+
document.removeEventListener('astro:page-load', updatePathname);
|
|
59
|
+
clearInterval(intervalId);
|
|
60
|
+
};
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// Check if we're on the X-ray page
|
|
64
|
+
const normalizedXrayTabLink = xrayTabLink.replace(/\/$/, '');
|
|
65
|
+
const isOnXrayPage =
|
|
66
|
+
pathname === normalizedXrayTabLink ||
|
|
67
|
+
pathname === `${normalizedXrayTabLink}/` ||
|
|
68
|
+
pathname.startsWith(`${normalizedXrayTabLink}/`);
|
|
69
|
+
|
|
70
|
+
// When on X-ray page: read pending count from localStorage (set by XRayPage)
|
|
71
|
+
// When not on X-ray page: poll API for unseen requests
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (isOnXrayPage) {
|
|
74
|
+
// On X-ray page: poll localStorage for pending count (set by XRayPage)
|
|
75
|
+
const checkPendingCount = () => {
|
|
76
|
+
const count = loadPendingCountFromStorage();
|
|
77
|
+
setNewCount(count);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
checkPendingCount();
|
|
81
|
+
const intervalId = setInterval(checkPendingCount, 500);
|
|
82
|
+
return () => clearInterval(intervalId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Not on X-ray page: reload seen IDs from storage (XRayPage may have updated them)
|
|
86
|
+
seenIdsRef.current = loadSeenIdsFromStorage();
|
|
87
|
+
|
|
88
|
+
// Poll API for unseen requests
|
|
89
|
+
const xrayApi = window.__XRAY_API__;
|
|
90
|
+
if (!xrayApi) return;
|
|
91
|
+
|
|
92
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
93
|
+
let isUnauthorized = false;
|
|
94
|
+
|
|
95
|
+
const poll = async () => {
|
|
96
|
+
// Skip polling if tab is not visible
|
|
97
|
+
if (document.visibilityState !== 'visible') {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await fetchRecentRequests(xrayApi);
|
|
102
|
+
|
|
103
|
+
// Stop polling on 401 to avoid console errors
|
|
104
|
+
if (result.status === 'unauthorized') {
|
|
105
|
+
isUnauthorized = true;
|
|
106
|
+
if (intervalId) {
|
|
107
|
+
clearInterval(intervalId);
|
|
108
|
+
intervalId = null;
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Count requests that haven't been seen yet
|
|
114
|
+
const unseenCount = result.data.filter(
|
|
115
|
+
(req) => !seenIdsRef.current.has(req.request_id)
|
|
116
|
+
).length;
|
|
117
|
+
|
|
118
|
+
setNewCount(unseenCount);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Poll immediately when tab becomes visible
|
|
122
|
+
const handleVisibilityChange = () => {
|
|
123
|
+
if (document.visibilityState === 'visible' && !isUnauthorized) {
|
|
124
|
+
poll();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
129
|
+
|
|
130
|
+
poll();
|
|
131
|
+
intervalId = setInterval(poll, POLL_INTERVAL);
|
|
132
|
+
return () => {
|
|
133
|
+
if (intervalId) clearInterval(intervalId);
|
|
134
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
135
|
+
};
|
|
136
|
+
}, [xrayTabLink, isOnXrayPage]);
|
|
137
|
+
|
|
138
|
+
// Find the X-ray tab button in the DOM
|
|
139
|
+
const findXrayTabButton = (): HTMLElement | null => {
|
|
140
|
+
// Find by href attribute - works across different header layouts
|
|
141
|
+
const link = document.querySelector(`a[href="${xrayTabLink}"].stl-ui-button`);
|
|
142
|
+
return link as HTMLElement | null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Find and update the X-ray tab badge in the DOM
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const xrayTabButton = findXrayTabButton();
|
|
148
|
+
|
|
149
|
+
if (newCount === 0) {
|
|
150
|
+
// Remove badge if it exists
|
|
151
|
+
if (badgeRef.current) {
|
|
152
|
+
badgeRef.current.remove();
|
|
153
|
+
badgeRef.current = null;
|
|
154
|
+
}
|
|
155
|
+
// Also remove any existing badge in DOM
|
|
156
|
+
if (xrayTabButton) {
|
|
157
|
+
const existingBadge = xrayTabButton.querySelector('.xray-tab-badge');
|
|
158
|
+
if (existingBadge) existingBadge.remove();
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const updateBadge = () => {
|
|
164
|
+
const xrayTabButton = findXrayTabButton();
|
|
165
|
+
if (!xrayTabButton) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Find or create badge
|
|
170
|
+
let badge = badgeRef.current;
|
|
171
|
+
if (!badge) {
|
|
172
|
+
// Check if badge already exists in DOM (from previous mount or inline script)
|
|
173
|
+
const existingBadge = xrayTabButton.querySelector('.xray-tab-badge');
|
|
174
|
+
if (existingBadge) {
|
|
175
|
+
badge = existingBadge as HTMLSpanElement;
|
|
176
|
+
badgeRef.current = badge;
|
|
177
|
+
} else {
|
|
178
|
+
badge = document.createElement('span');
|
|
179
|
+
badge.className = 'xray-tab-badge';
|
|
180
|
+
badgeRef.current = badge;
|
|
181
|
+
|
|
182
|
+
// Find the span inside the button and append badge after it
|
|
183
|
+
const labelSpan = xrayTabButton.querySelector('span');
|
|
184
|
+
if (labelSpan) {
|
|
185
|
+
labelSpan.insertAdjacentElement('afterend', badge);
|
|
186
|
+
} else {
|
|
187
|
+
xrayTabButton.appendChild(badge);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Update badge text
|
|
193
|
+
badge.textContent = newCount > 99 ? '99+' : newCount.toString();
|
|
194
|
+
return true;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Try immediately
|
|
198
|
+
if (updateBadge()) return;
|
|
199
|
+
|
|
200
|
+
// If not found, retry a few times with increasing delays
|
|
201
|
+
const retryDelays = [50, 100, 250, 500];
|
|
202
|
+
let retryIndex = 0;
|
|
203
|
+
|
|
204
|
+
const retry = () => {
|
|
205
|
+
if (retryIndex >= retryDelays.length) return;
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
if (!updateBadge()) {
|
|
208
|
+
retryIndex++;
|
|
209
|
+
retry();
|
|
210
|
+
}
|
|
211
|
+
}, retryDelays[retryIndex]);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
retry();
|
|
215
|
+
}, [newCount, xrayTabLink]);
|
|
216
|
+
|
|
217
|
+
// This component doesn't render anything visible - it just manages the badge in the DOM
|
|
218
|
+
return null;
|
|
219
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { XRayPage, XRayTabBadge } from './components';
|
|
3
|
+
export type { XRayPageProps, XRayTabBadgeProps } from './components';
|
|
4
|
+
|
|
5
|
+
// Note: Astro components are exported separately via '@stainlessdev/docs-xray/astro'
|
|
6
|
+
// to avoid issues with non-Astro contexts (like astro.config.ts)
|
|
7
|
+
|
|
8
|
+
// Note: Use xrayIntegration from '@stainlessdev/docs-xray/integration' in astro.config.ts
|
|
9
|
+
|
|
10
|
+
// Types
|
|
11
|
+
export type {
|
|
12
|
+
XrayRequestLog,
|
|
13
|
+
XrayRequestLogDetail,
|
|
14
|
+
SessionUser,
|
|
15
|
+
FetchResult,
|
|
16
|
+
EndpointInfo,
|
|
17
|
+
EndpointMap,
|
|
18
|
+
ParsedUserAgent,
|
|
19
|
+
DetailTab,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
// Utilities
|
|
23
|
+
export {
|
|
24
|
+
parseUserAgent,
|
|
25
|
+
formatUserAgentSummary,
|
|
26
|
+
normalizeRouteParams,
|
|
27
|
+
getEndpointInfo,
|
|
28
|
+
getStatusText,
|
|
29
|
+
getStatusClass,
|
|
30
|
+
formatRelativeTime,
|
|
31
|
+
formatTime,
|
|
32
|
+
formatDate,
|
|
33
|
+
loadSeenIdsFromStorage,
|
|
34
|
+
saveSeenIdsToStorage,
|
|
35
|
+
savePendingCountToStorage,
|
|
36
|
+
loadPendingCountFromStorage,
|
|
37
|
+
} from './utils';
|
|
38
|
+
|
|
39
|
+
// Spec utilities
|
|
40
|
+
export {
|
|
41
|
+
walkSpec,
|
|
42
|
+
generateRoute,
|
|
43
|
+
buildEndpointMap,
|
|
44
|
+
} from './utils/spec';
|
|
45
|
+
export type {
|
|
46
|
+
SpecMethod,
|
|
47
|
+
BuildEndpointMapOptions,
|
|
48
|
+
BuildEndpointMapResult,
|
|
49
|
+
} from './utils/spec';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export type XrayIntegrationOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The X-ray API URL. If not provided, X-ray will be disabled.
|
|
9
|
+
*/
|
|
10
|
+
xrayApi?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Astro integration for X-ray badge functionality.
|
|
15
|
+
*
|
|
16
|
+
* This integration:
|
|
17
|
+
* - Injects the X-ray API URL into the page
|
|
18
|
+
* - Adds the badge script that shows new request counts
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { defineConfig } from 'astro/config';
|
|
23
|
+
* import { xrayIntegration } from '@stainlessdev/docs-xray/integration';
|
|
24
|
+
*
|
|
25
|
+
* export default defineConfig({
|
|
26
|
+
* integrations: [
|
|
27
|
+
* xrayIntegration({ xrayApi: process.env.XRAY_API }),
|
|
28
|
+
* ],
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function xrayIntegration(options: XrayIntegrationOptions = {}): AstroIntegration {
|
|
33
|
+
const { xrayApi } = options;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: '@stainlessdev/docs-xray',
|
|
37
|
+
hooks: {
|
|
38
|
+
'astro:config:setup': ({ injectScript }) => {
|
|
39
|
+
if (!xrayApi) return;
|
|
40
|
+
|
|
41
|
+
// Inject the API URL
|
|
42
|
+
injectScript('head-inline', `window.__XRAY_API__ = "${xrayApi}";`);
|
|
43
|
+
|
|
44
|
+
// Read and inject the badge script
|
|
45
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const badgeScriptPath = join(currentDir, '..', 'public', 'xray-badge.js');
|
|
47
|
+
const badgeScript = readFileSync(badgeScriptPath, 'utf-8');
|
|
48
|
+
|
|
49
|
+
injectScript('head-inline', badgeScript);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|