@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.
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export { default as XRayPage } from './XRayPage';
2
+ export type { XRayPageProps } from './XRayPage';
3
+
4
+ export { default as XRayTabBadge } from './XRayTabBadge';
5
+ export type { XRayTabBadgeProps } from './XRayTabBadge';
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
+ }