From c09764fa6516badddbdbb520af32e5d1541cf996 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:15:01 +0200 Subject: [PATCH] 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 --- backend/windmill-api/src/apps.rs | 9 +- docs/pipeline-local-dev.md | 2 +- frontend/src/app.html | 69 +++++ .../apps/editor/InWorkspaceAppViewer.svelte | 4 +- .../components/apps/editor/PublicApp.svelte | 208 ++++----------- .../apps/editor/PublicAppAccess.svelte | 81 ++++++ .../apps/editor/PublicAppFrame.svelte | 252 ++++++++++-------- .../apps/editor/PublicLowCodeApp.svelte | 91 +++++++ .../components/apps/editor/publicAppApi.ts | 82 ++++++ .../raw_apps/RawAppBackgroundRunner.svelte | 5 + .../components/raw_apps/RawAppPreview.svelte | 91 +++++-- frontend/src/lib/user.ts | 16 +- frontend/src/lib/userExt.ts | 15 ++ frontend/src/lib/utils.ts | 23 +- frontend/src/lib/utils/queryParams.ts | 22 ++ .../(logged)/user/(user)/logout/+page@.svelte | 2 + frontend/src/routes/(root)/+layout.svelte | 2 + frontend/src/routes/(styled)/+layout.svelte | 9 + .../[workspace]/[...path]/+page.svelte | 1 + .../approve/[workspace]/[job]/+page.svelte | 0 .../[job]/[resume]/[hmac]/+page.svelte | 0 .../src/routes/{ => (styled)}/dev/+page.js | 0 .../routes/{ => (styled)}/dev/+page.svelte | 0 .../routes/{ => (styled)}/flows/dev/+page.js | 0 .../{ => (styled)}/flows/dev/+page.svelte | 0 .../{ => (styled)}/gh_success/+page.svelte | 0 .../{ => (styled)}/kitchen_sink/+page.svelte | 0 .../deploy_animation/+page.svelte | 0 .../kitchen_sink/icons/+page.svelte | 0 .../kitchen_sink/login/+page.svelte | 0 .../oauth/callback/[client_name]/+page.svelte | 0 .../oauth/callback_slack/+page.svelte | 0 .../callback_slack/instance/+page.svelte | 0 .../oauth/callback_supabase/+page.svelte | 0 .../{ => (styled)}/pipeline_dev/+page.svelte | 0 .../public_run/[workspace]/[id]/+page.svelte | 0 .../routes/{ => (styled)}/replay/+page.svelte | 0 .../{ => (styled)}/scripts/dev/+page.js | 0 .../{ => (styled)}/scripts/dev/+page.svelte | 0 .../{ => (styled)}/scripts/p/[...path].svelte | 0 .../{ => (styled)}/test_dev/+layout.svelte | 0 .../test_dev/TestDevHeader.svelte | 0 .../test_dev/diff-comparison/+page.svelte | 0 .../test_dev/kitchen_sink/+page.svelte | 0 .../test_dev/public_app_imports/+page.svelte | 0 .../test_dev/sdk_app/+page.svelte | 0 .../test_dev/sdk_flow/+page.svelte | 0 .../test_dev/sdk_resource/+page.svelte | 0 .../test_dev/sdk_schema/+page.svelte | 0 .../test_dev/sdk_script/+page.svelte | 0 .../test_dev/test_dev_script/+page.svelte | 0 .../{ => (styled)}/user/close/+page.svelte | 0 .../user/forgot-password/+page.svelte | 0 .../login_callback/[client_name]/+page.svelte | 0 .../user/login_link_expired/+page.svelte | 0 .../user/reset-password/+page.svelte | 0 .../{ => (styled)}/view_graph/+page.svelte | 0 frontend/src/routes/+layout.svelte | 17 +- frontend/src/routes/a/[...path]/+page.svelte | 89 ++++--- .../[workspace]/[...secret]/+page.svelte | 64 +++-- frontend/vite.config.js | 35 ++- 61 files changed, 794 insertions(+), 395 deletions(-) create mode 100644 frontend/src/lib/components/apps/editor/PublicAppAccess.svelte create mode 100644 frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte create mode 100644 frontend/src/lib/components/apps/editor/publicAppApi.ts create mode 100644 frontend/src/lib/userExt.ts create mode 100644 frontend/src/lib/utils/queryParams.ts create mode 100644 frontend/src/routes/(styled)/+layout.svelte rename frontend/src/routes/{ => (styled)}/app_embed/[workspace]/[...path]/+page.svelte (99%) rename frontend/src/routes/{ => (styled)}/approve/[workspace]/[job]/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/dev/+page.js (100%) rename frontend/src/routes/{ => (styled)}/dev/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/flows/dev/+page.js (100%) rename frontend/src/routes/{ => (styled)}/flows/dev/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/gh_success/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/kitchen_sink/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/kitchen_sink/deploy_animation/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/kitchen_sink/icons/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/kitchen_sink/login/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/oauth/callback/[client_name]/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/oauth/callback_slack/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/oauth/callback_slack/instance/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/oauth/callback_supabase/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/pipeline_dev/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/public_run/[workspace]/[id]/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/replay/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/scripts/dev/+page.js (100%) rename frontend/src/routes/{ => (styled)}/scripts/dev/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/scripts/p/[...path].svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/+layout.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/TestDevHeader.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/diff-comparison/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/kitchen_sink/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/public_app_imports/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/sdk_app/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/sdk_flow/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/sdk_resource/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/sdk_schema/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/sdk_script/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/test_dev/test_dev_script/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/user/close/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/user/forgot-password/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/user/login_callback/[client_name]/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/user/login_link_expired/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/user/reset-password/+page.svelte (100%) rename frontend/src/routes/{ => (styled)}/view_graph/+page.svelte (100%) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index f276e6d143..26b369ce74 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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())); diff --git a/docs/pipeline-local-dev.md b/docs/pipeline-local-dev.md index e05acec9f0..fb8d49b296 100644 --- a/docs/pipeline-local-dev.md +++ b/docs/pipeline-local-dev.md @@ -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 diff --git a/frontend/src/app.html b/frontend/src/app.html index aad967fd91..3c9e232e0f 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -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 @@ } })() + %sveltekit.head% diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index ecc1e693e1..cb56b37498 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -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 @@ 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(EMBED_NAV_CONTEXT_KEY) - - const breakpoint = writable('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(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() - } - }) + {#if !inWorkspace} - + Powered by   Windmill + + {/await} {/if} -{#if notExists} -
There was an error loading the app, is the url correct? Go to Windmill -
-{:else if noPermission} - {#if guestAppPath && !$userStore} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
-
- {#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 {effectiveWorkspace} this app is part of - {:else}You must be logged in and have read access to this app{/if}
- {/if} -
- {#if !jwtError} - - {/if} -
+{#if notExists || noPermission} + {#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]} + + {/await} {:else if app} {#key app} {#if app.raw_app && effectiveWorkspace} - -
+ above it falls back to 150px. Styled inline: app.css may not be loaded here. --> +
} @@ -178,58 +118,28 @@ />
{:else if app.raw_app && !effectiveWorkspace} -
- - Unable to load raw app: workspace information is missing. - -
+ {#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]} +
+ + Unable to load raw app: workspace information is missing. + +
+ {/await} {:else} -
- {#await loadAppPreview()} - - {:then Module} - goto(path)} - gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} - /> - {:catch} -
- Reload the page to try again. -
- {/await} -
+ {#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]} + + {/await} {/if} {/key} {:else} - + {#await loadAppCss() then} + + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte new file mode 100644 index 0000000000..9a993b4972 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte @@ -0,0 +1,81 @@ + + +{#if notExists} +
There was an error loading the app, is the url correct? Go to Windmill +
+{:else} + {#if guestAppPath && !user} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+
+ {#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 {workspace} this app is part of + {:else}You must be logged in and have read access to this app{/if}
+ {/if} +
+ {#if !jwtError} + { + dropPrefetched() + onLoginSuccess() + }} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> + {/if} +
+{/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 934511abce..dbb9b4b88b 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -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} -
- - 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 wm_embed parameter. - -
+ {#await loadFrameUi() then { Alert }} +
+ + 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 wm_embed parameter. + +
+ {/await} {:else} - + {#await loadAppCss() then} + + {/await} {/if} {:else if status === 'loading'} - -{:else if status === 'notExists' && !offerSignIn} -
- - There was an error loading the app, is the url correct? - Go to Windmill - -
-{:else if status === 'sdkPrompt'} - - -{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} - -{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} -
- - The app could not be reached to find out who may open it. Reload to try again. - -
-{:else if offerSignIn} - - {#if signInDidNotHelp} - -
- You are signed in, but this app is not open to you -
-
- 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. -
+ {#await loadAppCss() then} + + {/await} +{:else if status === 'ready'} + {#if unsandboxed} + + {@render viewer()} + {:else if isRaw} + + {@render viewer()} {:else} - {#if guestAppPath} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
- {/if} - {#if signInError} -
- {signInError} -
- {/if} -
- { - signInError = undefined - accountSession = 'unknown' - initEmbedder() - }} - onLoginError={(message) => (signInError = message)} - popup - guestApp={guestAppPath} - rd={page.url.pathname + page.url.search + page.url.hash} - /> -
+ + {/if} -{:else if unsandboxed} - - {@render viewer()} -{:else if isRaw} - - {@render viewer()} {:else} - - + {#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }} + {#if status === 'notExists' && !offerSignIn} +
+ + There was an error loading the app, is the url correct? + Go to Windmill + +
+ {:else if status === 'sdkPrompt'} + + + {:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} + + {:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} +
+ + The app could not be reached to find out who may open it. Reload to try again. + +
+ {:else if offerSignIn} + + {#if signInDidNotHelp} + +
+ You are signed in, but this app is not open to you +
+
+ 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. +
+ {:else} + {#if guestAppPath} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+ {/if} + {#if signInError} +
+ {signInError} +
+ {/if} +
+ { + dropPrefetched() + signInError = undefined + accountSession = 'unknown' + initEmbedder() + }} + onLoginError={(message) => (signInError = message)} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> +
+ {/if} + {/if} + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte new file mode 100644 index 0000000000..f74be67166 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte @@ -0,0 +1,91 @@ + + +
+ {#await loadAppPreview()} + + {:then Module} + goto(path)} + gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} + /> + {:catch} +
+ Reload the page to try again. +
+ {/await} +
diff --git a/frontend/src/lib/components/apps/editor/publicAppApi.ts b/frontend/src/lib/components/apps/editor/publicAppApi.ts new file mode 100644 index 0000000000..674cb0b622 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/publicAppApi.ts @@ -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> + } +} + +/** 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 | 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(path: string): Promise { + 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 { + try { + return mapUserToUserExt( + await publicGet(`/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 { + try { + return (await publicGet('/ee_license')) || undefined + } catch { + return undefined + } +} + +let appCss: Promise | 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 { + return (appCss ??= import('$lib/assets/app.css')) +} diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index c3eadc1f68..9f675664b1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -52,6 +52,11 @@ // Job ids launched by this app instance — see `gateJobIds`. const launchedJobs = new Set() + /** 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 diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 8a79b4b8f9..9eced2d0e6 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -1,6 +1,6 @@ - +{#await runnerModule then { default: Runner }} + +{:catch} + {#if requestRefused} + +
+ Could not load the app runtime. Reload the page to try again. +
+ {/if} +{/await} {#if iframeSrc} + {/if} diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 006c2ab60c..12d6f237ba 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -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 { return mapUserToUserExt(await UserService.whoami({ workspace }), workspace) @@ -42,16 +43,3 @@ export function getWorkspaceRole(workspace: string): Promise { 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 -} diff --git a/frontend/src/lib/userExt.ts b/frontend/src/lib/userExt.ts new file mode 100644 index 0000000000..d06e1cd04d --- /dev/null +++ b/frontend/src/lib/userExt.ts @@ -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 +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index ec112607fd..8540b964e8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 { - const result: Record = {} - 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, diff --git a/frontend/src/lib/utils/queryParams.ts b/frontend/src/lib/utils/queryParams.ts new file mode 100644 index 0000000000..ffefc47eea --- /dev/null +++ b/frontend/src/lib/utils/queryParams.ts @@ -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 { + const result: Record = {} + params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } + result[key] = value + }) + return result +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index 75e31a9a00..c9623bc340 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -1,4 +1,6 @@ + +{@render children?.()} diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte similarity index 99% rename from frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte rename to frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index 85a72dd7e3..9b3a9c1e41 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -93,6 +93,7 @@ + // 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') + } + + -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
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 = {} - 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( + `/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( + `/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)) } -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
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[\\/]/ } + ] + } } } },