From 017b7abbb62ede640e7952343ea90abcc7c5d6ff Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Jun 2026 17:44:16 +0200 Subject: [PATCH] feat(deploy-to-hub): bundle raw apps, share live iframe, folder-scoped bundles - Detect modern raw apps (app table, raw_app=true) and push them to the Hub as raw apps: fetch source files + runnables + the compiled bundle (via the latest-version bundle secret) and shape them into the raw payload RawAppView expects. Fail loudly when no compiled bundle exists. - Capture the Hub id for raw apps and wire "Share as iframe"/"Unpublish" for them (post-bundle, like recordings); re-sync the embed on re-bundle for already-public apps. Factor the publish/unpublish flow into setAppShared + pushRawAppEmbed helpers. - Scope bundles to a single required f// (Select instead of MultiSelect) so relocated paths stay predictable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspaceSettings/DeployToHub.svelte | 199 +++++++++++++----- 1 file changed, 152 insertions(+), 47 deletions(-) diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index 02d54e96df..3036d0b7e8 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -5,7 +5,7 @@ import SchemaForm from '$lib/components/SchemaForm.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import MultiSelect from '$lib/components/select/MultiSelect.svelte' + import Select from '$lib/components/select/Select.svelte' import { sendUserToast } from '$lib/toast' import { AppService, @@ -134,25 +134,24 @@ timeStyle: 'short' }) - let selectedFolders = $state([]) + let selectedFolder = $state(undefined) let availableFolders = $derived( Array.from( new Set( workspaceItems .map((i) => i.path.split('/').slice(0, 2).join('/')) - .filter((p) => p.startsWith('f/') || p.startsWith('u/')) + .filter((p) => p.startsWith('f/')) ) ).sort() ) let filteredWorkspaceItems = $derived( - selectedFolders.length === 0 - ? workspaceItems - : workspaceItems.filter((i) => selectedFolders.some((f) => i.path.startsWith(f + '/'))) + selectedFolder ? workspaceItems.filter((i) => i.path.startsWith(selectedFolder + '/')) : [] ) let items = $derived(phase === 'predeploy' ? filteredWorkspaceItems : draftItems) let manualDeselected = $state>(new Set()) $effect(() => { - selectedFolders + selectedFolder + phase manualDeselected = new Set() }) let selectedItemKeys = $derived( @@ -267,10 +266,14 @@ const publicUrlByPath = new Map(publicApps.map((a, i) => [a.path, publicUrls[i]])) for (const a of apps) { const isPublic = a.execution_mode === 'anonymous' + // Raw apps live in the `app` table (value = files/runnables) but must be + // published to the Hub as raw apps, not low-code apps. + const isRaw = (a as any).raw_app === true next.push({ - key: `app:${a.path}`, + key: `${isRaw ? 'raw_app' : 'app'}:${a.path}`, path: a.path, - kind: 'app', + kind: isRaw ? 'raw_app' : 'app', + appTable: isRaw || undefined, summary: a.summary, rec: 'none', published: isPublic, @@ -387,6 +390,8 @@ workspaceItems = [] workspaceTriggers = [] schedulePreviews = {} + selectedFolder = undefined + manualDeselected = new Set() loadWorkspace($workspaceStore, seq) loadTriggers($workspaceStore, seq) } @@ -502,9 +507,10 @@ }) function openBundle() { - bundleName = hubName - bundleSummary = hubSummary - bundleReadme = hubReadme + const folderName = selectedFolder?.split('/').pop() ?? '' + bundleName = bundleName || hubName || folderName + bundleSummary = bundleSummary || hubSummary + bundleReadme = bundleReadme || hubReadme bundleDrawer?.openDrawer() } const TRIGGER_KIND_ROUTE: Record = { @@ -632,6 +638,40 @@ const a = await AppService.getAppByPath({ workspace, path: ref.path }) return { kind: 'app', path: ref.path, summary: a.summary, value: a.value } } else if (ref.kind === 'raw_app') { + // Modern raw apps live in the `app` table: fetch source files + + // runnables + the compiled bundle, and shape them into the `raw` + // payload the Hub's RawAppView expects (JSON is valid YAML). + const isModern = workspaceItems.some( + (i) => i.kind === 'raw_app' && i.path === ref.path && i.appTable + ) + if (isModern) { + const a = await AppService.getAppByPath({ workspace, path: ref.path }) + const secret = await AppService.getPublicSecretOfLatestVersionOfApp({ + workspace, + path: ref.path + }) + // The compiled JS bundle is required; a missing one means the app + // was never built/deployed, so fail loudly instead of pushing a blank app. + const [jsRes, cssRes] = await Promise.all([ + fetch(`/api/w/${workspace}/apps/get_data/v/${secret}.js`, { + credentials: 'include' + }), + fetch(`/api/w/${workspace}/apps/get_data/v/${secret}.css`, { + credentials: 'include' + }) + ]) + if (!jsRes.ok) { + throw new Error(`raw app ${ref.path} has no compiled bundle — deploy it first`) + } + const js = await jsRes.text() + const css = cssRes.ok ? await cssRes.text() : '' + const v: any = a.value ?? {} + const content = JSON.stringify({ + files: { ...(v.files ?? {}), '/bundle.js': js, '/bundle.css': css }, + runnables: v.runnables ?? {} + }) + return { kind: 'raw_app', path: ref.path, summary: a.summary, content } + } const r = await fetch(`/api/w/${workspace}/raw_apps/get_data/0/${ref.path}`, { credentials: 'include' }) @@ -690,13 +730,14 @@ project_slug: slug }) } else if (it.kind === 'raw_app') { - await postHub(workspace, '/hub/raw_apps', { + const resp = await postHub(workspace, '/hub/raw_apps', { raw: it.content ?? '', apps: [], summary: it.summary || it.newPath, description: undefined, project_slug: slug }) + if (typeof resp?.id === 'number') hubItemIds = { ...hubItemIds, [key]: resp.id } } } @@ -917,10 +958,13 @@ const workspace = $workspaceStore if (!workspace) return const slug = effectiveSlug || sanitizeSlug(hubName) + // Snapshot the selection up-front so a folder switch mid-deploy can't + // rewrite draftItems on success. + const itemsSnapshot = selectedItems.slice() deploying = true let failures = 0 try { - const seed: ItemRef[] = selectedItems + const seed: ItemRef[] = itemsSnapshot .filter((i) => i.kind !== 'resource') .map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path })) const bundle = await buildProjectBundle(seed, slug, buildBundleDeps(workspace)) @@ -981,6 +1025,21 @@ } } } + // A re-bundle clears the Hub-side embed (idempotent replace), so re-push it + // for any raw app that is already public — keeps the live iframe in sync + // without forcing an unpublish/share round-trip. + for (const it of bundle.items) { + if (it.kind !== 'raw_app') continue + const hubId = hubItemIds[`${it.kind}:${it.path}`] + const src = itemsSnapshot.find((i) => i.kind === 'raw_app' && i.path === it.path) + if (hubId && src?.published && src?.publicUrl) { + try { + await pushRawAppEmbed(workspace, hubId, src.publicUrl) + } catch (e: any) { + sendUserToast(`Failed to sync iframe for ${it.path}: ${e?.message ?? e}`, true) + } + } + } try { await pushTriggers(workspace, slug) } catch (e: any) { @@ -990,7 +1049,7 @@ await delay(150) deploymentStatus = {} - draftItems = selectedItems.map((i) => ({ ...i, rec: 'none' })) + draftItems = itemsSnapshot.map((i) => ({ ...i, rec: 'none' })) recordings = {} phase = 'draft' if (failures > 0) { @@ -1048,6 +1107,10 @@ } } async function startNewDraft() { + if (!selectedFolder || filteredWorkspaceItems.length === 0) { + sendUserToast(`Pick a folder with at least one item before starting a new draft.`, true) + return + } draftItems = filteredWorkspaceItems.map((i) => ({ ...i, rec: 'none' })) recordings = {} phase = 'draft' @@ -1250,21 +1313,54 @@ publishTarget = it publishDrawer?.openDrawer() } + // Set the Hub raw app's live-iframe URL (or clear it with null). The Hub renders + // from external_embed_url; project_slug scopes ownership. + async function pushRawAppEmbed(workspace: string, hubId: number, url: string | null) { + await postHub(workspace, `/hub/raw_apps/${hubId}/embed`, { + external_embed_url: url, + project_slug: effectiveSlug || sanitizeSlug(hubName) + }) + } + + // Flip an app/raw app between public (anonymous) and private (publisher) and keep + // the Hub raw-app iframe in sync. Returns the resolved public URL when shared. + async function setAppShared( + workspace: string, + it: DeployItem, + shared: boolean + ): Promise { + const app = await AppService.getAppByPath({ workspace, path: it.path }) + const policy = { + ...(app.policy ?? {}), + execution_mode: (shared ? 'anonymous' : 'publisher') as 'anonymous' | 'publisher' + } + await AppService.updateApp({ + workspace, + path: it.path, + requestBody: { policy, deployment_message: shared ? 'Share as iframe' : 'Unshare iframe' } + }) + const url = shared ? ((await resolvePublicUrl(workspace, it.path)) ?? null) : null + if (it.kind === 'raw_app') { + const hubId = hubItemIds[it.key] + if (!hubId) { + if (shared) sendUserToast('Push the bundle to the Hub first to share the live iframe', true) + } else if (!shared) { + await pushRawAppEmbed(workspace, hubId, null) + } else if (url) { + await pushRawAppEmbed(workspace, hubId, url) + } + } + return url + } + async function confirmPublish() { const it = publishTarget const workspace = $workspaceStore - if (!it || !workspace || it.kind !== 'app') return + if (!it || !workspace || !canPublishApp(it.kind)) return publishing = true try { - const app = await AppService.getAppByPath({ workspace, path: it.path }) - const policy = { ...(app.policy ?? {}), execution_mode: 'anonymous' as const } - await AppService.updateApp({ - workspace, - path: it.path, - requestBody: { policy, deployment_message: 'Share as iframe' } - }) - const url = await resolvePublicUrl(workspace, it.path) - patchItem(it.key, { published: true, publicUrl: url }) + const url = await setAppShared(workspace, it, true) + patchItem(it.key, { published: true, publicUrl: url ?? undefined }) sendUserToast(`${it.path} is now public`) publishDrawer?.closeDrawer() } catch (e: any) { @@ -1275,15 +1371,9 @@ } async function unpublishApp(it: DeployItem) { const workspace = $workspaceStore - if (!workspace || it.kind !== 'app') return + if (!workspace || !canPublishApp(it.kind)) return try { - const app = await AppService.getAppByPath({ workspace, path: it.path }) - const policy = { ...(app.policy ?? {}), execution_mode: 'publisher' as const } - await AppService.updateApp({ - workspace, - path: it.path, - requestBody: { policy, deployment_message: 'Unshare iframe' } - }) + await setAppShared(workspace, it, false) patchItem(it.key, { published: false, publicUrl: undefined }) sendUserToast('App unpublished') } catch (e: any) { @@ -1378,18 +1468,33 @@ {/if} - {#if phase === 'predeploy' && availableFolders.length > 0} + {#if phase === 'predeploy'}
- Filter by folder (optional) - ({ value: f, label: f }))} - placeholder="All folders" - /> + Folder (required) + {#if availableFolders.length === 0} + + This workspace has no f/<folder>/ items. + Personal paths (u/<user>/) cannot be bundled — + move the items you want to publish into a shared folder first. + + {:else} +