Captain Codeman Captain Codeman

Cross-Origin-Isolation with SvelteKit, Vite, and Firebase

The things we do for SharedArrayBuffer

Contents

Introduction

I wanted to use a package that relied on the SharedArrayBuffer API but thanks to Intel we can’t have nice things. At least not as easily as we used to. But apparently I just needed to enable “Cross Origin Isolation”, how hard could it be? Let’s find out …

What is Cross-Origin-Isolation?

So what exactly is it? It’s a “make the web more secure” opt-in feature that only lets you play with the sharp knives if you’ve put your chain-mail gloves on. Specifically, you can’t just go round letting any script or iframe load any resources it wants if you’re using dangerous features such as shared memory or else very bad things could happen (well, to your users, and then they might come and do bad things to you).

How do you do enable it? Just throw some “Cross-Origin-Opener-Policy” and “Cross-Origin-Embedder-Policy” headers on everything!

If you’re thinking that “COOP” and “COEP” sounds suspiciously like “CORS” (Cross-Origin Resource Sharing), the thing web developers absolutely love working with the most in the whole wide world, then you’d be right - they are all related.

Wait, why are you sobbing? Don’t run away! Come back, we’re not finished yet!

If you want to first read more technical guides these links should get you started:

Why use Cross-Origin-Isolation?

Apart from being able to use better precision timers (whoop-de-doo) it’s really about being able to use SharedArrayBuffer. OK, so why would you want to use that? In my case, it was to use wasm-vips which is a “can run in the browser because WASM” version of libvips which is a super-fast ultra-memory-efficient image processing library.

I’d previously been using the @jsquash packages that were derived from the Squoosh app and also use WASM, and they worked great, but I was also using libvips for server-side processing and when I tested the WASM version it was significantly faster.

Implementation Challenges

There are a few challenges to using Cross Origin Isolation. Some to do with SvelteKit, Vite, and Firebase. Some todo with making the rest of your site work once you enable it. Oh, didn’t I tell you? Getting access to SharedArrayBuffer is just the beginning, after that you’ll want to make Auth work again, and maybe having <img> elements load would be nice …

SvelteKit Development Mode

Let’s start at the very beginnning (great, now I have Julie Andrews in my head as well as the CORS stuff). It helps if we can run our code in development mode. The basic requirement is to add the COOP and COEP headers to our web page. This is easily done with SvelteKit by using a hooks.server.ts handle function:

export async function handle({ event, resolve }) {
  const { setHeaders } = event
  setHeaders({
    'Cross-Origin-Embedder-Policy': 'require-corp',
    'Cross-Origin-Opener-Policy': 'same-origin',
  })
  return await resolve(event)
}

With that added, we can check the window.crossOriginIsolated property to see if it’s enabled and bingo, we did it!

Great job everyone, take the rest of the day off :)

Ah, no - at this point our page is crossOriginIsolated but when we try to import our JS package it fails. Here’s a simple test to see if it’s loading.

<script lang="ts">
  import Vips from 'wasm-vips'

  // returns a promise to load the wasm lib
  const loadVips = Vips({ dynamicLibraries: [] })
</script>

{#await loadVips}
  <p>Loading VIPS</p>
{:then vips}
  <p>VIPS loaded!</p>
{/await}

The package never loads and the page is stuck showing “Loading VIPS”. A look at the Network tab shows us exactly why complete with a helpful explanation of how to fix it (this should be your first step when figuring out any CORS related issues):

vips-coep-fail

So how do we add the Cross-Origin-Embedder-Policy header to JS modules? Didn’t we already do that with hooks.server.ts? Unfortunately, no, the SvelteKit handle function isn’t middleware for all http requests like you may be used to - it only applies to the pages and endpoints that our app code serves up, not the JS modules or static files - those are handled by vite in dev mode. Fortunately vite provides an easy way to add them using the server.headers config:

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [sveltekit()],
  server: {
    headers: {
      // add COEP header to vite-served modules
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  }
})

With that added we’re on our way - we can load wasm-vips and the page changes to “VIPS loaded!” to show it worked!

SvelteKit Production Mode

Rant: It’s a real pity that SvelteKit doesn’t support adding middleware when using the node adapter. The objection to providing access to middleware hooks seems a little arbitrary and a conspiracy theorist might suggest it’s because the people behind it are employed by Vercel who’s serverless platform does not run full node. Whatever the reason, just as with development mode, we don’t have a way to handle things completely within the SvelteKit framework and even though we’re using the node adapter, we unfortunately have to abandon and re-implement the server it provides just to be able to add the middleware we need. IMO this is a pedantic limitation. End rant.

But anyway, here’s a custom server implementation that also adds http compression (another reason you may already want to implement your own server):

import polka from 'polka'
import compression from '@polka/compression'
import { handler } from './build/handler.js'

const compress = compression({ brotli: true })

function crossOrigin(req, res, next) {
  // add COEP header to the wasm-vips module
  if (req.url.startsWith('/_app/immutable/assets/vips-es6.')) {
    res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
  }
  next()
}

