perf: load public raw apps without the loading screen (#11608)

* perf: load public raw apps without the loading screen

* fix: keep license store and drop stale prefetches on public app pages

* fix: answer raw app requests with an error when the job bridge fails to load

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Diego Imbert
2026-10-08 09:15:01 +02:00
committed by GitHub
co-authored by Ruben Fiszel
parent 0e7d974ce9
commit c09764fa65
61 changed files with 794 additions and 395 deletions
+8 -1
View File
@@ -979,7 +979,14 @@ async fn get_raw_app_data(
// the opaque, sandboxed wrapper (incl. under a cross-origin-isolated
// / COEP `require-corp` embedder).
.header("X-Content-Type-Options", "nosniff")
.header("Cross-Origin-Resource-Policy", "cross-origin");
.header("Cross-Origin-Resource-Policy", "cross-origin")
// The secret names one app version, whose bundle is written once at deploy,
// so the URL's content never changes. `private`: the URL is a capability, so
// shared caches must not keep serving it after the app is deleted.
.header(
http::header::CACHE_CONTROL,
"private, max-age=31536000, immutable",
);
Ok(res.body(body).unwrap())
} else {
return Err(Error::NotFound("File not found".to_string()));
+1 -1
View File
@@ -69,7 +69,7 @@ makes the client own the whole cascade so the backend dispatcher never double-fi
- `frontend/src/lib/components/assets/AssetGraph/cascadeRun.ts` — reusable run primitives
(`makeLaunch`, `makeWaitJobTerminal`, `runDownstreamCascade`, `runBoundedCascade`) wrapping
`cascadeOrchestrator` + `graphTraversal`.
- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/pipeline_dev/+page.svelte`.
- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/(styled)/pipeline_dev/+page.svelte`.
**Modified**
- `cli/src/commands/pipeline/pipeline.ts` — `--local` on `show`/`run`; `show` split into graph
+69
View File
@@ -12,6 +12,17 @@
fill: #3b82f6;
}
/* Public app pages render their shell before app.css loads, if at all. */
html[data-wm-public-app] #svelte-global-loader {
display: none;
}
html[data-wm-public-app] body {
margin: 0;
}
html[data-wm-public-app='dark'] body {
background-color: #3b4252;
}
/* Background color for dark mode */
@media (prefers-color-scheme: dark) {
#mainbg {
@@ -123,6 +134,64 @@
}
})()
</script>
<script>
// Public app pages (`/public/<ws>/<secret>`, `/a/<custom path>`): skip the loading
// screen, and start the API calls the page makes first so they overlap the module
// download instead of following it. `publicAppApi.ts` takes these responses by URL,
// so each URL here must be exactly what it requests. Only the cookie credential is
// prefetched: a share link carrying a JWT, or the opaque viewer frame (`wm_embed`),
// sends its own and fetches normally. The bundle of an unsandboxed raw app is
// requested as soon as its secret is known, for the iframe to find in the HTTP cache.
;(function () {
var path = location.pathname
var pub = /^\/public\/([^/]+)\/([^/]+)\/?$/.exec(path)
var custom = /^\/a\/(.+?)\/?$/.exec(path)
if (!pub && !custom && !/^\/public\//.test(path)) return
// Dark as PublicApp decides it, painted before any stylesheet so that a dark app
// does not load over a white page.
var dark = false
try {
var mode = localStorage.getItem('dark-mode')
dark = mode ? mode === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
} catch (_) {}
document.documentElement.setAttribute('data-wm-public-app', dark ? 'dark' : '')
if (new URLSearchParams(location.search).get('wm_embed') === '1') return
var prefetched = (window.__wmPublicAppPrefetch = {})
function get(url) {
var res = fetch(url)
res.catch(function () {})
prefetched[url] = res
return res
}
function warmBundle(appRes, workspace) {
appRes
.then(function (r) {
return r.ok ? r.clone().json() : null
})
.then(function (app) {
if (!app || !app.raw_app || !app.bundle_secret || (app.policy || {}).sandbox) return
var ws = workspace || app.workspace_id
var v = '/api/w/' + ws + '/apps_u/get_data/v/' + app.bundle_secret
fetch(v + '.css').catch(function () {})
fetch(v + '.js').catch(function () {})
})
.catch(function () {})
}
if (pub) {
var w = '/api/w/' + pub[1]
get(w + '/apps_u/embed_token/' + pub[2])
warmBundle(get(w + '/apps_u/public_app/' + pub[2]), pub[1])
get(w + '/apps_u/guest_entry/' + pub[2])
get(w + '/users/whoami')
} else if (custom && custom[1].indexOf('.') === -1) {
// A `.` marks a trailing JWT segment, which no custom path contains.
get('/api/apps_u/embed_token_by_custom_path/' + custom[1])
warmBundle(get('/api/apps_u/public_app_by_custom_path/' + custom[1]))
get('/api/apps_u/guest_entry_by_custom_path/' + custom[1])
}
get('/api/ee_license')
})()
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
@@ -19,7 +19,7 @@
import type { Item } from '$lib/utils'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import type { UserExt } from '$lib/stores'
import { userStore, type UserExt } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { ExternalLink, Pen } from 'lucide-svelte'
@@ -251,7 +251,7 @@
<PublicApp
{app}
{workspace}
user={appUser}
user={appUser ?? $userStore}
{notExists}
{noPermission}
jwtError={false}
@@ -1,28 +1,15 @@
<script lang="ts">
import { applyDarkModeVariant } from '$lib/darkModeVariant'
import { enterpriseLicense, userStore, type UserExt } from '$lib/stores'
import { base } from '$app/paths'
import { page } from '$app/state'
import Login from '$lib/components/Login.svelte'
import type { UserExt } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import { getContext, setContext } from 'svelte'
import {
EMBED_NAV_CONTEXT_KEY,
IS_APP_PUBLIC_CONTEXT_KEY,
type EditorBreakpoint,
type EmbedNav
} from '../types'
import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen'
import { urlParamsToObject } from '$lib/utils'
import { goto } from '$app/navigation'
import { loadAppPreview } from './loadAppPreview'
import { setContext } from 'svelte'
import { IS_APP_PUBLIC_CONTEXT_KEY } from '../types'
import type { AppWithLastVersion } from '$lib/gen/types.gen'
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
import { twMerge } from 'tailwind-merge'
import { writable } from 'svelte/store'
import { loadAppCss } from './publicAppApi'
let {
notExists,
@@ -33,6 +20,7 @@
app,
workspace,
user = undefined,
license = undefined,
inWorkspace = false,
hideRefreshBar = false,
syncHashToUrl = true
@@ -46,11 +34,13 @@
onLoginSuccess: () => void
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
workspace: string | undefined
/** The viewer's membership in `workspace`, for the app's `ctx`. Without it `ctx` falls
* back to `userStore`, which describes the workspace the page is navigated to — the
* same one on every standalone viewer route, but not inside an AI session's preview
* tab, where the app belongs to the session's workspace. */
/** The viewer's membership in `workspace`, for the app's `ctx`. Callers pass it
* explicitly — `$userStore` describes the workspace the page is navigated to, which
* is not the app's inside an AI session's preview tab. */
user?: UserExt | undefined
/** The instance license, which fades the "Powered by" badge and lets a low-code app's
* custom CSS apply. */
license?: string | undefined
/**
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
@@ -65,23 +55,11 @@
// Use workspace from props or from app.workspace_id (for custom path responses)
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
// `ctx.username` / `ctx.groups` are per-workspace, and they sit next to `ctx.workspace`
// in the same object, so they have to come from the same workspace it names.
let ctxUser = $derived(user ?? $userStore)
// On the public surfaces (untrusted distribution) runnable-authored html/svg needs
// the viewer's approval before it renders, unless the app sandbox isolates it. The
// in-workspace viewer renders it verbatim. See getAppMarkupTrust.
setContext(IS_APP_PUBLIC_CONTEXT_KEY, !inWorkspace)
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
// so navigating it would just show a login screen. PublicAppFrame provides the
// relay; outside the opaque viewer this is undefined and goto works directly.
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
const breakpoint = writable<EditorBreakpoint>('lg')
const darkMode =
window.localStorage.getItem('dark-mode') ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
@@ -93,84 +71,46 @@
}
// This route bypasses the (root) layout, so restore the variant class too.
applyDarkModeVariant()
let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined)
async function loadGlobalUser() {
try {
globalUser = await UserService.globalWhoami()
} catch (error) {
console.error(error)
}
// const user = await fetch('/api/global/user')
// console.log(user)
}
// Only the no-access page reads it, to tell a signed-in non-member which
// workspace the app belongs to.
$effect(() => {
if (noPermission && !guestAppPath && !$userStore && !globalUser) {
loadGlobalUser()
}
})
</script>
<!-- Only the raw-app branch is imported statically: everything else waits for app.css and
its own chunk, see publicAppApi.ts. -->
{#if !inWorkspace}
<div
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
: ''}"
>
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
{#await loadAppCss() then}
<div
class="z-50 text-xs fixed bottom-1 right-2 {license && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
: ''}"
>
</div>
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
>
</div>
{/await}
{/if}
{#if notExists}
<div class="px-4 mt-20"
><Alert type="error" title="Not found"
>There was an error loading the app, is the url correct? <a href={base}>Go to Windmill</a>
</Alert></div
>
{:else if noPermission}
{#if guestAppPath && !$userStore}
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div>
<div class="text-center mt-8 text-sm text-primary">
You do not need a Windmill account. Signing in lets you open this app and nothing else.
</div>
{:else}
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
This app requires read access
</div>
<div class="text-center mt-8 text-sm text-primary">
{#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace}
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
>{effectiveWorkspace}</span
> this app is part of
{:else}You must be logged in and have read access to this app{/if}</div
>
{/if}
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
{#if !jwtError}
<Login
{onLoginSuccess}
popup
guestApp={guestAppPath}
rd={page.url.pathname + page.url.search + page.url.hash}
/>
{/if}
</div>
{#if notExists || noPermission}
{#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]}
<PublicAppAccess
{notExists}
{jwtError}
{guestAppPath}
{user}
workspace={effectiveWorkspace}
{onLoginSuccess}
/>
{/await}
{:else if app}
{#key app}
{#if app.raw_app && effectiveWorkspace}
<!-- The bundle's iframe is `h-full`, so every host has to give this box a height to
<!-- The bundle's iframe is full-height, so every host has to give this box a height to
resolve against: in the workspace the layout hands one down, and the share, embed
and custom-path routes floor themselves at the viewport. An iframe with no height
above it falls back to 150px. -->
<div class="h-full w-full">
above it falls back to 150px. Styled inline: app.css may not be loaded here. -->
<div style="height: 100%; width: 100%">
<RawAppPreview
workspace={effectiveWorkspace}
user={ctxUser}
{user}
secret={app.bundle_secret}
path={app.path}
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
@@ -178,58 +118,28 @@
/>
</div>
{:else if app.raw_app && !effectiveWorkspace}
<div class="px-4 mt-20">
<Alert type="error" title="Configuration error">
Unable to load raw app: workspace information is missing.
</Alert>
</div>
{#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]}
<div class="px-4 mt-20">
<Alert type="error" title="Configuration error">
Unable to load raw app: workspace information is missing.
</Alert>
</div>
{/await}
{:else}
<div
class={twMerge(
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
// the public viewer always used a plain `flex` wrapper. `min-h-full`, not
// `h-full`: the box has to reach the bottom of what the host gives it so the
// app's own background covers the window, and still grow with a grid taller
// than that. A viewport floor instead would overhang the page box by the
// height of the page header band.
inWorkspace ? 'min-h-full w-full flex flex-col' : 'min-h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
style={app?.value?.['css']?.['app']?.['viewer']?.style}
>
{#await loadAppPreview()}
<Skeleton layout={[[4], 0.5, [50]]} />
{:then Module}
<Module.default
noBackend={false}
{hideRefreshBar}
context={{
email: ctxUser?.email,
name: ctxUser?.name,
groups: ctxUser?.groups,
username: ctxUser?.username,
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
hash: page.url.hash.substring(1)
}}
workspace={effectiveWorkspace}
summary={app.summary}
app={app.value}
appPath={app.path}
{breakpoint}
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
/>
{:catch}
<div class="px-4 mt-20 w-full">
<Alert type="error" title="Could not load the app">Reload the page to try again.</Alert>
</div>
{/await}
</div>
{#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]}
<PublicLowCodeApp
{app}
workspace={effectiveWorkspace}
{user}
{license}
{inWorkspace}
{hideRefreshBar}
/>
{/await}
{/if}
{/key}
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
{#await loadAppCss() then}
<Skeleton layout={[[4], 0.5, [50]]} />
{/await}
{/if}
@@ -0,0 +1,81 @@
<script lang="ts">
import { base } from '$app/paths'
import { page } from '$app/state'
import Login from '$lib/components/Login.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { UserService, type GlobalWhoamiResponse } from '$lib/gen'
import type { UserExt } from '$lib/stores'
import { dropPrefetched } from './publicAppApi'
let {
notExists,
jwtError,
guestAppPath,
user,
workspace,
onLoginSuccess
}: {
notExists: boolean
jwtError: boolean
guestAppPath: string | undefined
user: UserExt | undefined
workspace: string | undefined
onLoginSuccess: () => void
} = $props()
let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined)
async function loadGlobalUser() {
try {
globalUser = await UserService.globalWhoami()
} catch (error) {
console.error(error)
}
}
// Only the no-access page reads it, to tell a signed-in non-member which
// workspace the app belongs to.
$effect(() => {
if (!notExists && !guestAppPath && !user && !globalUser) {
loadGlobalUser()
}
})
</script>
{#if notExists}
<div class="px-4 mt-20"
><Alert type="error" title="Not found"
>There was an error loading the app, is the url correct? <a href={base}>Go to Windmill</a>
</Alert></div
>
{:else}
{#if guestAppPath && !user}
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div>
<div class="text-center mt-8 text-sm text-primary">
You do not need a Windmill account. Signing in lets you open this app and nothing else.
</div>
{:else}
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
This app requires read access
</div>
<div class="text-center mt-8 text-sm text-primary">
{#if user}You are logged in but have no read access to this app{:else if globalUser && workspace}
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
>{workspace}</span
> this app is part of
{:else}You must be logged in and have read access to this app{/if}</div
>
{/if}
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
{#if !jwtError}
<Login
onLoginSuccess={() => {
dropPrefetched()
onLoginSuccess()
}}
popup
guestApp={guestAppPath}
rd={page.url.pathname + page.url.search + page.url.hash}
/>
{/if}
</div>
{/if}
@@ -23,24 +23,44 @@
* bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`.
*/
import { BROWSER } from 'esm-env'
import { OpenAPI, UserService } from '$lib/gen'
import { OpenAPI } from '$lib/gen/core/OpenAPI'
import { page } from '$app/state'
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import { base } from '$app/paths'
import { goto } from '$app/navigation'
import Login from '$lib/components/Login.svelte'
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils/queryParams'
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
import { loadAppPreview } from './loadAppPreview'
import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte'
import { dropPrefetched, loadAppCss } from './publicAppApi'
import {
hasStoredSdkConsent,
sdkConsentCovers,
storeSdkConsent
} from '$lib/components/raw_apps/sdkScopes'
// Everything but a rendered app (sign-in, errors, the SDK consent prompt) loads on
// demand: a raw app that renders right away needs none of it. See publicAppApi.ts.
let frameUi:
| Promise<{
Alert: typeof import('$lib/components/common/alert/Alert.svelte').default
Login: typeof import('$lib/components/Login.svelte').default
RawAppSdkConsent: typeof import('$lib/components/raw_apps/RawAppSdkConsent.svelte').default
}>
| undefined
function loadFrameUi() {
return (frameUi ??= Promise.all([
loadAppCss(),
import('$lib/components/common/alert/Alert.svelte'),
import('$lib/components/Login.svelte'),
import('$lib/components/raw_apps/RawAppSdkConsent.svelte')
]).then(([, alert, login, consent]) => ({
Alert: alert.default,
Login: login.default,
RawAppSdkConsent: consent.default
})))
}
type EmbedToken = {
token?: string | null
raw_app?: boolean
@@ -235,7 +255,8 @@
if (offerSignIn && accountSession === 'unknown') {
// Workspace-less, so it answers for an account and never for a guest
// (pinned to its workspace) or for nobody.
UserService.getCurrentEmail()
import('$lib/gen')
.then(({ UserService }) => UserService.getCurrentEmail())
.then(() => (accountSession = 'held'))
.catch(() => (accountSession = 'none'))
}
@@ -249,8 +270,12 @@
let staleGuestLogoutFailed = $state(false)
$effect(() => {
if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) {
UserService.logout()
.then(() => (staleGuestCleared = true))
import('$lib/gen')
.then(({ UserService }) => UserService.logout())
.then(() => {
dropPrefetched()
staleGuestCleared = true
})
.catch(() => (staleGuestLogoutFailed = true))
}
})
@@ -427,7 +452,10 @@
// Render the app directly on this origin: same-origin when unsandboxed
// (the default), or a single opaque bundle iframe when it's a sandboxed
// raw app. A low-code app's runtime downloads alongside the app payload.
if (!isRaw) loadAppPreview().catch(() => {})
if (!isRaw) {
loadAppCss().catch(() => {})
loadAppPreview().catch(() => {})
}
onViewerReady?.(undefined, requestTokenRefresh)
} else {
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
@@ -537,6 +565,7 @@
onMount(() => {
if (isViewer) {
// Only a sandboxed low-code app is ever framed as a viewer.
loadAppCss().catch(() => {})
loadAppPreview().catch(() => {})
window.addEventListener('message', handleViewerMessage)
installHashRelay()
@@ -569,105 +598,120 @@
{#if viewerReady}
{@render viewer()}
{:else if viewerOrphaned}
<div class="px-4 mt-20 max-w-xl mx-auto">
<Alert type="info" title="Open this app from Windmill">
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own
page, use the app's public URL without the <code>wm_embed</code> parameter.
</Alert>
</div>
{#await loadFrameUi() then { Alert }}
<div class="px-4 mt-20 max-w-xl mx-auto">
<Alert type="info" title="Open this app from Windmill">
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your
own page, use the app's public URL without the <code>wm_embed</code> parameter.
</Alert>
</div>
{/await}
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
{#await loadAppCss() then}
<Skeleton layout={[[4], 0.5, [50]]} />
{/await}
{/if}
{:else if status === 'loading'}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if status === 'notExists' && !offerSignIn}
<div class="px-4 mt-20">
<Alert type="error" title="Not found">
There was an error loading the app, is the url correct?
<a href={base}>Go to Windmill</a>
</Alert>
</div>
{:else if status === 'sdkPrompt'}
<!-- Raw-app frontend SDK: ask before the app's code runs, so the viewer sees
what it will be able to do with their identity. -->
<RawAppSdkConsent
scopes={sdkScopes ?? []}
onContinue={onSdkConsentContinue}
onDecline={onSdkConsentDecline}
/>
{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)}
<div class="px-4 mt-20">
<Alert type="error" title="Could not check access">
The app could not be reached to find out who may open it. Reload to try again.
</Alert>
</div>
{:else if offerSignIn}
<!-- Login happens here, on the embedder (main) window, so the session cookie
is set on the main origin only and never reaches the opaque iframe. -->
{#if signInDidNotHelp}
<!-- Offering the same sign-in again would loop: they are signed in, and this
app still will not open for them. Say why and stop. -->
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
You are signed in, but this app is not open to you
</div>
<div class="text-center mt-8 text-sm text-primary">
It is open to the people it was shared with{guestAppPath
? ', and to guests who have no Windmill account'
: ''}. Ask the person who shared it to give your account access.
</div>
{#await loadAppCss() then}
<Skeleton layout={[[4], 0.5, [50]]} />
{/await}
{:else if status === 'ready'}
{#if unsandboxed}
<!-- Same-origin (full session): the app was not opted into sandbox isolation
(the default). Rendered directly here; RawAppPreview reads
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
{@render viewer()}
{:else if isRaw}
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
untrusted author bundle stays isolated in its own opaque iframe (inside
RawAppPreview); no opaque viewer and no embed token are needed. -->
{@render viewer()}
{:else}
{#if guestAppPath}
<div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div>
<div class="text-center mt-8 text-sm text-primary">
You do not need a Windmill account. Signing in lets you open this app and nothing else.
</div>
{:else}
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
This app requires read access
</div>
{/if}
{#if signInError}
<div class="px-2 mx-auto mt-8 max-w-xl w-full">
<Alert type="error" title="Could not sign you in">{signInError}</Alert>
</div>
{/if}
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
<Login
onLoginSuccess={() => {
signInError = undefined
accountSession = 'unknown'
initEmbedder()
}}
onLoginError={(message) => (signInError = message)}
popup
guestApp={guestAppPath}
rd={page.url.pathname + page.url.search + page.url.hash}
/>
</div>
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
JWT path segment of share links); without this, the same-origin iframe
navigation would expose it to app-authored code via document.referrer.
Styled inline: it can render before app.css has loaded. -->
<iframe
bind:this={iframeEl}
src={buildViewerUrl()}
title="App"
style="display: block; width: 100%; height: 100%; border: 0"
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
allow="clipboard-read; clipboard-write; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{/if}
{:else if unsandboxed}
<!-- Same-origin (full session): the app was not opted into sandbox isolation
(the default). Rendered directly here; RawAppPreview reads
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
{@render viewer()}
{:else if isRaw}
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
untrusted author bundle stays isolated in its own opaque iframe (inside
RawAppPreview); no opaque viewer and no embed token are needed. -->
{@render viewer()}
{:else}
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
JWT path segment of share links); without this, the same-origin iframe
navigation would expose it to app-authored code via document.referrer. -->
<iframe
bind:this={iframeEl}
src={buildViewerUrl()}
title="App"
class="w-full h-full border-0 block"
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
allow="clipboard-read; clipboard-write; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }}
{#if status === 'notExists' && !offerSignIn}
<div class="px-4 mt-20">
<Alert type="error" title="Not found">
There was an error loading the app, is the url correct?
<a href={base}>Go to Windmill</a>
</Alert>
</div>
{:else if status === 'sdkPrompt'}
<!-- Raw-app frontend SDK: ask before the app's code runs, so the viewer sees
what it will be able to do with their identity. -->
<RawAppSdkConsent
scopes={sdkScopes ?? []}
onContinue={onSdkConsentContinue}
onDecline={onSdkConsentDecline}
/>
{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)}
<div class="px-4 mt-20">
<Alert type="error" title="Could not check access">
The app could not be reached to find out who may open it. Reload to try again.
</Alert>
</div>
{:else if offerSignIn}
<!-- Login happens here, on the embedder (main) window, so the session cookie
is set on the main origin only and never reaches the opaque iframe. -->
{#if signInDidNotHelp}
<!-- Offering the same sign-in again would loop: they are signed in, and this
app still will not open for them. Say why and stop. -->
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
You are signed in, but this app is not open to you
</div>
<div class="text-center mt-8 text-sm text-primary">
It is open to the people it was shared with{guestAppPath
? ', and to guests who have no Windmill account'
: ''}. Ask the person who shared it to give your account access.
</div>
{:else}
{#if guestAppPath}
<div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div
>
<div class="text-center mt-8 text-sm text-primary">
You do not need a Windmill account. Signing in lets you open this app and nothing else.
</div>
{:else}
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
This app requires read access
</div>
{/if}
{#if signInError}
<div class="px-2 mx-auto mt-8 max-w-xl w-full">
<Alert type="error" title="Could not sign you in">{signInError}</Alert>
</div>
{/if}
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
<Login
onLoginSuccess={() => {
dropPrefetched()
signInError = undefined
accountSession = 'unknown'
initEmbedder()
}}
onLoginError={(message) => (signInError = message)}
popup
guestApp={guestAppPath}
rd={page.url.pathname + page.url.search + page.url.hash}
/>
</div>
{/if}
{/if}
{/await}
{/if}
@@ -0,0 +1,91 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/state'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import type { AppWithLastVersion } from '$lib/gen'
import { enterpriseLicense, userStore, type UserExt } from '$lib/stores'
import { urlParamsToObject } from '$lib/utils'
import { getContext } from 'svelte'
import { writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
import { EMBED_NAV_CONTEXT_KEY, type EditorBreakpoint, type EmbedNav } from '../types'
import { loadAppPreview } from './loadAppPreview'
let {
app,
workspace,
user,
license,
inWorkspace,
hideRefreshBar
}: {
app: AppWithLastVersion & { value: any }
workspace: string | undefined
user: UserExt | undefined
license: string | undefined
inWorkspace: boolean
hideRefreshBar: boolean
} = $props()
// The public routes hold the viewer and license as props, while low-code components
// read them from the stores (the license gates the app's custom CSS, for one).
$effect(() => {
if (inWorkspace) return
userStore.set(user)
if (license) enterpriseLicense.set(license)
})
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
// so navigating it would just show a login screen. PublicAppFrame provides the
// relay; outside the opaque viewer this is undefined and goto works directly.
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
const breakpoint = writable<EditorBreakpoint>('lg')
</script>
<div
class={twMerge(
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
// the public viewer always used a plain `flex` wrapper. `min-h-full`, not
// `h-full`: the box has to reach the bottom of what the host gives it so the
// app's own background covers the window, and still grow with a grid taller
// than that. A viewport floor instead would overhang the page box by the
// height of the page header band.
inWorkspace ? 'min-h-full w-full flex flex-col' : 'min-h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
style={app?.value?.['css']?.['app']?.['viewer']?.style}
>
{#await loadAppPreview()}
<Skeleton layout={[[4], 0.5, [50]]} />
{:then Module}
<Module.default
noBackend={false}
{hideRefreshBar}
context={{
email: user?.email,
name: user?.name,
groups: user?.groups,
username: user?.username,
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
hash: page.url.hash.substring(1)
}}
{workspace}
summary={app.summary}
app={app.value}
appPath={app.path}
{breakpoint}
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
/>
{:catch}
<div class="px-4 mt-20 w-full">
<Alert type="error" title="Could not load the app">Reload the page to try again.</Alert>
</div>
{/await}
</div>
@@ -0,0 +1,82 @@
// The public app routes (`/public/...`, `/a/...`) render a raw app as little more than its
// bundle's iframe, so they must not statically import the generated client, `$lib/stores`
// or `$lib/utils`: each drags in most of the app shell. `vite.config.js`
// (`assertLeanPublicAppRoutes`) fails the build if they do. Anything heavier is imported
// lazily by the branch that needs it.
import { OpenAPI } from '$lib/gen/core/OpenAPI'
import type { AppWithLastVersion, User } from '$lib/gen/types.gen'
import type { UserExt } from '$lib/stores'
import { mapUserToUserExt } from '$lib/userExt'
export type PublicAppValue = AppWithLastVersion & { value: any; workspace_id?: string }
declare global {
interface Window {
/** Responses `app.html` requested before any module loaded, keyed by URL. */
__wmPublicAppPrefetch?: Record<string, Promise<Response>>
}
}
/** Carries `status` like the generated client's `ApiError`, which callers branch on. */
export class PublicApiError extends Error {
constructor(public status: number) {
super(`Request failed with status ${status}`)
}
}
/** Prefetched responses answer for the credential the page loaded with; call this when it
* changes (a sign-in or logout on the page) so later reads go to the network. */
export function dropPrefetched(): void {
delete window.__wmPublicAppPrefetch
}
function takePrefetched(url: string): Promise<Response> | undefined {
const prefetched = window.__wmPublicAppPrefetch
const res = prefetched?.[url]
if (res) delete prefetched![url]
return res
}
/** GET an API path with the page credential: the bearer token in `OpenAPI.TOKEN` when the
* share link carries one, the session cookie otherwise. A prefetched response is only
* taken in the cookie case, which is the only credential `app.html` sends. */
export async function publicGet<T>(path: string): Promise<T> {
const url = `${OpenAPI.BASE}${path}`
const token = typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined
const res = await ((!token && takePrefetched(url)) ||
fetch(url, {
// As the generated client does: the opaque viewer frame is cross-origin to the
// API, whose `Access-Control-Allow-Origin: *` refuses a credentialed request.
credentials: OpenAPI.WITH_CREDENTIALS ? OpenAPI.CREDENTIALS : undefined,
headers: token ? { Authorization: `Bearer ${token}` } : {}
}))
if (!res.ok) throw new PublicApiError(res.status)
return (res.headers.get('content-type')?.includes('json') ? res.json() : res.text()) as T
}
export async function getUserExtOrUndefined(workspace: string): Promise<UserExt | undefined> {
try {
return mapUserToUserExt(
await publicGet<User>(`/w/${encodeURI(workspace)}/users/whoami`),
workspace
)
} catch {
return undefined
}
}
/** Only fades the "Powered by" badge, so a failure is not worth surfacing. */
export async function getLicenseOrUndefined(): Promise<string | undefined> {
try {
return (await publicGet<string>('/ee_license')) || undefined
} catch {
return undefined
}
}
let appCss: Promise<unknown> | undefined
/** The app-wide stylesheet, which these routes need only off the raw-app path: sign-in,
* errors, skeletons and low-code apps. A raw app's bundle brings its own styles. */
export function loadAppCss(): Promise<unknown> {
return (appCss ??= import('$lib/assets/app.css'))
}
@@ -52,6 +52,11 @@
// Job ids launched by this app instance — see `gateJobIds`.
const launchedJobs = new Set<string>()
/** For a host that loads this component after the bundle may already have sent requests. */
export function handleMessage(event: MessageEvent) {
return listener(event)
}
let listener = async (event) => {
// Only accept messages from the bundle iframe (opaque origin) or the
// detached preview window we opened, so other frames/extensions can't
@@ -1,6 +1,6 @@
<script lang="ts">
import { type UserExt } from '$lib/stores'
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
import type { UserExt } from '$lib/stores'
import type RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
import type { Runnable } from './rawAppPolicy'
import { getContext, onMount, untrack } from 'svelte'
import {
@@ -33,8 +33,7 @@
syncHashToUrl?: boolean
}
let { workspace, user, secret, path, runnables, oniframe, syncHashToUrl = true }: Props =
$props()
let { workspace, user, secret, path, runnables, oniframe, syncHashToUrl = true }: Props = $props()
const pageHash = () => (syncHashToUrl ? window.location.hash : '')
$effect(() => {
@@ -46,6 +45,48 @@
})
let iframe = $state() as HTMLIFrameElement | undefined
// White behind apps that set no background of their own, but only once the document
// has loaded: before that it would flash under a dark app.
let loadedSrc: string | undefined = $state(undefined)
// The job bridge reaches the generated client and the app shell's stores, which the
// bundle's first paint does not need, so it loads alongside the bundle. Requests the
// bundle sends before it mounts are queued and handed over in order.
const runnerModule = import('./RawAppBackgroundRunner.svelte')
let runner: ReturnType<typeof RawAppBackgroundRunner> | undefined = $state()
let queuedForRunner: MessageEvent[] = []
$effect(() => {
if (!runner) return
const queued = queuedForRunner
queuedForRunner = []
for (const event of queued) runner.handleMessage(event)
})
// If the job bridge cannot load (offline, or a chunk a redeploy removed), the bundle
// still paints, so its requests are answered with an error rather than left pending.
let runnerFailed = false
let requestRefused = $state(false)
function refuseRequest(event: MessageEvent) {
const data = event.data
if (typeof data?.type !== 'string' || data.reqId === undefined) return
requestRefused = true
;(event.source as Window | null)?.postMessage(
{
type: data.type + 'Res',
reqId: data.reqId,
error: true,
result: { message: 'Could not load the app runtime, reload the page to try again' }
},
'*'
)
}
runnerModule.catch((e) => {
console.error('Could not load the raw app job bridge', e)
runnerFailed = true
const queued = queuedForRunner
queuedForRunner = []
for (const event of queued) refuseRequest(event)
})
// Get initial hash from parent URL to pass to the iframe
let initialHash = ''
@@ -240,12 +281,16 @@
)
} catch (_) {}
}
} else if (data?.type === 'windmill:hashchange' && syncHashToUrl) {
} else if (data?.type === 'windmill:hashchange') {
// Keep the parent URL hash in sync for shareable URLs.
const newHash = data.hash || ''
if (window.location.hash !== newHash) {
if (syncHashToUrl && window.location.hash !== newHash) {
history.replaceState(null, '', newHash || window.location.pathname)
}
} else if (runnerFailed) {
refuseRequest(event)
} else if (!runner) {
queuedForRunner.push(event)
}
}
@@ -254,14 +299,27 @@
})
</script>
<RawAppBackgroundRunner
{workspace}
editor={false}
{iframe}
{runnables}
{path}
gateJobIds={!unsandboxed}
/>
{#await runnerModule then { default: Runner }}
<Runner
bind:this={runner}
{workspace}
editor={false}
{iframe}
{runnables}
{path}
gateJobIds={!unsandboxed}
/>
{:catch}
{#if requestRefused}
<!-- Styled inline: neither app.css nor another chunk can be counted on here. -->
<div
role="alert"
style="position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 1000; padding: 8px 12px; border: 1px solid #fca5a5; border-radius: 6px; background: #fef2f2; color: #991b1b; font: 13px/1.4 system-ui, sans-serif"
>
Could not load the app runtime. Reload the page to try again.
</div>
{/if}
{/await}
{#if iframeSrc}
<!-- `unsandboxed` (the default — publisher did not opt into isolation) adds
@@ -271,12 +329,15 @@
<!-- referrerpolicy (sandboxed only, for exact legacy parity): the hosting page
URL can carry a viewer credential (the JWT path segment of share links);
without this, the bundle document would see it via document.referrer. -->
<!-- Styled inline: on the public app routes it renders before app.css, if ever, loads. -->
<iframe
bind:this={iframe}
title="raw-app"
src={iframeSrc}
sandbox={sandboxAttr}
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
class="w-full h-full bg-white border-none"
onload={() => (loadedSrc = iframeSrc)}
style="display: block; width: 100%; height: 100%; border: none"
style:background={loadedSrc === iframeSrc ? 'white' : 'transparent'}
></iframe>
{/if}
+2 -14
View File
@@ -1,5 +1,6 @@
import { type User, UserService } from '$lib/gen'
import { UserService } from '$lib/gen'
import type { UserExt } from './stores.js'
import { mapUserToUserExt } from './userExt'
async function fetchUserExt(workspace: string): Promise<UserExt> {
return mapUserToUserExt(await UserService.whoami({ workspace }), workspace)
@@ -42,16 +43,3 @@ export function getWorkspaceRole(workspace: string): Promise<RoleLookup> {
export function clearWorkspaceRoleCache(): void {
workspaceRoleCache.clear()
}
function mapUserToUserExt(user: User, workspace: string): UserExt {
const ext: UserExt = {
...user,
workspace_id: workspace,
groups: user.groups!,
pgroups: user.groups!.map((x) => `g/${x}`)
}
if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) {
ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined
}
return ext
}
+15
View File
@@ -0,0 +1,15 @@
import type { User } from '$lib/gen/types.gen'
import type { UserExt } from './stores'
export function mapUserToUserExt(user: User, workspace: string): UserExt {
const ext: UserExt = {
...user,
workspace_id: workspace,
groups: user.groups!,
pgroups: user.groups!.map((x) => `g/${x}`)
}
if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) {
ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined
}
return ext
}
+1 -22
View File
@@ -1322,28 +1322,7 @@ export function isCodeInjection(expr: string | undefined): boolean {
return dynamicTemplateRegex.test(expr)
}
// Query params Windmill consumes internally and that should not be exposed to
// app logic via the `query` context. Only params we actually own are listed
// here — the `wm_` prefix is a naming convention, not a reserved namespace, so
// we don't strip it wholesale (that would break apps reading their own `wm_*`
// params). `wm_coep` is a transport flag for cross-origin isolation headers;
// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params
// (see PublicAppFrame).
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin'])
export function urlParamsToObject(
params: URLSearchParams,
opts?: { stripReserved?: boolean }
): Record<string, string> {
const result: Record<string, string> = {}
params.forEach((value, key) => {
if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) {
return
}
result[key] = value
})
return result
}
export { WINDMILL_RESERVED_QUERY_PARAMS, urlParamsToObject } from './utils/queryParams'
export async function tryEvery({
tryCode,
+22
View File
@@ -0,0 +1,22 @@
// Query params Windmill consumes internally and that should not be exposed to
// app logic via the `query` context. Only params we actually own are listed
// here — the `wm_` prefix is a naming convention, not a reserved namespace, so
// we don't strip it wholesale (that would break apps reading their own `wm_*`
// params). `wm_coep` is a transport flag for cross-origin isolation headers;
// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params
// (see PublicAppFrame).
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin'])
export function urlParamsToObject(
params: URLSearchParams,
opts?: { stripReserved?: boolean }
): Record<string, string> {
const result: Record<string, string> = {}
params.forEach((value, key) => {
if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) {
return
}
result[key] = value
})
return result
}
@@ -1,4 +1,6 @@
<script lang="ts">
// Resets to the root layout, which does not import it.
import '$lib/assets/app.css'
import { page } from '$app/state'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { clearUser } from '$lib/logout'
@@ -1,4 +1,6 @@
<script lang="ts">
// First, so its rules precede every component's.
import '$lib/assets/app.css'
import { base } from '$app/paths'
import { goto } from '$lib/navigation'
import { page } from '$app/state'
@@ -0,0 +1,9 @@
<script lang="ts">
// The root layout leaves app.css to the layouts under it so that the public app routes
// can skip it; every other top-level route lives in this group to get it.
import '$lib/assets/app.css'
let { children }: { children?: import('svelte').Snippet } = $props()
</script>
{@render children?.()}
@@ -93,6 +93,7 @@
<PublicApp
{app}
{workspace}
user={$userStore}
{notExists}
{noPermission}
jwtError={false}
+14 -3
View File
@@ -1,8 +1,17 @@
<script module lang="ts">
// app.css is imported by the layouts below this one, not here: the public app routes
// (`public/`, `a/`) load it only when they render more than a raw app's bundle.
// A production build links a layout's CSS ahead of its page's, but in dev a stylesheet
// lands when its module evaluates, and a page's can evaluate before its layout's. This
// module is imported at startup, so loading it here in dev keeps app.css first.
if (import.meta.env.DEV && !/^\/(public|a)\//.test(location.pathname)) {
import('$lib/assets/app.css')
}
</script>
<script lang="ts">
import { page } from '$app/state'
import { SvelteToast } from '@zerodevx/svelte-toast'
import '$lib/assets/app.css'
import { installDevPollingDormancy } from '$lib/utils/devPollingDormancy'
interface Props {
children?: import('svelte').Snippet
@@ -65,7 +74,9 @@
{@render children?.()}
<div class="wrap">
<SvelteToast options={toastOptions} />
{#await import('@zerodevx/svelte-toast') then { SvelteToast }}
<SvelteToast options={toastOptions} />
{/await}
</div>
<style>
+46 -43
View File
@@ -1,19 +1,23 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { OpenAPI } from '$lib/gen/core/OpenAPI'
import type { GuestEntry } from '$lib/gen/types.gen'
import type { UserExt } from '$lib/stores'
import { page } from '$app/state'
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import {
getLicenseOrUndefined,
getUserExtOrUndefined,
publicGet,
type PublicAppValue
} from '$lib/components/apps/editor/publicAppApi'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let app: PublicAppValue | undefined = $state(undefined)
let user: UserExt | undefined = $state(undefined)
let license: string | undefined = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let jwtError = $state(false)
@@ -73,6 +77,7 @@
const viewerUrl = `${base}/a/${parsedCustomPath.path}${page.url.search}${page.url.hash}`
let workspace: string | undefined = $state(undefined)
let embedWorkspace: string | undefined
let refresh: (() => void) | undefined
/** `<workspace>/<app_path>` when this app is open to guests. Resolved eagerly:
* PublicAppFrame renders its sign-in gate before `onViewerReady` fires. */
@@ -92,9 +97,9 @@
if (guestEntrySettled()) return
for (let attempt = 0; attempt < 3; attempt++) {
try {
const entry = await AppService.getGuestEntryByCustomPath({
customPath: parsedCustomPath.path
})
const entry = await publicGet<GuestEntry>(
`/apps_u/guest_entry_by_custom_path/${encodeURI(parsedCustomPath.path)}`
)
guestAppPath = `${entry.workspace_id}/${entry.app_path}`
guestEntry = 'guest'
return
@@ -123,46 +128,41 @@
} else if (parsedCustomPath.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
}
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}${consent}`,
{ headers }
const resp = await publicGet<{ token?: string; workspace_id?: string }>(
`/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}${consent}`
)
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
embedWorkspace = resp.workspace_id ?? embedWorkspace
return resp
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
try {
app = await AppService.getPublicAppByCustomPath({
customPath: parsedCustomPath.path
})
workspace = app.workspace_id
workspaceStore.set(app.workspace_id)
// The embed token names the workspace, so the user loads alongside the app
// rather than after it. The opaque viewer frame never fetches one.
const userLoad = embedWorkspace ? getUserExtOrUndefined(embedWorkspace) : undefined
const loaded = await publicGet<PublicAppValue>(
`/apps_u/public_app_by_custom_path/${encodeURI(parsedCustomPath.path)}`
)
const appWorkspace = loaded.workspace_id!
user = await (userLoad && embedWorkspace === appWorkspace
? userLoad
: getUserExtOrUndefined(appWorkspace))
import('$lib/stores').then(({ workspaceStore }) => workspaceStore.set(appWorkspace))
app = loaded
workspace = appWorkspace
noPermission = false
notExists = false
jwtError = false
try {
userStore.set(await getUserExt(app.workspace_id))
// A JWT in the custom path that fails to resolve a user is surfaced as a
// toast (matches the pre-sandbox custom-path viewer) rather than silently
// falling through to anonymous.
if (!$userStore && parsedCustomPath.jwt) {
jwtError = true
// A JWT in the custom path that fails to resolve a user is surfaced as a
// toast (matches the pre-sandbox custom-path viewer) rather than silently
// falling through to anonymous.
if (!user && parsedCustomPath.jwt) {
jwtError = true
import('$lib/toast').then(({ sendUserToast }) =>
sendUserToast('Could not authentify user with jwt token', true)
}
} catch (e) {
console.warn('Anonymous user')
)
}
} catch (e) {
if (e.status == 401) {
@@ -182,14 +182,15 @@
}
if (BROWSER) {
setLicense()
getLicenseOrUndefined().then((l) => (license = l))
loadGuestEntry()
}
</script>
<!-- The route is what gives the app a height: nothing above it has one (`app.html` wraps the
body in `display: contents`), and the viewer's own boxes are `h-full`. -->
<div class="h-screen w-full">
body in `display: contents`), and the viewer's own boxes are full-height. Styled
inline: a raw app renders here without app.css. -->
<div style="height: 100vh; width: 100%">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
@@ -203,6 +204,8 @@
{#snippet viewer()}
<PublicApp
{workspace}
{user}
{license}
{notExists}
{noPermission}
{jwtError}
@@ -1,18 +1,23 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { userStore } from '$lib/stores'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { OpenAPI } from '$lib/gen/core/OpenAPI'
import type { GuestEntry } from '$lib/gen/types.gen'
import type { UserExt } from '$lib/stores'
import { page } from '$app/state'
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import {
getLicenseOrUndefined,
getUserExtOrUndefined,
publicGet,
type PublicAppValue
} from '$lib/components/apps/editor/publicAppApi'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let app: PublicAppValue | undefined = $state(undefined)
let user: UserExt | undefined = $state(undefined)
let license: string | undefined = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let jwtError = $state(false)
@@ -70,35 +75,21 @@
} else if (parsedSecret.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
}
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(
`${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`,
{ headers }
)
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
return await publicGet(`/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`)
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
// Neither waits on the other: `app.html` already requested both.
const userLoad = getUserExtOrUndefined(workspace)
const appLoad = publicGet<PublicAppValue>(
`/w/${workspace}/apps_u/public_app/${encodeURI(parsedSecret.secret)}`
)
appLoad.catch(() => {})
user = await userLoad
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
app = await AppService.getPublicAppBySecret({
workspace,
path: parsedSecret.secret
})
app = await appLoad
noPermission = false
notExists = false
} catch (e) {
@@ -126,7 +117,9 @@
if (guestEntrySettled()) return
for (let attempt = 0; attempt < 3; attempt++) {
try {
const entry = await AppService.getGuestEntry({ workspace, path: parsedSecret.secret })
const entry = await publicGet<GuestEntry>(
`/w/${workspace}/apps_u/guest_entry/${encodeURI(parsedSecret.secret)}`
)
guestAppPath = `${workspace}/${entry.app_path}`
guestEntry = 'guest'
return
@@ -156,13 +149,14 @@
}
if (BROWSER) {
setLicense()
getLicenseOrUndefined().then((l) => (license = l))
}
</script>
<!-- The route is what gives the app a height: nothing above it has one (`app.html` wraps the
body in `display: contents`), and the viewer's own boxes are `h-full`. -->
<div class="h-screen w-full">
body in `display: contents`), and the viewer's own boxes are full-height. Styled
inline: a raw app renders here without app.css. -->
<div style="height: 100vh; width: 100%">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
@@ -177,6 +171,8 @@
<PublicApp
{app}
{workspace}
{user}
{license}
{notExists}
{noPermission}
{jwtError}
+28 -7
View File
@@ -108,25 +108,34 @@ const isClientBuild = (bundle) =>
Object.keys(bundle).some((file) => file.startsWith('_app/immutable/'))
/**
* Fail the build if a public app URL statically loads the low-code runtime or monaco.
* Fail the build if a public app URL statically loads more than a raw app needs.
*
* These pages also serve raw apps, which only need a small shell around their bundle's
* iframe. One static import of AppPreview (or of anything reaching monaco) makes every
* public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts.
* iframe, and the page shows nothing until its static imports have loaded. One static
* import of AppPreview (or of anything reaching monaco) makes every public app page
* preload ~650 chunks; one of the generated client, `$lib/stores` or `$lib/utils` drags
* in most of the app shell. The root layout wraps these pages, so it must not import
* app.css either. See loadAppPreview.ts and publicAppApi.ts.
*/
function assertLeanPublicAppRoutes() {
// Route directories, so +page.js counts as well as +page.svelte.
const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/']
const forbidden = [
'/src/lib/components/apps/editor/AppPreview.svelte',
'/node_modules/monaco-editor/'
'/node_modules/monaco-editor/',
'/src/lib/assets/app.css',
'/src/lib/gen/services.gen.ts',
'/src/lib/stores.ts',
'/src/lib/utils.ts',
'/src/lib/components/Login.svelte',
'/node_modules/@melt-ui/'
]
return {
name: 'wm-assert-lean-public-app-routes',
generateBundle(_options, bundle) {
if (!isClientBuild(bundle)) return
for (const route of routes) {
assertStaticClosureExcludes(this, bundle, route, forbidden, 'loadAppPreview.ts')
for (const route of [...routes, '/src/routes/+layout.svelte']) {
assertStaticClosureExcludes(this, bundle, route, forbidden, 'publicAppApi.ts')
}
}
}
@@ -372,7 +381,19 @@ const config = {
// which makes the *chunk* graph cyclic where the module graph is not, and
// modules then evaluate against uninitialized bindings. See
// docs/frontend-import-cycles.md.
advancedChunks: { groups: [{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }] }
// The client's config is split out for the public app routes, which set the
// credential without loading the client. It imports nothing at runtime, so
// its chunk cannot be part of a cycle.
advancedChunks: {
groups: [
{
name: 'gen-config',
test: /[\\/]src[\\/]lib[\\/]gen[\\/]core[\\/]OpenAPI\.ts$/,
priority: 1
},
{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }
]
}
}
}
},