Compare commits

...

29 Commits

Author SHA1 Message Date
Andrei Edell c2257d3bc1 changeset & final cleanup 2025-03-18 21:00:36 -10:00
Andrei Edell a0b409ee4f formatting fix 2025-03-18 20:48:18 -10:00
Andrei Edell 2c54bd510a Delete the demo errors 2025-03-18 20:46:56 -10:00
Andrei Edell 37690c3834 Remove a probably unneeded boundary 2025-03-18 20:38:35 -10:00
Andrei Edell cac4eee5ec ChatErrorBoundary in ImagePreview 2025-03-18 20:36:36 -10:00
Andrei Edell d9e383a546 PR Review: extract ChatErrorBoundary 2025-03-18 19:55:46 -10:00
Andrei Edell e1c93847ce prettier fix 2025-03-18 18:47:21 -10:00
Andrei Edell e7208c6a2f big cleanup: logs, old code, webp handling, sizings 2025-03-18 18:47:21 -10:00
Andrei Edell 885ab233cf changeset 2025-03-18 18:47:21 -10:00
Andrei Edell 7b353073ad fix linter error 2025-03-18 18:47:21 -10:00
Andrei Edell 8b7036cb2a force update trick to make rich toggle smoother 2025-03-18 18:47:21 -10:00
Andrei Edell 7ce23fd6a9 give link to echo mcp server 2025-03-18 18:47:21 -10:00
Andrei Edell b020cf6826 testing docs & 50 max urls 2025-03-18 18:47:21 -10:00
Andrei Edell beac41e329 clean up logs, ensure timeout cancel 2025-03-18 18:47:21 -10:00
Andrei Edell b52ca98421 fix url loading cancellation 2025-03-18 18:47:21 -10:00
Andrei Edell b228462f0f consistently use content type header for detecting images 2025-03-18 18:47:21 -10:00
Andrei Edell f7e4978ce6 dont modify response text when upgrading http->https 2025-03-18 18:47:21 -10:00
Andrei Edell a5db3683c1 remove unused functions, add isLocalhost helper 2025-03-18 18:47:21 -10:00
Andrei Edell c1ba3c43d5 re-process image/link previews when plain -> rich 2025-03-18 18:47:21 -10:00
Andrei Edell 27b0997ad8 webp support 2025-03-18 18:47:21 -10:00
Andrei Edell 854ab0ca87 svg support 2025-03-18 18:47:21 -10:00
Andrei Edell 9a37752314 properly size boxy or wide embed logos 2025-03-18 18:47:21 -10:00
Andrei Edell b27990b204 Fixed image loading issues 2025-03-18 18:47:21 -10:00
Andrei Edell 807e8cd519 normalize relative images in og metadata 2025-03-18 18:47:21 -10:00
Andrei Edell eeeedd6863 fixed heights for embeds to avoid vertical movement 2025-03-18 18:47:21 -10:00
Andrei Edell 73c9c83597 closer 2025-03-18 18:47:21 -10:00
Andrei Edell ff9760f1d7 successfully load 100 popular url embeds 2025-03-18 18:47:21 -10:00
Andrei Edell 52395b4b7f better throttling / batching & error displays for embeds 2025-03-18 18:47:21 -10:00
Andrei Edell 906d39c236 #2170 stricter URL handling, URL limits 2025-03-18 18:47:21 -10:00
10 changed files with 1891 additions and 392 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Start using ErrorBoundaries for chat widgets to handle crashes
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Harden MCP Response Display with network throttling, ErrorBoundaries, URL checking, HTTPs security, limit number of URLs parsed per response. Adds support for svg, webp, and gifs
+1 -3
View File
@@ -63,7 +63,6 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
type: data.ogType,
}
} catch (error) {
console.error(`Error fetching Open Graph data for ${url}:`, error)
// Return basic information based on the URL
try {
const urlObj = new URL(url)
@@ -100,8 +99,7 @@ export async function isImageUrl(url: string): Promise<boolean> {
const contentType = response.headers["content-type"]
return contentType && contentType.startsWith("image/")
} catch (error) {
console.error(`Error checking if URL is an image: ${url}`, error)
// If we can't determine, fall back to checking the file extension
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
}
}
@@ -0,0 +1,130 @@
import React from "react"
interface ChatErrorBoundaryProps {
children: React.ReactNode
errorTitle?: string
errorBody?: string
height?: string
}
interface ChatErrorBoundaryState {
hasError: boolean
error: Error | null
}
/**
* A reusable error boundary component specifically designed for chat widgets.
* It provides a consistent error UI with customizable title and body text.
*/
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error in ChatErrorBoundary:", error.message)
console.error("Component stack:", errorInfo.componentStack)
}
render() {
const { errorTitle, errorBody, height } = this.props
if (this.state.hasError) {
return (
<div
style={{
padding: "10px",
color: "var(--vscode-errorForeground)",
height: height || "auto",
maxWidth: "512px",
overflow: "auto",
border: "1px solid var(--vscode-editorError-foreground)",
borderRadius: "4px",
backgroundColor: "var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1))",
}}>
<h3 style={{ margin: "0 0 8px 0" }}>{errorTitle || "Something went wrong displaying this content"}</h3>
<p style={{ margin: "0" }}>{errorBody || `Error: ${this.state.error?.message || "Unknown error"}`}</p>
</div>
)
}
return this.props.children
}
}
/**
* A demo component that throws an error after a delay.
* This is useful for testing error boundaries during development
*/
interface ErrorAfterDelayProps {
numSecondsToWait?: number
}
interface ErrorAfterDelayState {
tickCount: number
}
export class ErrorAfterDelay extends React.Component<ErrorAfterDelayProps, ErrorAfterDelayState> {
private intervalID: NodeJS.Timeout | null = null
constructor(props: ErrorAfterDelayProps) {
super(props)
this.state = {
tickCount: 0,
}
}
componentDidMount() {
const secondsToWait = this.props.numSecondsToWait ?? 5
this.intervalID = setInterval(() => {
if (this.state.tickCount >= secondsToWait) {
if (this.intervalID) {
clearInterval(this.intervalID)
}
// Error boundaries don't catch async code :(
// So this only works by throwing inside of a setState
this.setState(() => {
throw new Error("This is an error for testing the error boundary")
})
} else {
this.setState({
tickCount: this.state.tickCount + 1,
})
}
}, 1000)
}
componentWillUnmount() {
if (this.intervalID) {
clearInterval(this.intervalID)
}
}
render() {
// Add a small visual indicator that this component will cause an error
return (
<div
style={{
position: "absolute",
top: 0,
right: 0,
background: "rgba(255, 0, 0, 0.5)",
color: "var(--vscode-errorForeground)",
padding: "2px 5px",
fontSize: "12px",
borderRadius: "0 0 0 4px",
zIndex: 100,
}}>
Error in {this.state.tickCount}/{this.props.numSecondsToWait ?? 5} seconds
</div>
)
}
}
export default ChatErrorBoundary
+2 -23
View File
@@ -25,6 +25,7 @@ import MarkdownBlock from "../common/MarkdownBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import McpResponseDisplay from "../mcp/McpResponseDisplay"
import CreditLimitError from "./CreditLimitError"
import { OptionsButtons } from "./OptionsButtons"
import { highlightMentions } from "./TaskHeader"
@@ -792,30 +793,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "api_req_finished":
return null // we should never see this message type
// case "mcp_server_response":
// return <McpResponseDisplay responseText={message.text || ""} />
case "mcp_server_response":
return (
<>
<div style={{ paddingTop: 0 }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Response
</div>
<CodeAccordian
code={message.text}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
</>
)
return <McpResponseDisplay responseText={message.text || ""} />
case "text":
return (
<div>
@@ -0,0 +1,336 @@
import React, { useEffect, useRef } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./McpRichUtil"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
interface ImagePreviewProps {
url: string
}
// Use a class component to ensure complete isolation between instances
class ImagePreview extends React.Component<
ImagePreviewProps,
{
loading: boolean
error: string | null
fetchStartTime: number
}
> {
private imgRef = React.createRef<HTMLImageElement>()
private timeoutId: NodeJS.Timeout | null = null
private heartbeatId: NodeJS.Timeout | null = null
constructor(props: ImagePreviewProps) {
super(props)
this.state = {
loading: true,
error: null,
fetchStartTime: Date.now(),
}
}
// Track aspect ratio for proper display
private aspectRatio: number = 1
componentDidMount() {
// Set up a timeout to handle cases where the image never loads or errors
this.timeoutId = setTimeout(() => {
console.log(`Image load timeout for ${this.props.url}`)
if (this.state.loading) {
this.setState({
loading: false,
error: `Timeout loading image: ${this.props.url}`,
})
}
}, 15000)
// Set up a heartbeat to update the UI with elapsed time
this.heartbeatId = setInterval(() => {
if (this.state.loading) {
this.forceUpdate() // Just update the component to show new elapsed time
}
}, 1000)
// First, check the content type to verify it's actually an image
this.checkContentType(this.props.url)
}
// Check if the URL is an image using content type verification
checkContentType(url: string) {
// Always verify content type, even for URLs that look like images by extension
checkIfImageUrl(url)
.then((isImage) => {
if (isImage) {
console.log(`URL is confirmed as image: ${url}`)
this.loadImage(url)
} else {
console.log(`URL is not an image: ${url}`)
this.handleImageError()
}
})
.catch((error) => {
console.log(`Error checking if URL is an image: ${error}`)
// Don't fallback to direct image loading on error
// Instead, report the error so the URL can be handled as a non-image
this.handleImageError()
})
}
// Load the image after content type check or as fallback
loadImage(url: string) {
const isSvg = /\.svg(\?.*)?$/i.test(url)
// For SVG files, we don't need to calculate aspect ratio as they're vector-based
if (isSvg) {
console.log(`SVG image detected, skipping aspect ratio calculation: ${url}`)
// Default aspect ratio for SVGs
this.aspectRatio = 1
this.handleImageLoad()
return
}
// Create a test image to check if the URL loads and get dimensions
const testImg = new Image()
testImg.onload = () => {
console.log(`Test image loaded successfully: ${url}`)
// Calculate aspect ratio for proper display
if (testImg.width > 0 && testImg.height > 0) {
this.aspectRatio = testImg.width / testImg.height
}
this.handleImageLoad()
}
testImg.onerror = () => {
console.log(`Test image failed to load: ${url}`)
this.handleImageError()
}
// Force CORS mode to be anonymous to avoid CORS issues
testImg.crossOrigin = "anonymous"
}
componentWillUnmount() {
this.cleanup()
}
private cleanup() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
if (this.heartbeatId) {
clearInterval(this.heartbeatId)
this.heartbeatId = null
}
}
// Handle image load event
handleImageLoad = () => {
console.log(`Image loaded successfully: ${this.props.url}`)
this.setState({ loading: false })
this.cleanup()
}
// Handle image error event
handleImageError = () => {
console.log(`Image failed to load: ${this.props.url}`)
this.setState({
loading: false,
error: `Failed to load image: ${this.props.url}`,
})
this.cleanup()
}
render() {
const { url } = this.props
const { loading, error, fetchStartTime } = this.state
// Calculate elapsed time for loading state
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
// Fallback display while loading
if (loading) {
return (
<div
className="image-preview-loading"
style={{
padding: "12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
height: "128px",
maxWidth: "512px",
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
<div
className="loading-spinner"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading image from {getSafeHostname(url)}...
</div>
{elapsedSeconds > 3 && (
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
{elapsedSeconds > 60
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
: `Waiting for ${elapsedSeconds}s...`}
</div>
)}
{/* Hidden image that we'll use to detect load/error events */}
{/\.svg(\?.*)?$/i.test(url) ? (
<object
type="image/svg+xml"
data={DOMPurify.sanitize(url)}
style={{ display: "none" }}
onLoad={this.handleImageLoad}
onError={this.handleImageError}
/>
) : (
<img
src={DOMPurify.sanitize(url)}
alt=""
ref={this.imgRef}
onLoad={this.handleImageLoad}
onError={this.handleImageError}
style={{ display: "none" }}
/>
)}
</div>
)
}
// Handle error state
if (error) {
return (
<div
className="image-preview-error"
style={{
padding: "12px",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
<div style={{ fontWeight: "bold" }}>Failed to load image</div>
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
Click to open in browser
</div>
</div>
)
}
// Render the image
return (
<div
className="image-preview"
style={{
margin: "10px 0",
maxWidth: "100%",
cursor: "pointer",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(formatUrlForOpening(url)),
})
}}>
{/\.svg(\?.*)?$/i.test(url) ? (
// Special handling for SVG images
<object
type="image/svg+xml"
data={DOMPurify.sanitize(url)}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
}}
aria-label={`SVG from ${getSafeHostname(url)}`}>
{/* Fallback if object tag fails */}
<img
src={DOMPurify.sanitize(url)}
alt={`SVG from ${getSafeHostname(url)}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
}}
/>
</object>
) : (
<img
src={DOMPurify.sanitize(url)}
alt={`Image from ${getSafeHostname(url)}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
// Use contain only for very extreme aspect ratios, otherwise use cover
objectFit: this.aspectRatio > 3 || this.aspectRatio < 0.33 ? "contain" : "cover",
}}
loading="eager"
onLoad={(e) => {
// Double-check aspect ratio from the actual loaded image
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
const newAspectRatio = img.naturalWidth / img.naturalHeight
// Update object-fit based on actual aspect ratio
// Use contain only for very extreme aspect ratios, otherwise use cover
if (newAspectRatio > 3 || newAspectRatio < 0.33) {
img.style.objectFit = "contain"
} else {
img.style.objectFit = "cover"
}
}
}}
/>
)}
</div>
)
}
}
// Create a wrapper component that memoizes the ImagePreview to prevent unnecessary re-renders
const MemoizedImagePreview = React.memo(
(props: ImagePreviewProps) => <ImagePreview {...props} />,
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
)
// Wrap the ImagePreview component with an error boundary
const ImagePreviewWithErrorBoundary: React.FC<ImagePreviewProps> = (props) => {
return (
<ChatErrorBoundary errorTitle="Something went wrong displaying this image">
<MemoizedImagePreview {...props} />
</ChatErrorBoundary>
)
}
export default ImagePreviewWithErrorBoundary
+349 -145
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import { getSafeHostname, normalizeRelativeUrl } from "./McpRichUtil"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
interface OpenGraphData {
title?: string
@@ -15,174 +17,376 @@ interface LinkPreviewProps {
url: string
}
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
// Error types for better UI feedback
type ErrorType = "timeout" | "network" | "general" | null
useEffect(() => {
const fetchOpenGraphData = async () => {
try {
setLoading(true)
// Use a class component to ensure complete isolation between instances
class LinkPreview extends React.Component<
LinkPreviewProps,
{
loading: boolean
error: ErrorType
errorMessage: string | null
ogData: OpenGraphData | null
hasCompletedFetch: boolean // Track if fetch has completed (success or error)
fetchStartTime: number // Track when the fetch started
}
> {
private messageListener: ((event: MessageEvent) => void) | null = null
private timeoutId: NodeJS.Timeout | null = null
private heartbeatId: NodeJS.Timeout | null = null
// Send a message to the extension to fetch Open Graph data
vscode.postMessage({
type: "fetchOpenGraphData",
text: url,
})
constructor(props: LinkPreviewProps) {
super(props)
this.state = {
loading: true,
error: null,
errorMessage: null,
ogData: null,
hasCompletedFetch: false,
fetchStartTime: 0,
}
}
// Set up a listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "openGraphData" && message.url === url) {
setOgData(message.openGraphData)
setLoading(false)
window.removeEventListener("message", messageListener)
}
}
componentDidMount() {
// Only fetch if we haven't completed a fetch yet
if (!this.state.hasCompletedFetch) {
this.fetchOpenGraphData()
}
}
window.addEventListener("message", messageListener)
componentWillUnmount() {
this.cleanup()
}
// Clean up the listener if the component unmounts
return () => {
window.removeEventListener("message", messageListener)
}
} catch (err) {
setError("Failed to fetch preview data")
setLoading(false)
}
// Prevent updates if fetch has completed
shouldComponentUpdate(nextProps: LinkPreviewProps, nextState: any) {
// If URL changes, allow update
if (nextProps.url !== this.props.url) {
return true
}
// Fetch Open Graph data immediately when component mounts
fetchOpenGraphData()
}, [url])
// If we've completed a fetch and state hasn't changed, prevent update
if (
this.state.hasCompletedFetch &&
this.state.loading === nextState.loading &&
this.state.error === nextState.error &&
this.state.ogData === nextState.ogData
) {
return false
}
// Fallback display while loading
if (loading) {
return true
}
private cleanup() {
// Clean up event listeners and timeouts
if (this.messageListener) {
window.removeEventListener("message", this.messageListener)
this.messageListener = null
}
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
if (this.heartbeatId) {
clearInterval(this.heartbeatId)
this.heartbeatId = null
}
}
private fetchOpenGraphData() {
try {
// Record fetch start time
const startTime = Date.now()
this.setState({ fetchStartTime: startTime })
// Send a message to the extension to fetch Open Graph data
vscode.postMessage({
type: "fetchOpenGraphData",
text: this.props.url,
})
// Set up a listener for the response
this.messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "openGraphData" && message.url === this.props.url) {
// Check if there was an error in the response
if (message.error) {
this.setState({
error: "network",
errorMessage: message.error,
loading: false,
hasCompletedFetch: true,
})
} else {
this.setState({
ogData: message.openGraphData,
loading: false,
hasCompletedFetch: true, // Mark as completed
})
}
this.cleanup()
}
}
window.addEventListener("message", this.messageListener)
// Instead of a fixed timeout, use a heartbeat to update the loading message
// with the elapsed time, but don't actually timeout
this.heartbeatId = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
if (elapsedSeconds > 0) {
this.forceUpdate() // Just update the component to show new elapsed time
}
}, 1000)
} catch (err) {
this.setState({
error: "general",
errorMessage: err instanceof Error ? err.message : "Unknown error occurred",
loading: false,
hasCompletedFetch: true, // Mark as completed on error
})
this.cleanup()
}
}
render() {
const { url } = this.props
const { loading, error, errorMessage, ogData, fetchStartTime } = this.state
// Calculate elapsed time for loading state
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
// Fallback display while loading
if (loading) {
return (
<div
className="link-preview-loading"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
height: "128px",
maxWidth: "512px",
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
<div
className="loading-spinner"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading preview for {getSafeHostname(url)}...
</div>
{elapsedSeconds > 5 && (
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
{elapsedSeconds > 60
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
: `Waiting for ${elapsedSeconds}s...`}
</div>
)}
</div>
)
}
// Handle different error states with specific messages
if (error) {
let errorDisplay = "Unable to load preview"
if (error === "timeout") {
errorDisplay = "Preview request timed out"
} else if (error === "network") {
errorDisplay = "Network error loading preview"
}
return (
<div
className="link-preview-error"
style={{
padding: "12px",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
height: "128px",
maxWidth: "512px",
overflow: "auto",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
<div style={{ fontWeight: "bold" }}>{errorDisplay}</div>
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
{errorMessage && <div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.8 }}>{errorMessage}</div>}
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
Click to open in browser
</div>
</div>
)
}
// Create a fallback object if ogData is null
const data = ogData || {
title: getSafeHostname(url),
description: "No description available",
siteName: getSafeHostname(url),
url: url,
}
// Render the Open Graph preview
return (
<div
className="link-preview-loading"
className="link-preview"
style={{
padding: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
overflow: "hidden",
cursor: "pointer",
height: "128px",
maxWidth: "512px",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
{data.image && (
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
<img
src={DOMPurify.sanitize(normalizeRelativeUrl(data.image, url))}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "contain", // Use contain for link preview thumbnails to handle logos
objectPosition: "center", // Center the image
}}
onLoad={(e) => {
// Check aspect ratio to determine if we should use contain or cover
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
const aspectRatio = img.naturalWidth / img.naturalHeight
// Use contain for extreme aspect ratios (logos), cover for photos
if (aspectRatio > 2.5 || aspectRatio < 0.4) {
img.style.objectFit = "contain"
} else {
img.style.objectFit = "cover"
}
}
}}
onError={(e) => {
console.log(`Image could not be loaded: ${data.image}`)
// Hide the broken image
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
<div
className="loading-spinner"
className="link-preview-content"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading preview for {new URL(url).hostname}...
flex: 1,
padding: "12px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
height: "100%", // Ensure full height
}}>
{/* Top section with title and URL - top aligned */}
<div className="link-preview-top">
<div
className="link-preview-title"
style={{
fontWeight: "bold",
marginBottom: "4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.title || "No title"}
</div>
<div
className="link-preview-url"
style={{
fontSize: "12px",
color: "var(--vscode-textLink-foreground, #3794ff)",
marginBottom: "8px", // Increased for better separation
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.siteName || getSafeHostname(url)}
</div>
</div>
{/* Description with space-around in the remaining space */}
<div
className="link-preview-description-container"
style={{
flex: 1, // Take up remaining space
display: "flex",
flexDirection: "column",
justifyContent: "space-around", // Space around in the remaining area
}}>
<div
className="link-preview-description"
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
}}>
{data.description || "No description available"}
</div>
</div>
</div>
</div>
)
}
}
// Create a fallback object if ogData is null
const data = ogData || {
title: new URL(url).hostname,
description: "No description available",
siteName: new URL(url).hostname,
url: url,
}
// Create a wrapper component that memoizes the LinkPreview to prevent unnecessary re-renders
const MemoizedLinkPreview = React.memo(
(props: LinkPreviewProps) => <LinkPreview {...props} />,
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
)
// Render the Open Graph preview
// Wrap the LinkPreview component with an error boundary
const LinkPreviewWithErrorBoundary: React.FC<LinkPreviewProps> = (props) => {
return (
<div
className="link-preview"
style={{
display: "flex",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
overflow: "hidden",
cursor: "pointer",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
{data.image && (
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
<img
src={DOMPurify.sanitize(data.image)}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
<div
className="link-preview-content"
style={{
flex: 1,
padding: "12px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
className="link-preview-title"
style={{
fontWeight: "bold",
marginBottom: "4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.title || "No title"}
</div>
<div
className="link-preview-url"
style={{
fontSize: "12px",
color: "var(--vscode-textLink-foreground, #3794ff)",
marginBottom: "8px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.siteName || new URL(url).hostname}
</div>
<div
className="link-preview-description"
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
}}>
{data.description || "No description available"}
</div>
</div>
</div>
<ChatErrorBoundary errorTitle="Something went wrong displaying this link preview">
<MemoizedLinkPreview {...props} />
</ChatErrorBoundary>
)
}
export default LinkPreview
export default LinkPreviewWithErrorBoundary
@@ -1,180 +1,23 @@
import React, { useEffect, useState, useCallback } from "react"
import { vscode } from "../../utils/vscode"
import LinkPreview from "./LinkPreview"
import ImagePreview from "./ImagePreview"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import DOMPurify from "dompurify"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
import {
safeCreateUrl,
isUrl,
getSafeHostname,
isLocalhostUrl,
normalizeRelativeUrl,
formatUrlForOpening,
checkIfImageUrl,
} from "./McpRichUtil"
// We'll use the backend isImageUrl function for HEAD requests
// This is a client-side fallback for data URLs and obvious image extensions
const isImageUrlSync = (str: string): boolean => {
// Check for data URLs which are definitely images
if (str.startsWith("data:image/")) {
return true
}
// Check for common image file extensions
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
}
export const isUrl = (str: string): boolean => {
// Basic URL validation
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
return urlPattern.test(str)
}
// Function to check if a URL is an image using HEAD request
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
// For data URLs, we can check synchronously
if (url.startsWith("data:image/")) {
return true
}
// For http/https URLs, we need to send a message to the extension
if (url.startsWith("http")) {
try {
// Create a promise that will resolve when we get a response
return new Promise((resolve) => {
// Set up a one-time listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "isImageUrlResult" && message.url === url) {
window.removeEventListener("message", messageListener)
resolve(message.isImage)
}
}
window.addEventListener("message", messageListener)
// Send the request to the extension
vscode.postMessage({
type: "checkIsImageUrl",
text: url,
})
// Set a timeout to avoid hanging indefinitely
setTimeout(() => {
window.removeEventListener("message", messageListener)
// Fall back to extension check
resolve(isImageUrlSync(url))
}, 3000)
})
} catch (error) {
console.error("Error checking if URL is an image:", error)
return isImageUrlSync(url)
}
}
// Fall back to extension check for other URLs
return isImageUrlSync(url)
}
// No longer needed as our regex directly extracts the URL part
// Helper to ensure URL is in a format that can be opened
export const formatUrlForOpening = (url: string): string => {
// If it's a data URI, return as is
if (url.startsWith("data:image/")) {
return url
}
// If it's a regular URL but doesn't have a protocol, add https://
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return `https://${url}`
}
return url
}
// Find all URLs (both image and regular) in an object
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
const imageUrls: string[] = []
const regularUrls: string[] = []
const pendingChecks: Promise<void>[] = []
if (typeof obj === "object" && obj !== null) {
for (const value of Object.values(obj)) {
if (typeof value === "string") {
// First check with synchronous method
if (isImageUrlSync(value)) {
imageUrls.push(value)
} else if (isUrl(value)) {
// For URLs that don't obviously look like images, we'll check asynchronously
const checkPromise = checkIfImageUrl(value).then((isImage) => {
if (isImage) {
imageUrls.push(value)
} else {
regularUrls.push(value)
}
})
pendingChecks.push(checkPromise)
}
} else if (typeof value === "object") {
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
imageUrls.push(...nestedUrls.imageUrls)
regularUrls.push(...nestedUrls.regularUrls)
})
pendingChecks.push(nestedUrlsPromise)
}
}
}
// Wait for all async checks to complete
await Promise.all(pendingChecks)
return { imageUrls, regularUrls }
}
// Extract URLs from text using regex
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
const imageUrls: string[] = []
const regularUrls: string[] = []
const pendingChecks: Promise<void>[] = []
// Match URLs with image: prefix and extract just the URL part
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
if (imageMatches) {
// Extract just the URL part from matches with image: prefix
const extractedUrls = imageMatches
.map((match) => {
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
return urlMatch ? urlMatch[1] : null
})
.filter(Boolean) as string[]
imageUrls.push(...extractedUrls)
}
// Match all URLs (including those that might be in the middle of paragraphs)
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
if (urlMatches) {
// Filter out URLs that are already in imageUrls
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
// Check each URL to see if it's an image
for (const url of filteredUrls) {
// First check with synchronous method
if (isImageUrlSync(url)) {
imageUrls.push(url)
} else {
// For URLs that don't obviously look like images, we'll check asynchronously
const checkPromise = checkIfImageUrl(url).then((isImage) => {
if (isImage) {
imageUrls.push(url)
} else {
regularUrls.push(url)
}
})
pendingChecks.push(checkPromise)
}
}
}
// Wait for all async checks to complete
await Promise.all(pendingChecks)
return { imageUrls, regularUrls }
}
// Maximum number of URLs to process in total, per response
export const MAX_URLS = 50
const ResponseHeader = styled.div`
display: flex;
@@ -271,7 +114,7 @@ interface McpResponseDisplayProps {
// Represents a URL found in the text with its position and metadata
interface UrlMatch {
url: string // The actual URL
fullMatch: string // The full matched text (including any prefix like "image:")
fullMatch: string // The full matched text
index: number // Position in the text
isImage: boolean // Whether this URL is an image
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
@@ -282,59 +125,159 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
// Get saved preference from localStorage, default to 'rich'
const savedMode = localStorage.getItem("mcpDisplayMode")
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
return savedMode === "plain" ? "plain" : "rich"
})
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
const [error, setError] = useState<string | null>(null)
// Add a counter state for forcing re-renders to make toggling run smoother
const [forceUpdateCounter, setForceUpdateCounter] = useState(0)
const toggleDisplayMode = useCallback(() => {
const newMode = displayMode === "rich" ? "plain" : "rich"
// Force an immediate re-render
setForceUpdateCounter((prev) => prev + 1)
// Update display mode and save preference
setDisplayMode(newMode)
localStorage.setItem("mcpDisplayMode", newMode)
// If switching to plain mode, cancel any ongoing processing
if (newMode === "plain") {
console.log("Switching to plain mode - cancelling URL processing")
setUrlMatches([]) // Clear any existing matches when switching to plain mode
} else {
// If switching to rich mode, the useEffect will re-run and fetch data
console.log("Switching to rich mode - will start URL processing")
}
}, [displayMode])
// Find all URLs in the text and determine if they're images
useEffect(() => {
// Skip all processing if in plain mode
if (displayMode === "plain") {
setIsLoading(false)
setUrlMatches([]) // Clear any existing matches when in plain mode
return
}
// Use a direct boolean for cancellation that's scoped to this effect run
let processingCanceled = false
const processResponse = async () => {
console.log("Processing MCP response for URL extraction")
setIsLoading(true)
setError(null)
try {
const text = responseText || ""
const matches: UrlMatch[] = []
const urlRegex = /https?:\/\/[^\s]+/g
const urlRegex = /https?:\/\/[^\s<>"']+/g
let urlMatch: RegExpExecArray | null
let urlCount = 0
while ((urlMatch = urlRegex.exec(text)) !== null) {
// First pass: Extract all URLs and immediately make them available for rendering
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < MAX_URLS) {
// Get the original URL from the match - never modify the original URL text
const url = urlMatch[0]
const fullMatch = url
// Skip invalid URLs
if (!isUrl(url)) {
console.log("Skipping invalid URL:", url)
continue
}
// Skip localhost URLs to prevent security issues
if (isLocalhostUrl(url)) {
console.log("Skipping localhost URL:", url)
continue
}
matches.push({
url,
fullMatch,
fullMatch: url,
index: urlMatch.index,
isImage: false, // Will check later
isProcessed: false,
})
urlCount++
}
// Check if URLs are images
for (const match of matches) {
match.isImage = await checkIfImageUrl(match.url)
console.log(`Found ${matches.length} URLs in text, will check if they are images`)
// Set matches immediately so UI can start rendering with loading states
setUrlMatches(matches.sort((a, b) => a.index - b.index))
// Mark loading as complete to show content immediately
setIsLoading(false)
// Process image checks in the background - one at a time to avoid network flooding
const processImageChecks = async () => {
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
for (let i = 0; i < matches.length; i++) {
// Skip already processed URLs (from extension check)
if (matches[i].isProcessed) continue
// Check if processing has been canceled (switched to plain mode)
if (processingCanceled) {
console.log("URL processing canceled - display mode changed to plain")
return
}
const match = matches[i]
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
try {
// Process each URL individually
const isImage = await checkIfImageUrl(match.url)
// Skip if processing has been canceled
if (processingCanceled) return
// Update the match in place
match.isImage = isImage
match.isProcessed = true
// Update state after each URL to show progress
// Create a new array to ensure React detects the state change
setUrlMatches([...matches])
} catch (err) {
console.log(`URL check error: ${match.url}`, err)
match.isProcessed = true
// Update state even on error
if (!processingCanceled) {
setUrlMatches([...matches])
}
}
// Delay between URL processing to avoid overwhelming the network
if (!processingCanceled && i < matches.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
}
// Sort by position in the text
matches.sort((a, b) => a.index - b.index)
setUrlMatches(matches)
// Start the background processing
processImageChecks()
} catch (error) {
console.error("Error processing MCP response:", error)
} finally {
setError("Failed to process response content. Switch to plain text mode to view safely.")
setIsLoading(false)
}
}
processResponse()
}, [responseText])
// Cleanup function to cancel processing if component unmounts or dependencies change
return () => {
processingCanceled = true
console.log("Cleaning up URL processing")
}
}, [responseText, displayMode, forceUpdateCounter])
// Function to render content based on display mode
const renderContent = () => {
@@ -343,15 +286,26 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
return <UrlText>{responseText}</UrlText>
}
// Show error message if there was an error
if (error) {
return (
<>
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "10px" }}>{error}</div>
<UrlText>{responseText}</UrlText>
</>
)
}
// For rich display mode, show the text with embedded content
if (displayMode === "rich" && !isLoading) {
if (!isLoading) {
// We already know displayMode is "rich" if we get here
// Create an array of text segments and embedded content
const segments: JSX.Element[] = []
let lastIndex = 0
let segmentIndex = 0
// Reset the processed flag for all URLs
const processedUrls = new Set<string>()
// Track embed count for logging
let embedCount = 0
// Add the text before the first URL
if (urlMatches.length === 0) {
@@ -375,38 +329,51 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
const urlEndIndex = index + fullMatch.length
// Add embedded content after the URL
// For images, use the ImagePreview component
if (match.isImage) {
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<img
src={DOMPurify.sanitize(url)}
alt={`Image for ${url}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
cursor: "pointer",
}}
onClick={() => {
const formattedUrl = formatUrlForOpening(url)
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(formattedUrl),
})
}}
/>
</div>,
)
} else if (!processedUrls.has(url)) {
// For non-image URLs, only show the preview once
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<LinkPreview url={formatUrlForOpening(url)} />
<div key={`embed-image-${url}-${segmentIndex++}`}>
{/* Use formatUrlForOpening for network calls but preserve original URL in display */}
<ImagePreview url={formatUrlForOpening(url)} />
</div>,
)
embedCount++
// console.log(`Added image embed for ${url}, embed count: ${embedCount}`);
} else if (match.isProcessed) {
// For non-image URLs or URLs we haven't processed yet, show link preview
try {
// Skip localhost URLs
if (!isLocalhostUrl(url)) {
// Use a unique key that includes the URL to ensure each preview is isolated
segments.push(
<div key={`embed-${url}-${segmentIndex++}`} style={{ margin: "10px 0" }}>
{/* Already using formatUrlForOpening for link previews */}
<LinkPreview url={formatUrlForOpening(url)} />
</div>,
)
// Mark this URL as processed
processedUrls.add(url)
embedCount++
// console.log(`Added link preview for ${url}, embed count: ${embedCount}`);
}
} catch (e) {
console.log("Link preview could not be created")
// Show error message for failed link preview
segments.push(
<div
key={`embed-error-${segmentIndex++}`}
style={{
margin: "10px 0",
padding: "8px",
color: "var(--vscode-errorForeground)",
border: "1px solid var(--vscode-editorError-foreground)",
borderRadius: "4px",
height: "128px", // Fixed height
overflow: "auto", // Allow scrolling if content overflows
}}>
Failed to create preview for: {url}
</div>,
)
}
}
// Update lastIndex for next segment
@@ -442,7 +409,7 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
</ResponseContainer>
)
} catch (error) {
console.error("Error parsing MCP response:", error)
console.log("Error rendering MCP response - falling back to plain text")
return (
<ResponseContainer>
<ResponseHeader>
@@ -457,4 +424,13 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
}
}
export default McpResponseDisplay
// Wrap the entire McpResponseDisplay component with an error boundary
const McpResponseDisplayWithErrorBoundary: React.FC<McpResponseDisplayProps> = (props) => {
return (
<ChatErrorBoundary>
<McpResponseDisplay {...props} />
</ChatErrorBoundary>
)
}
export default McpResponseDisplayWithErrorBoundary
@@ -0,0 +1,183 @@
import { vscode } from "../../utils/vscode"
// Safely create a URL object with error handling and ensure HTTPS
export const safeCreateUrl = (url: string): URL | null => {
try {
// Convert HTTP to HTTPS for security
if (url.startsWith("http://")) {
url = url.replace("http://", "https://")
}
return new URL(url)
} catch (e) {
// If the URL doesn't have a protocol, add https://
if (!url.startsWith("https://")) {
try {
return new URL(`https://${url}`)
} catch (e) {
console.log(`Invalid URL: ${url}`)
return null
}
}
console.log(`Invalid URL: ${url}`)
return null
}
}
// Check if a string is a valid URL
export const isUrl = (str: string): boolean => {
return safeCreateUrl(str) !== null
}
// Get hostname safely
export const getSafeHostname = (url: string): string => {
try {
const urlObj = safeCreateUrl(url)
return urlObj ? urlObj.hostname : "unknown-host"
} catch (e) {
return "unknown-host"
}
}
// Check if a URL is a localhost URL by examining the hostname
export const isLocalhostUrl = (url: string): boolean => {
try {
const hostname = getSafeHostname(url)
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
hostname.endsWith(".local")
)
} catch (e) {
// If we can't parse the URL, assume it's not localhost
return false
}
}
// Function to normalize relative URLs by combining with a base URL
export const normalizeRelativeUrl = (relativeUrl: string, baseUrl: string): string => {
// If it's already an absolute URL or a data URL, return as is
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://") || relativeUrl.startsWith("data:")) {
return relativeUrl
}
try {
// Parse the base URL
const baseUrlObj = safeCreateUrl(baseUrl)
if (!baseUrlObj) {
return relativeUrl // If we can't parse the base URL, return original
}
// Handle different types of relative paths
if (relativeUrl.startsWith("//")) {
// Protocol-relative URL
return `${baseUrlObj.protocol}${relativeUrl}`
} else if (relativeUrl.startsWith("/")) {
// Root-relative URL
return `${baseUrlObj.protocol}//${baseUrlObj.host}${relativeUrl}`
} else {
// Path-relative URL
// Get the directory part of the URL
let basePath = baseUrlObj.pathname
if (!basePath.endsWith("/")) {
// If the path doesn't end with a slash, remove the file part
basePath = basePath.substring(0, basePath.lastIndexOf("/") + 1)
}
return `${baseUrlObj.protocol}//${baseUrlObj.host}${basePath}${relativeUrl}`
}
} catch (error) {
console.log(`Error normalizing relative URL: ${error}`)
return relativeUrl // Return original on error
}
}
// Helper to ensure URL is in a format that can be opened
export const formatUrlForOpening = (url: string): string => {
// If it's a data URI, return as is
if (url.startsWith("data:image/")) {
return url
}
// Use safeCreateUrl to validate and format the URL
const urlObj = safeCreateUrl(url)
if (urlObj) {
return urlObj.href
}
console.log(`Invalid URL format: ${url}`)
// Return a safe fallback that won't crash
return "about:blank"
}
// Function to check if a URL is an image using HEAD request
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
// For data URLs, we can check synchronously
if (url.startsWith("data:image/")) {
return true
}
// Create a secure URL for the check but don't modify the original URL
let secureUrl = url
// Convert HTTP to HTTPS for security in the network request only
if (secureUrl.startsWith("http://")) {
secureUrl = secureUrl.replace("http://", "https://")
console.log(`Using HTTPS version for image check: ${secureUrl}`)
}
// Validate URL before proceeding
if (!isUrl(url)) {
console.log("Invalid URL format:", url)
return false
}
// For https URLs, we need to send a message to the extension
if (url.startsWith("https")) {
try {
// Create a promise that will resolve when we get a response
return new Promise((resolve) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
// Set up a one-time listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "isImageUrlResult" && message.url === url) {
window.removeEventListener("message", messageListener)
resolve(message.isImage)
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
window.addEventListener("message", messageListener)
// Send the request to the extension
vscode.postMessage({
type: "checkIsImageUrl",
text: url,
})
// Set a timeout to avoid hanging indefinitely
timeoutId = setTimeout(() => {
window.removeEventListener("message", messageListener)
console.log("Hit timeout waiting for checkIsImageUrl")
resolve(false)
}, 3000)
})
} catch (error) {
console.log("Error checking if URL is an image:", url)
// Don't fall back to extension check on error
// Instead, return false to indicate it's not an image
return false
}
}
// Don't fall back to extension check for other URLs
// Only data URLs (handled above) are guaranteed to be images
// For all other URLs, we need proper content type verification
console.log(`URL protocol not supported for image check: ${url}`)
return false
}
@@ -0,0 +1,683 @@
# How To Test Rich MCP Responses
Use the `echo` MCP server to read back one of the test cases below into an MCP response.
https://github.com/Garoth/echo-mcp
Manually check the embeds, images, and whatever other enhancements for proper rendering.
Remember that toggling Rich MCP off should cancel pending fetches. If the toggle was
set to Plain, then the image/link previews should never be fetched until it's enabled.
Remember that rich display mode will only load the first n URLs, currently set to 50
## Main Test Case
Working Image URLs
jpg: https://yavuzceliker.github.io/sample-images/image-205.jpg
webp: https://seenandheard.app/assets/img/face-2.webp
svg: https://seenandheard.app/assets/img/logo-white.svg
Looks like Image URL but is website
site: https://github.com/google/pprof/blob/main/doc/images/webui/flame-multi.png
raw png: https://raw.githubusercontent.com/google/pprof/refs/heads/main/doc/images/webui/flame-multi.png
Gif:
https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/01_Das_Sandberg-Modell.gif/750px-01_Das_Sandberg-Modell.gif
Normal Working URLs for OG Embeds
https://www.google.com
https://www.blogger.com
https://youtube.com
https://linkedin.com
https://support.google.com
https://cloudflare.com
https://microsoft.com
https://apple.com
https://en.wikipedia.org
https://play.google.com
https://wordpress.org
Attack URLs & Unsupported Formats
data:text/html,<h1>Hello World</h1>
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
javascript:alert('XSS')
mailto:user@example.com
tel:+1-234-567-8901
sms:+1-234-567-8901?body=Hello
https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
Broken & Weird Edge Cases
https://tectum.io/blog/dex-tools/
http://0.0.0.0:8025/img.png
https://localhost:8080/img.jpg
http://localhost:8080/
https://localhost/
http://httpbin.org/#/
https://snthonstcrgrfonhenth.com/nthshtf
http://domain/.well-known/acme-challenge/token
https://<strong>dextools</strong>.apiable.io/(Only
## Generated Links Test Case
1. https://www.google.com
2. http://example.com/path/to/resource?query=value#fragment
3. https://images.unsplash.com/photo-1575936123452-b67c3203c357
4. file:///home/user/document.txt
5. https://user:password@example.com:8080/path
6. http://192.168.1.1:8080
7. https://www.example.com/path with spaces/file.html
8. ftp://ftp.example.com/pub/file.zip
9. https://www.example.com/index.php?id=1&name=test
10. https://subdomain.example.co.uk/path
11. https://www.example.com/path/to/image.jpg
12. https://www.example.com:8443/secure
13. http://localhost:3000
14. https://www.example.com/path/to/file.pdf#page=10
15. https://www.example.com/search?q=query+with+spaces
16. https://www.example.com/path/to/file.html#section-2
17. https://www.example.com/path/to/file.php?id=123&action=view
18. https://www.example.com/path/to/file.html?param1=value1&param2=value2#fragment
19. https://www.example.com/path/to/file.html?param=value with spaces
20. https://www.example.com/path/to/file.html?param=value%20with%20encoded%20spaces
21. https://www.example.com/path/to/file.html?param=value+with+plus+signs
22. https://www.example.com/path/to/file.html?param=special@characters!
23. https://www.example.com/path/to/file.html?param=special%40characters%21
24. https://www.example.com/path/to/file.html?param=value&param=duplicate
25. https://www.example.com/path/to/file.html?param=
26. https://www.example.com/path/to/file.html?=value
27. https://www.example.com/path/to/file.html?
28. https://www.example.com/path/to/file.html#
29. https://www.example.com/path/to/file.html#fragment1#fragment2
30. https://www.example.com/path/to/file.html?param1=value1#fragment?param2=value2
31. https://www.example.com/index.html#!hashbang
32. https://www.example.com/path/to/file.html?param=value#fragment=value
33. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment
34. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment=value
35. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment?param3=value3
36. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment&param3=value3
37. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment#fragment2
38. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment/path
39. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment?param3=value3&param4=value4
40. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment&param3=value3&param4=value4
41. data:text/html,<h1>Hello World</h1>
42. data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
43. javascript:alert('XSS')
44. mailto:user@example.com
45. tel:+1-234-567-8901
46. sms:+1-234-567-8901?body=Hello
47. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
48. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
49. https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
50. https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
51. https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
52. https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
53. https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
54. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
55. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
56. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
57. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
58. https://www.example.com/path/to/file.html?param=<body onload="alert('XSS')">
59. https://www.example.com/path/to/file.html?param=<input autofocus onfocus="alert('XSS')">
60. https://www.example.com/path/to/file.html?param=<video src="x" onerror="alert('XSS')">
61. https://www.example.com/path/to/file.html?param=<audio src="x" onerror="alert('XSS')">
62. https://www.example.com/path/to/file.html?param=<iframe srcdoc="<script>alert('XSS')</script>">
63. https://www.example.com/path/to/file.html?param=<math><maction actiontype="statusline#" xlink:href="javascript:alert('XSS')">Click
64. https://www.example.com/path/to/file.html?param=<form action="javascript:alert('XSS')"><input type="submit">
65. https://www.example.com/path/to/file.html?param=<isindex action="javascript:alert('XSS')" type="image">
66. https://www.example.com/path/to/file.html?param=<object data="javascript:alert('XSS')">
67. https://www.example.com/path/to/file.html?param=<embed src="javascript:alert('XSS')">
68. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
69. https://www.example.com/path/to/file.html?param=<marquee onstart="alert('XSS')">
70. https://www.example.com/path/to/file.html?param=<div style="background-image: url(javascript:alert('XSS'))">
71. https://www.example.com/path/to/file.html?param=<link rel="stylesheet" href="javascript:alert('XSS')">
72. https://www.example.com/path/to/file.html?param=<table background="javascript:alert('XSS')">
73. https://www.example.com/path/to/file.html?param=<div style="width: expression(alert('XSS'))">
74. https://www.example.com/path/to/file.html?param=<style>@import "javascript:alert('XSS')";</style>
75. https://www.example.com/path/to/file.html?param=<meta http-equiv="refresh" content="0;url=javascript:alert('XSS')">
76. https://www.example.com/path/to/file.html?param=<iframe src="data:text/html,<script>alert('XSS')</script>">
77. https://www.example.com/path/to/file.html?param=<svg><set attributeName="onload" to="alert('XSS')" />
78. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
79. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
80. https://www.example.com/path/to/file.html?param=<svg><animate xlink:href="#xss" attributeName="href" values="javascript:alert('XSS')" />
81. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" />
82. https://www.example.com/path/to/file.html?param=<svg><a xlink:href="javascript:alert('XSS')"><text x="20" y="20">XSS</text></a>
83. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" /><text x="20" y="20">XSS</text></a>
84. https://www.example.com/path/to/file.html?param=<svg><discard onbegin="alert('XSS')" />
85. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
86. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
87. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
88. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
89. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
90. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
91. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
92. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
93. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
94. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
95. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
96. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
97. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
98. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
99. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
100. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
101. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
102. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
103. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
104. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
105. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
106. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
107. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
108. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
## Popular URLs by Popularity Test Case
1. https://www.google.com
2. https://www.blogger.com
3. https://youtube.com
4. https://linkedin.com
5. https://support.google.com
6. https://cloudflare.com
7. https://microsoft.com
8. https://apple.com
9. https://en.wikipedia.org
10. https://play.google.com
11. https://wordpress.org
12. https://docs.google.com
13. https://mozilla.org
14. https://maps.google.com
15. https://youtu.be
16. https://drive.google.com
17. https://bp.blogspot.com
18. https://sites.google.com
19. https://googleusercontent.com
20. https://accounts.google.com
21. https://t.me
22. https://europa.eu
23. https://plus.google.com
24. https://whatsapp.com
25. https://adobe.com
26. https://facebook.com
27. https://policies.google.com
28. https://uol.com.br
29. https://istockphoto.com
30. https://vimeo.com
31. https://vk.com
32. https://github.com
33. https://amazon.com
34. https://search.google.com
35. https://bbc.co.uk
36. https://google.de
37. https://live.com
38. https://gravatar.com
39. https://nih.gov
40. https://dan.com
41. https://files.wordpress.com
42. https://www.yahoo.com
43. https://cnn.com
44. https://dropbox.com
45. https://wikimedia.org
46. https://creativecommons.org
47. https://google.com.br
48. https://line.me
49. https://googleblog.com
50. https://opera.com
51. https://es.wikipedia.org
52. https://globo.com
53. https://brandbucket.com
54. https://myspace.com
55. https://slideshare.net
56. https://paypal.com
57. https://tiktok.com
58. https://netvibes.com
59. https://theguardian.com
60. https://who.int
61. https://goo.gl
62. https://medium.com
63. https://tools.google.com
64. https://draft.blogger.com
65. https://pt.wikipedia.org
66. https://fr.wikipedia.org
67. https://www.weebly.com
68. https://news.google.com
69. https://developers.google.com
70. https://w3.org
71. https://mail.google.com
72. https://gstatic.com
73. https://jimdofree.com
74. https://cpanel.net
75. https://imdb.com
76. https://wa.me
77. https://feedburner.com
78. https://enable-javascript.com
79. https://nytimes.com
80. https://workspace.google.com
81. https://ok.ru
82. https://google.es
83. https://dailymotion.com
84. https://afternic.com
85. https://bloomberg.com
86. https://amazon.de
87. https://photos.google.com
88. https://wiley.com
89. https://aliexpress.com
90. https://indiatimes.com
91. https://youronlinechoices.com
92. https://elpais.com
93. https://tinyurl.com
94. https://yadi.sk
95. https://spotify.com
96. https://huffpost.com
97. https://ru.wikipedia.org
98. https://google.fr
99. https://webmd.com
100. https://samsung.com
101. https://independent.co.uk
102. https://amazon.co.jp
103. https://get.google.com
104. https://amazon.co.uk
105. https://4shared.com
106. https://telegram.me
107. https://planalto.gov.br
108. https://businessinsider.com
109. https://ig.com.br
110. https://issuu.com
111. https://www.gov.br
112. https://wsj.com
113. https://hugedomains.com
114. https://picasaweb.google.com
115. https://usatoday.com
116. https://scribd.com
117. https://www.gov.uk
118. https://storage.googleapis.com
119. https://huffingtonpost.com
120. https://bbc.com
121. https://estadao.com.br
122. https://nature.com
123. https://mediafire.com
124. https://washingtonpost.com
125. https://forms.gle
126. https://namecheap.com
127. https://forbes.com
128. https://mirror.co.uk
129. https://soundcloud.com
130. https://fb.com
131. https://marketingplatform.google
132. https://domainmarket.com
133. https://ytimg.com
134. https://terra.com.br
135. https://google.co.uk
136. https://shutterstock.com
137. https://dailymail.co.uk
138. https://reg.ru
139. https://t.co
140. https://cdc.gov
141. https://thesun.co.uk
142. https://wp.com
143. https://cnet.com
144. https://instagram.com
145. https://researchgate.net
146. https://google.it
147. https://fandom.com
148. https://office.com
149. https://list-manage.com
150. https://msn.com
151. https://un.org
152. https://de.wikipedia.org
153. https://ovh.com
154. https://mail.ru
155. https://bing.com
156. https://news.yahoo.com
157. https://myaccount.google.com
158. https://hatena.ne.jp
159. https://shopify.com
160. https://adssettings.google.com
161. https://bit.ly
162. https://reuters.com
163. https://booking.com
164. https://discord.com
165. https://buydomains.com
166. https://nasa.gov
167. https://aboutads.info
168. https://time.com
169. https://abril.com.br
170. https://change.org
171. https://nginx.org
172. https://twitter.com
173. https://www.wikipedia.org
174. https://archive.org
175. https://cbsnews.com
176. https://networkadvertising.org
177. https://telegraph.co.uk
178. https://pinterest.com
179. https://google.co.jp
180. https://pixabay.com
181. https://zendesk.com
182. https://cpanel.com
183. https://vistaprint.com
184. https://sky.com
185. https://windows.net
186. https://alicdn.com
187. https://google.ca
188. https://lemonde.fr
189. https://newyorker.com
190. https://webnode.page
191. https://surveymonkey.com
192. https://translate.google.com
193. https://calendar.google.com
194. https://amazonaws.com
195. https://academia.edu
196. https://apache.org
197. https://imageshack.us
198. https://akamaihd.net
199. https://nginx.com
200. https://discord.gg
201. https://thetimes.co.uk
202. https://search.yahoo.com
203. https://amazon.fr
204. https://yelp.com
205. https://berkeley.edu
206. https://google.ru
207. https://sedoparking.com
208. https://cbc.ca
209. https://unesco.org
210. https://ggpht.com
211. https://privacyshield.gov
212. https://www.over-blog.com
213. https://clarin.com
214. https://www.wix.com
215. https://whitehouse.gov
216. https://icann.org
217. https://gnu.org
218. https://yandex.ru
219. https://francetvinfo.fr
220. https://gmail.com
221. https://mozilla.com
222. https://ziddu.com
223. https://guardian.co.uk
224. https://twitch.tv
225. https://sedo.com
226. https://foxnews.com
227. https://rambler.ru
228. https://books.google.com
229. https://stanford.edu
230. https://wikihow.com
231. https://it.wikipedia.org
232. https://20minutos.es
233. https://sfgate.com
234. https://liveinternet.ru
235. https://ja.wikipedia.org
236. https://000webhost.com
237. https://espn.com
238. https://eventbrite.com
239. https://disney.com
240. https://statista.com
241. https://addthis.com
242. https://pinterest.fr
243. https://lavanguardia.com
244. https://vkontakte.ru
245. https://doubleclick.net
246. https://bp2.blogger.com
247. https://skype.com
248. https://sciencedaily.com
249. https://bloglovin.com
250. https://insider.com
251. https://pl.wikipedia.org
252. https://sputniknews.com
253. https://id.wikipedia.org
254. https://doi.org
255. https://nypost.com
256. https://elmundo.es
257. https://abcnews.go.com
258. https://ipv4.google.com
259. https://deezer.com
260. https://express.co.uk
261. https://detik.com
262. https://mystrikingly.com
263. https://rakuten.co.jp
264. https://amzn.to
265. https://arxiv.org
266. https://alibaba.com
267. https://fb.me
268. https://wikia.com
269. https://t-online.de
270. https://telegra.ph
271. https://mega.nz
272. https://usnews.com
273. https://plos.org
274. https://naver.com
275. https://ibm.com
276. https://smh.com.au
277. https://dw.com
278. https://google.nl
279. https://lefigaro.fr
280. https://bp1.blogger.com
281. https://picasa.google.com
282. https://theatlantic.com
283. https://nydailynews.com
284. https://themeforest.net
285. https://rtve.es
286. https://newsweek.com
287. https://ovh.net
288. https://ca.gov
289. https://goodreads.com
290. https://economist.com
291. https://target.com
292. https://marca.com
293. https://kickstarter.com
294. https://hindustantimes.com
295. https://weibo.com
296. https://finance.yahoo.com
297. https://huawei.com
298. https://e-monsite.com
299. https://hubspot.com
300. https://npr.org
301. https://netflix.com
302. https://gizmodo.com
303. https://netlify.app
304. https://yandex.com
305. https://mashable.com
306. https://cnil.fr
307. https://latimes.com
308. https://steampowered.com
309. https://rt.com
310. https://photobucket.com
311. https://quora.com
312. https://nbcnews.com
313. https://android.com
314. https://instructables.com
315. https://www.canalblog.com
316. https://www.livejournal.com
317. https://ouest-france.fr
318. https://tripadvisor.com
319. https://ovhcloud.com
320. https://pexels.com
321. https://oracle.com
322. https://yahoo.co.jp
323. https://addtoany.com
324. https://sakura.ne.jp
325. https://cointernet.com.co
326. https://twimg.com
327. https://britannica.com
328. https://php.net
329. https://standard.co.uk
330. https://groups.google.com
331. https://cnbc.com
332. https://loc.gov
333. https://qq.com
334. https://buzzfeed.com
335. https://godaddy.com
336. https://ikea.com
337. https://disqus.com
338. https://taringa.net
339. https://ea.com
340. https://dropcatch.com
341. https://techcrunch.com
342. https://canva.com
343. https://offset.com
344. https://ebay.com
345. https://zoom.us
346. https://cambridge.org
347. https://unsplash.com
348. https://playstation.com
349. https://people.com
350. https://springer.com
351. https://psychologytoday.com
352. https://sendspace.com
353. https://home.pl
354. https://rapidshare.com
355. https://prezi.com
356. https://photos1.blogger.com
357. https://thenai.org
358. https://ftc.gov
359. https://google.pl
360. https://ted.com
361. https://secureserver.net
362. https://code.google.com
363. https://plesk.com
364. https://aol.com
365. https://biglobe.ne.jp
366. https://hp.com
367. https://canada.ca
368. https://linktr.ee
369. https://hollywoodreporter.com
370. https://ietf.org
371. https://clickbank.net
372. https://harvard.edu
373. https://amazon.es
374. https://oup.com
375. https://timeweb.ru
376. https://engadget.com
377. https://vice.com
378. https://cornell.edu
379. https://dreamstime.com
380. https://tmz.com
381. https://gofundme.com
382. https://pbs.org
383. https://stackoverflow.com
384. https://abc.net.au
385. https://sciencedirect.com
386. https://ft.com
387. https://variety.com
388. https://alexa.com
389. https://abc.es
390. https://walmart.com
391. https://gooyaabitemplates.com
392. https://redbull.com
393. https://ssl-images-amazon.com
394. https://theverge.com
395. https://spiegel.de
396. https://about.com
397. https://nationalgeographic.com
398. https://bandcamp.com
399. https://m.wikipedia.org
400. https://zippyshare.com
401. https://wired.com
402. https://freepik.com
403. https://outlook.com
404. https://mit.edu
405. https://sapo.pt
406. https://goo.ne.jp
407. https://java.com
408. https://google.co.th
409. https://scmp.com
410. https://mayoclinic.org
411. https://scholastic.com
412. https://nba.com
413. https://reverbnation.com
414. https://depositfiles.com
415. https://video.google.com
416. https://howstuffworks.com
417. https://cbslocal.com
418. https://merriam-webster.com
419. https://focus.de
420. https://admin.ch
421. https://gfycat.com
422. https://com.com
423. https://narod.ru
424. https://boston.com
425. https://sony.com
426. https://justjared.com
427. https://bitly.com
428. https://jstor.org
429. https://amebaownd.com
430. https://g.co
431. https://gsmarena.com
432. https://lexpress.fr
433. https://reddit.com
434. https://usgs.gov
435. https://bigcommerce.com
436. https://gettyimages.com
437. https://ign.com
438. https://justgiving.com
439. https://techradar.com
440. https://weather.com
441. https://amazon.ca
442. https://justice.gov
443. https://sciencemag.org
444. https://pcmag.com
445. https://theconversation.com
446. https://foursquare.com
447. https://flickr.com
448. https://giphy.com
449. https://tvtropes.org
450. https://fifa.com
451. https://upenn.edu
452. https://digg.com
453. https://bestfreecams.club
454. https://histats.com
455. https://salesforce.com
456. https://blog.google
457. https://apnews.com
458. https://theglobeandmail.com
459. https://m.me
460. https://europapress.es
461. https://washington.edu
462. https://thefreedictionary.com
463. https://jhu.edu
464. https://euronews.com
465. https://liberation.fr
466. https://ads.google.com
467. https://trustpilot.com
468. https://google.com.tw
469. https://softonic.com
470. https://kakao.com
471. https://storage.canalblog.com
472. https://interia.pl
473. https://metro.co.uk
474. https://viglink.com
475. https://last.fm
476. https://blackberry.com
477. https://public-api.wordpress.com
478. https://sina.com.cn
479. https://unicef.org
480. https://archives.gov
481. https://nps.gov
482. https://utexas.edu
483. https://biblegateway.com
484. https://usda.gov
485. https://indiegogo.com
486. https://nikkei.com
487. https://radiofrance.fr
488. https://repubblica.it
489. https://substack.com
490. https://ap.org
491. https://nicovideo.jp
492. https://joomla.org
493. https://news.com.au
494. https://allaboutcookies.org
495. https://mailchimp.com
496. https://stores.jp
497. https://intel.com
498. https://bp0.blogger.com
499. https://box.com
499. https://nhk.or.jp