mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
co-authored by
Ruben Fiszel
parent
0e7d974ce9
commit
c09764fa65
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <WindmillIcon /> 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 <WindmillIcon /> 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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.()}
|
||||
+1
@@ -93,6 +93,7 @@
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
user={$userStore}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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[\\/]/ }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user