mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat(frontend): warn when COEP blocks cross-origin resources in raw app editor preview (#10328)
* feat(frontend): warn when COEP blocks cross-origin resources in raw app editor preview Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gr3udvsSKYyH6mnDvGDqEE * fix(frontend): hedge COEP toast wording and attach warning on detached preview initial load Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gr3udvsSKYyH6mnDvGDqEE * chore(frontend): condense COEP warning rationale comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gr3udvsSKYyH6mnDvGDqEE --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
74af4ed939
commit
7751d3e43e
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import Modal from '../common/modal/Modal.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
/** Inline preview iframe rendering the user's app (same-origin app-preview.html) */
|
||||
iframe: HTMLIFrameElement | undefined
|
||||
}
|
||||
|
||||
let { iframe }: Props = $props()
|
||||
|
||||
let open = $state(false)
|
||||
|
||||
// One toast per remote origin — a page pulling many assets from one host
|
||||
// should not stack toasts.
|
||||
const warnedOrigins = new Set<string>()
|
||||
|
||||
const RESOURCE_TAGS = new Set(['IMG', 'SCRIPT', 'LINK', 'AUDIO', 'VIDEO', 'SOURCE', 'IFRAME'])
|
||||
|
||||
// The editor is cross-origin isolated (COEP require-corp, needed for the
|
||||
// SharedArrayBuffer-based TS workers), and that extends to the nested preview:
|
||||
// cross-origin resources lacking CORS/CORP headers are blocked there while the
|
||||
// deployed app (served without COEP) loads them fine — warn instead of staying silent.
|
||||
function onResourceError(e: Event) {
|
||||
// Elements belong to the preview window's realm, so no instanceof checks —
|
||||
// duck-type instead. Resource load errors don't bubble but do capture
|
||||
// through the window; runtime ErrorEvents target the window itself and are
|
||||
// skipped by the tagName check.
|
||||
const el = e.target as (Element & { currentSrc?: string; src?: string; href?: string }) | null
|
||||
if (!el?.tagName || !RESOURCE_TAGS.has(el.tagName)) return
|
||||
const raw = el.currentSrc || el.src || el.href
|
||||
if (typeof raw !== 'string' || !raw) return
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// Same-origin failures (plain 404s) and non-http(s) schemes are not COEP blocks.
|
||||
if (!/^https?:$/.test(url.protocol) || url.origin === window.location.origin) return
|
||||
if (warnedOrigins.has(url.origin)) return
|
||||
warnedOrigins.add(url.origin)
|
||||
// `error` events carry no failure reason, so a 404/DNS failure on a
|
||||
// cross-origin URL looks identical to a COEP block — hedge the wording.
|
||||
sendUserToast(
|
||||
`Cross-origin resource (${url.host}) failed to load — likely the editor's COEP/CORS isolation. A valid URL will still load on the deployed app.`,
|
||||
'warning',
|
||||
[{ label: 'Read more', callback: () => (open = true) }],
|
||||
undefined,
|
||||
10000
|
||||
)
|
||||
}
|
||||
|
||||
export function attachTo(win: Window | null | undefined) {
|
||||
win?.addEventListener('error', onResourceError, true)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = iframe
|
||||
if (!el) return
|
||||
const attach = () => attachTo(el.contentWindow)
|
||||
el.addEventListener('load', attach)
|
||||
// The iframe may already be loaded when this mounts; addEventListener
|
||||
// dedupes the (handler, capture) pair, so the load event re-firing on the
|
||||
// same window cannot double-attach.
|
||||
if (el.contentDocument?.readyState === 'complete') attach()
|
||||
return () => el.removeEventListener('load', attach)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Cross-origin resources in the editor preview" kind="X">
|
||||
<div class="flex flex-col gap-3">
|
||||
<p>
|
||||
The app editor runs in a <b>cross-origin isolated</b> context (COOP/COEP headers). This is
|
||||
required for <code>SharedArrayBuffer</code>, which powers the TypeScript language workers and
|
||||
lets the editor build and preview your frontend live in the browser.
|
||||
</p>
|
||||
<p>
|
||||
A side effect is that the browser refuses to load cross-origin resources (images, scripts,
|
||||
stylesheets, media…) unless the remote server explicitly opts in with CORS or a
|
||||
<code>Cross-Origin-Resource-Policy</code> header. Resources from servers that don't are
|
||||
blocked <b>in the editor preview only</b>. When this is the cause, the browser console shows
|
||||
<code>ERR_BLOCKED_BY_RESPONSE</code> — a plain 404 or DNS error instead means the URL itself is
|
||||
broken and will fail on the deployed app too.
|
||||
</p>
|
||||
<p>
|
||||
The deployed app is served without these headers, so the same resources load normally there —
|
||||
open the deployed app link to verify. If you control the remote server, sending
|
||||
<code>Cross-Origin-Resource-Policy: cross-origin</code> makes the resource load in the editor too.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -29,6 +29,7 @@
|
||||
} from './utils'
|
||||
import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom'
|
||||
import InlineElementPrompt from './InlineElementPrompt.svelte'
|
||||
import RawAppCoepWarning from './RawAppCoepWarning.svelte'
|
||||
import DarkModeObserver from '../DarkModeObserver.svelte'
|
||||
import { getAppliedDarkModeVariant, type DarkModeVariant } from '$lib/darkModeVariant'
|
||||
import RawAppSidebar from './RawAppSidebar.svelte'
|
||||
@@ -351,6 +352,7 @@
|
||||
let iframe: HTMLIFrameElement | undefined = $state(undefined)
|
||||
const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html'
|
||||
let previewIframe: HTMLIFrameElement | undefined = $state(undefined)
|
||||
let coepWarning: RawAppCoepWarning | undefined = $state(undefined)
|
||||
let previewIframeLoaded = $state(false)
|
||||
let lastBuild: { css: string; js: string } | undefined = undefined
|
||||
// Detached preview tab/window rendering the same app-preview bundle as the
|
||||
@@ -1164,6 +1166,9 @@
|
||||
) {
|
||||
externalPreviewReady = true
|
||||
feedExternalPreview()
|
||||
// The detached window is cross-origin isolated like the inline preview,
|
||||
// so blocked external resources warrant the same COEP warning.
|
||||
coepWarning?.attachTo(externalPreviewWindow)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1504,6 +1509,9 @@
|
||||
win.addEventListener('load', () => {
|
||||
externalPreviewReady = true
|
||||
feedExternalPreview()
|
||||
// Attach here too: against an artifact that predates the handshake, this
|
||||
// is the only place the freshly opened window is ever seen loaded.
|
||||
coepWarning?.attachTo(win)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2539,6 +2547,7 @@
|
||||
src={PREVIEW_SHELL_URL}
|
||||
class="w-full flex-1 block"
|
||||
></iframe>
|
||||
<RawAppCoepWarning bind:this={coepWarning} iframe={previewIframe} />
|
||||
{#if buildError}
|
||||
<!-- top-12 clears the tab bar; `before:bg-surface` backs the
|
||||
Alert's translucent red; `isolate` pins the pseudo's stacking context. -->
|
||||
|
||||
Reference in New Issue
Block a user