Skip to content

web: serve COOP/COEP cross-origin isolation headers (fix audio in iOS WKWebView / MIDIWeb)#1022

Merged
bwhitman merged 4 commits into
mainfrom
claude/loving-banach-cc0d92
Jun 22, 2026
Merged

web: serve COOP/COEP cross-origin isolation headers (fix audio in iOS WKWebView / MIDIWeb)#1022
bwhitman merged 4 commits into
mainfrom
claude/loving-banach-cc0d92

Conversation

@bwhitman

Copy link
Copy Markdown
Collaborator

Problem

Per 5of12/MIDIWeb-Hub#23: Tulip Web (/run) and the AMYboard editor (/editor) produce no audio inside iOS WKWebView (e.g. the MIDIWeb app), even though they work in iOS Safari.

Root cause is cross-origin isolation. Both apps need crossOriginIsolated === true (for SharedArrayBuffer + AudioWorklet + WASM), which requires the document to be served with:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

Today those headers are injected client-side by the mini-coi.js service-worker shim. Safari honors it; iOS WKWebView does not, so isolation never happens and audio is silent.

Fix

Serve the COOP/COEP/CORP headers directly from Vercel via vercel.json, so isolation no longer depends on a service worker.

Scoped to the app routes (/run, /editor) rather than /(.*). The marketing landing pages embed a cross-origin YouTube iframe and a createsend newsletter script — under a global COEP require-corp, the newsletter script (no CORP header) would be blocked. Tulip's landing page has no COEP today, so a global rule would be a regression. Scoping targets exactly the pages that need isolation and leaves the marketing pages untouched.

Verified the app routes are COEP-safe: every cross-origin CDN dependency (codemirror, bootstrap, webmidi, jquery from cdnjs / jsdelivr / googleapis) already returns Cross-Origin-Resource-Policy: cross-origin, and all same-origin WASM/JS passes COEP inherently. This is the same COEP policy the apps already run under in Safari via mini-coi, so resource loading is unchanged.

mini-coi.js is left in place as a harmless fallback for non-Vercel/local serving (it sets identical headers).

Also: tulip/web/vercel.json was never deployed

The release/preview workflows run vercel deploy stage, but tulip/web/build.sh never copied vercel.json into stage/ — so the file was silently ignored in production (its existing cache rules weren't applied either; hashed assets were served with Vercel's default max-age=0 instead of immutable). Added the cp vercel.json stage/ that amyboardweb/dev.py already does.

How to test (preview deploys)

  • Tulip Web: https://tulip-pr-<N>.vercel.app/run/
  • AMYboard: https://amyboard-pr-<N>.vercel.app/editor/

On each, in the console:

window.crossOriginIsolated === true
typeof SharedArrayBuffer === "function"

…and confirm audio (Tulip: bleep on first interaction; AMYboard: Simulate → tap a key). Marketing landing pages (/) should be unchanged (no COEP header; YouTube embed still loads).

Final verification of the actual MIDIWeb/WKWebView case needs an iOS device.

🤖 Generated with Claude Code

iOS WKWebView (e.g. the MIDIWeb app) does not honor the mini-coi.js
service-worker shim that Tulip Web and AMYboard Web use to become
cross-origin isolated, so SharedArrayBuffer / AudioWorklet / WASM never
initialize there and the apps produce no audio (works in iOS Safari).

Serve the COOP/COEP/CORP headers directly from Vercel instead, scoped to
the app routes (/run, /editor) so the marketing landing pages keep
loading their cross-origin YouTube embed + newsletter script (COEP would
block third-party resources that lack CORP).

Also copy tulip/web/vercel.json into stage/ at build time: the
release/preview workflows run `vercel deploy stage`, so the file was
previously ignored and its headers (incl. the existing cache rules) were
never served. amyboardweb's dev.py already does the equivalent copy.

Refs 5of12/MIDIWeb-Hub#23

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

🔌 AMYboard PR preview

Editor + flasher: https://amyboard-pr-1022.vercel.app/editor/

This preview bundles this PR's firmware — its flasher only flashes this build (not the release). Rebuilt on every push; removed when the PR closes.

The hardware CI has been kicked off and should return within a few minutes, stand by!

The /run catch-all cache rule used "/((?!run/(amy|tulipcc)-).*)", whose
nested capturing group inside the negative lookahead is rejected by
Vercel's path-to-regexp ("Capturing groups are not allowed"). It never
surfaced before because tulip/web/vercel.json was never deployed; the
previous commit (copying it into stage/) made Vercel parse it for the
first time, failing the deploy.

Use bare alternation "/((?!run/amy-|run/tulipcc-).*)" instead, mirroring
amyboardweb's working "/((?!amy-|amyboard-).*)". Validated with
path-to-regexp@6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

🌷 Tulip Web PR preview

Tulip Web: https://tulip-pr-1022.vercel.app/run/

Flash a Tulip to this build: on-device run tulip.upgrade(pr=1022).

Rebuilt on every push; removed when the PR closes.

@bwhitman

Copy link
Copy Markdown
Collaborator Author

Verified on the Vercel previews ✅

Both previews build & deploy cleanly, serve the isolation headers on the app routes only, and the apps come up cross-origin isolated with no COEP-blocked resources. Checked with curl (headers) + headless Chrome via CDP (crossOriginIsolated / SharedArrayBuffer / blocked-resource + console scan).