polka()
  .use(compress)
  .use(crossOrigin)
  .use(handler)
  .listen(8080)

We’re only adding the COEP header to the specific script module that requires it, and we do it by checking the prefix up to the file hash part (because that can change between versions). We could simplify this and just set it on all responses, at the cost of a little inefficieny serving unnecessary additional headers.

You may be wondering why we don’t just set both COEP and COOP headers on all responses, to apply to the app page as well, and to do the same in the vite server.headers config. The reason is that in dev mode those headers aren’t applied to the page requests, so you need that hooks.server.ts handle function for dev mode to work.

I decided this was the cleanest and most efficient approach.

The end result is that our wasm-vips package will now work in both development and production, and that may be all you need.

But due to your site being crossOriginIsolated you may face some other challenges …

What Did We Break?

Cross Origin Isolation means you can’t just load any resource you want as you did before. You have to think about every request and every response.

  • <img> and <script> elements may need crossorigin="anonymous" attributes, and CORS headers in their response if not being served from the same origin.
  • <iframe> elements may need allow="cross-origin-isolated" to inherit the Cross Origin Isolation.
  • Web Workers will also need the COEP header if they also want to utilize SharedArrayBuffer

The biggest challenge is if you have a module that has to be loaded from an external site and they don’t want to support Cross Origin Isolation (looking at you Stripe!). In this case, you’re kind of shit out of luck but have a few options:

Maybe you can switch from using embedded elements or checkout to a Stripe-hosted one you redirect to?

Maybe using the data-sveltekit-reload attribute to force the pages that must use Cross Origin Isolation and those that can’t use Cross Origin Isolation to reload in order to enable and disable it? (with appropriate route checks in hooks.server.ts when setting the headers or using specific +page.server.ts load functions instead).

Sometimes there are no perfect options, and you just have to be pragmatic.

Proxied Resources (Firebase)

Another particular challenge I faced was that I was already proxying the Firebase auth sign-in helper code to use a custom domain as per the best practices for signInWithRedirect flows and you need to do that anyway to use Firebase signin from localhost but those files now also required the COEP header to function.

For dev mode, this is handled by adding a proxy to our vite config:

import { sveltekit } from '@sveltejs/kit/vite';
import mkcert from 'vite-plugin-mkcert'
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [mkcert(), sveltekit()],
  server: {
    headers: {
      // add COEP header to vite-served modules
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
    proxy: {
      // proxy firebase auth sign-in helper files
      '/__/': {
        target: 'https://my-project.firebaseapp.com',
        changeOrigin: true,
        configure: (proxy, options) => {
          // add COEP header to proxied firebase files
          proxy.on('proxyRes', function (proxyRes, req, res) {
            proxyRes.headers['Cross-Origin-Embedder-Policy'] = 'require-corp'
          });
        }
      },
    },
  }
})

NOTE: the addition of vite-plugin-mkcert for an easier way to add local SSL - SK was recently fixed to work with it again.

For the production server, we can use http-proxy-middleware to do the equivalent:

import polka from 'polka'
import compression from '@polka/compression'
import { createProxyMiddleware } from 'http-proxy-middleware'
import { handler } from './build/handler.js'

const compress = compression({ brotli: true })

// proxy firebase auth domain files
const proxyMiddleware = createProxyMiddleware({
  pathFilter: '/__/',
  target: 'https://my-project.firebaseapp.com',
  secure: false,
  changeOrigin: true,
  on: {
    proxyRes: (proxyRes, req, res) => {
      // add cross-origin-isolated headers to proxied files
      proxyRes.headers['Cross-Origin-Embedder-Policy'] = 'require-corp'
    },
  }
})

function crossOrigin(req, res, next) {
  // add COEP header to the wasm-vips module
  if (req.url.startsWith('/_app/immutable/assets/vips-es6.')) {
    res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
  }
  next()
}

polka()
  .use(proxyMiddleware)
  .use(compress)
  .use(crossOrigin)
  .use(handler)
  .listen(5173)

If you do also use Web Workers, you will need to add the http headers to the appropriate module responses (or else just add the headers to all responses). If you want the most efficient approach it’s just a case of identifying the requests that are being blocked using the browser dev tools Network tab and adding them to the check, e.g.

function crossOrigin(req, res, next) {
  // add COEP header to the wasm-vips module and worker
  if (
    req.url.startsWith('/_app/immutable/assets/vips-es6.') ||
    req.url.startsWith('/_app/immutable/workers/worker-') ||
    req.url.startsWith('/_app/immutable/workers/assets/vips-es6-')
  ) {
    res.setHeader(process.env.COEP_NAME, process.env.COEP_VALUE)
  }
  next()
}

Conclusion

How big a task it will be depends a lot on what your site is using. It might be simple, or you might have to chase some things down and refactor some pieces.

Was it worth it? The reason for switching from the @jsquash packages to wasm-vips was performance and the ability to deal with large image files that are constrained and re-sampled before upload. A batch of images that took 3m 30s before took less than 40s after, a 5x speedup, so it was well worth the additional work for a site where a user could be uploading several thousand images each day.