@hosty-sdk/app 0.24.0 → 0.26.0
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/README.md +106 -11
- package/dist/assistant.d.ts +15 -0
- package/dist/assistant.js +38 -1
- package/dist/background-server.d.ts +16 -0
- package/dist/background-server.js +52 -0
- package/dist/browser-auth.d.ts +2 -0
- package/dist/browser-auth.js +5 -0
- package/dist/continuity-browser.d.ts +11 -0
- package/dist/continuity-browser.js +92 -0
- package/dist/continuity.d.ts +9 -0
- package/dist/continuity.js +9 -0
- package/dist/overlay-react.d.ts +5 -1
- package/dist/overlay-react.js +7 -4
- package/dist/providers-server.d.ts +3 -1
- package/dist/providers-server.js +4 -2
- package/dist/providers.d.ts +4 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +15 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/session-server.d.ts +2 -0
- package/dist/ui-context.d.ts +2 -0
- package/dist/ui-context.js +3 -0
- package/dist/ui-embedded.d.ts +45 -0
- package/dist/ui-embedded.js +278 -0
- package/dist/ui-frame.d.ts +30 -0
- package/dist/ui-frame.js +104 -0
- package/dist/ui-host.d.ts +80 -0
- package/dist/ui-host.js +439 -0
- package/dist/ui-react.d.ts +35 -0
- package/dist/ui-react.js +225 -0
- package/dist/ui-server.d.ts +32 -0
- package/dist/ui-server.js +123 -0
- package/dist/ui-transport.d.ts +16 -0
- package/dist/ui-transport.js +68 -0
- package/dist/ui-types.d.ts +99 -0
- package/dist/ui-types.js +43 -0
- package/dist/ui.d.ts +20 -0
- package/dist/ui.js +36 -0
- package/package.json +33 -1
package/dist/ui-react.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { bindUiClient, ui } from "./ui.js";
|
|
5
|
+
import { createUiHost } from "./ui-host.js";
|
|
6
|
+
import { EmbeddedUiClient } from "./ui-embedded.js";
|
|
7
|
+
import { UiHostContext } from "./ui-context.js";
|
|
8
|
+
import { HostyAppFrame } from "./ui-frame.js";
|
|
9
|
+
import { panelKey } from "./ui-types.js";
|
|
10
|
+
import { uiPath as normalizeUiPath } from "./ui-transport.js";
|
|
11
|
+
export { HostyAppFrame } from "./ui-frame.js";
|
|
12
|
+
export function useHostyUi() { return ui; }
|
|
13
|
+
export function useHostyUiHost() { return useContext(UiHostContext); }
|
|
14
|
+
/** Internal composition root, owned by HostyOverlay rather than mounted again by domain apps. */
|
|
15
|
+
export function HostyUiRoot({ children, uiPath, panelLayout = "auto", active = true }) {
|
|
16
|
+
const [host, setHost] = useState(null);
|
|
17
|
+
const [environment, setEnvironment] = useState(null);
|
|
18
|
+
const mounted = useRef(false);
|
|
19
|
+
const clientRef = useRef(null);
|
|
20
|
+
const activeRef = useRef(active);
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
activeRef.current = active;
|
|
23
|
+
clientRef.current?.setActive(active);
|
|
24
|
+
}, [active]);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const path = normalizeUiPath(uiPath);
|
|
27
|
+
const client = window.parent === window ? createUiHost({ uiPath: path, active: activeRef.current }) : new EmbeddedUiClient(path);
|
|
28
|
+
client.setActive(activeRef.current);
|
|
29
|
+
clientRef.current = client;
|
|
30
|
+
const release = bindUiClient(client), stop = client.subscribe(event => { if (event.type === "environment")
|
|
31
|
+
setEnvironment(event.value); });
|
|
32
|
+
if (client instanceof EmbeddedUiClient)
|
|
33
|
+
setHost(null);
|
|
34
|
+
else
|
|
35
|
+
setHost(client);
|
|
36
|
+
return () => { stop(); release(); client.dispose(); clientRef.current = null; };
|
|
37
|
+
}, [uiPath]);
|
|
38
|
+
const allowed = environment?.mode === "standalone" || environment?.connection === "connected" || environment?.connection === "unsupported";
|
|
39
|
+
if (allowed)
|
|
40
|
+
mounted.current = true;
|
|
41
|
+
return _jsxs(UiHostContext.Provider, { value: host, children: [_jsx("div", { hidden: !allowed, inert: !allowed, style: { display: allowed ? "contents" : "none" }, children: mounted.current && (panelLayout === "auto" && host ? _jsx(HostyPanelWorkspace, { children: children }) : children) }), !allowed && _jsx("div", { role: "status", className: "hosty-panel-notice", style: { padding: 24, font: "14px system-ui" }, children: environment?.connection === "blocked" ? _jsxs(_Fragment, { children: [_jsx("strong", { children: environment.code === "actor_mismatch" ? "Use the same Hosty account in this app and its host" : "Cannot verify this app’s host" }), _jsx("p", { children: "Check your account and access in Hosty Core, then reconnect. Your existing discussion is unchanged." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), children: "Reconnect" })] }) : "Connecting to the workspace…" })] });
|
|
42
|
+
}
|
|
43
|
+
const empty = { bootstrap: null, catalog: [], tools: [], selected: null, expanded: false, width: 360, error: null };
|
|
44
|
+
const noop = () => () => { };
|
|
45
|
+
/** A single retained frame tree for dock and drawer; only CSS placement changes at the breakpoint. */
|
|
46
|
+
export function HostyPanelWorkspace({ children, railMode = "opened", className = "", initialWidth, initialExpanded, onWidthChange, onExpandedChange, onStartApp, onAskAssistant, onAuthRequired, outbound, renderIcon }) {
|
|
47
|
+
const host = useHostyUiHost();
|
|
48
|
+
const state = useSyncExternalStore(host?.subscribeSnapshot ?? noop, host?.getSnapshot ?? (() => empty), () => empty);
|
|
49
|
+
const unsupported = state.error?.code.startsWith("unsupported") === true;
|
|
50
|
+
const root = useRef(null), content = useRef(null), body = useRef(null), close = useRef(null);
|
|
51
|
+
const opener = useRef(null), panelOpener = useRef(null);
|
|
52
|
+
const [size, setSize] = useState(1200), [chooser, setChooser] = useState(false), [error, setError] = useState(null);
|
|
53
|
+
const chooserRef = useRef(null);
|
|
54
|
+
const bodyId = useId();
|
|
55
|
+
const workspaceOwner = useRef({});
|
|
56
|
+
useEffect(() => host?.claimWorkspace(workspaceOwner.current), [host]);
|
|
57
|
+
const drawer = size < 900, expanded = state.expanded && !!state.selected;
|
|
58
|
+
const selectedKey = state.selected ? panelKey(state.selected) : null;
|
|
59
|
+
const tabs = railMode === "catalog" ? state.catalog : state.tools.map(tool => state.catalog.find(p => panelKey(p.panel) === tool.key)).filter((p) => !!p);
|
|
60
|
+
const selected = state.catalog.find(p => panelKey(p.panel) === selectedKey);
|
|
61
|
+
const tool = state.tools.find(t => t.key === selectedKey);
|
|
62
|
+
const width = Math.min(state.width, Math.max(280, size - 480 - 60));
|
|
63
|
+
const optionsRef = useRef({ onWidthChange, onExpandedChange });
|
|
64
|
+
optionsRef.current = { onWidthChange, onExpandedChange };
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!host)
|
|
67
|
+
return;
|
|
68
|
+
if (initialWidth !== undefined)
|
|
69
|
+
host.setWidth(initialWidth);
|
|
70
|
+
if (initialExpanded !== undefined)
|
|
71
|
+
host.setExpanded(initialExpanded);
|
|
72
|
+
}, [host]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!host || railMode !== "catalog")
|
|
75
|
+
return;
|
|
76
|
+
let live = true, pending = false, completed = false;
|
|
77
|
+
let attemptedCatalog = null;
|
|
78
|
+
const restore = () => {
|
|
79
|
+
const current = host.getSnapshot();
|
|
80
|
+
if (!current.expanded) {
|
|
81
|
+
attemptedCatalog = null;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!live || pending || completed || current.selected || current.catalog === attemptedCatalog)
|
|
85
|
+
return;
|
|
86
|
+
const first = current.catalog.find(panel => panel.available);
|
|
87
|
+
if (!first)
|
|
88
|
+
return;
|
|
89
|
+
pending = true;
|
|
90
|
+
attemptedCatalog = current.catalog;
|
|
91
|
+
void host.selectPanel(first.panel).then(result => { completed = result.status === "opened"; })
|
|
92
|
+
.finally(() => { pending = false; restore(); });
|
|
93
|
+
};
|
|
94
|
+
// A refusal consumes only this catalog generation. A refresh can retry, including one
|
|
95
|
+
// received while resolution was pending, but a user collapse always prevents restoration.
|
|
96
|
+
const stop = host.subscribeSnapshot(restore);
|
|
97
|
+
restore();
|
|
98
|
+
return () => { live = false; stop(); };
|
|
99
|
+
}, [host, railMode]);
|
|
100
|
+
const lastPrefs = useRef({ width: initialWidth ?? 360, expanded: initialExpanded ?? false });
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!host || state !== host.getSnapshot())
|
|
103
|
+
return;
|
|
104
|
+
if (state.width !== lastPrefs.current.width)
|
|
105
|
+
optionsRef.current.onWidthChange?.(state.width);
|
|
106
|
+
if (state.expanded !== lastPrefs.current.expanded)
|
|
107
|
+
optionsRef.current.onExpandedChange?.(state.expanded);
|
|
108
|
+
lastPrefs.current = { width: state.width, expanded: state.expanded };
|
|
109
|
+
}, [host, state.width, state.expanded]);
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
if (!root.current)
|
|
112
|
+
return;
|
|
113
|
+
const read = () => setSize(root.current.getBoundingClientRect().width || window.innerWidth);
|
|
114
|
+
read();
|
|
115
|
+
const observer = new ResizeObserver(read);
|
|
116
|
+
observer.observe(root.current);
|
|
117
|
+
return () => observer.disconnect();
|
|
118
|
+
}, []);
|
|
119
|
+
useLayoutEffect(() => {
|
|
120
|
+
if (!expanded)
|
|
121
|
+
return;
|
|
122
|
+
panelOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
123
|
+
return () => { const previous = panelOpener.current; if (previous?.isConnected)
|
|
124
|
+
previous.focus(); };
|
|
125
|
+
}, [expanded]);
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
if (!expanded || !drawer)
|
|
128
|
+
return;
|
|
129
|
+
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
130
|
+
close.current?.focus();
|
|
131
|
+
const dismiss = () => host?.setExpanded(false);
|
|
132
|
+
window.addEventListener("hosty:ui:drawer-dismiss", dismiss);
|
|
133
|
+
return () => { window.removeEventListener("hosty:ui:drawer-dismiss", dismiss); if (opener.current?.isConnected)
|
|
134
|
+
opener.current.focus(); };
|
|
135
|
+
}, [expanded, drawer, host]);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (expanded && tool?.readiness === "ready")
|
|
138
|
+
body.current?.querySelector(".hosty-panel-frame:not([hidden]) iframe")?.focus();
|
|
139
|
+
}, [expanded, selectedKey, tool?.readiness]);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const dialog = chooserRef.current;
|
|
142
|
+
if (!chooser || !dialog)
|
|
143
|
+
return;
|
|
144
|
+
dialog.showModal();
|
|
145
|
+
return () => { if (dialog.open)
|
|
146
|
+
dialog.close(); };
|
|
147
|
+
}, [chooser]);
|
|
148
|
+
async function select(panel) {
|
|
149
|
+
if (!host)
|
|
150
|
+
return;
|
|
151
|
+
if (expanded && selectedKey === panelKey(panel.panel)) {
|
|
152
|
+
host.setExpanded(false);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const result = await host.selectPanel(panel.panel);
|
|
156
|
+
if (result.status !== "opened")
|
|
157
|
+
setError(panel.available ? "Could not open this tool. Try again." : `${panel.appName} is ${panel.state}.`);
|
|
158
|
+
else {
|
|
159
|
+
setError(null);
|
|
160
|
+
setChooser(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const resize = (event) => {
|
|
164
|
+
const start = event.clientX, old = width, handle = event.currentTarget;
|
|
165
|
+
handle.setPointerCapture(event.pointerId);
|
|
166
|
+
const move = (next) => host?.setWidth(Math.min(size - 540, old + start - next.clientX));
|
|
167
|
+
const end = () => { handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", end); handle.removeEventListener("lostpointercapture", end); };
|
|
168
|
+
handle.addEventListener("pointermove", move);
|
|
169
|
+
handle.addEventListener("pointerup", end);
|
|
170
|
+
handle.addEventListener("lostpointercapture", end);
|
|
171
|
+
};
|
|
172
|
+
return _jsxs("div", { ref: root, className: `hosty-panel-workspace ${className}`, "data-hosty-panel-layout": drawer ? "drawer" : "dock", style: { "--hosty-panel-width": `${width}px`, ...(unsupported ? { display: "contents" } : {}) }, children: [_jsx("style", { children: styles }), _jsx("div", { ref: content, className: "hosty-panel-document", style: unsupported ? { display: "contents" } : undefined, inert: expanded && drawer, children: children }), _jsxs("nav", { className: "hosty-panel-rail", "aria-label": "Tools", style: unsupported ? { display: "none" } : undefined, inert: unsupported || expanded && drawer, children: [tabs.map((panel, index) => _jsxs("button", { type: "button", title: `${panel.appName}: ${panel.label}`, "aria-label": panel.label, "aria-controls": bodyId, "aria-expanded": expanded && selectedKey === panelKey(panel.panel), onClick: () => void select(panel), onKeyDown: event => {
|
|
173
|
+
const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
174
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : delta ? (index + delta + tabs.length) % tabs.length : null;
|
|
175
|
+
if (next !== null) {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
event.currentTarget.parentElement?.children[next]?.focus();
|
|
178
|
+
}
|
|
179
|
+
}, children: [renderIcon?.(panel) ?? panel.label.slice(0, 2), state.tools.find(t => t.key === panelKey(panel.panel))?.attention ? _jsx("span", { className: "hosty-panel-attention", "aria-label": "Needs attention", children: "\u2022" }) : null] }, panelKey(panel.panel))), _jsx("button", { type: "button", "aria-label": "Add tool", title: "Add tool", onClick: () => { setError(null); setChooser(true); void host?.refresh(); }, children: "+" })] }), expanded && drawer && _jsx("button", { type: "button", className: "hosty-panel-backdrop", "aria-label": "Return to document", onClick: () => host?.setExpanded(false) }), _jsxs("aside", { ref: body, id: bodyId, className: "hosty-panel-body", hidden: !expanded, inert: !expanded, role: drawer ? "dialog" : "complementary", "aria-modal": drawer && expanded ? true : undefined, "aria-label": selected?.label ?? "Tool", onKeyDown: event => {
|
|
180
|
+
if (!drawer)
|
|
181
|
+
return;
|
|
182
|
+
if (event.key === "Escape") {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
host?.setExpanded(false);
|
|
185
|
+
}
|
|
186
|
+
if (event.key === "Tab") {
|
|
187
|
+
const focusable = Array.from(body.current?.querySelectorAll('button,a[href],iframe,[tabindex="0"]') ?? []).filter(e => !e.closest("[hidden]") && !e.disabled);
|
|
188
|
+
const first = focusable[0], last = focusable.at(-1);
|
|
189
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
last?.focus();
|
|
192
|
+
}
|
|
193
|
+
else if (!event.shiftKey && document.activeElement === last) {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
first?.focus();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}, children: [drawer && expanded && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => body.current?.querySelector(".hosty-panel-frame:not([hidden]) iframe")?.focus() }), _jsx("div", { className: "hosty-panel-resize", role: "separator", "aria-label": "Tool panel width", "aria-orientation": "vertical", tabIndex: drawer ? -1 : 0, "aria-valuemin": 280, "aria-valuemax": Math.max(280, Math.min(800, size - 540)), "aria-valuenow": width, onPointerDown: resize, onDoubleClick: () => host?.setWidth(360), onKeyDown: event => {
|
|
199
|
+
if (event.key === "Home") {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
host?.setWidth(360);
|
|
202
|
+
}
|
|
203
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
host?.setWidth(width + (event.key === "ArrowLeft" ? 16 : -16));
|
|
206
|
+
}
|
|
207
|
+
} }), _jsxs("header", { children: [_jsx("strong", { children: selected?.label ?? "Tool" }), tool?.resolved.standaloneHref && _jsx("a", { href: tool.resolved.standaloneHref, target: "_blank", rel: "noopener noreferrer", title: "Open separately", "aria-label": "Open separately", children: "\u2197" }), _jsx("button", { ref: close, type: "button", onClick: () => host?.setExpanded(false), "aria-label": "Return to document", title: "Return to document", children: "\u00D7" })] }), tool?.resolved.state === "degraded" && _jsx("div", { className: "hosty-panel-state", role: "status", children: "This app\u2019s health check is failing. Its page may still work." }), tool?.readiness === "unreported" && _jsxs("div", { className: "hosty-panel-state", role: "status", children: ["This tool has not confirmed that it loaded. If the page is blank or refuses to connect, use ", _jsx("a", { href: tool.resolved.standaloneHref ?? undefined, target: "_blank", rel: "noopener noreferrer", children: "Open separately" }), "."] }), tool?.readiness === "auth-required" && _jsx("div", { className: "hosty-panel-state", role: "status", children: "Check access in the tool below, then retry opening the discussion." }), state.tools.map(item => _jsx("div", { className: "hosty-panel-frame", hidden: item.key !== selectedKey, inert: item.key !== selectedKey, children: item.resolved.surface ? _jsx(HostyAppFrame, { surface: { ...item.resolved.surface, panelId: item.target.panel.panelId }, title: state.catalog.find(p => panelKey(p.panel) === item.key)?.label ?? "Tool", standaloneHref: item.resolved.standaloneHref, onAskAssistant: onAskAssistant, onAuthRequired: onAuthRequired, onAttention: count => host?.setAttention(item.target.panel, count), outbound: outbound && panelKey(outbound.target.panel) === item.key && outbound.target.route === item.target.route ? outbound : null }, JSON.stringify(item.resolved.surface))
|
|
208
|
+
: _jsxs("div", { className: "hosty-panel-notice", children: [_jsxs("p", { children: ["This tool is unavailable (", item.resolved.state, ")."] }), _jsx("button", { type: "button", onClick: () => void host?.selectPanel(item.target.panel), children: "Retry opening" })] }) }, item.key)), drawer && expanded && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => close.current?.focus() })] }), chooser && _jsxs("dialog", { ref: chooserRef, className: "hosty-tool-chooser", onCancel: () => setChooser(false), "aria-label": "Add tool", children: [_jsxs("header", { children: [_jsx("strong", { children: "Add tool" }), _jsx("button", { type: "button", onClick: () => setChooser(false), "aria-label": "Close tool chooser", children: "\u00D7" })] }), _jsx("p", { children: "Open a tool beside your document." }), error && _jsx("p", { role: "status", children: error }), state.error && _jsx("p", { role: "status", children: state.error.message }), !state.bootstrap?.canHost && _jsx("p", { children: "This app needs optional panel access in Hosty Core to show tools here. You can still use the app." }), state.bootstrap?.canHost && !state.catalog.length && _jsx("p", { children: "No tools are available for your account." }), _jsx("ul", { children: state.catalog.map(panel => _jsxs("li", { children: [_jsxs("button", { type: "button", onClick: () => void select(panel), children: [_jsx("strong", { children: panel.label }), _jsxs("span", { children: [panel.appName, panel.available ? "" : ` · ${panel.state}`] })] }), !panel.available && panel.canStart && onStartApp && _jsx("button", { type: "button", onClick: () => void Promise.resolve(onStartApp(panel.panel.appId)).then(() => host?.refresh()), children: "Start app" })] }, panelKey(panel.panel))) }), _jsx("button", { type: "button", onClick: () => void host?.refresh(), children: "Refresh tools" })] }), error && !chooser && _jsxs("div", { className: "hosty-panel-error", role: "status", children: [error, " ", _jsx("button", { type: "button", onClick: () => { setChooser(true); setError(null); }, children: "Choose tool" })] })] });
|
|
209
|
+
}
|
|
210
|
+
const styles = `
|
|
211
|
+
.hosty-panel-workspace{--hp-bg:#f8f9fb;--hp-fg:#20242c;--hp-muted:#606875;--hp-border:#e0e4eb;display:flex;position:relative;min-width:0;min-height:0;width:100%;height:100dvh;overflow:hidden;background:var(--hp-bg);color:var(--hp-fg)}
|
|
212
|
+
html.dark .hosty-panel-workspace,html[data-hosty-theme=dark] .hosty-panel-workspace{--hp-bg:#14171c;--hp-fg:#f0f2f5;--hp-muted:#a2aab7;--hp-border:#323843}
|
|
213
|
+
.hosty-panel-document{flex:1;min-width:0;min-height:0;overflow:auto}.hosty-panel-rail{order:3;flex:0 0 48px;display:flex;flex-direction:column;align-items:center;gap:8px;padding:10px 0;border-left:1px solid var(--hp-border);overflow:auto}
|
|
214
|
+
.hosty-panel-workspace button,.hosty-panel-workspace a{font:inherit}.hosty-panel-rail button,.hosty-panel-body header button,.hosty-panel-body header a,.hosty-tool-chooser header button{position:relative;display:grid;place-items:center;flex-shrink:0;border:1px solid transparent;border-radius:8px;width:32px;height:32px;padding:0;background:transparent;color:inherit;font:500 13px system-ui;cursor:pointer;text-decoration:none}
|
|
215
|
+
.hosty-panel-rail button:hover,.hosty-panel-rail button[aria-expanded=true]{border-color:var(--hp-border);background:color-mix(in srgb,var(--hp-fg) 8%,transparent)}
|
|
216
|
+
.hosty-panel-workspace :focus-visible{outline:2px solid #5286ee;outline-offset:2px}.hosty-panel-attention{position:absolute;top:0;right:1px;color:#e9a52b;font-size:20px}
|
|
217
|
+
.hosty-panel-body{order:2;display:flex;flex:0 0 var(--hosty-panel-width);flex-direction:column;position:relative;min-width:0;min-height:0;margin-left:12px;border-left:1px solid var(--hp-border);background:var(--hp-bg)}
|
|
218
|
+
.hosty-panel-body[hidden],.hosty-panel-frame[hidden]{display:none}.hosty-panel-body header,.hosty-tool-chooser header{display:flex;align-items:center;gap:6px;min-height:44px;padding:0 10px;border-bottom:1px solid var(--hp-border);font:500 13px system-ui}.hosty-panel-body header strong,.hosty-tool-chooser header strong{flex:1}
|
|
219
|
+
.hosty-panel-focus-guard{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
|
|
220
|
+
.hosty-panel-frame{position:relative;flex:1;min-height:0;overflow:hidden}.hosty-panel-frame iframe{display:block}.hosty-panel-resize{position:absolute;left:-12px;top:0;bottom:0;width:12px;cursor:col-resize;touch-action:none}.hosty-panel-resize:hover{background:color-mix(in srgb,var(--hp-fg) 8%,transparent)}
|
|
221
|
+
.hosty-panel-state{padding:8px 12px;font:12px/1.4 system-ui;border-bottom:1px solid var(--hp-border);color:var(--hp-muted)}.hosty-panel-state a{text-decoration:underline}
|
|
222
|
+
.hosty-panel-workspace[data-hosty-panel-layout=drawer] .hosty-panel-body{position:absolute;inset:0 0 0 auto;width:min(440px,100%);z-index:3;margin:0;box-shadow:-8px 0 30px #0002}.hosty-panel-workspace[data-hosty-panel-layout=drawer] .hosty-panel-resize{display:none}
|
|
223
|
+
.hosty-panel-backdrop{position:absolute;inset:0;border:0;background:#0005;z-index:2;cursor:pointer}.hosty-panel-notice{padding:24px;font:14px/1.5 system-ui}.hosty-panel-review{position:absolute;top:8px;right:8px;z-index:2;padding:10px;background:var(--hp-bg);border:1px solid var(--hp-border);border-radius:8px}
|
|
224
|
+
.hosty-tool-chooser{width:min(440px,calc(100vw - 32px));max-height:80dvh;border:1px solid var(--hp-border);border-radius:12px;padding:16px;background:var(--hp-bg);color:var(--hp-fg);font:14px/1.5 system-ui}.hosty-tool-chooser::backdrop{background:#0005}.hosty-tool-chooser ul{list-style:none;padding:0;display:grid;gap:8px}.hosty-tool-chooser li{display:flex;gap:8px}.hosty-tool-chooser li>button{flex:1;border:1px solid var(--hp-border);background:transparent;color:inherit;border-radius:8px;padding:12px;text-align:left;cursor:pointer}.hosty-tool-chooser li span{display:block;color:var(--hp-muted);font-size:12px}.hosty-panel-error{position:absolute;bottom:16px;right:60px;z-index:4;background:var(--hp-bg);border:1px solid var(--hp-border);border-radius:8px;padding:12px;font:13px system-ui}
|
|
225
|
+
`;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
import { type PanelTarget, type UiBootstrap, type UiPanel, type UiResolvedPanel } from "@hosty-sdk/app/ui/types";
|
|
3
|
+
/** Server transport for generic UI metadata. Credentials never appear in returned metadata. */
|
|
4
|
+
export declare class UiServerClient {
|
|
5
|
+
private readonly appId;
|
|
6
|
+
private readonly core;
|
|
7
|
+
private readonly service;
|
|
8
|
+
constructor(options?: ProviderServerOptions);
|
|
9
|
+
private request;
|
|
10
|
+
bootstrap(options: {
|
|
11
|
+
parentOrigin?: string;
|
|
12
|
+
}, token: string, signal?: AbortSignal): Promise<UiBootstrap>;
|
|
13
|
+
listPanels(token: string, signal?: AbortSignal): Promise<UiPanel[]>;
|
|
14
|
+
resolvePanel(target: PanelTarget, token: string, signal?: AbortSignal): Promise<UiResolvedPanel>;
|
|
15
|
+
}
|
|
16
|
+
type UiAuthenticatedContext = {
|
|
17
|
+
session: {
|
|
18
|
+
status: "active";
|
|
19
|
+
identity: {
|
|
20
|
+
hostRole: string | null;
|
|
21
|
+
};
|
|
22
|
+
} | {
|
|
23
|
+
status: "not-present" | "expired" | "forbidden" | "unavailable" | "misconfigured";
|
|
24
|
+
};
|
|
25
|
+
token: string | null;
|
|
26
|
+
};
|
|
27
|
+
/** A fixed Web Request/Response adapter; never a caller-chosen Core proxy. */
|
|
28
|
+
export declare function createHostyUiResponse(request: Request, context: UiAuthenticatedContext, options?: ProviderServerOptions & {
|
|
29
|
+
administratorOnly?: boolean;
|
|
30
|
+
uiPath?: string;
|
|
31
|
+
}): Promise<Response>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { UiError } from "@hosty-sdk/app/ui/types";
|
|
2
|
+
/** Server transport for generic UI metadata. Credentials never appear in returned metadata. */
|
|
3
|
+
export class UiServerClient {
|
|
4
|
+
appId;
|
|
5
|
+
core;
|
|
6
|
+
service;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
|
|
9
|
+
this.core = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
|
|
10
|
+
this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
|
|
11
|
+
if (!this.appId || !this.service)
|
|
12
|
+
throw new UiError("hosty_misconfigured", "Hosty app credentials are not configured.", 503);
|
|
13
|
+
}
|
|
14
|
+
async request(path, token, body, signal) {
|
|
15
|
+
if (!token)
|
|
16
|
+
throw new UiError("app_identity_required", "Sign in to use application panels.", 401);
|
|
17
|
+
const response = await fetch(new URL(`/api/internal/apps/${encodeURIComponent(this.appId)}/ui/${path}`, this.core), {
|
|
18
|
+
method: body === undefined ? "GET" : "POST", redirect: "error", cache: "no-store",
|
|
19
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
20
|
+
headers: { authorization: `Bearer ${this.service}`, "X-Hosty-User-Token": token, "content-type": "application/json" },
|
|
21
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
const value = await response.json().catch(() => null);
|
|
24
|
+
if (response.status === 404)
|
|
25
|
+
throw new UiError("unsupported_core", "Update Core to use application panels.", 409);
|
|
26
|
+
if (!response.ok)
|
|
27
|
+
throw new UiError(value?.code ?? "ui_unavailable", value?.message ?? "Application panels are unavailable.", response.status);
|
|
28
|
+
if (!value)
|
|
29
|
+
throw new UiError("ui_response_invalid", "Core returned invalid panel metadata.", 502);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
bootstrap(options, token, signal) {
|
|
33
|
+
return this.request(`bootstrap${options.parentOrigin ? `?parentOrigin=${encodeURIComponent(options.parentOrigin)}` : ""}`, token, undefined, signal);
|
|
34
|
+
}
|
|
35
|
+
async listPanels(token, signal) {
|
|
36
|
+
return (await this.request("panels", token, undefined, signal)).panels;
|
|
37
|
+
}
|
|
38
|
+
resolvePanel(target, token, signal) {
|
|
39
|
+
return this.request("panels/resolve", token, target, signal);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const responseHeaders = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
43
|
+
/** A fixed Web Request/Response adapter; never a caller-chosen Core proxy. */
|
|
44
|
+
export async function createHostyUiResponse(request, context, options = {}) {
|
|
45
|
+
try {
|
|
46
|
+
if (context.session.status !== "active" || !context.token) {
|
|
47
|
+
const state = context.session.status;
|
|
48
|
+
return Response.json({ code: state, message: "Sign in to use application panels." }, { status: state === "forbidden" ? 403 : state === "unavailable" ? 503 : 401, headers: responseHeaders });
|
|
49
|
+
}
|
|
50
|
+
if (options.administratorOnly && context.session.identity.hostRole !== "host.admin")
|
|
51
|
+
throw new UiError("admin_required", "This application requires administrator access.", 403);
|
|
52
|
+
const url = new URL(request.url);
|
|
53
|
+
const prefix = (options.uiPath ?? "/api/hosty/ui").replace(/\/$/, "");
|
|
54
|
+
const route = url.pathname.startsWith(prefix + "/") ? url.pathname.slice(prefix.length + 1) : "";
|
|
55
|
+
const client = new UiServerClient(options);
|
|
56
|
+
let result;
|
|
57
|
+
if (request.method === "GET" && route === "bootstrap") {
|
|
58
|
+
if ([...url.searchParams.keys()].some(k => k !== "parentOrigin") || url.searchParams.getAll("parentOrigin").length > 1)
|
|
59
|
+
throw new UiError("ui_request_invalid", "Invalid bootstrap request.", 400);
|
|
60
|
+
result = await client.bootstrap({ parentOrigin: url.searchParams.get("parentOrigin") ?? undefined }, context.token, request.signal);
|
|
61
|
+
}
|
|
62
|
+
else if (request.method === "GET" && route === "panels") {
|
|
63
|
+
result = { panels: await client.listPanels(context.token, request.signal) };
|
|
64
|
+
}
|
|
65
|
+
else if (request.method === "POST" && route === "panels/resolve") {
|
|
66
|
+
if (!sameOrigin(request) || request.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json")
|
|
67
|
+
throw new UiError("origin_denied", "Open panels from this application page.", 403);
|
|
68
|
+
const body = await boundedBody(request);
|
|
69
|
+
result = await client.resolvePanel(JSON.parse(body), context.token, request.signal);
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
throw new UiError("ui_route_not_found", "Unknown panel operation.", 404);
|
|
73
|
+
return Response.json(result, { headers: responseHeaders });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const known = error instanceof UiError;
|
|
77
|
+
return Response.json({ code: known ? error.code : error instanceof SyntaxError ? "ui_request_invalid" : "ui_unavailable",
|
|
78
|
+
message: known ? error.message : "Application panels are unavailable. Retry opening the same target." }, { status: known ? error.status : error instanceof SyntaxError ? 400 : 503, headers: responseHeaders });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function boundedBody(request) {
|
|
82
|
+
const reader = request.body?.getReader();
|
|
83
|
+
if (!reader)
|
|
84
|
+
return "";
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let length = 0;
|
|
87
|
+
try {
|
|
88
|
+
for (;;) {
|
|
89
|
+
const { value, done } = await reader.read();
|
|
90
|
+
if (done)
|
|
91
|
+
break;
|
|
92
|
+
length += value.byteLength;
|
|
93
|
+
if (length > 16_384) {
|
|
94
|
+
await reader.cancel();
|
|
95
|
+
throw new UiError("ui_request_invalid", "Panel request is too large.", 413);
|
|
96
|
+
}
|
|
97
|
+
chunks.push(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
reader.releaseLock();
|
|
102
|
+
}
|
|
103
|
+
const bytes = new Uint8Array(length);
|
|
104
|
+
let offset = 0;
|
|
105
|
+
for (const chunk of chunks) {
|
|
106
|
+
bytes.set(chunk, offset);
|
|
107
|
+
offset += chunk.byteLength;
|
|
108
|
+
}
|
|
109
|
+
return new TextDecoder().decode(bytes);
|
|
110
|
+
}
|
|
111
|
+
function sameOrigin(request) {
|
|
112
|
+
try {
|
|
113
|
+
const origin = request.headers.get("origin");
|
|
114
|
+
const source = new URL(origin ?? "");
|
|
115
|
+
const requestUrl = new URL(request.url);
|
|
116
|
+
const destination = new URL(`${source.protocol}//${request.headers.get("host") ?? requestUrl.host}`);
|
|
117
|
+
return origin === source.origin && ["http:", "https:"].includes(source.protocol) && origin === destination.origin
|
|
118
|
+
&& (source.protocol === requestUrl.protocol || request.headers.get("sec-fetch-site") === "same-origin");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type PanelTarget, type PresentationResult } from "./ui-types.js";
|
|
2
|
+
export declare const UI_VERSION = 1;
|
|
3
|
+
export declare const DISCOVERY_TIMEOUT = 5000;
|
|
4
|
+
export declare const RESPONSE_TIMEOUT = 10000;
|
|
5
|
+
export type UiMessage = Record<string, unknown> & {
|
|
6
|
+
type: string;
|
|
7
|
+
version: 1;
|
|
8
|
+
};
|
|
9
|
+
export declare function readUiMessage(value: unknown): UiMessage | null;
|
|
10
|
+
export declare function validId(value: unknown): value is string;
|
|
11
|
+
export declare function canonicalSurfaceRoute(value: unknown): string | null;
|
|
12
|
+
export declare function validTarget(value: unknown): value is PanelTarget;
|
|
13
|
+
export declare function uiPath(path?: string): string;
|
|
14
|
+
export declare function uiRequest<T>(path: string, suffix: string, body?: unknown): Promise<T>;
|
|
15
|
+
export declare function failure(requestId: string, error: unknown): PresentationResult;
|
|
16
|
+
export declare function waitForPresentation(operation: Promise<PresentationResult>, requestId: string, signal?: AbortSignal): Promise<PresentationResult>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { appFetch } from "./browser-auth.js";
|
|
2
|
+
import { UiError, isSurfaceRoute } from "./ui-types.js";
|
|
3
|
+
export const UI_VERSION = 1;
|
|
4
|
+
export const DISCOVERY_TIMEOUT = 5_000;
|
|
5
|
+
export const RESPONSE_TIMEOUT = 10_000;
|
|
6
|
+
export function readUiMessage(value) {
|
|
7
|
+
if (!value || typeof value !== "object")
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
if (new TextEncoder().encode(JSON.stringify(value)).length > 16_384)
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const msg = value;
|
|
17
|
+
return msg.version === UI_VERSION && typeof msg.type === "string" && msg.type.startsWith("hosty:ui:") ? msg : null;
|
|
18
|
+
}
|
|
19
|
+
export function validId(value) { return typeof value === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(value); }
|
|
20
|
+
export function canonicalSurfaceRoute(value) {
|
|
21
|
+
if (!isSurfaceRoute(value))
|
|
22
|
+
return null;
|
|
23
|
+
const route = new URL(value, "https://surface.invalid");
|
|
24
|
+
for (const key of ["hosty_launch", "hosty_theme", "hosty_theme_preference"])
|
|
25
|
+
route.searchParams.delete(key);
|
|
26
|
+
return `${route.pathname}${route.search}${route.hash}`;
|
|
27
|
+
}
|
|
28
|
+
export function validTarget(value) {
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return false;
|
|
31
|
+
const target = value, panel = target.panel;
|
|
32
|
+
return !!panel && [panel.appId, panel.installationId, panel.panelId].every(s => typeof s === "string" && s.length > 0 && s.length <= 256)
|
|
33
|
+
&& (target.route === undefined || isSurfaceRoute(target.route));
|
|
34
|
+
}
|
|
35
|
+
export function uiPath(path = "/api/hosty/ui") {
|
|
36
|
+
if (!path.startsWith("/") || path.startsWith("//") || /[?#\\]/.test(path))
|
|
37
|
+
throw new Error("uiPath must be a same-origin path.");
|
|
38
|
+
return path.replace(/\/$/, "");
|
|
39
|
+
}
|
|
40
|
+
export async function uiRequest(path, suffix, body) {
|
|
41
|
+
const response = await appFetch(`${path}/${suffix}`, { method: body === undefined ? "GET" : "POST", cache: "no-store", redirect: "error",
|
|
42
|
+
signal: AbortSignal.timeout(RESPONSE_TIMEOUT), headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
43
|
+
body: body === undefined ? undefined : JSON.stringify(body) });
|
|
44
|
+
const value = await response.json().catch(() => null);
|
|
45
|
+
if (response.status === 404)
|
|
46
|
+
throw new UiError("unsupported_app", "This app does not support shared panels.", 404);
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new UiError(value?.code ?? "ui_unavailable", value?.message ?? "Unable to open the panel.", response.status);
|
|
49
|
+
if (!value)
|
|
50
|
+
throw new UiError("ui_response_invalid", "The app returned invalid panel metadata.", 502);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
export function failure(requestId, error) {
|
|
54
|
+
return { status: error instanceof UiError && error.code.startsWith("unsupported") ? "unsupported" : "blocked", requestId,
|
|
55
|
+
code: error instanceof UiError ? error.code : "ui_unavailable" };
|
|
56
|
+
}
|
|
57
|
+
export function waitForPresentation(operation, requestId, signal) {
|
|
58
|
+
return new Promise(resolve => {
|
|
59
|
+
const finish = (value) => { clearTimeout(timer); signal?.removeEventListener("abort", uncertain); resolve(value); };
|
|
60
|
+
const uncertain = () => finish({ status: "uncertain", requestId, code: "presentation_unknown" });
|
|
61
|
+
const timer = setTimeout(uncertain, RESPONSE_TIMEOUT);
|
|
62
|
+
if (signal?.aborted)
|
|
63
|
+
uncertain();
|
|
64
|
+
else
|
|
65
|
+
signal?.addEventListener("abort", uncertain, { once: true });
|
|
66
|
+
void operation.then(finish, error => finish(failure(requestId, error)));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** A tool within one Core installation; no user credential or conversation identity. */
|
|
2
|
+
export type PanelRef = {
|
|
3
|
+
appId: string;
|
|
4
|
+
installationId: string;
|
|
5
|
+
panelId: string;
|
|
6
|
+
};
|
|
7
|
+
export type PanelTarget = {
|
|
8
|
+
panel: PanelRef;
|
|
9
|
+
route?: string;
|
|
10
|
+
};
|
|
11
|
+
export type AppSurfaceRef = {
|
|
12
|
+
appId: string;
|
|
13
|
+
installationId: string;
|
|
14
|
+
endpoint: string;
|
|
15
|
+
route: string;
|
|
16
|
+
};
|
|
17
|
+
export type UiFramePolicy = {
|
|
18
|
+
microphone: boolean;
|
|
19
|
+
popupEscape: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type UiPeer = {
|
|
22
|
+
appId: string;
|
|
23
|
+
installationId: string;
|
|
24
|
+
origins: string[];
|
|
25
|
+
};
|
|
26
|
+
export type UiBootstrap = {
|
|
27
|
+
version: 1;
|
|
28
|
+
coreOrigin: string;
|
|
29
|
+
userId: string;
|
|
30
|
+
app: UiPeer;
|
|
31
|
+
canHost: boolean;
|
|
32
|
+
pinnedOpen: boolean;
|
|
33
|
+
peer?: UiPeer | null;
|
|
34
|
+
};
|
|
35
|
+
export type UiPanel = {
|
|
36
|
+
panel: PanelRef;
|
|
37
|
+
label: string;
|
|
38
|
+
appName: string;
|
|
39
|
+
icon: string | null;
|
|
40
|
+
endpoint: string;
|
|
41
|
+
route: string;
|
|
42
|
+
origin: string | null;
|
|
43
|
+
available: boolean;
|
|
44
|
+
state: string;
|
|
45
|
+
canStart: boolean;
|
|
46
|
+
framePolicy: UiFramePolicy;
|
|
47
|
+
};
|
|
48
|
+
export type UiFrameSurface = AppSurfaceRef & {
|
|
49
|
+
origin: string;
|
|
50
|
+
framePolicy: UiFramePolicy;
|
|
51
|
+
panelId?: string;
|
|
52
|
+
};
|
|
53
|
+
export type UiResolvedPanel = {
|
|
54
|
+
target: PanelTarget;
|
|
55
|
+
surface: UiFrameSurface | null;
|
|
56
|
+
available: boolean;
|
|
57
|
+
state: string;
|
|
58
|
+
standaloneHref: string | null;
|
|
59
|
+
};
|
|
60
|
+
export type UiEnvironment = {
|
|
61
|
+
mode: "standalone" | "embedded";
|
|
62
|
+
connection: "connecting" | "connected" | "unsupported" | "blocked";
|
|
63
|
+
code?: string;
|
|
64
|
+
};
|
|
65
|
+
export type SurfaceReadiness = "loading" | "auth-required" | "ready" | "unreported";
|
|
66
|
+
export type PresentationResult = {
|
|
67
|
+
status: "opened";
|
|
68
|
+
requestId: string;
|
|
69
|
+
target: PanelTarget;
|
|
70
|
+
readiness: SurfaceReadiness;
|
|
71
|
+
} | {
|
|
72
|
+
status: "blocked" | "unsupported" | "rejected" | "uncertain" | "unknown";
|
|
73
|
+
requestId: string;
|
|
74
|
+
code: string;
|
|
75
|
+
};
|
|
76
|
+
export type PresentationOptions = {
|
|
77
|
+
placement: "side";
|
|
78
|
+
requestId: string;
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
};
|
|
81
|
+
export type UiEvent = {
|
|
82
|
+
type: "environment";
|
|
83
|
+
value: UiEnvironment;
|
|
84
|
+
} | {
|
|
85
|
+
type: "presentation";
|
|
86
|
+
value: PresentationResult;
|
|
87
|
+
};
|
|
88
|
+
/** UI endpoints preserve typed operational failures without hiding identity refusals. */
|
|
89
|
+
export declare class UiError extends Error {
|
|
90
|
+
readonly code: string;
|
|
91
|
+
readonly status: number;
|
|
92
|
+
constructor(code: string, message: string, status?: number);
|
|
93
|
+
}
|
|
94
|
+
export declare function panelKey(panel: PanelRef): string;
|
|
95
|
+
/** Reject traversal before URL normalization can erase it. Query/fragment are opaque navigation. */
|
|
96
|
+
export declare function isSurfaceRoute(route: unknown): route is string;
|
|
97
|
+
export declare function routeWithin(route: string, declared: string): boolean;
|
|
98
|
+
/** Constructs navigation only. Core must advertise pinnedOpen and revalidate at redemption. */
|
|
99
|
+
export declare function buildSurfaceOpenUrl(coreOrigin: string, destination: AppSurfaceRef): string;
|
package/dist/ui-types.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** UI endpoints preserve typed operational failures without hiding identity refusals. */
|
|
2
|
+
export class UiError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(code, message, status = 409) { super(message); this.code = code; this.status = status; }
|
|
6
|
+
}
|
|
7
|
+
export function panelKey(panel) {
|
|
8
|
+
return JSON.stringify([panel.appId, panel.installationId, panel.panelId]);
|
|
9
|
+
}
|
|
10
|
+
/** Reject traversal before URL normalization can erase it. Query/fragment are opaque navigation. */
|
|
11
|
+
export function isSurfaceRoute(route) {
|
|
12
|
+
if (typeof route !== "string" || route.length > 8192 || !route.startsWith("/") || route.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(route))
|
|
13
|
+
return false;
|
|
14
|
+
const path = route.split(/[?#]/, 1)[0];
|
|
15
|
+
if (/%(?:2f|5c|25)/i.test(path) || /%(?![\da-f]{2})/i.test(path))
|
|
16
|
+
return false;
|
|
17
|
+
try {
|
|
18
|
+
return !decodeURIComponent(path).split("/").some(part => part === "." || part === "..");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function routeWithin(route, declared) {
|
|
25
|
+
if (!isSurfaceRoute(route) || !isSurfaceRoute(declared))
|
|
26
|
+
return false;
|
|
27
|
+
const path = new URL(route, "https://surface.invalid").pathname;
|
|
28
|
+
const root = new URL(declared, "https://surface.invalid").pathname.replace(/\/$/, "") || "/";
|
|
29
|
+
return root === "/" || path === root || path.startsWith(root + "/");
|
|
30
|
+
}
|
|
31
|
+
/** Constructs navigation only. Core must advertise pinnedOpen and revalidate at redemption. */
|
|
32
|
+
export function buildSurfaceOpenUrl(coreOrigin, destination) {
|
|
33
|
+
if (!isSurfaceRoute(destination.route) || !destination.installationId || !destination.endpoint || !destination.appId)
|
|
34
|
+
throw new UiError("destination_invalid", "Invalid app surface destination.");
|
|
35
|
+
const core = new URL(coreOrigin);
|
|
36
|
+
if (!["http:", "https:"].includes(core.protocol) || core.username || core.password)
|
|
37
|
+
throw new UiError("core_origin_invalid", "Invalid Core browser origin.");
|
|
38
|
+
const url = new URL(`/api/apps/${encodeURIComponent(destination.appId)}/open`, core.origin);
|
|
39
|
+
url.searchParams.set("installationId", destination.installationId);
|
|
40
|
+
url.searchParams.set("endpoint", destination.endpoint);
|
|
41
|
+
url.searchParams.set("route", destination.route);
|
|
42
|
+
return url.href;
|
|
43
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PanelTarget, PresentationOptions, PresentationResult, UiEnvironment, UiEvent } from "@hosty-sdk/app/ui/types";
|
|
2
|
+
export * from "@hosty-sdk/app/ui/types";
|
|
3
|
+
export interface UiClient {
|
|
4
|
+
getEnvironment(): UiEnvironment;
|
|
5
|
+
openSurface(target: PanelTarget, options: PresentationOptions): Promise<PresentationResult>;
|
|
6
|
+
getPresentation(requestId: string, options?: {
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}): Promise<PresentationResult>;
|
|
9
|
+
subscribe(listener: (event: UiEvent) => void): () => void;
|
|
10
|
+
reportSurfaceState(state: {
|
|
11
|
+
route: string;
|
|
12
|
+
state: "ready" | "blocked";
|
|
13
|
+
code?: string;
|
|
14
|
+
}): void;
|
|
15
|
+
}
|
|
16
|
+
/** One document-local facade shared by domain apps; it never selects a provider or opens popups. */
|
|
17
|
+
export declare const ui: UiClient;
|
|
18
|
+
/** Internal root integration; exported from this source only, not as an ambient browser global. */
|
|
19
|
+
export declare function bindUiClient(client: UiClient): () => void;
|
|
20
|
+
export declare function createPresentationRequestId(): string;
|