- You do not need a Windmill account. Signing in lets you open this app and nothing else.
-
- {:else}
-
- {#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}
-
+ 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}
-
- {/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}
+
+ {/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[\\/]/ }
+ ]
+ }
}
}
},