mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 00:03:57 +00:00
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:
co-authored by
Claude Opus 5
parent
d8b9174235
commit
e651b4cd63
@@ -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'
|
||||
|
||||
@@ -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,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('?')) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function capitalize(word: string): string {
|
||||
return word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user