Zack Hobson

Embedding Mastodon posts with a web native component Sunday September 3, 2023

This is the source code for the embed widget that (hopefully) appears at the top of this post. Here is how it's being used in this page:

<embed-post
  data-src="https://butts.team/@zenhob/110987984593500666"
  data-maxheight="325"
  data-maxwidth="800"
></embed-post>
<script async="async" src="/static/js/embed-post.js"></script>

As you can see, there isn't much to it. After my last post, where I learned about native web components, I started to wonder how hard it would be to use this technique to build a widget for embedding Mastodon posts. This is something I've never done before, but it wasn't that hard to figure out, so I wrote about the process. There are helpful links throughout, mostly to MDN, because that's my favorite reference. Since I already went into technical detail about web components in the last post, I will skip over many of those details here.

Figuring it out

First off, let's try adding .json to the end of the post URL and loading that in the browser. Sure enough, that displays a JSON representation of the post! Could it be that simple? Sadly, attempting to fetch that URL from within this page using fetch() fails with a CORS error. That makes sense: CORS is a safeguard to prevent web apps from making unauthorized requests on behalf of the user, and this interface isn't meant to be available to an outside web application. This seems like a dead end.

After an unsuccessful web search, I ended up poking around in the Mastodon code. Here we can find the code describing CORS behavior for Mastodon, and noticed that the /api path is excepted from CORS, indicating that it is for external use! It turns out there is an endpoint under this path that Mastodon provides explicitly for this purpose: /api/oembed. This is a pretty roundabout way to figure that out, but it didn't take long.

With this knowledge, we can fetch something related to the post, given the URL:

const hostname = new URL(postUrl).host
const resp = await fetch(`https://${hostname}/api/oembed?url=${postUrl}`)
const embed = await resp.json()

What we get is a response specifically for embedding, which contains a snippet of HTML. This snippet declares an <iframe> pointing at the original post, and loads some JavaScript. It looks something like this:

<iframe
  src="https://butts.team/@zenhob/110987984593500666/embed"
  class="mastodon-embed"
  allowfullscreen="allowfullscreen"
></iframe>
<script src="https://butts.team/embed.js" async="async"></script>

It's rarely a great idea to accept raw HTML and insert into your UI, there are serious security implications! For a service under my control, with my own post, this seems like something that can be done safely. Given that, we are just going to shovel it into the innerHTML of the component.

this.#content.innerHTML = embed.html

That works! At this point the post is visible through an awkwardly sized viewport on the page. The docs for /api/oembed explain how to specify the size of the embedded frame, so as long as our styles match those sizes, our embed should fit perfectly. The optional attributes data-maxwidth and data-maxheight are used to configure the styles and the API call.

Wrapping it up

By this point the bulk of core functionality exists, but there is still some polishing to do. User-provided maxheight and maxwidth values are applied using CSS variables in inline styles. I added a little "loading" message that's visible until the fetch completes and the embed is visible.

The code for this component makes use of some JavaScript features that the last one did not, like static and private members. This is mostly because I haven't really written much JavaScript lately and I am unused to our age of evergreen browsers, where I can use a more modern JS dialect without issue.