perf: lazy-load the low-code runtime on public app pages (#11087)

* perf: lazy-load the low-code runtime on public app pages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bSzETfUb23CRRMEJdwqeS

* perf: fetch the low-code runtime alongside the app payload

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bSzETfUb23CRRMEJdwqeS

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-11 11:14:09 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent d8b9174235
commit e651b4cd63
18 changed files with 161 additions and 84 deletions
@@ -7,10 +7,10 @@
import {
cleanValueProperties,
orderedJsonStringify,
orderedYamlStringify,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import type { Script } from '$lib/gen'
type DiffData = {
@@ -5,7 +5,8 @@
import SchemaViewer from './SchemaViewer.svelte'
import FlowGraphViewer from './FlowGraphViewer.svelte'
import { Loader2 } from 'lucide-svelte'
import { orderedYamlStringify, cleanValueProperties, replaceFalseWithUndefined } from '$lib/utils'
import { cleanValueProperties, replaceFalseWithUndefined } from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import { workspaceStore } from '$lib/stores'
import { watch } from 'runed'
@@ -2,12 +2,8 @@
import { Button } from '$lib/components/common'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { classes } from '$lib/components/common/alert/model'
import {
cleanValueProperties,
orderedYamlStringify,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import { cleanValueProperties, replaceFalseWithUndefined, type Value } from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import { AlertCircle, Diff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { fade } from 'svelte/transition'
+7 -9
View File
@@ -16,15 +16,13 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import {
Auth0Icon,
GithubIcon,
GitlabIcon,
GoogleIcon,
MicrosoftIcon,
NextcloudIcon,
OktaIcon
} from '$lib/components/icons'
import Auth0Icon from '$lib/components/icons/Auth0Icon.svelte'
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
import GitlabIcon from '$lib/components/icons/GitlabIcon.svelte'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
import MicrosoftIcon from '$lib/components/icons/MicrosoftIcon.svelte'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import OktaIcon from '$lib/components/icons/OktaIcon.svelte'
import PocketIdIcon from '$lib/components/icons/PocketIdIcon.svelte'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
@@ -24,7 +24,8 @@ doesn't reflow the parent.
import RawAppFileDiff from './raw_apps/RawAppFileDiff.svelte'
import type { RawAppFileItem } from './raw_apps/rawAppDiffUtils'
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { cleanValueProperties, replaceFalseWithUndefined } from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import { scriptLangToEditorLang } from '$lib/scripts'
interface Props {
@@ -9,10 +9,10 @@
cleanValueProperties,
displayDate,
emptyString,
orderedYamlStringify,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
import { AppService, type AppWithLastVersion, type AppHistory } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Skeleton } from '$lib/components/common'
@@ -6,8 +6,9 @@
import { page } from '$app/state'
import Login from '$lib/components/Login.svelte'
import { isCloudHosted } from '$lib/cloud'
import { Alert, Skeleton } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
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, onMount, setContext } from 'svelte'
import {
EMBED_NAV_CONTEXT_KEY,
@@ -18,7 +19,7 @@
import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen'
import { urlParamsToObject } from '$lib/utils'
import { goto } from '$app/navigation'
import AppPreview from './AppPreview.svelte'
import { loadAppPreview } from './loadAppPreview'
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
import { twMerge } from 'tailwind-merge'
@@ -188,27 +189,35 @@
)}
style={app?.value?.['css']?.['app']?.['viewer']?.style}
>
<AppPreview
noBackend={false}
{hideRefreshBar}
context={{
email: $userStore?.email,
name: $userStore?.name,
groups: $userStore?.groups,
username: $userStore?.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))}
/>
{#await loadAppPreview()}
<Skeleton layout={[[4], 0.5, [50]]} />
{:then Module}
<Module.default
noBackend={false}
{hideRefreshBar}
context={{
email: $userStore?.email,
name: $userStore?.name,
groups: $userStore?.groups,
username: $userStore?.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>
{/if}
{/key}
@@ -26,12 +26,14 @@
import { OpenAPI, UserService } from '$lib/gen'
import { page } from '$app/state'
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
import { Alert, Skeleton } from '$lib/components/common'
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 { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
import { loadAppPreview } from './loadAppPreview'
import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte'
import { hasStoredSdkConsent, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes'
@@ -420,7 +422,8 @@
if (unsandboxed || isRaw) {
// 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.
// raw app. A low-code app's runtime downloads alongside the app payload.
if (!isRaw) loadAppPreview().catch(() => {})
onViewerReady?.(undefined, requestTokenRefresh)
} else {
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
@@ -529,6 +532,8 @@
onMount(() => {
if (isViewer) {
// Only a sandboxed low-code app is ever framed as a viewer.
loadAppPreview().catch(() => {})
window.addEventListener('message', handleViewerMessage)
installHashRelay()
// Announce readiness so the embedder sends us the token.
@@ -0,0 +1,12 @@
// AppPreview must stay a dynamic import: it statically reaches every low-code component
// (and through them monaco, ag-grid, chart.js...), none of which a raw app renders.
// assertLeanPublicAppRoutes in vite.config.js fails the build if that regresses.
let pending: Promise<typeof import('./AppPreview.svelte')> | undefined
export function loadAppPreview() {
pending ??= import('./AppPreview.svelte').catch((e) => {
console.error('Could not load the low-code app viewer', e)
throw e
})
return pending
}
@@ -1,5 +1,6 @@
import { extToLang } from '$lib/editorLangUtils'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { cleanValueProperties, replaceFalseWithUndefined } from '$lib/utils'
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
// A raw app rendered as a *folder of files* for diffing. Each entry is one
// virtual file: real `files` keep their natural path, runnables become
+1 -1
View File
@@ -1,6 +1,6 @@
import { goto as svelteGoto } from '$app/navigation'
import { base as svelteBase } from '$app/paths'
import { serializeParam } from '$lib/svelte5UtilsKit.svelte'
import { serializeParam } from '$lib/utils/serializeParam'
export function goto(path: string, options = {}) {
if (svelteBase == '' || path.startsWith('?')) {
+1 -4
View File
@@ -2,7 +2,4 @@ export { updatePolicy } from './components/apps/editor/appPolicy'
export { genWmillTs } from './components/raw_apps/utils'
export { updateRawAppPolicy } from './components/raw_apps/rawAppPolicy'
export * as wmillTsRaw from './rawAppWmillTs?raw'
export function capitalize(word: string): string {
return word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
}
export { capitalize } from './utils/capitalize'
+1 -8
View File
@@ -2,20 +2,13 @@
// This file should only be imported in SvelteKit apps as it depends on $app/environment
import { z } from 'zod'
import { serializeParam } from '$lib/utils/serializeParam'
export type SearchParamsResult<S extends z.ZodType> =
S extends z.ZodObject<infer Shape>
? { -readonly [K in keyof Shape]: z.infer<Shape[K]> } & Record<string, unknown>
: z.infer<S> & Record<string, unknown>
/** Serialize a value to a URL search param string. Primitives are written as-is; anything else is JSON. */
export function serializeParam(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}
/** Parse a raw string from the URL back to a typed value guided by the zod field schema. */
function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown {
// Unwrap nullable / optional / default wrappers to get the inner type
+3 -24
View File
@@ -7,7 +7,6 @@
// import { get } from 'svelte/store'
import { deepEqual } from 'fast-equals'
import YAML from 'yaml'
import { type UserExt } from './stores'
import { sendUserToast } from './toast'
import type { CompletedJob, Job, RunnableKind, Script, ScriptLang, Retry } from './gen'
@@ -815,7 +814,8 @@ export function addDeterminant(word: string): string {
return (/^[aeiou]/i.test(word) ? 'an ' : 'a ') + word
}
export { capitalize } from './sharedUtils'
// Not from './sharedUtils': that module drags the app policy code into every page.
export { capitalize } from './utils/capitalize'
export function addWhitespaceBeforeCapitals(word?: string): string {
if (!word) {
@@ -1483,27 +1483,6 @@ export function orderedJsonStringify(obj: any, space?: string | number) {
return JSON.stringify(obj, (Array.from(allKeys) as string[]).sort(), space)
}
function sortObjectKeys(obj: any): any {
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
const sortedObj: any = {}
Object.keys(obj)
.sort()
.forEach((key) => {
sortedObj[key] = sortObjectKeys(obj[key])
})
return sortedObj
} else if (Array.isArray(obj)) {
return obj.map((item) => sortObjectKeys(item))
} else {
return obj
}
}
export function orderedYamlStringify(obj: any) {
const sortedObj = sortObjectKeys(obj)
return YAML.stringify(sortedObj)
}
function evalJs(expr: string) {
let template = `
return function (fields) {
@@ -2039,7 +2018,7 @@ import tokensFile from './assets/tokens/tokens.json'
import githubDarkTokens from './assets/tokens/githubDark.json'
import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig'
import BarsStaggered from './components/icons/BarsStaggered.svelte'
import { GitIcon } from './components/icons'
import GitIcon from './components/icons/GitIcon.svelte'
import { Bot, Code, Package } from 'lucide-svelte'
import type { DbInput } from './components/dbTypes'
export function getCssColor(
+3
View File
@@ -0,0 +1,3 @@
export function capitalize(word: string): string {
return word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
}
+23
View File
@@ -0,0 +1,23 @@
// Kept out of $lib/utils, which every page loads: `yaml` is only needed by the diff views.
import YAML from 'yaml'
function sortObjectKeys(obj: any): any {
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
const sortedObj: any = {}
Object.keys(obj)
.sort()
.forEach((key) => {
sortedObj[key] = sortObjectKeys(obj[key])
})
return sortedObj
} else if (Array.isArray(obj)) {
return obj.map((item) => sortObjectKeys(item))
} else {
return obj
}
}
export function orderedYamlStringify(obj: any) {
const sortedObj = sortObjectKeys(obj)
return YAML.stringify(sortedObj)
}
+12
View File
@@ -0,0 +1,12 @@
// A leaf on purpose. navigation.ts (loaded by nearly every page) must not pull in zod
// through svelte5UtilsKit, and svelte5UtilsKit must not import navigation.ts: the
// sharedUtils declaration build type-checks it via utils.ts -> stores.ts ->
// dbManagerDrawerModel, where $app/* does not resolve.
/** Serialize a value to a URL search param string. Primitives are written as-is; anything else is JSON. */
export function serializeParam(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}
+48 -1
View File
@@ -73,6 +73,52 @@ function assertAcyclicChunks() {
}
}
/**
* Fail the build if a public app URL statically loads the low-code runtime or monaco.
*
* 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.
*/
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/'
]
return {
name: 'wm-assert-lean-public-app-routes',
generateBundle(_options, bundle) {
const chunks = Object.entries(bundle).filter(([, c]) => c.type === 'chunk')
if (!chunks.some(([file]) => file.startsWith('_app/immutable/'))) return
const idsOf = (c) => c.moduleIds ?? Object.keys(c.modules ?? {})
for (const route of routes) {
const starts = chunks
.filter(([, c]) => idsOf(c).some((id) => id.includes(route)))
.map(([file]) => file)
if (!starts.length)
this.error(`No chunk contains ${route}; update assertLeanPublicAppRoutes`)
const seen = new Set(starts)
const queue = [...starts]
while (queue.length) {
const chunk = bundle[queue.shift()]
if (!chunk) continue
const hit = idsOf(chunk).find((id) => forbidden.some((f) => id.includes(f)))
if (hit) {
this.error(`${route} statically loads ${hit}; import it lazily (see loadAppPreview.ts)`)
}
for (const dep of chunk.imports ?? []) {
if (seen.has(dep)) continue
seen.add(dep)
queue.push(dep)
}
}
}
}
}
}
const remoteUrl =
process.env.REMOTE ??
(process.env.BACKEND_PORT
@@ -188,7 +234,8 @@ const config = {
sveltekit(),
...(process.env.HTTPS === 'true' ? [mkcert()] : []),
plugin,
assertAcyclicChunks()
assertAcyclicChunks(),
assertLeanPublicAppRoutes()
],
define: { __pkg__: version },
optimizeDeps: {