The things we do for SharedArrayBuffer
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 …
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:
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.
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 …
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):
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!
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 …
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.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.
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()
}
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.