route COOP/COEP/CORP crossOriginIsolated SharedArrayBuffer
tulip-pr-1022.vercel.app/run/ ✅ all three true function, constructs OK
tulip-pr-1022.vercel.app/ (marketing) none false
amyboard-pr-1022.vercel.app/editor/ ✅ all three true function, constructs OK
amyboard-pr-1022.vercel.app/ (marketing) none false
  • No COEP-blocked resources on either app route — every cross-origin CDN dep (codemirror / bootstrap / webmidi / jquery) already sends Cross-Origin-Resource-Policy: cross-origin.
  • /run/ runtime behaviour is identical to current production (same single pre-existing memory access out of bounds headless-only main-loop error + benign ERR_ABORTED; no new errors). Confirmed by running the same check against tulip.computer/run/.
  • The tulip/web/vercel.json cache rules now actually apply too (hashed assets → immutable) — the file was previously never deployed.

Remaining: on-device verification in MIDIWeb (iOS WKWebView), which CI can't do. The preview URLs are already isolated, so loading https://amyboard-pr-1022.vercel.app/editor/ (Simulate → tap a key) or https://tulip-pr-1022.vercel.app/run/ (bleep on first interaction) in MIDIWeb should now produce audio without merging to prod.

With COOP/COEP now served by the server (Vercel via vercel.json; webdev.py
for local dev), the mini-coi.js shim is redundant wherever the app is actually
served. Left active it still registers a service worker on first visit and
fires a location.reload() via its updatefound handler — an unnecessary full
reload on every fresh load. Bail out when self.crossOriginIsolated is already
true, keeping the shim only as a fallback for header-less static hosts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bwhitman bwhitman force-pushed the claude/loving-banach-cc0d92 branch from 7a092c7 to 456cebb Compare June 22, 2026 19:52
…ut the slash

Tulip Web's /run page references its assets relatively (amyrepl.css, tree.js,
the WASM/mjs, etc.), which only resolve when the document path ends in /run/.
Loading /run without a trailing slash resolved every asset against / and 404'd
them, so loadMicroPython/start_audio were undefined and the app showed a blank
page / stray textarea. iOS hit this because the URL had no trailing slash;
desktop used the /run/ link and was fine.

Add <base href="/run/"> so assets resolve correctly whether the page loads as
/run or /run/. This mirrors amyboardweb/editor's existing <base href="/"> (which
is why the editor already works at both /editor and /editor/), so no redirect is
needed. Pre-existing on prod too — surfaced now that we point people at /run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bwhitman bwhitman force-pushed the claude/loving-banach-cc0d92 branch from 456cebb to bc8d847 Compare June 22, 2026 19:56
@github-actions

Copy link
Copy Markdown

🎛️ HW CI (physical bench)

AMYboard (USB-MIDI + AMY zP → audio): ✅ PASS — flashed this PR’s firmware; all checks matched the references.

Tulip (TULIP4_R11; serial-REPL audio + WiFi screenshot): ✅ PASS — flashed this PR’s firmware; all checks matched the references.

⬇️ Artifacts: recordings · screenshot · serial logs · run logs

Self-hosted bench. Audio spectral-compared to ref/hwci_basic.wav + ref/tulip_basic.wav; Tulip screenshot pixel-compared to ref/tulip_screenshot.png. Both analog outs share one capture card, so the tests run sequentially.

@bwhitman

Copy link
Copy Markdown
Collaborator Author

✅ Confirmed working on a real iOS device

Two distinct bugs were in play

  1. iOS WKWebView / MIDIWeb had no audio — the original report. Cause: cross-origin isolation came only from the mini-coi.js service-worker shim, which iOS WKWebView doesn't honor → no SharedArrayBuffer/AudioWorklet. Fix: serve COOP/COEP/CORP from the server (scoped to /run and /editor).

  2. /run was blank on iOS Safari — surfaced while testing. Cause was not COOP/COEP: Tulip's /run page references its assets relatively, so loading /run without a trailing slash resolved them against / and 404'd everything (loadMicroPython/start_audio undefined → blank page + stray textarea). Pre-existing on prod too. Fix: <base href="/run/">, mirroring amyboardweb/editor's existing <base href="/"> (which is why the editor already worked at both /editor and /editor/). No redirect needed.

Also: tulip/web/build.sh now copies vercel.json into stage/ (it was never deployed before, so its cache rules were dead too), fixed an invalid Vercel route pattern (nested capture group), and made mini-coi.js bail when already isolated (redundant now that the server sends the headers).

Verification (headless Chrome on the previews)

route crossOriginIsolated SharedArrayBuffer 404s COEP-blocked
tulip-pr-1022…/run (no slash) ✅ true function 0 0
tulip-pr-1022…/run/ ✅ true function 0 0
amyboard-pr-1022…/editor ✅ true function 0 0
amyboard-pr-1022…/editor/ ✅ true function 0 0

Marketing landing pages (/) remain un-isolated (COEP scoped to app routes only, so their YouTube/newsletter embeds are unaffected). Confirmed on a physical iOS device.

@bwhitman bwhitman merged commit 8c2883b into main Jun 22, 2026
3 checks passed
pull Bot pushed a commit to manmuqingshan/tulipcc that referenced this pull request Jun 23, 2026
…firmware flasher

PR shorepine#1022 moved cross-origin isolation from the root-scoped mini-coi service
worker to per-path Vercel headers scoped to /editor only. The editor embeds the
firmware flasher with <iframe src="esptool/index.html"> and uses <base href="/">,
so that resolves to /esptool/index.html — which then no longer received COEP.
A require-corp page refuses to embed a non-isolated iframe, so the Upgrade
Firmware tab rendered as "www.amyboard.com refused to connect".

Apply the same COOP/COEP/CORP headers to /esptool and /esptool/(.*) so the
flasher page is cross-origin isolated and embeddable again. This matches local
dev, where webdev.py already sends these headers for every path (which is why
the bug was prod/preview-only). esptool's only cross-origin dependency
(crypto-js from jsDelivr) already returns Cross-Origin-Resource-Policy:
cross-origin, so it still loads under COEP — no esptool changes needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant