Problem
synapseVite() in dev exposes two URL spaces with overlapping responsibilities:
| Route |
Purpose |
What happens if a human opens it |
/ |
Iframe target — internal artifact. The __preview host iframes it. |
Loads the SPA. Synapse handshake never fires (no parent host). Silent broken UI with empty state. |
/__preview |
The preview host page (theme toggle, MCP bridge, iframe wrapper). |
Works as designed. |
Vite's SPA fallback dutifully serves the bare React bundle for any unmatched path (/, /_preview, /foo, /anything). The bare bundle has no MCP bridge — useCallTool calls go nowhere — and the user sees an empty UI with no explanation. There's no signal that they're on the wrong URL.
This bit a real demo this week. The dev landed on /_preview (typo for /__preview), got served the bare bundle via SPA fallback, and saw the app's empty state asking them to "run make sync" — when in fact the data was already loaded; it was just that the UI had no way to reach the MCP server.
Workaround currently shipping in synapse-consortium
A 3-line redirect in main.tsx:
if (import.meta.env.DEV && window.top === window.self) {
window.location.replace("/__preview");
}
This is a band-aid. It treats the symptom (bare iframe target is reachable) instead of the cause (the iframe target shouldn't share the SPA root). It also requires every app to opt in — easy to forget.
Proposed fix (two layers)
Fix 1 — Make the iframe target structurally distinct from the user-facing root
Today: /__preview iframes /.
Proposed:
| Route |
Purpose |
/ |
The preview host. The thing developers should land on. |
/__embed (or /?_synapse=embed) |
Iframe target. Explicit. Cannot be reached by accident. |
Now any URL a developer types — /, /_preview, /foo — falls through Vite's SPA fallback to the preview host. The bare React bundle is served only when the host page explicitly requests it via the embed route.
This is a breaking change for anyone bookmarking /__preview. Land it as a v0.5 of the plugin with a one-cycle legacyPreviewRoute: true opt-out. Apps don't need code changes either way — they just open a different URL during dev.
Implementation surface: ~30 lines in packages/synapse/src/vite/plugin.ts. Update the previewHostHtml to point its iframe at the embed route. Add a configureServer middleware that serves the bare bundle only on the embed route, and the preview host on everything else.
Fix 2 — Make the bare React app self-diagnostic at the SDK level
In packages/synapse/src/connect.ts (or wherever the handshake lives): if no ui/initialize response arrives within ~1.5s and window.top === window.self, render a small unmistakable banner over the app:
"This Synapse app is meant to be embedded. You're running it standalone — no MCP bridge is connected, so tool calls will fail. Open the dev preview at /"
Why this matters even after Fix 1:
- Defense in depth — someone could navigate directly to the embed URL or load the bundle in a host that doesn't speak ext-apps.
- It's a signal, not a redirect — explains why the UI is empty.
- Production-safe: only renders if the handshake actually fails. Correct behavior in any environment.
- ~30 lines in the SDK. Every Synapse app gets it for free.
Comparison
| Approach |
Per-app code |
Bookmark survival |
Self-explanatory failure |
Long-term |
Per-app main.tsx redirect (current band-aid) |
required |
n/a |
no |
❌ |
transformIndexHtml injection in plugin |
none |
yes |
no |
⚠️ band-aid |
| Fix 1 (route flip) |
none |
breaks once |
partial |
✅ |
| Fix 2 (SDK self-diagnostic) |
none |
yes |
yes |
✅ |
| Fix 1 + 2 |
none |
breaks once |
yes |
✅✅ |
Acceptance
Problem
synapseVite()in dev exposes two URL spaces with overlapping responsibilities:/__previewhost iframes it./__previewVite's SPA fallback dutifully serves the bare React bundle for any unmatched path (
/,/_preview,/foo,/anything). The bare bundle has no MCP bridge —useCallToolcalls go nowhere — and the user sees an empty UI with no explanation. There's no signal that they're on the wrong URL.This bit a real demo this week. The dev landed on
/_preview(typo for/__preview), got served the bare bundle via SPA fallback, and saw the app's empty state asking them to "runmake sync" — when in fact the data was already loaded; it was just that the UI had no way to reach the MCP server.Workaround currently shipping in synapse-consortium
A 3-line redirect in
main.tsx:This is a band-aid. It treats the symptom (bare iframe target is reachable) instead of the cause (the iframe target shouldn't share the SPA root). It also requires every app to opt in — easy to forget.
Proposed fix (two layers)
Fix 1 — Make the iframe target structurally distinct from the user-facing root
Today:
/__previewiframes/.Proposed:
//__embed(or/?_synapse=embed)Now any URL a developer types —
/,/_preview,/foo— falls through Vite's SPA fallback to the preview host. The bare React bundle is served only when the host page explicitly requests it via the embed route.This is a breaking change for anyone bookmarking
/__preview. Land it as av0.5of the plugin with a one-cyclelegacyPreviewRoute: trueopt-out. Apps don't need code changes either way — they just open a different URL during dev.Implementation surface: ~30 lines in
packages/synapse/src/vite/plugin.ts. Update thepreviewHostHtmlto point its iframe at the embed route. Add aconfigureServermiddleware that serves the bare bundle only on the embed route, and the preview host on everything else.Fix 2 — Make the bare React app self-diagnostic at the SDK level
In
packages/synapse/src/connect.ts(or wherever the handshake lives): if noui/initializeresponse arrives within ~1.5s andwindow.top === window.self, render a small unmistakable banner over the app:Why this matters even after Fix 1:
Comparison
main.tsxredirect (current band-aid)transformIndexHtmlinjection in pluginAcceptance
vite/plugin.tsbehind a default-on flag, with alegacyPreviewRouteopt-out for one cycle. README + CHANGELOG updated.main.tsxband-aid insynapse-apps-private/synapse-consortiumand any other apps that adopted it.