Everything started when I decided to be more active on Bluesky and needed a way to integrate it more seamlessly with my website. I wanted to embed my posts directly into my blog posts, and have a share/comment button that would open the post on Bluesky.
Everthing was supposed to be simple, as the team has very detailed documentation at their main site. But because of the setup of this website and the perfectionist in me, it turned out a bit more complicated than I expected.
Aparently, Bluesky uses the oEmbed API format to allow embedding posts on other websites. I thought it would be a great opportunity to create a more general solution that could be used to embed content from other services as well. So I started with an Astro component for oEmbed. It worked great at the first try, but then I realized I could set the theme as well. And yikes, the headache began. As I wanted it to be perfect, aside from got the theme right at the first load of the page, I want the component to be able to change the theme on the fly, meaning reload when the theme changes.
We need a proxy endpoint?
So I had to change the component to Vue to make use of prevous setup regarding theme management. But Vue component are client-side code, and most of these oEmbed endpoints won’t allow the app to fetch data from them because of CORS policy. So I had to create a Cloudflare Worker to act as a proxy to fetch data from these endpoints. And here is how it looks like:
worker.js // Cache for providers list
let providersCache = null ;
let providersCacheTime = 0 ;
const CACHE_DURATION = 24 * 60 * 60 * 1000 ; // 24 hours
const ALLOWED_ORIGINS = [
// Add your allowed origins here
];
function isOriginAllowed ( request ) {
const origin = request.headers. get ( 'Origin' );
if ( ! origin) return false ;
return ALLOWED_ORIGINS . some ( allowed => origin. startsWith (allowed));
}
function corsHeaders ( request ) {
const origin = request.headers. get ( 'Origin' );
// Only return specific origin if it's allowed, otherwise no CORS headers
return isOriginAllowed (request) ? {
'Access-Control-Allow-Origin' : origin,
'Access-Control-Allow-Methods' : 'GET, OPTIONS' ,
'Access-Control-Allow-Headers' : 'Content-Type' ,
'Access-Control-Max-Age' : '86400' ,
} : {};
}
async function getProviders () {
// Return cached providers if available and not expired
if (providersCache && (Date. now () - providersCacheTime < CACHE_DURATION )) {
return providersCache;
}
try {
const response = await fetch ( 'https://oembed.com/providers.json' );
if ( ! response.ok) {
throw new Error ( `Failed to fetch providers: ${ response . status }` );
}
providersCache = await response. json ();
providersCacheTime = Date. now ();
return providersCache;
} catch (error) {
// If we have a cached version, use it even if expired
if (providersCache) {
return providersCache;
}
throw error;
}
}
function findProvider ( url , providers ) {
for ( const provider of providers) {
for ( const endpoint of provider.endpoints) {
for ( const scheme of endpoint.schemes || []) {
const pattern = new RegExp (
'^' + scheme. replace ( / \* / g , '.*' ). replace ( / \? / g , ' \\ ?' ) + '$'
);
if (pattern. test (url)) {
return {
name: provider.provider_name,
endpoint: endpoint.url,
formats: endpoint.formats || [ 'json' ],
};
}
}
// If no schemes defined but url matches provider url
if ( ! endpoint.schemes && url. startsWith (provider.provider_url)) {
return {
name: provider.provider_name,
endpoint: endpoint.url,
formats: endpoint.formats || [ 'json' ],
};
}
}
}
return null ;
}
async function fetchOembedData ( provider , targetUrl , options = {}) {
const embedUrl = new URL (provider.endpoint);
embedUrl.searchParams. set ( 'url' , targetUrl);
// Set format preference (prefer json if available)
const format = provider.formats. includes ( 'json' ) ? 'json' : provider.formats[ 0 ];
embedUrl.searchParams. set ( 'format' , format);
// Add additional parameters if provided
for ( const [ key , value ] of Object. entries (options)) {
if (value) {
embedUrl.searchParams. set (key, value);
}
}
const response = await fetch (embedUrl);
if ( ! response.ok) {
throw new Error ( `Failed to fetch oEmbed data: ${ response . status }` );
}
return response. json ();
}
export default {
async fetch (request, env, ctx) {
// Check if origin is allowed
if ( ! isOriginAllowed (request)) {
return new Response ( 'Forbidden' , {
status: 403 ,
statusText: 'Forbidden' ,
headers: {
'Content-Type' : 'application/json' ,
}
});
}
// Handle CORS preflight requests
if (request.method === 'OPTIONS' ) {
return new Response ( null , {
headers: corsHeaders (request),
});
}
// Parse URL and get parameters
const url = new URL (request.url);
const targetUrl = url.searchParams. get ( 'url' );
if ( ! targetUrl) {
return new Response ( 'Missing URL parameter' , {
status: 400 ,
headers: {
'Content-Type' : 'application/json' ,
... corsHeaders (request),
},
});
}
try {
// Get providers list
const providers = await getProviders ();
// Find the appropriate provider
const provider = findProvider (targetUrl, providers);
if ( ! provider) {
return new Response ( JSON . stringify ({
error: 'Unsupported URL format' ,
message: 'No oEmbed provider found for this URL'
}), {
status: 400 ,
headers: {
'Content-Type' : 'application/json' ,
... corsHeaders (request),
},
});
}
// Get additional options from query parameters
const options = {};
const validOptions = [ 'maxwidth' , 'maxheight' , 'theme' , 'format' , 'lang' ];
for ( const option of validOptions) {
const value = url.searchParams. get (option);
if (value) {
options[option] = value;
}
}
const data = await fetchOembedData (provider, targetUrl, options);
return new Response ( JSON . stringify (data), {
headers: {
'Content-Type' : 'application/json' ,
'Cache-Control' : 'public, max-age=3600' ,
'X-Provider' : provider.name,
... corsHeaders (request),
},
});
} catch (error) {
console. error ( 'Error:' , error);
return new Response ( JSON . stringify ({
error: 'Error processing request' ,
message: error.message
}), {
status: 500 ,
headers: {
'Content-Type' : 'application/json' ,
... corsHeaders (request),
},
});
}
} ,
} ;
The Vue component
stc/components/OEmbed.vue < script setup lang = "ts" >
import { useDark } from '@vueuse/core'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps <{
url : string
maxWidth ?: number
}>()
const isDark = useDark ()
const oembedData = ref < any >( null )
const containerRef = ref < HTMLElement >()
async function fetchOembedData () {
try {
const response = await fetch (
`https://worker.vinh.dev/oembed?url=${ encodeURIComponent ( props . url ) }&theme=${ isDark . value ? 'dark' : 'light'}` ,
)
oembedData.value = await response. json ()
if (oembedData.value?.type === 'rich' ) {
nextTick (() => injectContent (oembedData.value.html))
}
} catch (error) {
console. error ( 'Failed to fetch oembed data:' , error)
}
}
function injectContent ( html : string ) {
if ( ! containerRef.value)
return
// Clear previous content
containerRef.value.innerHTML = ''
// Create temporary container
const temp = document. createElement ( 'div' )
temp.innerHTML = html
// Extract scripts
const scripts = Array. from (temp. getElementsByTagName ( 'script' ))
// Remove scripts from temp
scripts. forEach (( oldScript ) => {
oldScript.parentNode?. removeChild (oldScript)
})
// Insert HTML
containerRef.value.innerHTML = temp.innerHTML
// Re-add scripts
scripts. forEach (( oldScript ) => {
const newScript = document. createElement ( 'script' )
Array. from (oldScript.attributes). forEach (( attr ) => {
newScript. setAttribute (attr.name, attr.value)
})
newScript.innerHTML = oldScript.innerHTML
containerRef.value?. appendChild (newScript)
})
}
onMounted (() => {
fetchOembedData ()
})
onUnmounted (() => {
if (containerRef.value) {
containerRef.value.innerHTML = ''
}
})
// Refetch when theme changes
watch (isDark, () => {
fetchOembedData ()
})
</ script >
< template >
< div v-if = " oembedData " class = "oembed-container" >
< div
v-if = " oembedData.type === 'rich' "
ref = "containerRef"
: style = " `max-width: ${ maxWidth || 800 }px;` "
/ >
< img
v-else-if = " oembedData.type === 'photo' "
: src = " oembedData.url "
: alt = " oembedData.title || '' "
: width = " oembedData.width "
: height = " oembedData.height "
: style = " `max-width: ${ maxWidth || 800 }px;` "
>
< div
v-else-if = " oembedData.type === 'video' "
class = "video-container"
: style = " `max-width: ${ maxWidth || 800 }px;` "
v-html = " oembedData.html "
/ >
< a
v-else-if = " oembedData.type === 'link' "
: href = " oembedData.url "
target = "_blank"
rel = "noopener noreferrer"
>
{{ oembedData.title || url }}
</ a >
</ div >
</ template >
< style scoped >
.oembed-container {
margin : 1 rem 0 ;
width : 100 % ;
}
.video-container {
position : relative ;
padding-bottom : 56.25 % ;
height : 0 ;
overflow : hidden ;
}
.video-container :deep( iframe ) {
position : absolute ;
top : 0 ;
left : 0 ;
width : 100 % ;
height : 100 % ;
}
img {
max-width : 100 % ;
height : auto ;
}
</ style >
Noticed that we need a injectContent
function to inject the HTML content into the container? This is because the HTML content returned from the oEmbed endpoint being santized by v-html
directive. And we need to extract the scripts from the HTML content and re-add them to the container.
Usage and results
Now we can use the OEmbed
component to embed content from Bluesky and other services with the following code in MDX files:
import OEmbed from '@/components/OEmbed.vue'
< OEmbed client : only = "vue" url = "https://bsky.app/profile/vinh.dev/post/3lalirzsyuc2i" />
And we will got this (sadly, Bluesky’s emebed itself does not support dark theme yet):
Conclusion
This is just long story of mine to embed Bluesky’s posts and other services’ contents. You don’t have to do exact the same. Just like I said in the beginning, there are simpler ways to do this without all these fancy stuff. So find the one that suits you most and go with it. Have fun at coding and don’t forget to follow me on Bluesky / vinh.dev . Cheers!