@hosty-sdk/app 0.23.0 → 0.25.12
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 +72 -0
- package/dist/assistant.d.ts +15 -0
- package/dist/assistant.js +38 -1
- package/dist/overlay-react.d.ts +3 -1
- package/dist/overlay-react.js +3 -2
- package/dist/providers-server.d.ts +3 -1
- package/dist/providers-server.js +4 -2
- package/dist/providers.d.ts +10 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +9 -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 +21 -1
package/README.md
CHANGED
|
@@ -299,3 +299,75 @@ listen for `APP_SESSION_ENDED`, and call `renewAppActivity` directly from a clic
|
|
|
299
299
|
or reload to renew access. `appActivityNeedsRenewal()` supports proactive click renewal near expiry.
|
|
300
300
|
See [the activity contract](../../docs/features/app-activity-window/feature.md), including the
|
|
301
301
|
separate Core-approved assistant session leases.
|
|
302
|
+
|
|
303
|
+
## Shared panels (Core 0.129.0 / SDK 0.25.0)
|
|
304
|
+
|
|
305
|
+
`HostyOverlay` composes one UI owner per top-level browser document. Its default
|
|
306
|
+
`panelLayout="auto"` adds an opened-tool rail, an **Add tool** chooser and a resizable
|
|
307
|
+
workspace. In a directly embedded app it delegates presentation to the verified parent
|
|
308
|
+
and renders no local rail. Use `panelLayout="slot"` with exactly one
|
|
309
|
+
`HostyPanelWorkspace` inside your chrome, or `"none"` for a custom controller renderer.
|
|
310
|
+
Panel hosting requires reviewed optional `apps.panels.read` (or existing `apps.read`);
|
|
311
|
+
participating as a child does not require catalog access.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
// app/api/hosty/ui/[...action]/route.ts — own app configuration/session only
|
|
315
|
+
import { createHostyUiRouteHandler } from "@hosty-sdk/app/server";
|
|
316
|
+
const handler = createHostyUiRouteHandler(config);
|
|
317
|
+
export const GET = handler;
|
|
318
|
+
export const POST = handler;
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Non-Next servers use `createHostyUiResponse` from `@hosty-sdk/app/ui/server` with their
|
|
322
|
+
revalidated own app-user session/token. `UiServerClient` provides only `bootstrap`,
|
|
323
|
+
`listPanels` and `resolvePanel`; never expose a generic Core proxy or caller-supplied credentials.
|
|
324
|
+
Both server adapters return `no-store` metadata. The React root's `uiPath` defaults to
|
|
325
|
+
`/api/hosty/ui` and must be a same-origin path.
|
|
326
|
+
|
|
327
|
+
Finalize a business handoff before requesting placement. Freeze the provider's app ID,
|
|
328
|
+
installation ID and interface key before `ProviderClient.assistant(...)`, then read its
|
|
329
|
+
current descriptor and map the completed result:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { resolveAssistantPresentation } from "@hosty-sdk/app/assistant";
|
|
333
|
+
import { buildSurfaceOpenUrl } from "@hosty-sdk/app/ui";
|
|
334
|
+
const presentation = resolveAssistantPresentation(currentProvider, finalized, { expectedProvider });
|
|
335
|
+
const href = buildSurfaceOpenUrl(bootstrap.coreOrigin, presentation.destination);
|
|
336
|
+
// bootstrap.pinnedOpen must be true before relying on this link.
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The pure mapper never selects an assistant or invokes it. Explicit `result.open.panelId`
|
|
340
|
+
selects a matching declared panel; otherwise the unique longest endpoint/path boundary
|
|
341
|
+
wins. Ties return `ambiguous_surface`, navigation-only providers return `no_panel`, and
|
|
342
|
+
both retain the exact same-session destination for explicit separate opening. A changed
|
|
343
|
+
provider installation is refused. Core validates pinned links again after login.
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
import { useHostyUi } from "@hosty-sdk/app/ui/react";
|
|
347
|
+
import { createPresentationRequestId } from "@hosty-sdk/app/ui";
|
|
348
|
+
const ui = useHostyUi();
|
|
349
|
+
const requestId = createPresentationRequestId(); // retain with the completed result
|
|
350
|
+
const result = await ui.openSurface(presentation.target!, { placement: "side", requestId });
|
|
351
|
+
// Reuse requestId and target for retries; do not repeat the business action.
|
|
352
|
+
const current = await ui.getPresentation(requestId);
|
|
353
|
+
const unsubscribe = ui.subscribe(event => { /* environment or presentation update */ });
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`opened` confirms placement, with separate `loading`, `auth-required`, `ready` or
|
|
357
|
+
`unreported` readiness. Providers call `ui.reportSurfaceState({ route, state: "ready" })`
|
|
358
|
+
after their exact route has loaded; iframe load alone is not readiness. Deadline/abort
|
|
359
|
+
returns `uncertain`, not cancellation. Query or retry the same ID. An unsupported host
|
|
360
|
+
never automatically opens a tab or creates a nested workspace. **Open separately** is an
|
|
361
|
+
explicit `target="_blank" rel="noopener noreferrer"` link to the pinned destination.
|
|
362
|
+
|
|
363
|
+
Framework-neutral clients use `createUiHost` from `@hosty-sdk/app/ui/host`, its snapshots,
|
|
364
|
+
`registerFrame`, `openSurface`, `getPresentation`, `setExpanded`, `setWidth` and `dispose`.
|
|
365
|
+
React clients normally use the overlay-owned controller via `useHostyUiHost` and the shared
|
|
366
|
+
`HostyAppFrame`. Core projects the target's minimal microphone/popup policy; an app message
|
|
367
|
+
cannot widen it. No delegated-token responder is required.
|
|
368
|
+
|
|
369
|
+
Trust is limited to exact mounted window/origin, document generation and stateless Core
|
|
370
|
+
peer metadata. Matching opaque Core/user contexts prevents accidental account mixing
|
|
371
|
+
between cooperating apps; it is not attestation of app code or delegated API authority.
|
|
372
|
+
Every target API still authorizes its own session. Older surfaces remain `unreported`.
|
|
373
|
+
See [the panel contract and verification](../../docs/features/sdk-panel-system/feature.md).
|
package/dist/assistant.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ProviderDescriptor } from "./providers.js";
|
|
2
|
+
import { type AppSurfaceRef, type PanelTarget } from "@hosty-sdk/app/ui/types";
|
|
1
3
|
/** Preserve provider refusal details through app-owned server transports. */
|
|
2
4
|
export declare class AssistantError extends Error {
|
|
3
5
|
readonly code: string;
|
|
@@ -12,6 +14,7 @@ export type AssistantContract = {
|
|
|
12
14
|
export type AssistantDestination = {
|
|
13
15
|
endpoint: string;
|
|
14
16
|
path: string;
|
|
17
|
+
panelId?: string;
|
|
15
18
|
};
|
|
16
19
|
export type AssistantHandoffResult = {
|
|
17
20
|
conversationId: string;
|
|
@@ -51,6 +54,18 @@ export declare function resolveAssistantDestination(destination: AssistantDestin
|
|
|
51
54
|
path?: string | null;
|
|
52
55
|
url?: string | null;
|
|
53
56
|
}[]): string;
|
|
57
|
+
/** Pure mapping after finalization; the presentation host separately checks current access. */
|
|
58
|
+
export declare function resolveAssistantPresentation(provider: ProviderDescriptor, result: AssistantHandoffResult, options: {
|
|
59
|
+
expectedProvider: {
|
|
60
|
+
appId: string;
|
|
61
|
+
installationId: string;
|
|
62
|
+
key: string;
|
|
63
|
+
};
|
|
64
|
+
}): {
|
|
65
|
+
target: PanelTarget | null;
|
|
66
|
+
destination: AppSurfaceRef;
|
|
67
|
+
reason?: "no_panel" | "ambiguous_surface";
|
|
68
|
+
};
|
|
54
69
|
export declare class AssistantClient {
|
|
55
70
|
private readonly baseUrl;
|
|
56
71
|
private readonly contract;
|
package/dist/assistant.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSurfaceRoute, routeWithin } from "@hosty-sdk/app/ui/types";
|
|
1
2
|
/** Preserve provider refusal details through app-owned server transports. */
|
|
2
3
|
export class AssistantError extends Error {
|
|
3
4
|
code;
|
|
@@ -27,7 +28,7 @@ export function createAssistantRequestId(now = Date.now()) {
|
|
|
27
28
|
}
|
|
28
29
|
/** Resolve only inside a declared UI surface on the selected app's resolved endpoint. */
|
|
29
30
|
export function resolveAssistantDestination(destination, surfaces) {
|
|
30
|
-
if (!destination ||
|
|
31
|
+
if (!destination || !isSurfaceRoute(destination.path))
|
|
31
32
|
throw new Error("Invalid assistant UI destination.");
|
|
32
33
|
if (/%(?:2f|5c|25)/i.test(destination.path.split(/[?#]/, 1)[0]))
|
|
33
34
|
throw new Error("Invalid assistant UI destination.");
|
|
@@ -42,6 +43,42 @@ export function resolveAssistantDestination(destination, surfaces) {
|
|
|
42
43
|
}
|
|
43
44
|
throw new Error("The assistant returned a destination outside its declared UI.");
|
|
44
45
|
}
|
|
46
|
+
/** Pure mapping after finalization; the presentation host separately checks current access. */
|
|
47
|
+
export function resolveAssistantPresentation(provider, result, options) {
|
|
48
|
+
const expected = options.expectedProvider;
|
|
49
|
+
if (!provider.installationId)
|
|
50
|
+
throw new AssistantError("unsupported_core", "Update Core to identify assistant installations.", 409);
|
|
51
|
+
if (provider.appId !== expected.appId || provider.key !== expected.key || provider.installationId !== expected.installationId)
|
|
52
|
+
throw new AssistantError("provider_changed", "The original assistant installation is no longer available.", 409);
|
|
53
|
+
try {
|
|
54
|
+
resolveAssistantDestination(result.open, provider.uiSurfaces ?? []);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new AssistantError("destination_invalid", "The assistant returned an undeclared destination.", 409);
|
|
58
|
+
}
|
|
59
|
+
const destination = { appId: provider.appId, installationId: provider.installationId,
|
|
60
|
+
endpoint: result.open.endpoint, route: result.open.path };
|
|
61
|
+
const panels = (provider.uiSurfaces ?? []).filter(s => s.kind === "panel" && s.panelId && s.url
|
|
62
|
+
&& s.endpoint === destination.endpoint && routeWithin(destination.route, s.path));
|
|
63
|
+
let selected;
|
|
64
|
+
if (result.open.panelId !== undefined) {
|
|
65
|
+
selected = panels.find(s => s.panelId === result.open.panelId);
|
|
66
|
+
if (!selected)
|
|
67
|
+
throw new AssistantError("destination_invalid", "The assistant panel does not match its destination.", 409);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (!panels.length)
|
|
71
|
+
return { target: null, destination, reason: "no_panel" };
|
|
72
|
+
const length = (path) => new URL(path, "https://surface.invalid").pathname.replace(/\/$/, "").length;
|
|
73
|
+
const longest = Math.max(...panels.map(s => length(s.path)));
|
|
74
|
+
const matches = panels.filter(s => length(s.path) === longest);
|
|
75
|
+
if (new Set(matches.map(s => s.panelId)).size !== 1)
|
|
76
|
+
return { target: null, destination, reason: "ambiguous_surface" };
|
|
77
|
+
selected = matches[0];
|
|
78
|
+
}
|
|
79
|
+
return { target: { panel: { appId: provider.appId, installationId: provider.installationId, panelId: selected.panelId },
|
|
80
|
+
route: destination.route }, destination };
|
|
81
|
+
}
|
|
45
82
|
export class AssistantClient {
|
|
46
83
|
baseUrl;
|
|
47
84
|
contract;
|
package/dist/overlay-react.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
/** Mount once around protected content. Presentation and recovery belong to Hosty. */
|
|
3
|
-
export declare function HostyOverlay({ children, probePath, appCodePath }: {
|
|
3
|
+
export declare function HostyOverlay({ children, probePath, appCodePath, uiPath, panelLayout }: {
|
|
4
4
|
children: ReactNode;
|
|
5
5
|
probePath?: string;
|
|
6
6
|
appCodePath?: string;
|
|
7
|
+
uiPath?: string;
|
|
8
|
+
panelLayout?: "auto" | "slot" | "none";
|
|
7
9
|
}): import("react").JSX.Element;
|
package/dist/overlay-react.js
CHANGED
|
@@ -7,6 +7,7 @@ import { useAppActivity } from "./activity-react.js";
|
|
|
7
7
|
import { APP_SESSION_ENDED, APP_SESSION_RESTORED, APP_ACTIVITY_RENEWED, acceptAppAuthProtocol } from "./browser-auth.js";
|
|
8
8
|
import { buildCoreOpenUrl, readRecoveryParams } from "./index.js";
|
|
9
9
|
import { permissionReviewUrl, requestPermissionReview } from "./permissions.js";
|
|
10
|
+
import { HostyUiRoot } from "./ui-react.js";
|
|
10
11
|
function snapshot(body) {
|
|
11
12
|
const value = body;
|
|
12
13
|
if (value?.status !== "active" || typeof value.userId !== "string" || !value.userId)
|
|
@@ -29,7 +30,7 @@ function sessionReady(session) {
|
|
|
29
30
|
return session?.setup === "ready" && (!session.activityRequired || session.recoveryReady) && !activityExpired(session);
|
|
30
31
|
}
|
|
31
32
|
/** Mount once around protected content. Presentation and recovery belong to Hosty. */
|
|
32
|
-
export function HostyOverlay({ children, probePath = "/api/hosty/session", appCodePath = "/api/auth/app-code" }) {
|
|
33
|
+
export function HostyOverlay({ children, probePath = "/api/hosty/session", appCodePath = "/api/auth/app-code", uiPath, panelLayout = "auto" }) {
|
|
33
34
|
const actor = useRef(null);
|
|
34
35
|
const changingActor = useRef(false);
|
|
35
36
|
const [session, setSession] = useState(null);
|
|
@@ -165,7 +166,7 @@ export function HostyOverlay({ children, probePath = "/api/hosty/session", appCo
|
|
|
165
166
|
action = activity.renew;
|
|
166
167
|
actionLabel = "Renew access";
|
|
167
168
|
}
|
|
168
|
-
return _jsxs(_Fragment, { children: [_jsx("div", { "data-hosty-content": true, hidden: !ready, inert: !ready, style: ready ? { display: "contents" } : { display: "none" }, children: mounted.current ? children : null }), !ready && _jsx(OverlaySurface, { pending: pending, title: title, message: message, action: action, actionLabel: actionLabel, reviewUrl: reviewUrl })] });
|
|
169
|
+
return _jsxs(_Fragment, { children: [_jsx("div", { "data-hosty-content": true, hidden: !ready, inert: !ready, style: ready ? { display: "contents" } : { display: "none" }, children: _jsx(HostyUiRoot, { uiPath: uiPath, panelLayout: panelLayout, active: ready, children: mounted.current ? children : null }) }), !ready && _jsx(OverlaySurface, { pending: pending, title: title, message: message, action: action, actionLabel: actionLabel, reviewUrl: reviewUrl })] });
|
|
169
170
|
}
|
|
170
171
|
function OverlaySurface({ pending, title, message, action, actionLabel, reviewUrl }) {
|
|
171
172
|
const [portal, setPortal] = useState(null);
|
|
@@ -24,7 +24,9 @@ export declare class ProviderClient {
|
|
|
24
24
|
signal?: AbortSignal;
|
|
25
25
|
}): Promise<SpeechResult>;
|
|
26
26
|
/** User-attributed requests preserve the assistant's existing handoff and dispatch policy. */
|
|
27
|
-
assistant(provider: Pick<ProviderDescriptor, "appId" | "key"
|
|
27
|
+
assistant(provider: Pick<ProviderDescriptor, "appId" | "key"> & {
|
|
28
|
+
installationId?: string | null;
|
|
29
|
+
}, userToken: () => Promise<string>): Promise<AssistantClient>;
|
|
28
30
|
/** Provider-side validation checks live grants on every request; never cache this result. */
|
|
29
31
|
validate(token: string, kind: ProviderKind, key?: string, signal?: AbortSignal): Promise<ProviderInvocation>;
|
|
30
32
|
}
|
package/dist/providers-server.js
CHANGED
|
@@ -31,6 +31,8 @@ export class ProviderClient {
|
|
|
31
31
|
const issued = await this.coreRequest(`/providers/${provider.kind}/token`, { providerAppId: provider.appId, key: provider.key }, signal, userToken);
|
|
32
32
|
if (issued.provider.version !== 1 || !issued.provider.url)
|
|
33
33
|
throw new ProviderError("provider_incompatible", "This provider does not support interface version 1.", 409);
|
|
34
|
+
if (provider.installationId && issued.provider.installationId !== provider.installationId)
|
|
35
|
+
throw new ProviderError("provider_changed", "The selected assistant installation changed. Start a new discussion explicitly.", 409);
|
|
34
36
|
const url = new URL(issued.provider.url);
|
|
35
37
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
36
38
|
throw new ProviderError("provider_url_invalid", "Invalid provider endpoint.", 409);
|
|
@@ -58,8 +60,8 @@ export class ProviderClient {
|
|
|
58
60
|
const binding = await this.connect(target, undefined, await userToken());
|
|
59
61
|
return new AssistantClient(binding.url, { version: binding.provider.version, capabilities: binding.provider.capabilities }, async () => {
|
|
60
62
|
const fresh = await this.connect(target, undefined, await userToken());
|
|
61
|
-
if (fresh.url !== binding.url)
|
|
62
|
-
throw new ProviderError("provider_changed", "The assistant endpoint changed. Reconnect explicitly.", 409);
|
|
63
|
+
if (fresh.url !== binding.url || fresh.provider.installationId !== binding.provider.installationId)
|
|
64
|
+
throw new ProviderError("provider_changed", "The assistant endpoint or installation changed. Reconnect explicitly.", 409);
|
|
63
65
|
return { token: fresh.token };
|
|
64
66
|
});
|
|
65
67
|
}
|
package/dist/providers.d.ts
CHANGED
|
@@ -9,6 +9,16 @@ export type ProviderDescriptor = {
|
|
|
9
9
|
capabilities: string[];
|
|
10
10
|
url: string | null;
|
|
11
11
|
available: boolean;
|
|
12
|
+
/** Declared browser surfaces for resolving assistant handoffs; absent on older Core versions. */
|
|
13
|
+
uiSurfaces?: Array<{
|
|
14
|
+
endpoint: string | null;
|
|
15
|
+
path: string;
|
|
16
|
+
url: string | null;
|
|
17
|
+
kind?: "panel" | "navigation" | "entrypoint";
|
|
18
|
+
panelId?: string | null;
|
|
19
|
+
}> | null;
|
|
20
|
+
/** Opaque Core installation identity; compare for equality, never parse. */
|
|
21
|
+
installationId?: string | null;
|
|
12
22
|
};
|
|
13
23
|
export type AppPermissionState = {
|
|
14
24
|
required: string[];
|
package/dist/server.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export declare function getAppId(config: HostyAppConfig): string;
|
|
|
47
47
|
export declare function createHostySessionRouteHandler(config: HostyAppConfig, options?: {
|
|
48
48
|
administratorOnly?: boolean;
|
|
49
49
|
}): (request: Request) => Promise<Response>;
|
|
50
|
+
/** Mount GET/POST at /api/hosty/ui/[...action] for the shared panel protocol. */
|
|
51
|
+
export declare function createHostyUiRouteHandler(config: HostyAppConfig, options?: {
|
|
52
|
+
administratorOnly?: boolean;
|
|
53
|
+
uiPath?: string;
|
|
54
|
+
}): (request: Request) => Promise<Response>;
|
|
50
55
|
export declare function getCoreOrigin(): string | null;
|
|
51
56
|
/** Browser-reachable Core origin. Core always injects it (falls back to its loopback
|
|
52
57
|
* listen URL on localhost-only installs), so a missing value is a broken environment. */
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// bundle — the service token lives here.
|
|
3
3
|
import "server-only";
|
|
4
4
|
import { createHostySessionResponse } from "./session-server.js";
|
|
5
|
+
import { createHostyUiResponse } from "./ui-server.js";
|
|
5
6
|
import { resolveAppAuthProtocol, appAuthProtocolMinimum, isValidCodeVerifier } from "./app-code.js";
|
|
6
7
|
export { isValidCodeVerifier } from "./app-code.js";
|
|
7
8
|
import { classifyRevalidationHttpStatus, } from "./index.js";
|
|
@@ -28,6 +29,14 @@ export function createHostySessionRouteHandler(config, options = {}) {
|
|
|
28
29
|
return createHostySessionResponse(session, recovery, { ...options, signal: request.signal });
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
/** Mount GET/POST at /api/hosty/ui/[...action] for the shared panel protocol. */
|
|
33
|
+
export function createHostyUiRouteHandler(config, options = {}) {
|
|
34
|
+
return async (request) => {
|
|
35
|
+
const token = readAppIdentityToken(request.headers, config);
|
|
36
|
+
const session = await resolveAppSession(token, config);
|
|
37
|
+
return createHostyUiResponse(request, { session, token }, { ...options, appId: getAppId(config) });
|
|
38
|
+
};
|
|
39
|
+
}
|
|
31
40
|
export function getCoreOrigin() {
|
|
32
41
|
return process.env.HOSTY_CORE_ORIGIN?.trim() || null;
|
|
33
42
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type UiClient } from "./ui.js";
|
|
2
|
+
import { type PanelTarget, type PresentationOptions, type PresentationResult, type UiEnvironment, type UiEvent } from "./ui-types.js";
|
|
3
|
+
/** Direct-child transport. Discovery carries no identity and never creates a fallback host. */
|
|
4
|
+
export declare class EmbeddedUiClient implements UiClient {
|
|
5
|
+
private readonly path;
|
|
6
|
+
private environment;
|
|
7
|
+
private nonce;
|
|
8
|
+
private readonly listeners;
|
|
9
|
+
private readonly pending;
|
|
10
|
+
private channel;
|
|
11
|
+
private verifying;
|
|
12
|
+
private disposed;
|
|
13
|
+
private deadline;
|
|
14
|
+
private retries;
|
|
15
|
+
private state;
|
|
16
|
+
private active;
|
|
17
|
+
private offered;
|
|
18
|
+
private readonly onMessage;
|
|
19
|
+
private readonly onFocus;
|
|
20
|
+
private readonly onKey;
|
|
21
|
+
constructor(path: string);
|
|
22
|
+
getEnvironment: () => UiEnvironment;
|
|
23
|
+
setActive(active: boolean): void;
|
|
24
|
+
subscribe: (listener: (event: UiEvent) => void) => () => void;
|
|
25
|
+
private emit;
|
|
26
|
+
private retirePending;
|
|
27
|
+
private setEnvironment;
|
|
28
|
+
private probe;
|
|
29
|
+
private discover;
|
|
30
|
+
private post;
|
|
31
|
+
private recheck;
|
|
32
|
+
private receive;
|
|
33
|
+
private request;
|
|
34
|
+
private wait;
|
|
35
|
+
openSurface: (target: PanelTarget, options: PresentationOptions) => Promise<PresentationResult>;
|
|
36
|
+
getPresentation: (requestId: string, options?: {
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}) => Promise<PresentationResult>;
|
|
39
|
+
reportSurfaceState: (state: {
|
|
40
|
+
route: string;
|
|
41
|
+
state: "ready" | "blocked";
|
|
42
|
+
code?: string;
|
|
43
|
+
}) => void;
|
|
44
|
+
dispose: () => void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createPresentationRequestId } from "./ui.js";
|
|
2
|
+
import { UiError } from "./ui-types.js";
|
|
3
|
+
import { DISCOVERY_TIMEOUT, failure, readUiMessage, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
|
|
4
|
+
/** Direct-child transport. Discovery carries no identity and never creates a fallback host. */
|
|
5
|
+
export class EmbeddedUiClient {
|
|
6
|
+
path;
|
|
7
|
+
environment = { mode: "embedded", connection: "connecting" };
|
|
8
|
+
nonce = createPresentationRequestId();
|
|
9
|
+
listeners = new Set();
|
|
10
|
+
pending = new Map();
|
|
11
|
+
channel = null;
|
|
12
|
+
verifying = null;
|
|
13
|
+
disposed = false;
|
|
14
|
+
deadline;
|
|
15
|
+
retries = [];
|
|
16
|
+
state = null;
|
|
17
|
+
active = true;
|
|
18
|
+
offered = false;
|
|
19
|
+
onMessage = (event) => { void this.receive(event); };
|
|
20
|
+
onFocus = () => { void this.recheck(); };
|
|
21
|
+
onKey = (event) => { if (this.active && !event.defaultPrevented && event.key === "Escape")
|
|
22
|
+
this.post({ type: "hosty:ui:dismiss" }); };
|
|
23
|
+
constructor(path) {
|
|
24
|
+
this.path = path;
|
|
25
|
+
window.addEventListener("message", this.onMessage);
|
|
26
|
+
window.addEventListener("focus", this.onFocus);
|
|
27
|
+
window.addEventListener("keydown", this.onKey);
|
|
28
|
+
this.discover();
|
|
29
|
+
if (window.parent !== window.top)
|
|
30
|
+
this.setEnvironment("unsupported", "unsupported_nesting");
|
|
31
|
+
}
|
|
32
|
+
getEnvironment = () => this.environment;
|
|
33
|
+
setActive(active) {
|
|
34
|
+
if (this.active === active)
|
|
35
|
+
return;
|
|
36
|
+
if (!active)
|
|
37
|
+
this.post({ type: "hosty:ui:auth-required" });
|
|
38
|
+
this.active = active;
|
|
39
|
+
this.channel = null;
|
|
40
|
+
this.verifying = null;
|
|
41
|
+
this.nonce = createPresentationRequestId();
|
|
42
|
+
clearTimeout(this.deadline);
|
|
43
|
+
this.retries.forEach(clearTimeout);
|
|
44
|
+
if (!active) {
|
|
45
|
+
for (const [requestId, entry] of this.pending)
|
|
46
|
+
entry.resolve(entry.sent
|
|
47
|
+
? { status: "uncertain", requestId, code: "presentation_unknown" }
|
|
48
|
+
: { status: "blocked", requestId, code: "activity_required" });
|
|
49
|
+
this.pending.clear();
|
|
50
|
+
this.setEnvironment("blocked", "activity_required");
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
this.setEnvironment("connecting");
|
|
54
|
+
this.discover();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
subscribe = (listener) => {
|
|
58
|
+
this.listeners.add(listener);
|
|
59
|
+
listener({ type: "environment", value: this.environment });
|
|
60
|
+
return () => { this.listeners.delete(listener); };
|
|
61
|
+
};
|
|
62
|
+
emit(event) { if (!this.disposed)
|
|
63
|
+
for (const listener of this.listeners)
|
|
64
|
+
listener(event); }
|
|
65
|
+
retirePending() {
|
|
66
|
+
for (const [requestId, entry] of this.pending)
|
|
67
|
+
entry.resolve(entry.sent
|
|
68
|
+
? { status: "uncertain", requestId, code: "presentation_unknown" }
|
|
69
|
+
: { status: "unsupported", requestId, code: "host_unavailable" });
|
|
70
|
+
}
|
|
71
|
+
setEnvironment(connection, code) {
|
|
72
|
+
if (this.disposed)
|
|
73
|
+
return;
|
|
74
|
+
this.environment = { mode: "embedded", connection, ...(code ? { code } : {}) };
|
|
75
|
+
this.emit({ type: "environment", value: this.environment });
|
|
76
|
+
}
|
|
77
|
+
probe() {
|
|
78
|
+
if (this.active && !this.disposed && window.parent === window.top && !this.channel)
|
|
79
|
+
window.parent.postMessage({ type: "hosty:ui:probe", version: 1, versions: [1], childNonce: this.nonce }, "*");
|
|
80
|
+
}
|
|
81
|
+
discover() {
|
|
82
|
+
clearTimeout(this.deadline);
|
|
83
|
+
this.retries.forEach(clearTimeout);
|
|
84
|
+
this.deadline = setTimeout(() => {
|
|
85
|
+
if (this.environment.connection === "connecting")
|
|
86
|
+
this.setEnvironment(this.offered ? "blocked" : "unsupported", "host_unavailable");
|
|
87
|
+
}, DISCOVERY_TIMEOUT);
|
|
88
|
+
this.probe();
|
|
89
|
+
this.retries = [150, 500, 1500].map(delay => setTimeout(() => this.probe(), delay));
|
|
90
|
+
}
|
|
91
|
+
post(value) {
|
|
92
|
+
const channel = this.channel;
|
|
93
|
+
if (!channel || !this.active || this.disposed)
|
|
94
|
+
return;
|
|
95
|
+
window.parent.postMessage({ version: 1, childNonce: this.nonce, hostWindowId: channel.hostWindowId, generation: channel.generation, ...value }, channel.origin);
|
|
96
|
+
}
|
|
97
|
+
async recheck() {
|
|
98
|
+
const channel = this.channel;
|
|
99
|
+
if (!this.active || !channel || this.environment.connection !== "connected")
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
const own = await uiRequest(this.path, `bootstrap?parentOrigin=${encodeURIComponent(channel.origin)}`);
|
|
103
|
+
if (this.disposed || this.channel !== channel)
|
|
104
|
+
return false;
|
|
105
|
+
if (own.userId !== channel.own.userId || own.coreOrigin !== channel.own.coreOrigin || own.app.installationId !== channel.own.app.installationId
|
|
106
|
+
|| own.peer?.installationId !== channel.own.peer?.installationId || own.peer?.appId !== channel.own.peer?.appId)
|
|
107
|
+
throw new UiError("actor_mismatch", "Reconnect using the same Hosty account.");
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (this.channel !== channel || this.disposed)
|
|
112
|
+
return false;
|
|
113
|
+
this.post({ type: "hosty:ui:surface-state", route: this.state?.route, state: "auth-required" });
|
|
114
|
+
this.setEnvironment("blocked", error instanceof UiError ? error.code : "ui_unavailable");
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async receive(event) {
|
|
119
|
+
const msg = readUiMessage(event.data);
|
|
120
|
+
if (!msg || !this.active || event.source !== window.parent || window.parent !== window.top || event.origin === "null" || this.disposed)
|
|
121
|
+
return;
|
|
122
|
+
if (msg.type === "hosty:ui:probe-request") {
|
|
123
|
+
if (this.channel && event.origin === this.channel.origin && (msg.generation !== this.channel.generation || msg.hostWindowId !== this.channel.hostWindowId)) {
|
|
124
|
+
this.retirePending();
|
|
125
|
+
this.channel = null;
|
|
126
|
+
this.setEnvironment("connecting");
|
|
127
|
+
this.discover();
|
|
128
|
+
}
|
|
129
|
+
if (this.channel) {
|
|
130
|
+
void this.recheck();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.probe();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (msg.childNonce !== this.nonce || !validId(msg.hostWindowId) || !validId(msg.generation))
|
|
137
|
+
return;
|
|
138
|
+
if (msg.type === "hosty:ui:offer") {
|
|
139
|
+
this.offered = true;
|
|
140
|
+
if (this.channel?.origin === event.origin && this.channel.hostWindowId === msg.hostWindowId
|
|
141
|
+
&& this.channel.generation === msg.generation && this.environment.connection === "connected")
|
|
142
|
+
return;
|
|
143
|
+
const key = `${this.nonce}:${event.origin}:${msg.hostWindowId}:${msg.generation}`;
|
|
144
|
+
if (this.verifying === key)
|
|
145
|
+
return;
|
|
146
|
+
this.verifying = key;
|
|
147
|
+
try {
|
|
148
|
+
const own = await uiRequest(this.path, `bootstrap?parentOrigin=${encodeURIComponent(event.origin)}`);
|
|
149
|
+
if (this.disposed || !this.active || this.verifying !== key)
|
|
150
|
+
return;
|
|
151
|
+
if (own.version !== 1 || !own.userId || !own.peer?.origins.includes(event.origin))
|
|
152
|
+
throw new UiError("host_untrusted", "Hosty cannot verify this host.");
|
|
153
|
+
const previous = this.channel;
|
|
154
|
+
if (previous && (previous.origin !== event.origin || previous.hostWindowId !== msg.hostWindowId || previous.generation !== msg.generation))
|
|
155
|
+
this.retirePending();
|
|
156
|
+
this.channel = { origin: event.origin, hostWindowId: msg.hostWindowId, generation: msg.generation, own };
|
|
157
|
+
this.post({ type: "hosty:ui:accept", coreOrigin: own.coreOrigin, userId: own.userId, appId: own.app.appId, installationId: own.app.installationId });
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (this.disposed || !this.active || this.verifying !== key)
|
|
161
|
+
return;
|
|
162
|
+
const code = error instanceof UiError ? error.code : "ui_unavailable";
|
|
163
|
+
this.setEnvironment(code.startsWith("unsupported") ? "unsupported" : "blocked", code);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (this.verifying === key)
|
|
167
|
+
this.verifying = null;
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const channel = this.channel;
|
|
172
|
+
if (!channel || event.origin !== channel.origin || msg.hostWindowId !== channel.hostWindowId || msg.generation !== channel.generation)
|
|
173
|
+
return;
|
|
174
|
+
if (msg.type === "hosty:ui:connected") {
|
|
175
|
+
const own = channel.own;
|
|
176
|
+
if (msg.code || msg.coreOrigin !== own.coreOrigin || msg.userId !== own.userId || msg.appId !== own.peer?.appId || msg.installationId !== own.peer?.installationId) {
|
|
177
|
+
this.setEnvironment("blocked", "actor_mismatch");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
clearTimeout(this.deadline);
|
|
181
|
+
this.setEnvironment("connected");
|
|
182
|
+
if (this.state)
|
|
183
|
+
this.reportSurfaceState(this.state);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (this.environment.connection !== "connected")
|
|
187
|
+
return;
|
|
188
|
+
if (msg.type === "hosty:ui:result" && validId(msg.requestId) && validResult(msg.result, msg.requestId)) {
|
|
189
|
+
const result = msg.result;
|
|
190
|
+
this.pending.get(msg.requestId)?.resolve(result);
|
|
191
|
+
this.emit({ type: "presentation", value: result });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async request(requestId, message, signal) {
|
|
195
|
+
if (!validId(requestId))
|
|
196
|
+
return { status: "rejected", requestId, code: "invalid_presentation" };
|
|
197
|
+
if (!this.active)
|
|
198
|
+
return { status: "blocked", requestId, code: "activity_required" };
|
|
199
|
+
if (signal?.aborted)
|
|
200
|
+
return { status: "uncertain", requestId, code: "presentation_unknown" };
|
|
201
|
+
const target = message.target;
|
|
202
|
+
const input = target ? JSON.stringify([target.panel.appId, target.panel.installationId, target.panel.panelId, target.route ?? null]) : null;
|
|
203
|
+
const existing = this.pending.get(requestId);
|
|
204
|
+
if (existing) {
|
|
205
|
+
if (input !== null && existing.input !== null && input !== existing.input)
|
|
206
|
+
return { status: "rejected", requestId, code: "request_conflict" };
|
|
207
|
+
// A status response cannot satisfy an opening dispatched later. Finish that query first;
|
|
208
|
+
// status queried during an opening can simply share the in-flight operation's result.
|
|
209
|
+
if (input !== null && existing.input === null) {
|
|
210
|
+
const result = await this.wait(requestId, existing, signal);
|
|
211
|
+
if (this.pending.get(requestId) === existing)
|
|
212
|
+
return result;
|
|
213
|
+
return this.request(requestId, message, signal);
|
|
214
|
+
}
|
|
215
|
+
return this.wait(requestId, existing, signal);
|
|
216
|
+
}
|
|
217
|
+
if (this.pending.size >= 4)
|
|
218
|
+
return { status: "blocked", requestId, code: "presentation_busy" };
|
|
219
|
+
let settle;
|
|
220
|
+
const operation = new Promise(resolve => { settle = resolve; });
|
|
221
|
+
const entry = { input, operation, waiters: 0, sent: false, resolve: result => {
|
|
222
|
+
if (this.pending.get(requestId) === entry)
|
|
223
|
+
this.pending.delete(requestId);
|
|
224
|
+
settle(result);
|
|
225
|
+
} };
|
|
226
|
+
// Reserve identity before asynchronous revalidation so racing callers cannot share
|
|
227
|
+
// different payloads or overwrite each other's completion/cleanup handlers.
|
|
228
|
+
this.pending.set(requestId, entry);
|
|
229
|
+
void this.recheck().then(verified => {
|
|
230
|
+
if (this.pending.get(requestId) !== entry)
|
|
231
|
+
return;
|
|
232
|
+
if (!verified)
|
|
233
|
+
entry.resolve({ status: this.environment.connection === "blocked" ? "blocked" : "unsupported", requestId, code: this.environment.code ?? "host_unavailable" });
|
|
234
|
+
else {
|
|
235
|
+
entry.sent = true;
|
|
236
|
+
this.post({ ...message, requestId });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
return this.wait(requestId, entry, signal);
|
|
240
|
+
}
|
|
241
|
+
wait(requestId, entry, signal) {
|
|
242
|
+
entry.waiters++;
|
|
243
|
+
return waitForPresentation(entry.operation, requestId, signal).finally(() => {
|
|
244
|
+
// Ending a caller's wait does not cancel a sent message. Keep its reservation until
|
|
245
|
+
// its response arrives or the channel is retired, so it cannot settle newer work.
|
|
246
|
+
if (--entry.waiters === 0 && !entry.sent && this.pending.get(requestId) === entry)
|
|
247
|
+
this.pending.delete(requestId);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
openSurface = (target, options) => validTarget(target) && options.placement === "side"
|
|
251
|
+
? this.request(options.requestId, { type: "hosty:ui:open", target, placement: "side" }, options.signal)
|
|
252
|
+
: Promise.resolve({ status: "rejected", requestId: options.requestId, code: "invalid_presentation" });
|
|
253
|
+
getPresentation = (requestId, options) => this.request(requestId, { type: "hosty:ui:status" }, options?.signal);
|
|
254
|
+
reportSurfaceState = (state) => {
|
|
255
|
+
this.state = state;
|
|
256
|
+
if (this.environment.connection === "connected")
|
|
257
|
+
this.post({ type: "hosty:ui:surface-state", ...state });
|
|
258
|
+
};
|
|
259
|
+
dispose = () => {
|
|
260
|
+
this.disposed = true;
|
|
261
|
+
clearTimeout(this.deadline);
|
|
262
|
+
this.retries.forEach(clearTimeout);
|
|
263
|
+
window.removeEventListener("message", this.onMessage);
|
|
264
|
+
window.removeEventListener("focus", this.onFocus);
|
|
265
|
+
window.removeEventListener("keydown", this.onKey);
|
|
266
|
+
for (const [id, entry] of this.pending)
|
|
267
|
+
entry.resolve(failure(id, new UiError("host_unavailable", "Host disconnected.")));
|
|
268
|
+
this.pending.clear();
|
|
269
|
+
this.listeners.clear();
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function validResult(value, id) {
|
|
273
|
+
if (!value || typeof value !== "object")
|
|
274
|
+
return false;
|
|
275
|
+
const v = value;
|
|
276
|
+
return v.requestId === id && (v.status === "opened" ? validTarget(v.target) && ["loading", "ready", "unreported", "auth-required"].includes(v.readiness)
|
|
277
|
+
: ["blocked", "unsupported", "rejected", "uncertain", "unknown"].includes(v.status) && "code" in v && typeof v.code === "string");
|
|
278
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type HostyResolvedTheme, type HostyThemePreference } from "./theme.js";
|
|
2
|
+
import type { UiFrameSurface } from "./ui-types.js";
|
|
3
|
+
export declare function parseFrameMcpReview(event: {
|
|
4
|
+
source: unknown;
|
|
5
|
+
origin: string;
|
|
6
|
+
data: unknown;
|
|
7
|
+
}, frame: unknown, src: string, appId: string): {
|
|
8
|
+
appId: string;
|
|
9
|
+
targetAppId: string;
|
|
10
|
+
} | null;
|
|
11
|
+
export declare function frameSandbox(policy: UiFrameSurface["framePolicy"]): string;
|
|
12
|
+
export type HostyAppFrameProps = {
|
|
13
|
+
surface: UiFrameSurface;
|
|
14
|
+
title: string;
|
|
15
|
+
src?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
theme?: HostyResolvedTheme;
|
|
18
|
+
themePreference?: HostyThemePreference;
|
|
19
|
+
standaloneHref?: string | null;
|
|
20
|
+
onAskAssistant?: (text: string, appId: string) => void;
|
|
21
|
+
onAttention?: (count: number) => void;
|
|
22
|
+
onAuthRequired?: (appId: string) => void;
|
|
23
|
+
onMessage?: (event: MessageEvent, frame: Window | null) => void;
|
|
24
|
+
outbound?: {
|
|
25
|
+
message: unknown;
|
|
26
|
+
nonce: number;
|
|
27
|
+
} | null;
|
|
28
|
+
};
|
|
29
|
+
/** Shared frame coordinator. No delegated-token responder and no authority inferred from messages. */
|
|
30
|
+
export declare function HostyAppFrame(props: HostyAppFrameProps): import("react").JSX.Element;
|