feat: give full-code app previews the deployed app's window.ctx (#11763)

* feat: give full-code app previews the deployed app's window.ctx

* chore: pin the UI Builder build that sets window.ctx in the preview

* fix: hold the editor preview until the viewer belongs to the app's workspace

* fix: let Rebuild retry a failed viewer lookup in the editor preview
This commit is contained in:
AlexRV12
2026-10-14 15:47:21 +02:00
committed by GitHub
parent 468cf1d994
commit c5e6f67d53
4 changed files with 64 additions and 29 deletions
+15 -2
View File
@@ -67,7 +67,7 @@ const DEFAULT_PORT = 4000;
const DEFAULT_HOST = "localhost";
// HTML template with live reload and SQL migration modal
const createHTML = (jsPath: string, cssPath: string) => `
const createHTML = (jsPath: string, cssPath: string, appCtx: unknown) => `
<!DOCTYPE html>
<html lang="en">
<head>
@@ -228,6 +228,7 @@ const createHTML = (jsPath: string, cssPath: string) => `
</div>
</div>
</div>
<script>window.ctx = ${JSON.stringify(appCtx).replace(/</g, "\\u003c")};</script>
<script src="${jsPath}"></script>
<script>
// Live reload via EventSource
@@ -406,6 +407,18 @@ async function dev(opts: DevOptions, appFolder?: string) {
await requireLogin(opts);
const workspaceId = workspace.workspaceId;
// What the deployed app receives as `window.ctx`: the viewer's membership in this
// workspace, shaped as the frontend's UserExt, or no viewer when it can't be looked up.
const viewer = await wmill.whoami({ workspace: workspaceId }).catch(() => undefined);
const appCtx = {
ctx: viewer && {
...viewer,
workspace_id: workspaceId,
pgroups: (viewer.groups ?? []).map((g) => `g/${g}`),
},
workspace: workspaceId,
};
// Resolve relative imports in app inline scripts from local (not-yet-deployed)
// content so previews use locally-edited workspace libs instead of deployed.
// Computed here as a startup snapshot; re-run `wmill app dev` to pick up
@@ -966,7 +979,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
// Serve injected HTML for root and any other path
res.writeHead(200, { "Content-Type": "text/html" });
res.end(createHTML("/dist/bundle.js", "/dist/bundle.css"));
res.end(createHTML("/dist/bundle.js", "/dist/bundle.css", appCtx));
});
// Create WebSocket server on the same HTTP server
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "d3fdb70",
"sha256": "8c894b3f02d5d1872a966c37d43314f05ce42900f7330bdcf45a18a772deb2b9"
"version": "6880a7f",
"sha256": "05d49a6ed4790b444058574ccb650c8d3882161bbf004c0f8c0370964e853367"
}
@@ -52,6 +52,7 @@
import { captureScale, MAX_IMAGE_EDGE } from '../copilot/chat/imageUtils'
import { rawAppLintStore } from './lintStore'
import { dbSchemas, userStore } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import RawAppSdkConsent from './RawAppSdkConsent.svelte'
import {
hasStoredSdkConsent,
@@ -258,6 +259,7 @@
const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace)
// Everything under the editor acts on it too (see operatingWorkspace.svelte.ts).
setOperatingWorkspace(() => opWorkspace)
const operatingUser = useActingUser(() => opWorkspace)
// The path autosaves land on, which is what the session preview loads the app by.
const draftStoragePath = $derived(autosavePath ?? liveEditorDraftStoragePath)
@@ -508,6 +510,8 @@ addEventListener('message', function (e) {
const SDK_CONSENT_PENDING_TEXT =
'The preview is waiting for the user to approve the permissions the app declares in policy.frontend_sdk_scopes, so the app code is not running and the page is empty. This is not a bug in the app: do not change the code. Ask the user to approve the permissions in the preview, then retry.'
const SDK_STARTING_TEXT =
'The preview is still loading the permissions the app declares in policy.frontend_sdk_scopes, so the app code has not started yet. Retry in a moment.'
const VIEWER_PENDING_TEXT =
'The preview is still looking up the viewer the app receives in window.ctx, so the app code has not started yet. This is not a bug in the app: do not change the code. Retry in a moment.'
const PREVIEW_SETTLE_MS = 1000
let editorDestroyed = false
@@ -1813,12 +1817,13 @@ addEventListener('message', function (e) {
}
}
function syncExternalPreview() {
function syncExternalPreview() {
if (previewSdkPending || previewUserPending || !externalPreviewReady) return
if (lastBuild) {
postToExternalPreview({
type: 'preview',
css: lastBuild.css,
js: lastBuild.js,
ctx: previewCtx(),
sdk: previewSdk,
storage: previewStorage()
@@ -1832,18 +1837,41 @@ addEventListener('message', function (e) {
function feedPreviewIframe(build: { css: string; js: string }) {
// Between dropping a credential and settling its replacement the shell stays
// blank; whichever settles last — the mint or the shell's own `load` — starts
// the app. Same for a shell still reloading: it replays once ready.
// the app. Same for a shell still reloading: it replays once ready.
if (previewSdkPending || previewUserPending || !previewIframeLoaded) return
runtimeError = undefined
emptyRender = false
postToPreview({
type: 'preview',
css: build.css,
js: build.js,
ctx: previewCtx(),
sdk: previewSdk,
storage: previewStorage()
})
}
// The deployed app's `window.ctx`: the viewer's membership in the app's workspace.
function previewCtx() {
return $state.snapshot({ ctx: operatingUser.current, workspace: opWorkspace })
}
// A build fed before the viewer is known keeps that viewer until the next one. Outside
// the navigation workspace the viewer is looked up; in it, `$userStore` still holds the
// previous workspace's membership right after a switch. A failed lookup also settles
// this: the app then runs with no viewer, as we don't know who it is.
const previewUserPending = $derived(
!!opWorkspace &&
(!operatingUser.resolved(opWorkspace) ||
(!!operatingUser.current && operatingUser.current.workspace_id !== opWorkspace))
)
$effect(() => {
if (previewUserPending) return
untrack(() => {
if (lastBuild) feedPreviewIframe(lastBuild)
syncExternalPreview()
})
})
// The shell seeds a fresh document's storage shim from this; unsandboxed
// previews use real storage and get none.
@@ -2005,6 +2033,7 @@ addEventListener('message', function (e) {
if (editorDestroyed) return undefined
if (buildTracker.pending) return { text: BUILD_PENDING_TEXT }
if (sdkPrompt) return { text: SDK_CONSENT_PENDING_TEXT }
if (previewSdkPending) return { text: SDK_STARTING_TEXT }
if (previewUserPending) return { text: VIEWER_PENDING_TEXT }
const res = await readPreviewDom(query)
return res && buildError !== undefined
@@ -2183,6 +2212,7 @@ addEventListener('message', function (e) {
if (editorDestroyed) throw new Error('The app preview was closed')
if (buildTracker.pending) throw new Error(BUILD_PENDING_TEXT)
if (sdkPrompt) throw new Error(SDK_CONSENT_PENDING_TEXT)
if (previewSdkPending) throw new Error(SDK_STARTING_TEXT)
if (previewUserPending) throw new Error(VIEWER_PENDING_TEXT)
if (buildError !== undefined) throw new Error(buildFailedText(buildError))
const body = await requestFromPreview('elementRect', { selector: 'body' })
@@ -2860,6 +2890,8 @@ addEventListener('message', function (e) {
startIcon={{ icon: RefreshCw }}
title="Replay the last build into the preview"
aria-label="Rebuild"
onClick={() => {
// Retries a viewer lookup that failed; the hold replays the build once it answers.
operatingUser.forgetFailures()
if (lastBuild) {
feedPreviewIframe(lastBuild)
@@ -1074,20 +1074,14 @@ const App = () => {
description="Windmill passes the workspace and the viewer to the app."
code={${JSON.stringify(BUILDER_CONTEXT_CODE)}}
>
{ctx.workspace ? (
<>
<div>
<span className="muted">Workspace </span>
<strong>{ctx.workspace}</strong>
</div>
<div>
<span className="muted">Viewer </span>
<strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong>
</div>
</>
) : (
<span className="muted">The editor preview has no viewer. Deploy the app to see yours here.</span>
)}
<div>
<span className="muted">Workspace </span>
<strong>{ctx.workspace}</strong>
</div>
<div>
<span className="muted">Viewer </span>
<strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong>
</div>
</Card>
</div>
</main>
@@ -1166,15 +1160,11 @@ const builderAppSvelte = `<script lang="ts">
<section class="card">
{@render header(steps.length + 1, 'Know who is viewing', 'Windmill passes the workspace and the viewer to the app.')}
<div class="card-body">
{#if ctx.workspace}
<div><span class="muted">Workspace </span><strong>{ctx.workspace}</strong></div>
<div>
<span class="muted">Viewer </span>
<strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong>
</div>
{:else}
<span class="muted">The editor preview has no viewer. Deploy the app to see yours here.</span>
{/if}
<div><span class="muted">Workspace </span><strong>{ctx.workspace}</strong></div>
<div>
<span class="muted">Viewer </span>
<strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong>
</div>
</div>
<pre class="snippet"><code>{contextCode}</code></pre>
</section>